فهرست منبع

Require Web modules and connection during startup

turtle1999 3 هفته پیش
والد
کامیت
f5b5a9da51

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md
-2026-09-09-consumer-owned-startup-strictness.md: dd93e3867452e4456e2d7e1853e01aff66deb881
-2026-09-09-consumer-owned-startup-strictness.zh.md: dfb84c803ead167807897fe18d39f19d00eecac4
+2026-09-09-consumer-owned-startup-strictness.md: 1f3007d6bfc134c8fc741b87f001261f3f0ab468
+2026-09-09-consumer-owned-startup-strictness.zh.md: c1b52dc6e3fd9688878eb57c4815f983ed4b11b1

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md

@@ -12,7 +12,7 @@ Best-effort Loader reconciliation preserves usable plugins, but applications sti
 
 DSH owns startup strictness outside vendored Cordis. App-boot audits the settled initial tree against one global list of stable entry ids. A listed entry that is present, enabled, and not active rejects startup and disposes the application. A listed id that is absent or disabled has no effect. The bootstrap Include is required by entry identity because a missing or invalid root configuration prevents application assembly. Other inactive entries produce one warning and leave successful siblings running.
 
-The required ids are `agent-loop`, `webserver`, `headless-runner`, `acp`, and `sdk-jsonrpc-server`. They represent shared Agent execution and the endpoints of the shipped Web, headless, ACP, and SDK applications. Their injected providers do not need separate list entries: a missing provider leaves the listed endpoint pending or failed.
+The required ids are `agent-loop`, `webserver`, `modules`, `connection`, `headless-runner`, `acp`, and `sdk-jsonrpc-server`. They represent shared Agent execution, application endpoints, and Web bootstrap/transport. Web needs its client module registry and authenticated connection even when the HTTP server can listen without them. Providers already required through injection need no separate entry: their absence leaves a listed consumer pending or failed.
 
 The audit runs only during initial application boot. Later config HMR remains best effort and keeps the failed candidate visible for repair.
 
@@ -28,4 +28,4 @@ Stable required entry ids are part of application assembly. Renaming one require
 
 ## Testing
 
-App-boot unit tests cover absent and disabled required ids, optional import failure, config evaluation failure, synchronous and asynchronous `apply()` failure, pending dependencies, and required failure teardown. The built Web-profile acceptance serves the full UI with optional failures and exits nonzero when the required HTTP port is occupied.
+App-boot unit tests cover absent and disabled required ids, optional import failure, config evaluation failure, synchronous and asynchronous `apply()` failure, pending dependencies, and required failure teardown. The built Web-profile acceptance serves the full UI with optional failures and exits nonzero without readiness when the required HTTP port is occupied or `modules` or `connection` cannot activate.

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md

@@ -12,7 +12,7 @@ Best-effort Loader reconcile 会保留可用 plugin,但应用仍需一组最
 
 DSH 在 vendored Cordis 之外持有启动严格语义。App-boot 用一份全局稳定 entry id list 审计已结算的初始 tree。List 中存在、启用且未 active 的 entry 会使启动 reject,并拆卸应用。List 中缺失或禁用的 id 不产生影响。Bootstrap Include 按 entry 身份被视为 required,因为根配置缺失或无效会阻止应用组装。其他 inactive entry 输出一次 warning,并让成功 sibling 继续运行。
 
-Required id 为 `agent-loop`、`webserver`、`headless-runner`、`acp` 和 `sdk-jsonrpc-server`。它们分别代表共享 Agent 执行,以及随附 Web、headless、ACP 和 SDK 应用的 endpoint。其 injected provider 不需要单列:provider 缺失会让已列出的 endpoint 保持 pending 或失败。
+Required id 为 `agent-loop`、`webserver`、`modules`、`connection`、`headless-runner`、`acp` 和 `sdk-jsonrpc-server`。它们分别代表共享 Agent 执行、应用 endpoint,以及 Web 启动与传输。即使 HTTP server 不依赖它们也能监听,Web 仍需要客户端模块注册表和经过认证的连接。通过注入已成为必需项的 provider 不需要单列:它们缺失时,已列出的消费方会保持 pending 或失败。
 
 该审计只在应用首次启动时运行。之后的 config HMR 仍采用 best effort,并保留 failed candidate 供后续修复。
 
@@ -28,4 +28,4 @@ Required id 为 `agent-loop`、`webserver`、`headless-runner`、`acp` 和 `sdk-
 
 ## 测试
 
-App-boot 单元测试覆盖缺失和禁用的 required id、optional import failure、config evaluation failure、同步和异步 `apply()` failure、pending dependency,以及 required failure teardown。构建后的 Web-profile acceptance 会在 optional failure 存在时继续提供完整 UI,并在 required HTTP port 被占用时以非零码退出。
+App-boot 单元测试覆盖缺失和禁用的 required id、optional import failure、config evaluation failure、同步和异步 `apply()` failure、pending dependency,以及 required failure teardown。构建后的 Web-profile acceptance 会在 optional failure 存在时继续提供完整 UI,并在 required HTTP port 被占用或 `modules`、`connection` 无法激活时以非零码退出,且不报告就绪。

+ 40 - 1
apps/cli/tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts

@@ -161,7 +161,9 @@ describe.skipIf(!builtArtifactsExist)('dsh Web profile best-effort startup', ()
       const cookie = auth.headers.get('set-cookie')?.split(';', 1)[0]
       if (cookie === undefined) throw new Error('Web authentication response did not set a cookie')
       const page = await fetch(new URL('/', startup.url), { headers: { cookie } })
-      expect(await page.text()).toContain('<div id="root"></div>')
+      const html = await page.text()
+      expect(html).toContain('<div id="root"></div>')
+      expect(html).toContain('__DSH_BOOT__')
       expect(readFileSync(fixture.events, 'utf8')).toBe('good apply\n')
       expect(startup.stderr).toContain('web-probe-import-failure')
       expect(startup.stderr).toContain('@deepseek-ai/dsh-tool-todo')
@@ -191,6 +193,43 @@ describe.skipIf(!builtArtifactsExist)('dsh Web profile best-effort startup', ()
     `)
   })
 
+  it.each(['modules', 'connection'])('fails the full Web profile when required %s cannot activate', async (id) => {
+    const fixture = createFixture()
+    writeFileSync(fixture.patch, `${readFileSync(fixture.patch, 'utf8')}- id: ${id}\n  inject: [webProbeMissingRequiredService]\n`)
+    try {
+      const result = await execa(process.execPath, [
+        dshBin,
+        '--profile', 'web',
+        '--patch', fixture.patch,
+        '--no-open',
+        '--port', '0',
+      ], {
+        cwd: fixture.root,
+        env: {
+          ...process.env,
+          DEEPSEEK_API_KEY: 'keyless-web-required-no-call',
+          DSH_AGENTS_HOME: join(fixture.root, '.agents'),
+          DSH_HOME: fixture.home,
+          DSH_TELEMETRY_DISABLED: '1',
+          NODE_NO_WARNINGS: '1',
+        },
+        input: '',
+        reject: false,
+        timeout: 90_000,
+        killSignal: 'SIGKILL',
+      })
+      expect(result.timedOut).toBe(false)
+      expect(result.signal).toBeUndefined()
+      expect(result.exitCode).toBe(1)
+      expect(result.stdout).not.toContain('dsh web: http://')
+      expect(result.stderr).toContain('required startup failure')
+      expect(result.stderr).toContain(`${id} (@deepseek-ai/dsh-client-${id}): pending (waiting for service: webProbeMissingRequiredService)`)
+      expect(readFileSync(fixture.events, 'utf8')).toBe('good apply\ngood dispose\n')
+    } finally {
+      rmSync(fixture.root, { recursive: true, force: true })
+    }
+  })
+
   it('fails the full Web profile when its required HTTP server cannot bind', async () => {
     const root = mkdtempSync(join(tmpdir(), 'dsh-web-required-bind-'))
     const home = join(root, 'home')

+ 2 - 2
packages/boot/app-boot/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md
-README.md: 55689d496dcd1610ba45c0fc6140296968652410
-README.zh.md: 7d320a045d16571576340e5238a1372f4ec6f4ba
+README.md: ee97801e8ca3eedb4cd1fac85d0ed4d669ed8783
+README.zh.md: 2bf1b8e691d1ace0d5bb97fd55641abf727dc589

+ 1 - 1
packages/boot/app-boot/README.md

@@ -40,7 +40,7 @@ installFailLoud('dsh')
 const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHOT))
 ```
 
-With that entry point, startup keeps every plugin that can activate. An enabled failed plugin produces a labelled warning. A failed required entry makes startup dispose the whole app and exit nonzero; required ids absent from a profile and disabled required entries do not affect startup. The global required list covers shared Agent execution and each application's endpoint: `agent-loop`, `webserver`, `headless-runner`, `acp`, and `sdk-jsonrpc-server`.
+With that entry point, startup keeps every plugin that can activate. An enabled failed plugin produces a labelled warning. A failed required entry makes startup dispose the whole app and exit nonzero; required ids absent from a profile and disabled required entries do not affect startup. The global required list covers shared Agent execution, application endpoints, and Web bootstrap/transport: `agent-loop`, `webserver`, `modules`, `connection`, `headless-runner`, `acp`, and `sdk-jsonrpc-server`.
 
 <a id="profiles"></a>
 ### Profiles

+ 1 - 1
packages/boot/app-boot/README.zh.md

@@ -40,7 +40,7 @@ installFailLoud('dsh')
 const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHOT))
 ```
 
-有了这个入口,启动会保留所有能够激活的插件。启用但失败的插件会产生带标签的警告。required entry 失败时,启动会拆卸整个应用并以非零码退出;profile 中不存在的 required id 和已禁用的 required entry 不影响启动。全局 required list 覆盖共享 Agent 执行与各应用的 endpoint:`agent-loop`、`webserver`、`headless-runner`、`acp` 和 `sdk-jsonrpc-server`。
+有了这个入口,启动会保留所有能够激活的插件。启用但失败的插件会产生带标签的警告。required entry 失败时,启动会拆卸整个应用并以非零码退出;profile 中不存在的 required id 和已禁用的 required entry 不影响启动。全局 required list 覆盖共享 Agent 执行、应用 endpoint,以及 Web 启动与传输:`agent-loop`、`webserver`、`modules`、`connection`、`headless-runner`、`acp` 和 `sdk-jsonrpc-server`。
 
 <a id="profiles"></a>
 ### Profile

+ 4 - 3
packages/boot/app-boot/src/index.ts

@@ -696,13 +696,14 @@ const FIBER_FAILED = 3 as FiberState.FAILED
  * Entry ids whose presence defines a usable DSH application.
  *
  * The list is global rather than profile metadata. Missing or disabled ids do
- * not affect startup; an enabled listed entry must activate. Surface endpoints
- * cover their injected providers, while `agent-loop` covers shared Agent
- * execution.
+ * not affect startup; an enabled listed entry must activate. The list covers
+ * shared Agent execution, application endpoints, and Web bootstrap/transport.
  */
 export const REQUIRED_STARTUP_ENTRY_IDS: readonly string[] = Object.freeze([
   'agent-loop',
   'webserver',
+  'modules',
+  'connection',
   'headless-runner',
   'acp',
   'sdk-jsonrpc-server',

+ 14 - 8
packages/boot/app-boot/tests/app-boot.spec.ts

@@ -532,11 +532,13 @@ describe('auditStartupEntries', () => {
     await: error === undefined ? async () => undefined : async () => { throw error },
   })
 
-  it('pins the global list to shared execution and application endpoints', () => {
+  it('pins the global list to shared execution, application endpoints, and Web startup', () => {
     expect(Object.isFrozen(REQUIRED_STARTUP_ENTRY_IDS)).toBe(true)
     expect(REQUIRED_STARTUP_ENTRY_IDS).toEqual([
       'agent-loop',
       'webserver',
+      'modules',
+      'connection',
       'headless-runner',
       'acp',
       'sdk-jsonrpc-server',
@@ -546,10 +548,14 @@ describe('auditStartupEntries', () => {
 
   it('ignores active, disabled, and absent required entries', async () => {
     const warn = vi.fn()
-    await expect(auditStartupEntries(ctxWith([
-      { fiber: fiber(2), options: { id: 'active', name: 'active' } },
-      { fiber: fiber(3, new Error('disabled failure')), disabled: true, options: { id: 'webserver', name: 'disabled' } },
-    ]), NAME, warn)).resolves.toBeUndefined()
+    await expect(auditStartupEntries(ctxWith([]), NAME, warn)).resolves.toBeUndefined()
+    for (const disabled of [false, true]) {
+      await expect(auditStartupEntries(ctxWith(REQUIRED_STARTUP_ENTRY_IDS.map(id => ({
+        fiber: disabled ? fiber(3, new Error('disabled failure')) : fiber(2),
+        disabled,
+        options: { id, name: './required.mjs' },
+      }))), NAME, warn)).resolves.toBeUndefined()
+    }
     expect(warn).not.toHaveBeenCalled()
   })
 
@@ -649,16 +655,16 @@ describe('auditStartupEntries', () => {
     expect(diagnostic).toContain('unexpected-state (./unexpected-state.mjs): fiber state 1')
   })
 
-  it('rejects required failures after warning about optional failures', async () => {
+  it.each(['webserver', 'modules', 'connection'])('rejects required %s failures after warning about optional failures', async (id) => {
     const warn = vi.fn()
     const requiredError = new Error('address already in use')
     const optionalError = new Error('todo unavailable')
     await expect(auditStartupEntries(ctxWith([
-      { fiber: fiber(3, requiredError), options: { id: 'webserver', name: '@deepseek-ai/dsh-host-webserver' } },
+      { fiber: fiber(3, requiredError), options: { id, name: './required.mjs' } },
       { fiber: fiber(3, optionalError), options: { id: 'tool-todo', name: '@deepseek-ai/dsh-tool-todo' } },
     ]), NAME, warn)).rejects.toThrow([
       'required startup failure: 1 entry did not activate',
-      `webserver (@deepseek-ai/dsh-host-webserver): ${requiredError.stack!}`,
+      `${id} (./required.mjs): ${requiredError.stack!}`,
     ].join('\n'))
     expect(warn).toHaveBeenCalledWith(`${NAME}: warning: 1 entry did not activate\ntool-todo (@deepseek-ai/dsh-tool-todo): ${optionalError.stack!}\n`)
   })

+ 2 - 0
packages/bundle/web-app/tests/web-app.spec.ts

@@ -317,6 +317,8 @@ describe('web-app runtime glue', () => {
 
   it.each([
     { id: 'webserver', announces: false },
+    { id: 'modules', announces: false },
+    { id: 'connection', announces: false },
     { id: 'optional-tool', announces: true },
   ])('announces readiness=$announces after the $id sibling fails', async ({ id, announces }) => {
     stageDist()