diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index b5952cca66..af914c1f4c 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md -README.md: e72c6f302f5e5a0f3f4c209393b715c992d5a528 -README.zh.md: f063d0c7bdc35a42c0ef372971cc2ca6086b0372 +README.md: d8251091c67d562698e69459bda44ea805914833 +README.zh.md: c2ecfa9b5717529b041a45c3773ab624719e5a1b diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index e72c6f302f..d8251091c6 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -89,7 +89,7 @@ This section explains how the outcomes above are realized and points at the code - **External bundles are groups.** The vendored `EntryGroup.update` is all-or-nothing, so `composeExternalLayer` wraps each `runtime`-stage external layer's inserts in one `cordis:contained-group` under the ids the bundle declares; the group's `create()` records a failed row on the root's `pluginFailures` registry instead of rejecting, and `assertEntriesActivated` exempts recorded rows while still failing a built-in row left pending. - **Row ids are owned, not rewritten.** Entry ids are unique per tree and a `create()` that finds an existing id re-parents that entry instead of rejecting, so `composeProfileStack` decides ownership before anything mounts: built-in and boot-staged layers claim first and a duplicate among them fails the boot, a contained bundle that collides is left out whole, a user insert of a taken id is dropped, and every such row is a `conflict` record in `pluginFailures`. Boot, live recomposition, and `--dump-config` compose through the same function. - **Fail-loud is boot-scoped.** `installFailLoud` exits on any unhandled rejection because during startup one is a load failure; the launcher uninstalls it once the tree is up and installs `installRuntimeGuards`, which reports a rejection and keeps running and exits on an uncaught exception. Nested fibers (a `ctx.inject()` continuation) that fail under a built-in entry are reported by `warnNestedFiberFailures` as advisory lines. -- **The probe never runs a package in the host.** `probePackage` reads an installed package's manifest here and imports it in a child process, so a package that throws, exits, hangs, or brings its own copy of cordis costs one child and yields a record with the reason. +- **The probe never runs a package in the host.** `probePackage` reads an installed package's manifest here and imports it in a child process, so a package that throws, exits, hangs, or brings its own copy of cordis costs one child and yields a record with the reason. It calls a package a `plugin` only when the package declares itself to dsh — a `dsh` section or a dependency on `@deepseek-ai/cordis` — and its main export is plugin-shaped; a bare function export (`lodash`) is a `library`. - **Profile module fallback.** Bare plugin specifiers resolve through the Loader from the config directory. Plain Node maintains one symlink per package in the installation dependency closure. A packaged executable instead reads each installed export map with Node ESM conditions and writes real proxy packages that re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. Missing exports stay unavailable, malformed maps fail startup, and a cross-process writer lock replaces stale entries without exposing partial proxies. A selected external bundle absent from the installation closure receives a profile-local `.dsh-module-fallback` link; existing pnpm entries win, projected links are excluded from later closure discovery, and cleanup removes only dsh-owned links. - **One rejection checkpoint.** `assertEntriesActivated` keeps the exact reasons it folds into the boot diagnostic visible through the next process rejection checkpoint, so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. - **Two-stage failure labels.** `boot()` distinguishes `host preparation failed` — `prepare` threw before any config-tree entry mounted — from `plugin tree failed to load`, and appends the deepest plugin error's stack so the startup diagnostic preserves the original activation error instead of only the wrap chain. diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index f063d0c7bd..c2ecfa9b57 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -89,7 +89,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head - **外部组合包即组。** vendored 的 `EntryGroup.update` 是整组事务,因此 `composeExternalLayer` 把每个 `runtime` 阶段外部层的插入行按组合包声明的 id 包进一个 `cordis:contained-group`;该组的 `create()` 把失败的行记录到根上的 `pluginFailures` 注册表而不是 reject,`assertEntriesActivated` 豁免已记录的行,但内置行停在 pending 时仍然失败。 - **行 id 归属而非改写。** entry id 在整棵树内唯一,而 `create()` 遇到已有 id 时会把那个 entry 挪到自己名下而不是 reject,所以 `composeProfileStack` 在任何行挂载之前先判定归属:内置层与 boot 阶段的层先占有 id,它们之间重复即启动失败;撞名的受控组合包整层排除;用户层插入已被占用的 id 时该行丢弃;每一条被排除的行都是 `pluginFailures` 里的一条 `conflict` 记录。启动、运行时重组与 `--dump-config` 走同一个函数。 - **fail-loud 只在启动期。** `installFailLoud` 对任何未处理 rejection 退出,因为启动期间它就是加载失败;树起来后 launcher 卸载它并安装 `installRuntimeGuards`:rejection 被报告并继续运行,未捕获异常被报告并退出。内置条目下失败的嵌套 fiber(`ctx.inject()` 的延续)由 `warnNestedFiberFailures` 以提示行报告。 -- **探针从不在宿主内运行包。** `probePackage` 在本进程读取已安装包的 manifest,在子进程里 import 它,因此抛错、退出、挂起或自带 cordis 副本的包只消耗一个子进程,得到一条带原因的记录。 +- **探针从不在宿主内运行包。** `probePackage` 在本进程读取已安装包的 manifest,在子进程里 import 它,因此抛错、退出、挂起或自带 cordis 副本的包只消耗一个子进程,得到一条带原因的记录。只有包向 dsh 声明了自己——有 `dsh` 段或依赖 `@deepseek-ai/cordis`——且主导出是插件形状时才判为 `plugin`;光是导出一个函数(`lodash`)的包是 `library`。 - **Profile 模块后备机制。** 裸插件 specifier 由 Loader 从配置目录解析。普通 Node 会为安装依赖闭包中的每个包维护一个符号链接。打包可执行文件无法让操作系统符号链接进入 pkg 的 `/snapshot` 树,因此会按 Node ESM 条件读取已安装包的 export map,并写入重新导出虚拟模块 URL 的真实代理包。缺失 export 保持不可用,错误 export map 会让启动失败,跨进程 writer lock 则会在不暴露部分代理的情况下替换陈旧条目。所选外部 bundle 若不在安装闭包中,则会获得 profile 本地的 `.dsh-module-fallback` 链接;已有 pnpm 条目优先,后续闭包发现会排除投影链接,清理也只删除 dsh 自有链接。 - **单一 rejection 检查点。** `assertEntriesActivated` 把折入启动诊断的确切原因保持到下一个进程级 rejection 检查点可见,使 `installFailLoud` 能合并 Loader 的重复通知,而所有无关的未处理 rejection 仍然致命。 - **两阶段失败标签。** `boot()` 区分 `host preparation failed`(`prepare` 在任何配置树条目挂载前抛出)与 `plugin tree failed to load`(此后的一切失败),并追加最深层插件错误的堆栈,使启动诊断保留原始激活错误,而不只是包装链。 diff --git a/packages/boot/app-boot/src/probe.ts b/packages/boot/app-boot/src/probe.ts index e4adeed8d6..fd2b588f4e 100644 --- a/packages/boot/app-boot/src/probe.ts +++ b/packages/boot/app-boot/src/probe.ts @@ -53,7 +53,12 @@ export interface PluginProbe { readonly description?: string /** Display title from `dsh.title`, when declared. */ readonly title?: string - /** `bundle` (declares `dsh.bundle`), `plugin` (its main export is a cordis plugin), else `library`. */ + /** + * `bundle` (declares `dsh.bundle`); `plugin` (declares itself to dsh — a + * `dsh` section or a dependency on `@deepseek-ai/cordis` — and its main + * export is a cordis plugin); else `library`. A function export alone is + * not a plugin: `lodash` exports one too. + */ readonly kind: 'bundle' | 'plugin' | 'library' /** Whether the package can be enabled or added: it imported and shares the harness's cordis. */ readonly ok: boolean @@ -200,6 +205,20 @@ function describeBundlePatch(binName: string, patchPath: string): { rows: Plugin return { rows, overrides } } +/** + * Whether a package declares itself to dsh: a `dsh` section in its manifest, + * or a dependency on `@deepseek-ai/cordis`. A cordis plugin needs at least + * cordis's types, and the harness's plugin conventions put display and + * composition metadata under `dsh`; a package with neither that happens to + * export a function (`lodash`, `debug`) is a library. + * @param manifest - the package manifest. + * @returns true for a package that declares itself to dsh. + */ +function declaresDsh(manifest: ProbedManifest): boolean { + if (manifest.dsh !== undefined) return true + return '@deepseek-ai/cordis' in (manifest.peerDependencies ?? {}) || '@deepseek-ai/cordis' in (manifest.dependencies ?? {}) +} + /** * Probe one installed package: read its manifest, then import it in a child * process. The child never runs inside the host, so a package that throws at @@ -221,7 +240,9 @@ export async function probePackage(options: ProbeOptions): Promise const report = await runChild(options, packageDir, hasMain ? options.packageName : '', declaredAddable.map(entry => entry.name)) const harnessCordis = resolveHarnessCordis() const cordisSameCopy = report.cordis === null || harnessCordis === undefined ? null : report.cordis === harnessCordis - const kind: PluginProbe['kind'] = declaredBundle !== undefined ? 'bundle' : report.main.isPlugin ? 'plugin' : 'library' + const kind: PluginProbe['kind'] = declaredBundle !== undefined + ? 'bundle' + : report.main.isPlugin && declaresDsh(manifest) ? 'plugin' : 'library' const addable: PluginProbeAddable[] = declaredAddable.map((entry) => { // The child reports every declared name; a missing report is a child // that did not run the loop, which the report parse already rejected. diff --git a/packages/boot/app-boot/tests/probe.spec.ts b/packages/boot/app-boot/tests/probe.spec.ts index c8918d73d6..7ffac9bccd 100644 --- a/packages/boot/app-boot/tests/probe.spec.ts +++ b/packages/boot/app-boot/tests/probe.spec.ts @@ -99,12 +99,19 @@ describe('probePackage', () => { expect(Date.parse(probe.checkedAt)).not.toBeNaN() }) - it('classifies a plain plugin package and a library', async () => { + it('classifies a plugin by its declaration to dsh, and a library by the lack of one', async () => { const { profileDir, installAnchor } = stage({ - 'plain-plugin': { main: 'export function apply() {}\n' }, + 'peer-plugin': { main: 'export function apply() {}\n', manifest: { peerDependencies: { '@deepseek-ai/cordis': '*' } } }, + 'titled-plugin': { main: 'export default { apply() {} }\n', manifest: { dsh: { title: 'Titled' } } }, 'plain-lib': { main: 'export const helper = 1\n' }, + // A function export alone is not a plugin: lodash exports one too. + 'function-lib': { main: 'export default function _() {}\n' }, }) - expect((await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'plain-plugin' })).kind).toBe('plugin') + const kindOf = async (packageName: string): Promise => + (await probePackage({ binName: NAME, profileDir, installAnchor, packageName })).kind + expect(await kindOf('peer-plugin')).toBe('plugin') + expect(await kindOf('titled-plugin')).toBe('plugin') + expect(await kindOf('function-lib')).toBe('library') const lib = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'plain-lib' }) expect(lib.kind).toBe('library') expect(lib.ok).toBe(true)