From 788368e31410ed695fd5a10696a823dd9424b0e4 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 6 Aug 2026 20:52:26 +0800 Subject: [PATCH 01/19] feat(cmdline): hand the launcher's remaining arguments to the app it boots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A launcher provides three values before the tree mounts: ctx.cmdlineArgs (get() is the whole interface) carrying everything after its own flags, ctx.appExit for a bounded exit, and ctx.appPatches for decisions a later recomposition must keep. An app's startup row injects cmdlineArgs and calls runStartup() with its own commander program. Rows the app configures inject its startup service, so they wait until the startup row has resolved their values and provided it; --help prints, disables those rows, and exits without the app ever starting. A changed row is recycled — disabled, then re-enabled with its new values — because a row's config is resolved when the Loader creates its fiber, while the row is still waiting. Recycling never touches inject: an inject update restarts the row from its unwrapped callback and loses the plugin's own static injections. A mount still in flight is allowed to settle first, so the disable has a fiber to dispose instead of racing one into existence. --- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 + docs/module-graph.zh.md | 3 + packages/boot/README.md | 3 +- packages/boot/README.zh.md | 3 +- packages/boot/cmdline/README.i18n.yaml | 6 + packages/boot/cmdline/README.md | 72 ++++ packages/boot/cmdline/README.zh.md | 72 ++++ packages/boot/cmdline/package.json | 42 +++ packages/boot/cmdline/src/index.ts | 311 ++++++++++++++++++ packages/boot/cmdline/src/invariant.ts | 34 ++ packages/boot/cmdline/tests/cmdline.spec.ts | 268 +++++++++++++++ packages/boot/cmdline/tsconfig.json | 24 ++ pnpm-lock.yaml | 19 ++ scripts/check-workspace-constraints.ts | 3 + .../verify-package-readme-model-experience.ts | 1 + tsconfig.host.json | 1 + 17 files changed, 865 insertions(+), 4 deletions(-) create mode 100644 packages/boot/cmdline/README.i18n.yaml create mode 100644 packages/boot/cmdline/README.md create mode 100644 packages/boot/cmdline/README.zh.md create mode 100644 packages/boot/cmdline/package.json create mode 100644 packages/boot/cmdline/src/index.ts create mode 100644 packages/boot/cmdline/src/invariant.ts create mode 100644 packages/boot/cmdline/tests/cmdline.spec.ts create mode 100644 packages/boot/cmdline/tsconfig.json diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 1f3d01750c..954f038557 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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/module-graph.md -module-graph.md: 2b1f8dd9d41ab5ad34a4787ffa54c6b7d13144af -module-graph.zh.md: 192943312b672c1ae10d32182e9ccd7456eac90b +module-graph.md: 3efb73d075f3d5d7a8bae990fc2f524dc710bcb7 +module-graph.zh.md: df3b9b38497893471b2613c0c95da409dda0262b diff --git a/docs/module-graph.md b/docs/module-graph.md index 2b1f8dd9d4..3efb73d075 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -133,6 +133,7 @@ flowchart TD end subgraph group_boot["packages/boot"] pkg_app_boot["app-boot"] + pkg_cmdline["cmdline"] end subgraph group_bundle["packages/bundle"] pkg_base["base"] @@ -313,6 +314,7 @@ flowchart TD pkg_timeout --> pkg_invariants pkg_scope --> pkg_invariants pkg_llm_mock_server --> pkg_invariants + pkg_cmdline --> pkg_invariants pkg_base --> pkg_invariants pkg_client_modules --> pkg_invariants pkg_client_schema_form --> pkg_invariants @@ -1252,6 +1254,7 @@ flowchart TD | [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/support/invariants) | | [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/support/invariants) | | [`llm-mock-server`](../packages/support/llm-mock-server) | `support` | [`invariants`](../packages/support/invariants) | +| [`cmdline`](../packages/boot/cmdline) | `boot` | [`invariants`](../packages/support/invariants) | | [`base`](../packages/bundle/base) | `bundle` | [`invariants`](../packages/support/invariants) | | [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) | | [`client-schema-form`](../packages/client/schema-form) | `client` | [`invariants`](../packages/support/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 192943312b..df3b9b3849 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -135,6 +135,7 @@ flowchart TD end subgraph group_boot["packages/boot"] pkg_app_boot["app-boot"] + pkg_cmdline["cmdline"] end subgraph group_bundle["packages/bundle"] pkg_base["base"] @@ -315,6 +316,7 @@ flowchart TD pkg_timeout --> pkg_invariants pkg_scope --> pkg_invariants pkg_llm_mock_server --> pkg_invariants + pkg_cmdline --> pkg_invariants pkg_base --> pkg_invariants pkg_client_modules --> pkg_invariants pkg_client_schema_form --> pkg_invariants @@ -1254,6 +1256,7 @@ flowchart TD | [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/support/invariants) | | [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/support/invariants) | | [`llm-mock-server`](../packages/support/llm-mock-server) | `support` | [`invariants`](../packages/support/invariants) | +| [`cmdline`](../packages/boot/cmdline) | `boot` | [`invariants`](../packages/support/invariants) | | [`base`](../packages/bundle/base) | `bundle` | [`invariants`](../packages/support/invariants) | | [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) | | [`client-schema-form`](../packages/client/schema-form) | `client` | [`invariants`](../packages/support/invariants) | diff --git a/packages/boot/README.md b/packages/boot/README.md index 5e4e483b60..58a824a7f4 100644 --- a/packages/boot/README.md +++ b/packages/boot/README.md @@ -7,5 +7,6 @@ The channel-neutral boot library the app bins share: `apps/cli`, the [`scaffold/ | Package | Role | ctx key | |---|---|---| | `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) | +| `cmdline/` | Launcher-to-app command-line handoff and app-owned startup parsing | `cmdlineArgs`, `appExit`, `appReady` | -The boot sequence and personal-config contract are documented in [`app-boot/README.md`](app-boot/README.md). +The boot sequence and personal-config contract are documented in [`app-boot/README.md`](app-boot/README.md); app-owned command lines are documented in [`cmdline/README.md`](cmdline/README.md). diff --git a/packages/boot/README.zh.md b/packages/boot/README.zh.md index 95a3f98129..7357b920a0 100644 --- a/packages/boot/README.zh.md +++ b/packages/boot/README.zh.md @@ -7,5 +7,6 @@ | 包 | 职责 | ctx 键 | |---|---|---| | `app-boot/` | app bin 的共享启动粘合层:加载 `.env`、会明确报错的 Loader 保护机制、感知快照的配置解析,以及等待整棵树停稳的启动序列 | (供各 bin 使用的库) | +| `cmdline/` | 启动器到应用的命令行交接,以及由应用持有的启动解析 | `cmdlineArgs`、`appExit`、`appReady` | -启动序列与个人配置约定见 [`app-boot/README.md`](app-boot/README.md)。 +启动序列与个人配置约定见 [`app-boot/README.md`](app-boot/README.md);由应用持有的命令行见 [`cmdline/README.md`](cmdline/README.md)。 diff --git a/packages/boot/cmdline/README.i18n.yaml b/packages/boot/cmdline/README.i18n.yaml new file mode 100644 index 0000000000..f1e5a30951 --- /dev/null +++ b/packages/boot/cmdline/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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: 3d7aa7fd58c7e542ac0c733eb0794436cb0fc42d +README.zh.md: d6eb191e1c0c8136a613d5e9fe29bb66420139ac diff --git a/packages/boot/cmdline/README.md b/packages/boot/cmdline/README.md new file mode 100644 index 0000000000..3d7aa7fd58 --- /dev/null +++ b/packages/boot/cmdline/README.md @@ -0,0 +1,72 @@ +# `@deepseek-ai/dsh-cmdline` + +English | [中文](README.zh.md) + +The command line a dsh launcher hands to the app it boots. The launcher parses only its own flags (`--profile`, `--patch`, the config dumps) and hands **everything after them** to the tree verbatim, so an app owns its flag family, its `--help` text, and its parse errors instead of the launcher knowing them. + +## The three launcher values + +A launcher calls `provideCmdline(ctx, host)` before any tree entry mounts, which provides: + +- `ctx.cmdlineArgs` — the invocation's inner arguments. `get()` is the whole interface, and it returns a snapshot: `dsh --profile tui --resume abc` yields `['--resume', 'abc']`. +- `ctx.appExit` — a bounded process-exit request, wired to the launcher's shutdown controller. +- `ctx.appPatches` — where a startup row records its decisions, for a launcher that recomposes its tree. Omitted by a host that never does. + +An embedding host with no command line provides an empty list; that is the honest answer, not a missing value. + +## Startup rows and the services their rows wait for + +An app reads those arguments from a **startup row** — a plugin that injects `cmdlineArgs` and calls `runStartup(ctx, service, program, plan)`: + +```ts ignore +export const name = 'web-startup' +export const inject = ['cmdlineArgs'] + +export function apply(ctx: Context): Promise { + return runStartup(ctx, 'webStartup', webCommand(), planWebStartup) +} +``` + +Every row the app configures from flags injects that startup service in the bundle patch: + +```yaml +- id: webserver + name: '@deepseek-ai/dsh-host-webserver' + inject: [webStartup] + config: + host: 127.0.0.1 + port: 3080 +``` + +`runStartup` parses the arguments, asks `plan` what each waiting row's values should be, applies them, and provides the startup service, which is what lets those rows start. On `--help`, `--version`, a parse error, or a `program.error(...)` from the plan, it writes commander's text, disables the waiting rows, and requests exit — the app never starts, and the settlement audit sees a tree that was asked not to start it. + +`plan` receives every waiting row's **composed** options, so a decision reads what the bundle patches and the user's own layers agreed on before overriding it; `overrideConfig(row, { port })` replaces exactly the named keys. A row absent from the plan starts on its composed values, and planning a change for a row also enables it. + +A row whose required config the startup **supplies** rather than overrides must ship `disabled: true`, because a waiting row's config is validated when its fiber is created — before the startup service arrives — and a missing required key fails the boot there. The one-shot runner's `task` is the shipped example. A row shipped disabled for another reason is turned on the same way: `dsh web --dev` plans `{ disabled: false }` for the HMR receiver. + +The decisions also reach the launcher through `ctx.appPatches`, which is what keeps them alive across a recomposition: without it, a user editing a live patch file would rebuild every row from its composed options and silently move a server started on `--port 8080` back to the composed port. + +### Why a changed row is recycled + +A waiting row's config is resolved when the Loader creates its fiber, which happens while the row is still waiting. Writing a new config onto that fiber never reaches the plugin, so each changed row is disabled and re-enabled, which drops the stale fiber and resolves the config again. A row whose own mount is still in flight is allowed to settle first, so the disable has a fiber to dispose instead of racing one into existence. + +Recycling deliberately leaves `inject` alone. Updating a row's `inject` restarts it from its unwrapped callback, which loses the plugin's own static injections — a row that declares `inject = ['httpServer', 'apiProxy']` would come back unable to read either. + +### One command line, one owner + +A composition has exactly one command-line owner. An app that layers over another one disables the underlying startup row and names both startup services, so the rows it absorbed start on their composed values — [`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. + +## Model Experience + +None, as this package resolves the process's own command line before any session exists. + +#### KV Cache effect + +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 a startup row provides it; nothing links the two statically, so a bundle that ships waiting rows without its startup row fails at settlement (pending entries naming the service) rather than at load. diff --git a/packages/boot/cmdline/README.zh.md b/packages/boot/cmdline/README.zh.md new file mode 100644 index 0000000000..d6eb191e1c --- /dev/null +++ b/packages/boot/cmdline/README.zh.md @@ -0,0 +1,72 @@ +# `@deepseek-ai/dsh-cmdline` + +[English](README.md) | 中文 + +dsh 启动器交给它所引导应用的那条命令行。启动器只解析属于自己的 flag(`--profile`、`--patch`、配置 dump),并把**其后的一切**原样交给配置树,因此 flag 家族、`--help` 文本和解析错误都由应用自己持有,启动器不必知道它们。 + +## 启动器提供的三个值 + +启动器在任何配置树条目挂载之前调用 `provideCmdline(ctx, host)`,它提供: + +- `ctx.cmdlineArgs`:本次调用的内层参数。`get()` 就是它的全部接口,返回一份快照:`dsh --profile tui --resume abc` 得到 `['--resume', 'abc']`。 +- `ctx.appExit`:一个有边界的进程退出请求,接到启动器的关停控制器上。 +- `ctx.appPatches`:启动行记录自身决策的去处,面向会重新组合自己配置树的启动器。从不重新组合的宿主不提供它。 + +没有命令行的嵌入宿主提供空列表;这是诚实的答案,而不是缺失的值。 + +## 启动行,以及各行所等待的服务 + +应用从**启动行**读取这些参数:启动行是一个注入 `cmdlineArgs` 并调用 `runStartup(ctx, service, program, plan)` 的插件: + +```ts ignore +export const name = 'web-startup' +export const inject = ['cmdlineArgs'] + +export function apply(ctx: Context): Promise { + return runStartup(ctx, 'webStartup', webCommand(), planWebStartup) +} +``` + +应用用 flag 配置的每一行,都在组合包 patch 中注入那个启动服务: + +```yaml +- id: webserver + name: '@deepseek-ai/dsh-host-webserver' + inject: [webStartup] + config: + host: 127.0.0.1 + port: 3080 +``` + +`runStartup` 解析参数,向 `plan` 询问每个等待中的行应有的取值,应用这些取值,然后提供启动服务,正是这一步让这些行得以启动。遇到 `--help`、`--version`、解析错误,或 `plan` 发出的 `program.error(...)` 时,它输出 commander 的文本,禁用等待中的行并请求退出:应用从不启动,结算审计看到的是一棵被要求不要启动它的树。 + +`plan` 收到的是每个等待中的行**组合后**的选项,因此决策在覆盖之前能读到组合包 patch 与用户自己那几层达成的结果;`overrideConfig(row, { port })` 只替换点名的那些配置键。plan 中未出现的行按组合后的取值启动;而为某一行 plan 了改动,也会顺带启用它。 + +必填配置由启动流程**供给**而非覆盖的行,必须以 `disabled: true` 交付,因为等待中的行的配置在其 fiber 创建时就会被校验(此时启动服务尚未到达),缺少一个必填键会在那里就让 boot 失败。一次性运行器的 `task` 就是随附的例子。因其他原因以禁用状态交付的行也以同样方式打开:`dsh web --dev` 为 HMR(热模块替换)接收方 plan 了一个 `{ disabled: false }`。 + +这些决策同时经 `ctx.appPatches` 到达启动器,正是这一点让它们在一次重新组合中存活下来:没有它,用户编辑一个活动的 patch 文件就会把每一行都从其组合后的选项重建出来,并悄悄把一台以 `--port 8080` 启动的服务器挪回组合后的端口。 + +### 为什么改动过的行要回收重建 + +等待中的行的配置在 Loader 创建它的 fiber 时就已解析,而这发生在该行仍在等待的时候。把新配置写到这个 fiber 上,永远到不了插件,因此每个改动过的行都会先禁用再重新启用,从而丢弃陈旧的 fiber 并重新解析配置。自身挂载仍在进行中的行会先被放行至停稳,这样禁用时才有一个 fiber 可供 dispose(资源释放),而不是与一个正在诞生的 fiber 抢跑。 + +回收重建刻意不动 `inject`。更新一行的 `inject` 会让它从未经包装的回调重新启动,从而丢失插件自身的静态注入:声明了 `inject = ['httpServer', 'apiProxy']` 的行回来之后,两个服务都读不到。 + +### 一条命令行,一个所有者 + +一套组合有且只有一个命令行所有者。叠加在另一应用之上的应用会禁用下层的启动行,并同时点名两个启动服务,使它吸收过来的行按组合后的取值启动:[`dsh-headless`](../../bundle/headless/README.md) 相对 [`dsh-web-app`](../../bundle/web-app/README.md) 就是这么做的。 + +树外插件会带来自己的一份 commander 副本,因此 commander 的控制流错误按结构识别,而不是按类身份识别;按身份判断会把已经打印出来的 help 重新抛成致命的加载失败。 + +## 模型体验 + +无。本包在任何会话存在之前解析进程自身的命令行。 + +#### KV Cache 影响 + +无;本包既不组装也不发送提供方请求。 + +## 已知限制与延期工作 + +- **启动器的 flag 必须写在应用参数之前**:切分按位置进行,启动器不认识的第一个 token 就是内层参数的起点,因此写在某个应用 flag 之后的 `--patch` 属于应用。启动器的解析器会消耗掉一个 `--`,因此必须以字面量 `--` 存活到应用的参数需要写成 `-- --`。 +- **启动服务没有声明所有者**:各行点名它,由启动行提供它;两者之间没有静态关联,因此交付了等待中的行却缺少对应启动行的组合包会在结算时失败(出现指向该服务的待处理条目),而不是在加载时失败。 diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json new file mode 100644 index 0000000000..7ac7f488d9 --- /dev/null +++ b/packages/boot/cmdline/package.json @@ -0,0 +1,42 @@ +{ + "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", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "dependencies": { + "commander": "^15.0.0" + }, + "peerDependencies": { + "@cordisjs/plugin-include": "^1.0.4", + "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@cordisjs/plugin-include": "workspace:^", + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/boot/cmdline/src/index.ts b/packages/boot/cmdline/src/index.ts new file mode 100644 index 0000000000..3dce71079e --- /dev/null +++ b/packages/boot/cmdline/src/index.ts @@ -0,0 +1,311 @@ +/** + * @deepseek-ai/dsh-cmdline — the command line a dsh launcher hands to the app + * it boots. + * + * The launcher parses only its own flags (`--profile`, `--patch`, the config + * dumps) and hands everything after them to the tree verbatim through the + * {@link CmdlineArgs} service, so an app owns its flag family, its `--help` + * text, and its parse errors instead of the launcher knowing them. + * + * An app consumes those arguments from a **startup plugin**: a row that + * injects `cmdlineArgs` and calls {@link runStartup}. Every row the app + * configures from flags declares `inject: []` in the bundle + * patch and therefore waits until the startup plugin provides that service; + * `--help` prints, disables exactly those rows, and requests exit, so the app + * never starts. + * @module @deepseek-ai/dsh-cmdline + */ + +import type { Command } from 'commander' +import type { Context } from 'cordis' +import type { PatchOptions } from '@cordisjs/plugin-include' +import type { Entry, EntryOptions } from '@cordisjs/plugin-loader' +// Empty type import carries the loader Context merge used to walk the tree. +import type {} from '@cordisjs/plugin-loader' + +/** + * The invocation's inner arguments: everything after the launcher's own flags, + * verbatim and in argv order. `dsh --profile tui --resume abc` yields + * `['--resume', 'abc']`. + */ +export interface CmdlineArgs { + /** + * Read the inner arguments. + * @returns the arguments in argv order; empty when the invocation carried none. + */ + get(): readonly string[] +} + +/** Request bounded process exit; the launcher wires it to its shutdown controller. */ +export interface AppExit { + /** + * Request exit once the tree has been disposed. + * @param code - the process exit code. + */ + (code: number): void +} + +/** + * The launcher's own patch layer, above every layer a user can edit. + * + * A startup row's decisions are facts about this invocation, so they must + * outlive a recomposition of the tree: a launcher that re-applies its patch + * stack when the user edits a live patch file rebuilds every row from its + * composed options, which would otherwise silently reset a flag-configured + * row (a browser served on `--port 8080` would move back to the composed + * port on an unrelated edit). + */ +export interface AppPatches { + /** + * Record patches the launcher must keep applying on every later composition. + * @param patches - the startup row's decisions, as patches over the composed rows. + */ + contribute(patches: readonly PatchOptions[]): void +} + +declare module 'cordis' { + interface Context { + /** The invocation's inner arguments; provided by a launcher before the tree mounts. */ + cmdlineArgs?: CmdlineArgs + /** Bounded process-exit request; provided by a launcher before the tree mounts. */ + appExit?: AppExit + /** The launcher's own patch layer; provided by a launcher that recomposes its tree. */ + appPatches?: AppPatches + } +} + +/** The launcher facts an app's startup row needs. */ +export interface CmdlineHost { + /** The invocation's inner arguments, in argv order. */ + args: readonly string[] + /** Bounded process-exit request. */ + exit: AppExit + /** + * Sink for startup decisions a later recomposition must keep. A launcher + * that never recomposes its tree (a one-shot embedding host) omits it. + */ + contribute?: AppPatches['contribute'] +} + +/** + * Provide the command line, the exit request, and the patch sink on a host + * context before any tree entry mounts. These are launcher facts, not config: + * an embedding host with no command line provides an empty argument list. + * @param ctx - the host context the tree will mount under. + * @param host - the invocation's arguments, exit request, and optional patch sink. + */ +export function provideCmdline(ctx: Context, host: CmdlineHost): void { + const snapshot = [...host.args] + ctx.provide('cmdlineArgs', { get: () => snapshot }) + ctx.provide('appExit', host.exit) + const contribute = host.contribute + if (contribute !== undefined) ctx.provide('appPatches', { contribute }) +} + +/** 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, + stderr: process.stderr, +} + +/** + * What a startup plugin changes on one waiting row. A row with a change is + * re-enabled as part of applying it; `{ disabled: true }` keeps it off (and + * `{ disabled: false }` is how a row a bundle ships disabled gets turned on). + */ +export type RowChange = Omit, 'id' | 'inject'> + +/** + * Decide this invocation's changes for the rows waiting on an app's startup + * service. + * + * Runs after a successful parse, with every waiting row's composed options + * (bundle layers, the user's layers, and any `--patch` overlay already + * applied), so a decision can read what the composition agreed on before + * overriding it. Call `program.error(...)` 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. + * @returns row id → the changes for that row; ids absent from the map start unchanged. + */ +export type StartupPlan = (program: Command, rows: readonly EntryOptions[]) => Map + +/** + * Run one app's startup: parse the invocation's inner arguments with the app's + * own commander program, apply the resulting changes to the waiting rows, and + * release them by providing the startup service they inject. + * + * A waiting row's config is resolved when the Loader creates its fiber, which + * happens while the row is still waiting, so writing a new config onto that + * fiber would never reach the plugin. Each changed row is therefore recycled — + * disabled, then re-enabled with its new values — which drops the stale fiber + * and resolves the config again. Recycling deliberately leaves `inject` alone: + * an `inject` update restarts the row from its unwrapped callback and loses the + * plugin's own static injections. + * + * Help, version, and rejected arguments are terminal for the process: the text + * is written, every waiting row is disabled so the settlement audit sees a tree + * that was asked not to start this app, and `ctx.appExit` is requested. + * + * An app that layers over another one (the one-shot bundle rides over the web + * bundle) disables the underlying startup row and names both startup services, + * because a composition has exactly one command-line owner: the rows of the app + * it absorbed then start on their composed values. + * @param ctx - plugin context carrying `cmdlineArgs`, `appExit`, and the Loader. + * @param services - the startup service name, or names, that this app's rows declare in their `inject`. + * @param program - the app's commander program, with its flags and description already declared. + * @param plan - this invocation's per-row changes; omitted starts the waiting rows unchanged. + * @returns nothing once the waiting rows are released, or once the exit was requested. + * @throws when the launcher provided no command line, when a startup service is + * declared by no row, or when `plan` names a row that is not waiting. + */ +export async function runStartup( + ctx: Context, + services: string | readonly string[], + program: Command, + plan: StartupPlan = () => new Map(), +): Promise { + const names = typeof services === 'string' ? [services] : services + // Read through the global service store, not the property proxy: these are + // optional host values, and a row that injects only `cmdlineArgs` may not + // read the others as declared injections. + const args = ctx.get('cmdlineArgs') + const exit = ctx.get('appExit') + if (args === undefined || exit === undefined) { + throw new Error(`${program.name()}: the launcher must provide ctx.cmdlineArgs and ctx.appExit before the tree mounts`) + } + program + .exitOverride() + .configureOutput({ + writeOut: text => void internals.stdout.write(text), + writeErr: text => void internals.stderr.write(text), + }) + let decisions: Map + let rows: EntryOptions[] + try { + program.parse(args.get(), { from: 'user' }) + // An app can dispose the whole tree while this row is still parsing (an + // early SIGTERM, or another app exiting). There is then nothing to + // configure and nothing to release, and the checks below would blame the + // bundle for a tree that simply went away. + if (ctx.get('loader') === undefined) return + rows = waitingRows(ctx, names) + decisions = plan(program, rows) + } catch (error) { + // exitOverride turns help, version, a parse error, and a plan's own + // program.error() into a CommanderError; commander has already written the + // text through the output configured above. + if (!isCommanderError(error)) throw error + for (const entry of waitingEntries(ctx, names)) await stopRow(entry) + exit(error.exitCode) + return + } + const unknown = [...decisions.keys()].filter(id => !rows.some(row => row.id === id)) + if (unknown.length > 0) { + throw new Error(`${program.name()}: startup planned changes for row(s) ${unknown.join(', ')}, which inject none of ${names.join(', ')}`) + } + const contributed: PatchOptions[] = [] + for (const entry of waitingEntries(ctx, names)) { + const change = decisions.get(entry.options.id) + if (change === undefined) continue + await stopRow(entry) + await entry.update({ disabled: false, ...change }) + contributed.push({ id: entry.options.id, disabled: false, ...change }) + } + // Hand the same decisions to the launcher as patches, so a later + // recomposition of the tree (a user editing a live patch file) rebuilds + // these rows with this invocation's values instead of the composed ones. + if (contributed.length > 0) ctx.get('appPatches')?.contribute(contributed) + // The rows are ready; providing the service they inject starts them, and a + // row this invocation left disabled stays that way. + for (const service of names) ctx.provide(service, true) +} + +/** + * Stop a waiting row, including one whose own mount is still in flight. + * + * Disabling alone is not a barrier: a row whose init has not finished has no + * fiber yet, so the update returns while that init goes on to create one, and + * the re-enable would then take the config-patch path, which a still-waiting + * fiber never applies — the row would start on stale values. Letting the mount + * settle first gives the disable a fiber to dispose. A row the composition + * ships disabled has no mount to settle and is left alone. + * @param entry - the waiting row's Loader entry. + */ +async function stopRow(entry: Entry): Promise { + await entry.refresh() + await entry.update({ disabled: true }) +} + +/** + * Merge flag overrides over a waiting row's composed config. + * + * A row's composed config is what the bundle patches and the user's own layers + * agreed on; a flag replaces exactly the keys it names and leaves the rest of + * that agreement intact. + * @param options - the waiting row's composed options. + * @param overrides - the values this invocation's flags decided, by config key. + * @returns the change to put in a {@link StartupPlan}'s map. + */ +export function overrideConfig(options: EntryOptions, overrides: Record): RowChange { + return { config: { ...(options.config ?? {}) as Record, ...overrides } } +} + +/** + * The composed options of every row waiting on one of `services`, in tree order. + * @param ctx - plugin context whose Loader tree carries the rows. + * @param services - the startup service names. + * @returns the waiting rows' options. + * @throws when a startup service is declared by no row, which means the bundle + * patch and its startup plugin disagree. + */ +function waitingRows(ctx: Context, services: readonly string[]): EntryOptions[] { + for (const service of services) { + if (waitingEntries(ctx, [service]).length === 0) { + throw new Error(`${service}: no row injects this startup service — the bundle patch must set "inject: [${service}]" on every row this app configures`) + } + } + return waitingEntries(ctx, services).map(entry => entry.options) +} + +/** + * The Loader entries waiting on any of `services`. + * @param ctx - plugin context whose Loader tree carries the rows. + * @param services - the startup service names. + * @returns the waiting entries in tree order. + */ +function waitingEntries(ctx: Context, services: readonly string[]): Entry[] { + // Called only after runStartup established the tree is still live. + return [...ctx.loader.entries()].filter(entry => services.some(service => waitsFor(entry.options.inject, service))) +} + +/** + * Whether a thrown value is commander's own control-flow error (help, version, + * a parse error, or `program.error`). + * + * Detected structurally, not with `instanceof`: an out-of-tree plugin brings + * its own commander copy, whose `CommanderError` class is a different identity + * from this package's, and an identity check there would rethrow a printed + * help as a fatal load failure. + * @param error - the thrown value. + * @returns true when the value carries commander's error code and exit code. + */ +function isCommanderError(error: unknown): error is { code: string; exitCode: number } { + if (typeof error !== 'object' || error === null) return false + const candidate = error as { code?: unknown; exitCode?: unknown } + return typeof candidate.code === 'string' && candidate.code.startsWith('commander.') + && typeof candidate.exitCode === 'number' +} + +/** + * Whether a row's `inject` declaration names `service`. + * @param inject - the row's `inject` value: the array form, the object form, or absent. + * @param service - the startup service name. + * @returns true when the row waits for it. + */ +function waitsFor(inject: EntryOptions['inject'], service: string): boolean { + if (inject === undefined || inject === null) return false + // The array form lists service names; the object form maps each name to its + // intercept config. Both name the service as a key of the same shape. + return Array.isArray(inject) ? inject.includes(service) : Object.hasOwn(inject, service) +} diff --git a/packages/boot/cmdline/src/invariant.ts b/packages/boot/cmdline/src/invariant.ts new file mode 100644 index 0000000000..f1ec75678f --- /dev/null +++ b/packages/boot/cmdline/src/invariant.ts @@ -0,0 +1,34 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-cmdline`. + * @module @deepseek-ai/dsh-cmdline/invariant + */ + +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-cmdline' + +/** Cordis companion plugin name. */ +export const name = 'cmdline-invariant' +/** Service required before the companion can register. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the owned relation is "no row is left waiting for a + * startup service", which is a property of the whole tree at Loader + * settlement, and the invariant service carries no settlement signal to + * evaluate it at. Observing it from the entry stream would fire while startup + * is still parsing, when every waiting row is legitimately still waiting. The + * launcher's post-settlement audit (`assertEntriesActivated`) already reports + * a startup service that was never provided as a pending entry naming it, and + * the built-bin e2e asserts the apps boot with flag values applied. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/boot/cmdline/tests/cmdline.spec.ts b/packages/boot/cmdline/tests/cmdline.spec.ts new file mode 100644 index 0000000000..b4ea1a3624 --- /dev/null +++ b/packages/boot/cmdline/tests/cmdline.spec.ts @@ -0,0 +1,268 @@ +/** + * The launcher-to-app command line over a REAL Loader tree: a startup row parses the + * invocation's inner arguments and releases the rows waiting for it, waiting rows start + * with the resolved values, `--help` leaves the app unstarted, and a + * bundle whose patch and startup plugin disagree fails loud. + */ + +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Command } from 'commander' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import { afterEach, describe, expect, it } from 'vitest' +import { internals, overrideConfig, provideCmdline, runStartup, type RowChange, type StartupPlan } from '../src/index.ts' + +/** Every value one boot of the fixture tree observed. */ +interface Observed { + applied: { id: string; config: Record }[] + exits: number[] + out: string +} + +/** A booted fixture tree: what it observed, and its root for direct startup calls. */ +interface Fixture { + observed: Observed + ctx: Context + /** Patches the startup row handed the launcher for later compositions. */ + contributed: unknown[] +} + +/** Cordis FiberState.ACTIVE, mirrored because the const enum has no runtime object. */ +const FIBER_ACTIVE = 2 + +const disposers: (() => Promise)[] = [] + +afterEach(async () => { + for (const dispose of disposers.splice(0)) await dispose() + internals.stdout = process.stdout + internals.stderr = process.stderr +}) + +/** The fixture's flag family: one `--port` over the waiting row's composed config. */ +function demoCommand(): Command { + return new Command().name('demo').exitOverride().option('--port ', 'listen port') +} + +/** The fixture's plan: `--port` overrides the waiting row, absent leaves it composed. */ +const demoPlan: StartupPlan = (program, rows) => { + const port = program.opts<{ port?: string }>().port + if (port === undefined) return new Map() + if (!/^\d+$/.test(port)) program.error(`error: --port must be a number, got ${JSON.stringify(port)}`) + const row = rows.find(candidate => candidate.id === 'waiting') + return new Map(row === undefined ? [] : [['waiting', overrideConfig(row, { port: Number(port) })]]) +} + +/** + * Mount a tree with one waiting row, and — unless the caller drives startup + * itself — a startup row that calls {@link runStartup} on this package's real + * code path. + * @param args - the invocation's inner arguments. + * @param options - fixture knobs for the shapes a bundle patch can produce. + * @returns the booted fixture. + */ +async function bootFixture( + args: string[], + options: { injectObjectForm?: boolean; withoutStartupRow?: boolean; slowWaitingImport?: boolean } = {}, +): Promise { + const dir = mkdtempSync(join(tmpdir(), 'dsh-cmdline-')) + const observed: Observed = { applied: [], exits: [], out: '' } + writeFileSync(join(dir, 'waiting.mjs'), ` +${options.slowWaitingImport === true ? 'await new Promise(resolve => setTimeout(resolve, 30))' : ''} +export const name = 'waiting' +export function apply(ctx, config) { globalThis.__observed.applied.push({ id: 'waiting', 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, 'startup.mjs'), ` +export const name = 'startup' +export const inject = ['cmdlineArgs'] +export function apply(ctx) { return globalThis.__runStartup(ctx) } +`) + writeFileSync(join(dir, 'cordis.yml'), [ + '- id: waiting', + ` name: ${pathToFileURL(join(dir, 'waiting.mjs')).href}`, + options.injectObjectForm === true ? ' inject: { demoStartup: null }' : ' inject: [demoStartup]', + ' config:', + ' port: 3080', + ' host: 127.0.0.1', + ...options.withoutStartupRow === true ? [] : [ + '- id: startup', + ` name: ${pathToFileURL(join(dir, 'startup.mjs')).href}`, + ], + '', + ].join('\n')) + const observing = { write: (chunk: string) => { observed.out += chunk; return true } } + internals.stdout = observing + internals.stderr = observing + const globals = globalThis as unknown as { __observed: Observed; __runStartup: (ctx: Context) => Promise } + globals.__observed = observed + globals.__runStartup = (ctx: Context) => runStartup(ctx, 'demoStartup', demoCommand(), demoPlan) + + const contributed: unknown[] = [] + const ctx = new Context() + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + provideCmdline(ctx, { + args, + exit: code => void observed.exits.push(code), + contribute: patches => void contributed.push(...patches), + }) + await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(join(dir, 'cordis.yml')).href } }) + await ctx.loader.await() + disposers.push(async () => { await ctx.fiber.dispose() }) + return { observed, ctx, contributed } +} + +describe('runStartup', () => { + it('starts a waiting row only after the startup service arrives, with the flag value applied over its composed config', async () => { + const { observed } = await bootFixture(['--port', '8080']) + expect(observed.applied).toEqual([{ id: 'waiting', config: { port: 8080, host: '127.0.0.1' } }]) + expect(observed.exits).toEqual([]) + }) + + it('starts the waiting row unchanged when the invocation carries no flags', async () => { + const { observed } = await bootFixture([]) + expect(observed.applied).toEqual([{ id: 'waiting', config: { port: 3080, host: '127.0.0.1' } }]) + }) + + it('applies the flag value to a row whose own mount was still in flight', async () => { + // The row has no fiber yet when startup disables it, so the disable is not + // a barrier: the in-flight mount still produces one. Without disposing + // that late fiber, the row would start on its composed port. + const { observed } = await bootFixture(['--port', '8080'], { slowWaitingImport: true }) + expect(observed.applied).toEqual([{ id: 'waiting', config: { port: 8080, host: '127.0.0.1' } }]) + }) + + it('starts a row that injects the startup service in the intercept-map form of inject', async () => { + const { observed } = await bootFixture(['--port', '8080'], { injectObjectForm: true }) + expect(observed.applied).toEqual([{ id: 'waiting', config: { port: 8080, host: '127.0.0.1' } }]) + }) + + it('prints the app help, leaves the app unstarted, and requests exit 0', async () => { + const { observed } = await bootFixture(['--help']) + expect(observed.out).toContain('Usage: demo') + expect(observed.applied).toEqual([]) + expect(observed.exits).toEqual([0]) + }) + + it('rejects the invocation from the plan without starting the app', async () => { + const { observed } = await bootFixture(['--port', 'abc']) + expect(observed.out).toContain('--port must be a number') + expect(observed.applied).toEqual([]) + expect(observed.exits).toEqual([1]) + }) +}) + +describe('startup-service lifetime', () => { + it('unloads the waiting rows when the startup row is disposed, and reopens on a fresh run', async () => { + // The startup service is an effect of the startup row: HMR restarting that + // row must take its app down with it, then bring it back. + const { ctx, observed } = await bootFixture(['--port', '8080']) + const startup = [...ctx.loader.entries()].find(entry => entry.options.id === 'startup') + const waiting = [...ctx.loader.entries()].find(entry => entry.options.id === 'waiting') + expect(waiting?.fiber?.state).toBe(FIBER_ACTIVE) + await startup?.update({ disabled: true }) + expect(waiting?.fiber?.state).not.toBe(FIBER_ACTIVE) + await startup?.update({ disabled: false }) + await ctx.loader.await() + expect(waiting?.fiber?.state).toBe(FIBER_ACTIVE) + // The second run re-resolved the same arguments, so the row is back on the + // flag value rather than the composed one. + expect(observed.applied.at(-1)).toEqual({ id: 'waiting', config: { port: 8080, host: '127.0.0.1' } }) + }) +}) + +describe('runStartup rejects a bundle that disagrees with its own patch', () => { + it('fails when no row declares the startup service it provides', async () => { + // The patch and its startup plugin disagree; a silent no-op would leave + // the app's rows waiting forever with no explanation. + const { ctx } = await bootFixture([], { withoutStartupRow: true }) + await expect(runStartup(ctx, 'absentStartup', demoCommand(), demoPlan)) + .rejects.toThrow('absentStartup: no row injects this startup service') + }) + + it('fails when the plan names a row that is not waiting', async () => { + const { ctx, observed } = await bootFixture([], { withoutStartupRow: true }) + const plan: StartupPlan = () => new Map([['not-waiting', {}]]) + await expect(runStartup(ctx, 'demoStartup', demoCommand(), plan)) + .rejects.toThrow('startup planned changes for row(s) not-waiting') + expect(observed.applied).toEqual([]) + }) + + it('rethrows a plan failure that is not commander asking to exit', async () => { + const { ctx, observed } = await bootFixture([], { withoutStartupRow: true }) + const plan: StartupPlan = () => { throw new Error('plan exploded') } + await expect(runStartup(ctx, 'demoStartup', demoCommand(), plan)).rejects.toThrow('plan exploded') + expect(observed.exits).toEqual([]) + }) + + it('rethrows a thrown value that is not an object at all', async () => { + const { ctx } = await bootFixture([], { withoutStartupRow: true }) + const plan: StartupPlan = () => { + const thrown: unknown = 'plan threw a string' + throw thrown + } + await expect(runStartup(ctx, 'demoStartup', demoCommand(), plan)).rejects.toThrow('plan threw a string') + }) +}) + +describe('the launcher patch layer', () => { + it('hands the startup row\'s decisions to the launcher as patches', async () => { + const { contributed } = await bootFixture(['--port', '8080']) + // The same decisions the rows started with: a launcher that recomposes its + // tree re-applies these, so an unrelated user edit cannot reset the port. + expect(contributed).toEqual([ + { id: 'waiting', disabled: false, config: { port: 8080, host: '127.0.0.1' } }, + ]) + }) + + it('contributes nothing when the invocation decided nothing', async () => { + const { contributed } = await bootFixture([]) + expect(contributed).toEqual([]) + }) +}) + +describe('an app with nothing to decide', () => { + it('starts every waiting row unchanged when it declares no plan', async () => { + const { ctx, observed } = await bootFixture([], { withoutStartupRow: true }) + // The list form of the service argument, which an app layering over + // another one uses to absorb that app's startup service. + await runStartup(ctx, ['demoStartup'], demoCommand()) + expect(observed.applied).toEqual([{ id: 'waiting', config: { port: 3080, host: '127.0.0.1' } }]) + }) + + it('overrides a row that carries no composed config', () => { + expect(overrideConfig({ id: 'row', name: 'plugin' }, { port: 8080 })).toEqual({ config: { port: 8080 } }) + }) +}) + +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: () => {} }) + args.push('--tampered') + expect(ctx.cmdlineArgs?.get()).toEqual(['--resume', 'abc']) + }) + + it('fails loud when a startup row runs without the launcher values', async () => { + const ctx = new Context() + await expect(runStartup(ctx, 'demoStartup', demoCommand())) + .rejects.toThrow('the launcher must provide ctx.cmdlineArgs and ctx.appExit') + }) + + it('opens nothing, and blames nobody, when the tree was disposed while startup was parsing', async () => { + // An early SIGTERM disposes the Loader mid-parse. There is nothing left to + // open, and the bundle did nothing wrong. + const exits: number[] = [] + const ctx = new Context() + provideCmdline(ctx, { args: [], exit: code => void exits.push(code) }) + await expect(runStartup(ctx, 'demoStartup', demoCommand())).resolves.toBeUndefined() + expect(exits).toEqual([]) + }) +}) diff --git a/packages/boot/cmdline/tsconfig.json b/packages/boot/cmdline/tsconfig.json new file mode 100644 index 0000000000..f4bcebf1e8 --- /dev/null +++ b/packages/boot/cmdline/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/include" + }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df45d792b2..3d890741ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1175,6 +1175,25 @@ importers: specifier: ^4.0.9 version: 4.0.9 + packages/boot/cmdline: + dependencies: + commander: + specifier: ^15.0.0 + version: 15.0.0 + devDependencies: + '@cordisjs/plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/bundle/base: dependencies: '@deepseek-ai/cordis-plugin-hmr': diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 0fcc4c16aa..49ff148922 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -165,6 +165,9 @@ function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] { ...exportDefault(manifest, './loader') === './lib/loader.js' ? ['lib/loader.js'] : [], // web-react's store subpath ships its own bundle (single-entry builds; no shared chunk). ...exportDefault(manifest, './store') === './lib/store/index.js' ? ['lib/store/index.js'] : [], + // A surface bundle's startup row is its own bundle: the Loader imports it + // as a row module, so it cannot ride inside the package entry. + ...exportDefault(manifest, './startup') === './lib/startup.js' ? ['lib/startup.js'] : [], ...extras, // Subpaths whose runtime default is the tsc-emitted tree (lib/types/*.js — // browser-safe source channels rehomed off src so plain Node can import diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 2b962ca471..1ffc0384f3 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -146,6 +146,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/tasks/tasks-local': { kind: 'indirect', reason: 'The registry backend delegates model rendering to producer plugins and dsh-tool-tasks.' }, 'packages/examples/acp-demo': { kind: 'indirect', reason: 'The app bundle delegates request composition to dsh-agent-spine-demo and dsh-acp.' }, 'packages/boot/app-boot': { kind: 'indirect', reason: 'Only the loaded plugin tree contributes model context.' }, + 'packages/boot/cmdline': { kind: 'none', reason: 'Resolves the process command line before any session exists; configured rows own every model-visible consequence.' }, 'packages/examples/jsonrpc-demo': { kind: 'indirect', reason: 'Only the externally configured plugin tree contributes model context.' }, 'packages/interaction/permission': { kind: 'indirect', reason: 'The service writes mechanism events rendered by dsh-user-approval and dsh-tool-bash.' }, 'packages/interaction/user-interaction': { kind: 'indirect', reason: 'Model-facing consumers render provider answers and seam errors.' }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 11f0ba7a88..32ae7df42d 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -222,6 +222,7 @@ { "path": "./packages/bundle/headless" }, { "path": "./packages/bundle/web-app" }, { "path": "./packages/boot/app-boot" }, + { "path": "./packages/boot/cmdline" }, { "path": "./packages/scaffold/server" }, { "path": "./packages/examples/jsonrpc-demo" }, { "path": "./packages/support/llm-replay" }, From 82728808d49b0ccd59c0e88993eb28d5f525c418 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 6 Aug 2026 20:52:26 +0800 Subject: [PATCH 02/19] feat(bundle): the web and one-shot apps own their own flags dsh-web-app owns --host/--port/--dev/--workspace-root/--trusted-host and its --help in a web-startup row; the rows it configures wait for the webStartup service, and the client-plugin HMR receiver now ships disabled so --dev is a row toggle rather than a runtime insert (the Loader cannot resolve a row inserted from inside a mounting plugin). dsh-headless owns the task positional and rejects a missing task as its own usage error. Its runner ships disabled, not merely waiting: the schema requires the task, and a row's config is validated when its fiber is created, before the startup row can supply one. A composition has exactly one command-line owner, so the patch disables the web startup row and this one provides webStartup too, leaving the web rows on their composed one-shot values. The keyless web scaffold provides the same three values with no arguments, which is what an embedding host with no command line does. --- apps/cli/tests/web-agent-presets.e2e.ts | 5 +- apps/web/package.json | 1 + apps/web/tests/scaffold.ts | 15 +- apps/web/tests/smoke-real.e2e.ts | 5 +- packages/boot/cmdline/package.json | 12 +- packages/bundle/headless/README.i18n.yaml | 4 +- packages/bundle/headless/README.md | 4 +- packages/bundle/headless/README.zh.md | 4 +- packages/bundle/headless/cordis.patch.yml | 14 +- packages/bundle/headless/package.json | 12 +- packages/bundle/headless/src/startup.ts | 70 ++++++++ .../bundle/headless/tests/startup.spec.ts | 146 ++++++++++++++++ packages/bundle/headless/tsconfig.json | 6 + packages/bundle/web-app/README.i18n.yaml | 4 +- packages/bundle/web-app/README.md | 2 +- packages/bundle/web-app/README.zh.md | 2 +- packages/bundle/web-app/cordis.patch.yml | 26 ++- packages/bundle/web-app/package.json | 11 +- packages/bundle/web-app/src/startup.ts | 152 ++++++++++++++++ packages/bundle/web-app/tests/startup.spec.ts | 163 ++++++++++++++++++ .../web-app/tests/trusted-hosts.spec.ts | 33 ++++ packages/bundle/web-app/tsconfig.json | 6 + pnpm-lock.yaml | 31 +++- 23 files changed, 692 insertions(+), 36 deletions(-) create mode 100644 packages/bundle/headless/src/startup.ts create mode 100644 packages/bundle/headless/tests/startup.spec.ts create mode 100644 packages/bundle/web-app/src/startup.ts create mode 100644 packages/bundle/web-app/tests/startup.spec.ts create mode 100644 packages/bundle/web-app/tests/trusted-hosts.spec.ts diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 20543965dc..85c5227bd4 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import { boot, healProfilesModuleFallback, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' +import { provideCmdline } from '@deepseek-ai/dsh-cmdline' import { SessionId } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' @@ -100,7 +101,9 @@ async function bootWeb(settingsFile: string, extra: PatchOptions[] = []): Promis await mkdir(profileDir, { recursive: true }) const rootConfig = join(profileDir, 'cordis.yml') await writeFile(rootConfig, '[]\n') - return await boot('dsh-test', rootConfig, patches) + return await boot('dsh-test', rootConfig, patches, (bootCtx) => { + provideCmdline(bootCtx, { args: [], exit: () => {} }) + }) } const toolNames = (ctx: Context, agent?: Agent): string[] => diff --git a/apps/web/package.json b/apps/web/package.json index 6f4bce8dc2..1bf97cb17c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -28,6 +28,7 @@ "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-web-react": "workspace:^", + "@deepseek-ai/dsh-cmdline": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@types/node": "^22.0.0", "@types/react": "~18.3.1", diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 0104a7bee7..a93828282e 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -35,7 +35,6 @@ import Include, { type PatchOptions } from '@deepseek-ai/cordis-plugin-include' import Group from '@deepseek-ai/cordis-plugin-group' import { scrubRequestHeaders, stabilizeFixtureMessageIds } from '@deepseek-ai/dsh-acp-snapshot' import { - addHarnessSourceSection, assertEntriesLoaded, composeEntries, healProfilesModuleFallback, @@ -65,6 +64,7 @@ import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' // Empty type imports carry the httpServer/agents/sessionPersistence Context merges. import type {} from '@deepseek-ai/dsh-host-webserver' import type {} from '@deepseek-ai/dsh-agent' +import { provideCmdline } from '@deepseek-ai/dsh-cmdline' import { REPO_ROOT, requireDist } from './support.ts' /** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the other snapshot suites). */ @@ -459,6 +459,16 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { + throw new Error(`web e2e scaffold: the web app requested exit ${String(code)} with no arguments to reject`) + }, + }) await ctx.plugin(Loader) ctx.loader.builtins.include = Include // `cordis:group` beside it, exactly as `boot()` registers it: a group row is @@ -469,9 +479,6 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { addHarnessSourceSection(promptCtx, REPO_ROOT) }) - } await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(rootConfig).href, patches }, diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index b0acf074b4..ae5805dc8a 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -485,10 +485,13 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke child = spawn( process.execPath, [ - '--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', String(port), + '--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', + // Launcher flags come first: the first token the launcher does not own + // starts the web app's own arguments. // Pin the in-browser picker: the shipped `-auto` row would resolve to // the native OS chooser on this bind, and no page can drive that. '--patch', fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), + '--port', String(port), ], { cwd: sessionsDir, diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json index 7ac7f488d9..4e3953bd1b 100644 --- a/packages/boot/cmdline/package.json +++ b/packages/boot/cmdline/package.json @@ -28,15 +28,15 @@ "commander": "^15.0.0" }, "peerDependencies": { - "@cordisjs/plugin-include": "^1.0.4", - "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@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", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { - "@cordisjs/plugin-include": "workspace:^", - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index 2ded85f25b..2ce1b72942 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/packages/bundle/headless/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/headless/README.md -README.md: f8b76b77f2beb22f501a49f0fc4cf5cd72223765 -README.zh.md: aae8ab5bea663b8909de942f72615f5ef9b16c84 +README.md: 45c87f0c85cbb68ad0366ea5f2c86e55fc307309 +README.zh.md: 22322692450fa85a87e9faf903abee0d38968f91 diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index f8b76b77f2..45c87f0c85 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides directly over [`dsh-base`](../base/README.md): it supplies the coding persona and tool mode, disables HMR, mounts Code Mode's worker as a core execution capability, and inserts this package's `headless-runner` plugin (config `{task}`). It mounts no Host, HTTP server, Web runtime, or browser plugin. +The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides directly over [`dsh-base`](../base/README.md): it supplies the coding persona and tool mode, disables HMR, mounts Code Mode's worker as a core execution capability, and inserts this package's `headless-runner` plugin (config `{task}`, shipped disabled until the startup row supplies the task). It mounts no Host, HTTP server, Web runtime, or browser plugin. -After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. It flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.headlessIo` host hook (final `turn/end` completed → 0, otherwise 1). A terminal `error` reason also writes its code and message to stderr; successful runs keep stderr empty. The process opens no listening port. The launcher patches the task text in (`dsh run "task"`) and fails loud when the selected profile lacks this row. +After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. It flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.headlessIo` host hook (final `turn/end` completed → 0, otherwise 1). A terminal `error` reason also writes its code and message to stderr; successful runs keep stderr empty. The process opens no listening port. The task text is this app's command line: the `headless-startup` row ([`src/startup.ts`](src/startup.ts)) reads it as the positional argument of `dsh --profile headless "task"` from `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), prints the app's `--help`, and rejects an invocation with no task instead of letting the runner's schema fail. ## Model Experience diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index aae8ab5bea..2232269245 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.md) 之上:提供编码 persona 和工具模式、禁用 HMR(热模块替换)、将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`)。它不挂载任何 Host、HTTP server、Web runtime 或浏览器插件。 +dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.md) 之上:提供编码 persona 和工具模式、禁用 HMR(热模块替换)、将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`,在启动行供给任务之前以禁用状态交付)。它不挂载任何 Host、HTTP server、Web runtime 或浏览器插件。 -Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md),通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),将任务作为普通用户消息提交,并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout,再经启动器提供的 `ctx.headlessIo` 宿主钩子请求退出(最终 `turn/end` 完成 → 0,否则为 1)。最终 reason 为 `error` 时,还会将持久化的 code 与 message 写入 stderr;成功运行时 stderr 保持为空。进程不会打开监听端口。启动器把任务文本 patch 进来(`dsh run "task"`);若所选 profile 缺少该行,则显式报错。 +Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md),通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),将任务作为普通用户消息提交,并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout,再经启动器提供的 `ctx.headlessIo` 宿主钩子请求退出(最终 `turn/end` 完成 → 0,否则为 1)。最终 reason 为 `error` 时,还会将持久化的 code 与 message 写入 stderr;成功运行时 stderr 保持为空。进程不会打开监听端口。任务文本就是这个应用的命令行:`headless-startup` 行([`src/startup.ts`](src/startup.ts))从 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md))把它读作 `dsh --profile headless "task"` 的位置参数,打印应用自己的 `--help`,并拒绝没有任务的调用,而不是让 runner 的 schema 失败。 ## 模型体验 diff --git a/packages/bundle/headless/cordis.patch.yml b/packages/bundle/headless/cordis.patch.yml index 8b147714be..6904931acc 100644 --- a/packages/bundle/headless/cordis.patch.yml +++ b/packages/bundle/headless/cordis.patch.yml @@ -1,7 +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 launcher -# patches the runner's `task`; the direct driver creates an Agent through the -# core registry and prints the final durable assistant message. +# 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. - id: system-prompt config: @@ -22,5 +23,12 @@ - id: code-runtime name: '@deepseek-ai/dsh-code-runtime-worker' + - id: headless-startup + name: '@deepseek-ai/dsh-headless/startup' + + # Shipped off, not merely waiting: the runner's schema requires the task. + # The startup row enables it with the task after parsing this app's argv. - id: headless-runner name: '@deepseek-ai/dsh-headless' + inject: [headlessStartup] + disabled: true diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index c5090d4b93..5216aa3048 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -11,6 +11,10 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./startup": { + "types": "./lib/types/startup.d.ts", + "default": "./lib/startup.js" + }, "./invariant": { "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" @@ -22,6 +26,7 @@ "files": [ "lib/index.js", "lib/invariant.js", + "lib/startup.js", "cordis.patch.yml", "lib/types/**/*.d.ts" ], @@ -32,15 +37,19 @@ } }, "dependencies": { + "@deepseek-ai/dsh-cmdline": "workspace:^", "@deepseek-ai/dsh-code-runtime-worker": "workspace:^", - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0", + "commander": "^15.0.0" }, "peerDependencies": { + "@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent-default-model": "^0.0.1", "@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": { @@ -50,6 +59,7 @@ "@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 new file mode 100644 index 0000000000..eb9907a6b7 --- /dev/null +++ b/packages/bundle/headless/src/startup.ts @@ -0,0 +1,70 @@ +/** + * The one-shot app's startup row: it owns the `dsh --profile headless` command + * line — the task text is this command's positional argument — and its + * `--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 + */ + +import { Command } from 'commander' +import type { Context } from 'cordis' +import type { EntryOptions } from '@cordisjs/plugin-loader' +import { overrideConfig, runStartup, type RowChange } 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' + +/** Services required before the task can be resolved. */ +export const inject = ['cmdlineArgs'] + +/** The startup service the one-shot runner row injects. */ +export const HEADLESS_STARTUP_SERVICE = 'headlessStartup' + +/** The runner row this app configures. */ +const RUNNER_ROW_ID = 'headless-runner' + +/** + * This app's command: the task positional, its description, and its help text. + * @returns a fresh program, so one process can parse more than once (tests). + */ +function headlessCommand(): Command { + return new Command() + .name('dsh --profile headless') + .description('Answer one task, print the final assistant message, and exit.') + .helpOption('-h, --help', 'show this help') + .argument('[task...]', 'the task text; multiple words are joined by spaces') + .addHelpText('after', ` +Examples: + dsh --profile headless "run the tests" answer one task and exit +`) +} + +/** + * Turn the parsed command line into the runner row's task. + * @param program - the parsed headless command. + * @param rows - the waiting rows' composed options, in tree order. + * @returns row id → changes. + */ +function planHeadlessStartup(program: Command, rows: readonly EntryOptions[]): Map { + const task = program.args.join(' ') + if (task === '') program.error('error: a task is required, for example: dsh --profile headless "run the tests"') + const runner = rows.find(row => row.id === RUNNER_ROW_ID) + if (runner === undefined) throw new Error(`headless-startup: the composition has no waiting "${RUNNER_ROW_ID}" row to run the task`) + return new Map([[RUNNER_ROW_ID, overrideConfig(runner, { task })]]) +} + +/** + * Resolve the task and start the rows waiting for it. + * @param ctx - plugin context carrying the command line and the Loader. + * @returns nothing once the runner is released, or once `--help` or a missing task requested exit. + */ +export function apply(ctx: Context): Promise { + return runStartup(ctx, [HEADLESS_STARTUP_SERVICE, WEB_STARTUP_SERVICE], headlessCommand(), planHeadlessStartup) +} diff --git a/packages/bundle/headless/tests/startup.spec.ts b/packages/bundle/headless/tests/startup.spec.ts new file mode 100644 index 0000000000..1b4d6f1430 --- /dev/null +++ b/packages/bundle/headless/tests/startup.spec.ts @@ -0,0 +1,146 @@ +/** + * The one-shot app's startup row over a REAL Loader tree: the task + * positional reaches the runner row, a missing task is a usage error, and the + * web startup service this app absorbs releases its rows on the composed values. + */ + +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context } from 'cordis' +import z from 'schemastery' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import { internals, provideCmdline } from '@deepseek-ai/dsh-cmdline' +import { WEB_STARTUP_SERVICE } from '@deepseek-ai/dsh-web-app/startup' +import { afterEach, describe, expect, it } from 'vitest' +import { apply, HEADLESS_STARTUP_SERVICE } from '../src/startup.ts' + +/** What one boot of the fixture tree observed. */ +interface Observed { + started: Record> + exits: number[] + out: string + /** Patches the startup row handed the launcher for later compositions. */ + contributed: unknown[] +} + +const disposers: (() => Promise)[] = [] + +afterEach(async () => { + for (const dispose of disposers.splice(0)) await dispose() + internals.stdout = process.stdout + internals.stderr = process.stderr +}) + +/** + * Boot the real headless startup row over stand-ins for the runner row and one + * web row it absorbs. + * @param args - the invocation's inner arguments. + * @returns what the boot observed. + */ +async function bootStartup(args: string[], options: { withoutRunner?: boolean } = {}): Promise { + const dir = mkdtempSync(join(tmpdir(), 'dsh-headless-startup-')) + const observed: Observed = { started: {}, exits: [], out: '', contributed: [] } + // The runner's real schema requires the task, which is exactly what makes a + // waiting-but-enabled row fail at fiber creation; the stand-in keeps that. + writeFileSync(join(dir, 'row.mjs'), ` +export const Config = globalThis.__headlessRunnerConfigSchema +export function apply(ctx, config) { globalThis.__headlessStartupObserved.started[ctx.fiber.entry.options.id] = config ?? {} } +`) + writeFileSync(join(dir, 'plain-row.mjs'), ` +export function apply(ctx, config) { globalThis.__headlessStartupObserved.started[ctx.fiber.entry.options.id] = config ?? {} } +`) + // 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, 'startup-row.mjs'), ` +export const name = 'headless-startup' +export const inject = ['cmdlineArgs'] +export const apply = ctx => globalThis.__headlessStartupApply(ctx) +`) + const rowUrl = pathToFileURL(join(dir, 'row.mjs')).href + const plainRowUrl = pathToFileURL(join(dir, 'plain-row.mjs')).href + writeFileSync(join(dir, 'cordis.yml'), [ + // A composition that lost the runner still injects the startup service, so + // the 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}]`, + // Shipped off, like the bundle patch: the schema below requires the task, + // which only the startup row can supply. + ' disabled: true', + '- id: webserver', + ` name: ${plainRowUrl}`, + ` inject: [${WEB_STARTUP_SERVICE}]`, + ' config:', + ' port: 0', + '- id: headless-startup', + ` name: ${pathToFileURL(join(dir, 'startup-row.mjs')).href}`, + '', + ].join('\n')) + const observing = { write: (chunk: string) => { observed.out += chunk; return true } } + internals.stdout = observing + internals.stderr = observing + const globals = globalThis as unknown as { + __headlessStartupObserved: Observed + __headlessStartupApply: typeof apply + __headlessRunnerConfigSchema: unknown + } + globals.__headlessStartupObserved = observed + globals.__headlessStartupApply = apply + globals.__headlessRunnerConfigSchema = z.object({ task: z.string().required() }) + + const ctx = new Context() + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + provideCmdline(ctx, { + args, + exit: code => void observed.exits.push(code), + contribute: patches => void observed.contributed.push(...patches), + }) + await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(join(dir, 'cordis.yml')).href } }) + await ctx.loader.await() + disposers.push(async () => { await ctx.fiber.dispose() }) + return observed +} + +describe('headless startup', () => { + it('joins the task positional and starts the runner with it', async () => { + const observed = await bootStartup(['run', 'the', 'tests']) + expect(observed.started['headless-runner']).toEqual({ task: 'run the tests' }) + expect(observed.exits).toEqual([]) + }) + + it('hands the task to the launcher as a patch, so a recomposition keeps it', async () => { + const observed = await bootStartup(['run', 'the', 'tests']) + expect(observed.contributed).toEqual([ + { id: 'headless-runner', disabled: false, config: { task: 'run the tests' } }, + ]) + }) + + it('starts the web rows it absorbed on the composed one-shot values', async () => { + const observed = await bootStartup(['task']) + expect(observed.started.webserver).toEqual({ port: 0 }) + }) + + it('rejects an invocation with no task instead of failing inside the runner schema', async () => { + const observed = await bootStartup([]) + expect(observed.out).toContain('a task is required') + expect(observed.started).toEqual({}) + expect(observed.exits).toEqual([1]) + }) + + it('fails the boot when the composition has no runner row to give the task to', async () => { + await expect(bootStartup(['task'], { withoutRunner: true })) + .rejects.toThrow('the composition has no waiting "headless-runner" row') + }) + + it('prints its own help and starts nothing', async () => { + const observed = await bootStartup(['--help']) + expect(observed.out).toContain('dsh --profile headless') + expect(observed.started).toEqual({}) + expect(observed.exits).toEqual([0]) + }) +}) diff --git a/packages/bundle/headless/tsconfig.json b/packages/bundle/headless/tsconfig.json index 9ae3212f6b..17d11ed3ff 100644 --- a/packages/bundle/headless/tsconfig.json +++ b/packages/bundle/headless/tsconfig.json @@ -31,6 +31,12 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../ui/cmdline" + }, + { + "path": "../web-app" } ] } diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index 6594cdb6d1..e702feca98 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md -README.md: d89ae4a7e28506166498caf0032f864bbb109cc5 -README.zh.md: 746ec2e8b6748a0d72f697d0aea5f3809e7106ee +README.md: 1b54e6d29ad49c62b7862bf7fffcd6d24831c643 +README.zh.md: 82b7c4c2574aa93697e8483c362cf4ec75630f34 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index d89ae4a7e2..1b54e6d29a 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner over it, registers the web-surface prompt section and the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true. The `dsh web` launcher alias patches `mode`/`lanAddresses` and the flag family over these rows. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle. +The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner over it, registers the web-surface prompt section and the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true. This bundle also owns the app command line: the `web-startup` row ([`src/startup.ts`](src/startup.ts)) parses `--host`, `--port`, `--dev`, `--workspace-root`, and repeatable `--trusted-host` from `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)) and prints the app's `--help`. Every row it configures injects `webStartup`, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. `mode` and `lanAddresses` resolve on every boot because they describe the invocation. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle. ## Model Experience diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 746ec2e8b6..82b7c4c257 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 web 表层提示词段落和 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。`dsh web` 启动器别名把 `mode`/`lanAddresses` 与相应 flag 家族 patch 到这些行上。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。 +dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 web 表层提示词段落和 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。本组合包还持有应用命令行:`web-startup` 行([`src/startup.ts`](src/startup.ts))从 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md))解析 `--host`、`--port`、`--dev`、`--workspace-root` 以及可重复的 `--trusted-host`,并打印应用自己的 `--help`。它所配置的每一行都注入 `webStartup`,因此在参数解析完成之前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。`mode` 与 `lanAddresses` 在每次 boot 时解析,因为它们描述的是本次调用。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。 ## 模型体验 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 69335c90ac..7825d68383 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -3,9 +3,13 @@ # the profile's own cordis.patch.yml and any --patch overlays still to come. # # A patch replaces the targeted row's whole `config`, so each row below -# restates every key it owns. The `dsh web` launcher alias turns --host/--port/ -# --dev/--trusted-host into further patches over these rows -# (`--dev` inserts the dsh-client-hmr row). +# restates every key it owns. +# +# Rows this app configures from flags declare `inject: [webStartup]`: they wait +# until the web-startup row has parsed --host/--port/--dev/--workspace-root/ +# --trusted-host and provided that service with the resolved values. +# `dsh --profile web --help` therefore prints this app's own help and exits +# without ever binding a port. # ── surface-specific values the base deliberately omits ───────────────────── @@ -76,6 +80,11 @@ - id: api-gateway name: '@deepseek-ai/dsh-host-apiproxy' + # Owns the web flag family and its --help; provides webStartup with the + # values this invocation resolved. Nothing waiting on it starts first. + - id: web-startup + name: '@deepseek-ai/dsh-web-app/startup' + # ── layer 2: transport/service ────────────────────────────────────────────── # Plain route-registration carrier; host and port arrive as `dsh web` @@ -83,6 +92,7 @@ # row below through the fallback seat. - id: webserver name: '@deepseek-ai/dsh-host-webserver' + inject: [webStartup] config: host: 127.0.0.1 port: 3080 @@ -96,12 +106,19 @@ # these host-owned shell variables. - id: web-runtime name: '@deepseek-ai/dsh-web-app' + inject: [webStartup] config: mode: production printUrl: true surfaceContext: true - # ── browser plugin roster (dsh.client rows; node halves are layer-2 hosts) ── + # The client-plugin HMR receiver ships disabled; `--dev` enables it. + - id: client-hmr + name: '@deepseek-ai/dsh-client-hmr' + inject: [webStartup] + disabled: true + + # ── browser plugin roster (dshClient rows; node halves are layer-2 hosts) ── # Dual-face: node half scans this very tree for dsh.client rows, composes # window.__DSH_BOOT__, serves /plugins//client.js; browser half is the @@ -114,6 +131,7 @@ # webserver under /api; browser half is the fetch/SSE client. - id: connection name: '@deepseek-ai/dsh-client-connection' + inject: [webStartup] - id: api-remotes name: '@deepseek-ai/dsh-api-remotes' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index f8cd162a11..e8240e1b63 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -11,6 +11,10 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./startup": { + "types": "./lib/types/startup.d.ts", + "default": "./lib/startup.js" + }, "./invariant": { "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" @@ -22,6 +26,7 @@ "files": [ "lib/index.js", "lib/invariant.js", + "lib/startup.js", "cordis.patch.yml", "lib/types/**/*.d.ts" ], @@ -61,6 +66,7 @@ "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", + "@deepseek-ai/dsh-cmdline": "workspace:^", "@deepseek-ai/dsh-code-runtime-worker": "workspace:^", "@deepseek-ai/dsh-frontend": "workspace:^", "@deepseek-ai/dsh-frontend-static": "workspace:^", @@ -74,15 +80,18 @@ "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-storage-json": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0", + "commander": "^15.0.0" }, "peerDependencies": { + "@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-bash-env": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-bash-env": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/bundle/web-app/src/startup.ts b/packages/bundle/web-app/src/startup.ts new file mode 100644 index 0000000000..1b1969b0d6 --- /dev/null +++ b/packages/bundle/web-app/src/startup.ts @@ -0,0 +1,152 @@ +/** + * The web app's startup row: it owns the `dsh --profile web` flag family + * (`--host`, `--port`, `--dev`, `--workspace-root`, `--trusted-host`) and its + * `--help` text, turns those flags into changes on the rows that inject + * {@link WEB_STARTUP_SERVICE}, and then provides it. Until it does, no web row + * starts, so `dsh --profile web --help` prints this command's help and the + * server never binds. + * @module @deepseek-ai/dsh-web-app/startup + */ + +import { networkInterfaces } from 'node:os' +import { Command } from 'commander' +import type { Context } from 'cordis' +import type { EntryOptions } from '@cordisjs/plugin-loader' +import { overrideConfig, runStartup, type RowChange } from '@deepseek-ai/dsh-cmdline' + +/** Stable Cordis plugin name. */ +export const name = 'web-startup' + +/** Services required before the flags can be resolved. */ +export const inject = ['cmdlineArgs'] + +/** + * The startup service every flag-configured web row injects. The rows are + * listed in this bundle's `cordis.patch.yml`; a row this startup plans changes + * for without injecting the service fails loud. + */ +export const WEB_STARTUP_SERVICE = 'webStartup' + +/** The webserver schema's all-interfaces bind literal: only this bind derives LAN authorities. */ +const ALL_INTERFACES_HOST = '0.0.0.0' + +/** + * Non-internal IPv4 interface addresses of this machine — the IP-literal + * authorities an all-interfaces bind is reachable by on the LAN. + * @returns the addresses in interface order (possibly empty). + */ +function lanIPv4Addresses(): string[] { + return Object.values(networkInterfaces()).flat() + .filter((iface): iface is NonNullable => iface !== undefined && iface.family === 'IPv4' && !iface.internal) + .map(iface => iface.address) +} + +/** + * One LAN-trust resolution for one invocation, sampled exactly once: the + * machine's LAN IP literals when the effective bind is all-interfaces, and the + * `trustedHosts` value built from them plus the explicit extras. The single + * sample is deliberate — display must advertise only addresses the fence was + * configured with, so the `web-runtime` row receives this same snapshot. + * Derived entries are port-less IP literals: DNS rebinding needs an + * attacker-controlled name, so an IP-literal Host is safe on any port, and the + * bound port may be OS-assigned, unknowable before the server binds. + * @param bindHost - the effective webserver bind host (the flag, else the composed row value). + * @param extra - `--trusted-host` values, in argv order. + * @returns the sampled LAN addresses and the connection row's `trustedHosts` value (each possibly empty). + */ +export function resolveLanTrust( + bindHost: string | undefined, + extra: readonly string[], +): { lanAddresses: string[]; trustedHosts: string[] } { + const lanAddresses = bindHost === ALL_INTERFACES_HOST ? lanIPv4Addresses() : [] + return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] } +} + +/** The web flag family, as commander parsed it. */ +interface WebOptions { + host?: string + port?: string + dev?: boolean + workspaceRoot?: string + trustedHost?: string[] +} + +/** + * This app's command: its flags, its description, and its help text. + * @returns a fresh program, so one process can parse more than once (tests). + */ +function webCommand(): Command { + return new Command() + .name('dsh --profile web') + .description('Serve the DeepSeek Harness browser UI.') + .helpOption('-h, --help', 'show this help') + .option('--host ', 'bind host; pass 0.0.0.0 to reach it from another machine') + .option('--port ', 'listen port; pass 0 to let the OS pick a free one') + .option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)') + .option('--workspace-root ', 'parent directory for workspaces created from the browser UI') + .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') + .addHelpText('after', ` +Examples: + dsh web serve on the composed host and port + dsh web --port 8080 serve on another port + dsh web --host 0.0.0.0 reach it from another machine on the LAN + dsh web --dev mount the client-plugin HMR receiver +`) +} + +/** + * Turn the parsed flags into the changes each waiting row needs. + * @param program - the parsed web command. + * @param rows - the waiting rows' composed options, in tree order. + * @returns row id → changes; rows absent from the map start on their composed values. + */ +function planWebStartup(program: Command, rows: readonly EntryOptions[]): Map { + const options = program.opts() + if (options.port !== undefined && !/^\d+$/.test(options.port)) { + program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`) + } + const row = (id: string): EntryOptions => { + const found = rows.find(candidate => candidate.id === id) + if (found === undefined) throw new Error(`web-startup: the web composition has no waiting "${id}" row to configure`) + return found + } + const plan = new Map() + const webserver = row('webserver') + const composedHost = (webserver.config as { host?: string } | undefined)?.host + plan.set('webserver', overrideConfig(webserver, { + ...options.host !== undefined && { host: options.host }, + ...options.port !== undefined && { port: Number(options.port) }, + })) + if (options.workspaceRoot !== undefined) { + plan.set('api-gateway', overrideConfig(row('api-gateway'), { workspaceRoot: options.workspaceRoot })) + } + const { lanAddresses, trustedHosts } = resolveLanTrust(options.host ?? composedHost, options.trustedHost ?? []) + if (trustedHosts.length > 0) { + // Additive over the composed value: a cordis.patch.yml-configured fence + // authority must survive the derived LAN literals and the flag extras — + // dropping it silently would weaken security-relevant configuration. + const connection = row('connection') + const composedTrusted = (connection.config as { trustedHosts?: string[] } | undefined)?.trustedHosts ?? [] + plan.set('connection', overrideConfig(connection, { trustedHosts: [...composedTrusted, ...trustedHosts] })) + } + // mode and lanAddresses are resolved on every boot, never pass-throughs of + // composed values: they describe this invocation, not the deployment. + plan.set('web-runtime', overrideConfig(row('web-runtime'), { + mode: options.dev === true ? 'development' : 'production', + lanAddresses, + })) + // The receiver ships disabled so `--dev` is a row toggle rather than a + // runtime insert (the Loader cannot resolve a row inserted from inside a + // mounting plugin). + if (options.dev === true) plan.set('client-hmr', { disabled: false }) + return plan +} + +/** + * Resolve the web flag family and start the rows waiting for it. + * @param ctx - plugin context carrying the command line and the Loader. + * @returns nothing once the waiting rows are released, or once `--help` requested exit. + */ +export function apply(ctx: Context): Promise { + return runStartup(ctx, WEB_STARTUP_SERVICE, webCommand(), planWebStartup) +} diff --git a/packages/bundle/web-app/tests/startup.spec.ts b/packages/bundle/web-app/tests/startup.spec.ts new file mode 100644 index 0000000000..c3cdfa08dc --- /dev/null +++ b/packages/bundle/web-app/tests/startup.spec.ts @@ -0,0 +1,163 @@ +/** + * The web app's startup row over a REAL Loader tree carrying this bundle's + * waiting row ids: flags reach the rows they configure, absent flags leave the + * composed values standing, `--dev` enables the shipped-disabled HMR receiver, + * and `--help` leaves the app unstarted. + */ + +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import { internals, provideCmdline } from '@deepseek-ai/dsh-cmdline' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { apply, WEB_STARTUP_SERVICE } from '../src/startup.ts' + +vi.mock('node:os', async importOriginal => ({ + ...await importOriginal(), + networkInterfaces: () => ({ + lo0: [{ family: 'IPv4', internal: true, address: '127.0.0.1' }], + en0: [{ family: 'IPv4', internal: false, address: '192.168.1.5' }], + }), +})) + +/** What one boot of the fixture tree observed. */ +interface Observed { + /** Config each waiting row started with, by row id; absent means it never started. */ + started: Record> + exits: number[] + out: string +} + +const disposers: (() => Promise)[] = [] + +afterEach(async () => { + for (const dispose of disposers.splice(0)) await dispose() + internals.stdout = process.stdout + internals.stderr = process.stderr +}) + +/** One stand-in for a row this bundle's patch makes wait for the web startup. */ +interface WaitingRow { + id: string + config?: Record + disabled?: boolean +} + +/** The waiting rows this bundle's patch declares, with the composed values they ship. */ +const WAITING_ROWS: WaitingRow[] = [ + { id: 'webserver', config: { host: '127.0.0.1', port: 3080 } }, + { id: 'api-gateway', config: { provider: 'deepseek-official' } }, + { id: 'connection', config: { trustedHosts: ['configured.internal'] } }, + { id: 'web-runtime', config: { mode: 'production', printUrl: true } }, + { id: 'client-hmr', disabled: true }, +] + +/** + * Boot the real startup row over stand-ins for this bundle's waiting rows. + * @param args - the invocation's inner arguments. + * @returns what the boot observed. + */ +async function bootStartup(args: string[], rows: readonly WaitingRow[] = WAITING_ROWS): Promise { + const dir = mkdtempSync(join(tmpdir(), 'dsh-web-startup-')) + const observed: Observed = { started: {}, exits: [], out: '' } + writeFileSync(join(dir, 'row.mjs'), ` +export function apply(ctx, config) { globalThis.__webStartupObserved.started[ctx.fiber.entry.options.id] = config ?? {} } +`) + // 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, 'startup-row.mjs'), ` +export const name = 'web-startup' +export const inject = ['cmdlineArgs'] +export const apply = ctx => globalThis.__webStartupApply(ctx) +`) + const rowUrl = pathToFileURL(join(dir, 'row.mjs')).href + const lines = rows.flatMap(row => [ + `- id: ${row.id}`, + ` name: ${rowUrl}`, + ` inject: [${WEB_STARTUP_SERVICE}]`, + ...row.disabled === true ? [' disabled: true'] : [], + ...row.config === undefined ? [] : [' config:', ...Object.entries(row.config).map(([key, value]) => ` ${key}: ${JSON.stringify(value)}`)], + ]) + lines.push('- id: web-startup', ` name: ${pathToFileURL(join(dir, 'startup-row.mjs')).href}`) + writeFileSync(join(dir, 'cordis.yml'), lines.join('\n') + '\n') + const observing = { write: (chunk: string) => { observed.out += chunk; return true } } + internals.stdout = observing + internals.stderr = observing + const globals = globalThis as unknown as { __webStartupObserved: Observed; __webStartupApply: typeof apply } + globals.__webStartupObserved = observed + globals.__webStartupApply = apply + + const ctx = new Context() + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + provideCmdline(ctx, { args, exit: code => void observed.exits.push(code) }) + await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(join(dir, 'cordis.yml')).href } }) + await ctx.loader.await() + disposers.push(async () => { await ctx.fiber.dispose() }) + return observed +} + +describe('web startup', () => { + it('applies each flag to the row that owns it and leaves the rest composed', async () => { + const observed = await bootStartup(['--port', '8080', '--workspace-root', '/w']) + expect(observed.started.webserver).toEqual({ host: '127.0.0.1', port: 8080 }) + expect(observed.started['api-gateway']).toEqual({ provider: 'deepseek-official', workspaceRoot: '/w' }) + expect(observed.started['web-runtime']).toEqual({ mode: 'production', printUrl: true, lanAddresses: [] }) + expect(observed.started['client-hmr']).toBeUndefined() + expect(observed.exits).toEqual([]) + }) + + it('starts every row on its composed values when the invocation carries no flags', async () => { + const observed = await bootStartup([]) + expect(observed.started.webserver).toEqual({ host: '127.0.0.1', port: 3080 }) + expect(observed.started.connection).toEqual({ trustedHosts: ['configured.internal'] }) + }) + + it('adds the LAN literals over the configured fence authorities for an all-interfaces bind', async () => { + const observed = await bootStartup(['--host', '0.0.0.0', '--trusted-host', 'lab.internal']) + expect(observed.started.webserver).toEqual({ host: '0.0.0.0', port: 3080 }) + expect(observed.started.connection).toEqual({ trustedHosts: ['configured.internal', '192.168.1.5', 'lab.internal'] }) + // Display gets the same single sample the fence was configured with. + expect(observed.started['web-runtime']).toEqual({ mode: 'production', printUrl: true, lanAddresses: ['192.168.1.5'] }) + }) + + it('enables the shipped-disabled HMR receiver for --dev', async () => { + const observed = await bootStartup(['--dev']) + expect(observed.started['client-hmr']).toEqual({}) + expect(observed.started['web-runtime']).toEqual({ mode: 'development', printUrl: true, lanAddresses: [] }) + }) + + it('prints its own help and starts nothing', async () => { + const observed = await bootStartup(['--help']) + expect(observed.out).toContain('dsh --profile web') + expect(observed.out).toContain('--trusted-host') + expect(observed.started).toEqual({}) + expect(observed.exits).toEqual([0]) + }) + + it('fails the boot when the composition lost a row this app configures', async () => { + // The bundle patch and this startup plugin must agree on the row set; a + // missing row would otherwise silently drop the flag that targets it. + const withoutWebserver = WAITING_ROWS.filter(row => row.id !== 'webserver') + await expect(bootStartup([], withoutWebserver)) + .rejects.toThrow('the web composition has no waiting "webserver" row') + }) + + it('derives the fence authorities alone when the composition configured none', async () => { + const withoutTrust = WAITING_ROWS.map(row => row.id === 'connection' ? { id: 'connection' } : row) + const observed = await bootStartup(['--host', '0.0.0.0'], withoutTrust) + expect(observed.started.connection).toEqual({ trustedHosts: ['192.168.1.5'] }) + }) + + it('rejects a non-numeric port before anything binds', async () => { + const observed = await bootStartup(['--port', 'abc']) + expect(observed.out).toContain('--port must be a number') + expect(observed.started).toEqual({}) + expect(observed.exits).toEqual([1]) + }) +}) diff --git a/packages/bundle/web-app/tests/trusted-hosts.spec.ts b/packages/bundle/web-app/tests/trusted-hosts.spec.ts new file mode 100644 index 0000000000..110aaeae61 --- /dev/null +++ b/packages/bundle/web-app/tests/trusted-hosts.spec.ts @@ -0,0 +1,33 @@ +/** Single-sample LAN-trust resolution for the /api browser-trust fence (`resolveLanTrust`). */ + +import { describe, expect, it, vi } from 'vitest' +import { resolveLanTrust } from '../src/startup.ts' + +vi.mock('node:os', () => ({ + networkInterfaces: () => ({ + lo0: [ + { family: 'IPv4', internal: true, address: '127.0.0.1' }, + ], + en0: [ + { family: 'IPv6', internal: false, address: 'fe80::1' }, + { family: 'IPv4', internal: false, address: '192.168.1.5' }, + ], + en1: [ + { family: 'IPv4', internal: false, address: '10.0.0.7' }, + ], + utun0: undefined, + }), +})) + +describe('resolveLanTrust', () => { + it('samples non-internal IPv4 addresses once for an all-interfaces bind: trust and display share them', () => { + const { lanAddresses, trustedHosts } = resolveLanTrust('0.0.0.0', ['harness.internal:3080']) + expect(lanAddresses).toEqual(['192.168.1.5', '10.0.0.7']) + expect(trustedHosts).toEqual(['192.168.1.5', '10.0.0.7', 'harness.internal:3080']) + }) + + it('derives nothing for a loopback or unresolved bind — extras alone stand, no LAN URL to print', () => { + expect(resolveLanTrust('127.0.0.1', [])).toEqual({ lanAddresses: [], trustedHosts: [] }) + expect(resolveLanTrust(undefined, ['lab.internal'])).toEqual({ lanAddresses: [], trustedHosts: ['lab.internal'] }) + }) +}) diff --git a/packages/bundle/web-app/tsconfig.json b/packages/bundle/web-app/tsconfig.json index 6aadb534cb..b15ebb1664 100644 --- a/packages/bundle/web-app/tsconfig.json +++ b/packages/bundle/web-app/tsconfig.json @@ -14,6 +14,12 @@ { "path": "../../../vendor/schemastery" }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../ui/cmdline" + }, { "path": "../../host/frontend-static" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d890741ac..a05f336f3e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -356,6 +356,9 @@ importers: '@deepseek-ai/dsh-client-web-react': specifier: workspace:^ version: link:../../packages/client/web-react + '@deepseek-ai/dsh-cmdline': + specifier: workspace:^ + version: link:../../packages/boot/cmdline '@deepseek-ai/dsh-pwsh-local': specifier: workspace:^ version: link:../../packages/bash/pwsh-local @@ -1181,18 +1184,18 @@ importers: specifier: ^15.0.0 version: 15.0.0 devDependencies: - '@cordisjs/plugin-include': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ version: link:../../../vendor/include - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/bundle/base: dependencies: @@ -1443,12 +1446,18 @@ importers: packages/bundle/headless: dependencies: + '@deepseek-ai/dsh-cmdline': + specifier: workspace:^ + version: link:../../boot/cmdline '@deepseek-ai/dsh-code-runtime-worker': specifier: workspace:^ version: link:../../code-runtime/code-runtime-worker '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + commander: + specifier: ^15.0.0 + version: 15.0.0 devDependencies: '@deepseek-ai/cordis': specifier: ^4.0.0-rc.7 @@ -1471,6 +1480,9 @@ 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: @@ -1558,6 +1570,9 @@ importers: '@deepseek-ai/dsh-client-ui-workspace': specifier: workspace:^ version: link:../../client/ui-workspace + '@deepseek-ai/dsh-cmdline': + specifier: workspace:^ + version: link:../../boot/cmdline '@deepseek-ai/dsh-code-runtime-worker': specifier: workspace:^ version: link:../../code-runtime/code-runtime-worker @@ -1600,10 +1615,16 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + commander: + specifier: ^15.0.0 + version: 15.0.0 devDependencies: '@deepseek-ai/cordis': specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader '@deepseek-ai/dsh-bash-env': specifier: workspace:^ version: link:../../bash/bash-env From 37cbd155f5386286fb9ae7b93798de639b4f54ec Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 6 Aug 2026 20:52:26 +0800 Subject: [PATCH 03/19] refactor(cli)!: the launcher parses only its own flags Launcher flags come first and end at the first token dsh does not recognize; everything after reaches the booted app verbatim, so dsh --profile tui --resume works with no launcher change and dsh --profile web --help prints the web app's help. A bare dsh -h, which has no app to hand the flag to, still prints the launcher's own. src/web.ts is deleted: the Web flag family, its LAN-trust sampling, and the one-shot task positional now live in their bundles, and runProfile no longer knows any row id. What the startup row decides comes back as a launcher-owned patch layer above every layer a user can edit, so a live config edit recomposes the tree without resetting a served port. dsh web and dsh --profile web finally boot through one path, which also gives --profile web the harness-source prompt section that only the alias used to add. --- apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 20 +- apps/cli/README.zh.md | 22 ++- apps/cli/package.json | 1 + apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 34 ++-- apps/cli/reference/README.zh.md | 34 ++-- apps/cli/src/args.ts | 228 +++++++++-------------- apps/cli/src/bin.ts | 20 +- apps/cli/src/profile-boot.ts | 148 +++++++-------- apps/cli/src/web.ts | 144 --------------- apps/cli/tests/args.spec.ts | 63 ++++--- apps/cli/tests/built-bin.e2e.ts | 267 +++++++++++++++++++++++---- apps/cli/tests/trusted-hosts.spec.ts | 45 ----- apps/cli/tsconfig.json | 3 + pnpm-lock.yaml | 3 + 16 files changed, 518 insertions(+), 522 deletions(-) delete mode 100644 apps/cli/src/web.ts delete mode 100644 apps/cli/tests/trusted-hosts.spec.ts diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 101332555d..b4c933291e 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/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 apps/cli/README.md -README.md: dd29f7fc03a783079ea3194de99589c1f545be5b -README.zh.md: 60e7aa1ec1ea2fad7e3f3d97a0f6bf42355adffc +README.md: 86d890ebec7121a9f8431f52789b8346ba59deb2 +README.zh.md: 80b9a6d56bdb49f72d25f7485662a6814f5184a3 diff --git a/apps/cli/README.md b/apps/cli/README.md index dd29f7fc03..86d890ebec 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -9,15 +9,27 @@ The `dsh` command is the product launcher for profiles: ordered stacks of plugin | Command | Purpose | |---|---| | `dsh --profile ` | Boot the named profile under `$DSH_HOME/profiles/`. | -| `dsh run [--profile ] [--patch ...] "task"` | Run one fresh persisted session directly over core, print the final answer, and exit; the profile defaults to `headless` and mounts no Web server. | -| `dsh web` | Alias of `--profile web` with the Web flag family (`--host`, `--port`, `--dev`, ...). | +| `dsh --profile headless "task"` | Run one fresh persisted session, print the final answer, and exit. | +| `dsh web` | Alias of `--profile web`. | | `dsh plugin --profile ` | Manage a profile's plugins by forwarding to pnpm in the profile directory. | -The invoking directory is the default workspace root. `dsh run` requires non-blank task text and the selected profile must mount the `headless-runner` row; `--profile` preserves custom one-shot profiles. The `web` and `headless` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`. +The invoking directory is the default workspace root. The `web` and `headless` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`. + +## App arguments + +The launcher parses only its own flags and hands everything after them to the booted profile, where that app's own startup row parses them ([`dsh-cmdline`](../../packages/ui/cmdline/README.md)). Launcher flags therefore come first, and the first token the launcher does not recognize starts the app's arguments: + +```sh +dsh --profile web --port 8080 # --port belongs to the web app +dsh --profile tui --resume # --resume belongs to the terminal app +dsh --profile headless "run the tests" +dsh --profile web --help # the web app's flags, not the launcher's +dsh --help # the launcher's own help +``` ## Profiles -A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml` (the user's own patch layer, hot-reloaded on long-lived surfaces). The tree composes over an empty root: each bundle's patch in `dsh.profile.bundles` order, then the profile's `cordis.patch.yml`, then the home-level `$DSH_HOME/cordis.patch.yml`, then `--patch` overlays, then flag patches. Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins. Use `--dump-default-config` and `--dump-config` to inspect the composed tree without booting it. +A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml` (the user's own patch layer, hot-reloaded on long-lived surfaces). The tree composes over an empty root: each bundle's patch in `dsh.profile.bundles` order, then the profile's `cordis.patch.yml`, then the home-level `$DSH_HOME/cordis.patch.yml`, then `--patch` overlays. Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins. Use `--dump-default-config` and `--dump-config` to inspect the composed tree without booting it. The [CLI behavior reference](reference/README.md) owns exact layer precedence, flags, shutdown behavior, deployment defaults, and the source launcher. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 60e7aa1ec1..80b9a6d56b 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -9,18 +9,30 @@ | 命令 | 用途 | |---|---| | `dsh --profile ` | 启动位于 `$DSH_HOME/profiles/` 的指定 profile。 | -| `dsh run [--profile ] [--patch ...] "task"` | 直接在 core 上运行一个新的持久化会话,打印最终答案并退出;profile 默认为 `headless`,且不挂载 Web server。 | -| `dsh web` | `--profile web` 的别名,附带 Web flag 系列(`--host`、`--port`、`--dev` 等)。 | +| `dsh --profile headless "task"` | 运行一个新的持久化会话,打印最终答案并退出。 | +| `dsh web` | `--profile web` 的别名。 | | `dsh plugin --profile ` | 通过在 profile 目录中转发给 pnpm 来管理该 profile 的插件。 | -调用目录是默认 workspace 根目录。`dsh run` 要求任务文本非空白,且所选 profile 必须挂载 `headless-runner` 行;`--profile` 保留对自定义一次性 profile 的支持。`web` 和 `headless` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。 +调用目录是默认 workspace 根目录。`web` 和 `headless` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。 + +## 应用参数 + +启动器只解析属于自己的 flag,并把其后的一切交给启动起来的 profile,由该应用自己的启动行解析([`dsh-cmdline`](../../packages/ui/cmdline/README.md))。因此启动器的 flag 必须写在前面,而启动器不认识的第一个 token 就是应用参数的起点: + +```sh +dsh --profile web --port 8080 # --port belongs to the web app +dsh --profile tui --resume # --resume belongs to the terminal app +dsh --profile headless "run the tests" +dsh --profile web --help # the web app's flags, not the launcher's +dsh --help # the launcher's own help +``` ## Profile -profile 目录包含一个 `package.json`(树外插件依赖,加上 profile manifest(元数据清单)`dsh.profile` 及其有序的 `bundles` 列表)和一个 `cordis.patch.yml`(用户自己的 patch 层,在长期运行的 surface 上热重载)。配置树在空根之上组合:先按 `dsh.profile.bundles` 顺序应用各组合包的 patch,然后是 profile 的 `cordis.patch.yml`,然后是 home 级的 `$DSH_HOME/cordis.patch.yml`,然后是 `--patch` overlay,最后是 flag patch。`dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`),再从 profile 自己的 `node_modules` 解析;pnpm 把树外插件安装在后者。使用 `--dump-default-config` 和 `--dump-config` 可在不启动的情况下检查组合后的配置树。 +profile 目录包含一个 `package.json`(树外插件依赖,加上 profile manifest(元数据清单)`dsh.profile` 及其有序的 `bundles` 列表)和一个 `cordis.patch.yml`(用户自己的 patch 层,在长期运行的 surface 上热重载)。配置树在空根之上组合:先按 `dsh.profile.bundles` 顺序应用各组合包的 patch,然后是 profile 的 `cordis.patch.yml`,然后是 home 级的 `$DSH_HOME/cordis.patch.yml`,然后是 `--patch` overlay。`dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`),再从 profile 自己的 `node_modules` 解析;pnpm 把树外插件安装在后者。使用 `--dump-default-config` 和 `--dump-config` 可在不启动的情况下检查组合后的配置树。 [CLI(命令行界面)行为参考](reference/README.md)负责确切的层优先级、flag、关闭行为、部署默认值和源码启动器。 ## 开发 -生产运行需要已构建的包与前端产物。在 checkout 中,`pnpm run dsh` 会运行 TypeScript 入口并转发参数;[源码启动器参考](reference/README.md#source-launcher)说明 PATH 符号链接和模块解析约定。 +生产运行需要已构建的包与前端产物。在 checkout 中,`pnpm run dsh` 会运行 TypeScript 入口并转发参数;[源码启动器参考](reference/README.md#source-launcher)说明 PATH 符号链接和模块解析契约。 diff --git a/apps/cli/package.json b/apps/cli/package.json index a2695f59c9..4442006b0a 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -27,6 +27,7 @@ "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-session": "workspace:^", + "@deepseek-ai/dsh-cmdline": "workspace:^", "@deepseek-ai/dsh-headless": "workspace:^", "@deepseek-ai/dsh-mcp-client": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index ba0fd57bc6..9916a9eeb3 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/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 apps/cli/reference/README.md -README.md: 756fed1f1802600e82948ce8ca808706b2299660 -README.zh.md: edd20c7fc3a3103097aa5e3949418e373172cadb +README.md: 13c0d000eec045cc34f2b7eb5fe5ba9ac9ed557e +README.zh.md: 5392db3220013a50040bf59f212040e8d0291037 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 756fed1f18..13c0d000ee 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -2,17 +2,32 @@ English | [中文](README.zh.md) -This reference defines the profile, one-shot run, web-alias, plugin-management, and config-dump command modes. Argv is parsed once through [`src/args.ts`](../src/args.ts), and [`src/bin.ts`](../src/bin.ts) dynamically imports only the selected runner. +This reference defines the profile, web-alias, plugin-management, and config-dump command modes. Argv is parsed once through [`src/args.ts`](../src/args.ts), and [`src/bin.ts`](../src/bin.ts) dynamically imports only the selected runner. ## Profile boot -`dsh --profile ` boots the profile at `$DSH_HOME/profiles/`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), each `--patch ` overlay in argv order, and launcher flag patches. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit. +`dsh --profile ` boots the profile at `$DSH_HOME/profiles/`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), and each `--patch ` overlay in argv order. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit. Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. A bare plugin `name` in any patch row resolves through the profile directory's Node parent-walk, which reaches the maintained installation fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch). -The `web` and `headless` profiles auto-initialize from shipped templates on first use (`web`: base + web-app; `headless`: base + headless). On load, the exact installation-owned headless tuple (base + web-app + headless) normalizes to the shipped template; extra, missing, or reordered bundle lists are user-owned and remain untouched. Any other missing profile fails loud with a hint to run `dsh plugin --profile add `. +The `web` and `headless` profiles auto-initialize from shipped templates on first use (`web`: base + web-app; `headless`: base + headless). Any other missing profile fails loud with a hint to run `dsh plugin --profile add `. -Profile boot accepts no positional task. A profile that mounts the one-shot runner row (`headless-runner`) therefore fails loud with the canonical `dsh run --profile ""` command instead of reaching the row's raw required-field error. +### App arguments + +The launcher's flags come first and end at the first token it does not recognize; everything from there on is handed to the booted profile verbatim through `ctx.cmdlineArgs`, where that app's own startup row parses it ([`dsh-cmdline`](../../../packages/ui/cmdline/README.md)). `dsh --profile web --port 8080` therefore reaches the web app's `--port`, `dsh --profile web --help` prints that app's help and boots nothing, and `dsh --help` (no profile to hand it to) prints the launcher's own. `-V`/`--version` prints the launcher's version when it appears before the app-argument boundary. + +A composition mounts once. A Loader row that injects `cmdlineArgs` parses this app's arguments and provides what it resolved as a service; each row configured from flags injects that service, and Loader waits for it before evaluating the row's config (`port: !!js ctx.webStartup.port ?? 3080`). A flag therefore beats the value written beside it. This precedence requires the row to retain that expression; a user patch that replaces the whole `config` with literals removes the runtime read. Help and rejected arguments request exit — nonzero for a rejection, 0 for help — without activating rows that depend on the startup service. A live `cordis.patch.yml` edit re-evaluates expressions against services that are still up, so it cannot reset a served port. + +Launcher flags must come before app arguments, and the launcher's parser consumes one `--`: an app argument that must arrive as a literal `--` needs `-- --`. A first app argument equal to `web` or `plugin` selects that subcommand instead. A profile with no active row injecting `cmdlineArgs` accepts no app arguments; it rejects them before mounting any row instead of silently ignoring them. + +The shipped apps own these command lines: + +| Profile | Arguments | +|---|---| +| `web` | `--host`, `--port`, `--dev`, `--workspace-root`, repeatable `--trusted-host` | +| `headless` | the task text, as the positional argument | + +A one-shot task (`dsh --profile headless "run the tests"`) creates one fresh persisted Agent through the core registry, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It prints the text on stdout and exits 0 for `completed`, else 1. An invocation with no task is a usage error from that app. The shipped headless profile mounts no ApiProxy, Host, HTTP server, Web runtime, or browser client; a successful run writes nothing to stderr and opens no listening port. Inspect the composed tree without booting it: @@ -21,13 +36,7 @@ dsh --profile web --dump-default-config dsh --profile web --patch ./extra.yml --dump-config ``` -`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr. - -## One-shot run - -`dsh run [--profile ] [--patch ...] ` joins the task arguments with spaces, rejects a missing or blank task, and defaults `--profile` to `headless`. Repeatable `--patch` overlays occupy the same layer position as profile-boot overlays. A custom selected profile must mount `headless-runner`; otherwise launch fails before boot with a diagnostic naming that missing row. - -The launcher patches the task text into the runner row. After Loader settlement, the runner reads the shared `ctx.agentDefaultModel` default, creates one fresh persisted Agent through `ctx.agents`, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It prints the text on stdout and exits 0 for `completed`, else 1. The shipped headless profile mounts no ApiProxy, Host, HTTP server, Web runtime, or browser client; a successful run writes nothing to stderr and opens no listening port. +`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr. A dump never runs an app's startup row, so it shows the composed tree before any app argument is resolved and rejects an invocation that carries app arguments. ## Plugin management @@ -43,12 +52,13 @@ Git-hosted plugins that ship sources build during install through their `prepare ## Web alias -`dsh web` is a hardcoded alias for `--profile web` that additionally accepts the Web flag family. `--host`, `--port`, and repeatable `--trusted-host` values become patches over the composed rows; their owning plugin schemas validate them at boot. `--dev` switches the web-runtime row to development mode and inserts the client-plugin HMR receiver; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. +`dsh web` is a hardcoded alias for `--profile web`; the flags after it belong to the web app, which owns them in its bundle's startup row. `--host`, `--port`, and `--workspace-root` override the composed values of the rows that carry them, repeatable `--trusted-host` adds authorities over the composed fence configuration, and `--dev` switches the web-runtime row to development mode and enables the client-plugin HMR receiver the bundle ships disabled; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. ```sh dsh web dsh web --patch ./extra.cordis.yml dsh web --dump-config +dsh web --help ``` The production Web runner needs built package and frontend artifacts (`pnpm run build`). It serves `http://127.0.0.1:3080` by default. Binding all interfaces also trusts the machine's discovered LAN IP literals; `--trusted-host` adds named authorities accepted by the `/api` browser-trust fence. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index edd20c7fc3..5392db3220 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -2,17 +2,32 @@ [English](README.md) | 中文 -本参考定义 profile、一次性运行、web 别名、插件管理和配置 dump 命令模式。参数由 [`src/args.ts`](../src/args.ts) 统一解析,[`src/bin.ts`](../src/bin.ts) 只动态导入选中的运行器。 +本参考定义 profile、web 别名、插件管理和配置 dump 命令模式。参数由 [`src/args.ts`](../src/args.ts) 统一解析,[`src/bin.ts`](../src/bin.ts) 只动态导入选中的运行器。 ## Profile 启动 -`dsh --profile ` 启动位于 `$DSH_HOME/profiles/` 的 profile。生效配置树在空根节点之上按以下顺序逐层组合:profile manifest(元数据清单)的 `dsh.profile.bundles` 列表所列的各个组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(各 profile 共享的机器本地偏好,因此优先级高于逐 profile 的层)、按 argv 顺序的各个 `--patch ` overlay,以及启动器 flag patch。后应用的层按行胜出;patch 替换目标行完整的 `config` 值,而不是深度合并各键,并且可以插入新行。配置解析、schema 校验、模块解析或插件启动失败会得到报告并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。 +`dsh --profile ` 启动位于 `$DSH_HOME/profiles/` 的 profile。生效配置树在空根节点之上按以下顺序逐层组合:profile manifest(元数据清单)的 `dsh.profile.bundles` 列表所列的各个组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(各 profile 共享的机器本地偏好,因此优先级高于逐 profile 的层)、以及按 argv 顺序的各个 `--patch ` overlay。后应用的层按行胜出;patch 替换目标行完整的 `config` 值,而不是深度合并各键,并且可以插入新行。配置解析、schema 校验、模块解析或插件启动失败会得到报告并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。 组合包名称先从 dsh 安装解析,再从 profile 目录解析。因此内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`)总是来自与正在运行的 `dsh` 相同的安装;树外组合包来自 profile 由 pnpm 管理的 `node_modules`。任何 patch 行中的裸插件 `name` 通过 profile 目录的 Node 父目录逐级查找解析,该查找可达到持续维护的安装后备目录 `$DSH_HOME/profiles/node_modules`(安装的应用和组合包所依赖的每个包对应一个符号链接,每次启动时修复)。 -`web` 和 `headless` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app;`headless`:base + headless)。加载时,与安装所管理的 headless 元组(base + web-app + headless)完全一致的列表会规范化为随附模板;包含额外项、缺少项或调整过顺序的组合包列表由用户拥有,保持不变。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile add `。 +`web` 和 `headless` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app;`headless`:base + headless)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile add `。 -Profile 启动不接受位置参数任务。因此,挂载了一次性运行器行(`headless-runner`)的 profile 会显式报错,并提示规范命令 `dsh run --profile ""`,而不会触发该行原始的必填字段错误。 +### 应用参数 + +启动器自己的 flag 写在最前面,并在它不认识的第一个 token 处结束;从那里开始的一切都通过 `ctx.cmdlineArgs` 原样交给启动起来的 profile,由该应用自己的启动行解析([`dsh-cmdline`](../../../packages/ui/cmdline/README.md))。因此 `dsh --profile web --port 8080` 到达的是 web 应用的 `--port`,`dsh --profile web --help` 打印的是该应用的 help 且什么也不启动,而 `dsh --help`(没有可以交付的 profile)打印的是启动器自己的 help。`-V`/`--version` 写在应用参数边界之前时会打印启动器的版本。 + +一套组合只挂载一次。注入 `cmdlineArgs` 的 Loader 行解析本应用的参数,并把结果作为服务提供出去;由 flag 配置的每一行都会注入该服务,Loader 会等服务激活后再求值该行配置(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值。该优先级要求配置行保留这一表达式;若用户 patch 用字面量替换整份 `config`,运行时读取也会随之消失。help 和被拒绝的参数会请求退出——拒绝时以非零状态,help 时以 0——且不会激活依赖启动服务的行。在线编辑 `cordis.patch.yml` 会针对仍然在线的服务重新求值表达式,因此不会重置已在服务的端口。 + +启动器的 flag 必须写在应用参数之前,且启动器的解析器会消耗掉一个 `--`:必须以字面量 `--` 送达应用的参数需要写成 `-- --`。如果应用的第一个参数恰好等于 `web` 或 `plugin`,会选择对应的子命令。若 profile 中没有注入 `cmdlineArgs` 的活跃行,该 profile 不接受应用参数;启动器会在挂载任何行之前拒绝这些参数,而不是静默忽略。 + +随附的各应用持有这些命令行: + +| Profile | 参数 | +|---|---| +| `web` | `--host`、`--port`、`--dev`、`--workspace-root`、可重复的 `--trusted-host` | +| `headless` | 任务文本,作为位置参数 | + +一次性任务(`dsh --profile headless "run the tests"`)通过核心注册表创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对 Session 执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 stdout 打印文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出。没有任务的调用是该应用的用法错误。随附 headless profile 不挂载 ApiProxy、Host、HTTP 服务器、Web 运行时或浏览器客户端;成功运行不会向 stderr 写入任何内容,也不会打开监听端口。 可在不启动的情况下检查组合出的配置树: @@ -21,13 +36,7 @@ dsh --profile web --dump-default-config dsh --profile web --patch ./extra.yml --dump-config ``` -`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。 - -## 一次性运行 - -`dsh run [--profile ] [--patch ...] ` 会用空格拼接任务参数,拒绝缺失或空白任务,并让 `--profile` 默认为 `headless`。可重复使用的 `--patch` overlay 与 profile 启动的 overlay 位于同一层。所选的自定义 profile 必须挂载 `headless-runner`;否则启动器会在启动前失败,并在诊断中指明缺少该行。 - -启动器把任务文本 patch 进运行器行。Loader 结算后,运行器读取共享的 `ctx.agentDefaultModel` 默认值,通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对 Session 执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 stdout 打印文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出。随附 headless profile 不挂载 ApiProxy、Host、HTTP 服务器、Web 运行时或浏览器客户端;成功运行不会向 stderr 写入任何内容,也不会打开监听端口。 +`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。dump 从不运行应用的启动行,因此它展示的是任何应用参数被解析之前的组合配置树,并拒绝携带应用参数的调用。 ## 插件管理 @@ -43,12 +52,13 @@ Git 托管、随附源码的插件在安装期间通过其 `prepare` 脚本构 ## Web 别名 -`dsh web` 是 `--profile web` 的硬编码别名,并额外接受 Web flag 系列。`--host`、`--port` 和可重复的 `--trusted-host` 值会成为作用在组合行之上的 patch;负责这些值的插件 schema 会在启动时验证它们。`--dev` 把 web-runtime 行切换到开发模式并插入客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。 +`dsh web` 是 `--profile web` 的硬编码别名;写在它之后的 flag 属于 web 应用,由该应用在其组合包的启动行中持有。`--host`、`--port` 和 `--workspace-root` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 在组合出的围栏配置之上追加 authority,`--dev` 把 web-runtime 行切换到开发模式并启用组合包以禁用状态交付的客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。 ```sh dsh web dsh web --patch ./extra.cordis.yml dsh web --dump-config +dsh web --help ``` 生产 Web 运行器需要已构建的包和前端产物(`pnpm run build`)。默认服务地址是 `http://127.0.0.1:3080`。绑定所有接口时,还会信任机器自动发现的 LAN IP 字面量;`--trusted-host` 可添加 `/api` 浏览器信任围栏接受的具名 authority。 diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 97f5222398..43d68a2c7c 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -1,31 +1,30 @@ /** - * Commander adapter for the `dsh` command-line entry. The default command - * boots a named profile (`--profile `), optionally with extra `--patch` - * overlays. `run` owns one-shot task execution, defaulting to the headless - * profile; `web` is a hardcoded alias for `--profile web` that adds the Web - * flag family; `plugin` manages a profile's plugin dependencies by forwarding - * to pnpm. Commander owns help, version, and parse errors. + * Commander adapter for the `dsh` command line. + * + * The launcher parses only what it owns — which profile to boot, which extra + * patch overlays to apply, and the config dumps — and hands **everything after + * its own flags** to the booted tree verbatim, where the booted app's startup row + * parses its own flag family and prints its own `--help` (see + * `@deepseek-ai/dsh-cmdline`). Launcher flags therefore come first: the first + * token this parser does not recognize starts the inner arguments, so + * `dsh --profile tui --resume abc` boots the tui profile with `--resume abc`, + * and `dsh --profile web -h` prints the web app's help, not this one's. + * + * `web` is a hardcoded alias for `--profile web`; `plugin` manages a profile's + * plugin dependencies by forwarding to pnpm. * @module @deepseek-ai/dsh/args */ import { Command, CommanderError } from 'commander' -/** Boot a named profile. */ +/** Boot a named profile and hand it the invocation's inner arguments. */ interface ProfileInvocation { mode: 'profile' profile: string /** Extra patch-list overlays applied after the profile's own layer, in argv order. */ patches: string[] -} - -/** Run one task through a profile mounting the headless runner. */ -interface RunInvocation { - mode: 'run' - profile: string - /** Extra patch-list overlays applied after the profile's own layer, in argv order. */ - patches: string[] - /** Non-blank task text joined from the variadic positional arguments. */ - task: string + /** Everything after the launcher's own flags, verbatim, for the booted app's startup row. */ + args: string[] } /** Print a composed profile tree and exit without booting. */ @@ -37,21 +36,6 @@ interface DumpConfigInvocation { patches: string[] } -/** - * Browser UI: `dsh web` (alias of `--profile web`). Host and port remain - * unvalidated pass-throughs to the webserver schema; absent values leave the - * shipped web bundle values intact. - */ -interface WebInvocation { - mode: 'web' - patches: string[] - host?: string - port?: number - dev: boolean - /** Extra authorities for the /api browser-trust fence. */ - trustedHosts?: string[] -} - /** Manage a profile's plugins: forward `args` to pnpm inside the profile directory. */ interface PluginInvocation { mode: 'plugin' @@ -61,31 +45,63 @@ interface PluginInvocation { } /** The resolved `dsh` invocation. Help, version, and errors exit inside {@link parseDshArgs}. */ -export type DshInvocation = ProfileInvocation | RunInvocation | DumpConfigInvocation | WebInvocation | PluginInvocation +export type DshInvocation = ProfileInvocation | DumpConfigInvocation | PluginInvocation -/** Raw web-subcommand options straight from Commander. */ -interface WebOptions { +/** Launcher flags shared by the default command and the `web` alias. */ +interface BootOptions { patch?: string[] - host?: string - port?: string - dev?: boolean - trustedHost?: string[] dumpConfig?: boolean dumpDefaultConfig?: boolean } -/** Raw run-subcommand options straight from Commander. */ -interface RunOptions { - profile: string - patch?: string[] -} - /** * Repeatable single-value collector: `--patch a.yml --patch b.yml`. Never - * variadic — a variadic `--patch` would swallow a following positional task. + * variadic — a variadic `--patch` would swallow the inner arguments. */ const collect = (value: string, previous: string[] = []): string[] => [...previous, value] +/** The launcher's own help text; each app prints its own. */ +const HELP_EXAMPLES = ` +Examples: + dsh --profile web boot the web profile (same as: dsh web) + dsh --profile headless "run the tests" answer one task, print the result, and exit + dsh --profile tui --patch ./extra.yml boot a custom profile with one extra overlay + dsh --profile tui --resume arguments after the launcher flags reach the app + dsh --profile web --help the web app's own flags and help + dsh plugin --profile tui add install a plugin into the tui profile +` + +/** + * Resolve a boot or dump invocation from the launcher flags and the leftover + * inner arguments. + * @param program - the command whose options were parsed (the root, or the `web` alias). + * @param profile - the profile these flags boot. + * @param options - the launcher flags commander collected. + * @param args - the leftover arguments, in argv order. + * @returns the resolved invocation. + */ +function resolveBoot(program: Command, profile: string, options: BootOptions, args: string[]): DshInvocation { + const patches = options.patch ?? [] + if (patches.includes('')) program.error('error: --patch needs a path') + if (options.dumpConfig !== true && options.dumpDefaultConfig !== true) { + return { mode: 'profile', profile, patches, args } + } + if (options.dumpConfig === true && options.dumpDefaultConfig === true) { + program.error('error: --dump-config and --dump-default-config are mutually exclusive') + } + // The dump is boot-free: it never runs the app's startup row, so it cannot + // show what that app's flags would decide, and printing a tree that differs + // from the same invocation's boot would mislead. + if (args.length > 0) { + program.error(`error: config dumps take no app arguments, got ${args.map(argument => JSON.stringify(argument)).join(' ')}`) + } + const defaultOnly = options.dumpDefaultConfig === true + if (defaultOnly && patches.length > 0) { + program.error('error: --dump-default-config prints the bundle layers and takes no --patch') + } + return { mode: 'dump-config', profile, defaultOnly, patches } +} + /** * Resolve argv into one invocation, or print and exit for help, version, or an * error. @@ -95,121 +111,61 @@ const collect = (value: string, previous: string[] = []): string[] => [...previo */ export function parseDshArgs(argv: readonly string[], version: string): DshInvocation { let resolved: DshInvocation | undefined - const program = new Command() + // Annotated, not inferred: the actions below call back into `program`, and an + // inferred type would be circular through its own chain. + const program: Command = new Command() + program .name('dsh') .version(version, '-V, --version', 'output the version number') .description('dsh: boot a DeepSeek Harness profile — an ordered stack of plugin-bundle patch layers under your own overrides.') - .addHelpText('after', ` -Examples: - dsh --profile web boot the web profile (same as: dsh web) - dsh run "run the tests" answer one task, print the result, and exit - dsh run --profile custom "run the tests" run one task through a custom one-shot profile - dsh --profile tui --patch ./extra.yml boot a custom profile with one extra overlay - dsh plugin --profile tui add install a plugin into the tui profile - dsh web --port 8080 the web alias with its flag family -`) + .addHelpText('after', HELP_EXAMPLES) .exitOverride() + // The launcher's flags come first and end at the first token it does not + // know; everything from there on belongs to the booted app, including + // its -h. `dsh -h` with no profile still prints this help, below. + .helpOption(false) + .allowUnknownOption() + .passThroughOptions() .enablePositionalOptions() + .argument('[args...]', 'arguments for the booted profile\'s app (see: dsh --profile --help)') .option('--profile ', 'the profile under $DSH_HOME/profiles to boot') .option('--patch ', 'extra patch-list overlay applied after the profile layer (repeatable)', collect) .option('--dump-config', 'print the composed profile tree and exit') .option('--dump-default-config', 'print the profile tree without its user layer or --patch overlays and exit') - .action((options: { - profile?: string - patch?: string[] - dumpConfig?: boolean - dumpDefaultConfig?: boolean - }) => { - const profile = options.profile ?? program.error('error: --profile is required') - if (profile === '') program.error('error: --profile needs a name') - const patches = options.patch ?? [] - if (patches.includes('')) program.error('error: --patch needs a path') - if (options.dumpConfig === true || options.dumpDefaultConfig === true) { - if (options.dumpConfig === true && options.dumpDefaultConfig === true) { - program.error('error: --dump-config and --dump-default-config are mutually exclusive') - } - const defaultOnly = options.dumpDefaultConfig === true - if (defaultOnly && patches.length > 0) { - program.error('error: --dump-default-config prints the bundle layers and takes no --patch') - } - resolved = { mode: 'dump-config', profile, defaultOnly, patches } - return + .action((args: string[], options: BootOptions & { profile?: string }) => { + // With the app owning -h, the launcher's own help is what a bare + // `dsh -h` (no profile to hand it to) must print. + if (options.profile === undefined) { + if (args.some(argument => argument === '-h' || argument === '--help')) program.help() + program.error('error: --profile is required') } - resolved = { mode: 'profile', profile, patches } + const profile = options.profile + if (profile === '') program.error('error: --profile needs a name') + resolved = resolveBoot(program, profile, options, args) }) /** Reject parent options supplied before a subcommand. */ const rejectParentOptions = (command: string): void => { - const parent = program.opts<{ - profile?: string - patch?: string[] - dumpConfig?: boolean - dumpDefaultConfig?: boolean - }>() + const parent = program.opts() if (parent.profile !== undefined || parent.patch !== undefined || parent.dumpConfig !== undefined || parent.dumpDefaultConfig !== undefined) { program.error(`error: ${command} takes none of parent --profile, --patch, --dump-config, or --dump-default-config`) } } - const run = program.command('run').description('run one task through a profile mounting the headless runner') - run - .option('--profile ', 'one-shot profile under $DSH_HOME/profiles', 'headless') - .option('--patch ', 'extra patch-list overlay applied after the profile layer (repeatable)', collect) - .argument('', 'task text') - .action((task: string[], options: RunOptions) => { - rejectParentOptions('run') - const profile = options.profile - if (profile === '') program.error('error: --profile needs a name') - const patches = options.patch ?? [] - if (patches.includes('')) program.error('error: --patch needs a path') - const joined = task.join(' ') - if (joined.trim() === '') program.error('error: run needs a non-blank task') - resolved = { mode: 'run', profile, patches, task: joined } - }) - - const web = program.command('web').description('serve the browser UI (alias of --profile web) on the configured host and port') + const web = program.command('web').description('boot the web profile (alias of --profile web); the web app\'s own flags follow') web + .helpOption(false) + .allowUnknownOption() + .passThroughOptions() + .enablePositionalOptions() + .argument('[args...]', 'arguments for the web app (see: dsh web --help)') .option('--patch ', 'extra patch-list overlay applied after the profile layer (repeatable)', collect) - .option('--host ', 'bind host; pass 0.0.0.0 to reach it from another machine') - .option('--port ', 'listen port; pass 0 to let the OS pick a free one') - .option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)') - .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') .option('--dump-config', 'print the composed web-profile tree (with the user layer and any --patch) and exit') .option('--dump-default-config', 'print the web profile\'s bundle layers (no user layer) and exit') - .action((options: WebOptions) => { + .action((args: string[], options: BootOptions) => { rejectParentOptions('web') - const patches = options.patch ?? [] - if (patches.includes('')) program.error('error: --patch needs a path') - if (options.dumpConfig === true || options.dumpDefaultConfig === true) { - if (options.dumpConfig === true && options.dumpDefaultConfig === true) { - program.error('error: --dump-config and --dump-default-config are mutually exclusive') - } - const defaultOnly = options.dumpDefaultConfig === true - if (defaultOnly && patches.length > 0) { - program.error('error: --dump-default-config prints the bundle layers and takes no --patch') - } - // The dump is boot-free and does not derive flag patches; silently - // dropping them would print a tree that differs from the same - // invocation's boot. - if (options.host !== undefined || options.port !== undefined || options.dev === true - || options.trustedHost !== undefined) { - program.error('error: config dumps take no web flags (--host/--port/--dev/--trusted-host)') - } - resolved = { mode: 'dump-config', profile: 'web', defaultOnly, patches } - return - } - if (options.port !== undefined && !/^\d+$/.test(options.port)) { - program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`) - } - resolved = { - mode: 'web', - patches, - ...options.host !== undefined && { host: options.host }, - ...options.port !== undefined && { port: Number(options.port) }, - dev: options.dev === true, - ...options.trustedHost !== undefined && { trustedHosts: options.trustedHost }, - } + resolved = resolveBoot(web, 'web', options, args) }) const plugin = program.command('plugin').description('manage a profile\'s plugins by forwarding the remaining arguments to pnpm in the profile directory') diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index b332a64615..d0e8e9d138 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -10,7 +10,7 @@ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' -import { loadLayeredEnv } from '@deepseek-ai/dsh-app-boot' +import { loadEnv } from '@deepseek-ai/dsh-app-boot' import { parseDshArgs } from './args.ts' // Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit @@ -24,33 +24,19 @@ function readVersion(): string { return typeof manifest.version === 'string' ? manifest.version : '0.0.0' } +loadEnv('dsh') const invocation = parseDshArgs(process.argv.slice(2), readVersion()) switch (invocation.mode) { case 'profile': { const { runProfile } = await import('./profile-boot.ts') await runProfile({ - environment: loadLayeredEnv('dsh'), profile: invocation.profile, patchFiles: invocation.patches, + args: invocation.args, }) break } - case 'run': { - const { runProfile } = await import('./profile-boot.ts') - await runProfile({ - environment: loadLayeredEnv('dsh'), - profile: invocation.profile, - patchFiles: invocation.patches, - task: invocation.task, - }) - break - } - case 'web': { - const { runWeb } = await import('./web.ts') - await runWeb(invocation, loadLayeredEnv('dsh')) - break - } case 'plugin': { const { runPlugin } = await import('./plugin.ts') process.exit(runPlugin(invocation.profile, invocation.args)) diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 8c37af124a..5acebe9b44 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -1,9 +1,13 @@ /** * Shared profile boot for every `dsh` surface: resolve the profile, stack its - * patch layers (bundle layers in `dsh.profile.bundles` order, the profile's own - * `cordis.patch.yml`, `--patch` overlays, flag-derived patches, the telemetry - * switch), mount the tree over the profile's empty root config, keep the - * profile patch layer live, and wire fail-loud plus bounded shutdown. + * patch layers (bundle layers in `dsh.profile.bundles` order, the profile's + * own `cordis.patch.yml`, `--patch` overlays, the telemetry switch), mount the + * tree over the profile's empty root config, keep the profile patch layer + * live, and wire fail-loud plus bounded shutdown. + * + * App flags are not the launcher's business: the invocation's inner arguments + * are provided to the tree through `ctx.cmdlineArgs`, and the booted app's + * startup row parses them and configures its own rows. * @module @deepseek-ai/dsh/profile-boot */ @@ -12,7 +16,7 @@ import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { FiberState, type Context } from '@deepseek-ai/cordis' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' -import { dshHomePath } from '@deepseek-ai/dsh-paths' +import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { boot, composeEntries, @@ -25,7 +29,7 @@ import { watchUserPatches, type Profile, } from '@deepseek-ai/dsh-app-boot' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths' /** Shipped agent-preset root: beside this app's own config, in both source and built layouts. */ const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', import.meta.url)) @@ -33,6 +37,7 @@ const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', im /** Harness-home directory holding locally authored agent presets. */ const USER_PRESET_DIR = '.agent-presets' import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' +import { hasCmdlineConsumer, provideCmdline } from '@deepseek-ai/dsh-cmdline' import type { HeadlessIo } from '@deepseek-ai/dsh-headless' import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts' import { resolveWindowsShellLayer } from './windows-shell.ts' @@ -55,7 +60,7 @@ export const INSTALL_ANCHOR = fileURLToPath(new URL('../package.json', import.me /** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets. */ const TELEMETRY_ROW_ID = 'telemetry-otel' -/** The one-shot runner row a `dsh run` task requires and configures. */ +/** The one-shot runner row: its presence means this composition exits by itself. */ const HEADLESS_ROW_ID = 'headless-runner' /** The empty root entry list every profile tree patches over. */ @@ -104,9 +109,6 @@ export function prepareProfile(name: string, userLayer = true): Profile { return profile } -/** Read-only row index of a profile composition before launcher flag patches. */ -export type ProfileRows = ReadonlyMap - /** One profile's patch layers (application order) and the row index of its pre-flag composition. */ interface ComposedProfile { profile: Profile @@ -116,14 +118,13 @@ interface ComposedProfile { windowsShellPatches: PatchOptions[] /** The home-level user layer (`$DSH_HOME/cordis.patch.yml`), applied after the profile's own. */ homePatches: PatchOptions[] - /** Layers above the user layers on a live reload: --patch overlays, flag patches, the telemetry switch. */ - overlayAndFlags: PatchOptions[] + /** Layers above the user layers on a live reload: `--patch` overlays and the telemetry switch. */ + overlays: PatchOptions[] /** - * id → row of the pre-flag composition (bundles + user layers + overlays), - * for flag merges and row checks. Flag patches must not insert rows the - * launcher consults here (they only override values and insert dev glue). + * id → row of the composed tree (bundles + user layers + overlays), for the + * launcher's own row checks. */ - rows: ProfileRows + rows: ReadonlyMap } /** The full patch stack of one composed profile, in application order. */ @@ -133,7 +134,7 @@ function allPatches(composed: ComposedProfile): PatchOptions[] { ...composed.windowsShellPatches, ...composed.profile.patches, ...composed.homePatches, - ...composed.overlayAndFlags, + ...composed.overlays, ] } @@ -143,36 +144,28 @@ function allPatches(composed: ComposedProfile): PatchOptions[] { * is Windows), the profile's user layer, the home-level user layer * (`$DSH_HOME/cordis.patch.yml` — machine-local preferences that apply to * every profile, so it outranks the per-profile layer), `--patch` overlays, - * then flag patches derived from the composed rows, then the telemetry - * switch. + * then the telemetry switch. * @param name - the profile name. * @param patchFiles - `--patch` overlay paths, in argv order. - * @param deriveFlagPatches - launcher hook turning composed rows into flag patches. * @returns the profile, its patch layers, and the composed row index. */ function composeProfile( name: string, patchFiles: readonly string[], - deriveFlagPatches: (rows: ComposedProfile['rows']) => PatchOptions[] = () => [], ): ComposedProfile { const profile = prepareProfile(name) const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? [] const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file))) const bundlePatches = profile.layers.flatMap(layer => layer.patches) const windowsShellPatches = resolveWindowsShellLayer(process.platform, profile.layers, NAME)?.patches ?? [] - const rows = new Map() + const rows = new Map() for (const row of composeEntries([bundlePatches, windowsShellPatches, profile.patches, homePatches, overlays])) { if (typeof row.id === 'string') rows.set(row.id, row) } - const overlayAndFlags = [...overlays, ...deriveFlagPatches(rows)] - // The agent-preset roots are an assembly fact of every dsh launcher, not a - // patch author's choice: the shipped set sits beside this app's config and - // the user's own under the Harness home. Resolved per boot ($DSH_HOME may - // differ per run) and only patched when the composed tree actually mounts - // the roster — a one-shot `dsh run` composes agents from the same roster - // `dsh web` offers. + const composedOverlays = [...overlays] + // Preset roots belong to every dsh composition that mounts the roster. if (rows.has('agent-presets')) { - overlayAndFlags.push({ + composedOverlays.push({ id: 'agent-presets', config: { ...(rows.get('agent-presets')?.config ?? {}) as Record, @@ -184,58 +177,55 @@ function composeProfile( }) } const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID)) - if (telemetryPatch !== undefined) overlayAndFlags.push(telemetryPatch) - return { profile, bundlePatches, windowsShellPatches, homePatches, overlayAndFlags, rows } + if (telemetryPatch !== undefined) composedOverlays.push(telemetryPatch) + return { profile, bundlePatches, windowsShellPatches, homePatches, overlays: composedOverlays, rows } } /** Options for {@link runProfile}. */ export interface RunProfileOptions { + /** This run's frozen environment snapshot, provided before any entry mounts. */ + environment: EnvironmentSnapshot /** The profile name to boot. */ profile: string /** `--patch` overlay paths, in argv order. */ patchFiles: readonly string[] - /** Launcher hook turning the pre-flag composed rows into flag patches (the web alias's flag family). */ - deriveFlagPatches?: (rows: ProfileRows) => PatchOptions[] - /** `dsh run` task text; requires the composition to mount the headless runner row. */ - task?: string - /** Surface setup registered after Loader installation and before any config-tree entry mounts. */ - prepare?: (ctx: Context, rows: ProfileRows) => Promise | void - /** This run's frozen environment snapshot, provided to the tree before any entry mounts. */ - environment: EnvironmentSnapshot -} - -/** Re-throw setup failures unless this invocation's signal already owns shutdown. */ -function suppressSignalShutdownError(signal: AbortSignal, error: unknown): void { - if (!signal.aborted) throw error + /** The invocation's inner arguments, handed to the tree through `ctx.cmdlineArgs`. */ + args: readonly string[] + /** Host setup registered after Loader installation and before any config-tree entry mounts. */ + prepare?: (ctx: Context) => Promise | void } /** * Boot one profile invocation end to end and leave process lifetime to the - * mounted plugins (or to the one-shot runner when `task` is present). - * @param options - profile name, overlays, flag patches, and the optional task. + * mounted plugins (or to a one-shot runner the composition mounts). + * @param options - environment snapshot, profile name, overlays, and the booted app's own arguments. * @returns the settled root context and the shutdown controller. */ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Context; shutdown: ProcessShutdown }> { - const composed = composeProfile(options.profile, options.patchFiles, options.deriveFlagPatches) - if (options.task !== undefined) { - if (!composed.rows.has(HEADLESS_ROW_ID)) { - throw new Error( - `dsh: profile ${JSON.stringify(options.profile)} takes no task — its composition mounts no "${HEADLESS_ROW_ID}" row ` - + '(the headless profile does)', - ) - } - composed.overlayAndFlags.push({ id: HEADLESS_ROW_ID, config: { task: options.task } }) - } else if (composed.rows.has(HEADLESS_ROW_ID)) { - // The inverse misuse: a one-shot composition booted without its task - // would otherwise die in the runner row's schema with a raw "required" - // error naming no fix. + const composed = composeProfile(options.profile, options.patchFiles) + if (!hasCmdlineConsumer([...composed.rows.values()]) && options.args.length > 0) { throw new Error( - `dsh: profile ${JSON.stringify(options.profile)} mounts the one-shot runner and needs a task: ` - + `dsh run --profile ${options.profile} ""`, + `${NAME}: profile ${JSON.stringify(options.profile)} takes no app arguments because no active row injects cmdlineArgs; ` + + `got ${options.args.map(argument => JSON.stringify(argument)).join(' ')}`, ) } + // A one-shot composition ends by itself, which changes what a signal means + // and makes watching the user's patch layer pointless. + const headlessRow = composed.rows.get(HEADLESS_ROW_ID) + const oneShot = headlessRow !== undefined && headlessRow.disabled !== true const app: { current?: Context } = {} + // Readiness for rows that publish it (the web URL line): a row can activate + // before concurrently mounted siblings finish or fail. + let bootSettled: () => void = () => {} + let bootFailed: (reason: unknown) => void = () => {} + const ready = new Promise((resolve, reject) => { + bootSettled = resolve + bootFailed = reject + }) + // Nothing awaits `ready` on a composition that publishes no readiness, and + // an unobserved rejection must not take the process down on its own. + ready.catch(() => {}) const shutdown = createProcessShutdown(async () => { await app.current?.fiber.dispose() }) const signalShutdown = new AbortController() const interrupt = (code: number): void => { @@ -243,9 +233,9 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con shutdown.interrupt(code) } // Signals own teardown throughout the startup window, not only after boot() - // settles: an inserted entry point can publish readiness before sibling rows + // settles: an inserted startup row can publish readiness before sibling rows // finish mounting. - process.on('SIGTERM', () => { interrupt(options.task === undefined ? 0 : 143) }) + process.on('SIGTERM', () => { interrupt(oneShot ? 143 : 0) }) process.on('SIGINT', () => { interrupt(130) }) installFailLoud(NAME, process, async () => { await app.current?.fiber.dispose() @@ -253,7 +243,9 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con const rootConfig = join(composed.profile.dir, PROFILE_ROOT_FILENAME) // Recomposition for the live user layers: bundle layers below, overlays - // and flag patches above, so a user edit can never displace them. BOTH + // above, so a user edit can never displace them. What an app's startup row + // resolved is not in here at all — it lives in that row's own service, which + // survives a recomposition. BOTH // user files are re-read per generation (the HMR watcher hands us only the // changed file's patches, which one of the reads duplicates — fresh reads // keep the two watchers from stitching in each other's stale copy). @@ -267,19 +259,27 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con ...composed.windowsShellPatches, ...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [], ...loadOptionalPatches(NAME, homePatchPath()) ?? [], - ...composed.overlayAndFlags, + ...composed.overlays, ]) // One-shot runs exit through the runner; watching would only hold the // process open after its exit request. - const watchProfilePatch = options.task === undefined + const watchProfilePatch = !oneShot // Cloned for the same insert-aliasing reason as composeLive: the boot // application must not mutate the objects later reloads recompose from. const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), async (hostCtx) => { app.current = hostCtx - // Before any config-tree entry mounts, so a plugin that resolves a - // user-facing value at construction already sees this run's layers. + // Before any config-tree entry mounts, so plugins resolve all launch-time + // environment values from the same immutable provenance snapshot. hostCtx.provide(DSH_ENVIRONMENT_KEY, options.environment) - if (options.task !== undefined) { + // The command line is a launcher fact every app reads the same way: its + // own arguments, and the bounded exit its startup row requests after + // printing help or rejecting them. + provideCmdline(hostCtx, { + args: options.args, + exit: code => void shutdown.shutdown(code), + ready, + }) + if (oneShot) { const io: HeadlessIo = { stdout: process.stdout, stderr: process.stderr, @@ -287,9 +287,13 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con } hostCtx.provide('headlessIo', io) } - await options.prepare?.(hostCtx, composed.rows) + await options.prepare?.(hostCtx) + }).catch((cause: unknown) => { + bootFailed(cause) + throw cause }) app.current = ctx + bootSettled() // A surface can dispose the whole tree while startup or this post-boot // watcher setup is still in flight. Loader presence and fiber state own // liveness; the local signal fact distinguishes that expected exit race @@ -323,7 +327,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con compose: composeLive, }) } catch (error) { - suppressSignalShutdownError(signalShutdown.signal, error) + if (!signalShutdown.signal.aborted) throw error } } return { ctx, shutdown } diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts deleted file mode 100644 index 8d056d100b..0000000000 --- a/apps/cli/src/web.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * `dsh web` — the browser-surface alias over the profile boot: `--profile web` - * plus the Web flag family (`--host/--port/--dev/--trusted-host`), each flag - * becoming a patch over the composed profile - * tree. All web runtime glue (dist serving, prompt section, URL line) lives - * in the `@deepseek-ai/dsh-web-app` bundle; this launcher only derives - * flag patches and the LAN-trust snapshot. - * @module @deepseek-ai/dsh/web - */ - -import { networkInterfaces } from 'node:os' -import { fileURLToPath } from 'node:url' -import type { Context } from '@deepseek-ai/cordis' -import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' -import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot' -import type { EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' -import { runProfile, type ProfileRows } from './profile-boot.ts' - -const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) - -/** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation. */ -const ALL_INTERFACES_HOST = '0.0.0.0' - -/** - * Non-internal IPv4 interface addresses of this machine — the IP-literal - * authorities an all-interfaces bind is reachable by on the LAN. - * @returns the addresses in interface order (possibly empty). - */ -function lanIPv4Addresses(): string[] { - return Object.values(networkInterfaces()).flat() - .filter((iface): iface is NonNullable => iface !== undefined && iface.family === 'IPv4' && !iface.internal) - .map(iface => iface.address) -} - -/** - * One LAN-trust resolution for one invocation, sampled exactly once: the - * machine's LAN IP literals when the effective bind is all-interfaces, and - * the `trustedHosts` value built from them plus the explicit extras. The - * single sample is deliberate — display must advertise only addresses the - * fence was configured with, so the web-app row receives this same snapshot. - * Derived entries are port-less IP literals: DNS rebinding needs an - * attacker-controlled name, so an IP-literal Host is safe on any port, and - * the bound port may be OS-assigned, unknowable pre-boot. - * @param bindHost - the effective webserver bind host (CLI flag, else the composed row value). - * @param extra - `--trusted-host` values, in argv order. - * @returns the sampled LAN addresses and the connection row's `trustedHosts` value (each possibly empty). - */ -export function resolveLanTrust( - bindHost: string | undefined, - extra: readonly string[], -): { lanAddresses: string[]; trustedHosts: string[] } { - const lanAddresses = bindHost === ALL_INTERFACES_HOST ? lanIPv4Addresses() : [] - return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] } -} - -/** The `dsh web` flag family, already parsed by the argument adapter. */ -export interface WebFlags { - patches: string[] - host?: string - port?: number - dev: boolean - trustedHosts?: string[] -} - -/** - * Derive the web alias's flag patches over an already-composed profile tree. - * Patches replace a row's whole config, so each patched row's composed values - * are re-read and merged under the overrides. - * @param rows - the composed row index from {@link composeProfile}. - * @param flags - the parsed flag family. - * @returns the flag patch list, in application order. - */ -function deriveWebFlagPatches( - rows: ProfileRows, - flags: WebFlags, -): PatchOptions[] { - const overrides = new Map>() - const put = (entryId: string, key: string, value: unknown): void => { - const bag = overrides.get(entryId) ?? {} - bag[key] = value - overrides.set(entryId, bag) - } - if (flags.host !== undefined) put('webserver', 'host', flags.host) - if (flags.port !== undefined) put('webserver', 'port', flags.port) - const composedHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host - const { lanAddresses, trustedHosts } = resolveLanTrust(flags.host ?? composedHost, flags.trustedHosts ?? []) - if (trustedHosts.length > 0) { - // Additive over the composed value: a cordis.patch.yml-configured fence - // authority must survive the derived LAN literals and flag extras — a - // silent drop of security-relevant fence configuration. - const composedTrusted = (rows.get('connection')?.config as { trustedHosts?: string[] } | undefined)?.trustedHosts ?? [] - put('connection', 'trustedHosts', [...composedTrusted, ...trustedHosts]) - } - // mode and lanAddresses are launcher-derived on every boot (--dev also - // inserts the client-hmr row), never pass-throughs of composed values. - put('web-runtime', 'mode', flags.dev ? 'development' : 'production') - put('web-runtime', 'lanAddresses', lanAddresses) - // The agent-preset roots are patched by the shared profile boot: they are - // an assembly fact of every dsh launcher, and `dsh run` composes agents - // from the same roster this alias offers. - const patches = [...overrides.entries()].map(([id, bag]): PatchOptions => { - const composed = rows.get(id) - if (composed === undefined) throw new Error(`dsh: patch target row "${id}" not found in the web profile composition`) - return { id, config: { ...(composed.config ?? {}) as Record, ...bag } } - }) - if (flags.dev) patches.push({ insert: [{ id: 'client-hmr', name: '@deepseek-ai/dsh-client-hmr' }] }) - return patches -} - -/** - * Whether the composed Web runtime keeps its model- and shell-visible surface - * context. The bundle schema defaults the field to true, so only an explicit - * false suppresses both the bundle contributions and the launcher-owned - * source-checkout section. - * @param rows - the composed Web profile rows before launcher flag patches. - * @returns true unless the web-runtime row explicitly disables surface context. - */ -export function webSurfaceContextEnabled(rows: ProfileRows): boolean { - return (rows.get('web-runtime')?.config as { surfaceContext?: boolean } | undefined)?.surfaceContext !== false -} - -/** - * Serve the browser UI from the web profile. Host/port flags are passed - * through only when given (absent, the composed profile values - * stand); `web-runtime.mode` and `lanAddresses` are launcher-derived on - * every boot. The URL line is printed by the web-app bundle's runtime row - * after Loader settlement. - * @param flags - the parsed `dsh web` flag family. - * @param environment - this run's frozen environment snapshot. - */ -export async function runWeb(flags: WebFlags, environment: EnvironmentSnapshot): Promise { - await runProfile({ - environment, - profile: 'web', - patchFiles: flags.patches, - deriveFlagPatches: rows => deriveWebFlagPatches(rows, flags), - prepare: (ctx: Context, rows: ProfileRows) => { - if (!webSurfaceContextEnabled(rows)) return - ctx.inject(['systemPrompt'], (promptCtx) => { - addHarnessSourceSection(promptCtx, SOURCE_ROOT) - }) - }, - }) -} diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index e30739214d..ce0a5b2ba4 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -21,22 +21,28 @@ function exitCode(argv: string[]): number { afterEach(() => { vi.restoreAllMocks() }) describe('parseDshArgs', () => { - it('routes profile boots, one-shot runs, and the web alias', () => { - expect(parse(['--profile', 'tui'])).toEqual({ mode: 'profile', profile: 'tui', patches: [] }) + it('routes profile boots and the web alias, handing the rest to the app', () => { + expect(parse(['--profile', 'tui'])).toEqual({ mode: 'profile', profile: 'tui', patches: [], args: [] }) expect(parse(['--profile', 'tui', '--patch', 'a.yml', '--patch', 'b.yml'])) - .toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml', 'b.yml'] }) - expect(parse(['run', 'run', 'the', 'tests'])) - .toEqual({ mode: 'run', profile: 'headless', patches: [], task: 'run the tests' }) - expect(parse(['run', '--profile', 'custom', '--patch', 'a.yml', '--patch', 'b.yml', 'run', 'the', 'tests'])) - .toEqual({ mode: 'run', profile: 'custom', patches: ['a.yml', 'b.yml'], task: 'run the tests' }) - expect(parse(['run', '--', '--profile', 'is', 'task', 'text'])) - .toEqual({ mode: 'run', profile: 'headless', patches: [], task: '--profile is task text' }) - expect(parse(['web'])).toEqual({ mode: 'web', dev: false, patches: [] }) - expect(parse(['web', '--patch', 'web.yml'])).toEqual({ mode: 'web', dev: false, patches: ['web.yml'] }) + .toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml', 'b.yml'], args: [] }) + expect(parse(['web'])).toEqual({ mode: 'profile', profile: 'web', patches: [], args: [] }) + expect(parse(['web', '--patch', 'web.yml'])) + .toEqual({ mode: 'profile', profile: 'web', patches: ['web.yml'], args: [] }) + }) + + it('ends the launcher flags at the first token it does not own', () => { + // App flags, including its -h, and positionals reach the app verbatim. + expect(parse(['--profile', 'tui', '--resume', 'abc'])) + .toEqual({ mode: 'profile', profile: 'tui', patches: [], args: ['--resume', 'abc'] }) + expect(parse(['--profile', 'web', '-h'])) + .toEqual({ mode: 'profile', profile: 'web', patches: [], args: ['-h'] }) expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev'])) - .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, patches: [] }) - expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9'])) - .toEqual({ mode: 'web', dev: false, patches: [], trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] }) + .toEqual({ mode: 'profile', profile: 'web', patches: [], args: ['--host', '0.0.0.0', '--port', '8080', '--dev'] }) + expect(parse(['--profile', 'headless', 'run', 'the', 'tests'])) + .toEqual({ mode: 'profile', profile: 'headless', patches: [], args: ['run', 'the', 'tests'] }) + // Launcher flags placed after that boundary belong to the app too. + expect(parse(['--profile', 'tui', '--patch', 'a.yml', '--resume', 'b', '--patch', 'late.yml'])) + .toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml'], args: ['--resume', 'b', '--patch', 'late.yml'] }) }) it('routes the plugin pnpm forwarder', () => { @@ -64,18 +70,12 @@ describe('parseDshArgs', () => { .toEqual({ mode: 'dump-config', profile: 'web', defaultOnly: true, patches: [] }) }) - it('rejects missing profile, flags outside the current grammar, and contradictory inputs', () => { + it('rejects missing profile, removed flags, and contradictory inputs', () => { expect(exitCode([])).toBe(1) - expect(exitCode(['tui'])).toBe(1) // a bare word is a task without --profile - expect(exitCode(['--config', 'c.yml'])).toBe(1) // outside the current grammar - expect(exitCode(['-p', 'task'])).toBe(1) // outside the current grammar - expect(exitCode(['--profile', 'headless', 'task'])).toBe(1) // tasks belong to `run` - expect(exitCode(['run'])).toBe(1) - expect(exitCode(['run', ''])).toBe(1) - expect(exitCode(['run', '--profile', '', 'task'])).toBe(1) - expect(exitCode(['run', '--patch=', 'task'])).toBe(1) - expect(exitCode(['--profile', 'headless', 'run', 'task'])).toBe(1) - expect(exitCode(['--patch', 'parent.yml', 'run', 'task'])).toBe(1) + expect(exitCode(['tui'])).toBe(1) // an app argument without --profile has no app to reach + expect(exitCode(['--config', 'c.yml'])).toBe(1) // removed + expect(exitCode(['-p', 'task'])).toBe(1) // removed + expect(exitCode(['run', 'task'])).toBe(1) // app-owned task replaced the launcher subcommand expect(exitCode(['--profile', ''])).toBe(1) expect(exitCode(['--profile', 'x', '--patch='])).toBe(1) expect(exitCode(['--dump-config'])).toBe(1) @@ -87,21 +87,20 @@ describe('parseDshArgs', () => { expect(exitCode(['web', '--dump-config', '--dump-default-config'])).toBe(1) expect(exitCode(['web', '--dump-default-config', '--patch', 'w.yml'])).toBe(1) expect(exitCode(['web', '--patch='])).toBe(1) - // Boot-free dumps derive no flag patches; silently dropping the flags - // would print a tree that differs from the same invocation's boot. + // A dump never runs the app's startup row, so it cannot show what that + // app's own flags would decide; printing a tree that differs from the same + // invocation's boot would mislead. expect(exitCode(['web', '--dump-config', '--port', '8080'])).toBe(1) - expect(exitCode(['web', '--dump-config', '--dev'])).toBe(1) - // A non-numeric port fails at the flag, not deep in the webserver schema. - expect(exitCode(['web', '--port', 'abc'])).toBe(1) + expect(exitCode(['--profile', 'web', '--dump-config', '-h'])).toBe(1) expect(exitCode(['plugin', 'add', 'x'])).toBe(1) // --profile required expect(exitCode(['plugin', '--profile', 'tui'])).toBe(1) // nothing to forward expect(exitCode(['plugin', '--profile', ''])).toBe(1) expect(exitCode(['--profile', 'x', 'plugin', 'add', 'y'])).toBe(1) }) - it('exits 0 for help and version', () => { + it('keeps its own help for an invocation with no app to hand it to', () => { expect(exitCode(['--help'])).toBe(0) - expect(exitCode(['run', '--help'])).toBe(0) + expect(exitCode(['-h'])).toBe(0) expect(exitCode(['--version'])).toBe(0) }) }) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 128fbbd42c..c8fc8e8f64 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -193,8 +193,105 @@ function createEnvironmentProbeProfile(home: string, project: string): void { ].join('\n')) } +interface StartupFixture { + home: string + ready: string + echo: string + /** An always-running row's echo, used to observe that a user patch reload landed. */ + witness: string +} + +/** + * A custom profile whose bundle owns a command line: a startup row whose + * `cmdlineArgs` injection identifies it to the launcher, and a row that reads + * what it resolved through a `!!js` config expression. Both plugin modules resolve + * `@deepseek-ai/dsh-cmdline` and `commander` through the profile module + * fallback, exactly as an installed out-of-tree bundle does. + */ +function createStartupFixture(): StartupFixture { + const home = mkdtempSync(join(tmpdir(), 'dsh-profile-startup-')) + const profileDir = join(home, 'profiles', 'startup') + // Written straight into the installed location: a row module resolves its + // own imports from where it is installed, and only inside the profile does + // Node's parent walk reach the installation fallback these plugins need. + const bundleDir = join(profileDir, 'node_modules', 'dsh-startup-bundle') + mkdirSync(bundleDir, { recursive: true }) + writeFileSync(join(bundleDir, 'startup.mjs'), [ + "import { Command } from 'commander'", + "import { runStartup } from '@deepseek-ai/dsh-cmdline'", + "export const name = 'fixture-startup'", + "export const inject = ['cmdlineArgs']", + 'export function apply(ctx) {', + " const program = new Command().name('fixture').option('--generation ', 'echoed generation')", + " return runStartup(ctx, 'fixtureStartup', program, parsed => ({ generation: parsed.opts().generation }))", + '}', + '', + ].join('\n')) + writeFileSync(join(bundleDir, 'waiting.mjs'), [ + "import { writeFileSync } from 'node:fs'", + "import { join } from 'node:path'", + "export const name = 'startup-fixture'", + 'export function apply(ctx, config = {}) {', + ' const heartbeat = setInterval(() => {}, 1000)', + " writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))", + " writeFileSync(process.env.RAW_READY_FILE, 'ready')", + ' ctx.effect(() => () => { clearInterval(heartbeat) })', + '}', + '', + ].join('\n')) + writeFileSync(join(bundleDir, 'witness.mjs'), [ + "import { writeFileSync } from 'node:fs'", + "import { join } from 'node:path'", + "export const name = 'reload-witness'", + 'export function apply(ctx, config = {}) {', + " writeFileSync(join(process.env.DSH_HOME, 'witness'), String(config.generation ?? 'bundle-default'))", + '}', + '', + ].join('\n')) + writeFileSync(join(bundleDir, 'cordis.patch.yml'), [ + '- insert:', + ' - id: startup-fixture', + ` name: ${pathToFileURL(join(bundleDir, 'waiting.mjs')).href}`, + ' inject: [fixtureStartup]', + ' config:', + // The flag the startup row resolved wins over the value written beside it. + " generation: !!js ctx.get('fixtureStartup')?.generation ?? 'bundle-default'", + ' - id: fixture-startup', + ` name: ${pathToFileURL(join(bundleDir, 'startup.mjs')).href}`, + ' inject: [cmdlineArgs]', + ' - id: reload-witness', + ` name: ${pathToFileURL(join(bundleDir, 'witness.mjs')).href}`, + '', + ].join('\n')) + writeFileSync(join(bundleDir, 'package.json'), JSON.stringify({ + name: 'dsh-startup-bundle', + version: '0.0.0', + type: 'module', + dsh: { bundle: { patch: './cordis.patch.yml' } }, + }, undefined, 2)) + writeFileSync(join(profileDir, 'package.json'), JSON.stringify({ + name: 'dsh-profile-startup', + private: true, + dependencies: {}, + dsh: { profile: { bundles: ['dsh-startup-bundle'] } }, + }, undefined, 2)) + writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n') + return { home, ready: join(home, 'ready'), echo: join(home, 'config-echo'), witness: join(home, 'witness') } +} + +function startStartupProfile(fixture: StartupFixture, args: readonly string[]) { + return execa(process.execPath, [dshBin, '--profile', 'startup', ...args], { + cwd: fixture.home, + input: '', + reject: false, + timeout: 25_000, + killSignal: 'SIGKILL', + env: { DSH_HOME: fixture.home, RAW_READY_FILE: fixture.ready }, + }) +} + describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => { - it('requires --profile and rejects inputs outside the current grammar', async () => { + it('requires --profile and rejects removed commands', async () => { const bare = await runBuiltBin() expect(bare.code).toBe(1) expect(bare.stdout).toBe('') @@ -202,46 +299,63 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', const help = await runBuiltBin(['--help']) expect(help.code).toBe(0) expect(help.stdout).toContain('dsh --profile web') - expect(help.stdout).toContain('dsh run "run the tests"') expect(help.stdout).toContain('dsh plugin --profile') expect(help.stdout).not.toMatch(/^\s+(?:tui|meta|upgrade)\b/mu) - for (const outsideGrammar of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['--profile', 'headless', 'task']]) { - const result = await runBuiltBin(outsideGrammar) + for (const removed of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['run', 'task']]) { + const result = await runBuiltBin(removed) expect(result.code).toBe(1) } }, 30_000) - it('prints run help without initializing the selected profile', async () => { - const parent = mkdtempSync(join(tmpdir(), 'dsh-run-help-')) - const home = join(parent, 'not-created') + it('routes help and usage errors without activating startup-dependent rows', async () => { + const home = mkdtempSync(join(tmpdir(), 'dsh-app-help-')) try { - const result = await runBuiltBin(['run', '--help'], { DSH_HOME: home }) - expect(result.code).toBe(0) - expect(result.stderr).toBe('') - expect(result.stdout).toContain('Usage: dsh run [options] ') - expect(existsSync(home)).toBe(false) - } finally { - rmSync(parent, { recursive: true, force: true }) - } - }) + const web = await runBuiltBin(['--profile', 'web', '--help'], { + DSH_HOME: home, + DSH_TELEMETRY_DISABLED: '1', + }) + expect(web.code).toBe(0) + expect(web.stderr).toBe('') + expect(web.stdout).toContain('Usage: dsh --profile web') + expect(web.stdout).toContain('--port ') + expect(web.stdout).not.toContain('dsh web: http://') - it('runs the default headless profile through the published run command', async () => { - const apiKey = 'built-dsh-run-key' + const headlessHelp = await runBuiltBin(['--profile', 'headless', '--help'], { + DSH_HOME: home, + DSH_TELEMETRY_DISABLED: '1', + }) + expect(headlessHelp.code).toBe(0) + expect(headlessHelp.stderr).toBe('') + expect(headlessHelp.stdout).toContain('Usage: dsh --profile headless') + + const missingTask = await runBuiltBin(['--profile', 'headless'], { + DSH_HOME: home, + DSH_TELEMETRY_DISABLED: '1', + }) + expect(missingTask.code).toBe(1) + expect(missingTask.stderr).toContain('a task is required') + } finally { + rmSync(home, { recursive: true, force: true }) + } + }, 30_000) + + it('runs the headless profile through its app-owned task positional', async () => { + const apiKey = 'built-dsh-headless-key' const server = await startMockLlmServer({ sequence: ['success'], apiKey, - successText: 'published dsh run reached the mock', + successText: 'published headless profile reached the mock', }) - const home = mkdtempSync(join(tmpdir(), 'dsh-built-run-')) + const home = mkdtempSync(join(tmpdir(), 'dsh-built-headless-')) try { - const result = await runBuiltBin(['run', 'answer', 'from', 'the', 'published', 'entry'], { + const result = await runBuiltBin(['--profile', 'headless', 'answer', 'from', 'the', 'published', 'entry'], { DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: apiKey, DEEPSEEK_BASE_URL: server.baseURL, }) expect(result.code, result.stderr).toBe(0) - expect(result.stdout).toBe('published dsh run reached the mock') + expect(result.stdout).toBe('published headless profile reached the mock') expect(result.stderr).toBe('') expect(server.requests.length).toBeGreaterThan(0) expect(server.requests.every(request => request.path === '/chat/completions')).toBe(true) @@ -317,9 +431,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', }, 30_000) it('reports a patch-overlay boot failure without hanging', async () => { - // An HMR main-watcher initial scan that refreshes the include - // mid-initial-apply deadlocks the failing apply's rollback against the - // refresh drain: dsh exits 13 with no diagnostic instead of settling + // The HMR main watcher's initial scan once refreshed the include + // mid-initial-apply, deadlocking the failing apply's rollback against the + // refresh drain: dsh exited 13 with no diagnostic instead of settling // ([Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.md)). const home = mkdtempSync(join(tmpdir(), 'dsh-invalid-patch-')) try { @@ -336,6 +450,18 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } }, 30_000) + it('rejects arguments when no active row injects the profile command line', async () => { + const fixture = createProfileLifecycleFixture() + try { + const result = await runBuiltBin(['--profile', 'lifecycle', '--help'], { DSH_HOME: fixture.home }) + expect(result.code).toBe(1) + expect(result.stderr).toContain('takes no app arguments because no active row injects cmdlineArgs') + expect(existsSync(fixture.ready)).toBe(false) + } finally { + rmSync(fixture.home, { recursive: true, force: true }) + } + }, 30_000) + it('applies a custom profile bundle and disposes it on a startup-time signal', async () => { const fixture = createProfileLifecycleFixture() const child = startProfileLifecycle(fixture) @@ -404,6 +530,83 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } }, 30_000) + it('hands the app arguments to the profile, which applies them before its rows start', async () => { + const fixture = createStartupFixture() + const child = startStartupProfile(fixture, ['--generation', 'flagged']) + try { + await waitForFile(fixture.ready) + // The waiting row started once, already carrying the flag value: the + // launcher never saw --generation, and the app resolved it first. + expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged') + child.kill('SIGTERM') + expect((await child).exitCode).toBe(0) + } finally { + child.kill('SIGKILL') + rmSync(fixture.home, { recursive: true, force: true }) + } + }, 30_000) + + it('starts a waiting row on its composed value when the invocation carries no app arguments', async () => { + const fixture = createStartupFixture() + const child = startStartupProfile(fixture, []) + try { + await waitForFile(fixture.ready) + expect(readFileSync(fixture.echo, 'utf8')).toBe('bundle-default') + child.kill('SIGTERM') + expect((await child).exitCode).toBe(0) + } finally { + child.kill('SIGKILL') + rmSync(fixture.home, { recursive: true, force: true }) + } + }, 30_000) + + it('keeps the app arguments across a user patch reload', async () => { + // A live edit recomposes every row while the startup service remains + // active, so each config expression reads the same invocation value (a + // served port does not move back to its composed fallback). + const fixture = createStartupFixture() + const profilePatch = join(fixture.home, 'profiles', 'startup', 'cordis.patch.yml') + const child = startStartupProfile(fixture, ['--generation', 'flagged']) + try { + // Both rows: the waiting one carries the flag value, and the witness is + // what a reload will re-mount. They start independently, so neither + // marker implies the other. + await waitForFile(fixture.ready) + await waitForFile(fixture.witness) + expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged') + // An edit to an unrelated row: the witness re-mounts, which is how this + // test knows the whole tree was recomposed. + rmSync(fixture.witness) + writeFileSync(profilePatch, [ + '- id: reload-witness', + ' config:', + ' generation: reloaded', + '', + ].join('\n')) + await waitForFile(fixture.witness) + expect(readFileSync(fixture.witness, 'utf8')).toBe('reloaded') + expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged') + child.kill('SIGTERM') + expect((await child).exitCode).toBe(0) + } finally { + child.kill('SIGKILL') + rmSync(fixture.home, { recursive: true, force: true }) + } + }, 30_000) + + it("prints the app's own help, starts none of its rows, and exits", async () => { + const fixture = createStartupFixture() + try { + const result = await startStartupProfile(fixture, ['--help']) + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('Usage: fixture') + expect(result.stdout).toContain('--generation') + expect(existsSync(fixture.ready)).toBe(false) + } finally { + rmSync(fixture.home, { recursive: true, force: true }) + } + }, 30_000) + it('anchors a relative add spec to the invoking directory, not the profile', async () => { // `dsh plugin --profile x add .` from a plugin checkout must install THAT // checkout — pnpm's cwd is the profile directory, so an un-anchored `.` @@ -490,20 +693,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'") }, 30_000) - it('prints a headless profile with no Host, HTTP, or browser rows', async () => { - const { stdout, code, stderr } = await runBuiltBin( - ['--profile', 'headless', '--dump-default-config'], - { DSH_HOME: home }, - ) - expect(code).toBe(0) - expect(stderr).toBe('') - expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-default-model'") - expect(stdout).toContain("name: '@deepseek-ai/dsh-headless'") - expect(stdout).not.toContain("name: '@deepseek-ai/dsh-host-") - expect(stdout).not.toContain("name: '@deepseek-ai/dsh-web-app'") - expect(stdout).not.toContain("name: '@deepseek-ai/dsh-client-") - }, 30_000) - it('composes the profile user layer and a --patch overlay in order', async () => { // Auto-init the web profile first, then write its user layer. const init = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home }) diff --git a/apps/cli/tests/trusted-hosts.spec.ts b/apps/cli/tests/trusted-hosts.spec.ts deleted file mode 100644 index 5647d01536..0000000000 --- a/apps/cli/tests/trusted-hosts.spec.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** Single-sample LAN-trust resolution for the /api browser-trust fence (`resolveLanTrust`). */ - -import { describe, expect, it, vi } from 'vitest' -import { resolveLanTrust, webSurfaceContextEnabled } from '../src/web.ts' - -vi.mock('node:os', () => ({ - networkInterfaces: () => ({ - lo0: [ - { family: 'IPv4', internal: true, address: '127.0.0.1' }, - ], - en0: [ - { family: 'IPv6', internal: false, address: 'fe80::1' }, - { family: 'IPv4', internal: false, address: '192.168.1.5' }, - ], - en1: [ - { family: 'IPv4', internal: false, address: '10.0.0.7' }, - ], - utun0: undefined, - }), -})) - -describe('resolveLanTrust', () => { - it('samples non-internal IPv4 addresses once for an all-interfaces bind: trust and display share them', () => { - const { lanAddresses, trustedHosts } = resolveLanTrust('0.0.0.0', ['harness.internal:3080']) - expect(lanAddresses).toEqual(['192.168.1.5', '10.0.0.7']) - expect(trustedHosts).toEqual(['192.168.1.5', '10.0.0.7', 'harness.internal:3080']) - }) - - it('derives nothing for a loopback or unresolved bind — extras alone stand, no LAN URL to print', () => { - expect(resolveLanTrust('127.0.0.1', [])).toEqual({ lanAddresses: [], trustedHosts: [] }) - expect(resolveLanTrust(undefined, ['lab.internal'])).toEqual({ lanAddresses: [], trustedHosts: ['lab.internal'] }) - }) -}) - -describe('webSurfaceContextEnabled', () => { - it('defaults to enabled and honors an explicit complete-prompt disable', () => { - expect(webSurfaceContextEnabled(new Map())).toBe(true) - expect(webSurfaceContextEnabled(new Map([ - ['web-runtime', { config: { mode: 'production' } }], - ]))).toBe(true) - expect(webSurfaceContextEnabled(new Map([ - ['web-runtime', { config: { surfaceContext: false } }], - ]))).toBe(false) - }) -}) diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index d830e8fba6..a288a5aa95 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../packages/boot/app-boot" }, + { + "path": "../../packages/ui/cmdline" + }, { "path": "../../packages/bundle/base" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a05f336f3e..5ae88ddf96 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -153,6 +153,9 @@ importers: '@deepseek-ai/dsh-client-ui-agent-preset': specifier: workspace:^ version: link:../../packages/client/ui-agent-preset + '@deepseek-ai/dsh-cmdline': + specifier: workspace:^ + version: link:../../packages/boot/cmdline '@deepseek-ai/dsh-command-compact': specifier: workspace:^ version: link:../../packages/compact/command-compact From f749e048812a7c7bc0977bfbe4ab081d43877904 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 6 Aug 2026 20:52:26 +0800 Subject: [PATCH 04/19] docs: record how an app comes to own its command line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Agent Note keeps the three vendored-Loader facts the mechanism turns on — a row's config is resolved and validated when its fiber is created, while it is still waiting; an inject update loses the plugin's static injections; a row cannot be inserted from inside a mounting plugin — with the alternatives they ruled out. --- ...026-08-06-app-owned-command-line.i18n.yaml | 6 +++ .../2026-08-06-app-owned-command-line.md | 45 +++++++++++++++++++ .../2026-08-06-app-owned-command-line.zh.md | 45 +++++++++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 .agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md create mode 100644 .agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md 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 new file mode 100644 index 0000000000..11165122e5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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: f7db56e298c11f2663f63cc05d71121a03856668 +2026-08-06-app-owned-command-line.zh.md: f17fdc9a78d9baa36852392e11744a585adfe578 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 new file mode 100644 index 0000000000..f7db56e298 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md @@ -0,0 +1,45 @@ +# Agent Note: Apps own their command line through `ctx.cmdlineArgs` + +Status: implemented + +English | [中文](2026-08-06-app-owned-command-line.zh.md) + +## Problem + +After profiles, compositions were installable but their command lines were not. `apps/cli` still declared the Web flag family (`--host`, `--port`, `--dev`, `--workspace-root`, `--trusted-host`) and the one-shot task positional, then derived patches for row ids it hardcoded (`webserver`, `api-gateway`, `connection`, `web-runtime`). An out-of-tree app such as [turtle-ui](https://github.com/deepseek-harness/turtle-ui) could contribute rows but had no way to accept a flag: `dsh --profile tui --resume ` had nowhere to be parsed, and `dsh --profile web --help` printed the launcher's help rather than the web app's. + +## Decision + +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.appPatches`. An app consumes them from a **startup row** that injects `cmdlineArgs` and calls `runStartup(ctx, service, program, plan)` with its own commander program; rows the app configures inject that startup service in the bundle patch, so they cannot start before their values are resolved, and `--help` prints, disables those rows, and exits without the app ever starting. + +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. + +Two further consequences fell out of review. An app's decisions are also handed back to the launcher as patches (`ctx.appPatches`), because the launcher re-applies its whole patch stack when a user edits a live patch file: without that layer, an unrelated edit rebuilt every row from its composed options and silently moved a server started on `--port 8080` back to the composed port, dropping `--dev` and the derived `/api` fence authorities with it. 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. + +## How a waiting row actually receives its values + +Three vendored-Loader facts shaped the mechanism, all found by probe: + +- **A row's config is resolved when the Loader creates its fiber, which happens while the row is still waiting for its startup service.** Writing a new config onto that waiting fiber never reaches the plugin. Each changed row is therefore recycled — disabled, then re-enabled with its new values — which drops the stale fiber and resolves the config again. +- **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. Recycling therefore never touches `inject`; the waiting rows are released by providing the service. +- **A row's config is validated at fiber creation too**, so a row whose *required* config the startup supplies (the one-shot runner's `task`) must ship `disabled: true`; making it wait is not enough, because the boot fails before the startup row can run. It only appeared to work because the startup module happened to import first. + +A related constraint: 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 startup enables it. Recycling also lets a still-in-flight mount settle first, since disabling alone is not a barrier. + +## Alternatives considered + +- **Releasing the rows by clearing their `inject`** (one atomic update per row): 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. +- **Reading flags from the row's config through `!!js ctx.get('webStartup')`**: config expressions are interpolated when the fiber is created, before the startup service exists, so every waiting row would read `undefined`. +- **The launcher running each bundle's startup function before boot** (no cordis involvement): simplest and strictly earlier than "boot, then help", but it makes app startup a second plugin protocol outside the tree. The maintainer's ruling was a startup *service* other rows depend on, which keeps one protocol. +- **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` cost is a boot: the tree mounts far enough for the startup row to run, then tears down. The rows waiting on that app never start, which is what the maintainer accepted when choosing the service-shaped design. +- A startup service has no statically declared owner: a bundle shipping waiting rows without its startup row fails at settlement with pending entries naming the service, not at load. +- 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 `-- --`. +- `--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 new file mode 100644 index 0000000000..f17fdc9a78 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md @@ -0,0 +1,45 @@ +# Agent Note: 应用通过 `ctx.cmdlineArgs` 持有自己的命令行 + +Status: implemented + +[English](2026-08-06-app-owned-command-line.md) | 中文 + +## 问题 + +profile 落地之后,组合可以安装,命令行却不能。`apps/cli` 仍然声明着 Web flag 家族(`--host`、`--port`、`--dev`、`--workspace-root`、`--trusted-host`)和一次性任务位置参数,再为自己硬编码的行 id(`webserver`、`api-gateway`、`connection`、`web-runtime`)派生 patch。像 [turtle-ui](https://github.com/deepseek-harness/turtle-ui) 这样的树外应用能贡献行,却无处接受一个 flag:`dsh --profile tui --resume ` 没有地方可供解析,而 `dsh --profile web --help` 打印的是启动器的 help,而不是 web 应用的 help。 + +## 决策 + +启动器只解析属于自己的部分(`--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.appPatches`。应用从**启动行**消费它们:启动行注入 `cmdlineArgs`,并以自己的 commander program 调用 `runStartup(ctx, service, program, plan)`;应用所配置的行在组合包 patch 中注入这个启动服务,因此在取值解析完成之前无法启动,而 `--help` 会打印文本、禁用这些行并退出,应用自始至终不会启动。 + +已交付的各应用把自己的 flag 搬进了组合包:`dsh-web-app` 持有 Web 家族(并为 `--dev` 启用它如今以禁用状态交付的 `client-hmr` 行),`dsh-headless` 持有任务位置参数,缺少任务时按用法错误拒绝。`apps/cli/src/web.ts` 已删除;`runProfile` 不再知道任何行 id。在树外,turtle-ui 以同样的方式获得了 `--resume ` / `--session `,这才是这套设计的真正验证:一个已安装的插件加上了一个 flag,启动器毫无改动。 + +评审中还落出两条后果。应用的决策同时以 patch 的形式交还给启动器(`ctx.appPatches`),因为用户编辑一个活动的 patch 文件时,启动器会重新施加自己的整个 patch 栈:没有这一层,一次无关的编辑就会把每一行都从其组合出的选项重建出来,把一台以 `--port 8080` 启动的服务器悄悄挪回组合出的端口,并连带丢掉 `--dev` 和由此派生的 `/api` 围栏 authority。另外,`dsh --profile web` 现在也会加上过去只有 `dsh web` 别名才会加的 harness 源码提示词章节 —— 两条路径终于以完全相同的方式引导,这也意味着名为 `web` 的用户 profile 会继承它。 + +## 等待中的行实际如何拿到自己的取值 + +vendored Loader 的三个事实塑造了这套机制,三者都是靠探针试出来的: + +- **行的配置在 Loader 创建其 fiber 时就已解析,而这发生在它仍在等待自己启动服务的时候。** 把新配置写到这个等待中的 fiber 上,永远到不了插件。因此每个改动过的行都会被回收重建:先禁用,再带着新取值重新启用,从而丢弃陈旧的 fiber 并重新解析配置。 +- **更新一行的 `inject` 会丢失插件自身的静态注入。** Loader 从 `runtime.callback`(未经包装的函数)重启被替换的行,此时 `Inject.resolve(plugin.inject)` 什么也找不到:声明了 `inject = ['httpServer', 'apiProxy']` 的行回来之后,两个服务都读不到。因此回收重建绝不触碰 `inject`;等待中的行是靠提供服务来放行的。 +- **行的配置同样在 fiber 创建时被校验**,因此一个*必填*配置由启动流程提供的行(一次性运行器的 `task`)必须以 `disabled: true` 交付;只让它等待并不够,因为 boot 会在启动行得以运行之前就失败。它之所以看起来能工作,只是因为启动模块碰巧先被 import。 + +还有一条相关约束:不能从正在挂载的插件内部插入一行(`tree.create` 返回一个带前缀的 id,随后它自己解析不出来),因此条件性的行以 `disabled: true` 交付,由启动流程启用。回收重建还会先让某次仍在进行中的挂载结算完毕,因为单靠禁用并不构成屏障。 + +## 曾考虑的替代方案 + +- **通过清空行的 `inject` 来放行**(每行一次原子更新):孤立测试可行,在真实 web 树上失败,因为清空 `inject` 恰恰会丢失插件的静态注入。在插件真的去读它声明过的服务之前,这个失败是静默的。 +- **通过 `!!js ctx.get('webStartup')` 从行配置中读取 flag**:配置表达式在 fiber 创建时求值,早于启动服务存在,因此每个等待中的行都会读到 `undefined`。 +- **由启动器在 boot 之前运行每个组合包的启动函数**(完全不经过 cordis):最简单,而且严格早于「先 boot 再 help」,但这会让应用启动成为配置树之外的第二套插件协议。维护者的裁定是做成其他行所依赖的启动*服务*,从而只保留一套协议。 +- **两个应用解析同一份 argv**(一次性组合包叠加在 web 组合包之上):两个解析器不可能同时持有 `-h`。一套组合有且只有一个命令行所有者:叠加的组合包禁用下层的启动行,并同时提供这两个启动服务,使被吸收的行按组合后的取值启动。 +- **`instanceof CommanderError`**:树外插件会带来自己的一份 commander 副本,类身份因此不同,已经打印出来的 `--help` 会被重新抛成致命的加载失败。改为按结构识别 commander 的控制流错误。 + +## 后果 + +- 应用的 flag、help 文本和用法错误与它们所配置的行放在一起;给已安装的插件加一个 flag 不需要改动启动器。 +- `--help` 的代价是一次 boot:配置树挂载到足以运行启动行,随后拆除。等待该应用的行从不启动,这正是维护者选择服务形态的设计时所接受的代价。 +- 启动服务没有静态声明的所有者:交付了等待中的行却缺少对应启动行的组合包会在结算时失败,报出指向该服务的待处理条目,而不是在加载时失败。 +- 启动器的 flag 必须写在应用参数之前;如果应用的第一个参数恰好是 `web` 或 `plugin`,选中的将是这两个子命令,而且启动器的解析器会消耗掉一个 `--`,因此要给应用传一个字面量 `--` 需要写成 `-- --`。 +- `--dump-config` 从不运行启动行,因此它在任何应用参数被解析之前打印组合,并拒绝携带应用参数的调用。 From 1f0a0440f3e8c52824fb0c6499117c103362c47e Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 7 Aug 2026 11:58:03 +0800 Subject: [PATCH 05/19] refactor(cmdline)!: an app's entrypoint provides values its rows read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the patch round trip. An app's entrypoint resolves the command line into a service, and the rows it configures read that service from their own config — port: !!js ctx.get('webStartup')?.port ?? 3080 — so the resolved value beats the value written beside it and nothing is written back into a row or handed to the launcher. A bundle names the entrypoint row in its manifest (dsh.bundle.entrypoint), which is what lets the boot mount in two passes: entrypoints alone, then the whole composition. That ordering is required, not cosmetic — 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 already active. What this removes: ctx.appPatches and the launcher-owned patch layer, the disable/re-enable recycle and its in-flight-mount barrier, overrideConfig, and the reload hazard they existed for. A live config edit now re-applies the second pass against services that are still up, so a served port survives by construction. What it adds: ctx.appReady, because Loader settlement no longer means the app is up — a row mounted in the second pass can observe a settled tree while that pass is still running, or already rolling back. The web URL line waits for it, so a boot that fails in the second pass announces nothing. --- ...026-08-06-app-owned-command-line.i18n.yaml | 4 +- .../2026-08-06-app-owned-command-line.md | 31 ++- .../2026-08-06-app-owned-command-line.zh.md | 31 ++- docs/config-catalog.md | 2 +- packages/boot/app-boot/src/index.ts | 26 ++ packages/boot/app-boot/src/profile.ts | 50 +++- packages/boot/app-boot/tests/profile.spec.ts | 32 +++ .../boot/app-boot/tests/user-patches.spec.ts | 86 ++++++- packages/boot/cmdline/README.i18n.yaml | 4 +- packages/boot/cmdline/README.md | 43 ++-- packages/boot/cmdline/README.zh.md | 43 ++-- packages/boot/cmdline/src/index.ts | 230 +++++++---------- packages/boot/cmdline/tests/cmdline.spec.ts | 232 +++++++----------- packages/bundle/headless/cordis.patch.yml | 7 +- packages/bundle/headless/package.json | 3 +- packages/bundle/headless/src/startup.ts | 35 ++- .../bundle/headless/tests/startup.spec.ts | 111 ++++----- packages/bundle/web-app/cordis.patch.yml | 37 ++- packages/bundle/web-app/package.json | 3 +- packages/bundle/web-app/src/index.ts | 24 +- packages/bundle/web-app/src/startup.ts | 85 +++---- packages/bundle/web-app/tests/startup.spec.ts | 162 ++++++------ packages/bundle/web-app/tests/web-app.spec.ts | 32 +++ 23 files changed, 720 insertions(+), 593 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml index 11165122e5..c0ec7836ae 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: f7db56e298c11f2663f63cc05d71121a03856668 -2026-08-06-app-owned-command-line.zh.md: f17fdc9a78d9baa36852392e11744a585adfe578 +2026-08-06-app-owned-command-line.md: 4765629c0cc3fee1d850de215af18bdbe51324bb +2026-08-06-app-owned-command-line.zh.md: 48782fbb9ce53ba9b3e8dbc6c2f746c7f1d46ea1 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 f7db56e298..4765629c0c 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,34 +12,39 @@ 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.appPatches`. An app consumes them from a **startup row** that injects `cmdlineArgs` and calls `runStartup(ctx, service, program, plan)` with its own commander program; rows the app configures inject that startup service in the bundle patch, so they cannot start before their values are resolved, and `--help` prints, disables those rows, and exits without the app ever starting. +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 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 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. -Two further consequences fell out of review. An app's decisions are also handed back to the launcher as patches (`ctx.appPatches`), because the launcher re-applies its whole patch stack when a user edits a live patch file: without that layer, an unrelated edit rebuilt every row from its composed options and silently moved a server started on `--port 8080` back to the composed port, dropping `--dev` and the derived `/api` fence authorities with it. 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 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. -## How a waiting row actually receives its values +## Why the boot has phases -Three vendored-Loader facts shaped the mechanism, all found by probe: +Four vendored-Loader facts shaped the mechanism, all found by probe: -- **A row's config is resolved when the Loader creates its fiber, which happens while the row is still waiting for its startup service.** Writing a new config onto that waiting fiber never reaches the plugin. Each changed row is therefore recycled — disabled, then re-enabled with its new values — which drops the stale fiber and resolves the config again. -- **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. Recycling therefore never touches `inject`; the waiting rows are released by providing the service. -- **A row's config is validated at fiber creation too**, so a row whose *required* config the startup supplies (the one-shot runner's `task`) must ship `disabled: true`; making it wait is not enough, because the boot fails before the startup row can run. It only appeared to work because the startup module happened to import first. +- **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 related constraint: 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 startup enables it. Recycling also lets a still-in-flight mount settle first, since disabling alone is not a barrier. +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. ## Alternatives considered -- **Releasing the rows by clearing their `inject`** (one atomic update per row): 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. -- **Reading flags from the row's config through `!!js ctx.get('webStartup')`**: config expressions are interpolated when the fiber is created, before the startup service exists, so every waiting row would read `undefined`. -- **The launcher running each bundle's startup function before boot** (no cordis involvement): simplest and strictly earlier than "boot, then help", but it makes app startup a second plugin protocol outside the tree. The maintainer's ruling was a startup *service* other rows depend on, which keeps one protocol. +- **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. - **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` cost is a boot: the tree mounts far enough for the startup row to run, then tears down. The rows waiting on that app never start, which is what the maintainer accepted when choosing the service-shaped design. -- A startup service has no statically declared owner: a bundle shipping waiting rows without its startup row fails at settlement with pending entries naming the service, not at load. +- `--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. +- 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 `-- --`. - `--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 f17fdc9a78..48782fbb9c 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,34 +12,39 @@ 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.appPatches`。应用从**启动行**消费它们:启动行注入 `cmdlineArgs`,并以自己的 commander program 调用 `runStartup(ctx, service, program, plan)`;应用所配置的行在组合包 patch 中注入这个启动服务,因此在取值解析完成之前无法启动,而 `--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 胜过写在它旁边的值,也没有任何东西被写回任何一行。 + +boot 分两趟挂载,这正是 manifest 声明所换来的:先是各入口点,然后才是整套组合。行的配置表达式在 include 施加该行时求值,而严格的 `ctx.get` 只对提供方 fiber 已经 active 的服务作答,因此配置树的其余部分必须在入口点起来之后才施加。于是 `--help` 在第二趟存在之前就退出;用户编辑一个活动的 patch 文件时,这一趟会针对仍然在线的服务重新施加,因此已经服务中的端口不会被悄悄重置。 已交付的各应用把自己的 flag 搬进了组合包:`dsh-web-app` 持有 Web 家族(并为 `--dev` 启用它如今以禁用状态交付的 `client-hmr` 行),`dsh-headless` 持有任务位置参数,缺少任务时按用法错误拒绝。`apps/cli/src/web.ts` 已删除;`runProfile` 不再知道任何行 id。在树外,turtle-ui 以同样的方式获得了 `--resume ` / `--session `,这才是这套设计的真正验证:一个已安装的插件加上了一个 flag,启动器毫无改动。 -评审中还落出两条后果。应用的决策同时以 patch 的形式交还给启动器(`ctx.appPatches`),因为用户编辑一个活动的 patch 文件时,启动器会重新施加自己的整个 patch 栈:没有这一层,一次无关的编辑就会把每一行都从其组合出的选项重建出来,把一台以 `--port 8080` 启动的服务器悄悄挪回组合出的端口,并连带丢掉 `--dev` 和由此派生的 `/api` 围栏 authority。另外,`dsh --profile web` 现在也会加上过去只有 `dsh web` 别名才会加的 harness 源码提示词章节 —— 两条路径终于以完全相同的方式引导,这也意味着名为 `web` 的用户 profile 会继承它。 +还有两条后果。Loader 结算不再意味着「应用已经起来」——在第二趟中挂载的行可能看到一棵已结算的树,而挂载它的那一趟仍在进行,甚至已经在回滚——因此公布就绪信号的行(web 的 URL 行)改为等待 `ctx.appReady`。另外,`dsh --profile web` 现在也会加上过去只有 `dsh web` 别名才会加的 harness 源码提示词章节:两条路径终于以完全相同的方式引导,这也意味着名为 `web` 的用户 profile 会继承它。 -## 等待中的行实际如何拿到自己的取值 +## 为什么 boot 分阶段 -vendored Loader 的三个事实塑造了这套机制,三者都是靠探针试出来的: +vendored Loader 的四个事实塑造了这套机制,它们都是靠探针试出来的: -- **行的配置在 Loader 创建其 fiber 时就已解析,而这发生在它仍在等待自己启动服务的时候。** 把新配置写到这个等待中的 fiber 上,永远到不了插件。因此每个改动过的行都会被回收重建:先禁用,再带着新取值重新启用,从而丢弃陈旧的 fiber 并重新解析配置。 -- **更新一行的 `inject` 会丢失插件自身的静态注入。** Loader 从 `runtime.callback`(未经包装的函数)重启被替换的行,此时 `Inject.resolve(plugin.inject)` 什么也找不到:声明了 `inject = ['httpServer', 'apiProxy']` 的行回来之后,两个服务都读不到。因此回收重建绝不触碰 `inject`;等待中的行是靠提供服务来放行的。 -- **行的配置同样在 fiber 创建时被校验**,因此一个*必填*配置由启动流程提供的行(一次性运行器的 `task`)必须以 `disabled: true` 交付;只让它等待并不够,因为 boot 会在启动行得以运行之前就失败。它之所以看起来能工作,只是因为启动模块碰巧先被 import。 +- **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` 及其重载链路)。 -还有一条相关约束:不能从正在挂载的插件内部插入一行(`tree.create` 返回一个带前缀的 id,随后它自己解析不出来),因此条件性的行以 `disabled: true` 交付,由启动流程启用。回收重建还会先让某次仍在进行中的挂载结算完毕,因为单靠禁用并不构成屏障。 +这些事实合起来排除了「一趟之内用服务配置各行」,并确立了分阶段挂载:各行保留自己的 `inject` 和自己的配置,而启动器在两阶段之间所做的,仅仅是再施加一次组合。 ## 曾考虑的替代方案 -- **通过清空行的 `inject` 来放行**(每行一次原子更新):孤立测试可行,在真实 web 树上失败,因为清空 `inject` 恰恰会丢失插件的静态注入。在插件真的去读它声明过的服务之前,这个失败是静默的。 -- **通过 `!!js ctx.get('webStartup')` 从行配置中读取 flag**:配置表达式在 fiber 创建时求值,早于启动服务存在,因此每个等待中的行都会读到 `undefined`。 -- **由启动器在 boot 之前运行每个组合包的启动函数**(完全不经过 cordis):最简单,而且严格早于「先 boot 再 help」,但这会让应用启动成为配置树之外的第二套插件协议。维护者的裁定是做成其他行所依赖的启动*服务*,从而只保留一套协议。 +- **把解析出的取值写进每一行**(逐行一次配置更新,外加交还给启动器的一层 patch,使重载无法撤销它):它能工作,但这意味着 patch 在应用与启动器之间来回传递、同一件事有两套机制,以及一套其正确性依赖 Loader 重启内部细节的回收重建。维护者否决了这次往返;供各行读取的服务取代了这一切。 +- **通过清空行的 `inject` 来放行**:孤立测试可行,在真实 web 树上失败,因为清空 `inject` 恰恰会丢失插件的静态注入。在插件真的去读它声明过的服务之前,这个失败是静默的。 +- **在单趟挂载中让各行等待该服务**:配置表达式在任何行存在之前就已插值,因此每个读取方都会看到 `undefined`。 +- **由启动器在 boot 之前运行每个组合包的启动函数**(完全不经过 cordis):严格早于「先 boot 再 help」,但这会让应用启动成为配置树之外的第二套插件协议。声明一个入口点*行*则只保留一套协议:入口点就是一个普通的行,可 dump、可 patch,叠加的组合包也能像禁用其他行那样禁用它。 - **两个应用解析同一份 argv**(一次性组合包叠加在 web 组合包之上):两个解析器不可能同时持有 `-h`。一套组合有且只有一个命令行所有者:叠加的组合包禁用下层的启动行,并同时提供这两个启动服务,使被吸收的行按组合后的取值启动。 - **`instanceof CommanderError`**:树外插件会带来自己的一份 commander 副本,类身份因此不同,已经打印出来的 `--help` 会被重新抛成致命的加载失败。改为按结构识别 commander 的控制流错误。 ## 后果 - 应用的 flag、help 文本和用法错误与它们所配置的行放在一起;给已安装的插件加一个 flag 不需要改动启动器。 -- `--help` 的代价是一次 boot:配置树挂载到足以运行启动行,随后拆除。等待该应用的行从不启动,这正是维护者选择服务形态的设计时所接受的代价。 -- 启动服务没有静态声明的所有者:交付了等待中的行却缺少对应启动行的组合包会在结算时失败,报出指向该服务的待处理条目,而不是在加载时失败。 +- `--help` 只挂载各入口点然后退出,组合中的其余部分从不启动。 +- 启动服务没有静态声明的所有者:交付了读取行却缺少对应入口点的组合包会在结算时失败,报出指向该服务的待处理条目,而不是在加载时失败。 +- 用户 patch 若整体替换某行的 `config`,会连同其中的表达式一起丢掉,该行上 flag 的优先级也随之消失。 - 启动器的 flag 必须写在应用参数之前;如果应用的第一个参数恰好是 `web` 或 `plugin`,选中的将是这两个子命令,而且启动器的解析器会消耗掉一个 `--`,因此要给应用传一个字面量 `--` 需要写成 `-- --`。 - `--dump-config` 从不运行启动行,因此它在任何应用参数被解析之前打印组合,并拒绝携带应用参数的调用。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 64c7722a3c..7cd481e6d5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2546,7 +2546,7 @@ export interface Config { export type WebMode = 'production' | 'development' ``` -Source: [`packages/bundle/web-app/src/index.ts:32`](../packages/bundle/web-app/src/index.ts) +Source: [`packages/bundle/web-app/src/index.ts:36`](../packages/bundle/web-app/src/index.ts) ## `@deepseek-ai/dsh-web-fetch-local` diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 854da5646a..e5596229ae 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -39,6 +39,7 @@ export { PROFILES_DIR, readProfileManifest, resolveBundleDir, + resolveEntrypoints, resolveProfileDir, writeProfileManifest, type DshBundleManifest, @@ -527,6 +528,31 @@ export async function mountRootInclude( return entry } +/** + * 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 + * 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 + * active, not before. + * @param ctx - the booted context whose root include to re-apply. + * @param patches - the full patch list for this generation. + * @returns nothing once the new generation has settled; a disposed tree is a no-op. + * @throws when the tree was booted without the root include. + */ +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 + // (`--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 + await entry.update({ config: { ...includeConfig, patches: [...patches] } }) + await ctx.get('loader')?.await() +} + /** * The slice of `process` {@link installFailLoud} needs — injectable so tests * exercise the handler without registering on (or exiting) the real process. diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index e19bb13c41..e105287808 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -42,6 +42,16 @@ 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. */ @@ -79,6 +89,37 @@ 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. */ @@ -391,7 +432,14 @@ 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) - return { packageName, packageDir, patchPath, patches: loadOverlayPatches(binName, patchPath) } + const entrypoint = bundleManifest.dsh?.bundle?.entrypoint + return { + packageName, + packageDir, + patchPath, + patches: loadOverlayPatches(binName, patchPath), + ...entrypoint === undefined ? {} : { entrypoint }, + } }) 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 bd0294475d..48166042f0 100644 --- a/packages/boot/app-boot/tests/profile.spec.ts +++ b/packages/boot/app-boot/tests/profile.spec.ts @@ -17,6 +17,7 @@ import { PROFILE_TEMPLATES, readProfileManifest, resolveBundleDir, + resolveEntrypoints, resolveProfileDir, writeProfileManifest, } from '../src/index.ts' @@ -197,6 +198,37 @@ 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/app-boot/tests/user-patches.spec.ts b/packages/boot/app-boot/tests/user-patches.spec.ts index 2e67bd08f8..a55d2d246f 100644 --- a/packages/boot/app-boot/tests/user-patches.spec.ts +++ b/packages/boot/app-boot/tests/user-patches.spec.ts @@ -13,7 +13,9 @@ import { Context } from '@deepseek-ai/cordis' import Hmr from '@deepseek-ai/cordis-plugin-hmr' import Loader from '@deepseek-ai/cordis-plugin-loader' import Timer from '@deepseek-ai/cordis-plugin-timer' +import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { + applyRootPatches, boot, loadOptionalPatches, PROFILE_PATCH_FILENAME, @@ -92,23 +94,81 @@ describe('loadOptionalPatches', () => { }) }) -describe('boot with user patches', () => { - function writeTree(dir: string): string { - writeFileSync(join(dir, 'noop.mjs'), [ - 'export const name = "noop"', - 'export function apply(_ctx, config = {}) {', - ' if (config.fail) throw new Error("candidate config failed")', - '}', +function writeTree(dir: string): string { + writeFileSync(join(dir, 'noop.mjs'), [ + 'export const name = "noop"', + 'export function apply(_ctx, config = {}) {', + ' if (config.fail) throw new Error("candidate config failed")', + '}', + '', + ].join('\n')) + writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n') + return join(dir, 'cordis.yml') +} + +function entryConfig(ctx: Context, id: string): unknown { + return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config +} + +describe('applyRootPatches', () => { + it('mounts a later phase whose rows read what the first phase provided', async () => { + // The phased boot in one test: a row's `!!js` config is evaluated when the + // include applies it, so a value an earlier phase provided is what a later + // phase's rows read. + const dir = tmp() + writeFileSync(join(dir, 'provider.mjs'), [ + 'export const name = "provider"', + 'export function apply(ctx) { ctx.provide("phaseOne", { value: "resolved" }) }', '', ].join('\n')) - writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n') - return join(dir, 'cordis.yml') - } + writeFileSync(join(dir, 'reader.mjs'), [ + 'export const name = "reader"', + 'export const inject = ["phaseOne"]', + 'export function apply() {}', + '', + ].join('\n')) + writeFileSync(join(dir, 'cordis.yml'), '[]\n') + const composition: PatchOptions[] = [{ + insert: [ + { id: 'provider', name: './provider.mjs' }, + { + id: 'reader', + name: './reader.mjs', + inject: ['phaseOne'], + config: { value: { __jsExpr: "ctx.get('phaseOne')?.value ?? 'fallback'" } }, + }, + ], + }] + const ctx = await boot(NAME, join(dir, 'cordis.yml'), [ + ...structuredClone(composition), + { id: 'reader', disabled: true }, + ]) + try { + // Phase one leaves the reader disabled, so the plugin never ran. + const reader = [...ctx.loader.entries()].find(entry => entry.options.id === 'reader') + expect(reader?.fiber).toBeUndefined() + await applyRootPatches(ctx, structuredClone(composition)) + // Phase two evaluates its config expression against the provided value. + expect(entryConfig(ctx, 'reader')).toEqual({ value: 'resolved' }) + } finally { + await ctx.fiber.dispose() + } + }) - function entryConfig(ctx: Context, id: string): unknown { - return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config - } + it('does nothing on a tree that was already disposed', async () => { + const dir = tmp() + const ctx = await boot(NAME, writeTree(dir)) + await ctx.fiber.dispose() + await expect(applyRootPatches(ctx, [])).resolves.toBeUndefined() + }) + it('fails loud when the tree was booted without the root include', async () => { + const ctx = new Context() + await expect(applyRootPatches(ctx, [])).rejects.toThrow('requires the root Include entry') + }) +}) + +describe('boot with user patches', () => { it('applies id-targeted overrides, inserts, and interpolates !!js from the environment', async () => { const dir = tmp() const userDir = tmp() diff --git a/packages/boot/cmdline/README.i18n.yaml b/packages/boot/cmdline/README.i18n.yaml index f1e5a30951..7c986b0d26 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: 3d7aa7fd58c7e542ac0c733eb0794436cb0fc42d -README.zh.md: d6eb191e1c0c8136a613d5e9fe29bb66420139ac +README.md: acdc3a310f0062f1b27dbd74d20b81e1a8198bca +README.zh.md: 365a2c7f3cdf5710ce7e3abe76f009dc1ba4217f diff --git a/packages/boot/cmdline/README.md b/packages/boot/cmdline/README.md index 3d7aa7fd58..acdc3a310f 100644 --- a/packages/boot/cmdline/README.md +++ b/packages/boot/cmdline/README.md @@ -4,57 +4,59 @@ English | [中文](README.zh.md) The command line a dsh launcher hands to the app it boots. The launcher parses only its own flags (`--profile`, `--patch`, the config dumps) and hands **everything after them** to the tree verbatim, so an app owns its flag family, its `--help` text, and its parse errors instead of the launcher knowing them. -## The three launcher values +## The launcher values A launcher calls `provideCmdline(ctx, host)` before any tree entry mounts, which provides: - `ctx.cmdlineArgs` — the invocation's inner arguments. `get()` is the whole interface, and it returns a snapshot: `dsh --profile tui --resume abc` yields `['--resume', 'abc']`. - `ctx.appExit` — a bounded process-exit request, wired to the launcher's shutdown controller. -- `ctx.appPatches` — where a startup row records its decisions, for a launcher that recomposes its tree. Omitted by a host that never does. +- `ctx.appReady` — settles when the launcher has finished mounting, for a row that publishes readiness (a URL line a supervisor waits for). An embedding host with no command line provides an empty list; that is the honest answer, not a missing value. -## Startup rows and the services their rows wait for +## Entrypoints, and the service their app reads -An app reads those arguments from a **startup row** — a plugin that injects `cmdlineArgs` and calls `runStartup(ctx, service, program, plan)`: +An app reads those arguments from its **entrypoint row** — a plugin that injects `cmdlineArgs` and calls `runStartup(ctx, service, program, plan)`: ```ts ignore export const name = 'web-startup' export const inject = ['cmdlineArgs'] -export function apply(ctx: Context): Promise { - return runStartup(ctx, 'webStartup', webCommand(), planWebStartup) +export function apply(ctx: Context): void { + runStartup(ctx, 'webStartup', webCommand(), planWebStartup) } ``` -Every row the app configures from flags injects that startup service in the bundle patch: +The bundle's `package.json` names that row, which is what makes the boot mount it before everything else: + +```json +{ "dsh": { "bundle": { "patch": "./cordis.patch.yml", "entrypoint": "web-startup" } } } +``` + +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: ```yaml - id: webserver name: '@deepseek-ai/dsh-host-webserver' inject: [webStartup] config: - host: 127.0.0.1 - port: 3080 + host: !!js ctx.get('webStartup')?.host ?? '127.0.0.1' + port: !!js ctx.get('webStartup')?.port ?? 3080 ``` -`runStartup` parses the arguments, asks `plan` what each waiting row's values should be, applies them, and provides the startup service, which is what lets those rows start. On `--help`, `--version`, a parse error, or a `program.error(...)` from the plan, it writes commander's text, disables the waiting rows, and requests exit — the app never starts, and the settlement audit sees a tree that was asked not to start it. +`runStartup` parses the arguments, asks `plan` for the values, and provides them as the service. On `--help`, `--version`, a parse error, or a `program.error(...)` from the plan, it writes commander's text and requests exit — nothing is provided, and the rest of the composition never mounts. -`plan` receives every waiting row's **composed** options, so a decision reads what the bundle patches and the user's own layers agreed on before overriding it; `overrideConfig(row, { port })` replaces exactly the named keys. A row absent from the plan starts on its composed values, and planning a change for a row also enables it. +`plan` receives the options of every row that injects the service, for a value that has to take the composition into account: the `/api` fence authorities are the shipped example, since a bind the composition configured decides whether LAN literals are derived at all. -A row whose required config the startup **supplies** rather than overrides must ship `disabled: true`, because a waiting row's config is validated when its fiber is created — before the startup service arrives — and a missing required key fails the boot there. The one-shot runner's `task` is the shipped example. A row shipped disabled for another reason is turned on the same way: `dsh web --dev` plans `{ disabled: false }` for the HMR receiver. +### Why the boot has phases -The decisions also reach the launcher through `ctx.appPatches`, which is what keeps them alive across a recomposition: without it, a user editing a live patch file would rebuild every row from its composed options and silently move a server started on `--port 8080` back to the composed port. +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. -### Why a changed row is recycled - -A waiting row's config is resolved when the Loader creates its fiber, which happens while the row is still waiting. Writing a new config onto that fiber never reaches the plugin, so each changed row is disabled and re-enabled, which drops the stale fiber and resolves the config again. A row whose own mount is still in flight is allowed to settle first, so the disable has a fiber to dispose instead of racing one into existence. - -Recycling deliberately leaves `inject` alone. Updating a row's `inject` restarts it from its unwrapped callback, which loses the plugin's own static injections — a row that declares `inject = ['httpServer', 'apiProxy']` would come back unable to read either. +`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. ### One command line, one owner -A composition has exactly one command-line owner. An app that layers over another one disables the underlying startup row and names both startup services, so the rows it absorbed start on their composed values — [`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 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). 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. @@ -69,4 +71,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 a startup row provides it; nothing links the two statically, so a bundle that ships waiting rows without its startup row fails at settlement (pending entries naming the service) rather than at load. +- **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 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 d6eb191e1c..365a2c7f3c 100644 --- a/packages/boot/cmdline/README.zh.md +++ b/packages/boot/cmdline/README.zh.md @@ -4,57 +4,59 @@ dsh 启动器交给它所引导应用的那条命令行。启动器只解析属于自己的 flag(`--profile`、`--patch`、配置 dump),并把**其后的一切**原样交给配置树,因此 flag 家族、`--help` 文本和解析错误都由应用自己持有,启动器不必知道它们。 -## 启动器提供的三个值 +## 启动器提供的值 启动器在任何配置树条目挂载之前调用 `provideCmdline(ctx, host)`,它提供: - `ctx.cmdlineArgs`:本次调用的内层参数。`get()` 就是它的全部接口,返回一份快照:`dsh --profile tui --resume abc` 得到 `['--resume', 'abc']`。 - `ctx.appExit`:一个有边界的进程退出请求,接到启动器的关停控制器上。 -- `ctx.appPatches`:启动行记录自身决策的去处,面向会重新组合自己配置树的启动器。从不重新组合的宿主不提供它。 +- `ctx.appReady`:在启动器挂载完毕时结算,供需要公布就绪信号的行使用(例如督程会等待的 URL 行)。 没有命令行的嵌入宿主提供空列表;这是诚实的答案,而不是缺失的值。 -## 启动行,以及各行所等待的服务 +## 入口点,以及它的应用所读取的服务 -应用从**启动行**读取这些参数:启动行是一个注入 `cmdlineArgs` 并调用 `runStartup(ctx, service, program, plan)` 的插件: +应用从自己的**入口点行**读取这些参数:入口点行是一个注入 `cmdlineArgs` 并调用 `runStartup(ctx, service, program, plan)` 的插件: ```ts ignore export const name = 'web-startup' export const inject = ['cmdlineArgs'] -export function apply(ctx: Context): Promise { - return runStartup(ctx, 'webStartup', webCommand(), planWebStartup) +export function apply(ctx: Context): void { + runStartup(ctx, 'webStartup', webCommand(), planWebStartup) } ``` -应用用 flag 配置的每一行,都在组合包 patch 中注入那个启动服务: +组合包的 `package.json` 点名那一行,这正是 boot 先于其他一切挂载它的依据: + +```json +{ "dsh": { "bundle": { "patch": "./cordis.patch.yml", "entrypoint": "web-startup" } } } +``` + +应用用 flag 配置的每一行随后读取入口点解析出的取值,各自点名自己取用的键,以及回退时使用的值: ```yaml - id: webserver name: '@deepseek-ai/dsh-host-webserver' inject: [webStartup] config: - host: 127.0.0.1 - port: 3080 + host: !!js ctx.get('webStartup')?.host ?? '127.0.0.1' + port: !!js ctx.get('webStartup')?.port ?? 3080 ``` -`runStartup` 解析参数,向 `plan` 询问每个等待中的行应有的取值,应用这些取值,然后提供启动服务,正是这一步让这些行得以启动。遇到 `--help`、`--version`、解析错误,或 `plan` 发出的 `program.error(...)` 时,它输出 commander 的文本,禁用等待中的行并请求退出:应用从不启动,结算审计看到的是一棵被要求不要启动它的树。 +`runStartup` 解析参数,向 `plan` 索取取值,并把它们作为服务提供出去。遇到 `--help`、`--version`、解析错误,或 `plan` 发出的 `program.error(...)` 时,它输出 commander 的文本并请求退出:什么也不会被提供,组合的其余部分也从不挂载。 -`plan` 收到的是每个等待中的行**组合后**的选项,因此决策在覆盖之前能读到组合包 patch 与用户自己那几层达成的结果;`overrideConfig(row, { port })` 只替换点名的那些配置键。plan 中未出现的行按组合后的取值启动;而为某一行 plan 了改动,也会顺带启用它。 +`plan` 收到的是所有注入该服务的行的选项,用于那些必须顾及组合本身的取值:随附的例子是 `/api` 栅栏 authority,因为组合所配置的 bind 决定了是否要派生 LAN 字面量。 -必填配置由启动流程**供给**而非覆盖的行,必须以 `disabled: true` 交付,因为等待中的行的配置在其 fiber 创建时就会被校验(此时启动服务尚未到达),缺少一个必填键会在那里就让 boot 失败。一次性运行器的 `task` 就是随附的例子。因其他原因以禁用状态交付的行也以同样方式打开:`dsh web --dev` 为 HMR(热模块替换)接收方 plan 了一个 `{ disabled: false }`。 +### 为什么 boot 分阶段 -这些决策同时经 `ctx.appPatches` 到达启动器,正是这一点让它们在一次重新组合中存活下来:没有它,用户编辑一个活动的 patch 文件就会把每一行都从其组合后的选项重建出来,并悄悄把一台以 `--port 8080` 启动的服务器挪回组合后的端口。 +行的配置表达式在 include 施加该行时求值,而严格的 `ctx.get` 只对提供方 fiber 已经 active 的服务作答。因此一套组合分两趟挂载:先是各入口点,然后才是其余部分——这正是 manifest(元数据清单)声明所换来的东西。后一趟的行读到的是活的取值,`--help` 在第二趟存在之前就退出,而用户编辑一个活动的 patch 文件时,这一趟会针对仍然在线的服务重新运行,因此 flag 不会被悄悄重置。 -### 为什么改动过的行要回收重建 - -等待中的行的配置在 Loader 创建它的 fiber 时就已解析,而这发生在该行仍在等待的时候。把新配置写到这个 fiber 上,永远到不了插件,因此每个改动过的行都会先禁用再重新启用,从而丢弃陈旧的 fiber 并重新解析配置。自身挂载仍在进行中的行会先被放行至停稳,这样禁用时才有一个 fiber 可供 dispose(资源释放),而不是与一个正在诞生的 fiber 抢跑。 - -回收重建刻意不动 `inject`。更新一行的 `inject` 会让它从未经包装的回调重新启动,从而丢失插件自身的静态注入:声明了 `inject = ['httpServer', 'apiProxy']` 的行回来之后,两个服务都读不到。 +`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 重新抛成致命的加载失败。 @@ -69,4 +71,5 @@ export function apply(ctx: Context): Promise { ## 已知限制与延期工作 - **启动器的 flag 必须写在应用参数之前**:切分按位置进行,启动器不认识的第一个 token 就是内层参数的起点,因此写在某个应用 flag 之后的 `--patch` 属于应用。启动器的解析器会消耗掉一个 `--`,因此必须以字面量 `--` 存活到应用的参数需要写成 `-- --`。 -- **启动服务没有声明所有者**:各行点名它,由启动行提供它;两者之间没有静态关联,因此交付了等待中的行却缺少对应启动行的组合包会在结算时失败(出现指向该服务的待处理条目),而不是在加载时失败。 +- **启动服务没有声明所有者**:各行点名它,由入口点提供它;两者之间没有静态关联,因此交付了读取行却缺少对应入口点的组合包会在结算时失败(出现指向该服务的待处理条目),而不是在加载时失败。 +- **用户 patch 若整体替换某行的 `config`,会连同其中的表达式一起丢掉**:flag 胜过的是表达式旁写着的那个值,而不是用户用字面量替换掉表达式之后的结果;保留表达式才能保留 flag 的优先级。 diff --git a/packages/boot/cmdline/src/index.ts b/packages/boot/cmdline/src/index.ts index 3dce71079e..43c3b9ec58 100644 --- a/packages/boot/cmdline/src/index.ts +++ b/packages/boot/cmdline/src/index.ts @@ -8,17 +8,21 @@ * text, and its parse errors instead of the launcher knowing them. * * An app consumes those arguments from a **startup plugin**: a row that - * injects `cmdlineArgs` and calls {@link runStartup}. Every row the app - * configures from flags declares `inject: []` in the bundle - * patch and therefore waits until the startup plugin provides that service; - * `--help` prints, disables exactly those rows, and requests exit, so the app - * never starts. + * 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 + * 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. * @module @deepseek-ai/dsh-cmdline */ import type { Command } from 'commander' import type { Context } from 'cordis' -import type { PatchOptions } from '@cordisjs/plugin-include' import type { Entry, EntryOptions } from '@cordisjs/plugin-loader' // Empty type import carries the loader Context merge used to walk the tree. import type {} from '@cordisjs/plugin-loader' @@ -45,61 +49,47 @@ export interface AppExit { (code: number): void } -/** - * The launcher's own patch layer, above every layer a user can edit. - * - * A startup row's decisions are facts about this invocation, so they must - * outlive a recomposition of the tree: a launcher that re-applies its patch - * stack when the user edits a live patch file rebuilds every row from its - * composed options, which would otherwise silently reset a flag-configured - * row (a browser served on `--port 8080` would move back to the composed - * port on an unrelated edit). - */ -export interface AppPatches { - /** - * Record patches the launcher must keep applying on every later composition. - * @param patches - the startup row's decisions, as patches over the composed rows. - */ - contribute(patches: readonly PatchOptions[]): void -} - declare module 'cordis' { interface Context { /** The invocation's inner arguments; provided by a launcher before the tree mounts. */ cmdlineArgs?: CmdlineArgs /** Bounded process-exit request; provided by a launcher before the tree mounts. */ appExit?: AppExit - /** The launcher's own patch layer; provided by a launcher that recomposes its tree. */ - appPatches?: AppPatches + /** Settles when the launcher has mounted the whole composition; see {@link CmdlineHost.ready}. */ + appReady?: Promise } } -/** The launcher facts an app's startup row needs. */ +/** The launcher facts an app needs. */ export interface CmdlineHost { /** The invocation's inner arguments, in argv order. */ args: readonly string[] /** Bounded process-exit request. */ exit: AppExit /** - * Sink for startup decisions a later recomposition must keep. A launcher - * that never recomposes its tree (a one-shot embedding host) omits it. + * 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. */ - contribute?: AppPatches['contribute'] + ready?: Promise } /** - * Provide the command line, the exit request, and the patch sink on a host - * context before any tree entry mounts. These are launcher facts, not config: - * an embedding host with no command line provides an empty argument list. + * Provide the command line and the exit request on a host context before any + * tree entry mounts. Both are launcher facts, not config: an embedding host + * with no command line provides an empty argument list. * @param ctx - the host context the tree will mount under. - * @param host - the invocation's arguments, exit request, and optional patch sink. + * @param host - the invocation's arguments and its exit request. */ export function provideCmdline(ctx: Context, host: CmdlineHost): void { const snapshot = [...host.args] ctx.provide('cmdlineArgs', { get: () => snapshot }) ctx.provide('appExit', host.exit) - const contribute = host.contribute - if (contribute !== undefined) ctx.provide('appPatches', { contribute }) + if (host.ready !== undefined) ctx.provide('appReady', host.ready) } /** The process streams commander output is written to; production writes to the process. */ @@ -109,62 +99,55 @@ export const internals: { stdout: { write(chunk: string): unknown }; stderr: { w } /** - * What a startup plugin changes on one waiting row. A row with a change is - * re-enabled as part of applying it; `{ disabled: true }` keeps it off (and - * `{ disabled: false }` is how a row a bundle ships disabled gets turned on). - */ -export type RowChange = Omit, 'id' | 'inject'> - -/** - * Decide this invocation's changes for the rows waiting on an app's startup - * service. + * Resolve this invocation into the values the app's rows read. * - * Runs after a successful parse, with every waiting row's composed options - * (bundle layers, the user's layers, and any `--patch` overlay already - * applied), so a decision can read what the composition agreed on before - * overriding it. Call `program.error(...)` to reject the invocation with a - * usage message instead of throwing. + * Runs after a successful parse, with the waiting rows' composed options + * available for a value that has to take the composition into account (the + * `/api` fence authorities are the shipped example). Call `program.error(...)` + * 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. - * @returns row id → the changes for that row; ids absent from the map start unchanged. + * @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[]) => Map +export type StartupPlan = (program: Command, rows: readonly EntryOptions[]) => T /** * Run one app's startup: parse the invocation's inner arguments with the app's - * own commander program, apply the resulting changes to the waiting rows, and - * release them by providing the startup service they inject. + * own commander program, provide the resolved values as `service`, and start + * the rows that were waiting for it. * - * A waiting row's config is resolved when the Loader creates its fiber, which - * happens while the row is still waiting, so writing a new config onto that - * fiber would never reach the plugin. Each changed row is therefore recycled — - * disabled, then re-enabled with its new values — which drops the stale fiber - * and resolves the config again. Recycling deliberately leaves `inject` alone: - * an `inject` update restarts the row from its unwrapped callback and loses the - * plugin's own static injections. + * 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. * * Help, version, and rejected arguments are terminal for the process: the text - * is written, every waiting row is disabled so the settlement audit sees a tree - * that was asked not to start this app, and `ctx.appExit` is requested. + * is written, the service is never provided, the app's rows stay disabled, and + * `ctx.appExit` is requested. * * An app that layers over another one (the one-shot bundle rides over the web - * bundle) disables the underlying startup row and names both startup services, - * because a composition has exactly one command-line owner: the rows of the app - * it absorbed then start on their composed values. + * bundle) disables the underlying startup row and names both services, because + * a composition has exactly one command-line owner: the rows of the app it + * absorbed then start on the values their own fallbacks name. * @param ctx - plugin context carrying `cmdlineArgs`, `appExit`, and the Loader. - * @param services - the startup service name, or names, that this app's rows declare in their `inject`. + * @param services - the service name, or names, this startup row provides. * @param program - the app's commander program, with its flags and description already declared. - * @param plan - this invocation's per-row changes; omitted starts the waiting rows unchanged. - * @returns nothing once the waiting rows are released, or once the exit was requested. - * @throws when the launcher provided no command line, when a startup service is - * declared by no row, or when `plan` names a row that is not waiting. + * @param plan - this invocation's resolved values; omitted provides an empty value. + * @returns the resolved values, or `undefined` when the app asked to exit + * instead (help, version, or arguments it rejected). + * @throws when the launcher provided no command line, or when a named service + * is injected by no row. */ -export async function runStartup( +export function runStartup( ctx: Context, services: string | readonly string[], program: Command, - plan: StartupPlan = () => new Map(), -): Promise { + plan: StartupPlan = (() => ({}) as T), +): T | undefined { const names = typeof services === 'string' ? [services] : services // Read through the global service store, not the property proxy: these are // optional host values, and a row that injects only `cmdlineArgs` may not @@ -180,75 +163,47 @@ export async function runStartup( writeOut: text => void internals.stdout.write(text), writeErr: text => void internals.stderr.write(text), }) - let decisions: Map - let rows: EntryOptions[] + let values: T try { program.parse(args.get(), { from: 'user' }) // An app can dispose the whole tree while this row is still parsing (an - // early SIGTERM, or another app exiting). There is then nothing to - // configure and nothing to release, and the checks below would blame the - // bundle for a tree that simply went away. - if (ctx.get('loader') === undefined) return - rows = waitingRows(ctx, names) - decisions = plan(program, rows) + // early SIGTERM, or another app exiting). There is then nothing to resolve + // 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)) } catch (error) { // exitOverride turns help, version, a parse error, and a plan's own // program.error() into a CommanderError; commander has already written the - // text through the output configured above. + // text through the output configured above. The app's rows ship disabled, + // so leaving them alone is what keeps the app unstarted. if (!isCommanderError(error)) throw error - for (const entry of waitingEntries(ctx, names)) await stopRow(entry) exit(error.exitCode) - return + return undefined } - const unknown = [...decisions.keys()].filter(id => !rows.some(row => row.id === id)) - if (unknown.length > 0) { - throw new Error(`${program.name()}: startup planned changes for row(s) ${unknown.join(', ')}, which inject none of ${names.join(', ')}`) - } - const contributed: PatchOptions[] = [] - for (const entry of waitingEntries(ctx, names)) { - const change = decisions.get(entry.options.id) - if (change === undefined) continue - await stopRow(entry) - await entry.update({ disabled: false, ...change }) - contributed.push({ id: entry.options.id, disabled: false, ...change }) - } - // Hand the same decisions to the launcher as patches, so a later - // recomposition of the tree (a user editing a live patch file) rebuilds - // these rows with this invocation's values instead of the composed ones. - if (contributed.length > 0) ctx.get('appPatches')?.contribute(contributed) - // The rows are ready; providing the service they inject starts them, and a - // row this invocation left disabled stays that way. - for (const service of names) ctx.provide(service, true) + for (const service of names) ctx.provide(service, values) + return values } /** - * Stop a waiting row, including one whose own mount is still in flight. + * Turn on a row this composition ships disabled, because this invocation asked + * for it (`dsh web --dev` and its client-plugin reload chain). * - * Disabling alone is not a barrier: a row whose init has not finished has no - * fiber yet, so the update returns while that init goes on to create one, and - * the re-enable would then take the config-patch path, which a still-waiting - * fiber never applies — the row would start on stale values. Letting the mount - * settle first gives the disable a fiber to dispose. A row the composition - * ships disabled has no mount to settle and is left alone. - * @param entry - the waiting row's Loader entry. + * 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. + * @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. */ -async function stopRow(entry: Entry): Promise { - await entry.refresh() - await entry.update({ disabled: true }) -} - -/** - * Merge flag overrides over a waiting row's composed config. - * - * A row's composed config is what the bundle patches and the user's own layers - * agreed on; a flag replaces exactly the keys it names and leaves the rest of - * that agreement intact. - * @param options - the waiting row's composed options. - * @param overrides - the values this invocation's flags decided, by config key. - * @returns the change to put in a {@link StartupPlan}'s map. - */ -export function overrideConfig(options: EntryOptions, overrides: Record): RowChange { - return { config: { ...(options.config ?? {}) as Record, ...overrides } } +export async function enableRow(ctx: Context, id: string): Promise { + const entry = [...ctx.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 }) } /** @@ -256,8 +211,8 @@ export function overrideConfig(options: EntryOptions, overrides: Record services.some(service => waitsFor(entry.options.inject, service))) + return [...ctx.loader.entries()].filter(entry => waitsForAny(entry.options.inject, services)) } /** @@ -298,14 +253,15 @@ function isCommanderError(error: unknown): error is { code: string; exitCode: nu } /** - * Whether a row's `inject` declaration names `service`. + * Whether a row's `inject` declaration names any of `services`. * @param inject - the row's `inject` value: the array form, the object form, or absent. - * @param service - the startup service name. - * @returns true when the row waits for it. + * @param services - the startup service names. + * @returns true when the row waits for one of them. */ -function waitsFor(inject: EntryOptions['inject'], service: string): boolean { +function waitsForAny(inject: EntryOptions['inject'], services: readonly string[]): boolean { if (inject === undefined || inject === null) return false // The array form lists service names; the object form maps each name to its // intercept config. Both name the service as a key of the same shape. - return Array.isArray(inject) ? inject.includes(service) : Object.hasOwn(inject, service) + const declared = Array.isArray(inject) ? inject : Object.keys(inject) + return services.some(service => declared.includes(service)) } diff --git a/packages/boot/cmdline/tests/cmdline.spec.ts b/packages/boot/cmdline/tests/cmdline.spec.ts index b4ea1a3624..ee405bb135 100644 --- a/packages/boot/cmdline/tests/cmdline.spec.ts +++ b/packages/boot/cmdline/tests/cmdline.spec.ts @@ -1,8 +1,8 @@ /** - * The launcher-to-app command line over a REAL Loader tree: a startup row parses the - * invocation's inner arguments and releases the rows waiting for it, waiting rows start - * with the resolved values, `--help` leaves the app unstarted, and a - * bundle whose patch and startup plugin disagree fails loud. + * 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. */ import { mkdtempSync, writeFileSync } from 'node:fs' @@ -13,12 +13,14 @@ import { Command } from 'commander' import { Context } from 'cordis' 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, overrideConfig, provideCmdline, runStartup, type RowChange, type StartupPlan } from '../src/index.ts' +import { internals, provideCmdline, runStartup, type StartupPlan } from '../src/index.ts' /** Every value one boot of the fixture tree observed. */ interface Observed { - applied: { id: string; config: Record }[] + /** Config the reading row started with; absent means it never started. */ + started?: Record exits: number[] out: string } @@ -27,13 +29,8 @@ interface Observed { interface Fixture { observed: Observed ctx: Context - /** Patches the startup row handed the launcher for later compositions. */ - contributed: unknown[] } -/** Cordis FiberState.ACTIVE, mirrored because the const enum has no runtime object. */ -const FIBER_ACTIVE = 2 - const disposers: (() => Promise)[] = [] afterEach(async () => { @@ -42,202 +39,145 @@ afterEach(async () => { internals.stderr = process.stderr }) -/** The fixture's flag family: one `--port` over the waiting row's composed config. */ +/** The fixture app's flag family: one `--port` its rows read from the service. */ function demoCommand(): Command { return new Command().name('demo').exitOverride().option('--port ', 'listen port') } -/** The fixture's plan: `--port` overrides the waiting row, absent leaves it composed. */ -const demoPlan: StartupPlan = (program, rows) => { +/** The fixture app's plan: the resolved values its rows read. */ +const demoPlan: StartupPlan<{ port?: number }> = (program) => { const port = program.opts<{ port?: string }>().port - if (port === undefined) return new Map() + if (port === undefined) return {} if (!/^\d+$/.test(port)) program.error(`error: --port must be a number, got ${JSON.stringify(port)}`) - const row = rows.find(candidate => candidate.id === 'waiting') - return new Map(row === undefined ? [] : [['waiting', overrideConfig(row, { port: Number(port) })]]) + return { port: Number(port) } } +/** A YAML `!!js` expression node, as the include parses one out of a patch file. */ +const expression = (source: string): unknown => ({ __jsExpr: source }) + /** - * Mount a tree with one waiting row, and — unless the caller drives startup - * itself — a startup row that calls {@link runStartup} on this package's real - * code path. + * Mount a two-row composition the way a profile boot does: the entrypoint row + * alone first, then everything. * @param args - the invocation's inner arguments. - * @param options - fixture knobs for the shapes a bundle patch can produce. + * @param plan - the app's plan; defaults to the fixture's own. * @returns the booted fixture. */ async function bootFixture( args: string[], - options: { injectObjectForm?: boolean; withoutStartupRow?: boolean; slowWaitingImport?: boolean } = {}, + plan: StartupPlan = demoPlan, + options: { withoutEntrypoint?: boolean } = {}, ): Promise { const dir = mkdtempSync(join(tmpdir(), 'dsh-cmdline-')) - const observed: Observed = { applied: [], exits: [], out: '' } - writeFileSync(join(dir, 'waiting.mjs'), ` -${options.slowWaitingImport === true ? 'await new Promise(resolve => setTimeout(resolve, 30))' : ''} -export const name = 'waiting' -export function apply(ctx, config) { globalThis.__observed.applied.push({ id: 'waiting', config }) } + const observed: Observed = { exits: [], out: '' } + writeFileSync(join(dir, 'reader.mjs'), ` +export const name = 'reader' +export const inject = ['demoStartup'] +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, 'startup.mjs'), ` -export const name = 'startup' + writeFileSync(join(dir, 'entrypoint.mjs'), ` +export const name = 'demo-startup' export const inject = ['cmdlineArgs'] export function apply(ctx) { return globalThis.__runStartup(ctx) } `) - writeFileSync(join(dir, 'cordis.yml'), [ - '- id: waiting', - ` name: ${pathToFileURL(join(dir, 'waiting.mjs')).href}`, - options.injectObjectForm === true ? ' inject: { demoStartup: null }' : ' inject: [demoStartup]', - ' config:', - ' port: 3080', - ' host: 127.0.0.1', - ...options.withoutStartupRow === true ? [] : [ - '- id: startup', - ` name: ${pathToFileURL(join(dir, 'startup.mjs')).href}`, - ], - '', - ].join('\n')) + writeFileSync(join(dir, 'cordis.yml'), '[]\n') const observing = { write: (chunk: string) => { observed.out += chunk; return true } } internals.stdout = observing internals.stderr = observing - const globals = globalThis as unknown as { __observed: Observed; __runStartup: (ctx: Context) => Promise } + const globals = globalThis as unknown as { __observed: Observed; __runStartup: (ctx: Context) => void } globals.__observed = observed - globals.__runStartup = (ctx: Context) => runStartup(ctx, 'demoStartup', demoCommand(), demoPlan) + globals.__runStartup = (ctx: Context) => { runStartup(ctx, 'demoStartup', demoCommand(), plan) } - const contributed: unknown[] = [] + // The composition, exactly as a profile delivers one: include patches whose + // config carries `!!js` expressions. + const composition: PatchOptions[] = [{ + insert: [ + ...options.withoutEntrypoint === true + ? [] + : [{ id: 'demo-startup', name: pathToFileURL(join(dir, 'entrypoint.mjs')).href }], + { + id: 'reader', + name: pathToFileURL(join(dir, 'reader.mjs')).href, + inject: ['demoStartup'], + config: { port: expression("ctx.get('demoStartup')?.port ?? 3080") }, + }, + ], + }] const ctx = new Context() await ctx.plugin(Loader) ctx.loader.builtins.include = Include - provideCmdline(ctx, { - args, - exit: code => void observed.exits.push(code), - contribute: patches => void contributed.push(...patches), + 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({ + name: 'cordis:include', + config: { ...rootConfig, patches: [...structuredClone(composition), { id: 'reader', disabled: true }] }, }) - await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(join(dir, 'cordis.yml')).href } }) await ctx.loader.await() disposers.push(async () => { await ctx.fiber.dispose() }) - return { observed, ctx, contributed } + 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('runStartup', () => { - it('starts a waiting row only after the startup service arrives, with the flag value applied over its composed config', async () => { + it('lets a row read the flag value the app resolved', async () => { const { observed } = await bootFixture(['--port', '8080']) - expect(observed.applied).toEqual([{ id: 'waiting', config: { port: 8080, host: '127.0.0.1' } }]) + expect(observed.started).toEqual({ port: 8080 }) expect(observed.exits).toEqual([]) }) - it('starts the waiting row unchanged when the invocation carries no flags', async () => { + it('leaves a row on the value written beside the expression when no flag names one', async () => { const { observed } = await bootFixture([]) - expect(observed.applied).toEqual([{ id: 'waiting', config: { port: 3080, host: '127.0.0.1' } }]) + expect(observed.started).toEqual({ port: 3080 }) }) - it('applies the flag value to a row whose own mount was still in flight', async () => { - // The row has no fiber yet when startup disables it, so the disable is not - // a barrier: the in-flight mount still produces one. Without disposing - // that late fiber, the row would start on its composed port. - const { observed } = await bootFixture(['--port', '8080'], { slowWaitingImport: true }) - expect(observed.applied).toEqual([{ id: 'waiting', config: { port: 8080, host: '127.0.0.1' } }]) - }) - - it('starts a row that injects the startup service in the intercept-map form of inject', async () => { - const { observed } = await bootFixture(['--port', '8080'], { injectObjectForm: true }) - expect(observed.applied).toEqual([{ id: 'waiting', config: { port: 8080, host: '127.0.0.1' } }]) - }) - - it('prints the app help, leaves the app unstarted, and requests exit 0', async () => { + 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') - expect(observed.applied).toEqual([]) + expect(observed.started).toBeUndefined() expect(observed.exits).toEqual([0]) }) it('rejects the invocation from the plan without starting the app', async () => { const { observed } = await bootFixture(['--port', 'abc']) expect(observed.out).toContain('--port must be a number') - expect(observed.applied).toEqual([]) + expect(observed.started).toBeUndefined() expect(observed.exits).toEqual([1]) }) -}) - -describe('startup-service lifetime', () => { - it('unloads the waiting rows when the startup row is disposed, and reopens on a fresh run', async () => { - // The startup service is an effect of the startup row: HMR restarting that - // row must take its app down with it, then bring it back. - const { ctx, observed } = await bootFixture(['--port', '8080']) - const startup = [...ctx.loader.entries()].find(entry => entry.options.id === 'startup') - const waiting = [...ctx.loader.entries()].find(entry => entry.options.id === 'waiting') - expect(waiting?.fiber?.state).toBe(FIBER_ACTIVE) - await startup?.update({ disabled: true }) - expect(waiting?.fiber?.state).not.toBe(FIBER_ACTIVE) - await startup?.update({ disabled: false }) - await ctx.loader.await() - expect(waiting?.fiber?.state).toBe(FIBER_ACTIVE) - // The second run re-resolved the same arguments, so the row is back on the - // flag value rather than the composed one. - expect(observed.applied.at(-1)).toEqual({ id: 'waiting', config: { port: 8080, host: '127.0.0.1' } }) - }) -}) - -describe('runStartup rejects a bundle that disagrees with its own patch', () => { - it('fails when no row declares the startup service it provides', async () => { - // The patch and its startup plugin disagree; a silent no-op would leave - // the app's rows waiting forever with no explanation. - const { ctx } = await bootFixture([], { withoutStartupRow: true }) - await expect(runStartup(ctx, 'absentStartup', demoCommand(), demoPlan)) - .rejects.toThrow('absentStartup: no row injects this startup service') - }) - - it('fails when the plan names a row that is not waiting', async () => { - const { ctx, observed } = await bootFixture([], { withoutStartupRow: true }) - const plan: StartupPlan = () => new Map([['not-waiting', {}]]) - await expect(runStartup(ctx, 'demoStartup', demoCommand(), plan)) - .rejects.toThrow('startup planned changes for row(s) not-waiting') - expect(observed.applied).toEqual([]) - }) it('rethrows a plan failure that is not commander asking to exit', async () => { - const { ctx, observed } = await bootFixture([], { withoutStartupRow: true }) + const { ctx } = await bootFixture([], demoPlan, { withoutEntrypoint: true }) const plan: StartupPlan = () => { throw new Error('plan exploded') } - await expect(runStartup(ctx, 'demoStartup', demoCommand(), plan)).rejects.toThrow('plan exploded') - expect(observed.exits).toEqual([]) + 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([], { withoutStartupRow: true }) + const { ctx } = await bootFixture([], demoPlan, { withoutEntrypoint: true }) const plan: StartupPlan = () => { const thrown: unknown = 'plan threw a string' throw thrown } - await expect(runStartup(ctx, 'demoStartup', demoCommand(), plan)).rejects.toThrow('plan threw a string') - }) -}) - -describe('the launcher patch layer', () => { - it('hands the startup row\'s decisions to the launcher as patches', async () => { - const { contributed } = await bootFixture(['--port', '8080']) - // The same decisions the rows started with: a launcher that recomposes its - // tree re-applies these, so an unrelated user edit cannot reset the port. - expect(contributed).toEqual([ - { id: 'waiting', disabled: false, config: { port: 8080, host: '127.0.0.1' } }, - ]) + expect(() => { runStartup(ctx, 'demoStartup', demoCommand(), plan) }).toThrow('plan threw a string') }) - it('contributes nothing when the invocation decided nothing', async () => { - const { contributed } = await bootFixture([]) - expect(contributed).toEqual([]) - }) -}) - -describe('an app with nothing to decide', () => { - it('starts every waiting row unchanged when it declares no plan', async () => { - const { ctx, observed } = await bootFixture([], { withoutStartupRow: true }) - // The list form of the service argument, which an app layering over - // another one uses to absorb that app's startup service. - await runStartup(ctx, ['demoStartup'], demoCommand()) - expect(observed.applied).toEqual([{ id: 'waiting', config: { port: 3080, host: '127.0.0.1' } }]) + 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 + // every row of the app on its fallbacks with no explanation. + const { ctx } = await bootFixture([], demoPlan, { withoutEntrypoint: true }) + expect(() => { runStartup(ctx, 'absentStartup', demoCommand()) }) + .toThrow('absentStartup: no row injects this startup service') }) - it('overrides a row that carries no composed config', () => { - expect(overrideConfig({ id: 'row', name: 'plugin' }, { port: 8080 })).toEqual({ config: { port: 8080 } }) + it('provides an empty value when the app declares no plan', async () => { + const { ctx } = await bootFixture([], demoPlan, { withoutEntrypoint: true }) + runStartup(ctx, 'demoStartup', demoCommand()) + expect(ctx.get('demoStartup')).toEqual({}) }) }) @@ -250,19 +190,19 @@ describe('provideCmdline', () => { expect(ctx.cmdlineArgs?.get()).toEqual(['--resume', 'abc']) }) - it('fails loud when a startup row runs without the launcher values', async () => { + it('fails loud when an entrypoint runs without the launcher values', () => { const ctx = new Context() - await expect(runStartup(ctx, 'demoStartup', demoCommand())) - .rejects.toThrow('the launcher must provide ctx.cmdlineArgs and ctx.appExit') + expect(() => { runStartup(ctx, 'demoStartup', demoCommand()) }) + .toThrow('the launcher must provide ctx.cmdlineArgs and ctx.appExit') }) - it('opens nothing, and blames nobody, when the tree was disposed while startup was parsing', async () => { - // An early SIGTERM disposes the Loader mid-parse. There is nothing left to - // open, and the bundle did nothing wrong. + it('resolves nothing when the tree was disposed while the entrypoint parsed', () => { + // An early SIGTERM takes the Loader with it; there is nothing left to + // configure, and the bundle did nothing wrong. const exits: number[] = [] const ctx = new Context() provideCmdline(ctx, { args: [], exit: code => void exits.push(code) }) - await expect(runStartup(ctx, 'demoStartup', demoCommand())).resolves.toBeUndefined() + expect(() => { runStartup(ctx, 'demoStartup', demoCommand()) }).not.toThrow() expect(exits).toEqual([]) }) }) diff --git a/packages/bundle/headless/cordis.patch.yml b/packages/bundle/headless/cordis.patch.yml index 6904931acc..eb8a2289cd 100644 --- a/packages/bundle/headless/cordis.patch.yml +++ b/packages/bundle/headless/cordis.patch.yml @@ -26,9 +26,10 @@ - id: headless-startup name: '@deepseek-ai/dsh-headless/startup' - # Shipped off, not merely waiting: the runner's schema requires the task. - # The startup row enables it with the task after parsing this app's argv. + # Reads its task from the headlessStartup service after the startup row + # resolves this app's command line. - id: headless-runner name: '@deepseek-ai/dsh-headless' inject: [headlessStartup] - disabled: true + config: + task: !!js ctx.get('headlessStartup')?.task diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index 5216aa3048..5d2af07463 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -33,7 +33,8 @@ "license": "BSD-3-Clause", "dsh": { "bundle": { - "patch": "./cordis.patch.yml" + "patch": "./cordis.patch.yml", + "entrypoint": "headless-startup" } }, "dependencies": { diff --git a/packages/bundle/headless/src/startup.ts b/packages/bundle/headless/src/startup.ts index eb9907a6b7..0f613aae08 100644 --- a/packages/bundle/headless/src/startup.ts +++ b/packages/bundle/headless/src/startup.ts @@ -15,7 +15,7 @@ import { Command } from 'commander' import type { Context } from 'cordis' import type { EntryOptions } from '@cordisjs/plugin-loader' -import { overrideConfig, runStartup, type RowChange } from '@deepseek-ai/dsh-cmdline' +import { runStartup } from '@deepseek-ai/dsh-cmdline' import { WEB_STARTUP_SERVICE } from '@deepseek-ai/dsh-web-app/startup' /** Stable Cordis plugin name. */ @@ -24,12 +24,18 @@ export const name = 'headless-startup' /** Services required before the task can be resolved. */ export const inject = ['cmdlineArgs'] -/** The startup service the one-shot runner row injects. */ +/** The service this row provides and the one-shot runner row reads. */ export const HEADLESS_STARTUP_SERVICE = 'headlessStartup' -/** The runner row this app configures. */ +/** The row that runs the task, and the only reason this app has a command line. */ const RUNNER_ROW_ID = 'headless-runner' +/** What the runner row reads from {@link HEADLESS_STARTUP_SERVICE}. */ +export interface HeadlessStartupValues { + /** The task text this invocation asked for. */ + task: string +} + /** * This app's command: the task positional, its description, and its help text. * @returns a fresh program, so one process can parse more than once (tests). @@ -49,22 +55,25 @@ Examples: /** * Turn the parsed command line into the runner row's task. * @param program - the parsed headless command. - * @param rows - the waiting rows' composed options, in tree order. - * @returns row id → changes. + * @param rows - the rows waiting on this app's service, in tree order. + * @returns the runner row's service value. + * @throws when the composition has no runner row, which would otherwise accept + * a task and silently run nothing. */ -function planHeadlessStartup(program: Command, rows: readonly EntryOptions[]): Map { +function planHeadlessStartup(program: Command, rows: readonly EntryOptions[]): HeadlessStartupValues { const task = program.args.join(' ') if (task === '') program.error('error: a task is required, for example: dsh --profile headless "run the tests"') - const runner = rows.find(row => row.id === RUNNER_ROW_ID) - if (runner === undefined) throw new Error(`headless-startup: the composition has no waiting "${RUNNER_ROW_ID}" row to run the task`) - return new Map([[RUNNER_ROW_ID, overrideConfig(runner, { task })]]) + if (!rows.some(row => row.id === RUNNER_ROW_ID)) { + throw new Error(`headless-startup: the composition has no waiting "${RUNNER_ROW_ID}" row to run the task`) + } + return { task } } /** - * Resolve the task and start the rows waiting for it. + * Resolve the task and start the runner that reads it. * @param ctx - plugin context carrying the command line and the Loader. - * @returns nothing once the runner is released, or once `--help` or a missing task requested exit. + * @returns nothing once the runner is started, or once `--help` or a missing task requested exit. */ -export function apply(ctx: Context): Promise { - return runStartup(ctx, [HEADLESS_STARTUP_SERVICE, WEB_STARTUP_SERVICE], headlessCommand(), planHeadlessStartup) +export function apply(ctx: Context): void { + runStartup(ctx, [HEADLESS_STARTUP_SERVICE, WEB_STARTUP_SERVICE], headlessCommand(), planHeadlessStartup) } diff --git a/packages/bundle/headless/tests/startup.spec.ts b/packages/bundle/headless/tests/startup.spec.ts index 1b4d6f1430..fc908306e3 100644 --- a/packages/bundle/headless/tests/startup.spec.ts +++ b/packages/bundle/headless/tests/startup.spec.ts @@ -1,7 +1,8 @@ /** - * The one-shot app's startup row over a REAL Loader tree: the task - * positional reaches the runner row, a missing task is a usage error, and the - * web startup service this app absorbs releases its rows on the composed values. + * The one-shot app's entrypoint 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. */ import { mkdtempSync, writeFileSync } from 'node:fs' @@ -9,21 +10,17 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { Context } from 'cordis' -import z from 'schemastery' import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' import { internals, provideCmdline } from '@deepseek-ai/dsh-cmdline' import { WEB_STARTUP_SERVICE } from '@deepseek-ai/dsh-web-app/startup' import { afterEach, describe, expect, it } from 'vitest' -import { apply, HEADLESS_STARTUP_SERVICE } from '../src/startup.ts' +import { apply, HEADLESS_STARTUP_SERVICE, type HeadlessStartupValues } from '../src/startup.ts' /** What one boot of the fixture tree observed. */ interface Observed { - started: Record> exits: number[] out: string - /** Patches the startup row handed the launcher for later compositions. */ - contributed: unknown[] } const disposers: (() => Promise)[] = [] @@ -35,112 +32,90 @@ afterEach(async () => { }) /** - * Boot the real headless startup row over stand-ins for the runner row and one - * web row it absorbs. + * Mount the real entrypoint 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. - * @returns what the boot observed. + * @param options - fixture knobs for the shapes a composition can take. + * @returns the resolved service values (absent when the app requested exit) and what the boot observed. */ -async function bootStartup(args: string[], options: { withoutRunner?: boolean } = {}): Promise { +async function bootStartup( + args: string[], + options: { withoutRunner?: boolean } = {}, +): Promise<{ task: HeadlessStartupValues | undefined; web: unknown; observed: Observed }> { const dir = mkdtempSync(join(tmpdir(), 'dsh-headless-startup-')) - const observed: Observed = { started: {}, exits: [], out: '', contributed: [] } - // The runner's real schema requires the task, which is exactly what makes a - // waiting-but-enabled row fail at fiber creation; the stand-in keeps that. - writeFileSync(join(dir, 'row.mjs'), ` -export const Config = globalThis.__headlessRunnerConfigSchema -export function apply(ctx, config) { globalThis.__headlessStartupObserved.started[ctx.fiber.entry.options.id] = config ?? {} } -`) - writeFileSync(join(dir, 'plain-row.mjs'), ` -export function apply(ctx, config) { globalThis.__headlessStartupObserved.started[ctx.fiber.entry.options.id] = config ?? {} } -`) + const observed: Observed = { exits: [], out: '' } + writeFileSync(join(dir, 'row.mjs'), 'export function apply() {}\n') // 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, 'startup-row.mjs'), ` + writeFileSync(join(dir, 'entrypoint.mjs'), ` export const name = 'headless-startup' export const inject = ['cmdlineArgs'] export const apply = ctx => globalThis.__headlessStartupApply(ctx) `) const rowUrl = pathToFileURL(join(dir, 'row.mjs')).href - const plainRowUrl = pathToFileURL(join(dir, 'plain-row.mjs')).href writeFileSync(join(dir, 'cordis.yml'), [ - // A composition that lost the runner still injects the startup service, so - // the startup row reaches its own row check rather than the generic one. + // A composition that lost the runner still injects the service, so the + // entrypoint 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}]`, - // Shipped off, like the bundle patch: the schema below requires the task, - // which only the startup row can supply. ' disabled: true', '- id: webserver', - ` name: ${plainRowUrl}`, + ` name: ${rowUrl}`, ` inject: [${WEB_STARTUP_SERVICE}]`, - ' config:', - ' port: 0', + ' disabled: true', '- id: headless-startup', - ` name: ${pathToFileURL(join(dir, 'startup-row.mjs')).href}`, + ` name: ${pathToFileURL(join(dir, 'entrypoint.mjs')).href}`, '', ].join('\n')) const observing = { write: (chunk: string) => { observed.out += chunk; return true } } internals.stdout = observing internals.stderr = observing - const globals = globalThis as unknown as { - __headlessStartupObserved: Observed - __headlessStartupApply: typeof apply - __headlessRunnerConfigSchema: unknown - } - globals.__headlessStartupObserved = observed - globals.__headlessStartupApply = apply - globals.__headlessRunnerConfigSchema = z.object({ task: z.string().required() }) + ;(globalThis as unknown as { __headlessStartupApply: typeof apply }).__headlessStartupApply = apply const ctx = new Context() await ctx.plugin(Loader) ctx.loader.builtins.include = Include - provideCmdline(ctx, { - args, - exit: code => void observed.exits.push(code), - contribute: patches => void observed.contributed.push(...patches), - }) + provideCmdline(ctx, { args, exit: code => void observed.exits.push(code) }) await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(join(dir, 'cordis.yml')).href } }) await ctx.loader.await() disposers.push(async () => { await ctx.fiber.dispose() }) - return observed + return { + task: ctx.get(HEADLESS_STARTUP_SERVICE) as HeadlessStartupValues | undefined, + web: ctx.get(WEB_STARTUP_SERVICE), + observed, + } } describe('headless startup', () => { - it('joins the task positional and starts the runner with it', async () => { - const observed = await bootStartup(['run', 'the', 'tests']) - expect(observed.started['headless-runner']).toEqual({ task: 'run the tests' }) + it('joins the task positional into the value the runner reads', async () => { + const { task, observed } = await bootStartup(['run', 'the', 'tests']) + expect(task).toEqual({ task: 'run the tests' }) expect(observed.exits).toEqual([]) }) - it('hands the task to the launcher as a patch, so a recomposition keeps it', async () => { - const observed = await bootStartup(['run', 'the', 'tests']) - expect(observed.contributed).toEqual([ - { id: 'headless-runner', disabled: false, config: { task: 'run the tests' } }, - ]) - }) - - it('starts the web rows it absorbed on the composed one-shot values', async () => { - const observed = await bootStartup(['task']) - expect(observed.started.webserver).toEqual({ port: 0 }) + it('provides the web service it absorbed, so those rows resolve on their own fallbacks', async () => { + const { web } = await bootStartup(['task']) + expect(web).toEqual({ task: 'task' }) }) it('rejects an invocation with no task instead of failing inside the runner schema', async () => { - const observed = await bootStartup([]) + const { task, observed } = await bootStartup([]) expect(observed.out).toContain('a task is required') - expect(observed.started).toEqual({}) + expect(task).toBeUndefined() expect(observed.exits).toEqual([1]) }) + it('prints its own help and resolves nothing', async () => { + const { task, observed } = await bootStartup(['--help']) + expect(observed.out).toContain('dsh --profile headless') + expect(task).toBeUndefined() + expect(observed.exits).toEqual([0]) + }) + it('fails the boot when the composition has no runner row to give the task to', async () => { await expect(bootStartup(['task'], { withoutRunner: true })) .rejects.toThrow('the composition has no waiting "headless-runner" row') }) - - it('prints its own help and starts nothing', async () => { - const observed = await bootStartup(['--help']) - expect(observed.out).toContain('dsh --profile headless') - expect(observed.started).toEqual({}) - expect(observed.exits).toEqual([0]) - }) }) diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 7825d68383..bc410b698b 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -5,11 +5,13 @@ # A patch replaces the targeted row's whole `config`, so each row below # restates every key it owns. # -# Rows this app configures from flags declare `inject: [webStartup]`: they wait -# until the web-startup row has parsed --host/--port/--dev/--workspace-root/ -# --trusted-host and provided that service with the resolved values. -# `dsh --profile web --help` therefore prints this app's own help and exits -# without ever binding a port. +# 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. # ── surface-specific values the base deliberately omits ───────────────────── @@ -79,9 +81,13 @@ # shares. The base layer's agent-default-model service owns the default model. - id: api-gateway name: '@deepseek-ai/dsh-host-apiproxy' + inject: [webStartup] + config: + workspaceRoot: !!js ctx.get('webStartup')?.workspaceRoot - # Owns the web flag family and its --help; provides webStartup with the - # values this invocation resolved. Nothing waiting on it starts first. + # 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. - id: web-startup name: '@deepseek-ai/dsh-web-app/startup' @@ -94,8 +100,8 @@ name: '@deepseek-ai/dsh-host-webserver' inject: [webStartup] config: - host: 127.0.0.1 - port: 3080 + host: !!js ctx.get('webStartup')?.host ?? '127.0.0.1' + port: !!js ctx.get('webStartup')?.port ?? 3080 # Web glue owned by this bundle: resolves the built frontend dist (an # assembly fact of dsh-web-app, never user config), mounts the @@ -108,11 +114,15 @@ name: '@deepseek-ai/dsh-web-app' inject: [webStartup] config: - mode: production + mode: !!js ctx.get('webStartup')?.mode ?? 'production' printUrl: true surfaceContext: true + lanAddresses: !!js ctx.get('webStartup')?.lanAddresses ?? [] - # The client-plugin HMR receiver ships disabled; `--dev` enables it. + # The client-plugin reload chain: a dev-only row this bundle ships off, + # which the entrypoint turns on for `--dev`. It is a row rather than a + # child of web-runtime because its node half is a client-side package, + # which a host-side bundle cannot import. - id: client-hmr name: '@deepseek-ai/dsh-client-hmr' inject: [webStartup] @@ -132,6 +142,11 @@ - id: connection name: '@deepseek-ai/dsh-client-connection' inject: [webStartup] + config: + # The LAN literals an all-interfaces bind derived plus the + # --trusted-host extras. A deployment that configures its own fence + # authorities adds them to this list. + trustedHosts: !!js ctx.get('webStartup')?.trustedHosts ?? [] - id: api-remotes name: '@deepseek-ai/dsh-api-remotes' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index e8240e1b63..0e2eff0e3b 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -33,7 +33,8 @@ "license": "BSD-3-Clause", "dsh": { "bundle": { - "patch": "./cordis.patch.yml" + "patch": "./cordis.patch.yml", + "entrypoint": "web-startup" } }, "dependencies": { diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts index 286a006f28..abf2ac4ca3 100644 --- a/packages/bundle/web-app/src/index.ts +++ b/packages/bundle/web-app/src/index.ts @@ -13,6 +13,7 @@ import { createRequire } from 'node:module' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' +import { enableRow } from '@deepseek-ai/dsh-cmdline' import * as FrontendStatic from '@deepseek-ai/dsh-frontend-static' import type {} from '@deepseek-ai/cordis-plugin-loader' import type {} from '@deepseek-ai/dsh-host-webserver' @@ -22,6 +23,9 @@ import type {} from '@deepseek-ai/dsh-bash-env' /** Stable Cordis plugin name. */ export const name = 'web-app' +/** The client-plugin reload chain row this bundle ships disabled, for `--dev`. */ +const HMR_ROW_ID = 'client-hmr' + /** Services required before the web runtime can mount. */ export const inject = ['httpServer'] @@ -112,6 +116,11 @@ export const internals: { resolveDistIndex: () => string } = { resolveDistIndex */ export function apply(ctx: Context, config: Config): void { 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) if (config.surfaceContext) { ctx.inject(['systemPrompt'], (promptCtx) => { promptCtx.systemPrompt.section({ @@ -143,15 +152,20 @@ export function apply(ctx: Context, config: Config): void { const port = ctx.httpServer.port console.log(`dsh web: ${localWebUrl(ctx)}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${String(port)})`}`) } - const loader = ctx.get('loader') - if (loader === undefined) printUrl() + // 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. + const settled = ctx.get('appReady') ?? ctx.get('loader')?.await() + if (settled === undefined) printUrl() else { - void loader.await().then(() => { - // The tree can be disposed while settlement was in flight (early + void settled.then(() => { + // The tree can be disposed while the boot was in flight (early // SIGTERM); a URL line for a dead server would only mislead, and // reading the torn-down port would turn a clean shutdown into a crash. if (ctx.get('httpServer') !== undefined) printUrl() - }) + // A failed boot is reported by the launcher; this row only stays quiet. + }, () => {}) } } } diff --git a/packages/bundle/web-app/src/startup.ts b/packages/bundle/web-app/src/startup.ts index 1b1969b0d6..96692e0116 100644 --- a/packages/bundle/web-app/src/startup.ts +++ b/packages/bundle/web-app/src/startup.ts @@ -12,7 +12,7 @@ import { networkInterfaces } from 'node:os' import { Command } from 'commander' import type { Context } from 'cordis' import type { EntryOptions } from '@cordisjs/plugin-loader' -import { overrideConfig, runStartup, type RowChange } from '@deepseek-ai/dsh-cmdline' +import { runStartup } from '@deepseek-ai/dsh-cmdline' /** Stable Cordis plugin name. */ export const name = 'web-startup' @@ -21,12 +21,32 @@ export const name = 'web-startup' export const inject = ['cmdlineArgs'] /** - * The startup service every flag-configured web row injects. The rows are - * listed in this bundle's `cordis.patch.yml`; a row this startup plans changes - * for without injecting the service fails loud. + * The service this row provides and every flag-configured web row reads. The + * rows are listed in this bundle's `cordis.patch.yml`, where each names the key + * it takes from here and the value it falls back to. */ export const WEB_STARTUP_SERVICE = 'webStartup' +/** What the web rows read from {@link WEB_STARTUP_SERVICE}. */ +export interface WebStartupValues { + /** `--host`, absent when the invocation did not name one. */ + host?: string + /** `--port`, absent when the invocation did not name one. */ + port?: number + /** `--workspace-root`, absent when the invocation did not name one. */ + workspaceRoot?: string + /** Web runtime mode; `--dev` selects development, which also mounts the client-plugin reload chain. */ + mode: 'production' | 'development' + /** + * The `/api` fence authorities for this invocation: the LAN literals an + * all-interfaces bind derived, plus the `--trusted-host` extras, over what + * the composition already configured. + */ + trustedHosts: string[] + /** The LAN literals the fence was configured with, for display. */ + lanAddresses: string[] +} + /** The webserver schema's all-interfaces bind literal: only this bind derives LAN authorities. */ const ALL_INTERFACES_HOST = '0.0.0.0' @@ -95,58 +115,39 @@ Examples: } /** - * Turn the parsed flags into the changes each waiting row needs. + * Turn the parsed flags into the values the web rows read. * @param program - the parsed web command. * @param rows - the waiting rows' composed options, in tree order. - * @returns row id → changes; rows absent from the map start on their composed values. + * @returns the web rows' service value. */ -function planWebStartup(program: Command, rows: readonly EntryOptions[]): Map { +function planWebStartup(program: Command, rows: readonly EntryOptions[]): WebStartupValues { const options = program.opts() if (options.port !== undefined && !/^\d+$/.test(options.port)) { program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`) } - const row = (id: string): EntryOptions => { - const found = rows.find(candidate => candidate.id === id) - if (found === undefined) throw new Error(`web-startup: the web composition has no waiting "${id}" row to configure`) - return found - } - const plan = new Map() - const webserver = row('webserver') - const composedHost = (webserver.config as { host?: string } | undefined)?.host - plan.set('webserver', overrideConfig(webserver, { + const webserver = rows.find(row => row.id === 'webserver') + if (webserver === undefined) throw new Error('web-startup: the web composition has no waiting "webserver" row to configure') + // The bind this invocation ends on: the flag, else what the row falls back + // to, which is the same literal its config expression names. + const bindHost = options.host ?? (webserver.config as { host?: string } | undefined)?.host + const { lanAddresses, trustedHosts } = resolveLanTrust(bindHost, options.trustedHost ?? []) + return { ...options.host !== undefined && { host: options.host }, ...options.port !== undefined && { port: Number(options.port) }, - })) - if (options.workspaceRoot !== undefined) { - plan.set('api-gateway', overrideConfig(row('api-gateway'), { workspaceRoot: options.workspaceRoot })) - } - const { lanAddresses, trustedHosts } = resolveLanTrust(options.host ?? composedHost, options.trustedHost ?? []) - if (trustedHosts.length > 0) { - // Additive over the composed value: a cordis.patch.yml-configured fence - // authority must survive the derived LAN literals and the flag extras — - // dropping it silently would weaken security-relevant configuration. - const connection = row('connection') - const composedTrusted = (connection.config as { trustedHosts?: string[] } | undefined)?.trustedHosts ?? [] - plan.set('connection', overrideConfig(connection, { trustedHosts: [...composedTrusted, ...trustedHosts] })) - } - // mode and lanAddresses are resolved on every boot, never pass-throughs of - // composed values: they describe this invocation, not the deployment. - plan.set('web-runtime', overrideConfig(row('web-runtime'), { + ...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot }, + // mode and lanAddresses describe this invocation, never the deployment, so + // they are resolved on every boot. mode: options.dev === true ? 'development' : 'production', + trustedHosts, lanAddresses, - })) - // The receiver ships disabled so `--dev` is a row toggle rather than a - // runtime insert (the Loader cannot resolve a row inserted from inside a - // mounting plugin). - if (options.dev === true) plan.set('client-hmr', { disabled: false }) - return plan + } } /** - * Resolve the web flag family and start the rows waiting for it. + * Resolve the web flag family and start the rows that read it. * @param ctx - plugin context carrying the command line and the Loader. - * @returns nothing once the waiting rows are released, or once `--help` requested exit. + * @returns nothing once the web rows are started, or once `--help` requested exit. */ -export function apply(ctx: Context): Promise { - return runStartup(ctx, WEB_STARTUP_SERVICE, webCommand(), planWebStartup) +export function apply(ctx: Context): void { + runStartup(ctx, WEB_STARTUP_SERVICE, webCommand(), planWebStartup) } diff --git a/packages/bundle/web-app/tests/startup.spec.ts b/packages/bundle/web-app/tests/startup.spec.ts index c3cdfa08dc..7ac0f5192c 100644 --- a/packages/bundle/web-app/tests/startup.spec.ts +++ b/packages/bundle/web-app/tests/startup.spec.ts @@ -1,8 +1,8 @@ /** - * The web app's startup row over a REAL Loader tree carrying this bundle's - * waiting row ids: flags reach the rows they configure, absent flags leave the - * composed values standing, `--dev` enables the shipped-disabled HMR receiver, - * and `--help` leaves the app unstarted. + * The web app's entrypoint 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. */ import { mkdtempSync, writeFileSync } from 'node:fs' @@ -14,7 +14,7 @@ import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' import { internals, provideCmdline } from '@deepseek-ai/dsh-cmdline' import { afterEach, describe, expect, it, vi } from 'vitest' -import { apply, WEB_STARTUP_SERVICE } from '../src/startup.ts' +import { apply, WEB_STARTUP_SERVICE, type WebStartupValues } from '../src/startup.ts' vi.mock('node:os', async importOriginal => ({ ...await importOriginal(), @@ -26,8 +26,6 @@ vi.mock('node:os', async importOriginal => ({ /** What one boot of the fixture tree observed. */ interface Observed { - /** Config each waiting row started with, by row id; absent means it never started. */ - started: Record> exits: number[] out: string } @@ -40,57 +38,57 @@ afterEach(async () => { internals.stderr = process.stderr }) -/** One stand-in for a row this bundle's patch makes wait for the web startup. */ -interface WaitingRow { - id: string - config?: Record - disabled?: boolean -} - -/** The waiting rows this bundle's patch declares, with the composed values they ship. */ -const WAITING_ROWS: WaitingRow[] = [ - { id: 'webserver', config: { host: '127.0.0.1', port: 3080 } }, - { id: 'api-gateway', config: { provider: 'deepseek-official' } }, - { id: 'connection', config: { trustedHosts: ['configured.internal'] } }, - { id: 'web-runtime', config: { mode: 'production', printUrl: true } }, - { id: 'client-hmr', disabled: true }, -] - /** - * Boot the real startup row over stand-ins for this bundle's waiting rows. + * Mount the real entrypoint 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. - * @returns what the boot observed. + * @param webserverConfig - the composed `webserver` row config, or `null` to omit the row. + * @returns the resolved service value (absent when the app requested exit) and what the boot observed. */ -async function bootStartup(args: string[], rows: readonly WaitingRow[] = WAITING_ROWS): Promise { +async function bootStartup( + args: string[], + webserverConfig: Record | null = { host: '127.0.0.1', port: 3080 }, +): Promise<{ values: WebStartupValues | undefined; observed: Observed; ctx: Context }> { const dir = mkdtempSync(join(tmpdir(), 'dsh-web-startup-')) - const observed: Observed = { started: {}, exits: [], out: '' } - writeFileSync(join(dir, 'row.mjs'), ` -export function apply(ctx, config) { globalThis.__webStartupObserved.started[ctx.fiber.entry.options.id] = config ?? {} } -`) + const observed: Observed = { exits: [], out: '' } + writeFileSync(join(dir, 'row.mjs'), 'export function apply() {}\n') // 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, 'startup-row.mjs'), ` + writeFileSync(join(dir, 'entrypoint.mjs'), ` export const name = 'web-startup' export const inject = ['cmdlineArgs'] export const apply = ctx => globalThis.__webStartupApply(ctx) `) const rowUrl = pathToFileURL(join(dir, 'row.mjs')).href - const lines = rows.flatMap(row => [ - `- id: ${row.id}`, + writeFileSync(join(dir, 'cordis.yml'), [ + ...webserverConfig === null ? [] : [ + '- id: webserver', + ` name: ${rowUrl}`, + ` inject: [${WEB_STARTUP_SERVICE}]`, + ' disabled: true', + ' config:', + ...Object.entries(webserverConfig).map(([key, value]) => ` ${key}: ${JSON.stringify(value)}`), + ], + // A second reader keeps the composition honest when the webserver row is + // the one under test: the service must still have someone to serve. + '- id: web-runtime', ` name: ${rowUrl}`, ` inject: [${WEB_STARTUP_SERVICE}]`, - ...row.disabled === true ? [' disabled: true'] : [], - ...row.config === undefined ? [] : [' config:', ...Object.entries(row.config).map(([key, value]) => ` ${key}: ${JSON.stringify(value)}`)], - ]) - lines.push('- id: web-startup', ` name: ${pathToFileURL(join(dir, 'startup-row.mjs')).href}`) - writeFileSync(join(dir, 'cordis.yml'), lines.join('\n') + '\n') + ' disabled: true', + // The reload chain this bundle ships off, which `--dev` turns on. + '- id: client-hmr', + ` name: ${rowUrl}`, + ` inject: [${WEB_STARTUP_SERVICE}]`, + ' disabled: true', + '- id: web-startup', + ` name: ${pathToFileURL(join(dir, 'entrypoint.mjs')).href}`, + '', + ].join('\n')) const observing = { write: (chunk: string) => { observed.out += chunk; return true } } internals.stdout = observing internals.stderr = observing - const globals = globalThis as unknown as { __webStartupObserved: Observed; __webStartupApply: typeof apply } - globals.__webStartupObserved = observed - globals.__webStartupApply = apply + ;(globalThis as unknown as { __webStartupApply: typeof apply }).__webStartupApply = apply const ctx = new Context() await ctx.plugin(Loader) @@ -99,65 +97,67 @@ export const apply = ctx => globalThis.__webStartupApply(ctx) await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(join(dir, 'cordis.yml')).href } }) await ctx.loader.await() disposers.push(async () => { await ctx.fiber.dispose() }) - return observed + return { values: ctx.get(WEB_STARTUP_SERVICE) as WebStartupValues | undefined, observed, ctx } } + describe('web startup', () => { - it('applies each flag to the row that owns it and leaves the rest composed', async () => { - const observed = await bootStartup(['--port', '8080', '--workspace-root', '/w']) - expect(observed.started.webserver).toEqual({ host: '127.0.0.1', port: 8080 }) - expect(observed.started['api-gateway']).toEqual({ provider: 'deepseek-official', workspaceRoot: '/w' }) - expect(observed.started['web-runtime']).toEqual({ mode: 'production', printUrl: true, lanAddresses: [] }) - expect(observed.started['client-hmr']).toBeUndefined() - expect(observed.exits).toEqual([]) + it('resolves each flag into the value its row reads', async () => { + const { values } = await bootStartup(['--port', '8080', '--workspace-root', '/w']) + expect(values).toEqual({ + port: 8080, + workspaceRoot: '/w', + mode: 'production', + trustedHosts: [], + lanAddresses: [], + }) }) - it('starts every row on its composed values when the invocation carries no flags', async () => { - const observed = await bootStartup([]) - expect(observed.started.webserver).toEqual({ host: '127.0.0.1', port: 3080 }) - expect(observed.started.connection).toEqual({ trustedHosts: ['configured.internal'] }) + it('names no value for a flag the invocation left out, so each row keeps its own', async () => { + const { values } = await bootStartup([]) + expect(values).toEqual({ mode: 'production', trustedHosts: [], lanAddresses: [] }) + expect(values).not.toHaveProperty('host') + expect(values).not.toHaveProperty('port') }) - it('adds the LAN literals over the configured fence authorities for an all-interfaces bind', async () => { - const observed = await bootStartup(['--host', '0.0.0.0', '--trusted-host', 'lab.internal']) - expect(observed.started.webserver).toEqual({ host: '0.0.0.0', port: 3080 }) - expect(observed.started.connection).toEqual({ trustedHosts: ['configured.internal', '192.168.1.5', 'lab.internal'] }) + it('derives the LAN literals for an all-interfaces bind, and the extras with them', async () => { + const { values } = await bootStartup(['--host', '0.0.0.0', '--trusted-host', 'lab.internal']) + expect(values?.trustedHosts).toEqual(['192.168.1.5', 'lab.internal']) // Display gets the same single sample the fence was configured with. - expect(observed.started['web-runtime']).toEqual({ mode: 'production', printUrl: true, lanAddresses: ['192.168.1.5'] }) + expect(values?.lanAddresses).toEqual(['192.168.1.5']) }) - it('enables the shipped-disabled HMR receiver for --dev', async () => { - const observed = await bootStartup(['--dev']) - expect(observed.started['client-hmr']).toEqual({}) - expect(observed.started['web-runtime']).toEqual({ mode: 'development', printUrl: true, lanAddresses: [] }) + it('reads the composed bind when no flag names one, so a configured 0.0.0.0 still derives them', async () => { + const { values } = await bootStartup([], { host: '0.0.0.0', port: 3080 }) + expect(values?.lanAddresses).toEqual(['192.168.1.5']) }) - it('prints its own help and starts nothing', async () => { - const observed = await bootStartup(['--help']) + it('reports the development mode for --dev, which the web runtime reads', async () => { + const { values } = await bootStartup(['--dev']) + // The runtime row is what turns the reload chain on, in the phase whose + // host rows it needs; this row only reports the mode. + expect(values?.mode).toBe('development') + }) + + it('prints its own help and resolves nothing', async () => { + const { values, observed } = await bootStartup(['--help']) expect(observed.out).toContain('dsh --profile web') expect(observed.out).toContain('--trusted-host') - expect(observed.started).toEqual({}) + expect(values).toBeUndefined() expect(observed.exits).toEqual([0]) }) - it('fails the boot when the composition lost a row this app configures', async () => { - // The bundle patch and this startup plugin must agree on the row set; a - // missing row would otherwise silently drop the flag that targets it. - const withoutWebserver = WAITING_ROWS.filter(row => row.id !== 'webserver') - await expect(bootStartup([], withoutWebserver)) - .rejects.toThrow('the web composition has no waiting "webserver" row') - }) - - it('derives the fence authorities alone when the composition configured none', async () => { - const withoutTrust = WAITING_ROWS.map(row => row.id === 'connection' ? { id: 'connection' } : row) - const observed = await bootStartup(['--host', '0.0.0.0'], withoutTrust) - expect(observed.started.connection).toEqual({ trustedHosts: ['192.168.1.5'] }) - }) - it('rejects a non-numeric port before anything binds', async () => { - const observed = await bootStartup(['--port', 'abc']) + const { values, observed } = await bootStartup(['--port', 'abc']) expect(observed.out).toContain('--port must be a number') - expect(observed.started).toEqual({}) + expect(values).toBeUndefined() expect(observed.exits).toEqual([1]) }) + + 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 + // 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 eeb2aefc07..ab56e87db4 100644 --- a/packages/bundle/web-app/tests/web-app.spec.ts +++ b/packages/bundle/web-app/tests/web-app.spec.ts @@ -131,6 +131,38 @@ describe('web-app runtime glue', () => { await ctx.fiber.dispose() }) + it('waits for the launcher readiness the phased boot provides, and stays quiet when that 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. + 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 new Promise(resolve => setTimeout(resolve, 0)) + expect(log).not.toHaveBeenCalled() + announce!() + await new Promise(resolve => setTimeout(resolve, 0)) + expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567') + await ready.fiber.dispose() + + // A boot that failed announces nothing: the launcher reports it, and a URL + // for a process that is about to exit would only mislead. + log.mockClear() + const failed = new Context() + failed.provide('httpServer', fakeHttpServer().server) + const rejection = Promise.reject(new Error('boot failed')) + rejection.catch(() => {}) + failed.provide('appReady', rejection) + apply(failed, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(log).not.toHaveBeenCalled() + await failed.fiber.dispose() + }) + it('defers the URL line until Loader settlement and drops it when the server is gone', async () => { stageDist() // Settlement path: the line waits for loader.await() so supervisors can From b692f38506f4b1f0ff2e74f6ee017b691c52feb5 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 7 Aug 2026 15:43:26 +0800 Subject: [PATCH 06/19] refactor(cli): discover app startup rows from injection --- ...026-08-06-app-owned-command-line.i18n.yaml | 4 +- .../2026-08-06-app-owned-command-line.md | 33 +++---- .../2026-08-06-app-owned-command-line.zh.md | 33 +++---- docs/user/develop/basic/publish.i18n.yaml | 4 +- docs/user/develop/basic/publish.md | 17 +++- docs/user/develop/basic/publish.zh.md | 29 ++++-- docs/user/guide/config.i18n.yaml | 4 +- docs/user/guide/config.md | 11 ++- docs/user/guide/config.zh.md | 13 ++- packages/boot/app-boot/src/index.ts | 7 +- packages/boot/app-boot/src/profile.ts | 50 +--------- packages/boot/app-boot/tests/profile.spec.ts | 32 ------- packages/boot/cmdline/README.i18n.yaml | 4 +- packages/boot/cmdline/README.md | 24 +++-- packages/boot/cmdline/README.zh.md | 24 +++-- packages/boot/cmdline/package.json | 3 +- packages/boot/cmdline/src/index.ts | 66 +++++++------ packages/boot/cmdline/tests/cmdline.spec.ts | 92 +++++++++++++------ packages/bundle/headless/cordis.patch.yml | 7 +- packages/bundle/headless/package.json | 5 +- packages/bundle/headless/src/startup.ts | 8 +- .../bundle/headless/tests/startup.spec.ts | 11 ++- packages/bundle/web-app/cordis.patch.yml | 17 ++-- packages/bundle/web-app/package.json | 3 +- packages/bundle/web-app/src/index.ts | 33 ++++--- packages/bundle/web-app/tests/startup.spec.ts | 11 ++- packages/bundle/web-app/tests/web-app.spec.ts | 37 +++++--- pnpm-lock.yaml | 3 - 28 files changed, 302 insertions(+), 283 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml index 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: From 7e3a82eacc5ba8206a70ab187ddea882f8030f76 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 7 Aug 2026 17:27:38 +0800 Subject: [PATCH 07/19] refactor(loader): resolve config after injected services --- ...026-08-05-profile-plugin-bundles.i18n.yaml | 4 +- .../2026-08-05-profile-plugin-bundles.md | 6 +- .../2026-08-05-profile-plugin-bundles.zh.md | 6 +- ...026-08-06-app-owned-command-line.i18n.yaml | 4 +- .../2026-08-06-app-owned-command-line.md | 4 +- .../2026-08-06-app-owned-command-line.zh.md | 4 +- docs/cordis-api/fiber.i18n.yaml | 4 +- docs/cordis-api/fiber.md | 24 ++-- docs/cordis-api/fiber.zh.md | 24 ++-- docs/cordis-primer.i18n.yaml | 4 +- docs/cordis-primer.md | 2 +- docs/cordis-primer.zh.md | 2 +- packages/boot/app-boot/src/index.ts | 27 +--- packages/boot/app-boot/tests/app-boot.spec.ts | 34 +++++- .../boot/app-boot/tests/user-patches.spec.ts | 115 ++++++++++-------- packages/boot/cmdline/README.i18n.yaml | 4 +- packages/boot/cmdline/README.md | 16 +-- packages/boot/cmdline/README.zh.md | 16 +-- packages/bundle/headless/README.i18n.yaml | 4 +- packages/bundle/headless/README.md | 2 +- packages/bundle/headless/README.zh.md | 2 +- packages/bundle/headless/cordis.patch.yml | 2 +- packages/bundle/headless/src/startup.ts | 2 +- .../bundle/headless/tests/startup.spec.ts | 58 ++++----- packages/bundle/web-app/cordis.patch.yml | 14 +-- packages/bundle/web-app/src/startup.ts | 52 ++++++-- packages/bundle/web-app/tests/startup.spec.ts | 51 ++++++-- scripts/test-invariants.spec.ts | 75 +++--------- scripts/test-invariants.ts | 25 ++-- vendor/README.md | 3 +- vendor/cordis/src/events.ts | 6 + vendor/cordis/src/fiber.ts | 31 +++-- vendor/hmr/src/index.ts | 2 +- vendor/include/src/index.ts | 17 ++- vendor/loader/src/config/entry.ts | 35 +++--- vendor/loader/src/config/group.ts | 4 + vendor/loader/src/config/tree.ts | 2 +- vendor/loader/src/index.ts | 23 +++- 38 files changed, 404 insertions(+), 306 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml index 938e802716..baee9e065f 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.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-05-profile-plugin-bundles.md -2026-08-05-profile-plugin-bundles.md: 2924b3cb445064fd47d82bcc94ec8d77ded5721b -2026-08-05-profile-plugin-bundles.zh.md: b2287034010bcac1048bb385b2266f1bc75921da +2026-08-05-profile-plugin-bundles.md: 385977b2d085a39bcda89bca0fb6543f08e7a961 +2026-08-05-profile-plugin-bundles.zh.md: 22ed4100b97db3f7c48bf55688f1a78edb512add diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md index 2924b3cb44..385977b2d0 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md @@ -10,11 +10,9 @@ The `dsh` launcher hardcoded its compositions: `base.cordis.yml` + `web.cordis.y ## Decision -Everything becomes a **profile**: a directory `$DSH_HOME/profiles/` with a `package.json` (pnpm-managed out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list) and a user `cordis.patch.yml`. A **bundle** is an npm package declaring `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the two manifest kinds live under distinct `dsh.profile` / `dsh.bundle` keys so a package.json states which role it plays. The tree composes over an empty root by applying each bundle's patch in `dsh.profile.bundles` order, then the user layer, then `--patch` overlays, then flag patches — one `applyEntryPatches` call, identical for boot, flag derivation, and `--dump-config`. +Everything becomes a **profile**: a directory `$DSH_HOME/profiles/` with a `package.json` (pnpm-managed out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list) and a user `cordis.patch.yml`. A **bundle** is an npm package declaring `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the two manifest kinds live under distinct `dsh.profile` / `dsh.bundle` keys so a package.json states which role it plays. The tree composes over an empty root by applying each bundle's patch in `dsh.profile.bundles` order, then the user layer and `--patch` overlays — one `applyEntryPatches` call shared by boot and `--dump-config`. App invocation values later moved from launcher-derived patches to startup services in the [app-owned command-line decision](2026-08-06-app-owned-command-line.md). -The shipped bundles are `@deepseek-ai/dsh-base` (shared core rows), `@deepseek-ai/dsh-web-app` (browser Host rows and Web runtime glue), and `@deepseek-ai/dsh-headless` (a direct one-shot runner over base, without web-app). `dsh web` is the Web-flag alias for `--profile web`; `dsh run [--profile ] "task"` owns one-shot execution and defaults to the headless profile; generic `dsh --profile ` boots without a task. Patch overlays use `--patch`. `dsh plugin --profile ` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract. - -The [`dsh run` command decision](../feature/2026-08-08-dsh-run-headless-command.md) owns the one-shot grammar; this note owns the profile composition it selects. +The shipped bundles are `@deepseek-ai/dsh-base` (shared core rows), `@deepseek-ai/dsh-web-app` (browser Host rows and Web runtime glue), and `@deepseek-ai/dsh-headless` (a direct one-shot runner over base, without web-app). Generic `dsh --profile ` hands its remaining arguments to that profile's command-line startup row: Web owns its flag family, while headless owns its task positional. Patch overlays use launcher-owned `--patch`. `dsh plugin --profile ` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract. Resolution is two-anchored by construction: `dsh.profile.bundles` names resolve from the dsh installation first, then the profile directory — so in-box bundles always come from the same installation as the running `dsh` and pnpm never manages them — while bare plugin names in patch rows resolve through the profile directory's Node parent-walk into the maintained flat fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch). diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md index b228703401..22ed4100b9 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md @@ -10,11 +10,9 @@ Status: implemented ## Decision -一切都变成 **profile**:即目录 `$DSH_HOME/profiles/`,其中包含一个 `package.json`(pnpm 管理的树外插件 `dependencies`,加上 profile manifest(元数据清单)`dsh.profile` 及其有序的 `bundles` 层列表)和一份用户 `cordis.patch.yml`。**组合包**(bundle)是声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;两种 manifest 分别位于互不相同的 `dsh.profile` / `dsh.bundle` 键下,因此一份 package.json 能说明自己扮演哪种角色。配置树在空的根之上组合:按 `dsh.profile.bundles` 顺序应用每个组合包的 patch,然后是用户层,然后是 `--patch` overlay,最后是 flag patch——全部收敛为一次 `applyEntryPatches` 调用,启动、flag 派生与 `--dump-config` 使用完全相同的路径。 +一切都变成 **profile**:即目录 `$DSH_HOME/profiles/`,其中包含一个 `package.json`(pnpm 管理的树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和一份用户 `cordis.patch.yml`。**组合包**(bundle)是声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;两种 manifest 分别位于互不相同的 `dsh.profile` / `dsh.bundle` 键下,因此一份 package.json 能说明自己扮演哪种角色。配置树在空的根之上组合:按 `dsh.profile.bundles` 顺序应用每个组合包的 patch,然后是用户层与 `--patch` overlay——启动与 `--dump-config` 共享同一条 `applyEntryPatches` 路径。随后,[应用持有命令行的决策](2026-08-06-app-owned-command-line.md)又把调用期取值从启动器派生的 patch 迁移到了启动服务。 -随附的组合包是 `@deepseek-ai/dsh-base`(共享核心配置行)、`@deepseek-ai/dsh-web-app`(浏览器 Host 配置行与 Web 运行时粘合层)和 `@deepseek-ai/dsh-headless`(直接叠加在 base 上且不含 web-app 的一次性 runner)。`dsh web` 是携带 Web flag 家族的 `--profile web` 别名;`dsh run [--profile ] "task"` 负责一次性执行,默认使用 headless profile;通用的 `dsh --profile ` 启动 profile 而不携带任务。patch overlay 使用 `--patch`。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](2026-08-09-headless-direct-core-entry-point.md)负责 headless 组合约定。 - -[`dsh run` 命令决策](../feature/2026-08-08-dsh-run-headless-command.md)负责一次性语法;本 Agent Note 负责该语法所选择的 profile 组合。 +随附的组合包是 `@deepseek-ai/dsh-base`(共享核心配置行)、`@deepseek-ai/dsh-web-app`(浏览器 Host 配置行与 Web 运行时粘合层)和 `@deepseek-ai/dsh-headless`(直接叠加在 base 上且不含 web-app 的一次性 runner)。通用的 `dsh --profile ` 把剩余参数交给该 profile 的命令行启动行:Web 持有自己的 flag 家族,headless 则持有任务位置参数。patch overlay 使用启动器持有的 `--patch`。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](2026-08-09-headless-direct-core-entry-point.md)负责 headless 组合约定。 解析在构造上就是双锚点的:`dsh.profile.bundles` 中的名称先从 dsh 安装目录解析,再从 profile 目录解析——因此内置组合包始终来自与运行中 `dsh` 相同的安装,pnpm 从不管理它们——而 patch 行中的裸插件名称经 profile 目录的 Node 父目录逐级查找,落到受维护的扁平回退目录 `$DSH_HOME/profiles/node_modules`(安装目录的应用与各组合包所依赖的每个包各一个符号链接,每次启动时修复)。 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 f38cc36d1e..728edb1ec0 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: e533338118f1b195589ed05ad972d1d4a55e610c -2026-08-06-app-owned-command-line.zh.md: 00f492629fd08383726e71ad7eea608df22fb772 +2026-08-06-app-owned-command-line.md: 269f9193e6cf7852ba9652c961bfdd309080ae0b +2026-08-06-app-owned-command-line.zh.md: 943932062983622267f28591dcc22ca2d12274e0 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 e533338118..269f9193e6 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 @@ -25,8 +25,8 @@ Two further consequences. Loader mounts sibling rows concurrently, so one row ca Four framework facts shape the mechanism: - **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. +- **Cordis activates a fiber only after all declared injections are active.** Immediately before each activation, Cordis runs the `internal/config` waterfall against the fiber's own context; Loader's listener interpolates the raw config after Cordis snapshots its injected services. +- **Provider replacement and HMR must preserve the same contract.** Fiber reactivation re-runs the waterfall, HMR carries the raw config 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. 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. 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 00f492629f..9439320629 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 @@ -25,8 +25,8 @@ boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Lo 四条框架事实塑造了这套机制: - **profile 的各行位于根 include 的 `patches` 选项内部。** Include 是条目树所有者,因此它的静态条目配置解析器会插值 Include 自身的选项,同时为目标行保留嵌套的 `!!js` 节点,而不是在 Include 上下文中递归求值。 -- **Cordis 只在所有声明的注入都已激活后才激活 fiber。** Loader 为该 fiber 提供延迟配置解析器;Cordis 快照注入服务之后,解析器会在每次激活前一刻基于 fiber 自身上下文运行。 -- **提供方替换与 HMR 必须保持相同契约。** fiber 重新激活时会重跑解析器,HMR 会把它带给替换 fiber,而待处理行可以接受选项变更,不会针对缺失服务提前求值表达式。 +- **Cordis 只在所有声明的注入都已激活后才激活 fiber。** 每次激活前一刻,Cordis 会基于 fiber 自身上下文运行 `internal/config` waterfall;Cordis 快照注入服务之后,Loader 的监听器再插值原始配置。 +- **提供方替换与 HMR 必须保持相同契约。** fiber 重新激活时会重跑 waterfall,HMR 会把原始配置带给替换 fiber,而待处理行可以接受选项变更,不会针对缺失服务提前求值表达式。 - **不能从正在挂载的插件内部插入一行**——`tree.create` 返回一个带前缀的 id,随后它自己解析不出来——因此条件性的行以 `disabled: true` 交付,再由活跃行启用(`dsh web --dev` 及其重载链路);启用后的行继续遵循普通注入顺序。 这样,依赖顺序就由真正持有它的接缝负责。各行保留自己的 `inject` 和配置,Loader 只挂载一次组合,启动器只提供 argv 与进程生命周期服务。 diff --git a/docs/cordis-api/fiber.i18n.yaml b/docs/cordis-api/fiber.i18n.yaml index 6c01366dc1..537be01dbc 100644 --- a/docs/cordis-api/fiber.i18n.yaml +++ b/docs/cordis-api/fiber.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/cordis-api/fiber.md -fiber.md: 36d2861ac6a53e8186a92d86c65ba228d4b59ee5 -fiber.zh.md: fafa559ca911677c43893862190009d82c39c56b +fiber.md: 182b77390b29b8a90504437d0ccc2dfeba23921a +fiber.zh.md: 9ed3e52618586dc3815b9d913439d11a227fb64b diff --git a/docs/cordis-api/fiber.md b/docs/cordis-api/fiber.md index 36d2861ac6..182b77390b 100644 --- a/docs/cordis-api/fiber.md +++ b/docs/cordis-api/fiber.md @@ -34,7 +34,7 @@ Register a cleanup-aware effect on this fiber. **Returns** a disposer that tears the effect down and settles once done. -[Source](../../vendor/cordis/src/fiber.ts#L420) +[Source](../../vendor/cordis/src/fiber.ts#L415) ### ctx.fiber @@ -97,7 +97,7 @@ public state Current lifecycle state; transitions emit `internal/status`. -[Source](../../vendor/cordis/src/fiber.ts#L192) +[Source](../../vendor/cordis/src/fiber.ts#L194) ### fiber.dispose @@ -108,7 +108,7 @@ public readonly dispose: () => Promise Dispose this fiber: unload the plugin, then settle once cleanup finished. -[Source](../../vendor/cordis/src/fiber.ts#L194) +[Source](../../vendor/cordis/src/fiber.ts#L196) ### fiber.store @@ -119,7 +119,7 @@ public store: Dict | undefined Snapshot of required service implementations while loaded; `undefined` otherwise. -[Source](../../vendor/cordis/src/fiber.ts#L196) +[Source](../../vendor/cordis/src/fiber.ts#L198) ### fiber.inertia @@ -130,7 +130,7 @@ public inertia: Promise | undefined The in-flight load/unload transition, if one is currently running. -[Source](../../vendor/cordis/src/fiber.ts#L198) +[Source](../../vendor/cordis/src/fiber.ts#L200) ### fiber.name @@ -141,7 +141,7 @@ get name() The plugin's display name, inherited from the nearest named ancestor, else `'root'`. -[Source](../../vendor/cordis/src/fiber.ts#L341) +[Source](../../vendor/cordis/src/fiber.ts#L336) ### fiber.assertActive() @@ -159,7 +159,7 @@ Throw if the fiber has already been disposed. **Returns** nothing when the fiber is still active. -[Source](../../vendor/cordis/src/fiber.ts#L356) +[Source](../../vendor/cordis/src/fiber.ts#L351) ### fiber.effect(execute, label?) @@ -190,7 +190,7 @@ Register a cleanup-aware effect on this fiber. **Returns** a disposer that tears the effect down and settles once done. -[Source](../../vendor/cordis/src/fiber.ts#L420) +[Source](../../vendor/cordis/src/fiber.ts#L415) ### fiber.getEffects() @@ -207,7 +207,7 @@ Return metadata for currently registered effects. **Returns** one `EffectMeta` tree per labeled live effect. -[Source](../../vendor/cordis/src/fiber.ts#L573) +[Source](../../vendor/cordis/src/fiber.ts#L568) ### fiber.await() @@ -225,7 +225,7 @@ Wait for current lifecycle work and rethrow startup errors. **Returns** this fiber, once it has settled into a stable state. -[Source](../../vendor/cordis/src/fiber.ts#L702) +[Source](../../vendor/cordis/src/fiber.ts#L704) ### fiber.restart() @@ -243,7 +243,7 @@ Dispose and immediately reload this plugin with its current config. **Returns** a promise resolving once the reload settled. -[Source](../../vendor/cordis/src/fiber.ts#L716) +[Source](../../vendor/cordis/src/fiber.ts#L718) ### fiber.update(config, noSave?) @@ -271,7 +271,7 @@ Runs the `internal/update` waterfall first, so update hooks (and HMR) can veto o **Returns** the update waterfall result; the default restart returns a promise. -[Source](../../vendor/cordis/src/fiber.ts#L734) +[Source](../../vendor/cordis/src/fiber.ts#L736) ## Effect diff --git a/docs/cordis-api/fiber.zh.md b/docs/cordis-api/fiber.zh.md index fafa559ca9..9ed3e52618 100644 --- a/docs/cordis-api/fiber.zh.md +++ b/docs/cordis-api/fiber.zh.md @@ -36,7 +36,7 @@ effect(execute: () => Effect, label?: string): AsyncDisposable> **返回**一个用于撤销该作用的清理函数,并在清理完成后结算。 -[源码](../../vendor/cordis/src/fiber.ts#L420) +[源码](../../vendor/cordis/src/fiber.ts#L415) ### ctx.fiber @@ -99,7 +99,7 @@ public state 当前生命周期状态;状态转换会发出 `internal/status`。 -[源码](../../vendor/cordis/src/fiber.ts#L192) +[源码](../../vendor/cordis/src/fiber.ts#L194) ### fiber.dispose @@ -110,7 +110,7 @@ public readonly dispose: () => Promise dispose 此 fiber:卸载插件,并在清理完成后结算。 -[源码](../../vendor/cordis/src/fiber.ts#L194) +[源码](../../vendor/cordis/src/fiber.ts#L196) ### fiber.store @@ -121,7 +121,7 @@ public store: Dict | undefined 加载期间所需服务实现的快照;其他情况下为 `undefined`。 -[源码](../../vendor/cordis/src/fiber.ts#L196) +[源码](../../vendor/cordis/src/fiber.ts#L198) ### fiber.inertia @@ -132,7 +132,7 @@ public inertia: Promise | undefined 当前正在进行的加载或卸载转换;如果没有此类转换,则为 undefined。 -[源码](../../vendor/cordis/src/fiber.ts#L198) +[源码](../../vendor/cordis/src/fiber.ts#L200) ### fiber.name @@ -143,7 +143,7 @@ get name() 插件的显示名称,继承自最近的具名祖先;如果不存在,则为 `'root'`。 -[源码](../../vendor/cordis/src/fiber.ts#L341) +[源码](../../vendor/cordis/src/fiber.ts#L336) ### fiber.assertActive() @@ -161,7 +161,7 @@ assertActive() **返回**:fiber 仍处于活动状态时不返回任何内容。 -[源码](../../vendor/cordis/src/fiber.ts#L356) +[源码](../../vendor/cordis/src/fiber.ts#L351) ### fiber.effect(execute, label?) @@ -192,7 +192,7 @@ effect(execute: () => Effect, label?: string): AsyncDisposable> **返回**一个用于撤销该作用的清理函数,并在清理完成后结算。 -[源码](../../vendor/cordis/src/fiber.ts#L420) +[源码](../../vendor/cordis/src/fiber.ts#L415) ### fiber.getEffects() @@ -209,7 +209,7 @@ getEffects() **返回**:每个带标签的活动作用对应一棵 `EffectMeta` 树。 -[源码](../../vendor/cordis/src/fiber.ts#L573) +[源码](../../vendor/cordis/src/fiber.ts#L568) ### fiber.await() @@ -227,7 +227,7 @@ async await() **返回**:进入稳定状态后的此 fiber。 -[源码](../../vendor/cordis/src/fiber.ts#L702) +[源码](../../vendor/cordis/src/fiber.ts#L704) ### fiber.restart() @@ -245,7 +245,7 @@ dispose 此插件,并立即使用其当前配置重新加载。 **返回**一个在重新加载完成后兑现的 promise。 -[源码](../../vendor/cordis/src/fiber.ts#L716) +[源码](../../vendor/cordis/src/fiber.ts#L718) ### fiber.update(config, noSave?) @@ -273,7 +273,7 @@ update(config: any, noSave = false) **返回**更新 waterfall 的结果;默认的重新启动操作返回一个 promise。 -[源码](../../vendor/cordis/src/fiber.ts#L734) +[源码](../../vendor/cordis/src/fiber.ts#L736) ## Effect diff --git a/docs/cordis-primer.i18n.yaml b/docs/cordis-primer.i18n.yaml index ad9cfe716e..180ba85c01 100644 --- a/docs/cordis-primer.i18n.yaml +++ b/docs/cordis-primer.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/cordis-primer.md -cordis-primer.md: 93725949a9490f757edebcf3e8391db9e73321b1 -cordis-primer.zh.md: fd2a327b526b210986bc1574013fca2c0cec5dda +cordis-primer.md: d1e7c5fd8eaaa89fe448d238359389d945cd6346 +cordis-primer.zh.md: d6ce0f2024f65b006c9505daffaa06a08bb56875 diff --git a/docs/cordis-primer.md b/docs/cordis-primer.md index 93725949a9..d1e7c5fd8e 100644 --- a/docs/cordis-primer.md +++ b/docs/cordis-primer.md @@ -35,7 +35,7 @@ For single-decision events, short-circuiting is the design. A policy listener ca ## Loader Configuration -`@deepseek-ai/cordis-plugin-include` parses `!!js` into expression nodes, but the Loader interpolates only an entry's `config` before mounting the plugin. Entry metadata (`id`, `name`, `group`, `disabled`, `inject`, `intercept`, and `isolate`) remains literal; `disabled: !!js ...` is therefore a truthy object that always disables the entry. Use explicit config overlays when environment selection changes which plugins are mounted. +`@deepseek-ai/cordis-plugin-include` parses `!!js` into expression nodes. Loader interpolates only an entry's `config`, after declared injections activate, against that plugin context (`ctx.serviceName`); Include preserves nested row expressions until target activation. Entry metadata (`id`, `name`, `group`, `disabled`, `inject`, `intercept`, `isolate`) stays literal, so `disabled: !!js ...` always disables the entry. Use overlays when the environment selects plugins. ## Practical Rules diff --git a/docs/cordis-primer.zh.md b/docs/cordis-primer.zh.md index fd2a327b52..d6ce0f2024 100644 --- a/docs/cordis-primer.zh.md +++ b/docs/cordis-primer.zh.md @@ -39,7 +39,7 @@ Cordis 是 DeepSeek Harness SDK 底层以 vendor 方式引入的插件框架。 ## Loader 配置 -`@deepseek-ai/cordis-plugin-include` 将 `!!js` 解析为表达式节点,但 Loader 仅在挂载插件前对条目的 `config` 做插值。条目元数据(`id`、`name`、`group`、`disabled`、`inject`、`intercept` 和 `isolate`)保持字面值;因此 `disabled: !!js ...` 是一个 truthy 对象,会始终禁用该条目。需要根据环境选择挂载哪些插件时,请使用显式的配置覆盖层。 +`@deepseek-ai/cordis-plugin-include` 将 `!!js` 解析为表达式节点。Loader 只在声明的注入激活后,基于该插件上下文(`ctx.serviceName`)插值条目的 `config`;Include 会保留嵌套行表达式,直到目标行激活。条目元数据(`id`、`name`、`group`、`disabled`、`inject`、`intercept`、`isolate`)保持字面值,因此 `disabled: !!js ...` 始终禁用该条目。由环境选择插件时,请使用 overlay。 ## 实践规则 diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 5f8d6643a1..41274e4a62 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -199,7 +199,7 @@ export function loadLayeredEnv( const bootstrapIncludes = new WeakMap() // The include's YAML dialect (`!!js` scalars become expression nodes the -// Loader interpolates against each entry's context at mount time), imported +// Loader interpolates against each entry's injection-ready context), imported // from the include itself so patch parsing and config dumping can never drift // from what the include mounts. User patch layers share it so they may // reference `process.env`. @@ -527,31 +527,6 @@ export async function mountRootInclude( return entry } -/** - * 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 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 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. - * @returns nothing once the new generation has settled; a disposed tree is a no-op. - * @throws when the tree was booted without the root include. - */ -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 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 - await entry.update({ config: { ...includeConfig, patches: [...patches] } }) - await ctx.get('loader')?.await() -} - /** * The slice of `process` {@link installFailLoud} needs — injectable so tests * exercise the handler without registering on (or exiting) the real process. diff --git a/packages/boot/app-boot/tests/app-boot.spec.ts b/packages/boot/app-boot/tests/app-boot.spec.ts index 84c1748498..8bbb8fddfa 100644 --- a/packages/boot/app-boot/tests/app-boot.spec.ts +++ b/packages/boot/app-boot/tests/app-boot.spec.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve, sep } from 'node:path' import { pathToFileURL } from 'node:url' @@ -699,7 +699,18 @@ describe('boot', () => { '}', '', ].join('\n')) - writeFileSync(join(dir, 'cordis.yml'), '- id: exiting\n name: ./exiting.mjs\n') + writeFileSync(join(dir, 'delayed.mjs'), [ + 'await new Promise(resolve => setTimeout(resolve, 10))', + 'export function apply() {}', + '', + ].join('\n')) + writeFileSync(join(dir, 'cordis.yml'), [ + '- id: exiting', + ' name: ./exiting.mjs', + '- id: delayed', + ' name: ./delayed.mjs', + '', + ].join('\n')) const ctx = await boot(NAME, join(dir, 'cordis.yml')) expect(ctx.get('loader')).toBeUndefined() }) @@ -712,6 +723,25 @@ describe('boot', () => { ) }) + it('labels a deferred config failure with its row and leaves the source file unchanged', async () => { + const dir = tmp() + const configPath = join(dir, 'cordis.yml') + const config = [ + '- id: invalid-config', + ' name: ./noop.mjs', + ' config:', + ' value: !!js "JSON.parse(\'invalid\')"', + '', + ].join('\n') + writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n') + writeFileSync(configPath, config) + + await expect(boot(NAME, configPath)).rejects.toThrow( + 'failed to apply loader entry invalid-config (./noop.mjs)', + ) + expect(readFileSync(configPath, 'utf8')).toBe(config) + }) + it('appends the deepest cause with its original stack to the load failure', async () => { const dir = tmp() writeFileSync(join(dir, 'failing.mjs'), [ diff --git a/packages/boot/app-boot/tests/user-patches.spec.ts b/packages/boot/app-boot/tests/user-patches.spec.ts index a55d2d246f..da58524e1d 100644 --- a/packages/boot/app-boot/tests/user-patches.spec.ts +++ b/packages/boot/app-boot/tests/user-patches.spec.ts @@ -11,11 +11,10 @@ import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Hmr from '@deepseek-ai/cordis-plugin-hmr' +import Include, { type PatchOptions } from '@deepseek-ai/cordis-plugin-include' import Loader from '@deepseek-ai/cordis-plugin-loader' import Timer from '@deepseek-ai/cordis-plugin-timer' -import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { - applyRootPatches, boot, loadOptionalPatches, PROFILE_PATCH_FILENAME, @@ -110,61 +109,81 @@ function entryConfig(ctx: Context, id: string): unknown { return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config } -describe('applyRootPatches', () => { - it('mounts a later phase whose rows read what the first phase provided', async () => { - // The phased boot in one test: a row's `!!js` config is evaluated when the - // include applies it, so a value an earlier phase provided is what a later - // phase's rows read. +describe('Loader config interpolation', () => { + it("resolves Include's own !!js options", async () => { const dir = tmp() - writeFileSync(join(dir, 'provider.mjs'), [ - 'export const name = "provider"', - 'export function apply(ctx) { ctx.provide("phaseOne", { value: "resolved" }) }', - '', - ].join('\n')) - writeFileSync(join(dir, 'reader.mjs'), [ - 'export const name = "reader"', - 'export const inject = ["phaseOne"]', - 'export function apply() {}', - '', - ].join('\n')) - writeFileSync(join(dir, 'cordis.yml'), '[]\n') - const composition: PatchOptions[] = [{ - insert: [ - { id: 'provider', name: './provider.mjs' }, - { - id: 'reader', - name: './reader.mjs', - inject: ['phaseOne'], - config: { value: { __jsExpr: "ctx.get('phaseOne')?.value ?? 'fallback'" } }, - }, - ], - }] - const ctx = await boot(NAME, join(dir, 'cordis.yml'), [ - ...structuredClone(composition), - { id: 'reader', disabled: true }, - ]) + writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n') + writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n') + const ctx = new Context() + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + ctx.provide('includePath', pathToFileURL(join(dir, 'cordis.yml')).href) try { - // Phase one leaves the reader disabled, so the plugin never ran. - const reader = [...ctx.loader.entries()].find(entry => entry.options.id === 'reader') - expect(reader?.fiber).toBeUndefined() - await applyRootPatches(ctx, structuredClone(composition)) - // Phase two evaluates its config expression against the provided value. - expect(entryConfig(ctx, 'reader')).toEqual({ value: 'resolved' }) + await ctx.loader.create({ + name: 'cordis:include', + config: { path: { __jsExpr: "ctx.get('includePath')" } }, + }) + await ctx.loader.await() + expect([...ctx.loader.entries()].some(entry => entry.options.id === 'noop')).toBe(true) } finally { await ctx.fiber.dispose() } }) - it('does nothing on a tree that was already disposed', async () => { + it('waits for row injections before resolving !!js and resolves again after provider replacement', async () => { const dir = tmp() - const ctx = await boot(NAME, writeTree(dir)) - await ctx.fiber.dispose() - await expect(applyRootPatches(ctx, [])).resolves.toBeUndefined() - }) + writeFileSync(join(dir, 'provider.mjs'), [ + 'export const name = "provider"', + 'export function apply(ctx, config) { ctx.provide("phaseOne", config) }', + '', + ].join('\n')) + writeFileSync(join(dir, 'reader.mjs'), [ + 'export const name = "reader"', + 'export const inject = ["phaseOne"]', + 'export function apply(ctx, config) { ctx.provide("readerResult", config) }', + '', + ].join('\n')) + writeFileSync(join(dir, 'cordis.yml'), '[]\n') + const composition: PatchOptions[] = [{ + insert: [ + { + // Consumer-first order proves interpolation follows injection + // readiness rather than YAML position. + id: 'reader', + name: './reader.mjs', + inject: ['phaseOne'], + config: { value: { __jsExpr: 'ctx.phaseOne.fail ? (() => { throw new Error("rejected provider") })() : ctx.phaseOne.value' } }, + }, + { id: 'provider', name: './provider.mjs', config: { value: 'first' } }, + ], + }] + const ctx = await boot(NAME, join(dir, 'cordis.yml'), composition) + try { + expect(ctx.get('readerResult')).toEqual({ value: 'first' }) + const provider = [...ctx.loader.entries()].find(entry => entry.options.id === 'provider') + expect(provider).toBeDefined() + await provider?.update({ disabled: true }) + await ctx.loader.await() + expect(ctx.get('readerResult')).toBeUndefined() + await provider?.update({ config: { value: 'second' } }) + await provider?.update({ disabled: false }) + await ctx.loader.await() + expect(ctx.get('readerResult')).toEqual({ value: 'second' }) - it('fails loud when the tree was booted without the root include', async () => { - const ctx = new Context() - await expect(applyRootPatches(ctx, [])).rejects.toThrow('requires the root Include entry') + await provider?.update({ disabled: true }) + await provider?.update({ config: { fail: true } }) + await provider?.update({ disabled: false }) + await expect(ctx.loader.await()).rejects.toThrow('rejected provider') + expect(ctx.get('readerResult')).toBeUndefined() + + await provider?.update({ disabled: true }) + await provider?.update({ config: { value: 'recovered' } }) + await provider?.update({ disabled: false }) + await ctx.loader.await() + expect(ctx.get('readerResult')).toEqual({ value: 'recovered' }) + } finally { + await ctx.fiber.dispose() + } }) }) diff --git a/packages/boot/cmdline/README.i18n.yaml b/packages/boot/cmdline/README.i18n.yaml index dcd4d2416d..9207c4b35d 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: 242ba184507d88c50e0dcf2ada0a0f7714d87e28 -README.zh.md: 76a76ad6090fcc28d50f9ea2a48d4e2581e361f2 +README.md: cd3350678d38802c18ff26dd47214b5019b8c404 +README.zh.md: ad726cd0726cbbd22736321a8c52b04e23d557fa diff --git a/packages/boot/cmdline/README.md b/packages/boot/cmdline/README.md index 242ba18450..cd3350678d 100644 --- a/packages/boot/cmdline/README.md +++ b/packages/boot/cmdline/README.md @@ -35,7 +35,7 @@ The Loader-row injection is also its discovery declaration, so no bundle manifes inject: [cmdlineArgs] ``` -The launcher finds active rows with that injection in the composed tree and mounts them before everything else. +The launcher uses that injection only to reject arguments for a composition with no command-line owner. Loader mounts the composition once and holds each row until its own injections are active. 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: @@ -44,19 +44,19 @@ Every row the app configures from flags then reads what the startup row resolved name: '@deepseek-ai/dsh-host-webserver' inject: [webStartup] config: - host: !!js ctx.get('webStartup')?.host ?? '127.0.0.1' - port: !!js ctx.get('webStartup')?.port ?? 3080 + host: !!js ctx.webStartup.host ?? '127.0.0.1' + port: !!js ctx.webStartup.port ?? 3080 ``` -`runStartup` parses the arguments, asks `plan` for the values, and provides them as the service. On `--help`, `--version`, a parse error, or a `program.error(...)` from the plan, it writes commander's text and requests exit — nothing is provided, and the rest of the composition never mounts. +`runStartup` parses the arguments, asks `plan` for the values, and provides them as the service. On `--help`, `--version`, a parse error, or a `program.error(...)` from the plan, it writes commander's text and requests exit — nothing is provided, so rows that depend on the startup service never activate. -`plan` receives the options of every row that injects the service, for a value that has to take the composition into account: the `/api` fence authorities are the shipped example, since a bind the composition configured decides whether LAN literals are derived at all. +`plan` receives the startup context and the options of every row that injects the service, for a value that has to take the composition into account. Include still holds nested expressions raw at this point, so a plan that needs a composed fallback can interpolate the relevant row config against the pre-service startup context; the `/api` fence authorities are the shipped example. -### Why the boot has phases +### How injection orders config -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. +Loader defers a row's `!!js` interpolation until that row's declared injections are active, then evaluates against the row's plugin context. The example above can therefore read `ctx.webStartup` directly: Cordis has already populated that injected service before Loader asks for `webserver`'s config. Include trees preserve nested expression nodes until each target row reaches this point. Provider replacement and live patch reload repeat interpolation against the current injected services, so a launch 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 the startup row: 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). Loader applies the enabled row's ordinary injection ordering. ### One command line, one owner diff --git a/packages/boot/cmdline/README.zh.md b/packages/boot/cmdline/README.zh.md index 76a76ad609..ad726cd072 100644 --- a/packages/boot/cmdline/README.zh.md +++ b/packages/boot/cmdline/README.zh.md @@ -35,7 +35,7 @@ Loader 行的注入同时也是发现声明,因此无需组合包 manifest 字 inject: [cmdlineArgs] ``` -启动器在组合结果中找出带有该注入的活跃行,并先于其他一切挂载它们。 +启动器只用该注入来拒绝那些没有命令行所有者却带有应用参数的组合。Loader 只挂载一次整套组合,并让每一行等待自身的注入激活。 应用用 flag 配置的每一行随后读取启动行解析出的取值,各自点名自己取用的键,以及回退时使用的值: @@ -44,19 +44,19 @@ Loader 行的注入同时也是发现声明,因此无需组合包 manifest 字 name: '@deepseek-ai/dsh-host-webserver' inject: [webStartup] config: - host: !!js ctx.get('webStartup')?.host ?? '127.0.0.1' - port: !!js ctx.get('webStartup')?.port ?? 3080 + host: !!js ctx.webStartup.host ?? '127.0.0.1' + port: !!js ctx.webStartup.port ?? 3080 ``` -`runStartup` 解析参数,向 `plan` 索取取值,并把它们作为服务提供出去。遇到 `--help`、`--version`、解析错误,或 `plan` 发出的 `program.error(...)` 时,它输出 commander 的文本并请求退出:什么也不会被提供,组合的其余部分也从不挂载。 +`runStartup` 解析参数,向 `plan` 索取取值,并把它们作为服务提供出去。遇到 `--help`、`--version`、解析错误,或 `plan` 发出的 `program.error(...)` 时,它输出 commander 的文本并请求退出:什么也不会被提供,因此依赖启动服务的行不会激活。 -`plan` 收到的是所有注入该服务的行的选项,用于那些必须顾及组合本身的取值:随附的例子是 `/api` 栅栏 authority,因为组合所配置的 bind 决定了是否要派生 LAN 字面量。 +`plan` 会收到启动上下文,以及所有注入该服务的行的选项,用于那些必须顾及组合本身的取值。此时 Include 仍保留着嵌套表达式的原始形态,因此需要组合回退值的 plan 可以基于服务提供前的启动上下文插值相关行配置;随附的例子是 `/api` 栅栏 authority。 -### 为什么 boot 分阶段 +### 注入如何排列配置求值 -行的配置表达式在 include 施加该行时求值,而严格的 `ctx.get` 只对提供方 fiber 已经 active 的服务作答。因此一套组合分两趟挂载:先是各个活跃的 `cmdlineArgs` 消费方,然后才是其余部分。后一趟的行读到的是活的取值,`--help` 在第二趟存在之前就退出,而用户编辑一个活动的 patch 文件时,这一趟会针对仍然在线的服务重新运行,因此 flag 不会被悄悄重置。 +Loader 会把一行的 `!!js` 插值推迟到该行声明的注入全部激活之后,再基于该行的插件上下文求值。所以上例可以直接读取 `ctx.webStartup`:Loader 索取 `webserver` 的配置之前,Cordis 已经填入了这个注入服务。Include 树会保留嵌套表达式节点,直到各个目标行到达这一时点。提供方替换与活动 patch 重载都会针对当前注入服务重新插值,因此启动 flag 不会被悄悄重置。 -`enableRow(ctx, id)` 打开某个组合包以禁用状态交付、只有部分调用才需要的行(`dsh web --dev` 及其客户端插件重载链路)。要从与被启用行同一趟挂载的行里调用它,而不是从启动行:在第一趟被启用的行会去等待第二趟才挂载的服务。 +`enableRow(ctx, id)` 打开某个组合包以禁用状态交付、只有部分调用才需要的行(`dsh web --dev` 及其客户端插件重载链路)。Loader 会对启用后的行应用普通的注入顺序。 ### 一条命令行,一个所有者 diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index 2ce1b72942..f64ead7a50 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/packages/bundle/headless/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/headless/README.md -README.md: 45c87f0c85cbb68ad0366ea5f2c86e55fc307309 -README.zh.md: 22322692450fa85a87e9faf903abee0d38968f91 +README.md: 459d0f32788265d43e75922067da3c03d054f444 +README.zh.md: e3ca9d13512e3a13ac71c5cda650fca958609062 diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index 45c87f0c85..459d0f3278 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides directly over [`dsh-base`](../base/README.md): it supplies the coding persona and tool mode, disables HMR, mounts Code Mode's worker as a core execution capability, and inserts this package's `headless-runner` plugin (config `{task}`, shipped disabled until the startup row supplies the task). It mounts no Host, HTTP server, Web runtime, or browser plugin. +The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides directly over [`dsh-base`](../base/README.md): it supplies the coding persona and tool mode, disables HMR, mounts Code Mode's worker as a core execution capability, and inserts this package's `headless-runner` plugin (config `{task}`, resolved from the injected startup service). It mounts no Host, HTTP server, Web runtime, or browser plugin. After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. It flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.headlessIo` host hook (final `turn/end` completed → 0, otherwise 1). A terminal `error` reason also writes its code and message to stderr; successful runs keep stderr empty. The process opens no listening port. The task text is this app's command line: the `headless-startup` row ([`src/startup.ts`](src/startup.ts)) reads it as the positional argument of `dsh --profile headless "task"` from `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), prints the app's `--help`, and rejects an invocation with no task instead of letting the runner's schema fail. diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index 2232269245..e3ca9d1351 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.md) 之上:提供编码 persona 和工具模式、禁用 HMR(热模块替换)、将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`,在启动行供给任务之前以禁用状态交付)。它不挂载任何 Host、HTTP server、Web runtime 或浏览器插件。 +dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.md) 之上:提供编码 persona 和工具模式、禁用 HMR(热模块替换)、将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`,从注入的启动服务解析)。它不挂载任何 Host、HTTP server、Web runtime 或浏览器插件。 Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md),通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),将任务作为普通用户消息提交,并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout,再经启动器提供的 `ctx.headlessIo` 宿主钩子请求退出(最终 `turn/end` 完成 → 0,否则为 1)。最终 reason 为 `error` 时,还会将持久化的 code 与 message 写入 stderr;成功运行时 stderr 保持为空。进程不会打开监听端口。任务文本就是这个应用的命令行:`headless-startup` 行([`src/startup.ts`](src/startup.ts))从 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md))把它读作 `dsh --profile headless "task"` 的位置参数,打印应用自己的 `--help`,并拒绝没有任务的调用,而不是让 runner 的 schema 失败。 diff --git a/packages/bundle/headless/cordis.patch.yml b/packages/bundle/headless/cordis.patch.yml index abe5a95e0d..2c03de11af 100644 --- a/packages/bundle/headless/cordis.patch.yml +++ b/packages/bundle/headless/cordis.patch.yml @@ -33,4 +33,4 @@ name: '@deepseek-ai/dsh-headless' inject: [headlessStartup] config: - task: !!js ctx.get('headlessStartup')?.task + task: !!js ctx.headlessStartup.task diff --git a/packages/bundle/headless/src/startup.ts b/packages/bundle/headless/src/startup.ts index 74f9dfb6b1..e960c63554 100644 --- a/packages/bundle/headless/src/startup.ts +++ b/packages/bundle/headless/src/startup.ts @@ -64,7 +64,7 @@ function planHeadlessStartup(program: Command, rows: readonly EntryOptions[]): H } /** - * Resolve the task and start the runner that reads it. + * Resolve the task for the runner waiting on `headlessStartup`. * @param ctx - plugin context carrying the command line and the Loader. * @returns nothing once the runner is started, or once `--help` or a missing task requested exit. */ diff --git a/packages/bundle/headless/tests/startup.spec.ts b/packages/bundle/headless/tests/startup.spec.ts index 651ef0aebb..51c6708c8f 100644 --- a/packages/bundle/headless/tests/startup.spec.ts +++ b/packages/bundle/headless/tests/startup.spec.ts @@ -1,8 +1,7 @@ /** - * 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. + * The one-shot app's startup row over a real Loader tree: the task positional + * becomes the injected runner config, while help and usage errors leave the + * runner pending. */ import { mkdtempSync, writeFileSync } from 'node:fs' @@ -13,7 +12,6 @@ import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' import { internals, provideCmdline } from '@deepseek-ai/dsh-cmdline' -import { WEB_STARTUP_SERVICE } from '@deepseek-ai/dsh-web-app/startup' import { afterEach, describe, expect, it } from 'vitest' import { apply, HEADLESS_STARTUP_SERVICE, type HeadlessStartupValues } from '../src/startup.ts' @@ -21,6 +19,7 @@ import { apply, HEADLESS_STARTUP_SERVICE, type HeadlessStartupValues } from '../ interface Observed { exits: number[] out: string + runnerConfig?: unknown } const disposers: (() => Promise)[] = [] @@ -32,22 +31,20 @@ afterEach(async () => { }) /** - * 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. + * Mount the real startup row over a runner stand-in. * @param args - the invocation's inner arguments. - * @param options - fixture knobs for the shapes a composition can take. - * @returns the resolved service values (absent when the app requested exit) and what the boot observed. + * @param options - fixture knobs for invalid compositions. + * @returns the resolved startup value and observed runner/process effects. */ async function bootStartup( args: string[], options: { withoutRunner?: boolean } = {}, -): Promise<{ task: HeadlessStartupValues | undefined; web: unknown; observed: Observed }> { +): Promise<{ task: HeadlessStartupValues | undefined; observed: Observed }> { const dir = mkdtempSync(join(tmpdir(), 'dsh-headless-startup-')) const observed: Observed = { exits: [], out: '' } - writeFileSync(join(dir, 'row.mjs'), 'export function apply() {}\n') - // 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, 'row.mjs'), 'export function apply(_ctx, config) { globalThis.__headlessStartupObserved.runnerConfig = config }\n') + // Loader imports through Node's resolver, so this fixture delegates to the + // source-plane plugin already imported by the test. writeFileSync(join(dir, 'startup.mjs'), ` export const name = 'headless-startup' export const inject = ['cmdlineArgs'] @@ -55,16 +52,11 @@ 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 - // 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}]`, - ' disabled: true', - '- id: webserver', - ` name: ${rowUrl}`, - ` inject: [${WEB_STARTUP_SERVICE}]`, - ' disabled: true', + ' config:', + ' task: !!js ctx.headlessStartup.task', '- id: headless-startup', ` name: ${pathToFileURL(join(dir, 'startup.mjs')).href}`, ' inject: [cmdlineArgs]', @@ -73,7 +65,12 @@ export const apply = ctx => globalThis.__headlessStartupApply(ctx) const observing = { write: (chunk: string) => { observed.out += chunk; return true } } internals.stdout = observing internals.stderr = observing - ;(globalThis as unknown as { __headlessStartupApply: typeof apply }).__headlessStartupApply = apply + const globals = globalThis as unknown as { + __headlessStartupApply: typeof apply + __headlessStartupObserved: Observed + } + globals.__headlessStartupApply = apply + globals.__headlessStartupObserved = observed const ctx = new Context() await ctx.plugin(Loader) @@ -84,38 +81,35 @@ export const apply = ctx => globalThis.__headlessStartupApply(ctx) disposers.push(async () => { await ctx.fiber.dispose() }) return { task: ctx.get(HEADLESS_STARTUP_SERVICE) as HeadlessStartupValues | undefined, - web: ctx.get(WEB_STARTUP_SERVICE), observed, } } describe('headless startup', () => { - it('joins the task positional into the value the runner reads', async () => { + it('joins the task positional into the runner config', async () => { const { task, observed } = await bootStartup(['run', 'the', 'tests']) expect(task).toEqual({ task: 'run the tests' }) + expect(observed.runnerConfig).toEqual({ task: 'run the tests' }) expect(observed.exits).toEqual([]) }) - it('provides the web service it absorbed, so those rows resolve on their own fallbacks', async () => { - const { web } = await bootStartup(['task']) - expect(web).toEqual({ task: 'task' }) - }) - - it('rejects an invocation with no task instead of failing inside the runner schema', async () => { + it('rejects an invocation with no task and leaves the runner pending', async () => { const { task, observed } = await bootStartup([]) expect(observed.out).toContain('a task is required') expect(task).toBeUndefined() + expect(observed.runnerConfig).toBeUndefined() expect(observed.exits).toEqual([1]) }) - it('prints its own help and resolves nothing', async () => { + it('prints its own help and leaves the runner pending', async () => { const { task, observed } = await bootStartup(['--help']) expect(observed.out).toContain('dsh --profile headless') expect(task).toBeUndefined() + expect(observed.runnerConfig).toBeUndefined() expect(observed.exits).toEqual([0]) }) - it('fails the boot when the composition has no runner row to give the task to', async () => { + it('fails when the composition has no runner row', async () => { await expect(bootStartup(['task'], { withoutRunner: true })) .rejects.toThrow('the composition has no waiting "headless-runner" row') }) diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 7a86cb1df0..656a3374cb 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -7,11 +7,10 @@ # # 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 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. +# over the value written beside it. The web-startup row injects `cmdlineArgs` +# and provides `webStartup`; Loader delays dependent-row config interpolation +# until that service is active. `dsh --profile web --help` provides no service, +# so the server rows never activate. # ── surface-specific values the base deliberately omits ───────────────────── @@ -85,9 +84,8 @@ config: workspaceRoot: !!js ctx.get('webStartup')?.workspaceRoot - # 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. + # This app's command-line startup row. It owns the web flag family and its + # --help, and provides webStartup to the rows that inject it. - id: web-startup name: '@deepseek-ai/dsh-web-app/startup' inject: [cmdlineArgs] diff --git a/packages/bundle/web-app/src/startup.ts b/packages/bundle/web-app/src/startup.ts index 96692e0116..e636366f3c 100644 --- a/packages/bundle/web-app/src/startup.ts +++ b/packages/bundle/web-app/src/startup.ts @@ -11,7 +11,7 @@ import { networkInterfaces } from 'node:os' import { Command } from 'commander' import type { Context } from 'cordis' -import type { EntryOptions } from '@cordisjs/plugin-loader' +import { interpolate, type EntryOptions } from '@cordisjs/plugin-loader' import { runStartup } from '@deepseek-ai/dsh-cmdline' /** Stable Cordis plugin name. */ @@ -50,6 +50,20 @@ export interface WebStartupValues { /** The webserver schema's all-interfaces bind literal: only this bind derives LAN authorities. */ const ALL_INTERFACES_HOST = '0.0.0.0' +/** + * Read the deployment trust list before its row mounts and validates config. + * @param config - the connection row's config resolved before `webStartup` exists. + * @returns its configured authorities, or an empty list when absent. + * @throws when the file-backed config is not an array of strings. + */ +function configuredTrustedHosts(config: unknown): string[] { + const value = (config as { trustedHosts?: unknown } | undefined)?.trustedHosts + if (value === undefined) return [] + const valid = Array.isArray(value) && value.every((entry: unknown) => typeof entry === 'string') + if (!valid) throw new Error('web-startup: the composed connection trustedHosts must be an array of strings') + return value +} + /** * Non-internal IPv4 interface addresses of this machine — the IP-literal * authorities an all-interfaces bind is reachable by on the LAN. @@ -118,19 +132,33 @@ Examples: * Turn the parsed flags into the values the web rows read. * @param program - the parsed web command. * @param rows - the waiting rows' composed options, in tree order. + * @param ctx - the startup context used to resolve composed fallbacks before `webStartup` exists. * @returns the web rows' service value. */ -function planWebStartup(program: Command, rows: readonly EntryOptions[]): WebStartupValues { +function planWebStartup(program: Command, rows: readonly EntryOptions[], ctx: Context): WebStartupValues { const options = program.opts() if (options.port !== undefined && !/^\d+$/.test(options.port)) { program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`) } - const webserver = rows.find(row => row.id === 'webserver') - if (webserver === undefined) throw new Error('web-startup: the web composition has no waiting "webserver" row to configure') - // The bind this invocation ends on: the flag, else what the row falls back - // to, which is the same literal its config expression names. - const bindHost = options.host ?? (webserver.config as { host?: string } | undefined)?.host - const { lanAddresses, trustedHosts } = resolveLanTrust(bindHost, options.trustedHost ?? []) + const row = (id: string): EntryOptions => { + const found = rows.find(candidate => candidate.id === id) + if (found === undefined) throw new Error(`web-startup: the web composition has no waiting ${JSON.stringify(id)} row to configure`) + return found + } + const webserver = row('webserver') + row('api-gateway') + row('web-runtime') + const connection = row('connection') + // Include preserves nested row expressions until their own injections are + // active. Resolve just the composed fields this startup plan needs against + // the pre-service context, where their `ctx.get('webStartup')` fallback wins. + const webserverConfig = interpolate(ctx, webserver.config) as { host?: string } | undefined + const connectionConfig: unknown = interpolate(ctx, connection.config) + const bindHost = options.host ?? webserverConfig?.host + const sampled = resolveLanTrust(bindHost, options.trustedHost ?? []) + // Preserve deployment authorities when invocation-derived LAN literals or + // explicit extras become the runtime value read by the connection row. + const composedTrusted = configuredTrustedHosts(connectionConfig) return { ...options.host !== undefined && { host: options.host }, ...options.port !== undefined && { port: Number(options.port) }, @@ -138,15 +166,15 @@ function planWebStartup(program: Command, rows: readonly EntryOptions[]): WebSta // mode and lanAddresses describe this invocation, never the deployment, so // they are resolved on every boot. mode: options.dev === true ? 'development' : 'production', - trustedHosts, - lanAddresses, + trustedHosts: [...composedTrusted, ...sampled.trustedHosts], + lanAddresses: sampled.lanAddresses, } } /** - * Resolve the web flag family and start the rows that read it. + * Resolve the web flag family for rows waiting on `webStartup`. * @param ctx - plugin context carrying the command line and the Loader. - * @returns nothing once the web rows are started, or once `--help` requested exit. + * @returns nothing once the values are provided, or once `--help` requested exit. */ export function apply(ctx: Context): void { runStartup(ctx, WEB_STARTUP_SERVICE, webCommand(), planWebStartup) diff --git a/packages/bundle/web-app/tests/startup.spec.ts b/packages/bundle/web-app/tests/startup.spec.ts index 83def845cd..5a7c80dcc4 100644 --- a/packages/bundle/web-app/tests/startup.spec.ts +++ b/packages/bundle/web-app/tests/startup.spec.ts @@ -40,14 +40,16 @@ afterEach(async () => { /** * 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. + * composed bind it reads before the dependent rows activate. * @param args - the invocation's inner arguments. * @param webserverConfig - the composed `webserver` row config, or `null` to omit the row. + * @param trustedHosts - authorities the composed connection row already carries, or `null` when it carries none. * @returns the resolved service value (absent when the app requested exit) and what the boot observed. */ async function bootStartup( args: string[], webserverConfig: Record | null = { host: '127.0.0.1', port: 3080 }, + trustedHosts: unknown = [], ): Promise<{ values: WebStartupValues | undefined; observed: Observed; ctx: Context }> { const dir = mkdtempSync(join(tmpdir(), 'dsh-web-startup-')) const observed: Observed = { exits: [], out: '' } @@ -68,8 +70,20 @@ export const apply = ctx => globalThis.__webStartupApply(ctx) ` inject: [${WEB_STARTUP_SERVICE}]`, ' disabled: true', ' config:', - ...Object.entries(webserverConfig).map(([key, value]) => ` ${key}: ${JSON.stringify(value)}`), + ...Object.entries(webserverConfig).map(([key, value]) => ` ${key}: !!js ctx.get('${WEB_STARTUP_SERVICE}')?.${key} ?? ${JSON.stringify(value)}`), ], + '- id: connection', + ` name: ${rowUrl}`, + ` inject: [${WEB_STARTUP_SERVICE}]`, + ' disabled: true', + ...trustedHosts === null ? [] : [ + ' config:', + ` trustedHosts: !!js ctx.get('${WEB_STARTUP_SERVICE}')?.trustedHosts ?? ${JSON.stringify(trustedHosts)}`, + ], + '- id: api-gateway', + ` name: ${rowUrl}`, + ` inject: [${WEB_STARTUP_SERVICE}]`, + ' disabled: true', // A second reader keeps the composition honest when the webserver row is // the one under test: the service must still have someone to serve. '- id: web-runtime', @@ -121,13 +135,36 @@ describe('web startup', () => { expect(values).not.toHaveProperty('port') }) - it('derives the LAN literals for an all-interfaces bind, and the extras with them', async () => { - const { values } = await bootStartup(['--host', '0.0.0.0', '--trusted-host', 'lab.internal']) - expect(values?.trustedHosts).toEqual(['192.168.1.5', 'lab.internal']) + it('adds LAN literals and explicit extras after the composed fence authorities', async () => { + const { values } = await bootStartup( + ['--host', '0.0.0.0', '--trusted-host', 'lab.internal', 'lab-2.internal', '--trusted-host', '10.0.0.9'], + { host: '127.0.0.1', port: 3080 }, + ['profile.internal'], + ) + expect(values?.trustedHosts).toEqual([ + 'profile.internal', '192.168.1.5', 'lab.internal', 'lab-2.internal', '10.0.0.9', + ]) // Display gets the same single sample the fence was configured with. expect(values?.lanAddresses).toEqual(['192.168.1.5']) }) + it('starts from an empty trust list when the composed connection row names none', async () => { + const { values } = await bootStartup( + ['--trusted-host', 'lab.internal'], + { host: '127.0.0.1', port: 3080 }, + null, + ) + expect(values?.trustedHosts).toEqual(['lab.internal']) + }) + + it.each([ + 'profile.internal', + ['profile.internal', 1], + ])('rejects an invalid composed trust list before transforming it (%j)', async (trustedHosts) => { + await expect(bootStartup([], { host: '127.0.0.1', port: 3080 }, trustedHosts)) + .rejects.toThrow('the composed connection trustedHosts must be an array of strings') + }) + it('reads the composed bind when no flag names one, so a configured 0.0.0.0 still derives them', async () => { const { values } = await bootStartup([], { host: '0.0.0.0', port: 3080 }) expect(values?.lanAddresses).toEqual(['192.168.1.5']) @@ -135,8 +172,8 @@ describe('web startup', () => { it('reports the development mode for --dev, which the web runtime reads', async () => { const { values } = await bootStartup(['--dev']) - // The runtime row is what turns the reload chain on, in the phase whose - // host rows it needs; this row only reports the mode. + // The runtime row turns the reload chain on after its host dependencies + // activate; this row only reports the mode. expect(values?.mode).toBe('development') }) diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index 5613ed035e..ab7370c033 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -39,18 +39,6 @@ function requiredConfig() { }) } -function queuedReadinessConfig( - ctx: Context, - onPublished: (dispose: () => void) => void, -) { - return z.transform(z.any(), () => { - queueMicrotask(() => { - onPublished(ctx.provide(TEST_INVARIANT_READY_SERVICE, true)) - }) - return {} - }, true) -} - function invalidConfigApply(): never { throw new Error('invalid plugin apply executed') } @@ -189,84 +177,55 @@ describe('global test invariant host', () => { expect(apply).not.toHaveBeenCalled() }) - it('disposes invalid config when readiness refresh wins the rejection-handler race', async () => { + it('disposes invalid config after delayed invariant readiness', async () => { await withDelayedFirstCompanion( async ({ started, release }) => { const ctx = new Context() const apply = vi.fn(invalidConfigApply) - let disposeQueuedReadiness: (() => void) | undefined const plugin = { apply, - Config: z.intersect([ - queuedReadinessConfig(ctx, (dispose) => { - disposeQueuedReadiness = dispose - }), - requiredConfig(), - ]), + Config: requiredConfig(), } const fiber = ctx.plugin(plugin, {}) - const firstError = await rejectionOf(fiber) - expectRequiredConfigValidation(firstError) - expect(fiber.state).toBe(FiberState.DISPOSED) + const returnedError = rejectionOf(fiber) + await started + expect(fiber.state).toBe(FiberState.PENDING) expect(apply).not.toHaveBeenCalled() - await started - if (disposeQueuedReadiness === undefined) throw new Error('queued readiness was not published') - disposeQueuedReadiness() release() - await ctx.plugin(TestInvariantProbe) - - const secondError = await rejectionOf(fiber) - expect(secondError).toBe(firstError) + expectRequiredConfigValidation(await returnedError) expect(fiber.state).toBe(FiberState.DISPOSED) expect(apply).not.toHaveBeenCalled() }, ) }) - it('retains a valid plugin failure when readiness wins the initial-probe race', async () => { + it('retains a valid plugin failure after delayed invariant readiness', async () => { await withDelayedFirstCompanion( async ({ started, release }) => { const ctx = new Context() const failure = new Error('valid plugin apply failed') - const applied = deferred() const apply = vi.fn(function validConfigApply() { - applied.resolve() throw failure }) - let disposeQueuedReadiness: (() => void) | undefined const plugin = { apply, - Config: queuedReadinessConfig(ctx, (dispose) => { - disposeQueuedReadiness = dispose - }), + Config: z.object({}), } const fiber = ctx.plugin(plugin, {}) const returnedError = rejectionOf(fiber) - try { - await Promise.all([started, applied.promise]) - expect(fiber.state).toBe(FiberState.FAILED) - expect(apply).toHaveBeenCalledOnce() - expect(ctx.registry.has(plugin)).toBe(true) - expect(ctx.registry.get(plugin)?.fibers).toHaveLength(1) + await started + expect(fiber.state).toBe(FiberState.PENDING) + expect(apply).not.toHaveBeenCalled() - if (disposeQueuedReadiness === undefined) throw new Error('queued readiness was not published') - Reflect.deleteProperty(fiber.inject, TEST_INVARIANT_READY_SERVICE) - disposeQueuedReadiness() - release() - - expect(await returnedError).toBe(failure) - expect(fiber.state).toBe(FiberState.FAILED) - expect(apply).toHaveBeenCalledOnce() - expect(ctx.registry.has(plugin)).toBe(true) - expect(ctx.registry.get(plugin)?.fibers).toHaveLength(1) - } finally { - Reflect.deleteProperty(fiber.inject, TEST_INVARIANT_READY_SERVICE) - disposeQueuedReadiness?.() - release() - } + release() + expect(await returnedError).toBe(failure) + expect(fiber.state).toBe(FiberState.FAILED) + expect(apply).toHaveBeenCalledOnce() + expect(ctx.registry.has(plugin)).toBe(true) + expect(ctx.registry.get(plugin)?.fibers).toHaveLength(1) }, ) }) diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index fa3c3cc7e4..5b447f5f4a 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -6,7 +6,7 @@ */ import { expect } from 'vitest' -import { FiberState, Inject, RegistryService } from '@deepseek-ai/cordis' +import { FiberState, Inject, RegistryService, ValidationError } from '@deepseek-ai/cordis' import type { Context, Plugin } from '@deepseek-ai/cordis' import { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { @@ -248,22 +248,25 @@ function withInvariantReadiness(plugin: Plugin, callback: PluginCallback): Plugi function joinInvariantStartup( fiber: PluginFiber, invariantReady: Promise, - disposeInitialFailure = false, + disposePendingValidationFailure = false, ): PluginFiber { // RegistryService returns a thenable wrapper whose context still points to // the raw Fiber. Calling inherited await() on the wrapper would return and // assimilate that thenable, accidentally following later plugin startup. const rawFiber = fiber.ctx.fiber - const initialized = disposeInitialFailure - ? rawFiber.await().catch(async (error: unknown) => { - // Config validation is the only failure recorded while a gated fiber - // is initially PENDING. Dispose it even if queued readiness publication - // changes its state before this rejection handler runs. - await rawFiber.dispose() + const readiness = invariantReady.then(async () => { + try { + return await rawFiber.await() + } catch (error) { + // Config resolves only after the readiness injection activates. Dispose + // validation failures owned by an initially pending target; ordinary + // callback failures remain inspectable. + if (disposePendingValidationFailure && error instanceof ValidationError) { + await rawFiber.dispose() + } throw error - }) - : Promise.resolve() - const readiness = initialized.then(() => invariantReady).then(() => rawFiber.await()) + } + }) const joined = Object.create(fiber) as PluginFiber joined.then = readiness.then.bind(readiness) return joined diff --git a/vendor/README.md b/vendor/README.md index 470b517549..0666143b54 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -37,7 +37,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface. 6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. `Fiber.update()` returns its `internal/update` waterfall result, allowing Loader callers to await a restart while preserving synchronous config validation. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. -8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates start candidates concurrently, await every outcome, undo changes and additions on failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. +8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates start candidates concurrently, await every outcome, contain sibling-start failures after their owning tree is disposed, undo changes and additions on live-update failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. 9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Module watches realpath their existing base directory, attach change listeners before declaring the service ready, and use that spelling for Node module-cache identity; exact config watches realpath the deepest existing watch ancestor and restore the missing suffix. Those native paths prevent Windows short-name aliases from colliding with long-form libuv event paths while exact-config callbacks keep the requested filename. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/boot/app-boot/tests/hmr-config.spec.ts`. 10. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. 11. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts`. @@ -45,6 +45,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 13. **`include/src/index.ts` `writeTask` type**: widened the optional `writeTask?: NodeJS.Timeout` property to `NodeJS.Timeout | undefined` — the debounced writer assigns `undefined` on flush, which `exactOptionalPropertyTypes` rejects on a plain optional. Type-only; no behavior change. 14. **`include/src/index.ts` durable debounced writes**: serialized and tracked config-file writes, retried transient `EACCES`/`EBUSY`/`EPERM` rename failures with a bounded backoff, observed asynchronous timer rejections, and drained the latest write during Include teardown. Windows can briefly retain a destination handle after a Loader child disposes; the upstream fire-and-forget rename escaped as an unhandled rejection and could lose the persisted `disabled` state. A terminal failure is logged by the asynchronous writer and remains on the queue so `Include.stop()` rethrows it instead of silently declaring persistence complete; Cordis's ordinary fiber teardown retains its separate error-containment contract. Covered by `packages/host/directory-picker-auto/tests/loader-composition.spec.ts` with injected transient and terminal rename failures. 15. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table's `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for('schemastery')` and Schemastery's `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table's two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md). +16. **Lazy Loader config resolution across `cordis/src/{events,fiber}.ts`, `loader/src/{index,config/entry}.ts`, `include/src/index.ts`, and `hmr/src/index.ts`**: ports [cordiverse/cordis#41](https://github.com/cordiverse/cordis/pull/41), retaining raw fiber config and resolving it through `internal/config` only after declared injections are active. Provider replacement re-resolves the raw expression, pending updates retain it, and HMR transfers it. Resolution applies only to the entry root, so child plugins mounted by a row keep caller-owned config identity. Include adds a static entry-config resolver so its own options interpolate while nested row `!!js` nodes remain deferred. Deferred failures retain the owning row diagnostic, and tree teardown does not persist failure-driven self-disposal. Covered by `packages/boot/app-boot/tests/{app-boot,user-patches}.spec.ts`, `packages/boot/cmdline/tests/cmdline.spec.ts`, `apps/cli/tests/web-agent-presets.e2e.ts`, and the built custom-profile cases in `apps/cli/tests/built-bin.e2e.ts`. ## Sync procedure diff --git a/vendor/cordis/src/events.ts b/vendor/cordis/src/events.ts index e18940a830..e9bd85e3e1 100644 --- a/vendor/cordis/src/events.ts +++ b/vendor/cordis/src/events.ts @@ -331,6 +331,12 @@ export interface Events { 'internal/plugin'(fiber: Fiber): void /** A fiber changed lifecycle state; receives the fiber and its previous state. */ 'internal/status'(fiber: Fiber, oldValue: FiberState): void + /** + * Resolve raw plugin config after the fiber's injections become active. + * @param config - the raw config for this activation. + * @mode waterfall + */ + 'internal/config'(this: Fiber, config: any, next: () => any): any /** Interception hook for a service binding (no core producer). */ 'internal/service'(this: Context, name: string, value: any): void /** Waterfall: a fiber config update is being applied; skip `next()` to veto. */ diff --git a/vendor/cordis/src/fiber.ts b/vendor/cordis/src/fiber.ts index a8c804207d..38a3197e29 100644 --- a/vendor/cordis/src/fiber.ts +++ b/vendor/cordis/src/fiber.ts @@ -188,6 +188,8 @@ export class Fiber { public readonly ctx: Context /** The validated plugin config (updated by `update()`). */ public config: any + /** The raw plugin config, re-resolved before each activation. */ + public _config: any /** Current lifecycle state; transitions emit `internal/status`. */ public state = FiberState.PENDING /** Dispose this fiber: unload the plugin, then settle once cleanup finished. */ @@ -224,6 +226,7 @@ export class Fiber { public runtime: Plugin.Runtime | null, getOuterStack: () => string[], ) { + this._config = config const collect = (dispose: Disposable) => { this._disposables.push(dispose) } @@ -259,16 +262,8 @@ export class Fiber { collect, } - let shouldRefresh = false this.dispose = parent.fiber.effect(() => { const remove = runtime.fibers.push(this) - try { - this.config = resolveConfig(runtime, config) - shouldRefresh = true - } catch (error) { - this.ctx.logger.error(error) - this._error = error - } return async () => { this.uid = null emitPluginDisposed(this.context, this) @@ -320,7 +315,7 @@ export class Fiber { for (const name of Object.keys(this.inject)) { this._checkImpl(name) } - if (shouldRefresh) this._refresh() + this._refresh() } } else { this.uid = 0 @@ -643,6 +638,11 @@ export class Fiber { }) } + private _resolveConfig(config: any) { + config = this.context.waterfall(this, 'internal/config', config, () => config) + return this.runtime ? resolveConfig(this.runtime, config) : config + } + private async _reload() { this.store = { ...this._store } const oldEpoch = this._runner.epoch @@ -652,7 +652,9 @@ export class Fiber { // the load. Do not run plugin code for a stale epoch; the state update // below will drain any effects collected while the fiber was PENDING. if (this._runner.epoch === oldEpoch) { + this.config = this._resolveConfig(this._config) await this._execute(this._runner) + this._error = undefined } } catch (reason) { // impl guarantees that the error is non-null (?) @@ -733,7 +735,16 @@ export class Fiber { */ update(config: any, noSave = false) { this.assertActive() - config = resolveConfig(this.runtime!, config) + this._config = config + if (this.state !== FiberState.ACTIVE) { + // Config resolution may access injected services, so defer it until the + // fiber can activate. + this._error = undefined + this._setEpoch(INACTIVE) + this._refresh() + return + } + config = this._resolveConfig(config) return this.context.waterfall(this, 'internal/update', config, noSave, () => { this.config = config this._error = undefined diff --git a/vendor/hmr/src/index.ts b/vendor/hmr/src/index.ts index 290899ba1f..f79d8344dc 100644 --- a/vendor/hmr/src/index.ts +++ b/vendor/hmr/src/index.ts @@ -502,7 +502,7 @@ class Hmr extends Service { const reload = (plugin: any, runtime: Plugin.Runtime) => { if (!runtime) return for (const oldFiber of runtime.fibers) { - const fiber = oldFiber.parent.registry.plugin(plugin, oldFiber.config, this.getOuterStack) + const fiber = oldFiber.parent.registry.plugin(plugin, oldFiber._config, this.getOuterStack) fiber.entry = oldFiber.entry if (fiber.entry) fiber.entry.fiber = fiber } diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index 04079cc598..c67b591978 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -1,4 +1,4 @@ -import { EntryTree, isJsExpr, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import { EntryConfigResolver, EntryTree, interpolate, isJsExpr, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { Context, Service } from '@deepseek-ai/cordis' import { extname } from 'node:path' import { access, constants, readFile, rename, writeFile } from 'node:fs/promises' @@ -174,6 +174,21 @@ export namespace Include { export class Include extends EntryTree { static inject = ['loader'] + /** + * Resolve Include's own options while preserving nested entry expressions. + * @param ctx - the Include plugin context. + * @param config - the raw Include config. + * @returns resolved Include options with `initial` and `patches` untouched. + */ + static [EntryConfigResolver](ctx: Context, config: Include.Config): Include.Config { + const { initial, patches, ...own } = config + return { + ...interpolate(ctx, own), + ...(initial === undefined ? {} : { initial }), + ...(patches === undefined ? {} : { patches }), + } + } + public filename: string private type?: string private readonly: boolean diff --git a/vendor/loader/src/config/entry.ts b/vendor/loader/src/config/entry.ts index 215198468f..0f35cdfa97 100644 --- a/vendor/loader/src/config/entry.ts +++ b/vendor/loader/src/config/entry.ts @@ -3,7 +3,13 @@ import { deepEqual, isNullable } from '@deepseek-ai/cosmokit' import { Loader } from '../index.ts' import { EntryGroup } from './group.ts' import { EntryTree } from './tree.ts' -import { evaluate, interpolate } from './utils.ts' +import { evaluate } from './utils.ts' + +/** Static plugin hook for resolving a container config while preserving nested entry configs. */ +export const EntryConfigResolver = Symbol.for('cordis.loader.entry-config-resolver') + +/** Resolver installed at {@link EntryConfigResolver}. */ +export type EntryConfigResolver = (ctx: Context, config: any) => any /** Serialized plugin entry options stored in loader config files. */ export interface EntryOptions { @@ -101,17 +107,12 @@ export class Entry { return evaluate(this.ctx, expr) } - _resolveConfig(plugin: any): [any, any?] { - if (plugin[EntryGroup.key]) return this.options.config - return interpolate(this.ctx, this.options.config) - } - private async _patchContext(diff: string[]) { await this.context.waterfall('loader/patch-context', this, async () => { Object.setPrototypeOf(this.ctx, this.parent.ctx) if (this.fiber?.uid && (diff.includes('config') || this.options.group)) { - await this.fiber.update(this._resolveConfig(this.fiber.runtime!.callback), true) + await this.fiber.update(this.options.config, true) } }) } @@ -258,7 +259,15 @@ export class Entry { this._initTask = undefined if (!this.loader.getTasks().length) this.ctx.reflect.notify(['loader']) } - await this.fiber?.await() + await this._await() + } + + async _await() { + try { + await this.fiber?.await() + } catch (error) { + throw updateError('apply', this.options, error) + } } private async _init() { @@ -278,17 +287,13 @@ export class Entry { private async _start(plugin: any) { let fiber: Fiber | undefined try { - fiber = await this._create(plugin) + await this._patchContext([]) + this.loader.showLog(this, 'apply') + fiber = this.fiber = this.ctx.registry.plugin(plugin, this.options.config, this.getOuterStack) await fiber.await() } catch (error) { await this._dispose(fiber) throw error } } - - private async _create(plugin: any): Promise { - await this._patchContext([]) - this.loader.showLog(this, 'apply') - return this.fiber = this.ctx.registry.plugin(plugin, this._resolveConfig(plugin), this.getOuterStack) - } } diff --git a/vendor/loader/src/config/group.ts b/vendor/loader/src/config/group.ts index a7b0997297..2e322b1e26 100644 --- a/vendor/loader/src/config/group.ts +++ b/vendor/loader/src/config/group.ts @@ -69,6 +69,10 @@ export class EntryGroup { try { const outcomes = await Promise.allSettled(config.map(options => this.create(options))) + // Disposal owns termination: sibling starts can still be settling after + // the containing tree has gone away, but their failures no longer + // describe a live update to roll back. + if (this.ctx.fiber.uid === null) return const failures = outcomes .filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected') .map(outcome => outcome.reason) diff --git a/vendor/loader/src/config/tree.ts b/vendor/loader/src/config/tree.ts index 4b5ac78ef7..c1925f0b90 100644 --- a/vendor/loader/src/config/tree.ts +++ b/vendor/loader/src/config/tree.ts @@ -51,7 +51,7 @@ export abstract class EntryTree { continue } const outcomes = await Promise.allSettled( - [...this.entries()].map(entry => entry.fiber?.await()), + [...this.entries()].map(entry => entry._await()), ) const failures = outcomes .filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected') diff --git a/vendor/loader/src/index.ts b/vendor/loader/src/index.ts index fa1f852cff..3fe3e57949 100644 --- a/vendor/loader/src/index.ts +++ b/vendor/loader/src/index.ts @@ -1,9 +1,16 @@ -import { Context, Inject, Service } from '@deepseek-ai/cordis' +import { Context, FiberState, Inject, Service, type Fiber } from '@deepseek-ai/cordis' import { defineProperty, isNullable, type Dict } from '@deepseek-ai/cosmokit' import { ModuleLoader } from './internal.ts' -import { Entry, type EntryOptions } from './config/entry.ts' +import { + Entry, + EntryConfigResolver, + type EntryConfigResolver as ConfigResolver, + type EntryOptions, +} from './config/entry.ts' +import { EntryGroup } from './config/group.ts' import isolate from './config/isolate.ts' import { EntryTree } from './config/tree.ts' +import { interpolate } from './config/utils.ts' /** Re-export entry node APIs. */ export * from './config/entry.ts' @@ -87,6 +94,15 @@ export class Loader extends EntryTree { ctx.reflect.provide('loader', this, this[Service.check]) + ctx.on('internal/config', function (this: Fiber, _config, next) { + const config = next() + if (!this.entry || this.parent.fiber?.entry === this.entry) return config + const plugin = this.runtime?.callback as Record | undefined + if (plugin?.[EntryGroup.key]) return config + const resolve = plugin?.[EntryConfigResolver] as ConfigResolver | undefined + return resolve ? resolve(this.ctx, config) : interpolate(this.ctx, config) + }, { global: true }) + ctx.on('internal/update', async function (config, noSave, next) { if (!this.entry || noSave || this.parent.fiber?.entry === this.entry) return next() await next() @@ -127,7 +143,8 @@ export class Loader extends EntryTree { if (!ctx.registry.has(fiber.runtime!.callback)) return // case 5: the entry's tree is being disposed - if (!fiber.entry.parent.tree.ctx.fiber.uid) return + const treeOwner = fiber.entry.parent.tree.ctx.fiber + if (!treeOwner.uid || treeOwner.state === FiberState.UNLOADING) return // case 6: Loader is replacing or removing this exact fiber if (fiber.entry._disposing) return From d4ccfbd80ff7e19279c9851bd8c1ed16816aac30 Mon Sep 17 00:00:00 2001 From: Turtle Date: Sun, 9 Aug 2026 18:23:25 +0800 Subject: [PATCH 08/19] refactor(cli)!: complete app-owned profile startup --- ...19-gui-layering-and-rpc-protocol.i18n.yaml | 4 +-- ...026-07-19-gui-layering-and-rpc-protocol.md | 8 ++--- ...-07-19-gui-layering-and-rpc-protocol.zh.md | 8 ++--- ...-07-29-dsh-source-launch-tsx-esm.i18n.yaml | 4 +-- .../2026-07-29-dsh-source-launch-tsx-esm.md | 2 +- ...2026-07-29-dsh-source-launch-tsx-esm.zh.md | 2 +- ...026-08-05-profile-plugin-bundles.i18n.yaml | 4 +-- .../2026-08-05-profile-plugin-bundles.md | 2 +- .../2026-08-05-profile-plugin-bundles.zh.md | 2 +- ...026-08-06-app-owned-command-line.i18n.yaml | 4 +-- .../2026-08-06-app-owned-command-line.md | 4 +-- .../2026-08-06-app-owned-command-line.zh.md | 4 +-- ...headless-direct-core-entry-point.i18n.yaml | 4 +-- ...-08-09-headless-direct-core-entry-point.md | 6 ++-- ...-09-headless-direct-core-entry-point.zh.md | 6 ++-- ...3-cli-signal-shutdown-escalation.i18n.yaml | 4 +-- ...26-08-03-cli-signal-shutdown-escalation.md | 2 +- ...08-03-cli-signal-shutdown-escalation.zh.md | 2 +- ...6-08-08-dsh-run-headless-command.i18n.yaml | 4 +-- .../2026-08-08-dsh-run-headless-command.md | 2 ++ .../2026-08-08-dsh-run-headless-command.zh.md | 2 ++ ...-20-remove-stdio-and-echo-agents.i18n.yaml | 4 +-- ...2026-07-20-remove-stdio-and-echo-agents.md | 4 +-- ...6-07-20-remove-stdio-and-echo-agents.zh.md | 4 +-- .../2026-08-08-remove-cli-demo.i18n.yaml | 4 +-- .../2026-08-08-remove-cli-demo.md | 14 ++++---- .../2026-08-08-remove-cli-demo.zh.md | 14 ++++---- README.i18n.yaml | 4 +-- README.md | 2 +- README.zh.md | 2 +- apps/cli/README.i18n.yaml | 4 +-- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/package.json | 1 + apps/cli/reference/README.i18n.yaml | 4 +-- apps/cli/reference/README.md | 6 ++-- apps/cli/reference/README.zh.md | 6 ++-- apps/cli/src/bin.ts | 4 +-- apps/cli/src/profile-boot.ts | 7 +++- apps/cli/tests/headless-shutdown.e2e.ts | 2 +- apps/cli/tsconfig.json | 5 ++- docs/config-catalog.i18n.yaml | 4 +-- docs/config-catalog.md | 13 +++---- docs/config-catalog.zh.md | 13 +++---- docs/testing.i18n.yaml | 4 +-- docs/testing.md | 2 +- docs/testing.zh.md | 2 +- docs/user/develop/basic/publish.i18n.yaml | 4 +-- docs/user/develop/basic/publish.md | 14 ++++++-- docs/user/develop/basic/publish.zh.md | 14 ++++++-- docs/user/guide/quickstart.i18n.yaml | 4 +-- docs/user/guide/quickstart.md | 6 ++-- docs/user/guide/quickstart.zh.md | 6 ++-- examples/headless-agent/README.i18n.yaml | 4 +-- examples/headless-agent/README.md | 4 +-- examples/headless-agent/README.zh.md | 4 +-- ...cordis.yml => headless-profile.cordis.yml} | 0 .../headless-agent/tests/headless.snapshot.ts | 36 +++++++++---------- .../session.expected.jsonl | 8 ++--- .../stderr.expected.txt | 0 package.json | 2 +- packages/boot/README.i18n.yaml | 4 +-- packages/boot/app-boot/README.i18n.yaml | 4 +-- packages/boot/app-boot/README.md | 2 +- packages/boot/app-boot/README.zh.md | 2 +- packages/boot/app-boot/src/index.ts | 2 +- packages/boot/cmdline/README.i18n.yaml | 6 ++-- packages/boot/cmdline/README.md | 2 +- packages/boot/cmdline/README.zh.md | 2 +- packages/boot/cmdline/package.json | 2 +- packages/boot/cmdline/src/index.ts | 11 +++--- packages/bundle/headless/src/index.ts | 2 +- packages/bundle/headless/tsconfig.json | 5 +-- packages/bundle/web-app/README.i18n.yaml | 4 +-- packages/bundle/web-app/README.md | 8 ++--- packages/bundle/web-app/README.zh.md | 8 ++--- packages/bundle/web-app/cordis.patch.yml | 11 +++--- packages/bundle/web-app/package.json | 2 +- packages/bundle/web-app/src/index.ts | 4 +-- packages/bundle/web-app/src/startup.ts | 24 +++++-------- packages/bundle/web-app/tests/startup.spec.ts | 7 +--- packages/bundle/web-app/tsconfig.json | 5 ++- .../core/agent-default-model/README.i18n.yaml | 4 +-- packages/core/agent-default-model/README.md | 2 +- .../core/agent-default-model/README.zh.md | 2 +- packages/examples/README.i18n.yaml | 4 +-- packages/examples/README.md | 2 +- packages/examples/README.zh.md | 2 +- packages/host/apiproxy/README.i18n.yaml | 4 +-- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- pnpm-lock.yaml | 6 ++++ scripts/gen-cordis-catalog.ts | 3 ++ .../request-response.expected.json | 4 +-- tsconfig.base.json | 2 ++ tsdown.config.ts | 2 +- vendor/loader/src/config/entry.ts | 7 +++- 97 files changed, 260 insertions(+), 224 deletions(-) rename examples/headless-agent/tests/fixtures/{dsh-run.cordis.yml => headless-profile.cordis.yml} (100%) rename examples/headless-agent/tests/snapshots/{dsh-run => headless-profile}/session.expected.jsonl (91%) rename examples/headless-agent/tests/snapshots/{dsh-run => headless-profile}/stderr.expected.txt (100%) diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml index bc2d26325d..3d370c1e37 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.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-07-19-gui-layering-and-rpc-protocol.md -2026-07-19-gui-layering-and-rpc-protocol.md: 514deb890d4e08d465db869669078473d32fb215 -2026-07-19-gui-layering-and-rpc-protocol.zh.md: f6fa71e3dac25f48b2ad4744a0cc695417528b34 +2026-07-19-gui-layering-and-rpc-protocol.md: da96ae97f2a2d64aeef7794bd82ccbd86602b1ad +2026-07-19-gui-layering-and-rpc-protocol.zh.md: 36dc7391bc3f9bb0d5105fea14a2763d0b7159a1 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md index 514deb890d..da96ae97f2 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md @@ -10,7 +10,7 @@ English | [中文](2026-07-19-gui-layering-and-rpc-protocol.zh.md) We need a UI integration layer. Beyond the existing ACP/stdio baseline, more product clients are coming — Web (server), Electron, and others. We call them Clients and want the following capabilities: -- One `dsh` process supporting both `dsh web` (serve) and `dsh run` (headless) — one process, two modes (a design reservation) +- One `dsh` process supporting both `dsh web` (serve) and `dsh --profile headless` (headless) — one process, two modes (a design reservation) - Launching inside Electron with the same Web technologies as `dsh web` That demands a stable layered responsibility model in the engineering codebase, so future clients plug in cleanly. @@ -31,7 +31,7 @@ Directories layer as follows: - **Fetch-arrival plugin packages** (`ui-layout`, `ui-sidebar`, `ui-conversation`, `ui-trajectory`): dual-entry — the root index is the node half (an empty `apply`, existing so the host Loader governs lifecycle and the web plugin registry discovers the package.json `dsh.client` declaration); the implementation lives under `src/client/`, shipped as the `./client` subpath (a tsdown closure-factory bundle). Cross-plugin consumption of `/client` is type-only; value cooperation goes through cordis services. - `apps/` holds the externally exported applications, assembled from Client / Host mixtures. - `apps/web` (`dsh-frontend`) is the vite application: a thin `main.ts` over the shell surface exported by `dsh-client-web`. - - `apps/cli` (`@deepseek-ai/dsh`) dispatches commands: `dsh web` = Host + webserver + the built `dsh-frontend` dist; `dsh run` = [a direct core Agent/Session entry point](2026-08-09-headless-direct-core-entry-point.md), with zero Host, HTTP, or browser layer. + - `apps/cli` (`@deepseek-ai/dsh`) dispatches commands: `dsh web` = Host + webserver + the built `dsh-frontend` dist; `dsh --profile headless` = [a direct core Agent/Session entry point](2026-08-09-headless-direct-core-entry-point.md), with zero Host, HTTP, or browser layer. - A future Electron application reuses the same web client packages over an IPC fetch carrier. ``` @@ -79,7 +79,7 @@ Packages under `packages/host/*` and `packages/client/*` **must carry the direct 2. **Write an assembly module under `apps/`**: `startHost()` + a client subclass + the application's private signal/print/exit semantics; a mixture never becomes a package — assembly is written in the app. 3. **Import `dsh-host-webserver` only if you need HTTP carriage**, otherwise zero ports. -The two existing applications preserve the division: the Web application mounts Host, carrier, and browser composition, while `dsh run` mounts a direct core runner with zero Host, HTTP, or ports. ACP-class protocol bridges do not follow the client-carrier checklist: they expose core to the external ecosystem and mount directly via `ctx.plugin(entry-point plugin)` without fetch. +The two existing applications preserve the division: the Web application mounts Host, carrier, and browser composition, while `dsh --profile headless` mounts a direct core runner with zero Host, HTTP, or ports. ACP-class protocol bridges do not follow the client-carrier checklist: they expose core to the external ecosystem and mount directly via `ctx.plugin(entry-point plugin)` without fetch. ## Message protocol @@ -215,7 +215,7 @@ All four quadrant full forms pass through `onEnvelope`; the base implementation | Subclass | Package | doFetch | Purpose | |---|---|---|---| -| `InProcessApiClient` | apiproxy itself | the injected `{ fetch }` handler | **The isomorphic point**: `new InProcessApiClient(toFetchHandler(api))` never touches the network yet runs the real wire serialization/zod/SSE framing; carrier tests and callers can exercise the protocol without opening a port, while product `dsh run` drives core directly | +| `InProcessApiClient` | apiproxy itself | the injected `{ fetch }` handler | **The isomorphic point**: `new InProcessApiClient(toFetchHandler(api))` never touches the network yet runs the real wire serialization/zod/SSE framing; carrier tests and callers can exercise the protocol without opening a port, while product `dsh --profile headless` drives core directly | | `WebApiClient` | dsh-client-connection | `globalThis.fetch` uplink + one same-origin WebSocket downlink per logical stream | the browser client; physical boundary in the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md) | | `FixtureApiClient` | dsh-client-connection | unused (protocol-layer override) | serverless UI development (`?fixture`): overrides the `callUnary`/`openMux`/`openHost`/`respond` virtuals and is itself the fake server (frame rpcIds minted by it, semantics self-consistent) | | IPC bridge subclass (hypothetical example — no such shell exists) | an Electron shell | IPC serialization round trip | would swap only doFetch; contract and base class unchanged | diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md index f6fa71e3da..36dc7391bc 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md @@ -9,7 +9,7 @@ Status: implemented ## Problem 需要提供 UI 对接层,除已有 ACP(Agent Client Protocol)/stdio 基线外,还需要 Web(server)、Electron 等其他产品客户端。我们把它们统一称为 Client。希望具备以下能力: -- 一个 `dsh` 进程同时支持 `dsh web`(启动)和 `dsh run`(headless),一个进程两种模式(设计预留) +- 一个 `dsh` 进程同时支持 `dsh web`(启动)和 `dsh --profile headless`(headless),一个进程两种模式(设计预留) - 在 Electron 中使用与 `dsh web` 相同的 Web 技术启动 那么当前的工程代码需要稳定的分层职责模型,便于以后接入各类 client。 @@ -29,7 +29,7 @@ Status: implemented - **fetch 到达插件包**(`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dsh.client` 声明);实现住在 `src/client/` 下,经 `./client` 子路径发布(tsdown 闭包工厂 bundle)。跨插件消费 `/client` 只限类型;值层面的协作走 cordis 服务。 - `apps/` 作为对外导出的应用入口,可以由 Client / Host 混合组装。 - `apps/web`(`dsh-frontend`)是 vite 应用:`dsh-client-web` 导出的壳表面之上的一层薄 `main.ts`。 - - `apps/cli`(`@deepseek-ai/dsh`)分发命令:`dsh web` = Host + webserver + 构建出的 `dsh-frontend` dist;`dsh run` = [直接使用核心 Agent/Session 的入口](2026-08-09-headless-direct-core-entry-point.md),不含 Host、HTTP 或浏览器层。 + - `apps/cli`(`@deepseek-ai/dsh`)分发命令:`dsh web` = Host + webserver + 构建出的 `dsh-frontend` dist;`dsh --profile headless` = [直接使用核心 Agent/Session 的入口](2026-08-09-headless-direct-core-entry-point.md),不含 Host、HTTP 或浏览器层。 - 将来的 Electron 应用经由 IPC fetch 载体复用同一套 web client 包。 ``` @@ -77,7 +77,7 @@ TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig. 2. **在 `apps/` 下写拼装模块**:`startHost()` + 客户端子类 + 该应用私有的信号/打印/退出语义;混合体不建包,拼装写在 app 里。 3. **需要 HTTP 承载才 import `dsh-host-webserver`**,否则零端口。 -现有两个应用保持这一区分:Web 应用挂载 Host、载体与浏览器组合,而 `dsh run` 挂载直接使用核心服务的 runner,不包含 Host、HTTP 或端口。ACP 类协议桥不遵循 client 载体清单:它把 core 暴露给外部生态,直接通过 `ctx.plugin(入口插件)` 挂载,不使用 fetch。 +现有两个应用保持这一区分:Web 应用挂载 Host、载体与浏览器组合,而 `dsh --profile headless` 挂载直接使用核心服务的 runner,不包含 Host、HTTP 或端口。ACP 类协议桥不遵循 client 载体清单:它把 core 暴露给外部生态,直接通过 `ctx.plugin(入口插件)` 挂载,不使用 fetch。 ## 消息协议 @@ -213,7 +213,7 @@ export type ResponseValue = | 子类 | 所在包 | doFetch | 用途 | |---|---|---|---| -| `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧;载体测试与调用方可以在不打开端口的情况下运行这套协议,而产品 `dsh run` 直接驱动 core | +| `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧;载体测试与调用方可以在不打开端口的情况下运行这套协议,而产品 `dsh --profile headless` 直接驱动 core | | `WebApiClient` | dsh-client-connection | `globalThis.fetch` 上行 + 每逻辑流一条同源 WebSocket 下行 | 浏览器客户端;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md) | | `FixtureApiClient` | dsh-client-connection | 不用(协议层覆写) | 无 server 的 UI 开发(`?fixture`):覆写 `callUnary`/`openMux`/`openHost`/`respond` 虚方法,自己就是假 server(帧 rpcId 由它 mint,语义自洽) | | IPC 桥子类(假想示例——尚无此形态) | Electron 壳 | IPC 序列化往返 | 只需换 doFetch,约定/基类零改 | diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml index 8f17ecd480..f4fb753e79 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.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-07-29-dsh-source-launch-tsx-esm.md -2026-07-29-dsh-source-launch-tsx-esm.md: ed22e51d59a25db130b3760ce484c116bade4348 -2026-07-29-dsh-source-launch-tsx-esm.zh.md: bdd549092eb30f7749c8f7561068daafe3548b28 +2026-07-29-dsh-source-launch-tsx-esm.md: 5cf4a227f388a1ac8315594af4e0256864ef17f5 +2026-07-29-dsh-source-launch-tsx-esm.zh.md: b5a52b3d01840337c0091310e50d8fac34245519 diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md index ed22e51d59..5cf4a227f3 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md @@ -35,4 +35,4 @@ The node-compat CI matrix (Node 22.19 and 26) gains `dsh-source-launch-smoke` (` - One launch vector across the whole engines range, including future Node lines that change native TypeScript support; the smoke gate enforces it per matrix line. - TypeScript transformation is delegated to tsx/esbuild again, reversing the prior note's goal of proving Node-native transformation; that goal is unreachable while vendored sources use non-erasable syntax and Node ships no transform mode. - The runtime declared-dependency enforcement in source launches is gone; undeclared workspace imports now surface only through static gates or built-mode resolution failures. -- Startup improves ~0.4s over the full tsx default (`demo:headless` now aliases the same `dsh run` source launch; ACP keeps `--import tsx` because its graph was not audited for CJS-hook dependence and its launch latency is not on the interactive path). +- Startup improves ~0.4s over the full tsx default (`demo:headless` now aliases the same `dsh --profile headless` source launch; ACP keeps `--import tsx` because its graph was not audited for CJS-hook dependence and its launch latency is not on the interactive path). diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md index bdd549092e..b5a52b3d01 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md @@ -35,4 +35,4 @@ node-compat CI 矩阵(Node 22.19 与 26)新增 `dsh-source-launch-smoke`(` - 整个 engines 范围(包括未来改变原生 TypeScript 支持的 Node 版本线)只有一个启动向量;冒烟门禁按矩阵行强制执行。 - TypeScript 转换重新委托给 tsx/esbuild,逆转了前一篇 Agent Note「证明 Node 原生转换可用」的目标;在 vendor 源码使用不可擦除语法且 Node 不再提供 transform 模式的情况下,该目标不可达。 - 源码启动中的运行时依赖声明强制不复存在;未声明的 workspace import 现在只能通过静态门禁或构建模式的解析失败暴露。 -- 启动相比完整 tsx 默认形态快约 0.4s(`demo:headless` 现为同一条 `dsh run` 源码启动命令的别名;ACP 保留 `--import tsx`,因为它的依赖图尚未就 CJS 钩子依赖性做审计,且其启动延迟不在交互路径上)。 +- 启动相比完整 tsx 默认形态快约 0.4s(`demo:headless` 现为同一条 `dsh --profile headless` 源码启动命令的别名;ACP 保留 `--import tsx`,因为它的依赖图尚未就 CJS 钩子依赖性做审计,且其启动延迟不在交互路径上)。 diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml index baee9e065f..c583d6f373 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.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-05-profile-plugin-bundles.md -2026-08-05-profile-plugin-bundles.md: 385977b2d085a39bcda89bca0fb6543f08e7a961 -2026-08-05-profile-plugin-bundles.zh.md: 22ed4100b97db3f7c48bf55688f1a78edb512add +2026-08-05-profile-plugin-bundles.md: 54626e3f48a2ba7db19813e6e883f0e77499d0e2 +2026-08-05-profile-plugin-bundles.zh.md: 357e0f63d4eba0f0985c9e14aad54595c7b41c77 diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md index 385977b2d0..54626e3f48 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md @@ -22,7 +22,7 @@ Two supporting refactors: the webserver's built-in static dist serving became th - **Dependency-scan plus partial `patchOrder`** (the original sketch): scanning `dependencies` for bundles and ordering unlisted ones alphabetically has two sources of truth and an implicit tie-break; one explicit ordered `dsh.profile.bundles` list is smaller and fully deterministic. A raw `pnpm add` inside the profile installs a library without activating any patch — explicit, no spooky scan. - **`link:` entries for in-box bundles**: pnpm cannot version, install, or update a `link:` into the installation, it embeds a machine path in a user file, and it breaks when the installation moves. The two-anchor resolution plus healed symlink fallback gives the same guarantee ("bundles come from the installation") without ceremony. -- **A pre-boot `context` module in the bundle manifest** for boot-time values (dist path, flag facts): rejected in favor of pure plugins — the glue is ordinary rows the launcher patches, so the composition stays fully dumpable and the manifest stays data-only. The launcher-owned `ctx.headlessIo` host hook is the one host-provided slot, and it is provided in `boot()`'s `prepare` hook, before any config-tree entry mounts. +- **A pre-boot `context` module in the bundle manifest** for boot-time values (dist path, flag facts): rejected in favor of pure plugins — the glue is ordinary rows and app-owned startup services, so the composition stays fully dumpable and the manifest stays data-only. The launcher-owned `ctx.headlessIo` host hook is the one host-provided slot, and it is provided in `boot()`'s `prepare` hook, before any config-tree entry mounts. - **Transitive bundle auto-application**: only direct `dsh.profile.bundles` entries contribute layers; a meta-bundle wanting to re-export another bundle's patch must do so explicitly in its own patch file. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md index 22ed4100b9..357e0f63d4 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md @@ -22,7 +22,7 @@ Status: implemented - **依赖扫描加部分 `patchOrder`**(最初的草案):扫描 `dependencies` 找出组合包、未列出者按字母序排列,会产生两个真源和一条隐式决胜规则;一份显式有序的 `dsh.profile.bundles` 列表更小、完全确定。在 profile 内直接 `pnpm add` 只会安装一个库,不激活任何 patch——行为显式,没有暗中扫描。 - **内置组合包使用 `link:` 条目**:pnpm 无法对指向安装目录的 `link:` 做版本管理、安装或更新,它会把机器路径嵌进用户文件,并且在安装目录移动后失效。双锚点解析加上每次启动修复的符号链接回退提供了同样的保证(「组合包来自安装目录」),且没有这些繁文缛节。 -- **在组合包 manifest 中放一个启动前 `context` 模块**承载启动期取值(dist 路径、flag 事实):否决,改用纯插件——粘合逻辑就是启动器 patch 的普通配置行,因此组合始终可完整 dump,manifest 保持纯数据。启动器持有的 `ctx.headlessIo` 宿主钩子是唯一由宿主提供的 slot,且在任何配置树条目挂载之前,于 `boot()` 的 `prepare` 钩子中提供。 +- **在组合包 manifest 中放一个启动前 `context` 模块**承载启动期取值(dist 路径、flag 事实):否决,改用纯插件——粘合逻辑就是普通配置行和由应用持有的启动服务,因此组合始终可完整 dump,manifest 保持纯数据。启动器持有的 `ctx.headlessIo` 宿主钩子是唯一由宿主提供的 slot,且在任何配置树条目挂载之前,于 `boot()` 的 `prepare` 钩子中提供。 - **组合包的传递式自动应用**:只有直接列在 `dsh.profile.bundles` 中的条目才贡献层;想重新导出另一个组合包 patch 的元组合包,必须在自己的 patch 文件中显式完成。 ## Consequences 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 728edb1ec0..97b4a529f3 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: 269f9193e6cf7852ba9652c961bfdd309080ae0b -2026-08-06-app-owned-command-line.zh.md: 943932062983622267f28591dcc22ca2d12274e0 +2026-08-06-app-owned-command-line.md: 948de243abe39c7b4af014f8709e102a53aa9797 +2026-08-06-app-owned-command-line.zh.md: 00cce42d123c788f78386a718f7711cad0e0c234 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 269f9193e6..948de243ab 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 @@ -29,7 +29,7 @@ Four framework facts shape the mechanism: - **Provider replacement and HMR must preserve the same contract.** Fiber reactivation re-runs the waterfall, HMR carries the raw config 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. -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. +This leaves dependency ordering in Cordis activation and Loader interpolation, which own it. Rows keep their `inject` and config, Loader mounts the composition once, and the launcher only provides argv and process-lifecycle services. ## Alternatives considered @@ -37,7 +37,7 @@ This puts dependency ordering at the seam that owns it. Rows keep their `inject` - **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. - **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. +- **Both apps parsing the same argv** (a custom composition combines Web and one-shot startup rows): two parsers cannot both own `-h`. A composition has exactly one command-line owner, so a layering bundle disables the startup row it absorbs and provides every startup service its retained rows inject. - **`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 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 9439320629..00cce42d12 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 @@ -29,7 +29,7 @@ boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Lo - **提供方替换与 HMR 必须保持相同契约。** fiber 重新激活时会重跑 waterfall,HMR 会把原始配置带给替换 fiber,而待处理行可以接受选项变更,不会针对缺失服务提前求值表达式。 - **不能从正在挂载的插件内部插入一行**——`tree.create` 返回一个带前缀的 id,随后它自己解析不出来——因此条件性的行以 `disabled: true` 交付,再由活跃行启用(`dsh web --dev` 及其重载链路);启用后的行继续遵循普通注入顺序。 -这样,依赖顺序就由真正持有它的接缝负责。各行保留自己的 `inject` 和配置,Loader 只挂载一次组合,启动器只提供 argv 与进程生命周期服务。 +这样,依赖顺序仍由负责它的 Cordis 激活与 Loader 插值流程处理。各行保留自己的 `inject` 和配置,Loader 只挂载一次组合,启动器只提供 argv 与进程生命周期服务。 ## 曾考虑的替代方案 @@ -37,7 +37,7 @@ boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Lo - **通过清空行的 `inject` 来放行**:孤立测试可行,在真实 web 树上失败,因为清空 `inject` 恰恰会丢失插件的静态注入。在插件真的去读它声明过的服务之前,这个失败是静默的。 - **由启动器管理两趟挂载**:它可以让提供方先于读取行激活,但会重复组合、把顺序变成启动器职责,还掩盖了 Loader 的缺陷——嵌套表达式在 include 上下文而不是目标行的注入上下文中求值。 - **由启动器在 boot 之前运行每个组合包的启动函数**(完全不经过 cordis):严格早于「先 boot 再 help」,但这会让应用启动成为配置树之外的第二套插件协议。使用注入 `cmdlineArgs` 的启动行则只保留一套协议:它就是一个普通的行,可 dump、可 patch,叠加的组合包也能像禁用其他行那样禁用它。 -- **两个应用解析同一份 argv**(一次性组合包叠加在 web 组合包之上):两个解析器不可能同时持有 `-h`。一套组合有且只有一个命令行所有者:叠加的组合包禁用下层的启动行,并同时提供这两个启动服务,使被吸收的行按组合后的取值启动。 +- **两个应用解析同一份 argv**(自定义组合同时包含 Web 与一次性启动行):两个解析器不可能同时持有 `-h`。一套组合有且只有一个命令行所有者,因此叠加的组合包要禁用被吸收的启动行,并提供保留下来的各行所注入的全部启动服务。 - **`instanceof CommanderError`**:树外插件会带来自己的一份 commander 副本,类身份因此不同,已经打印出来的 `--help` 会被重新抛成致命的加载失败。改为按结构识别 commander 的控制流错误。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml index b851627050..989c195965 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.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-09-headless-direct-core-entry-point.md -2026-08-09-headless-direct-core-entry-point.md: 49afe2993de7302adbedcdf9e8e2347d6424ee2a -2026-08-09-headless-direct-core-entry-point.zh.md: 73c1cbe5ac777025f63f46751b1d5ccebbfe9676 +2026-08-09-headless-direct-core-entry-point.md: e411214a666787ff62626728c4e6887bfc3ec311 +2026-08-09-headless-direct-core-entry-point.zh.md: d17aab2352c9f55b58e52856ebb83cd6351afb10 diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md index 49afe2993d..e411214a66 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md @@ -20,11 +20,11 @@ The shipped `headless` profile contains `dsh-base` and `dsh-headless`. The headl `loadProfile` recognizes the exact installation-owned headless tuple (`dsh-base`, `dsh-web-app`, `dsh-headless`) and normalizes it to the shipped headless template while preserving every other manifest field. Extra, missing, or reordered bundle lists are user-owned and remain untouched. -This note owns the headless transport and completion contracts. [`dsh run` owns one-shot headless execution](../feature/2026-08-08-dsh-run-headless-command.md) owns the command grammar, [GUI layering and RPC protocol](2026-07-19-gui-layering-and-rpc-protocol.md) owns browser gateway boundaries, [web config-tree boot and transport layering](2026-07-24-web-config-tree-boot-and-transport-layering.md) owns the Web tree, and [the default model follows the picker](../feature/2026-08-07-default-model-follows-the-picker.md) owns persistence of the shared Agent default. +This note owns the headless transport and completion contracts. [Apps own their command lines](2026-08-06-app-owned-command-line.md) owns the current `dsh --profile headless` grammar; the former [`dsh run` decision](../feature/2026-08-08-dsh-run-headless-command.md) records the superseded launcher-owned grammar, [GUI layering and RPC protocol](2026-07-19-gui-layering-and-rpc-protocol.md) owns browser gateway boundaries, [web config-tree boot and transport layering](2026-07-24-web-config-tree-boot-and-transport-layering.md) owns the Web tree, and [the default model follows the picker](../feature/2026-08-07-default-model-follows-the-picker.md) owns persistence of the shared Agent default. ## Verification -Package tests use the real Session store and Agent registry around a scripted Agent factory to pin idle-to-idle aggregation, late asynchronous completion, terminal model diagnostics, other non-completed exits, direct failures, Loader-time disposal, and flush-before-exit ordering. The keyless assembled snapshots drive `dsh run` through a replayed tool round trip, record a `user/message` with `source.kind: 'user'`, and expose a terminal model failure on stderr. Built-bin acceptance reaches a mock provider through the published entry and requires final text on stdout, exit 0, and empty stderr. Config-dump acceptance excludes every Host, Web, and Client package from the shipped headless tree; PTY shutdown coverage requires no observation line and bounded disposal. +Package tests use the real Session store and Agent registry around a scripted Agent factory to pin idle-to-idle aggregation, late asynchronous completion, terminal model diagnostics, other non-completed exits, direct failures, Loader-time disposal, and flush-before-exit ordering. The keyless assembled snapshots drive `dsh --profile headless` through a replayed tool round trip, record a `user/message` with `source.kind: 'user'`, and expose a terminal model failure on stderr. Built-bin acceptance reaches a mock provider through the published entry and requires final text on stdout, exit 0, and empty stderr. Config-dump acceptance excludes every Host, Web, and Client package from the shipped headless tree; PTY shutdown coverage requires no observation line and bounded disposal. ## Alternatives considered @@ -39,6 +39,6 @@ Package tests use the real Session store and Agent registry around a scripted Ag ## Consequences -`dsh run` provides a local Agent task rather than browser observation, Host APIs, or HTTP. Users who need those capabilities choose `dsh web`. Successful stderr is empty, completion follows durable flush, and the persisted Session remains available to later tooling. Its initial user message records `source.kind: 'user'` and therefore carries no ApiProxy `rpcId`. +`dsh --profile headless` provides a local Agent task rather than browser observation, Host APIs, or HTTP. Users who need those capabilities choose `dsh web`. Successful stderr is empty, completion follows durable flush, and the persisted Session remains available to later tooling. Its initial user message records `source.kind: 'user'` and therefore carries no ApiProxy `rpcId`. ApiProxy carrier coverage stays in the ApiProxy package. Custom one-shot profiles may include Host or Web bundles explicitly, while the shipped profile and the recognized installation-owned tuple are Web-free. diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md index 73c1cbe5ac..d17aab2352 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md @@ -20,11 +20,11 @@ Status: implemented `loadProfile` 识别安装过程拥有的精确 headless 元组(`dsh-base`、`dsh-web-app`、`dsh-headless`),将其规范化为随附的 headless 模板,并保留 manifest(元数据清单)的其他所有字段。带额外项、缺少项或顺序不同的组合包列表归用户所有,保持不变。 -本 Agent Note 负责 headless 的传输与完成约定。[`dsh run` 负责一次性 headless 执行](../feature/2026-08-08-dsh-run-headless-command.md)负责命令语法,[GUI 分层与 RPC 协议](2026-07-19-gui-layering-and-rpc-protocol.md)负责浏览器网关边界,[Web 配置树启动与传输分层](2026-07-24-web-config-tree-boot-and-transport-layering.md)负责 Web 插件树,[默认模型跟随选择器](../feature/2026-08-07-default-model-follows-the-picker.md)负责共享 Agent 默认值的持久化。 +本 Agent Note 负责 headless 的传输与完成约定。[应用持有自己的命令行](2026-08-06-app-owned-command-line.md)负责当前的 `dsh --profile headless` 语法;原 [`dsh run` 决策](../feature/2026-08-08-dsh-run-headless-command.md)记录已被取代的启动器持有语法,[GUI 分层与 RPC 协议](2026-07-19-gui-layering-and-rpc-protocol.md)负责浏览器网关边界,[Web 配置树启动与传输分层](2026-07-24-web-config-tree-boot-and-transport-layering.md)负责 Web 插件树,[默认模型跟随选择器](../feature/2026-08-07-default-model-follows-the-picker.md)负责共享 Agent 默认值的持久化。 ## 验证 -包测试围绕脚本化 Agent 工厂使用真实的会话存储与 Agent 注册表,固定空闲态到空闲态的聚合、延迟异步完成、终止态模型诊断、其他未完成退出、直接失败、Loader 加载期间的 dispose(资源释放),以及退出前 flush 的顺序。组装后的无密钥快照通过回放的工具往返驱动 `dsh run`,记录一条带 `source.kind: 'user'` 的 `user/message`,并在 stderr 暴露终止态模型失败。构建后二进制验收通过已发布入口访问 mock 提供方,并要求最终文本出现在 stdout、退出状态为 0 且 stderr 为空。配置转储验收排除随附 headless 树中的所有 Host、Web 与 Client 包;PTY 关闭覆盖要求不出现观察行,并在有界时间内完成 dispose。 +包测试围绕脚本化 Agent 工厂使用真实的会话存储与 Agent 注册表,固定空闲态到空闲态的聚合、延迟异步完成、终止态模型诊断、其他未完成退出、直接失败、Loader 加载期间的 dispose(资源释放),以及退出前 flush 的顺序。组装后的无密钥快照通过回放的工具往返驱动 `dsh --profile headless`,记录一条带 `source.kind: 'user'` 的 `user/message`,并在 stderr 暴露终止态模型失败。构建后二进制验收通过已发布入口访问 mock 提供方,并要求最终文本出现在 stdout、退出状态为 0 且 stderr 为空。配置转储验收排除随附 headless 树中的所有 Host、Web 与 Client 包;PTY 关闭覆盖要求不出现观察行,并在有界时间内完成 dispose。 ## 考虑过的替代方案 @@ -39,6 +39,6 @@ Status: implemented ## 后果 -`dsh run` 提供本地 Agent 任务,而不是浏览器观察、Host API 或 HTTP。需要这些能力的用户选择 `dsh web`。成功时 stderr 为空,完成结果在持久化 flush 后推导,持久化会话仍可供后续工具使用。初始用户消息记录 `source.kind: 'user'`,因此不携带 ApiProxy `rpcId`。 +`dsh --profile headless` 提供本地 Agent 任务,而不是浏览器观察、Host API 或 HTTP。需要这些能力的用户选择 `dsh web`。成功时 stderr 为空,完成结果在持久化 flush 后推导,持久化会话仍可供后续工具使用。初始用户消息记录 `source.kind: 'user'`,因此不携带 ApiProxy `rpcId`。 ApiProxy 载体覆盖保留在 ApiProxy 包中。自定义一次性 profile 可以显式包含 Host 或 Web 组合包;随附 profile 与可识别的安装过程所属元组均不含 Web。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml index 34f0f1457e..e4ff6a9cfa 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.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/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md -2026-08-03-cli-signal-shutdown-escalation.md: 55917400fac2728d13dc2cdd799a7e234b6ed661 -2026-08-03-cli-signal-shutdown-escalation.zh.md: c7897a8d77e8c2ebad43cec4e12170b04c837350 +2026-08-03-cli-signal-shutdown-escalation.md: 173d06482cd8a1fcbb985763cc313e3f9b170bc6 +2026-08-03-cli-signal-shutdown-escalation.zh.md: efa52524199906cf636cb2b55cb4249857dc1348 diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md index 55917400fa..173d06482c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md +++ b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md @@ -6,7 +6,7 @@ English | [中文](2026-08-03-cli-signal-shutdown-escalation.zh.md) ## Problem -The default telemetry mount added SIGINT/SIGTERM handlers to `dsh web` and the headless command (now `dsh run`) so process exit could drain the Cordis tree instead of dropping queued telemetry. Each handler used a one-way boolean latch and exited only after `ctx.fiber.dispose()` settled. Headless normal completion also awaited that disposal without a bound. +The default telemetry mount added SIGINT/SIGTERM handlers to `dsh web` and the headless command (now `dsh --profile headless`) so process exit could drain the Cordis tree instead of dropping queued telemetry. Each handler used a one-way boolean latch and exited only after `ctx.fiber.dispose()` settled. Headless normal completion also awaited that disposal without a bound. A user then reproduced the headless command hanging immediately after the observation URL and ignoring repeated `Ctrl+C`; `DSH_TELEMETRY_DISABLED=1` removed the hang, while a standalone Node handler in the same Linux sandbox received SIGINT. This isolated the pending disposer to telemetry rather than terminal signal forwarding. OTel's `BatchLogRecordProcessor.shutdown()` awaits `exporter.forceFlush()` before the `exportTimeoutMillis`-bounded completion promise, and the OTLP exporter's `forceFlush()` waits directly on its in-flight HTTP Promise. A proxy/sandbox connection that never obtains a socket can therefore leave provider shutdown pending despite both configured SDK timeouts. diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md index c7897a8d77..efa5252419 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -默认挂载遥测后,`dsh web` 与 headless 命令(现为 `dsh run`)新增了 SIGINT/SIGTERM 处理器,使进程退出时可以排空 Cordis 插件树,而不是丢弃排队中的遥测数据。每个处理器都使用单向布尔闩锁(latch),并且只有在 `ctx.fiber.dispose()` 结算后才退出。headless 正常完成时同样会无界等待整棵树执行 dispose(资源释放)。 +默认挂载遥测后,`dsh web` 与 headless 命令(现为 `dsh --profile headless`)新增了 SIGINT/SIGTERM 处理器,使进程退出时可以排空 Cordis 插件树,而不是丢弃排队中的遥测数据。每个处理器都使用单向布尔闩锁(latch),并且只有在 `ctx.fiber.dispose()` 结算后才退出。headless 正常完成时同样会无界等待整棵树执行 dispose(资源释放)。 随后有用户复现,headless 命令在打印观察 URL 后立即卡死,重复按 `Ctrl+C` 也没有反应;设置 `DSH_TELEMETRY_DISABLED=1` 后不再卡死,而同一 Linux 沙箱中的独立 Node 信号处理器能够收到 SIGINT。这将待结算的 disposer 定位到遥测,而非终端信号转发。OTel 的 `BatchLogRecordProcessor.shutdown()` 会先等待 `exporter.forceFlush()`,再进入受 `exportTimeoutMillis` 限制的完成 promise;OTLP 导出器的 `forceFlush()` 则直接等待正在进行的 HTTP Promise。因此,代理/沙箱连接始终无法取得 socket 时,即使已经配置两项 SDK 超时,也会让提供方关闭一直待结算。 diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml index 730f57e681..7b9076e9b4 100644 --- a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.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/feature/2026-08-08-dsh-run-headless-command.md -2026-08-08-dsh-run-headless-command.md: ed095f4077a23e51bffb647d24eed19ba09e11ed -2026-08-08-dsh-run-headless-command.zh.md: 89d54e35573f14786e05d648f2b42891ca27a043 +2026-08-08-dsh-run-headless-command.md: 779e568790a58899488ea87292c1bc2db329617f +2026-08-08-dsh-run-headless-command.zh.md: 5a21033e921cb181aa6987259d37b4bc5004e2d9 diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md index ed095f4077..779e568790 100644 --- a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md +++ b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md @@ -4,6 +4,8 @@ Status: implemented English | [中文](2026-08-08-dsh-run-headless-command.zh.md) +> **Superseded command grammar.** [Apps now own their command lines](../architecture/2026-08-06-app-owned-command-line.md): the headless startup row parses the task from `dsh --profile headless `, and the launcher no longer has a `run` invocation or patches task text into rows. This note remains the rejected launcher-owned design context; the direct execution and completion contract it selected remains current in [headless is a direct core entry point](../architecture/2026-08-09-headless-direct-core-entry-point.md). + ## Problem Generic profile boot and one-shot task execution have different lifecycle contracts. A root grammar that accepts optional task text makes one argv shape mean either a long-lived process or a terminating task according to a plugin row discovered only after composition. It also exposes a profile implementation detail as the primary user command and gives custom profiles no explicit one-shot entry. diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md index 89d54e3557..5a21033e92 100644 --- a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md @@ -4,6 +4,8 @@ Status: implemented [English](2026-08-08-dsh-run-headless-command.md) | 中文 +> **命令语法已被取代。** [应用现在持有自己的命令行](../architecture/2026-08-06-app-owned-command-line.md):headless 启动行从 `dsh --profile headless ` 解析任务,启动器不再包含 `run` 调用,也不再把任务文本 patch 进配置行。本笔记保留被否决的启动器持有设计背景;它选定的直接执行与完成约定仍由 [headless 是直接 core 入口](../architecture/2026-08-09-headless-direct-core-entry-point.md)持有。 + ## 问题 通用 profile 启动与一次性任务执行具有不同的生命周期约定。若根语法接受可选任务文本,同一种 argv 形态会表示常驻进程或终止式任务,具体含义取决于组合完成后才发现的插件配置行。它还会把 profile 实现细节暴露成主要用户命令,并使自定义 profile 缺少明确的一次性入口。 diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml index dcef1b190f..b23fcae758 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.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/simplification/2026-07-20-remove-stdio-and-echo-agents.md -2026-07-20-remove-stdio-and-echo-agents.md: 256e626f4ff41016d0227eef7cc3e4e51e15058b -2026-07-20-remove-stdio-and-echo-agents.zh.md: 013135eff5d1dbbc570561e70c751ff4de289989 +2026-07-20-remove-stdio-and-echo-agents.md: 23fcb90599c2ff96cd7ac0e6f7ee8fd508a6d1ad +2026-07-20-remove-stdio-and-echo-agents.zh.md: 7aabf5612a54245327da9af804f745237472cb88 diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md index 256e626f4f..23fcb90599 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md @@ -19,7 +19,7 @@ The stdio and Echo agents are removed without compatibility packages, modes, com The remaining application roles are explicit: - `@deepseek-ai/dsh-tui` owns terminal-interactive execution. It rejects non-TTY streams before Loader boot; `apps/cli/config/base.cordis.yml` plus the `tui.cordis.yml` overlay own the complete coding composition, with PTY plus terminal-snapshot coverage in `apps/cli/tests/`. -- [`dsh run`](../../../../apps/cli/README.md) owns non-interactive execution. Its `headless` profile is the product composition; `examples/headless-agent` owns replay snapshots, generic real-agent suites, and an unexported keyless Loader driver. +- [`dsh --profile headless`](../../../../apps/cli/README.md) owns non-interactive execution. Its `headless` profile is the product composition; `examples/headless-agent` owns replay snapshots, generic real-agent suites, and an unexported keyless Loader driver. - [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.md) and `@deepseek-ai/dsh-jsonrpc` own their framed protocol integrations. The SDK project model and create/config workflows replace the `stdio` run-interface option with `tui`; generated TUI projects compose `@deepseek-ai/dsh-tui` and create or resume one exact session. Repository-facing demo documentation requires a DeepSeek API key and leads with the real Headless or TUI agents. @@ -30,7 +30,7 @@ Keyless validation is test-owned. The Headless Loader smoke uses a fixture adapt TUI and Headless Loader coverage run the real app packages in source and built modes. PTY-driven subprocess coverage is reserved for the TUI lifecycle; other entry-point smokes use the one-shot pipe protocol. Headless proves its task/result and tool-call contracts. Generated graphs and repository searches reject stale package, command, leaf, SDK-interface, `createStdioChat`, and `StdioRuntime` references. -The built `dsh` bin rejects a piped TUI launch before Loader boot and points at `dsh run`; `apps/cli/tests/built-bin.e2e.ts` pins the product one-shot entry under plain Node, including output and invalid arguments. `examples/headless-agent/tests/headless.snapshot.ts` pins product persistence, while `apps/cli/tests/headless-shutdown.e2e.ts` owns bounded signal escalation. The headless example's test-only JSONL driver preserves assembled canonical-event snapshots without creating a second CLI contract. Code Mode has programmatic TUI snapshots and an ACP overlay demo. Time-context integration uses the explicit Headless test composition for two ordered turns, while its package tests own finer elapsed-time behavior. +The built `dsh` bin rejects a piped TUI launch before Loader boot and points at `dsh --profile headless`; `apps/cli/tests/built-bin.e2e.ts` pins the product one-shot entry under plain Node, including output and invalid arguments. `examples/headless-agent/tests/headless.snapshot.ts` pins product persistence, while `apps/cli/tests/headless-shutdown.e2e.ts` owns bounded signal escalation. The headless example's test-only JSONL driver preserves assembled canonical-event snapshots without creating a second CLI contract. Code Mode has programmatic TUI snapshots and an ACP overlay demo. Time-context integration uses the explicit Headless test composition for two ordered turns, while its package tests own finer elapsed-time behavior. ## Alternatives considered diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md index 013135eff5..7aabf5612a 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md @@ -19,7 +19,7 @@ DeepSeek Harness 在 TUI 和 Headless coding agent 之外,还提供了两个 保留的应用角色均有明确归属: - `@deepseek-ai/dsh-tui` 负责终端交互式执行。它会在 Loader 启动前拒绝非 TTY 流;`apps/cli/config/base.cordis.yml` 与 `tui.cordis.yml` overlay 拥有完整 coding 组装,PTY 与终端快照覆盖则位于 `apps/cli/tests/`。 -- [`dsh run`](../../../../apps/cli/README.md) 负责非交互式执行。其 `headless` profile 是产品组装;`examples/headless-agent` 负责回放快照、通用真实 agent 测试套件和未导出的无密钥 Loader driver。 +- [`dsh --profile headless`](../../../../apps/cli/README.md) 负责非交互式执行。其 `headless` profile 是产品组装;`examples/headless-agent` 负责回放快照、通用真实 agent 测试套件和未导出的无密钥 Loader driver。 - [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.md) 和 `@deepseek-ai/dsh-jsonrpc` 负责各自的分帧协议集成。 SDK 工程模型与 create/config 工作流将 `stdio` 运行接口选项替换为 `tui`;生成的 TUI 工程组合 `@deepseek-ai/dsh-tui`,并创建或恢复一个确切会话。仓库中的演示文档要求 DeepSeek API key,并优先引导到真实的 Headless 或 TUI agent。 @@ -30,7 +30,7 @@ SDK 工程模型与 create/config 工作流将 `stdio` 运行接口选项替换 TUI 与 Headless 的 Loader 覆盖以源码和构建产物两种模式运行真实 app 包。由 PTY 驱动的子进程覆盖仅用于 TUI 生命周期;其他入口冒烟测试使用单次管道协议。Headless 验证任务/结果约定和工具调用约定。生成图谱与仓库搜索会拒绝陈旧的包、命令、叶节点、SDK 接口、`createStdioChat` 和 `StdioRuntime` 引用。 -构建后的 `dsh` 可执行文件会在 Loader 启动前拒绝通过管道启动 TUI,并指向 `dsh run`;`apps/cli/tests/built-bin.e2e.ts` 在普通 Node 下固定产品的一次性入口,包括输出和无效参数。`examples/headless-agent/tests/headless.snapshot.ts` 固定产品持久化,`apps/cli/tests/headless-shutdown.e2e.ts` 则负责有界信号升级。headless 示例仅供测试的 JSONL driver 保留组装后的规范事件快照,而不会创建第二套 CLI(命令行界面)约定。Code Mode 由程序化 TUI 快照与 ACP overlay demo 覆盖。时间上下文集成通过显式的 Headless 测试组装执行两个有序轮次,而更细粒度的耗时行为由时间上下文的包级测试负责。 +构建后的 `dsh` 可执行文件会在 Loader 启动前拒绝通过管道启动 TUI,并指向 `dsh --profile headless`;`apps/cli/tests/built-bin.e2e.ts` 在普通 Node 下固定产品的一次性入口,包括输出和无效参数。`examples/headless-agent/tests/headless.snapshot.ts` 固定产品持久化,`apps/cli/tests/headless-shutdown.e2e.ts` 则负责有界信号升级。headless 示例仅供测试的 JSONL driver 保留组装后的规范事件快照,而不会创建第二套 CLI(命令行界面)约定。Code Mode 由程序化 TUI 快照与 ACP overlay demo 覆盖。时间上下文集成通过显式的 Headless 测试组装执行两个有序轮次,而更细粒度的耗时行为由时间上下文的包级测试负责。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.i18n.yaml index 217265168d..fac9ae9452 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.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/simplification/2026-08-08-remove-cli-demo.md -2026-08-08-remove-cli-demo.md: 403e01f94c976d2d17eb391830721b31675cd6a9 -2026-08-08-remove-cli-demo.zh.md: 7f11e0c17a15454b99b32d14ea6eda177f4b01f6 +2026-08-08-remove-cli-demo.md: 31879e8284daf5f34af6731b09fce743f9eb5391 +2026-08-08-remove-cli-demo.zh.md: d4dfef1ba8c4d27cd667e319519cfa1ace74baef diff --git a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md index 403e01f94c..31879e8284 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md +++ b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md @@ -6,29 +6,29 @@ English | [中文](2026-08-08-remove-cli-demo.zh.md) ## Problem -After [`dsh run`](../feature/2026-08-08-dsh-run-headless-command.md) became the product one-shot command, `@deepseek-ai/dsh-cli-demo` remained a second application package for the same job. It carried another executable, argument grammar, app composition, cancellation lifecycle, text/JSON/stream-JSON output contract, built artifact, documentation surface, and test suite. The two entry points also assembled different trees, so a successful demo did not prove the shipped `headless` profile and users had to choose between overlapping commands. +After [`dsh --profile headless`](../architecture/2026-08-06-app-owned-command-line.md) became the product one-shot command, `@deepseek-ai/dsh-cli-demo` remained a second application package for the same job. It carried another executable, argument grammar, app composition, cancellation lifecycle, text/JSON/stream-JSON output contract, built artifact, documentation surface, and test suite. The two entry points also assembled different trees, so a successful demo did not prove the shipped `headless` profile and users had to choose between overlapping commands. The replay suites still need canonical session events to pin assembled backend behavior. That testing need does not require a published command or compatibility contract. ## Decision -Delete `@deepseek-ai/dsh-cli-demo` completely: its package, bin, parser, app plugin, output formats, tests, workspace references, generated-catalog entries, and active documentation. No alias or compatibility package remains. The root `demo:headless` script is retained only as a direct alias of `dsh run`; the product command owns final-text stdout, the observation URL on stderr, persistence, exit status, and shutdown. +Delete `@deepseek-ai/dsh-cli-demo` completely: its package, bin, parser, app plugin, output formats, tests, workspace references, generated-catalog entries, and active documentation. No alias or compatibility package remains. The root `demo:headless` script is retained only as a direct alias of `dsh --profile headless`; the product command owns final-text stdout, failure diagnostics on stderr, persistence, exit status, and shutdown. `examples/headless-agent` becomes an explicit test composition. Its Loader configs mount `@deepseek-ai/dsh-agent-spine-demo`, one root agent, JSONL persistence, and checkpoint policy as separate rows instead of hiding them behind an app bundle. The support-tier `@deepseek-ai/dsh-loader-smoke` package owns the shared direct-agent turn helper; unexported example-local drivers select their Loader configuration and render canonical events as JSONL. They are launched only by tests, have no bin, and do not define a supported product output format. ## Alternatives considered -- **Keep `dsh-cli-demo` as an alias or wrapper around `dsh run`.** Rejected because a second bin and package would preserve two discoverable owners without adding capability. -- **Move JSON and stream-JSON flags onto `dsh run`.** Rejected because no current product consumer requires them; adopting the old demo protocol would enlarge the canonical CLI contract solely to save test machinery. +- **Keep `dsh-cli-demo` as an alias or wrapper around `dsh --profile headless`.** Rejected because a second bin and package would preserve two discoverable owners without adding capability. +- **Move JSON and stream-JSON flags onto `dsh --profile headless`.** Rejected because no current product consumer requires them; adopting the old demo protocol would enlarge the canonical CLI contract solely to save test machinery. - **Delete the canonical-event snapshots with the package.** Rejected because they pin model-visible assembled behavior that final-text product acceptance cannot observe. - **Keep the app plugin but delete only its bin.** Rejected because the hidden composition would still duplicate the explicit headless profile and conceal which services the test leaf mounts. ## Consequences -This is intentionally breaking. `dsh-cli-demo`, its `--output-format` choices, and imports from `@deepseek-ai/dsh-cli-demo/src/cli.ts` no longer resolve. There is no public event-stream replacement in this change; callers use `dsh run` for one-shot execution and must choose an existing protocol surface when they need structured automation. +This is intentionally breaking. `dsh-cli-demo`, its `--output-format` choices, and imports from `@deepseek-ai/dsh-cli-demo/src/cli.ts` no longer resolve. There is no public event-stream replacement in this change; callers use `dsh --profile headless` for one-shot execution and must choose an existing protocol surface when they need structured automation. -The repository retains backend replay coverage through test-only infrastructure, while product smoke and built-bin acceptance exercise `dsh run`. A separate one-shot package may return only if it owns a genuinely independent, versioned protocol that cannot belong to the product launcher; a second spelling or output shim is not enough. +The repository retains backend replay coverage through test-only infrastructure, while product smoke and built-bin acceptance exercise `dsh --profile headless`. A separate one-shot package may return only if it owns a genuinely independent, versioned protocol that cannot belong to the product launcher; a second spelling or output shim is not enough. ## Verification -Focused Loader smokes cover the explicit composition in source and plain-Node built modes, snapshot tests diff its canonical JSONL and persisted logs, product acceptance covers `dsh run`, and documentation plus generated graph/catalog gates reject live references to the removed package. The frozen Agent Note archive remains historical evidence and is not rewritten. +Focused Loader smokes cover the explicit composition in source and plain-Node built modes, snapshot tests diff its canonical JSONL and persisted logs, product acceptance covers `dsh --profile headless`, and documentation plus generated graph/catalog gates reject live references to the removed package. The frozen Agent Note archive remains historical evidence and is not rewritten. diff --git a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.zh.md b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.zh.md index 7f11e0c17a..d4dfef1ba8 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.zh.md @@ -6,29 +6,29 @@ Status: implemented ## 问题 -在 [`dsh run`](../feature/2026-08-08-dsh-run-headless-command.md) 成为产品的一次性命令后,`@deepseek-ai/dsh-cli-demo` 仍是承担同一工作的第二个应用包。它另行拥有一套可执行文件、参数语法、应用组装、取消生命周期、文本/JSON/stream-JSON 输出约定、构建产物、配套文档和测试套件。两个入口组装的树也不相同,因此 demo 成功不能证明已交付的 `headless` profile 可用,用户还必须在功能重叠的命令之间作出选择。 +在 [`dsh --profile headless`](../architecture/2026-08-06-app-owned-command-line.md) 成为产品的一次性命令后,`@deepseek-ai/dsh-cli-demo` 仍是承担同一工作的第二个应用包。它另行拥有一套可执行文件、参数语法、应用组装、取消生命周期、文本/JSON/stream-JSON 输出约定、构建产物、配套文档和测试套件。两个入口组装的树也不相同,因此 demo 成功不能证明已交付的 `headless` profile 可用,用户还必须在功能重叠的命令之间作出选择。 回放套件仍需要规范会话事件来固定组装后的后端行为。这一测试需求不需要已发布命令或兼容性约定。 ## 决策 -彻底删除 `@deepseek-ai/dsh-cli-demo`:包括它的包、bin、解析器、应用插件、输出格式、测试、workspace 引用、生成目录条目和现行文档。不保留别名或兼容包。根目录的 `demo:headless` 脚本仅作为 `dsh run` 的直接别名保留;stdout 上的最终文本、stderr 上的观察 URL、持久化、退出状态和关闭行为均由产品命令负责。 +彻底删除 `@deepseek-ai/dsh-cli-demo`:包括它的包、bin、解析器、应用插件、输出格式、测试、workspace 引用、生成目录条目和现行文档。不保留别名或兼容包。根目录的 `demo:headless` 脚本仅作为 `dsh --profile headless` 的直接别名保留;stdout 上的最终文本、stderr 上的失败诊断、持久化、退出状态和关闭行为均由产品命令负责。 `examples/headless-agent` 成为显式测试组装。其 Loader 配置把 `@deepseek-ai/dsh-agent-spine-demo`、一个根 agent(智能体)、JSONL 持久化和检查点策略挂载为独立配置行,不再将其隐藏在应用组合包之后。支持层的 `@deepseek-ai/dsh-loader-smoke` 包负责共享的直接 agent 轮次 helper;未导出的示例本地 driver 选择各自的 Loader 配置,并将规范事件渲染为 JSONL。这些 driver 只由测试启动,不提供 bin,也不定义受支持的产品输出格式。 ## 考虑过的替代方案 -- **保留 `dsh-cli-demo` 作为 `dsh run` 的别名或包装层。** 不予采纳:第二个 bin 和包会让同一功能继续存在两个可发现的归属方,却没有增加任何能力。 -- **把 JSON 和 stream-JSON 标志移到 `dsh run`。** 不予采纳:当前没有产品消费方需要这些标志;沿用旧 demo 协议,只会为了保留测试机制而扩大规范 CLI(命令行界面)约定。 +- **保留 `dsh-cli-demo` 作为 `dsh --profile headless` 的别名或包装层。** 不予采纳:第二个 bin 和包会让同一功能继续存在两个可发现的归属方,却没有增加任何能力。 +- **把 JSON 和 stream-JSON 标志移到 `dsh --profile headless`。** 不予采纳:当前没有产品消费方需要这些标志;沿用旧 demo 协议,只会为了保留测试机制而扩大规范 CLI(命令行界面)约定。 - **随包一并删除规范事件快照。** 不予采纳:这些快照固定了模型可见的组装行为,而只检查最终文本的产品验收无法观察这些行为。 - **保留应用插件,只删除它的 bin。** 不予采纳:隐藏的组装仍会重复显式的 headless profile,并掩盖测试叶节点挂载了哪些服务。 ## 后果 -这是有意为之的破坏性变更。`dsh-cli-demo`、它的 `--output-format` 选项以及对 `@deepseek-ai/dsh-cli-demo/src/cli.ts` 的导入都不再可解析。本变更不提供公开的事件流替代接口;调用方使用 `dsh run` 执行一次性任务,需要结构化自动化时则必须选择现有的协议接口。 +这是有意为之的破坏性变更。`dsh-cli-demo`、它的 `--output-format` 选项以及对 `@deepseek-ai/dsh-cli-demo/src/cli.ts` 的导入都不再可解析。本变更不提供公开的事件流替代接口;调用方使用 `dsh --profile headless` 执行一次性任务,需要结构化自动化时则必须选择现有的协议接口。 -仓库通过仅供测试的基础设施保留后端回放覆盖,产品冒烟测试和 built-bin 验收则运行 `dsh run`。只有当独立的一次性包负责一套真正独立、带版本且不能归产品启动器所有的协议时,它才可以重新引入;第二种命令写法或输出 shim 并不足以构成理由。 +仓库通过仅供测试的基础设施保留后端回放覆盖,产品冒烟测试和 built-bin 验收则运行 `dsh --profile headless`。只有当独立的一次性包负责一套真正独立、带版本且不能归产品启动器所有的协议时,它才可以重新引入;第二种命令写法或输出 shim 并不足以构成理由。 ## 验证 -聚焦的 Loader 冒烟测试在源码模式和由普通 Node 启动的构建模式下覆盖显式组装,快照测试对比其规范 JSONL 和持久化日志,产品验收覆盖 `dsh run`,文档检查及生成图谱/目录门禁则拒绝对已移除包的活跃引用。冻结的 Agent Note 归档保留为历史证据,不会被重写。 +聚焦的 Loader 冒烟测试在源码模式和由普通 Node 启动的构建模式下覆盖显式组装,快照测试对比其规范 JSONL 和持久化日志,产品验收覆盖 `dsh --profile headless`,文档检查及生成图谱/目录门禁则拒绝对已移除包的活跃引用。冻结的 Agent Note 归档保留为历史证据,不会被重写。 diff --git a/README.i18n.yaml b/README.i18n.yaml index 35cb82e776..43185dc239 100644 --- a/README.i18n.yaml +++ b/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 README.md -README.md: 3174630d021b3868986d6ad9989d257fe8ac29fb -README.zh.md: 377ea6372a9a531c1400d08b0ef33792b452dc65 +README.md: 9b8944027572072e08a39bd9e482996f8128224c +README.zh.md: a1b2b6a36c4baac8a49b88a42c9f17c986287ff1 diff --git a/README.md b/README.md index 3174630d02..9b89440275 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ The [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer Run one task, print the final answer, and exit: ```sh -dsh run "summarize this workspace" +dsh --profile headless "summarize this workspace" ``` ### Automation and SDKs diff --git a/README.zh.md b/README.zh.md index 377ea6372a..a1b2b6a36c 100644 --- a/README.zh.md +++ b/README.zh.md @@ -56,7 +56,7 @@ profile 布局、层语义与配置输出命令详见 [CLI(命令行界面) 运行一项任务,打印最终答案后退出: ```sh -dsh run "summarize this workspace" +dsh --profile headless "summarize this workspace" ``` ### 自动化与 SDK diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index b4c933291e..67dd461179 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/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 apps/cli/README.md -README.md: 86d890ebec7121a9f8431f52789b8346ba59deb2 -README.zh.md: 80b9a6d56bdb49f72d25f7485662a6814f5184a3 +README.md: 96c6932a1faf6f5ce9b64e0390e2a4b3dcb55fc4 +README.zh.md: ea80985a8f6ea43bcea45dfe169937388ab25df0 diff --git a/apps/cli/README.md b/apps/cli/README.md index 86d890ebec..96c6932a1f 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -17,7 +17,7 @@ The invoking directory is the default workspace root. The `web` and `headless` p ## App arguments -The launcher parses only its own flags and hands everything after them to the booted profile, where that app's own startup row parses them ([`dsh-cmdline`](../../packages/ui/cmdline/README.md)). Launcher flags therefore come first, and the first token the launcher does not recognize starts the app's arguments: +The launcher parses only its own flags and hands everything after them to the booted profile, where that app's own startup row parses them ([`dsh-cmdline`](../../packages/boot/cmdline/README.md)). Launcher flags therefore come first, and the first token the launcher does not recognize starts the app's arguments: ```sh dsh --profile web --port 8080 # --port belongs to the web app diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 80b9a6d56b..ea80985a8f 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -17,7 +17,7 @@ ## 应用参数 -启动器只解析属于自己的 flag,并把其后的一切交给启动起来的 profile,由该应用自己的启动行解析([`dsh-cmdline`](../../packages/ui/cmdline/README.md))。因此启动器的 flag 必须写在前面,而启动器不认识的第一个 token 就是应用参数的起点: +启动器只解析属于自己的 flag,并把其后的一切交给启动起来的 profile,由该应用自己的启动行解析([`dsh-cmdline`](../../packages/boot/cmdline/README.md))。因此启动器的 flag 必须写在前面,而启动器不认识的第一个 token 就是应用参数的起点: ```sh dsh --profile web --port 8080 # --port belongs to the web app diff --git a/apps/cli/package.json b/apps/cli/package.json index 4442006b0a..8b50f180f2 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -28,6 +28,7 @@ "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-session": "workspace:^", "@deepseek-ai/dsh-cmdline": "workspace:^", + "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-headless": "workspace:^", "@deepseek-ai/dsh-mcp-client": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 9916a9eeb3..b6c1ea5ab9 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/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 apps/cli/reference/README.md -README.md: 13c0d000eec045cc34f2b7eb5fe5ba9ac9ed557e -README.zh.md: 5392db3220013a50040bf59f212040e8d0291037 +README.md: b4a8dfe8a0473e69a0c82e33aba2d1f4210a2477 +README.zh.md: 287a215b6abb31c7f0375987210eb9703acf5657 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 13c0d000ee..b4a8dfe8a0 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -14,7 +14,7 @@ The `web` and `headless` profiles auto-initialize from shipped templates on firs ### App arguments -The launcher's flags come first and end at the first token it does not recognize; everything from there on is handed to the booted profile verbatim through `ctx.cmdlineArgs`, where that app's own startup row parses it ([`dsh-cmdline`](../../../packages/ui/cmdline/README.md)). `dsh --profile web --port 8080` therefore reaches the web app's `--port`, `dsh --profile web --help` prints that app's help and boots nothing, and `dsh --help` (no profile to hand it to) prints the launcher's own. `-V`/`--version` prints the launcher's version when it appears before the app-argument boundary. +The launcher's flags come first and end at the first token it does not recognize; everything from there on is handed to the booted profile verbatim through `ctx.cmdlineArgs`, where that app's own startup row parses it ([`dsh-cmdline`](../../../packages/boot/cmdline/README.md)). `dsh --profile web --port 8080` therefore reaches the web app's `--port`, `dsh --profile web --help` prints that app's help and boots nothing, and `dsh --help` (no profile to hand it to) prints the launcher's own. `-V`/`--version` prints the launcher's version when it appears before the app-argument boundary. A composition mounts once. A Loader row that injects `cmdlineArgs` parses this app's arguments and provides what it resolved as a service; each row configured from flags injects that service, and Loader waits for it before evaluating the row's config (`port: !!js ctx.webStartup.port ?? 3080`). A flag therefore beats the value written beside it. This precedence requires the row to retain that expression; a user patch that replaces the whole `config` with literals removes the runtime read. Help and rejected arguments request exit — nonzero for a rejection, 0 for help — without activating rows that depend on the startup service. A live `cordis.patch.yml` edit re-evaluates expressions against services that are still up, so it cannot reset a served port. @@ -24,7 +24,7 @@ The shipped apps own these command lines: | Profile | Arguments | |---|---| -| `web` | `--host`, `--port`, `--dev`, `--workspace-root`, repeatable `--trusted-host` | +| `web` | `--host`, `--port`, `--dev`, repeatable `--trusted-host` | | `headless` | the task text, as the positional argument | A one-shot task (`dsh --profile headless "run the tests"`) creates one fresh persisted Agent through the core registry, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It prints the text on stdout and exits 0 for `completed`, else 1. An invocation with no task is a usage error from that app. The shipped headless profile mounts no ApiProxy, Host, HTTP server, Web runtime, or browser client; a successful run writes nothing to stderr and opens no listening port. @@ -52,7 +52,7 @@ Git-hosted plugins that ship sources build during install through their `prepare ## Web alias -`dsh web` is a hardcoded alias for `--profile web`; the flags after it belong to the web app, which owns them in its bundle's startup row. `--host`, `--port`, and `--workspace-root` override the composed values of the rows that carry them, repeatable `--trusted-host` adds authorities over the composed fence configuration, and `--dev` switches the web-runtime row to development mode and enables the client-plugin HMR receiver the bundle ships disabled; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. +`dsh web` is a hardcoded alias for `--profile web`; the flags after it belong to the web app, which owns them in its bundle's startup row. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` adds authorities over the composed fence configuration, and `--dev` switches the web-runtime row to development mode and enables the client-plugin HMR receiver the bundle ships disabled; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. ```sh dsh web diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 5392db3220..287a215b6a 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -14,7 +14,7 @@ ### 应用参数 -启动器自己的 flag 写在最前面,并在它不认识的第一个 token 处结束;从那里开始的一切都通过 `ctx.cmdlineArgs` 原样交给启动起来的 profile,由该应用自己的启动行解析([`dsh-cmdline`](../../../packages/ui/cmdline/README.md))。因此 `dsh --profile web --port 8080` 到达的是 web 应用的 `--port`,`dsh --profile web --help` 打印的是该应用的 help 且什么也不启动,而 `dsh --help`(没有可以交付的 profile)打印的是启动器自己的 help。`-V`/`--version` 写在应用参数边界之前时会打印启动器的版本。 +启动器自己的 flag 写在最前面,并在它不认识的第一个 token 处结束;从那里开始的一切都通过 `ctx.cmdlineArgs` 原样交给启动起来的 profile,由该应用自己的启动行解析([`dsh-cmdline`](../../../packages/boot/cmdline/README.md))。因此 `dsh --profile web --port 8080` 到达的是 web 应用的 `--port`,`dsh --profile web --help` 打印的是该应用的 help 且什么也不启动,而 `dsh --help`(没有可以交付的 profile)打印的是启动器自己的 help。`-V`/`--version` 写在应用参数边界之前时会打印启动器的版本。 一套组合只挂载一次。注入 `cmdlineArgs` 的 Loader 行解析本应用的参数,并把结果作为服务提供出去;由 flag 配置的每一行都会注入该服务,Loader 会等服务激活后再求值该行配置(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值。该优先级要求配置行保留这一表达式;若用户 patch 用字面量替换整份 `config`,运行时读取也会随之消失。help 和被拒绝的参数会请求退出——拒绝时以非零状态,help 时以 0——且不会激活依赖启动服务的行。在线编辑 `cordis.patch.yml` 会针对仍然在线的服务重新求值表达式,因此不会重置已在服务的端口。 @@ -24,7 +24,7 @@ | Profile | 参数 | |---|---| -| `web` | `--host`、`--port`、`--dev`、`--workspace-root`、可重复的 `--trusted-host` | +| `web` | `--host`、`--port`、`--dev`、可重复的 `--trusted-host` | | `headless` | 任务文本,作为位置参数 | 一次性任务(`dsh --profile headless "run the tests"`)通过核心注册表创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对 Session 执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 stdout 打印文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出。没有任务的调用是该应用的用法错误。随附 headless profile 不挂载 ApiProxy、Host、HTTP 服务器、Web 运行时或浏览器客户端;成功运行不会向 stderr 写入任何内容,也不会打开监听端口。 @@ -52,7 +52,7 @@ Git 托管、随附源码的插件在安装期间通过其 `prepare` 脚本构 ## Web 别名 -`dsh web` 是 `--profile web` 的硬编码别名;写在它之后的 flag 属于 web 应用,由该应用在其组合包的启动行中持有。`--host`、`--port` 和 `--workspace-root` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 在组合出的围栏配置之上追加 authority,`--dev` 把 web-runtime 行切换到开发模式并启用组合包以禁用状态交付的客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。 +`dsh web` 是 `--profile web` 的硬编码别名;写在它之后的 flag 属于 web 应用,由该应用在其组合包的启动行中持有。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 在组合出的围栏配置之上追加 authority,`--dev` 把 web-runtime 行切换到开发模式并启用组合包以禁用状态交付的客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。 ```sh dsh web diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index d0e8e9d138..9aa44f8b22 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -10,7 +10,7 @@ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' -import { loadEnv } from '@deepseek-ai/dsh-app-boot' +import { loadLayeredEnv } from '@deepseek-ai/dsh-app-boot' import { parseDshArgs } from './args.ts' // Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit @@ -24,13 +24,13 @@ function readVersion(): string { return typeof manifest.version === 'string' ? manifest.version : '0.0.0' } -loadEnv('dsh') const invocation = parseDshArgs(process.argv.slice(2), readVersion()) switch (invocation.mode) { case 'profile': { const { runProfile } = await import('./profile-boot.ts') await runProfile({ + environment: loadLayeredEnv('dsh'), profile: invocation.profile, patchFiles: invocation.patches, args: invocation.args, diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 5acebe9b44..a3159d4f49 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -195,6 +195,11 @@ export interface RunProfileOptions { prepare?: (ctx: Context) => Promise | void } +/** Re-throw setup failures unless this invocation's signal already owns shutdown. */ +function suppressSignalShutdownError(signal: AbortSignal, error: unknown): void { + if (!signal.aborted) throw error +} + /** * Boot one profile invocation end to end and leave process lifetime to the * mounted plugins (or to a one-shot runner the composition mounts). @@ -327,7 +332,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con compose: composeLive, }) } catch (error) { - if (!signalShutdown.signal.aborted) throw error + suppressSignalShutdownError(signalShutdown.signal, error) } } return { ctx, shutdown } diff --git a/apps/cli/tests/headless-shutdown.e2e.ts b/apps/cli/tests/headless-shutdown.e2e.ts index b0bcbb7cae..42f2aad709 100644 --- a/apps/cli/tests/headless-shutdown.e2e.ts +++ b/apps/cli/tests/headless-shutdown.e2e.ts @@ -83,7 +83,7 @@ async function runHeadlessPtySmoke(): Promise { ].join('\n')) const launch = resolveExampleLaunch({ srcBin: dshBinScript, - configArgs: ['run', 'never complete'], + configArgs: ['--profile', 'headless', 'never complete'], tsconfigPath, env: { DSH_HOME: home, diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index a288a5aa95..cecf7a4bb9 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -21,7 +21,7 @@ "path": "../../packages/boot/app-boot" }, { - "path": "../../packages/ui/cmdline" + "path": "../../packages/boot/cmdline" }, { "path": "../../packages/bundle/base" @@ -50,6 +50,9 @@ { "path": "../../packages/core/tools" }, + { + "path": "../../packages/util/environment" + }, { "path": "../../packages/util/paths" }, diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index d4e0df8b0e..e4f119da06 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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/config-catalog.md -config-catalog.md: 64c7722a3cc3788d110eab8d9be161129684b942 -config-catalog.zh.md: 4cc146a38b408d9f1a7e5067419fead48520ae11 +config-catalog.md: 836a7f6a81f8c77be12fd10be5b8204be8c1acc0 +config-catalog.zh.md: 22dca1252d93ea9ce223464079f3c51c35eeb89d diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7cd481e6d5..836a7f6a81 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -572,7 +572,7 @@ Source: [`packages/goal/goal/src/index.ts:116`](../packages/goal/goal/src/index. Requires: `agentDefaultModel` · `agents` · `sessions` ```ts config-catalog -/** Plugin config: the task, patched in by the launcher. */ +/** Plugin config: the task resolved from this app's injected startup service. */ export interface Config { /** The prompt text for the single run. */ task: string @@ -2520,21 +2520,21 @@ Source: [`packages/web/web/src/index.ts:55`](../packages/web/web/src/index.ts) Requires: `httpServer` ```ts config-catalog -/** 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 - /** Print the URL line on activation; a headless layer over this bundle turns it off. */ + /** Print the URL line on activation; a non-interactive layer can turn it off. */ printUrl: boolean /** * Register the model-visible surface context (the `app:web-surface` prompt * section and the `DSH_WEB_URL`/`DSH_WEB_MODE` bash variables). A one-shot - * layer turns it off: its user is not interacting through the GUI, so the + * non-interactive layer can turn it off when its user is not in the GUI, so the * orientation text would be false. */ 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. @@ -2546,7 +2546,7 @@ export interface Config { export type WebMode = 'production' | 'development' ``` -Source: [`packages/bundle/web-app/src/index.ts:36`](../packages/bundle/web-app/src/index.ts) +Source: [`packages/bundle/web-app/src/index.ts:41`](../packages/bundle/web-app/src/index.ts) ## `@deepseek-ai/dsh-web-fetch-local` @@ -2792,6 +2792,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-client-ui-slots` ([`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts)) - `@deepseek-ai/dsh-client-web` ([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts)) - `@deepseek-ai/dsh-client-web-react` ([`packages/client/web-react/src/index.ts`](../packages/client/web-react/src/index.ts)) +- `@deepseek-ai/dsh-cmdline` ([`packages/boot/cmdline/src/index.ts`](../packages/boot/cmdline/src/index.ts)) - `@deepseek-ai/dsh-environment` ([`packages/util/environment/src/index.ts`](../packages/util/environment/src/index.ts)) - `@deepseek-ai/dsh-helper` ([`packages/scaffold/helper/src/index.ts`](../packages/scaffold/helper/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 4cc146a38b..22dca1252d 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -574,7 +574,7 @@ export interface Config { 需要:`agentDefaultModel` · `agents` · `sessions` ```ts config-catalog -/** Plugin config: the task, patched in by the launcher. */ +/** Plugin config: the task resolved from this app's injected startup service. */ export interface Config { /** The prompt text for the single run. */ task: string @@ -2521,21 +2521,21 @@ export interface WebServiceConfig { 需要:`httpServer` ```ts config-catalog -/** 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 - /** Print the URL line on activation; a headless layer over this bundle turns it off. */ + /** Print the URL line on activation; a non-interactive layer can turn it off. */ printUrl: boolean /** * Register the model-visible surface context (the `app:web-surface` prompt * section and the `DSH_WEB_URL`/`DSH_WEB_MODE` bash variables). A one-shot - * layer turns it off: its user is not interacting through the GUI, so the + * non-interactive layer can turn it off when its user is not in the GUI, so the * orientation text would be false. */ 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. @@ -2547,7 +2547,7 @@ export interface Config { export type WebMode = 'production' | 'development' ``` -来源:[`packages/bundle/web-app/src/index.ts:32`](../packages/bundle/web-app/src/index.ts) +来源:[`packages/bundle/web-app/src/index.ts:41`](../packages/bundle/web-app/src/index.ts) ## `@deepseek-ai/dsh-web-fetch-local` @@ -2792,6 +2792,7 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-slots`([`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts)) - `@deepseek-ai/dsh-client-web`([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts)) - `@deepseek-ai/dsh-client-web-react`([`packages/client/web-react/src/index.ts`](../packages/client/web-react/src/index.ts)) +- `@deepseek-ai/dsh-cmdline`([`packages/boot/cmdline/src/index.ts`](../packages/boot/cmdline/src/index.ts)) - `@deepseek-ai/dsh-environment`([`packages/util/environment/src/index.ts`](../packages/util/environment/src/index.ts)) - `@deepseek-ai/dsh-helper`([`packages/scaffold/helper/src/index.ts`](../packages/scaffold/helper/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol`([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index 2b608ec739..c3743e38c0 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.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/testing.md -testing.md: f5e8a478ec86c29c52f4127c51682c1c44fd23a7 -testing.zh.md: bd1fa7d23263d7c6e3bed65ef4ed09576ca47cc1 +testing.md: f330bb1e02f3613c63f3989a8f9128f737bf5c52 +testing.zh.md: db6facb4fa4bf07eda0a6ee7e558c8c60d4c331e diff --git a/docs/testing.md b/docs/testing.md index f5e8a478ec..f330bb1e02 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -9,7 +9,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning - **Unit** (`pnpm run test`): vitest over package and example specs under their `tests/**` directories plus repository script specs under `scripts/**/*.spec.ts`; tests stay with the code area they exercise. Every registry gets an HMR-safety test (dispose the contributing fiber, assert cleanup). Prefer edge cases, error paths, event ordering, concurrency races, and permanent tests for contract regressions (see `packages/core/agent-loop/tests/contract-regressions.spec.ts`). - **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. Per-file 100% on `packages/bash/pwsh-local/src` needs a real `pwsh`: without one its executor suites self-skip and `vitest.config.ts` exempts the file so pwsh-less hosts stay green, while CI runners ship pwsh and enforce the full bar. - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md)). -- **Snapshot** (`pnpm run test:snapshot`): keyless expected outputs cover external behavior — transport contracts and presentation, while persisted logs pin assembled backend behavior. ACP boots the real automation-server example, replays a recorded session, and diffs normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); headless backend scenarios boot their explicit example composition through an unexported JSONL test driver, while `apps/cli` separately owns product `dsh run` acceptance. Use `pnpm run test:snapshot:record` when a model transcript changes and `pnpm run test:snapshot:refresh` when replay input remains valid; review every JSONL and expected-output diff. One ACP scenario (`text-turn`) pins full system-prompt/tool-schema content; other fixtures tokenize it so an edit churns one line ([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). +- **Snapshot** (`pnpm run test:snapshot`): keyless expected outputs cover external behavior — transport contracts and presentation, while persisted logs pin assembled backend behavior. ACP boots the real automation-server example, replays a recorded session, and diffs normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); headless backend scenarios boot their explicit example composition through an unexported JSONL test driver, while `apps/cli` separately owns product `dsh --profile headless` acceptance. Use `pnpm run test:snapshot:record` when a model transcript changes and `pnpm run test:snapshot:refresh` when replay input remains valid; review every JSONL and expected-output diff. One ACP scenario (`text-turn`) pins full system-prompt/tool-schema content; other fixtures tokenize it so an edit churns one line ([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). - **Web browser snapshot** (`pnpm run test:web`; required Linux PR gate): Chromium compares replayed browser output with `apps/web/tests/snapshots/`. CI forces read-only `DSH_SNAPSHOT=replay`, never writing expected outputs; record/refresh stay local and every diff is reviewed ([web e2e lane](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md), [CI gate decision](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md)). `test:web` [builds first](../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md) for plugin CSS. Committed session-format JSONL uses the canonical packed-row layout, and the keyless snapshot gate discovers every such fixture by its `session` header; the [temporary migrator](../scripts/migrate-packed-session-fixtures.ts) rewrites older fixture layouts. diff --git a/docs/testing.zh.md b/docs/testing.zh.md index bd1fa7d232..db6facb4fa 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -9,7 +9,7 @@ - **单元测试**(`pnpm run test`):vitest 运行包(package)和示例各自的 `tests/**` 目录下的测试,以及匹配 `scripts/**/*.spec.ts` 的仓库脚本测试;测试文件与其所覆盖的代码区域放在一起。每个注册表都有一个 HMR(热模块替换)安全测试(dispose(资源释放)贡献的 fiber,断言清理完成)。优先覆盖边界情况、错误路径、事件顺序、并发竞态,以及针对约定回归的永久测试(见 `packages/core/agent-loop/tests/contract-regressions.spec.ts`)。 - **覆盖率门禁**(`pnpm run test:coverage`):门禁级运行,对 `packages/*/*/src` 按文件 100% 覆盖。未覆盖的行往往是门禁正确标记出的死代码(应删除),而非需要补写的测试。行覆盖率是必要条件,但永远不是充分条件:它证明行被执行过,不证明功能按交付预期工作。`packages/bash/pwsh-local/src` 的按文件 100% 覆盖需要真实的 `pwsh`:缺少它时其 executor 套件会自动跳过,`vitest.config.ts` 会豁免该文件以使无 pwsh 的主机保持绿色,而 CI runner 自带 pwsh,仍按完整标准执行门禁。 - **真实 API e2e**(`pnpm run test:e2e`):带密钥测试调用真实提供方 API,包括 DeepSeek 模型以及各提供方特有的冒烟测试;这些测试各自由自己的密钥控制(`EXA_API_KEY`、`PERPLEXITY_API_KEY` 等),缺少密钥时套件会自动跳过,使 keyless CI 保持绿色([真实 API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md))。 -- **快照**(`pnpm run test:snapshot`):无密钥预期输出覆盖对外行为(传输约定与呈现),持久化日志则固定组装后的后端行为。ACP 启动真实的自动化服务器示例、回放录制会话,并对归一化 JSON-RPC 与重新持久化的日志执行 diff([ACP 快照 Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md));headless 后端场景通过未导出的 JSONL 测试 driver 启动各自显式的示例组装,而 `apps/cli` 则单独负责产品 CLI(命令行界面)`dsh run` 的验收。当模型 transcript(文本记录)发生变化时使用 `pnpm run test:snapshot:record`,回放输入仍然有效时使用 `pnpm run test:snapshot:refresh`;请审查每一处 JSONL 与预期输出差异。一个 ACP 场景(`text-turn`)固定完整的系统提示词与工具 schema 内容;其他 fixture(测试前置数据)将其 token 化,因此修改只会扰动一行([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 +- **快照**(`pnpm run test:snapshot`):无密钥预期输出覆盖对外行为(传输约定与呈现),持久化日志则固定组装后的后端行为。ACP 启动真实的自动化服务器示例、回放录制会话,并对归一化 JSON-RPC 与重新持久化的日志执行 diff([ACP 快照 Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md));headless 后端场景通过未导出的 JSONL 测试 driver 启动各自显式的示例组装,而 `apps/cli` 则单独负责产品 CLI(命令行界面)`dsh --profile headless` 的验收。当模型 transcript(文本记录)发生变化时使用 `pnpm run test:snapshot:record`,回放输入仍然有效时使用 `pnpm run test:snapshot:refresh`;请审查每一处 JSONL 与预期输出差异。一个 ACP 场景(`text-turn`)固定完整的系统提示词与工具 schema 内容;其他 fixture(测试前置数据)将其 token 化,因此修改只会扰动一行([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 - **Web 浏览器快照**(`pnpm run test:web`;必需的 Linux PR(Pull Request)门禁):Chromium 将回放后的浏览器输出与 `apps/web/tests/snapshots/` 比较。CI 强制只读的 `DSH_SNAPSHOT=replay`,绝不写入预期输出;record/refresh 留在本地,每处 diff 都须评审([web e2e 车道](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md)、[CI 门禁决策](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md))。`test:web` 会[先构建](../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)以交付插件 CSS。 签入仓库的会话格式 JSONL 使用规范打包行布局,无密钥快照门禁会通过 `session` header 发现每一份此类 fixture;[临时迁移器](../scripts/migrate-packed-session-fixtures.ts)会改写旧版 fixture 布局。 diff --git a/docs/user/develop/basic/publish.i18n.yaml b/docs/user/develop/basic/publish.i18n.yaml index 963fe17378..ebcb75a3ca 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: c81e53d75ecccd31c9051f33252854dbe156c566 -publish.zh.md: c5a15be00bea838eb534dbf608c29d3832c2c0e1 +publish.md: 04520b0fb7d30c716e3c87761bd38f0c25824739 +publish.zh.md: 7b0e0141dc0522bb5ec356aa8cba1618c9517f09 diff --git a/docs/user/develop/basic/publish.md b/docs/user/develop/basic/publish.md index c81e53d75e..04520b0fb7 100644 --- a/docs/user/develop/basic/publish.md +++ b/docs/user/develop/basic/publish.md @@ -118,9 +118,19 @@ A bundle that defines a runnable app marks its startup row through the injection 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. +That row calls `runStartup` from [`@deepseek-ai/dsh-cmdline`](../../../../packages/boot/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. +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: + +```yaml +- id: my-app + name: '@example/my-app' + inject: [myAppStartup] + config: + port: !!js ctx.myAppStartup.port ?? 8080 +``` + +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 diff --git a/docs/user/develop/basic/publish.zh.md b/docs/user/develop/basic/publish.zh.md index c5a15be00b..7b0e0141dc 100644 --- a/docs/user/develop/basic/publish.zh.md +++ b/docs/user/develop/basic/publish.zh.md @@ -118,9 +118,19 @@ dsh --profile demo inject: [cmdlineArgs] ``` -该行使用应用自己的 commander program 调用 [`@deepseek-ai/dsh-cmdline`](../../../../packages/ui/cmdline/README.md) 中的 `runStartup`。启动器把自身 flag 之后的所有参数交给它,因此添加应用专属 flag 无需修改启动器。Loader 只挂载一次组合,等待每一行的注入,再基于其已注入的上下文求值该行的 `!!js` 配置。 +该行使用应用自己的 commander program 调用 [`@deepseek-ai/dsh-cmdline`](../../../../packages/boot/cmdline/README.md) 中的 `runStartup`。启动器把自身 flag 之后的所有参数交给它,因此添加应用专属 flag 无需修改启动器。Loader 只挂载一次组合,等待每一行的注入,再基于其已注入的上下文求值该行的 `!!js` 配置。 -受这些参数配置的行会注入启动服务,并在自己的 `!!js` 选项中读取它,同时把部署取值写在旁边作为回退。遇到 `--help` 时,该服务不会被提供,所以这些行不会激活。叠加在另一应用之上的应用会禁用下层启动行,因为一套组合只能有一个命令行所有者。 +受这些参数配置的行会注入启动服务,并在自己的 `!!js` 选项中读取它,同时把部署取值写在旁边作为回退: + +```yaml +- id: my-app + name: '@example/my-app' + inject: [myAppStartup] + config: + port: !!js ctx.myAppStartup.port ?? 8080 +``` + +遇到 `--help` 时,该服务不会被提供,所以这些行不会激活。叠加在另一应用之上的应用会禁用下层启动行,因为一套组合只能有一个命令行所有者。 ## 从 GitHub 安装:构建脚本这道坎 diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml index 5aa765be30..f31426730a 100644 --- a/docs/user/guide/quickstart.i18n.yaml +++ b/docs/user/guide/quickstart.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/quickstart.md -quickstart.md: 6a0b292ce12b32b7993b7de56b35f1df2e7a7153 -quickstart.zh.md: 008245f136e28630c7e8368eeec536e11112a885 +quickstart.md: 8e883efd470fef329a7308f1017cab0b7afcdd67 +quickstart.zh.md: e0e3f4e4754bca588831b6d204f77d1c97a140b3 diff --git a/docs/user/guide/quickstart.md b/docs/user/guide/quickstart.md index 6a0b292ce1..8e883efd47 100644 --- a/docs/user/guide/quickstart.md +++ b/docs/user/guide/quickstart.md @@ -36,10 +36,10 @@ DEEPSEEK_API_KEY=sk-your-key-here Run a non-interactive task and print its final answer: ```sh -pnpm run dsh run "summarize the architecture of this workspace" +pnpm run dsh --profile headless "summarize the architecture of this workspace" ``` -`dsh run` creates and persists a fresh session, prints the final assistant answer, and exits. It starts no Web server or listening port, and a successful run leaves stderr empty. +`dsh --profile headless` creates and persists a fresh session, prints the final assistant answer, and exits. It starts no Web server or listening port, and a successful run leaves stderr empty. ## Step 3: use the Web UI @@ -53,7 +53,7 @@ Open `http://127.0.0.1:3080`. The agent can read and write files, run commands, ## What happened -`dsh run` boots the `headless` profile: [`dsh-base`](../../../packages/bundle/base/cordis.patch.yml) and [`dsh-headless`](../../../packages/bundle/headless/cordis.patch.yml) compose over an empty root, then the runner drives the core Agent and Session services directly. `dsh web` instead composes `dsh-base` with [`dsh-web-app`](../../../packages/bundle/web-app/cordis.patch.yml), which owns the Host, HTTP, and browser layers. Both read the same default DeepSeek model route from `dsh-base`. +`dsh --profile headless` boots the `headless` profile: [`dsh-base`](../../../packages/bundle/base/cordis.patch.yml) and [`dsh-headless`](../../../packages/bundle/headless/cordis.patch.yml) compose over an empty root, then the runner drives the core Agent and Session services directly. `dsh web` instead composes `dsh-base` with [`dsh-web-app`](../../../packages/bundle/web-app/cordis.patch.yml), which owns the Host, HTTP, and browser layers. Both read the same default DeepSeek model route from `dsh-base`. ## Next steps diff --git a/docs/user/guide/quickstart.zh.md b/docs/user/guide/quickstart.zh.md index 008245f136..e0e3f4e475 100644 --- a/docs/user/guide/quickstart.zh.md +++ b/docs/user/guide/quickstart.zh.md @@ -36,10 +36,10 @@ DEEPSEEK_API_KEY=sk-your-key-here 运行一个非交互式任务并打印最终回答: ```sh -pnpm run dsh run "summarize the architecture of this workspace" +pnpm run dsh --profile headless "summarize the architecture of this workspace" ``` -`dsh run` 创建并持久化一个新会话,打印最终助手回答,然后退出。它不会启动 Web 服务器或监听端口;成功运行时 stderr 为空。 +`dsh --profile headless` 创建并持久化一个新会话,打印最终助手回答,然后退出。它不会启动 Web 服务器或监听端口;成功运行时 stderr 为空。 ## 第三步:使用 Web UI @@ -53,7 +53,7 @@ pnpm run dsh web ## 运行原理 -`dsh run` 启动 `headless` profile:[`dsh-base`](../../../packages/bundle/base/cordis.patch.yml) 和 [`dsh-headless`](../../../packages/bundle/headless/cordis.patch.yml) 在空根之上组合,随后 runner 直接驱动 core Agent 与 Session 服务。`dsh web` 则由 `dsh-base` 与 [`dsh-web-app`](../../../packages/bundle/web-app/cordis.patch.yml) 组合,后者拥有 Host、HTTP 与浏览器层。二者都从 `dsh-base` 读取同一个默认 DeepSeek 模型路由。 +`dsh --profile headless` 启动 `headless` profile:[`dsh-base`](../../../packages/bundle/base/cordis.patch.yml) 和 [`dsh-headless`](../../../packages/bundle/headless/cordis.patch.yml) 在空根之上组合,随后 runner 直接驱动 core Agent 与 Session 服务。`dsh web` 则由 `dsh-base` 与 [`dsh-web-app`](../../../packages/bundle/web-app/cordis.patch.yml) 组合,后者拥有 Host、HTTP 与浏览器层。二者都从 `dsh-base` 读取同一个默认 DeepSeek 模型路由。 ## 下一步 diff --git a/examples/headless-agent/README.i18n.yaml b/examples/headless-agent/README.i18n.yaml index 90dd1c4f2a..0871ba2c15 100644 --- a/examples/headless-agent/README.i18n.yaml +++ b/examples/headless-agent/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 examples/headless-agent/README.md -README.md: f12a56920c79f3a7e257c4e56163f323e4312d11 -README.zh.md: 9e409735f03afc62cd788fa2a5d1afdef0fa6c2a +README.md: 08b36e9c8e558db3b30175b20bd6e0af160ccef5 +README.zh.md: 40d11ebeeb1631af1421b3c013a5e548fd3fd488 diff --git a/examples/headless-agent/README.md b/examples/headless-agent/README.md index f12a56920c..08b36e9c8e 100644 --- a/examples/headless-agent/README.md +++ b/examples/headless-agent/README.md @@ -10,10 +10,10 @@ This directory owns the replay and real-model test composition for a headless co # repo root .env (gitignored) or exported env: # DEEPSEEK_API_KEY=sk-… # DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run dsh run "fix the failing test in this workspace" +pnpm run dsh --profile headless "fix the failing test in this workspace" ``` -The product command is [`dsh run`](../../apps/cli/README.md): it accepts one nonblank task, creates and persists a fresh session, prints the final assistant text, and exits. The root `demo:headless` script is only an alias of that command. +The product command is [`dsh --profile headless`](../../apps/cli/README.md): it accepts one nonblank task, creates and persists a fresh session, prints the final assistant text, and exits. The root `demo:headless` script is only an alias of that command. Snapshot suites run this directory's configuration through [`tests/fixtures/headless-driver.ts`](tests/fixtures/headless-driver.ts), an unexported test-only process that emits canonical session events as JSONL before its result record. That stream is test infrastructure, not a supported CLI output format. Child sessions surface only through parent tool events and results. diff --git a/examples/headless-agent/README.zh.md b/examples/headless-agent/README.zh.md index 9e409735f0..40d11ebeeb 100644 --- a/examples/headless-agent/README.zh.md +++ b/examples/headless-agent/README.zh.md @@ -10,10 +10,10 @@ # repo root .env (gitignored) or exported env: # DEEPSEEK_API_KEY=sk-… # DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run dsh run "fix the failing test in this workspace" +pnpm run dsh --profile headless "fix the failing test in this workspace" ``` -产品命令是 [`dsh run`](../../apps/cli/README.md):它接受一项非空任务,创建并持久化新会话,打印最终 assistant 文本,然后退出。根目录的 `demo:headless` 脚本只是该命令的别名。 +产品命令是 [`dsh --profile headless`](../../apps/cli/README.md):它接受一项非空任务,创建并持久化新会话,打印最终 assistant 文本,然后退出。根目录的 `demo:headless` 脚本只是该命令的别名。 快照套件通过 [`tests/fixtures/headless-driver.ts`](tests/fixtures/headless-driver.ts) 运行本目录的配置。这个未导出且仅供测试使用的进程会在结果记录之前,以 JSONL 发出规范会话事件。该事件流属于测试基础设施,不是受支持的 CLI(命令行界面)输出格式。子会话只通过父会话的工具事件和结果对外显示。 diff --git a/examples/headless-agent/tests/fixtures/dsh-run.cordis.yml b/examples/headless-agent/tests/fixtures/headless-profile.cordis.yml similarity index 100% rename from examples/headless-agent/tests/fixtures/dsh-run.cordis.yml rename to examples/headless-agent/tests/fixtures/headless-profile.cordis.yml diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index b09458ec3d..5fc337527e 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -52,9 +52,9 @@ const dshBinScript = fileURLToPath(new URL('../../../apps/cli/src/bin.ts', impor const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) const reasoningConfigPath = fileURLToPath(new URL('./fixtures/cli.cordis.yml', import.meta.url)) const deepseekDefaultsConfigPath = fileURLToPath(new URL('./fixtures/deepseek-defaults.cordis.yml', import.meta.url)) -const dshRunOverlayPath = fileURLToPath(new URL('./fixtures/dsh-run.cordis.yml', import.meta.url)) -const dshRunSessionExpected = join(snapshotsDir, 'dsh-run', 'session.expected.jsonl') -const dshRunFailureExpected = join(snapshotsDir, 'dsh-run', 'stderr.expected.txt') +const headlessOverlayPath = fileURLToPath(new URL('./fixtures/headless-profile.cordis.yml', import.meta.url)) +const headlessSessionExpected = join(snapshotsDir, 'headless-profile', 'session.expected.jsonl') +const headlessFailureExpected = join(snapshotsDir, 'headless-profile', 'stderr.expected.txt') const cliMockLlmPluginPath = fileURLToPath(new URL('./fixtures/cli-mock-llm.ts', import.meta.url)) const refreshing = process.env.DSH_SNAPSHOT === 'refresh' @@ -217,14 +217,14 @@ async function prepareCliMockFixture(cwd: string): Promise { } describe('headless stream-json snapshots', () => { - it('runs one task through the product dsh run command', async () => { - const task = 'Prove the product dsh run path with one real tool round trip.' + it('runs one task through the product headless profile command', async () => { + const task = 'Prove the product headless profile path with one real tool round trip.' const result = await runLoaderSmoke({ - label: 'product dsh run snapshot', - tempDirPrefix: 'headless-snapshot-dsh-run-', + label: 'product headless profile snapshot', + tempDirPrefix: 'headless-snapshot-profile-', binScript: dshBinScript, - configPath: dshRunOverlayPath, - binArgs: ['run', '--patch', dshRunOverlayPath, task], + configPath: headlessOverlayPath, + binArgs: ['--profile', 'headless', '--patch', headlessOverlayPath, task], tsconfigPath, env: { DSH_PERMISSION_MODE: 'danger-full-access', @@ -236,11 +236,11 @@ describe('headless stream-json snapshots', () => { const logs = await persistedLogs(cwd, join(cwd, '.dsh', 'sessions')) expect(logs).toHaveLength(1) const actual = logs[0] - if (actual === undefined) throw new Error('dsh run did not persist its session') + if (actual === undefined) throw new Error('the headless profile did not persist its session') const context = contextFromLogs([actual.content]) const session = scrubRequestHeaders(normalizeSessionLog(actual.content, context)) - if (refreshing) await writeFile(dshRunSessionExpected, session) - expect(session).toBe(await readFile(dshRunSessionExpected, 'utf8')) + if (refreshing) await writeFile(headlessSessionExpected, session) + expect(session).toBe(await readFile(headlessSessionExpected, 'utf8')) expect(session).toContain(task) expect(session).toContain('CLI tool round trip complete: CLI_TOOL_ROUND_TRIP') }, @@ -250,13 +250,13 @@ describe('headless stream-json snapshots', () => { expect(result.stderr).toBe('') }, LOADER_SMOKE_TEST_TIMEOUT_MS) - it('prints a terminal model failure through the product dsh run command', async () => { + it('prints a terminal model failure through the product headless profile command', async () => { const result = await runLoaderSmoke({ - label: 'product dsh run model failure snapshot', - tempDirPrefix: 'headless-snapshot-dsh-run-failure-', + label: 'product headless profile model failure snapshot', + tempDirPrefix: 'headless-snapshot-profile-failure-', binScript: dshBinScript, - configPath: dshRunOverlayPath, - binArgs: ['run', '--patch', dshRunOverlayPath, 'Trigger the keyless model failure.'], + configPath: headlessOverlayPath, + binArgs: ['--profile', 'headless', '--patch', headlessOverlayPath, 'Trigger the keyless model failure.'], tsconfigPath, expectedExitCode: 1, env: { @@ -268,7 +268,7 @@ describe('headless stream-json snapshots', () => { }) expect(result.stdout).toBe('\n') - await expect(result.stderr).toMatchFileSnapshot(dshRunFailureExpected) + await expect(result.stderr).toMatchFileSnapshot(headlessFailureExpected) }, LOADER_SMOKE_TEST_TIMEOUT_MS) it('prints the original Loader activation error through the assembled one-shot app', async () => { diff --git a/examples/headless-agent/tests/snapshots/dsh-run/session.expected.jsonl b/examples/headless-agent/tests/snapshots/headless-profile/session.expected.jsonl similarity index 91% rename from examples/headless-agent/tests/snapshots/dsh-run/session.expected.jsonl rename to examples/headless-agent/tests/snapshots/headless-profile/session.expected.jsonl index 260ae241b0..65b6f393a6 100644 --- a/examples/headless-agent/tests/snapshots/dsh-run/session.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/headless-profile/session.expected.jsonl @@ -2,16 +2,16 @@ {"type":"permission/preset","seq":0,"time":0,"data":{"preset":"danger-full-access"}} {"type":"sandbox/mode","seq":1,"time":0,"data":{"mode":"danger-full-access"}} {"type":"approval/policy","seq":2,"time":0,"data":{"policy":"never"}} -{"type":"agent/inbox/spliced","seq":3,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Prove the product dsh run path with one real tool round trip."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} +{"type":"agent/inbox/spliced","seq":3,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Prove the product headless profile path with one real tool round trip."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} {"type":"turn/start","seq":4,"time":0,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":5,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":7,"time":0,"data":{"content":[{"type":"text","text":"Prove the product dsh run path with one real tool round trip."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":0,"data":{"content":[{"type":"text","text":"Prove the product headless profile path with one real tool round trip."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"session/title","seq":9,"time":0,"data":{"title":"Prove the product dsh run","messageSeqs":[7],"source":{"kind":"fallback"}}} +{"type":"session/title","seq":9,"time":0,"data":{"title":"Prove the product headless profile","messageSeqs":[7],"source":{"kind":"fallback"}}} {"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"cli-mock","model":"cli-mock","reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":11,"time":0,"data":{"provider":"cli-mock","model":"cli-mock"}} -{"type":"session/title-llm-request","seq":12,"time":0,"data":{"titleProvider":"session-title-first-message-llm","messageSeqs":[7],"route":{"provider":"cli-mock","model":"cli-mock"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":7,\"text\":\"Prove the product dsh run path with one real tool round trip.\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}} +{"type":"session/title-llm-request","seq":12,"time":0,"data":{"titleProvider":"session-title-first-message-llm","messageSeqs":[7],"route":{"provider":"cli-mock","model":"cli-mock"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":7,\"text\":\"Prove the product headless profile path with one real tool round trip.\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}} {"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"cli-smoke-call","name":"bash","argumentsDelta":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}}} {"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}}}} diff --git a/examples/headless-agent/tests/snapshots/dsh-run/stderr.expected.txt b/examples/headless-agent/tests/snapshots/headless-profile/stderr.expected.txt similarity index 100% rename from examples/headless-agent/tests/snapshots/dsh-run/stderr.expected.txt rename to examples/headless-agent/tests/snapshots/headless-profile/stderr.expected.txt diff --git a/package.json b/package.json index 5b07ed60fa..77816c319d 100644 --- a/package.json +++ b/package.json @@ -122,7 +122,7 @@ "hygiene": "pnpm run rescope-vendor:check && pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure && pnpm run verify-vendored-links", "publish:npm-baseline": "tsx scripts/publish-npm-baseline.ts", "dsh": "node --import tsx/esm apps/cli/src/bin.ts", - "demo:headless": "node --import tsx/esm apps/cli/src/bin.ts run", + "demo:headless": "node --import tsx/esm apps/cli/src/bin.ts --profile headless", "demo:code-mode": "node scripts/demo-code-mode.mjs", "demo:cordis": "node scripts/demo-cordis.mjs", "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml", diff --git a/packages/boot/README.i18n.yaml b/packages/boot/README.i18n.yaml index 66d3b7b63c..9be0243c92 100644 --- a/packages/boot/README.i18n.yaml +++ b/packages/boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/README.md -README.md: 5e4e483b60adab0b22ddb5279f4cd8fb699b9c35 -README.zh.md: 95a3f98129a7d1fdfaffb3cac6fed77bab7cff56 +README.md: 58a824a7f4af3c62f09b363f7cae041651c536b2 +README.zh.md: 7357b920a067ce74f6f74a69a241d82895675ee4 diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index a55e250b6d..d1f9a72df5 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md -README.md: f3ffdae3846edba6f1a1a4821adade7b6c7fce76 -README.zh.md: 4f31fd743f1ddc57edc9c215a42e79a16afcdecb +README.md: 1d56b2b6d22c08574f8e361955bee1dbe2aca601 +README.zh.md: 5429a1322d0311f03c7c43946753a290e28cd936 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index f3ffdae384..1d56b2b6d2 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -42,7 +42,7 @@ User-level machine-local preferences also live in the Harness home: - **`.env`** — the product CLI's ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. `loadLayeredEnv` snapshots each value's source, rejects [bootstrap-only file variables](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md#decision) case-insensitively, and materializes accepted values into `process.env` for Loader expressions and third-party libraries. Managed credentials live separately in [`.credentials.yaml`](../../credentials/credentials-local/README.md); a credential left in either `.env` remains a lower-priority fallback. - **`cordis.patch.yml`** (home level) and **`profiles//cordis.patch.yml`** — the user patch layers, applied after every bundle layer (per-profile first, then the home-level file, which therefore outranks it): an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the composed tree is a stderr warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the layer with `[]`. -Long-lived surfaces keep `cordis.patch.yml` live through `watchUserPatches`; one-shot runs read only the startup value. The watcher targets the exact path even when the file or immediate parent does not exist, serializes bursts, and recomposes the user patches inside the caller's layer order (bundle layers below, overlay/flag patches above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh. +Long-lived surfaces keep `cordis.patch.yml` live through `watchUserPatches`; one-shot runs read only the startup value. The watcher targets the exact path even when the file or immediate parent does not exist, serializes bursts, and recomposes the user patches inside the caller's layer order (bundle layers below, overlays above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh. ## Model Experience diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 4f31fd743f..5429a1322d 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -42,7 +42,7 @@ profile 是位于 `$DSH_HOME/profiles/` 下的目录(Harness home 由 [` - **`.env`**:产品 CLI 的普通环境层;调用目录的文件优先于 Harness home 的文件,两者都低于继承环境。`loadLayeredEnv` 记录每个值的来源,按不区分大小写的方式拒绝 [bootstrap-only 文件变量](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md#decision),并把其余值物化进 `process.env`,供 Loader 表达式和第三方库使用。受管凭据另存于 [`.credentials.yaml`](../../credentials/credentials-local/README.md);留在任一 `.env` 中的凭据仍是低优先级后备值。 - **`cordis.patch.yml`**(home 级)与 **`profiles//cordis.patch.yml`**:用户 patch 层,应用在所有组合包层之后(先应用逐 profile 的文件,再应用 home 级文件,因此后者优先级更高):按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在组合后的树中,则输出一条 stderr 警告。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用该层,请使用 `[]`。 -长期运行的界面会持续应用 `cordis.patch.yml` 的变更,具体由 `watchUserPatches` 负责;一次性运行只读取启动时的值。即使该文件或其直接父目录不存在,监视器仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch(组合包层在下、overlay/标志 patch 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离观察方的失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。 +长期运行的界面会持续应用 `cordis.patch.yml` 的变更,具体由 `watchUserPatches` 负责;一次性运行只读取启动时的值。即使该文件或其直接父目录不存在,监视器仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch(组合包层在下、overlay 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离观察方的失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。 ## 模型体验 diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 41274e4a62..94d76dea81 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -215,7 +215,7 @@ export interface UserPatchWatchOptions { * Compose the full patch list for a fresh user-layer generation — * the same composition the app booted with, so a reload can interleave the * new user patches between app-owned layers (bundle layers below, - * overlay/flag patches above). Identity when omitted: the user layer + * overlays above). Identity when omitted: the user layer * is the whole patch list. */ compose?: (userPatches: PatchOptions[]) => PatchOptions[] diff --git a/packages/boot/cmdline/README.i18n.yaml b/packages/boot/cmdline/README.i18n.yaml index 9207c4b35d..7208acdab6 100644 --- a/packages/boot/cmdline/README.i18n.yaml +++ b/packages/boot/cmdline/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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: cd3350678d38802c18ff26dd47214b5019b8c404 -README.zh.md: ad726cd0726cbbd22736321a8c52b04e23d557fa +# pnpm run verify-translation-pairing --write packages/boot/cmdline/README.md +README.md: 5a7e267691cd19548a19e812390865f140e3a620 +README.zh.md: 6d5892e97708a6bc464dafee3dcba2521129ea93 diff --git a/packages/boot/cmdline/README.md b/packages/boot/cmdline/README.md index cd3350678d..5a7e267691 100644 --- a/packages/boot/cmdline/README.md +++ b/packages/boot/cmdline/README.md @@ -60,7 +60,7 @@ Loader defers a row's `!!js` interpolation until that row's declared injections ### One command line, one owner -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). +A composition has exactly one command-line owner. An app that layers over another one disables the underlying startup row and provides every startup service its retained rows inject. 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. diff --git a/packages/boot/cmdline/README.zh.md b/packages/boot/cmdline/README.zh.md index ad726cd072..6d5892e977 100644 --- a/packages/boot/cmdline/README.zh.md +++ b/packages/boot/cmdline/README.zh.md @@ -60,7 +60,7 @@ Loader 会把一行的 `!!js` 插值推迟到该行声明的注入全部激活 ### 一条命令行,一个所有者 -一套组合有且只有一个命令行所有者。叠加在另一应用之上的应用会禁用下层的启动行,并同时点名两个服务,使它吸收过来的行按各自回退值启动:[`dsh-headless`](../../bundle/headless/README.md) 相对 [`dsh-web-app`](../../bundle/web-app/README.md) 就是这么做的。 +一套组合有且只有一个命令行所有者。叠加在另一应用之上的应用会禁用下层的启动行,并提供保留下来的各行所注入的全部启动服务。 树外插件会带来自己的一份 commander 副本,因此 commander 的控制流错误按结构识别,而不是按类身份识别;按身份判断会把已经打印出来的 help 重新抛成致命的加载失败。 diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json index 7d1a93f71d..90af8c212f 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 app bundles: cmdlineArgs exposes inner arguments, while injected startup rows parse them into app-owned runtime services", + "description": "Command-line handoff 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", diff --git a/packages/boot/cmdline/src/index.ts b/packages/boot/cmdline/src/index.ts index f927a21072..beb2f6a4c3 100644 --- a/packages/boot/cmdline/src/index.ts +++ b/packages/boot/cmdline/src/index.ts @@ -140,10 +140,9 @@ export type StartupPlan = (program: Command, rows: readonly EntryOp * 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 - * bundle) disables the underlying startup row and names both services, because - * a composition has exactly one command-line owner: the rows of the app it - * absorbed then start on the values their own fallbacks name. + * A custom app that layers over another one disables the underlying startup + * row and names every startup service its retained rows inject, because a + * composition has exactly one command-line owner. * @param ctx - plugin context carrying `cmdlineArgs`, `appExit`, and the Loader. * @param services - the service name, or names, this startup row provides. * @param program - the app's commander program, with its flags and description already declared. @@ -186,8 +185,8 @@ export function runStartup( } catch (error) { // exitOverride turns help, version, a parse error, and a plan's own // program.error() into a CommanderError; commander has already written the - // text through the output configured above. The app's rows ship disabled, - // so leaving them alone is what keeps the app unstarted. + // text through the output configured above. With no startup service, + // dependent rows remain pending and the app stays unstarted. if (!isCommanderError(error)) throw error exit(error.exitCode) return undefined diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 4b1403ca59..92dc62d7ff 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -25,7 +25,7 @@ export const name = 'headless-runner' /** Core services required before the one-shot turn can start. */ export const inject = ['agentDefaultModel', 'agents', 'sessions'] -/** Plugin config: the task, patched in by the launcher. */ +/** Plugin config: the task resolved from this app's injected startup service. */ export interface Config { /** The prompt text for the single run. */ task: string diff --git a/packages/bundle/headless/tsconfig.json b/packages/bundle/headless/tsconfig.json index 17d11ed3ff..8e0b4ae4b3 100644 --- a/packages/bundle/headless/tsconfig.json +++ b/packages/bundle/headless/tsconfig.json @@ -33,10 +33,7 @@ "path": "../../support/invariants" }, { - "path": "../../ui/cmdline" - }, - { - "path": "../web-app" + "path": "../../boot/cmdline" } ] } diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index e702feca98..b1d297ff75 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md -README.md: 1b54e6d29ad49c62b7862bf7fffcd6d24831c643 -README.zh.md: 82b7c4c2574aa93697e8483c362cf4ec75630f34 +README.md: fb6a1a3ee5293c7e90afae11a76fe5a8598f3ee8 +README.zh.md: d8276514d94e658788371034795a073abefbf6ac diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index 1b54e6d29a..fb6a1a3ee5 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -2,19 +2,19 @@ English | [中文](README.zh.md) -The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner over it, registers the web-surface prompt section and the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true. This bundle also owns the app command line: the `web-startup` row ([`src/startup.ts`](src/startup.ts)) parses `--host`, `--port`, `--dev`, `--workspace-root`, and repeatable `--trusted-host` from `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)) and prints the app's `--help`. Every row it configures injects `webStartup`, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. `mode` and `lanAddresses` resolve on every boot because they describe the invocation. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle. +The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner over it, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true. This bundle also owns the app command line: the `web-startup` row ([`src/startup.ts`](src/startup.ts)) parses `--host`, `--port`, `--dev`, and repeatable `--trusted-host` from `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)) and prints the app's `--help`. Every row it configures injects `webStartup`, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. `mode` and `lanAddresses` resolve on every boot because they describe the invocation. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle. ## Model Experience -### Web-surface prompt section and bash runtime variables +### Harness-source and Web-surface context #### What the model sees -When `surfaceContext` is true, the `app:web-surface` global section (order −98) orients the model to the GUI: the canonical local URL, the "this page" referent, the HMR/rebuild update contract for the active mode, and the instruction not to start replacement servers. `DSH_WEB_URL` and `DSH_WEB_MODE` additionally appear in the managed bash environment with their descriptions, resolved per invocation from the live server. When it is false, neither the section nor the variables are registered. +When `surfaceContext` is true, the `harness:source` section identifies the on-disk Harness implementation without claiming it is the working directory, and the `app:web-surface` global section (order −98) orients the model to the GUI: the canonical local URL, the "this page" referent, the HMR/rebuild update contract for the active mode, and the instruction not to start replacement servers. `DSH_WEB_URL` and `DSH_WEB_MODE` additionally appear in the managed bash environment with their descriptions, resolved per invocation from the live server. When it is false, neither section nor the variables are registered. #### Token effect -One prompt paragraph per session plus two managed-environment variable lines; constant per process. +One source line and one prompt paragraph per session plus two managed-environment variable lines; constant per process. #### KV Cache effect diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 82b7c4c257..d8276514d9 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -2,19 +2,19 @@ [English](README.md) | 中文 -dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 web 表层提示词段落和 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。本组合包还持有应用命令行:`web-startup` 行([`src/startup.ts`](src/startup.ts))从 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md))解析 `--host`、`--port`、`--dev`、`--workspace-root` 以及可重复的 `--trusted-host`,并打印应用自己的 `--help`。它所配置的每一行都注入 `webStartup`,因此在参数解析完成之前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。`mode` 与 `lanAddresses` 在每次 boot 时解析,因为它们描述的是本次调用。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。 +dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。本组合包还持有应用命令行:`web-startup` 行([`src/startup.ts`](src/startup.ts))从 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md))解析 `--host`、`--port`、`--dev` 以及可重复的 `--trusted-host`,并打印应用自己的 `--help`。它所配置的每一行都注入 `webStartup`,因此在参数解析完成之前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。`mode` 与 `lanAddresses` 在每次 boot 时解析,因为它们描述的是本次调用。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。 ## 模型体验 -### Web 表层提示词段落与 bash 运行时变量 +### Harness 源码与 Web 表层上下文 #### 模型看到的内容 -当 `surfaceContext` 为 true 时,全局段落 `app:web-surface`(顺序 −98)向模型说明 GUI:规范的本地 URL、「this page」指代什么、当前模式下 HMR(热模块替换)/重建的更新约定,以及不要启动替代服务器的指令。`DSH_WEB_URL` 与 `DSH_WEB_MODE` 还会连同各自描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。当它为 false 时,该提示词段和这些变量都不会注册。 +当 `surfaceContext` 为 true 时,`harness:source` 段落标明磁盘上的 Harness 实现,但不会声称它就是工作目录;全局段落 `app:web-surface`(顺序 −98)则向模型说明 GUI:规范的本地 URL、「this page」指代什么、当前模式下 HMR(热模块替换)/重建的更新约定,以及不要启动替代服务器的指令。`DSH_WEB_URL` 与 `DSH_WEB_MODE` 还会连同各自描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。当它为 false 时,这两个段落和这些变量都不会注册。 #### Token 影响 -每个会话一段提示词,外加两行受管环境变量;每个进程内保持恒定。 +每个会话一行源码说明和一段提示词,外加两行受管环境变量;每个进程内保持恒定。 #### KV Cache 影响 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 656a3374cb..922600387e 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -80,9 +80,6 @@ # shares. The base layer's agent-default-model service owns the default model. - id: api-gateway name: '@deepseek-ai/dsh-host-apiproxy' - inject: [webStartup] - config: - workspaceRoot: !!js ctx.get('webStartup')?.workspaceRoot # This app's command-line startup row. It owns the web flag family and its # --help, and provides webStartup to the rows that inject it. @@ -92,9 +89,9 @@ # ── layer 2: transport/service ────────────────────────────────────────────── - # Plain route-registration carrier; host and port arrive as `dsh web` - # flag patches over these defaults. The dist is served by the web-runtime - # row below through the fallback seat. + # Plain route-registration carrier; host and port come from the app's + # startup service, with these deployment fallbacks. The dist is served by + # the web-runtime row below through the fallback seat. - id: webserver name: '@deepseek-ai/dsh-host-webserver' inject: [webStartup] @@ -119,7 +116,7 @@ lanAddresses: !!js ctx.get('webStartup')?.lanAddresses ?? [] # The client-plugin reload chain: a dev-only row this bundle ships off, - # which the entrypoint turns on for `--dev`. It is a row rather than a + # which the runtime row turns on for `--dev`. It is a row rather than a # child of web-runtime because its node half is a client-side package, # which a host-side bundle cannot import. - id: client-hmr diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index e8240e1b63..b6740c7b77 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -38,8 +38,8 @@ }, "dependencies": { "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^", "@deepseek-ai/dsh-client-hmr": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts index 27b9a4e27a..5385fd1d66 100644 --- a/packages/bundle/web-app/src/index.ts +++ b/packages/bundle/web-app/src/index.ts @@ -41,12 +41,12 @@ export type WebMode = 'production' | 'development' export interface Config { /** Whether this process mounted the client-plugin HMR receiver (`dsh web --dev`). */ mode: WebMode - /** Print the URL line on activation; a headless layer over this bundle turns it off. */ + /** Print the URL line on activation; a non-interactive layer can turn it off. */ printUrl: boolean /** * Register the model-visible surface context (the `app:web-surface` prompt * section and the `DSH_WEB_URL`/`DSH_WEB_MODE` bash variables). A one-shot - * layer turns it off: its user is not interacting through the GUI, so the + * non-interactive layer can turn it off when its user is not in the GUI, so the * orientation text would be false. */ surfaceContext: boolean diff --git a/packages/bundle/web-app/src/startup.ts b/packages/bundle/web-app/src/startup.ts index e636366f3c..a276e7e032 100644 --- a/packages/bundle/web-app/src/startup.ts +++ b/packages/bundle/web-app/src/startup.ts @@ -1,10 +1,10 @@ /** * The web app's startup row: it owns the `dsh --profile web` flag family - * (`--host`, `--port`, `--dev`, `--workspace-root`, `--trusted-host`) and its - * `--help` text, turns those flags into changes on the rows that inject - * {@link WEB_STARTUP_SERVICE}, and then provides it. Until it does, no web row - * starts, so `dsh --profile web --help` prints this command's help and the - * server never binds. + * (`--host`, `--port`, `--dev`, `--trusted-host`) and its `--help` text, + * turns those flags into changes on the rows that inject + * {@link WEB_STARTUP_SERVICE}, and then provides it. Until it does, no + * flag-configured web row starts, so `dsh --profile web --help` prints this + * command's help and the server never binds. * @module @deepseek-ai/dsh-web-app/startup */ @@ -33,8 +33,6 @@ export interface WebStartupValues { host?: string /** `--port`, absent when the invocation did not name one. */ port?: number - /** `--workspace-root`, absent when the invocation did not name one. */ - workspaceRoot?: string /** Web runtime mode; `--dev` selects development, which also mounts the client-plugin reload chain. */ mode: 'production' | 'development' /** @@ -101,7 +99,6 @@ interface WebOptions { host?: string port?: string dev?: boolean - workspaceRoot?: string trustedHost?: string[] } @@ -117,14 +114,13 @@ function webCommand(): Command { .option('--host ', 'bind host; pass 0.0.0.0 to reach it from another machine') .option('--port ', 'listen port; pass 0 to let the OS pick a free one') .option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)') - .option('--workspace-root ', 'parent directory for workspaces created from the browser UI') .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') .addHelpText('after', ` Examples: - dsh web serve on the composed host and port - dsh web --port 8080 serve on another port - dsh web --host 0.0.0.0 reach it from another machine on the LAN - dsh web --dev mount the client-plugin HMR receiver + dsh --profile web serve on the composed host and port + dsh --profile web --port 8080 serve on another port + dsh --profile web --host 0.0.0.0 reach it from another machine on the LAN + dsh --profile web --dev mount the client-plugin HMR receiver `) } @@ -146,7 +142,6 @@ function planWebStartup(program: Command, rows: readonly EntryOptions[], ctx: Co return found } const webserver = row('webserver') - row('api-gateway') row('web-runtime') const connection = row('connection') // Include preserves nested row expressions until their own injections are @@ -162,7 +157,6 @@ function planWebStartup(program: Command, rows: readonly EntryOptions[], ctx: Co return { ...options.host !== undefined && { host: options.host }, ...options.port !== undefined && { port: Number(options.port) }, - ...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot }, // mode and lanAddresses describe this invocation, never the deployment, so // they are resolved on every boot. mode: options.dev === true ? 'development' : 'production', diff --git a/packages/bundle/web-app/tests/startup.spec.ts b/packages/bundle/web-app/tests/startup.spec.ts index 5a7c80dcc4..f58c771aaa 100644 --- a/packages/bundle/web-app/tests/startup.spec.ts +++ b/packages/bundle/web-app/tests/startup.spec.ts @@ -80,10 +80,6 @@ export const apply = ctx => globalThis.__webStartupApply(ctx) ' config:', ` trustedHosts: !!js ctx.get('${WEB_STARTUP_SERVICE}')?.trustedHosts ?? ${JSON.stringify(trustedHosts)}`, ], - '- id: api-gateway', - ` name: ${rowUrl}`, - ` inject: [${WEB_STARTUP_SERVICE}]`, - ' disabled: true', // A second reader keeps the composition honest when the webserver row is // the one under test: the service must still have someone to serve. '- id: web-runtime', @@ -118,10 +114,9 @@ export const apply = ctx => globalThis.__webStartupApply(ctx) describe('web startup', () => { it('resolves each flag into the value its row reads', async () => { - const { values } = await bootStartup(['--port', '8080', '--workspace-root', '/w']) + const { values } = await bootStartup(['--port', '8080']) expect(values).toEqual({ port: 8080, - workspaceRoot: '/w', mode: 'production', trustedHosts: [], lanAddresses: [], diff --git a/packages/bundle/web-app/tsconfig.json b/packages/bundle/web-app/tsconfig.json index b15ebb1664..195aa985e8 100644 --- a/packages/bundle/web-app/tsconfig.json +++ b/packages/bundle/web-app/tsconfig.json @@ -18,7 +18,10 @@ "path": "../../../vendor/loader" }, { - "path": "../../ui/cmdline" + "path": "../../boot/app-boot" + }, + { + "path": "../../boot/cmdline" }, { "path": "../../host/frontend-static" diff --git a/packages/core/agent-default-model/README.i18n.yaml b/packages/core/agent-default-model/README.i18n.yaml index 7835a159bc..c84c0ea271 100644 --- a/packages/core/agent-default-model/README.i18n.yaml +++ b/packages/core/agent-default-model/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/core/agent-default-model/README.md -README.md: 98bc7d082e62a764868f8acd323c4617e9839e61 -README.zh.md: 807b612bd25e49aa318c13c8c8dc7595a6459080 +README.md: e86be7c37a1f994ca52f018144ef6a2409bd1eea +README.zh.md: 00250c28ef8c03d4b33fe1c1bfca138a022f6638 diff --git a/packages/core/agent-default-model/README.md b/packages/core/agent-default-model/README.md index 98bc7d082e..e86be7c37a 100644 --- a/packages/core/agent-default-model/README.md +++ b/packages/core/agent-default-model/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The deployment default used when an entry point creates an Agent that has no session-local model selection. `AgentDefaultModelService` provides `ctx.agentDefaultModel`; direct entry points such as `dsh run` and Host-backed entry points such as ApiProxy read the same service instead of owning parallel provider/model defaults. +The deployment default used when an entry point creates an Agent that has no session-local model selection. `AgentDefaultModelService` provides `ctx.agentDefaultModel`; direct entry points such as `dsh --profile headless` and Host-backed entry points such as ApiProxy read the same service instead of owning parallel provider/model defaults. The plugin config requires `{ provider, model }`. That composition entry is the base of the `agent-default-model` Settings section; a mounted settings provider layers the user's choice over it and changes are visible on the next `currentSelection()` read. `reasoningEffort` belongs to the Settings section but deliberately not to plugin config: a complete saved selection can clear an effort when the next selected model has none, while a composition value would be inherited again. diff --git a/packages/core/agent-default-model/README.zh.md b/packages/core/agent-default-model/README.zh.md index 807b612bd2..00250c28ef 100644 --- a/packages/core/agent-default-model/README.zh.md +++ b/packages/core/agent-default-model/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -该部署默认值供入口在创建尚无会话级模型选择的 Agent 时使用。`AgentDefaultModelService` 提供 `ctx.agentDefaultModel`;`dsh run` 这类直接入口与 ApiProxy 这类由 Host 支撑的入口读取同一服务,而不是分别持有平行的提供方/模型默认值。 +该部署默认值供入口在创建尚无会话级模型选择的 Agent 时使用。`AgentDefaultModelService` 提供 `ctx.agentDefaultModel`;`dsh --profile headless` 这类直接入口与 ApiProxy 这类由 Host 支撑的入口读取同一服务,而不是分别持有平行的提供方/模型默认值。 插件配置必须提供 `{ provider, model }`。该组合配置项构成 Settings 中 `agent-default-model` 分节的基础层;挂载的设置提供方在其上叠加用户选择,更改会在下一次调用 `currentSelection()` 时可见。`reasoningEffort` 属于该 Settings 分节,但特意不属于插件配置:完整保存的选择必须能在下一个选定模型没有推理(reasoning)强度时清除旧值,而组合配置值会再次被继承。 diff --git a/packages/examples/README.i18n.yaml b/packages/examples/README.i18n.yaml index 2270947eea..7cd26439c2 100644 --- a/packages/examples/README.i18n.yaml +++ b/packages/examples/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/examples/README.md -README.md: d8369b1e263e72c7b0ac1687c3b14a5d723ab944 -README.zh.md: acb402e925f692beaacbe0ab4e029691d664dbe8 +README.md: 0048d14ec49776f036d841bbc0579a6867e513bb +README.zh.md: 1b7acc5646071f4fc21e9238e4e440c192a1e82a diff --git a/packages/examples/README.md b/packages/examples/README.md index d8369b1e26..0048d14ec4 100644 --- a/packages/examples/README.md +++ b/packages/examples/README.md @@ -10,7 +10,7 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling | [`acp-demo/`](acp-demo/README.md) | `@deepseek-ai/dsh-acp-demo` | ACP automation application bundle | | [`jsonrpc-demo/`](jsonrpc-demo/README.md) | `@deepseek-ai/dsh-jsonrpc-demo` | External-config JSON-RPC runtime | -`agent-spine-demo` is the shared bundle; `acp-demo` adds its automation entry point, while `jsonrpc-demo` boots a deployment-owned plugin tree. Product one-shot execution belongs to `dsh run`; no package in this directory provides it. +`agent-spine-demo` is the shared bundle; `acp-demo` adds its automation entry point, while `jsonrpc-demo` boots a deployment-owned plugin tree. Product one-shot execution belongs to `dsh --profile headless`; no package in this directory provides it. These packages are not product API. Product seams and entry points remain in their owning groups; demo bundles select concrete compositions. diff --git a/packages/examples/README.zh.md b/packages/examples/README.zh.md index acb402e925..1b7acc5646 100644 --- a/packages/examples/README.zh.md +++ b/packages/examples/README.zh.md @@ -10,7 +10,7 @@ | [`acp-demo/`](acp-demo/README.md) | `@deepseek-ai/dsh-acp-demo` | ACP(Agent Client Protocol)自动化应用组合包 | | [`jsonrpc-demo/`](jsonrpc-demo/README.md) | `@deepseek-ai/dsh-jsonrpc-demo` | 外部配置 JSON-RPC 运行时 | -`agent-spine-demo` 是共享组合包;`acp-demo` 添加自动化入口,`jsonrpc-demo` 则启动由部署方拥有的插件树。产品单次执行由 `dsh run` 提供;本目录没有任何包提供该功能。 +`agent-spine-demo` 是共享组合包;`acp-demo` 添加自动化入口,`jsonrpc-demo` 则启动由部署方拥有的插件树。产品单次执行由 `dsh --profile headless` 提供;本目录没有任何包提供该功能。 这些包不是产品 API。产品 seam 与产品入口仍位于各自的归属组;演示组合包选择具体组合。 diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index e4ee0922d6..8bf63adb20 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/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/host/apiproxy/README.md -README.md: 03726c6671ec711704870d23722d83c72d9c4d35 -README.zh.md: f001866af2015671ed6429b392e3f880000e6c38 +README.md: d59f3f5ddb9929356e23a467ce5840f1673663f7 +README.zh.md: 737361f2bd85c0ea02b9d29734f58c34bc324969 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 03726c6671..d59f3f5ddb 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -56,7 +56,7 @@ The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-pag ## Carrier layer (`/client` + root) -`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` remains the isomorphic point for callers and carrier tests that need the full wire serialization/validation path without a network. Product `dsh run` is a direct core entry point and does not mount this package. +`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` remains the isomorphic point for callers and carrier tests that need the full wire serialization/validation path without a network. Product `dsh --profile headless` is a direct core entry point and does not mount this package. ## Model Experience diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index f001866af2..737361f2bd 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -56,7 +56,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr ## 载体层(`/client` + 根路径) -`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装/解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient` 以 `toFetchHandler(api)` 为基础,仍是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供需要该路径的调用方和载体测试使用。产品的 `dsh run` 是直连 core 的入口,不挂载本包。 +`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装/解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient` 以 `toFetchHandler(api)` 为基础,仍是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供需要该路径的调用方和载体测试使用。产品的 `dsh --profile headless` 是直连 core 的入口,不挂载本包。 ## 模型体验 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1dc47e50d8..99801b0001 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -168,6 +168,9 @@ importers: '@deepseek-ai/dsh-compact-tool-result-prune': specifier: workspace:^ version: link:../../packages/compact/compact-tool-result-prune + '@deepseek-ai/dsh-environment': + specifier: workspace:^ + version: link:../../packages/util/environment '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../packages/goal/goal @@ -1492,6 +1495,9 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes + '@deepseek-ai/dsh-app-boot': + specifier: workspace:^ + version: link:../../boot/app-boot '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../../client/connection diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 7ee9320cea..3de7e389a8 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -111,6 +111,9 @@ export const SERVICE_PAGE: Record = { */ export const SERVICE_WALK_EXEMPTIONS: Record = { agent: 'not a service: the DX accessor field on Agent.ctx (root accessor defaulting to undefined) — docs/subsystems/core.md owns the Agent handle', + appExit: 'not a service: launcher-provided bounded process-exit callback — packages/boot/cmdline/README.md owns the launcher contract', + appReady: 'not a service: launcher-provided whole-composition readiness promise — packages/boot/cmdline/README.md owns the launcher contract', + cmdlineArgs: 'not a service: launcher-provided immutable app argument accessor — packages/boot/cmdline/README.md owns the launcher contract', configuredAgentIdentities: 'not a service: launcher-provided boot-context value (ConfiguredAgentIdentities | undefined) — packages/core/agent-loop/README.md owns this launcher contract', launcherSessionQueryPath: 'not a service: launcher-provided boot-context value (string | undefined) — packages/session-query/session-query-sqlite/README.md owns this launcher contract', dshHomePath: 'not a service: boot-provided root accessor function (typeof dshHomePath | undefined) for Loader !!js config expressions — packages/boot/app-boot/README.md owns the boot contract', diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 63050b2079..f033ece889 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -8,11 +8,11 @@ }, { "role": "user", - "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Install\n\nClone the repository, then run the installer:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, builds the required repository artifacts, and launches the Web UI.\n\nThe default active checkout is `~/.dsh/source/current`, and the launcher is linked into `~/.local/bin`. Re-run the installer to update. [`scripts/install.sh`](scripts/install.sh) owns alternate locations, update mechanics, and recovery options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\n`dsh` boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nThe [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh run \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/self-modification/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" + "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Install\n\nClone the repository, then run the installer:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, builds the required repository artifacts, and launches the Web UI.\n\nThe default active checkout is `~/.dsh/source/current`, and the launcher is linked into `~/.local/bin`. Re-run the installer to update. [`scripts/install.sh`](scripts/install.sh) owns alternate locations, update mechanics, and recovery options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\n`dsh` boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nThe [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh --profile headless \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/self-modification/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 安装\n\n克隆仓库,然后运行安装器:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。\n\n默认生效的检出位于 `~/.dsh/source/current`,启动器链接到 `~/.local/bin`。再次运行安装器即可更新。其他位置、更新机制和恢复选项由 [`scripts/install.sh`](scripts/install.sh) 负责。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n`dsh` 启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh run \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均为可组合的 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/self-modification/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" + "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 安装\n\n克隆仓库,然后运行安装器:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。\n\n默认生效的检出位于 `~/.dsh/source/current`,启动器链接到 `~/.local/bin`。再次运行安装器即可更新。其他位置、更新机制和恢复选项由 [`scripts/install.sh`](scripts/install.sh) 负责。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n`dsh` 启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh --profile headless \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均为可组合的 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/self-modification/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" }, { "role": "user", diff --git a/tsconfig.base.json b/tsconfig.base.json index b5a7e9b1e4..fcdea094f3 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -131,6 +131,8 @@ // group prefix with a dedicated wildcard per group instead. "@deepseek-ai/dsh-host-*/invariant": ["./packages/host/*/src/invariant.ts"], "@deepseek-ai/dsh-client-*/invariant": ["./packages/client/*/src/invariant.ts"], + "@deepseek-ai/dsh-headless/startup": ["./packages/bundle/headless/src/startup.ts"], + "@deepseek-ai/dsh-web-app/startup": ["./packages/bundle/web-app/src/startup.ts"], "@deepseek-ai/dsh-client-*/client": ["./packages/client/*/src/client"], // One wildcard maps every @deepseek-ai/dsh- to its source. Package // dir names are unique across groups, so first-on-disk-wins resolution is diff --git a/tsdown.config.ts b/tsdown.config.ts index 2042e81db3..6dd12f3eb4 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -17,7 +17,7 @@ export default defineConfig(({ env }) => { const client = isBuildFaceClient(env?.DSH_BUILD_FACE) return { workspace: ['vendor/*', 'packages/*/*', 'apps/cli'], - entry: client ? '' : ['lib/types/{index,invariant}.js'], + entry: client ? '' : ['lib/types/{index,invariant,startup}.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/vendor/loader/src/config/entry.ts b/vendor/loader/src/config/entry.ts index 0f35cdfa97..4eef5505e1 100644 --- a/vendor/loader/src/config/entry.ts +++ b/vendor/loader/src/config/entry.ts @@ -8,7 +8,12 @@ import { evaluate } from './utils.ts' /** Static plugin hook for resolving a container config while preserving nested entry configs. */ export const EntryConfigResolver = Symbol.for('cordis.loader.entry-config-resolver') -/** Resolver installed at {@link EntryConfigResolver}. */ +/** + * Resolve a container's own config while preserving any nested entry configs. + * @param ctx - the container plugin context. + * @param config - the container's raw config. + * @returns the config to validate for this activation. + */ export type EntryConfigResolver = (ctx: Context, config: any) => any /** Serialized plugin entry options stored in loader config files. */ From 37ee7b0f24ac0fd0def22be76dcd96ad6b1606a2 Mon Sep 17 00:00:00 2001 From: Turtle Date: Sun, 9 Aug 2026 18:42:54 +0800 Subject: [PATCH 09/19] fix(cmdline): reject multiple command-line owners --- ...026-08-06-app-owned-command-line.i18n.yaml | 4 +- .../2026-08-06-app-owned-command-line.md | 2 +- .../2026-08-06-app-owned-command-line.zh.md | 2 +- apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 2 +- apps/cli/reference/README.zh.md | 2 +- packages/boot/cmdline/README.i18n.yaml | 4 +- packages/boot/cmdline/README.md | 2 +- packages/boot/cmdline/README.zh.md | 2 +- packages/boot/cmdline/src/index.ts | 21 +++++++++- packages/boot/cmdline/tests/cmdline.spec.ts | 38 ++++++++++++++++++- 11 files changed, 68 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml index 97b4a529f3..995447c42e 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: 948de243abe39c7b4af014f8709e102a53aa9797 -2026-08-06-app-owned-command-line.zh.md: 00cce42d123c788f78386a718f7711cad0e0c234 +2026-08-06-app-owned-command-line.md: 21433d96d1dbcb26f4104fffb5a78b389d78bca8 +2026-08-06-app-owned-command-line.zh.md: 7b123f89c8f844ae396df09136d69215f5ad8d26 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 948de243ab..21433d96d1 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,7 +12,7 @@ After profiles, compositions were installable but their command lines were not. The launcher parses only what it owns — `--profile`, `--patch`, the config dumps — and hands **everything after its own flags** to the booted tree verbatim. The split is positional: the first token the launcher does not recognize starts the app's arguments (commander's `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`). A bare `dsh -h`, which has no app to hand the flag to, still prints the launcher's own help. -The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `provideCmdline(ctx, host)` before any entry mounts, providing `ctx.cmdlineArgs` (whose whole interface is `get(): readonly string[]`), `ctx.appExit`, and `ctx.appReady`. An app consumes them from its **startup row**. Both the Loader row and plugin inject `cmdlineArgs`; the plugin calls `runStartup(ctx, service, program, plan)` with its own commander program and provides what it resolved as its own service. The Loader-row injection is also the launcher's discovery declaration; there is no parallel bundle-manifest field. The rows the app configures inject that service and read it from their own config expressions (`port: !!js ctx.webStartup.port ?? 3080`), so a flag beats the value written beside it and nothing is written back into any row. +The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `provideCmdline(ctx, host)` before any entry mounts, providing `ctx.cmdlineArgs` (whose whole interface is `get(): readonly string[]`), `ctx.appExit`, and `ctx.appReady`. An app consumes them from its **startup row**. Both the Loader row and plugin inject `cmdlineArgs`; the plugin calls `runStartup(ctx, service, program, plan)` with its own commander program and provides what it resolved as its own service. The Loader-row injection is also the launcher's discovery declaration; there is no parallel bundle-manifest field. Before boot, the launcher rejects nonempty app arguments with no active declaration and any composition with multiple active declarations. The rows the app configures inject that service and read it from their own config expressions (`port: !!js ctx.webStartup.port ?? 3080`), so a flag beats the value written beside it and nothing is written back into any row. The 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. 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 00cce42d12..7b123f89c8 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,7 +12,7 @@ profile 落地之后,组合可以安装,命令行却不能。`apps/cli` 仍 启动器只解析属于自己的部分(`--profile`、`--patch`、配置 dump),并把**自己 flag 之后的一切**原样交给引导起来的配置树。切分按位置进行:启动器不认识的第一个 token 就是应用参数的起点(依靠 commander 的 `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`)。裸的 `dsh -h` 没有可交付的应用,仍然打印启动器自己的 help。 -新包 `@deepseek-ai/dsh-cmdline` 持有这次交接。启动器在任何条目挂载之前调用 `provideCmdline(ctx, host)`,提供 `ctx.cmdlineArgs`(其全部接口就是 `get(): readonly string[]`)、`ctx.appExit` 和 `ctx.appReady`。应用从自己的**启动行**消费它们。Loader 行与插件都注入 `cmdlineArgs`;插件以自己的 commander program 调用 `runStartup(ctx, service, program, plan)`,再把解析结果作为自己的服务提供出去。Loader 行的注入同时也是启动器的发现声明,不再需要一份平行的组合包 manifest 字段。应用所配置的行注入该服务,再从各自的配置表达式中读取它(`port: !!js ctx.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 字段。启动器会在 boot 前拒绝没有活跃声明却带有非空应用参数的调用,也会拒绝存在多个活跃声明的组合。应用所配置的行注入该服务,再从各自的配置表达式中读取它(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。 boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Loader 随后在激活前一刻,基于已注入就绪的插件上下文插值该行的 `!!js`。Include 会保留嵌套的行表达式,直到目标行到达这一时点。`--help` 不提供启动服务,因此依赖行永不激活;活动 patch 重载会针对仍然在线的服务再次插值,所以已经服务中的端口不会被悄悄重置。 diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index b6c1ea5ab9..bce5b999d5 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/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 apps/cli/reference/README.md -README.md: b4a8dfe8a0473e69a0c82e33aba2d1f4210a2477 -README.zh.md: 287a215b6abb31c7f0375987210eb9703acf5657 +README.md: f28d77ccba7380426df2dd1769e33be0f7256d27 +README.zh.md: 3d8fbae31780c00f05384a1e4010fda2b6ce3246 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index b4a8dfe8a0..f28d77ccba 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -18,7 +18,7 @@ The launcher's flags come first and end at the first token it does not recognize A composition mounts once. A Loader row that injects `cmdlineArgs` parses this app's arguments and provides what it resolved as a service; each row configured from flags injects that service, and Loader waits for it before evaluating the row's config (`port: !!js ctx.webStartup.port ?? 3080`). A flag therefore beats the value written beside it. This precedence requires the row to retain that expression; a user patch that replaces the whole `config` with literals removes the runtime read. Help and rejected arguments request exit — nonzero for a rejection, 0 for help — without activating rows that depend on the startup service. A live `cordis.patch.yml` edit re-evaluates expressions against services that are still up, so it cannot reset a served port. -Launcher flags must come before app arguments, and the launcher's parser consumes one `--`: an app argument that must arrive as a literal `--` needs `-- --`. A first app argument equal to `web` or `plugin` selects that subcommand instead. A profile with no active row injecting `cmdlineArgs` accepts no app arguments; it rejects them before mounting any row instead of silently ignoring them. +Launcher flags must come before app arguments, and the launcher's parser consumes one `--`: an app argument that must arrive as a literal `--` needs `-- --`. A first app argument equal to `web` or `plugin` selects that subcommand instead. A profile with no active row injecting `cmdlineArgs` accepts no app arguments; it rejects them before mounting any row instead of silently ignoring them. A composition with multiple active rows injecting `cmdlineArgs` is always rejected because two parsers cannot own the same command line. The shipped apps own these command lines: diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 287a215b6a..3d8fbae317 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -18,7 +18,7 @@ 一套组合只挂载一次。注入 `cmdlineArgs` 的 Loader 行解析本应用的参数,并把结果作为服务提供出去;由 flag 配置的每一行都会注入该服务,Loader 会等服务激活后再求值该行配置(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值。该优先级要求配置行保留这一表达式;若用户 patch 用字面量替换整份 `config`,运行时读取也会随之消失。help 和被拒绝的参数会请求退出——拒绝时以非零状态,help 时以 0——且不会激活依赖启动服务的行。在线编辑 `cordis.patch.yml` 会针对仍然在线的服务重新求值表达式,因此不会重置已在服务的端口。 -启动器的 flag 必须写在应用参数之前,且启动器的解析器会消耗掉一个 `--`:必须以字面量 `--` 送达应用的参数需要写成 `-- --`。如果应用的第一个参数恰好等于 `web` 或 `plugin`,会选择对应的子命令。若 profile 中没有注入 `cmdlineArgs` 的活跃行,该 profile 不接受应用参数;启动器会在挂载任何行之前拒绝这些参数,而不是静默忽略。 +启动器的 flag 必须写在应用参数之前,且启动器的解析器会消耗掉一个 `--`:必须以字面量 `--` 送达应用的参数需要写成 `-- --`。如果应用的第一个参数恰好等于 `web` 或 `plugin`,会选择对应的子命令。若 profile 中没有注入 `cmdlineArgs` 的活跃行,该 profile 不接受应用参数;启动器会在挂载任何行之前拒绝这些参数,而不是静默忽略。若组合中有多个注入 `cmdlineArgs` 的活跃行,启动器总会拒绝该组合,因为两个解析器不能共同持有同一条命令行。 随附的各应用持有这些命令行: diff --git a/packages/boot/cmdline/README.i18n.yaml b/packages/boot/cmdline/README.i18n.yaml index 7208acdab6..7a7e72c3ce 100644 --- a/packages/boot/cmdline/README.i18n.yaml +++ b/packages/boot/cmdline/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/cmdline/README.md -README.md: 5a7e267691cd19548a19e812390865f140e3a620 -README.zh.md: 6d5892e97708a6bc464dafee3dcba2521129ea93 +README.md: dc267080d32d492e132df4592ddf742454a95ad2 +README.zh.md: e183156ab4a7907f8ae1e3259b2e09d458cec47d diff --git a/packages/boot/cmdline/README.md b/packages/boot/cmdline/README.md index 5a7e267691..dc267080d3 100644 --- a/packages/boot/cmdline/README.md +++ b/packages/boot/cmdline/README.md @@ -35,7 +35,7 @@ The Loader-row injection is also its discovery declaration, so no bundle manifes inject: [cmdlineArgs] ``` -The launcher uses that injection only to reject arguments for a composition with no command-line owner. Loader mounts the composition once and holds each row until its own injections are active. +The launcher uses that injection only to reject arguments for a composition with no command-line owner, and to reject a composition with multiple owners. Loader mounts the composition once and holds each row until its own injections are active. 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: diff --git a/packages/boot/cmdline/README.zh.md b/packages/boot/cmdline/README.zh.md index 6d5892e977..e183156ab4 100644 --- a/packages/boot/cmdline/README.zh.md +++ b/packages/boot/cmdline/README.zh.md @@ -35,7 +35,7 @@ Loader 行的注入同时也是发现声明,因此无需组合包 manifest 字 inject: [cmdlineArgs] ``` -启动器只用该注入来拒绝那些没有命令行所有者却带有应用参数的组合。Loader 只挂载一次整套组合,并让每一行等待自身的注入激活。 +启动器只用该注入来拒绝那些没有命令行所有者却带有应用参数的组合,以及拒绝存在多个所有者的组合。Loader 只挂载一次整套组合,并让每一行等待自身的注入激活。 应用用 flag 配置的每一行随后读取启动行解析出的取值,各自点名自己取用的键,以及回退时使用的值: diff --git a/packages/boot/cmdline/src/index.ts b/packages/boot/cmdline/src/index.ts index beb2f6a4c3..32a2572d2b 100644 --- a/packages/boot/cmdline/src/index.ts +++ b/packages/boot/cmdline/src/index.ts @@ -98,9 +98,28 @@ export function provideCmdline(ctx: Context, host: CmdlineHost): void { * adding the same injection its startup plugin already requires. * @param rows - the composed Loader rows. * @returns whether this composition has a command-line owner. + * @throws when more than one active row claims the command line. */ export function hasCmdlineConsumer(rows: readonly EntryOptions[]): boolean { - return rows.some(row => row.disabled !== true && waitsForAny(row.inject, ['cmdlineArgs'])) + const consumers: string[] = [] + const visit = (entries: readonly EntryOptions[], ancestorDisabled = false, prefix = ''): void => { + for (const row of entries) { + const id = prefix + row.id + // Loader group containers stay active when disabled, but their children + // inherit that disabled state. + const active = row.group === true || (!ancestorDisabled && row.disabled !== true) + if (active && waitsForAny(row.inject, ['cmdlineArgs'])) consumers.push(id) + if (row.group === true && Array.isArray(row.config)) { + visit(row.config, ancestorDisabled || row.disabled === true, `${id}:`) + } + } + } + visit(rows) + if (consumers.length > 1) { + const ids = consumers.map(id => JSON.stringify(id)).join(', ') + throw new Error(`dsh-cmdline: multiple active rows inject cmdlineArgs (${ids}); disable all but one startup row`) + } + return consumers.length === 1 } /** The process streams commander output is written to; production writes to the process. */ diff --git a/packages/boot/cmdline/tests/cmdline.spec.ts b/packages/boot/cmdline/tests/cmdline.spec.ts index d724fe9531..c6e8888357 100644 --- a/packages/boot/cmdline/tests/cmdline.spec.ts +++ b/packages/boot/cmdline/tests/cmdline.spec.ts @@ -130,6 +130,40 @@ describe('hasCmdlineConsumer', () => { { id: 'ordinary', name: 'ordinary' }, { id: 'disabled-startup', name: 'disabled-startup', inject: ['cmdlineArgs'], disabled: true }, ])).toBe(false) + expect(() => hasCmdlineConsumer([ + { id: 'web-startup', name: 'web-startup', inject: ['cmdlineArgs'] }, + { id: 'tui-startup', name: 'tui-startup', inject: ['cmdlineArgs'] }, + ])).toThrow('multiple active rows inject cmdlineArgs ("web-startup", "tui-startup")') + }) + + it('walks nested groups and ignores consumers disabled by an ancestor', () => { + expect(hasCmdlineConsumer([{ + id: 'app', + name: 'cordis:group', + group: true, + config: [{ id: 'startup', name: 'startup', inject: ['cmdlineArgs'] }], + }])).toBe(true) + expect(hasCmdlineConsumer([{ + id: 'app', + name: 'cordis:group', + group: true, + disabled: true, + config: [{ id: 'startup', name: 'startup', inject: ['cmdlineArgs'] }], + }])).toBe(false) + expect(() => hasCmdlineConsumer([ + { + id: 'first', + name: 'cordis:group', + group: true, + config: [{ id: 'startup', name: 'startup', inject: ['cmdlineArgs'] }], + }, + { + id: 'second', + name: 'cordis:group', + group: true, + config: [{ id: 'startup', name: 'startup', inject: ['cmdlineArgs'] }], + }, + ])).toThrow('multiple active rows inject cmdlineArgs ("first:startup", "second:startup")') }) }) @@ -187,9 +221,9 @@ describe('runStartup', () => { .toThrow('absentStartup: no row injects this startup service') }) - it('provides an empty value when the app declares no plan', async () => { + it('accepts a service-name list when the app declares no plan', async () => { const { ctx } = await bootFixture([], demoPlan, { withoutStartup: true }) - runStartup(ctx, 'demoStartup', demoCommand()) + runStartup(ctx, ['demoStartup'], demoCommand()) expect(ctx.get('demoStartup')).toEqual({}) }) }) From a4d8c0da9b63d14c047705d711ff717d1b47d6d4 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 10 Aug 2026 19:58:40 +0800 Subject: [PATCH 10/19] fix(web): include the HMR receiver in the initial client graph --- ...026-08-06-app-owned-command-line.i18n.yaml | 4 +- .../2026-08-06-app-owned-command-line.md | 2 +- .../2026-08-06-app-owned-command-line.zh.md | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- packages/boot/cmdline/README.i18n.yaml | 4 +- packages/boot/cmdline/README.md | 2 +- packages/boot/cmdline/README.zh.md | 2 +- packages/boot/cmdline/src/index.ts | 4 +- packages/boot/cmdline/tests/cmdline.spec.ts | 52 +++++++++++++++++-- packages/bundle/web-app/README.i18n.yaml | 4 +- packages/bundle/web-app/README.md | 2 +- packages/bundle/web-app/README.zh.md | 2 +- packages/bundle/web-app/cordis.patch.yml | 14 ++--- packages/bundle/web-app/src/index.ts | 17 +++--- packages/bundle/web-app/tests/web-app.spec.ts | 31 ++++++----- vendor/README.md | 1 + vendor/loader/src/config/entry.ts | 22 +++++++- 19 files changed, 125 insertions(+), 48 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml index 995447c42e..f59ff0b1a8 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: 21433d96d1dbcb26f4104fffb5a78b389d78bca8 -2026-08-06-app-owned-command-line.zh.md: 7b123f89c8f844ae396df09136d69215f5ad8d26 +2026-08-06-app-owned-command-line.md: 8556c2bbe27189a0784edf4b2a376c932807e020 +2026-08-06-app-owned-command-line.zh.md: f5a7be3500f239e03e0f05d724fa53ffaf28e624 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 21433d96d1..8556c2bbe2 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 @@ -27,7 +27,7 @@ Four framework facts shape the mechanism: - **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.** Immediately before each activation, Cordis runs the `internal/config` waterfall against the fiber's own context; Loader's listener interpolates the raw config after Cordis snapshots its injected services. - **Provider replacement and HMR must preserve the same contract.** Fiber reactivation re-runs the waterfall, HMR carries the raw config 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. +- **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). Enablement is an in-memory Loader override rather than an options rewrite, so Include reapplication cannot silently disable it. The Web bundle also starts client discovery only after enabling the optional row, ensuring the first browser graph already contains its HMR receiver. This leaves dependency ordering in Cordis activation and Loader interpolation, which own it. Rows keep their `inject` and config, Loader mounts the composition once, and the launcher only provides argv and process-lifecycle services. 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 7b123f89c8..f5a7be3500 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 @@ -27,7 +27,7 @@ boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Lo - **profile 的各行位于根 include 的 `patches` 选项内部。** Include 是条目树所有者,因此它的静态条目配置解析器会插值 Include 自身的选项,同时为目标行保留嵌套的 `!!js` 节点,而不是在 Include 上下文中递归求值。 - **Cordis 只在所有声明的注入都已激活后才激活 fiber。** 每次激活前一刻,Cordis 会基于 fiber 自身上下文运行 `internal/config` waterfall;Cordis 快照注入服务之后,Loader 的监听器再插值原始配置。 - **提供方替换与 HMR 必须保持相同契约。** fiber 重新激活时会重跑 waterfall,HMR 会把原始配置带给替换 fiber,而待处理行可以接受选项变更,不会针对缺失服务提前求值表达式。 -- **不能从正在挂载的插件内部插入一行**——`tree.create` 返回一个带前缀的 id,随后它自己解析不出来——因此条件性的行以 `disabled: true` 交付,再由活跃行启用(`dsh web --dev` 及其重载链路);启用后的行继续遵循普通注入顺序。 +- **不能从正在挂载的插件内部插入一行**——`tree.create` 返回一个带前缀的 id,随后它自己解析不出来——因此条件性的行以 `disabled: true` 交付,再由活跃行启用(`dsh web --dev` 及其重载链路)。启用采用 Loader 的内存覆盖而非改写选项,因此 Include 重新应用配置时不会悄然将其禁用。Web 组合包还会在启用可选行之后才启动客户端发现,确保首份浏览器图中已经包含 HMR 接收端。 这样,依赖顺序仍由负责它的 Cordis 激活与 Loader 插值流程处理。各行保留自己的 `inject` 和配置,Loader 只挂载一次组合,启动器只提供 argv 与进程生命周期服务。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index e4f119da06..3761963233 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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/config-catalog.md -config-catalog.md: 836a7f6a81f8c77be12fd10be5b8204be8c1acc0 -config-catalog.zh.md: 22dca1252d93ea9ce223464079f3c51c35eeb89d +config-catalog.md: 64e65e93b165ede2ac6c8fa399b9ce461938b939 +config-catalog.zh.md: 9f4a7ab071d68cfaf8ae3ea42458babfee67c9fd diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 836a7f6a81..64e65e93b1 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2546,7 +2546,7 @@ export interface Config { export type WebMode = 'production' | 'development' ``` -Source: [`packages/bundle/web-app/src/index.ts:41`](../packages/bundle/web-app/src/index.ts) +Source: [`packages/bundle/web-app/src/index.ts:40`](../packages/bundle/web-app/src/index.ts) ## `@deepseek-ai/dsh-web-fetch-local` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 22dca1252d..9f4a7ab071 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2547,7 +2547,7 @@ export interface Config { export type WebMode = 'production' | 'development' ``` -来源:[`packages/bundle/web-app/src/index.ts:41`](../packages/bundle/web-app/src/index.ts) +来源:[`packages/bundle/web-app/src/index.ts:40`](../packages/bundle/web-app/src/index.ts) ## `@deepseek-ai/dsh-web-fetch-local` diff --git a/packages/boot/cmdline/README.i18n.yaml b/packages/boot/cmdline/README.i18n.yaml index 7a7e72c3ce..db3d559d0a 100644 --- a/packages/boot/cmdline/README.i18n.yaml +++ b/packages/boot/cmdline/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/cmdline/README.md -README.md: dc267080d32d492e132df4592ddf742454a95ad2 -README.zh.md: e183156ab4a7907f8ae1e3259b2e09d458cec47d +README.md: 571ea7acf9f7be1ee2bdadafae2fc71b99d4536a +README.zh.md: 271acd6be4d58bf12d41bc02dd3ccabc7359a269 diff --git a/packages/boot/cmdline/README.md b/packages/boot/cmdline/README.md index dc267080d3..571ea7acf9 100644 --- a/packages/boot/cmdline/README.md +++ b/packages/boot/cmdline/README.md @@ -56,7 +56,7 @@ Every row the app configures from flags then reads what the startup row resolved Loader defers a row's `!!js` interpolation until that row's declared injections are active, then evaluates against the row's plugin context. The example above can therefore read `ctx.webStartup` directly: Cordis has already populated that injected service before Loader asks for `webserver`'s config. Include trees preserve nested expression nodes until each target row reaches this point. Provider replacement and live patch reload repeat interpolation against the current injected services, so a launch 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). Loader applies the enabled row's ordinary injection ordering. +`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). The activation is an in-memory override: it does not rewrite the row's configured `disabled` value and survives config reapplication for that mounted entry. Loader applies the enabled row's ordinary injection ordering. ### One command line, one owner diff --git a/packages/boot/cmdline/README.zh.md b/packages/boot/cmdline/README.zh.md index e183156ab4..271acd6be4 100644 --- a/packages/boot/cmdline/README.zh.md +++ b/packages/boot/cmdline/README.zh.md @@ -56,7 +56,7 @@ Loader 行的注入同时也是发现声明,因此无需组合包 manifest 字 Loader 会把一行的 `!!js` 插值推迟到该行声明的注入全部激活之后,再基于该行的插件上下文求值。所以上例可以直接读取 `ctx.webStartup`:Loader 索取 `webserver` 的配置之前,Cordis 已经填入了这个注入服务。Include 树会保留嵌套表达式节点,直到各个目标行到达这一时点。提供方替换与活动 patch 重载都会针对当前注入服务重新插值,因此启动 flag 不会被悄悄重置。 -`enableRow(ctx, id)` 打开某个组合包以禁用状态交付、只有部分调用才需要的行(`dsh web --dev` 及其客户端插件重载链路)。Loader 会对启用后的行应用普通的注入顺序。 +`enableRow(ctx, id)` 打开某个组合包以禁用状态交付、只有部分调用才需要的行(`dsh web --dev` 及其客户端插件重载链路)。该激活是内存中的覆盖:它不会改写行所配置的 `disabled` 值,并会在已挂载条目的配置重新应用后继续生效。Loader 会对启用后的行应用普通的注入顺序。 ### 一条命令行,一个所有者 diff --git a/packages/boot/cmdline/src/index.ts b/packages/boot/cmdline/src/index.ts index 32a2572d2b..1e2c9e3d0b 100644 --- a/packages/boot/cmdline/src/index.ts +++ b/packages/boot/cmdline/src/index.ts @@ -221,6 +221,8 @@ 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 a row mounted beside it enables it after startup resolves the invocation. + * The Loader keeps that activation in memory, separate from serialized options, + * so reapplying the composition cannot restore the invocation's row to disabled. * @param ctx - plugin context whose Loader tree carries the row. * @param id - the row id. * @returns nothing once the row has started or is waiting for its dependencies. @@ -231,7 +233,7 @@ export async function enableRow(ctx: Context, id: string): Promise { 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 }) + await entry.enableRuntime() } /** diff --git a/packages/boot/cmdline/tests/cmdline.spec.ts b/packages/boot/cmdline/tests/cmdline.spec.ts index c6e8888357..9c046d4b94 100644 --- a/packages/boot/cmdline/tests/cmdline.spec.ts +++ b/packages/boot/cmdline/tests/cmdline.spec.ts @@ -234,17 +234,63 @@ describe('enableRow', () => { await expect(enableRow(withoutLoader, 'client-hmr')).rejects.toThrow('requires the Loader service') const ctx = new Context() - let update: unknown + let enabled = false ctx.provide('loader', { entries: () => [{ options: { id: 'client-hmr' }, - update: async (options: unknown) => { update = options }, + enableRuntime: async () => { enabled = true }, }], } as never) await enableRow(ctx, 'client-hmr') - expect(update).toEqual({ disabled: false }) + expect(enabled).toBe(true) await expect(enableRow(ctx, 'absent')).rejects.toThrow('no "absent" row to enable') }) + + it('keeps invocation-only activation through config reapplication', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-runtime-enable-')) + const observed = { starts: 0, stops: 0 } + ;(globalThis as unknown as { __runtimeEnableObserved: typeof observed }).__runtimeEnableObserved = observed + writeFileSync(join(dir, 'conditional.mjs'), ` +export function apply(ctx) { + globalThis.__runtimeEnableObserved.starts += 1 + ctx.effect(() => () => { globalThis.__runtimeEnableObserved.stops += 1 }) +} +`) + writeFileSync(join(dir, 'cordis.yml'), [ + '- id: conditional', + ` name: ${pathToFileURL(join(dir, 'conditional.mjs')).href}`, + ' disabled: true', + '', + ].join('\n')) + + const ctx = new Context() + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await ctx.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(join(dir, 'cordis.yml')).href }, + }) + await ctx.loader.await() + const conditional = [...ctx.loader.entries()].find(entry => entry.options.id === 'conditional') + const include = [...ctx.loader.entries()].find(entry => entry.options.name === 'cordis:include') + expect(conditional).toBeDefined() + expect(include?.fiber).toBeDefined() + expect(conditional?.options.disabled).toBe(true) + expect(observed).toEqual({ starts: 0, stops: 0 }) + + await enableRow(ctx, 'conditional') + await ctx.loader.await() + expect(conditional?.disabled).toBe(false) + expect(conditional?.options.disabled).toBe(true) + expect(observed).toEqual({ starts: 1, stops: 0 }) + + await include!.fiber!.update(include!.options.config, true) + await ctx.loader.await() + expect(conditional?.disabled).toBe(false) + expect(conditional?.options.disabled).toBe(true) + expect(observed).toEqual({ starts: 1, stops: 0 }) + disposers.push(async () => { await ctx.fiber.dispose() }) + }) }) describe('provideCmdline', () => { diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index b1d297ff75..6053356414 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md -README.md: fb6a1a3ee5293c7e90afae11a76fe5a8598f3ee8 -README.zh.md: d8276514d94e658788371034795a073abefbf6ac +README.md: 47b582225e768ac035d12947939c7a7eb700458c +README.zh.md: 61e134f90e7ae57cb6220e92880c001f0d06bae2 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index fb6a1a3ee5..47b582225e 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner over it, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true. This bundle also owns the app command line: the `web-startup` row ([`src/startup.ts`](src/startup.ts)) parses `--host`, `--port`, `--dev`, and repeatable `--trusted-host` from `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)) and prints the app's `--help`. Every row it configures injects `webStartup`, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. `mode` and `lanAddresses` resolve on every boot because they describe the invocation. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle. +The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, enables the optional HMR row before client-module discovery so the first development graph contains its reload receiver, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true. This bundle also owns the app command line: the `web-startup` row ([`src/startup.ts`](src/startup.ts)) parses `--host`, `--port`, `--dev`, and repeatable `--trusted-host` from `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)) and prints the app's `--help`. Every row it configures injects `webStartup`, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. `mode` and `lanAddresses` resolve on every boot because they describe the invocation. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle. ## Model Experience diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index d8276514d9..61e134f90e 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。本组合包还持有应用命令行:`web-startup` 行([`src/startup.ts`](src/startup.ts))从 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md))解析 `--host`、`--port`、`--dev` 以及可重复的 `--trusted-host`,并打印应用自己的 `--help`。它所配置的每一行都注入 `webStartup`,因此在参数解析完成之前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。`mode` 与 `lanAddresses` 在每次 boot 时解析,因为它们描述的是本次调用。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。 +dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,在客户端模块发现前启用可选的 HMR 行,确保首份开发模式图中包含它的重载接收端,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。本组合包还持有应用命令行:`web-startup` 行([`src/startup.ts`](src/startup.ts))从 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md))解析 `--host`、`--port`、`--dev` 以及可重复的 `--trusted-host`,并打印应用自己的 `--help`。它所配置的每一行都注入 `webStartup`,因此在参数解析完成之前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。`mode` 与 `lanAddresses` 在每次 boot 时解析,因为它们描述的是本次调用。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。 ## 模型体验 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 922600387e..37b19e7645 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -116,8 +116,8 @@ lanAddresses: !!js ctx.get('webStartup')?.lanAddresses ?? [] # The client-plugin reload chain: a dev-only row this bundle ships off, - # which the runtime row turns on for `--dev`. It is a row rather than a - # child of web-runtime because its node half is a client-side package, + # which the runtime row turns on before client discovery. It is a row rather + # than a child of web-runtime because its node half is a client-side package, # which a host-side bundle cannot import. - id: client-hmr name: '@deepseek-ai/dsh-client-hmr' @@ -126,12 +126,14 @@ # ── browser plugin roster (dshClient rows; node halves are layer-2 hosts) ── - # Dual-face: node half scans this very tree for dsh.client rows, composes - # window.__DSH_BOOT__, serves /plugins//client.js; browser half is the - # module table the shell kernel constructs before cordis exists (adopted - # as a plugin entry by the kernel, never fetched). + # Dual-face: this waits for the runtime row to decide whether HMR belongs + # in the first graph. The node half then scans this tree, composes + # window.__DSH_BOOT__, and serves /plugins//client.js; the browser half + # is the module table the shell kernel constructs before cordis exists + # (adopted as a plugin entry by the kernel, never fetched). - id: modules name: '@deepseek-ai/dsh-client-modules' + inject: [webClientRoster] # Owns both ends of the web transport: node half binds the gateway to the # webserver under /api; browser half is the fetch/SSE client. diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts index 5385fd1d66..c93f6ec597 100644 --- a/packages/bundle/web-app/src/index.ts +++ b/packages/bundle/web-app/src/index.ts @@ -25,11 +25,10 @@ import type {} from '@deepseek-ai/dsh-bash-env' /** Stable Cordis plugin name. */ 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)) +const HMR_ROW_ID = 'client-hmr' +const CLIENT_ROSTER_SERVICE = 'webClientRoster' /** Services required before the web runtime can mount. */ export const inject = ['httpServer'] @@ -118,14 +117,16 @@ 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. + * @returns nothing once the invocation's client roster and runtime contributions are registered. */ 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 - // startup row: it needs host services that also activate after webStartup. + // Client discovery must start after the optional HMR row has a pending + // fiber. Otherwise its first browser graph omits the reload receiver, which + // cannot use that receiver to discover itself later. if (config.mode === 'development') await enableRow(ctx, HMR_ROW_ID) + // Release client discovery only after the optional row has a pending fiber. + ctx.provide(CLIENT_ROSTER_SERVICE, true) + ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() }) if (config.surfaceContext) { ctx.inject(['systemPrompt'], (promptCtx) => { addHarnessSourceSection(promptCtx, SOURCE_ROOT) diff --git a/packages/bundle/web-app/tests/web-app.spec.ts b/packages/bundle/web-app/tests/web-app.spec.ts index 1962710b5e..8c2539a20f 100644 --- a/packages/bundle/web-app/tests/web-app.spec.ts +++ b/packages/bundle/web-app/tests/web-app.spec.ts @@ -49,6 +49,19 @@ function fakeHttpServer(): { server: HttpServerService; seat: () => unknown } { return { server, seat: () => fallback } } +/** Install the optional HMR row the runtime sequences before client discovery. */ +function provideHmrRow(ctx: Context, settle: () => Promise = async () => {}): string[] { + const updates: string[] = [] + ctx.provide('loader', { + entries: () => [{ + options: { id: 'client-hmr' }, + enableRuntime: async () => { updates.push('client-hmr') }, + }], + await: settle, + } as never) + return updates +} + interface BashContribution { name: string variables: Record @@ -68,14 +81,7 @@ 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 enabledRows = provideHmrRow(ctx) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) await apply(ctx, new Config({ mode: 'development', printUrl: true, surfaceContext: true, lanAddresses: ['192.168.1.5'] })) await ctx.plugin(SystemPrompt, { persona: '' }) @@ -83,7 +89,8 @@ describe('web-app runtime glue', () => { await new Promise(resolve => setTimeout(resolve, 0)) expect(seat()).toBeDefined() // frontend-static claimed the fallback - expect(hmrUpdates).toEqual([{ disabled: false }]) + expect(enabledRows).toEqual(['client-hmr']) + expect(ctx.get('webClientRoster')).toBe(true) 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') @@ -148,7 +155,7 @@ describe('web-app runtime glue', () => { // this row itself has activated. const ready = new Context() ready.provide('httpServer', fakeHttpServer().server) - ready.provide('loader', { await: () => Promise.resolve() } as never) + provideHmrRow(ready) let announce: () => void ready.provide('appReady', new Promise((resolve) => { announce = resolve })) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) @@ -182,7 +189,7 @@ describe('web-app runtime glue', () => { settled.provide('httpServer', fakeHttpServer().server) let release: () => void const settlement = new Promise((resolve) => { release = resolve }) - settled.provide('loader', { await: () => settlement } as never) + provideHmrRow(settled, () => settlement) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) await apply(settled, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) await new Promise(resolve => setTimeout(resolve, 0)) @@ -202,7 +209,7 @@ describe('web-app runtime glue', () => { await child let releaseTorn: () => void const tornSettlement = new Promise((resolve) => { releaseTorn = resolve }) - torn.provide('loader', { await: () => tornSettlement } as never) + provideHmrRow(torn, () => tornSettlement) await apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) await child.dispose() // the httpServer service goes away releaseTorn!() diff --git a/vendor/README.md b/vendor/README.md index 0666143b54..87e65ed07f 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -46,6 +46,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 14. **`include/src/index.ts` durable debounced writes**: serialized and tracked config-file writes, retried transient `EACCES`/`EBUSY`/`EPERM` rename failures with a bounded backoff, observed asynchronous timer rejections, and drained the latest write during Include teardown. Windows can briefly retain a destination handle after a Loader child disposes; the upstream fire-and-forget rename escaped as an unhandled rejection and could lose the persisted `disabled` state. A terminal failure is logged by the asynchronous writer and remains on the queue so `Include.stop()` rethrows it instead of silently declaring persistence complete; Cordis's ordinary fiber teardown retains its separate error-containment contract. Covered by `packages/host/directory-picker-auto/tests/loader-composition.spec.ts` with injected transient and terminal rename failures. 15. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table's `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for('schemastery')` and Schemastery's `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table's two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md). 16. **Lazy Loader config resolution across `cordis/src/{events,fiber}.ts`, `loader/src/{index,config/entry}.ts`, `include/src/index.ts`, and `hmr/src/index.ts`**: ports [cordiverse/cordis#41](https://github.com/cordiverse/cordis/pull/41), retaining raw fiber config and resolving it through `internal/config` only after declared injections are active. Provider replacement re-resolves the raw expression, pending updates retain it, and HMR transfers it. Resolution applies only to the entry root, so child plugins mounted by a row keep caller-owned config identity. Include adds a static entry-config resolver so its own options interpolate while nested row `!!js` nodes remain deferred. Deferred failures retain the owning row diagnostic, and tree teardown does not persist failure-driven self-disposal. Covered by `packages/boot/app-boot/tests/{app-boot,user-patches}.spec.ts`, `packages/boot/cmdline/tests/cmdline.spec.ts`, `apps/cli/tests/web-agent-presets.e2e.ts`, and the built custom-profile cases in `apps/cli/tests/built-bin.e2e.ts`. +17. **In-memory Loader entry activation in `loader/src/config/entry.ts`**: an invocation can activate a row shipped with `disabled: true` without mutating its serialized options. The override belongs to the mounted entry object, survives Include config reapplication, respects disabled ancestors, and disappears with the entry. Covered by `packages/boot/cmdline/tests/cmdline.spec.ts` and `apps/web/tests/hmr-live.e2e.ts`. ## Sync procedure diff --git a/vendor/loader/src/config/entry.ts b/vendor/loader/src/config/entry.ts index 4eef5505e1..3fc74177f9 100644 --- a/vendor/loader/src/config/entry.ts +++ b/vendor/loader/src/config/entry.ts @@ -73,6 +73,8 @@ export class Entry { _initTask?: Promise _disposing = 0 + private runtimeEnabled = false + private runtimeEnableTask?: Promise constructor(public loader: Loader) { this.ctx = loader.ctx.extend({ [Entry.key]: this }) @@ -99,15 +101,31 @@ export class Entry { private _disabled(options: EntryOptions) { // group is always enabled if (options.group) return false - if (options.disabled) return true + if (options.disabled && !this.runtimeEnabled) return true let entry = this.parent.ctx.fiber.entry while (entry) { - if (entry.options.disabled) return true + if (entry.options.disabled && !entry.runtimeEnabled) return true entry = entry.parent.ctx.fiber.entry } return false } + /** + * Enable this in-memory entry without rewriting its configured `disabled` + * value; the override survives config reapplication for this entry object. + * @returns a promise settling after its initial activation attempt. + */ + enableRuntime(): Promise { + if (this.runtimeEnableTask !== undefined) return this.runtimeEnableTask + this.runtimeEnabled = true + this.runtimeEnableTask = this.refresh().catch((error: unknown) => { + this.runtimeEnabled = false + this.runtimeEnableTask = undefined + throw error + }) + return this.runtimeEnableTask + } + evaluate(expr: string) { return evaluate(this.ctx, expr) } From 18328ce615fb7a2e1defd1bce911f50f68ec1c61 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 10 Aug 2026 19:59:24 +0800 Subject: [PATCH 11/19] refactor(cli): remove the unused profile preparation hook --- apps/cli/src/profile-boot.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index a3159d4f49..4d7b525d07 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -191,8 +191,6 @@ export interface RunProfileOptions { patchFiles: readonly string[] /** The invocation's inner arguments, handed to the tree through `ctx.cmdlineArgs`. */ args: readonly string[] - /** Host setup registered after Loader installation and before any config-tree entry mounts. */ - prepare?: (ctx: Context) => Promise | void } /** Re-throw setup failures unless this invocation's signal already owns shutdown. */ @@ -271,7 +269,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con const watchProfilePatch = !oneShot // Cloned for the same insert-aliasing reason as composeLive: the boot // application must not mutate the objects later reloads recompose from. - const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), async (hostCtx) => { + const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), (hostCtx) => { app.current = hostCtx // Before any config-tree entry mounts, so plugins resolve all launch-time // environment values from the same immutable provenance snapshot. @@ -292,7 +290,6 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con } hostCtx.provide('headlessIo', io) } - await options.prepare?.(hostCtx) }).catch((cause: unknown) => { bootFailed(cause) throw cause From b374c16facc68607794557847891c63f533674c7 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 10 Aug 2026 19:59:25 +0800 Subject: [PATCH 12/19] test(agent-loop): wait for asynchronous reload startup --- .../tests/config-session-id.spec.ts | 44 +++++++------------ 1 file changed, 16 insertions(+), 28 deletions(-) diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 40b226f165..4ac477d42f 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -113,27 +113,19 @@ describe('config-driven session id', () => { const config = { agents: [{ id: 'main', sessionId: SessionId('config-exact-reload'), provider: 'mock', model: 'mock' }] } const firstLoop = await ctx.plugin(AgentLoop, config) - let first: Agent | undefined - for (let i = 0; i < 50 && first === undefined; i++) { - await new Promise(resolve => setTimeout(resolve, 5)) - first = ctx.agents.get(SessionId('config-exact-reload')) - } - expect(first).toBeDefined() - first!.followup(createUserMessage({ content: [{ type: 'text', text: 'remember me' }], source: { kind: 'user' } })) - await waitForIdle(ctx, first!) + await expect.poll(() => ctx.agents.get(SessionId('config-exact-reload')), { timeout: 5_000 }).toBeDefined() + const first = ctx.agents.get(SessionId('config-exact-reload'))! + first.followup(createUserMessage({ content: [{ type: 'text', text: 'remember me' }], source: { kind: 'user' } })) + await waitForIdle(ctx, first) await firstLoop.dispose() const secondLoop = await ctx.plugin(AgentLoop, config) - let second: Agent | undefined - for (let i = 0; i < 50 && second === undefined; i++) { - await new Promise(resolve => setTimeout(resolve, 5)) - second = ctx.agents.get(SessionId('config-exact-reload')) - } - expect(second).toBeDefined() - expect(JSON.stringify(second!.session.deriveMessages())).toContain('remember me') - second!.followup(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } })) - await waitForIdle(ctx, second!) - await ctx.sessions.flush(second!.session) + await expect.poll(() => ctx.agents.get(SessionId('config-exact-reload')), { timeout: 5_000 }).toBeDefined() + const second = ctx.agents.get(SessionId('config-exact-reload'))! + expect(JSON.stringify(second.session.deriveMessages())).toContain('remember me') + second.followup(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } })) + await waitForIdle(ctx, second) + await ctx.sessions.flush(second.session) const loaded = await ctx.sessionPersistence.load(SessionId('config-exact-reload')) expect(loaded.events.filter(event => event.type === 'turn/start')).toHaveLength(2) @@ -423,18 +415,14 @@ describe('config-driven session id', () => { await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('second')])) - // The deferred resume runs on a microtask after the backend is available. - let resumed: Agent | undefined - for (let i = 0; i < 50 && !resumed; i++) { - await new Promise(r => setTimeout(r, 5)) - resumed = ctx2.agents.get(SessionId('sticky-1')) - } - expect(resumed).toBeDefined() + // The deferred resume runs after the backend is available. + await expect.poll(() => ctx2.agents.get(SessionId('sticky-1')), { timeout: 5_000 }).toBeDefined() + const resumed = ctx2.agents.get(SessionId('sticky-1'))! // The live session id IS the resumed id (NOT a fresh ${id}-session-), // and the prior turn's user message is in the derived history. - expect(resumed!.id).toBe(SessionId('sticky-1')) - expect(resumed!.session.id).toBe('sticky-1') - const derived = resumed!.session.deriveMessages() + expect(resumed.id).toBe(SessionId('sticky-1')) + expect(resumed.session.id).toBe('sticky-1') + const derived = resumed.session.deriveMessages() expect(JSON.stringify(derived)).toContain('remember me') await ctx2.fiber.dispose() }) From 1ebb12432b468e79d0f068fe5bd3060432e9d020 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 10 Aug 2026 20:32:00 +0800 Subject: [PATCH 13/19] test(cli): shut down startup fixtures portably --- apps/cli/tests/built-bin.e2e.ts | 34 ++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index c8fc8e8f64..53922c4f59 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -144,8 +144,8 @@ function startProfileLifecycle(fixture: ProfileLifecycleFixture) { } function requestProfileShutdown( - child: ReturnType, - fixture: ProfileLifecycleFixture, + child: Pick, 'kill'>, + fixture: Pick, ): void { if (process.platform === 'win32') { writeFileSync(fixture.interrupt, 'interrupt') @@ -197,6 +197,7 @@ interface StartupFixture { home: string ready: string echo: string + interrupt: string /** An always-running row's echo, used to observe that a user patch reload landed. */ witness: string } @@ -228,11 +229,16 @@ function createStartupFixture(): StartupFixture { '', ].join('\n')) writeFileSync(join(bundleDir, 'waiting.mjs'), [ - "import { writeFileSync } from 'node:fs'", + "import { existsSync, writeFileSync } from 'node:fs'", "import { join } from 'node:path'", "export const name = 'startup-fixture'", 'export function apply(ctx, config = {}) {', - ' const heartbeat = setInterval(() => {}, 1000)', + ' let interrupted = false', + ' const heartbeat = setInterval(() => {', + ' if (interrupted || !existsSync(process.env.RAW_INTERRUPT_FILE)) return', + ' interrupted = true', + " process.emit('SIGTERM')", + ' }, 20)', " writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))", " writeFileSync(process.env.RAW_READY_FILE, 'ready')", ' ctx.effect(() => () => { clearInterval(heartbeat) })', @@ -276,7 +282,13 @@ function createStartupFixture(): StartupFixture { dsh: { profile: { bundles: ['dsh-startup-bundle'] } }, }, undefined, 2)) writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n') - return { home, ready: join(home, 'ready'), echo: join(home, 'config-echo'), witness: join(home, 'witness') } + return { + home, + ready: join(home, 'ready'), + echo: join(home, 'config-echo'), + interrupt: join(home, 'interrupt'), + witness: join(home, 'witness'), + } } function startStartupProfile(fixture: StartupFixture, args: readonly string[]) { @@ -286,7 +298,11 @@ function startStartupProfile(fixture: StartupFixture, args: readonly string[]) { reject: false, timeout: 25_000, killSignal: 'SIGKILL', - env: { DSH_HOME: fixture.home, RAW_READY_FILE: fixture.ready }, + env: { + DSH_HOME: fixture.home, + RAW_READY_FILE: fixture.ready, + RAW_INTERRUPT_FILE: fixture.interrupt, + }, }) } @@ -538,7 +554,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', // The waiting row started once, already carrying the flag value: the // launcher never saw --generation, and the app resolved it first. expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged') - child.kill('SIGTERM') + requestProfileShutdown(child, fixture) expect((await child).exitCode).toBe(0) } finally { child.kill('SIGKILL') @@ -552,7 +568,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', try { await waitForFile(fixture.ready) expect(readFileSync(fixture.echo, 'utf8')).toBe('bundle-default') - child.kill('SIGTERM') + requestProfileShutdown(child, fixture) expect((await child).exitCode).toBe(0) } finally { child.kill('SIGKILL') @@ -586,7 +602,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', await waitForFile(fixture.witness) expect(readFileSync(fixture.witness, 'utf8')).toBe('reloaded') expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged') - child.kill('SIGTERM') + requestProfileShutdown(child, fixture) expect((await child).exitCode).toBe(0) } finally { child.kill('SIGKILL') From 668bdb3d8eba134107d39f11563ba625ed582158 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 10 Aug 2026 20:49:40 +0800 Subject: [PATCH 14/19] refactor(cmdline): keep readiness in web app --- ...026-08-06-app-owned-command-line.i18n.yaml | 4 +- .../2026-08-06-app-owned-command-line.md | 4 +- .../2026-08-06-app-owned-command-line.zh.md | 4 +- apps/cli/src/profile-boot.ts | 16 ------- packages/boot/README.i18n.yaml | 4 +- packages/boot/README.md | 2 +- packages/boot/README.zh.md | 2 +- packages/boot/cmdline/README.i18n.yaml | 4 +- packages/boot/cmdline/README.md | 1 - packages/boot/cmdline/README.zh.md | 1 - packages/boot/cmdline/src/index.ts | 12 ----- packages/boot/cmdline/tests/cmdline.spec.ts | 4 +- packages/bundle/web-app/README.i18n.yaml | 4 +- packages/bundle/web-app/README.md | 2 +- packages/bundle/web-app/README.zh.md | 2 +- packages/bundle/web-app/src/index.ts | 10 ++--- packages/bundle/web-app/tests/web-app.spec.ts | 45 +++++-------------- scripts/gen-cordis-catalog.ts | 1 - 18 files changed, 34 insertions(+), 88 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml index f59ff0b1a8..895019d166 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md -2026-08-06-app-owned-command-line.md: 8556c2bbe27189a0784edf4b2a376c932807e020 -2026-08-06-app-owned-command-line.zh.md: f5a7be3500f239e03e0f05d724fa53ffaf28e624 +2026-08-06-app-owned-command-line.md: 3dae1cb209ae9083ac6ab6616a140b6f129bc931 +2026-08-06-app-owned-command-line.zh.md: 81750eec8a78a811dd90454d88fa8ed1611dcce6 diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md index 8556c2bbe2..3dae1cb209 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md @@ -12,13 +12,13 @@ After profiles, compositions were installable but their command lines were not. The launcher parses only what it owns — `--profile`, `--patch`, the config dumps — and hands **everything after its own flags** to the booted tree verbatim. The split is positional: the first token the launcher does not recognize starts the app's arguments (commander's `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`). A bare `dsh -h`, which has no app to hand the flag to, still prints the launcher's own help. -The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `provideCmdline(ctx, host)` before any entry mounts, providing `ctx.cmdlineArgs` (whose whole interface is `get(): readonly string[]`), `ctx.appExit`, and `ctx.appReady`. An app consumes them from its **startup row**. Both the Loader row and plugin inject `cmdlineArgs`; the plugin calls `runStartup(ctx, service, program, plan)` with its own commander program and provides what it resolved as its own service. The Loader-row injection is also the launcher's discovery declaration; there is no parallel bundle-manifest field. Before boot, the launcher rejects nonempty app arguments with no active declaration and any composition with multiple active declarations. The rows the app configures inject that service and read it from their own config expressions (`port: !!js ctx.webStartup.port ?? 3080`), so a flag beats the value written beside it and nothing is written back into any row. +The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `provideCmdline(ctx, host)` before any entry mounts, providing `ctx.cmdlineArgs` (whose whole interface is `get(): readonly string[]`) and `ctx.appExit`. An app consumes them from its **startup row**. Both the Loader row and plugin inject `cmdlineArgs`; the plugin calls `runStartup(ctx, service, program, plan)` with its own commander program and provides what it resolved as its own service. The Loader-row injection is also the launcher's discovery declaration; there is no parallel bundle-manifest field. Before boot, the launcher rejects nonempty app arguments with no active declaration and any composition with multiple active declarations. The rows the app configures inject that service and read it from their own config expressions (`port: !!js ctx.webStartup.port ?? 3080`), so a flag beats the value written beside it and nothing is written back into any row. The boot mounts the composition once. Cordis holds each row until its injections are active; Loader then interpolates that row's `!!js` against the injection-ready plugin context immediately before activation. Include keeps nested row expressions raw until their target row reaches this point. `--help` provides no startup service, so dependent rows never activate, and a live patch reload interpolates again against the service that remains active, so a served port cannot be silently reset. The shipped apps moved their flags into their bundles: `dsh-web-app` owns the Web family (and enables the `client-hmr` row it now ships disabled, for `--dev`), and `dsh-headless` owns the task positional and rejects a missing task as a usage error. `apps/cli/src/web.ts` is gone; `runProfile` no longer knows any flag-target row id. Out of tree, turtle-ui gained `--resume ` / `--session ` the same way, which is the design's real validation: an installed plugin added a flag with no launcher change. -Two further consequences. Loader mounts sibling rows concurrently, so one row can activate while another still mounts or while the whole boot is rolling back; a row that publishes readiness (the web URL line) therefore awaits `ctx.appReady`. The Web bundle's runtime plugin owns the harness-source prompt section too, so `dsh web` and `dsh --profile web` boot identically without Web-specific launcher setup. +Two further consequences. Loader mounts sibling rows concurrently, so one row can activate while another still mounts or while the whole boot is rolling back; the Web bundle therefore publishes its URL only after its own Loader tree settles. The Web bundle's runtime plugin owns the harness-source prompt section too, so `dsh web` and `dsh --profile web` boot identically without Web-specific launcher setup. ## Why Loader owns the ordering diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md index f5a7be3500..81750eec8a 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md @@ -12,13 +12,13 @@ profile 落地之后,组合可以安装,命令行却不能。`apps/cli` 仍 启动器只解析属于自己的部分(`--profile`、`--patch`、配置 dump),并把**自己 flag 之后的一切**原样交给引导起来的配置树。切分按位置进行:启动器不认识的第一个 token 就是应用参数的起点(依靠 commander 的 `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`)。裸的 `dsh -h` 没有可交付的应用,仍然打印启动器自己的 help。 -新包 `@deepseek-ai/dsh-cmdline` 持有这次交接。启动器在任何条目挂载之前调用 `provideCmdline(ctx, host)`,提供 `ctx.cmdlineArgs`(其全部接口就是 `get(): readonly string[]`)、`ctx.appExit` 和 `ctx.appReady`。应用从自己的**启动行**消费它们。Loader 行与插件都注入 `cmdlineArgs`;插件以自己的 commander program 调用 `runStartup(ctx, service, program, plan)`,再把解析结果作为自己的服务提供出去。Loader 行的注入同时也是启动器的发现声明,不再需要一份平行的组合包 manifest 字段。启动器会在 boot 前拒绝没有活跃声明却带有非空应用参数的调用,也会拒绝存在多个活跃声明的组合。应用所配置的行注入该服务,再从各自的配置表达式中读取它(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。 +新包 `@deepseek-ai/dsh-cmdline` 持有这次交接。启动器在任何条目挂载之前调用 `provideCmdline(ctx, host)`,提供 `ctx.cmdlineArgs`(其全部接口就是 `get(): readonly string[]`)与 `ctx.appExit`。应用从自己的**启动行**消费它们。Loader 行与插件都注入 `cmdlineArgs`;插件以自己的 commander program 调用 `runStartup(ctx, service, program, plan)`,再把解析结果作为自己的服务提供出去。Loader 行的注入同时也是启动器的发现声明,不再需要一份平行的组合包 manifest 字段。启动器会在 boot 前拒绝没有活跃声明却带有非空应用参数的调用,也会拒绝存在多个活跃声明的组合。应用所配置的行注入该服务,再从各自的配置表达式中读取它(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。 boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Loader 随后在激活前一刻,基于已注入就绪的插件上下文插值该行的 `!!js`。Include 会保留嵌套的行表达式,直到目标行到达这一时点。`--help` 不提供启动服务,因此依赖行永不激活;活动 patch 重载会针对仍然在线的服务再次插值,所以已经服务中的端口不会被悄悄重置。 已交付的各应用把自己的 flag 搬进了组合包:`dsh-web-app` 持有 Web 家族(并为 `--dev` 启用它如今以禁用状态交付的 `client-hmr` 行),`dsh-headless` 持有任务位置参数,缺少任务时按用法错误拒绝。`apps/cli/src/web.ts` 已删除;`runProfile` 不再知道任何 flag 目标行 id。在树外,turtle-ui 以同样的方式获得了 `--resume ` / `--session `,这才是这套设计的真正验证:一个已安装的插件加上了一个 flag,启动器毫无改动。 -还有两条后果。Loader 会并发挂载兄弟行,因此一行可能已经激活,而另一行仍在挂载,或整次 boot 正在回滚;所以公布就绪信号的行(web 的 URL 行)会等待 `ctx.appReady`。另外,Web 组合包的运行时插件也持有 harness 源码提示词段,因此 `dsh web` 与 `dsh --profile web` 无需 Web 专用启动器设置即可按完全相同的方式启动。 +还有两条后果。Loader 会并发挂载兄弟行,因此一行可能已经激活,而另一行仍在挂载,或整次 boot 正在回滚;所以 Web 组合包只会在自身的 Loader 配置树结算后公布 URL。另外,Web 组合包的运行时插件也持有 harness 源码提示词段,因此 `dsh web` 与 `dsh --profile web` 无需 Web 专用启动器设置即可按完全相同的方式启动。 ## 为什么由 Loader 持有顺序 diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 4d7b525d07..0266f8518f 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -218,17 +218,6 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con const oneShot = headlessRow !== undefined && headlessRow.disabled !== true const app: { current?: Context } = {} - // Readiness for rows that publish it (the web URL line): a row can activate - // before concurrently mounted siblings finish or fail. - let bootSettled: () => void = () => {} - let bootFailed: (reason: unknown) => void = () => {} - const ready = new Promise((resolve, reject) => { - bootSettled = resolve - bootFailed = reject - }) - // Nothing awaits `ready` on a composition that publishes no readiness, and - // an unobserved rejection must not take the process down on its own. - ready.catch(() => {}) const shutdown = createProcessShutdown(async () => { await app.current?.fiber.dispose() }) const signalShutdown = new AbortController() const interrupt = (code: number): void => { @@ -280,7 +269,6 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con provideCmdline(hostCtx, { args: options.args, exit: code => void shutdown.shutdown(code), - ready, }) if (oneShot) { const io: HeadlessIo = { @@ -290,12 +278,8 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con } hostCtx.provide('headlessIo', io) } - }).catch((cause: unknown) => { - bootFailed(cause) - throw cause }) app.current = ctx - bootSettled() // A surface can dispose the whole tree while startup or this post-boot // watcher setup is still in flight. Loader presence and fiber state own // liveness; the local signal fact distinguishes that expected exit race diff --git a/packages/boot/README.i18n.yaml b/packages/boot/README.i18n.yaml index 9be0243c92..0de587115e 100644 --- a/packages/boot/README.i18n.yaml +++ b/packages/boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/README.md -README.md: 58a824a7f4af3c62f09b363f7cae041651c536b2 -README.zh.md: 7357b920a067ce74f6f74a69a241d82895675ee4 +README.md: 79d653260ea4a9d9a4c71a593b41a6a7e17efa14 +README.zh.md: 839be164328ef168cd6ac18bf2f1dcb930dfce3e diff --git a/packages/boot/README.md b/packages/boot/README.md index 58a824a7f4..79d653260e 100644 --- a/packages/boot/README.md +++ b/packages/boot/README.md @@ -7,6 +7,6 @@ The channel-neutral boot library the app bins share: `apps/cli`, the [`scaffold/ | Package | Role | ctx key | |---|---|---| | `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) | -| `cmdline/` | Launcher-to-app command-line handoff and app-owned startup parsing | `cmdlineArgs`, `appExit`, `appReady` | +| `cmdline/` | Launcher-to-app command-line handoff and app-owned startup parsing | `cmdlineArgs`, `appExit` | The boot sequence and personal-config contract are documented in [`app-boot/README.md`](app-boot/README.md); app-owned command lines are documented in [`cmdline/README.md`](cmdline/README.md). diff --git a/packages/boot/README.zh.md b/packages/boot/README.zh.md index 7357b920a0..839be16432 100644 --- a/packages/boot/README.zh.md +++ b/packages/boot/README.zh.md @@ -7,6 +7,6 @@ | 包 | 职责 | ctx 键 | |---|---|---| | `app-boot/` | app bin 的共享启动粘合层:加载 `.env`、会明确报错的 Loader 保护机制、感知快照的配置解析,以及等待整棵树停稳的启动序列 | (供各 bin 使用的库) | -| `cmdline/` | 启动器到应用的命令行交接,以及由应用持有的启动解析 | `cmdlineArgs`、`appExit`、`appReady` | +| `cmdline/` | 启动器到应用的命令行交接,以及由应用持有的启动解析 | `cmdlineArgs`、`appExit` | 启动序列与个人配置约定见 [`app-boot/README.md`](app-boot/README.md);由应用持有的命令行见 [`cmdline/README.md`](cmdline/README.md)。 diff --git a/packages/boot/cmdline/README.i18n.yaml b/packages/boot/cmdline/README.i18n.yaml index db3d559d0a..f5e9413afd 100644 --- a/packages/boot/cmdline/README.i18n.yaml +++ b/packages/boot/cmdline/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/cmdline/README.md -README.md: 571ea7acf9f7be1ee2bdadafae2fc71b99d4536a -README.zh.md: 271acd6be4d58bf12d41bc02dd3ccabc7359a269 +README.md: a1512ae3357f06cd4de6347ea5ec2197fea40a90 +README.zh.md: e27060db433e5c234febb28d6c120d75f82072cc diff --git a/packages/boot/cmdline/README.md b/packages/boot/cmdline/README.md index 571ea7acf9..a1512ae335 100644 --- a/packages/boot/cmdline/README.md +++ b/packages/boot/cmdline/README.md @@ -10,7 +10,6 @@ A launcher calls `provideCmdline(ctx, host)` before any tree entry mounts, which - `ctx.cmdlineArgs` — the invocation's inner arguments. `get()` is the whole interface, and it returns a snapshot: `dsh --profile tui --resume abc` yields `['--resume', 'abc']`. - `ctx.appExit` — a bounded process-exit request, wired to the launcher's shutdown controller. -- `ctx.appReady` — settles when the launcher has finished mounting, for a row that publishes readiness (a URL line a supervisor waits for). An embedding host with no command line provides an empty list; that is the honest answer, not a missing value. diff --git a/packages/boot/cmdline/README.zh.md b/packages/boot/cmdline/README.zh.md index 271acd6be4..e27060db43 100644 --- a/packages/boot/cmdline/README.zh.md +++ b/packages/boot/cmdline/README.zh.md @@ -10,7 +10,6 @@ dsh 启动器交给它所引导应用的那条命令行。启动器只解析属 - `ctx.cmdlineArgs`:本次调用的内层参数。`get()` 就是它的全部接口,返回一份快照:`dsh --profile tui --resume abc` 得到 `['--resume', 'abc']`。 - `ctx.appExit`:一个有边界的进程退出请求,接到启动器的关停控制器上。 -- `ctx.appReady`:在启动器挂载完毕时结算,供需要公布就绪信号的行使用(例如督程会等待的 URL 行)。 没有命令行的嵌入宿主提供空列表;这是诚实的答案,而不是缺失的值。 diff --git a/packages/boot/cmdline/src/index.ts b/packages/boot/cmdline/src/index.ts index 1e2c9e3d0b..6806e0a273 100644 --- a/packages/boot/cmdline/src/index.ts +++ b/packages/boot/cmdline/src/index.ts @@ -53,8 +53,6 @@ declare module 'cordis' { cmdlineArgs?: CmdlineArgs /** Bounded process-exit request; provided by a launcher before the tree mounts. */ appExit?: AppExit - /** Settles when the launcher has mounted the whole composition; see {@link CmdlineHost.ready}. */ - appReady?: Promise } } @@ -64,15 +62,6 @@ export interface CmdlineHost { args: readonly string[] /** Bounded process-exit request. */ exit: AppExit - /** - * Settles when the launcher has finished mounting, which a row that - * publishes readiness (a URL line a supervisor waits for) must await. - * - * Loader mounts sibling rows concurrently, so one row can become active - * while another is still mounting or while the whole boot is rolling back. - * Rejects with the boot failure. - */ - ready?: Promise } /** @@ -86,7 +75,6 @@ export function provideCmdline(ctx: Context, host: CmdlineHost): void { const snapshot = [...host.args] ctx.provide('cmdlineArgs', { get: () => snapshot }) ctx.provide('appExit', host.exit) - if (host.ready !== undefined) ctx.provide('appReady', host.ready) } /** diff --git a/packages/boot/cmdline/tests/cmdline.spec.ts b/packages/boot/cmdline/tests/cmdline.spec.ts index 9c046d4b94..61a5d75197 100644 --- a/packages/boot/cmdline/tests/cmdline.spec.ts +++ b/packages/boot/cmdline/tests/cmdline.spec.ts @@ -297,11 +297,9 @@ describe('provideCmdline', () => { it('hands the app a snapshot the caller cannot mutate afterwards', () => { const ctx = new Context() const args = ['--resume', 'abc'] - const ready = Promise.resolve() - provideCmdline(ctx, { args, exit: () => {}, ready }) + provideCmdline(ctx, { args, exit: () => {} }) args.push('--tampered') expect(ctx.cmdlineArgs?.get()).toEqual(['--resume', 'abc']) - expect(ctx.appReady).toBe(ready) }) it('fails loud when a startup row runs without the launcher values', () => { diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index 6053356414..7f12af35c8 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md -README.md: 47b582225e768ac035d12947939c7a7eb700458c -README.zh.md: 61e134f90e7ae57cb6220e92880c001f0d06bae2 +README.md: e2cca9ddcca5690f36ce3e952a2814767acdad43 +README.zh.md: 321f7853c821f262a38b35530a4df8b2e18fff49 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index 47b582225e..e2cca9ddcc 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, enables the optional HMR row before client-module discovery so the first development graph contains its reload receiver, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true. This bundle also owns the app command line: the `web-startup` row ([`src/startup.ts`](src/startup.ts)) parses `--host`, `--port`, `--dev`, and repeatable `--trusted-host` from `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)) and prints the app's `--help`. Every row it configures injects `webStartup`, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. `mode` and `lanAddresses` resolve on every boot because they describe the invocation. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle. +The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, enables the optional HMR row before client-module discovery so the first development graph contains its reload receiver, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true, after its Loader tree settles so a sibling failure cannot announce a dead app. This bundle also owns the app command line: the `web-startup` row ([`src/startup.ts`](src/startup.ts)) parses `--host`, `--port`, `--dev`, and repeatable `--trusted-host` from `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)) and prints the app's `--help`. Every row it configures injects `webStartup`, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. `mode` and `lanAddresses` resolve on every boot because they describe the invocation. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle. ## Model Experience diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 61e134f90e..321f7853c8 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,在客户端模块发现前启用可选的 HMR 行,确保首份开发模式图中包含它的重载接收端,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。本组合包还持有应用命令行:`web-startup` 行([`src/startup.ts`](src/startup.ts))从 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md))解析 `--host`、`--port`、`--dev` 以及可重复的 `--trusted-host`,并打印应用自己的 `--help`。它所配置的每一行都注入 `webStartup`,因此在参数解析完成之前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。`mode` 与 `lanAddresses` 在每次 boot 时解析,因为它们描述的是本次调用。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。 +dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,在客户端模块发现前启用可选的 HMR 行,确保首份开发模式图中包含它的重载接收端,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时等自身的 Loader 配置树结算后再打印 `dsh web:` URL 行,避免兄弟行失败时公告一个已失效的应用。本组合包还持有应用命令行:`web-startup` 行([`src/startup.ts`](src/startup.ts))从 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md))解析 `--host`、`--port`、`--dev` 以及可重复的 `--trusted-host`,并打印应用自己的 `--help`。它所配置的每一行都注入 `webStartup`,因此在参数解析完成之前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。`mode` 与 `lanAddresses` 在每次 boot 时解析,因为它们描述的是本次调用。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。 ## 模型体验 diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts index c93f6ec597..30edbdcb68 100644 --- a/packages/bundle/web-app/src/index.ts +++ b/packages/bundle/web-app/src/index.ts @@ -159,10 +159,10 @@ export async function apply(ctx: Context, config: Config): Promise { const port = ctx.httpServer.port console.log(`dsh web: ${localWebUrl(ctx)}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${String(port)})`}`) } - // A launcher tells this row when the whole concurrent composition is up; - // this row's own activation can precede a sibling failure. A hand-built - // tree falls back to Loader settlement, or prints at once without Loader. - const settled = ctx.get('appReady') ?? ctx.get('loader')?.await() + // This row's own activation can precede a sibling failure. The app owns + // readiness by waiting for its Loader tree, or prints at once in a + // hand-built context without Loader. + const settled = ctx.get('loader')?.await() if (settled === undefined) printUrl() else { void settled.then(() => { @@ -170,7 +170,7 @@ export async function apply(ctx: Context, config: Config): Promise { // SIGTERM); a URL line for a dead server would only mislead, and // reading the torn-down port would turn a clean shutdown into a crash. if (ctx.get('httpServer') !== undefined) printUrl() - // A failed boot is reported by the launcher; this row only stays quiet. + // Loader reports a failed boot; this row only stays quiet. }, () => {}) } } diff --git a/packages/bundle/web-app/tests/web-app.spec.ts b/packages/bundle/web-app/tests/web-app.spec.ts index 8c2539a20f..df34637cab 100644 --- a/packages/bundle/web-app/tests/web-app.spec.ts +++ b/packages/bundle/web-app/tests/web-app.spec.ts @@ -149,39 +149,7 @@ describe('web-app runtime glue', () => { await ctx.fiber.dispose() }) - it('waits for launcher readiness and stays quiet when the whole boot failed', async () => { - stageDist() - // Launcher readiness covers siblings that may still be mounting after - // this row itself has activated. - const ready = new Context() - ready.provide('httpServer', fakeHttpServer().server) - provideHmrRow(ready) - let announce: () => void - ready.provide('appReady', new Promise((resolve) => { announce = resolve })) - const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - await apply(ready, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) - await new Promise(resolve => setTimeout(resolve, 0)) - expect(log).not.toHaveBeenCalled() - announce!() - await new Promise(resolve => setTimeout(resolve, 0)) - expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567') - await ready.fiber.dispose() - - // A boot that failed announces nothing: the launcher reports it, and a URL - // for a process that is about to exit would only mislead. - log.mockClear() - const failed = new Context() - failed.provide('httpServer', fakeHttpServer().server) - const rejection = Promise.reject(new Error('boot failed')) - rejection.catch(() => {}) - failed.provide('appReady', rejection) - await apply(failed, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) - await new Promise(resolve => setTimeout(resolve, 0)) - expect(log).not.toHaveBeenCalled() - await failed.fiber.dispose() - }) - - it('defers the URL line until Loader settlement and drops it when the server is gone', async () => { + it('defers the URL line until Loader settlement and drops it on failure or teardown', async () => { stageDist() // Settlement path: the line waits for loader.await() so supervisors can // RPC immediately after observing it. @@ -199,6 +167,17 @@ describe('web-app runtime glue', () => { expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567') await settled.fiber.dispose() + // Failed path: Loader reports the sibling failure; the app prints no URL + // for a process that is about to exit. + log.mockClear() + const failed = new Context() + failed.provide('httpServer', fakeHttpServer().server) + provideHmrRow(failed, async () => { throw new Error('boot failed') }) + await apply(failed, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(log).not.toHaveBeenCalled() + await failed.fiber.dispose() + // Torn-down path: settlement resolves after the webserver is gone — no // line, no crash. log.mockClear() diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 3de7e389a8..79419be5ed 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -112,7 +112,6 @@ export const SERVICE_PAGE: Record = { export const SERVICE_WALK_EXEMPTIONS: Record = { agent: 'not a service: the DX accessor field on Agent.ctx (root accessor defaulting to undefined) — docs/subsystems/core.md owns the Agent handle', appExit: 'not a service: launcher-provided bounded process-exit callback — packages/boot/cmdline/README.md owns the launcher contract', - appReady: 'not a service: launcher-provided whole-composition readiness promise — packages/boot/cmdline/README.md owns the launcher contract', cmdlineArgs: 'not a service: launcher-provided immutable app argument accessor — packages/boot/cmdline/README.md owns the launcher contract', configuredAgentIdentities: 'not a service: launcher-provided boot-context value (ConfiguredAgentIdentities | undefined) — packages/core/agent-loop/README.md owns this launcher contract', launcherSessionQueryPath: 'not a service: launcher-provided boot-context value (string | undefined) — packages/session-query/session-query-sqlite/README.md owns this launcher contract', From 09e2d2ddc1e32fb6ee0189f7afb1db54168488d1 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 10 Aug 2026 21:49:11 +0800 Subject: [PATCH 15/19] refactor(cmdline): make command providers ordinary --- ...026-08-06-app-owned-command-line.i18n.yaml | 4 +- .../2026-08-06-app-owned-command-line.md | 14 +- .../2026-08-06-app-owned-command-line.zh.md | 14 +- apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 10 +- apps/cli/reference/README.zh.md | 10 +- apps/cli/src/args.ts | 10 +- apps/cli/src/profile-boot.ts | 30 +-- apps/cli/tests/args.spec.ts | 4 +- apps/cli/tests/built-bin.e2e.ts | 44 ++-- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 15 +- docs/config-catalog.zh.md | 15 +- docs/user/develop/basic/publish.i18n.yaml | 4 +- docs/user/develop/basic/publish.md | 11 +- docs/user/develop/basic/publish.zh.md | 11 +- docs/user/guide/config.i18n.yaml | 4 +- docs/user/guide/config.md | 2 +- docs/user/guide/config.zh.md | 2 +- packages/boot/cmdline/README.i18n.yaml | 4 +- packages/boot/cmdline/README.md | 24 +-- packages/boot/cmdline/README.zh.md | 24 +-- packages/boot/cmdline/package.json | 6 +- packages/boot/cmdline/src/index.ts | 169 +++------------- packages/boot/cmdline/src/invariant.ts | 12 +- packages/boot/cmdline/tests/cmdline.spec.ts | 121 +++-------- packages/bundle/headless/README.i18n.yaml | 4 +- packages/bundle/headless/README.md | 4 +- packages/bundle/headless/README.zh.md | 4 +- packages/bundle/headless/cordis.patch.yml | 12 +- packages/bundle/headless/src/index.ts | 2 +- packages/bundle/headless/src/startup.ts | 39 ++-- .../bundle/headless/tests/startup.spec.ts | 30 +-- packages/bundle/web-app/README.i18n.yaml | 4 +- packages/bundle/web-app/README.md | 2 +- packages/bundle/web-app/README.zh.md | 2 +- packages/bundle/web-app/cordis.patch.yml | 43 ++-- packages/bundle/web-app/src/index.ts | 58 ++++-- packages/bundle/web-app/src/startup.ts | 117 ++--------- packages/bundle/web-app/tests/startup.spec.ts | 191 ++++++------------ .../web-app/tests/trusted-hosts.spec.ts | 7 +- packages/bundle/web-app/tests/web-app.spec.ts | 36 ++-- pnpm-lock.yaml | 7 +- 46 files changed, 400 insertions(+), 742 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml index 895019d166..15abf1380b 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: 3dae1cb209ae9083ac6ab6616a140b6f129bc931 -2026-08-06-app-owned-command-line.zh.md: 81750eec8a78a811dd90454d88fa8ed1611dcce6 +2026-08-06-app-owned-command-line.md: 4a05cac5ed7f44fb55c2d4498bf28a43befdb073 +2026-08-06-app-owned-command-line.zh.md: 86a37f416d17c4615152b29d73f171803f24c4c3 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 3dae1cb209..4a05cac5ed 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,9 +12,9 @@ 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[]`) and `ctx.appExit`. An app consumes them from its **startup row**. Both the Loader row and plugin inject `cmdlineArgs`; the plugin calls `runStartup(ctx, service, program, plan)` with its own commander program and provides what it resolved as its own service. The Loader-row injection is also the launcher's discovery declaration; there is no parallel bundle-manifest field. Before boot, the launcher rejects nonempty app arguments with no active declaration and any composition with multiple active declarations. The rows the app configures inject that service and read it from their own config expressions (`port: !!js ctx.webStartup.port ?? 3080`), so a flag beats the value written beside it and nothing is written back into any row. +The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `provideCmdline(ctx, host)` before any entry mounts, providing `ctx.cmdlineArgs` (whose whole interface is `get(): readonly string[]`) and `ctx.appExit`. Any ordinary app plugin may inject `cmdlineArgs`, call `parseCmdline(ctx, program, plan)` with its own commander program, and provide the returned value as an app-owned service. Its Loader row carries no launcher marker or special kind, and the launcher does not inspect the composition for an owner. Multiple plugins may read the same immutable snapshot; a profile with no reader ignores its app arguments. Rows configured from a provider inject its service and read direct lazy config expressions (`port: !!js ctx.webStartup.port ?? 3080`), so a flag beats the value written beside it and nothing is written back into any row. -The boot mounts the composition once. Cordis holds each row until its injections are active; Loader then interpolates that row's `!!js` against the injection-ready plugin context immediately before activation. Include keeps nested row expressions raw until their target row reaches this point. `--help` provides no startup service, so dependent rows never activate, and a live patch reload interpolates again against the service that remains active, so a served port cannot be silently reset. +The 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` leaves the provider's service absent, so dependent rows never activate, and a live patch reload interpolates again against the service that remains active, so a served port cannot be silently reset. The shipped apps moved their flags into their bundles: `dsh-web-app` owns the Web family (and enables the `client-hmr` row it now ships disabled, for `--dev`), and `dsh-headless` owns the task positional and rejects a missing task as a usage error. `apps/cli/src/web.ts` is gone; `runProfile` no longer knows any flag-target row id. Out of tree, turtle-ui gained `--resume ` / `--session ` the same way, which is the design's real validation: an installed plugin added a flag with no launcher change. @@ -36,16 +36,16 @@ This leaves dependency ordering in Cordis activation and Loader interpolation, w - **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. - **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** (a custom composition combines Web and one-shot startup rows): two parsers cannot both own `-h`. A composition has exactly one command-line owner, so a layering bundle disables the startup row it absorbs and provides every startup service its retained rows inject. +- **The launcher running each bundle's command function before boot** (no Cordis involvement): strictly earlier than "boot, then help", but it makes app startup a second plugin protocol outside the tree. An ordinary `cmdlineArgs`-injected provider keeps one protocol and remains dumpable and patchable. +- **A launcher-enforced command-line owner**: rejecting zero or multiple readers would arbitrate overlaps such as `-h`, but `get()` is an immutable read and normal composition may need several app-owned services. Plugins therefore share the snapshot and own any parser interaction through ordinary composition. - **`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. - 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. +- `--help` leaves every row that depends on the provider's service pending and requests bounded exit; unrelated rows may activate concurrently before teardown. +- An app-owned service has no statically declared provider: a bundle shipping consumer rows without that provider 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 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. +- `--dump-config` never runs app command-line providers, 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 81750eec8a..86a37f416d 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,9 +12,9 @@ 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`。应用从自己的**启动行**消费它们。Loader 行与插件都注入 `cmdlineArgs`;插件以自己的 commander program 调用 `runStartup(ctx, service, program, plan)`,再把解析结果作为自己的服务提供出去。Loader 行的注入同时也是启动器的发现声明,不再需要一份平行的组合包 manifest 字段。启动器会在 boot 前拒绝没有活跃声明却带有非空应用参数的调用,也会拒绝存在多个活跃声明的组合。应用所配置的行注入该服务,再从各自的配置表达式中读取它(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。 +新包 `@deepseek-ai/dsh-cmdline` 持有这次交接。启动器在任何条目挂载之前调用 `provideCmdline(ctx, host)`,提供 `ctx.cmdlineArgs`(其全部接口就是 `get(): readonly string[]`)与 `ctx.appExit`。任何普通应用插件都可以注入 `cmdlineArgs`,用自己的 commander program 调用 `parseCmdline(ctx, program, plan)`,再把返回值作为应用自有服务提供出去。它的 Loader 行不携带启动器标记或特殊类型,启动器也不会检查组合中的所有者。多个插件可以读取同一份不可变快照;没有读取方的 profile 会忽略自己的应用参数。由提供方配置的行注入其服务,并在惰性配置表达式中直接读取它(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。 -boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Loader 随后在激活前一刻,基于已注入就绪的插件上下文插值该行的 `!!js`。Include 会保留嵌套的行表达式,直到目标行到达这一时点。`--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` 不再知道任何 flag 目标行 id。在树外,turtle-ui 以同样的方式获得了 `--resume ` / `--session `,这才是这套设计的真正验证:一个已安装的插件加上了一个 flag,启动器毫无改动。 @@ -36,16 +36,16 @@ boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Lo - **把解析出的取值写进每一行**(逐行一次配置更新,外加交还给启动器的一层 patch,使重载无法撤销它):它能工作,但这意味着 patch 在应用与启动器之间来回传递、同一件事有两套机制,以及一套其正确性依赖 Loader 重启内部细节的回收重建。维护者否决了这次往返;供各行读取的服务取代了这一切。 - **通过清空行的 `inject` 来放行**:孤立测试可行,在真实 web 树上失败,因为清空 `inject` 恰恰会丢失插件的静态注入。在插件真的去读它声明过的服务之前,这个失败是静默的。 - **由启动器管理两趟挂载**:它可以让提供方先于读取行激活,但会重复组合、把顺序变成启动器职责,还掩盖了 Loader 的缺陷——嵌套表达式在 include 上下文而不是目标行的注入上下文中求值。 -- **由启动器在 boot 之前运行每个组合包的启动函数**(完全不经过 cordis):严格早于「先 boot 再 help」,但这会让应用启动成为配置树之外的第二套插件协议。使用注入 `cmdlineArgs` 的启动行则只保留一套协议:它就是一个普通的行,可 dump、可 patch,叠加的组合包也能像禁用其他行那样禁用它。 -- **两个应用解析同一份 argv**(自定义组合同时包含 Web 与一次性启动行):两个解析器不可能同时持有 `-h`。一套组合有且只有一个命令行所有者,因此叠加的组合包要禁用被吸收的启动行,并提供保留下来的各行所注入的全部启动服务。 +- **由启动器在 boot 之前运行每个组合包的命令函数**(完全不经过 Cordis):严格早于「先 boot 再 help」,但这会让应用启动成为配置树之外的第二套插件协议。使用注入 `cmdlineArgs` 的普通提供方只保留一套协议,并且仍可 dump、可 patch。 +- **由启动器强制指定命令行所有者**:拒绝零个或多个读取方可以裁决 `-h` 等重叠项,但 `get()` 是不可变读取,普通组合也可能需要多个应用自有服务。因此插件共享该快照,并通过普通组合持有各自解析器的交互。 - **`instanceof CommanderError`**:树外插件会带来自己的一份 commander 副本,类身份因此不同,已经打印出来的 `--help` 会被重新抛成致命的加载失败。改为按结构识别 commander 的控制流错误。 ## 后果 - 应用的 flag、help 文本和用法错误与它们所配置的行放在一起;给已安装的插件加一个 flag 不需要改动启动器。 - 启动器仍会识别 headless runner 以管理一次性进程生命周期,并识别 telemetry 行以应用环境开关;两条路径都不解析应用参数。 -- `--help` 会让所有依赖启动服务的行保持待处理并请求有边界的退出;无关行可能在拆除前并发激活。没有注入 `cmdlineArgs` 的活跃行的 profile 会在挂载前拒绝非空应用参数,而不是忽略它们。 -- 启动服务没有静态声明的所有者:交付了读取行却缺少对应启动行的组合包会在结算时失败,报出指向该服务的待处理条目,而不是在加载时失败。 +- `--help` 会让所有依赖提供方服务的行保持待处理并请求有边界的退出;无关行可能在拆除前并发激活。 +- 应用自有服务没有静态声明的提供方:交付了消费行却缺少对应提供方的组合包会在结算时失败,报出指向该服务的待处理条目,而不是在加载时失败。 - 用户 patch 若整体替换某行的 `config`,会连同其中的表达式一起丢掉,该行上 flag 的优先级也随之消失。 - 启动器的 flag 必须写在应用参数之前;如果应用的第一个参数恰好等于 `web` 或 `plugin`,会选择对应的子命令;`-V`/`--version` 在该边界之前仍归启动器持有;而且启动器的解析器会消耗掉一个 `--`,因此要给应用传一个字面量 `--` 需要写成 `-- --`。 -- `--dump-config` 从不运行启动行,因此它在任何应用参数被解析之前打印组合,并拒绝携带应用参数的调用。 +- `--dump-config` 从不运行应用命令行提供方,因此它在任何应用参数被解析之前打印组合,并拒绝携带应用参数的调用。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 67dd461179..04026262c8 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/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 apps/cli/README.md -README.md: 96c6932a1faf6f5ce9b64e0390e2a4b3dcb55fc4 -README.zh.md: ea80985a8f6ea43bcea45dfe169937388ab25df0 +README.md: 4fae5338a89ce12c2620e123530acf883ae9efff +README.zh.md: a2d086b8ff12fb07f2446fc4162de09739bcdeab diff --git a/apps/cli/README.md b/apps/cli/README.md index 96c6932a1f..4fae5338a8 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -17,7 +17,7 @@ The invoking directory is the default workspace root. The `web` and `headless` p ## App arguments -The launcher parses only its own flags and hands everything after them to the booted profile, where that app's own startup row parses them ([`dsh-cmdline`](../../packages/boot/cmdline/README.md)). Launcher flags therefore come first, and the first token the launcher does not recognize starts the app's arguments: +The launcher parses only its own flags and hands everything after them to the booted profile, where any injected app plugin may parse the shared immutable snapshot ([`dsh-cmdline`](../../packages/boot/cmdline/README.md)). Launcher flags therefore come first, and the first token the launcher does not recognize starts the app's arguments: ```sh dsh --profile web --port 8080 # --port belongs to the web app diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index ea80985a8f..a2d086b8ff 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -17,7 +17,7 @@ ## 应用参数 -启动器只解析属于自己的 flag,并把其后的一切交给启动起来的 profile,由该应用自己的启动行解析([`dsh-cmdline`](../../packages/boot/cmdline/README.md))。因此启动器的 flag 必须写在前面,而启动器不认识的第一个 token 就是应用参数的起点: +启动器只解析属于自己的 flag,并把其后的一切交给启动起来的 profile,任何注入它的应用插件都可以解析这份共享的不可变快照([`dsh-cmdline`](../../packages/boot/cmdline/README.md))。因此启动器的 flag 必须写在前面,而启动器不认识的第一个 token 就是应用参数的起点: ```sh dsh --profile web --port 8080 # --port belongs to the web app diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index bce5b999d5..8f6db85427 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/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 apps/cli/reference/README.md -README.md: f28d77ccba7380426df2dd1769e33be0f7256d27 -README.zh.md: 3d8fbae31780c00f05384a1e4010fda2b6ce3246 +README.md: fd0647347312051a1814a5e3464b34032ae70dfc +README.zh.md: afe4b9ba5651e962288ffebbe7c095ad20bcc617 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index f28d77ccba..fd06473473 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -14,11 +14,11 @@ The `web` and `headless` profiles auto-initialize from shipped templates on firs ### App arguments -The launcher's flags come first and end at the first token it does not recognize; everything from there on is handed to the booted profile verbatim through `ctx.cmdlineArgs`, where that app's own startup row parses it ([`dsh-cmdline`](../../../packages/boot/cmdline/README.md)). `dsh --profile web --port 8080` therefore reaches the web app's `--port`, `dsh --profile web --help` prints that app's help and boots nothing, and `dsh --help` (no profile to hand it to) prints the launcher's own. `-V`/`--version` prints the launcher's version when it appears before the app-argument boundary. +The launcher's flags come first and end at the first token it does not recognize; everything from there on is handed to the booted profile verbatim through `ctx.cmdlineArgs`, where any injected app plugin may parse it ([`dsh-cmdline`](../../../packages/boot/cmdline/README.md)). `dsh --profile web --port 8080` therefore reaches the web app's `--port`, `dsh --profile web --help` prints that app's help and boots nothing, and `dsh --help` (no profile to hand it to) prints the launcher's own. `-V`/`--version` prints the launcher's version when it appears before the app-argument boundary. -A composition mounts once. A Loader row that injects `cmdlineArgs` parses this app's arguments and provides what it resolved as a service; each row configured from flags injects that service, and Loader waits for it before evaluating the row's config (`port: !!js ctx.webStartup.port ?? 3080`). A flag therefore beats the value written beside it. This precedence requires the row to retain that expression; a user patch that replaces the whole `config` with literals removes the runtime read. Help and rejected arguments request exit — nonzero for a rejection, 0 for help — without activating rows that depend on the startup service. A live `cordis.patch.yml` edit re-evaluates expressions against services that are still up, so it cannot reset a served port. +A composition mounts once. An ordinary plugin injects `cmdlineArgs`, parses this app's arguments, and provides what it resolved as a service; each row configured from flags injects that service, and Loader waits for it before evaluating the row's config (`port: !!js ctx.webStartup.port ?? 3080`). A flag therefore beats the value written beside it. This precedence requires the row to retain that expression; a user patch that replaces the whole `config` with literals removes the runtime read. Help and rejected arguments request exit — nonzero for a rejection, 0 for help — without activating rows that depend on the provider's service. A live `cordis.patch.yml` edit re-evaluates expressions against services that are still up, so it cannot reset a served port. -Launcher flags must come before app arguments, and the launcher's parser consumes one `--`: an app argument that must arrive as a literal `--` needs `-- --`. A first app argument equal to `web` or `plugin` selects that subcommand instead. A profile with no active row injecting `cmdlineArgs` accepts no app arguments; it rejects them before mounting any row instead of silently ignoring them. A composition with multiple active rows injecting `cmdlineArgs` is always rejected because two parsers cannot own the same command line. +Launcher flags must come before app arguments, and the launcher's parser consumes one `--`: an app argument that must arrive as a literal `--` needs `-- --`. A first app argument equal to `web` or `plugin` selects that subcommand instead. `ctx.cmdlineArgs.get()` is a shared immutable read: multiple plugins may parse the same snapshot, while a profile with no reader ignores its app arguments. The shipped apps own these command lines: @@ -36,7 +36,7 @@ dsh --profile web --dump-default-config dsh --profile web --patch ./extra.yml --dump-config ``` -`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr. A dump never runs an app's startup row, so it shows the composed tree before any app argument is resolved and rejects an invocation that carries app arguments. +`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr. A dump never runs app command-line providers, so it shows the composed tree before any app argument is resolved and rejects an invocation that carries app arguments. ## Plugin management @@ -52,7 +52,7 @@ Git-hosted plugins that ship sources build during install through their `prepare ## Web alias -`dsh web` is a hardcoded alias for `--profile web`; the flags after it belong to the web app, which owns them in its bundle's startup row. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` adds authorities over the composed fence configuration, and `--dev` switches the web-runtime row to development mode and enables the client-plugin HMR receiver the bundle ships disabled; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. +`dsh web` is a hardcoded alias for `--profile web`; the flags after it belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities), and `--dev` switches the web-runtime row to development mode and enables the client-plugin HMR receiver the bundle ships disabled; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. ```sh dsh web diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 3d8fbae317..afe4b9ba56 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -14,11 +14,11 @@ ### 应用参数 -启动器自己的 flag 写在最前面,并在它不认识的第一个 token 处结束;从那里开始的一切都通过 `ctx.cmdlineArgs` 原样交给启动起来的 profile,由该应用自己的启动行解析([`dsh-cmdline`](../../../packages/boot/cmdline/README.md))。因此 `dsh --profile web --port 8080` 到达的是 web 应用的 `--port`,`dsh --profile web --help` 打印的是该应用的 help 且什么也不启动,而 `dsh --help`(没有可以交付的 profile)打印的是启动器自己的 help。`-V`/`--version` 写在应用参数边界之前时会打印启动器的版本。 +启动器自己的 flag 写在最前面,并在它不认识的第一个 token 处结束;从那里开始的一切都通过 `ctx.cmdlineArgs` 原样交给启动起来的 profile,任何注入它的应用插件都可以解析([`dsh-cmdline`](../../../packages/boot/cmdline/README.md))。因此 `dsh --profile web --port 8080` 到达的是 web 应用的 `--port`,`dsh --profile web --help` 打印的是该应用的 help 且什么也不启动,而 `dsh --help`(没有可以交付的 profile)打印的是启动器自己的 help。`-V`/`--version` 写在应用参数边界之前时会打印启动器的版本。 -一套组合只挂载一次。注入 `cmdlineArgs` 的 Loader 行解析本应用的参数,并把结果作为服务提供出去;由 flag 配置的每一行都会注入该服务,Loader 会等服务激活后再求值该行配置(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值。该优先级要求配置行保留这一表达式;若用户 patch 用字面量替换整份 `config`,运行时读取也会随之消失。help 和被拒绝的参数会请求退出——拒绝时以非零状态,help 时以 0——且不会激活依赖启动服务的行。在线编辑 `cordis.patch.yml` 会针对仍然在线的服务重新求值表达式,因此不会重置已在服务的端口。 +一套组合只挂载一次。普通插件注入 `cmdlineArgs`、解析本应用参数,并把结果作为服务提供出去;由 flag 配置的每一行都会注入该服务,Loader 会等服务激活后再求值该行配置(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值。该优先级要求配置行保留这一表达式;若用户 patch 用字面量替换整份 `config`,运行时读取也会随之消失。help 和被拒绝的参数会请求退出——拒绝时以非零状态,help 时以 0——且不会激活依赖提供方服务的行。在线编辑 `cordis.patch.yml` 会针对仍然在线的服务重新求值表达式,因此不会重置已在服务的端口。 -启动器的 flag 必须写在应用参数之前,且启动器的解析器会消耗掉一个 `--`:必须以字面量 `--` 送达应用的参数需要写成 `-- --`。如果应用的第一个参数恰好等于 `web` 或 `plugin`,会选择对应的子命令。若 profile 中没有注入 `cmdlineArgs` 的活跃行,该 profile 不接受应用参数;启动器会在挂载任何行之前拒绝这些参数,而不是静默忽略。若组合中有多个注入 `cmdlineArgs` 的活跃行,启动器总会拒绝该组合,因为两个解析器不能共同持有同一条命令行。 +启动器的 flag 必须写在应用参数之前,且启动器的解析器会消耗掉一个 `--`:必须以字面量 `--` 送达应用的参数需要写成 `-- --`。如果应用的第一个参数恰好等于 `web` 或 `plugin`,会选择对应的子命令。`ctx.cmdlineArgs.get()` 是共享的不可变读取:多个插件可以解析同一份快照,没有读取方的 profile 则会忽略自己的应用参数。 随附的各应用持有这些命令行: @@ -36,7 +36,7 @@ dsh --profile web --dump-default-config dsh --profile web --patch ./extra.yml --dump-config ``` -`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。dump 从不运行应用的启动行,因此它展示的是任何应用参数被解析之前的组合配置树,并拒绝携带应用参数的调用。 +`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。dump 从不运行应用命令行提供方,因此它展示的是任何应用参数被解析之前的组合配置树,并拒绝携带应用参数的调用。 ## 插件管理 @@ -52,7 +52,7 @@ Git 托管、随附源码的插件在安装期间通过其 `prepare` 脚本构 ## Web 别名 -`dsh web` 是 `--profile web` 的硬编码别名;写在它之后的 flag 属于 web 应用,由该应用在其组合包的启动行中持有。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 在组合出的围栏配置之上追加 authority,`--dev` 把 web-runtime 行切换到开发模式并启用组合包以禁用状态交付的客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。 +`dsh web` 是 `--profile web` 的硬编码别名;写在它之后的 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),`--dev` 把 web-runtime 行切换到开发模式并启用组合包以禁用状态交付的客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。 ```sh dsh web diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 43d68a2c7c..27d92dcf66 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -3,8 +3,8 @@ * * The launcher parses only what it owns — which profile to boot, which extra * patch overlays to apply, and the config dumps — and hands **everything after - * its own flags** to the booted tree verbatim, where the booted app's startup row - * parses its own flag family and prints its own `--help` (see + * its own flags** to the booted tree verbatim, where injected app plugins parse + * their own flag families and print their own `--help` (see * `@deepseek-ai/dsh-cmdline`). Launcher flags therefore come first: the first * token this parser does not recognize starts the inner arguments, so * `dsh --profile tui --resume abc` boots the tui profile with `--resume abc`, @@ -23,7 +23,7 @@ interface ProfileInvocation { profile: string /** Extra patch-list overlays applied after the profile's own layer, in argv order. */ patches: string[] - /** Everything after the launcher's own flags, verbatim, for the booted app's startup row. */ + /** Everything after the launcher's own flags, verbatim, for injected app plugins. */ args: string[] } @@ -89,8 +89,8 @@ function resolveBoot(program: Command, profile: string, options: BootOptions, ar if (options.dumpConfig === true && options.dumpDefaultConfig === true) { program.error('error: --dump-config and --dump-default-config are mutually exclusive') } - // The dump is boot-free: it never runs the app's startup row, so it cannot - // show what that app's flags would decide, and printing a tree that differs + // The dump is boot-free: it never runs app command-line providers, so it + // cannot show what those flags would decide, and printing a tree that differs // from the same invocation's boot would mislead. if (args.length > 0) { program.error(`error: config dumps take no app arguments, got ${args.map(argument => JSON.stringify(argument)).join(' ')}`) diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 0266f8518f..f3c356f199 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -6,8 +6,8 @@ * live, and wire fail-loud plus bounded shutdown. * * App flags are not the launcher's business: the invocation's inner arguments - * are provided to the tree through `ctx.cmdlineArgs`, and the booted app's - * startup row parses them and configures its own rows. + * are provided to the tree through `ctx.cmdlineArgs`, where any injected app + * plugin may read the same immutable snapshot. * @module @deepseek-ai/dsh/profile-boot */ @@ -37,7 +37,7 @@ const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', im /** Harness-home directory holding locally authored agent presets. */ const USER_PRESET_DIR = '.agent-presets' import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' -import { hasCmdlineConsumer, provideCmdline } from '@deepseek-ai/dsh-cmdline' +import { provideCmdline } from '@deepseek-ai/dsh-cmdline' import type { HeadlessIo } from '@deepseek-ai/dsh-headless' import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts' import { resolveWindowsShellLayer } from './windows-shell.ts' @@ -206,12 +206,6 @@ function suppressSignalShutdownError(signal: AbortSignal, error: unknown): void */ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Context; shutdown: ProcessShutdown }> { const composed = composeProfile(options.profile, options.patchFiles) - if (!hasCmdlineConsumer([...composed.rows.values()]) && options.args.length > 0) { - throw new Error( - `${NAME}: profile ${JSON.stringify(options.profile)} takes no app arguments because no active row injects cmdlineArgs; ` - + `got ${options.args.map(argument => JSON.stringify(argument)).join(' ')}`, - ) - } // A one-shot composition ends by itself, which changes what a signal means // and makes watching the user's patch layer pointless. const headlessRow = composed.rows.get(HEADLESS_ROW_ID) @@ -225,8 +219,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con shutdown.interrupt(code) } // Signals own teardown throughout the startup window, not only after boot() - // settles: an inserted startup row can publish readiness before sibling rows - // finish mounting. + // settles: an inserted provider can publish before sibling rows finish mounting. process.on('SIGTERM', () => { interrupt(oneShot ? 143 : 0) }) process.on('SIGINT', () => { interrupt(130) }) installFailLoud(NAME, process, async () => { @@ -235,9 +228,9 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con const rootConfig = join(composed.profile.dir, PROFILE_ROOT_FILENAME) // Recomposition for the live user layers: bundle layers below, overlays - // above, so a user edit can never displace them. What an app's startup row - // resolved is not in here at all — it lives in that row's own service, which - // survives a recomposition. BOTH + // above, so a user edit can never displace them. Parsed app arguments are + // not in here at all — they live in app-provided services that survive a + // recomposition. BOTH // user files are re-read per generation (the HMR watcher hands us only the // changed file's patches, which one of the reads duplicates — fresh reads // keep the two watchers from stitching in each other's stale copy). @@ -263,9 +256,8 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con // Before any config-tree entry mounts, so plugins resolve all launch-time // environment values from the same immutable provenance snapshot. hostCtx.provide(DSH_ENVIRONMENT_KEY, options.environment) - // The command line is a launcher fact every app reads the same way: its - // own arguments, and the bounded exit its startup row requests after - // printing help or rejecting them. + // The command line and bounded exit request are launcher facts available + // to every app plugin that injects the argument snapshot. provideCmdline(hostCtx, { args: options.args, exit: code => void shutdown.shutdown(code), @@ -280,8 +272,8 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con } }) app.current = ctx - // A surface can dispose the whole tree while startup or this post-boot - // watcher setup is still in flight. Loader presence and fiber state own + // A surface can dispose the whole tree while boot or this post-boot watcher + // setup is still in flight. Loader presence and fiber state own // liveness; the local signal fact distinguishes that expected exit race // from a real HMR error. if (watchProfilePatch diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index ce0a5b2ba4..89a16921b2 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -87,8 +87,8 @@ describe('parseDshArgs', () => { expect(exitCode(['web', '--dump-config', '--dump-default-config'])).toBe(1) expect(exitCode(['web', '--dump-default-config', '--patch', 'w.yml'])).toBe(1) expect(exitCode(['web', '--patch='])).toBe(1) - // A dump never runs the app's startup row, so it cannot show what that - // app's own flags would decide; printing a tree that differs from the same + // A dump never runs app command-line providers, so it cannot show what + // those flags would decide; printing a tree that differs from the same // invocation's boot would mislead. expect(exitCode(['web', '--dump-config', '--port', '8080'])).toBe(1) expect(exitCode(['--profile', 'web', '--dump-config', '-h'])).toBe(1) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 53922c4f59..b9e99604c1 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -128,8 +128,8 @@ function createProfileLifecycleFixture(): ProfileLifecycleFixture { return { home, ready, settled, disposed, interrupt } } -function startProfileLifecycle(fixture: ProfileLifecycleFixture) { - return execa(process.execPath, [dshBin, '--profile', 'lifecycle'], { +function startProfileLifecycle(fixture: ProfileLifecycleFixture, args: readonly string[] = []) { + return execa(process.execPath, [dshBin, '--profile', 'lifecycle', ...args], { cwd: fixture.home, input: '', reject: false, @@ -203,9 +203,9 @@ interface StartupFixture { } /** - * A custom profile whose bundle owns a command line: a startup row whose - * `cmdlineArgs` injection identifies it to the launcher, and a row that reads - * what it resolved through a `!!js` config expression. Both plugin modules resolve + * A custom profile whose ordinary provider plugin injects `cmdlineArgs`, plus + * a row that reads its app-owned service through a `!!js` config expression. + * Both plugin modules resolve * `@deepseek-ai/dsh-cmdline` and `commander` through the profile module * fallback, exactly as an installed out-of-tree bundle does. */ @@ -219,12 +219,13 @@ function createStartupFixture(): StartupFixture { mkdirSync(bundleDir, { recursive: true }) writeFileSync(join(bundleDir, 'startup.mjs'), [ "import { Command } from 'commander'", - "import { runStartup } from '@deepseek-ai/dsh-cmdline'", + "import { parseCmdline } from '@deepseek-ai/dsh-cmdline'", "export const name = 'fixture-startup'", "export const inject = ['cmdlineArgs']", 'export function apply(ctx) {', " const program = new Command().name('fixture').option('--generation ', 'echoed generation')", - " return runStartup(ctx, 'fixtureStartup', program, parsed => ({ generation: parsed.opts().generation }))", + ' const values = parseCmdline(ctx, program, parsed => ({ generation: parsed.opts().generation }))', + ' if (values !== undefined) ctx.provide(\'fixtureStartup\', values)', '}', '', ].join('\n')) @@ -260,11 +261,10 @@ function createStartupFixture(): StartupFixture { ` name: ${pathToFileURL(join(bundleDir, 'waiting.mjs')).href}`, ' inject: [fixtureStartup]', ' config:', - // The flag the startup row resolved wins over the value written beside it. - " generation: !!js ctx.get('fixtureStartup')?.generation ?? 'bundle-default'", + // Lazy interpolation runs only after the provider's service is injected. + " generation: !!js ctx.fixtureStartup.generation ?? 'bundle-default'", ' - id: fixture-startup', ` name: ${pathToFileURL(join(bundleDir, 'startup.mjs')).href}`, - ' inject: [cmdlineArgs]', ' - id: reload-witness', ` name: ${pathToFileURL(join(bundleDir, 'witness.mjs')).href}`, '', @@ -466,21 +466,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } }, 30_000) - it('rejects arguments when no active row injects the profile command line', async () => { + it('lets a profile without a parser ignore app arguments and dispose on a startup-time signal', async () => { const fixture = createProfileLifecycleFixture() - try { - const result = await runBuiltBin(['--profile', 'lifecycle', '--help'], { DSH_HOME: fixture.home }) - expect(result.code).toBe(1) - expect(result.stderr).toContain('takes no app arguments because no active row injects cmdlineArgs') - expect(existsSync(fixture.ready)).toBe(false) - } finally { - rmSync(fixture.home, { recursive: true, force: true }) - } - }, 30_000) - - it('applies a custom profile bundle and disposes it on a startup-time signal', async () => { - const fixture = createProfileLifecycleFixture() - const child = startProfileLifecycle(fixture) + const child = startProfileLifecycle(fixture, ['--unclaimed']) try { await waitForFile(fixture.ready) requestProfileShutdown(child, fixture) @@ -551,8 +539,8 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', const child = startStartupProfile(fixture, ['--generation', 'flagged']) try { await waitForFile(fixture.ready) - // The waiting row started once, already carrying the flag value: the - // launcher never saw --generation, and the app resolved it first. + // The consumer started once, already carrying the flag value: the + // launcher never saw --generation, and the app provider resolved it first. expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged') requestProfileShutdown(child, fixture) expect((await child).exitCode).toBe(0) @@ -562,7 +550,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } }, 30_000) - it('starts a waiting row on its composed value when the invocation carries no app arguments', async () => { + it('starts a consumer on its composed value when the invocation carries no app arguments', async () => { const fixture = createStartupFixture() const child = startStartupProfile(fixture, []) try { @@ -577,7 +565,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', }, 30_000) it('keeps the app arguments across a user patch reload', async () => { - // A live edit recomposes every row while the startup service remains + // A live edit recomposes every row while the provider service remains // active, so each config expression reads the same invocation value (a // served port does not move back to its composed fallback). const fixture = createStartupFixture() diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 3761963233..3957d29f57 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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/config-catalog.md -config-catalog.md: 64e65e93b165ede2ac6c8fa399b9ce461938b939 -config-catalog.zh.md: 9f4a7ab071d68cfaf8ae3ea42458babfee67c9fd +config-catalog.md: 0813c9e1f1d761b69180bc919d0629e10c7661bc +config-catalog.zh.md: cda44f7904196fe2bf401fed2dc5b5e8b28ccf1c diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 64e65e93b1..0813c9e1f1 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -572,7 +572,7 @@ Source: [`packages/goal/goal/src/index.ts:116`](../packages/goal/goal/src/index. Requires: `agentDefaultModel` · `agents` · `sessions` ```ts config-catalog -/** Plugin config: the task resolved from this app's injected startup service. */ +/** Plugin config: the task resolved from this app's injected provider service. */ export interface Config { /** The prompt text for the single run. */ task: string @@ -2520,7 +2520,7 @@ Source: [`packages/web/web/src/index.ts:55`](../packages/web/web/src/index.ts) Requires: `httpServer` ```ts config-catalog -/** Plugin config: composed deployment settings plus per-invocation startup values. */ +/** Plugin config: composed deployment settings plus per-invocation command-line values. */ export interface Config { /** Whether this process mounted the client-plugin HMR receiver (`dsh web --dev`). */ mode: WebMode @@ -2533,20 +2533,15 @@ export interface Config { * orientation text would be false. */ surfaceContext: boolean - /** - * 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. - */ - lanAddresses: string[] + /** Explicit `--trusted-host` authorities from this invocation. */ + trustedHosts: string[] } /** Web runtime mode: production, or development when the client-plugin HMR receiver is active. */ export type WebMode = 'production' | 'development' ``` -Source: [`packages/bundle/web-app/src/index.ts:40`](../packages/bundle/web-app/src/index.ts) +Source: [`packages/bundle/web-app/src/index.ts:43`](../packages/bundle/web-app/src/index.ts) ## `@deepseek-ai/dsh-web-fetch-local` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 9f4a7ab071..cda44f7904 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -574,7 +574,7 @@ export interface Config { 需要:`agentDefaultModel` · `agents` · `sessions` ```ts config-catalog -/** Plugin config: the task resolved from this app's injected startup service. */ +/** Plugin config: the task resolved from this app's injected provider service. */ export interface Config { /** The prompt text for the single run. */ task: string @@ -2521,7 +2521,7 @@ export interface WebServiceConfig { 需要:`httpServer` ```ts config-catalog -/** Plugin config: composed deployment settings plus per-invocation startup values. */ +/** Plugin config: composed deployment settings plus per-invocation command-line values. */ export interface Config { /** Whether this process mounted the client-plugin HMR receiver (`dsh web --dev`). */ mode: WebMode @@ -2534,20 +2534,15 @@ export interface Config { * orientation text would be false. */ surfaceContext: boolean - /** - * 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. - */ - lanAddresses: string[] + /** Explicit `--trusted-host` authorities from this invocation. */ + trustedHosts: string[] } /** Web runtime mode: production, or development when the client-plugin HMR receiver is active. */ export type WebMode = 'production' | 'development' ``` -来源:[`packages/bundle/web-app/src/index.ts:40`](../packages/bundle/web-app/src/index.ts) +来源:[`packages/bundle/web-app/src/index.ts:43`](../packages/bundle/web-app/src/index.ts) ## `@deepseek-ai/dsh-web-fetch-local` diff --git a/docs/user/develop/basic/publish.i18n.yaml b/docs/user/develop/basic/publish.i18n.yaml index ebcb75a3ca..91dba947bb 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: 04520b0fb7d30c716e3c87761bd38f0c25824739 -publish.zh.md: 7b0e0141dc0522bb5ec356aa8cba1618c9517f09 +publish.md: 8437c7ea5c4cb966f9f3d68977949c78986ec9a5 +publish.zh.md: 4409dbfda060a84b316029d87ec985209cfa286a diff --git a/docs/user/develop/basic/publish.md b/docs/user/develop/basic/publish.md index 04520b0fb7..8437c7ea5c 100644 --- a/docs/user/develop/basic/publish.md +++ b/docs/user/develop/basic/publish.md @@ -99,7 +99,7 @@ The effective configuration composes over an empty root by applying, in order: 3. The home-level `$DSH_HOME/cordis.patch.yml` — machine-local preferences shared by every profile. 4. Each `--patch ` overlay, in argv order. -App arguments are not another patch layer. A surface bundle can resolve them through a startup service, described below. +App arguments are not another patch layer. A surface bundle can resolve them through an ordinary app-owned 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: @@ -110,17 +110,16 @@ In-box bundle names always resolve from the dsh installation itself; pnpm manage ## 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: +A bundle that defines a runnable app mounts an ordinary provider plugin: ```yaml - id: hello-startup name: 'dsh-hello-plugin/startup' - inject: [cmdlineArgs] ``` -That row calls `runStartup` from [`@deepseek-ai/dsh-cmdline`](../../../../packages/boot/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. +The plugin exports `inject = ['cmdlineArgs']`, calls `parseCmdline` from [`@deepseek-ai/dsh-cmdline`](../../../../packages/boot/cmdline/README.md) with its own commander program, and provides the returned value as its app-owned service. The launcher hands every plugin the same immutable arguments after launcher flags, so app-specific flags need no launcher change and multiple plugins may parse the snapshot. The Loader row needs no launcher marker or special kind. -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: +Rows configured by those arguments inject the provider's service and read it from their own `!!js` options, with the deployment value beside it as the fallback: ```yaml - id: my-app @@ -130,7 +129,7 @@ Rows configured by those arguments inject the startup service and read it from t port: !!js ctx.myAppStartup.port ?? 8080 ``` -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. +On `--help`, the provider publishes no service, so those rows never activate. Loader mounts the composition once, waits for each row's ordinary injections, and only then evaluates that row's `!!js` config against its injected context. ## Installing from GitHub: the build-script catch diff --git a/docs/user/develop/basic/publish.zh.md b/docs/user/develop/basic/publish.zh.md index 7b0e0141dc..4409dbfda0 100644 --- a/docs/user/develop/basic/publish.zh.md +++ b/docs/user/develop/basic/publish.zh.md @@ -99,7 +99,7 @@ dsh --profile demo 3. home 级的 `$DSH_HOME/cordis.patch.yml`——各 profile 共享的机器本地偏好。 4. 每个 `--patch ` overlay,按 argv 顺序。 -应用参数不是另一层 patch。表层组合包可以通过下文所述的启动服务解析它们。 +应用参数不是另一层 patch。表层组合包可以通过下文所述的普通应用自有服务解析它们。 后应用的层按行胜出,且 patch 会替换目标行的整个 `config` 值,而不是深度合并各键。这给组合包作者带来两个推论: @@ -110,17 +110,16 @@ dsh --profile demo ## 让表层组合包持有自己的命令行 -定义了可运行应用的组合包可以通过启动行本来就需要的注入来标记它: +定义了可运行应用的组合包挂载一个普通提供方插件: ```yaml - id: hello-startup name: 'dsh-hello-plugin/startup' - inject: [cmdlineArgs] ``` -该行使用应用自己的 commander program 调用 [`@deepseek-ai/dsh-cmdline`](../../../../packages/boot/cmdline/README.md) 中的 `runStartup`。启动器把自身 flag 之后的所有参数交给它,因此添加应用专属 flag 无需修改启动器。Loader 只挂载一次组合,等待每一行的注入,再基于其已注入的上下文求值该行的 `!!js` 配置。 +该插件导出 `inject = ['cmdlineArgs']`,使用自己的 commander program 调用 [`@deepseek-ai/dsh-cmdline`](../../../../packages/boot/cmdline/README.md) 中的 `parseCmdline`,再把返回值作为应用自有服务提供出去。启动器把自身 flag 之后的同一份不可变参数交给每个插件,因此添加应用专属 flag 无需修改启动器,多个插件也可以解析该快照。Loader 行不需要启动器标记或特殊类型。 -受这些参数配置的行会注入启动服务,并在自己的 `!!js` 选项中读取它,同时把部署取值写在旁边作为回退: +受这些参数配置的行会注入提供方服务,并在自己的 `!!js` 选项中读取它,同时把部署取值写在旁边作为回退: ```yaml - id: my-app @@ -130,7 +129,7 @@ dsh --profile demo port: !!js ctx.myAppStartup.port ?? 8080 ``` -遇到 `--help` 时,该服务不会被提供,所以这些行不会激活。叠加在另一应用之上的应用会禁用下层启动行,因为一套组合只能有一个命令行所有者。 +遇到 `--help` 时,提供方不会发布该服务,所以这些行不会激活。Loader 只挂载一次组合,等待每一行的普通注入,再基于其已注入的上下文求值该行的 `!!js` 配置。 ## 从 GitHub 安装:构建脚本这道坎 diff --git a/docs/user/guide/config.i18n.yaml b/docs/user/guide/config.i18n.yaml index 3010782d20..00a8867092 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: 7a8492d45fc3710958853b8498f90f5a19b62f4a -config.zh.md: 62a1693a13cdd4b2428085187b73b69d429cde6e +config.md: 1d3ad5ce36d4b360ba5156b6be28a6caae4a23d4 +config.zh.md: 7f8bfaa77066f2976a5667e3ac402814a7afdf96 diff --git a/docs/user/guide/config.md b/docs/user/guide/config.md index 7a8492d45f..1d3ad5ce36 100644 --- a/docs/user/guide/config.md +++ b/docs/user/guide/config.md @@ -51,7 +51,7 @@ 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`, 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. +`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: an ordinary bundle plugin injects `cmdlineArgs` and provides parsed values as its own service, while rows that inject and 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 `apiKey` and `baseURL`, so restate every key the row must retain. diff --git a/docs/user/guide/config.zh.md b/docs/user/guide/config.zh.md index 62a1693a13..7f8bfaa770 100644 --- a/docs/user/guide/config.zh.md +++ b/docs/user/guide/config.zh.md @@ -51,7 +51,7 @@ Cordis 会并发启动同级配置项。插件通过 `inject` 声明必需服务 ## 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` 表达式的行会让本次调用的取值优先。 +`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`,也会移除该行原有的 `apiKey` 与 `baseURL`;因此必须重新写出该行需要保留的全部键。 diff --git a/packages/boot/cmdline/README.i18n.yaml b/packages/boot/cmdline/README.i18n.yaml index f5e9413afd..6a032582cf 100644 --- a/packages/boot/cmdline/README.i18n.yaml +++ b/packages/boot/cmdline/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/cmdline/README.md -README.md: a1512ae3357f06cd4de6347ea5ec2197fea40a90 -README.zh.md: e27060db433e5c234febb28d6c120d75f82072cc +README.md: 98335e901bdf8fe33e14c1ad4c1a320d77f30c96 +README.zh.md: 28ea749943c60089c6b4725cb61e121f82aa0114 diff --git a/packages/boot/cmdline/README.md b/packages/boot/cmdline/README.md index a1512ae335..98335e901b 100644 --- a/packages/boot/cmdline/README.md +++ b/packages/boot/cmdline/README.md @@ -13,30 +13,28 @@ 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. -## Startup rows, and the service their app reads +## Ordinary providers and injected config -An app reads those arguments from its **startup row** — a Loader row and plugin that inject `cmdlineArgs` and calls `runStartup(ctx, service, program, plan)`: +Any app plugin may inject `cmdlineArgs`, parse it, and publish an ordinary app-owned service. `parseCmdline(ctx, program, plan)` is only a commander adapter; the caller owns the returned value and service: ```ts ignore export const name = 'web-startup' export const inject = ['cmdlineArgs'] export function apply(ctx: Context): void { - runStartup(ctx, 'webStartup', webCommand(), planWebStartup) + const values = parseCmdline(ctx, webCommand(), planWebStartup) + if (values !== undefined) ctx.provide('webStartup', values) } ``` -The Loader-row injection is also its discovery declaration, so no bundle manifest field is needed: +Its Loader row carries no launcher marker or special kind: ```yaml - id: web-startup name: '@deepseek-ai/dsh-web-app/startup' - inject: [cmdlineArgs] ``` -The launcher uses that injection only to reject arguments for a composition with no command-line owner, and to reject a composition with multiple owners. Loader mounts the composition once and holds each row until its own injections are active. - -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: +Every row configured from those values uses ordinary service injection and direct lazy config access: ```yaml - id: webserver @@ -47,9 +45,7 @@ Every row the app configures from flags then reads what the startup row resolved port: !!js ctx.webStartup.port ?? 3080 ``` -`runStartup` parses the arguments, asks `plan` for the values, and provides them as the service. On `--help`, `--version`, a parse error, or a `program.error(...)` from the plan, it writes commander's text and requests exit — nothing is provided, so rows that depend on the startup service never activate. - -`plan` receives the startup context and the options of every row that injects the service, for a value that has to take the composition into account. Include still holds nested expressions raw at this point, so a plan that needs a composed fallback can interpolate the relevant row config against the pre-service startup context; the `/api` fence authorities are the shipped example. +`parseCmdline` parses the immutable arguments and asks `plan` for the app-owned value. On `--help`, `--version`, a parse error, or a `program.error(...)` from the plan, it writes commander's text, requests exit, and returns `undefined`; the provider publishes nothing, so dependent rows never activate. ### How injection orders config @@ -57,9 +53,9 @@ Loader defers a row's `!!js` interpolation until that row's declared injections `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). The activation is an in-memory override: it does not rewrite the row's configured `disabled` value and survives config reapplication for that mounted entry. Loader applies the enabled row's ordinary injection ordering. -### One command line, one owner +### Shared immutable arguments -A composition has exactly one command-line owner. An app that layers over another one disables the underlying startup row and provides every startup service its retained rows inject. +`get()` does not consume or mutate argv. Multiple plugins can parse the same snapshot and independently provide services. The launcher does not inspect the composition for a command-line owner; a profile with no reader simply ignores its app arguments. 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. @@ -74,5 +70,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.** 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. +- **An app-owned service has no statically declared provider.** Consumer rows name it through ordinary injection; a bundle that omits its provider fails at settlement with 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 e27060db43..28ea749943 100644 --- a/packages/boot/cmdline/README.zh.md +++ b/packages/boot/cmdline/README.zh.md @@ -13,30 +13,28 @@ dsh 启动器交给它所引导应用的那条命令行。启动器只解析属 没有命令行的嵌入宿主提供空列表;这是诚实的答案,而不是缺失的值。 -## 启动行,以及它的应用所读取的服务 +## 普通提供方与注入配置 -应用从自己的**启动行**读取这些参数:这是一个在 Loader 行与插件中都注入 `cmdlineArgs`,并调用 `runStartup(ctx, service, program, plan)` 的插件: +任何应用插件都可以注入 `cmdlineArgs`、解析它,再发布一个普通的应用自有服务。`parseCmdline(ctx, program, plan)` 只适配 commander;返回值与服务都归调用方持有: ```ts ignore export const name = 'web-startup' export const inject = ['cmdlineArgs'] export function apply(ctx: Context): void { - runStartup(ctx, 'webStartup', webCommand(), planWebStartup) + const values = parseCmdline(ctx, webCommand(), planWebStartup) + if (values !== undefined) ctx.provide('webStartup', values) } ``` -Loader 行的注入同时也是发现声明,因此无需组合包 manifest 字段: +它的 Loader 行不携带启动器标记,也没有特殊类型: ```yaml - id: web-startup name: '@deepseek-ai/dsh-web-app/startup' - inject: [cmdlineArgs] ``` -启动器只用该注入来拒绝那些没有命令行所有者却带有应用参数的组合,以及拒绝存在多个所有者的组合。Loader 只挂载一次整套组合,并让每一行等待自身的注入激活。 - -应用用 flag 配置的每一行随后读取启动行解析出的取值,各自点名自己取用的键,以及回退时使用的值: +所有由这些取值配置的行都使用普通服务注入,并在惰性配置中直接访问该服务: ```yaml - id: webserver @@ -47,9 +45,7 @@ Loader 行的注入同时也是发现声明,因此无需组合包 manifest 字 port: !!js ctx.webStartup.port ?? 3080 ``` -`runStartup` 解析参数,向 `plan` 索取取值,并把它们作为服务提供出去。遇到 `--help`、`--version`、解析错误,或 `plan` 发出的 `program.error(...)` 时,它输出 commander 的文本并请求退出:什么也不会被提供,因此依赖启动服务的行不会激活。 - -`plan` 会收到启动上下文,以及所有注入该服务的行的选项,用于那些必须顾及组合本身的取值。此时 Include 仍保留着嵌套表达式的原始形态,因此需要组合回退值的 plan 可以基于服务提供前的启动上下文插值相关行配置;随附的例子是 `/api` 栅栏 authority。 +`parseCmdline` 解析不可变参数,再向 `plan` 索取应用自有取值。遇到 `--help`、`--version`、解析错误,或 `plan` 发出的 `program.error(...)` 时,它输出 commander 文本、请求退出并返回 `undefined`;提供方什么也不发布,因此依赖行不会激活。 ### 注入如何排列配置求值 @@ -57,9 +53,9 @@ Loader 会把一行的 `!!js` 插值推迟到该行声明的注入全部激活 `enableRow(ctx, id)` 打开某个组合包以禁用状态交付、只有部分调用才需要的行(`dsh web --dev` 及其客户端插件重载链路)。该激活是内存中的覆盖:它不会改写行所配置的 `disabled` 值,并会在已挂载条目的配置重新应用后继续生效。Loader 会对启用后的行应用普通的注入顺序。 -### 一条命令行,一个所有者 +### 共享不可变参数 -一套组合有且只有一个命令行所有者。叠加在另一应用之上的应用会禁用下层的启动行,并提供保留下来的各行所注入的全部启动服务。 +`get()` 不会消费或修改 argv。多个插件可以解析同一份快照,并分别提供服务。启动器不会检查组合中的命令行所有者;没有读取方的 profile 只会忽略自己的应用参数。 树外插件会带来自己的一份 commander 副本,因此 commander 的控制流错误按结构识别,而不是按类身份识别;按身份判断会把已经打印出来的 help 重新抛成致命的加载失败。 @@ -74,5 +70,5 @@ Loader 会把一行的 `!!js` 插值推迟到该行声明的注入全部激活 ## 已知限制与延期工作 - **启动器的 flag 必须写在应用参数之前**:切分按位置进行,启动器不认识的第一个 token 就是内层参数的起点,因此写在某个应用 flag 之后的 `--patch` 属于应用。启动器的解析器会消耗掉一个 `--`,因此必须以字面量 `--` 存活到应用的参数需要写成 `-- --`。 -- **启动服务没有声明所有者**:读取行点名它,由 `cmdlineArgs` 消费方提供它;这两种注入之间没有静态关联,因此交付了读取行却缺少对应启动行的组合包会在结算时失败(出现指向该服务的待处理条目),而不是在加载时失败。 +- **应用自有服务没有静态声明的提供方**:消费行通过普通注入点名它;缺少提供方的组合包会在结算时失败,由待处理条目点名该服务,而不是在加载时失败。 - **用户 patch 若整体替换某行的 `config`,会连同其中的表达式一起丢掉**:flag 胜过的是表达式旁写着的那个值,而不是用户用字面量替换掉表达式之后的结果;保留表达式才能保留 flag 的优先级。 diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json index 90af8c212f..28647bfc24 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 handoff between a dsh launcher and app bundles: cmdlineArgs exposes inner arguments, while injected startup rows parse them into app-owned runtime services", + "description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs", "version": "0.0.1", "private": true, "type": "module", @@ -24,9 +24,6 @@ "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", - "dependencies": { - "commander": "^15.0.0" - }, "peerDependencies": { "@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-invariants": "^0.0.1", @@ -35,6 +32,7 @@ "devDependencies": { "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "commander": "^15.0.0", "@deepseek-ai/dsh-invariants": "workspace:^", "@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 6806e0a273..1236b32cb5 100644 --- a/packages/boot/cmdline/src/index.ts +++ b/packages/boot/cmdline/src/index.ts @@ -7,22 +7,17 @@ * {@link CmdlineArgs} service, so an app owns its flag family, its `--help` * text, and its parse errors instead of the launcher knowing them. * - * 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.webStartup.port ?? 3080` — so a flag beats - * the value written beside it. Nothing is handed back to the launcher. - * - * 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. + * Any app plugin can inject `cmdlineArgs` and call {@link parseCmdline}. A + * provider may publish the parsed values as its own service, and ordinary rows + * can inject that service and read it from lazily resolved config — + * `port: !!js ctx.webStartup.port ?? 3080` — so a flag beats the value written + * beside it. No row has launcher-level command-line status. * @module @deepseek-ai/dsh-cmdline */ import type { Command } from 'commander' import type { Context } from 'cordis' -import type { Entry, EntryOptions } from '@cordisjs/plugin-loader' -// Empty type import carries the loader Context merge used to walk the tree. +// Empty type import carries the Loader Context merge used by enableRow. import type {} from '@cordisjs/plugin-loader' /** @@ -72,44 +67,11 @@ export interface CmdlineHost { * @param host - the invocation's arguments and its exit request. */ export function provideCmdline(ctx: Context, host: CmdlineHost): void { - const snapshot = [...host.args] + const snapshot: readonly string[] = Object.freeze([...host.args]) ctx.provide('cmdlineArgs', { get: () => snapshot }) ctx.provide('appExit', host.exit) } -/** - * 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. - * @throws when more than one active row claims the command line. - */ -export function hasCmdlineConsumer(rows: readonly EntryOptions[]): boolean { - const consumers: string[] = [] - const visit = (entries: readonly EntryOptions[], ancestorDisabled = false, prefix = ''): void => { - for (const row of entries) { - const id = prefix + row.id - // Loader group containers stay active when disabled, but their children - // inherit that disabled state. - const active = row.group === true || (!ancestorDisabled && row.disabled !== true) - if (active && waitsForAny(row.inject, ['cmdlineArgs'])) consumers.push(id) - if (row.group === true && Array.isArray(row.config)) { - visit(row.config, ancestorDisabled || row.disabled === true, `${id}:`) - } - } - } - visit(rows) - if (consumers.length > 1) { - const ids = consumers.map(id => JSON.stringify(id)).join(', ') - throw new Error(`dsh-cmdline: multiple active rows inject cmdlineArgs (${ids}); disable all but one startup row`) - } - return consumers.length === 1 -} - /** 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, @@ -117,58 +79,36 @@ export const internals: { stdout: { write(chunk: string): unknown }; stderr: { w } /** - * Resolve this invocation into the values the app's rows read. - * - * Runs after a successful parse, with the waiting rows' composed options - * available for a value that has to take the composition into account (the - * `/api` fence authorities are the shipped example). Call `program.error(...)` - * to reject the invocation with a usage message instead of throwing. + * Resolve parsed arguments into an app-owned value. Call + * `program.error(...)` 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. + * @param ctx - the plugin context that received the command line. + * @returns the value an ordinary provider plugin may publish. */ -export type StartupPlan = (program: Command, rows: readonly EntryOptions[], ctx: Context) => T +export type CmdlinePlan = (program: Command, ctx: Context) => T /** - * Run one app's startup: parse the invocation's inner arguments with the app's - * own commander program and provide the resolved values as `service`. The - * Loader then activates the rows that were waiting for the provided service. + * Parse the launcher's immutable argument snapshot with an app's commander + * program. The caller decides whether and how to publish the returned value; + * this helper has no Loader-row or service ownership semantics. * - * The rows read their values from the service, so nothing is written into - * 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, dependent rows stay pending, and - * `ctx.appExit` is requested. - * - * A custom app that layers over another one disables the underlying startup - * row and names every startup service its retained rows inject, because a - * composition has exactly one command-line owner. - * @param ctx - plugin context carrying `cmdlineArgs`, `appExit`, and the Loader. - * @param services - the service name, or names, this startup row provides. + * Help, version, and rejected arguments are terminal for the process: commander + * writes the text, the helper requests `ctx.appExit`, and it returns + * `undefined` so the caller publishes nothing. + * @param ctx - plugin context carrying `cmdlineArgs` and `appExit`. * @param program - the app's commander program, with its flags and description already declared. - * @param plan - this invocation's resolved values; omitted provides an empty value. - * @returns the resolved values, or `undefined` when the app asked to exit - * instead (help, version, or arguments it rejected). - * @throws when the launcher provided no command line, or when a named service - * is injected by no row. + * @param plan - this invocation's resolved value; omitted returns an empty object. + * @returns the resolved value, or `undefined` when the app asked to exit. + * @throws when the launcher did not provide the command line and exit request. */ -export function runStartup( +export function parseCmdline( ctx: Context, - services: string | readonly string[], program: Command, - plan: StartupPlan = (() => ({}) as T), + plan: CmdlinePlan = (() => ({}) as T), ): T | undefined { - const names = typeof services === 'string' ? [services] : services - // Read through the global service store, not the property proxy: these are - // optional host values, and a row that injects only `cmdlineArgs` may not - // read the others as declared injections. + // Read through the global service store, not the property proxy: appExit is + // an optional host value and the plugin only needs to inject cmdlineArgs. const args = ctx.get('cmdlineArgs') const exit = ctx.get('appExit') if (args === undefined || exit === undefined) { @@ -180,26 +120,17 @@ export function runStartup( writeOut: text => void internals.stdout.write(text), writeErr: text => void internals.stderr.write(text), }) - let values: T try { program.parse(args.get(), { from: 'user' }) - // An app can dispose the whole tree while this row is still parsing (an - // early SIGTERM, or another app exiting). There is then nothing to resolve - // 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), ctx) + return plan(program, 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 - // text through the output configured above. With no startup service, - // dependent rows remain pending and the app stays unstarted. + // text through the output configured above. if (!isCommanderError(error)) throw error exit(error.exitCode) return undefined } - for (const service of names) ctx.provide(service, values) - return values } /** @@ -224,34 +155,6 @@ export async function enableRow(ctx: Context, id: string): Promise { await entry.enableRuntime() } -/** - * The composed options of every row waiting on one of `services`, in tree order. - * @param ctx - plugin context whose Loader tree carries the rows. - * @param services - the startup service names. - * @returns the waiting rows' options. - * @throws when a service is injected by no row, which means the bundle patch - * and its startup plugin disagree. - */ -function waitingRows(ctx: Context, services: readonly string[]): EntryOptions[] { - for (const service of services) { - if (waitingEntries(ctx, [service]).length === 0) { - throw new Error(`${service}: no row injects this startup service — the bundle patch must set "inject: [${service}]" on every row this app configures`) - } - } - return waitingEntries(ctx, services).map(entry => entry.options) -} - -/** - * The Loader entries waiting on any of `services`. - * @param ctx - plugin context whose Loader tree carries the rows. - * @param services - the startup service names. - * @returns the waiting entries in tree order. - */ -function waitingEntries(ctx: Context, services: readonly string[]): Entry[] { - // Called only after runStartup established the tree is still live. - return [...ctx.loader.entries()].filter(entry => waitsForAny(entry.options.inject, services)) -} - /** * Whether a thrown value is commander's own control-flow error (help, version, * a parse error, or `program.error`). @@ -269,17 +172,3 @@ function isCommanderError(error: unknown): error is { code: string; exitCode: nu return typeof candidate.code === 'string' && candidate.code.startsWith('commander.') && typeof candidate.exitCode === 'number' } - -/** - * Whether a row's `inject` declaration names any of `services`. - * @param inject - the row's `inject` value: the array form, the object form, or absent. - * @param services - the startup service names. - * @returns true when the row waits for one of them. - */ -function waitsForAny(inject: EntryOptions['inject'], services: readonly string[]): boolean { - if (inject === undefined || inject === null) return false - // The array form lists service names; the object form maps each name to its - // intercept config. Both name the service as a key of the same shape. - const declared = Array.isArray(inject) ? inject : Object.keys(inject) - return services.some(service => declared.includes(service)) -} diff --git a/packages/boot/cmdline/src/invariant.ts b/packages/boot/cmdline/src/invariant.ts index f1ec75678f..cab932a8e6 100644 --- a/packages/boot/cmdline/src/invariant.ts +++ b/packages/boot/cmdline/src/invariant.ts @@ -14,14 +14,10 @@ export const name = 'cmdline-invariant' export const inject = ['invariants'] /** - * No runtime invariant: the owned relation is "no row is left waiting for a - * startup service", which is a property of the whole tree at Loader - * settlement, and the invariant service carries no settlement signal to - * evaluate it at. Observing it from the entry stream would fire while startup - * is still parsing, when every waiting row is legitimately still waiting. The - * launcher's post-settlement audit (`assertEntriesActivated`) already reports - * a startup service that was never provided as a pending entry naming it, and - * the built-bin e2e asserts the apps boot with flag values applied. + * No runtime invariant: `cmdlineArgs` is an immutable launcher fact that any + * number of ordinary plugins may read. App-owned providers and consumers use + * normal Cordis service injection, whose missing dependencies are already + * reported by Loader settlement. */ const install: InvariantInstaller = () => {} diff --git a/packages/boot/cmdline/tests/cmdline.spec.ts b/packages/boot/cmdline/tests/cmdline.spec.ts index 61a5d75197..9faf6ed7d5 100644 --- a/packages/boot/cmdline/tests/cmdline.spec.ts +++ b/packages/boot/cmdline/tests/cmdline.spec.ts @@ -15,7 +15,7 @@ import Include from '@cordisjs/plugin-include' import type { PatchOptions } from '@cordisjs/plugin-include' import { afterEach, describe, expect, it } from 'vitest' import { - enableRow, hasCmdlineConsumer, internals, provideCmdline, runStartup, type StartupPlan, + enableRow, internals, parseCmdline, provideCmdline, type CmdlinePlan, } from '../src/index.ts' /** Every value one boot of the fixture tree observed. */ @@ -26,7 +26,7 @@ interface Observed { out: string } -/** A booted fixture tree: what it observed, and its root for direct startup calls. */ +/** A booted fixture tree: what it observed, and its root for direct parser calls. */ interface Fixture { observed: Observed ctx: Context @@ -46,7 +46,7 @@ function demoCommand(): Command { } /** The fixture app's plan: the resolved values its rows read. */ -const demoPlan: StartupPlan<{ port?: number }> = (program) => { +const demoPlan: CmdlinePlan<{ port?: number }> = (program) => { const port = program.opts<{ port?: string }>().port if (port === undefined) return {} if (!/^\d+$/.test(port)) program.error(`error: --port must be a number, got ${JSON.stringify(port)}`) @@ -65,8 +65,8 @@ const expression = (source: string): unknown => ({ __jsExpr: source }) */ async function bootFixture( args: string[], - plan: StartupPlan = demoPlan, - options: { objectInject?: boolean; withoutStartup?: boolean } = {}, + plan: CmdlinePlan = demoPlan, + options: { objectInject?: boolean; withoutProvider?: boolean } = {}, ): Promise { const dir = mkdtempSync(join(tmpdir(), 'dsh-cmdline-')) const observed: Observed = { exits: [], out: '' } @@ -81,28 +81,31 @@ export function apply(ctx, config) { globalThis.__observed.started = config } writeFileSync(join(dir, 'startup.mjs'), ` export const name = 'demo-startup' export const inject = ['cmdlineArgs'] -export function apply(ctx) { return globalThis.__runStartup(ctx) } +export function apply(ctx) { return globalThis.__provideDemoArgs(ctx) } `) writeFileSync(join(dir, 'cordis.yml'), '[]\n') const observing = { write: (chunk: string) => { observed.out += chunk; return true } } internals.stdout = observing internals.stderr = observing - const globals = globalThis as unknown as { __observed: Observed; __runStartup: (ctx: Context) => void } + const globals = globalThis as unknown as { __observed: Observed; __provideDemoArgs: (ctx: Context) => void } globals.__observed = observed - globals.__runStartup = (ctx: Context) => { runStartup(ctx, 'demoStartup', demoCommand(), plan) } + globals.__provideDemoArgs = (ctx: Context) => { + const values = parseCmdline(ctx, demoCommand(), plan) + if (values !== undefined) ctx.provide('demoStartup', values) + } // The composition, exactly as a profile delivers one: include patches whose // config carries `!!js` expressions. const composition: PatchOptions[] = [{ insert: [ - ...options.withoutStartup === true + ...options.withoutProvider === true ? [] - : [{ id: 'demo-startup', name: pathToFileURL(join(dir, 'startup.mjs')).href, inject: ['cmdlineArgs'] }], + : [{ id: 'demo-startup', name: pathToFileURL(join(dir, 'startup.mjs')).href }], { id: 'reader', name: pathToFileURL(join(dir, 'reader.mjs')).href, inject: options.objectInject === true ? { demoStartup: { required: true } } : ['demoStartup'], - config: { port: expression('ctx.demoStartup?.port ?? 3080') }, + config: { port: expression('ctx.demoStartup.port ?? 3080') }, }, ], }] @@ -119,55 +122,7 @@ export function apply(ctx) { return globalThis.__runStartup(ctx) } 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) - expect(() => hasCmdlineConsumer([ - { id: 'web-startup', name: 'web-startup', inject: ['cmdlineArgs'] }, - { id: 'tui-startup', name: 'tui-startup', inject: ['cmdlineArgs'] }, - ])).toThrow('multiple active rows inject cmdlineArgs ("web-startup", "tui-startup")') - }) - - it('walks nested groups and ignores consumers disabled by an ancestor', () => { - expect(hasCmdlineConsumer([{ - id: 'app', - name: 'cordis:group', - group: true, - config: [{ id: 'startup', name: 'startup', inject: ['cmdlineArgs'] }], - }])).toBe(true) - expect(hasCmdlineConsumer([{ - id: 'app', - name: 'cordis:group', - group: true, - disabled: true, - config: [{ id: 'startup', name: 'startup', inject: ['cmdlineArgs'] }], - }])).toBe(false) - expect(() => hasCmdlineConsumer([ - { - id: 'first', - name: 'cordis:group', - group: true, - config: [{ id: 'startup', name: 'startup', inject: ['cmdlineArgs'] }], - }, - { - id: 'second', - name: 'cordis:group', - group: true, - config: [{ id: 'startup', name: 'startup', inject: ['cmdlineArgs'] }], - }, - ])).toThrow('multiple active rows inject cmdlineArgs ("first:startup", "second:startup")') - }) -}) - -describe('runStartup', () => { +describe('parseCmdline', () => { it('lets a row read the flag value the app resolved', async () => { const { observed } = await bootFixture(['--port', '8080']) expect(observed.started).toEqual({ port: 8080 }) @@ -179,7 +134,7 @@ describe('runStartup', () => { expect(observed.started).toEqual({ port: 3080 }) }) - it('recognizes the Loader object form of a startup-service injection', async () => { + it('recognizes the Loader object form of a provider-service injection', async () => { const { observed } = await bootFixture(['--port', '8080'], demoPlan, { objectInject: true }) expect(observed.started).toEqual({ port: 8080 }) }) @@ -199,32 +154,24 @@ describe('runStartup', () => { }) it('rethrows a plan failure that is not commander asking to exit', async () => { - const { ctx } = await bootFixture([], demoPlan, { withoutStartup: true }) - const plan: StartupPlan = () => { throw new Error('plan exploded') } - expect(() => { runStartup(ctx, 'demoStartup', demoCommand(), plan) }).toThrow('plan exploded') + const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true }) + const plan: CmdlinePlan = () => { throw new Error('plan exploded') } + expect(() => { parseCmdline(ctx, demoCommand(), plan) }).toThrow('plan exploded') }) it('rethrows a thrown value that is not an object at all', async () => { - const { ctx } = await bootFixture([], demoPlan, { withoutStartup: true }) - const plan: StartupPlan = () => { + const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true }) + const plan: CmdlinePlan = () => { const thrown: unknown = 'plan threw a string' throw thrown } - expect(() => { runStartup(ctx, 'demoStartup', demoCommand(), plan) }).toThrow('plan threw a string') + expect(() => { parseCmdline(ctx, demoCommand(), plan) }).toThrow('plan threw a string') }) - it('fails loud when no row injects the service the app provides', async () => { - // 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, { withoutStartup: true }) - expect(() => { runStartup(ctx, 'absentStartup', demoCommand()) }) - .toThrow('absentStartup: no row injects this startup service') - }) - - it('accepts a service-name list when the app declares no plan', async () => { - const { ctx } = await bootFixture([], demoPlan, { withoutStartup: true }) - runStartup(ctx, ['demoStartup'], demoCommand()) - expect(ctx.get('demoStartup')).toEqual({}) + it('returns values without inspecting Loader rows or owning a service', async () => { + const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true }) + expect(parseCmdline(ctx, demoCommand())).toEqual({}) + expect(ctx.get('demoStartup')).toBeUndefined() }) }) @@ -302,19 +249,17 @@ describe('provideCmdline', () => { expect(ctx.cmdlineArgs?.get()).toEqual(['--resume', 'abc']) }) - it('fails loud when a startup row runs without the launcher values', () => { + it('fails loud when a parser runs without the launcher values', () => { const ctx = new Context() - expect(() => { runStartup(ctx, 'demoStartup', demoCommand()) }) + expect(() => { parseCmdline(ctx, demoCommand()) }) .toThrow('the launcher must provide ctx.cmdlineArgs and ctx.appExit') }) - 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[] = [] + it('lets multiple parsers read the same immutable snapshot', () => { const ctx = new Context() - provideCmdline(ctx, { args: [], exit: code => void exits.push(code) }) - expect(() => { runStartup(ctx, 'demoStartup', demoCommand()) }).not.toThrow() - expect(exits).toEqual([]) + provideCmdline(ctx, { args: ['--port', '8080'], exit: () => {} }) + expect(parseCmdline(ctx, demoCommand(), demoPlan)).toEqual({ port: 8080 }) + expect(parseCmdline(ctx, demoCommand(), demoPlan)).toEqual({ port: 8080 }) + expect(Object.isFrozen(ctx.cmdlineArgs?.get())).toBe(true) }) }) diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index f64ead7a50..4377802ae4 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/packages/bundle/headless/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/headless/README.md -README.md: 459d0f32788265d43e75922067da3c03d054f444 -README.zh.md: e3ca9d13512e3a13ac71c5cda650fca958609062 +README.md: 31a4894dbb191d2244371ca7272339e96e253053 +README.zh.md: 6e8d28f10071fbab175c4f14f1aaa9618b8f598a diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index 459d0f3278..31a4894dbb 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides directly over [`dsh-base`](../base/README.md): it supplies the coding persona and tool mode, disables HMR, mounts Code Mode's worker as a core execution capability, and inserts this package's `headless-runner` plugin (config `{task}`, resolved from the injected startup service). It mounts no Host, HTTP server, Web runtime, or browser plugin. +The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides directly over [`dsh-base`](../base/README.md): it supplies the coding persona and tool mode, disables HMR, mounts Code Mode's worker as a core execution capability, and inserts this package's `headless-runner` plugin (config `{task}`, resolved from the injected `headlessStartup` provider). It mounts no Host, HTTP server, Web runtime, or browser plugin. -After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. It flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.headlessIo` host hook (final `turn/end` completed → 0, otherwise 1). A terminal `error` reason also writes its code and message to stderr; successful runs keep stderr empty. The process opens no listening port. The task text is this app's command line: the `headless-startup` row ([`src/startup.ts`](src/startup.ts)) reads it as the positional argument of `dsh --profile headless "task"` from `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), prints the app's `--help`, and rejects an invocation with no task instead of letting the runner's schema fail. +After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. It flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.headlessIo` host hook (final `turn/end` completed → 0, otherwise 1). A terminal `error` reason also writes its code and message to stderr; successful runs keep stderr empty. The process opens no listening port. The task text is this app's command line: the ordinary `headless-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), reads the positional argument of `dsh --profile headless "task"`, prints the app's `--help`, and provides `headlessStartup`; the runner injects that service and reads its task from lazy config. A missing or whitespace-only task is rejected before the runner activates. ## Model Experience diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index e3ca9d1351..6e8d28f100 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.md) 之上:提供编码 persona 和工具模式、禁用 HMR(热模块替换)、将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`,从注入的启动服务解析)。它不挂载任何 Host、HTTP server、Web runtime 或浏览器插件。 +dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.md) 之上:提供编码 persona 和工具模式、禁用 HMR(热模块替换)、将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`,从注入的 `headlessStartup` 提供方解析)。它不挂载任何 Host、HTTP server、Web runtime 或浏览器插件。 -Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md),通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),将任务作为普通用户消息提交,并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout,再经启动器提供的 `ctx.headlessIo` 宿主钩子请求退出(最终 `turn/end` 完成 → 0,否则为 1)。最终 reason 为 `error` 时,还会将持久化的 code 与 message 写入 stderr;成功运行时 stderr 保持为空。进程不会打开监听端口。任务文本就是这个应用的命令行:`headless-startup` 行([`src/startup.ts`](src/startup.ts))从 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md))把它读作 `dsh --profile headless "task"` 的位置参数,打印应用自己的 `--help`,并拒绝没有任务的调用,而不是让 runner 的 schema 失败。 +Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md),通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),将任务作为普通用户消息提交,并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout,再经启动器提供的 `ctx.headlessIo` 宿主钩子请求退出(最终 `turn/end` 完成 → 0,否则为 1)。最终 reason 为 `error` 时,还会将持久化的 code 与 message 写入 stderr;成功运行时 stderr 保持为空。进程不会打开监听端口。任务文本就是这个应用的命令行:普通 `headless-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),读取 `dsh --profile headless "task"` 的位置参数、打印应用自己的 `--help`,并提供 `headlessStartup`;runner 注入该服务,再从惰性配置中读取任务。缺失或只有空白的任务会在 runner 激活前被拒绝。 ## 模型体验 diff --git a/packages/bundle/headless/cordis.patch.yml b/packages/bundle/headless/cordis.patch.yml index 2c03de11af..8d2e1ff4ab 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 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. +# It mounts no Host, HTTP server, Web runtime, or browser plugin. An ordinary +# provider plugin injects `cmdlineArgs`, parses the task positional +# (`dsh --profile headless ""`) and this app's --help, then the direct +# driver creates an Agent through the core registry and prints its durable result. - id: system-prompt config: @@ -25,10 +25,8 @@ - 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. + # Reads its task from the ordinary headlessStartup provider. - id: headless-runner name: '@deepseek-ai/dsh-headless' inject: [headlessStartup] diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 92dc62d7ff..284f948aac 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -25,7 +25,7 @@ export const name = 'headless-runner' /** Core services required before the one-shot turn can start. */ export const inject = ['agentDefaultModel', 'agents', 'sessions'] -/** Plugin config: the task resolved from this app's injected startup service. */ +/** Plugin config: the task resolved from this app's injected provider service. */ export interface Config { /** The prompt text for the single run. */ task: string diff --git a/packages/bundle/headless/src/startup.ts b/packages/bundle/headless/src/startup.ts index e960c63554..7999bb09d2 100644 --- a/packages/bundle/headless/src/startup.ts +++ b/packages/bundle/headless/src/startup.ts @@ -1,16 +1,13 @@ /** - * The one-shot app's startup row: it owns the `dsh --profile headless` command - * line — the task text is this command's positional argument — and its - * `--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. + * The one-shot app's command-line provider: it parses the task positional and + * `--help`, then publishes {@link HEADLESS_STARTUP_SERVICE}. The runner is an + * ordinary consumer whose lazy config waits for that service. * @module @deepseek-ai/dsh-headless/startup */ import { Command } from 'commander' import type { Context } from 'cordis' -import type { EntryOptions } from '@cordisjs/plugin-loader' -import { runStartup } from '@deepseek-ai/dsh-cmdline' +import { parseCmdline } from '@deepseek-ai/dsh-cmdline' /** Stable Cordis plugin name. */ export const name = 'headless-startup' @@ -18,12 +15,9 @@ export const name = 'headless-startup' /** Services required before the task can be resolved. */ export const inject = ['cmdlineArgs'] -/** The service this row provides and the one-shot runner row reads. */ +/** Service provided by this plugin and injected by the one-shot runner. */ export const HEADLESS_STARTUP_SERVICE = 'headlessStartup' -/** The row that runs the task, and the only reason this app has a command line. */ -const RUNNER_ROW_ID = 'headless-runner' - /** What the runner row reads from {@link HEADLESS_STARTUP_SERVICE}. */ export interface HeadlessStartupValues { /** The task text this invocation asked for. */ @@ -47,27 +41,22 @@ Examples: } /** - * Turn the parsed command line into the runner row's task. + * Turn the parsed command line into the runner's task. * @param program - the parsed headless command. - * @param rows - the rows waiting on this app's service, in tree order. - * @returns the runner row's service value. - * @throws when the composition has no runner row, which would otherwise accept - * a task and silently run nothing. + * @returns the runner's service value. */ -function planHeadlessStartup(program: Command, rows: readonly EntryOptions[]): HeadlessStartupValues { +function planHeadlessStartup(program: Command): HeadlessStartupValues { const task = program.args.join(' ') - if (task === '') program.error('error: a task is required, for example: dsh --profile headless "run the tests"') - if (!rows.some(row => row.id === RUNNER_ROW_ID)) { - throw new Error(`headless-startup: the composition has no waiting "${RUNNER_ROW_ID}" row to run the task`) - } + if (task.trim() === '') program.error('error: a task is required, for example: dsh --profile headless "run the tests"') return { task } } /** - * Resolve the task for the runner waiting on `headlessStartup`. - * @param ctx - plugin context carrying the command line and the Loader. - * @returns nothing once the runner is started, or once `--help` or a missing task requested exit. + * Parse and provide the one-shot task as an ordinary Cordis service. + * @param ctx - plugin context carrying the command line. + * @returns nothing once the task is provided, or when the command requested exit. */ export function apply(ctx: Context): void { - runStartup(ctx, HEADLESS_STARTUP_SERVICE, headlessCommand(), planHeadlessStartup) + const values = parseCmdline(ctx, headlessCommand(), planHeadlessStartup) + if (values !== undefined) ctx.provide(HEADLESS_STARTUP_SERVICE, values) } diff --git a/packages/bundle/headless/tests/startup.spec.ts b/packages/bundle/headless/tests/startup.spec.ts index 51c6708c8f..dce5387b84 100644 --- a/packages/bundle/headless/tests/startup.spec.ts +++ b/packages/bundle/headless/tests/startup.spec.ts @@ -1,7 +1,7 @@ /** - * The one-shot app's startup row over a real Loader tree: the task positional - * becomes the injected runner config, while help and usage errors leave the - * runner pending. + * The one-shot app's ordinary command-line provider over a real Loader tree: + * the task becomes injected runner config, while help and usage errors leave + * the consumer pending. */ import { mkdtempSync, writeFileSync } from 'node:fs' @@ -31,15 +31,11 @@ afterEach(async () => { }) /** - * Mount the real startup row over a runner stand-in. + * Mount the real provider over a runner stand-in. * @param args - the invocation's inner arguments. - * @param options - fixture knobs for invalid compositions. - * @returns the resolved startup value and observed runner/process effects. + * @returns the resolved service value and observed runner/process effects. */ -async function bootStartup( - args: string[], - options: { withoutRunner?: boolean } = {}, -): Promise<{ task: HeadlessStartupValues | undefined; observed: Observed }> { +async function bootStartup(args: string[]): Promise<{ task: HeadlessStartupValues | undefined; observed: Observed }> { const dir = mkdtempSync(join(tmpdir(), 'dsh-headless-startup-')) const observed: Observed = { exits: [], out: '' } writeFileSync(join(dir, 'row.mjs'), 'export function apply(_ctx, config) { globalThis.__headlessStartupObserved.runnerConfig = config }\n') @@ -52,14 +48,13 @@ export const apply = ctx => globalThis.__headlessStartupApply(ctx) `) const rowUrl = pathToFileURL(join(dir, 'row.mjs')).href writeFileSync(join(dir, 'cordis.yml'), [ - options.withoutRunner === true ? '- id: displaced-runner' : '- id: headless-runner', + '- id: headless-runner', ` name: ${rowUrl}`, ` inject: [${HEADLESS_STARTUP_SERVICE}]`, ' config:', ' task: !!js ctx.headlessStartup.task', '- id: headless-startup', ` name: ${pathToFileURL(join(dir, 'startup.mjs')).href}`, - ' inject: [cmdlineArgs]', '', ].join('\n')) const observing = { write: (chunk: string) => { observed.out += chunk; return true } } @@ -85,7 +80,7 @@ export const apply = ctx => globalThis.__headlessStartupApply(ctx) } } -describe('headless startup', () => { +describe('headless command-line provider', () => { it('joins the task positional into the runner config', async () => { const { task, observed } = await bootStartup(['run', 'the', 'tests']) expect(task).toEqual({ task: 'run the tests' }) @@ -93,8 +88,8 @@ describe('headless startup', () => { expect(observed.exits).toEqual([]) }) - it('rejects an invocation with no task and leaves the runner pending', async () => { - const { task, observed } = await bootStartup([]) + it.each([{ args: [] }, { args: [' '] }])('rejects an invocation with no non-whitespace task ($args)', async ({ args }) => { + const { task, observed } = await bootStartup(args) expect(observed.out).toContain('a task is required') expect(task).toBeUndefined() expect(observed.runnerConfig).toBeUndefined() @@ -108,9 +103,4 @@ describe('headless startup', () => { expect(observed.runnerConfig).toBeUndefined() expect(observed.exits).toEqual([0]) }) - - it('fails when the composition has no runner row', async () => { - await expect(bootStartup(['task'], { withoutRunner: true })) - .rejects.toThrow('the composition has no waiting "headless-runner" row') - }) }) diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index 7f12af35c8..9040315855 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md -README.md: e2cca9ddcca5690f36ce3e952a2814767acdad43 -README.zh.md: 321f7853c821f262a38b35530a4df8b2e18fff49 +README.md: b6fa225f5e0a0a079605a4fb9064b79287ab21cd +README.zh.md: 68af959719b9bd146eddd143aa9d98400e65fa68 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index e2cca9ddcc..b6fa225f5e 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, enables the optional HMR row before client-module discovery so the first development graph contains its reload receiver, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true, after its Loader tree settles so a sibling failure cannot announce a dead app. This bundle also owns the app command line: the `web-startup` row ([`src/startup.ts`](src/startup.ts)) parses `--host`, `--port`, `--dev`, and repeatable `--trusted-host` from `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)) and prints the app's `--help`. Every row it configures injects `webStartup`, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. `mode` and `lanAddresses` resolve on every boot because they describe the invocation. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle. +The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, trustedHosts}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, enables the optional HMR row before client-module discovery so the first development graph contains its reload receiver, samples bind-dependent LAN trust once, provides it as `webRuntime` to the browser-trust fence and client roster, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true, after its Loader tree settles so a sibling failure cannot announce a dead app. This bundle also owns the app command line: the ordinary `web-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), parses `--host`, `--port`, `--dev`, repeatable `--trusted-host`, and the app's `--help`, then provides `webStartup`. Flag-configured rows inject that service and read it directly from lazy config, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle. ## Model Experience diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 321f7853c8..68af959719 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,在客户端模块发现前启用可选的 HMR 行,确保首份开发模式图中包含它的重载接收端,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时等自身的 Loader 配置树结算后再打印 `dsh web:` URL 行,避免兄弟行失败时公告一个已失效的应用。本组合包还持有应用命令行:`web-startup` 行([`src/startup.ts`](src/startup.ts))从 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md))解析 `--host`、`--port`、`--dev` 以及可重复的 `--trusted-host`,并打印应用自己的 `--help`。它所配置的每一行都注入 `webStartup`,因此在参数解析完成之前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。`mode` 与 `lanAddresses` 在每次 boot 时解析,因为它们描述的是本次调用。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。 +dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, trustedHosts}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,在客户端模块发现前启用可选的 HMR 行,确保首份开发模式图中包含它的重载接收端,只采样一次依赖 bind 的 LAN 信任信息并将其作为 `webRuntime` 提供给浏览器信任栅栏和客户端名录,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时等自身的 Loader 配置树结算后再打印 `dsh web:` URL 行,避免兄弟行失败时公告一个已失效的应用。本组合包还持有应用命令行:普通 `web-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),解析 `--host`、`--port`、`--dev`、可重复的 `--trusted-host` 以及应用自己的 `--help`,再提供 `webStartup`。由 flag 配置的行会注入该服务,并在惰性配置中直接读取它,因此参数解析完成前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。 ## 模型体验 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 37b19e7645..199c46a33b 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -5,12 +5,11 @@ # A patch replaces the targeted row's whole `config`, so each row below # restates every key it owns. # -# 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 injects `cmdlineArgs` -# and provides `webStartup`; Loader delays dependent-row config interpolation -# until that service is active. `dsh --profile web --help` provides no service, -# so the server rows never activate. +# The web-startup plugin injects `cmdlineArgs` and provides `webStartup` as an +# ordinary Cordis service. Rows configured from flags inject that service, so +# Loader resolves their expressions only after it exists. The web runtime then +# provides bind-dependent `webRuntime` values to the trust fence and client +# roster. `dsh --profile web --help` provides neither service, so no server binds. # ── surface-specific values the base deliberately omits ───────────────────── @@ -81,39 +80,39 @@ - id: api-gateway name: '@deepseek-ai/dsh-host-apiproxy' - # This app's command-line startup row. It owns the web flag family and its - # --help, and provides webStartup to the rows that inject it. + # Ordinary provider for the parsed Web flags. Its plugin-level injection + # waits for cmdlineArgs; no launcher metadata or special row kind is needed. - id: web-startup name: '@deepseek-ai/dsh-web-app/startup' - inject: [cmdlineArgs] # ── layer 2: transport/service ────────────────────────────────────────────── # Plain route-registration carrier; host and port come from the app's - # startup service, with these deployment fallbacks. The dist is served by + # webStartup provider, with these deployment fallbacks. The dist is served by # the web-runtime row below through the fallback seat. - id: webserver name: '@deepseek-ai/dsh-host-webserver' inject: [webStartup] config: - host: !!js ctx.get('webStartup')?.host ?? '127.0.0.1' - port: !!js ctx.get('webStartup')?.port ?? 3080 + host: !!js ctx.webStartup.host ?? '127.0.0.1' + port: !!js ctx.webStartup.port ?? 3080 # Web glue owned by this bundle: resolves the built frontend dist (an # assembly fact of dsh-web-app, never user config), mounts the # frontend-static fallback owner, registers the web-surface prompt - # section and bash runtime variables, and prints the URL line. `dsh web` - # patches mode/lanAddresses over these defaults. A complete agent-preset + # section and bash runtime variables, and prints the URL line. The webStartup + # provider supplies invocation-only values; after the server binds, this row + # samples LAN trust once and provides `webRuntime`. A complete agent-preset # persona suppresses the prompt section for that agent while retaining # these host-owned shell variables. - id: web-runtime name: '@deepseek-ai/dsh-web-app' inject: [webStartup] config: - mode: !!js ctx.get('webStartup')?.mode ?? 'production' + mode: !!js ctx.webStartup.mode printUrl: true surfaceContext: true - lanAddresses: !!js ctx.get('webStartup')?.lanAddresses ?? [] + trustedHosts: !!js ctx.webStartup.trustedHosts # The client-plugin reload chain: a dev-only row this bundle ships off, # which the runtime row turns on before client discovery. It is a row rather @@ -133,18 +132,18 @@ # (adopted as a plugin entry by the kernel, never fetched). - id: modules name: '@deepseek-ai/dsh-client-modules' - inject: [webClientRoster] + inject: [webRuntime] # Owns both ends of the web transport: node half binds the gateway to the # webserver under /api; browser half is the fetch/SSE client. - id: connection name: '@deepseek-ai/dsh-client-connection' - inject: [webStartup] + inject: [webRuntime] config: - # The LAN literals an all-interfaces bind derived plus the - # --trusted-host extras. A deployment that configures its own fence - # authorities adds them to this list. - trustedHosts: !!js ctx.get('webStartup')?.trustedHosts ?? [] + # LAN literals derived from the active bind plus --trusted-host extras. + # A deployment adding authorities keeps this expression and concatenates + # its literals, for example: ['app.internal', ...ctx.webRuntime.trustedHosts]. + trustedHosts: !!js ctx.webRuntime.trustedHosts - id: api-remotes name: '@deepseek-ai/dsh-api-remotes' diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts index 30edbdcb68..0a8ec7ffbb 100644 --- a/packages/bundle/web-app/src/index.ts +++ b/packages/bundle/web-app/src/index.ts @@ -11,6 +11,7 @@ */ import { createRequire } from 'node:module' +import { networkInterfaces } from 'node:os' import { fileURLToPath } from 'node:url' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' @@ -28,7 +29,9 @@ export const name = 'web-app' /** This dsh installation's root, from either this package's source or built entry. */ const SOURCE_ROOT = fileURLToPath(new URL('../../../..', import.meta.url)) const HMR_ROW_ID = 'client-hmr' -const CLIENT_ROSTER_SERVICE = 'webClientRoster' + +/** Runtime service that releases Web rows after bind-dependent values resolve. */ +const WEB_RUNTIME_SERVICE = 'webRuntime' /** Services required before the web runtime can mount. */ export const inject = ['httpServer'] @@ -36,7 +39,7 @@ 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: composed deployment settings plus per-invocation startup values. */ +/** Plugin config: composed deployment settings plus per-invocation command-line values. */ export interface Config { /** Whether this process mounted the client-plugin HMR receiver (`dsh web --dev`). */ mode: WebMode @@ -49,22 +52,25 @@ export interface Config { * orientation text would be false. */ surfaceContext: boolean - /** - * 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. - */ - lanAddresses: string[] + /** Explicit `--trusted-host` authorities from this invocation. */ + trustedHosts: string[] } export const Config: z = z.object({ mode: z.union([z.const('production'), z.const('development')]).default('production'), printUrl: z.boolean().default(true), surfaceContext: z.boolean().default(true), - lanAddresses: z.array(String).default([]), + trustedHosts: z.array(String).default([]), }) +/** Bind-dependent Web values shared by the trust fence and URL display. */ +export interface WebRuntimeValues { + /** LAN IPv4 literals sampled once when the server binds all interfaces. */ + lanAddresses: string[] + /** LAN literals followed by explicit invocation authorities. */ + trustedHosts: string[] +} + /** Environment variable naming the canonical local URL of this Web GUI. */ const DSH_WEB_URL = 'DSH_WEB_URL' as const /** Environment variable naming the Web runtime mode. */ @@ -73,6 +79,27 @@ const DSH_WEB_MODE = 'DSH_WEB_MODE' as const // Display-only mirror of the webserver schema's loopback host: the address the // local URL always prints. Not a source of truth — the schema is. const LOOPBACK_HOST = '127.0.0.1' +/** The webserver schema's all-interfaces bind literal. */ +const ALL_INTERFACES_HOST = '0.0.0.0' + +/** + * Resolve one LAN-trust snapshot from the active server bind. + * + * Derived entries are port-less IP literals: DNS rebinding needs an + * attacker-controlled name, while an IP-literal Host is safe on any port and + * an OS-assigned port is unknowable before bind. + * @param bindHost - the active webserver bind host. + * @param extra - explicit `--trusted-host` values, in argument order. + * @returns the LAN display addresses and invocation-derived fence authorities. + */ +export function resolveLanTrust(bindHost: string, extra: readonly string[]): WebRuntimeValues { + const lanAddresses = bindHost === ALL_INTERFACES_HOST + ? Object.values(networkInterfaces()).flat() + .filter((iface): iface is NonNullable => iface !== undefined && iface.family === 'IPv4' && !iface.internal) + .map(iface => iface.address) + : [] + return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] } +} /** Model-visible orientation and acceptance boundary for sessions created through `dsh web`. */ function webSurfacePrompt(webUrl: string, mode: WebMode): string { @@ -124,8 +151,10 @@ export async function apply(ctx: Context, config: Config): Promise { // fiber. Otherwise its first browser graph omits the reload receiver, which // cannot use that receiver to discover itself later. if (config.mode === 'development') await enableRow(ctx, HMR_ROW_ID) - // Release client discovery only after the optional row has a pending fiber. - ctx.provide(CLIENT_ROSTER_SERVICE, true) + const runtime = resolveLanTrust(ctx.httpServer.host, config.trustedHosts) + // Release dependent rows only after the optional row has a pending fiber and + // bind-dependent trust has been sampled once. + ctx.provide(WEB_RUNTIME_SERVICE, runtime) ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() }) if (config.surfaceContext) { ctx.inject(['systemPrompt'], (promptCtx) => { @@ -153,9 +182,8 @@ export async function apply(ctx: Context, config: Config): Promise { // 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 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] + // Reuse the exact LAN snapshot provided to the /api trust fence. + const lanCandidate = runtime.lanAddresses[0] const port = ctx.httpServer.port console.log(`dsh web: ${localWebUrl(ctx)}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${String(port)})`}`) } diff --git a/packages/bundle/web-app/src/startup.ts b/packages/bundle/web-app/src/startup.ts index a276e7e032..78d24f553a 100644 --- a/packages/bundle/web-app/src/startup.ts +++ b/packages/bundle/web-app/src/startup.ts @@ -1,18 +1,14 @@ /** - * The web app's startup row: it owns the `dsh --profile web` flag family - * (`--host`, `--port`, `--dev`, `--trusted-host`) and its `--help` text, - * turns those flags into changes on the rows that inject - * {@link WEB_STARTUP_SERVICE}, and then provides it. Until it does, no - * flag-configured web row starts, so `dsh --profile web --help` prints this - * command's help and the server never binds. + * The web app's command-line provider: it parses the `dsh --profile web` flag + * family (`--host`, `--port`, `--dev`, `--trusted-host`) and its `--help` + * text, then provides the immutable values as {@link WEB_STARTUP_SERVICE}. + * Ordinary rows inject that service before reading it from lazy config. * @module @deepseek-ai/dsh-web-app/startup */ -import { networkInterfaces } from 'node:os' import { Command } from 'commander' import type { Context } from 'cordis' -import { interpolate, type EntryOptions } from '@cordisjs/plugin-loader' -import { runStartup } from '@deepseek-ai/dsh-cmdline' +import { parseCmdline } from '@deepseek-ai/dsh-cmdline' /** Stable Cordis plugin name. */ export const name = 'web-startup' @@ -20,11 +16,7 @@ export const name = 'web-startup' /** Services required before the flags can be resolved. */ export const inject = ['cmdlineArgs'] -/** - * The service this row provides and every flag-configured web row reads. The - * rows are listed in this bundle's `cordis.patch.yml`, where each names the key - * it takes from here and the value it falls back to. - */ +/** Service provided by this ordinary plugin and injected by flag-configured rows. */ export const WEB_STARTUP_SERVICE = 'webStartup' /** What the web rows read from {@link WEB_STARTUP_SERVICE}. */ @@ -35,63 +27,8 @@ export interface WebStartupValues { port?: number /** Web runtime mode; `--dev` selects development, which also mounts the client-plugin reload chain. */ mode: 'production' | 'development' - /** - * The `/api` fence authorities for this invocation: the LAN literals an - * all-interfaces bind derived, plus the `--trusted-host` extras, over what - * the composition already configured. - */ + /** Explicit `--trusted-host` authorities, in argument order. */ trustedHosts: string[] - /** The LAN literals the fence was configured with, for display. */ - lanAddresses: string[] -} - -/** The webserver schema's all-interfaces bind literal: only this bind derives LAN authorities. */ -const ALL_INTERFACES_HOST = '0.0.0.0' - -/** - * Read the deployment trust list before its row mounts and validates config. - * @param config - the connection row's config resolved before `webStartup` exists. - * @returns its configured authorities, or an empty list when absent. - * @throws when the file-backed config is not an array of strings. - */ -function configuredTrustedHosts(config: unknown): string[] { - const value = (config as { trustedHosts?: unknown } | undefined)?.trustedHosts - if (value === undefined) return [] - const valid = Array.isArray(value) && value.every((entry: unknown) => typeof entry === 'string') - if (!valid) throw new Error('web-startup: the composed connection trustedHosts must be an array of strings') - return value -} - -/** - * Non-internal IPv4 interface addresses of this machine — the IP-literal - * authorities an all-interfaces bind is reachable by on the LAN. - * @returns the addresses in interface order (possibly empty). - */ -function lanIPv4Addresses(): string[] { - return Object.values(networkInterfaces()).flat() - .filter((iface): iface is NonNullable => iface !== undefined && iface.family === 'IPv4' && !iface.internal) - .map(iface => iface.address) -} - -/** - * One LAN-trust resolution for one invocation, sampled exactly once: the - * machine's LAN IP literals when the effective bind is all-interfaces, and the - * `trustedHosts` value built from them plus the explicit extras. The single - * sample is deliberate — display must advertise only addresses the fence was - * configured with, so the `web-runtime` row receives this same snapshot. - * Derived entries are port-less IP literals: DNS rebinding needs an - * attacker-controlled name, so an IP-literal Host is safe on any port, and the - * bound port may be OS-assigned, unknowable before the server binds. - * @param bindHost - the effective webserver bind host (the flag, else the composed row value). - * @param extra - `--trusted-host` values, in argv order. - * @returns the sampled LAN addresses and the connection row's `trustedHosts` value (each possibly empty). - */ -export function resolveLanTrust( - bindHost: string | undefined, - extra: readonly string[], -): { lanAddresses: string[]; trustedHosts: string[] } { - const lanAddresses = bindHost === ALL_INTERFACES_HOST ? lanIPv4Addresses() : [] - return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] } } /** The web flag family, as commander parsed it. */ @@ -125,51 +62,29 @@ Examples: } /** - * Turn the parsed flags into the values the web rows read. + * Turn the parsed flags into the value injected rows read. * @param program - the parsed web command. - * @param rows - the waiting rows' composed options, in tree order. - * @param ctx - the startup context used to resolve composed fallbacks before `webStartup` exists. - * @returns the web rows' service value. + * @returns this invocation's immutable Web options. */ -function planWebStartup(program: Command, rows: readonly EntryOptions[], ctx: Context): WebStartupValues { +function planWebStartup(program: Command): WebStartupValues { const options = program.opts() if (options.port !== undefined && !/^\d+$/.test(options.port)) { program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`) } - const row = (id: string): EntryOptions => { - const found = rows.find(candidate => candidate.id === id) - if (found === undefined) throw new Error(`web-startup: the web composition has no waiting ${JSON.stringify(id)} row to configure`) - return found - } - const webserver = row('webserver') - row('web-runtime') - const connection = row('connection') - // Include preserves nested row expressions until their own injections are - // active. Resolve just the composed fields this startup plan needs against - // the pre-service context, where their `ctx.get('webStartup')` fallback wins. - const webserverConfig = interpolate(ctx, webserver.config) as { host?: string } | undefined - const connectionConfig: unknown = interpolate(ctx, connection.config) - const bindHost = options.host ?? webserverConfig?.host - const sampled = resolveLanTrust(bindHost, options.trustedHost ?? []) - // Preserve deployment authorities when invocation-derived LAN literals or - // explicit extras become the runtime value read by the connection row. - const composedTrusted = configuredTrustedHosts(connectionConfig) return { ...options.host !== undefined && { host: options.host }, ...options.port !== undefined && { port: Number(options.port) }, - // mode and lanAddresses describe this invocation, never the deployment, so - // they are resolved on every boot. mode: options.dev === true ? 'development' : 'production', - trustedHosts: [...composedTrusted, ...sampled.trustedHosts], - lanAddresses: sampled.lanAddresses, + trustedHosts: options.trustedHost ?? [], } } /** - * Resolve the web flag family for rows waiting on `webStartup`. - * @param ctx - plugin context carrying the command line and the Loader. - * @returns nothing once the values are provided, or once `--help` requested exit. + * Parse and provide the Web invocation as an ordinary Cordis service. + * @param ctx - plugin context carrying the command line. + * @returns nothing once values are provided, or when the command requested exit. */ export function apply(ctx: Context): void { - runStartup(ctx, WEB_STARTUP_SERVICE, webCommand(), planWebStartup) + const values = parseCmdline(ctx, webCommand(), planWebStartup) + if (values !== undefined) ctx.provide(WEB_STARTUP_SERVICE, values) } diff --git a/packages/bundle/web-app/tests/startup.spec.ts b/packages/bundle/web-app/tests/startup.spec.ts index f58c771aaa..91ec241889 100644 --- a/packages/bundle/web-app/tests/startup.spec.ts +++ b/packages/bundle/web-app/tests/startup.spec.ts @@ -1,8 +1,6 @@ /** - * 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. + * The Web command-line provider over a real Loader tree: its ordinary service + * releases a consumer whose config reads `ctx.webStartup` directly. */ import { mkdtempSync, writeFileSync } from 'node:fs' @@ -13,21 +11,14 @@ import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' import { internals, provideCmdline } from '@deepseek-ai/dsh-cmdline' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { apply, WEB_STARTUP_SERVICE, type WebStartupValues } from '../src/startup.ts' -vi.mock('node:os', async importOriginal => ({ - ...await importOriginal(), - networkInterfaces: () => ({ - lo0: [{ family: 'IPv4', internal: true, address: '127.0.0.1' }], - en0: [{ family: 'IPv4', internal: false, address: '192.168.1.5' }], - }), -})) - -/** What one boot of the fixture tree observed. */ +/** What one fixture boot observed. */ interface Observed { exits: number[] out: string + readerConfig?: unknown } const disposers: (() => Promise)[] = [] @@ -39,67 +30,48 @@ afterEach(async () => { }) /** - * Mount the real startup row over a stand-in for the `webserver` row whose - * composed bind it reads before the dependent rows activate. + * Mount the real provider and a consumer using injection-ordered config. * @param args - the invocation's inner arguments. - * @param webserverConfig - the composed `webserver` row config, or `null` to omit the row. - * @param trustedHosts - authorities the composed connection row already carries, or `null` when it carries none. - * @returns the resolved service value (absent when the app requested exit) and what the boot observed. + * @returns the service value and observed consumer/process effects. */ -async function bootStartup( - args: string[], - webserverConfig: Record | null = { host: '127.0.0.1', port: 3080 }, - trustedHosts: unknown = [], -): Promise<{ values: WebStartupValues | undefined; observed: Observed; ctx: Context }> { +async function bootProvider(args: string[]): Promise<{ + values: WebStartupValues | undefined + observed: Observed +}> { const dir = mkdtempSync(join(tmpdir(), 'dsh-web-startup-')) const observed: Observed = { exits: [], out: '' } - writeFileSync(join(dir, 'row.mjs'), 'export function apply() {}\n') - // 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, 'startup.mjs'), ` + writeFileSync(join(dir, 'reader.mjs'), ` +export function apply(_ctx, config) { globalThis.__webStartupObserved.readerConfig = config } +`) + // Node imports the fixture row outside Vite's source resolver, so delegate + // to the source-plane plugin already imported by this test. + writeFileSync(join(dir, 'provider.mjs'), ` export const name = 'web-startup' export const inject = ['cmdlineArgs'] export const apply = ctx => globalThis.__webStartupApply(ctx) `) - const rowUrl = pathToFileURL(join(dir, 'row.mjs')).href writeFileSync(join(dir, 'cordis.yml'), [ - ...webserverConfig === null ? [] : [ - '- id: webserver', - ` name: ${rowUrl}`, - ` inject: [${WEB_STARTUP_SERVICE}]`, - ' disabled: true', - ' config:', - ...Object.entries(webserverConfig).map(([key, value]) => ` ${key}: !!js ctx.get('${WEB_STARTUP_SERVICE}')?.${key} ?? ${JSON.stringify(value)}`), - ], - '- id: connection', - ` name: ${rowUrl}`, + '- id: reader', + ` name: ${pathToFileURL(join(dir, 'reader.mjs')).href}`, ` inject: [${WEB_STARTUP_SERVICE}]`, - ' disabled: true', - ...trustedHosts === null ? [] : [ - ' config:', - ` trustedHosts: !!js ctx.get('${WEB_STARTUP_SERVICE}')?.trustedHosts ?? ${JSON.stringify(trustedHosts)}`, - ], - // A second reader keeps the composition honest when the webserver row is - // the one under test: the service must still have someone to serve. - '- id: web-runtime', - ` name: ${rowUrl}`, - ` inject: [${WEB_STARTUP_SERVICE}]`, - ' disabled: true', - // The reload chain this bundle ships off, which `--dev` turns on. - '- id: client-hmr', - ` name: ${rowUrl}`, - ` inject: [${WEB_STARTUP_SERVICE}]`, - ' disabled: true', - '- id: web-startup', - ` name: ${pathToFileURL(join(dir, 'startup.mjs')).href}`, - ' inject: [cmdlineArgs]', + ' config:', + " host: !!js ctx.webStartup.host ?? '127.0.0.1'", + ' port: !!js ctx.webStartup.port ?? 3080', + ' mode: !!js ctx.webStartup.mode', + ' trustedHosts: !!js ctx.webStartup.trustedHosts', + '- id: provider', + ` name: ${pathToFileURL(join(dir, 'provider.mjs')).href}`, '', ].join('\n')) const observing = { write: (chunk: string) => { observed.out += chunk; return true } } internals.stdout = observing internals.stderr = observing - ;(globalThis as unknown as { __webStartupApply: typeof apply }).__webStartupApply = apply + const globals = globalThis as unknown as { + __webStartupApply: typeof apply + __webStartupObserved: Observed + } + globals.__webStartupApply = apply + globals.__webStartupObserved = observed const ctx = new Context() await ctx.plugin(Loader) @@ -108,89 +80,56 @@ export const apply = ctx => globalThis.__webStartupApply(ctx) await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(join(dir, 'cordis.yml')).href } }) await ctx.loader.await() disposers.push(async () => { await ctx.fiber.dispose() }) - return { values: ctx.get(WEB_STARTUP_SERVICE) as WebStartupValues | undefined, observed, ctx } + return { + values: ctx.get(WEB_STARTUP_SERVICE) as WebStartupValues | undefined, + observed, + } } - -describe('web startup', () => { - it('resolves each flag into the value its row reads', async () => { - const { values } = await bootStartup(['--port', '8080']) +describe('web command-line provider', () => { + it('publishes each flag and releases direct service expressions', async () => { + const { values, observed } = await bootProvider([ + '--host', '0.0.0.0', + '--port', '8080', + '--dev', + '--trusted-host', 'lab.internal', 'lab-2.internal', + '--trusted-host', '10.0.0.9', + ]) expect(values).toEqual({ + host: '0.0.0.0', port: 8080, + mode: 'development', + trustedHosts: ['lab.internal', 'lab-2.internal', '10.0.0.9'], + }) + expect(observed.readerConfig).toEqual(values) + expect(observed.exits).toEqual([]) + }) + + it('leaves deployment values to each consumer when flags omit them', async () => { + const { values, observed } = await bootProvider([]) + expect(values).toEqual({ mode: 'production', trustedHosts: [] }) + expect(observed.readerConfig).toEqual({ + host: '127.0.0.1', + port: 3080, mode: 'production', trustedHosts: [], - lanAddresses: [], }) }) - it('names no value for a flag the invocation left out, so each row keeps its own', async () => { - const { values } = await bootStartup([]) - expect(values).toEqual({ mode: 'production', trustedHosts: [], lanAddresses: [] }) - expect(values).not.toHaveProperty('host') - expect(values).not.toHaveProperty('port') - }) - - it('adds LAN literals and explicit extras after the composed fence authorities', async () => { - const { values } = await bootStartup( - ['--host', '0.0.0.0', '--trusted-host', 'lab.internal', 'lab-2.internal', '--trusted-host', '10.0.0.9'], - { host: '127.0.0.1', port: 3080 }, - ['profile.internal'], - ) - expect(values?.trustedHosts).toEqual([ - 'profile.internal', '192.168.1.5', 'lab.internal', 'lab-2.internal', '10.0.0.9', - ]) - // Display gets the same single sample the fence was configured with. - expect(values?.lanAddresses).toEqual(['192.168.1.5']) - }) - - it('starts from an empty trust list when the composed connection row names none', async () => { - const { values } = await bootStartup( - ['--trusted-host', 'lab.internal'], - { host: '127.0.0.1', port: 3080 }, - null, - ) - expect(values?.trustedHosts).toEqual(['lab.internal']) - }) - - it.each([ - 'profile.internal', - ['profile.internal', 1], - ])('rejects an invalid composed trust list before transforming it (%j)', async (trustedHosts) => { - await expect(bootStartup([], { host: '127.0.0.1', port: 3080 }, trustedHosts)) - .rejects.toThrow('the composed connection trustedHosts must be an array of strings') - }) - - it('reads the composed bind when no flag names one, so a configured 0.0.0.0 still derives them', async () => { - const { values } = await bootStartup([], { host: '0.0.0.0', port: 3080 }) - expect(values?.lanAddresses).toEqual(['192.168.1.5']) - }) - - it('reports the development mode for --dev, which the web runtime reads', async () => { - const { values } = await bootStartup(['--dev']) - // The runtime row turns the reload chain on after its host dependencies - // activate; this row only reports the mode. - expect(values?.mode).toBe('development') - }) - - it('prints its own help and resolves nothing', async () => { - const { values, observed } = await bootStartup(['--help']) + it('prints its own help and leaves the consumer pending', async () => { + const { values, observed } = await bootProvider(['--help']) expect(observed.out).toContain('dsh --profile web') expect(observed.out).toContain('--trusted-host') expect(values).toBeUndefined() + expect(observed.readerConfig).toBeUndefined() expect(observed.exits).toEqual([0]) }) - it('rejects a non-numeric port before anything binds', async () => { - const { values, observed } = await bootStartup(['--port', 'abc']) + it('rejects a non-numeric port before the consumer activates', async () => { + const { values, observed } = await bootProvider(['--port', 'abc']) expect(observed.out).toContain('--port must be a number') expect(values).toBeUndefined() + expect(observed.readerConfig).toBeUndefined() expect(observed.exits).toEqual([1]) }) - - it('fails the boot when the composition lost the row whose bind it reads', async () => { - // 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/trusted-hosts.spec.ts b/packages/bundle/web-app/tests/trusted-hosts.spec.ts index 110aaeae61..5972569b0d 100644 --- a/packages/bundle/web-app/tests/trusted-hosts.spec.ts +++ b/packages/bundle/web-app/tests/trusted-hosts.spec.ts @@ -1,7 +1,7 @@ /** Single-sample LAN-trust resolution for the /api browser-trust fence (`resolveLanTrust`). */ import { describe, expect, it, vi } from 'vitest' -import { resolveLanTrust } from '../src/startup.ts' +import { resolveLanTrust } from '../src/index.ts' vi.mock('node:os', () => ({ networkInterfaces: () => ({ @@ -26,8 +26,9 @@ describe('resolveLanTrust', () => { expect(trustedHosts).toEqual(['192.168.1.5', '10.0.0.7', 'harness.internal:3080']) }) - it('derives nothing for a loopback or unresolved bind — extras alone stand, no LAN URL to print', () => { + it('derives nothing for a loopback bind — extras alone stand, no LAN URL to print', () => { expect(resolveLanTrust('127.0.0.1', [])).toEqual({ lanAddresses: [], trustedHosts: [] }) - expect(resolveLanTrust(undefined, ['lab.internal'])).toEqual({ lanAddresses: [], trustedHosts: ['lab.internal'] }) + expect(resolveLanTrust('127.0.0.1', ['lab.internal'])) + .toEqual({ lanAddresses: [], trustedHosts: ['lab.internal'] }) }) }) diff --git a/packages/bundle/web-app/tests/web-app.spec.ts b/packages/bundle/web-app/tests/web-app.spec.ts index df34637cab..f8c5079f17 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 - * app startup row's LAN snapshot. + * runtime's bind-dependent LAN snapshot. */ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' @@ -14,6 +14,14 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { HttpServerService } from '@deepseek-ai/dsh-host-webserver' import { apply, Config, internals } from '../src/index.ts' +vi.mock('node:os', async importOriginal => ({ + ...await importOriginal(), + networkInterfaces: () => ({ + lo0: [{ family: 'IPv4', internal: true, address: '127.0.0.1' }], + en0: [{ family: 'IPv4', internal: false, address: '192.168.1.5' }], + }), +})) + let dist: string | undefined afterEach(() => { @@ -36,9 +44,10 @@ function stageDist(): string { } /** A fake httpServer capturing the fallback seat and index taps. */ -function fakeHttpServer(): { server: HttpServerService; seat: () => unknown } { +function fakeHttpServer(host: '127.0.0.1' | '0.0.0.0' = '127.0.0.1'): { server: HttpServerService; seat: () => unknown } { let fallback: unknown const server = { + host, port: 4567, registerFallback: (handler: unknown) => { fallback = handler @@ -72,7 +81,7 @@ describe('web-app runtime glue', () => { it('mounts dist serving, prompt section, bash variables, and prints the URL with the LAN snapshot', async () => { stageDist() const ctx = new Context() - const { server, seat } = fakeHttpServer() + const { server, seat } = fakeHttpServer('0.0.0.0') ctx.provide('httpServer', server) const contributions: BashContribution[] = [] ctx.provide('bashEnv', { @@ -83,14 +92,17 @@ describe('web-app runtime glue', () => { } as never) const enabledRows = provideHmrRow(ctx) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - await 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, trustedHosts: ['lab.internal'] })) 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(enabledRows).toEqual(['client-hmr']) - expect(ctx.get('webClientRoster')).toBe(true) + expect(ctx.get('webRuntime')).toEqual({ + lanAddresses: ['192.168.1.5'], + trustedHosts: ['192.168.1.5', 'lab.internal'], + }) 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') @@ -107,7 +119,7 @@ describe('web-app runtime glue', () => { const ctx = new Context() ctx.provide('httpServer', fakeHttpServer().server) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, lanAddresses: [] })) + await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, trustedHosts: [] })) await ctx.plugin(SystemPrompt, { persona: '' }) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() @@ -128,7 +140,7 @@ describe('web-app runtime glue', () => { return () => {} }, } as never) - await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: false, lanAddresses: [] })) + await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: false, trustedHosts: [] })) await ctx.plugin(SystemPrompt, { persona: '' }) await new Promise(resolve => setTimeout(resolve, 0)) const assembly = await ctx.systemPrompt.assemble() @@ -143,7 +155,7 @@ describe('web-app runtime glue', () => { const ctx = new Context() ctx.provide('httpServer', fakeHttpServer().server) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - await apply(ctx, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) + await apply(ctx, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567') await ctx.fiber.dispose() @@ -159,7 +171,7 @@ describe('web-app runtime glue', () => { const settlement = new Promise((resolve) => { release = resolve }) provideHmrRow(settled, () => settlement) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - await apply(settled, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) + await apply(settled, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() release!() @@ -173,7 +185,7 @@ describe('web-app runtime glue', () => { const failed = new Context() failed.provide('httpServer', fakeHttpServer().server) provideHmrRow(failed, async () => { throw new Error('boot failed') }) - await apply(failed, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) + await apply(failed, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() await failed.fiber.dispose() @@ -189,7 +201,7 @@ describe('web-app runtime glue', () => { let releaseTorn: () => void const tornSettlement = new Promise((resolve) => { releaseTorn = resolve }) provideHmrRow(torn, () => tornSettlement) - await apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) + await apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] })) await child.dispose() // the httpServer service goes away releaseTorn!() await new Promise(resolve => setTimeout(resolve, 0)) @@ -205,7 +217,7 @@ describe('web-app runtime glue', () => { const { server } = fakeHttpServer() Object.defineProperty(server, 'port', { get: () => undefined }) ctx.provide('httpServer', server) - await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, lanAddresses: [] })) + await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, trustedHosts: [] })) 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 99801b0001..9e74ebfd80 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1185,10 +1185,6 @@ importers: version: 4.0.9 packages/boot/cmdline: - dependencies: - commander: - specifier: ^15.0.0 - version: 15.0.0 devDependencies: '@deepseek-ai/cordis': specifier: ^4.0.0-rc.7 @@ -1202,6 +1198,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + commander: + specifier: ^15.0.0 + version: 15.0.0 packages/bundle/base: dependencies: From 5dcee005ddc31f53f79a21dbe2a2864fcd2f3f23 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 10 Aug 2026 21:52:06 +0800 Subject: [PATCH 16/19] test(cli): guard the headless composition boundary --- apps/cli/tests/built-bin.e2e.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index b9e99604c1..015a729d5a 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -697,6 +697,19 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'") }, 30_000) + it('prints the headless profile without Host or browser layers', async () => { + const { stdout, code, stderr } = await runBuiltBin( + ['--profile', 'headless', '--dump-default-config'], + { DSH_HOME: home }, + ) + expect(code).toBe(0) + expect(stderr).toBe('') + expect(stdout).toContain("name: '@deepseek-ai/dsh-headless'") + expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-host-/) + expect(stdout).not.toContain("name: '@deepseek-ai/dsh-web-app'") + expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-client-/) + }, 30_000) + it('composes the profile user layer and a --patch overlay in order', async () => { // Auto-init the web profile first, then write its user layer. const init = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home }) From 45b300dc58ab9428715ececcb38ca0d9beb00587 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 10 Aug 2026 22:04:48 +0800 Subject: [PATCH 17/19] docs(notes): archive superseded dsh run decision --- .../feature/2026-08-08-dsh-run-headless-command.i18n.yaml | 6 ++++++ .../feature/2026-08-08-dsh-run-headless-command.md | 1 + .../feature/2026-08-08-dsh-run-headless-command.zh.md | 1 + .agents/notes/archived/manifest.json | 3 +++ .../2026-08-09-headless-direct-core-entry-point.i18n.yaml | 4 ++-- .../2026-08-09-headless-direct-core-entry-point.md | 2 +- .../2026-08-09-headless-direct-core-entry-point.zh.md | 2 +- .../feature/2026-08-08-dsh-run-headless-command.i18n.yaml | 6 ------ 8 files changed, 15 insertions(+), 10 deletions(-) create mode 100644 .agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.i18n.yaml rename .agents/notes/{implemented => archived}/feature/2026-08-08-dsh-run-headless-command.md (99%) rename .agents/notes/{implemented => archived}/feature/2026-08-08-dsh-run-headless-command.zh.md (99%) delete mode 100644 .agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml diff --git a/.agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.i18n.yaml b/.agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.i18n.yaml new file mode 100644 index 0000000000..d07841d540 --- /dev/null +++ b/.agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/archived/feature/2026-08-08-dsh-run-headless-command.md +2026-08-08-dsh-run-headless-command.md: ce9cff965192357022c49655983fe6ff8d554b9f +2026-08-08-dsh-run-headless-command.zh.md: 0484c069ed6365235e4616542a4fb3d5ceb2d880 diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md b/.agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.md similarity index 99% rename from .agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md rename to .agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.md index 779e568790..ce9cff9651 100644 --- a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md +++ b/.agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.md @@ -1,6 +1,7 @@ # Agent Note: `dsh run` owns one-shot headless execution Status: implemented +Archived: 2026-08-10 English | [中文](2026-08-08-dsh-run-headless-command.zh.md) diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md b/.agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md rename to .agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.zh.md index 5a21033e92..0484c069ed 100644 --- a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md +++ b/.agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.zh.md @@ -1,6 +1,7 @@ # Agent Note: `dsh run` 负责一次性 headless 执行 Status: implemented +Archived: 2026-08-10 [English](2026-08-08-dsh-run-headless-command.md) | 中文 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 638d87373d..d3d1795489 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -253,6 +253,9 @@ "feature/2026-07-31-web-cards-toolrow.i18n.yaml": "sha256:f9a6ab72a77934cdcc02167c7313f08d7e9925362017b34bed7ad56c8c70fbaa", "feature/2026-07-31-web-cards-toolrow.md": "sha256:5058f7cec4497d1cb0a5c8e77b88fddacac6eead034f3edec88e8514919b8a3e", "feature/2026-07-31-web-cards-toolrow.zh.md": "sha256:ba84ef2e1be61211ab5ba6950b78ede3d3a979f252bc068d3e04e2c025f7bc03", + "feature/2026-08-08-dsh-run-headless-command.i18n.yaml": "sha256:1c2b4c5b61b9263b6267275d6fc69faeaad3cc887f0728a7ed4172d817af812b", + "feature/2026-08-08-dsh-run-headless-command.md": "sha256:7695fe7fd322377d5986f14e35f13337f4cd376405c758218a81230f6d182d1c", + "feature/2026-08-08-dsh-run-headless-command.zh.md": "sha256:113c14a36c64d2facc8ae46f37c7aa76359d8cacb9c18fcba26a723f15d036fb", "process/2026-06-11-doc-sync-enforcement.i18n.yaml": "sha256:33b6d5874427bd7a2bd82e7e2f4f482b12448b2464aef15a9c57975edb48554d", "process/2026-06-11-doc-sync-enforcement.md": "sha256:aa2fe83d519fc30d48dff19e596e83c8922aacc9e063e14fe2cc35b769b9100e", "process/2026-06-11-doc-sync-enforcement.zh.md": "sha256:698017bd35f030fdea3eac51df9e43138c48140f504739d687b7251d13fced2b", diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml index 989c195965..a5e5d22982 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.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-09-headless-direct-core-entry-point.md -2026-08-09-headless-direct-core-entry-point.md: e411214a666787ff62626728c4e6887bfc3ec311 -2026-08-09-headless-direct-core-entry-point.zh.md: d17aab2352c9f55b58e52856ebb83cd6351afb10 +2026-08-09-headless-direct-core-entry-point.md: b705df2e6d88e096ee3ba50a6156b815dbd98b98 +2026-08-09-headless-direct-core-entry-point.zh.md: 439f4c21a2ce1741e8d483bc307508550da1bec7 diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md index e411214a66..b705df2e6d 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md @@ -20,7 +20,7 @@ The shipped `headless` profile contains `dsh-base` and `dsh-headless`. The headl `loadProfile` recognizes the exact installation-owned headless tuple (`dsh-base`, `dsh-web-app`, `dsh-headless`) and normalizes it to the shipped headless template while preserving every other manifest field. Extra, missing, or reordered bundle lists are user-owned and remain untouched. -This note owns the headless transport and completion contracts. [Apps own their command lines](2026-08-06-app-owned-command-line.md) owns the current `dsh --profile headless` grammar; the former [`dsh run` decision](../feature/2026-08-08-dsh-run-headless-command.md) records the superseded launcher-owned grammar, [GUI layering and RPC protocol](2026-07-19-gui-layering-and-rpc-protocol.md) owns browser gateway boundaries, [web config-tree boot and transport layering](2026-07-24-web-config-tree-boot-and-transport-layering.md) owns the Web tree, and [the default model follows the picker](../feature/2026-08-07-default-model-follows-the-picker.md) owns persistence of the shared Agent default. +This note owns the headless transport and completion contracts. [Apps own their command lines](2026-08-06-app-owned-command-line.md) owns the current `dsh --profile headless` grammar; the former [`dsh run` decision](../../archived/feature/2026-08-08-dsh-run-headless-command.md) records the superseded launcher-owned grammar, [GUI layering and RPC protocol](2026-07-19-gui-layering-and-rpc-protocol.md) owns browser gateway boundaries, [web config-tree boot and transport layering](2026-07-24-web-config-tree-boot-and-transport-layering.md) owns the Web tree, and [the default model follows the picker](../feature/2026-08-07-default-model-follows-the-picker.md) owns persistence of the shared Agent default. ## Verification diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md index d17aab2352..439f4c21a2 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md @@ -20,7 +20,7 @@ Status: implemented `loadProfile` 识别安装过程拥有的精确 headless 元组(`dsh-base`、`dsh-web-app`、`dsh-headless`),将其规范化为随附的 headless 模板,并保留 manifest(元数据清单)的其他所有字段。带额外项、缺少项或顺序不同的组合包列表归用户所有,保持不变。 -本 Agent Note 负责 headless 的传输与完成约定。[应用持有自己的命令行](2026-08-06-app-owned-command-line.md)负责当前的 `dsh --profile headless` 语法;原 [`dsh run` 决策](../feature/2026-08-08-dsh-run-headless-command.md)记录已被取代的启动器持有语法,[GUI 分层与 RPC 协议](2026-07-19-gui-layering-and-rpc-protocol.md)负责浏览器网关边界,[Web 配置树启动与传输分层](2026-07-24-web-config-tree-boot-and-transport-layering.md)负责 Web 插件树,[默认模型跟随选择器](../feature/2026-08-07-default-model-follows-the-picker.md)负责共享 Agent 默认值的持久化。 +本 Agent Note 负责 headless 的传输与完成约定。[应用持有自己的命令行](2026-08-06-app-owned-command-line.md)负责当前的 `dsh --profile headless` 语法;原 [`dsh run` 决策](../../archived/feature/2026-08-08-dsh-run-headless-command.md)记录已被取代的启动器持有语法,[GUI 分层与 RPC 协议](2026-07-19-gui-layering-and-rpc-protocol.md)负责浏览器网关边界,[Web 配置树启动与传输分层](2026-07-24-web-config-tree-boot-and-transport-layering.md)负责 Web 插件树,[默认模型跟随选择器](../feature/2026-08-07-default-model-follows-the-picker.md)负责共享 Agent 默认值的持久化。 ## 验证 diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml deleted file mode 100644 index 7b9076e9b4..0000000000 --- a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# 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/feature/2026-08-08-dsh-run-headless-command.md -2026-08-08-dsh-run-headless-command.md: 779e568790a58899488ea87292c1bc2db329617f -2026-08-08-dsh-run-headless-command.zh.md: 5a21033e921cb181aa6987259d37b4bc5004e2d9 From ca391942215ab298f0b593d307e8f54f6c7ef6ef Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 10 Aug 2026 22:18:33 +0800 Subject: [PATCH 18/19] docs(web): align client roster terminology --- packages/bundle/web-app/cordis.patch.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 199c46a33b..fb41923d71 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -123,7 +123,7 @@ inject: [webStartup] disabled: true - # ── browser plugin roster (dshClient rows; node halves are layer-2 hosts) ── + # ── browser plugin roster (dsh.client rows; node halves are layer-2 hosts) ── # Dual-face: this waits for the runtime row to decide whether HMR belongs # in the first graph. The node half then scans this tree, composes From dab601e1236afd788b7e54103dfa6f2f9d569fd4 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 10 Aug 2026 22:34:07 +0800 Subject: [PATCH 19/19] fix(vendor): align command providers with Cordis rescope --- packages/boot/cmdline/src/index.ts | 6 +++--- packages/boot/cmdline/src/invariant.ts | 2 +- packages/boot/cmdline/tests/cmdline.spec.ts | 8 ++++---- packages/bundle/headless/src/startup.ts | 2 +- packages/bundle/headless/tests/startup.spec.ts | 6 +++--- packages/bundle/web-app/src/startup.ts | 2 +- packages/bundle/web-app/tests/startup.spec.ts | 6 +++--- scripts/rescope-vendor.ts | 4 ++-- vendor/README.md | 6 +++--- 9 files changed, 21 insertions(+), 21 deletions(-) diff --git a/packages/boot/cmdline/src/index.ts b/packages/boot/cmdline/src/index.ts index 1236b32cb5..fb502cb0e2 100644 --- a/packages/boot/cmdline/src/index.ts +++ b/packages/boot/cmdline/src/index.ts @@ -16,9 +16,9 @@ */ import type { Command } from 'commander' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' // Empty type import carries the Loader Context merge used by enableRow. -import type {} from '@cordisjs/plugin-loader' +import type {} from '@deepseek-ai/cordis-plugin-loader' /** * The invocation's inner arguments: everything after the launcher's own flags, @@ -42,7 +42,7 @@ export interface AppExit { (code: number): void } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** The invocation's inner arguments; provided by a launcher before the tree mounts. */ cmdlineArgs?: CmdlineArgs diff --git a/packages/boot/cmdline/src/invariant.ts b/packages/boot/cmdline/src/invariant.ts index cab932a8e6..b18094a1f4 100644 --- a/packages/boot/cmdline/src/invariant.ts +++ b/packages/boot/cmdline/src/invariant.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-cmdline/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-cmdline' diff --git a/packages/boot/cmdline/tests/cmdline.spec.ts b/packages/boot/cmdline/tests/cmdline.spec.ts index 9faf6ed7d5..bc9b63c9aa 100644 --- a/packages/boot/cmdline/tests/cmdline.spec.ts +++ b/packages/boot/cmdline/tests/cmdline.spec.ts @@ -9,10 +9,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { Command } from 'commander' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' -import type { PatchOptions } from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { afterEach, describe, expect, it } from 'vitest' import { enableRow, internals, parseCmdline, provideCmdline, type CmdlinePlan, diff --git a/packages/bundle/headless/src/startup.ts b/packages/bundle/headless/src/startup.ts index 7999bb09d2..bfb4d44e51 100644 --- a/packages/bundle/headless/src/startup.ts +++ b/packages/bundle/headless/src/startup.ts @@ -6,7 +6,7 @@ */ import { Command } from 'commander' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { parseCmdline } from '@deepseek-ai/dsh-cmdline' /** Stable Cordis plugin name. */ diff --git a/packages/bundle/headless/tests/startup.spec.ts b/packages/bundle/headless/tests/startup.spec.ts index dce5387b84..07c200202e 100644 --- a/packages/bundle/headless/tests/startup.spec.ts +++ b/packages/bundle/headless/tests/startup.spec.ts @@ -8,9 +8,9 @@ import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import { internals, provideCmdline } from '@deepseek-ai/dsh-cmdline' import { afterEach, describe, expect, it } from 'vitest' import { apply, HEADLESS_STARTUP_SERVICE, type HeadlessStartupValues } from '../src/startup.ts' diff --git a/packages/bundle/web-app/src/startup.ts b/packages/bundle/web-app/src/startup.ts index 78d24f553a..040fe843c0 100644 --- a/packages/bundle/web-app/src/startup.ts +++ b/packages/bundle/web-app/src/startup.ts @@ -7,7 +7,7 @@ */ import { Command } from 'commander' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { parseCmdline } from '@deepseek-ai/dsh-cmdline' /** Stable Cordis plugin name. */ diff --git a/packages/bundle/web-app/tests/startup.spec.ts b/packages/bundle/web-app/tests/startup.spec.ts index 91ec241889..5108d04232 100644 --- a/packages/bundle/web-app/tests/startup.spec.ts +++ b/packages/bundle/web-app/tests/startup.spec.ts @@ -7,9 +7,9 @@ import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import { internals, provideCmdline } from '@deepseek-ai/dsh-cmdline' import { afterEach, describe, expect, it } from 'vitest' import { apply, WEB_STARTUP_SERVICE, type WebStartupValues } from '../src/startup.ts' diff --git a/scripts/rescope-vendor.ts b/scripts/rescope-vendor.ts index e083d375e3..bca3183795 100644 --- a/scripts/rescope-vendor.ts +++ b/scripts/rescope-vendor.ts @@ -126,7 +126,7 @@ const POSTCONDITIONS: readonly PostCondition[] = [ { file: 'packages/boot/app-boot/tsdown.config.ts', text: '[\'@deepseek-ai/cordis-plugin-include\']', count: 1 }, { file: 'tsconfig.base.json', text: '"@deepseek-ai/cordis-plugin-loader": ["./vendor/loader/src"]', count: 1 }, // One insertion, once: a duplicated log entry is what a non-idempotent apply produced. - { file: 'vendor/README.md', text: '15. **`@deepseek-ai` rescope**', count: 1 }, + { file: 'vendor/README.md', text: '17. **`@deepseek-ai` rescope**', count: 1 }, { file: 'knip.json', text: '@cordisjs', count: 0 }, { file: 'pnpm-workspace.yaml', text: 'cordis@4.0.0-rc.7', count: 0 }, // The preset ids in this table are product data, not package names. @@ -319,7 +319,7 @@ const EXACT_EDITS: readonly ExactEdit[] = [ id: 'vendor-readme-local-modification-log', file: 'vendor/README.md', find: '\n## Sync procedure', - replace: '15. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table\'s `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for(\'schemastery\')` and Schemastery\'s `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table\'s two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md).\n\n## Sync procedure', + replace: '17. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table\'s `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for(\'schemastery\')` and Schemastery\'s `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table\'s two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md).\n\n## Sync procedure', expect: 1, }, { diff --git a/vendor/README.md b/vendor/README.md index 87e65ed07f..e83f9b4140 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -44,9 +44,9 @@ Keep this log exhaustive — every divergence from upstream must be listed. 12. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a user patch layer present at registration must apply once. Covered by the patch-overlay boot-failure built-bin case in `apps/cli/tests/built-bin.e2e.ts`. 13. **`include/src/index.ts` `writeTask` type**: widened the optional `writeTask?: NodeJS.Timeout` property to `NodeJS.Timeout | undefined` — the debounced writer assigns `undefined` on flush, which `exactOptionalPropertyTypes` rejects on a plain optional. Type-only; no behavior change. 14. **`include/src/index.ts` durable debounced writes**: serialized and tracked config-file writes, retried transient `EACCES`/`EBUSY`/`EPERM` rename failures with a bounded backoff, observed asynchronous timer rejections, and drained the latest write during Include teardown. Windows can briefly retain a destination handle after a Loader child disposes; the upstream fire-and-forget rename escaped as an unhandled rejection and could lose the persisted `disabled` state. A terminal failure is logged by the asynchronous writer and remains on the queue so `Include.stop()` rethrows it instead of silently declaring persistence complete; Cordis's ordinary fiber teardown retains its separate error-containment contract. Covered by `packages/host/directory-picker-auto/tests/loader-composition.spec.ts` with injected transient and terminal rename failures. -15. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table's `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for('schemastery')` and Schemastery's `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table's two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md). -16. **Lazy Loader config resolution across `cordis/src/{events,fiber}.ts`, `loader/src/{index,config/entry}.ts`, `include/src/index.ts`, and `hmr/src/index.ts`**: ports [cordiverse/cordis#41](https://github.com/cordiverse/cordis/pull/41), retaining raw fiber config and resolving it through `internal/config` only after declared injections are active. Provider replacement re-resolves the raw expression, pending updates retain it, and HMR transfers it. Resolution applies only to the entry root, so child plugins mounted by a row keep caller-owned config identity. Include adds a static entry-config resolver so its own options interpolate while nested row `!!js` nodes remain deferred. Deferred failures retain the owning row diagnostic, and tree teardown does not persist failure-driven self-disposal. Covered by `packages/boot/app-boot/tests/{app-boot,user-patches}.spec.ts`, `packages/boot/cmdline/tests/cmdline.spec.ts`, `apps/cli/tests/web-agent-presets.e2e.ts`, and the built custom-profile cases in `apps/cli/tests/built-bin.e2e.ts`. -17. **In-memory Loader entry activation in `loader/src/config/entry.ts`**: an invocation can activate a row shipped with `disabled: true` without mutating its serialized options. The override belongs to the mounted entry object, survives Include config reapplication, respects disabled ancestors, and disappears with the entry. Covered by `packages/boot/cmdline/tests/cmdline.spec.ts` and `apps/web/tests/hmr-live.e2e.ts`. +15. **Lazy Loader config resolution across `cordis/src/{events,fiber}.ts`, `loader/src/{index,config/entry}.ts`, `include/src/index.ts`, and `hmr/src/index.ts`**: ports [cordiverse/cordis#41](https://github.com/cordiverse/cordis/pull/41), retaining raw fiber config and resolving it through `internal/config` only after declared injections are active. Provider replacement re-resolves the raw expression, pending updates retain it, and HMR transfers it. Resolution applies only to the entry root, so child plugins mounted by a row keep caller-owned config identity. Include adds a static entry-config resolver so its own options interpolate while nested row `!!js` nodes remain deferred. Deferred failures retain the owning row diagnostic, and tree teardown does not persist failure-driven self-disposal. Covered by `packages/boot/app-boot/tests/{app-boot,user-patches}.spec.ts`, `packages/boot/cmdline/tests/cmdline.spec.ts`, `apps/cli/tests/web-agent-presets.e2e.ts`, and the built custom-profile cases in `apps/cli/tests/built-bin.e2e.ts`. +16. **In-memory Loader entry activation in `loader/src/config/entry.ts`**: an invocation can activate a row shipped with `disabled: true` without mutating its serialized options. The override belongs to the mounted entry object, survives Include config reapplication, respects disabled ancestors, and disappears with the entry. Covered by `packages/boot/cmdline/tests/cmdline.spec.ts` and `apps/web/tests/hmr-live.e2e.ts`. +17. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table's `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for('schemastery')` and Schemastery's `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table's two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md). ## Sync procedure