refactor(loader): resolve config after injected services

This commit is contained in:
Turtle
2026-08-10 23:45:04 +08:00
parent b692f38506
commit 7e3a82eacc
38 changed files with 404 additions and 306 deletions
@@ -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
@@ -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/<name>` 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/<name>` 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 <name>] "task"` owns one-shot execution and defaults to the headless profile; generic `dsh --profile <name>` boots without a task. Patch overlays use `--patch`. `dsh plugin --profile <name> <args...>` 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 <name>` 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 <name> <args...>` 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).
@@ -10,11 +10,9 @@ Status: implemented
## Decision
一切都变成 **profile**:即目录 `$DSH_HOME/profiles/<name>`,其中包含一个 `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/<name>`,其中包含一个 `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 <name>] "task"` 负责一次性执行,默认使用 headless profile;通用的 `dsh --profile <name>` 启动 profile 而不携带任务。patch overlay 使用 `--patch``dsh plugin --profile <name> <args...>` 是一层薄薄的 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 <name>` 把剩余参数交给该 profile 的命令行启动行:Web 持有自己的 flag 家族,headless 则持有任务位置参数。patch overlay 使用启动器持有的 `--patch``dsh plugin --profile <name> <args...>` 是一层薄薄的 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`(安装目录的应用与各组合包所依赖的每个包各一个符号链接,每次启动时修复)。
@@ -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
@@ -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.
@@ -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` waterfallCordis 快照注入服务之后,Loader 的监听器再插值原始配置
- **提供方替换与 HMR 必须保持相同契约。** fiber 重新激活时会重跑 waterfallHMR 会把原始配置带给替换 fiber,而待处理行可以接受选项变更,不会针对缺失服务提前求值表达式。
- **不能从正在挂载的插件内部插入一行**——`tree.create` 返回一个带前缀的 id,随后它自己解析不出来——因此条件性的行以 `disabled: true` 交付,再由活跃行启用(`dsh web --dev` 及其重载链路);启用后的行继续遵循普通注入顺序。
这样,依赖顺序就由真正持有它的接缝负责。各行保留自己的 `inject` 和配置,Loader 只挂载一次组合,启动器只提供 argv 与进程生命周期服务。
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/cordis-api/fiber.md
fiber.md: 36d2861ac6a53e8186a92d86c65ba228d4b59ee5
fiber.zh.md: fafa559ca911677c43893862190009d82c39c56b
fiber.md: 182b77390b29b8a90504437d0ccc2dfeba23921a
fiber.zh.md: 9ed3e52618586dc3815b9d913439d11a227fb64b
+12 -12
View File
@@ -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<void>
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<Impl> | 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<void> | 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
+12 -12
View File
@@ -36,7 +36,7 @@ effect(execute: () => Effect, label?: string): AsyncDisposable<Promise<void>>
**返回**一个用于撤销该作用的清理函数,并在清理完成后结算。
[源码](../../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<void>
dispose 此 fiber:卸载插件,并在清理完成后结算。
[源码](../../vendor/cordis/src/fiber.ts#L194)
[源码](../../vendor/cordis/src/fiber.ts#L196)
### fiber.store
@@ -121,7 +121,7 @@ public store: Dict<Impl> | undefined
加载期间所需服务实现的快照;其他情况下为 `undefined`。
[源码](../../vendor/cordis/src/fiber.ts#L196)
[源码](../../vendor/cordis/src/fiber.ts#L198)
### fiber.inertia
@@ -132,7 +132,7 @@ public inertia: Promise<void> | 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<Promise<void>>
**返回**一个用于撤销该作用的清理函数,并在清理完成后结算。
[源码](../../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
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/cordis-primer.md
cordis-primer.md: 93725949a9490f757edebcf3e8391db9e73321b1
cordis-primer.zh.md: fd2a327b526b210986bc1574013fca2c0cec5dda
cordis-primer.md: d1e7c5fd8eaaa89fe448d238359389d945cd6346
cordis-primer.zh.md: d6ce0f2024f65b006c9505daffaa06a08bb56875
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
## 实践规则
+1 -26
View File
@@ -199,7 +199,7 @@ export function loadLayeredEnv(
const bootstrapIncludes = new WeakMap<Context, Entry>()
// 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<void> {
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.
+32 -2
View File
@@ -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'), [
@@ -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()
}
})
})
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/cmdline/README.md
README.md: 242ba184507d88c50e0dcf2ada0a0f7714d87e28
README.zh.md: 76a76ad6090fcc28d50f9ea2a48d4e2581e361f2
README.md: cd3350678d38802c18ff26dd47214b5019b8c404
README.zh.md: ad726cd0726cbbd22736321a8c52b04e23d557fa
+8 -8
View File
@@ -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
+8 -8
View File
@@ -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 会对启用后的行应用普通的注入顺序
### 一条命令行,一个所有者
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bundle/headless/README.md
README.md: 45c87f0c85cbb68ad0366ea5f2c86e55fc307309
README.zh.md: 22322692450fa85a87e9faf903abee0d38968f91
README.md: 459d0f32788265d43e75922067da3c03d054f444
README.zh.md: e3ca9d13512e3a13ac71c5cda650fca958609062
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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 失败。
+1 -1
View File
@@ -33,4 +33,4 @@
name: '@deepseek-ai/dsh-headless'
inject: [headlessStartup]
config:
task: !!js ctx.get('headlessStartup')?.task
task: !!js ctx.headlessStartup.task
+1 -1
View File
@@ -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.
*/
+26 -32
View File
@@ -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<void>)[] = []
@@ -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')
})
+6 -8
View File
@@ -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]
+40 -12
View File
@@ -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<WebOptions>()
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)
+44 -7
View File
@@ -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<string, unknown> | 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')
})
+17 -58
View File
@@ -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)
},
)
})
+14 -11
View File
@@ -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<void>,
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
+2 -1
View File
@@ -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
+6
View File
@@ -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. */
+21 -10
View File
@@ -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
+1 -1
View File
@@ -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
}
+16 -1
View File
@@ -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
+20 -15
View File
@@ -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<Fiber> {
await this._patchContext([])
this.loader.showLog(this, 'apply')
return this.fiber = this.ctx.registry.plugin(plugin, this._resolveConfig(plugin), this.getOuterStack)
}
}
+4
View File
@@ -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)
+1 -1
View File
@@ -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')
+20 -3
View File
@@ -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<PropertyKey, unknown> | 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