refactor(cmdline): keep readiness in web app

This commit is contained in:
Turtle
2026-08-10 20:49:40 +08:00
parent 1ebb12432b
commit 668bdb3d8e
18 changed files with 34 additions and 88 deletions
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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).
+1 -1
View File
@@ -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)。
+2 -2
View File
@@ -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
-1
View File
@@ -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.
-1
View File
@@ -10,7 +10,6 @@ dsh 启动器交给它所引导应用的那条命令行。启动器只解析属
- `ctx.cmdlineArgs`:本次调用的内层参数。`get()` 就是它的全部接口,返回一份快照:`dsh --profile tui --resume abc` 得到 `['--resume', 'abc']`
- `ctx.appExit`:一个有边界的进程退出请求,接到启动器的关停控制器上。
- `ctx.appReady`:在启动器挂载完毕时结算,供需要公布就绪信号的行使用(例如督程会等待的 URL 行)。
没有命令行的嵌入宿主提供空列表;这是诚实的答案,而不是缺失的值。
-12
View File
@@ -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<void>
}
}
@@ -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<void>
}
/**
@@ -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)
}
/**
+1 -3
View File
@@ -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', () => {
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 之上的同级表层,不挂载本组合包。
## 模型体验
+5 -5
View File
@@ -159,10 +159,10 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
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<void> {
// 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.
}, () => {})
}
}
+12 -33
View File
@@ -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<void>((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()