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 c0ec7836ae..f38cc36d1e 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: 4765629c0cc3fee1d850de215af18bdbe51324bb -2026-08-06-app-owned-command-line.zh.md: 48782fbb9ce53ba9b3e8dbc6c2f746c7f1d46ea1 +2026-08-06-app-owned-command-line.md: e533338118f1b195589ed05ad972d1d4a55e610c +2026-08-06-app-owned-command-line.zh.md: 00f492629fd08383726e71ad7eea608df22fb772 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 4765629c0c..e533338118 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,39 +12,40 @@ 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 **entrypoint row** — named by its bundle manifest (`dsh.bundle.entrypoint`) — which injects `cmdlineArgs` and calls `runStartup(ctx, service, program, plan)` with its own commander program, then provides what it resolved as its own service. The rows the app configures read that service from their own config expressions (`port: !!js ctx.get('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[]`), `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. 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 in two passes, which is what the manifest declaration buys: entrypoints alone, then the whole composition. A row's config expressions are evaluated when the include applies the row, and a strict `ctx.get` only answers for a service whose providing fiber is active, so the rest of the tree has to be applied after the entrypoints are up. `--help` therefore exits before the second pass exists, and a user editing a live patch file re-applies that pass against services that are still up, so a served port cannot be silently reset. +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 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. +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 settlement stopped meaning "the app is up" — a row mounted in the second pass can observe a settled tree while the pass that mounted it is still going, or already rolling back — so a row that publishes readiness (the web URL line) awaits `ctx.appReady` instead. And `dsh --profile web` now adds the harness-source prompt section that only the `dsh web` alias used to add: the two paths finally boot identically, which also means a user profile named `web` inherits it. +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. -## Why the boot has phases +## Why Loader owns the ordering -Four vendored-Loader facts shaped the mechanism, all found by probe: +Four framework facts shape the mechanism: -- **A profile's rows arrive as the root include's `patches` option, and an entry's whole config is interpolated when that entry starts.** Every `!!js` in every row is therefore evaluated once, when the include mounts — before any row exists. Rows in the root config *file* would interpolate per row, but a profile root is empty by design. -- **A strict `ctx.get` hides a service whose providing fiber is not yet ACTIVE**, and a plugin's own fiber is not active while its `apply` is still running. Providing a service and configuring rows from it in the same pass cannot work. -- **Updating a row's `inject` loses the plugin's own static injections.** The Loader restarts a replaced row from `runtime.callback`, the unwrapped function, and `Inject.resolve(plugin.inject)` then finds nothing: a row declaring `inject = ['httpServer', 'apiProxy']` comes back unable to read either. -- **A row cannot be inserted from inside a mounting plugin** — `tree.create` returns a prefixed id it then fails to resolve — so a conditional row ships `disabled: true` and a row that mounts beside it enables it (`dsh web --dev` and its reload chain). +- **A profile's rows arrive inside the root include's `patches` option.** Include is an entry-tree owner, so its static entry-config resolver interpolates Include's own options while preserving nested `!!js` nodes for their target rows instead of recursively evaluating them in the Include context. +- **Cordis activates a fiber only after all declared injections are active.** Loader supplies a deferred config resolver to that fiber; the resolver runs immediately before each activation against the fiber's own context, after Cordis snapshots its injected services. +- **Provider replacement and HMR must preserve the same contract.** Fiber reactivation re-runs the resolver, HMR carries it to the replacement fiber, and a pending row accepts option changes without prematurely evaluating expressions against absent services. +- **A row cannot be inserted from inside a mounting plugin** — `tree.create` returns a prefixed id it then fails to resolve — so a conditional row ships `disabled: true` and an active row enables it (`dsh web --dev` and its reload chain); the enabled row then follows ordinary injection ordering. -Together these rule out configuring rows from a service in one pass, and rule in the phased mount: rows keep their own `inject` and their own config, and the only thing the launcher does between phases is apply the composition again. +This puts dependency ordering at the seam that owns it. Rows keep their `inject` and config, Loader mounts the composition once, and the launcher only provides argv and process-lifecycle services. ## Alternatives considered - **Writing the resolved values into each row** (a config update per row, plus a patch layer handed back to the launcher so a reload could not undo it): it worked, but it meant patches travelling from an app to the launcher and back, two mechanisms for one fact, and a recycle whose correctness depended on Loader restart internals. The maintainer rejected the round trip; the service the rows read replaced all of it. - **Releasing rows by clearing their `inject`**: it worked in isolation and failed on the real web tree, because clearing `inject` is exactly what loses the plugin's static injections. The failure is silent until a plugin reads a service it declared. -- **Rows waiting on the service in a single-pass mount**: the config expressions are interpolated before any row exists, so every reader would see `undefined`. -- **The launcher running each bundle's startup function before boot** (no cordis involvement): strictly earlier than "boot, then help", but it makes app startup a second plugin protocol outside the tree. Declaring an entrypoint *row* keeps one protocol: the entrypoint is an ordinary row, dumpable and patchable, and a layering bundle disables it like any other. +- **Launcher-managed two-pass mounting**: it can make a provider active before readers are applied, but duplicates the composition, makes ordering a launcher concern, and conceals the Loader defect that nested expressions were evaluated in the include context rather than the target row's injected context. +- **The launcher running each bundle's startup function before boot** (no cordis involvement): strictly earlier than "boot, then help", but it makes app startup a second plugin protocol outside the tree. Using a `cmdlineArgs`-injected startup row keeps one protocol: it is an ordinary row, dumpable and patchable, and a layering bundle disables it like any other. - **Both apps parsing the same argv** (the one-shot bundle rides over the web bundle): two parsers cannot both own `-h`. A composition has exactly one command-line owner: the layering bundle disables the underlying startup row and names both startup services, so the absorbed rows start on their composed values. - **`instanceof CommanderError`**: an out-of-tree plugin brings its own commander copy, so the class identity differs and a printed `--help` was rethrown as a fatal load failure. Commander's control-flow errors are detected structurally instead. ## Consequences - An app's flags, help text, and usage errors live with the rows they configure; adding a flag to an installed plugin needs no launcher change. -- `--help` mounts only the entrypoints and exits, so nothing else in the composition ever starts. -- A startup service has no statically declared owner: a bundle shipping reading rows without its entrypoint fails at settlement with pending entries naming the service, not at load. +- The launcher still recognizes the headless runner for one-shot process lifetime and the telemetry row for its environment switch; neither path interprets app arguments. +- `--help` leaves every row that depends on a startup service pending and requests bounded exit; unrelated rows may activate concurrently before teardown. A profile with no active row injecting `cmdlineArgs` rejects nonempty app arguments before mounting instead of ignoring them. +- A startup service has no statically declared owner: a bundle shipping reading rows without its startup row fails at settlement with pending entries naming the service, not at load. - A user patch that replaces a row's whole `config` drops its expressions, and with them the flag's precedence for that row. -- Launcher flags must precede app arguments; a first app argument reading `web` or `plugin` selects those subcommands instead, and the launcher's parser consumes one `--`, so a literal `--` for the app needs `-- --`. +- Launcher flags must precede app arguments; a first app argument equal to `web` or `plugin` selects that subcommand instead, `-V`/`--version` remains launcher-owned before that boundary, and the launcher's parser consumes one `--`, so a literal `--` for the app needs `-- --`. - `--dump-config` never runs a startup row, so it prints the composition before any app argument is resolved and rejects an invocation that carries app arguments. 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 48782fbb9c..00f492629f 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,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 `,这才是这套设计的真正验证:一个已安装的插件加上了一个 flag,启动器毫无改动。 +已交付的各应用把自己的 flag 搬进了组合包:`dsh-web-app` 持有 Web 家族(并为 `--dev` 启用它如今以禁用状态交付的 `client-hmr` 行),`dsh-headless` 持有任务位置参数,缺少任务时按用法错误拒绝。`apps/cli/src/web.ts` 已删除;`runProfile` 不再知道任何 flag 目标行 id。在树外,turtle-ui 以同样的方式获得了 `--resume ` / `--session `,这才是这套设计的真正验证:一个已安装的插件加上了一个 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` 从不运行启动行,因此它在任何应用参数被解析之前打印组合,并拒绝携带应用参数的调用。 diff --git a/docs/user/develop/basic/publish.i18n.yaml b/docs/user/develop/basic/publish.i18n.yaml index d849ac4ae0..963fe17378 100644 --- a/docs/user/develop/basic/publish.i18n.yaml +++ b/docs/user/develop/basic/publish.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 docs/user/develop/basic/publish.md -publish.md: 7657654b1467c14b22e0eb6372c2bc4e77db2f38 -publish.zh.md: 7af2ae3a06cc74597d5cbd6fddd46fbab069e287 +publish.md: c81e53d75ecccd31c9051f33252854dbe156c566 +publish.zh.md: c5a15be00bea838eb534dbf608c29d3832c2c0e1 diff --git a/docs/user/develop/basic/publish.md b/docs/user/develop/basic/publish.md index 7657654b14..c81e53d75e 100644 --- a/docs/user/develop/basic/publish.md +++ b/docs/user/develop/basic/publish.md @@ -98,7 +98,8 @@ The effective configuration composes over an empty root by applying, in order: 2. The profile's own `cordis.patch.yml`. 3. The home-level `$DSH_HOME/cordis.patch.yml` — machine-local preferences shared by every profile. 4. Each `--patch ` overlay, in argv order. -5. Launcher flag patches (for example `dsh web --port`). + +App arguments are not another patch layer. A surface bundle can resolve them through a startup service, described below. Later layers win per row, and a patch replaces a row's entire `config` value rather than deep-merging keys. Two consequences for bundle authors: @@ -107,6 +108,20 @@ Later layers win per row, and a patch replaces a row's entire `config` value rat In-box bundle names always resolve from the dsh installation itself; pnpm manages only out-of-tree packages, so your bundle can rely on `@deepseek-ai/dsh-base` being present and current. +## Give a surface bundle its own command line + +A bundle that defines a runnable app marks its startup row through the injection it already requires: + +```yaml +- id: hello-startup + name: 'dsh-hello-plugin/startup' + inject: [cmdlineArgs] +``` + +That row calls `runStartup` from [`@deepseek-ai/dsh-cmdline`](../../../../packages/ui/cmdline/README.md) with the app's own commander program. The launcher hands it every argument after the launcher flags, so app-specific flags need no launcher change. Loader mounts the composition once, waits for each row's injections, and only then evaluates that row's `!!js` config against its injected context. + +Rows configured by those arguments inject the startup service and read it from their own `!!js` options, with the deployment value beside it as the fallback. On `--help`, the service is not provided, so those rows never activate. An app layered over another app disables the lower startup row, because one composition has one command-line owner. + ## Installing from GitHub: the build-script catch Publishing to a registry is not required — users can install straight from a git host: diff --git a/docs/user/develop/basic/publish.zh.md b/docs/user/develop/basic/publish.zh.md index 7af2ae3a06..c5a15be00b 100644 --- a/docs/user/develop/basic/publish.zh.md +++ b/docs/user/develop/basic/publish.zh.md @@ -2,14 +2,14 @@ [English](publish.md) | 中文 -前几篇教程通过 `--patch` overlay 加载本地插件。本教程把它打包成可安装的**组合包**,用 `dsh plugin add` 安装进一个 **profile**,并解释决定组合后配置的层顺序。请先完成[插件配置](./config.md)。 +前几篇教程通过 `--patch` overlay 加载本地插件。本教程把它打包成可安装的**组合包**(bundle),用 `dsh plugin add` 安装进一个 **profile**,并解释决定组合后配置的层顺序。请先完成[插件配置](./config.md)。 -## 两个概念,两种 manifest(元数据清单) +## 两个概念,两种 manifest -安装机制建立在两个概念之上。二者都由一份 `package.json` 描述,但它们在 `dsh` 键下携带的 manifest 种类不同,回答的问题也不同: +安装机制建立在两个概念之上。二者都由一份 `package.json` 描述,但它们在 `dsh` 键下携带的 manifest(元数据清单)种类不同,回答的问题也不同: -- **组合包**是附带一个配置层的 npm 包。它的 manifest 声明 `dsh.bundle`,回答的是「这个包贡献什么?」:一个插入或覆盖插件行的 patch 文件。 -- **profile** 是位于 `$DSH_HOME/profiles/` 下、描述一份可启动组合的目录。它的 manifest 声明 `dsh.profile`,回答的是「这套配置由哪些组合包按什么顺序组成?」。 +- **组合包**是附带一个配置层的 npm 包。它的 manifest 声明 `dsh.bundle`,回答的是"这个包贡献什么?":一个插入或覆盖插件行的 patch 文件。 +- **profile** 是位于 `$DSH_HOME/profiles/` 下、描述一份可启动组合的目录。它的 manifest 声明 `dsh.profile`,回答的是"这套配置由哪些组合包按什么顺序组成?"。 组合包是你编写并分发的东西;profile 是用户用 `dsh --profile ` 启动的东西。没有东西同时是两者。 @@ -98,7 +98,8 @@ dsh --profile demo 2. profile 自己的 `cordis.patch.yml`。 3. home 级的 `$DSH_HOME/cordis.patch.yml`——各 profile 共享的机器本地偏好。 4. 每个 `--patch ` overlay,按 argv 顺序。 -5. 启动器 flag patch(例如 `dsh web --port`)。 + +应用参数不是另一层 patch。表层组合包可以通过下文所述的启动服务解析它们。 后应用的层按行胜出,且 patch 会替换目标行的整个 `config` 值,而不是深度合并各键。这给组合包作者带来两个推论: @@ -107,6 +108,20 @@ dsh --profile demo 内置组合包名称始终从 dsh 安装目录本身解析;pnpm 只管理树外的包,所以你的组合包可以放心依赖 `@deepseek-ai/dsh-base` 存在且与安装保持一致。 +## 让表层组合包持有自己的命令行 + +定义了可运行应用的组合包可以通过启动行本来就需要的注入来标记它: + +```yaml +- id: hello-startup + name: 'dsh-hello-plugin/startup' + inject: [cmdlineArgs] +``` + +该行使用应用自己的 commander program 调用 [`@deepseek-ai/dsh-cmdline`](../../../../packages/ui/cmdline/README.md) 中的 `runStartup`。启动器把自身 flag 之后的所有参数交给它,因此添加应用专属 flag 无需修改启动器。Loader 只挂载一次组合,等待每一行的注入,再基于其已注入的上下文求值该行的 `!!js` 配置。 + +受这些参数配置的行会注入启动服务,并在自己的 `!!js` 选项中读取它,同时把部署取值写在旁边作为回退。遇到 `--help` 时,该服务不会被提供,所以这些行不会激活。叠加在另一应用之上的应用会禁用下层启动行,因为一套组合只能有一个命令行所有者。 + ## 从 GitHub 安装:构建脚本这道坎 发布到注册表不是必须的——用户可以直接从 git 托管安装: @@ -127,7 +142,7 @@ dsh plugin --profile demo add github:you/hello-plugin 然后重新执行 `add`。 -请如实看待这项授权:**允许该包的代码在安装时于你的机器上执行**,且不在 agent(智能体)运行的任何沙箱之内。只对源码可信的包授权,并锁定 commit(`github:you/hello-plugin#`),让后续推送无法悄悄改变实际运行的内容。 +请如实看待这项授权:**允许该包的代码在安装时于你的机器上执行**,且不在 agent 运行的任何沙箱之内。只对源码可信的包授权,并锁定 commit(`github:you/hello-plugin#`),让后续推送无法悄悄改变实际运行的内容。 如果不想让用户做这项授权,就改为分发构建产物——以下两种形式都不需要任何构建权限: diff --git a/docs/user/guide/config.i18n.yaml b/docs/user/guide/config.i18n.yaml index 7feb2c7f07..3010782d20 100644 --- a/docs/user/guide/config.i18n.yaml +++ b/docs/user/guide/config.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 docs/user/guide/config.md -config.md: cd778065801ae58a46703ae3447f835f80abf062 -config.zh.md: 6f6d37bfe8f7ad29c154d65c1763279655006435 +config.md: 7a8492d45fc3710958853b8498f90f5a19b62f4a +config.zh.md: 62a1693a13cdd4b2428085187b73b69d429cde6e diff --git a/docs/user/guide/config.md b/docs/user/guide/config.md index cd77806580..7a8492d45f 100644 --- a/docs/user/guide/config.md +++ b/docs/user/guide/config.md @@ -18,6 +18,10 @@ A minimal configuration is a list of plugin entries: ```yaml - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + models: + - deepseek-v4-flash - id: bash name: '@deepseek-ai/dsh-bash-local' @@ -47,16 +51,17 @@ Cordis starts sibling entries concurrently. A plugin declares required services ## CLI patch layers -`dsh --profile ` composes the profile's bundle patch layers (its manifest's `dsh.profile.bundles` list, in order) over an empty root, then the profile's own `~/.dsh/profiles//cordis.patch.yml`, then each `--patch ` overlay, then CLI-flag patches. Later layers win per row. +`dsh --profile ` composes the profile's bundle patch layers (its manifest's `dsh.profile.bundles` list, in order) over an empty root, then the profile's own `~/.dsh/profiles//cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and each `--patch ` overlay. Later layers win per row. App flags are not another patch layer: the bundle's `cmdlineArgs`-injected startup row resolves them into a service, and rows that retain a `!!js` read of that service give the invocation value precedence. -A patch replaces a row's entire `config` value; it does not deep-merge keys. For example, patching `llm-deepseek` with only `config: { thinking: disabled }` also removes that row's configured `apiKeyEnv` and `baseURL`, so restate every key the row must retain. +A patch replaces a row's entire `config` value; it does not deep-merge keys. For example, patching `llm-deepseek` with only `config: { thinking: disabled }` also removes that row's configured `apiKey` and `baseURL`, so restate every key the row must retain. ## JavaScript values and environment variables -The Cordis loader evaluates runtime expressions tagged with `!!js` for non-secret runtime values. Bundled LLM adapters carry credential references such as `apiKeyEnv`; the value belongs in an environment layer or `$DSH_HOME/.credentials.yaml`, not Cordis configuration. +The Cordis loader evaluates runtime expressions tagged with `!!js`. Keep API keys and other secrets in the gitignored `.env` file at the repository root, never in committed configuration. ```yaml config: + apiKey: !!js process.env.DEEPSEEK_API_KEY cwd: !!js process.cwd() ``` diff --git a/docs/user/guide/config.zh.md b/docs/user/guide/config.zh.md index 6f6d37bfe8..62a1693a13 100644 --- a/docs/user/guide/config.zh.md +++ b/docs/user/guide/config.zh.md @@ -18,6 +18,10 @@ Harness 使用 `cordis.yml` 描述 agent(智能体)加载哪些插件以及 ```yaml - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + models: + - deepseek-v4-flash - id: bash name: '@deepseek-ai/dsh-bash-local' @@ -47,16 +51,17 @@ Cordis 会并发启动同级配置项。插件通过 `inject` 声明必需服务 ## CLI 补丁层 -`dsh --profile ` 按该 profile 的 manifest(元数据清单)中 `dsh.profile.bundles` 列表的顺序,在空根之上组合各组合包补丁层,随后依次应用该 profile 自己的 `~/.dsh/profiles//cordis.patch.yml`、home 级 `$DSH_HOME/cordis.patch.yml`、每个 `--patch ` overlay,最后是 CLI(命令行界面)标志补丁。同一行以较后的层为准。 +`dsh --profile ` 按该 profile 的 manifest(元数据清单)中 `dsh.profile.bundles` 列表的顺序,在空根之上组合各组合包补丁层,随后依次应用该 profile 自己的 `~/.dsh/profiles//cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 与每个 `--patch ` overlay。同一行以较后的层为准。应用 flag 并不是另一层 patch:组合包中注入 `cmdlineArgs` 的启动行把它们解析成服务,而保留了读取该服务的 `!!js` 表达式的行会让本次调用的取值优先。 -补丁会替换目标行的整个 `config` 值,而不是深度合并各个键。例如,只用 `config: { thinking: disabled }` 修补 `llm-deepseek`,也会移除该行原有的 `apiKeyEnv` 与 `baseURL`;因此必须重新写出该行需要保留的全部键。 +补丁会替换目标行的整个 `config` 值,而不是深度合并各个键。例如,只用 `config: { thinking: disabled }` 修补 `llm-deepseek`,也会移除该行原有的 `apiKey` 与 `baseURL`;因此必须重新写出该行需要保留的全部键。 ## JavaScript 值和环境变量 -Cordis loader 会求值以 `!!js` 标记的运行时表达式,用于非机密的运行时值。仓库内置的 LLM(大语言模型)适配器携带 `apiKeyEnv` 等凭据引用;对应的值应放在环境层或 `$DSH_HOME/.credentials.yaml`,而不是 Cordis 配置中。 +Cordis loader 使用 `!!js` 标签读取运行时表达式。API key 等凭据应放在仓库根目录、已被 Git 忽略的 `.env` 中,不能提交到配置文件。 ```yaml config: + apiKey: !!js process.env.DEEPSEEK_API_KEY cwd: !!js process.cwd() ``` @@ -64,4 +69,4 @@ config: ## 精确配置参考 -每个插件当前支持的字段、类型和默认值见自动生成的[插件配置目录](../../config-catalog.md)。理解插件如何组合可继续阅读[架构说明](../../architecture.md)和[能力 seam](../../capability-seams.md);要创建自己的配置,优先复制并修改[示例目录说明](../../../examples/README.md)中最接近的例子。 +每个插件当前支持的字段、类型和默认值见自动生成的[插件配置目录](../../config-catalog.md)。理解插件如何组合可继续阅读[架构说明](../../architecture.md)和[能力接口](../../capability-seams.md);要创建自己的配置,优先复制并修改[示例目录说明](../../../examples/README.md)中最接近的例子。 diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index e5596229ae..5f8d6643a1 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -39,7 +39,6 @@ export { PROFILES_DIR, readProfileManifest, resolveBundleDir, - resolveEntrypoints, resolveProfileDir, writeProfileManifest, type DshBundleManifest, @@ -532,10 +531,10 @@ export async function mountRootInclude( * Re-apply the root include's patch list on a booted tree, and wait for the * result to settle. * - * This is how a boot mounts its composition in phases: an app's entrypoint row + * This is how a boot mounts its composition in phases: an app's startup row * resolves what the rest of the tree reads (`!!js ctx.get('webStartup')?.port`), * and a row's config expressions are evaluated when the include applies them — - * so the rest of the composition must be applied after the entrypoints are + * so the rest of the composition must be applied after the startup rows are * active, not before. * @param ctx - the booted context whose root include to re-apply. * @param patches - the full patch list for this generation. @@ -545,7 +544,7 @@ export async function mountRootInclude( export async function applyRootPatches(ctx: Context, patches: readonly PatchOptions[]): Promise { const entry = bootstrapIncludes.get(ctx) if (entry === undefined) throw new Error('dsh: applying root patches requires the root Include entry') - // A surface can dispose the whole tree while an entrypoint is still parsing + // A surface can dispose the whole tree while a startup row is still parsing // (`--help`, or an early SIGTERM); there is then nothing left to mount. if (ctx.get('loader') === undefined) return const { patches: _previous, ...includeConfig } = entry.options.config as Include.Config diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index e105287808..e19bb13c41 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -42,16 +42,6 @@ export const PROFILE_PATCH_FILENAME = 'cordis.patch.yml' export interface DshBundleManifest { /** The patch layer this bundle exports, relative to its package root. */ patch: string - /** - * Id of the row in that patch which must run before every other row of the - * composition — the app's entrypoint. - * - * An entrypoint resolves what the rest of the tree needs in order to be - * configured at all (the command line an app was invoked with), and provides - * it as a service. The boot mounts entrypoints alone first, so by the time - * any other row's config is resolved, `ctx.get('')` answers. - */ - entrypoint?: string } /** The profile half of the `dsh` manifest section: what a profile directory composes. */ @@ -89,37 +79,6 @@ export interface ProfileLayer { patchPath: string /** The parsed patch list. */ patches: PatchOptions[] - /** Row id this bundle declares as its entrypoint, when it has one. */ - entrypoint?: string -} - -/** - * The composition's entrypoint row ids, in bundle order. - * @param binName - the diagnostic prefix on the thrown error. - * @param profile - the loaded profile. - * @param rows - the composed rows, so an entrypoint a later layer removed or - * disabled is not mounted (the one-shot bundle takes over the web one this way). - * @returns the row ids to mount before the rest of the tree. - * @throws when a bundle declares an entrypoint its own patch never inserts. - */ -export function resolveEntrypoints( - binName: string, - profile: Profile, - rows: readonly { id?: string; disabled?: boolean | null }[], -): string[] { - const entrypoints: string[] = [] - for (const layer of profile.layers) { - if (layer.entrypoint === undefined) continue - const row = rows.find(candidate => candidate.id === layer.entrypoint) - if (row === undefined) { - throw new Error( - `${binName}: bundle ${JSON.stringify(layer.packageName)} declares entrypoint ${JSON.stringify(layer.entrypoint)}, ` - + 'which the composed tree has no row for', - ) - } - if (row.disabled !== true) entrypoints.push(layer.entrypoint) - } - return entrypoints } /** A loaded profile: resolved bundle layers plus the user's own patch layer. */ @@ -432,14 +391,7 @@ export function loadProfile( throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`) } const patchPath = join(packageDir, declared) - const entrypoint = bundleManifest.dsh?.bundle?.entrypoint - return { - packageName, - packageDir, - patchPath, - patches: loadOverlayPatches(binName, patchPath), - ...entrypoint === undefined ? {} : { entrypoint }, - } + return { packageName, packageDir, patchPath, patches: loadOverlayPatches(binName, patchPath) } }) const patchPath = join(dir, PROFILE_PATCH_FILENAME) const patches = options.userLayer !== false && existsSync(patchPath) diff --git a/packages/boot/app-boot/tests/profile.spec.ts b/packages/boot/app-boot/tests/profile.spec.ts index 48166042f0..bd0294475d 100644 --- a/packages/boot/app-boot/tests/profile.spec.ts +++ b/packages/boot/app-boot/tests/profile.spec.ts @@ -17,7 +17,6 @@ import { PROFILE_TEMPLATES, readProfileManifest, resolveBundleDir, - resolveEntrypoints, resolveProfileDir, writeProfileManifest, } from '../src/index.ts' @@ -198,37 +197,6 @@ describe('loadProfile', () => { }) }) -describe('resolveEntrypoints', () => { - const profile = (layers: { packageName: string; entrypoint?: string }[]): Parameters[1] => ({ - name: 'p', - dir: '/p', - patchPath: '/p/cordis.patch.yml', - patches: [], - layers: layers.map(layer => ({ ...layer, packageDir: '/b', patchPath: '/b/cordis.patch.yml', patches: [] })), - }) - - it('names each bundle entrypoint in bundle order', () => { - expect(resolveEntrypoints( - 'dsh', - profile([{ packageName: 'a' }, { packageName: 'b', entrypoint: 'b-startup' }, { packageName: 'c', entrypoint: 'c-startup' }]), - [{ id: 'b-startup' }, { id: 'c-startup' }, { id: 'other' }], - )).toEqual(['b-startup', 'c-startup']) - }) - - it('skips an entrypoint a later layer disabled, which is how one app takes over another', () => { - expect(resolveEntrypoints( - 'dsh', - profile([{ packageName: 'web', entrypoint: 'web-startup' }, { packageName: 'one-shot', entrypoint: 'one-shot-startup' }]), - [{ id: 'web-startup', disabled: true }, { id: 'one-shot-startup' }], - )).toEqual(['one-shot-startup']) - }) - - it('fails loud when a bundle declares an entrypoint its patch never inserts', () => { - expect(() => resolveEntrypoints('dsh', profile([{ packageName: 'b', entrypoint: 'absent' }]), [{ id: 'other' }])) - .toThrow('declares entrypoint "absent", which the composed tree has no row for') - }) -}) - describe('composeEntries', () => { it('applies layers over an empty root and reports skipped patches', () => { const warnings: string[] = [] diff --git a/packages/boot/cmdline/README.i18n.yaml b/packages/boot/cmdline/README.i18n.yaml index 7c986b0d26..dcd4d2416d 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/ui/cmdline/README.md -README.md: acdc3a310f0062f1b27dbd74d20b81e1a8198bca -README.zh.md: 365a2c7f3cdf5710ce7e3abe76f009dc1ba4217f +README.md: 242ba184507d88c50e0dcf2ada0a0f7714d87e28 +README.zh.md: 76a76ad6090fcc28d50f9ea2a48d4e2581e361f2 diff --git a/packages/boot/cmdline/README.md b/packages/boot/cmdline/README.md index acdc3a310f..242ba18450 100644 --- a/packages/boot/cmdline/README.md +++ b/packages/boot/cmdline/README.md @@ -14,9 +14,9 @@ A launcher calls `provideCmdline(ctx, host)` before any tree entry mounts, which An embedding host with no command line provides an empty list; that is the honest answer, not a missing value. -## Entrypoints, and the service their app reads +## Startup rows, and the service their app reads -An app reads those arguments from its **entrypoint row** — a plugin that injects `cmdlineArgs` and calls `runStartup(ctx, service, program, plan)`: +An app reads those arguments from its **startup row** — a Loader row and plugin that inject `cmdlineArgs` and calls `runStartup(ctx, service, program, plan)`: ```ts ignore export const name = 'web-startup' @@ -27,13 +27,17 @@ export function apply(ctx: Context): void { } ``` -The bundle's `package.json` names that row, which is what makes the boot mount it before everything else: +The Loader-row injection is also its discovery declaration, so no bundle manifest field is needed: -```json -{ "dsh": { "bundle": { "patch": "./cordis.patch.yml", "entrypoint": "web-startup" } } } +```yaml +- id: web-startup + name: '@deepseek-ai/dsh-web-app/startup' + inject: [cmdlineArgs] ``` -Every row the app configures from flags then reads what the entrypoint resolved, naming the key it takes and the value it falls back to: +The launcher finds active rows with that injection in the composed tree and mounts them before everything else. + +Every row the app configures from flags then reads what the startup row resolved, naming the key it takes and the value it falls back to: ```yaml - id: webserver @@ -50,13 +54,13 @@ Every row the app configures from flags then reads what the entrypoint resolved, ### Why the boot has phases -A row's config expressions are evaluated when the include applies it, and a strict `ctx.get` only answers for a service whose providing fiber is already active. A composition therefore mounts in two passes: the entrypoints alone, then everything else — which is exactly what the manifest declaration buys. The rows of a later pass read live values, a `--help` exits before the second pass exists, and a user editing a live patch file re-runs that pass against services that are still up, so a flag cannot be silently reset. +A row's config expressions are evaluated when the include applies it, and a strict `ctx.get` only answers for a service whose providing fiber is already active. A composition therefore mounts in two passes: active `cmdlineArgs` consumers alone, then everything else. The rows of the later pass read live values, a `--help` exits before the second pass exists, and a user editing a live patch file re-runs that pass against services that are still up, so a flag cannot be silently reset. -`enableRow(ctx, id)` turns on a row a bundle ships disabled because only some invocations want it (`dsh web --dev` and its client-plugin reload chain). Call it from a row that mounts beside the one being enabled, not from an entrypoint: a row enabled in the first pass would wait for services the second pass has yet to mount. +`enableRow(ctx, id)` turns on a row a bundle ships disabled because only some invocations want it (`dsh web --dev` and its client-plugin reload chain). Call it from a row that mounts beside the one being enabled, not from the startup row: a row enabled in the first pass would wait for services the second pass has yet to mount. ### One command line, one owner -A composition has exactly one command-line owner. An app that layers over another one disables the underlying entrypoint row and names both services, so the rows it absorbed start on the values their own fallbacks name — [`dsh-headless`](../../bundle/headless/README.md) does this over [`dsh-web-app`](../../bundle/web-app/README.md). +A composition has exactly one command-line owner. An app that layers over another one disables the underlying startup row and names both services, so the rows it absorbed start on the values their own fallbacks name — [`dsh-headless`](../../bundle/headless/README.md) does this over [`dsh-web-app`](../../bundle/web-app/README.md). An out-of-tree plugin brings its own commander copy, so commander's control-flow errors are detected structurally rather than by class identity; an identity check would rethrow a printed help as a fatal load failure. @@ -71,5 +75,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Launcher flags must precede app arguments.** The split is positional: the first token the launcher does not recognize starts the inner arguments, so `--patch` placed after an app flag belongs to the app. The launcher's parser consumes one `--`, so an app argument that must survive as a literal `--` needs `-- --`. -- **A startup service has no declared owner.** The rows name it and an entrypoint provides it; nothing links the two statically, so a bundle that ships reading rows without its entrypoint fails at settlement (pending entries naming the service) rather than at load. +- **A startup service has no declared owner.** Reading rows name it and a `cmdlineArgs` consumer provides it; nothing links those two injections statically, so a bundle that ships reading rows without its startup row fails at settlement (pending entries naming the service) rather than at load. - **A user patch that replaces a row's whole `config` drops its expressions.** A flag beats the value written beside it, not a literal a user wrote in place of the expression; keeping the expression is what keeps the flag winning. diff --git a/packages/boot/cmdline/README.zh.md b/packages/boot/cmdline/README.zh.md index 365a2c7f3c..76a76ad609 100644 --- a/packages/boot/cmdline/README.zh.md +++ b/packages/boot/cmdline/README.zh.md @@ -14,9 +14,9 @@ dsh 启动器交给它所引导应用的那条命令行。启动器只解析属 没有命令行的嵌入宿主提供空列表;这是诚实的答案,而不是缺失的值。 -## 入口点,以及它的应用所读取的服务 +## 启动行,以及它的应用所读取的服务 -应用从自己的**入口点行**读取这些参数:入口点行是一个注入 `cmdlineArgs` 并调用 `runStartup(ctx, service, program, plan)` 的插件: +应用从自己的**启动行**读取这些参数:这是一个在 Loader 行与插件中都注入 `cmdlineArgs`,并调用 `runStartup(ctx, service, program, plan)` 的插件: ```ts ignore export const name = 'web-startup' @@ -27,13 +27,17 @@ export function apply(ctx: Context): void { } ``` -组合包的 `package.json` 点名那一行,这正是 boot 先于其他一切挂载它的依据: +Loader 行的注入同时也是发现声明,因此无需组合包 manifest 字段: -```json -{ "dsh": { "bundle": { "patch": "./cordis.patch.yml", "entrypoint": "web-startup" } } } +```yaml +- id: web-startup + name: '@deepseek-ai/dsh-web-app/startup' + inject: [cmdlineArgs] ``` -应用用 flag 配置的每一行随后读取入口点解析出的取值,各自点名自己取用的键,以及回退时使用的值: +启动器在组合结果中找出带有该注入的活跃行,并先于其他一切挂载它们。 + +应用用 flag 配置的每一行随后读取启动行解析出的取值,各自点名自己取用的键,以及回退时使用的值: ```yaml - id: webserver @@ -50,13 +54,13 @@ export function apply(ctx: Context): void { ### 为什么 boot 分阶段 -行的配置表达式在 include 施加该行时求值,而严格的 `ctx.get` 只对提供方 fiber 已经 active 的服务作答。因此一套组合分两趟挂载:先是各入口点,然后才是其余部分——这正是 manifest(元数据清单)声明所换来的东西。后一趟的行读到的是活的取值,`--help` 在第二趟存在之前就退出,而用户编辑一个活动的 patch 文件时,这一趟会针对仍然在线的服务重新运行,因此 flag 不会被悄悄重置。 +行的配置表达式在 include 施加该行时求值,而严格的 `ctx.get` 只对提供方 fiber 已经 active 的服务作答。因此一套组合分两趟挂载:先是各个活跃的 `cmdlineArgs` 消费方,然后才是其余部分。后一趟的行读到的是活的取值,`--help` 在第二趟存在之前就退出,而用户编辑一个活动的 patch 文件时,这一趟会针对仍然在线的服务重新运行,因此 flag 不会被悄悄重置。 -`enableRow(ctx, id)` 打开某个组合包以禁用状态交付、只有部分调用才需要的行(`dsh web --dev` 及其客户端插件重载链路)。要从与被启用行同一趟挂载的行里调用它,而不是从入口点:在第一趟被启用的行会去等待第二趟才挂载的服务。 +`enableRow(ctx, id)` 打开某个组合包以禁用状态交付、只有部分调用才需要的行(`dsh web --dev` 及其客户端插件重载链路)。要从与被启用行同一趟挂载的行里调用它,而不是从启动行:在第一趟被启用的行会去等待第二趟才挂载的服务。 ### 一条命令行,一个所有者 -一套组合有且只有一个命令行所有者。叠加在另一应用之上的应用会禁用下层的入口点行,并同时点名两个服务,使它吸收过来的行按各自回退值启动:[`dsh-headless`](../../bundle/headless/README.md) 相对 [`dsh-web-app`](../../bundle/web-app/README.md) 就是这么做的。 +一套组合有且只有一个命令行所有者。叠加在另一应用之上的应用会禁用下层的启动行,并同时点名两个服务,使它吸收过来的行按各自回退值启动:[`dsh-headless`](../../bundle/headless/README.md) 相对 [`dsh-web-app`](../../bundle/web-app/README.md) 就是这么做的。 树外插件会带来自己的一份 commander 副本,因此 commander 的控制流错误按结构识别,而不是按类身份识别;按身份判断会把已经打印出来的 help 重新抛成致命的加载失败。 @@ -71,5 +75,5 @@ export function apply(ctx: Context): void { ## 已知限制与延期工作 - **启动器的 flag 必须写在应用参数之前**:切分按位置进行,启动器不认识的第一个 token 就是内层参数的起点,因此写在某个应用 flag 之后的 `--patch` 属于应用。启动器的解析器会消耗掉一个 `--`,因此必须以字面量 `--` 存活到应用的参数需要写成 `-- --`。 -- **启动服务没有声明所有者**:各行点名它,由入口点提供它;两者之间没有静态关联,因此交付了读取行却缺少对应入口点的组合包会在结算时失败(出现指向该服务的待处理条目),而不是在加载时失败。 +- **启动服务没有声明所有者**:读取行点名它,由 `cmdlineArgs` 消费方提供它;这两种注入之间没有静态关联,因此交付了读取行却缺少对应启动行的组合包会在结算时失败(出现指向该服务的待处理条目),而不是在加载时失败。 - **用户 patch 若整体替换某行的 `config`,会连同其中的表达式一起丢掉**:flag 胜过的是表达式旁写着的那个值,而不是用户用字面量替换掉表达式之后的结果;保留表达式才能保留 flag 的优先级。 diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json index 4e3953bd1b..7d1a93f71d 100644 --- a/packages/boot/cmdline/package.json +++ b/packages/boot/cmdline/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-cmdline", - "description": "Command-line seam between a dsh launcher and surface bundles: the cmdlineArgs service exposing the invocation's inner arguments, the startup host for contributing flag-derived config patches, and the commander adapter startup plugins share", + "description": "Command-line seam between a dsh launcher and app bundles: cmdlineArgs exposes inner arguments, while injected startup rows parse them into app-owned runtime services", "version": "0.0.1", "private": true, "type": "module", @@ -28,7 +28,6 @@ "commander": "^15.0.0" }, "peerDependencies": { - "@deepseek-ai/cordis-plugin-include": "^1.0.4", "@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/cordis": "^4.0.0-rc.7" diff --git a/packages/boot/cmdline/src/index.ts b/packages/boot/cmdline/src/index.ts index 43c3b9ec58..f927a21072 100644 --- a/packages/boot/cmdline/src/index.ts +++ b/packages/boot/cmdline/src/index.ts @@ -10,14 +10,12 @@ * An app consumes those arguments from a **startup plugin**: a row that * injects `cmdlineArgs` and calls {@link runStartup}. What that plugin resolves * becomes its own service, and the rows it configures read the values from - * there — `port: !!js ctx.get('webStartup')?.port ?? 3080` — so a flag beats + * there — `port: !!js ctx.webStartup.port ?? 3080` — so a flag beats * the value written beside it. Nothing is handed back to the launcher. * - * Those rows ship `disabled: true`, because a row's config is resolved when the - * Loader creates its fiber and a strict `ctx.get` only sees a service whose - * providing fiber is already active. The startup plugin enables them once its - * own fiber is active, and keeps them enabled when a recomposition of the tree - * puts them back. + * Loader delays each row's config interpolation until its declared injections + * are active. A startup row consumes `cmdlineArgs`, provides the app's resolved + * values, and thereby activates only the rows that depend on those values. * @module @deepseek-ai/dsh-cmdline */ @@ -70,10 +68,9 @@ export interface CmdlineHost { * Settles when the launcher has finished mounting, which a row that * publishes readiness (a URL line a supervisor waits for) must await. * - * A boot mounts in phases, so Loader settlement no longer means the whole - * composition is up: a row mounted in a later phase can observe a settled - * tree while rows beside it have yet to mount, or while the phase that - * mounted it is already rolling back. Rejects with the boot failure. + * 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 } @@ -92,6 +89,20 @@ export function provideCmdline(ctx: Context, host: CmdlineHost): void { if (host.ready !== undefined) ctx.provide('appReady', host.ready) } +/** + * Detect whether an active row consumes the launcher's command line. + * + * The Loader-row injection is the declaration: an active row that names + * `cmdlineArgs` owns startup for this composition. No bundle manifest field or + * plugin import is needed, so an out-of-tree app adds its command line by + * adding the same injection its startup plugin already requires. + * @param rows - the composed Loader rows. + * @returns whether this composition has a command-line owner. + */ +export function hasCmdlineConsumer(rows: readonly EntryOptions[]): boolean { + return rows.some(row => row.disabled !== true && waitsForAny(row.inject, ['cmdlineArgs'])) +} + /** The process streams commander output is written to; production writes to the process. */ export const internals: { stdout: { write(chunk: string): unknown }; stderr: { write(chunk: string): unknown } } = { stdout: process.stdout, @@ -107,26 +118,26 @@ export const internals: { stdout: { write(chunk: string): unknown }; stderr: { w * to reject the invocation with a usage message instead of throwing. * @param program - the parsed commander program. * @param rows - the waiting rows' composed options, in tree order. + * @param ctx - the startup row's context, for resolving composed fallbacks before the service exists. * @returns the service value the app's rows read; `undefined` keys let a row's * own fallback stand. */ -export type StartupPlan = (program: Command, rows: readonly EntryOptions[]) => T +export type StartupPlan = (program: Command, rows: readonly EntryOptions[], ctx: Context) => T /** * Run one app's startup: parse the invocation's inner arguments with the app's - * own commander program, provide the resolved values as `service`, and start - * the rows that were waiting for it. + * own commander program and provide the resolved values as `service`. The + * Loader then activates the rows that were waiting for the provided service. * * The rows read their values from the service, so nothing is written into - * their config from here: a row asks for `ctx.get('')?.` and - * falls back to the value written beside it, which is why a flag wins. They are - * enabled from inside an injection on the service itself, because a strict - * `ctx.get` only resolves a service whose providing fiber is already active, - * and re-enabled whenever a recomposition of the tree disables them again — a - * user editing a live patch file must not take the app down. + * their config from here: a row asks for `ctx..` and + * falls back to the value written beside it, which is why a flag wins. Loader + * resolves a row's config only after its injections are active. A live + * recomposition reads the service that remains active, so editing a user patch + * cannot reset an invocation value. * * Help, version, and rejected arguments are terminal for the process: the text - * is written, the service is never provided, the app's rows stay disabled, and + * is written, the service is never provided, dependent rows stay pending, and * `ctx.appExit` is requested. * * An app that layers over another one (the one-shot bundle rides over the web @@ -171,7 +182,7 @@ export function runStartup( // and nothing to start, and the check below would blame the bundle for a // tree that simply went away. if (ctx.get('loader') === undefined) return undefined - values = plan(program, waitingRows(ctx, names)) + values = plan(program, waitingRows(ctx, names), ctx) } catch (error) { // exitOverride turns help, version, a parse error, and a plan's own // program.error() into a CommanderError; commander has already written the @@ -191,17 +202,16 @@ export function runStartup( * * A row cannot be inserted from inside a mounting plugin — the Loader returns a * prefixed id it then fails to resolve — so a conditional row ships disabled - * and an entrypoint enables it. - * Call it from a row that mounts alongside the one being enabled: an - * entrypoint runs before the rest of the composition, so a row it enabled - * there would wait for services that have yet to mount. + * and a row mounted beside it enables it after startup resolves the invocation. * @param ctx - plugin context whose Loader tree carries the row. * @param id - the row id. - * @returns nothing once the row has started. - * @throws when the composition has no row with that id. + * @returns nothing once the row has started or is waiting for its dependencies. + * @throws when the Loader or named row is absent. */ export async function enableRow(ctx: Context, id: string): Promise { - const entry = [...ctx.loader.entries()].find(candidate => candidate.options.id === id) + const loader = ctx.get('loader') + if (loader === undefined) throw new Error('dsh-cmdline: enabling a row requires the Loader service') + const entry = [...loader.entries()].find(candidate => candidate.options.id === id) if (entry === undefined) throw new Error(`dsh-cmdline: the composition has no ${JSON.stringify(id)} row to enable`) await entry.update({ disabled: false }) } diff --git a/packages/boot/cmdline/tests/cmdline.spec.ts b/packages/boot/cmdline/tests/cmdline.spec.ts index ee405bb135..d724fe9531 100644 --- a/packages/boot/cmdline/tests/cmdline.spec.ts +++ b/packages/boot/cmdline/tests/cmdline.spec.ts @@ -1,8 +1,7 @@ /** * The launcher-to-app command line over a REAL Loader tree, mounted the way a - * profile boot mounts it: the entrypoint row first, then the rest of the - * composition, whose rows read the entrypoint's values from their own config - * expressions. `--help` never reaches that second phase. + * profile boot mounts it: Loader holds each row until its injections are + * active, then resolves that row's config against its injection-ready context. */ import { mkdtempSync, writeFileSync } from 'node:fs' @@ -15,7 +14,9 @@ import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' import type { PatchOptions } from '@cordisjs/plugin-include' import { afterEach, describe, expect, it } from 'vitest' -import { internals, provideCmdline, runStartup, type StartupPlan } from '../src/index.ts' +import { + enableRow, hasCmdlineConsumer, internals, provideCmdline, runStartup, type StartupPlan, +} from '../src/index.ts' /** Every value one boot of the fixture tree observed. */ interface Observed { @@ -56,8 +57,8 @@ const demoPlan: StartupPlan<{ port?: number }> = (program) => { const expression = (source: string): unknown => ({ __jsExpr: source }) /** - * Mount a two-row composition the way a profile boot does: the entrypoint row - * alone first, then everything. + * Mount a two-row composition the way a profile boot does: both rows at once, + * with Loader ordering config resolution from their injections. * @param args - the invocation's inner arguments. * @param plan - the app's plan; defaults to the fixture's own. * @returns the booted fixture. @@ -65,7 +66,7 @@ const expression = (source: string): unknown => ({ __jsExpr: source }) async function bootFixture( args: string[], plan: StartupPlan = demoPlan, - options: { withoutEntrypoint?: boolean } = {}, + options: { objectInject?: boolean; withoutStartup?: boolean } = {}, ): Promise { const dir = mkdtempSync(join(tmpdir(), 'dsh-cmdline-')) const observed: Observed = { exits: [], out: '' } @@ -77,7 +78,7 @@ export function apply(ctx, config) { globalThis.__observed.started = config } // The Loader imports a row through Node's own resolver, which cannot resolve // this workspace's sources; the row delegates to the real function the test // imported through the source-plane path mapping. - writeFileSync(join(dir, 'entrypoint.mjs'), ` + writeFileSync(join(dir, 'startup.mjs'), ` export const name = 'demo-startup' export const inject = ['cmdlineArgs'] export function apply(ctx) { return globalThis.__runStartup(ctx) } @@ -94,14 +95,14 @@ export function apply(ctx) { return globalThis.__runStartup(ctx) } // config carries `!!js` expressions. const composition: PatchOptions[] = [{ insert: [ - ...options.withoutEntrypoint === true + ...options.withoutStartup === true ? [] - : [{ id: 'demo-startup', name: pathToFileURL(join(dir, 'entrypoint.mjs')).href }], + : [{ id: 'demo-startup', name: pathToFileURL(join(dir, 'startup.mjs')).href, inject: ['cmdlineArgs'] }], { id: 'reader', name: pathToFileURL(join(dir, 'reader.mjs')).href, - inject: ['demoStartup'], - config: { port: expression("ctx.get('demoStartup')?.port ?? 3080") }, + inject: options.objectInject === true ? { demoStartup: { required: true } } : ['demoStartup'], + config: { port: expression('ctx.demoStartup?.port ?? 3080') }, }, ], }] @@ -109,22 +110,29 @@ export function apply(ctx) { return globalThis.__runStartup(ctx) } await ctx.plugin(Loader) ctx.loader.builtins.include = Include provideCmdline(ctx, { args, exit: code => void observed.exits.push(code) }) - const rootConfig = { path: pathToFileURL(join(dir, 'cordis.yml')).href } - // Phase one: the entrypoint alone. - const includeId = await ctx.loader.create({ + await ctx.loader.create({ name: 'cordis:include', - config: { ...rootConfig, patches: [...structuredClone(composition), { id: 'reader', disabled: true }] }, + config: { path: pathToFileURL(join(dir, 'cordis.yml')).href, patches: structuredClone(composition) }, }) await ctx.loader.await() disposers.push(async () => { await ctx.fiber.dispose() }) - if (observed.exits.length === 0) { - // Phase two: the whole composition, now that the entrypoint's values answer. - await ctx.loader.resolve(includeId).update({ config: { ...rootConfig, patches: structuredClone(composition) } }) - await ctx.loader.await() - } return { observed, ctx } } +describe('hasCmdlineConsumer', () => { + it('recognizes active array and object injections', () => { + expect(hasCmdlineConsumer([ + { id: 'ordinary', name: 'ordinary' }, + { id: 'disabled-startup', name: 'disabled-startup', inject: ['cmdlineArgs'], disabled: true }, + { id: 'tui-startup', name: 'tui-startup', inject: { cmdlineArgs: { required: true } } }, + ])).toBe(true) + expect(hasCmdlineConsumer([ + { id: 'ordinary', name: 'ordinary' }, + { id: 'disabled-startup', name: 'disabled-startup', inject: ['cmdlineArgs'], disabled: true }, + ])).toBe(false) + }) +}) + describe('runStartup', () => { it('lets a row read the flag value the app resolved', async () => { const { observed } = await bootFixture(['--port', '8080']) @@ -137,6 +145,11 @@ describe('runStartup', () => { expect(observed.started).toEqual({ port: 3080 }) }) + it('recognizes the Loader object form of a startup-service injection', async () => { + const { observed } = await bootFixture(['--port', '8080'], demoPlan, { objectInject: true }) + expect(observed.started).toEqual({ port: 8080 }) + }) + it('prints the app help, starts no reading row, and requests exit 0', async () => { const { observed } = await bootFixture(['--help']) expect(observed.out).toContain('Usage: demo') @@ -152,13 +165,13 @@ describe('runStartup', () => { }) it('rethrows a plan failure that is not commander asking to exit', async () => { - const { ctx } = await bootFixture([], demoPlan, { withoutEntrypoint: true }) + const { ctx } = await bootFixture([], demoPlan, { withoutStartup: true }) const plan: StartupPlan = () => { throw new Error('plan exploded') } expect(() => { runStartup(ctx, 'demoStartup', demoCommand(), plan) }).toThrow('plan exploded') }) it('rethrows a thrown value that is not an object at all', async () => { - const { ctx } = await bootFixture([], demoPlan, { withoutEntrypoint: true }) + const { ctx } = await bootFixture([], demoPlan, { withoutStartup: true }) const plan: StartupPlan = () => { const thrown: unknown = 'plan threw a string' throw thrown @@ -167,36 +180,57 @@ describe('runStartup', () => { }) it('fails loud when no row injects the service the app provides', async () => { - // The bundle patch and its entrypoint disagree; a silent no-op would leave + // The bundle patch and its startup row disagree; a silent no-op would leave // every row of the app on its fallbacks with no explanation. - const { ctx } = await bootFixture([], demoPlan, { withoutEntrypoint: true }) + const { ctx } = await bootFixture([], demoPlan, { withoutStartup: true }) expect(() => { runStartup(ctx, 'absentStartup', demoCommand()) }) .toThrow('absentStartup: no row injects this startup service') }) it('provides an empty value when the app declares no plan', async () => { - const { ctx } = await bootFixture([], demoPlan, { withoutEntrypoint: true }) + const { ctx } = await bootFixture([], demoPlan, { withoutStartup: true }) runStartup(ctx, 'demoStartup', demoCommand()) expect(ctx.get('demoStartup')).toEqual({}) }) }) +describe('enableRow', () => { + it('enables the named Loader row and fails loud when the Loader or row is absent', async () => { + const withoutLoader = new Context() + await expect(enableRow(withoutLoader, 'client-hmr')).rejects.toThrow('requires the Loader service') + + const ctx = new Context() + let update: unknown + ctx.provide('loader', { + entries: () => [{ + options: { id: 'client-hmr' }, + update: async (options: unknown) => { update = options }, + }], + } as never) + await enableRow(ctx, 'client-hmr') + expect(update).toEqual({ disabled: false }) + await expect(enableRow(ctx, 'absent')).rejects.toThrow('no "absent" row to enable') + }) +}) + describe('provideCmdline', () => { it('hands the app a snapshot the caller cannot mutate afterwards', () => { const ctx = new Context() const args = ['--resume', 'abc'] - provideCmdline(ctx, { args, exit: () => {} }) + const ready = Promise.resolve() + provideCmdline(ctx, { args, exit: () => {}, ready }) args.push('--tampered') expect(ctx.cmdlineArgs?.get()).toEqual(['--resume', 'abc']) + expect(ctx.appReady).toBe(ready) }) - it('fails loud when an entrypoint runs without the launcher values', () => { + it('fails loud when a startup row runs without the launcher values', () => { const ctx = new Context() expect(() => { runStartup(ctx, 'demoStartup', demoCommand()) }) .toThrow('the launcher must provide ctx.cmdlineArgs and ctx.appExit') }) - it('resolves nothing when the tree was disposed while the entrypoint parsed', () => { + it('resolves nothing when the tree was disposed while the startup row parsed', () => { // An early SIGTERM takes the Loader with it; there is nothing left to // configure, and the bundle did nothing wrong. const exits: number[] = [] diff --git a/packages/bundle/headless/cordis.patch.yml b/packages/bundle/headless/cordis.patch.yml index eb8a2289cd..abe5a95e0d 100644 --- a/packages/bundle/headless/cordis.patch.yml +++ b/packages/bundle/headless/cordis.patch.yml @@ -1,8 +1,8 @@ # The dsh-headless bundle patch: one-shot task mode directly over dsh-base. # It mounts no Host, HTTP server, Web runtime, or browser plugin. The startup -# row owns the task positional (`dsh --profile headless ""`) and this -# app's --help; the direct driver creates an Agent through the core registry -# and prints the final durable assistant message. +# row injects `cmdlineArgs`, owns the task positional +# (`dsh --profile headless ""`) and this app's --help; the direct driver +# creates an Agent through the core registry and prints its durable result. - id: system-prompt config: @@ -25,6 +25,7 @@ - id: headless-startup name: '@deepseek-ai/dsh-headless/startup' + inject: [cmdlineArgs] # Reads its task from the headlessStartup service after the startup row # resolves this app's command line. diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index 5d2af07463..e439fe75f7 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -33,8 +33,7 @@ "license": "BSD-3-Clause", "dsh": { "bundle": { - "patch": "./cordis.patch.yml", - "entrypoint": "headless-startup" + "patch": "./cordis.patch.yml" } }, "dependencies": { @@ -50,7 +49,6 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-web-app": "^0.0.1", "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { @@ -60,7 +58,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-web-app": "workspace:^", "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/bundle/headless/src/startup.ts b/packages/bundle/headless/src/startup.ts index 0f613aae08..74f9dfb6b1 100644 --- a/packages/bundle/headless/src/startup.ts +++ b/packages/bundle/headless/src/startup.ts @@ -4,11 +4,6 @@ * `--help` text, then provides {@link HEADLESS_STARTUP_SERVICE} with the task * the user asked for. The runner waits for it, so a missing task is a usage * error printed by this command instead of a schema failure inside the runner. - * - * This app layers over the web app, and a composition has exactly one - * command-line owner: the bundle patch disables the web startup row, and this - * one also provides {@link WEB_STARTUP_SERVICE} so the web rows start on their - * composed (one-shot) values. * @module @deepseek-ai/dsh-headless/startup */ @@ -16,7 +11,6 @@ import { Command } from 'commander' import type { Context } from 'cordis' import type { EntryOptions } from '@cordisjs/plugin-loader' import { runStartup } from '@deepseek-ai/dsh-cmdline' -import { WEB_STARTUP_SERVICE } from '@deepseek-ai/dsh-web-app/startup' /** Stable Cordis plugin name. */ export const name = 'headless-startup' @@ -75,5 +69,5 @@ function planHeadlessStartup(program: Command, rows: readonly EntryOptions[]): H * @returns nothing once the runner is started, or once `--help` or a missing task requested exit. */ export function apply(ctx: Context): void { - runStartup(ctx, [HEADLESS_STARTUP_SERVICE, WEB_STARTUP_SERVICE], headlessCommand(), planHeadlessStartup) + runStartup(ctx, HEADLESS_STARTUP_SERVICE, headlessCommand(), planHeadlessStartup) } diff --git a/packages/bundle/headless/tests/startup.spec.ts b/packages/bundle/headless/tests/startup.spec.ts index fc908306e3..651ef0aebb 100644 --- a/packages/bundle/headless/tests/startup.spec.ts +++ b/packages/bundle/headless/tests/startup.spec.ts @@ -1,5 +1,5 @@ /** - * The one-shot app's entrypoint row over a REAL Loader tree: the task + * The one-shot app's startup row over a REAL Loader tree: the task * positional becomes the value the runner row reads, a missing task is a usage * error, and the web service this app absorbs is provided too, so the web rows * it rides over resolve on their own fallbacks. @@ -32,7 +32,7 @@ afterEach(async () => { }) /** - * Mount the real entrypoint row over stand-ins for the runner row and one web + * Mount the real startup row over stand-ins for the runner row and one web * row this app absorbs, the way a profile mounts phase one. * @param args - the invocation's inner arguments. * @param options - fixture knobs for the shapes a composition can take. @@ -48,7 +48,7 @@ async function bootStartup( // The Loader imports a row through Node's own resolver, which cannot resolve // this workspace's sources; the row delegates to the real plugin the test // imported through the source-plane path mapping. - writeFileSync(join(dir, 'entrypoint.mjs'), ` + writeFileSync(join(dir, 'startup.mjs'), ` export const name = 'headless-startup' export const inject = ['cmdlineArgs'] export const apply = ctx => globalThis.__headlessStartupApply(ctx) @@ -56,7 +56,7 @@ export const apply = ctx => globalThis.__headlessStartupApply(ctx) const rowUrl = pathToFileURL(join(dir, 'row.mjs')).href writeFileSync(join(dir, 'cordis.yml'), [ // A composition that lost the runner still injects the service, so the - // entrypoint reaches its own row check rather than the generic one. + // startup row reaches its own row check rather than the generic one. options.withoutRunner === true ? '- id: displaced-runner' : '- id: headless-runner', ` name: ${rowUrl}`, ` inject: [${HEADLESS_STARTUP_SERVICE}]`, @@ -66,7 +66,8 @@ export const apply = ctx => globalThis.__headlessStartupApply(ctx) ` inject: [${WEB_STARTUP_SERVICE}]`, ' disabled: true', '- id: headless-startup', - ` name: ${pathToFileURL(join(dir, 'entrypoint.mjs')).href}`, + ` name: ${pathToFileURL(join(dir, 'startup.mjs')).href}`, + ' inject: [cmdlineArgs]', '', ].join('\n')) const observing = { write: (chunk: string) => { observed.out += chunk; return true } } diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index bc410b698b..7a86cb1df0 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -7,11 +7,11 @@ # # Rows this app configures from flags read them from the `webStartup` service: # each names the key it takes and the value it falls back to, so a flag wins -# over the value written beside it. The web-startup row is this bundle's -# manifest-declared entrypoint, so it runs before any of them and has already -# parsed --host/--port/--dev/--workspace-root/--trusted-host by the time their -# config is resolved. `dsh --profile web --help` therefore prints this app's own -# help and exits before the rest of the composition mounts at all. +# over the value written beside it. The web-startup row injects `cmdlineArgs`, +# so the launcher runs it first; it has parsed --host/--port/--dev/ +# --workspace-root/--trusted-host by the time those configs resolve. +# `dsh --profile web --help` therefore prints this app's own help and exits +# before the rest of the composition mounts at all. # ── surface-specific values the base deliberately omits ───────────────────── @@ -85,11 +85,12 @@ config: workspaceRoot: !!js ctx.get('webStartup')?.workspaceRoot - # This bundle's entrypoint (declared in its package.json): it owns the web - # flag family and its --help, and provides webStartup with the values this - # invocation resolved. The boot runs it before every row above. + # This app's command-line startup row: its `cmdlineArgs` injection makes the + # launcher mount it first. It owns the web flag family and its --help, and + # provides webStartup with the values this invocation resolved. - id: web-startup name: '@deepseek-ai/dsh-web-app/startup' + inject: [cmdlineArgs] # ── layer 2: transport/service ────────────────────────────────────────────── diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 0e2eff0e3b..e8240e1b63 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -33,8 +33,7 @@ "license": "BSD-3-Clause", "dsh": { "bundle": { - "patch": "./cordis.patch.yml", - "entrypoint": "web-startup" + "patch": "./cordis.patch.yml" } }, "dependencies": { diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts index abf2ac4ca3..27b9a4e27a 100644 --- a/packages/bundle/web-app/src/index.ts +++ b/packages/bundle/web-app/src/index.ts @@ -4,15 +4,17 @@ * manifest field). The plugin owns the browser-surface glue: it resolves * the built frontend dist (workspace knowledge of this bundle, never user * config), mounts the `frontend-static` fallback owner over it, registers the - * web-surface prompt section and the bash-visible web runtime variables, and - * prints the URL line when configured to. Flag-derived values (`mode`, - * `lanAddresses`, `printUrl`) arrive as launcher patches over this row. + * harness-source and web-surface prompt sections, the bash-visible web runtime + * variables, and the URL line. App command-line values arrive through the + * `webStartup` service expressions in the bundle patch. * @module @deepseek-ai/dsh-web-app */ import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' +import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot' import { enableRow } from '@deepseek-ai/dsh-cmdline' import * as FrontendStatic from '@deepseek-ai/dsh-frontend-static' import type {} from '@deepseek-ai/cordis-plugin-loader' @@ -26,13 +28,16 @@ export const name = 'web-app' /** The client-plugin reload chain row this bundle ships disabled, for `--dev`. */ const HMR_ROW_ID = 'client-hmr' +/** This dsh installation's root, from either this package's source or built entry. */ +const SOURCE_ROOT = fileURLToPath(new URL('../../../..', import.meta.url)) + /** Services required before the web runtime can mount. */ export const inject = ['httpServer'] /** Web runtime mode: production, or development when the client-plugin HMR receiver is active. */ export type WebMode = 'production' | 'development' -/** Plugin config: the surface facts the launcher patches over this bundle's defaults. */ +/** Plugin config: composed deployment settings plus per-invocation startup values. */ export interface Config { /** Whether this process mounted the client-plugin HMR receiver (`dsh web --dev`). */ mode: WebMode @@ -46,7 +51,7 @@ export interface Config { */ surfaceContext: boolean /** - * LAN IPv4 addresses sampled once by the launcher when the effective bind + * LAN IPv4 addresses sampled once by the app startup row when the effective bind * is all-interfaces — the exact snapshot the /api trust fence was * configured with, so the printed LAN URL can never name an address the * fence rejects. Empty on a loopback bind. @@ -113,16 +118,17 @@ export const internals: { resolveDistIndex: () => string } = { resolveDistIndex * variables, and the URL line. * @param ctx - plugin context carrying the httpServer service. * @param config - validated {@link Config}. + * @returns nothing once optional development rows are active and runtime contributions are registered. */ -export function apply(ctx: Context, config: Config): void { +export async function apply(ctx: Context, config: Config): Promise { ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() }) // The client-plugin reload chain is a row this bundle ships off, because it // exists only in development. Turning it on belongs here rather than in the - // entrypoint: it needs the host rows this phase of the boot mounts, and the - // entrypoint runs before them. - if (config.mode === 'development') void enableRow(ctx, HMR_ROW_ID) + // startup row: it needs host services that also activate after webStartup. + if (config.mode === 'development') await enableRow(ctx, HMR_ROW_ID) if (config.surfaceContext) { ctx.inject(['systemPrompt'], (promptCtx) => { + addHarnessSourceSection(promptCtx, SOURCE_ROOT) promptCtx.systemPrompt.section({ name: 'app:web-surface', order: -98, @@ -146,16 +152,15 @@ export function apply(ctx: Context, config: Config): void { // sibling rows (the /api route owner) are still mounting. Await Loader // settlement first; a hand-built tree without a Loader prints at once. const printUrl = (): void => { - // The launcher's boot-time LAN snapshot, not a fresh sample: the printed + // The startup row's boot-time LAN snapshot, not a fresh sample: the printed // LAN URL must name an address the /api trust fence was configured with. const lanCandidate = config.lanAddresses[0] const port = ctx.httpServer.port console.log(`dsh web: ${localWebUrl(ctx)}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${String(port)})`}`) } - // A launcher that mounts in phases tells this row when the whole - // composition is up; Loader settlement alone would let the line print - // between phases, announcing a server whose boot can still fail. A - // hand-built tree has neither and prints at once. + // 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() if (settled === undefined) printUrl() else { diff --git a/packages/bundle/web-app/tests/startup.spec.ts b/packages/bundle/web-app/tests/startup.spec.ts index 7ac0f5192c..83def845cd 100644 --- a/packages/bundle/web-app/tests/startup.spec.ts +++ b/packages/bundle/web-app/tests/startup.spec.ts @@ -1,5 +1,5 @@ /** - * The web app's entrypoint row over a REAL Loader tree: every flag lands in the + * The web app's startup row over a REAL Loader tree: every flag lands in the * `webStartup` service the web rows read, the bind it reports comes from the * flag or from what the composition falls back to, `--help` resolves nothing, * and a rejected argument exits without resolving anything. @@ -39,7 +39,7 @@ afterEach(async () => { }) /** - * Mount the real entrypoint row over a stand-in for the `webserver` row whose + * Mount the real startup row over a stand-in for the `webserver` row whose * composed bind it reads, the way a profile mounts phase one. * @param args - the invocation's inner arguments. * @param webserverConfig - the composed `webserver` row config, or `null` to omit the row. @@ -55,7 +55,7 @@ async function bootStartup( // The Loader imports a row through Node's own resolver, which cannot resolve // this workspace's sources; the row delegates to the real plugin the test // imported through the source-plane path mapping. - writeFileSync(join(dir, 'entrypoint.mjs'), ` + writeFileSync(join(dir, 'startup.mjs'), ` export const name = 'web-startup' export const inject = ['cmdlineArgs'] export const apply = ctx => globalThis.__webStartupApply(ctx) @@ -82,7 +82,8 @@ export const apply = ctx => globalThis.__webStartupApply(ctx) ` inject: [${WEB_STARTUP_SERVICE}]`, ' disabled: true', '- id: web-startup', - ` name: ${pathToFileURL(join(dir, 'entrypoint.mjs')).href}`, + ` name: ${pathToFileURL(join(dir, 'startup.mjs')).href}`, + ' inject: [cmdlineArgs]', '', ].join('\n')) const observing = { write: (chunk: string) => { observed.out += chunk; return true } } @@ -155,7 +156,7 @@ describe('web startup', () => { }) it('fails the boot when the composition lost the row whose bind it reads', async () => { - // The bundle patch and this entrypoint must agree on the row set; a + // The bundle patch and this startup row must agree on the row set; a // missing row would otherwise silently drop the flag that targets it. await expect(bootStartup([], null)) .rejects.toThrow('the web composition has no waiting "webserver" row to configure') diff --git a/packages/bundle/web-app/tests/web-app.spec.ts b/packages/bundle/web-app/tests/web-app.spec.ts index ab56e87db4..1962710b5e 100644 --- a/packages/bundle/web-app/tests/web-app.spec.ts +++ b/packages/bundle/web-app/tests/web-app.spec.ts @@ -2,7 +2,7 @@ * Web runtime glue behavior: dist resolution through the bundle's own hook, * the frontend-static child claiming the fallback seat, the web-surface * prompt section and bash runtime variables, and URL-line printing with the - * launcher's LAN snapshot. + * app startup row's LAN snapshot. */ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' @@ -68,15 +68,25 @@ describe('web-app runtime glue', () => { return () => {} }, } as never) + const hmrUpdates: unknown[] = [] + ctx.provide('loader', { + entries: () => [{ + options: { id: 'client-hmr' }, + update: async (options: unknown) => { hmrUpdates.push(options) }, + }], + await: async () => {}, + } as never) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(ctx, new Config({ mode: 'development', printUrl: true, surfaceContext: true, lanAddresses: ['192.168.1.5'] })) + await apply(ctx, new Config({ mode: 'development', printUrl: true, surfaceContext: true, lanAddresses: ['192.168.1.5'] })) await ctx.plugin(SystemPrompt, { persona: '' }) // Settle the injected registrations. await new Promise(resolve => setTimeout(resolve, 0)) expect(seat()).toBeDefined() // frontend-static claimed the fallback + expect(hmrUpdates).toEqual([{ disabled: false }]) expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567 (LAN: http://192.168.1.5:4567)') const assembly = await ctx.systemPrompt.assemble() + expect(assembly.sections.find(entry => entry.name === 'harness:source')?.text).toContain('DeepSeek Harness implementation checkout') const section = assembly.sections.find(entry => entry.name === 'app:web-surface') expect(section?.text).toContain('http://127.0.0.1:4567') expect(section?.text).toContain('--dev') @@ -90,7 +100,7 @@ describe('web-app runtime glue', () => { const ctx = new Context() ctx.provide('httpServer', fakeHttpServer().server) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, lanAddresses: [] })) + await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, lanAddresses: [] })) await ctx.plugin(SystemPrompt, { persona: '' }) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() @@ -111,11 +121,12 @@ describe('web-app runtime glue', () => { return () => {} }, } as never) - apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: false, lanAddresses: [] })) + await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: false, lanAddresses: [] })) await ctx.plugin(SystemPrompt, { persona: '' }) await new Promise(resolve => setTimeout(resolve, 0)) const assembly = await ctx.systemPrompt.assemble() expect(assembly.sections.some(entry => entry.name === 'app:web-surface')).toBe(false) + expect(assembly.sections.some(entry => entry.name === 'harness:source')).toBe(false) expect(contributions).toEqual([]) await ctx.fiber.dispose() }) @@ -125,23 +136,23 @@ describe('web-app runtime glue', () => { const ctx = new Context() ctx.provide('httpServer', fakeHttpServer().server) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(ctx, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) + await apply(ctx, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567') await ctx.fiber.dispose() }) - it('waits for the launcher readiness the phased boot provides, and stays quiet when that boot failed', async () => { + it('waits for launcher readiness and stays quiet when the whole boot failed', async () => { stageDist() - // The launcher-provided readiness wins over Loader settlement: a phased - // boot settles the Loader between phases, long before the app is up. + // Launcher readiness covers siblings that may still be mounting after + // this row itself has activated. const ready = new Context() ready.provide('httpServer', fakeHttpServer().server) ready.provide('loader', { await: () => Promise.resolve() } as never) let announce: () => void ready.provide('appReady', new Promise((resolve) => { announce = resolve })) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(ready, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) + await apply(ready, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() announce!() @@ -157,7 +168,7 @@ describe('web-app runtime glue', () => { const rejection = Promise.reject(new Error('boot failed')) rejection.catch(() => {}) failed.provide('appReady', rejection) - apply(failed, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) + 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() @@ -173,7 +184,7 @@ describe('web-app runtime glue', () => { const settlement = new Promise((resolve) => { release = resolve }) settled.provide('loader', { await: () => settlement } as never) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(settled, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) + await apply(settled, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() release!() @@ -192,7 +203,7 @@ describe('web-app runtime glue', () => { let releaseTorn: () => void const tornSettlement = new Promise((resolve) => { releaseTorn = resolve }) torn.provide('loader', { await: () => tornSettlement } as never) - apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) + await apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) await child.dispose() // the httpServer service goes away releaseTorn!() await new Promise(resolve => setTimeout(resolve, 0)) @@ -208,7 +219,7 @@ describe('web-app runtime glue', () => { const { server } = fakeHttpServer() Object.defineProperty(server, 'port', { get: () => undefined }) ctx.provide('httpServer', server) - apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, lanAddresses: [] })) + await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, lanAddresses: [] })) await ctx.plugin(SystemPrompt, { persona: '' }) await new Promise(resolve => setTimeout(resolve, 0)) await expect(ctx.systemPrompt.assemble()).rejects.toThrow('httpServer service missing') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ae88ddf96..1dc47e50d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1483,9 +1483,6 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - '@deepseek-ai/dsh-web-app': - specifier: workspace:^ - version: link:../web-app packages/bundle/web-app: dependencies: