From 668bdb3d8eba134107d39f11563ba625ed582158 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 10 Aug 2026 20:49:40 +0800 Subject: [PATCH] refactor(cmdline): keep readiness in web app --- ...026-08-06-app-owned-command-line.i18n.yaml | 4 +- .../2026-08-06-app-owned-command-line.md | 4 +- .../2026-08-06-app-owned-command-line.zh.md | 4 +- apps/cli/src/profile-boot.ts | 16 ------- packages/boot/README.i18n.yaml | 4 +- packages/boot/README.md | 2 +- packages/boot/README.zh.md | 2 +- packages/boot/cmdline/README.i18n.yaml | 4 +- packages/boot/cmdline/README.md | 1 - packages/boot/cmdline/README.zh.md | 1 - packages/boot/cmdline/src/index.ts | 12 ----- packages/boot/cmdline/tests/cmdline.spec.ts | 4 +- packages/bundle/web-app/README.i18n.yaml | 4 +- packages/bundle/web-app/README.md | 2 +- packages/bundle/web-app/README.zh.md | 2 +- packages/bundle/web-app/src/index.ts | 10 ++--- packages/bundle/web-app/tests/web-app.spec.ts | 45 +++++-------------- scripts/gen-cordis-catalog.ts | 1 - 18 files changed, 34 insertions(+), 88 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml index f59ff0b1a8..895019d166 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.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-08-06-app-owned-command-line.md -2026-08-06-app-owned-command-line.md: 8556c2bbe27189a0784edf4b2a376c932807e020 -2026-08-06-app-owned-command-line.zh.md: f5a7be3500f239e03e0f05d724fa53ffaf28e624 +2026-08-06-app-owned-command-line.md: 3dae1cb209ae9083ac6ab6616a140b6f129bc931 +2026-08-06-app-owned-command-line.zh.md: 81750eec8a78a811dd90454d88fa8ed1611dcce6 diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md index 8556c2bbe2..3dae1cb209 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md @@ -12,13 +12,13 @@ After profiles, compositions were installable but their command lines were not. The launcher parses only what it owns — `--profile`, `--patch`, the config dumps — and hands **everything after its own flags** to the booted tree verbatim. The split is positional: the first token the launcher does not recognize starts the app's arguments (commander's `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`). A bare `dsh -h`, which has no app to hand the flag to, still prints the launcher's own help. -The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `provideCmdline(ctx, host)` before any entry mounts, providing `ctx.cmdlineArgs` (whose whole interface is `get(): readonly string[]`), `ctx.appExit`, and `ctx.appReady`. An app consumes them from its **startup row**. Both the Loader row and plugin inject `cmdlineArgs`; the plugin calls `runStartup(ctx, service, program, plan)` with its own commander program and provides what it resolved as its own service. The Loader-row injection is also the launcher's discovery declaration; there is no parallel bundle-manifest field. Before boot, the launcher rejects nonempty app arguments with no active declaration and any composition with multiple active declarations. The rows the app configures inject that service and read it from their own config expressions (`port: !!js ctx.webStartup.port ?? 3080`), so a flag beats the value written beside it and nothing is written back into any row. +The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `provideCmdline(ctx, host)` before any entry mounts, providing `ctx.cmdlineArgs` (whose whole interface is `get(): readonly string[]`) and `ctx.appExit`. An app consumes them from its **startup row**. Both the Loader row and plugin inject `cmdlineArgs`; the plugin calls `runStartup(ctx, service, program, plan)` with its own commander program and provides what it resolved as its own service. The Loader-row injection is also the launcher's discovery declaration; there is no parallel bundle-manifest field. Before boot, the launcher rejects nonempty app arguments with no active declaration and any composition with multiple active declarations. The rows the app configures inject that service and read it from their own config expressions (`port: !!js ctx.webStartup.port ?? 3080`), so a flag beats the value written beside it and nothing is written back into any row. The boot mounts the composition once. Cordis holds each row until its injections are active; Loader then interpolates that row's `!!js` against the injection-ready plugin context immediately before activation. Include keeps nested row expressions raw until their target row reaches this point. `--help` provides no startup service, so dependent rows never activate, and a live patch reload interpolates again against the service that remains active, so a served port cannot be silently reset. The shipped apps moved their flags into their bundles: `dsh-web-app` owns the Web family (and enables the `client-hmr` row it now ships disabled, for `--dev`), and `dsh-headless` owns the task positional and rejects a missing task as a usage error. `apps/cli/src/web.ts` is gone; `runProfile` no longer knows any flag-target row id. Out of tree, turtle-ui gained `--resume ` / `--session ` the same way, which is the design's real validation: an installed plugin added a flag with no launcher change. -Two further consequences. Loader mounts sibling rows concurrently, so one row can activate while another still mounts or while the whole boot is rolling back; a row that publishes readiness (the web URL line) therefore awaits `ctx.appReady`. The Web bundle's runtime plugin owns the harness-source prompt section too, so `dsh web` and `dsh --profile web` boot identically without Web-specific launcher setup. +Two further consequences. Loader mounts sibling rows concurrently, so one row can activate while another still mounts or while the whole boot is rolling back; the Web bundle therefore publishes its URL only after its own Loader tree settles. The Web bundle's runtime plugin owns the harness-source prompt section too, so `dsh web` and `dsh --profile web` boot identically without Web-specific launcher setup. ## Why Loader owns the ordering diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md index f5a7be3500..81750eec8a 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md @@ -12,13 +12,13 @@ profile 落地之后,组合可以安装,命令行却不能。`apps/cli` 仍 启动器只解析属于自己的部分(`--profile`、`--patch`、配置 dump),并把**自己 flag 之后的一切**原样交给引导起来的配置树。切分按位置进行:启动器不认识的第一个 token 就是应用参数的起点(依靠 commander 的 `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`)。裸的 `dsh -h` 没有可交付的应用,仍然打印启动器自己的 help。 -新包 `@deepseek-ai/dsh-cmdline` 持有这次交接。启动器在任何条目挂载之前调用 `provideCmdline(ctx, host)`,提供 `ctx.cmdlineArgs`(其全部接口就是 `get(): readonly string[]`)、`ctx.appExit` 和 `ctx.appReady`。应用从自己的**启动行**消费它们。Loader 行与插件都注入 `cmdlineArgs`;插件以自己的 commander program 调用 `runStartup(ctx, service, program, plan)`,再把解析结果作为自己的服务提供出去。Loader 行的注入同时也是启动器的发现声明,不再需要一份平行的组合包 manifest 字段。启动器会在 boot 前拒绝没有活跃声明却带有非空应用参数的调用,也会拒绝存在多个活跃声明的组合。应用所配置的行注入该服务,再从各自的配置表达式中读取它(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。 +新包 `@deepseek-ai/dsh-cmdline` 持有这次交接。启动器在任何条目挂载之前调用 `provideCmdline(ctx, host)`,提供 `ctx.cmdlineArgs`(其全部接口就是 `get(): readonly string[]`)与 `ctx.appExit`。应用从自己的**启动行**消费它们。Loader 行与插件都注入 `cmdlineArgs`;插件以自己的 commander program 调用 `runStartup(ctx, service, program, plan)`,再把解析结果作为自己的服务提供出去。Loader 行的注入同时也是启动器的发现声明,不再需要一份平行的组合包 manifest 字段。启动器会在 boot 前拒绝没有活跃声明却带有非空应用参数的调用,也会拒绝存在多个活跃声明的组合。应用所配置的行注入该服务,再从各自的配置表达式中读取它(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。 boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Loader 随后在激活前一刻,基于已注入就绪的插件上下文插值该行的 `!!js`。Include 会保留嵌套的行表达式,直到目标行到达这一时点。`--help` 不提供启动服务,因此依赖行永不激活;活动 patch 重载会针对仍然在线的服务再次插值,所以已经服务中的端口不会被悄悄重置。 已交付的各应用把自己的 flag 搬进了组合包:`dsh-web-app` 持有 Web 家族(并为 `--dev` 启用它如今以禁用状态交付的 `client-hmr` 行),`dsh-headless` 持有任务位置参数,缺少任务时按用法错误拒绝。`apps/cli/src/web.ts` 已删除;`runProfile` 不再知道任何 flag 目标行 id。在树外,turtle-ui 以同样的方式获得了 `--resume ` / `--session `,这才是这套设计的真正验证:一个已安装的插件加上了一个 flag,启动器毫无改动。 -还有两条后果。Loader 会并发挂载兄弟行,因此一行可能已经激活,而另一行仍在挂载,或整次 boot 正在回滚;所以公布就绪信号的行(web 的 URL 行)会等待 `ctx.appReady`。另外,Web 组合包的运行时插件也持有 harness 源码提示词段,因此 `dsh web` 与 `dsh --profile web` 无需 Web 专用启动器设置即可按完全相同的方式启动。 +还有两条后果。Loader 会并发挂载兄弟行,因此一行可能已经激活,而另一行仍在挂载,或整次 boot 正在回滚;所以 Web 组合包只会在自身的 Loader 配置树结算后公布 URL。另外,Web 组合包的运行时插件也持有 harness 源码提示词段,因此 `dsh web` 与 `dsh --profile web` 无需 Web 专用启动器设置即可按完全相同的方式启动。 ## 为什么由 Loader 持有顺序 diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 4d7b525d07..0266f8518f 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -218,17 +218,6 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con const oneShot = headlessRow !== undefined && headlessRow.disabled !== true const app: { current?: Context } = {} - // Readiness for rows that publish it (the web URL line): a row can activate - // before concurrently mounted siblings finish or fail. - let bootSettled: () => void = () => {} - let bootFailed: (reason: unknown) => void = () => {} - const ready = new Promise((resolve, reject) => { - bootSettled = resolve - bootFailed = reject - }) - // Nothing awaits `ready` on a composition that publishes no readiness, and - // an unobserved rejection must not take the process down on its own. - ready.catch(() => {}) const shutdown = createProcessShutdown(async () => { await app.current?.fiber.dispose() }) const signalShutdown = new AbortController() const interrupt = (code: number): void => { @@ -280,7 +269,6 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con provideCmdline(hostCtx, { args: options.args, exit: code => void shutdown.shutdown(code), - ready, }) if (oneShot) { const io: HeadlessIo = { @@ -290,12 +278,8 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con } hostCtx.provide('headlessIo', io) } - }).catch((cause: unknown) => { - bootFailed(cause) - throw cause }) app.current = ctx - bootSettled() // A surface can dispose the whole tree while startup or this post-boot // watcher setup is still in flight. Loader presence and fiber state own // liveness; the local signal fact distinguishes that expected exit race diff --git a/packages/boot/README.i18n.yaml b/packages/boot/README.i18n.yaml index 9be0243c92..0de587115e 100644 --- a/packages/boot/README.i18n.yaml +++ b/packages/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/README.md -README.md: 58a824a7f4af3c62f09b363f7cae041651c536b2 -README.zh.md: 7357b920a067ce74f6f74a69a241d82895675ee4 +README.md: 79d653260ea4a9d9a4c71a593b41a6a7e17efa14 +README.zh.md: 839be164328ef168cd6ac18bf2f1dcb930dfce3e diff --git a/packages/boot/README.md b/packages/boot/README.md index 58a824a7f4..79d653260e 100644 --- a/packages/boot/README.md +++ b/packages/boot/README.md @@ -7,6 +7,6 @@ The channel-neutral boot library the app bins share: `apps/cli`, the [`scaffold/ | Package | Role | ctx key | |---|---|---| | `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) | -| `cmdline/` | Launcher-to-app command-line handoff and app-owned startup parsing | `cmdlineArgs`, `appExit`, `appReady` | +| `cmdline/` | Launcher-to-app command-line handoff and app-owned startup parsing | `cmdlineArgs`, `appExit` | The boot sequence and personal-config contract are documented in [`app-boot/README.md`](app-boot/README.md); app-owned command lines are documented in [`cmdline/README.md`](cmdline/README.md). diff --git a/packages/boot/README.zh.md b/packages/boot/README.zh.md index 7357b920a0..839be16432 100644 --- a/packages/boot/README.zh.md +++ b/packages/boot/README.zh.md @@ -7,6 +7,6 @@ | 包 | 职责 | ctx 键 | |---|---|---| | `app-boot/` | app bin 的共享启动粘合层:加载 `.env`、会明确报错的 Loader 保护机制、感知快照的配置解析,以及等待整棵树停稳的启动序列 | (供各 bin 使用的库) | -| `cmdline/` | 启动器到应用的命令行交接,以及由应用持有的启动解析 | `cmdlineArgs`、`appExit`、`appReady` | +| `cmdline/` | 启动器到应用的命令行交接,以及由应用持有的启动解析 | `cmdlineArgs`、`appExit` | 启动序列与个人配置约定见 [`app-boot/README.md`](app-boot/README.md);由应用持有的命令行见 [`cmdline/README.md`](cmdline/README.md)。 diff --git a/packages/boot/cmdline/README.i18n.yaml b/packages/boot/cmdline/README.i18n.yaml index db3d559d0a..f5e9413afd 100644 --- a/packages/boot/cmdline/README.i18n.yaml +++ b/packages/boot/cmdline/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/cmdline/README.md -README.md: 571ea7acf9f7be1ee2bdadafae2fc71b99d4536a -README.zh.md: 271acd6be4d58bf12d41bc02dd3ccabc7359a269 +README.md: a1512ae3357f06cd4de6347ea5ec2197fea40a90 +README.zh.md: e27060db433e5c234febb28d6c120d75f82072cc diff --git a/packages/boot/cmdline/README.md b/packages/boot/cmdline/README.md index 571ea7acf9..a1512ae335 100644 --- a/packages/boot/cmdline/README.md +++ b/packages/boot/cmdline/README.md @@ -10,7 +10,6 @@ A launcher calls `provideCmdline(ctx, host)` before any tree entry mounts, which - `ctx.cmdlineArgs` — the invocation's inner arguments. `get()` is the whole interface, and it returns a snapshot: `dsh --profile tui --resume abc` yields `['--resume', 'abc']`. - `ctx.appExit` — a bounded process-exit request, wired to the launcher's shutdown controller. -- `ctx.appReady` — settles when the launcher has finished mounting, for a row that publishes readiness (a URL line a supervisor waits for). An embedding host with no command line provides an empty list; that is the honest answer, not a missing value. diff --git a/packages/boot/cmdline/README.zh.md b/packages/boot/cmdline/README.zh.md index 271acd6be4..e27060db43 100644 --- a/packages/boot/cmdline/README.zh.md +++ b/packages/boot/cmdline/README.zh.md @@ -10,7 +10,6 @@ dsh 启动器交给它所引导应用的那条命令行。启动器只解析属 - `ctx.cmdlineArgs`:本次调用的内层参数。`get()` 就是它的全部接口,返回一份快照:`dsh --profile tui --resume abc` 得到 `['--resume', 'abc']`。 - `ctx.appExit`:一个有边界的进程退出请求,接到启动器的关停控制器上。 -- `ctx.appReady`:在启动器挂载完毕时结算,供需要公布就绪信号的行使用(例如督程会等待的 URL 行)。 没有命令行的嵌入宿主提供空列表;这是诚实的答案,而不是缺失的值。 diff --git a/packages/boot/cmdline/src/index.ts b/packages/boot/cmdline/src/index.ts index 1e2c9e3d0b..6806e0a273 100644 --- a/packages/boot/cmdline/src/index.ts +++ b/packages/boot/cmdline/src/index.ts @@ -53,8 +53,6 @@ declare module 'cordis' { cmdlineArgs?: CmdlineArgs /** Bounded process-exit request; provided by a launcher before the tree mounts. */ appExit?: AppExit - /** Settles when the launcher has mounted the whole composition; see {@link CmdlineHost.ready}. */ - appReady?: Promise } } @@ -64,15 +62,6 @@ export interface CmdlineHost { args: readonly string[] /** Bounded process-exit request. */ exit: AppExit - /** - * Settles when the launcher has finished mounting, which a row that - * publishes readiness (a URL line a supervisor waits for) must await. - * - * Loader mounts sibling rows concurrently, so one row can become active - * while another is still mounting or while the whole boot is rolling back. - * Rejects with the boot failure. - */ - ready?: Promise } /** @@ -86,7 +75,6 @@ export function provideCmdline(ctx: Context, host: CmdlineHost): void { const snapshot = [...host.args] ctx.provide('cmdlineArgs', { get: () => snapshot }) ctx.provide('appExit', host.exit) - if (host.ready !== undefined) ctx.provide('appReady', host.ready) } /** diff --git a/packages/boot/cmdline/tests/cmdline.spec.ts b/packages/boot/cmdline/tests/cmdline.spec.ts index 9c046d4b94..61a5d75197 100644 --- a/packages/boot/cmdline/tests/cmdline.spec.ts +++ b/packages/boot/cmdline/tests/cmdline.spec.ts @@ -297,11 +297,9 @@ describe('provideCmdline', () => { it('hands the app a snapshot the caller cannot mutate afterwards', () => { const ctx = new Context() const args = ['--resume', 'abc'] - const ready = Promise.resolve() - provideCmdline(ctx, { args, exit: () => {}, ready }) + provideCmdline(ctx, { args, exit: () => {} }) args.push('--tampered') expect(ctx.cmdlineArgs?.get()).toEqual(['--resume', 'abc']) - expect(ctx.appReady).toBe(ready) }) it('fails loud when a startup row runs without the launcher values', () => { diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index 6053356414..7f12af35c8 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/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: 47b582225e768ac035d12947939c7a7eb700458c -README.zh.md: 61e134f90e7ae57cb6220e92880c001f0d06bae2 +README.md: e2cca9ddcca5690f36ce3e952a2814767acdad43 +README.zh.md: 321f7853c821f262a38b35530a4df8b2e18fff49 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index 47b582225e..e2cca9ddcc 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, enables the optional HMR row before client-module discovery so the first development graph contains its reload receiver, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true. This bundle also owns the app command line: the `web-startup` row ([`src/startup.ts`](src/startup.ts)) parses `--host`, `--port`, `--dev`, and repeatable `--trusted-host` from `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)) and prints the app's `--help`. Every row it configures injects `webStartup`, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. `mode` and `lanAddresses` resolve on every boot because they describe the invocation. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle. +The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, enables the optional HMR row before client-module discovery so the first development graph contains its reload receiver, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true, after its Loader tree settles so a sibling failure cannot announce a dead app. This bundle also owns the app command line: the `web-startup` row ([`src/startup.ts`](src/startup.ts)) parses `--host`, `--port`, `--dev`, and repeatable `--trusted-host` from `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)) and prints the app's `--help`. Every row it configures injects `webStartup`, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. `mode` and `lanAddresses` resolve on every boot because they describe the invocation. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle. ## Model Experience diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 61e134f90e..321f7853c8 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,在客户端模块发现前启用可选的 HMR 行,确保首份开发模式图中包含它的重载接收端,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。本组合包还持有应用命令行:`web-startup` 行([`src/startup.ts`](src/startup.ts))从 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md))解析 `--host`、`--port`、`--dev` 以及可重复的 `--trusted-host`,并打印应用自己的 `--help`。它所配置的每一行都注入 `webStartup`,因此在参数解析完成之前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。`mode` 与 `lanAddresses` 在每次 boot 时解析,因为它们描述的是本次调用。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。 +dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,在客户端模块发现前启用可选的 HMR 行,确保首份开发模式图中包含它的重载接收端,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时等自身的 Loader 配置树结算后再打印 `dsh web:` URL 行,避免兄弟行失败时公告一个已失效的应用。本组合包还持有应用命令行:`web-startup` 行([`src/startup.ts`](src/startup.ts))从 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md))解析 `--host`、`--port`、`--dev` 以及可重复的 `--trusted-host`,并打印应用自己的 `--help`。它所配置的每一行都注入 `webStartup`,因此在参数解析完成之前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。`mode` 与 `lanAddresses` 在每次 boot 时解析,因为它们描述的是本次调用。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。 ## 模型体验 diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts index c93f6ec597..30edbdcb68 100644 --- a/packages/bundle/web-app/src/index.ts +++ b/packages/bundle/web-app/src/index.ts @@ -159,10 +159,10 @@ export async function apply(ctx: Context, config: Config): Promise { const port = ctx.httpServer.port console.log(`dsh web: ${localWebUrl(ctx)}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${String(port)})`}`) } - // A launcher tells this row when the whole concurrent composition is up; - // this row's own activation can precede a sibling failure. A hand-built - // tree falls back to Loader settlement, or prints at once without Loader. - const settled = ctx.get('appReady') ?? ctx.get('loader')?.await() + // This row's own activation can precede a sibling failure. The app owns + // readiness by waiting for its Loader tree, or prints at once in a + // hand-built context without Loader. + const settled = ctx.get('loader')?.await() if (settled === undefined) printUrl() else { void settled.then(() => { @@ -170,7 +170,7 @@ export async function apply(ctx: Context, config: Config): Promise { // SIGTERM); a URL line for a dead server would only mislead, and // reading the torn-down port would turn a clean shutdown into a crash. if (ctx.get('httpServer') !== undefined) printUrl() - // A failed boot is reported by the launcher; this row only stays quiet. + // Loader reports a failed boot; this row only stays quiet. }, () => {}) } } diff --git a/packages/bundle/web-app/tests/web-app.spec.ts b/packages/bundle/web-app/tests/web-app.spec.ts index 8c2539a20f..df34637cab 100644 --- a/packages/bundle/web-app/tests/web-app.spec.ts +++ b/packages/bundle/web-app/tests/web-app.spec.ts @@ -149,39 +149,7 @@ describe('web-app runtime glue', () => { await ctx.fiber.dispose() }) - it('waits for launcher readiness and stays quiet when the whole boot failed', async () => { - stageDist() - // Launcher readiness covers siblings that may still be mounting after - // this row itself has activated. - const ready = new Context() - ready.provide('httpServer', fakeHttpServer().server) - provideHmrRow(ready) - let announce: () => void - ready.provide('appReady', new Promise((resolve) => { announce = resolve })) - const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - await apply(ready, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) - await new Promise(resolve => setTimeout(resolve, 0)) - expect(log).not.toHaveBeenCalled() - announce!() - await new Promise(resolve => setTimeout(resolve, 0)) - expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567') - await ready.fiber.dispose() - - // A boot that failed announces nothing: the launcher reports it, and a URL - // for a process that is about to exit would only mislead. - log.mockClear() - const failed = new Context() - failed.provide('httpServer', fakeHttpServer().server) - const rejection = Promise.reject(new Error('boot failed')) - rejection.catch(() => {}) - failed.provide('appReady', rejection) - await apply(failed, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) - await new Promise(resolve => setTimeout(resolve, 0)) - expect(log).not.toHaveBeenCalled() - await failed.fiber.dispose() - }) - - it('defers the URL line until Loader settlement and drops it when the server is gone', async () => { + it('defers the URL line until Loader settlement and drops it on failure or teardown', async () => { stageDist() // Settlement path: the line waits for loader.await() so supervisors can // RPC immediately after observing it. @@ -199,6 +167,17 @@ describe('web-app runtime glue', () => { expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567') await settled.fiber.dispose() + // Failed path: Loader reports the sibling failure; the app prints no URL + // for a process that is about to exit. + log.mockClear() + const failed = new Context() + failed.provide('httpServer', fakeHttpServer().server) + provideHmrRow(failed, async () => { throw new Error('boot failed') }) + await apply(failed, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(log).not.toHaveBeenCalled() + await failed.fiber.dispose() + // Torn-down path: settlement resolves after the webserver is gone — no // line, no crash. log.mockClear() diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 3de7e389a8..79419be5ed 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -112,7 +112,6 @@ export const SERVICE_PAGE: Record = { export const SERVICE_WALK_EXEMPTIONS: Record = { agent: 'not a service: the DX accessor field on Agent.ctx (root accessor defaulting to undefined) — docs/subsystems/core.md owns the Agent handle', appExit: 'not a service: launcher-provided bounded process-exit callback — packages/boot/cmdline/README.md owns the launcher contract', - appReady: 'not a service: launcher-provided whole-composition readiness promise — packages/boot/cmdline/README.md owns the launcher contract', cmdlineArgs: 'not a service: launcher-provided immutable app argument accessor — packages/boot/cmdline/README.md owns the launcher contract', configuredAgentIdentities: 'not a service: launcher-provided boot-context value (ConfiguredAgentIdentities | undefined) — packages/core/agent-loop/README.md owns this launcher contract', launcherSessionQueryPath: 'not a service: launcher-provided boot-context value (string | undefined) — packages/session-query/session-query-sqlite/README.md owns this launcher contract',