Browse Source

Merge branch 'feat/plugin-mgmt-1-boot' into feat/plugin-mgmt-2-manager

Yichen Jiang 1 month ago
parent
commit
db2e34dd02

+ 2 - 2
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

+ 1 - 1
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.

+ 1 - 1
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`(此后的一切失败),并追加最深层插件错误的堆栈,使启动诊断保留原始激活错误,而不只是包装链。

+ 23 - 2
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<PluginProbe>
   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.

+ 10 - 3
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<string> =>
+      (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)