refactor(plugins): manage static declarations and native row outcomes
This commit is contained in:
1 parent
96be622603
commit
b351ca0578
40 files changed
+583
-361
No files matched your search
+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-09-04-plugin-manager-over-the-profile-runtime.md
|
||||
2026-09-04-plugin-manager-over-the-profile-runtime.md: 472d5655c069e45b8c966d82423558808862a48b
|
||||
2026-09-04-plugin-manager-over-the-profile-runtime.zh.md: a5c368bacffd4c1b4668b48fa21c27052b1709c9
|
||||
2026-09-04-plugin-manager-over-the-profile-runtime.md: 0d101b4c134bdf779d640275d5dea267f920a96b
|
||||
2026-09-04-plugin-manager-over-the-profile-runtime.zh.md: 582aca7d0b7395860d7dcd1277ad97b48d9b08db
|
||||
+3
-1
@@ -4,6 +4,8 @@ Status: implemented
|
||||
|
||||
English | [中文](2026-09-04-plugin-manager-over-the-profile-runtime.zh.md)
|
||||
|
||||
Enablement transactions, contained failure records and execution probes in this record are superseded by [native entry diagnostics and static declarations](2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.md). The manager/Remote separation, pnpm process choice, mutation exclusion and patch-file ownership remain applicable.
|
||||
|
||||
## Problem
|
||||
|
||||
Installing a plugin was a terminal-only act: `dsh plugin --profile web add <spec>` ran pnpm, appended every bundle it found to `dsh.profile.bundles`, and the next start composed it. Nothing running could learn what was installed but not enabled, switch a bundle off without editing `package.json` by hand, add one package's module to the profile's user layer or to one agent preset, or say which rows a package's service kept alive. The Web surface could list rows through `pluginInventory/list` and nothing more, while the launcher's `profileRuntime` (previous note) already recomposed the tree on a user patch reload and `reconcileInstalledBundles` already separated installation from enablement. The missing piece was the host service that performs the operations and reports each package as one thing.
|
||||
@@ -38,4 +40,4 @@ A running Web host can install, enable, disable, retry, and remove third-party b
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/boot/plugin-manager/tests/plugin-manager.spec.ts` boots a temporary profile through `boot()` with the profile runtime the launcher provides and a fake pnpm that edits the manifest the way the real one does: the view fold (installed, enabled, probed, waiting, user-disabled, first-party, hand-written manifests), install with and without enabling and its failures (exit code, spawn error, timeout, log tail, the manifest restored after a failed run), the post-install removal of a library and of a bundle whose row id another layer owns while a refused probe keeps its package, the one-mutation-at-a-time refusal and the running-session refusal, enable and disable live and on a `startup` profile, the boot-stage rollback, retry of a flaky isolated row, rows in the global and a preset's layer with conflicts, dependents by provided service and by user-layer reference, and uninstall. `packages/host/plugin-manager/tests/plugin-manager.spec.ts` pins the relay: every Remote method with its arguments, each `plugins/*` code crossing as a Remote error with the failure as its cause, the generic refusal as `gateway/bad-request`, and the readers into the context. `apps/cli/tests/plugin.spec.ts` pins `dsh plugin add` and `remove` through the installer with a fake pnpm and probe: the new bundle enabled, a plain library removed again with its reason, and the exit codes of a failed or missing pnpm. `packages/boot/app-boot/tests/contained-group.spec.ts` pins the waiting-row record across a reload.
|
||||
`packages/boot/plugin-manager/tests/plugin-manager.spec.ts` covers manager operations over real profile files and a native Loader tree. The Host adapter tests cover direct delegation, error codes and dependency-recovery notifications; `apps/cli/tests/plugin.spec.ts` covers the shared installer. Lifecycle and static-discovery verification follows the superseding note.
|
||||
+3
-1
@@ -4,6 +4,8 @@ Status: implemented
|
||||
|
||||
[English](2026-09-04-plugin-manager-over-the-profile-runtime.md) | 中文
|
||||
|
||||
本文中的启用事务、contained 失败记录和执行 probe 已由[原生条目诊断与静态声明](2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.zh.md)取代。管理器与 Remote 的拆分、pnpm 进程选择、修改互斥和 patch 文件归属仍然适用。
|
||||
|
||||
## 问题
|
||||
|
||||
安装插件曾是只有终端能做的事:`dsh plugin --profile web add <spec>` 运行 pnpm,把找到的每个组合包追加进 `dsh.profile.bundles`,下次启动再组合。运行中的任何东西都无法得知哪些包装了但没启用,无法不手改 `package.json` 就关掉一个组合包,无法把某个包的模块加进 profile 的用户层或某个 agent preset,也说不出一个包的服务撑着哪些行。Web 界面能经 `pluginInventory/list` 列出行,仅此而已;而 launcher 的 `profileRuntime`(前一篇笔记)已经能在用户 patch 重载时重新组合树,`reconcileInstalledBundles` 也已经把安装与启用分开。缺的是执行这些操作并把每个包报告成一个整体的宿主服务。
|
||||
@@ -38,4 +40,4 @@ Status: implemented
|
||||
|
||||
## 测试
|
||||
|
||||
`packages/boot/plugin-manager/tests/plugin-manager.spec.ts` 经 `boot()` 启动一个临时 profile,带上 launcher 提供的 profile runtime 与一个按真实 pnpm 的方式编辑 manifest 的假 pnpm:视图折叠(已安装、已启用、已探测、等待中、用户停用、一方包、手写 manifest),带与不带启用的安装及其失败(退出码、spawn 错误、超时、日志尾部、失败后恢复的 manifest),装后移除库包与行 id 被别的层占有的组合包而探针拒绝的包保留,一次只跑一个变更的拒绝与会话运行中的拒绝,live 与 `startup` profile 上的启用与停用,boot 阶段的回滚,不稳定隔离行的重试,全局层与 preset 层里的行及其冲突,按提供服务与按用户层引用的依赖检测,以及卸载。`packages/host/plugin-manager/tests/plugin-manager.spec.ts` 钉住转接层:每个 Remote 方法及其参数、每个 `plugins/*` 码以失败为 cause 过线成 Remote 错误、通用拒绝过线成 `gateway/bad-request`,以及读向上下文的读取器。`apps/cli/tests/plugin.spec.ts` 用假 pnpm 与假探针钉住经安装器的 `dsh plugin add` 与 `remove`:新组合包被启用、普通库带原因被再移除,以及 pnpm 失败或缺失时的退出码。`packages/boot/app-boot/tests/contained-group.spec.ts` 钉住等待中的行记录跨重载保留。
|
||||
`packages/boot/plugin-manager/tests/plugin-manager.spec.ts` 覆盖实际 profile 文件与原生 Loader 树上的管理操作。Host 适配器测试覆盖直接转接、错误码与依赖恢复通知;`apps/cli/tests/plugin.spec.ts` 覆盖共用安装器。生命周期与静态发现的验证遵循取代这些机制的新笔记。
|
||||
+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-09-11-native-entry-diagnostics-and-static-plugin-declarations.md
|
||||
2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.md: 58e41379ff3a0c8907f664d055c323986bc3ba0c
|
||||
2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.zh.md: b8ea8ef0266f0977f5db84e40c76eef0a0db4d4f
|
||||
2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.md: db2d9bc211b896a8ae8bbb230c7ab3a1e5183fd0
|
||||
2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.zh.md: 416d3c769a9474b9be85209ebaa55305a6eea1ac
|
||||
+2
@@ -39,3 +39,5 @@ Installed metadata, enabled layer selection and current runtime health are indep
|
||||
## Verification
|
||||
|
||||
`packages/boot/app-boot/tests/entry-issues.spec.ts` covers trust and stage, import/config/apply/disabled/pending failures, nested and anonymous provenance, active old config after failed updates, recovery, and awaited removed-fiber teardown. Composition tests cover duplicate ownership and unchanged parents. Static metadata tests use an import-time file-writing fixture and assert no execution, retain unknown packages, and reject malformed declarations. Inventory tests verify actual Loader failures and composition conflicts without a historical failure registry. Process-guard and user-patch tests retain fatal detached failures and best-effort live reload diagnostics.
|
||||
|
||||
The [manager/Remote split](2026-09-04-plugin-manager-over-the-profile-runtime.md) remains in force. Manager integration tests cover retained unknown packages, static declarations, per-entry enable results, failed overrides with original ownership, and runtime-change notifications after dependency recovery.
|
||||
+2
@@ -39,3 +39,5 @@ Status: implemented
|
||||
## Verification
|
||||
|
||||
`packages/boot/app-boot/tests/entry-issues.spec.ts` 覆盖 trust 与 stage、导入/配置/apply/disabled/pending 失败、嵌套与匿名来源、失败更新后的活跃旧配置、恢复及已移除 fiber 的清理等待。组合测试覆盖重复归属与父组保持。静态元信息测试使用导入时写文件的 fixture,确认未执行、保留未知包并拒绝无效声明。inventory 测试直接验证 Loader 失败与组合冲突,不依赖历史失败注册表。进程处理与用户 patch 测试保留脱离管理失败的致命策略,以及尽力执行的实时重载诊断。
|
||||
|
||||
[管理器与 Remote 的拆分](2026-09-04-plugin-manager-over-the-profile-runtime.zh.md)继续适用。管理器集成测试覆盖未知包保留、静态声明、逐行启用结果、保留原归属的失败覆盖,以及依赖恢复后的运行变化通知。
|
||||
@@ -2,9 +2,8 @@
|
||||
* `dsh plugin --profile <name> <args...>` — profile plugin management from
|
||||
* the terminal. `add <spec...>` and `remove <name...>` go through the plugin
|
||||
* installer the Web host shares: pnpm runs in the profile directory, every
|
||||
* new package is probed, a package that declares neither a bundle nor a
|
||||
* plugin module (or whose row id a composed layer already owns) is removed
|
||||
* again with the reason printed, and every newly installed bundle joins the
|
||||
* new package is read statically, a conflicting bundle is removed with its
|
||||
* reason printed, undeclared packages remain installed, and every new bundle joins the
|
||||
* layer list — the CLI's install-and-enable semantics. Every other pnpm verb
|
||||
* is forwarded verbatim and followed by a reconcile of the
|
||||
* `dsh.profile.bundles` layer list against the installed state, so `update`
|
||||
@@ -26,7 +25,7 @@ import {
|
||||
readProfileManifest,
|
||||
reconcileInstalledBundles,
|
||||
resolveProfileDir,
|
||||
type probePackage,
|
||||
type readPackageMetadata,
|
||||
type ProfileManifest,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import {
|
||||
@@ -37,12 +36,12 @@ import { INSTALL_ANCHOR } from './profile-boot.ts'
|
||||
const NAME = 'dsh'
|
||||
|
||||
/** The tooling bounds the command runs with; the Web host reads the same values from its config. */
|
||||
const TOOLING: PluginToolingConfig = { pnpmCommand: 'pnpm', installTimeoutMs: 600_000, probeTimeoutMs: 20_000, installLogTailBytes: 16_384 }
|
||||
const TOOLING: PluginToolingConfig = { pnpmCommand: 'pnpm', installTimeoutMs: 600_000, installLogTailBytes: 16_384 }
|
||||
|
||||
/** Test seams: the child spawner and the package probe, so no pnpm or probe child runs. */
|
||||
/** Test seams: the child spawner and the static metadata reader. */
|
||||
export interface PluginCommandInternals {
|
||||
spawn?: SpawnLike
|
||||
probe?: typeof probePackage
|
||||
metadata?: typeof readPackageMetadata
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* `dsh plugin add` and `remove` through the shared installer: a temporary
|
||||
* harness home, a fake pnpm that stages packages and edits the manifest the
|
||||
* way the real one does, and a fake probe. Nothing boots.
|
||||
* way the real one does, and a static metadata reader. Nothing boots.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events'
|
||||
@@ -11,7 +11,7 @@ import { join } from 'node:path'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { readProfileManifest, resolveProfileDir, type probePackage } from '@deepseek-ai/dsh-app-boot'
|
||||
import { readProfileManifest, resolveProfileDir, type readPackageMetadata } from '@deepseek-ai/dsh-app-boot'
|
||||
import type { SpawnLike } from '@deepseek-ai/dsh-plugin-manager'
|
||||
import { runPlugin } from '../src/plugin.ts'
|
||||
|
||||
@@ -93,19 +93,17 @@ function fakePnpm(calls: string[][], failWith?: { code: number } | { error: Node
|
||||
}
|
||||
}
|
||||
|
||||
/** A probe that reads the staged manifest: a package with `dsh.bundle` is a bundle, anything else a library. */
|
||||
const fakeProbe: typeof probePackage = (options) => {
|
||||
/** Static fixture declarations: a bundle or an unknown package. */
|
||||
const fakeMetadata: typeof readPackageMetadata = (options) => {
|
||||
const manifest = JSON.parse(readFileSync(join(options.profileDir, 'node_modules', options.packageName, 'package.json'), 'utf8')) as { dsh?: { bundle?: unknown } }
|
||||
return Promise.resolve({
|
||||
return {
|
||||
packageName: options.packageName,
|
||||
kind: manifest.dsh?.bundle === undefined ? 'library' : 'bundle',
|
||||
ok: true,
|
||||
kind: manifest.dsh?.bundle === undefined ? 'unknown' : 'bundle',
|
||||
cordisSameCopy: null,
|
||||
rows: [],
|
||||
overrides: [],
|
||||
addable: [],
|
||||
checkedAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
describe('dsh plugin', () => {
|
||||
@@ -114,7 +112,7 @@ describe('dsh plugin', () => {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY')
|
||||
Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true })
|
||||
try {
|
||||
expect(await runPlugin('web', ['add', 'ext-bundle'], { spawn: fakePnpm(calls), probe: fakeProbe })).toBe(0)
|
||||
expect(await runPlugin('web', ['add', 'ext-bundle'], { spawn: fakePnpm(calls), metadata: fakeMetadata })).toBe(0)
|
||||
} finally {
|
||||
if (descriptor === undefined) delete (process.stdout as { isTTY?: boolean }).isTTY
|
||||
else Object.defineProperty(process.stdout, 'isTTY', descriptor)
|
||||
@@ -123,32 +121,32 @@ describe('dsh plugin', () => {
|
||||
expect(stdout).toContain('\u001b[32m+\u001b[39m ext-bundle')
|
||||
})
|
||||
|
||||
it('add initializes the profile, installs through the installer, enables the new bundle, and removes a plain library again', async () => {
|
||||
it('add enables the new bundle and retains an undeclared dependency', async () => {
|
||||
const calls: string[][] = []
|
||||
|
||||
const code = await runPlugin('web', ['add', 'ext-bundle', 'ext-lib'], { spawn: fakePnpm(calls), probe: fakeProbe })
|
||||
const code = await runPlugin('web', ['add', 'ext-bundle', 'ext-lib'], { spawn: fakePnpm(calls), metadata: fakeMetadata })
|
||||
|
||||
expect(code).toBe(0)
|
||||
const profileDir = resolveProfileDir('web', home)
|
||||
expect(stderr).toContain(`dsh: initialized profile web at ${profileDir}`)
|
||||
expect(calls).toEqual([['add', 'ext-bundle'], ['add', 'ext-lib'], ['remove', 'ext-lib']])
|
||||
expect(calls).toEqual([['add', 'ext-bundle'], ['add', 'ext-lib']])
|
||||
const manifest = readProfileManifest('dsh', profileDir)
|
||||
expect(Object.keys(manifest.dependencies ?? {})).toContain('ext-bundle')
|
||||
expect(Object.keys(manifest.dependencies ?? {})).not.toContain('ext-lib')
|
||||
expect(Object.keys(manifest.dependencies ?? {})).toContain('ext-lib')
|
||||
expect(manifest.dsh?.profile?.bundles).toContain('ext-bundle')
|
||||
// The log reaches its readers plain: colours are off for the child and stripped from what it still prints.
|
||||
expect(spawnEnvs).toEqual(['0', '0', '0'])
|
||||
expect(spawnEnvs).toEqual(['0', '0'])
|
||||
expect(stdout).toContain('+ ext-bundle')
|
||||
expect(stdout).not.toContain('\u001b[')
|
||||
expect(stderr).toContain('dsh: removed ext-lib again: declares neither a dsh bundle nor a plugin module')
|
||||
expect(existsSync(join(profileDir, '.dsh-plugins', 'ext-bundle.json'))).toBe(true)
|
||||
expect(stderr).toContain('ext-lib declares no dsh.bundle')
|
||||
expect(existsSync(join(profileDir, '.dsh-plugins', 'ext-bundle.json'))).toBe(false)
|
||||
})
|
||||
|
||||
it('remove goes through the installer and forgets the probe record', async () => {
|
||||
const calls: string[][] = []
|
||||
await runPlugin('web', ['add', 'ext-bundle'], { spawn: fakePnpm(calls), probe: fakeProbe })
|
||||
await runPlugin('web', ['add', 'ext-bundle'], { spawn: fakePnpm(calls), metadata: fakeMetadata })
|
||||
|
||||
const code = await runPlugin('web', ['remove', 'ext-bundle'], { spawn: fakePnpm(calls), probe: fakeProbe })
|
||||
const code = await runPlugin('web', ['remove', 'ext-bundle'], { spawn: fakePnpm(calls), metadata: fakeMetadata })
|
||||
|
||||
expect(code).toBe(0)
|
||||
expect(calls).toEqual([['add', 'ext-bundle'], ['remove', 'ext-bundle']])
|
||||
@@ -160,13 +158,13 @@ describe('dsh plugin', () => {
|
||||
})
|
||||
|
||||
it('reports a failed pnpm run with its exit code, the git hint, and a missing pnpm as 127', async () => {
|
||||
const failed = await runPlugin('web', ['add', 'github:acme/plugin'], { spawn: fakePnpm([], { code: 1 }), probe: fakeProbe })
|
||||
const failed = await runPlugin('web', ['add', 'github:acme/plugin'], { spawn: fakePnpm([], { code: 1 }), metadata: fakeMetadata })
|
||||
expect(failed).toBe(1)
|
||||
expect(stderr).toContain('dsh: pnpm failed in profile directory')
|
||||
expect(stderr).toContain('git-hosted plugins build on install via their prepare script')
|
||||
|
||||
const error = Object.assign(new Error('spawn pnpm ENOENT'), { code: 'ENOENT' })
|
||||
const missing = await runPlugin('web', ['add', 'ext-bundle'], { spawn: fakePnpm([], { error }), probe: fakeProbe })
|
||||
const missing = await runPlugin('web', ['add', 'ext-bundle'], { spawn: fakePnpm([], { error }), metadata: fakeMetadata })
|
||||
expect(missing).toBe(127)
|
||||
expect(stderr).toContain('dsh: pnpm not found on PATH')
|
||||
})
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/config-catalog.md
|
||||
config-catalog.md: 0e08b1b34d5bb8b94af20c5f3b5a61b809f29fb9
|
||||
config-catalog.zh.md: 626d68b5ee448e02ce7d43b72c06f604f0b09782
|
||||
config-catalog.md: 4e1b7423c570c1bbc6139454ad71ca314d968daf
|
||||
config-catalog.zh.md: 3342b48bc9e07ff26437eb073d69aecd51ec91b9
|
||||
@@ -965,8 +965,6 @@ export interface Config {
|
||||
pnpmCommand: string
|
||||
/** Bound on one install or remove run, in milliseconds. */
|
||||
installTimeoutMs: number
|
||||
/** Bound on one package probe, in milliseconds. */
|
||||
probeTimeoutMs: number
|
||||
/** How many trailing bytes of an install run's output an install failure reports. */
|
||||
installLogTailBytes: number
|
||||
}
|
||||
@@ -3606,6 +3604,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
|
||||
- `@deepseek-ai/dsh-output-retention` ([`packages/util/output-retention/src/index.ts`](../packages/util/output-retention/src/index.ts))
|
||||
- `@deepseek-ai/dsh-package-manifest` ([`packages/util/package-manifest/src/index.ts`](../packages/util/package-manifest/src/index.ts))
|
||||
- `@deepseek-ai/dsh-plugin-manager` ([`packages/boot/plugin-manager/src/index.ts`](../packages/boot/plugin-manager/src/index.ts))
|
||||
- `@deepseek-ai/dsh-remote-mock` ([`packages/test-support/remote-mock/src/index.ts`](../packages/test-support/remote-mock/src/index.ts))
|
||||
- `@deepseek-ai/dsh-sandbox-windows-acl` ([`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts))
|
||||
- `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts))
|
||||
- `@deepseek-ai/dsh-sdk-client` ([`packages/sdk/client/src/index.ts`](../packages/sdk/client/src/index.ts))
|
||||
|
||||
@@ -967,8 +967,6 @@ export interface Config {
|
||||
pnpmCommand: string
|
||||
/** Bound on one install or remove run, in milliseconds. */
|
||||
installTimeoutMs: number
|
||||
/** Bound on one package probe, in milliseconds. */
|
||||
probeTimeoutMs: number
|
||||
/** How many trailing bytes of an install run's output an install failure reports. */
|
||||
installLogTailBytes: number
|
||||
}
|
||||
@@ -3608,6 +3606,7 @@ export interface Config {
|
||||
- `@deepseek-ai/dsh-output-retention`([`packages/util/output-retention/src/index.ts`](../packages/util/output-retention/src/index.ts))
|
||||
- `@deepseek-ai/dsh-package-manifest` ([`packages/util/package-manifest/src/index.ts`](../packages/util/package-manifest/src/index.ts))
|
||||
- `@deepseek-ai/dsh-plugin-manager`([`packages/boot/plugin-manager/src/index.ts`](../packages/boot/plugin-manager/src/index.ts))
|
||||
- `@deepseek-ai/dsh-remote-mock` ([`packages/test-support/remote-mock/src/index.ts`](../packages/test-support/remote-mock/src/index.ts))
|
||||
- `@deepseek-ai/dsh-sandbox-windows-acl`([`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts))
|
||||
- `@deepseek-ai/dsh-scope`([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts))
|
||||
- `@deepseek-ai/dsh-sdk-client`([`packages/sdk/client/src/index.ts`](../packages/sdk/client/src/index.ts))
|
||||
|
||||
@@ -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/event-producer-consumer.md
|
||||
event-producer-consumer.md: 2b0a51dcd204bc574c69b72fffa60f3fa17470b0
|
||||
event-producer-consumer.zh.md: 6fbe967a82854b9f98f034300cb6c0919487d75d
|
||||
event-producer-consumer.md: ab13e26bc97dbfc66affe3b663ff0133a9cc0b54
|
||||
event-producer-consumer.zh.md: 52c96177900aeec76638a52664738786fcb6e6f3
|
||||
@@ -47,8 +47,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
|
||||
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
|
||||
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:72`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
|
||||
| `plugins/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:244`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugins/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:250`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugins/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:248`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugins/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:254`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:50`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:60`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
|
||||
@@ -85,6 +85,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) |
|
||||
| `internal/plugin` | - | `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules` |
|
||||
| `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` |
|
||||
| `internal/status` | - | [`agent`](../packages/core/agent), [`app-boot`](../packages/boot/app-boot), `inspector` |
|
||||
| `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, [`plugin-manager`](../packages/boot/plugin-manager) |
|
||||
| `internal/update` | - | [`app-boot`](../packages/boot/app-boot) |
|
||||
| `loader/entry-init` | - | [`plugin-manager`](../packages/boot/plugin-manager) |
|
||||
| `loader/partial-dispose` | - | [`plugin-manager`](../packages/boot/plugin-manager) |
|
||||
|
||||
Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program.
|
||||
@@ -10,7 +10,7 @@
|
||||
| 事件 | 模式 | 声明位置 | 派发方 | 监听方 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:246`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
|
||||
| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:80`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
|
||||
| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:82`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
|
||||
| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:373`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
|
||||
| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:258`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:267`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
@@ -49,8 +49,8 @@
|
||||
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
|
||||
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
|
||||
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:72`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
|
||||
| `plugins/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:244`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugins/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:250`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugins/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:248`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugins/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:254`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:50`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:60`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
|
||||
@@ -59,10 +59,10 @@
|
||||
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
|
||||
| `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
|
||||
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:168`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:148`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:159`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:170`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:144`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:150`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:161`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
|
||||
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`session-reference`](../packages/context/session-reference), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
|
||||
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
@@ -87,6 +87,9 @@
|
||||
| `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) |
|
||||
| `internal/plugin` | - | `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules` |
|
||||
| `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` |
|
||||
| `internal/status` | - | [`agent`](../packages/core/agent), [`app-boot`](../packages/boot/app-boot), `inspector` |
|
||||
| `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, [`plugin-manager`](../packages/boot/plugin-manager) |
|
||||
| `internal/update` | - | [`app-boot`](../packages/boot/app-boot) |
|
||||
| `loader/entry-init` | - | [`plugin-manager`](../packages/boot/plugin-manager) |
|
||||
| `loader/partial-dispose` | - | [`plugin-manager`](../packages/boot/plugin-manager) |
|
||||
|
||||
Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program.
|
||||
@@ -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/subsystems/core.md
|
||||
core.md: f69c7fdf96eff4c2e2b2da38defed8fcb4f8cdb7
|
||||
core.zh.md: 2ff9c1ae85bece8d7621b5eb4924bf8028d1f21b
|
||||
core.md: 26ce43696593831eb01994c53d238504f5846217
|
||||
core.zh.md: 3475c279228af2c856c671d45aa0f6a6d288435b
|
||||
@@ -936,7 +936,7 @@ The row injects only the Loader; the profile runtime, the preset roster, and the
|
||||
@Remote('list') async list(): Promise<PluginPackageView[]>
|
||||
|
||||
/**
|
||||
* Install a package with pnpm, probe it, and leave it disabled unless asked otherwise.
|
||||
* Install a package with pnpm, read its declarations, and leave it disabled unless asked otherwise.
|
||||
* @param spec - what to install, in pnpm's own vocabulary.
|
||||
* @param options - `enable` puts every newly installed bundle into the layer list at once.
|
||||
* @returns what the run installed and enabled.
|
||||
@@ -944,7 +944,7 @@ The row injects only the Loader; the profile runtime, the preset roster, and the
|
||||
@Remote('add') async add(spec: string, options?: { enable?: boolean }): Promise<PluginInstallResult>
|
||||
|
||||
/**
|
||||
* Remove a package from the profile with its user-layer rows and probe record.
|
||||
* Remove a package from the profile with its user-layer rows and any obsolete discovery cache.
|
||||
* @param packageName - the installed dependency to remove.
|
||||
*/
|
||||
@Remote('uninstall') async uninstall(packageName: string): Promise<void>
|
||||
|
||||
@@ -946,7 +946,7 @@ The row injects only the Loader; the profile runtime, the preset roster, and the
|
||||
@Remote('list') async list(): Promise<PluginPackageView[]>
|
||||
|
||||
/**
|
||||
* Install a package with pnpm, probe it, and leave it disabled unless asked otherwise.
|
||||
* Install a package with pnpm, read its declarations, and leave it disabled unless asked otherwise.
|
||||
* @param spec - what to install, in pnpm's own vocabulary.
|
||||
* @param options - `enable` puts every newly installed bundle into the layer list at once.
|
||||
* @returns what the run installed and enabled.
|
||||
@@ -954,7 +954,7 @@ The row injects only the Loader; the profile runtime, the preset roster, and the
|
||||
@Remote('add') async add(spec: string, options?: { enable?: boolean }): Promise<PluginInstallResult>
|
||||
|
||||
/**
|
||||
* Remove a package from the profile with its user-layer rows and probe record.
|
||||
* Remove a package from the profile with its user-layer rows and any obsolete discovery cache.
|
||||
* @param packageName - the installed dependency to remove.
|
||||
*/
|
||||
@Remote('uninstall') async uninstall(packageName: string): Promise<void>
|
||||
|
||||
@@ -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: f965d63b3f131e24d2e4e0777cf414ce84f3af43
|
||||
README.zh.md: b11e0c0eaca454a9154ce1d0f877f538561702bf
|
||||
README.md: 9815609d4638d7adfd981d76dcf318386fa9aa1b
|
||||
README.zh.md: 4f59bffb0ab75d2bdd48f7cad384204320585748
|
||||
@@ -62,6 +62,32 @@ Installed dependencies default to `external`; template bundles and packages in `
|
||||
|
||||
The launcher provides `ctx.profileRuntime` before any configuration entry mounts. It owns row provenance, accepted composition, conflicts, and user-disabled rows. Watchers and management operations share its serial recomposition queue. Recomposition waits for current entries and removed fibers, then publishes accepted options and reports per-entry issues; a failed update can leave a fiber running its previous valid config. `installFailLoud` remains installed until shutdown for process-level unhandled rejections.
|
||||
|
||||
<a id="patch-files"></a>
|
||||
### Patch files
|
||||
|
||||
Every layer above is a `cordis.patch.yml`: a top-level YAML sequence of the include plugin's `PatchOptions` — id-targeted overrides and `insert` lists — in the Loader's dialect, where `!!js` marks an expression the row's fiber evaluates. The `./patch-file` export is the one place that reads and writes such a file, so a file the boot accepts is a file the agent-preset roster and the plugin manager accept.
|
||||
|
||||
Read a layer with `parsePatchList` (text in hand) or `readPatchListFile` (an absent file reads as `undefined`). Both anchor a relative `insert` row name such as `./plugin.js` to the file's own directory and fail loud on anything that is not a sequence of mappings, because a patch file that cannot be applied at all is a misconfiguration; a patch whose target row is absent stays a per-entry Loader warning.
|
||||
|
||||
Write through `mutatePatchFile`. The callback receives a `PatchDocument` and edits it by row id, the way the Loader addresses rows:
|
||||
|
||||
```ts
|
||||
import { mutatePatchFile } from '@deepseek-ai/dsh-app-boot/patch-file'
|
||||
|
||||
const file = '/home/me/.dsh/profiles/web/cordis.patch.yml'
|
||||
await mutatePatchFile(file, (document) => {
|
||||
document.setRowField('tool-web', 'disabled', true) // the id-targeted patch is created when absent
|
||||
document.deleteRowField('tool-web', 'config') // a patch reduced to its id is removed whole
|
||||
document.appendInsert({ id: 'tool-foo', name: 'dsh-tool-foo' }) // into the root list
|
||||
document.appendInsert({ id: 'sql', name: 'dsh-sql' }, 'agents') // into the group with that id
|
||||
document.removeInsert('tool-foo') // an emptied insert patch is removed whole
|
||||
}, { binName: 'dsh', mode: 0o600, dirMode: 0o700 })
|
||||
```
|
||||
|
||||
`setRowField` never accepts `id` or `insert`; `rowField` reads one key back, with a `!!js` scalar returned as its source text. `appendInsert` refuses an id the file already inserts, and `insertedRow`/`removeInsert` find rows inside inserted groups too. Values written are plain data; a `!!js` scalar on another key is left untouched, which is what lets a user layer revert a `disabled: true` it wrote without disturbing a bundle's `!!js` gate on a different row.
|
||||
|
||||
`mutatePatchFile` takes the `<file>.lock` sibling the way `dsh-atomic-write` does, reads the file (absent reads as empty), applies the edit, replaces the file atomically with the stated permission bits when the text changed, and returns the patch list as re-read from the written text. An edit that changes nothing writes nothing.
|
||||
|
||||
### Previewing the effective configuration
|
||||
|
||||
Before you boot, you can print the exact configuration the app will mount: the dump shows the composed entry list with `!!js` expressions verbatim, grouped under comments naming each source file and the patch layers that changed it, as one loadable YAML document. Patches that match no row are reported with their layer label; a missing, unparsable, or invalid config fails the dump.
|
||||
|
||||
@@ -62,6 +62,32 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
|
||||
|
||||
launcher 在任何配置行挂载前提供 `ctx.profileRuntime`。它拥有行来源、已接受的组合、冲突和用户禁用行信息。文件监听与管理操作共用它的串行重组队列。重组等待当前条目和已移除 fiber 完成后,发布已接受的选项并报告逐行问题;更新失败时,fiber 可能仍使用先前的有效配置运行。`installFailLoud` 保持到应用关闭,处理进程级未处理 rejection。
|
||||
|
||||
<a id="patch-files"></a>
|
||||
### 补丁文件
|
||||
|
||||
上面的每一层都是一个 `cordis.patch.yml`:一个顶层 YAML 序列,元素是 include 插件的 `PatchOptions`——按 id 定位的覆盖与 `insert` 列表——采用 Loader 的方言,其中 `!!js` 标记一个由该行 fiber 求值的表达式。`./patch-file` 导出是读写这种文件的唯一地方,因此启动接受的文件就是 agent preset roster 与插件管理器接受的文件。
|
||||
|
||||
用 `parsePatchList`(已有文本)或 `readPatchListFile`(文件不存在时读到 `undefined`)读取一层。两者都把 `insert` 行中相对的名字(如 `./plugin.js`)锚定到文件自己的目录,并对任何不是"映射序列"的内容直接报错,因为一个完全无法施加的补丁文件就是配置错误;而目标行不存在的单条补丁仍然只是 Loader 的逐条警告。
|
||||
|
||||
通过 `mutatePatchFile` 写入。回调拿到一个 `PatchDocument`,按 Loader 寻址行的方式以行 id 编辑:
|
||||
|
||||
```ts
|
||||
import { mutatePatchFile } from '@deepseek-ai/dsh-app-boot/patch-file'
|
||||
|
||||
const file = '/home/me/.dsh/profiles/web/cordis.patch.yml'
|
||||
await mutatePatchFile(file, (document) => {
|
||||
document.setRowField('tool-web', 'disabled', true) // the id-targeted patch is created when absent
|
||||
document.deleteRowField('tool-web', 'config') // a patch reduced to its id is removed whole
|
||||
document.appendInsert({ id: 'tool-foo', name: 'dsh-tool-foo' }) // into the root list
|
||||
document.appendInsert({ id: 'sql', name: 'dsh-sql' }, 'agents') // into the group with that id
|
||||
document.removeInsert('tool-foo') // an emptied insert patch is removed whole
|
||||
}, { binName: 'dsh', mode: 0o600, dirMode: 0o700 })
|
||||
```
|
||||
|
||||
`setRowField` 永远不接受 `id` 与 `insert`;`rowField` 读回一个键,`!!js` 标量以其源文本返回。`appendInsert` 拒绝文件已插入的 id;`insertedRow`/`removeInsert` 也能找到插入组内部的行。写入的值都是普通数据;别的键上的 `!!js` 标量原样不动,这正是用户层能撤回自己写的 `disabled: true` 而不惊动组合包在另一行上的 `!!js` 门的原因。
|
||||
|
||||
`mutatePatchFile` 像 `dsh-atomic-write` 一样占用 `<file>.lock` 兄弟文件,读取文件(不存在按空处理),施加编辑,在文本有变化时以声明的权限位原子替换文件,然后返回从写入文本重新读出的补丁列表。什么都没改的编辑什么都不写。
|
||||
|
||||
### 预览生效配置
|
||||
|
||||
启动前,你可以打印应用将挂载的确切配置:dump 会以 `!!js` 表达式原样展示组合后的条目列表,并按注释分组标明每个源文件及其 patch 层,输出是一份可加载的 YAML 文档。未匹配到任何行的 patch 会连同其层标签一起报告;配置缺失、无法解析或字段无效都会使 dump 失败。
|
||||
|
||||
@@ -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/plugin-manager/README.md
|
||||
README.md: 39fc1f9a10e1359ce4806f214a91a6bce08e8126
|
||||
README.zh.md: 4da79eed6bdd3c1274afe9a475614e8f17fac07b
|
||||
README.md: 490c0522eaf27136a5650cfdbe4fe672820948da
|
||||
README.zh.md: b31f461c8cdd83428f4e1107ad636d3658cee4d2
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
`dsh-plugin-manager` changes a profile's plugins. `PluginInstaller` needs the profile on disk: it runs pnpm there, probes every package a run added in a child process, and removes what has no place in a profile — `dsh plugin add` uses it before any plugin starts. `PluginManager` needs the booted tree: it enables and disables bundles through `profileRuntime`, retries failed ones, adds and removes rows in the global or a preset's user layer, reports dependents, and folds manifest, probe record, and live tree into one view per package. Failures carry `plugins/*` codes; [`dsh-host-plugin-manager`](../../host/plugin-manager/README.md) exposes the manager as the `plugins` Remote.
|
||||
`dsh-plugin-manager` installs packages and manages their declared plugin rows through the CLI and Web host. Installation reads metadata without executing modules. The manager enables whole bundle layers, edits global or preset user patches, retries failures, and reports current runtime issues. Failures carry `plugins/*` codes; the [Host adapter](../../host/plugin-manager/README.md) exposes the operations through Remote.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -39,7 +39,7 @@ declare const installAnchor: string
|
||||
const installer = new PluginInstaller({
|
||||
profileDir, profileName: 'web', installAnchor,
|
||||
loadProfile: () => loadProfile('dsh', 'web', installAnchor, undefined, { userLayer: false }),
|
||||
config: { pnpmCommand: 'pnpm', installTimeoutMs: 600_000, probeTimeoutMs: 20_000, installLogTailBytes: 16_384 },
|
||||
config: { pnpmCommand: 'pnpm', installTimeoutMs: 600_000, installLogTailBytes: 16_384 },
|
||||
installLog: (chunk) => process.stdout.write(chunk.text),
|
||||
color: process.stdout.isTTY,
|
||||
})
|
||||
@@ -47,7 +47,7 @@ const outcome = await installer.add('@acme/dsh-sql-tool')
|
||||
console.log(outcome.installed, outcome.removed)
|
||||
```
|
||||
|
||||
`add` takes a pnpm spec — a registry name, a `github:` or git URL, a tarball, an absolute path — runs `pnpm add`, records what pnpm wrote to `dependencies`, and probes every new package. A successful `pnpm add` is not yet an installed plugin: a package that declares neither a bundle nor a plugin module, or a bundle whose row id a composed layer already owns, is removed again with `pnpm remove` and listed under `removed` with the reason; a package the probe refused stays installed for a view to explain. New bundles are left disabled and listed under `installedOnly`, so the caller decides whether to enable them — the CLI always does, the Web host only when asked. A non-zero exit, a spawn failure, or the timeout fails the call with `plugins/install-failed` and the tail of the log, and the profile manifest is restored to what it was before the run.
|
||||
`add` runs pnpm in the profile, reconciles `dependencies`, and statically checks new bundle declarations against current row ownership. Conflicting bundles are removed with a reason; undeclared and unreadable packages remain installed. New bundles stay disabled unless the caller enables them. A failed pnpm run restores the pre-run manifest and reports `plugins/install-failed` with the log tail.
|
||||
|
||||
### Managing the booted profile
|
||||
|
||||
@@ -72,11 +72,11 @@ const manager = new PluginManager(ctx, {
|
||||
console.log(await manager.list())
|
||||
```
|
||||
|
||||
`list` returns one view per package the profile knows: its template and installed bundles and every other installed dependency. A view carries the manifest facts (name, version, title, description, `engines.dsh`), what the package is (`bundle`, `plugin`, or `library`), who supplied it (`builtin` or `external`), when its rows mount (`boot` or `runtime`), whether it is installed and enabled, and a folded `status`: `running`, `partial`, or `failed` for an enabled bundle by how many of its rows are active; `disabled` for an installed bundle outside the layer list; `not-enableable` when the probe refused it, with the reason; `restart-required` when the manifest and the live tree disagree on a profile that applies changes at its next start; `plain` for a library or plugin module, which is added to a composition rather than enabled. Rows come from the live tree while the bundle is composed — phase, disabled-by, and the recorded failure of an isolated row — and from the probe record otherwise, under the ids their patches declare. `addable` lists the modules the package declares in `dsh.plugins`, each with its default config and the probe's verdict.
|
||||
`list` reports package identity, `bundle` / `plugin` / `unknown` classification, trust, stage, installation and enablement. Runtime rows carry actual phases and failures; disabled bundles show static patch declarations. `addable` comes only from `dsh.plugins`, including `.` for the main export and declared defaults. An active row can retain its previous config after an update fails. Package `issues` also names failed rows its patch overrides, without transferring their ownership.
|
||||
|
||||
`add` is the installer's `add` guarded by the running-agent count, with `enable` putting every newly installed bundle into the layer list at once. `enable` puts an installed bundle into the layer list and, on a live profile, recomposes the tree with it through the profile runtime. The recomposition is the Loader's own transaction: a bundle the tree rejects — a `boot`-stage bundle whose row throws — rolls back, the layer list is restored, and the call fails with `plugins/enable-failed` naming the reason, while the tree that was running keeps running. A `runtime`-stage bundle whose row fails is isolated instead: the call succeeds, the view reports the row's failure, and `retry` composes the bundle again from scratch. `disable` is the reverse; a template bundle, which is not a dependency, cannot be disabled. On a profile whose `patchReload` is `startup`, both write the manifest and report `effect: 'restart'`. `uninstall` disables the bundle when enabled, drops every user-layer row that names one of the package's modules, runs `pnpm remove`, and forgets the probe record.
|
||||
`enable` selects the whole bundle layer and recomposes live profiles. Per-row failures retain the enabled choice and successful siblings; results report `issues`, and the list can show `partial` or `failed`. Preparation failures revert the enable selection and raise `plugins/enable-failed`. `disable` removes the whole layer, including overrides. `retry` disables, awaits cleanup, and enables again. Startup-only profiles report `effect: restart`. `uninstall` disables the bundle, removes user-inserted references, runs pnpm remove, and cleans obsolete discovery records.
|
||||
|
||||
`addRow` inserts a row naming one of the package's modules — its main export for a `plugin` package, or a `dsh.plugins` entry — into the profile's global `cordis.patch.yml` (`target: { kind: 'global' }`) or an agent preset's user layer (`{ kind: 'preset', preset }`, through the roster's `overlayPathFor`). The row id derives from the package name and subpath unless given; a taken id fails with `plugins/row-conflict`. `removeRow` removes an inserted row and `setRowDisabled` writes or removes a `disabled: true` for any row — deny-only, so a bundle's own `!!js` gate is restored rather than overridden. The global layer is recomposed live on the spot; a preset's layer reaches its next standing generation. `dependents` says what disabling or removing a package would strand: services its rows provide that rows outside it inject, and user-layer rows naming its modules.
|
||||
`addRow` writes an explicitly declared `dsh.plugins` module to the profile’s global `cordis.patch.yml` or a preset user layer. It preserves declared defaults, checks the target row id, and performs no pre-mount import. `removeRow` removes a user insert. `setRowDisabled` writes or removes `disabled: true`, preserving the bundle’s own condition. Global edits recompose immediately on live profiles; preset edits apply to subsequent generations. `dependents` reports injection dependents and user-layer module references.
|
||||
|
||||
The manager runs one mutation at a time — a second call while one runs fails with `plugins/busy` naming the operation in flight — and `add` and `uninstall` refuse to change `node_modules` while a session is running, with `plugins/agents-running`. Every change is followed by a `plugins/changed` event on the context, and an install run emits pnpm's output as `plugins/install-log` chunks, each naming the command line it ran and the profile directory it ran in, and, with colour on, carrying pnpm's SGR escapes.
|
||||
|
||||
@@ -102,25 +102,25 @@ The subprocess seam scrubs secret-shaped variables and has no shell mode, and pn
|
||||
|
||||
### Retry is disable then enable
|
||||
|
||||
The Loader's transactional update leaves an unchanged row alone, so a failed isolated row would not restart on a plain recomposition. Retry takes the bundle out of the layer list and puts it back: two recompositions, and the manifest ends as it began.
|
||||
Retry removes the entire layer and awaits removed-fiber cleanup before adding it again. It does not rely on changing a synthetic group or silently compensating for failed plugin effects.
|
||||
|
||||
### What the manager reads and what it is handed
|
||||
|
||||
The manager reads the Loader tree, the reflect store, and the `pluginFailures` registry through the context it is built over. Everything that belongs to another package — the profile runtime, the preset roster, the agent registry — arrives through `PluginManagerOptions` as a reader called per call, and the roster only as `PresetLayers`: the layer path, the preset list, and the composition rows the row operations need. The package therefore depends on app-boot alone, whose `./patch-file` export reads and writes the layers, and on nothing that composes presets or agents.
|
||||
The manager reads Loader entries, the reflect store and entry-owned diagnostics. Profile, preset and agent facts arrive through per-call readers. The [Host adapter](../../host/plugin-manager/README.md) converts Loader lifecycle changes into `plugins/changed` notifications after settlement, including a pending row whose provider becomes available.
|
||||
|
||||
### Source map
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | The package API: re-exports of the classes, options, types, and failure codes |
|
||||
| [`src/installer.ts`](src/installer.ts) | `PluginInstaller`: pnpm runs with streamed output, the probe record cache, and the post-install checks |
|
||||
| [`src/installer.ts`](src/installer.ts) | `PluginInstaller`: pnpm runs with streamed output, static declarations, and the post-install checks |
|
||||
| [`src/manager.ts`](src/manager.ts) | `PluginManager`: every operation over the booted profile, the one-at-a-time mutex, user-layer edits, and dependents |
|
||||
| [`src/view.ts`](src/view.ts) | The view fold: manifest, probe, and live-tree rows into one `PluginPackageView`, and the row-ownership walk |
|
||||
| [`src/view.ts`](src/view.ts) | The view fold: manifest, declarations, and live-tree rows into one `PluginPackageView`, and the row-ownership walk |
|
||||
| [`src/modules.ts`](src/modules.ts) | Declared `dsh.plugins` modules: row naming, derived row ids, and their wire view |
|
||||
| [`src/helpers.ts`](src/helpers.ts) | Shared vocabulary: the diagnostic prefix, tooling bounds, the spawn seam, and the manifest readers |
|
||||
| [`src/types.ts`](src/types.ts) | Payloads, the `plugins/changed` and `plugins/install-log` events, and the `plugins/*` failure codes with their details |
|
||||
| [`src/errors.ts`](src/errors.ts) | `PluginOperationError` and the code-discriminated failure union |
|
||||
| — | No runtime invariant companion is published; every view is folded from the manifest, the probe cache, and Loader-owned state on each call. |
|
||||
| — | No runtime invariant companion is published; every view is folded from the manifest, static declarations, and Loader-owned state on each call. |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -131,7 +131,7 @@ The manager reads the Loader tree, the reflect store, and the `pluginFailures` r
|
||||
|
||||
Read these when the manager's contract is not enough: the runtime it drives, the files it edits, and the surfaces that call it.
|
||||
|
||||
- [App boot](../app-boot/README.md) — the profile runtime, external bundle isolation, and the package probe.
|
||||
- [App boot](../app-boot/README.md) — the profile runtime, external bundle isolation, and static package declarations.
|
||||
- [Patch files](../app-boot/README.md#patch-files) — how user-layer rows are read and written.
|
||||
- [Agent presets](../../preset/agent-presets/README.md) — the per-preset user layer a preset target writes.
|
||||
- [Host plugin manager](../../host/plugin-manager/README.md) — the `plugins` Remote over this manager.
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
`dsh-plugin-manager` 负责改动 profile 的插件。`PluginInstaller` 只需要磁盘上的 profile:在那里运行 pnpm,在子进程里探测本次新增的每个包,并把在 profile 里没有位置的包再移除——`dsh plugin add` 在任何插件启动之前就用它。`PluginManager` 需要启动好的树:经 `profileRuntime` 启用与停用组合包、重试失败的组合包、在全局或某个预设的用户层里增删行、报告依赖方,并把 manifest、探针记录与实时树折叠成每个包的一份视图。失败带 `plugins/*` 代码;[`dsh-host-plugin-manager`](../../host/plugin-manager/README.zh.md) 把管理器暴露为 `plugins` Remote。
|
||||
`dsh-plugin-manager` 通过 CLI 与 Web 宿主安装包并管理它们声明的插件行。安装读取元信息,不执行模块。管理器启停整份组合包层,编辑全局或预设用户 patch,重试失败并报告当前运行问题。失败携带 `plugins/*` 错误码;[Host 适配器](../../host/plugin-manager/README.zh.md) 通过 Remote 暴露这些操作。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -39,7 +39,7 @@ declare const installAnchor: string
|
||||
const installer = new PluginInstaller({
|
||||
profileDir, profileName: 'web', installAnchor,
|
||||
loadProfile: () => loadProfile('dsh', 'web', installAnchor, undefined, { userLayer: false }),
|
||||
config: { pnpmCommand: 'pnpm', installTimeoutMs: 600_000, probeTimeoutMs: 20_000, installLogTailBytes: 16_384 },
|
||||
config: { pnpmCommand: 'pnpm', installTimeoutMs: 600_000, installLogTailBytes: 16_384 },
|
||||
installLog: (chunk) => process.stdout.write(chunk.text),
|
||||
color: process.stdout.isTTY,
|
||||
})
|
||||
@@ -47,7 +47,7 @@ const outcome = await installer.add('@acme/dsh-sql-tool')
|
||||
console.log(outcome.installed, outcome.removed)
|
||||
```
|
||||
|
||||
`add` 接受一个 pnpm spec——registry 名字、`github:` 或 git URL、tarball、绝对路径——运行 `pnpm add`,记录 pnpm 写进 `dependencies` 的内容,并探测每个新包。`pnpm add` 成功还不等于装好了插件:既不声明组合包也不声明插件模块的包,或者某个行 id 已被已组合层占有的组合包,会再以 `pnpm remove` 移除并连同原因列在 `removed` 里;探针拒绝的包保留在原处,留给视图说明。新组合包保持停用并列在 `installedOnly` 里,由调用方决定是否启用——CLI 一律启用,Web 宿主只在被要求时启用。非零退出、spawn 失败或超时都以 `plugins/install-failed` 与日志尾部让调用失败,并把 profile manifest 恢复到运行前的样子。
|
||||
`add` 在 profile 中执行 pnpm,核对 `dependencies`,并按当前行归属静态检查新组合包声明。冲突组合包会被移除并给出原因;未声明或声明不可读的包保持已安装。新组合包保持禁用,除非调用方启用。pnpm 执行失败会恢复运行前的 manifest,并通过 `plugins/install-failed` 报告日志尾部。
|
||||
|
||||
### 管理已启动的 profile
|
||||
|
||||
@@ -72,11 +72,11 @@ const manager = new PluginManager(ctx, {
|
||||
console.log(await manager.list())
|
||||
```
|
||||
|
||||
`list` 为 profile 知道的每个包返回一份视图:模板组合包与已安装的组合包,以及其他每个已安装的依赖。视图携带 manifest 事实(名字、版本、标题、描述、`engines.dsh`),这个包是什么(`bundle`、`plugin` 或 `library`),谁提供它(`builtin` 或 `external`),它的行何时挂载(`boot` 或 `runtime`),是否已安装与已启用,以及折叠出的 `status`:已启用的组合包按活跃行的多少是 `running`、`partial` 或 `failed`;已安装但不在层列表中的组合包是 `disabled`;探针拒绝时是 `not-enableable` 并附原因;在启动时才应用变更的 profile 上 manifest 与在线树不一致时是 `restart-required`;库或插件模块是 `plain`,它们被添加进组合而不是被启用。组合包已组合时行来自在线树——阶段、被谁停用,以及隔离行记录的失败——否则来自探针记录,id 保持各自 patch 声明的样子。`addable` 列出包在 `dsh.plugins` 里声明的模块,各自带默认配置与探针的判定。
|
||||
`list` 报告包身份、`bundle` / `plugin` / `unknown` 分类、trust、stage、安装与启用状态。运行行携带实际阶段与失败;禁用组合包显示静态 patch 声明。`addable` 只来自 `dsh.plugins`,包括表示主入口的 `.` 和声明的默认配置。行更新失败后,活跃实例可能保留先前配置。包的 `issues` 还报告其 patch 覆盖的失败行,但不转移这些行的归属。
|
||||
|
||||
`add` 就是安装器的 `add` 加上运行中 agent 数的守卫,`enable` 让每个新装的组合包一并进入层列表。`enable` 把已安装的组合包放进层列表,并在 live profile 上经 profile runtime 带着它重新组合树。这次重新组合就是 Loader 自己的事务:树拒绝的组合包——`boot` 阶段而行抛错的组合包——回滚,层列表恢复,调用以点名原因的 `plugins/enable-failed` 失败,而原本运行的树继续运行。`runtime` 阶段而行失败的组合包则被隔离:调用成功,视图报告该行的失败,`retry` 从头重新组合它。`disable` 是反向操作;模板组合包不是依赖,无法停用。在 `patchReload` 为 `startup` 的 profile 上,两者只写 manifest 并报告 `effect: 'restart'`。`uninstall` 在组合包已启用时先停用它,删除每一条点名该包模块的用户层行,运行 `pnpm remove`,并忘掉探针记录。
|
||||
`enable` 选择整份组合包层,并在实时 profile 中重组。逐行失败保留启用选择与成功的其他行;结果报告 `issues`,列表可显示 `partial` 或 `failed`。准备失败会撤销启用选择,并抛出 `plugins/enable-failed`。`disable` 移除整层,包括覆盖。`retry` 先禁用并等待清理,再启用。仅启动时生效的 profile 报告 `effect: restart`。`uninstall` 禁用组合包、删除用户插入的引用、执行 pnpm remove 并清理过期发现记录。
|
||||
|
||||
`addRow` 把一条点名该包某个模块的行——`plugin` 包的主导出,或某个 `dsh.plugins` 条目——插入 profile 的全局 `cordis.patch.yml`(`target: { kind: 'global' }`)或某个 agent preset 的用户层(`{ kind: 'preset', preset }`,经 roster 的 `overlayPathFor`)。行 id 未给出时由包名与子路径派生;已被占用的 id 以 `plugins/row-conflict` 失败。`removeRow` 移除一条插入的行,`setRowDisabled` 为任意行写入或移除 `disabled: true`——只写拒绝,因此组合包自己的 `!!js` 门被恢复而不是被覆盖。全局层当场在线重新组合;preset 的层在其下一个常驻代际生效。`dependents` 说明停用或移除一个包会搁浅什么:其行提供而包外的行注入的服务,以及点名其模块的用户层行。
|
||||
`addRow` 将显式声明的 `dsh.plugins` 模块写入 profile 的全局 `cordis.patch.yml` 或预设用户层。它保留声明的默认配置,检查目标行 id,不在挂载前 import。`removeRow` 删除用户插入。`setRowDisabled` 写入或删除 `disabled: true`,保留组合包自己的条件。全局编辑在实时 profile 中立即重组;预设编辑应用于后续代际。`dependents` 报告注入依赖方与用户层模块引用。
|
||||
|
||||
管理器一次只跑一个变更——上一个还在跑时再调用会以 `plugins/busy` 失败并点名正在进行的操作——`add` 与 `uninstall` 在有会话运行时拒绝改动 `node_modules`,报 `plugins/agents-running`。每次变更之后在上下文上发出 `plugins/changed` 事件,安装运行把 pnpm 的输出以 `plugins/install-log` 分块发出,每块都写明所跑的命令行与所在的 profile 目录,开了颜色时还带着 pnpm 的 SGR 转义。
|
||||
|
||||
@@ -102,25 +102,25 @@ subprocess seam 会清洗形似密钥的变量且没有 shell 模式,而 pnpm
|
||||
|
||||
### 重试即先停用再启用
|
||||
|
||||
Loader 的事务性更新不会碰未改变的行,因此一条失败的隔离行在普通的重新组合中不会重新启动。重试把组合包移出层列表再放回去:两次重新组合,manifest 首尾如一。
|
||||
重试移除整层,并等待已移除 fiber 清理完成后再添加。它不依赖合成 group,也不会静默补偿失败插件的副作用。
|
||||
|
||||
### 管理器自己读什么,别人交给它什么
|
||||
|
||||
管理器经它所在的上下文读取 Loader 树、reflect store 与 `pluginFailures` 注册表。属于别的包的东西——profile runtime、preset roster、agent 注册表——都经 `PluginManagerOptions` 以按调用读取的读取器交进来,roster 只以 `PresetLayers` 的形态交进来:层文件路径、preset 列表,以及行操作需要的组合行。因此本包只依赖 app-boot(其 `./patch-file` 导出负责读写各层),不依赖任何组合 preset 或 agent 的包。
|
||||
管理器读取 Loader 条目、reflect 存储与条目自身诊断。profile、预设和 agent 信息通过逐次调用的读取器提供。[Host 适配器](../../host/plugin-manager/README.zh.md) 在状态稳定后将 Loader 生命周期变化转换为 `plugins/changed` 通知,包括等待中的行在提供方出现后恢复运行。
|
||||
|
||||
### 源码地图
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | 包 API:类、选项、类型与失败码的 re-export |
|
||||
| [`src/installer.ts`](src/installer.ts) | `PluginInstaller`:流式输出的 pnpm 运行、探针记录缓存与装后检查 |
|
||||
| [`src/installer.ts`](src/installer.ts) | `PluginInstaller`:pnpm 流式输出、静态声明与装后检查 |
|
||||
| [`src/manager.ts`](src/manager.ts) | `PluginManager`:已启动 profile 上的每项操作、一次一个的互斥、用户层编辑与依赖查询 |
|
||||
| [`src/view.ts`](src/view.ts) | 视图折叠:把 manifest、探针与在线树的行折成一份 `PluginPackageView`,以及行归属遍历 |
|
||||
| [`src/view.ts`](src/view.ts) | 将 manifest、声明与实际行状态组合为 `PluginPackageView`,并查询行归属 |
|
||||
| [`src/modules.ts`](src/modules.ts) | 声明的 `dsh.plugins` 模块:行命名、派生行 id 及其 wire 视图 |
|
||||
| [`src/helpers.ts`](src/helpers.ts) | 共享词汇:诊断前缀、工具边界、spawn 测试缝与 manifest 读取器 |
|
||||
| [`src/types.ts`](src/types.ts) | 载荷、`plugins/changed` 与 `plugins/install-log` 事件,以及 `plugins/*` 失败码及其 details |
|
||||
| [`src/errors.ts`](src/errors.ts) | `PluginOperationError` 与按码区分的失败联合 |
|
||||
| — | 不发布运行时不变量伴随件;每份视图都在每次调用时从 manifest、探针缓存与 Loader 持有的状态折叠而来。 |
|
||||
| — | 不发布独立运行不变量模块;每次调用直接从 manifest、静态声明与 Loader 状态生成视图。 |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -131,7 +131,7 @@ Loader 的事务性更新不会碰未改变的行,因此一条失败的隔离
|
||||
|
||||
当管理器的契约还不够时读这些:它驱动的运行时、它编辑的文件,以及调用它的表面。
|
||||
|
||||
- [App boot](../app-boot/README.zh.md)——profile runtime、外部组合包隔离与包探针。
|
||||
- [App boot](../app-boot/README.zh.md)——profile runtime、外部组合包隔离与静态包声明。
|
||||
- [补丁文件](../app-boot/README.zh.md#patch-files)——用户层的行如何读写。
|
||||
- [Agent presets](../../preset/agent-presets/README.zh.md)——preset 目标所写的每预设用户层。
|
||||
- [宿主插件管理器](../../host/plugin-manager/README.zh.md)——本管理器之上的 `plugins` Remote。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-plugin-manager",
|
||||
"description": "Plugin management over a dsh profile: install, enable, disable, retry, user-layer rows, dependents, and one view per package, shared by the dsh plugin command and the Web host",
|
||||
"version": "0.1.5-rc.1",
|
||||
"version": "0.1.5-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -47,8 +47,6 @@ export interface PluginToolingConfig {
|
||||
readonly pnpmCommand: string
|
||||
/** Bound on one install or remove run, in milliseconds. */
|
||||
readonly installTimeoutMs: number
|
||||
/** Bound on one package probe, in milliseconds. */
|
||||
readonly probeTimeoutMs: number
|
||||
/** How many trailing bytes of an install run's output an install failure reports. */
|
||||
readonly installLogTailBytes: number
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* and the Web host's `plugins` Remote.
|
||||
*
|
||||
* {@link PluginInstaller} changes what a profile has installed: it runs pnpm
|
||||
* in the profile directory, probes every new package in a child process, and
|
||||
* removes what has no place in a profile. It needs the profile on disk, not
|
||||
* in the profile directory, reads static package declarations, and rejects
|
||||
* conflicting bundle layers while retaining undeclared packages. It needs the profile on disk, not
|
||||
* a booted tree, so the CLI uses it before any plugin starts.
|
||||
* {@link PluginManager} adds the operations that need the booted tree —
|
||||
* enabling, disabling, and retrying bundles through the profile runtime,
|
||||
@@ -21,6 +21,3 @@ export type * from './types.ts'
|
||||
export type { PluginToolingConfig, SpawnLike } from './helpers.ts'
|
||||
export { PluginInstaller, type PluginInstallerOptions, type PluginInstallOutcome } from './installer.ts'
|
||||
export { PluginManager, type PluginManagerOptions, type PresetLayers } from './manager.ts'
|
||||
|
||||
/** Group id of an isolated bundle, for callers that address its rows in the tree. */
|
||||
export { bundleGroupId } from '@deepseek-ai/dsh-app-boot'
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Installing and removing packages in one profile with pnpm, and probing
|
||||
* them, over the profile on disk: nothing here touches a running tree, which
|
||||
* Installing and removing packages with pnpm and reading static declarations
|
||||
* from the profile on disk: nothing here touches a running tree, which
|
||||
* is what lets the `dsh plugin` command install before any plugin starts.
|
||||
* @module @deepseek-ai/dsh-plugin-manager/installer
|
||||
*/
|
||||
@@ -13,15 +13,12 @@ import {
|
||||
awaitChildClose,
|
||||
claimLayerIds,
|
||||
healProfilesModuleFallback,
|
||||
PLUGIN_PROBE_DIR,
|
||||
probePackage,
|
||||
readProbeCache,
|
||||
readPackageMetadata,
|
||||
readProfileManifest,
|
||||
reconcileInstalledBundles,
|
||||
resolveBundleDir,
|
||||
resolveProfileLayer,
|
||||
writeProbeCache,
|
||||
type PluginProbe,
|
||||
type PackageMetadata,
|
||||
type Profile,
|
||||
type ProfileManifest,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
@@ -57,15 +54,15 @@ export interface PluginInstallerOptions {
|
||||
readonly color: boolean
|
||||
/** Test seam: the child spawner; defaults to `node:child_process`. */
|
||||
readonly spawn?: SpawnLike
|
||||
/** Test seam: the package probe; defaults to app-boot's. */
|
||||
readonly probe?: typeof probePackage
|
||||
/** Test seam: the static metadata reader; defaults to app-boot’s. */
|
||||
readonly metadata?: typeof readPackageMetadata
|
||||
}
|
||||
|
||||
/** The installed package's manifest slice the view reads. */
|
||||
export type InstalledManifest = ProfileManifest & { description?: string; dsh?: ProfileManifest['dsh'] & { title?: string } }
|
||||
|
||||
/**
|
||||
* Installs and removes packages in one profile with pnpm, and probes them.
|
||||
* Installs and removes packages in one profile with pnpm, and reads their declarations.
|
||||
*
|
||||
* Every run reads the profile manifest afresh and writes it through the
|
||||
* app-boot helpers the `dsh plugin` command uses, so the CLI and the Web
|
||||
@@ -76,14 +73,14 @@ export type InstalledManifest = ProfileManifest & { description?: string; dsh?:
|
||||
*/
|
||||
export class PluginInstaller {
|
||||
private readonly spawn: SpawnLike
|
||||
private readonly probeRunner: typeof probePackage
|
||||
private readonly metadataReader: typeof readPackageMetadata
|
||||
|
||||
/**
|
||||
* @param options - the profile, the tooling bounds, and the output sink.
|
||||
*/
|
||||
constructor(private readonly options: PluginInstallerOptions) {
|
||||
this.spawn = options.spawn ?? spawnChild
|
||||
this.probeRunner = options.probe ?? probePackage
|
||||
this.metadataReader = options.metadata ?? readPackageMetadata
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -128,29 +125,20 @@ export class PluginInstaller {
|
||||
}
|
||||
|
||||
/**
|
||||
* The package's probe record, from the profile cache or a fresh probe
|
||||
* written to it. A record for another installed version is not reused.
|
||||
* Read the installed manifest and patch without executing modules or caching runtime verdicts.
|
||||
* @param packageName - the installed package.
|
||||
* @returns the record.
|
||||
* @returns its current static declarations.
|
||||
*/
|
||||
async probe(packageName: string): Promise<PluginProbe> {
|
||||
const version = this.readInstalledManifest(packageName)?.version
|
||||
const cached = readProbeCache(this.options.profileDir, packageName, version)
|
||||
if (cached !== undefined) return cached
|
||||
const probe = await this.probeRunner({
|
||||
binName: NAME,
|
||||
profileDir: this.options.profileDir,
|
||||
installAnchor: this.options.installAnchor,
|
||||
packageName,
|
||||
timeoutMs: this.options.config.probeTimeoutMs,
|
||||
metadata(packageName: string): PackageMetadata {
|
||||
return this.metadataReader({
|
||||
binName: NAME, profileDir: this.options.profileDir,
|
||||
installAnchor: this.options.installAnchor, packageName,
|
||||
})
|
||||
writeProbeCache(this.options.profileDir, probe)
|
||||
return probe
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a package with pnpm, probe every package the run added, and
|
||||
* remove what has no place in a profile. New bundles are left disabled.
|
||||
* Install with pnpm and reject bundles whose declarations conflict with the profile.
|
||||
* Undeclared packages remain installed; new bundles are left disabled.
|
||||
* @param spec - what to install, in pnpm's own vocabulary: a registry
|
||||
* name, a `github:` or git URL, a tarball, or an absolute path.
|
||||
* @returns what the run installed and what it removed again.
|
||||
@@ -182,7 +170,7 @@ export class PluginInstaller {
|
||||
const installed: string[] = []
|
||||
const removed: PluginInstallRejection[] = []
|
||||
for (const name of added) {
|
||||
const reason = await this.rejection(name)
|
||||
const reason = this.rejection(name)
|
||||
if (reason === undefined) {
|
||||
installed.push(name)
|
||||
continue
|
||||
@@ -201,7 +189,7 @@ export class PluginInstaller {
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `pnpm remove`, reconcile the layer list, and forget the probe record.
|
||||
* Run `pnpm remove`, reconcile the layer list, and remove any obsolete discovery record.
|
||||
* @param packageName - the dependency to remove.
|
||||
* @throws {PluginOperationError} `plugins/install-failed` when pnpm fails.
|
||||
*/
|
||||
@@ -210,29 +198,22 @@ export class PluginInstaller {
|
||||
const before = readProfileManifest(NAME, profileDir)
|
||||
await this.runPnpm(['remove', packageName], packageName)
|
||||
reconcileInstalledBundles(NAME, profileDir, installAnchor, before, { autoEnable: false })
|
||||
rmSync(join(profileDir, PLUGIN_PROBE_DIR, `${packageName.replaceAll('/', '__')}.json`), { force: true })
|
||||
rmSync(join(profileDir, '.dsh-plugins', `${packageName.replaceAll('/', '__')}.json`), { force: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* The post-install check of one package pnpm added: a package that is
|
||||
* neither a bundle nor a plugin module has no place in a profile, and a
|
||||
* bundle whose row id another layer already owns could only mount as a
|
||||
* conflict record. A package the probe cannot run stays installed: the
|
||||
* view reports it as not enableable with the probe's own reason.
|
||||
* Check a new bundle against the profile’s row ownership and stage rules.
|
||||
* Undeclared or unreadable packages remain installed for repair or removal.
|
||||
* @returns why the package is removed again, or undefined to keep it.
|
||||
*/
|
||||
private async rejection(packageName: string): Promise<string | undefined> {
|
||||
let probe: PluginProbe
|
||||
private rejection(packageName: string): string | undefined {
|
||||
let metadata: PackageMetadata
|
||||
try {
|
||||
probe = await this.probe(packageName)
|
||||
metadata = this.metadata(packageName)
|
||||
} catch {
|
||||
return undefined // the failure is re-derived on every list and shown there
|
||||
return undefined // Keep unreadable declarations installed so the user can repair or remove the package.
|
||||
}
|
||||
// A refused probe (an import that throws, another cordis copy) keeps the
|
||||
// package: the view shows it as not enableable with the probe's reason.
|
||||
if (!probe.ok) return undefined
|
||||
if (probe.kind === 'library') return 'declares neither a dsh bundle nor a plugin module'
|
||||
if (probe.kind !== 'bundle') return undefined
|
||||
if (metadata.kind !== 'bundle') return undefined
|
||||
const { profileDir, installAnchor } = this.options
|
||||
try {
|
||||
const layer = resolveProfileLayer(NAME, readProfileManifest(NAME, profileDir), packageName, installAnchor, profileDir)
|
||||
|
||||
@@ -13,8 +13,10 @@ import {
|
||||
healProfilesModuleFallback,
|
||||
loadProfile,
|
||||
readProfileManifest,
|
||||
recordContainedStates,
|
||||
type probePackage,
|
||||
rootIncludeEntry,
|
||||
inspectEntryIssues,
|
||||
type EntryIssue,
|
||||
type readPackageMetadata,
|
||||
type ProfileRuntime,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { mutatePatchFile, readPatchListFile, type PatchRow } from '@deepseek-ai/dsh-app-boot/patch-file'
|
||||
@@ -30,6 +32,7 @@ import type {
|
||||
PluginInstallResult,
|
||||
PluginPackageView,
|
||||
PluginRowAddition,
|
||||
PluginRowIssue,
|
||||
PluginRowReference,
|
||||
PluginRowTarget,
|
||||
PluginServiceDependent,
|
||||
@@ -72,8 +75,8 @@ export interface PluginManagerOptions {
|
||||
readonly runningAgents: () => number
|
||||
/** Test seam: the child spawner; defaults to `node:child_process`. */
|
||||
readonly spawn?: SpawnLike
|
||||
/** Test seam: the package probe; defaults to app-boot's. */
|
||||
readonly probe?: typeof probePackage
|
||||
/** Test seam: the static metadata reader; defaults to app-boot’s. */
|
||||
readonly metadata?: typeof readPackageMetadata
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,7 +160,7 @@ export class PluginManager {
|
||||
// The chunks feed the Web install dialog's terminal, which draws SGR colour.
|
||||
color: true,
|
||||
...this.options.spawn === undefined ? {} : { spawn: this.options.spawn },
|
||||
...this.options.probe === undefined ? {} : { probe: this.options.probe },
|
||||
...this.options.metadata === undefined ? {} : { metadata: this.options.metadata },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -172,13 +175,14 @@ export class PluginManager {
|
||||
const manifest = readProfileManifest(NAME, runtime.dir)
|
||||
const names = [...new Set([...bundlesOf(manifest), ...Object.keys(dependenciesOf(manifest))])]
|
||||
const views: PluginPackageView[] = []
|
||||
for (const name of names) views.push(await packageView(this.ctx, runtime, installer, manifest, name))
|
||||
const issues = await inspectEntryIssues(this.ctx)
|
||||
for (const name of names) views.push(packageView(this.ctx, runtime, installer, manifest, name, issues))
|
||||
return views
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Install a package into the profile with pnpm, probe it, and leave it
|
||||
* Install a package into the profile with pnpm, read its declarations, and leave it
|
||||
* disabled unless asked otherwise. The run's output is emitted as
|
||||
* `plugins/install-log` chunks carrying the returned `jobId`.
|
||||
* @param spec - what to install, in pnpm's own vocabulary: a registry
|
||||
@@ -215,7 +219,7 @@ export class PluginManager {
|
||||
|
||||
/**
|
||||
* Remove a package from the profile: disable it when enabled, drop every
|
||||
* user-layer row that names it, run `pnpm remove`, and forget its probe.
|
||||
* user-layer row that names it, run `pnpm remove`, and clean obsolete discovery metadata.
|
||||
* @param packageName - the installed dependency to remove.
|
||||
* @throws {PluginOperationError} `plugins/not-installed`, `plugins/agents-running`,
|
||||
* or `plugins/install-failed` when pnpm exits non-zero.
|
||||
@@ -245,12 +249,12 @@ export class PluginManager {
|
||||
|
||||
/**
|
||||
* Put an installed bundle into the layer list and, on a live profile,
|
||||
* recompose the tree with it. A rejected recomposition restores the list
|
||||
* and reports the tree's reason; the tree that was running keeps running.
|
||||
* recompose the tree. Per-row failures retain enabled selection and successful siblings.
|
||||
* Preparation failures revert enable selection and report their reason.
|
||||
* @param packageName - the installed bundle.
|
||||
* @returns whether the list changed and whether the change is live.
|
||||
* @throws {PluginOperationError} `plugins/not-installed`, `plugins/not-enableable`
|
||||
* for a package that declares no bundle or whose probe refused it, or
|
||||
* for a package without a readable bundle declaration, or
|
||||
* `plugins/enable-failed`.
|
||||
*/
|
||||
async enable(packageName: string): Promise<PluginEnableResult> {
|
||||
@@ -261,15 +265,11 @@ export class PluginManager {
|
||||
const runtime = this.runtime()
|
||||
const installer = this.installer(runtime)
|
||||
installer.assertInstalled(packageName)
|
||||
const probe = await installer.probe(packageName).catch((error: unknown) => {
|
||||
const reason = `cannot be probed: ${messageOf(error)}`
|
||||
try {
|
||||
installer.metadata(packageName)
|
||||
} catch (error) {
|
||||
const reason = `cannot read package declarations: ${messageOf(error)}`
|
||||
throw new PluginOperationError('plugins/not-enableable', `${NAME}: ${packageName} ${reason}`, { packageName, reason })
|
||||
})
|
||||
if (!probe.ok) {
|
||||
// The probe states a reason with every refusal; the fallback keeps the type total.
|
||||
/* v8 ignore next */
|
||||
const reason = probe.reason ?? 'the probe refused it'
|
||||
throw new PluginOperationError('plugins/not-enableable', `${NAME}: ${packageName} cannot be enabled: ${reason}`, { packageName, reason })
|
||||
}
|
||||
let changed: boolean
|
||||
try {
|
||||
@@ -291,26 +291,19 @@ export class PluginManager {
|
||||
profile: loadProfile(NAME, runtime.profileName, runtime.installAnchor, undefined, { userLayer: false }),
|
||||
})
|
||||
await runtime.recompose({ reloadBundles: true })
|
||||
// The boot audit does not run again; record what the bundle's rows
|
||||
// came to, so the list shows a waiting or failed row with its reason.
|
||||
await recordContainedStates(this.ctx)
|
||||
} catch (error) {
|
||||
const reason = messageOf(error)
|
||||
disableBundle(NAME, runtime.dir, packageName)
|
||||
// The tree without the bundle is the tree that was running a moment
|
||||
// ago; recomposing back to it is the update that already succeeded.
|
||||
/* v8 ignore next */
|
||||
await runtime.recompose({ reloadBundles: true }).catch(() => undefined)
|
||||
throw new PluginOperationError(
|
||||
'plugins/enable-failed',
|
||||
`${NAME}: enabling ${packageName} failed and the layer list was restored: ${reason}`,
|
||||
`${NAME}: enabling ${packageName} could not apply the layer; its enable selection was reverted: ${reason}`,
|
||||
{ packageName, reason },
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
}
|
||||
this.changed('enable', packageName)
|
||||
return { changed, effect: 'live' }
|
||||
return { changed, effect: 'live', ...this.issueResult(await inspectEntryIssues(this.ctx)) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -338,12 +331,12 @@ export class PluginManager {
|
||||
}
|
||||
if (changed) await runtime.recompose({ reloadBundles: true })
|
||||
this.changed('disable', packageName)
|
||||
return { changed, effect: 'live' }
|
||||
return { changed, effect: 'live', ...this.issueResult(await inspectEntryIssues(this.ctx)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose an enabled bundle again from scratch: its group leaves the tree
|
||||
* and returns, so rows that failed at boot get another start.
|
||||
* Remove the whole bundle layer, await its cleanup, and enable it again.
|
||||
* Every owned row gets a fresh activation attempt.
|
||||
* @param packageName - the enabled bundle.
|
||||
* @returns the enable outcome of the second step.
|
||||
* @throws {PluginOperationError} `plugins/bad-request` when the bundle is not enabled, or the enable failures.
|
||||
@@ -372,7 +365,7 @@ export class PluginManager {
|
||||
* `id` overrides the derived row id, `config` overrides the declared default.
|
||||
* @returns where the row landed.
|
||||
* @throws {PluginOperationError} `plugins/not-installed`, `plugins/not-enableable`
|
||||
* when the module is not one the probe found addable, `plugins/row-conflict`,
|
||||
* when the module is absent from `dsh.plugins`, `plugins/row-conflict`,
|
||||
* or `plugins/unavailable` for a preset target without a roster.
|
||||
*/
|
||||
async addRow(
|
||||
@@ -391,18 +384,20 @@ export class PluginManager {
|
||||
const runtime = this.runtime()
|
||||
const installer = this.installer(runtime)
|
||||
installer.assertInstalled(packageName)
|
||||
const probe = await installer.probe(packageName)
|
||||
const metadata = installer.metadata(packageName)
|
||||
const declared = options?.module ?? '.'
|
||||
const addable = declared === '.' && probe.kind === 'plugin'
|
||||
? { ok: true, config: undefined, error: undefined }
|
||||
: probe.addable.find(entry => entry.name === declared)
|
||||
if (addable === undefined || !addable.ok) {
|
||||
const reason = addable?.error ?? `${declared} is not a module ${packageName} declares addable`
|
||||
const addable = metadata.addable.find(entry => entry.name === declared)
|
||||
if (addable === undefined) {
|
||||
const reason = `${declared} is not a module ${packageName} declares addable`
|
||||
throw new PluginOperationError('plugins/not-enableable', `${NAME}: ${reason}`, { packageName, reason })
|
||||
}
|
||||
const moduleName = moduleSpecifier(packageName, declared)
|
||||
const rowId = options?.id ?? derivedRowId(packageName, declared)
|
||||
const row: PatchRow = { id: rowId, name: moduleName, config: options?.config ?? addable.config ?? {} }
|
||||
if (rowId.trim() === '' || rowId.includes(':')) {
|
||||
throw new PluginOperationError('plugins/bad-request', `${NAME}: row ids must be nonempty and cannot contain ':'`, {})
|
||||
}
|
||||
const config = options?.config !== undefined ? options.config : addable.config !== undefined ? addable.config : {}
|
||||
const row: PatchRow = { id: rowId, name: moduleName, config }
|
||||
if (await this.rowExists(runtime, target, rowId)) {
|
||||
throw new PluginOperationError('plugins/row-conflict', `${NAME}: a row ${JSON.stringify(rowId)} already exists`, { rowId, target })
|
||||
}
|
||||
@@ -522,7 +517,8 @@ export class PluginManager {
|
||||
/** Whether the target layer's composition already carries a row with `rowId`. */
|
||||
private async rowExists(runtime: ProfileRuntime, target: PluginRowTarget, rowId: string): Promise<boolean> {
|
||||
if (target.kind === 'global') {
|
||||
return [...this.ctx.loader.entries()].some(entry => entry.options.id === rowId)
|
||||
const tree = rootIncludeEntry(this.ctx)?.subtree
|
||||
return [...this.ctx.loader.entries()].some(entry => entry.parent.tree === tree && entry.options.id === rowId)
|
||||
|| (await readPatchListFile(NAME, runtime.patchPath, 'patches') ?? []).some(patch => patch.insert?.some(row => row.id === rowId))
|
||||
}
|
||||
const composition = (await this.presets().compositionInventory()).find(candidate => candidate.id === target.preset)
|
||||
@@ -553,6 +549,13 @@ export class PluginManager {
|
||||
return file
|
||||
}
|
||||
|
||||
/** Project runtime objects into the operation's JSON result. */
|
||||
private issueResult(issues: readonly EntryIssue[]): { issues?: PluginRowIssue[] } {
|
||||
return issues.length === 0 ? {} : { issues: issues.map(issue => ({
|
||||
entryId: issue.entry.id, moduleName: issue.entry.options.name, stage: issue.stage, message: issue.message,
|
||||
})) }
|
||||
}
|
||||
|
||||
private changed(reason: PluginChangeReason, packageName?: string): void {
|
||||
this.ctx.emit('plugins/changed', { reason, ...optional('packageName', packageName) })
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* @module @deepseek-ai/dsh-plugin-manager/modules
|
||||
*/
|
||||
|
||||
import type { PluginProbe } from '@deepseek-ai/dsh-app-boot'
|
||||
import type { PackageMetadata } from '@deepseek-ai/dsh-app-boot'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import { optional } from './helpers.ts'
|
||||
import type { PluginPackageAddableView } from './types.ts'
|
||||
@@ -32,20 +32,17 @@ export function derivedRowId(packageName: string, declared: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* The wire view of one probed addable module.
|
||||
* The wire view of one declared addable module.
|
||||
* @param packageName - the package.
|
||||
* @param entry - the probe's record of the declared module.
|
||||
* @param entry - the installed manifest’s module declaration.
|
||||
* @returns the view.
|
||||
*/
|
||||
export function addableView(packageName: string, entry: PluginProbe['addable'][number]): PluginPackageAddableView {
|
||||
export function addableView(packageName: string, entry: PackageMetadata['addable'][number]): PluginPackageAddableView {
|
||||
return {
|
||||
moduleName: moduleSpecifier(packageName, entry.name),
|
||||
declaredName: entry.name,
|
||||
...optional('title', entry.title),
|
||||
// The probe read both from JSON: a manifest field and a child's report.
|
||||
// Config comes from the installed JSON manifest.
|
||||
...optional('config', entry.config as JsonValue | undefined),
|
||||
ok: entry.ok,
|
||||
...optional('error', entry.error),
|
||||
...optional('configSchema', entry.configSchema as JsonValue | undefined),
|
||||
}
|
||||
}
|
||||
@@ -7,26 +7,25 @@
|
||||
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
|
||||
/** What an installed package is: a bundle layer, a plugin module, or a plain library. */
|
||||
export type PluginPackageKind = 'bundle' | 'plugin' | 'library'
|
||||
/** What an installed package is: a bundle layer, declared plugin modules, or an unknown package. */
|
||||
export type PluginPackageKind = 'bundle' | 'plugin' | 'unknown'
|
||||
|
||||
/** Who supplied a package: the installation's own bundles, or a dependency the user installed. */
|
||||
export type PluginPackageTrust = 'builtin' | 'external'
|
||||
|
||||
/** When a bundle's rows mount: isolated at `runtime` (the default), or with the built-in rows at `boot`. */
|
||||
/** Startup failure policy: external runtime rows are optional; boot rows are required. */
|
||||
export type PluginPackageStage = 'boot' | 'runtime'
|
||||
|
||||
/**
|
||||
* The state one package is in, folded from the profile manifest, the probe
|
||||
* record, and the live tree:
|
||||
* The state one package is in, folded from the manifest, declarations, and current entries:
|
||||
*
|
||||
* - `running`: enabled and every row active;
|
||||
* - `partial`: enabled with at least one row failed or waiting;
|
||||
* - `running`: enabled, all enabled rows active, and no current issue;
|
||||
* - `partial`: at least one active row, with a failed attempt or unresolved dependency;
|
||||
* - `failed`: enabled and no row active;
|
||||
* - `disabled`: installed and not in the layer list;
|
||||
* - `not-enableable`: installed but the probe refused it (import failure, foreign cordis copy);
|
||||
* - `not-enableable`: bundle declarations cannot be read;
|
||||
* - `restart-required`: its manifest state and the live tree disagree, which a profile without live reload resolves at the next start;
|
||||
* - `plain`: a library or plugin module, which is added to a composition rather than enabled.
|
||||
* - `plain`: a bundle-less package; explicitly declared modules can be added to a composition.
|
||||
*/
|
||||
export type PluginPackageStatus =
|
||||
| 'running'
|
||||
@@ -54,7 +53,7 @@ export interface PluginPackageRowView {
|
||||
readonly disabledBy?: 'user' | 'composition'
|
||||
/** Root-fiber phase, or null when the row has no live fiber. */
|
||||
readonly phase: PluginRowPhase
|
||||
/** The recorded startup failure of an isolated row, when one is recorded. */
|
||||
/** The current row issue, which can coexist with an active old fiber after an update fails. */
|
||||
readonly failure?: { readonly stage: string; readonly message: string }
|
||||
}
|
||||
|
||||
@@ -68,17 +67,12 @@ export interface PluginPackageAddableView {
|
||||
readonly title?: string
|
||||
/** Default row config, when declared. */
|
||||
readonly config?: JsonValue
|
||||
/** Whether the probe imported the module and found a plugin. */
|
||||
readonly ok: boolean
|
||||
/** The probe's import failure, when `ok` is false. */
|
||||
readonly error?: string
|
||||
/** The module's `Config` schema envelope, when it declares one. */
|
||||
readonly configSchema?: JsonValue
|
||||
|
||||
}
|
||||
|
||||
/** One package as the manager sees it. */
|
||||
export interface PluginPackageView {
|
||||
/** The package name; also the row-id prefix and group id of an isolated bundle. */
|
||||
/** The installed package name. */
|
||||
readonly name: string
|
||||
/** The installed version, when the manifest declares one. */
|
||||
readonly version?: string
|
||||
@@ -98,16 +92,16 @@ export interface PluginPackageView {
|
||||
readonly reason?: string
|
||||
/** The harness version range the package declares in `engines.dsh`. */
|
||||
readonly enginesDsh?: string
|
||||
/** Whether the package shares the harness's cordis copy; null when the probe could not tell. */
|
||||
/** Whether physical Cordis resolution matches the harness; null when unavailable. */
|
||||
readonly cordisSameCopy: boolean | null
|
||||
/** The rows the bundle contributes, from the live tree when enabled, else from the probe. */
|
||||
/** The rows the bundle contributes, from the live tree when enabled, else from its static patch. */
|
||||
readonly rows: readonly PluginPackageRowView[]
|
||||
/** Current issues in owned rows or existing rows this bundle overrides; ownership is unchanged. */
|
||||
readonly issues?: readonly PluginRowIssue[]
|
||||
/** Ids of built-in rows the bundle's patch overrides. */
|
||||
readonly overrides: readonly string[]
|
||||
/** Agent-plane modules the package declares addable. */
|
||||
readonly addable: readonly PluginPackageAddableView[]
|
||||
/** ISO time of the probe record this view was folded from, when one exists. */
|
||||
readonly probedAt?: string
|
||||
/** Whether the profile applies user patch files while running; false means changes wait for a restart. */
|
||||
readonly liveReload: boolean
|
||||
}
|
||||
@@ -121,7 +115,7 @@ export type PluginRowTarget =
|
||||
export interface PluginInstallRejection {
|
||||
/** The package name. */
|
||||
readonly name: string
|
||||
/** Why it was removed: not a dsh package, or a row id another layer already owns. */
|
||||
/** Why the bundle was rejected, such as a conflicting row id or invalid stage. */
|
||||
readonly reason: string
|
||||
}
|
||||
|
||||
@@ -147,6 +141,16 @@ export interface PluginEnableResult {
|
||||
readonly changed: boolean
|
||||
/** `live` when the tree was recomposed, `restart` when the profile applies changes at its next start. */
|
||||
readonly effect: 'live' | 'restart'
|
||||
/** Current row issues after a live application; absent when no issues were observed or application waits for restart. */
|
||||
readonly issues?: readonly PluginRowIssue[]
|
||||
}
|
||||
|
||||
/** Serializable outcome for an entry whose requested behavior is not fully active. */
|
||||
export interface PluginRowIssue {
|
||||
readonly entryId: string
|
||||
readonly moduleName: string
|
||||
readonly stage: string
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
/** One row outside a package that injects a service one of its rows provides. */
|
||||
@@ -182,7 +186,7 @@ export interface PluginRowAddition {
|
||||
}
|
||||
|
||||
/** Why the manager changed something, for a listener deciding what to refresh. */
|
||||
export type PluginChangeReason = 'install' | 'uninstall' | 'enable' | 'disable' | 'retry' | 'row'
|
||||
export type PluginChangeReason = 'install' | 'uninstall' | 'enable' | 'disable' | 'retry' | 'row' | 'runtime'
|
||||
|
||||
/** One chunk of an install run's output. */
|
||||
export interface PluginInstallLogChunk {
|
||||
@@ -211,9 +215,9 @@ export interface PluginOperationDetailsMap {
|
||||
'plugins/unavailable': { readonly reason: string }
|
||||
/** The package is not a profile dependency. */
|
||||
'plugins/not-installed': { readonly packageName: string }
|
||||
/** The package cannot be enabled or added, with the probe's reason. */
|
||||
/** The package cannot be enabled or added, with the declaration or request error. */
|
||||
'plugins/not-enableable': { readonly packageName: string; readonly reason: string }
|
||||
/** Enabling composed the bundle and the tree rejected it; the layer list was restored. */
|
||||
/** Preparation or the root Include rejected enablement; the layer selection was reverted. */
|
||||
'plugins/enable-failed': { readonly packageName: string; readonly reason: string }
|
||||
/** pnpm exited non-zero, could not be spawned, or timed out. */
|
||||
'plugins/install-failed': { readonly spec: string; readonly exitCode: number | null; readonly log: string }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* The view fold: one package's manifest facts, probe record, and live-tree
|
||||
* The view fold: one package's manifest facts, static declarations, and live-tree
|
||||
* rows folded into the `PluginPackageView` the manager lists, plus the row
|
||||
* ownership walk the dependents query shares.
|
||||
* @module @deepseek-ai/dsh-plugin-manager/view
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import type { Context, FiberState } from '@deepseek-ai/cordis'
|
||||
import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import { layerTrust, type PluginProbe, type ProfileManifest, type ProfileRuntime } from '@deepseek-ai/dsh-app-boot'
|
||||
import { rootIncludeEntry, layerTrust, type EntryIssue, type PackageMetadata, type ProfileManifest, type ProfileRuntime } from '@deepseek-ai/dsh-app-boot'
|
||||
import type { BundleStage } from '@deepseek-ai/dsh-package-manifest'
|
||||
import { bundlesOf, dependenciesOf, messageOf, optional } from './helpers.ts'
|
||||
import type { PluginInstaller } from './installer.ts'
|
||||
@@ -41,46 +41,56 @@ export interface RowFacts {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one package's manifest, probe, and tree facts into its view.
|
||||
* @param ctx - the context whose Loader tree and failure registry the rows come from.
|
||||
* Fold one package's manifest, declarations, and tree facts into its view.
|
||||
* @param ctx - the context whose Loader tree and current entry diagnostics the rows come from.
|
||||
* @param runtime - the booted profile.
|
||||
* @param installer - the installer over the same profile, for the probe record and the installed manifest.
|
||||
* @param installer - the installer over the same profile, for static declarations and the installed manifest.
|
||||
* @param manifest - the profile manifest, read once per list.
|
||||
* @param name - the package.
|
||||
* @param allIssues - issues observed once for this list request.
|
||||
* @returns the view.
|
||||
*/
|
||||
export async function packageView(
|
||||
export function packageView(
|
||||
ctx: Context,
|
||||
runtime: ProfileRuntime,
|
||||
installer: PluginInstaller,
|
||||
manifest: ProfileManifest,
|
||||
name: string,
|
||||
): Promise<PluginPackageView> {
|
||||
allIssues: readonly EntryIssue[],
|
||||
): PluginPackageView {
|
||||
const installed = name in dependenciesOf(manifest)
|
||||
const enabled = bundlesOf(manifest).includes(name)
|
||||
const layer = runtime.layers.find(candidate => candidate.packageName === name)
|
||||
const trust = layer?.trust ?? layerTrust(manifest, name)
|
||||
const liveReload = runtime.patchReload === 'live'
|
||||
let probe: PluginProbe | undefined
|
||||
let probeFailure: string | undefined
|
||||
let metadata: PackageMetadata | undefined
|
||||
let metadataFailure: string | undefined
|
||||
if (installed) {
|
||||
try {
|
||||
probe = await installer.probe(name)
|
||||
metadata = installer.metadata(name)
|
||||
} catch (error) {
|
||||
probeFailure = messageOf(error)
|
||||
metadataFailure = messageOf(error)
|
||||
}
|
||||
}
|
||||
const packageManifest = installer.readInstalledManifest(name)
|
||||
let packageManifest: ReturnType<PluginInstaller['readInstalledManifest']>
|
||||
try {
|
||||
packageManifest = installer.readInstalledManifest(name)
|
||||
} catch (error) {
|
||||
metadataFailure = messageOf(error)
|
||||
}
|
||||
const stage: BundleStage = layer?.stage
|
||||
?? (manifest.dsh?.profile?.stages?.[name] ?? packageManifest?.dsh?.bundle?.stage ?? 'runtime')
|
||||
const kind = probe?.kind ?? (layer !== undefined || packageManifest?.dsh?.bundle !== undefined ? 'bundle' : 'library')
|
||||
const kind = metadata?.kind ?? (layer !== undefined || packageManifest?.dsh?.bundle !== undefined ? 'bundle' : 'unknown')
|
||||
const composed = layer !== undefined
|
||||
const rows = composed ? composedRows(ctx, runtime, name) : probedRows(probe)
|
||||
const status = packageStatus({ kind, installed, enabled, composed, liveReload, probe, probeFailure, rows })
|
||||
const reason = probeFailure ?? probe?.reason ?? (status === 'restart-required'
|
||||
const rootTree = rootIncludeEntry(ctx)?.subtree
|
||||
const affected = allIssues.filter(issue => runtime.originOfEntry(issue.entry)?.packageName === name
|
||||
|| (issue.entry.parent.tree === rootTree && metadata?.overrides.includes(issue.entry.options.id)))
|
||||
const rows = composed ? composedRows(ctx, runtime, name, allIssues) : declaredRows(metadata)
|
||||
const status = packageStatus({ kind, installed, enabled, composed, liveReload, metadataFailure, rows, affected })
|
||||
const reason = metadataFailure ?? (status === 'restart-required'
|
||||
? 'the profile applies layer changes at its next start'
|
||||
: status === 'failed' || status === 'partial'
|
||||
? rows.find(row => row.failure !== undefined)?.failure?.message
|
||||
? affected[0]?.message ?? rows.find(row => row.failure !== undefined)?.failure?.message
|
||||
: undefined)
|
||||
return {
|
||||
name,
|
||||
@@ -94,12 +104,14 @@ export async function packageView(
|
||||
enabled,
|
||||
status,
|
||||
...optional('reason', reason),
|
||||
...optional('enginesDsh', probe?.enginesDsh),
|
||||
cordisSameCopy: probe?.cordisSameCopy ?? null,
|
||||
...optional('enginesDsh', metadata?.enginesDsh),
|
||||
cordisSameCopy: metadata?.cordisSameCopy ?? null,
|
||||
rows,
|
||||
overrides: probe?.overrides ?? [],
|
||||
addable: (probe?.addable ?? []).map(entry => addableView(name, entry)),
|
||||
...optional('probedAt', probe?.checkedAt),
|
||||
overrides: metadata?.overrides ?? [],
|
||||
addable: (metadata?.addable ?? []).map(entry => addableView(name, entry)),
|
||||
...affected.length === 0 ? {} : { issues: affected.map(issue => ({
|
||||
entryId: issue.entry.id, moduleName: issue.entry.options.name, stage: issue.stage, message: issue.message,
|
||||
})) },
|
||||
liveReload,
|
||||
}
|
||||
}
|
||||
@@ -111,53 +123,40 @@ function packageStatus(facts: {
|
||||
enabled: boolean
|
||||
composed: boolean
|
||||
liveReload: boolean
|
||||
probe: PluginProbe | undefined
|
||||
probeFailure: string | undefined
|
||||
metadataFailure: string | undefined
|
||||
affected: readonly EntryIssue[]
|
||||
rows: readonly PluginPackageRowView[]
|
||||
}): PluginPackageStatus {
|
||||
if (facts.kind !== 'bundle') return 'plain'
|
||||
if (facts.installed && (facts.probeFailure !== undefined || facts.probe?.ok === false)) return 'not-enableable'
|
||||
if (facts.installed && facts.metadataFailure !== undefined) return 'not-enableable'
|
||||
if (facts.enabled !== facts.composed) return 'restart-required'
|
||||
if (!facts.enabled) return 'disabled'
|
||||
const live = facts.rows.filter(row => row.enabled)
|
||||
if (live.length === 0) return 'running'
|
||||
if (live.length === 0) return facts.affected.length === 0 ? 'running' : 'failed'
|
||||
const active = live.filter(row => row.phase === 'active').length
|
||||
if (active === live.length) return 'running'
|
||||
if (active === live.length && facts.affected.length === 0) return 'running'
|
||||
return active === 0 ? 'failed' : 'partial'
|
||||
}
|
||||
|
||||
/** The rows a composed bundle owns in the live tree, plus rows only the failure registry knows. */
|
||||
function composedRows(ctx: Context, runtime: ProfileRuntime, name: string): PluginPackageRowView[] {
|
||||
const failures = ctx.get('pluginFailures')
|
||||
/** Current native entries and failed groups belonging to the bundle. */
|
||||
function composedRows(ctx: Context, runtime: ProfileRuntime, name: string, issues: readonly EntryIssue[]): PluginPackageRowView[] {
|
||||
const failures = new Map(issues.map(issue => [issue.entry, issue]))
|
||||
const rows: PluginPackageRowView[] = []
|
||||
const listed = new Set<string>()
|
||||
for (const { entry, rowId } of ownedEntries(ctx, runtime, name)) {
|
||||
listed.add(entry.id)
|
||||
const failure = failures?.get(entry.id)
|
||||
for (const entry of ctx.loader.entries()) {
|
||||
if (runtime.originOfEntry(entry)?.packageName !== name) continue
|
||||
const failure = failures.get(entry)
|
||||
if (entry.options.group && failure === undefined) continue
|
||||
const enabled = failure?.stage === 'disabled-expression' || !entry.disabled
|
||||
rows.push({
|
||||
entryId: entry.id,
|
||||
rowId,
|
||||
rowId: entry.options.id,
|
||||
moduleName: entry.options.name,
|
||||
enabled: !entry.disabled,
|
||||
// The runtime walks the groups holding the entry: a row inside a group the user disabled is the user's doing.
|
||||
...entry.disabled ? { disabledBy: runtime.userDisables(entry) ? 'user' as const : 'composition' as const } : {},
|
||||
phase: entry.fiber === undefined ? null : ROW_PHASE[entry.fiber.state],
|
||||
enabled,
|
||||
...enabled ? {} : { disabledBy: runtime.userDisables(entry) ? 'user' as const : 'composition' as const },
|
||||
phase: entry.fiber === undefined ? (failure === undefined ? null : 'failed') : ROW_PHASE[entry.fiber.state],
|
||||
...failure === undefined ? {} : { failure: { stage: failure.stage, message: failure.message } },
|
||||
})
|
||||
}
|
||||
/* v8 ignore next -- the root include provides the registry on every boot; the guard answers its optional type */
|
||||
for (const failure of failures?.list() ?? []) {
|
||||
if (listed.has(failure.entryId)) continue
|
||||
if (runtime.originOf(failure.rowId)?.packageName !== name) continue
|
||||
rows.push({
|
||||
entryId: failure.entryId,
|
||||
rowId: failure.rowId,
|
||||
moduleName: failure.moduleName,
|
||||
enabled: true,
|
||||
phase: 'failed',
|
||||
failure: { stage: failure.stage, message: failure.message },
|
||||
})
|
||||
}
|
||||
// A row the composition left out never reached the tree; the runtime's
|
||||
// conflicts name the bundle that lost, so nothing is looked up by id.
|
||||
for (const conflict of runtime.conflicts) {
|
||||
@@ -186,15 +185,15 @@ export function ownedEntries(ctx: Context, runtime: ProfileRuntime, name: string
|
||||
for (const entry of ctx.loader.entries()) {
|
||||
if (entry.options.group) continue
|
||||
const rowId = entry.options.id
|
||||
if (runtime.originOf(rowId)?.packageName !== name) continue
|
||||
if (runtime.originOfEntry(entry)?.packageName !== name) continue
|
||||
found.push({ entry, rowId })
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/** The rows a bundle would contribute, from its probe record, when it is not composed. */
|
||||
function probedRows(probe: PluginProbe | undefined): PluginPackageRowView[] {
|
||||
return (probe?.rows ?? []).map(row => ({
|
||||
/** The rows a bundle would contribute, from its patch declarations, when it is not composed. */
|
||||
function declaredRows(metadata: PackageMetadata | undefined): PluginPackageRowView[] {
|
||||
return (metadata?.rows ?? []).map(row => ({
|
||||
entryId: row.id ?? row.name,
|
||||
rowId: row.id ?? row.name,
|
||||
moduleName: row.name,
|
||||
|
||||
@@ -18,8 +18,8 @@ import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context, type Plugin } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import {
|
||||
boot, composeProfileStack, loadOptionalPatches, loadProfile, ProfileRuntime, readProbeCache, rootIncludeEntry,
|
||||
type ComposedStack, type Profile, type probePackage,
|
||||
boot, composeProfileStack, loadOptionalPatches, loadProfile, ProfileRuntime, rootIncludeEntry,
|
||||
type ComposedStack, type Profile, type readPackageMetadata,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import {
|
||||
PluginManager, type PluginInstallLogChunk, type PluginToolingConfig, type SpawnLike,
|
||||
@@ -31,13 +31,13 @@ const NAME = 'dsh-test'
|
||||
|
||||
/** A complete tooling config: the host's schema fills these defaults at load, the type does not. */
|
||||
function managerConfig(overrides: Partial<PluginToolingConfig> = {}): PluginToolingConfig {
|
||||
return { pnpmCommand: 'pnpm', installTimeoutMs: 1_000, probeTimeoutMs: 20_000, installLogTailBytes: 16_384, ...overrides }
|
||||
return { pnpmCommand: 'pnpm', installTimeoutMs: 1_000, installLogTailBytes: 16_384, ...overrides }
|
||||
}
|
||||
|
||||
/** Test seams: the child spawner and the package probe. */
|
||||
/** Test seams: the child spawner and the static metadata reader. */
|
||||
interface Internals {
|
||||
spawn?: SpawnLike
|
||||
probe?: typeof probePackage
|
||||
metadata?: typeof readPackageMetadata
|
||||
}
|
||||
|
||||
const contexts: Context[] = []
|
||||
@@ -57,6 +57,13 @@ const provider: Plugin.Function = (ctx) => { ctx.effect(() => ctx.reflect.provid
|
||||
const lonelyProvider: Plugin.Function = (ctx) => { ctx.effect(() => ctx.reflect.provide('lonelySvc', { ready: true })) }
|
||||
const consumer: Plugin.Object = { inject: ['fixtureSvc'], apply() {} }
|
||||
const prepare = (ctx: Context): void => {
|
||||
ctx.loader.builtins.configured = {
|
||||
Config: { '~standard': { version: 1, vendor: 'manager-test', validate(input: unknown) {
|
||||
const config = input as { value: number }
|
||||
return config.value < 0 ? { issues: [{ message: 'negative config' }] } : { value: config }
|
||||
} } },
|
||||
apply() {},
|
||||
}
|
||||
ctx.loader.builtins.good = good
|
||||
ctx.loader.builtins.throws = throws
|
||||
ctx.loader.builtins.flaky = flaky
|
||||
@@ -209,16 +216,14 @@ async function bootProfile(staged: StagedHome, internals: Internals = {}, config
|
||||
return { ...stack, patches: structuredClone(stack.patches) }
|
||||
}
|
||||
const profile = load()
|
||||
const ctx = await boot(NAME, join(staged.profileDir, 'cordis.yml'), composeFor(profile).patches, prepare)
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(ProfileRuntime, {
|
||||
profile,
|
||||
stack: composeFor(profile),
|
||||
installAnchor: staged.anchor,
|
||||
loadProfile: load,
|
||||
compose: composeFor,
|
||||
rootEntry: () => rootIncludeEntry(ctx),
|
||||
const ctx = await boot(NAME, join(staged.profileDir, 'cordis.yml'), composeFor(profile).patches, async (ctx) => {
|
||||
prepare(ctx)
|
||||
await ctx.plugin(ProfileRuntime, {
|
||||
profile, stack: composeFor(profile), installAnchor: staged.anchor,
|
||||
loadProfile: load, compose: composeFor, rootEntry: () => rootIncludeEntry(ctx),
|
||||
})
|
||||
})
|
||||
contexts.push(ctx)
|
||||
const changes: Booted['changes'] = []
|
||||
const log: PluginInstallLogChunk[] = []
|
||||
ctx.on('plugins/changed', (change) => { changes.push(change) })
|
||||
@@ -243,6 +248,110 @@ const entryIds = (ctx: Context): string[] => [...ctx.loader.entries()].map(entry
|
||||
const BUNDLE_ONE_ROW = '- insert:\n - id: hello\n name: cordis:good\n'
|
||||
|
||||
describe('PluginManager', () => {
|
||||
it('keeps an installed package with malformed metadata manageable', async () => {
|
||||
const staged = await stageHome()
|
||||
stagePackage(staged.profileDir, 'broken', {})
|
||||
addDependency(staged.profileDir, 'broken')
|
||||
const { manager } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir) })
|
||||
writeFileSync(join(staged.profileDir, 'node_modules/broken/package.json'), '{ invalid json')
|
||||
const view = (await manager.list())[0]!
|
||||
expect(view.name).toBe('broken')
|
||||
expect(view.installed).toBe(true)
|
||||
expect(view.reason).toBeTruthy()
|
||||
await manager.uninstall('broken')
|
||||
expect(await manager.list()).toEqual([])
|
||||
})
|
||||
it('shows failed group imports while omitting healthy structural groups', async () => {
|
||||
const staged = await stageHome()
|
||||
stagePackage(staged.profileDir, 'groups', { patch: `- insert:
|
||||
- id: healthy
|
||||
name: cordis:group
|
||||
group: true
|
||||
config: []
|
||||
- id: broken-group
|
||||
name: ./missing.mjs
|
||||
group: true
|
||||
config: []
|
||||
` })
|
||||
addDependency(staged.profileDir, 'groups')
|
||||
const { manager } = await bootProfile(staged)
|
||||
await manager.enable('groups')
|
||||
const view = (await manager.list())[0]!
|
||||
expect(view.status).toBe('failed')
|
||||
expect(view.rows.map(row => row.rowId)).toEqual(['broken-group'])
|
||||
expect(view.rows[0]?.phase).toBe('failed')
|
||||
expect(view.rows[0]?.failure?.stage).toBe('import')
|
||||
})
|
||||
|
||||
it('shows a failed config update beside the still-active old instance and attributes overrides to their owner', async () => {
|
||||
const staged = await stageHome()
|
||||
stagePackage(staged.profileDir, 'base', {
|
||||
patch: '- insert:\n - id: core-config\n name: cordis:configured\n config: { value: 1 }\n',
|
||||
})
|
||||
const manifest = manifestOf(staged.profileDir)
|
||||
manifest.dsh.profile.bundles.push('base')
|
||||
writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest))
|
||||
stagePackage(staged.profileDir, 'ext', { patch: '- insert:\n - id: ext-ok\n name: cordis:good\n- id: core-config\n config: { value: -1 }\n' })
|
||||
addDependency(staged.profileDir, 'ext')
|
||||
const { ctx, manager } = await bootProfile(staged)
|
||||
const result = await manager.enable('ext')
|
||||
expect(result.issues?.[0]?.entryId).toBe('include:core-config')
|
||||
const core = ctx.loader.resolve('include:core-config')
|
||||
expect(core.fiber?.state).toBe(2)
|
||||
expect(core.fiber?.config).toEqual({ value: 1 })
|
||||
expect(core.options.config).toEqual({ value: -1 })
|
||||
const views = await manager.list()
|
||||
const ext = views.find(view => view.name === 'ext')!
|
||||
expect(ext.status).toBe('partial')
|
||||
expect(ext.issues?.[0]?.stage).toBe('update')
|
||||
expect(ext.rows.map(row => row.rowId)).toEqual(['ext-ok'])
|
||||
expect(ctx.profileRuntime.originOfEntry(core)?.packageName).toBe('base')
|
||||
expect(views.find(view => view.name === 'base')?.rows[0]).toMatchObject({ phase: 'active', failure: { stage: 'update' } })
|
||||
writeFileSync(join(staged.profileDir, 'node_modules/ext/cordis.patch.yml'), '- id: core-config\n config: { value: -1 }\n')
|
||||
await ctx.profileRuntime.recompose({ reloadBundles: true })
|
||||
expect((await manager.list()).find(view => view.name === 'ext')?.status).toBe('failed')
|
||||
await manager.disable('ext')
|
||||
expect(core.fiber?.config).toEqual({ value: 1 })
|
||||
expect((await manager.list()).find(view => view.name === 'base')?.status).toBe('running')
|
||||
})
|
||||
|
||||
it('keeps a rejected preparation out of the layer list without promising rollback of live plugin effects', async () => {
|
||||
const staged = await stageHome()
|
||||
stagePackage(staged.profileDir, 'base', { patch: BUNDLE_ONE_ROW })
|
||||
const manifest = manifestOf(staged.profileDir)
|
||||
manifest.dsh.profile.bundles.push('base')
|
||||
writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest))
|
||||
stagePackage(staged.profileDir, 'clash', { patch: BUNDLE_ONE_ROW, stage: 'boot' })
|
||||
addDependency(staged.profileDir, 'clash')
|
||||
const { ctx, manager } = await bootProfile(staged)
|
||||
await expect(manager.enable('clash')).rejects.toMatchObject({ code: 'plugins/enable-failed' })
|
||||
expect(manifestOf(staged.profileDir).dsh.profile.bundles).toEqual(['base'])
|
||||
expect(ctx.loader.resolve('include:hello').fiber?.state).toBe(2)
|
||||
})
|
||||
|
||||
it('does not execute a declared main module during install or list and refuses an undeclared entry', async () => {
|
||||
const staged = await stageHome()
|
||||
const marker = join(staged.profileDir, 'executed')
|
||||
stagePackage(staged.profileDir, 'declared', {
|
||||
plugins: [{ name: '.', config: { custom: 1 } }],
|
||||
main: `import { writeFileSync } from 'node:fs'; writeFileSync(${JSON.stringify(marker)}, 'bad'); export function apply() {}`,
|
||||
})
|
||||
stagePackage(staged.profileDir, 'unknown', { main: 'export function apply() {}' })
|
||||
const { manager } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir) })
|
||||
await manager.add('declared')
|
||||
await manager.add('unknown')
|
||||
const views = await manager.list()
|
||||
expect(views.find(view => view.name === 'declared')?.addable[0]).toMatchObject({ moduleName: 'declared', config: { custom: 1 } })
|
||||
expect(existsSync(marker)).toBe(false)
|
||||
for (const id of ['', 'nested:child']) {
|
||||
await expect(manager.addRow('declared', { kind: 'global' }, { id })).rejects.toMatchObject({ code: 'plugins/bad-request' })
|
||||
}
|
||||
const added = await manager.addRow('declared', { kind: 'global' }, { config: null })
|
||||
expect(readFileSync(added.file, 'utf8')).toContain('config: null')
|
||||
expect(views.find(view => view.name === 'unknown')?.kind).toBe('unknown')
|
||||
await expect(manager.addRow('unknown', { kind: 'global' })).rejects.toMatchObject({ code: 'plugins/not-enableable' })
|
||||
})
|
||||
|
||||
it('reports plugins/unavailable without a profile runtime', async () => {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
@@ -251,11 +360,11 @@ describe('PluginManager', () => {
|
||||
})
|
||||
|
||||
describe('list', () => {
|
||||
it('folds installed, enabled, and probed facts into one view per package', async () => {
|
||||
it('folds installed, enabled, declared and running facts into one view per package', async () => {
|
||||
const staged = await stageHome()
|
||||
stagePackage(staged.profileDir, 'ext-bundle', { patch: BUNDLE_ONE_ROW, plugins: [{ name: './extra.js', title: 'Extra' }], files: { 'extra.js': 'export const name = "extra"\nexport function apply() {}\n' } })
|
||||
stagePackage(staged.profileDir, 'ext-lib', { main: 'export const x = 1\n' })
|
||||
stagePackage(staged.profileDir, 'ext-plugin', { main: 'export const name = "p"\nexport function apply() {}\n' })
|
||||
stagePackage(staged.profileDir, 'ext-plugin', { plugins: [{ name: '.' }], main: 'export const name = "p"\nexport function apply() {}\n' })
|
||||
addDependency(staged.profileDir, 'ext-bundle')
|
||||
addDependency(staged.profileDir, 'ext-lib')
|
||||
addDependency(staged.profileDir, 'ext-plugin')
|
||||
@@ -265,16 +374,15 @@ describe('PluginManager', () => {
|
||||
|
||||
expect(views.map(view => [view.name, view.kind, view.status, view.installed, view.enabled, view.trust])).toEqual([
|
||||
['ext-bundle', 'bundle', 'disabled', true, false, 'external'],
|
||||
['ext-lib', 'library', 'plain', true, false, 'external'],
|
||||
['ext-lib', 'unknown', 'plain', true, false, 'external'],
|
||||
['ext-plugin', 'plugin', 'plain', true, false, 'external'],
|
||||
])
|
||||
const bundle = views[0]
|
||||
expect(bundle).toMatchObject({ version: '1.0.0', title: 'Title of ext-bundle', description: 'staged ext-bundle', stage: 'runtime', liveReload: true })
|
||||
// Rows come from the probe while the bundle is not composed, under the ids the patch declares.
|
||||
// Rows come from static declarations while the bundle is not composed, under the ids the patch declares.
|
||||
expect(bundle?.rows).toEqual([{ entryId: 'hello', rowId: 'hello', moduleName: 'cordis:good', enabled: true, phase: null }])
|
||||
expect(bundle?.addable).toEqual([{ moduleName: 'ext-bundle/extra.js', declaredName: './extra.js', title: 'Extra', ok: true }])
|
||||
expect(bundle?.probedAt).toEqual(expect.any(String) as string)
|
||||
expect(readProbeCache(staged.profileDir, 'ext-bundle', '1.0.0')?.kind).toBe('bundle')
|
||||
expect(bundle?.addable).toEqual([{ moduleName: 'ext-bundle/extra.js', declaredName: './extra.js', title: 'Extra' }])
|
||||
expect(existsSync(join(staged.profileDir, '.dsh-plugins'))).toBe(false)
|
||||
})
|
||||
|
||||
it('reads a composed bundle\'s rows from the live tree with their failures', async () => {
|
||||
@@ -292,7 +400,7 @@ describe('PluginManager', () => {
|
||||
expect(view?.reason).toContain('boom at apply')
|
||||
expect(view?.rows.map(row => [row.entryId, row.phase, row.failure?.stage])).toEqual([
|
||||
['include:ok', 'active', undefined],
|
||||
['include:bad', 'failed', 'apply'],
|
||||
['include:bad', 'failed', 'activation'],
|
||||
])
|
||||
})
|
||||
|
||||
@@ -320,7 +428,7 @@ describe('PluginManager', () => {
|
||||
}])
|
||||
})
|
||||
|
||||
it('reports a package the probe refuses as not enableable, and a stale layer as restart-required', async () => {
|
||||
it('does not import a disabled bundle and reports a stale layer as restart-required', async () => {
|
||||
const staged = await stageHome('startup')
|
||||
stagePackage(staged.profileDir, 'ext-broken', { patch: BUNDLE_ONE_ROW, main: 'throw new Error("no import for you")\n' })
|
||||
addDependency(staged.profileDir, 'ext-broken')
|
||||
@@ -334,28 +442,29 @@ describe('PluginManager', () => {
|
||||
|
||||
const views = await manager.list()
|
||||
|
||||
expect(views.find(view => view.name === 'ext-broken')).toMatchObject({ status: 'not-enableable', reason: expect.stringContaining('no import for you') as string })
|
||||
expect(views.find(view => view.name === 'ext-broken')).toMatchObject({ status: 'disabled' })
|
||||
expect(views.find(view => view.name === 'ext-later')).toMatchObject({ status: 'restart-required', liveReload: false })
|
||||
})
|
||||
|
||||
it('reports a package whose probe cannot run with the probe\'s failure', async () => {
|
||||
it('reports unreadable declarations and treats physical peer identity as advisory', async () => {
|
||||
const staged = await stageHome()
|
||||
stagePackage(staged.profileDir, 'ext-odd', { patch: BUNDLE_ONE_ROW })
|
||||
addDependency(staged.profileDir, 'ext-odd')
|
||||
stagePackage(staged.profileDir, 'ext-refused', { patch: BUNDLE_ONE_ROW })
|
||||
addDependency(staged.profileDir, 'ext-refused')
|
||||
const { manager } = await bootProfile(staged, {
|
||||
probe: ({ packageName }) => packageName === 'ext-odd'
|
||||
? Promise.reject(new Error('probe exploded'))
|
||||
: Promise.resolve({ packageName, kind: 'bundle', ok: false, reason: 'foreign cordis', cordisSameCopy: false, rows: [], overrides: [], addable: [], checkedAt: 'now' }),
|
||||
metadata: ({ packageName }) => {
|
||||
if (packageName === 'ext-odd') throw new Error('invalid declarations')
|
||||
return { packageName, kind: 'bundle', cordisSameCopy: false, rows: [], overrides: [], addable: [] }
|
||||
},
|
||||
})
|
||||
|
||||
const views = await manager.list()
|
||||
|
||||
expect(views.find(view => view.name === 'ext-odd')).toMatchObject({ status: 'not-enableable', reason: 'probe exploded', cordisSameCopy: null, rows: [] })
|
||||
expect(views.find(view => view.name === 'ext-refused')).toMatchObject({ status: 'not-enableable', reason: 'foreign cordis', cordisSameCopy: false })
|
||||
await expect(manager.enable('ext-odd')).rejects.toMatchObject({ code: 'plugins/not-enableable', details: { reason: 'cannot be probed: probe exploded' } })
|
||||
await expect(manager.enable('ext-refused')).rejects.toMatchObject({ code: 'plugins/not-enableable', details: { reason: 'foreign cordis' } })
|
||||
expect(views.find(view => view.name === 'ext-odd')).toMatchObject({ status: 'not-enableable', reason: 'invalid declarations', cordisSameCopy: null, rows: [] })
|
||||
expect(views.find(view => view.name === 'ext-refused')).toMatchObject({ status: 'disabled', cordisSameCopy: false })
|
||||
await expect(manager.enable('ext-odd')).rejects.toMatchObject({ code: 'plugins/not-enableable', details: { reason: 'cannot read package declarations: invalid declarations' } })
|
||||
await expect(manager.enable('ext-refused')).resolves.toMatchObject({ effect: 'live' })
|
||||
})
|
||||
|
||||
it('reads a hand-written manifest: no dependencies, a template bundle, a ghost, and a builtin layer added after boot', async () => {
|
||||
@@ -375,7 +484,7 @@ describe('PluginManager', () => {
|
||||
expect(views.map(view => [view.name, view.trust, view.kind, view.status])).toEqual([
|
||||
['tpl', 'builtin', 'bundle', 'running'],
|
||||
['tpl-later', 'builtin', 'bundle', 'restart-required'],
|
||||
['ghost', 'builtin', 'library', 'plain'],
|
||||
['ghost', 'builtin', 'unknown', 'plain'],
|
||||
])
|
||||
await expect(manager.uninstall('ghost')).rejects.toMatchObject({ code: 'plugins/not-installed' })
|
||||
// Retrying takes the bundle out first, which a template bundle refuses.
|
||||
@@ -409,7 +518,7 @@ describe('PluginManager', () => {
|
||||
expect(off?.rows.some(row => row.failure !== undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('reads anonymous and gated probe rows', async () => {
|
||||
it('reads anonymous and gated static patch rows', async () => {
|
||||
const staged = await stageHome()
|
||||
stagePackage(staged.profileDir, 'ext-anon', { patch: '- insert:\n - name: cordis:good\n - id: gated\n name: cordis:good\n disabled: true\n' })
|
||||
addDependency(staged.profileDir, 'ext-anon')
|
||||
@@ -445,13 +554,12 @@ describe('PluginManager', () => {
|
||||
const result = await manager.add('ext-lib')
|
||||
|
||||
expect(result).toMatchObject({
|
||||
installed: [], plain: [], installedOnly: [],
|
||||
removed: [{ name: 'ext-lib', reason: 'declares neither a dsh bundle nor a plugin module' }],
|
||||
installed: ['ext-lib'], plain: ['ext-lib'], installedOnly: [], removed: [],
|
||||
})
|
||||
expect(calls).toEqual([['pnpm', 'add', 'ext-lib'], ['pnpm', 'remove', 'ext-lib']])
|
||||
expect(manifestOf(staged.profileDir).dependencies).not.toHaveProperty('ext-lib')
|
||||
expect(calls).toEqual([['pnpm', 'add', 'ext-lib']])
|
||||
expect(manifestOf(staged.profileDir).dependencies).toHaveProperty('ext-lib')
|
||||
expect(existsSync(join(staged.profileDir, '.dsh-plugins', 'ext-lib.json'))).toBe(false)
|
||||
expect((await manager.list()).some(view => view.name === 'ext-lib')).toBe(false)
|
||||
expect((await manager.list()).some(view => view.name === 'ext-lib')).toBe(true)
|
||||
})
|
||||
|
||||
it('removes an installed bundle whose row id another layer already owns', async () => {
|
||||
@@ -478,7 +586,7 @@ describe('PluginManager', () => {
|
||||
|
||||
it('removes an installed bundle the profile cannot resolve and says why', async () => {
|
||||
const staged = await stageHome()
|
||||
// The probe reads the manifest without judging the stage; resolving the
|
||||
// The static reader reads the manifest without judging the stage; resolving the
|
||||
// layer is what refuses it, and that refusal is the removal's reason.
|
||||
stagePackage(staged.profileDir, 'ext-odd', { patch: BUNDLE_ONE_ROW, stage: 'weird' })
|
||||
const calls: string[][] = []
|
||||
@@ -494,7 +602,7 @@ describe('PluginManager', () => {
|
||||
expect(manifestOf(staged.profileDir).dependencies).not.toHaveProperty('ext-odd')
|
||||
})
|
||||
|
||||
it('keeps a package whose probe refused it, for the view to explain', async () => {
|
||||
it('keeps an unexecuted package installed with its bundle disabled', async () => {
|
||||
const staged = await stageHome()
|
||||
stagePackage(staged.profileDir, 'ext-broken', { patch: BUNDLE_ONE_ROW, main: 'throw new Error("no import for you")\n' })
|
||||
const { manager } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir) })
|
||||
@@ -502,7 +610,7 @@ describe('PluginManager', () => {
|
||||
const result = await manager.add('ext-broken')
|
||||
|
||||
expect(result).toMatchObject({ installed: ['ext-broken'], removed: [] })
|
||||
expect((await manager.list()).find(view => view.name === 'ext-broken')).toMatchObject({ status: 'not-enableable' })
|
||||
expect((await manager.list()).find(view => view.name === 'ext-broken')).toMatchObject({ status: 'disabled' })
|
||||
})
|
||||
|
||||
it('restores the manifest when pnpm fails after writing it', async () => {
|
||||
@@ -562,7 +670,7 @@ describe('PluginManager', () => {
|
||||
await expect(manager.enable('ext-bundle')).resolves.toMatchObject({ changed: true })
|
||||
})
|
||||
|
||||
it('runs pnpm add, records the dependency, probes the package, and leaves it disabled', async () => {
|
||||
it('runs pnpm add, records the dependency and leaves its bundle disabled', async () => {
|
||||
const staged = await stageHome()
|
||||
stagePackage(staged.profileDir, 'ext-new', { patch: BUNDLE_ONE_ROW })
|
||||
const calls: string[][] = []
|
||||
@@ -572,7 +680,7 @@ describe('PluginManager', () => {
|
||||
|
||||
expect(calls).toEqual([['pnpm', 'add', 'github:acme/ext-new']])
|
||||
// The fake pnpm records the spec itself as the dependency name, which
|
||||
// resolves to nothing: a plain dependency whose probe cannot run.
|
||||
// resolves to nothing: an installed dependency whose declarations cannot be read.
|
||||
expect(result).toEqual({ installed: ['github:acme/ext-new'], removed: [], enabled: [], installedOnly: [], plain: ['github:acme/ext-new'], jobId: expect.any(String) as string })
|
||||
expect(log.map(chunk => [chunk.argv, chunk.cwd, chunk.stream, chunk.text, chunk.exitCode])).toEqual([
|
||||
[['pnpm', 'add', 'github:acme/ext-new'], staged.profileDir, 'stdout', '+ github:acme/ext-new 1.0.0\n', undefined],
|
||||
@@ -609,8 +717,8 @@ describe('PluginManager', () => {
|
||||
|
||||
expect(result).toMatchObject({ installed: ['ext-new'], enabled: ['ext-new'], installedOnly: [], plain: [] })
|
||||
expect(manifestOf(staged.profileDir).dsh.profile.bundles).toEqual(['ext-new'])
|
||||
expect(entryIds(ctx)).toEqual(expect.arrayContaining(['include:bundle/ext-new', 'include:hello']))
|
||||
expect(readProbeCache(staged.profileDir, 'ext-new')).toBeDefined()
|
||||
expect(entryIds(ctx)).toEqual(expect.arrayContaining(['include:hello']))
|
||||
expect(existsSync(join(staged.profileDir, '.dsh-plugins'))).toBe(false)
|
||||
expect(changes.map(change => change.reason)).toEqual(['enable', 'install'])
|
||||
expect((await manager.list()).find(view => view.name === 'ext-new')?.status).toBe('running')
|
||||
})
|
||||
@@ -709,7 +817,7 @@ describe('PluginManager', () => {
|
||||
|
||||
await expect(manager.enable('absent')).rejects.toMatchObject({ code: 'plugins/not-installed' })
|
||||
await expect(manager.enable('ext-lib')).rejects.toMatchObject({ code: 'plugins/not-enableable', details: { reason: expect.stringContaining('declares no dsh.bundle') as string } })
|
||||
await expect(manager.enable('ext-broken')).rejects.toMatchObject({ code: 'plugins/not-enableable', details: { reason: expect.stringContaining('no import for you') as string } })
|
||||
await expect(manager.enable('ext-broken')).resolves.toMatchObject({ effect: 'live' })
|
||||
// A template bundle is not a dependency and cannot be disabled.
|
||||
const manifest = manifestOf(staged.profileDir)
|
||||
manifest.dsh.profile.bundles.push('template')
|
||||
@@ -718,7 +826,7 @@ describe('PluginManager', () => {
|
||||
await expect(manager.retry('ext-lib')).rejects.toMatchObject({ code: 'plugins/bad-request' })
|
||||
})
|
||||
|
||||
it('restores the layer list when the tree rejects a boot-stage bundle', async () => {
|
||||
it('retains live enablement and reports failed boot-stage rows after startup', async () => {
|
||||
const staged = await stageHome()
|
||||
stagePackage(staged.profileDir, 'ext-fatal', { patch: '- insert:\n - id: bad\n name: cordis:throws\n', stage: 'boot' })
|
||||
addDependency(staged.profileDir, 'ext-fatal')
|
||||
@@ -727,16 +835,16 @@ describe('PluginManager', () => {
|
||||
const { ctx, manager } = await bootProfile(staged)
|
||||
await manager.enable('ext-fine')
|
||||
|
||||
await expect(manager.enable('ext-fatal')).rejects.toMatchObject({
|
||||
code: 'plugins/enable-failed', details: { packageName: 'ext-fatal', reason: expect.stringContaining('boom at apply') as string },
|
||||
})
|
||||
const result = await manager.enable('ext-fatal')
|
||||
expect(result.effect).toBe('live')
|
||||
expect(result.issues?.[0]?.message).toContain('boom at apply')
|
||||
|
||||
expect(manifestOf(staged.profileDir).dsh.profile.bundles).toEqual(['ext-fine'])
|
||||
expect(manifestOf(staged.profileDir).dsh.profile.bundles).toEqual(['ext-fine', 'ext-fatal'])
|
||||
expect(entryIds(ctx)).toContain('include:hello')
|
||||
expect(entryIds(ctx)).not.toContain('include:bad')
|
||||
expect(entryIds(ctx)).toContain('include:bad')
|
||||
})
|
||||
|
||||
it('retries an isolated bundle by composing it again', async () => {
|
||||
it('retries a bundle by removing and re-adding its layer', async () => {
|
||||
const staged = await stageHome()
|
||||
stagePackage(staged.profileDir, 'ext-flaky', { patch: '- insert:\n - id: once\n name: cordis:flaky\n' })
|
||||
addDependency(staged.profileDir, 'ext-flaky')
|
||||
@@ -755,7 +863,7 @@ describe('PluginManager', () => {
|
||||
describe('rows in user layers', () => {
|
||||
it('adds, disables, re-enables, and removes a row in the live global layer', async () => {
|
||||
const staged = await stageHome()
|
||||
stagePackage(staged.profileDir, '@acme/ext-plugin', { main: 'export const name = "p"\nexport function apply() {}\n' })
|
||||
stagePackage(staged.profileDir, '@acme/ext-plugin', { plugins: [{ name: '.' }], main: 'export const name = "p"\nexport function apply() {}\n' })
|
||||
addDependency(staged.profileDir, '@acme/ext-plugin')
|
||||
const { ctx, manager, changes } = await bootProfile(staged)
|
||||
|
||||
@@ -782,7 +890,7 @@ describe('PluginManager', () => {
|
||||
|
||||
it('detects a conflict through the layer file when the tree was not recomposed', async () => {
|
||||
const staged = await stageHome('startup')
|
||||
stagePackage(staged.profileDir, 'ext-plugin', { main: 'export const name = "p"\nexport function apply() {}\n' })
|
||||
stagePackage(staged.profileDir, 'ext-plugin', { plugins: [{ name: '.' }], main: 'export const name = "p"\nexport function apply() {}\n' })
|
||||
addDependency(staged.profileDir, 'ext-plugin')
|
||||
const { ctx, manager } = await bootProfile(staged)
|
||||
|
||||
@@ -809,13 +917,13 @@ describe('PluginManager', () => {
|
||||
expect(added.rowId).toBe('sql')
|
||||
expect(readFileSync(added.file, 'utf8')).toContain('- id: sql\n name: ext-bundle/tools/sql.js\n config:\n dsn: sqlite://\n')
|
||||
await expect(manager.addRow('ext-bundle', { kind: 'global' })).rejects.toMatchObject({ code: 'plugins/not-enableable' })
|
||||
await expect(manager.addRow('ext-bundle', { kind: 'global' }, { module: './missing.js' })).rejects.toMatchObject({ code: 'plugins/not-enableable' })
|
||||
await expect(manager.addRow('ext-bundle', { kind: 'global' }, { module: './missing.js' })).resolves.toMatchObject({ rowId: 'ext-bundle/missing.js' })
|
||||
await expect(manager.addRow('absent', { kind: 'global' })).rejects.toMatchObject({ code: 'plugins/not-installed' })
|
||||
})
|
||||
|
||||
it('writes a preset\'s layer through the roster, and refuses a preset target without one', async () => {
|
||||
const staged = await stageHome()
|
||||
stagePackage(staged.profileDir, 'ext-plugin', { main: 'export const name = "p"\nexport function apply() {}\n' })
|
||||
stagePackage(staged.profileDir, 'ext-plugin', { plugins: [{ name: '.' }], main: 'export const name = "p"\nexport function apply() {}\n' })
|
||||
addDependency(staged.profileDir, 'ext-plugin')
|
||||
const { ctx, manager } = await bootProfile(staged)
|
||||
await expect(manager.addRow('ext-plugin', { kind: 'preset', preset: 'standard' })).rejects.toMatchObject({ code: 'plugins/unavailable' })
|
||||
@@ -903,7 +1011,7 @@ describe('PluginManager', () => {
|
||||
await manager.enable('ext-bundle')
|
||||
await manager.addRow('ext-bundle', { kind: 'global' }, { module: './extra.js' })
|
||||
expect(entryIds(ctx)).toEqual(expect.arrayContaining(['include:hello', 'include:ext-bundle/extra.js']))
|
||||
expect(existsSync(join(staged.profileDir, '.dsh-plugins', 'ext-bundle.json'))).toBe(true)
|
||||
expect(existsSync(join(staged.profileDir, '.dsh-plugins', 'ext-bundle.json'))).toBe(false)
|
||||
|
||||
await manager.uninstall('ext-bundle')
|
||||
|
||||
|
||||
@@ -1425,13 +1425,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
signature: '@Remote(\'add\') async add(spec: string, options?: { enable?: boolean }): Promise<PluginInstallResult>',
|
||||
description: 'Install a package with pnpm, probe it, and leave it disabled unless asked otherwise.',
|
||||
description: 'Install a package with pnpm, read its declarations, and leave it disabled unless asked otherwise.',
|
||||
parameters: [{ name: 'spec', description: 'what to install, in pnpm\'s own vocabulary.' }, { name: 'options', description: '`enable` puts every newly installed bundle into the layer list at once.' }],
|
||||
returns: 'what the run installed and enabled.',
|
||||
},
|
||||
{
|
||||
signature: '@Remote(\'uninstall\') async uninstall(packageName: string): Promise<void>',
|
||||
description: 'Remove a package from the profile with its user-layer rows and probe record.',
|
||||
description: 'Remove a package from the profile with its user-layer rows and any obsolete discovery cache.',
|
||||
parameters: [{ name: 'packageName', description: 'the installed dependency to remove.' }],
|
||||
},
|
||||
{
|
||||
@@ -4908,7 +4908,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'PluginChangeReason',
|
||||
declaration: 'export type PluginChangeReason = \'install\' | \'uninstall\' | \'enable\' | \'disable\' | \'retry\' | \'row\';',
|
||||
declaration: 'export type PluginChangeReason = \'install\' | \'uninstall\' | \'enable\' | \'disable\' | \'retry\' | \'row\' | \'runtime\';',
|
||||
},
|
||||
{
|
||||
name: 'PluginDependents',
|
||||
@@ -4916,7 +4916,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'PluginEnableResult',
|
||||
declaration: 'export interface PluginEnableResult {\n readonly changed: boolean;\n readonly effect: \'live\' | \'restart\';\n}',
|
||||
declaration: 'export interface PluginEnableResult {\n readonly changed: boolean;\n readonly effect: \'live\' | \'restart\';\n readonly issues?: readonly PluginRowIssue[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'PluginInstallLogChunk',
|
||||
@@ -4932,11 +4932,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'PluginPackageAddableView',
|
||||
declaration: 'export interface PluginPackageAddableView {\n readonly moduleName: string;\n readonly declaredName: string;\n readonly title?: string;\n readonly config?: JsonValue;\n readonly ok: boolean;\n readonly error?: string;\n readonly configSchema?: JsonValue;\n}',
|
||||
declaration: 'export interface PluginPackageAddableView {\n readonly moduleName: string;\n readonly declaredName: string;\n readonly title?: string;\n readonly config?: JsonValue;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PluginPackageKind',
|
||||
declaration: 'export type PluginPackageKind = \'bundle\' | \'plugin\' | \'library\';',
|
||||
declaration: 'export type PluginPackageKind = \'bundle\' | \'plugin\' | \'unknown\';',
|
||||
},
|
||||
{
|
||||
name: 'PluginPackageRowView',
|
||||
@@ -4956,12 +4956,16 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'PluginPackageView',
|
||||
declaration: 'export interface PluginPackageView {\n readonly name: string;\n readonly version?: string;\n readonly title?: string;\n readonly description?: string;\n readonly kind: PluginPackageKind;\n readonly trust: PluginPackageTrust;\n readonly stage: PluginPackageStage;\n readonly installed: boolean;\n readonly enabled: boolean;\n readonly status: PluginPackageStatus;\n readonly reason?: string;\n readonly enginesDsh?: string;\n readonly cordisSameCopy: boolean | null;\n readonly rows: readonly PluginPackageRowView[];\n readonly overrides: readonly string[];\n readonly addable: readonly PluginPackageAddableView[];\n readonly probedAt?: string;\n readonly liveReload: boolean;\n}',
|
||||
declaration: 'export interface PluginPackageView {\n readonly name: string;\n readonly version?: string;\n readonly title?: string;\n readonly description?: string;\n readonly kind: PluginPackageKind;\n readonly trust: PluginPackageTrust;\n readonly stage: PluginPackageStage;\n readonly installed: boolean;\n readonly enabled: boolean;\n readonly status: PluginPackageStatus;\n readonly reason?: string;\n readonly enginesDsh?: string;\n readonly cordisSameCopy: boolean | null;\n readonly rows: readonly PluginPackageRowView[];\n readonly issues?: readonly PluginRowIssue[];\n readonly overrides: readonly string[];\n readonly addable: readonly PluginPackageAddableView[];\n readonly liveReload: boolean;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PluginRowAddition',
|
||||
declaration: 'export interface PluginRowAddition {\n readonly target: PluginRowTarget;\n readonly rowId: string;\n readonly file: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PluginRowIssue',
|
||||
declaration: 'export interface PluginRowIssue {\n readonly entryId: string;\n readonly moduleName: string;\n readonly stage: string;\n readonly message: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PluginRowPhase',
|
||||
declaration: 'export type PluginRowPhase = \'pending\' | \'loading\' | \'active\' | \'failed\' | \'unloading\' | null;',
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/plugin-manager/README.md
|
||||
README.md: da884551d16ea64c1ac16e54a5400f757f28b3da
|
||||
README.zh.md: 45d33ae40f318df3ceb908841011766d25893828
|
||||
README.md: b69895b5b2495380dd03443291019f8da3997fa6
|
||||
README.zh.md: 658caa69738ab4e62ab3cdadf2f242242ddd209b
|
||||
@@ -41,7 +41,6 @@ A manager failure reaches the client as a `RemoteError` with the same `code` and
|
||||
|---|---|---|
|
||||
| `pnpmCommand` | `pnpm` | The executable, resolved through `PATH` like the `dsh plugin` command. |
|
||||
| `installTimeoutMs` | `600000` | Bound on one install or remove run. |
|
||||
| `probeTimeoutMs` | `20000` | Bound on one package probe. |
|
||||
| `installLogTailBytes` | `16384` | How much trailing output an install failure reports. |
|
||||
|
||||
-----
|
||||
@@ -54,7 +53,7 @@ A manager failure reaches the client as a `RemoteError` with the same `code` and
|
||||
|
||||
### A relay, not a second manager
|
||||
|
||||
The constructor builds one `PluginManager` with readers into the context — `ctx.get('profileRuntime')`, `ctx.get('agentPresets')`, and the running-agent count from `ctx.get('agents')` — so what is composed is read when a call arrives, not when the row mounts. Every Remote method is `relay(() => this.manager.x(...))`: a `PluginOperationError` becomes the Remote error of its code through one exhaustive switch, and anything else is rethrown. Tests hand a manager in through `PluginManagerInternals.manager`; the spawn and probe seams pass through to the manager.
|
||||
The adapter supplies per-call profile, preset and agent readers to one `PluginManager`, relays each Remote method, and maps domain errors. It observes native entry/status events and publishes a coalesced `plugins/changed` notification after Loader settlement. Disposal cancels pending notifications and removes the listeners. Tests may replace the manager, pnpm spawner or static metadata reader.
|
||||
|
||||
### Source map
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@ kind: "package-reference"
|
||||
|---|---|---|
|
||||
| `pnpmCommand` | `pnpm` | 可执行文件,与 `dsh plugin` 命令一样经 `PATH` 解析。 |
|
||||
| `installTimeoutMs` | `600000` | 单次安装或移除运行的上限。 |
|
||||
| `probeTimeoutMs` | `20000` | 单次包探测的上限。 |
|
||||
| `installLogTailBytes` | `16384` | 安装失败时报告多少尾部输出。 |
|
||||
|
||||
-----
|
||||
@@ -54,7 +53,7 @@ kind: "package-reference"
|
||||
|
||||
### 转接,不是第二个管理器
|
||||
|
||||
构造器构造一个 `PluginManager`,交给它读向上下文的读取器——`ctx.get('profileRuntime')`、`ctx.get('agentPresets')`,以及从 `ctx.get('agents')` 数出的运行中 agent 数——因此组合里有什么在调用到达时读取,而不是在行挂载时。每个 Remote 方法都是 `relay(() => this.manager.x(...))`:`PluginOperationError` 经一个穷尽的 switch 变成其码对应的 Remote 错误,其他东西原样重抛。测试经 `PluginManagerInternals.manager` 交入管理器;spawn 与探针的测试缝透传给管理器。
|
||||
适配器为一个 `PluginManager` 提供逐次调用的 profile、预设与 agent 读取器,转接各 Remote 方法并映射领域错误。它观测原生条目与状态事件,在 Loader 完成后合并发布 `plugins/changed` 通知。销毁会取消待发通知并移除监听器。测试可替换管理器、pnpm 启动器或静态元信息读取器。
|
||||
|
||||
### 源码地图
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-host-plugin-manager",
|
||||
"description": "The plugins Remote of the Web host: pluginManager as a Typert service that relays each call to the shared plugin manager and maps its failures onto Remote error codes",
|
||||
"version": "0.1.5-rc.1",
|
||||
"version": "0.1.5-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import type { probePackage } from '@deepseek-ai/dsh-app-boot'
|
||||
import type { readPackageMetadata } from '@deepseek-ai/dsh-app-boot'
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-agent-presets'
|
||||
import {
|
||||
@@ -44,16 +44,14 @@ export interface Config {
|
||||
pnpmCommand: string
|
||||
/** Bound on one install or remove run, in milliseconds. */
|
||||
installTimeoutMs: number
|
||||
/** Bound on one package probe, in milliseconds. */
|
||||
probeTimeoutMs: number
|
||||
/** How many trailing bytes of an install run's output an install failure reports. */
|
||||
installLogTailBytes: number
|
||||
}
|
||||
|
||||
/** Test seams: the child spawner, the package probe, or a manager standing in for the shared one. */
|
||||
/** Test seams: the child spawner, the static metadata reader, or a manager standing in for the shared one. */
|
||||
export interface PluginManagerInternals {
|
||||
spawn?: SpawnLike
|
||||
probe?: typeof probePackage
|
||||
metadata?: typeof readPackageMetadata
|
||||
/** The manager every call relays to; defaults to one over this context's profile runtime. */
|
||||
manager?: PluginManager
|
||||
}
|
||||
@@ -73,7 +71,6 @@ export class PluginManagerRemote extends TypertRemoteService {
|
||||
static Config: z<Config> = z.object({
|
||||
pnpmCommand: z.string().default('pnpm'),
|
||||
installTimeoutMs: z.number().min(1_000).default(600_000),
|
||||
probeTimeoutMs: z.number().min(1_000).default(20_000),
|
||||
installLogTailBytes: z.number().min(256).default(16_384),
|
||||
})
|
||||
|
||||
@@ -87,8 +84,24 @@ export class PluginManagerRemote extends TypertRemoteService {
|
||||
presets: () => ctx.get('agentPresets'),
|
||||
runningAgents: () => (ctx.get('agents')?.list() ?? []).filter(agent => agent.status === 'running').length,
|
||||
...internals.spawn === undefined ? {} : { spawn: internals.spawn },
|
||||
...internals.probe === undefined ? {} : { probe: internals.probe },
|
||||
...internals.metadata === undefined ? {} : { metadata: internals.metadata },
|
||||
})
|
||||
const loader = ctx.loader
|
||||
let generation = 0
|
||||
let disposed = false
|
||||
ctx.effect(() => () => { disposed = true })
|
||||
const refresh = (): void => {
|
||||
const requested = ++generation
|
||||
// Loader events precede asynchronous import/update completion; publish only after settlement.
|
||||
void Promise.resolve().then(async () => {
|
||||
await loader.await()
|
||||
if (!disposed && requested === generation) ctx.emit('plugins/changed', { reason: 'runtime' })
|
||||
}).catch((error: unknown) => { ctx.logger.warn('plugin inventory refresh failed', error) })
|
||||
}
|
||||
ctx.on('internal/status', (fiber) => { if (fiber.entry !== undefined) refresh() }, { global: true })
|
||||
ctx.on('loader/entry-init', refresh, { global: true })
|
||||
ctx.on('loader/partial-dispose', refresh, { global: true })
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,7 +114,7 @@ export class PluginManagerRemote extends TypertRemoteService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a package with pnpm, probe it, and leave it disabled unless asked otherwise.
|
||||
* Install a package with pnpm, read its declarations, and leave it disabled unless asked otherwise.
|
||||
* @param spec - what to install, in pnpm's own vocabulary.
|
||||
* @param options - `enable` puts every newly installed bundle into the layer list at once.
|
||||
* @returns what the run installed and enabled.
|
||||
@@ -112,7 +125,7 @@ export class PluginManagerRemote extends TypertRemoteService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a package from the profile with its user-layer rows and probe record.
|
||||
* Remove a package from the profile with its user-layer rows and any obsolete discovery cache.
|
||||
* @param packageName - the installed dependency to remove.
|
||||
*/
|
||||
@Remote('uninstall')
|
||||
|
||||
@@ -16,9 +16,9 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
|
||||
'plugins/unavailable': PluginOperationDetailsMap['plugins/unavailable']
|
||||
/** The package is not a profile dependency. */
|
||||
'plugins/not-installed': PluginOperationDetailsMap['plugins/not-installed']
|
||||
/** The package cannot be enabled or added, with the probe's reason. */
|
||||
/** The package cannot be enabled or added, with the declaration or request error. */
|
||||
'plugins/not-enableable': PluginOperationDetailsMap['plugins/not-enableable']
|
||||
/** Enabling composed the bundle and the tree rejected it; the layer list was restored. */
|
||||
/** Preparation or the root Include rejected enablement; the layer selection was reverted. */
|
||||
'plugins/enable-failed': PluginOperationDetailsMap['plugins/enable-failed']
|
||||
/** pnpm exited non-zero, could not be spawned, or timed out. */
|
||||
'plugins/install-failed': PluginOperationDetailsMap['plugins/install-failed']
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import type { probePackage } from '@deepseek-ai/dsh-app-boot'
|
||||
import type { readPackageMetadata } from '@deepseek-ai/dsh-app-boot'
|
||||
import { remoteMethods, RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import {
|
||||
PluginOperationError, type PluginManager, type PluginOperationFailure, type SpawnLike,
|
||||
@@ -21,7 +21,7 @@ import PluginManagerRemote, { remoteErrorOf, type Config } from '@deepseek-ai/ds
|
||||
import type {} from '@deepseek-ai/dsh-host-plugin-manager/types'
|
||||
|
||||
/** A complete config: the schema fills defaults at load, the type does not. */
|
||||
const CONFIG: Config = { pnpmCommand: 'pnpm', installTimeoutMs: 1_000, probeTimeoutMs: 20_000, installLogTailBytes: 16_384 }
|
||||
const CONFIG: Config = { pnpmCommand: 'pnpm', installTimeoutMs: 1_000, installLogTailBytes: 16_384 }
|
||||
|
||||
const contexts: Context[] = []
|
||||
afterEach(async () => {
|
||||
@@ -45,6 +45,65 @@ async function mount(manager?: PluginManager): Promise<PluginManagerRemote> {
|
||||
}
|
||||
|
||||
describe('PluginManagerRemote', () => {
|
||||
it('reports a failed refresh and cancels publication when disposed during settlement', async () => {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.good = () => {}
|
||||
const id = await ctx.loader.create({ name: 'cordis:good' })
|
||||
const entry = ctx.loader.resolve(id)
|
||||
const remote = ctx.plugin(PluginManagerRemote, CONFIG)
|
||||
await remote
|
||||
const changes: string[] = []
|
||||
ctx.on('plugins/changed', ({ reason }) => { changes.push(reason) })
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const settle = vi.spyOn(ctx.loader, 'await').mockRejectedValueOnce(new Error('settlement unavailable'))
|
||||
try {
|
||||
ctx.emit('loader/entry-init', entry)
|
||||
await vi.waitFor(() => { expect(warn).toHaveBeenCalled() })
|
||||
let enter!: () => void
|
||||
const entered = new Promise<void>((resolve) => { enter = resolve })
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||
settle.mockImplementationOnce(async () => { enter(); await gate })
|
||||
ctx.emit('loader/entry-init', entry)
|
||||
await entered
|
||||
try {
|
||||
await remote.dispose()
|
||||
} finally { release() }
|
||||
await Promise.resolve()
|
||||
expect(changes).toEqual([])
|
||||
} finally {
|
||||
settle.mockRestore()
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('announces Loader dependency recovery and stops publishing after the Remote is disposed', async () => {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(Loader)
|
||||
const changes: string[] = []
|
||||
ctx.on('plugins/changed', ({ reason }) => { changes.push(reason) })
|
||||
const remote = ctx.plugin(PluginManagerRemote, CONFIG)
|
||||
await remote
|
||||
ctx.loader.builtins.waiting = { inject: ['lateService'], apply() {} }
|
||||
const id = await ctx.loader.create({ name: 'cordis:waiting' })
|
||||
await vi.waitFor(() => { expect(changes).toContain('runtime') })
|
||||
expect(ctx.loader.resolve(id).fiber?.state).toBe(0)
|
||||
changes.length = 0
|
||||
ctx.provide('lateService', {})
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.loader.resolve(id).fiber?.state).toBe(2)
|
||||
expect(changes).toContain('runtime')
|
||||
})
|
||||
await remote.dispose()
|
||||
const count = changes.length
|
||||
await ctx.loader.resolve(id).update({ disabled: true })
|
||||
await ctx.loader.await()
|
||||
expect(changes).toHaveLength(count)
|
||||
})
|
||||
|
||||
it('publishes the plugins namespace with one direct method per operation', async () => {
|
||||
const remote = await mount()
|
||||
expect(remote.typertRemote).toMatchObject({ serviceKey: 'pluginManager', namespace: 'plugins' })
|
||||
@@ -104,13 +163,13 @@ describe('PluginManagerRemote', () => {
|
||||
ctx.provide('profileRuntime', {
|
||||
dir: profileDir, profileName: 'web', installAnchor: join(profileDir, 'package.json'), patchReload: 'startup', current: { layers: [] },
|
||||
} as never)
|
||||
const probe: typeof probePackage = options => Promise.resolve({
|
||||
packageName: options.packageName, kind: 'plugin', ok: true, cordisSameCopy: null, rows: [], overrides: [], addable: [], checkedAt: '',
|
||||
const metadata: typeof readPackageMetadata = options => ({
|
||||
packageName: options.packageName, kind: 'plugin', cordisSameCopy: null, rows: [], overrides: [], addable: [{ name: '.' }],
|
||||
})
|
||||
const spawn: SpawnLike = () => { throw new Error('this test spawns nothing') }
|
||||
class SeamedRemote extends PluginManagerRemote {
|
||||
constructor(context: Context, config: Config) {
|
||||
super(context, config, { spawn, probe })
|
||||
super(context, config, { spawn, metadata })
|
||||
}
|
||||
}
|
||||
await ctx.plugin(SeamedRemote, CONFIG)
|
||||
@@ -122,7 +181,7 @@ describe('PluginManagerRemote', () => {
|
||||
// The agent-count reader: a running session refuses an install before pnpm runs.
|
||||
ctx.provide('agents', { list: () => [{ status: 'running' }, { status: 'idle' }] } as never)
|
||||
await expect(remote.add('anything')).rejects.toMatchObject({ code: 'plugins/agents-running', details: { operation: 'add', running: 1 } })
|
||||
// The probe seam answers for the package, then the roster reader finds no roster for a preset target.
|
||||
// The static reader answers for the package, then the roster reader finds no roster for a preset target.
|
||||
await expect(remote.addRow('pkg', { kind: 'preset', preset: 'standard' })).rejects.toMatchObject({ code: 'plugins/unavailable', details: { reason: 'no roster' } })
|
||||
} finally {
|
||||
rmSync(profileDir, { recursive: true, force: true })
|
||||
|
||||
Reference in new issue
Block a user