Merge pull request #976 from deepseek-harness/fix/nodeboot-dep
fix(cli): expose source-launch activation errors
This commit is contained in:
+2
-2
@@ -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-23-client-plugin-loading-model.md
|
||||
2026-07-23-client-plugin-loading-model.md: b0873b7aa7bccd3d613f3113fa18207770952e5d
|
||||
2026-07-23-client-plugin-loading-model.zh.md: f3472dbfc5a78924e77337bf92ce5983c8492c4c
|
||||
2026-07-23-client-plugin-loading-model.md: 02347f2964942b89ec1f0a6ec483f4c2b2f9e68c
|
||||
2026-07-23-client-plugin-loading-model.zh.md: ea927d35860fbbba567c47cea0ee3a45133ce0f4
|
||||
@@ -56,8 +56,8 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the
|
||||
|
||||
**Host side — compose the graph.**
|
||||
|
||||
1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, and `--dev` appends the `client-hmr` row in code (`AppCLIEntry`) before the settle/sweep so the fail-loud triple covers it. A roster row that fails to import is caught by the boot's `assertEntriesLoaded`.
|
||||
2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dshClient` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`. The `inject` edges and the `immediately` mark come from manifests, never hand-copied. It refuses a declared plugin without a built `./client` bundle, and any malformed declaration field — activation-time fail loud (a FAILED fiber the sweep reports).
|
||||
1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, and `--dev` appends the `client-hmr` row in code (`AppCLIEntry`) before the host activation audit so the same check covers it. A roster row that fails to import is caught by `assertEntriesLoaded`; a row whose fiber rejects is reported with its original stack by `assertEntriesActivated` ([host boot decision](2026-07-24-web-config-tree-boot-and-transport-layering.md)).
|
||||
2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dshClient` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`. The `inject` edges and the `immediately` mark come from manifests, never hand-copied. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the host audit reports either error from the FAILED fiber.
|
||||
3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per name forever and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Each bundle's content hash is its `rev` (cache busting + HMR diff anchor), the row set hashes into `graph.rev`, and every row is fetch-served: `/plugins/<id>/client.js?rev=…`. The graph types are single-sourced in the modules package's `./impl` export — the webserver knows nothing about the graph (it is a plain route-registration plugin; modules registers the bundle route and taps the index render itself).
|
||||
|
||||
Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a dshClient package existing in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted.
|
||||
|
||||
@@ -56,8 +56,8 @@ vendored Loader 经其 `internal` seam 消费模块系统——唯一调用点
|
||||
|
||||
**host 侧——组合这张图。**
|
||||
|
||||
1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,`--dev` 由代码(`AppCLIEntry`)在 settle/sweep 之前追加 `client-hmr` 行,使 fail-loud 三件套一并覆盖它。名册行 import 失败由 boot 的 `assertEntriesLoaded` 捕获。
|
||||
2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dshClient` 声明,组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`。`inject` 边与 `immediately` 标记都来自 manifest,永不人肉抄写。它拒绝声明了插件却没有已构建 `./client` bundle 的包,也拒绝任何畸形的声明字段——激活期大声失败(FAILED fiber,由 sweep 上报)。
|
||||
1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,`--dev` 由代码(`AppCLIEntry`)在 host 激活检查之前追加 `client-hmr` 行,使同一项检查覆盖它。名册行 import 失败由 `assertEntriesLoaded` 捕获;fiber reject 的行则由 `assertEntriesActivated` 报告原始 stack([host boot 决策](2026-07-24-web-config-tree-boot-and-transport-layering.md))。
|
||||
2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dshClient` 声明,组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`。`inject` 边与 `immediately` 标记都来自 manifest,永不人肉抄写。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下;畸形声明字段同样会让激活失败,host 检查会从 FAILED fiber 报告这两类错误。
|
||||
3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按名永久缓存,bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。每个 bundle 的内容哈希是其 `rev`(缓存失效 + HMR diff 锚点),行集合哈希进 `graph.rev`,每一行都经 fetch 供给:`/plugins/<id>/client.js?rev=…`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知(它是朴素路由注册插件;bundle 路由和 index 渲染 tap 都由 modules 自己注册)。
|
||||
|
||||
为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个 dshClient 包存在于仓库里,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。
|
||||
|
||||
+2
-2
@@ -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-24-web-config-tree-boot-and-transport-layering.md
|
||||
2026-07-24-web-config-tree-boot-and-transport-layering.md: a2080024d36d54162f4f4aa79896e51efd708f59
|
||||
2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 1c430bf2939f9f556f378bdeb78872a0091a592d
|
||||
2026-07-24-web-config-tree-boot-and-transport-layering.md: 88f94b1f58ae7a3451c7772f4a9ff7d6564254c0
|
||||
2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: ea2a8f70a6c2d4207d4388a9303fbc6ce6e94238
|
||||
+2
-2
@@ -12,9 +12,9 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)
|
||||
|
||||
## Decision
|
||||
|
||||
**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle sweep — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven, and the boot compensates with a fail-loud triple: `assertEntriesLoaded` (import failures), `installFailLoud` (late apply rejections), and an all-ACTIVE sweep (PENDING fibers — cordis inject waiting has no timeout).
|
||||
**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle audit — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven. The shared audit rejects imports with no fiber, awaits only failed fibers to recover original activation errors, and reports services that leave a fiber `PENDING`; before throwing, it marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. The Node app-boot artifact embeds `@cordisjs/plugin-include` while leaving `@cordisjs/plugin-loader` external, so the include's `EntryTree` and the host bind to one Loader peer instead of splitting a config tree across two Loader implementations.
|
||||
|
||||
**Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the triple. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep.
|
||||
**Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the activation audit. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep.
|
||||
|
||||
**Config sources have one declaration place each.** yml static values are engineering defaults; the profile json (`./.dsh-tmp-profile/config.json`, read-only, never created, cwd-anchored until the `$DSH_HOME` migration) is user config mapped through a static `PROFILE_MAPPINGS` table onto target rows (`provider`/`model` → the `api-gateway` row, `persistenceRoot` → the jsonl row); CLI flags map onto the `webserver` row with a field set disjoint from the json's; env values enter through yml `!!js` expressions, never through the mapping table. Patches replace a row's config wholesale, so the entry class re-reads the yml row's static values (bypass parse) and merges overrides on top. An unmapped json key fails loud. The resolved frontend `distIndex` rides the same patch channel — an assembly fact, not user config.
|
||||
|
||||
|
||||
+2
-2
@@ -12,9 +12,9 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host runtime(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle sweep 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动,boot 以 fail-loud 三件套补偿:`assertEntriesLoaded`(import 失败)、`installFailLoud`(迟到的 apply 拒绝)、all-ACTIVE sweep(PENDING fiber——cordis inject 等待没有超时)。
|
||||
**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host runtime(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle audit 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动。共享 audit 会拒绝没有 fiber 的 import、仅等待失败的 fiber 以恢复原始激活错误,并报告让 fiber 停在 `PENDING` 的服务;抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而无关的未处理 rejection 仍然致命。Node app-boot 产物内嵌 `@cordisjs/plugin-include`,但将 `@cordisjs/plugin-loader` 保持为外部依赖,因此 include 的 `EntryTree` 与 host 会绑定到同一个 Loader peer,而不会让一棵配置树横跨两个 Loader 实现。
|
||||
|
||||
**boot 胶水是一对 class。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有独立于 cordis 必须提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加三件套。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐图行 create、settle、sweep。
|
||||
**boot 胶水是一对 class。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有独立于 cordis 必须提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加 activation audit。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐图行 create、settle、sweep。
|
||||
|
||||
**每个配置源有唯一声明位置。** yml 静态值是工程默认;profile json(`./.dsh-tmp-profile/config.json`,只读、绝不创建、暂锚 cwd 直至 `$DSH_HOME` 迁移)是用户配置,经静态 `PROFILE_MAPPINGS` 表映射到目标行(`provider`/`model` → `api-gateway` 行,`persistenceRoot` → jsonl 行);CLI flags 映射到 `webserver` 行、字段集与 json 不相交;env 值经 yml `!!js` 表达式进入,绝不进映射表。patch 整体替换行 config,故 entry 类旁路 parse 重读 yml 行静态值再叠加覆盖。未映射的 json 键 fail loud。解析出的前端 `distIndex` 走同一 patch 通道——装配事实,不是用户配置。
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* for the Web/headless surface.
|
||||
* Everything here is what must exist before the Loader runs: the patch
|
||||
* composition over the shipped base and surface overlay (profile json + CLI
|
||||
* flags + the resolved frontend dist), and the fail-loud triple after the tree
|
||||
* flags + the resolved frontend dist), and the fail-loud activation audit after the tree
|
||||
* settles. The environment is what the bin already loaded (ambient plus the
|
||||
* invoking directory's `.env`); `$DSH_HOME/.env` belongs to the credential
|
||||
* provider and is never hoisted here.
|
||||
@@ -246,7 +246,7 @@ export class AppCLIEntry {
|
||||
if (telemetryPatch !== undefined) this.patches.push(telemetryPatch)
|
||||
}
|
||||
|
||||
/** Shared Loader boot; the dev HMR row mounts before await so the fail-loud sweep covers it. */
|
||||
/** Shared Loader boot; the dev HMR row mounts before await so the activation audit covers it. */
|
||||
private async bootTree(): Promise<void> {
|
||||
// One include of the shared base with every overlay as a sibling patch
|
||||
// list: patches never cross an include boundary, so nesting them would
|
||||
|
||||
@@ -328,7 +328,7 @@ Source: [`packages/bash/tool-bash/src/index.ts:104`](../../packages/bash/tool-ba
|
||||
|
||||
## `ctx.clientModuleHost` — `ClientModuleHostService`
|
||||
|
||||
The web plugin table service: incremental dshClient scan + wire composition + bundle route + index tap. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot sweep reports it).
|
||||
The web plugin table service: incremental dshClient scan + wire composition + bundle route + index tap. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
@@ -368,7 +368,7 @@ onRebuilt(listener: (id: string, rev: string) => void): () => void
|
||||
onGraphChanged(listener: () => void): () => void
|
||||
```
|
||||
|
||||
Source: [`packages/client/modules/src/index.ts:143`](../../packages/client/modules/src/index.ts)
|
||||
Source: [`packages/client/modules/src/index.ts:184`](../../packages/client/modules/src/index.ts)
|
||||
|
||||
## `ctx.codeRuntime` — `CodeRuntime` (abstract seam)
|
||||
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
/** Fail activation with a deterministic stack so the user-visible startup diagnostic is snapshot-stable. */
|
||||
export function apply() {
|
||||
const failure = new Error('startup activation snapshot failure')
|
||||
failure.stack = 'Error: startup activation snapshot failure\n at activation-error-fixture'
|
||||
throw failure
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
- id: activation-error
|
||||
name: ./activation-error.mjs
|
||||
@@ -8,6 +8,7 @@
|
||||
- id: telemetry-redact-rule
|
||||
name: './telemetry-redact-rule.ts'
|
||||
|
||||
# Managed child-process groups required by the bash executor.
|
||||
- id: subprocess
|
||||
name: '@deepseek-ai/dsh-subprocess-local'
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ const credentialsScenarioDir = join(snapshotsDir, 'missing-credential')
|
||||
const credentialsConfigPath = fileURLToPath(new URL('../credentials.cordis.snapshot.yml', import.meta.url))
|
||||
const ralphScenarioDir = join(snapshotsDir, 'ralph-loop')
|
||||
const ralphConfigPath = fileURLToPath(new URL('../ralph.cordis.snapshot.yml', import.meta.url))
|
||||
const startupFailureConfigPath = fileURLToPath(new URL('./fixtures/startup-activation-error/cordis.yml', import.meta.url))
|
||||
const startupFailureExpected = join(snapshotsDir, 'startup-activation-error', 'stderr.expected.txt')
|
||||
const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url))
|
||||
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
|
||||
const reasoningConfigPath = fileURLToPath(new URL('./fixtures/cli.cordis.yml', import.meta.url))
|
||||
@@ -167,6 +169,20 @@ async function persistedLogs(cwd: string): Promise<PersistedLog[]> {
|
||||
}
|
||||
|
||||
describe('headless stream-json snapshots', () => {
|
||||
it('prints the original Loader activation error through the assembled one-shot app', async () => {
|
||||
const result = await runLoaderSmoke({
|
||||
label: 'headless startup activation error snapshot',
|
||||
tempDirPrefix: 'headless-snapshot-startup-error-',
|
||||
binScript,
|
||||
configPath: startupFailureConfigPath,
|
||||
binArgs: ['--config', startupFailureConfigPath, '--output-format', 'stream-json', 'unreachable task'],
|
||||
tsconfigPath,
|
||||
expectedExitCode: 1,
|
||||
})
|
||||
expect(result.stdout).toBe('')
|
||||
await expect(result.stderr).toMatchFileSnapshot(startupFailureExpected)
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('retries a transient provider failure through the one-shot app', async () => {
|
||||
const prompt = await scenarioPrompt(retryScenarioDir, 'provider-retry')
|
||||
const streamExpected = join(retryScenarioDir, 'stream-json.expected.jsonl')
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
dsh-cli-demo: dsh-cli-demo: 1 entry did not activate
|
||||
./activation-error.mjs: Error: startup activation snapshot failure
|
||||
at activation-error-fixture
|
||||
@@ -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/client/modules/README.md
|
||||
README.md: efba9e2eb0b148677fc7ac18bfad6333fb6f80da
|
||||
README.zh.md: b057bfdd8c0a269252496d0c6a0fc4184932fd72
|
||||
README.md: 99565b349d782c58752ac3e73ce7c0be527f78a8
|
||||
README.zh.md: a8ed0a4949ccefce53933b4f2fb8f51f5291684f
|
||||
@@ -8,6 +8,8 @@ Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`wi
|
||||
|
||||
Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → surface; shell-own static registry (`registerStatic`, app-shell) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → fetch + execute + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the fetch branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (fetch + execute, registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and the materialized record so the next prefetch/import refetches (the HMR hook).
|
||||
|
||||
The Node half scans enabled Loader entries for web `dshClient` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, and serves it under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the module loader is browser-side kernel machinery; nothing here reaches a model request.
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`,app-shell)→ 模块;已注册 factory → 物化;模块图记录(`window.__DSH_BOOT__`)→ 抓取 + 执行 + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含抓取分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段加载钩子(抓取 + 执行,只注册;并发调用共享一个进行中的任务);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新抓取;它是 HMR(热模块替换)钩子。
|
||||
|
||||
Node 侧会扫描已启用的 Loader 配置项以发现 web `dshClient` 包,解析每个 `exports["./client"]`,把构建后的组合包哈希写入启动图,并通过 `/plugins` 提供该文件。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费客户端导出的构建产物;缺失文件共享一条构建要求,随后以 package/path list 列出各项,而无关的文件系统错误仍是独立故障。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。模块 loader 属于浏览器侧内核机制;这里没有任何内容进入模型请求。
|
||||
|
||||
@@ -58,6 +58,47 @@ interface PkgMeta {
|
||||
immediately: boolean
|
||||
}
|
||||
|
||||
/** Recovery instruction shared by grouped startup and steady-state bundle diagnostics. */
|
||||
const CLIENT_BUNDLE_BUILD_INSTRUCTION = 'run `pnpm run build` before launch'
|
||||
|
||||
/** Missing built client export, retained as structured data for activation-error grouping. */
|
||||
class MissingClientBundleError extends Error {
|
||||
constructor(
|
||||
readonly packageName: string,
|
||||
readonly clientPath: string,
|
||||
cause: unknown,
|
||||
) {
|
||||
super(
|
||||
[
|
||||
`client-modules: client bundle not found; ${CLIENT_BUNDLE_BUILD_INSTRUCTION}:`,
|
||||
` package: ${packageName}`,
|
||||
` path: ${clientPath}`,
|
||||
].join('\n'),
|
||||
{ cause },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Activation failures grouped by actionable package-build errors and unrelated failures. */
|
||||
class ClientPackageCompositionError extends AggregateError {
|
||||
constructor(failures: Error[]) {
|
||||
const missingBundles = failures.filter((error): error is MissingClientBundleError => error instanceof MissingClientBundleError)
|
||||
const otherFailures = failures.filter(error => !(error instanceof MissingClientBundleError))
|
||||
const packageNoun = failures.length === 1 ? 'package' : 'packages'
|
||||
const lines = [`client-modules: ${String(failures.length)} client ${packageNoun} failed to compose:`]
|
||||
if (missingBundles.length > 0) {
|
||||
lines.push(` client bundles not found; ${CLIENT_BUNDLE_BUILD_INSTRUCTION}:`)
|
||||
for (const error of missingBundles) {
|
||||
lines.push(` - package: ${error.packageName}`, ` path: ${error.clientPath}`)
|
||||
}
|
||||
}
|
||||
if (otherFailures.length > 0) {
|
||||
lines.push(' other failures:', ...otherFailures.map(error => ` - ${error.message}`))
|
||||
}
|
||||
super(failures, lines.join('\n'))
|
||||
}
|
||||
}
|
||||
|
||||
/** One composed table row: the wire entry plus its bundle path. */
|
||||
interface WebPluginRecord {
|
||||
entry: WebBootEntry
|
||||
@@ -138,7 +179,7 @@ export function injectBootManifest(html: string, graph: WebBootGraph): string {
|
||||
* + bundle route + index tap. Construction runs the activation scan
|
||||
* synchronously — a malformed declaration or missing bundle among the
|
||||
* already-loaded entries aggregates into one loud throw (FAILED fiber; the
|
||||
* boot sweep reports it).
|
||||
* boot activation audit reports it).
|
||||
*/
|
||||
export class ClientModuleHostService extends Service {
|
||||
static inject = ['httpServer', 'loader']
|
||||
@@ -194,10 +235,7 @@ export class ClientModuleHostService extends Service {
|
||||
const failures: Error[] = []
|
||||
this.flush(err => failures.push(err))
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(
|
||||
failures,
|
||||
`client-modules: ${String(failures.length)} client package(s) failed to compose:\n${failures.map(e => ` - ${e.message}`).join('\n')}`,
|
||||
)
|
||||
throw new ClientPackageCompositionError(failures)
|
||||
}
|
||||
|
||||
ctx.effect(
|
||||
@@ -322,6 +360,22 @@ export class ClientModuleHostService extends Service {
|
||||
return meta
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the activation-time bundle revision.
|
||||
* @param pkgName - package that declares the client bundle.
|
||||
* @param clientPath - absolute path of the built client artifact.
|
||||
* @returns the bundle content's short hash for use as its revision.
|
||||
* @throws {MissingClientBundleError} when the read fails with `ENOENT`; other filesystem errors are rethrown unchanged.
|
||||
*/
|
||||
private initialBundleRevision(pkgName: string, clientPath: string): string {
|
||||
try {
|
||||
return shortHash(readFileSync(clientPath))
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
throw new MissingClientBundleError(pkgName, clientPath, error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Reconcile one entry name against the live loader entries. @returns whether the table changed. */
|
||||
private processOne(entryName: string): boolean {
|
||||
let qualifies = false
|
||||
@@ -337,7 +391,7 @@ export class ClientModuleHostService extends Service {
|
||||
if (meta === null) return false
|
||||
// The rev rides the row from here on: a fiber restart reuses the row (and
|
||||
// its rev) untouched; only rebuilt() re-reads the bundle.
|
||||
const rev = shortHash(readFileSync(meta.clientPath))
|
||||
const rev = this.initialBundleRevision(entryName, meta.clientPath)
|
||||
this.table.set(entryName, { entry: graphRow(entryName, rev, meta.inject, meta.immediately), clientPath: meta.clientPath })
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/** Node-half composition diagnostics for package metadata and built client bundles. */
|
||||
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { HttpServerService } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { ClientModuleHostService } from '../src/index.ts'
|
||||
|
||||
let root: string | undefined
|
||||
|
||||
afterEach(() => {
|
||||
if (root !== undefined) rmSync(root, { recursive: true, force: true })
|
||||
root = undefined
|
||||
})
|
||||
|
||||
/** Create a resolvable dshClient package whose client export points at the returned path. */
|
||||
function writePackage(packageName: string): string {
|
||||
root ??= realpathSync(mkdtempSync(join(tmpdir(), 'dsh-client-modules-')))
|
||||
const pkgRoot = join(root, 'node_modules', ...packageName.split('/'))
|
||||
const clientPath = join(pkgRoot, 'lib', 'client.js')
|
||||
mkdirSync(pkgRoot, { recursive: true })
|
||||
writeFileSync(join(pkgRoot, 'package.json'), JSON.stringify({
|
||||
name: packageName,
|
||||
exports: {
|
||||
'./client': './lib/client.js',
|
||||
'./package.json': './package.json',
|
||||
},
|
||||
dshClient: { platform: 'web' },
|
||||
}))
|
||||
return clientPath
|
||||
}
|
||||
|
||||
/** Construct the node-half service over the enabled fixture entries. */
|
||||
function construct(packageNames: string[]): ClientModuleHostService {
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(root!).href + '/'
|
||||
ctx.provide('loader', {
|
||||
*entries() {
|
||||
for (const packageName of packageNames) {
|
||||
yield { options: { name: packageName }, fiber: {}, disabled: false }
|
||||
}
|
||||
},
|
||||
})
|
||||
const httpServer: Pick<HttpServerService, 'port' | 'register' | 'tapIndex'> = {
|
||||
port: 0,
|
||||
register: () => () => {},
|
||||
tapIndex: () => () => {},
|
||||
}
|
||||
ctx.provide('httpServer', httpServer as HttpServerService)
|
||||
return new ClientModuleHostService(ctx)
|
||||
}
|
||||
|
||||
describe('client bundle activation', () => {
|
||||
it('groups missing bundles under one source-build instruction with a package/path list', () => {
|
||||
const firstName = '@fixture/missing-first'
|
||||
const secondName = '@fixture/missing-second'
|
||||
const firstPath = writePackage(firstName)
|
||||
const secondPath = writePackage(secondName)
|
||||
expect(() => construct([firstName, secondName])).toThrow([
|
||||
'client-modules: 2 client packages failed to compose:',
|
||||
' client bundles not found; run `pnpm run build` before launch:',
|
||||
` - package: ${firstName}`,
|
||||
` path: ${firstPath}`,
|
||||
` - package: ${secondName}`,
|
||||
` path: ${secondPath}`,
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
it('does not report other bundle read failures as missing builds', () => {
|
||||
const packageName = '@fixture/unreadable-client'
|
||||
const clientPath = writePackage(packageName)
|
||||
mkdirSync(clientPath, { recursive: true })
|
||||
let thrown: unknown
|
||||
try {
|
||||
construct([packageName])
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
expect(String(thrown)).toContain('client-modules: 1 client package failed to compose:')
|
||||
expect(String(thrown)).toContain(' other failures:')
|
||||
expect(String(thrown)).toContain('EISDIR')
|
||||
expect(String(thrown)).not.toContain('pnpm run build')
|
||||
})
|
||||
})
|
||||
@@ -56,6 +56,7 @@ async function loadComposition(bindHost: '127.0.0.1' | '0.0.0.0'): Promise<{ ctx
|
||||
' config:',
|
||||
` host: '${bindHost}'`,
|
||||
' port: 0',
|
||||
' portConflict: increment',
|
||||
` distIndex: '${distIndex}'`,
|
||||
`- name: '${AUTO}'`,
|
||||
'',
|
||||
|
||||
@@ -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/app-boot/README.md
|
||||
README.md: 1beffd6fbff2b84202683b010cd104f7c84297c7
|
||||
README.zh.md: d9ce9774b9b492a98556bbd9aa4564b711dbe40e
|
||||
README.md: 54f754842d9a6673ed6791b94656139f0f1be6a3
|
||||
README.zh.md: dd56084812e8241f0db24601ce2baeba51252d42
|
||||
@@ -10,16 +10,16 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c
|
||||
| `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) |
|
||||
| `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) |
|
||||
| `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure |
|
||||
| `assertEntriesActive(ctx, binName)` | Throw when a settled enabled fiber is not ACTIVE, including missing injected services for PENDING entries |
|
||||
| `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services |
|
||||
| `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws |
|
||||
| `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape as personal config; read or parse failures throw a labelled error |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and ACTIVE, and return the root context |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and activated, and return the root context |
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
|
||||
| `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under |
|
||||
|
||||
Two failure classes the guards handle: `loader.await()` swallows init rejections (`Promise.allSettled`) — Node still exits non-zero on the resulting unhandled rejection, and `installFailLoud` replaces the noisy dump with one labelled line and a guaranteed `exit(1)`; a failed plugin import is only logged by the Loader (the process would otherwise exit 0 on a usable config typo), leaving a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every failed plugin.
|
||||
Two Loader failure classes require separate guards because tree settlement propagates neither to its caller. A failed plugin import leaves a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every unresolved plugin. A plugin callback or config failure leaves a failed fiber because `loader.await()` settles lifecycle tasks without propagating that error; `assertEntriesActivated` awaits the fiber explicitly and includes its original stack in the startup rejection. Before throwing, the audit marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while every unrelated unhandled rejection remains fatal.
|
||||
|
||||
Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers.
|
||||
Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers.
|
||||
|
||||
This package carries no loader hooks and no dev-mode surface. The [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence; built consumers continue to use plain Node package resolution.
|
||||
|
||||
|
||||
@@ -10,16 +10,16 @@
|
||||
| `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`(Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr) |
|
||||
| `installFailLoud(binName, proc?)` | 将 `boot()` 之后未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) |
|
||||
| `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 |
|
||||
| `assertEntriesActive(ctx, binName)` | 树结算后,如果已启用的 fiber 未处于 ACTIVE 状态,则抛出异常;对于 PENDING 条目还会列出缺失的注入服务 |
|
||||
| `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 |
|
||||
| `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 |
|
||||
| `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件,其形状与个人配置相同;读取或解析失败时抛出带标签的错误 |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载且处于 ACTIVE 状态,最后返回根上下文 |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文 |
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)自身源代码 checkout 的磁盘路径;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 |
|
||||
| `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 |
|
||||
|
||||
这些保护处理两类故障。`loader.await()` 会吞掉初始化 rejection(`Promise.allSettled`);Node 仍会因随后产生的未处理 rejection 以非零状态退出,而 `installFailLoud` 会把冗长转储替换为一行带标签的消息,并确保执行 `exit(1)`。插件导入失败则只会由 Loader 记录日志(否则,即使配置存在拼写错误,进程也会以代码 0 退出),并留下没有 fiber 的条目;`assertEntriesLoaded` 会将其转换为 `boot()` rejection,并在其中列出每个导入失败插件的名称。
|
||||
Loader 树结算不会向调用方传播两类故障,因此需要分别保护。插件导入失败会留下没有 fiber 的配置项,`assertEntriesLoaded` 将其转换为 `boot()` rejection,并列出每个未解析插件。插件回调或配置失败则会留下失败的 fiber,因为 `loader.await()` 只结算生命周期任务,不传播该错误;`assertEntriesActivated` 会显式等待该 fiber,并把原始错误堆栈写入启动 rejection。抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而所有无关的未处理 rejection 仍然致命。
|
||||
|
||||
配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUI/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。
|
||||
配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与 host 会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUI/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。
|
||||
|
||||
此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md)持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。
|
||||
|
||||
|
||||
@@ -160,16 +160,46 @@ export interface FailLoudProcess {
|
||||
exit(code: number): void
|
||||
}
|
||||
|
||||
// Loader rc.5 derives and drops a rejected promise after a fiber fails. Keep
|
||||
// exact reasons already folded into the boot diagnostic visible through the
|
||||
// next process rejection checkpoint so the process guard can coalesce them.
|
||||
const assembledActivationRejections = new Map<unknown, number>()
|
||||
|
||||
function retainAssembledRejection(reason: unknown): void {
|
||||
assembledActivationRejections.set(reason, (assembledActivationRejections.get(reason) ?? 0) + 1)
|
||||
}
|
||||
|
||||
function releaseAssembledRejection(reason: unknown): void {
|
||||
const count = assembledActivationRejections.get(reason)
|
||||
if (count === undefined || count === 1) {
|
||||
assembledActivationRejections.delete(reason)
|
||||
} else {
|
||||
assembledActivationRejections.set(reason, count - 1)
|
||||
}
|
||||
}
|
||||
|
||||
async function observeLoaderRejectionCheckpoint(reasons: readonly unknown[]): Promise<void> {
|
||||
for (const reason of reasons) retainAssembledRejection(reason)
|
||||
try {
|
||||
await new Promise<void>(resolve => setImmediate(resolve))
|
||||
} finally {
|
||||
for (const reason of reasons) releaseAssembledRejection(reason)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install before boot to turn a late unhandled plugin-init rejection into one
|
||||
* labelled stderr diagnostic and `exit(1)`. Stdout remains untouched for ACP;
|
||||
* the returned function removes the handler.
|
||||
* labelled stderr diagnostic and `exit(1)`. A rejection already included by
|
||||
* {@link assertEntriesActivated} is ignored during its process checkpoint;
|
||||
* every other rejection remains fatal. Stdout remains untouched for ACP; the
|
||||
* returned function removes the handler.
|
||||
* @param binName - the diagnostic prefix on the fatal-failure line.
|
||||
* @param proc - the process slice to register on; tests inject a fake.
|
||||
* @returns the uninstaller that removes the rejection handler.
|
||||
*/
|
||||
export function installFailLoud(binName: string, proc: FailLoudProcess = process): () => void {
|
||||
const handler = (err: unknown): void => {
|
||||
if (assembledActivationRejections.has(err)) return
|
||||
proc.stderr.write(`${binName}: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`)
|
||||
proc.exit(1)
|
||||
}
|
||||
@@ -192,28 +222,64 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Runtime mirrors for Cordis's erased const-enum fiber states. */
|
||||
const FIBER_ACTIVE = 2 as FiberState.ACTIVE
|
||||
/**
|
||||
* Value mirrors used because Cordis's const enum has no runtime object to import.
|
||||
* Keep aligned with `packages/cordis/tool-cordis/src/fiber-state.ts` and
|
||||
* `packages/client/web/src/loader-status.ts`.
|
||||
*/
|
||||
const FIBER_PENDING = 0 as FiberState.PENDING
|
||||
const FIBER_ACTIVE = 2 as FiberState.ACTIVE
|
||||
const FIBER_FAILED = 3 as FiberState.FAILED
|
||||
|
||||
/** Render a thrown plugin value without discarding an Error's original stack. */
|
||||
function formatActivationError(error: unknown): string {
|
||||
return error instanceof Error ? error.stack ?? error.message : String(error)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject enabled Loader entries whose fibers did not reach ACTIVE after settle.
|
||||
* @param ctx - The settled application root.
|
||||
* @param binName - Diagnostic prefix.
|
||||
* Reject a settled Loader tree when an enabled entry failed or remains inactive.
|
||||
* Plugin failures include the original thrown stack; pending entries name their
|
||||
* unresolved services because no plugin error exists for that state. Active
|
||||
* entries require no further wait; only failed fibers are awaited to recover
|
||||
* their private rejection reason.
|
||||
* @param ctx - the settled context whose Loader entries to audit.
|
||||
* @param binName - the diagnostic prefix on the thrown error.
|
||||
* @returns nothing when every enabled entry is active.
|
||||
* @throws after one process rejection checkpoint when an entry failed to
|
||||
* import, rejected during activation, or did not become active.
|
||||
*/
|
||||
export function assertEntriesActive(ctx: Context, binName: string): void {
|
||||
export async function assertEntriesActivated(ctx: Context, binName: string): Promise<void> {
|
||||
assertEntriesLoaded(ctx, binName)
|
||||
const failures: string[] = []
|
||||
const rejectionReasons: unknown[] = []
|
||||
for (const entry of ctx.loader.entries()) {
|
||||
if (entry.fiber === undefined || entry.disabled || entry.fiber.state === FIBER_ACTIVE) continue
|
||||
if (entry.fiber.state === FIBER_PENDING) {
|
||||
const missing = Object.keys(entry.fiber.inject).filter(service => ctx.get(service) === undefined)
|
||||
failures.push(`${entry.options.name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
|
||||
const fiber = entry.fiber
|
||||
if (fiber === undefined || entry.disabled) continue
|
||||
const state = fiber.state
|
||||
if (state === FIBER_ACTIVE) continue
|
||||
if (state === FIBER_FAILED) {
|
||||
try {
|
||||
await fiber.await()
|
||||
} catch (error) {
|
||||
rejectionReasons.push(error)
|
||||
failures.push(`${entry.options.name}: ${formatActivationError(error)}`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (state === FIBER_PENDING) {
|
||||
const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined)
|
||||
const subject = missing.length === 1 ? 'service' : 'services'
|
||||
failures.push(`${entry.options.name}: pending (waiting for ${subject}: ${missing.join(', ') || 'unknown'})`)
|
||||
} else {
|
||||
failures.push(`${entry.options.name}: fiber state ${String(entry.fiber.state)}`)
|
||||
failures.push(`${entry.options.name}: fiber state ${String(state)}`)
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new Error(`${binName}: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
|
||||
if (rejectionReasons.length > 0) {
|
||||
await observeLoaderRejectionCheckpoint(rejectionReasons)
|
||||
}
|
||||
const noun = failures.length === 1 ? 'entry' : 'entries'
|
||||
throw new Error(`${binName}: ${String(failures.length)} ${noun} did not activate\n${failures.join('\n')}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,9 +291,13 @@ export function assertEntriesActive(ctx: Context, binName: string): void {
|
||||
* bootstrap include is therefore statically imported and mounted as the
|
||||
* `cordis:include` builtin, loading through the ambient module pipeline
|
||||
* (vite/tsx/plain ESM) while the included tree's own specifiers stay
|
||||
* config-relative. A missing fiber rejects here; a later init rejection is
|
||||
* handled by {@link installFailLoud}. Built bins need the Loader's native
|
||||
* helper for bare plugin specifiers; relative specifiers do not.
|
||||
* config-relative. The package build embeds Include while leaving Loader
|
||||
* external, so the built include tree and host share one Loader peer. A
|
||||
* missing fiber rejects here; a later init rejection is rethrown with its
|
||||
* original stack by {@link assertEntriesActivated}; later unhandled
|
||||
* rejections remain covered by {@link installFailLoud}. Built bins need the
|
||||
* Loader's native helper for bare plugin specifiers; relative specifiers do
|
||||
* not.
|
||||
* @param binName - the diagnostic prefix for load-failure errors.
|
||||
* @param absoluteConfigPath - the config to include; must already be absolute
|
||||
* (see {@link resolveConfigPath}).
|
||||
@@ -259,12 +329,11 @@ export async function boot(
|
||||
// A surface can finish and dispose the whole tree while that await is still
|
||||
// pending: the TUI renders as soon as its own fiber starts, so an `/exit`
|
||||
// typed before the last entry settles tears the context down under us. The
|
||||
// Loader service goes with it, and both assertions below describe a live
|
||||
// Loader service goes with it, and the activation audit describes a live
|
||||
// tree — reading `ctx.loader` here would throw a TypeError over an app that
|
||||
// exited exactly as asked.
|
||||
if (ctx.get('loader') === undefined) return ctx
|
||||
assertEntriesLoaded(ctx, binName)
|
||||
assertEntriesActive(ctx, binName)
|
||||
await assertEntriesActivated(ctx, binName)
|
||||
return ctx
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import {
|
||||
addHarnessSourceSection, assertEntriesActive, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
|
||||
addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
|
||||
installFailLoud, loadEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess,
|
||||
} from '../src/index.ts'
|
||||
|
||||
@@ -135,6 +135,33 @@ describe('installFailLoud', () => {
|
||||
uninstallReal()
|
||||
expect(process.listenerCount('unhandledRejection')).toBe(before)
|
||||
})
|
||||
|
||||
it('does not report an activation rejection shared by entries in the boot audit', async () => {
|
||||
const proc = fakeProc()
|
||||
installFailLoud(NAME, proc)
|
||||
const error = new Error('assembled activation failure')
|
||||
const audit = assertEntriesActivated({
|
||||
loader: {
|
||||
entries: () => ['broken-a', 'broken-b'].map(name => ({
|
||||
options: { name },
|
||||
fiber: {
|
||||
state: 3,
|
||||
inject: {},
|
||||
ctx: { get: () => undefined },
|
||||
await: async () => { throw error },
|
||||
},
|
||||
})),
|
||||
},
|
||||
} as unknown as Context, NAME)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
proc.handlers[0]!(error)
|
||||
expect(proc.written).toEqual([])
|
||||
expect(proc.exits).toEqual([])
|
||||
await expect(audit).rejects.toThrow('assembled activation failure')
|
||||
proc.handlers[0]!(error)
|
||||
expect(proc.exits).toEqual([1])
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertEntriesLoaded', () => {
|
||||
@@ -157,6 +184,97 @@ describe('assertEntriesLoaded', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertEntriesActivated', () => {
|
||||
interface FakeFiber {
|
||||
state: number
|
||||
inject: Record<string, unknown>
|
||||
ctx: { get(name: string): unknown }
|
||||
await(): Promise<unknown>
|
||||
}
|
||||
|
||||
const ctxWith = (entries: Array<{ fiber?: FakeFiber; disabled?: boolean; options: { name: string } }>): Context => ({
|
||||
loader: { entries: () => entries },
|
||||
}) as unknown as Context
|
||||
|
||||
const fiber = (
|
||||
state: number,
|
||||
error?: unknown,
|
||||
inject: Record<string, unknown> = {},
|
||||
services: string[] = [],
|
||||
): FakeFiber => ({
|
||||
state,
|
||||
inject,
|
||||
ctx: { get: name => services.includes(name) ? {} : undefined },
|
||||
await: error === undefined ? async () => undefined : async () => { throw error },
|
||||
})
|
||||
|
||||
it('passes active entries and ignores disabled entries', async () => {
|
||||
let awaitCalls = 0
|
||||
const active = fiber(2)
|
||||
active.await = async () => {
|
||||
awaitCalls++
|
||||
return undefined
|
||||
}
|
||||
const disabled = fiber(3, new Error('disabled failure'))
|
||||
disabled.await = async () => {
|
||||
awaitCalls++
|
||||
throw new Error('disabled failure')
|
||||
}
|
||||
await expect(assertEntriesActivated(ctxWith([
|
||||
{ fiber: active, options: { name: 'active' } },
|
||||
{ fiber: disabled, disabled: true, options: { name: 'disabled' } },
|
||||
]), NAME)).resolves.toBeUndefined()
|
||||
expect(awaitCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('reports the plugin name and original activation stack instead of fiber state 3', async () => {
|
||||
const original = new Error('actual plugin failure')
|
||||
await expect(assertEntriesActivated(ctxWith([
|
||||
{ fiber: fiber(3, original), options: { name: 'broken-plugin' } },
|
||||
]), NAME)).rejects.toThrow(`${NAME}: 1 entry did not activate\nbroken-plugin: ${original.stack!}`)
|
||||
})
|
||||
|
||||
it('formats stackless and non-Error activation failures', async () => {
|
||||
const stackless = new Error('stackless failure')
|
||||
delete (stackless as { stack?: string }).stack
|
||||
await expect(assertEntriesActivated(ctxWith([
|
||||
{ fiber: fiber(3, stackless), options: { name: 'stackless' } },
|
||||
{ fiber: fiber(3, 'plain failure'), options: { name: 'plain' } },
|
||||
]), NAME)).rejects.toThrow(`${NAME}: 2 entries did not activate\nstackless: stackless failure\nplain: plain failure`)
|
||||
})
|
||||
|
||||
it('reports unresolved services for pending entries', async () => {
|
||||
let awaitCalls = 0
|
||||
const expected = [
|
||||
`${NAME}: 3 entries did not activate`,
|
||||
'waiting: pending (waiting for services: missingA, missingB)',
|
||||
'single-wait: pending (waiting for service: missing)',
|
||||
'unknown-wait: pending (waiting for services: unknown)',
|
||||
].join('\n')
|
||||
const waiting = fiber(0, undefined, { ready: {}, missingA: {}, missingB: {} }, ['ready'])
|
||||
const singleWait = fiber(0, undefined, { missing: {} })
|
||||
const unknownWait = fiber(0)
|
||||
for (const item of [waiting, singleWait, unknownWait]) {
|
||||
item.await = async () => {
|
||||
awaitCalls++
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
await expect(assertEntriesActivated(ctxWith([
|
||||
{ fiber: waiting, options: { name: 'waiting' } },
|
||||
{ fiber: singleWait, options: { name: 'single-wait' } },
|
||||
{ fiber: unknownWait, options: { name: 'unknown-wait' } },
|
||||
]), NAME)).rejects.toThrow(expected)
|
||||
expect(awaitCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('retains the numeric diagnostic for a settled unexpected state', async () => {
|
||||
await expect(assertEntriesActivated(ctxWith([
|
||||
{ fiber: fiber(4), options: { name: 'disposed' } },
|
||||
]), NAME)).rejects.toThrow('disposed: fiber state 4')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadOverlayPatches', () => {
|
||||
it('loads expressions and rejects missing, malformed, non-array, and non-mapping overlays', () => {
|
||||
const dir = tmp()
|
||||
@@ -232,31 +350,14 @@ describe('boot', () => {
|
||||
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`)
|
||||
})
|
||||
|
||||
it('rejects a settled tree with a pending inject and names every missing service', async () => {
|
||||
it('reports a pending real Loader fiber and the service unresolved in its own context', async () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, 'waiting.mjs'), "export const inject = ['alpha', 'beta']\nexport function apply() {}\n")
|
||||
writeFileSync(join(dir, 'waiting.mjs'), 'export const inject = ["neverProvided"]\nexport function apply() {}\n')
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: waiting\n name: ./waiting.mjs\n')
|
||||
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow('./waiting.mjs: pending (waiting for services: alpha, beta)')
|
||||
})
|
||||
|
||||
it('uses singular diagnostics for one missing pending dependency', () => {
|
||||
const ctx = {
|
||||
loader: { entries: () => [{ disabled: false, options: { name: 'waiting' }, fiber: { state: 0, inject: { alpha: {} } } }] },
|
||||
get: () => undefined,
|
||||
} as unknown as Context
|
||||
expect(() =>{ assertEntriesActive(ctx, NAME) }).toThrow('waiting: pending (waiting for service: alpha)')
|
||||
})
|
||||
|
||||
it('reports unknown pending dependencies and unexpected fiber states', () => {
|
||||
const entries = [
|
||||
{ disabled: false, options: { name: 'unknown' }, fiber: { state: 0, inject: {} } },
|
||||
{ disabled: false, options: { name: 'failed' }, fiber: { state: 3, inject: {} } },
|
||||
]
|
||||
const ctx = {
|
||||
loader: { entries: () => entries },
|
||||
get: () => undefined,
|
||||
} as unknown as Context
|
||||
expect(() =>{ assertEntriesActive(ctx, NAME) }).toThrow(`${NAME}: 2 entries did not activate\nunknown: pending (waiting for services: unknown)\nfailed: fiber state 3`)
|
||||
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow([
|
||||
`${NAME}: 1 entry did not activate`,
|
||||
'./waiting.mjs: pending (waiting for service: neverProvided)',
|
||||
].join('\n'))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
/**
|
||||
* Embed Include while keeping Loader external so the built include tree and
|
||||
* app host bind to one Loader peer.
|
||||
*/
|
||||
export default defineConfig({
|
||||
entry: ['lib/types/index.js', 'lib/types/invariant.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
deps: {
|
||||
alwaysBundle: ['@cordisjs/plugin-include'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user