feat(desktop): retain bounded plugin operation diagnostics
This commit is contained in:
1 parent
911d9a5746
commit
dfbe5d8da3
48 files changed
+1452
-42
No files matched your search
@@ -25,7 +25,8 @@
|
||||
"@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:*",
|
||||
"@deepseek-ai/dsh-skill-office": "workspace:*",
|
||||
"@deepseek-ai/dsh-deepseek-account": "workspace:*",
|
||||
"@deepseek-ai/dsh-workspace": "workspace:*"
|
||||
"@deepseek-ai/dsh-workspace": "workspace:*",
|
||||
"@deepseek-ai/dsh-plugin-manager": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:~",
|
||||
|
||||
@@ -14,6 +14,7 @@ import { installDesktopUpdateTaskControl } from './update-tasks.ts'
|
||||
import { installDesktopQuitInspection } from './quit-inspection.ts'
|
||||
import { installPlatformSessionPublisher } from './platform-session.ts'
|
||||
import { installOfficeEngineResolution } from './office-engine.ts'
|
||||
import { installOperationDiagnostics } from './operation-diagnostics.ts'
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const runtimeDir = process.argv[2] as string
|
||||
@@ -90,6 +91,7 @@ async function main(): Promise<void> {
|
||||
})
|
||||
process.once('disconnect', () => { void stop() })
|
||||
const { ctx } = await application
|
||||
installOperationDiagnostics(ctx, record => send({ type: 'plugin-operation', record }), 32)
|
||||
control.updateTasks = installDesktopUpdateTaskControl(ctx)
|
||||
control.quitInspection = installDesktopQuitInspection(ctx)
|
||||
await ctx.plugin(desktopOffice, {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/** Bounded delivery of management facts to the shell which owns durable diagnostics. */
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { parsePluginOperationRecord, type PluginOperationRecord } from '@deepseek-ai/dsh-plugin-manager/diagnostic'
|
||||
|
||||
/**
|
||||
* Observe manager operations after Host startup, with bounded concurrent IPC deliveries.
|
||||
* @param ctx - Context owning the subscription and its pending deliveries.
|
||||
* @param send - IPC transport promise which settles when delivery succeeds or fails.
|
||||
* @param maxPending - Maximum number of outstanding deliveries; excess events are counted and omitted.
|
||||
*/
|
||||
export function installOperationDiagnostics(
|
||||
ctx: Context, send: (record: PluginOperationRecord) => Promise<void>, maxPending: number,
|
||||
): void {
|
||||
if (!Number.isSafeInteger(maxPending) || maxPending < 1) throw new RangeError('Invalid diagnostic delivery budget')
|
||||
const runId = randomUUID()
|
||||
const pending = new Set<Promise<void>>()
|
||||
let dropped = 0
|
||||
let closed = false
|
||||
ctx.on('plugin-manager/operation', (operation) => {
|
||||
if (closed || pending.size >= maxPending) { dropped++; return }
|
||||
const record = parsePluginOperationRecord({ runId, operation, transportDropped: dropped })
|
||||
if (record === undefined) { dropped++; return }
|
||||
const delivery = Promise.resolve().then(async () => {
|
||||
try { await send(record) }
|
||||
catch (_error) { dropped++ /* IPC failure does not change the management operation. */ }
|
||||
})
|
||||
pending.add(delivery)
|
||||
void delivery.then(() => { pending.delete(delivery) })
|
||||
})
|
||||
ctx.effect(() => async () => {
|
||||
closed = true
|
||||
await Promise.allSettled(pending)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { PluginOperation } from '@deepseek-ai/dsh-plugin-manager/types'
|
||||
import { expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { installOperationDiagnostics } from '../src/operation-diagnostics.ts'
|
||||
|
||||
const operation: PluginOperation = {
|
||||
id: 'c254ab28-97c9-4382-95e6-2db9c66f98c9' as PluginOperation['id'],
|
||||
time: '2026-10-19T00:00:00.000Z', reason: 'plugin', phase: 'started', target: 'managed', stage: 'enable',
|
||||
}
|
||||
|
||||
it('bounds outstanding deliveries and drains them on context disposal', async () => {
|
||||
const ctx = new Context()
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
onTestFinished(async () => { release.resolve(undefined); await ctx.fiber.dispose() })
|
||||
const send = vi.fn(async () => { entered.resolve(undefined); await release.promise })
|
||||
installOperationDiagnostics(ctx, send, 1)
|
||||
ctx.emit('plugin-manager/operation', operation)
|
||||
await entered.promise
|
||||
for (let i = 0; i < 1000; i++) ctx.emit('plugin-manager/operation', operation)
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
let stopped = false
|
||||
const stopping = ctx.fiber.dispose().then(() => { stopped = true })
|
||||
await Promise.resolve(undefined)
|
||||
expect(stopped).toBe(false)
|
||||
release.resolve(undefined)
|
||||
await stopping
|
||||
ctx.emit('plugin-manager/operation', operation)
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('contains transport rejections during teardown', async () => {
|
||||
const ctx = new Context()
|
||||
onTestFinished(async () => { await ctx.fiber.dispose() })
|
||||
installOperationDiagnostics(ctx, async () => { throw new Error('disconnected') }, 1)
|
||||
ctx.emit('plugin-manager/operation', operation)
|
||||
await expect(ctx.fiber.dispose()).resolves.toBeUndefined()
|
||||
})
|
||||
@@ -30,6 +30,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../packages/credentials/deepseek-account"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/boot/plugin-manager"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -36,8 +36,8 @@
|
||||
en: 22153a298a6662e1
|
||||
zh: 3600803c8c9d0274
|
||||
/deepseek-harness-desktop/installation-ownership/runtime-and-plugin-activation:
|
||||
en: 3ee09d4d3ef08fc0
|
||||
zh: 60d7f05f57dea305
|
||||
en: 3a073562f01c033e
|
||||
zh: cd8ac91cc932417c
|
||||
/deepseek-harness-desktop/installation-ownership/host-environment:
|
||||
en: 98cf848762482d48
|
||||
zh: 74f9100a39ec8c2f
|
||||
|
||||
@@ -178,6 +178,10 @@ Native dialog details include at most 1,200 UTF-16 code units and eight diagnost
|
||||
|
||||
Before the first fatal dialog opens, Electron writes a crash report to the platform logs directory (`app.getPath('logs')`: `~/Library/Logs/DeepSeek Harness` on macOS, `logs` under the application’s `userData` directory on Windows and Linux), waiting at most one second for the write; a slow or failed write shows the dialog with an explicit pending or unavailable status. Get diagnostic report checks the same write and reveals a saved file in the system file manager without restarting or stopping the Host; a write that completes after the initial deadline remains available. The user attaches that file to a support ticket. If writing fails, the dialog hides Get diagnostic report and suggests providing a screenshot. The file `crash-<UTC time>-<source>.log` records the source (`host` for a Host exit, `web-boot` for a renderer boot failure, `renderer` for a renderer or document failure, `main` for a shell error), whether the backend had reached ready, application and runtime versions, the error with its enumerable properties and cause chain (cut at 256 KiB), the Host’s own error and explicitly selected startup metadata (at most 64 Ki characters, including a truncation notice; startup errors reserve space for metadata by limiting the original error to 8 Ki characters) when the Host reported a startup failure over IPC before exiting, and the primary window’s recent error-level console output (at most 64 KiB). A Host exit report therefore contains the retained stderr tail, which may include plugin output. A fatal failure during shutdown writes a report without opening a dialog. Files are owner-only where the platform supports it; startup keeps the ten newest reports and removes older ones without touching other files in the directory.
|
||||
|
||||
After saving the basic report, the shell attempts to append recent plugin-management records in an isolated Worker. The completion marker and omission counters distinguish a finished collection from missing or partial material. Recent operations are context, not proof of cause. Collection reads only two owned files under `logs/plugin-operations`, at most 64 KiB and 100 records; the complete appended section is limited to 64 KiB. A 500 ms deadline requests Worker termination; completion waits for its exit. The basic report remains available independently.
|
||||
|
||||
The journal retains two 256 KiB segments, accepts records up to 4 KiB, and bounds queued writes to 64 KiB. Exit, recovery restart and installer preparation wait at most 500 ms for queued writes; a timeout disables further writes, while an already-issued OS request may still settle. Records cover manager operations observed after Host startup, not direct CLI commands or manual file changes. Package-manager output remains in its existing detailed logs and is not copied into this summary. Missing records, a start without an end, and damaged history do not establish a successful operation. This collection does not diagnose kernel hangs, OOM or native crashes.
|
||||
|
||||
Recovery waits for Host shutdown before changing plugin activation. The native recovery action calls the shared app-boot recovery function under the profile transaction lock. It disables third-party bundles and renames the profile’s `cordis.patch.yml` to `cordis.patch.yml.bak-<timestamp>` (with an ordinal on collisions) without parsing it; the next startup creates an empty patch. Installed packages and earlier backups remain. The home-level patch is unchanged. The Electron console records the backup path (or its absence) and the unchanged home-level patch. Invalid profile data, rename failures, or write failures are reported as recovery-operation errors; completed changes remain, and Desktop does not restart as though recovery succeeded. Desktop has no profile-reset action or emergency HTML document.
|
||||
|
||||
### Host environment
|
||||
|
||||
@@ -178,6 +178,10 @@ macOS 上自定义菜单保留 Electron 的标准 Window 菜单及应用隐藏
|
||||
|
||||
首个致命弹窗打开前,Electron 会向平台日志目录(`app.getPath('logs')`:macOS 为 `~/Library/Logs/DeepSeek Harness`,Windows 与 Linux 为应用 `userData` 目录下的 `logs`)写入一份崩溃报告,最多等待写入一秒;写入缓慢或失败时明确显示仍在保存或无法保存。“获取诊断报告”检查同一次写入,并在系统文件管理器中定位已保存的文件,不停止 Host 或重启;超过首次等待期限后写入完成的文件仍可获取。用户可将文件作为工单附件;写入失败时隐藏获取报告按钮,并提示提供窗口截图。文件 `crash-<UTC 时间>-<source>.log` 记录来源(`host` 为 Host 退出、`web-boot` 为渲染进程启动失败、`renderer` 为渲染进程或文档失败、`main` 为壳自身错误)、后端是否已就绪、应用与运行时版本、包含可枚举属性与 cause 链的错误(截至 256 KiB)、Host 在退出前通过 IPC 报告启动失败时的错误及显式选择的启动元数据(最多 64 Ki 个字符,包含截断提示;启动错误的原始错误文本限制为 8 Ki 个字符,为元数据保留空间),以及主窗口最近的 error 级 console 输出(最多 64 KiB)。因此 Host 退出报告包含保留的 stderr 尾部,其中可能含有插件输出。关闭过程中的致命失败只写报告、不弹窗。平台支持时文件仅所有者可读;启动时保留最新十份报告并删除更早的,不触碰目录中的其他文件。
|
||||
|
||||
基本报告保存后,壳会尝试通过独立 Worker 追加近期插件管理记录。完成标记和遗漏计数用于区分已完成收集与缺失或不完整的材料。近期操作只是上下文,不是因果证据。收集只读取 `logs/plugin-operations` 下归其管理的两个文件,最多读取 64 KiB 和 100 条记录;完整追加内容不超过 64 KiB。500 ms 期限到达后请求终止 Worker,并在其退出后才确认收集结束。基本报告独立可用。
|
||||
|
||||
操作历史保留两个 256 KiB 文件段,单条记录不超过 4 KiB,排队写入不超过 64 KiB。退出、恢复重启和安装准备最多等待排队写入 500 ms;超时会停用后续写入,但已经发出的操作系统请求仍可能随后结束。记录覆盖 Host 启动后观察到的管理器操作,不包含直接执行的 CLI 命令或手工文件修改。包管理器输出保留在原有详细日志中,不复制到摘要。缺失记录、只有开始没有结束的操作以及损坏的历史,都不能说明操作已成功。此收集不诊断内核卡死、OOM 或原生崩溃。
|
||||
|
||||
恢复操作等待 Host 关闭后才修改插件启用状态。原生恢复操作在 profile 事务锁内调用共享 app-boot 恢复函数。它禁用第三方 bundle,并将 profile 的 `cordis.patch.yml` 重命名为 `cordis.patch.yml.bak-<timestamp>`(重名时追加序号),无需解析;下次启动创建空 patch。已安装包和已有备份保留。home 级 patch 不变。Electron 控制台记录备份路径(或原文件不存在)以及 home 级 patch 未修改。profile 数据无效、重命名失败或写入失败会作为恢复操作错误报告;已完成的修改保留,Desktop 不会假装恢复成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档。
|
||||
|
||||
### Host 环境
|
||||
|
||||
@@ -79,6 +79,8 @@
|
||||
"@deepseek-ai/dsh-client-ui-theme": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-shortcuts": "workspace:*",
|
||||
"@deepseek-ai/dsh-atomic-write": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-product-analytics": "workspace:*"
|
||||
"@deepseek-ai/dsh-client-product-analytics": "workspace:*",
|
||||
"@deepseek-ai/dsh-plugin-manager": "workspace:*",
|
||||
"zod": "4.4.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/** Manual built-code qualification: fresh processes, bounded work, and retained report output. */
|
||||
import assert from 'node:assert/strict'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdir, mkdtemp, open, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { readOperationHistory } from '../lib/types/operation-history.js'
|
||||
import { appendOperationReport } from '../lib/types/operation-report.js'
|
||||
import { writeCrashReport } from '../lib/types/crash-report.js'
|
||||
|
||||
const limits = { bytes: 65536, recordBytes: 4096, records: 100, outputBytes: 65536 }
|
||||
const record = index => ({
|
||||
runId: 'c254ab28-97c9-4382-95e6-2db9c66f98c8', transportDropped: 0,
|
||||
operation: { id: `c254ab28-97c9-4382-95e6-${index.toString(16).padStart(12, '0')}`,
|
||||
time: '2026-10-19T00:00:00.000Z', reason: 'bundle', phase: 'settled', target: `synthetic-plugin-${index % 50}`,
|
||||
stage: 'enable', application: 'restart-required', changed: true, version: '1.0.0' },
|
||||
})
|
||||
|
||||
if (process.argv[2] === '--sample') {
|
||||
const directory = process.argv[3]
|
||||
global.gc?.()
|
||||
const baseline = { ...process.memoryUsage(), maxRssKiB: process.resourceUsage().maxRSS }
|
||||
const start = performance.now()
|
||||
const report = await writeCrashReport(directory, {
|
||||
source: 'host', phase: 'startup', error: new Error('synthetic startup failure'), rendererConsole: [],
|
||||
operationHistoryPlanned: true, time: new Date(),
|
||||
app: { name: 'qualification', version: 'test', platform: process.platform, arch: process.arch,
|
||||
electron: 'not-running', node: process.version, locale: 'zh-CN' },
|
||||
})
|
||||
assert.ok(report)
|
||||
const basicMs = performance.now() - start
|
||||
const basic = await readFile(report)
|
||||
const collectStart = performance.now()
|
||||
const history = await readOperationHistory(directory, limits, new AbortController().signal)
|
||||
const collectMs = performance.now() - collectStart
|
||||
assert.ok(history.filesRead <= 2)
|
||||
assert.ok(history.bytesRead <= limits.bytes)
|
||||
assert.ok(history.records.length <= limits.records)
|
||||
assert.ok(history.records.length >= 50)
|
||||
const appendStart = performance.now()
|
||||
assert.equal(await appendOperationReport(report, directory, limits, AbortSignal.timeout(500), null), true)
|
||||
const appendMs = performance.now() - appendStart
|
||||
const output = await readFile(report)
|
||||
assert.equal(output.subarray(0, basic.length).equals(basic), true)
|
||||
assert.ok(output.length - basic.length <= limits.outputBytes)
|
||||
global.gc?.()
|
||||
const retained = process.memoryUsage()
|
||||
console.log(JSON.stringify({ basicMs, collectMs, appendMs, filesRead: history.filesRead, bytesRead: history.bytesRead,
|
||||
records: history.records.length, invalidRecords: history.invalidRecords, reportBytes: output.length,
|
||||
baseline, retained, peakRssKiB: process.resourceUsage().maxRSS, retainedOutputBytes: output.length }))
|
||||
} else {
|
||||
const outputPath = process.argv[2]
|
||||
if (outputPath === undefined) throw new Error('Provide an output JSON path; build Desktop Host TypeScript first')
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-diagnostic-qualification-'))
|
||||
const samples = []
|
||||
try {
|
||||
for (const scenario of ['ordinary-50', 'two-gib', 'many-unrelated-files', 'dense-and-corrupt']) {
|
||||
const directory = join(root, scenario)
|
||||
await mkdir(directory)
|
||||
const fixtureStart = performance.now()
|
||||
const tail = Array.from({ length: scenario === 'dense-and-corrupt' ? 2000 : 50 }, (_, i) => JSON.stringify(record(i))).join('\n') + '\n'
|
||||
if (scenario === 'two-gib') {
|
||||
await using file = await open(join(directory, 'operations.jsonl'), 'w')
|
||||
const size = 2 * 1024 ** 3
|
||||
await file.truncate(size)
|
||||
const bytes = Buffer.from('\n' + tail)
|
||||
await file.write(bytes, 0, bytes.length, size - bytes.length)
|
||||
} else {
|
||||
await writeFile(join(directory, 'operations.jsonl'), tail
|
||||
+ (scenario === 'dense-and-corrupt' ? '{broken}\n' + 'x'.repeat(16384) + '\n{"partial":' : ''))
|
||||
}
|
||||
if (scenario === 'many-unrelated-files') {
|
||||
await using raw = await open(join(directory, 'pnpm.log'), 'w')
|
||||
await raw.truncate(2 * 1024 ** 3)
|
||||
for (let batch = 0; batch < 100; batch++) {
|
||||
await Promise.all(Array.from({ length: 50 }, (_, i) => writeFile(join(directory, `rotated-${batch * 50 + i}.log`), 'unrelated')))
|
||||
}
|
||||
}
|
||||
const fixtureMs = performance.now() - fixtureStart
|
||||
for (let trial = 0; trial < 3; trial++) {
|
||||
const child = spawnSync(process.execPath, ['--expose-gc', '--max-old-space-size=64', fileURLToPath(import.meta.url), '--sample', directory], {
|
||||
encoding: 'utf8', timeout: 15000, windowsHide: true, maxBuffer: 1024 * 1024,
|
||||
})
|
||||
assert.equal(child.error, undefined)
|
||||
assert.equal(child.signal, null)
|
||||
assert.equal(child.status, 0, child.stderr)
|
||||
samples.push({ scenario, trial, fixtureMs, ...JSON.parse(child.stdout) })
|
||||
}
|
||||
}
|
||||
const evidence = { runtime: process.version, platform: process.platform, arch: process.arch,
|
||||
scope: 'Built component/report calls in fresh Node processes; not packaged Electron or cold filesystem cache. Fixture setup excluded. Heap capped at 64 MiB; peak RSS includes runtime/imports.',
|
||||
limits, samples }
|
||||
await writeFile(outputPath, JSON.stringify(evidence, null, 2) + '\n')
|
||||
console.log(JSON.stringify(evidence))
|
||||
} finally {
|
||||
assert.equal(dirname(resolve(root)), resolve(tmpdir()))
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/** Built Worker qualification with an injected stalled read; no product fault-injection option. */
|
||||
import assert from 'node:assert/strict'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { runOperationReportJob } from '../lib/types/operation-report-job.js'
|
||||
|
||||
if (process.argv[2] === '--sample') {
|
||||
const directory = process.argv[3]
|
||||
const stalled = process.argv[4] === 'stalled'
|
||||
const reportPath = join(directory, 'basic.log')
|
||||
const basic = 'Independent basic crash report\n'
|
||||
await writeFile(reportPath, basic)
|
||||
const start = performance.now()
|
||||
const result = await runOperationReportJob({ reportPath, directory, journal: null,
|
||||
limits: { bytes: 65536, recordBytes: 4096, records: 100, outputBytes: 65536 } }, stalled ? 2000 : 500)
|
||||
const elapsedMs = performance.now() - start
|
||||
const output = await readFile(reportPath, 'utf8')
|
||||
if (stalled) {
|
||||
assert.equal(await readFile(join(directory, 'read-entered'), 'utf8'), 'ready')
|
||||
assert.deepEqual(result, { appended: false, timedOut: true })
|
||||
assert.equal(output, basic)
|
||||
} else {
|
||||
assert.deepEqual(result, { appended: true, timedOut: false })
|
||||
assert.ok(output.startsWith(basic))
|
||||
}
|
||||
console.log(JSON.stringify({ stalled, elapsedMs, result, reportBytes: Buffer.byteLength(output), peakRssKiB: process.resourceUsage().maxRSS }))
|
||||
} else {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-worker-qualification-'))
|
||||
const samples = []
|
||||
try {
|
||||
for (const stalled of [false, true]) {
|
||||
const child = spawnSync(process.execPath, [
|
||||
...(stalled ? ['--import', new URL('../tests/fixtures/operation-read-stall.mjs', import.meta.url).href] : []),
|
||||
fileURLToPath(import.meta.url), '--sample', root, stalled ? 'stalled' : 'normal',
|
||||
], { windowsHide: true, timeout: 15000, encoding: 'utf8', maxBuffer: 1024 * 1024 })
|
||||
assert.equal(child.error, undefined)
|
||||
assert.equal(child.signal, null)
|
||||
assert.equal(child.status, 0, child.stderr)
|
||||
samples.push(JSON.parse(child.stdout))
|
||||
}
|
||||
const evidence = { runtime: process.version, platform: process.platform,
|
||||
scope: 'Real built Worker exit after normal collection and injected pending asynchronous read; not a kernel-hung filesystem or Electron measurement.', samples }
|
||||
if (process.argv[2] !== undefined) await writeFile(process.argv[2], JSON.stringify(evidence, null, 2) + '\n')
|
||||
console.log(JSON.stringify(evidence))
|
||||
} finally {
|
||||
assert.equal(dirname(resolve(root)), resolve(tmpdir()))
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ export interface CrashReportApp {
|
||||
|
||||
/** Inputs of {@link renderCrashReport} and {@link writeCrashReport}. */
|
||||
export interface CrashReportInput {
|
||||
/** The shell will attempt an independently bounded operation-history append after saving this report. */
|
||||
readonly operationHistoryPlanned?: boolean
|
||||
readonly source: CrashReportSource
|
||||
readonly phase: CrashReportPhase
|
||||
readonly error: unknown
|
||||
@@ -125,6 +127,9 @@ export function renderCrashReport(input: CrashReportInput): string {
|
||||
...(input.hostDiagnostic === undefined ? [] : ['--- host diagnostic (as reported by the Host process) ---', input.hostDiagnostic, '']),
|
||||
'--- renderer console (error level, oldest first) ---',
|
||||
consoleSection,
|
||||
...(input.operationHistoryPlanned === true ? ['', '--- auxiliary diagnostics ---',
|
||||
'Operation history is best effort. Without its completion marker below, it is unavailable or incomplete.',
|
||||
'Recent operations are context only. Missing records do not establish that no changes occurred.'] : []),
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { spawn, type ChildProcess } from 'node:child_process'
|
||||
import { join } from 'node:path'
|
||||
import type { PlatformSession } from '@deepseek-ai/dsh-deepseek-account'
|
||||
import { desktopNodeEnvironment } from './node-environment.ts'
|
||||
import { parsePluginOperationRecord, type PluginOperationRecord } from '@deepseek-ai/dsh-plugin-manager/diagnostic'
|
||||
|
||||
interface ReadyEvent {
|
||||
readonly type: 'ready'
|
||||
@@ -165,6 +166,7 @@ export class DesktopHostProcess {
|
||||
* `office-skills` resources fail Host startup.
|
||||
* @param packageManager - Bundled pnpm entry and Node launcher directory, scoped to package operations.
|
||||
* @param onPlatformSession - Private credential updates for embedded Platform views.
|
||||
* @param onPluginOperation - Optional diagnostic sink; malformed records are ignored without failing the Host.
|
||||
*/
|
||||
constructor(
|
||||
private readonly node: string,
|
||||
@@ -177,6 +179,7 @@ export class DesktopHostProcess {
|
||||
private readonly packageManager?: { readonly pnpm: string; readonly nodeBin: string },
|
||||
|
||||
private readonly onPlatformSession?: (session: PlatformSession | null) => void,
|
||||
private readonly onPluginOperation?: (record: PluginOperationRecord) => void,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -204,6 +207,17 @@ export class DesktopHostProcess {
|
||||
child.stderr?.on('data', (chunk: string) => { this.stderr = (this.stderr + chunk).slice(-MAX_HOST_DIAGNOSTIC_CHARS) })
|
||||
child.stdout?.pipe(process.stdout)
|
||||
child.on('message', (message: unknown) => {
|
||||
if (typeof message === 'object' && message !== null && 'type' in message && message.type === 'plugin-operation') {
|
||||
const record = 'record' in message ? parsePluginOperationRecord(message.record) : undefined
|
||||
if (record !== undefined) {
|
||||
try { this.onPluginOperation?.(record) }
|
||||
catch (_error) {
|
||||
// Diagnostic persistence cannot invalidate the Host's business IPC channel.
|
||||
console.error('desktop plugin operation diagnostic sink failed')
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!isDesktopHostEvent(message)) {
|
||||
this.fail(new Error('dsh desktop host sent an invalid IPC event'))
|
||||
child.kill('SIGTERM')
|
||||
|
||||
@@ -39,6 +39,8 @@ import { DesktopCommandManager } from './command-management.ts'
|
||||
import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts'
|
||||
import { DesktopFatalRecovery } from './fatal-recovery.ts'
|
||||
import { pruneCrashReports, RendererConsoleTail, writeCrashReport, type CrashReportSource } from './crash-report.ts'
|
||||
import { OperationJournal } from './operation-journal.ts'
|
||||
import { runOperationReportJob, type OperationReportJobResult } from './operation-report-job.ts'
|
||||
import { openWelcomeWindow } from './welcome-window.ts'
|
||||
import { WELCOME_IPC, needsWelcome, type WelcomeNotice } from './welcome-api.ts'
|
||||
import { connectDesktopWelcome, type DesktopWelcomeBackend } from './welcome-backend.ts'
|
||||
@@ -108,8 +110,13 @@ const recovery = new DesktopFatalRecovery({
|
||||
revealReport: async (path) => { await access(path); shell.showItemInFolder(path) },
|
||||
})
|
||||
|
||||
let operationJournal: OperationJournal | undefined
|
||||
let operationReportJob: Promise<OperationReportJobResult> | undefined
|
||||
|
||||
function persistCrashReport(error: unknown, source: CrashReportSource): Promise<string | undefined> {
|
||||
return writeCrashReport(app.getPath('logs'), {
|
||||
const directory = app.getPath('logs')
|
||||
const report = writeCrashReport(directory, {
|
||||
operationHistoryPlanned: true,
|
||||
source,
|
||||
phase: backendReady ? 'running' : 'startup',
|
||||
error,
|
||||
@@ -121,6 +128,16 @@ function persistCrashReport(error: unknown, source: CrashReportSource): Promise<
|
||||
},
|
||||
time: new Date(),
|
||||
})
|
||||
void report.then((path) => {
|
||||
if (path !== undefined && operationReportJob === undefined) {
|
||||
operationReportJob = runOperationReportJob({ reportPath: path, directory: join(directory, 'plugin-operations'),
|
||||
limits: { bytes: 64 * 1024, recordBytes: 4096, records: 100, outputBytes: 64 * 1024 },
|
||||
journal: operationJournal?.status() ?? null,
|
||||
}, 500)
|
||||
void operationReportJob.finally(() => { operationReportJob = undefined })
|
||||
}
|
||||
}).catch((_error: unknown) => { /* Basic persistence failure is owned by fatal recovery. */ })
|
||||
return report
|
||||
}
|
||||
|
||||
function reportFatal(error: unknown, source: CrashReportSource): void {
|
||||
@@ -450,7 +467,12 @@ async function main(): Promise<void> {
|
||||
const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject,
|
||||
hostInspectPort, { ...hostEnvironment, DSH_CLIENT_VERSION: desktopClientVersion() }, onFailure,
|
||||
primaryRuntime,
|
||||
resources, (next) => { platformView.setSession(next) })
|
||||
resources, (next) => { platformView.setSession(next) }, (record) => {
|
||||
operationJournal ??= new OperationJournal(join(app.getPath('logs'), 'plugin-operations'), {
|
||||
segmentBytes: 256 * 1024, recordBytes: 4096, queuedBytes: 64 * 1024,
|
||||
})
|
||||
operationJournal.enqueue(JSON.stringify(record))
|
||||
})
|
||||
return {
|
||||
start: async () => {
|
||||
const ready = await host.start()
|
||||
@@ -568,7 +590,10 @@ async function main(): Promise<void> {
|
||||
if (backend.host === undefined || welcomeBackend === undefined) throw new Error('desktop welcome: backend unavailable')
|
||||
return welcomeBackend.read()
|
||||
}
|
||||
stopForRecovery = () => backend.close()
|
||||
stopForRecovery = async () => {
|
||||
try { await backend.close() }
|
||||
finally { await operationJournal?.drain(500) }
|
||||
}
|
||||
|
||||
const reconcileBackend = (): Promise<void> => {
|
||||
startup ??= (async () => {
|
||||
@@ -630,6 +655,7 @@ async function main(): Promise<void> {
|
||||
// The backend's async cleanup callback can assign this after the reset above.
|
||||
const stopFailure = updateStopFailure as DesktopHostUncleanExitError | undefined
|
||||
if (stopFailure !== undefined) throw new DesktopUpdatePreparationError('stop-failed', locale.messages.updateStopFailed, stopFailure.message)
|
||||
await operationJournal?.drain(500)
|
||||
updateJournal?.action('install-confirmed')
|
||||
shellInstallerOwnsQuit = true
|
||||
} catch (error) {
|
||||
@@ -1261,7 +1287,11 @@ async function main(): Promise<void> {
|
||||
updateSchedule.dispose()
|
||||
updateDialog.dispose()
|
||||
mandatoryUI?.dispose()
|
||||
void Promise.all([Promise.resolve(mandatoryPolicy?.dispose()).then(() => policyAuth?.dispose()), backend.close(),
|
||||
void Promise.all([Promise.resolve(mandatoryPolicy?.dispose()).then(() => policyAuth?.dispose()),
|
||||
backend.close().then(async () => { await operationJournal?.drain(500) }, async (error: unknown) => {
|
||||
await operationJournal?.drain(500)
|
||||
throw error
|
||||
}),
|
||||
// A Platform cleanup failure is logged without cutting the remaining Host shutdown short.
|
||||
platformView.dispose().catch((error: unknown) => { console.error(error) })])
|
||||
.catch((error: unknown) => { console.error(error) }).finally(() => { app.quit() })
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/** Bounded, cancellable reads of Desktop-owned plugin operation history. */
|
||||
import { open } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { parsePluginOperationRecord, type PluginOperationRecord } from '@deepseek-ai/dsh-plugin-manager/diagnostic'
|
||||
|
||||
/** Resource budgets for auxiliary history collection, independent of basic crash report writing. */
|
||||
export interface OperationHistoryReadLimits {
|
||||
readonly bytes: number
|
||||
readonly recordBytes: number
|
||||
readonly records: number
|
||||
}
|
||||
|
||||
/** Collection facts; incomplete history must not be interpreted as absence of plugin changes. */
|
||||
export interface OperationHistoryReadResult {
|
||||
readonly records: readonly PluginOperationRecord[]
|
||||
readonly bytesRead: number
|
||||
readonly filesRead: number
|
||||
readonly truncated: boolean
|
||||
readonly invalidRecords: number
|
||||
readonly unavailableFiles: number
|
||||
readonly cancelled: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Read only the bounded tails of the two owned history files, newest segment first.
|
||||
* Cancellation stops subsequent I/O and parsing; an already-issued OS read must settle before its handle closes.
|
||||
* @param directory - Desktop's operation history directory; unrelated files are never enumerated.
|
||||
* @param limits - Positive byte, record-size and record-count budgets.
|
||||
* @param signal - Caller-owned collection deadline or shutdown cancellation.
|
||||
* @returns Partial records and explicit omissions; filesystem failures never reject the collection.
|
||||
*/
|
||||
export async function readOperationHistory(
|
||||
directory: string, limits: OperationHistoryReadLimits, signal: AbortSignal,
|
||||
): Promise<OperationHistoryReadResult> {
|
||||
if (![limits.bytes, limits.recordBytes, limits.records].every(value => Number.isSafeInteger(value) && value > 0)) {
|
||||
throw new RangeError('Operation history budgets must be positive safe integers')
|
||||
}
|
||||
const cancelled = (): boolean => signal.aborted
|
||||
const records: PluginOperationRecord[] = []
|
||||
let bytesRead = 0
|
||||
const seenFiles = new Set<string>()
|
||||
let truncated = false
|
||||
let invalidRecords = 0
|
||||
let unavailableFiles = 0
|
||||
for (const name of ['operations.jsonl', 'operations.previous.jsonl']) {
|
||||
if (cancelled()) break
|
||||
if (bytesRead >= limits.bytes || records.length >= limits.records) { truncated = true; break }
|
||||
try {
|
||||
await using handle = await open(join(directory, name), 'r')
|
||||
if (cancelled()) break
|
||||
const stat = await handle.stat()
|
||||
if (cancelled()) break
|
||||
if (!stat.isFile()) { unavailableFiles++; continue }
|
||||
const identity = `${String(stat.dev)}:${String(stat.ino)}`
|
||||
if (seenFiles.has(identity)) { truncated = true; continue }
|
||||
seenFiles.add(identity)
|
||||
const length = Math.min(stat.size, limits.bytes - bytesRead)
|
||||
const start = stat.size - length
|
||||
const buffer = Buffer.alloc(length)
|
||||
let offset = 0
|
||||
while (offset < length && !cancelled()) {
|
||||
const read = await handle.read(buffer, offset, Math.min(16 * 1024, length - offset), start + offset)
|
||||
bytesRead += read.bytesRead
|
||||
if (read.bytesRead === 0) break
|
||||
offset += read.bytesRead
|
||||
}
|
||||
if (cancelled()) break
|
||||
truncated ||= start > 0 || offset < length
|
||||
// An unterminated last record can still be in flight; never parse it as a committed record.
|
||||
let end = buffer.lastIndexOf(10, offset - 1)
|
||||
if (offset === 0) continue
|
||||
if (end !== offset - 1) truncated = true
|
||||
while (end >= 0 && records.length < limits.records && !cancelled()) {
|
||||
const preceding = end === 0 ? -1 : buffer.lastIndexOf(10, end - 1)
|
||||
if (preceding < 0 && start > 0) { truncated = true; break }
|
||||
const size = end - preceding - 1
|
||||
if (size > limits.recordBytes) {
|
||||
invalidRecords++
|
||||
} else if (size > 0) {
|
||||
try {
|
||||
const value: unknown = JSON.parse(buffer.subarray(preceding + 1, end).toString('utf8'))
|
||||
const record = parsePluginOperationRecord(value)
|
||||
if (record === undefined) invalidRecords++
|
||||
else records.push(record)
|
||||
} catch (_error) {
|
||||
// A corrupt record does not invalidate independently delimited older records.
|
||||
invalidRecords++
|
||||
}
|
||||
}
|
||||
end = preceding
|
||||
}
|
||||
if (end >= 0) truncated = true
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') unavailableFiles++
|
||||
}
|
||||
}
|
||||
return { records, bytesRead, filesRead: seenFiles.size, truncated, invalidRecords, unavailableFiles, cancelled: cancelled() }
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/** Single-shell, bounded persistence for already-projected operation diagnostics. */
|
||||
import { mkdir, open, rename, stat, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
/** Budgets include serialized JSON and its line delimiter. */
|
||||
export interface OperationJournalLimits {
|
||||
readonly segmentBytes: number
|
||||
readonly recordBytes: number
|
||||
readonly queuedBytes: number
|
||||
}
|
||||
|
||||
/** In-memory omissions for the current shell lifetime; missing records never imply no operations occurred. */
|
||||
export interface OperationJournalStatus {
|
||||
readonly dropped: number
|
||||
readonly failed: number
|
||||
readonly queuedBytes: number
|
||||
readonly disabled: boolean
|
||||
/** Damaged or oversized segments repaired; their lost record count is unknown. */
|
||||
readonly repairedSegments: number
|
||||
}
|
||||
|
||||
/** Owns two known files and one serial writer; the owning shell must exclude other writers. */
|
||||
export class OperationJournal {
|
||||
private readonly queue: string[] = []
|
||||
private queuedBytes = 0
|
||||
private dropped = 0
|
||||
private failed = 0
|
||||
private repairedSegments = 0
|
||||
private disabled = false
|
||||
private closed = false
|
||||
private writing: Promise<void> | undefined
|
||||
private initialized = false
|
||||
private readonly abort = new AbortController()
|
||||
|
||||
/**
|
||||
* @param directory - Directory reserved for the shell's operation journal.
|
||||
* @param limits - Positive byte budgets, with recordBytes no greater than either other budget.
|
||||
*/
|
||||
constructor(private readonly directory: string, private readonly limits: OperationJournalLimits) {
|
||||
if (![limits.segmentBytes, limits.recordBytes, limits.queuedBytes].every(value => Number.isSafeInteger(value) && value > 0)
|
||||
|| limits.recordBytes > limits.segmentBytes || limits.recordBytes > limits.queuedBytes) {
|
||||
throw new RangeError('Invalid operation journal budgets')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue one prevalidated, whitelisted JSON record without awaiting disk I/O.
|
||||
* @param record - An already serialized JSON object without literal line delimiters.
|
||||
* @returns Whether the record was accepted; disk failures are reported separately by status().
|
||||
*/
|
||||
enqueue(record: string): boolean {
|
||||
const line = record + '\n'
|
||||
const bytes = Buffer.byteLength(line)
|
||||
if (this.closed || this.disabled || bytes > this.limits.recordBytes
|
||||
|| bytes + this.queuedBytes > this.limits.queuedBytes || /[\r\n]/u.test(record)) {
|
||||
this.dropped++
|
||||
return false
|
||||
}
|
||||
this.queue.push(line)
|
||||
this.queuedBytes += bytes
|
||||
this.startWriter()
|
||||
return true
|
||||
}
|
||||
|
||||
/** @returns Current queue occupancy and omission counters. */
|
||||
status(): OperationJournalStatus {
|
||||
return { dropped: this.dropped, failed: this.failed, queuedBytes: this.queuedBytes, disabled: this.disabled,
|
||||
repairedSegments: this.repairedSegments }
|
||||
}
|
||||
|
||||
/** Stop accepting records and await completion of the owned writer. */
|
||||
async close(): Promise<void> {
|
||||
this.closed = true
|
||||
while (this.writing !== undefined) await this.writing
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for queued records during shutdown or installer handoff without holding the application indefinitely.
|
||||
* @param timeoutMs - Waiting budget before disabling the writer and cancelling subsequent I/O.
|
||||
* @returns True after the queue settles successfully; false on failure or deadline.
|
||||
* False does not confirm that an issued OS request has settled.
|
||||
*/
|
||||
async drain(timeoutMs: number): Promise<boolean> {
|
||||
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new RangeError('Invalid journal drain deadline')
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
return await Promise.race([
|
||||
(async () => {
|
||||
while (this.writing !== undefined) await this.writing
|
||||
return !this.disabled
|
||||
})(),
|
||||
new Promise<false>((resolve) => {
|
||||
timer = setTimeout(() => {
|
||||
this.disabled = true
|
||||
this.abort.abort()
|
||||
resolve(false)
|
||||
}, timeoutMs)
|
||||
}),
|
||||
])
|
||||
} finally { clearTimeout(timer) }
|
||||
}
|
||||
|
||||
private startWriter(): void {
|
||||
this.writing ??= this.writeQueue().finally(() => {
|
||||
this.writing = undefined
|
||||
if (this.queue.length > 0) this.startWriter()
|
||||
})
|
||||
}
|
||||
|
||||
private async writeQueue(): Promise<void> {
|
||||
try {
|
||||
await mkdir(this.directory, { recursive: true, mode: 0o700 })
|
||||
this.abort.signal.throwIfAborted()
|
||||
const current = join(this.directory, 'operations.jsonl')
|
||||
const previous = join(this.directory, 'operations.previous.jsonl')
|
||||
if (!this.initialized) {
|
||||
await this.prepareSegments(current, previous)
|
||||
this.initialized = true
|
||||
}
|
||||
for (let line = this.queue[0]; line !== undefined; line = this.queue[0]) {
|
||||
this.abort.signal.throwIfAborted()
|
||||
const bytes = Buffer.byteLength(line)
|
||||
let size = 0
|
||||
try { size = (await stat(current)).size }
|
||||
catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
this.abort.signal.throwIfAborted()
|
||||
if (size > this.limits.segmentBytes) {
|
||||
// An oversized pre-existing segment is not copied into the bounded retained history.
|
||||
await writeFile(current, '', { mode: 0o600 })
|
||||
this.repairedSegments++
|
||||
} else if (size + bytes > this.limits.segmentBytes) {
|
||||
await rename(current, previous)
|
||||
}
|
||||
this.abort.signal.throwIfAborted()
|
||||
await writeFile(current, line, { flag: 'a', mode: 0o600, signal: this.abort.signal })
|
||||
this.queue.shift()
|
||||
this.queuedBytes -= bytes
|
||||
}
|
||||
} catch (_error) {
|
||||
// A failed append may have written a partial line; disable this writer rather than concatenate another record.
|
||||
this.disabled = true
|
||||
this.failed += this.queue.length
|
||||
this.queue.length = 0
|
||||
this.queuedBytes = 0
|
||||
}
|
||||
}
|
||||
|
||||
private async prepareSegments(current: string, previous: string): Promise<void> {
|
||||
try {
|
||||
const size = (await stat(previous)).size
|
||||
this.abort.signal.throwIfAborted()
|
||||
if (size > this.limits.segmentBytes) {
|
||||
await writeFile(previous, '', { mode: 0o600 })
|
||||
this.repairedSegments++
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
this.abort.signal.throwIfAborted()
|
||||
let incomplete = false
|
||||
{
|
||||
await using handle = await open(current, 'r+').catch((error: unknown) => {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
this.abort.signal.throwIfAborted()
|
||||
return open(current, 'wx+', 0o600)
|
||||
})
|
||||
this.abort.signal.throwIfAborted()
|
||||
const size = (await handle.stat()).size
|
||||
this.abort.signal.throwIfAborted()
|
||||
if (size > this.limits.segmentBytes) {
|
||||
await handle.truncate(0)
|
||||
this.repairedSegments++
|
||||
} else if (size > 0) {
|
||||
const byte = Buffer.alloc(1)
|
||||
const read = await handle.read(byte, 0, 1, size - 1)
|
||||
incomplete = read.bytesRead !== 1 || byte[0] !== 10
|
||||
}
|
||||
}
|
||||
this.abort.signal.throwIfAborted()
|
||||
// Preserve complete earlier records, but do not join a new event to a previous process's partial append.
|
||||
if (incomplete) {
|
||||
await rename(current, previous)
|
||||
this.repairedSegments++
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/** Worker lifetime for an auxiliary report; completion includes worker exit. */
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import type { OperationReportLimits } from './operation-report.ts'
|
||||
import type { OperationJournalStatus } from './operation-journal.ts'
|
||||
|
||||
/** Facts passed to the isolated collector after the basic report is saved. */
|
||||
export interface OperationReportJobInput {
|
||||
readonly reportPath: string
|
||||
readonly directory: string
|
||||
readonly limits: OperationReportLimits
|
||||
readonly journal: OperationJournalStatus | null
|
||||
}
|
||||
|
||||
/** A timed-out job can have appended a partial section; the saved basic report remains valid. */
|
||||
export interface OperationReportJobResult {
|
||||
readonly appended: boolean
|
||||
readonly timedOut: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Start one isolated collector and await its exit, terminating it at the deadline.
|
||||
* @param input - Bounded collection request for an existing basic report.
|
||||
* @param timeoutMs - Wall-clock budget including worker startup, before termination is requested.
|
||||
* @returns Append outcome only after the worker exits; worker startup/error failures remain auxiliary failures.
|
||||
*/
|
||||
export async function runOperationReportJob(input: OperationReportJobInput, timeoutMs: number): Promise<OperationReportJobResult> {
|
||||
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new RangeError('Invalid operation report deadline')
|
||||
let worker: Worker
|
||||
try {
|
||||
worker = new Worker(new URL('./operation-report-worker.js', import.meta.url), {
|
||||
workerData: input, resourceLimits: { maxOldGenerationSizeMb: 32, maxYoungGenerationSizeMb: 8, stackSizeMb: 2 },
|
||||
})
|
||||
}
|
||||
catch (_error) { return { appended: false, timedOut: false } }
|
||||
return new Promise((resolve) => {
|
||||
let appended = false
|
||||
let failed = false
|
||||
let timedOut = false
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true
|
||||
void worker.terminate().catch(() => { failed = true })
|
||||
}, timeoutMs)
|
||||
worker.on('message', (message: unknown) => { appended = message === true })
|
||||
worker.once('error', () => { failed = true })
|
||||
worker.once('exit', (code) => {
|
||||
clearTimeout(timer)
|
||||
resolve({ appended: appended && code === 0 && !failed && !timedOut, timedOut })
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Isolated auxiliary-report I/O; the shell owns termination and observes worker exit. */
|
||||
import { parentPort, workerData } from 'node:worker_threads'
|
||||
import { z } from 'zod'
|
||||
import { appendOperationReport } from './operation-report.ts'
|
||||
|
||||
const positive = z.number().int().positive().max(Number.MAX_SAFE_INTEGER)
|
||||
const input = z.object({
|
||||
reportPath: z.string(), directory: z.string(),
|
||||
limits: z.object({ bytes: positive, recordBytes: positive, records: positive, outputBytes: positive }),
|
||||
journal: z.object({ dropped: z.number().int().nonnegative(), failed: z.number().int().nonnegative(),
|
||||
repairedSegments: z.number().int().nonnegative(), queuedBytes: z.number().int().nonnegative(), disabled: z.boolean() }).nullable(),
|
||||
}).parse(workerData)
|
||||
|
||||
const appended = await appendOperationReport(input.reportPath, input.directory, input.limits, new AbortController().signal, input.journal)
|
||||
parentPort?.postMessage(appended)
|
||||
@@ -0,0 +1,50 @@
|
||||
/** Best-effort operation history appended only after the independent basic report exists. */
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { readOperationHistory, type OperationHistoryReadLimits } from './operation-history.ts'
|
||||
import type { OperationJournalStatus } from './operation-journal.ts'
|
||||
|
||||
/** Read budgets and the complete appended section's UTF-8 byte budget. */
|
||||
export interface OperationReportLimits extends OperationHistoryReadLimits {
|
||||
readonly outputBytes: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a bounded summary without rewriting the basic report's bytes.
|
||||
* A missing completion marker means the auxiliary section is unavailable or incomplete.
|
||||
* @param reportPath - Successfully saved basic report.
|
||||
* @param directory - Owned operation history directory.
|
||||
* @param limits - Read and parse budgets.
|
||||
* @param signal - Collection deadline or shutdown cancellation; already-issued OS I/O must settle.
|
||||
* @param journal - Current writer status, or null when this shell has not received operation records.
|
||||
* @returns Whether the complete auxiliary section was appended.
|
||||
*/
|
||||
export async function appendOperationReport(
|
||||
reportPath: string, directory: string, limits: OperationReportLimits,
|
||||
signal: AbortSignal, journal: OperationJournalStatus | null,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
if (!Number.isSafeInteger(limits.outputBytes) || limits.outputBytes < 1) return false
|
||||
const history = await readOperationHistory(directory, limits, signal)
|
||||
if (signal.aborted) return false
|
||||
const { records, ...facts } = history
|
||||
const header = (omitted: number): string => '\n--- recent plugin operations (context, not proof of cause) ---\n'
|
||||
+ JSON.stringify({ journal, ...facts, outputRecordsOmitted: omitted }).slice(0, -1) + ',"records":['
|
||||
const footer = ']}\n--- operation history collection complete; consult omission counters above ---\n'
|
||||
let bytes = Buffer.byteLength(header(records.length) + footer)
|
||||
if (bytes > limits.outputBytes) return false
|
||||
const retained: string[] = []
|
||||
for (const record of records) {
|
||||
const encoded = JSON.stringify(record)
|
||||
const addition = Buffer.byteLength(encoded) + (retained.length === 0 ? 0 : 1)
|
||||
if (bytes + addition > limits.outputBytes) break
|
||||
retained.push(encoded)
|
||||
bytes += addition
|
||||
}
|
||||
const section = header(records.length - retained.length) + retained.join(',') + footer
|
||||
await writeFile(reportPath, section, { flag: 'a', signal })
|
||||
return true
|
||||
} catch (_error) {
|
||||
// The independently written basic report remains usable, including after a partial append.
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
|
||||
--- recent plugin operations (context, not proof of cause) ---
|
||||
{"journal":null,"bytesRead":260,"filesRead":1,"truncated":false,"invalidRecords":0,"unavailableFiles":0,"cancelled":false,"outputRecordsOmitted":0,"records":[{"runId":"c254ab28-97c9-4382-95e6-2db9c66f98c8","operation":{"id":"c254ab28-97c9-4382-95e6-2db9c66f98c9","time":"2026-10-19T00:00:00.000Z","reason":"bundle","target":"test-bundle","stage":"enable","phase":"started"},"transportDropped":0}]}
|
||||
--- operation history collection complete; consult omission counters above ---
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Qualification-only hook: hold a real collector at its first asynchronous file open. */
|
||||
import { registerHooks } from 'node:module'
|
||||
|
||||
registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
if (specifier === 'node:fs/promises' && context.parentURL?.endsWith('/operation-history.js')) {
|
||||
return { url: 'qualification:stalled-read', shortCircuit: true }
|
||||
}
|
||||
return next(specifier, context)
|
||||
},
|
||||
load(url, context, next) {
|
||||
if (url !== 'qualification:stalled-read') return next(url, context)
|
||||
return { format: 'module', shortCircuit: true, source: `
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { workerData } from 'node:worker_threads'
|
||||
export async function open() {
|
||||
await writeFile(join(workerData.directory, 'read-entered'), 'ready')
|
||||
setInterval(() => {}, 1000)
|
||||
return new Promise(() => {})
|
||||
}
|
||||
` }
|
||||
},
|
||||
})
|
||||
@@ -1,8 +1,10 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { DesktopHostFatalError, DesktopHostProcess, DesktopHostUncleanExitError, QUIT_INSPECTION_DEADLINE_MS } from '../src/host-process.ts'
|
||||
import { OperationJournal } from '../src/operation-journal.ts'
|
||||
import { readOperationHistory } from '../src/operation-history.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
const hosts: DesktopHostProcess[] = []
|
||||
@@ -73,6 +75,45 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('desktop host process', () => {
|
||||
it('persists validated operation IPC across Host lifetimes and isolates a failing diagnostic sink', async () => {
|
||||
const record = {
|
||||
runId: 'c254ab28-97c9-4382-95e6-2db9c66f98c8', transportDropped: 0,
|
||||
operation: { id: 'c254ab28-97c9-4382-95e6-2db9c66f98c9', time: '2026-10-19T00:00:00.000Z',
|
||||
reason: 'bundle', phase: 'started', target: 'test-bundle', stage: 'enable' },
|
||||
secret: 'not-persisted',
|
||||
}
|
||||
const runtime = projectWithHost(`
|
||||
process.send({ type: 'plugin-operation', record: { invalid: true } })
|
||||
process.send({ type: 'plugin-operation', record: ${JSON.stringify(record)} })
|
||||
${HTTP_HOST}`)
|
||||
const directory = join(runtime, 'operation-history')
|
||||
const journal = new OperationJournal(directory, { recordBytes: 4096, segmentBytes: 8192, queuedBytes: 8192 })
|
||||
onTestFinished(async () => { await journal.close() })
|
||||
const sink = vi.fn(value => journal.enqueue(JSON.stringify(value)))
|
||||
const first = new DesktopHostProcess(process.execPath, runtime, runtime, undefined, process.env,
|
||||
undefined, undefined, undefined, undefined, sink)
|
||||
hosts.push(first)
|
||||
await first.start()
|
||||
await first.stop()
|
||||
await journal.close()
|
||||
expect(sink).toHaveBeenCalledTimes(1)
|
||||
const saved = await readOperationHistory(directory, { bytes: 8192, recordBytes: 4096, records: 10 }, new AbortController().signal)
|
||||
expect(saved.records).toEqual([{ runId: record.runId, transportDropped: 0, operation: record.operation }])
|
||||
|
||||
const warning = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
try {
|
||||
const failure = vi.fn()
|
||||
const second = new DesktopHostProcess(process.execPath, runtime, runtime, undefined, process.env,
|
||||
failure, undefined, undefined, undefined, () => { throw new Error('sink unavailable') })
|
||||
hosts.push(second)
|
||||
await second.start()
|
||||
expect(await second.updateTasks('inspect')).toBe(false)
|
||||
await second.stop()
|
||||
expect(failure).not.toHaveBeenCalled()
|
||||
expect(warning).toHaveBeenCalledWith('desktop plugin operation diagnostic sink failed')
|
||||
} finally { warning.mockRestore() }
|
||||
})
|
||||
|
||||
it('correlates task inspections and admission changes over private IPC', async () => {
|
||||
const host = hostProcess(projectWithHost())
|
||||
await expect(host.updateTasks('inspect')).rejects.toThrow('Host is unavailable')
|
||||
|
||||
@@ -17,6 +17,7 @@ type InvokeEvent = { sender?: unknown; senderFrame: { url: string } }
|
||||
type InvokeHandler = (event: InvokeEvent, ...args: unknown[]) => unknown
|
||||
|
||||
vi.mock('../src/web-document.ts', () => ({ authenticateWebHost: async () => 'test-cookie', serveWebDocument: vi.fn(), forwardWebRequest: vi.fn() }))
|
||||
vi.mock('../src/operation-report-job.ts', () => ({ runOperationReportJob: vi.fn(async () => ({ appended: false, timedOut: false })) }))
|
||||
// Report persistence has its own unit tests; here it resolves within microtasks so the fatal
|
||||
// dialog never outlives the test that triggered it.
|
||||
vi.mock('../src/crash-report.ts', async importOriginal => ({
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { mkdtemp, mkdir, open, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { readOperationHistory } from '../src/operation-history.ts'
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return { ...actual, open: vi.fn(actual.open) }
|
||||
})
|
||||
|
||||
const limits = { bytes: 64 * 1024, recordBytes: 2048, records: 100 }
|
||||
function record(id: string | number) {
|
||||
return { runId: 'c254ab28-97c9-4382-95e6-2db9c66f98c8', transportDropped: 0,
|
||||
operation: { id: 'c254ab28-97c9-4382-95e6-2db9c66f98c9', time: '2026-10-19T00:00:00.000Z',
|
||||
reason: 'plugin', phase: 'started', target: String(id), stage: 'enable' } }
|
||||
}
|
||||
function line(id: string | number) { return JSON.stringify(record(id)) + '\n' }
|
||||
|
||||
async function fixture() {
|
||||
const path = await mkdtemp(join(tmpdir(), 'desktop-operation-history-'))
|
||||
onTestFinished(async () => { await rm(path, { recursive: true, force: true }) })
|
||||
return path
|
||||
}
|
||||
|
||||
it('collects recent complete records and reports malformed and incomplete entries', async () => {
|
||||
const path = await fixture()
|
||||
await writeFile(join(path, 'operations.jsonl'), line(1) + '{broken}\n' + line(2) + '{"incomplete":')
|
||||
await writeFile(join(path, 'operations.previous.jsonl'), line(0))
|
||||
const result = await readOperationHistory(path, limits, new AbortController().signal)
|
||||
expect(result.records).toEqual([record(2), record(1), record(0)])
|
||||
expect(result).toMatchObject({ invalidRecords: 1, truncated: true, cancelled: false, unavailableFiles: 0 })
|
||||
})
|
||||
|
||||
it('reads no more than its byte budget from a two GiB file and ignores unrelated history', async () => {
|
||||
const path = await fixture()
|
||||
await using large = await open(join(path, 'operations.jsonl'), 'w')
|
||||
const size = 2 * 1024 * 1024 * 1024
|
||||
await large.truncate(size)
|
||||
const tail = Buffer.from('\n' + line('last'))
|
||||
await large.write(tail, 0, tail.length, size - tail.length)
|
||||
await writeFile(join(path, 'pnpm.log'), 'unrelated output')
|
||||
const result = await readOperationHistory(path, limits, new AbortController().signal)
|
||||
expect(result.records).toEqual([record('last')])
|
||||
expect(result.bytesRead).toBe(limits.bytes)
|
||||
expect(result.truncated).toBe(true)
|
||||
})
|
||||
|
||||
it('bounds parsed record size and output count independently', async () => {
|
||||
const path = await fixture()
|
||||
await writeFile(join(path, 'operations.jsonl'), line(1) + '{"large":"' + 'x'.repeat(4096) + '"}\n' + line(2))
|
||||
const result = await readOperationHistory(path, { ...limits, records: 2 }, new AbortController().signal)
|
||||
expect(result.records).toEqual([record(2), record(1)])
|
||||
expect(result.invalidRecords).toBe(1)
|
||||
})
|
||||
|
||||
it('returns immediately without I/O when already cancelled', async () => {
|
||||
const result = await readOperationHistory('not-a-directory', limits, AbortSignal.abort())
|
||||
expect(result).toMatchObject({ records: [], bytesRead: 0, cancelled: true, unavailableFiles: 0 })
|
||||
})
|
||||
|
||||
it('reports unreadable segments without failing the whole collection', async () => {
|
||||
const path = await fixture()
|
||||
await mkdir(join(path, 'operations.jsonl'))
|
||||
await writeFile(join(path, 'operations.previous.jsonl'), line(1))
|
||||
const result = await readOperationHistory(path, limits, new AbortController().signal)
|
||||
expect(result.records).toEqual([record(1)])
|
||||
expect(result.unavailableFiles).toBe(1)
|
||||
})
|
||||
|
||||
it('awaits an in-flight read before closing and schedules no further reads after cancellation', async () => {
|
||||
const path = await fixture()
|
||||
const filename = join(path, 'operations.jsonl')
|
||||
await writeFile(filename, ' '.repeat(limits.bytes))
|
||||
const handle = await open(filename, 'r')
|
||||
onTestFinished(async () => { await handle.close() })
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const opener = vi.mocked(open).mockClear().mockResolvedValueOnce(handle)
|
||||
const reader = vi.spyOn(handle, 'read').mockImplementationOnce(async () => {
|
||||
started.resolve(undefined)
|
||||
await release.promise
|
||||
return { bytesRead: 1, buffer: Buffer.alloc(1) }
|
||||
})
|
||||
const controller = new AbortController()
|
||||
let settled = false
|
||||
const collecting = readOperationHistory(path, limits, controller.signal)
|
||||
.then((result) => { settled = true; return result })
|
||||
try {
|
||||
await started.promise
|
||||
controller.abort()
|
||||
await Promise.resolve(undefined)
|
||||
expect(settled).toBe(false)
|
||||
expect(handle.fd).not.toBe(-1)
|
||||
release.resolve(undefined)
|
||||
expect(await collecting).toMatchObject({ cancelled: true, bytesRead: 1, records: [] })
|
||||
expect(handle.fd).toBe(-1)
|
||||
expect(reader).toHaveBeenCalledTimes(1)
|
||||
expect(opener).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
release.resolve(undefined)
|
||||
await collecting
|
||||
reader.mockRestore()
|
||||
opener.mockClear()
|
||||
await handle.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('contains truncation between stat and read and still collects the previous segment', async () => {
|
||||
const path = await fixture()
|
||||
const filename = join(path, 'operations.jsonl')
|
||||
await writeFile(filename, line(2))
|
||||
await writeFile(join(path, 'operations.previous.jsonl'), line(1))
|
||||
const handle = await open(filename, 'r+')
|
||||
onTestFinished(async () => { await handle.close() })
|
||||
const originalStat = await handle.stat()
|
||||
const stat = vi.spyOn(handle, 'stat').mockImplementationOnce(async () => {
|
||||
await handle.truncate(0)
|
||||
return originalStat
|
||||
})
|
||||
const opener = vi.mocked(open).mockClear().mockResolvedValueOnce(handle)
|
||||
try {
|
||||
const result = await readOperationHistory(path, limits, new AbortController().signal)
|
||||
expect(result).toMatchObject({ records: [record(1)], truncated: true, unavailableFiles: 0 })
|
||||
expect(handle.fd).toBe(-1)
|
||||
} finally {
|
||||
stat.mockRestore()
|
||||
opener.mockClear()
|
||||
await handle.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects invalid resource budgets before opening a file', async () => {
|
||||
for (const bytes of [0, -1, 0.5, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
|
||||
await expect(readOperationHistory('not-a-directory', { ...limits, bytes }, new AbortController().signal))
|
||||
.rejects.toThrow(RangeError)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not collect the same open segment twice when it rotates during collection', async () => {
|
||||
const path = await fixture()
|
||||
const current = join(path, 'operations.jsonl')
|
||||
await writeFile(current, line(1))
|
||||
const handle = await open(current, 'r')
|
||||
onTestFinished(async () => { await handle.close() })
|
||||
const opener = vi.mocked(open).mockImplementationOnce(async () => {
|
||||
await rename(current, join(path, 'operations.previous.jsonl'))
|
||||
await writeFile(current, line(2))
|
||||
return handle
|
||||
})
|
||||
try {
|
||||
const result = await readOperationHistory(path, limits, new AbortController().signal)
|
||||
expect(result.records).toEqual([record(1)])
|
||||
expect(result).toMatchObject({ filesRead: 1, truncated: true })
|
||||
expect(handle.fd).toBe(-1)
|
||||
} finally {
|
||||
opener.mockClear()
|
||||
await handle.close()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { OperationJournal } from '../src/operation-journal.ts'
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return { ...actual, mkdir: vi.fn(actual.mkdir) }
|
||||
})
|
||||
|
||||
async function fixture() {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'desktop-operation-journal-'))
|
||||
onTestFinished(async () => { await rm(directory, { recursive: true, force: true }) })
|
||||
return directory
|
||||
}
|
||||
|
||||
it('retains two bounded segments after dense operations across 50 plugins', async () => {
|
||||
const directory = await fixture()
|
||||
const journal = new OperationJournal(directory, { recordBytes: 128, segmentBytes: 1024, queuedBytes: 128 * 1000 })
|
||||
onTestFinished(async () => { await journal.close() })
|
||||
for (let i = 0; i < 1000; i++) expect(journal.enqueue(JSON.stringify({ plugin: i % 50, operation: i }))).toBe(true)
|
||||
await journal.close()
|
||||
expect((await readdir(directory)).sort()).toEqual(['operations.jsonl', 'operations.previous.jsonl'])
|
||||
for (const name of await readdir(directory)) {
|
||||
expect((await stat(join(directory, name))).size).toBeLessThanOrEqual(1024)
|
||||
for (const line of (await readFile(join(directory, name), 'utf8')).trim().split('\n')) expect(() => { JSON.parse(line) }).not.toThrow()
|
||||
}
|
||||
expect(await readFile(join(directory, 'operations.jsonl'), 'utf8')).toContain('"operation":999')
|
||||
expect(journal.status()).toEqual({ dropped: 0, failed: 0, queuedBytes: 0, disabled: false, repairedSegments: 0 })
|
||||
})
|
||||
|
||||
it('bounds the queue and encoded record bytes including multibyte characters and newline', async () => {
|
||||
const directory = await fixture()
|
||||
const journal = new OperationJournal(directory, { recordBytes: 8, segmentBytes: 32, queuedBytes: 16 })
|
||||
onTestFinished(async () => { await journal.close() })
|
||||
expect(journal.enqueue('{"中":1}')).toBe(false)
|
||||
expect(journal.enqueue('{"a":1}')).toBe(true)
|
||||
expect(journal.enqueue('{"a":2}')).toBe(true)
|
||||
expect(journal.enqueue('{"a":3}')).toBe(false)
|
||||
expect(journal.status()).toMatchObject({ queuedBytes: 16, dropped: 2 })
|
||||
await journal.close()
|
||||
expect(await readFile(join(directory, 'operations.jsonl'), 'utf8')).toBe('{"a":1}\n{"a":2}\n')
|
||||
expect(journal.enqueue('{}')).toBe(false)
|
||||
})
|
||||
|
||||
it('disables persistence after a write failure without rejecting producers or close', async () => {
|
||||
const directory = await fixture()
|
||||
const blocked = join(directory, 'file')
|
||||
await writeFile(blocked, 'untouched')
|
||||
const journal = new OperationJournal(blocked, { recordBytes: 32, segmentBytes: 64, queuedBytes: 64 })
|
||||
expect(journal.enqueue('{"operation":1}')).toBe(true)
|
||||
await expect(journal.close()).resolves.toBeUndefined()
|
||||
expect(journal.status()).toEqual({ dropped: 0, failed: 1, queuedBytes: 0, disabled: true, repairedSegments: 0 })
|
||||
expect(await readFile(blocked, 'utf8')).toBe('untouched')
|
||||
})
|
||||
|
||||
it('starts a fresh segment after a previous process left a partial record', async () => {
|
||||
const directory = await fixture()
|
||||
const partial = '{"old":1}\n{"partial":'
|
||||
await writeFile(join(directory, 'operations.jsonl'), partial)
|
||||
const journal = new OperationJournal(directory, { recordBytes: 32, segmentBytes: 64, queuedBytes: 64 })
|
||||
expect(journal.enqueue('{"new":2}')).toBe(true)
|
||||
await journal.close()
|
||||
expect(await readFile(join(directory, 'operations.jsonl'), 'utf8')).toBe('{"new":2}\n')
|
||||
expect(await readFile(join(directory, 'operations.previous.jsonl'), 'utf8')).toBe(partial)
|
||||
expect(journal.status()).toMatchObject({ repairedSegments: 1, disabled: false })
|
||||
})
|
||||
|
||||
it('bounds both pre-existing oversized segments before writing new records', async () => {
|
||||
const directory = await fixture()
|
||||
for (const name of ['operations.jsonl', 'operations.previous.jsonl']) await writeFile(join(directory, name), 'x'.repeat(1000))
|
||||
const journal = new OperationJournal(directory, { recordBytes: 32, segmentBytes: 64, queuedBytes: 64 })
|
||||
journal.enqueue('{"new":2}')
|
||||
await journal.close()
|
||||
for (const name of await readdir(directory)) expect((await stat(join(directory, name))).size).toBeLessThanOrEqual(64)
|
||||
expect(journal.status()).toMatchObject({ repairedSegments: 2, disabled: false })
|
||||
})
|
||||
|
||||
it('stops scheduling writes after a drain deadline without claiming the in-flight request ended', async () => {
|
||||
const directory = await fixture()
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
vi.mocked(mkdir).mockImplementationOnce(async () => { entered.resolve(undefined); await release.promise; return undefined })
|
||||
const journal = new OperationJournal(directory, { recordBytes: 32, segmentBytes: 64, queuedBytes: 64 })
|
||||
onTestFinished(async () => { release.resolve(undefined); await journal.close(); vi.useRealTimers() })
|
||||
journal.enqueue('{"operation":1}')
|
||||
await entered.promise
|
||||
vi.useFakeTimers()
|
||||
const draining = journal.drain(500)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(await draining).toBe(false)
|
||||
expect(journal.status()).toMatchObject({ disabled: true, queuedBytes: 16 })
|
||||
expect(journal.enqueue('{"operation":2}')).toBe(false)
|
||||
release.resolve(undefined)
|
||||
await journal.close()
|
||||
expect(await readdir(directory)).toEqual([])
|
||||
expect(journal.status()).toMatchObject({ failed: 1, queuedBytes: 0 })
|
||||
})
|
||||
|
||||
it('can keep recording after successful installer preparation when the installer later fails', async () => {
|
||||
const directory = await fixture()
|
||||
const journal = new OperationJournal(directory, { recordBytes: 32, segmentBytes: 64, queuedBytes: 64 })
|
||||
onTestFinished(async () => { await journal.close() })
|
||||
journal.enqueue('{"operation":1}')
|
||||
expect(await journal.drain(1000)).toBe(true)
|
||||
expect(journal.enqueue('{"operation":2}')).toBe(true)
|
||||
await journal.close()
|
||||
expect(await readFile(join(directory, 'operations.jsonl'), 'utf8')).toBe('{"operation":1}\n{"operation":2}\n')
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { runOperationReportJob } from '../src/operation-report-job.ts'
|
||||
|
||||
const create = vi.hoisted(() => vi.fn<(...args: unknown[]) => EventEmitter>())
|
||||
vi.mock('node:worker_threads', () => ({ Worker: function (...args: unknown[]) { return create(...args) } }))
|
||||
afterEach(() => { vi.useRealTimers(); create.mockReset() })
|
||||
const input = { reportPath: 'saved.log', directory: 'history',
|
||||
limits: { bytes: 65536, recordBytes: 4096, records: 100, outputBytes: 65536 }, journal: null }
|
||||
|
||||
it('does not report cancellation complete until the terminated worker exits', async () => {
|
||||
vi.useFakeTimers()
|
||||
const worker = Object.assign(new EventEmitter(), { terminate: vi.fn(async () => 1) })
|
||||
create.mockReturnValue(worker)
|
||||
let settled = false
|
||||
const result = runOperationReportJob(input, 500).then((value) => { settled = true; return value })
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(worker.terminate).toHaveBeenCalledTimes(1)
|
||||
expect(settled).toBe(false)
|
||||
worker.emit('exit', 1)
|
||||
expect(await result).toEqual({ appended: false, timedOut: true })
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('requires successful worker exit after the complete append message', async () => {
|
||||
vi.useFakeTimers()
|
||||
const worker = new EventEmitter()
|
||||
create.mockReturnValue(worker)
|
||||
const result = runOperationReportJob(input, 500)
|
||||
worker.emit('message', true)
|
||||
worker.emit('exit', 0)
|
||||
expect(await result).toEqual({ appended: true, timedOut: false })
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('contains startup and runtime worker failures', async () => {
|
||||
create.mockImplementationOnce(() => { throw new Error('worker unavailable') })
|
||||
expect(await runOperationReportJob(input, 500)).toEqual({ appended: false, timedOut: false })
|
||||
const worker = new EventEmitter()
|
||||
create.mockReturnValue(worker)
|
||||
const result = runOperationReportJob(input, 500)
|
||||
worker.emit('error', new Error('collector failed'))
|
||||
worker.emit('exit', 1)
|
||||
expect(await result).toEqual({ appended: false, timedOut: false })
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
import { appendOperationReport } from '../src/operation-report.ts'
|
||||
import { writeCrashReport, type CrashReportInput } from '../src/crash-report.ts'
|
||||
|
||||
const input: CrashReportInput = {
|
||||
operationHistoryPlanned: true, source: 'host', phase: 'startup', error: new Error('startup failed'),
|
||||
rendererConsole: [], time: new Date('2026-10-19T00:00:00Z'),
|
||||
app: { name: 'test', version: '1', platform: 'win32', arch: 'x64', electron: '44', node: '24', locale: 'zh-CN' },
|
||||
}
|
||||
const limits = { bytes: 65536, recordBytes: 4096, records: 100, outputBytes: 65536 }
|
||||
async function fixture() {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'desktop-operation-report-'))
|
||||
onTestFinished(async () => { await rm(directory, { recursive: true, force: true }) })
|
||||
const report = await writeCrashReport(directory, input)
|
||||
if (report === undefined) throw new Error('Basic report fixture was not saved')
|
||||
return { directory, report, basic: await readFile(report, 'utf8') }
|
||||
}
|
||||
|
||||
it('preserves the independently saved basic report and appends whitelisted recent history', async () => {
|
||||
const { directory, report, basic } = await fixture()
|
||||
const record = { runId: 'c254ab28-97c9-4382-95e6-2db9c66f98c8', transportDropped: 0,
|
||||
operation: { id: 'c254ab28-97c9-4382-95e6-2db9c66f98c9', time: '2026-10-19T00:00:00.000Z',
|
||||
reason: 'bundle', phase: 'started', target: 'test-bundle', stage: 'enable', diagnostic: 'secret' } }
|
||||
await writeFile(join(directory, 'operations.jsonl'), JSON.stringify(record) + '\n')
|
||||
expect(await appendOperationReport(report, directory, limits, new AbortController().signal, null)).toBe(true)
|
||||
const combined = await readFile(report, 'utf8')
|
||||
expect(combined.startsWith(basic)).toBe(true)
|
||||
expect(combined).toContain('test-bundle')
|
||||
expect(combined).not.toContain('secret')
|
||||
expect(combined).toContain('operation history collection complete')
|
||||
await expect(combined.slice(basic.length)).toMatchFileSnapshot('./expected/operation-history.txt')
|
||||
})
|
||||
|
||||
it('leaves basic bytes unchanged when collection is cancelled or fails before append', async () => {
|
||||
const { directory, report, basic } = await fixture()
|
||||
expect(await appendOperationReport(report, directory, limits, AbortSignal.abort(), null)).toBe(false)
|
||||
expect(await appendOperationReport(report, directory, { ...limits, bytes: 0 }, new AbortController().signal, null)).toBe(false)
|
||||
expect(await readFile(report, 'utf8')).toBe(basic)
|
||||
expect(basic).toContain('unavailable or incomplete')
|
||||
})
|
||||
|
||||
it('bounds the entire appended section and refuses a budget smaller than its metadata', async () => {
|
||||
const { directory, report, basic } = await fixture()
|
||||
expect(await appendOperationReport(report, directory, { ...limits, outputBytes: 1 }, new AbortController().signal, null)).toBe(false)
|
||||
expect(await readFile(report, 'utf8')).toBe(basic)
|
||||
expect(await appendOperationReport(report, directory, { ...limits, outputBytes: 512 }, new AbortController().signal, null)).toBe(true)
|
||||
const summary = (await readFile(report, 'utf8')).slice(basic.length)
|
||||
expect(Buffer.byteLength(summary)).toBeLessThanOrEqual(512)
|
||||
expect(summary).toContain('"outputRecordsOmitted":0')
|
||||
})
|
||||
|
||||
it('omits oldest records to fit the output budget, counting UTF-8 bytes and all metadata', async () => {
|
||||
const { directory, report, basic } = await fixture()
|
||||
const records = Array.from({ length: 20 }, (_, index) => ({
|
||||
runId: 'c254ab28-97c9-4382-95e6-2db9c66f98c8', transportDropped: 0,
|
||||
operation: { id: 'c254ab28-97c9-4382-95e6-2db9c66f98c9', time: '2026-10-19T00:00:00.000Z',
|
||||
reason: 'bundle', phase: 'started', target: `测试插件-${index}`, stage: 'enable' },
|
||||
}))
|
||||
await writeFile(join(directory, 'operations.jsonl'), records.map(record => JSON.stringify(record)).join('\n') + '\n')
|
||||
expect(await appendOperationReport(report, directory, { ...limits, outputBytes: 1024 }, new AbortController().signal, null)).toBe(true)
|
||||
const section = (await readFile(report, 'utf8')).slice(basic.length)
|
||||
expect(Buffer.byteLength(section)).toBeLessThanOrEqual(1024)
|
||||
const summary = JSON.parse(section.split('\n').find(line => line.startsWith('{'))!) as {
|
||||
outputRecordsOmitted: number
|
||||
records: { operation: { target: string } }[]
|
||||
}
|
||||
expect(summary.outputRecordsOmitted).toBeGreaterThan(0)
|
||||
expect(summary.records.length + summary.outputRecordsOmitted).toBe(20)
|
||||
expect(summary.records[0]?.operation.target).toBe('测试插件-19')
|
||||
})
|
||||
@@ -31,6 +31,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/product-analytics/tsconfig.host.json"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/boot/plugin-manager"
|
||||
}
|
||||
],
|
||||
"exclude": [
|
||||
|
||||
@@ -33,6 +33,12 @@ if (clientVersion === undefined) throw new Error('desktop build: the client envi
|
||||
const clientVersionDefine = { 'process.env.DSH_CLIENT_VERSION': JSON.stringify(clientVersion) }
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: ['lib/types/operation-report-worker.js'],
|
||||
plugins: [packagedImportsPlugin({ packages: new Set(), nodeBuiltins: true })],
|
||||
outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024',
|
||||
fixedExtension: false, dts: false, clean: false, codeSplitting: false,
|
||||
},
|
||||
{
|
||||
entry: ['lib/types/command-manager-entry.js'],
|
||||
plugins: [packagedImportsPlugin({ packages: new Set(), nodeBuiltins: true })],
|
||||
|
||||
@@ -2423,7 +2423,7 @@ export interface PlanModeConfig {
|
||||
## `@deepseek-ai/dsh-plugin-manager`
|
||||
|
||||
- `inject`: `loader` · `profileContext`
|
||||
- `source`: [`packages/boot/plugin-manager/src/index.ts:42`](../packages/boot/plugin-manager/src/index.ts)
|
||||
- `source`: [`packages/boot/plugin-manager/src/index.ts:43`](../packages/boot/plugin-manager/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */
|
||||
|
||||
@@ -2425,7 +2425,7 @@ export interface PlanModeConfig {
|
||||
## `@deepseek-ai/dsh-plugin-manager`
|
||||
|
||||
- `inject`: `loader` · `profileContext`
|
||||
- `source`: [`packages/boot/plugin-manager/src/index.ts:42`](../packages/boot/plugin-manager/src/index.ts)
|
||||
- `source`: [`packages/boot/plugin-manager/src/index.ts:43`](../packages/boot/plugin-manager/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */
|
||||
|
||||
@@ -56,9 +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:269`](../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:275`](../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:282`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `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-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`) | - |
|
||||
|
||||
@@ -58,9 +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:269`](../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:275`](../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:282`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `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-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`) | - |
|
||||
|
||||
@@ -425,6 +425,23 @@ An installation moved between its Host phases. `installing` is announced once pe
|
||||
|
||||
Source: [`packages/boot/plugin-manager/src/types.ts`](../../packages/boot/plugin-manager/src/types.ts)
|
||||
|
||||
<a id="plugin-manageroperation--emit"></a>
|
||||
|
||||
#### `plugin-manager/operation` — emit
|
||||
|
||||
One manager invocation started or ended; raw installation specs and error messages are excluded.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* One manager invocation started or ended; raw installation specs and error messages are excluded.
|
||||
* @mode emit
|
||||
* @param operation - Correlated operation facts; a start without an end has an unknown outcome.
|
||||
*/
|
||||
'plugin-manager/operation'(operation: PluginOperation): void
|
||||
```
|
||||
|
||||
Source: [`packages/boot/plugin-manager/src/types.ts`](../../packages/boot/plugin-manager/src/types.ts)
|
||||
|
||||
<a id="plugin-market-events"></a>
|
||||
|
||||
### `plugin-market/*` events
|
||||
|
||||
@@ -425,6 +425,23 @@ An installation moved between its Host phases. `installing` is announced once pe
|
||||
|
||||
Source: [`packages/boot/plugin-manager/src/types.ts`](../../packages/boot/plugin-manager/src/types.ts)
|
||||
|
||||
<a id="plugin-manageroperation--emit"></a>
|
||||
|
||||
#### `plugin-manager/operation` — emit
|
||||
|
||||
One manager invocation started or ended; raw installation specs and error messages are excluded.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* One manager invocation started or ended; raw installation specs and error messages are excluded.
|
||||
* @mode emit
|
||||
* @param operation - Correlated operation facts; a start without an end has an unknown outcome.
|
||||
*/
|
||||
'plugin-manager/operation'(operation: PluginOperation): void
|
||||
```
|
||||
|
||||
Source: [`packages/boot/plugin-manager/src/types.ts`](../../packages/boot/plugin-manager/src/types.ts)
|
||||
|
||||
<a id="plugin-market-events"></a>
|
||||
|
||||
### `plugin-market/*` events
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
en: d5643a06f300c30a
|
||||
zh: 9348b6a7676a2849
|
||||
/deepseek-ai-dsh-plugin-manager/understand-the-implementation:
|
||||
en: 495dadf79c48c43c
|
||||
zh: 9884dd04a5bb49dc
|
||||
en: 531d50d67168f82b
|
||||
zh: 295fbc453029f0c4
|
||||
/deepseek-ai-dsh-plugin-manager/further-exploration:
|
||||
en: c6538243d8702a0d
|
||||
zh: 04a82d923353c75f
|
||||
|
||||
@@ -103,6 +103,8 @@ 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.
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
|
||||
@@ -103,6 +103,8 @@ 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 包操作不发送此服务事件。
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
|
||||
@@ -45,7 +45,11 @@
|
||||
"default": "./lib/types/tools.js"
|
||||
},
|
||||
"./tools/locale/*.json": "./locale/tools/*.json",
|
||||
"./locale/*.json": "./locale/*.json"
|
||||
"./locale/*.json": "./locale/*.json",
|
||||
"./diagnostic": {
|
||||
"types": "./lib/types/diagnostic.d.ts",
|
||||
"default": "./lib/types/diagnostic.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"locale/*.json",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/** Whitelisted operation records shared by diagnostic transport and durable readers. */
|
||||
import { z } from 'zod'
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { PluginOperation, PluginOperationId } from './types.ts'
|
||||
|
||||
/** Identifies the process lifetime which observed management operations. */
|
||||
export type PluginOperationRunId = Branded<'PluginOperationRunId'>
|
||||
|
||||
/** One diagnostic event; omissions and unknown outcomes must not be interpreted as successful operations. */
|
||||
export interface PluginOperationRecord {
|
||||
readonly runId: PluginOperationRunId
|
||||
readonly operation: PluginOperation
|
||||
readonly transportDropped: number
|
||||
}
|
||||
|
||||
const fields = {
|
||||
id: z.uuid().transform(value => value as PluginOperationId),
|
||||
time: z.iso.datetime(),
|
||||
reason: z.enum(['plugin', 'bundle', 'install', 'remove']),
|
||||
target: z.string().max(256).nullable(),
|
||||
stage: z.enum(['install', 'enable', 'remove']),
|
||||
enabled: z.boolean().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(),
|
||||
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(),
|
||||
restartReason: z.enum(['no-hmr', 'package-moved', 'host-module', 'unmanaged-plugin', 'module-comparison']).optional(),
|
||||
}),
|
||||
])
|
||||
const record = z.object({
|
||||
runId: z.uuid().transform(value => value as PluginOperationRunId),
|
||||
operation,
|
||||
transportDropped: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
|
||||
})
|
||||
|
||||
/**
|
||||
* Validate transport/file data and discard fields outside the diagnostic whitelist.
|
||||
* @param value - Untrusted decoded JSON.
|
||||
* @returns A bounded record, or undefined for malformed, oversized or unsupported records.
|
||||
*/
|
||||
export function parsePluginOperationRecord(value: unknown): PluginOperationRecord | undefined {
|
||||
const parsed = record.safeParse(value)
|
||||
return parsed.success ? parsed.data : undefined
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import type {
|
||||
BundleInfo, BundleRowInfo, ChangeResult, InspectOptions, InstallBundleOptions, ManagementError, PackageResult, PluginChange,
|
||||
PluginEntryId, PluginInfo, PluginInspectProblem, PluginInstallCancellation, PluginInstallProgress, PluginInstallRequestId,
|
||||
PluginRegistries, PluginSpecInspection, Registry,
|
||||
PluginOperation, PluginOperationId,
|
||||
} from './types.ts'
|
||||
export type * from './types.ts'
|
||||
export { classifyInstallFailure, type InstallFailureFacts } from './install-failure.ts'
|
||||
@@ -900,29 +901,55 @@ export class PluginManager extends TypertRemoteService {
|
||||
request: Pick<ChangeResult, 'stage' | 'target' | 'enabled'>,
|
||||
reason: PluginChange['reason'],
|
||||
): Promise<ChangeResult> {
|
||||
return withFileLock(join(this.profile.dir, 'package.json'), async () => {
|
||||
this.abort.signal.throwIfAborted()
|
||||
const before = this.diskState()
|
||||
const result: ChangeResult = { ...request, changed: false,
|
||||
...this.ownerContext.get('hmr') !== undefined ? { application: 'applied' } : { application: 'restart-required', restartReason: 'no-hmr' } }
|
||||
try {
|
||||
const outcome = await operation(result)
|
||||
if (outcome !== undefined) result.application = outcome
|
||||
// A reload that waits for a restart leaves the change saved but not applied.
|
||||
else if (result.application === 'applied' && result.restartReason !== undefined) result.application = 'restart-required'
|
||||
} catch (error) {
|
||||
if (error instanceof InstallCancelledError) {
|
||||
result.application = 'cancelled'
|
||||
} else {
|
||||
result.application = 'failed'
|
||||
result.error = managementError(error)
|
||||
const id = randomUUID() as PluginOperationId
|
||||
const facts = { id, reason, stage: request.stage,
|
||||
target: reason === 'install' ? null : request.target,
|
||||
...request.enabled === undefined ? {} : { enabled: request.enabled } }
|
||||
this.reportOperation({ ...facts, time: new Date().toISOString(), phase: 'started' })
|
||||
let settled: ChangeResult
|
||||
try {
|
||||
settled = await withFileLock(join(this.profile.dir, 'package.json'), async () => {
|
||||
this.abort.signal.throwIfAborted()
|
||||
const before = this.diskState()
|
||||
const result: ChangeResult = { ...request, changed: false,
|
||||
...this.ownerContext.get('hmr') !== undefined ? { application: 'applied' } : { application: 'restart-required', restartReason: 'no-hmr' } }
|
||||
try {
|
||||
const outcome = await operation(result)
|
||||
if (outcome !== undefined) result.application = outcome
|
||||
// A reload that waits for a restart leaves the change saved but not applied.
|
||||
else if (result.application === 'applied' && result.restartReason !== undefined) result.application = 'restart-required'
|
||||
} catch (error) {
|
||||
if (error instanceof InstallCancelledError) {
|
||||
result.application = 'cancelled'
|
||||
} else {
|
||||
result.application = 'failed'
|
||||
result.error = managementError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result.application !== 'restart-required') delete result.restartReason
|
||||
result.changed = before !== this.diskState()
|
||||
this.ownerContext.emit('plugin-manager/changed', { reason })
|
||||
return result
|
||||
}, { waitMs: this.lockWaitMs })
|
||||
if (result.application !== 'restart-required') delete result.restartReason
|
||||
result.changed = before !== this.diskState()
|
||||
this.ownerContext.emit('plugin-manager/changed', { reason })
|
||||
return result
|
||||
}, { waitMs: this.lockWaitMs })
|
||||
} catch (error) {
|
||||
this.reportOperation({ ...facts, time: new Date().toISOString(), phase: 'unknown' })
|
||||
throw error
|
||||
}
|
||||
this.reportOperation({ ...facts, time: new Date().toISOString(), phase: 'settled',
|
||||
stage: settled.stage, target: settled.bundle ?? facts.target,
|
||||
application: settled.application, changed: settled.changed,
|
||||
...settled.version === undefined ? {} : { version: settled.version },
|
||||
...settled.error === undefined ? {} : { errorCode: settled.error.code },
|
||||
...settled.restartReason === undefined ? {} : { restartReason: settled.restartReason } })
|
||||
return settled
|
||||
}
|
||||
|
||||
private reportOperation(operation: PluginOperation): void {
|
||||
try { this.ownerContext.emit('plugin-manager/operation', operation) }
|
||||
catch (_error) {
|
||||
// Diagnostic subscribers cannot change a management result or prevent the operation.
|
||||
console.error('dsh plugin-manager: operation diagnostic delivery failed')
|
||||
}
|
||||
}
|
||||
|
||||
private diskState(): string {
|
||||
|
||||
@@ -257,8 +257,40 @@ export interface PluginChange {
|
||||
readonly reason: 'plugin' | 'bundle' | 'install' | 'remove'
|
||||
}
|
||||
|
||||
/** Identifies one manager invocation, including time spent waiting for its profile lock. */
|
||||
export type PluginOperationId = Branded<'PluginOperationId'>
|
||||
|
||||
/** Management facts for diagnostic consumers; recent operations do not establish a crash's cause. */
|
||||
export type PluginOperation = {
|
||||
readonly id: PluginOperationId
|
||||
readonly time: string
|
||||
readonly reason: PluginChange['reason']
|
||||
/** Entry id or package name; installation specs are omitted until the package is resolved. */
|
||||
readonly target: string | null
|
||||
readonly stage: ChangeResult['stage']
|
||||
readonly enabled?: boolean | 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
|
||||
}
|
||||
/** An exception escaped the operation result; persisted or applied changes cannot be inferred. */
|
||||
| { readonly phase: 'unknown' }
|
||||
)
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
* One manager invocation started or ended; raw installation specs and error messages are excluded.
|
||||
* @mode emit
|
||||
* @param operation - Correlated operation facts; a start without an end has an unknown outcome.
|
||||
*/
|
||||
'plugin-manager/operation'(operation: PluginOperation): void
|
||||
/**
|
||||
* The profile's plugins, bundles, or composition changed: a manager
|
||||
* operation completed. A patch generation applied outside the manager,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { parsePluginOperationRecord } from '../src/diagnostic.ts'
|
||||
|
||||
const input = {
|
||||
runId: 'c254ab28-97c9-4382-95e6-2db9c66f98c8', transportDropped: 0,
|
||||
operation: { id: 'c254ab28-97c9-4382-95e6-2db9c66f98c9', time: '2026-10-19T00:00:00.000Z',
|
||||
reason: 'plugin', phase: 'settled', target: 'managed', stage: 'enable', changed: true, application: 'restart-required' },
|
||||
}
|
||||
|
||||
it('keeps only diagnostic fields at both record levels', () => {
|
||||
expect(parsePluginOperationRecord({ ...input, credential: 'secret', operation: {
|
||||
...input.operation, diagnostic: 'raw secret output', packageResult: { output: 'raw output' },
|
||||
} })).toEqual(input)
|
||||
})
|
||||
|
||||
it('rejects unsupported phases, oversized identifiers and invalid result fields', () => {
|
||||
for (const patch of [{ phase: 'future' }, { id: 'invalid' }, { target: 'x'.repeat(257) },
|
||||
{ time: 'not a time' }, { changed: 'yes' }, { application: 'unknown-success' }, { errorCode: 'raw-message' }]) {
|
||||
expect(parsePluginOperationRecord({ ...input, operation: { ...input.operation, ...patch } })).toBeUndefined()
|
||||
}
|
||||
expect(parsePluginOperationRecord({ ...input, transportDropped: -1 })).toBeUndefined()
|
||||
expect(parsePluginOperationRecord({ ...input, runId: '' })).toBeUndefined()
|
||||
})
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type ProfileContext, type RuntimeResolution,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import PluginManager, { type Config, type PluginChange, type PluginInstallLogChunk, type PluginInstallProgress, type PluginInstallRequestId } from '../src/index.ts'
|
||||
import type { PluginOperation } from '../src/types.ts'
|
||||
import Hmr from '@deepseek-ai/dsh-hmr'
|
||||
import Timer from '@deepseek-ai/cordis-plugin-timer'
|
||||
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
|
||||
@@ -458,6 +459,52 @@ it.each([
|
||||
})
|
||||
})
|
||||
|
||||
it('correlates diagnostic operation starts with persisted toggle results', async () => {
|
||||
const { manager, ctx, dir } = await fixture('startup')
|
||||
const facts: PluginOperation[] = []
|
||||
ctx.on('plugin-manager/operation', (event) => { facts.push(event) })
|
||||
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' })
|
||||
await manager.setBundleEnabled('extra', true)
|
||||
expect(facts[2]!.id).not.toBe(facts[0]!.id)
|
||||
})
|
||||
|
||||
it('excludes raw install specs and error diagnostics from operation facts', async () => {
|
||||
const { manager, ctx } = await fixture('startup')
|
||||
const facts: PluginOperation[] = []
|
||||
ctx.on('plugin-manager/operation', (event) => { facts.push(event) })
|
||||
await manager.installBundle('--https://user:[email protected]/private?token=secret')
|
||||
expect(facts).toHaveLength(2)
|
||||
expect(facts[1]).toMatchObject({ phase: 'settled', target: null, application: 'failed', errorCode: 'invalid-spec' })
|
||||
expect(JSON.stringify(facts)).not.toMatch(/secret|example|diagnostic|packageResult/)
|
||||
})
|
||||
|
||||
it('reports unknown outcomes when profile inspection rejects before a result exists', async () => {
|
||||
const { manager, ctx, dir } = await fixture('startup')
|
||||
const facts: PluginOperation[] = []
|
||||
ctx.on('plugin-manager/operation', (event) => { facts.push(event) })
|
||||
const patch = join(dir, 'cordis.patch.yml')
|
||||
rmSync(patch, { force: true })
|
||||
mkdirSync(patch)
|
||||
await expect(manager.setBundleEnabled('extra', false)).rejects.toThrow()
|
||||
expect(facts.map(event => event.phase)).toEqual(['started', 'unknown'])
|
||||
expect(facts[1]!.id).toBe(facts[0]!.id)
|
||||
expect(facts[1]).not.toHaveProperty('changed')
|
||||
})
|
||||
|
||||
it('preserves management results when a diagnostic subscriber throws', async () => {
|
||||
const { manager, ctx, dir } = await fixture('startup')
|
||||
const errors = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
onTestFinished(() => { errors.mockRestore() })
|
||||
ctx.on('plugin-manager/operation', () => { throw new Error('diagnostic sink unavailable') })
|
||||
expect(await manager.setBundleEnabled('extra', false)).toMatchObject({ changed: true, application: 'restart-required' })
|
||||
expect(readProfileManifest('test', dir).dsh?.profile?.bundles).not.toContain('extra')
|
||||
expect(errors).toHaveBeenCalledWith('dsh plugin-manager: operation diagnostic delivery failed')
|
||||
})
|
||||
|
||||
it('turns a plugin off and on without leaving a patch override the bundle already implies', async () => {
|
||||
const { manager, dir } = await fixture()
|
||||
const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
|
||||
|
||||
@@ -4405,6 +4405,14 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
description: 'An installation moved between its Host phases. `installing` is announced once per registry the installation asks, with the attempt\'s registry and position; `cancelling` and `applying` once.',
|
||||
parameters: [{ name: 'progress', description: 'the installation\'s request id and phase, with the attempt while installing.' }],
|
||||
},
|
||||
{
|
||||
name: 'plugin-manager/operation',
|
||||
mode: 'emit',
|
||||
signature: '\'plugin-manager/operation\'(operation: PluginOperation): void',
|
||||
summary: 'One manager invocation started or ended; raw installation specs and error messages are excluded.',
|
||||
description: 'One manager invocation started or ended; raw installation specs and error messages are excluded.',
|
||||
parameters: [{ name: 'operation', description: 'Correlated operation facts; a start without an end has an unknown outcome.' }],
|
||||
},
|
||||
{
|
||||
name: 'plugin-market/changed',
|
||||
mode: 'emit',
|
||||
@@ -6429,6 +6437,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'PluginLocalizedMeta',
|
||||
declaration: 'export interface PluginLocalizedMeta {\n readonly title?: LocalizedText;\n readonly description?: LocalizedText;\n readonly icon?: string;\n readonly error?: string;\n}',
|
||||
},
|
||||
{
|
||||
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});',
|
||||
},
|
||||
{
|
||||
name: 'PluginOperationId',
|
||||
declaration: 'export type PluginOperationId = Branded<\'PluginOperationId\'>;',
|
||||
},
|
||||
{
|
||||
name: 'PluginOptions',
|
||||
declaration: 'export type PluginOptions = Readonly<Record<string, PluginOptionValue>>;',
|
||||
|
||||
Generated
+9
@@ -709,6 +709,9 @@ importers:
|
||||
'@deepseek-ai/dsh-home-paths':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/util/home-paths
|
||||
'@deepseek-ai/dsh-plugin-manager':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/boot/plugin-manager
|
||||
'@deepseek-ai/node-addon-system':
|
||||
specifier: workspace:~
|
||||
version: link:../../native/system/packages/entry
|
||||
@@ -769,6 +772,9 @@ importers:
|
||||
vite:
|
||||
specifier: ^6.4.3
|
||||
version: 6.4.3(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
|
||||
zod:
|
||||
specifier: 4.4.3
|
||||
version: 4.4.3
|
||||
|
||||
apps/desktop-host:
|
||||
dependencies:
|
||||
@@ -799,6 +805,9 @@ importers:
|
||||
'@deepseek-ai/dsh-jobs':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/jobs/jobs
|
||||
'@deepseek-ai/dsh-plugin-manager':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/boot/plugin-manager
|
||||
'@deepseek-ai/dsh-schedule':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/schedule/schedule
|
||||
|
||||
@@ -317,6 +317,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
PluginInstallRequestId: 'boot.md',
|
||||
PluginSpecInspection: 'boot.md',
|
||||
PluginChange: 'boot.md',
|
||||
PluginOperation: 'boot.md',
|
||||
PluginInstallLogChunk: 'boot.md',
|
||||
PluginInstallProgress: 'boot.md',
|
||||
PluginRegistries: 'boot.md',
|
||||
|
||||
@@ -224,6 +224,7 @@
|
||||
"@deepseek-ai/dsh-host-webserver": ["./packages/host/webserver/src"],
|
||||
"@deepseek-ai/dsh-host-frontend-static": ["./packages/host/frontend-static/src"],
|
||||
"@deepseek-ai/dsh-plugin-manager": ["./packages/boot/plugin-manager/src"],
|
||||
"@deepseek-ai/dsh-plugin-manager/diagnostic": ["./packages/boot/plugin-manager/src/diagnostic.ts"],
|
||||
"@deepseek-ai/dsh-plugin-manager/types": ["./packages/boot/plugin-manager/src/types.ts"],
|
||||
"@deepseek-ai/dsh-plugin-manager/registry": ["./packages/boot/plugin-manager/src/registry.ts"],
|
||||
"@deepseek-ai/dsh-plugin-manager/operations": ["./packages/boot/plugin-manager/src/operations.ts"],
|
||||
|
||||
Reference in new issue
Block a user