fix(diagnostics): retain observed plugin versions for recovery

This commit is contained in:
winewill committed 2026-10-19 17:54:46 +08:00
1 parent 0596c66fbe
commit d136ac9f6f
11 files changed
+57 -17

No files matched your search

+4 -4
View File
@@ -56,10 +56,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `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), `remotes` |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
| `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` |
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:301`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:307`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:314`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/operation` | `emit` | [`packages/boot/plugin-manager/src/types.ts:293`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | - |
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:302`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:308`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:315`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/operation` | `emit` | [`packages/boot/plugin-manager/src/types.ts:294`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | - |
| `plugin-market/changed` | `emit` | [`packages/boot/plugin-market/src/types.ts:136`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` |
| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` |
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
+4 -4
View File
@@ -58,10 +58,10 @@
| `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), `remotes` |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
| `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` |
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:301`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:307`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:314`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/operation` | `emit` | [`packages/boot/plugin-manager/src/types.ts:293`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | - |
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:302`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:308`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:315`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/operation` | `emit` | [`packages/boot/plugin-manager/src/types.ts:294`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | - |
| `plugin-market/changed` | `emit` | [`packages/boot/plugin-market/src/types.ts:136`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` |
| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` |
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
@@ -24,8 +24,8 @@
en: d5643a06f300c30a
zh: 9348b6a7676a2849
/deepseek-ai-dsh-plugin-manager/understand-the-implementation:
en: 531d50d67168f82b
zh: 295fbc453029f0c4
en: 86c5eddc73fb60ca
zh: 3b9819ab0e336611
/deepseek-ai-dsh-plugin-manager/further-exploration:
en: c6538243d8702a0d
zh: 04a82d923353c75f
+1 -1
View File
@@ -103,7 +103,7 @@ A service run whose captured output stays silent for `idleTimeoutMs` is terminat
Results contain the last attempted stage, target, saved-state change, application status and error codes. Web dictionaries render management text; pnpm and Loader diagnostics remain unmodified. Unrelated pre-existing inactive entries return warnings; new or changed failures and inactive explicit enablement targets fail the operation. A failed or cancelled package run, or failed bundle validation, restores the manifest and lockfile snapshotted before pnpm ran ([rationale](../../../.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md)); later failures retain saved changes as described in [Failure behavior](#failure-behavior). Installations are tracked by request id until their call settles, so a cancellation names one run and joins its settlement without taking the profile lock. The CLI inherits authentication variables and terminal descriptors; service operations use a scrubbed environment and captured output.
The `plugin-manager/operation` event correlates each service invocation with its start and observed result, or an unknown outcome when an exception escapes result construction. It excludes raw installation specs and error text. Diagnostic consumers own persistence and delivery limits; synchronous subscriber failures do not change management results. Direct CLI package operations do not emit this service event.
The `plugin-manager/operation` event correlates each service invocation with its start and observed result, or an unknown outcome when an exception escapes result construction. It retains the package version observed before a toggle or removal, and the installed version when installation resolves it; missing metadata leaves the version unknown without blocking recovery. It excludes raw installation specs and error text. Diagnostic consumers own persistence and delivery limits; synchronous subscriber failures do not change management results. Direct CLI package operations do not emit this service event.
</details>
+1 -1
View File
@@ -103,7 +103,7 @@ service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止,
结果包含最后尝试的阶段、目标、磁盘变化、应用状态和错误码。Web 词典呈现管理文案;pnpm 与 Loader 的诊断保持原样。无关的已有故障作为警告返回;新出现、配置变化后的故障,以及显式启用目标未激活,都会使操作失败。包管理运行失败或被取消,或组合包校验失败时,会恢复 pnpm 运行前快照的 manifest 与 lockfile([理由](../../../.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md));后续失败按[失败行为](#failure-behavior)所述保留已保存的改动。安装按 request id 跟踪到调用结束,因此取消只针对一次运行,并且不取 profile 锁就能等待它结束。CLI 继承认证环境和终端描述符;service 使用清理后的环境并捕获输出。管理器直接读取文件和 Loader 状态,不维护第二份目标状态注册表,因此不发布单独的运行时不变式伴生入口。
`plugin-manager/operation` 事件关联每次服务调用的开始和已观察到的结果;若异常使结果无法构造,则记录结果未知。事件不包含原始安装 spec 和错误文本。诊断消费者负责持久化和传输限额;同步订阅者失败不会改变管理结果。直接执行的 CLI 包操作不发送此服务事件。
`plugin-manager/operation` 事件关联每次服务调用的开始和已观察到的结果;若异常使结果无法构造,则记录结果未知。事件保留启停或移除前观察到的包版本,以及安装完成后解析出的版本;元数据缺失时版本未知,不阻止恢复操作。事件不包含原始安装 spec 和错误文本。诊断消费者负责持久化和传输限额;同步订阅者失败不会改变管理结果。直接执行的 CLI 包操作不发送此服务事件。
</details>
@@ -20,13 +20,14 @@ const fields = {
target: z.string().max(256).nullable(),
stage: z.enum(['install', 'enable', 'remove']),
enabled: z.boolean().optional(),
version: z.string().max(128).optional(),
}
const operation: z.ZodType<PluginOperation> = z.discriminatedUnion('phase', [
z.object({ ...fields, phase: z.literal('started') }),
z.object({ ...fields, phase: z.literal('unknown') }),
z.object({ ...fields, phase: z.literal('settled'),
application: z.enum(['applied', 'restart-required', 'overridden', 'failed', 'cancelled']),
changed: z.boolean(), version: z.string().max(128).optional(),
changed: z.boolean(),
errorCode: z.enum(['management-required', 'unaddressable', 'unknown-plugin', 'invalid-spec', 'already-installed',
'ambiguous-install', 'not-bundle', 'not-removable', 'stop-profile', 'bundle-in-use', 'stale-approval',
'incompatible-version', 'operation-error']).optional(),
+17
View File
@@ -902,8 +902,10 @@ export class PluginManager extends TypertRemoteService {
reason: PluginChange['reason'],
): Promise<ChangeResult> {
const id = randomUUID() as PluginOperationId
const version = this.operationVersion(reason, request.target)
const facts = { id, reason, stage: request.stage,
target: reason === 'install' ? null : request.target,
...version === undefined ? {} : { version },
...request.enabled === undefined ? {} : { enabled: request.enabled } }
this.reportOperation({ ...facts, time: new Date().toISOString(), phase: 'started' })
let settled: ChangeResult
@@ -952,6 +954,21 @@ export class PluginManager extends TypertRemoteService {
}
}
/** Read the pre-operation package version without preventing recovery from damaged metadata. */
private operationVersion(reason: PluginChange['reason'], target: string): string | undefined {
if (reason === 'install') return undefined
try {
if (reason !== 'plugin') return bundleManifest(target, this.profile.dir, this.profile.installAnchor)?.version
const entry = [...this.ctx.loader.entries()].find(row => row.id === target)
const source = entry?.parent.tree.ctx.baseUrl
if (entry === undefined || source === undefined) return undefined
return this.ownerContext.get('pluginPackages')?.packageOf(entry.options.name, source)?.version
} catch (_error) {
// Disabling or removing a broken package must not depend on diagnostic metadata being readable.
return undefined
}
}
private diskState(): string {
return ['package.json', 'cordis.patch.yml', 'pnpm-workspace.yaml', PROFILE_COMPATIBILITY_FILENAME].map((file) => {
try { return readFileSync(join(this.profile.dir, file), 'utf8') }
+2 -1
View File
@@ -269,13 +269,14 @@ export type PluginOperation = {
readonly target: string | null
readonly stage: ChangeResult['stage']
readonly enabled?: boolean | undefined
/** Observed package version; absence means unavailable, never the latest registry version. */
readonly version?: string | undefined
} & (
| { readonly phase: 'started' }
| {
readonly phase: 'settled'
readonly application: ChangeResult['application']
readonly changed: boolean
readonly version?: string | undefined
readonly errorCode?: ManagementError['code'] | undefined
readonly restartReason?: RestartReason | undefined
}
@@ -21,3 +21,9 @@ it('rejects unsupported phases, oversized identifiers and invalid result fields'
expect(parsePluginOperationRecord({ ...input, transportDropped: -1 })).toBeUndefined()
expect(parsePluginOperationRecord({ ...input, runId: '' })).toBeUndefined()
})
it('retains an observed version before an operation settles', () => {
const started = { ...input, operation: { ...input.operation, phase: 'started', version: '1.2.3' } }
expect(parsePluginOperationRecord(started)?.operation).toMatchObject({ phase: 'started', version: '1.2.3' })
expect(parsePluginOperationRecord({ ...started, operation: { ...started.operation, version: 'x'.repeat(129) } })).toBeUndefined()
})
@@ -466,12 +466,27 @@ it('correlates diagnostic operation starts with persisted toggle results', async
await manager.setBundleEnabled('extra', false)
expect(readProfileManifest('test', dir).dsh?.profile?.bundles).not.toContain('extra')
expect(facts).toHaveLength(2)
expect(facts[0]).toMatchObject({ phase: 'started', reason: 'bundle', target: 'extra', enabled: false })
expect(facts[1]).toMatchObject({ id: facts[0]!.id, phase: 'settled', changed: true, application: 'restart-required' })
expect(facts[0]).toMatchObject({ phase: 'started', reason: 'bundle', target: 'extra', enabled: false, version: '1.0.0' })
expect(facts[1]).toMatchObject({ id: facts[0]!.id, phase: 'settled', changed: true, application: 'restart-required', version: '1.0.0' })
await manager.setBundleEnabled('extra', true)
expect(facts[2]!.id).not.toBe(facts[0]!.id)
})
it('retains an observed version for a refused removal and leaves damaged metadata unknown', async () => {
const { manager, ctx, dir } = await fixture('startup')
const facts: PluginOperation[] = []
ctx.on('plugin-manager/operation', (event) => { facts.push(event) })
expect(await manager.removeBundle('extra')).toMatchObject({ application: 'failed', error: { code: 'stop-profile' } })
expect(facts).toEqual([
expect.objectContaining({ phase: 'started', reason: 'remove', version: '1.0.0' }),
expect.objectContaining({ phase: 'settled', reason: 'remove', version: '1.0.0', application: 'failed' }),
])
writeFileSync(join(dir, 'node_modules', 'extra', 'package.json'), '{')
expect(await manager.setBundleEnabled('extra', false)).toMatchObject({ changed: true, application: 'restart-required' })
expect(facts[2]).not.toHaveProperty('version')
expect(facts[3]).not.toHaveProperty('version')
})
it('excludes raw install specs and error diagnostics from operation facts', async () => {
const { manager, ctx } = await fixture('startup')
const facts: PluginOperation[] = []
@@ -6439,7 +6439,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'PluginOperation',
declaration: 'export type PluginOperation = {\n readonly id: PluginOperationId;\n readonly time: string;\n readonly reason: PluginChange[\'reason\'];\n readonly target: string | null;\n readonly stage: ChangeResult[\'stage\'];\n readonly enabled?: boolean | undefined;\n} & ({\n readonly phase: \'started\';\n} | {\n readonly phase: \'settled\';\n readonly application: ChangeResult[\'application\'];\n readonly changed: boolean;\n readonly version?: string | undefined;\n readonly errorCode?: ManagementError[\'code\'] | undefined;\n readonly restartReason?: RestartReason | undefined;\n} | {\n readonly phase: \'unknown\';\n});',
declaration: 'export type PluginOperation = {\n readonly id: PluginOperationId;\n readonly time: string;\n readonly reason: PluginChange[\'reason\'];\n readonly target: string | null;\n readonly stage: ChangeResult[\'stage\'];\n readonly enabled?: boolean | undefined;\n readonly version?: string | undefined;\n} & ({\n readonly phase: \'started\';\n} | {\n readonly phase: \'settled\';\n readonly application: ChangeResult[\'application\'];\n readonly changed: boolean;\n readonly errorCode?: ManagementError[\'code\'] | undefined;\n readonly restartReason?: RestartReason | undefined;\n} | {\n readonly phase: \'unknown\';\n});',
},
{
name: 'PluginOperationId',