Browse Source

fix(web-app): gate readiness on required startup entries

turtle1999 1 month ago
parent
commit
97d1f7c203

+ 2 - 2
apps/cli/tests/built-bin.e2e.ts

@@ -760,14 +760,14 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
   it('keeps serving when an optional patch-overlay plugin fails', async () => {
     const home = mkdtempSync(join(tmpdir(), 'dsh-invalid-patch-'))
     try {
-      const result = await runBuiltBin(['--profile', 'web', '--patch', invalidProvider], {
+      const result = await runBuiltBin(['--profile', 'web', '--patch', invalidProvider, '--port', '0', '--no-open'], {
         DSH_HOME: home,
         DSH_BROWSER_OPEN_TEST_EXIT_ON_READY: '1',
         DEEPSEEK_API_KEY: 'keyless-invalid-config',
         DSH_TELEMETRY_DISABLED: '1',
         NODE_OPTIONS: `--import=${webReadyExitHook}`,
       })
-      expect(result.code).toBe(0)
+      expect(result.code, result.stderr).toBe(0)
       expect(result.stdout).toMatch(/^dsh web: http:\/\/127\.0\.0\.1:\d+\/\?token=[A-Za-z0-9_-]+$/u)
       expect(result.stderr).toContain('llm-pi-ai')
     } finally {

+ 2 - 2
packages/bundle/web-app/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/bundle/web-app/README.md
-README.md: 94560cf45affe0ee4c9570054c40c00e9185901b
-README.zh.md: c71f56943eaa65363bfa03c94289ec322ed9f448
+README.md: 29f1737a1a3367c5d1f4e9a37e7031fcd6ae9b9f
+README.zh.md: 55e3cc26d8f6b70d464280d2acb2ec0cf37bc937

+ 1 - 1
packages/bundle/web-app/README.md

@@ -77,7 +77,7 @@ A patch replaces the targeted row's whole `config`, so each web row restates eve
 
 ### Readiness
 
-The URL line and browser handoff are readiness signals: supervisors RPC as soon as they observe the line, and a browser requests the page as soon as it opens, so both run only after the Loader tree settles, every enabled entry passes the activation audit, and Connection authentication is available — or immediately in a hand-built tree without a Loader. A failed tree or a tree disposed mid-boot announces nothing.
+The URL line and browser handoff are readiness signals: supervisors RPC as soon as they observe the line, and a browser requests the page as soon as it opens, so both run only after the Loader tree settles, the required-startup audit passes, and Connection authentication is available — or immediately in a hand-built tree without a Loader. Optional plugin failures do not suppress readiness; a required startup failure or a tree disposed mid-boot announces nothing.
 
 ### LAN trust sampling
 

+ 1 - 1
packages/bundle/web-app/README.zh.md

@@ -77,7 +77,7 @@ patch 会替换目标行的整个 `config`,因此每个 Web 行都重述自己
 
 ### 就绪宣告
 
-URL 行与浏览器交接都是就绪信号:监督方一观察到该行就发起 RPC,浏览器一打开就请求页面,因此两者只在 Loader 配置树结算、每个启用的条目通过激活检查且 Connection 认证可用后运行——在没有 Loader 的手工构建树中则立即运行。失败的树或启动中途被释放的树不会宣告任何内容。
+URL 行与浏览器交接都是就绪信号:监督方一观察到该行就发起 RPC,浏览器一打开就请求页面,因此两者只在 Loader 配置树结算、通过 required 启动检查且 Connection 认证可用后运行——在没有 Loader 的手工构建树中则立即运行。可选插件失败不会阻止就绪宣告;required 启动失败或启动中途被释放的树不会宣告任何内容。
 
 ### LAN 信任采样
 

+ 2 - 2
packages/bundle/web-app/src/index.ts

@@ -18,7 +18,7 @@ import { networkInterfaces } from 'node:os'
 import { fileURLToPath } from 'node:url'
 import type { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
-import { addHarnessSourceSection, assertEntriesActivated } from '@deepseek-ai/dsh-app-boot'
+import { addHarnessSourceSection, auditStartupEntries } from '@deepseek-ai/dsh-app-boot'
 import type {} from '@deepseek-ai/dsh-client-connection'
 import * as FrontendStatic from '@deepseek-ai/dsh-host-frontend-static'
 import { launchedThroughSsh, launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
@@ -285,7 +285,7 @@ export function apply(ctx: Context, config: Config): void {
       if (settled === undefined) announceReady()
       else {
         void settled.then(async () => {
-          await assertEntriesActivated(connectionCtx, 'dsh web')
+          await auditStartupEntries(connectionCtx.root, 'dsh web', () => {})
           // The tree can be disposed while the boot was in flight (early
           // SIGTERM); a URL line or browser tab for a dead server would only
           // mislead, and reading torn-down services would turn a clean shutdown

+ 16 - 5
packages/bundle/web-app/tests/web-app.spec.ts

@@ -14,6 +14,7 @@ import { PassThrough } from 'node:stream'
 import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import Loader from '@deepseek-ai/cordis-plugin-loader'
+import * as AppBoot from '@deepseek-ai/dsh-app-boot'
 import { createLaunchEnvironmentSnapshot, DSH_LAUNCH_ENVIRONMENT_KEY } from '@deepseek-ai/dsh-launch-environment'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import type { WebServer } from '@deepseek-ai/dsh-host-webserver'
@@ -314,7 +315,10 @@ describe('web-app runtime glue', () => {
     await torn.fiber.dispose()
   })
 
-  it('keeps readiness private when Loader settles with a failed sibling', async () => {
+  it.each([
+    { id: 'webserver', announces: false },
+    { id: 'optional-tool', announces: true },
+  ])('announces readiness=$announces after the $id sibling fails', async ({ id, announces }) => {
     stageDist()
     const ctx = new Context()
     onTestFinished(() => ctx.fiber.dispose())
@@ -323,16 +327,23 @@ describe('web-app runtime glue', () => {
     provideConnection(ctx)
     await ctx.plugin(Loader)
     ctx.loader.builtins.failure = () => { throw new Error('sibling rejected') }
-    const id = await ctx.loader.create({ name: 'cordis:failure' })
+    await ctx.loader.root.update([{ id, name: 'cordis:failure' }])
     await ctx.loader.await()
     await expect(ctx.loader.resolve(id).fiber?.await()).rejects.toThrow('sibling rejected')
     const log = vi.spyOn(console, 'log').mockImplementation(() => {})
     const openBrowser = vi.fn(async () => {})
     internals.openBrowser = openBrowser
+    const audit = vi.spyOn(AppBoot, 'auditStartupEntries')
     apply(ctx, new Config({ openBrowser: true, printUrl: true, surfaceContext: false, trustedHosts: [] }))
-    await new Promise<void>(resolve => setImmediate(resolve))
-    expect(log).not.toHaveBeenCalled()
-    expect(openBrowser).not.toHaveBeenCalled()
+    await vi.waitFor(() => { expect(audit).toHaveBeenCalledOnce() })
+    await Promise.allSettled(audit.mock.results.map(result => result.value as Promise<void>))
+    if (announces) {
+      expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567/?token=test-token')
+      expect(openBrowser).toHaveBeenCalledWith('http://127.0.0.1:4567/?token=test-token')
+    } else {
+      expect(log).not.toHaveBeenCalled()
+      expect(openBrowser).not.toHaveBeenCalled()
+    }
   })
 
   it('fails loud when the prompt section resolves against a portless webserver', async () => {