From 36dce6b596a364afbdfc352567f955e8f641b8cf Mon Sep 17 00:00:00 2001 From: winewill Date: Fri, 23 Oct 2026 14:36:04 +0800 Subject: [PATCH] fix(desktop): preserve operation identity and bound diagnostic shutdown --- apps/desktop-host/src/fatal-diagnostic.ts | 9 ++-- .../tests/fatal-diagnostic.spec.ts | 22 ++++++++-- .../desktop/docs/diagnostics/README.i18n.yaml | 19 +++++---- apps/desktop/docs/diagnostics/README.md | 14 ++++--- apps/desktop/docs/diagnostics/README.zh.md | 12 ++++-- apps/desktop/src/fatal-recovery.ts | 4 +- apps/desktop/src/locale.ts | 16 +++---- apps/desktop/src/operation-journal.ts | 3 +- apps/desktop/tests/fatal-recovery.spec.ts | 19 +++++++++ apps/desktop/tests/main-startup.spec.ts | 3 +- apps/desktop/tests/operation-journal.spec.ts | 4 ++ packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 2 +- packages/boot/app-boot/README.zh.md | 2 +- packages/boot/app-boot/src/index.ts | 2 +- packages/boot/plugin-manager/README.i18n.yaml | 4 +- packages/boot/plugin-manager/README.md | 2 +- packages/boot/plugin-manager/README.zh.md | 2 +- packages/boot/plugin-manager/src/index.ts | 18 ++++++-- .../boot/plugin-manager/tests/manager.spec.ts | 42 +++++++++++++++++++ 20 files changed, 156 insertions(+), 47 deletions(-) diff --git a/apps/desktop-host/src/fatal-diagnostic.ts b/apps/desktop-host/src/fatal-diagnostic.ts index a6d5baf5e6..e72533dae0 100644 --- a/apps/desktop-host/src/fatal-diagnostic.ts +++ b/apps/desktop-host/src/fatal-diagnostic.ts @@ -9,6 +9,9 @@ export const MAX_FATAL_DIAGNOSTIC_CHARS = 64 * 1024 /** Original-error budget when startup metadata needs space in the same report. */ const MAX_ORIGINAL_ERROR_CHARS = 8 * 1024 +/** Startup entry details leave room for composition and provider observations. */ +const MAX_STARTUP_ENTRY_CHARS = 8 * 1024 + /** * Render the original error and explicitly selected startup metadata. * Selected metadata excludes configuration values and arbitrary startup logger arguments. @@ -26,9 +29,9 @@ export function formatFatalDiagnostic(error: unknown): string { if (value.startup !== undefined) { sections.push(`Configuration file: ${value.startup.configurationPath}`) } - sections.push('--- startup entries ---', inspect(value.entries, { - depth: 6, maxArrayLength: null, maxStringLength: 4096, - })) + const entries = inspect(value.entries, { depth: 6, maxArrayLength: null, maxStringLength: 4096 }) + sections.push('--- startup entries ---', entries.length <= MAX_STARTUP_ENTRY_CHARS ? entries + : `${entries.slice(0, MAX_STARTUP_ENTRY_CHARS).replace(/[\uD800-\uDBFF]$/u, '')}\n… (startup entries truncated at ${MAX_STARTUP_ENTRY_CHARS} characters)`) if (value.composition !== undefined) { const { plugins, services, omittedPlugins, omittedServices } = value.composition const sources = [...new Set(plugins.map(plugin => plugin.source))] diff --git a/apps/desktop-host/tests/fatal-diagnostic.spec.ts b/apps/desktop-host/tests/fatal-diagnostic.spec.ts index c48e815370..1f41358300 100644 --- a/apps/desktop-host/tests/fatal-diagnostic.spec.ts +++ b/apps/desktop-host/tests/fatal-diagnostic.spec.ts @@ -52,7 +52,7 @@ it('marks truncation within the complete IPC budget and preserves Unicode', () = const error = new Error('😀'.repeat(MAX_FATAL_DIAGNOSTIC_CHARS)) const report = formatFatalDiagnostic(error) expect(report.length).toBeLessThanOrEqual(MAX_FATAL_DIAGNOSTIC_CHARS) - expect(report).toContain(`Host diagnostic truncated at ${MAX_FATAL_DIAGNOSTIC_CHARS} characters`) + expect(report).toContain('Host diagnostic truncated at 65536 characters') expect(report.isWellFormed()).toBe(true) }) @@ -71,13 +71,13 @@ it('keeps missing-service evidence ahead of a truncated plugin inventory', () => omittedPlugins: 3, omittedServices: 2, } const report = formatFatalDiagnostic(error) - expect(report).toContain('original error truncated') + expect(report).toContain('original error truncated at 8192 characters') expect(report).toContain('Configuration file: /profile/critical-cordis.yml') expect(report).toContain('waiting-entry') expect(report).toContain('"service":"agents","providers":[]') expect(report).toContain('Omitted plugin rows: 3; omitted missing-service observations: 2.') expect(report).toContain('"source":"/profile/cordis.yml"') - expect(report).toContain(`Host diagnostic truncated at ${MAX_FATAL_DIAGNOSTIC_CHARS} characters`) + expect(report).toContain('Host diagnostic truncated at 65536 characters') expect(report.length).toBeLessThanOrEqual(MAX_FATAL_DIAGNOSTIC_CHARS) }) @@ -87,3 +87,19 @@ it('terminates traversal through cyclic and aggregate causes', () => { error.cause = error expect(formatFatalDiagnostic(error)).toContain('cycle') }) + +it.each(['many entries', 'long stack'])('retains composition evidence after %s', (scenario) => { + const failure = new Error('startup failure') + failure.stack = 'x'.repeat(70000) + const error = new StartupError('startup failed', scenario === 'long stack' + ? [{ id: 'failed', module: './failed.mjs', required: true, fiberState: undefined, outcome: { kind: 'failed', error: failure } }] + : Array.from({ length: 256 }, (_, index) => ({ id: `pending-${index}`, module: 'module'.repeat(60), required: true, fiberState: undefined, outcome: { kind: 'pending' as const, missing: ['agents'] } }))) + error.startup = { configurationPath: '/profile/cordis.yml', messages: [] } + error.composition = { plugins: [{ id: 'provider', module: 'example', version: '1.0.0', state: 'active', source: '/profile/cordis.yml', missing: [] }], services: [{ consumer: 'consumer', service: 'agents', providers: [] }], omittedPlugins: 0, omittedServices: 0 } + const report = formatFatalDiagnostic(error) + expect(report).toContain('Configuration file: /profile/cordis.yml') + expect(report).toContain('startup entries truncated') + expect(report).toContain('"service":"agents"') + expect(report).toContain('"version":"1.0.0"') + expect(report.length).toBeLessThanOrEqual(65536) +}) diff --git a/apps/desktop/docs/diagnostics/README.i18n.yaml b/apps/desktop/docs/diagnostics/README.i18n.yaml index 6bbde0f972..d3a1aaa975 100644 --- a/apps/desktop/docs/diagnostics/README.i18n.yaml +++ b/apps/desktop/docs/diagnostics/README.i18n.yaml @@ -2,24 +2,27 @@ # section, a hash of its English and Chinese blocks outside code blocks and generated regions. # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/docs/diagnostics/README.md +/: + en: ca7ea022a5be1df2 + zh: ca7ea022a5be1df2 /desktop-recovery-and-diagnostics: en: 4d4fd8a92047e5c6 zh: 15ec51d703ccd70b /desktop-recovery-and-diagnostics/summary: en: e61d0f55e426aa6f zh: dc13c7e054eac9d1 -/desktop-recovery-and-diagnostics/contents: - en: bd20ab7e73c7a170 +/desktop-recovery-and-diagnostics/table-of-contents: + en: 8f3a2a84eaf575af zh: 3087306f10e7c2cf /desktop-recovery-and-diagnostics/recover-from-a-failure: - en: 1e7d530e4e4e23d2 - zh: 27055f1d0e597609 + en: 9d0645dba352979d + zh: e1556207a04d9a71 /desktop-recovery-and-diagnostics/report-contents: - en: b1d48255932c903c - zh: 7f84b4d141adc80b + en: 1a2eb71ac2dccf63 + zh: 5cf59639d3e49591 /desktop-recovery-and-diagnostics/recent-operation-records: - en: 6be041c1187e78de - zh: 52ef5c179a4bb337 + en: 8f4db0caa4b329f6 + zh: 952ae7d48896cb39 /desktop-recovery-and-diagnostics/collection-and-shutdown: en: bac4a49f63d4aaf8 zh: df1ae908ee2148e1 diff --git a/apps/desktop/docs/diagnostics/README.md b/apps/desktop/docs/diagnostics/README.md index 49ff4ec58c..6079d91578 100644 --- a/apps/desktop/docs/diagnostics/README.md +++ b/apps/desktop/docs/diagnostics/README.md @@ -1,3 +1,7 @@ +--- +description: "Desktop failure recovery, diagnostic reports, and shutdown limits." +--- + # Desktop recovery and diagnostics English | [中文](README.zh.md) @@ -6,7 +10,7 @@ English | [中文](README.zh.md) This reference describes Desktop native failure recovery, diagnostic reports, recent plugin operation records, and collection and shutdown limits. -## Contents +## Table of Contents - [Recover from a failure](#recovery) - [Report contents](#report-contents) @@ -17,7 +21,7 @@ This reference describes Desktop native failure recovery, diagnostic reports, re ## Recover from a failure -Fatal main-window creation, main-document loading, preload, renderer, Web initialization, or backend failures open one native recovery dialog per application process. It shows a bounded tail of the first error, notes any truncation, and offers Exit, Restart, Disable third-party plugins, back up profile patch, and restart, and Get diagnostic report while report writing is pending or after it succeeds. Startup failures retain the Web loading page and spinner; runtime failures retain the current page. Expected shutdowns, cancelled navigation, and ordinary requests do not trigger recovery. The shared Web plugin manager reports package-operation errors; Host startup failures after plugin changes enter native recovery. There is no startup timeout heuristic. A listener failure containing `listen EADDRINUSE` replaces the diagnostics and reinstall advice with guidance to quit other running DSH instances, and offers Exit, Restart, and Get diagnostic report while report writing is pending or after it succeeds. +Fatal main-window creation, main-document loading, preload, renderer, Web initialization, or backend failures open one native recovery dialog per application process. It shows a bounded tail of the first error, notes any truncation, and offers “Exit”, “Restart”, “Disable third-party plugins, back up profile patch, and restart”, and “Get diagnostic report” while report writing is pending or after it succeeds. Startup failures retain the Web loading page and spinner; runtime failures retain the current page. Expected shutdowns, cancelled navigation, and ordinary requests do not trigger recovery. The shared Web plugin manager reports package-operation errors; Host startup failures after plugin changes enter native recovery. There is no startup timeout heuristic. A listener failure containing `listen EADDRINUSE` replaces the diagnostics and reinstall advice with guidance to quit other running DSH instances, and offers “Exit”, “Restart”, and “Get diagnostic report” while report writing is pending or after it succeeds. Recovery uses the last confirmed Desktop language from the local cache before Host settings are available; an absent or invalid cache falls back to system languages. @@ -29,9 +33,9 @@ Recovery waits for Host shutdown before changing plugin activation. The native r Native dialog details include at most 1,200 UTF-16 code units and eight diagnostic lines, plus the crash report path or its pending/unavailable status. Retrieving a pending or unavailable report preserves any recovery-operation failure headline and details. Configuration paths and startup entries precede the composition inventory so plugin rows cannot displace them when output is truncated. Host error diagnostics retain only the last 64 Ki characters written to stderr. Earlier output is discarded so a long-running Host does not grow the shell’s diagnostic buffer indefinitely. -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. +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. The attachment contains the original failure; later recovery-operation errors remain in the dialog and require a separate screenshot. Retrieving a saved report shows its sharing or reveal-failure heading while retaining the recovery error details. If writing fails, the dialog hides Get diagnostic report and suggests providing a screenshot. -The file `crash--.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). +The file `crash--.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 and startup-entry details to 8 Ki characters each, with explicit truncation notices) 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. @@ -45,7 +49,7 @@ Host `transportDropped` is a lower bound as of the last successful delivery; Des 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. +Exit, recovery restart and installer preparation wait at most 500 ms for queued writes; a timeout disables further writes and later drains return immediately without another wait, 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. diff --git a/apps/desktop/docs/diagnostics/README.zh.md b/apps/desktop/docs/diagnostics/README.zh.md index 0e8a51aef8..c71a12ba47 100644 --- a/apps/desktop/docs/diagnostics/README.zh.md +++ b/apps/desktop/docs/diagnostics/README.zh.md @@ -1,3 +1,7 @@ +--- +description: "Desktop failure recovery, diagnostic reports, and shutdown limits." +--- + # Desktop 故障恢复与诊断 [English](README.md) | 中文 @@ -17,7 +21,7 @@ ## 遇到故障怎么办 -主窗口创建、主文档加载、preload、渲染器、Web 初始化或后端的致命失败,会在每个应用进程中打开一次原生恢复对话框。对话框显示首次错误末尾的限长摘要,标明截断情况,并提供退出、重启、禁用第三方插件、备份 profile patch 并重启,以及报告仍在保存或已保存时的获取诊断报告。启动失败保留 Web 加载页和动画;运行中失败保留当前页面。预期关闭、取消导航和普通请求错误不会触发恢复。共享 Web 插件管理器报告包操作错误;插件变更后的 Host 启动失败会进入原生恢复。不通过启动超时推断故障。 包含 `listen EADDRINUSE` 的监听失败以退出其他正在运行的 DSH 实例的提示替代诊断和重装建议,提供退出、重启,以及报告仍在保存或已保存时的获取诊断报告。 +主窗口创建、主文档加载、preload、渲染器、Web 初始化或后端的致命失败,会在每个应用进程中打开一次原生恢复对话框。对话框显示首次错误末尾的限长摘要,标明截断情况,并提供“退出”“重启”“禁用第三方插件、备份 profile patch 并重启”,以及报告仍在保存或已保存时的“获取诊断报告”。启动失败保留 Web 加载页和动画;运行中失败保留当前页面。预期关闭、取消导航和普通请求错误不会触发恢复。共享 Web 插件管理器报告包操作错误;插件变更后的 Host 启动失败会进入原生恢复。不通过启动超时推断故障。包含 `listen EADDRINUSE` 的监听失败以退出其他正在运行的 DSH 实例的提示替代诊断和重装建议,提供“退出”“重启”,以及报告仍在保存或已保存时的“获取诊断报告”。 Host 设置可用前,恢复对话框使用本地缓存中最后确认的 Desktop 语言;缓存不存在或无效时回退到系统语言。 @@ -29,9 +33,9 @@ Host 设置可用前,恢复对话框使用本地缓存中最后确认的 Deskt 原生弹窗详情最多包含 1,200 个 UTF-16 代码单元和八行诊断,并附上崩溃报告路径或仍在保存、无法保存的状态。获取仍在保存或无法保存的报告时,保留恢复操作失败的标题和详情。配置路径和启动条目排在组合清单之前,避免输出截断时被插件记录挤掉。Host 错误诊断仅保留 stderr 输出的最后 64 Ki 个字符。更早的输出会被丢弃,避免长期运行的 Host 使壳的诊断缓冲区无限增长。 -首个致命弹窗打开前,Electron 会向平台日志目录(`app.getPath('logs')`:macOS 为 `~/Library/Logs/DeepSeek Harness`,Windows 与 Linux 为应用 `userData` 目录下的 `logs`)写入一份崩溃报告,最多等待写入一秒;写入缓慢或失败时明确显示仍在保存或无法保存。“获取诊断报告”检查同一次写入,并在系统文件管理器中定位已保存的文件,不停止 Host 或重启;超过首次等待期限后写入完成的文件仍可获取。用户可将文件作为工单附件;写入失败时隐藏获取报告按钮,并提示提供窗口截图。 +首个致命弹窗打开前,Electron 会向平台日志目录(`app.getPath('logs')`:macOS 为 `~/Library/Logs/DeepSeek Harness`,Windows 与 Linux 为应用 `userData` 目录下的 `logs`)写入一份崩溃报告,最多等待写入一秒;写入缓慢或失败时明确显示仍在保存或无法保存。“获取诊断报告”检查同一次写入,并在系统文件管理器中定位已保存的文件,不停止 Host 或重启;超过首次等待期限后写入完成的文件仍可获取。用户可将文件作为工单附件。附件只包含最初的故障;后续恢复操作错误保留在对话框中,需要另行截图。获取已保存报告会显示分享或打开失败的标题,同时保留恢复错误详情。写入失败时隐藏获取报告按钮,并提示提供窗口截图。 -文件 `crash--.log` 记录来源(`host` 为 Host 退出、`web-boot` 为渲染进程启动失败、`renderer` 为渲染进程或文档失败、`main` 为壳自身错误)、后端是否已就绪、应用与运行时版本、包含可枚举属性与 cause 链的错误(截至 256 KiB)、Host 在退出前通过 IPC 报告启动失败时的错误及显式选择的启动元数据(最多 64 Ki 个字符,包含截断提示;启动错误的原始错误文本限制为 8 Ki 个字符,为元数据保留空间),以及主窗口最近的 error 级 console 输出(最多 64 KiB)。 +文件 `crash--.log` 记录来源(`host` 为 Host 退出、`web-boot` 为渲染进程启动失败、`renderer` 为渲染进程或文档失败、`main` 为壳自身错误)、后端是否已就绪、应用与运行时版本、包含可枚举属性与 cause 链的错误(截至 256 KiB)、Host 在退出前通过 IPC 报告启动失败时的错误及显式选择的启动元数据(最多 64 Ki 个字符,包含截断提示;启动错误的原始错误文本和启动条目详情分别限制为 8 Ki 个字符,并明确标注截断,为结构化元数据保留空间),以及主窗口最近的 error 级 console 输出(最多 64 KiB)。 因此 Host 退出报告包含保留的 stderr 尾部,其中可能含有插件输出。关闭过程中的致命失败只写报告、不弹窗。平台支持时文件仅所有者可读;启动时保留最新十份报告并删除更早的,不触碰目录中的其他文件。 @@ -45,7 +49,7 @@ Host 的 `transportDropped` 只表示最近一次成功发送时的计数下界 操作历史保留两个 256 KiB 文件段,单条记录不超过 4 KiB,排队写入不超过 64 KiB。 -退出、恢复重启和安装准备最多等待排队写入 500 ms;超时会停用后续写入,但已经发出的操作系统请求仍可能随后结束。 +退出、恢复重启和安装准备最多等待排队写入 500 ms;超时会停用后续写入,此后的排空调用立即返回,不会再次等待;但已经发出的操作系统请求仍可能随后结束。 记录覆盖 Host 启动后观察到的管理器操作,不包含直接执行的 CLI 命令或手工文件修改。包管理器输出保留在原有详细日志中,不复制到摘要。缺失记录、只有开始没有结束的操作以及损坏的历史,都不能说明操作已成功。此收集不诊断内核卡死、OOM 或原生崩溃。 diff --git a/apps/desktop/src/fatal-recovery.ts b/apps/desktop/src/fatal-recovery.ts index 6a6d0f55d7..8bfe76ab37 100644 --- a/apps/desktop/src/fatal-recovery.ts +++ b/apps/desktop/src/fatal-recovery.ts @@ -29,7 +29,9 @@ const DETAIL_BUDGET = 1200 /** Tail lines of the error kept in the dialog; the report file holds the rest. */ const DETAIL_TAIL_LINES = 8 -/** Error tail with a shortening notice when needed, report path or write status, and reinstall advice. */ +/** At most eight error-tail lines; status, advice and truncation notice share the detail character budget. + * The message heading has its own field. + */ function dialogDetail(error: string, messages: DesktopMessages, reportState: ReportState): string { const advice = `\n\n${messages.startupReinstallAdvice}` const report = reportLine(messages, reportState) diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index 595ba4ab6f..74a1f19941 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -595,10 +595,10 @@ export const es = { diagnosticTruncated: '… Detalles del error acortados.', reportWrittenTo: 'Informe de diagnóstico: {path}', getDiagnosticReport: 'Obtener informe de diagnóstico', - reportPending: 'El informe de diagnóstico se está guardando. Selecciona Obtener informe de diagnóstico para volver a comprobarlo.', - reportUnavailable: 'No se pudo guardar el informe de diagnóstico. Puedes proporcionar una captura de pantalla de este diálogo al solicitar asistencia.', - reportShareHint: 'Se ha abierto la ubicación del informe. Adjunta el archivo seleccionado a tu solicitud de asistencia.', - reportRevealFailed: 'No se pudo abrir la ubicación del informe. Usa la ruta que aparece a continuación para encontrar el archivo.', + reportPending: 'El informe de diagnóstico se está guardando. Seleccione Obtener informe de diagnóstico para volver a comprobarlo.', + reportUnavailable: 'No se pudo guardar el informe de diagnóstico. Puede proporcionar una captura de pantalla de este diálogo al solicitar asistencia.', + reportShareHint: 'Se ha abierto la ubicación del informe. Adjunte el archivo seleccionado a su solicitud de asistencia.', + reportRevealFailed: 'No se pudo abrir la ubicación del informe. Use la ruta que aparece a continuación para encontrar el archivo.', startupReinstallAdvice: 'Si faltan archivos de la aplicación o están dañados, ciérrela y vuelva a instalarla. Sus tareas se almacenan por separado.', exitApplication: 'Salir', restartApplication: 'Reiniciar', @@ -949,10 +949,10 @@ export const de = { diagnosticTruncated: '… Fehlerdetails gekürzt.', reportWrittenTo: 'Diagnosebericht: {path}', getDiagnosticReport: 'Diagnosebericht abrufen', - reportPending: 'Der Diagnosebericht wird noch gespeichert. Wähle Diagnosebericht abrufen, um es erneut zu prüfen.', - reportUnavailable: 'Der Diagnosebericht konnte nicht gespeichert werden. Du kannst bei einer Supportanfrage einen Screenshot dieses Dialogs bereitstellen.', - reportShareHint: 'Der Speicherort des Berichts wurde geöffnet. Füge die ausgewählte Datei deiner Supportanfrage bei.', - reportRevealFailed: 'Der Speicherort des Berichts konnte nicht geöffnet werden. Verwende den folgenden Pfad, um die Datei zu finden.', + reportPending: 'Der Diagnosebericht wird noch gespeichert. Wählen Sie „Diagnosebericht abrufen“, um ihn erneut zu prüfen.', + reportUnavailable: 'Der Diagnosebericht konnte nicht gespeichert werden. Sie können bei einer Supportanfrage einen Screenshot dieses Dialogs bereitstellen.', + reportShareHint: 'Der Speicherort des Berichts wurde geöffnet. Fügen Sie die ausgewählte Datei Ihrer Supportanfrage bei.', + reportRevealFailed: 'Der Speicherort des Berichts konnte nicht geöffnet werden. Verwenden Sie den folgenden Pfad, um die Datei zu finden.', startupReinstallAdvice: 'Wenn Anwendungsdateien fehlen oder beschädigt sind, schließen Sie die Anwendung und installieren Sie sie erneut. Ihre Aufgaben werden separat gespeichert.', exitApplication: 'Beenden', restartApplication: 'Neustart', diff --git a/apps/desktop/src/operation-journal.ts b/apps/desktop/src/operation-journal.ts index 321a78ab46..b72cc6baac 100644 --- a/apps/desktop/src/operation-journal.ts +++ b/apps/desktop/src/operation-journal.ts @@ -85,11 +85,12 @@ export class OperationJournal { /** * 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. + * @returns True after the queue settles successfully; false immediately when disabled, or on failure or deadline. * False does not confirm that an issued OS request has settled. */ async drain(timeoutMs: number): Promise { if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new RangeError('Invalid journal drain deadline') + if (this.disabled) return false let timer: ReturnType | undefined try { return await Promise.race([ diff --git a/apps/desktop/tests/fatal-recovery.spec.ts b/apps/desktop/tests/fatal-recovery.spec.ts index 87bb69ab3a..80f75c2cef 100644 --- a/apps/desktop/tests/fatal-recovery.spec.ts +++ b/apps/desktop/tests/fatal-recovery.spec.ts @@ -322,6 +322,7 @@ it.each(['pending', 'failed'] as const)('keeps the recovery failure visible when const last = operations.show.mock.calls[2]![0] expect(last.message).toBe(operations.messages().recoveryOperationFailed) expect(last.detail).toContain('profile is read-only') + if (state === 'failed') expect(last.buttons).not.toContain(operations.messages().getDiagnosticReport) expect(last.detail).toContain(state === 'pending' ? operations.messages().reportPending : operations.messages().reportUnavailable) expect(operations.revealReport).not.toHaveBeenCalled() expect(operations.writeReport).toHaveBeenCalledOnce() @@ -330,3 +331,21 @@ it.each(['pending', 'failed'] as const)('keeps the recovery failure visible when vi.useRealTimers() } }) + +it.each([false, true])('keeps the original attachment when revealing after a recovery failure (reveal fails=%s)', async (revealFails) => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const { operations, stopped, recovery } = fixture('en', async () => REPORT_PATH) + const original = new Error('original startup failure') + stopped.resolve(undefined) + operations.disablePlugins.mockRejectedValueOnce(new Error('profile is read-only')) + if (revealFails) operations.revealReport.mockRejectedValueOnce(new Error('file manager unavailable')) + operations.show.mockResolvedValueOnce({ response: 2, checkboxChecked: false }) + .mockResolvedValueOnce({ response: 3, checkboxChecked: false }) + .mockResolvedValueOnce({ response: 0, checkboxChecked: false }) + await recovery.report(original, 'host') + const last = operations.show.mock.calls[2]![0] + expect(last.message).toBe(revealFails ? operations.messages().reportRevealFailed : operations.messages().reportShareHint) + expect(last.detail).toContain('profile is read-only') + expect(operations.writeReport).toHaveBeenCalledExactlyOnceWith(original, 'host') + expect(operations.revealReport).toHaveBeenCalledExactlyOnceWith(REPORT_PATH) +}) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index e60acc3adc..f0b1a23423 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -5,7 +5,7 @@ import { WINDOWS_TITLEBAR_HEIGHT } from '../src/windows-layout.ts' import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import type { IpcMainInvokeEvent } from 'electron' import { join } from 'node:path' -import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import type { MenuItemConstructorOptions, MessageBoxOptions } from 'electron' import { DESKTOP_IPC, type DesktopUpdateState } from '../src/ipc.ts' @@ -2313,6 +2313,7 @@ describe('desktop main startup', () => { harness.hosts[0]!.onPluginOperation!(record) const file = join(harness.app.getPath('userData'), 'plugin-operations', 'operations.jsonl') await vi.waitFor(() => { expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual(record) }) + expect(existsSync(join(harness.app.getPath('logs'), 'plugin-operations'))).toBe(false) }) it('writes the Host\'s own diagnostic into a startup-phase crash report when the Host reports a fatal error before ready', async () => { diff --git a/apps/desktop/tests/operation-journal.spec.ts b/apps/desktop/tests/operation-journal.spec.ts index 949748c6cd..98e97d36a5 100644 --- a/apps/desktop/tests/operation-journal.spec.ts +++ b/apps/desktop/tests/operation-journal.spec.ts @@ -93,6 +93,10 @@ it('stops scheduling writes after a drain deadline without claiming the in-fligh expect(await draining).toBe(false) expect(journal.status()).toMatchObject({ disabled: true, queuedBytes: 16 }) expect(journal.enqueue('{"operation":2}')).toBe(false) + let repeated: boolean | undefined + void journal.drain(500).then((result) => { repeated = result }) + await vi.advanceTimersByTimeAsync(0) + expect(repeated).toBe(false) release.resolve(undefined) await journal.close() expect(await readdir(directory)).toEqual([]) diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index a7af3e52d1..006df92bd8 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -24,8 +24,8 @@ en: 65bf2834b81f87da zh: c864ac60a3005b99 /deepseek-ai-dsh-app-boot/use-this-package/profiles: - en: 0a1b5f4f40d835a7 - zh: d8f4b54160aa033b + en: a44af3e33f800634 + zh: 09664fce7f3ac92e /deepseek-ai-dsh-app-boot/use-this-package/previewing-the-effective-configuration: en: 646a0ccdd7881ad1 zh: e3696acb1573dd7b diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 88aa256c12..26faa52aeb 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -53,7 +53,7 @@ Before a profile imports a plugin, DSH checks its declared DSH requirement again Admission happens only at composition boundaries DSH owns, and a denied row is denied in the launcher's own copy of the composition: no profile patch layer, dependency manifest, or bundle list changes. `prepareProfilePatches` composes over the launcher's empty profile root and runs while the root Include is mounted and on every profile recomposition, so a denied plugin never imports its module. `prepareProfileEntries` does the same for a preset's rows. A denied ordinary row becomes a detached `disabled: true` row; a native group stays mounted and its denied children do not load; a native Include that reaches a denied plugin is omitted whole, because its file is never rewritten. A row denied by policy keeps its configured `disabled` value in the profile, and every refusal is reported with the package, versions, and risk. A bundle is not a row, so `loadProfileDirectory` checks each bundle's own DSH requirement when it loads the profile's bundle layers, at startup and on every recomposition; an incompatible bundle without an exemption is skipped like an unreadable one and listed in `skippedBundles`. These boundaries do not cover plugins another embedder mounts through its own `ctx.plugin` call. Two edits made directly to files during a session are judged only at the next recomposition or startup: a running plugin's own `package.json` DSH requirement, and an entry-list file the Loader reads itself, such as the launcher's root config or a nested `cordis:include` file. `--dump-config` reports the configured composition, so a denied plugin row still appears there while a denied bundle contributes no rows, and `--dump-config-schema` imports each composed module to read its schema; run it only against a profile whose plugins you already trust. -Exact-version exemptions live in the profile's own `compatibility.json`, not in its `package.json`, so an exemption write never touches the dependency manifest, the bundle list, or Cordis patch files. It maps exact `package-name@version` keys to lists of exact DSH runtime versions; neither a plugin upgrade nor a DSH upgrade inherits permission. A missing file means no exemptions. A malformed one never stops the profile from starting: records the reader accepts still apply, each rejected record is reported on stderr beside the plugin denials, and the file is then treated as read-only, so a grant or revocation refuses and asks the user to repair it by hand instead of overwriting their content. [Plugin Manager](../plugin-manager/README.md#version-compatibility-and-exemptions) owns the grant, revocation, and risk confirmation each change requires. +Exact-version exemptions live in the profile's own `compatibility.json`, not in its `package.json`, so an exemption write never touches the dependency manifest, the bundle list, or Cordis patch files. It maps exact `package-name@version` keys to lists of exact DSH runtime versions; neither a plugin upgrade nor a DSH upgrade inherits permission. A missing file means no exemptions. A malformed one never stops the profile from starting: records the reader accepts still apply, each rejected record is reported on stderr beside the plugin denials, and the file is then treated as read-only, so a grant or revocation refuses and asks the user to repair it by hand instead of overwriting their content. [Plugin Manager](../plugin-manager/README.md#version-compatibility-and-exemptions) owns the grant, revocation, and risk confirmation each change requires. `validatePluginVersionExemption(packageVersion, runtimeVersion)` validates canonical exact identities without granting permission; invalid identities throw. Your machine-local preferences also live in the Harness home: diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index c4eb872d95..57d3f16b00 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -53,7 +53,7 @@ profile 导入插件前,DSH 会将其声明的 DSH 版本要求与 `getDshRunt 检查只发生在 DSH 自己持有的组合入口,改写的是启动器自己的那份组合:profile 的 patch 层、依赖清单与组合包列表都不会改变。`prepareProfilePatches` 在启动器的空 profile 根之上组合,并在挂载根 Include 时以及每次 profile 重新组合时执行,因此被拒绝的插件永远不会导入其模块;`prepareProfileEntries` 对 preset 行做同样的事。被拒绝的普通行会变成游离的 `disabled: true` 行;原生 group 保持挂载,其被拒绝的子行不会加载;若某个原生 Include 会到达被拒绝的插件,则整体省略该 Include,因为它的文件不会被改写。被策略拒绝的行在 profile 中保留其配置的 `disabled` 值,每次拒绝都会报告包名、版本与风险。组合包本身不是行,因此 `loadProfileDirectory` 在启动和每次重新组合加载 profile 的组合包层时,检查每个组合包自己声明的 DSH 版本要求;没有豁免的不兼容组合包会像无法读取的组合包一样被跳过,并列入 `skippedBundles`。这些入口不覆盖其他嵌入方通过自己的 `ctx.plugin` 调用挂载的插件。会话中直接改文件的两类编辑只在下一次重新组合或启动时才被判定:正在运行的插件自己的 `package.json` DSH 版本要求,以及 Loader 自己读取的入口清单文件(如启动器的根配置或嵌套的 `cordis:include` 文件)。`--dump-config` 报告的是配置出的组合,因此被拒绝的插件行仍会出现在其中,而被拒绝的组合包不提供任何行;`--dump-config-schema` 会导入每个组合模块以读取其 schema,请只对已经信任其插件的 profile 运行。 -精确版本豁免保存在 profile 自己的 `compatibility.json` 中,而不是 `package.json`,因此写豁免不会触及依赖清单、组合包列表或 Cordis patch 文件。它把精确的 `package-name@version` 键映射到精确 DSH 运行时版本列表;插件升级和 DSH 升级都不继承授权。文件缺失表示没有豁免。文件损坏绝不会阻止 profile 启动:读取器接受的记录仍然生效,每条被拒绝的记录会与插件拒绝信息一起输出到 stderr,此后该文件被视为只读,因此授予或撤销会拒绝执行并要求用户手工修复,而不是覆盖用户的内容。[插件管理器](../plugin-manager/README.zh.md#version-compatibility-and-exemptions)负责每次变更所需的授权、撤销与风险确认。 +精确版本豁免保存在 profile 自己的 `compatibility.json` 中,而不是 `package.json`,因此写豁免不会触及依赖清单、组合包列表或 Cordis patch 文件。它把精确的 `package-name@version` 键映射到精确 DSH 运行时版本列表;插件升级和 DSH 升级都不继承授权。文件缺失表示没有豁免。文件损坏绝不会阻止 profile 启动:读取器接受的记录仍然生效,每条被拒绝的记录会与插件拒绝信息一起输出到 stderr,此后该文件被视为只读,因此授予或撤销会拒绝执行并要求用户手工修复,而不是覆盖用户的内容。[插件管理器](../plugin-manager/README.zh.md#version-compatibility-and-exemptions)负责每次变更所需的授权、撤销与风险确认。 `validatePluginVersionExemption(packageVersion, runtimeVersion)` 校验规范的精确身份而不授予权限;身份非法时抛出错误。 你的机器本地偏好同样位于 harness home 中: diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index b30896ebe0..b215c2007a 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -24,7 +24,7 @@ export { sanitizeProfile } from './profile-sanitize.ts' export { getDshRuntimeVersion, evaluatePluginCompatibility, pluginCompatibilityWarning, type PluginCompatibility } from './plugin-compatibility.ts' export { PROFILE_COMPATIBILITY_FILENAME, readProfileCompatibility, readProfileVersionExemptions, - setProfileVersionExemption, type ProfileCompatibility, + setProfileVersionExemption, validatePluginVersionExemption, type ProfileCompatibility, } from './profile-compatibility.ts' import { prepareProfilePatches } from './compatibility-preflight.ts' import { ProfileRuntimeResolution, type RuntimeResolution } from './profile.ts' diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml index 1caaffafb0..67a48b4ec1 100644 --- a/packages/boot/plugin-manager/README.i18n.yaml +++ b/packages/boot/plugin-manager/README.i18n.yaml @@ -27,8 +27,8 @@ en: 99b084ff497011d2 zh: 6c047796c1991784 /deepseek-ai-dsh-plugin-manager/understand-the-implementation: - en: e8faa7fd7ed9e6e3 - zh: a58daaab16511d5c + en: 03e1309c5ea3ce41 + zh: a771b3c7e2f7b8e6 /deepseek-ai-dsh-plugin-manager/further-exploration: en: c6538243d8702a0d zh: 04a82d923353c75f diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md index 7068562429..ed3cdcc0a5 100644 --- a/packages/boot/plugin-manager/README.md +++ b/packages/boot/plugin-manager/README.md @@ -127,7 +127,7 @@ A service run whose captured output stays silent for `idleTimeoutMs` is terminat Results contain the last attempted stage, target, saved-state change, application status and error codes. Web dictionaries render management text; pnpm and Loader diagnostics remain unmodified. Unrelated pre-existing inactive entries return warnings; new or changed failures and inactive explicit enablement targets fail the operation. A failed or cancelled package run, or failed bundle validation, restores the manifest and lockfile snapshotted before pnpm ran ([rationale](../../../.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md)); later failures retain saved changes as described in [Failure behavior](#failure-behavior). Installations are tracked by request id until their call settles, so a cancellation names one run and joins its settlement without taking the profile lock. The CLI inherits authentication variables and terminal descriptors; service operations use a scrubbed environment and captured output. -The `plugin-manager/operation` event correlates each service invocation with its start and observed result, or an unknown outcome when an exception escapes result construction. Installation targets retain only parsed registry package names; bundle toggles and removals require the exact package name without a version range or surrounding whitespace. Plugin targets retain only known entry IDs. Versions describe request-time observations before locking or the resolved installation; invalid or oversized metadata is omitted without losing the operation. Raw specs and error text are excluded. Subscribers run independently without delaying management; synchronous throws and asynchronous rejections do not starve later subscribers or change the result. Direct CLI package operations do not emit this service event. +The `plugin-manager/operation` event correlates each service invocation with its start and observed result, or an unknown outcome when an exception escapes result construction. Installation targets retain only parsed registry package names; bundle toggles and removals require the exact package name without a version range or surrounding whitespace. Plugin targets retain only known entry IDs. Exact names need not be installed. Version-exemption records retain the validated package name and exact exemption version, not the currently installed version. Versions describe request-time observations before locking or the resolved installation; invalid or oversized metadata is omitted without losing the operation. Raw specs and error text are excluded. Subscribers run independently without delaying management; synchronous throws and asynchronous rejections do not starve later subscribers or change the result. Direct CLI package operations do not emit this service event. The `@deepseek-ai/dsh-plugin-manager/diagnostic` entry supplies Desktop Host and shell readers with run IDs, operation records and a parser that retains only whitelisted fields. Malformed or oversized records are rejected; readers report omissions and truncation. The shell accepts records only for the run ID announced by its Host. Diagnostic consumers own persistence and delivery limits; missing records never imply successful operations. diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md index 0d6f4ed6fe..9af35405c7 100644 --- a/packages/boot/plugin-manager/README.zh.md +++ b/packages/boot/plugin-manager/README.zh.md @@ -127,7 +127,7 @@ service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止, 结果包含最后尝试的阶段、目标、磁盘变化、应用状态和错误码。Web 词典呈现管理文案;pnpm 与 Loader 的诊断保持原样。无关的已有故障作为警告返回;新出现、配置变化后的故障,以及显式启用目标未激活,都会使操作失败。包管理运行失败或被取消,或组合包校验失败时,会恢复 pnpm 运行前快照的 manifest 与 lockfile([理由](../../../.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md));后续失败按[失败行为](#failure-behavior)所述保留已保存的改动。安装按 request id 跟踪到调用结束,因此取消只针对一次运行,并且不取 profile 锁就能等待它结束。CLI 继承认证环境和终端描述符;service 使用清理后的环境并捕获输出。管理器直接读取文件和 Loader 状态,不维护第二份目标状态注册表,因此不发布单独的运行时不变式伴生入口。 -`plugin-manager/operation` 事件关联每次服务调用的开始和已观察到的结果;若异常使结果无法构造,则记录结果未知。安装目标仅保留解析出的 registry 包名;启停或移除包的目标必须与包名完全一致,不能含版本范围或首尾空格;插件目标仅保留已知条目 ID。版本表示加锁前请求时观察到的值或安装解析出的值;非法或过长的元数据被省略,不丢弃操作记录。事件不包含原始 spec 和错误文本。订阅者独立执行,不延迟管理操作;同步抛错和异步拒绝都不会阻断后续订阅者或改变结果。直接执行的 CLI 包操作不发送此服务事件。 +`plugin-manager/operation` 事件关联每次服务调用的开始和已观察到的结果;若异常使结果无法构造,则记录结果未知。安装目标仅保留解析出的 registry 包名;启停或移除包的目标必须与包名完全一致,不能含版本范围或首尾空格;插件目标仅保留已知条目 ID。精确包名不要求已安装。版本豁免记录保留已校验的包名和精确豁免版本,而不是当前安装版本。版本表示加锁前请求时观察到的值或安装解析出的值;非法或过长的元数据被省略,不丢弃操作记录。事件不包含原始 spec 和错误文本。订阅者独立执行,不延迟管理操作;同步抛错和异步拒绝都不会阻断后续订阅者或改变结果。直接执行的 CLI 包操作不发送此服务事件。 `@deepseek-ai/dsh-plugin-manager/diagnostic` 入口为 Desktop Host 和壳读取器提供运行 ID、操作记录及仅保留白名单字段的解析器。格式错误或超限记录会被拒绝;读取器报告遗漏和截断。壳仅接受其 Host 已声明运行 ID 对应的记录。诊断消费者负责持久化和传输限额;缺失记录不能说明操作成功。 diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index d1bcc9fda4..97e717f6a3 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -21,7 +21,7 @@ import { readPluginMeta, readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries, readProfilePatches, bundlePatchPaths, evaluatePluginCompatibility, readProfileCompatibility, readProfileVersionExemptions, - setProfileVersionExemption, PROFILE_COMPATIBILITY_FILENAME, + setProfileVersionExemption, validatePluginVersionExemption, PROFILE_COMPATIBILITY_FILENAME, getDshRuntimeVersion, ResolutionRestartRequired, } from '@deepseek-ai/dsh-app-boot' import { OPTIONAL_BUNDLES, OFFICIAL_ON_DEMAND_CATALOG, PROTECTED_PLUGINS } from '@deepseek-ai/dsh-official-bundles' @@ -285,6 +285,15 @@ function installOperationTarget(spec: string): string | null { return parsed.kind === 'registry' ? parsed.name : null } +/** Exemption facts use canonical package identity, never the raw request or installed version. */ +function exemptionOperationIdentity(packageVersion: string, runtimeVersion: string): Pick { + try { validatePluginVersionExemption(packageVersion, runtimeVersion) } + catch (_error) { return { target: null } /* The operation still owns validation failure reporting. */ } + const separator = packageVersion.lastIndexOf('@') + return { target: installOperationTarget(packageVersion.slice(0, separator)), + version: diagnosticVersion(packageVersion.slice(separator + 1)) } +} + /** Unusable file metadata must not discard the rest of an operation record. */ function diagnosticVersion(value: unknown): string | undefined { return typeof value === 'string' && value.length <= 128 ? value : undefined @@ -444,7 +453,7 @@ export class PluginManager extends TypertRemoteService { return this.change(result => this.configure(async () => { await setProfileVersionExemption(this.profile.dir, packageVersion, runtimeVersion, enabled, acceptRisk === true) Object.assign(result, await this.reload()) - }), { stage: 'enable', target: packageVersion, enabled }, 'bundle') + }), { stage: 'enable', target: packageVersion, enabled }, 'bundle', exemptionOperationIdentity(packageVersion, runtimeVersion)) } /** Read current plugins, including why a row cannot be changed through the profile patch. @@ -1227,10 +1236,11 @@ export class PluginManager extends TypertRemoteService { operation: (result: ChangeResult) => Promise, request: Pick, reason: PluginChange['reason'], + identity?: Pick, ): Promise { const id = randomUUID() as PluginOperationId - const target = this.operationTarget(reason, request.target) - const version = this.operationVersion(reason, reason === 'plugin' ? request.target : target) + const target = identity === undefined ? this.operationTarget(reason, request.target) : identity.target + const version = identity === undefined ? this.operationVersion(reason, reason === 'plugin' ? request.target : target) : identity.version const facts = { id, reason, stage: request.stage, target, ...version === undefined ? {} : { version }, diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts index a2348e98fa..66ab2b323e 100644 --- a/packages/boot/plugin-manager/tests/manager.spec.ts +++ b/packages/boot/plugin-manager/tests/manager.spec.ts @@ -1667,6 +1667,8 @@ it.each(['live', 'startup'] as const)('requires exact risk acknowledgement and p expect(await manager.setVersionExemption('extra@1.0.0', runtime, true)).toMatchObject({ changed: false, application: 'failed' }) expect(manager.listVersionExemptions()).toEqual({ exemptions: {}, warnings: [] }) expect(existsSync(join(dir, 'compatibility.json'))).toBe(false) + const facts: PluginOperation[] = [] + ctx.on('plugin-manager/operation', (event) => { facts.push(event) }) expect(await manager.setVersionExemption('extra@1.0.0', runtime, true, true)).toMatchObject({ changed: true, application: mode === 'live' ? 'applied' : 'restart-required', }) @@ -1679,6 +1681,12 @@ it.each(['live', 'startup'] as const)('requires exact risk acknowledgement and p if (mode === 'live') expect(managed()?.fiber?.state).toBe(2) else expect(managed()).toBeUndefined() expect(await manager.setVersionExemption('extra@1.0.0', runtime, false)).toMatchObject({ changed: true }) + expect(facts.map(({ phase, target, version }) => ({ phase, target, version }))).toEqual([ + { phase: 'started', target: 'extra', version: '1.0.0' }, + { phase: 'settled', target: 'extra', version: '1.0.0' }, + { phase: 'started', target: 'extra', version: '1.0.0' }, + { phase: 'settled', target: 'extra', version: '1.0.0' }, + ]) expect(manager.listVersionExemptions()).toEqual({ exemptions: {}, warnings: [] }) expect(JSON.parse(readFileSync(join(dir, 'compatibility.json'), 'utf8'))).toEqual({}) if (mode === 'live') expect(managed()).toBeUndefined() @@ -2167,3 +2175,37 @@ it('installs over the running version without HMR and reports the restart', asyn expect(steps).toEqual(['checking', 'installing']) expect(pnpm.mock.calls.map(call => call[1])).toEqual([['add', 'extra@1.2.0', '--save-exact']]) }) + +it.each(['extra@^1.0.0', ' extra@1.0.0 ', 'https://user:secret@example.com/pkg'])('omits invalid exemption identity for %s', async (input) => { + const { manager, ctx } = await fixture() + const facts: PluginOperation[] = [] + ctx.on('plugin-manager/operation', (event) => { facts.push(event) }) + expect(await manager.setVersionExemption(input, getDshRuntimeVersion(), true, true)).toMatchObject({ application: 'failed' }) + expect(facts).toHaveLength(2) + for (const fact of facts) { + expect(fact.target).toBeNull() + expect(fact.version).toBeUndefined() + } + expect(JSON.stringify(facts)).not.toContain(input) +}) + +it('retains exact uninstalled bundle names in operation records', async () => { + const { manager, ctx } = await fixture() + const facts: PluginOperation[] = [] + ctx.on('plugin-manager/operation', (event) => { facts.push(event) }) + await manager.setBundleEnabled('absent', false) + expect(facts).toHaveLength(2) + for (const fact of facts) { + expect(fact.target).toBe('absent') + expect(fact.version).toBeUndefined() + } +}) + +it.each(['extra', '@scope/extra'])('records the exemption version independently of installed metadata for %s', async (name) => { + const { manager, ctx } = await fixture() + const facts: PluginOperation[] = [] + ctx.on('plugin-manager/operation', (event) => { facts.push(event) }) + await manager.setVersionExemption(`${name}@2.0.0`, getDshRuntimeVersion(), false) + expect(facts).toHaveLength(2) + for (const fact of facts) expect(fact).toMatchObject({ target: name, version: '2.0.0', enabled: false }) +})