refactor(cli): discover app startup rows from injection

This commit is contained in:
Turtle
2026-08-07 15:43:26 +08:00
parent 1f0a0440f3
commit b692f38506
28 changed files with 302 additions and 283 deletions
@@ -12,39 +12,40 @@ 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`。应用从自己的**入口点行**消费它们——该行由其组合包 manifest(元数据清单)点名(`dsh.bundle.entrypoint`),注入 `cmdlineArgs`,以自己的 commander program 调用 `runStartup(ctx, service, program, plan)`,再把解析结果作为自己的服务提供出去。应用所配置的行从各自的配置表达式中读取该服务(`port: !!js ctx.get('webStartup')?.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。
新包 `@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 字段。应用所配置的行注入该服务,再从各自的配置表达式中读取它(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。
boot 分两趟挂载,这正是 manifest 声明所换来的:先是各入口点,然后才是整套组合。行的配置表达式在 include 施加该行时求值,而严格的 `ctx.get` 只对提供方 fiber 已经 active 的服务作答,因此配置树的其余部分必须在入口点起来之后才施加。于是 `--help` 在第二趟存在之前就退出;用户编辑一个活动的 patch 文件时,这一趟会针对仍然在线的服务重新施加,因此已经服务中的端口不会被悄悄重置。
boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Loader 随后在激活前一刻,基于已注入就绪的插件上下文插值该行的 `!!js`。Include 会保留嵌套的行表达式,直到目标行到达这一时点。`--help` 不提供启动服务,因此依赖行永不激活;活动 patch 重载会针对仍然在线的服务再次插值,所以已经服务中的端口不会被悄悄重置。
已交付的各应用把自己的 flag 搬进了组合包:`dsh-web-app` 持有 Web 家族(并为 `--dev` 启用它如今以禁用状态交付的 `client-hmr` 行),`dsh-headless` 持有任务位置参数,缺少任务时按用法错误拒绝。`apps/cli/src/web.ts` 已删除;`runProfile` 不再知道任何行 id。在树外,turtle-ui 以同样的方式获得了 `--resume <session>` / `--session <id>`,这才是这套设计的真正验证:一个已安装的插件加上了一个 flag,启动器毫无改动。
已交付的各应用把自己的 flag 搬进了组合包:`dsh-web-app` 持有 Web 家族(并为 `--dev` 启用它如今以禁用状态交付的 `client-hmr` 行),`dsh-headless` 持有任务位置参数,缺少任务时按用法错误拒绝。`apps/cli/src/web.ts` 已删除;`runProfile` 不再知道任何 flag 目标行 id。在树外,turtle-ui 以同样的方式获得了 `--resume <session>` / `--session <id>`,这才是这套设计的真正验证:一个已安装的插件加上了一个 flag,启动器毫无改动。
还有两条后果。Loader 结算不再意味着「应用已经起来」——在第二趟中挂载的行可能看到一棵已结算的树,而挂载它的那一趟仍在进行,甚至已经在回滚——因此公布就绪信号的行(web 的 URL 行)改为等待 `ctx.appReady`。另外,`dsh --profile web` 现在也会加上过去只有 `dsh web` 别名才会加的 harness 源码提示词章节:两条路径终于以完全相同的方式引导,这也意味着名为 `web` 的用户 profile 会继承它。
还有两条后果。Loader 会并发挂载兄弟行,因此一行可能已经激活,而另一行仍在挂载,或整次 boot 正在回滚;所以公布就绪信号的行(web 的 URL 行)会等待 `ctx.appReady`。另外,Web 组合包的运行时插件也持有 harness 源码提示词段,因此 `dsh web` 与 `dsh --profile web` 无需 Web 专用启动器设置即可按完全相同的方式启动。
## 为什么 boot 分阶段
## 为什么由 Loader 持有顺序
vendored Loader 的四个事实塑造了这套机制,它们都是靠探针试出来的:
四条框架事实塑造了这套机制:
- **profile 的各行是作为根 include 的 `patches` 选项送达的,而一个条目的整份配置会在该条目启动时被插值。** 因此每一行里的每个 `!!js` 都会在 include 挂载时一次性求值——早于任何行的存在。位于根配置*文件*中的行会逐行插值,但 profile 的根按设计就是空的。
- **严格的 `ctx.get` 会隐藏提供方 fiber 尚未 ACTIVE 的服务**,而插件自身的 fiber 在其 `apply` 仍在运行时并未 active。在同一趟里既提供服务又用它配置各行,是不可能成立的。
- **更新一行的 `inject` 会丢失插件自身的静态注入。** Loader 从 `runtime.callback`(未经包装的函数)重启被替换的行,此时 `Inject.resolve(plugin.inject)` 什么也找不到:声明了 `inject = ['httpServer', 'apiProxy']` 的行回来之后,两个服务都读不到。
- **不能从正在挂载的插件内部插入一行**——`tree.create` 返回一个带前缀的 id,随后它自己解析不出来——因此条件性的行以 `disabled: true` 交付,由与它同趟挂载的行来启用(`dsh web --dev` 及其重载链路)。
- **profile 的各行位于根 include 的 `patches` 选项内部。** Include 是条目树所有者,因此它的静态条目配置解析器会插值 Include 自身的选项,同时为目标行保留嵌套的 `!!js` 节点,而不是在 Include 上下文中递归求值。
- **Cordis 只在所有声明的注入都已激活后才激活 fiber。** Loader 为该 fiber 提供延迟配置解析器;Cordis 快照注入服务之后,解析器会在每次激活前一刻基于 fiber 自身上下文运行。
- **提供方替换与 HMR 必须保持相同契约。** fiber 重新激活时会重跑解析器,HMR 会把它带给替换 fiber,而待处理行可以接受选项变更,不会针对缺失服务提前求值表达式。
- **不能从正在挂载的插件内部插入一行**——`tree.create` 返回一个带前缀的 id,随后它自己解析不出来——因此条件性的行以 `disabled: true` 交付,再由活跃行启用(`dsh web --dev` 及其重载链路);启用后的行继续遵循普通注入顺序。
这些事实合起来排除了「一趟之内用服务配置各行」,并确立了分阶段挂载:各行保留自己的 `inject` 和自己的配置,而启动器在两阶段之间所做的,仅仅是再施加一次组合。
这样,依赖顺序就由真正持有它的接缝负责。各行保留自己的 `inject` 和配置,Loader 只挂载一次组合,启动器只提供 argv 与进程生命周期服务。
## 曾考虑的替代方案
- **把解析出的取值写进每一行**(逐行一次配置更新,外加交还给启动器的一层 patch,使重载无法撤销它):它能工作,但这意味着 patch 在应用与启动器之间来回传递、同一件事有两套机制,以及一套其正确性依赖 Loader 重启内部细节的回收重建。维护者否决了这次往返;供各行读取的服务取代了这一切。
- **通过清空行的 `inject` 来放行**:孤立测试可行,在真实 web 树上失败,因为清空 `inject` 恰恰会丢失插件的静态注入。在插件真的去读它声明过的服务之前,这个失败是静默的。
- **在单趟挂载中让各行等待该服务**:配置表达式在任何行存在之前就已插值,因此每个读取方都会看到 `undefined`。
- **由启动器在 boot 之前运行每个组合包的启动函数**(完全不经过 cordis):严格早于「先 boot 再 help」,但这会让应用启动成为配置树之外的第二套插件协议。声明一个入口点*行*则只保留一套协议:入口点就是一个普通的行,可 dump、可 patch,叠加的组合包也能像禁用其他行那样禁用它。
- **由启动器管理两趟挂载**:它可以让提供方先于读取行激活,但会重复组合、把顺序变成启动器职责,还掩盖了 Loader 的缺陷——嵌套表达式在 include 上下文而不是目标行的注入上下文中求值。
- **由启动器在 boot 之前运行每个组合包的启动函数**(完全不经过 cordis):严格早于「先 boot 再 help」,但这会让应用启动成为配置树之外的第二套插件协议。使用注入 `cmdlineArgs` 的启动行则只保留一套协议:它就是一个普通的行,可 dump、可 patch,叠加的组合包也能像禁用其他行那样禁用它。
- **两个应用解析同一份 argv**(一次性组合包叠加在 web 组合包之上):两个解析器不可能同时持有 `-h`。一套组合有且只有一个命令行所有者:叠加的组合包禁用下层的启动行,并同时提供这两个启动服务,使被吸收的行按组合后的取值启动。
- **`instanceof CommanderError`**:树外插件会带来自己的一份 commander 副本,类身份因此不同,已经打印出来的 `--help` 会被重新抛成致命的加载失败。改为按结构识别 commander 的控制流错误。
## 后果
- 应用的 flag、help 文本和用法错误与它们所配置的行放在一起;给已安装的插件加一个 flag 不需要改动启动器。
- `--help` 只挂载各入口点然后退出,组合中的其余部分从不启动。
- 启动服务没有静态声明的所有者:交付了读取行却缺少对应入口点的组合包会在结算时失败,报出指向该服务的待处理条目,而不是在加载时失败。
- 启动器仍会识别 headless runner 以管理一次性进程生命周期,并识别 telemetry 行以应用环境开关;两条路径都不解析应用参数。
- `--help` 会让所有依赖启动服务的行保持待处理并请求有边界的退出;无关行可能在拆除前并发激活。没有注入 `cmdlineArgs` 的活跃行的 profile 会在挂载前拒绝非空应用参数,而不是忽略它们。
- 启动服务没有静态声明的所有者:交付了读取行却缺少对应启动行的组合包会在结算时失败,报出指向该服务的待处理条目,而不是在加载时失败。
- 用户 patch 若整体替换某行的 `config`,会连同其中的表达式一起丢掉,该行上 flag 的优先级也随之消失。
- 启动器的 flag 必须写在应用参数之前;如果应用的第一个参数恰好是 `web` 或 `plugin`,选中的将是这两个子命令,而且启动器的解析器会消耗掉一个 `--`,因此要给应用传一个字面量 `--` 需要写成 `-- --`。
- 启动器的 flag 必须写在应用参数之前;如果应用的第一个参数恰好等于 `web` 或 `plugin`,会选择对应的子命令;`-V`/`--version` 在该边界之前仍归启动器持有;而且启动器的解析器会消耗掉一个 `--`,因此要给应用传一个字面量 `--` 需要写成 `-- --`。
- `--dump-config` 从不运行启动行,因此它在任何应用参数被解析之前打印组合,并拒绝携带应用参数的调用。