dsh-config-manager 0.1.78 → 0.1.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/adapters/credentials.d.ts +6 -2
- package/lib/adapters/credentials.js +19 -3
- package/lib/adapters/file-collection.js +2 -1
- package/lib/adapters/mcp.d.ts +48 -2
- package/lib/adapters/mcp.js +265 -3
- package/lib/adapters/messages.d.ts +3 -0
- package/lib/adapters/messages.js +7 -0
- package/lib/adapters/plugin-files.js +2 -1
- package/lib/adapters/plugins.js +105 -20
- package/lib/adapters/pnpm-workspace.d.ts +32 -0
- package/lib/adapters/pnpm-workspace.js +113 -2
- package/lib/adapters/prompts.d.ts +6 -2
- package/lib/adapters/prompts.js +17 -2
- package/lib/adapters/providers.d.ts +19 -2
- package/lib/adapters/providers.js +59 -3
- package/lib/adapters/settings.d.ts +6 -2
- package/lib/adapters/settings.js +13 -2
- package/lib/adapters/skills.d.ts +25 -1
- package/lib/adapters/skills.js +60 -0
- package/lib/adapters/ui.d.ts +6 -2
- package/lib/adapters/ui.js +13 -2
- package/lib/adapters/units.d.ts +18 -0
- package/lib/adapters/units.js +39 -0
- package/lib/adapters/workspaces.js +2 -1
- package/lib/client.d.ts +401 -112
- package/lib/client.js +2899 -1525
- package/lib/core/backup.js +20 -0
- package/lib/core/mcp-connector-store.d.ts +159 -0
- package/lib/core/mcp-connector-store.js +445 -0
- package/lib/core/messages-core.d.ts +2 -0
- package/lib/core/messages-core.js +4 -0
- package/lib/core/messages.d.ts +20 -0
- package/lib/core/messages.js +44 -0
- package/lib/core/phase3-host.js +18 -1
- package/lib/core/plugin-cli.d.ts +22 -2
- package/lib/core/plugin-cli.js +22 -3
- package/lib/core/recovery-orchestrator.d.ts +3 -1
- package/lib/core/recovery-orchestrator.js +75 -16
- package/lib/core/rollback-activity.d.ts +89 -0
- package/lib/core/rollback-activity.js +350 -0
- package/lib/core/rollback-ledger.d.ts +0 -9
- package/lib/core/rollback-ledger.js +37 -0
- package/lib/core/rollback-merge.d.ts +22 -2
- package/lib/core/rollback-merge.js +63 -13
- package/lib/core/rollback-point.d.ts +93 -0
- package/lib/core/rollback-point.js +420 -101
- package/lib/core/rollback-undo-journal.d.ts +123 -0
- package/lib/core/rollback-undo-journal.js +517 -0
- package/lib/core/self-update.js +2 -4
- package/lib/core/text-fidelity.d.ts +89 -0
- package/lib/core/text-fidelity.js +176 -0
- package/lib/core/types.d.ts +12 -3
- package/lib/core/update-check.js +2 -4
- package/lib/core/validator.d.ts +9 -0
- package/lib/core/validator.js +12 -0
- package/lib/foreign/bundle.js +42 -3
- package/lib/foreign/claude-code.js +358 -26
- package/lib/foreign/claude-sessions.d.ts +51 -8
- package/lib/foreign/claude-sessions.js +111 -13
- package/lib/foreign/read-claude-code.d.ts +33 -4
- package/lib/foreign/read-claude-code.js +446 -65
- package/lib/foreign/read-claude-plugins.d.ts +36 -0
- package/lib/foreign/read-claude-plugins.js +403 -0
- package/lib/foreign/registry.js +25 -2
- package/lib/foreign/truth-table.js +6 -4
- package/lib/foreign/types.d.ts +170 -2
- package/lib/index.d.ts +14 -5
- package/lib/index.js +60 -16
- package/lib/market/git-file-writer.js +7 -1
- package/lib/profiles/dsh-profile-launcher.d.ts +20 -0
- package/lib/profiles/dsh-profile-launcher.js +58 -1
- package/lib/profiles/dsh-profile-shared.d.ts +8 -2
- package/lib/profiles/dsh-profile-shared.js +8 -2
- package/lib/routes/foreign.js +8 -1
- package/lib/routes/index.d.ts +1 -1
- package/lib/routes/kit.d.ts +2 -2
- package/lib/routes/sync.js +173 -9
- package/lib/schema/manifest.d.ts +6 -1
- package/lib/schema/manifest.js +55 -0
- package/lib/schema/types.d.ts +47 -2
- package/lib/sync/ancestor.d.ts +22 -0
- package/lib/sync/ancestor.js +66 -2
- package/lib/sync/autosync-config.d.ts +73 -0
- package/lib/sync/autosync-config.js +89 -0
- package/lib/sync/autosync-scheduler.d.ts +80 -6
- package/lib/sync/autosync-scheduler.js +263 -40
- package/lib/sync/gist/gist-transport.d.ts +18 -1
- package/lib/sync/gist/gist-transport.js +42 -3
- package/lib/sync/gist/messages.d.ts +2 -0
- package/lib/sync/gist/messages.js +5 -0
- package/lib/sync/git/git-transport.d.ts +42 -3
- package/lib/sync/git/git-transport.js +146 -3
- package/lib/sync/merge.d.ts +47 -14
- package/lib/sync/merge.js +242 -222
- package/lib/sync/messages.d.ts +8 -0
- package/lib/sync/messages.js +19 -0
- package/lib/sync/risk.d.ts +19 -2
- package/lib/sync/risk.js +10 -3
- package/lib/sync/s3/messages.d.ts +6 -0
- package/lib/sync/s3/messages.js +13 -0
- package/lib/sync/s3/s3-transport.d.ts +9 -1
- package/lib/sync/s3/s3-transport.js +78 -6
- package/lib/sync/s3/sigv4.js +11 -2
- package/lib/sync/sync-engine.d.ts +212 -8
- package/lib/sync/sync-engine.js +514 -58
- package/lib/sync/sync-history.d.ts +11 -0
- package/lib/sync/sync-history.js +20 -0
- package/lib/sync/sync-state.d.ts +43 -0
- package/lib/sync/sync-state.js +44 -0
- package/lib/sync/sync-watcher.d.ts +76 -0
- package/lib/sync/sync-watcher.js +202 -0
- package/lib/sync/text-merge.d.ts +85 -0
- package/lib/sync/text-merge.js +513 -0
- package/lib/sync/transport.d.ts +48 -0
- package/lib/sync/transport.js +12 -0
- package/lib/sync/watch-scope.d.ts +38 -0
- package/lib/sync/watch-scope.js +95 -0
- package/lib/sync/webdav/webdav-transport.d.ts +16 -2
- package/lib/sync/webdav/webdav-transport.js +128 -11
- package/lib/ui/foreign-view.js +10 -0
- package/lib/ui/i18n.d.ts +9 -0
- package/lib/ui/i18n.js +22 -0
- package/lib/ui/rollback-view.d.ts +12 -0
- package/lib/ui/rollback-view.js +2 -0
- package/lib/ui/sync-settings-view.d.ts +47 -0
- package/lib/ui/sync-settings-view.js +24 -0
- package/lib/ui/types.d.ts +21 -0
- package/lib/utils/env-lock.d.ts +1 -0
- package/lib/utils/env-lock.js +7 -4
- package/package.json +6 -6
- package/src/adapters/credentials.ts +22 -4
- package/src/adapters/file-collection.ts +2 -1
- package/src/adapters/mcp.test.ts +235 -1
- package/src/adapters/mcp.ts +276 -5
- package/src/adapters/messages.ts +7 -0
- package/src/adapters/plugin-files.ts +2 -1
- package/src/adapters/plugins.test.ts +176 -2
- package/src/adapters/plugins.ts +107 -21
- package/src/adapters/pnpm-workspace.test.ts +89 -1
- package/src/adapters/pnpm-workspace.ts +127 -2
- package/src/adapters/prompts.ts +20 -3
- package/src/adapters/providers.ts +62 -4
- package/src/adapters/settings.ts +16 -3
- package/src/adapters/skills.test.ts +141 -0
- package/src/adapters/skills.ts +58 -2
- package/src/adapters/ui.ts +16 -3
- package/src/adapters/units-entries.test.ts +190 -0
- package/src/adapters/units.ts +50 -0
- package/src/adapters/workspaces.ts +2 -1
- package/src/client/about/PluginUpdateDialog.tsx +4 -2
- package/src/client/about/ReleaseNotesDialog.tsx +4 -2
- package/src/client/chat/RollbackReviewDialog.tsx +3 -2
- package/src/client/common/Skeleton.tsx +32 -0
- package/src/client/common/api-base.test.ts +36 -0
- package/src/client/common/http.test.ts +12 -1
- package/src/client/common/http.ts +8 -2
- package/src/client/common/routes.ts +46 -3
- package/src/client/config-manager.module.css +321 -11
- package/src/client/home/HomePanel.tsx +14 -6
- package/src/client/import/ForeignImportView.tsx +3 -1
- package/src/client/import/import-wizard-steps.tsx +2 -1
- package/src/client/library/ArtifactRow.tsx +12 -2
- package/src/client/library/LibraryActions.tsx +6 -3
- package/src/client/library/LibraryPanel.tsx +21 -3
- package/src/client/library/RestorePlanView.tsx +4 -2
- package/src/client/locales.ts +3 -0
- package/src/client/market/market-api.test.ts +3 -2
- package/src/client/recovery/RecoveryPanel.tsx +54 -7
- package/src/client/recovery/RollbackCard.tsx +7 -2
- package/src/client/recovery/RollbackDiffPane.tsx +3 -2
- package/src/client/recovery/recovery-locales.ts +20 -2
- package/src/client/recovery/recovery-view.test.ts +39 -1
- package/src/client/recovery/recovery-view.ts +23 -1
- package/src/client/run-store.test.ts +1 -1
- package/src/client/run-store.ts +4 -0
- package/src/client/sync/AutosyncCard.tsx +49 -6
- package/src/client/sync/MergeModal.tsx +220 -0
- package/src/client/sync/MergePreviewView.tsx +306 -0
- package/src/client/sync/SyncActivityBar.tsx +31 -0
- package/src/client/sync/SyncConfirmView.tsx +2 -1
- package/src/client/sync/SyncPanel.tsx +130 -87
- package/src/client/sync/SyncProbeRow.tsx +162 -0
- package/src/client/sync/SyncSettingsView.test.ts +27 -2
- package/src/client/sync/WorkDirCard.tsx +44 -6
- package/src/client/sync/history-model.ts +4 -1
- package/src/client/sync/merge-view.test.ts +248 -0
- package/src/client/sync/merge-view.ts +398 -0
- package/src/client/sync/probe-view.test.ts +90 -0
- package/src/client/sync/probe-view.ts +63 -0
- package/src/client/sync/remote-device-view.test.ts +35 -0
- package/src/client/sync/remote-device-view.ts +25 -0
- package/src/client/sync/sync-api.test.ts +5 -3
- package/src/client/sync/sync-api.ts +212 -2
- package/src/client/sync/sync-entry.test.ts +78 -0
- package/src/client/sync/sync-entry.ts +53 -0
- package/src/client/sync/sync-locales.ts +234 -2
- package/src/client/sync/sync-view.test.ts +18 -1
- package/src/client/sync/sync-view.ts +20 -0
- package/src/core/backup.test.ts +41 -0
- package/src/core/backup.ts +20 -0
- package/src/core/mcp-connector-store.test.ts +265 -0
- package/src/core/mcp-connector-store.ts +516 -0
- package/src/core/messages-core.ts +4 -0
- package/src/core/messages.ts +46 -0
- package/src/core/phase3-host.ts +18 -1
- package/src/core/phase3-production-integration.test.ts +4 -0
- package/src/core/plugin-cli.test.ts +26 -0
- package/src/core/plugin-cli.ts +30 -4
- package/src/core/recovery-orchestrator.test.ts +104 -5
- package/src/core/recovery-orchestrator.ts +74 -17
- package/src/core/rollback-activity.test.ts +311 -0
- package/src/core/rollback-activity.ts +367 -0
- package/src/core/rollback-cross-session.test.ts +694 -0
- package/src/core/rollback-interrupted.test.ts +137 -0
- package/src/core/rollback-ledger.ts +32 -0
- package/src/core/rollback-merge.test.ts +130 -0
- package/src/core/rollback-merge.ts +78 -14
- package/src/core/rollback-point.ts +468 -91
- package/src/core/rollback-undo-journal.test.ts +508 -0
- package/src/core/rollback-undo-journal.ts +571 -0
- package/src/core/self-update.ts +2 -5
- package/src/core/text-fidelity.test.ts +189 -0
- package/src/core/text-fidelity.ts +197 -0
- package/src/core/types.ts +11 -3
- package/src/core/update-check.ts +2 -5
- package/src/core/validator.ts +14 -0
- package/src/foreign/bundle-origin.test.ts +265 -0
- package/src/foreign/bundle.ts +45 -5
- package/src/foreign/claude-code.test.ts +268 -0
- package/src/foreign/claude-code.ts +406 -25
- package/src/foreign/claude-plugins.test.ts +234 -0
- package/src/foreign/claude-sessions.test.ts +141 -0
- package/src/foreign/claude-sessions.ts +179 -23
- package/src/foreign/file-budget.test.ts +4 -1
- package/src/foreign/read-claude-code.test.ts +320 -0
- package/src/foreign/read-claude-code.ts +490 -71
- package/src/foreign/read-claude-plugins.ts +465 -0
- package/src/foreign/registry.ts +25 -2
- package/src/foreign/session-ir.test.ts +65 -0
- package/src/foreign/truth-table.test.ts +81 -0
- package/src/foreign/truth-table.ts +6 -3
- package/src/foreign/types.ts +162 -2
- package/src/index.facade.test.ts +26 -4
- package/src/index.ts +76 -19
- package/src/market/git-file-writer.test.ts +4 -0
- package/src/market/git-file-writer.ts +7 -1
- package/src/profiles/dsh-profile-launcher.test.ts +68 -3
- package/src/profiles/dsh-profile-launcher.ts +56 -1
- package/src/profiles/dsh-profile-shared.ts +8 -2
- package/src/routes/foreign.ts +8 -1
- package/src/routes/index.ts +1 -1
- package/src/routes/kit.ts +2 -2
- package/src/routes/sync.ts +176 -9
- package/src/schema/manifest-schema.test.ts +87 -2
- package/src/schema/manifest.ts +57 -1
- package/src/schema/types.ts +45 -2
- package/src/sync/ancestor.ts +76 -2
- package/src/sync/autosync-config.test.ts +73 -1
- package/src/sync/autosync-config.ts +124 -0
- package/src/sync/autosync-risk-tier.test.ts +38 -2
- package/src/sync/autosync-scheduler.test.ts +264 -12
- package/src/sync/autosync-scheduler.ts +296 -44
- package/src/sync/gist/gist-conditional-write.test.ts +203 -0
- package/src/sync/gist/gist-transport.ts +53 -5
- package/src/sync/gist/messages.ts +5 -0
- package/src/sync/git/git-probe.test.ts +80 -0
- package/src/sync/git/git-transport.test.ts +185 -0
- package/src/sync/git/git-transport.ts +178 -5
- package/src/sync/merge.test.ts +188 -55
- package/src/sync/merge.ts +282 -224
- package/src/sync/messages.ts +19 -0
- package/src/sync/risk.ts +29 -5
- package/src/sync/s3/messages.ts +13 -0
- package/src/sync/s3/s3-conditional-write.test.ts +330 -0
- package/src/sync/s3/s3-transport.test.ts +2 -1
- package/src/sync/s3/s3-transport.ts +87 -9
- package/src/sync/s3/sigv4.test.ts +4 -1
- package/src/sync/s3/sigv4.ts +11 -2
- package/src/sync/sync-device-identity.test.ts +267 -0
- package/src/sync/sync-engine.test.ts +410 -19
- package/src/sync/sync-engine.ts +602 -57
- package/src/sync/sync-history.test.ts +16 -1
- package/src/sync/sync-history.ts +26 -0
- package/src/sync/sync-state.test.ts +64 -0
- package/src/sync/sync-state.ts +79 -0
- package/src/sync/sync-watcher.test.ts +197 -0
- package/src/sync/sync-watcher.ts +246 -0
- package/src/sync/text-merge.test.ts +358 -0
- package/src/sync/text-merge.ts +554 -0
- package/src/sync/transport.ts +57 -0
- package/src/sync/watch-scope.test.ts +122 -0
- package/src/sync/watch-scope.ts +120 -0
- package/src/sync/webdav/webdav-conditional-write.test.ts +360 -0
- package/src/sync/webdav/webdav-transport.ts +137 -14
- package/src/ui/foreign-view.test.ts +34 -0
- package/src/ui/foreign-view.ts +10 -0
- package/src/ui/i18n.ts +22 -0
- package/src/ui/rollback-view.ts +14 -0
- package/src/ui/sync-settings-view.test.ts +28 -1
- package/src/ui/sync-settings-view.ts +43 -0
- package/src/ui/types.ts +22 -0
- package/src/utils/env-lock.ts +8 -5
|
@@ -130,6 +130,10 @@ test('P0-A:runExternalIntent 记录 external step + COMMITTED', async (t) => {
|
|
|
130
130
|
assert.ok(j);
|
|
131
131
|
assert.equal(j!.steps['ext']?.external, true);
|
|
132
132
|
assert.equal(j!.state, 'COMMITTED');
|
|
133
|
+
// 成功路径必须清算 external step:否则 journal 会以「COMMITTED + step 永远 planned」落盘,
|
|
134
|
+
// 事后读台账的人无法区分「已成功执行」与「根本没跑」(本机 0.1.78 事故现场正是这种形态)。
|
|
135
|
+
assert.equal(j!.steps['ext']?.status, 'done', '外部步骤成功后必须标 done');
|
|
136
|
+
assert.equal(typeof j!.steps['ext']?.appliedAt, 'string', '外部步骤成功后必须有 appliedAt');
|
|
133
137
|
assert.deepEqual(await recovery.store.scanActive(), []);
|
|
134
138
|
});
|
|
135
139
|
|
|
@@ -6,6 +6,7 @@ import test from 'node:test';
|
|
|
6
6
|
import assert from 'node:assert/strict';
|
|
7
7
|
import {
|
|
8
8
|
classifyDshPluginFailure, dshArgv, installAnchorFromProfileContext, installErrorFor, isTransientDshPluginFailure,
|
|
9
|
+
pluginInstallTarget,
|
|
9
10
|
profileNameFromArgv, profileNameFromDir, profileNameFromProfileContext,
|
|
10
11
|
resolveProcessProfileName, resolveProfileNameFromEnv,
|
|
11
12
|
type DshPluginResult,
|
|
@@ -199,6 +200,31 @@ const CARRIER: DesktopCarrier = {
|
|
|
199
200
|
env: { ELECTRON_RUN_AS_NODE: '1' },
|
|
200
201
|
};
|
|
201
202
|
|
|
203
|
+
test('pluginInstallTarget:安装目标决策表(issue #101 空转修复)', () => {
|
|
204
|
+
const cases: { name: string; pkg: string; input: { version?: string | undefined; spec?: string | undefined; tarballPath?: string | undefined }; expected: string }[] = [
|
|
205
|
+
{ name: '备份内 tarball 优先于版本与 spec', pkg: 'p', input: { version: '1.0.0', spec: '^2.0.0', tarballPath: '/h/plugins/p.tgz' }, expected: 'file:/h/plugins/p.tgz' },
|
|
206
|
+
{ name: '空 tarball 视为没有(回落 spec)', pkg: 'p', input: { tarballPath: '', spec: 'github:u/r' }, expected: 'github:u/r' },
|
|
207
|
+
{ name: '非 registry 来源(github:)原样,手里的 version 不再拼', pkg: 'p', input: { version: '1.0.0', spec: 'github:u/r' }, expected: 'github:u/r' },
|
|
208
|
+
{ name: '非 registry 来源(file:)原样', pkg: 'p', input: { spec: 'file:../local-p' }, expected: 'file:../local-p' },
|
|
209
|
+
{
|
|
210
|
+
name: '具体 semver → 精确版本(修复升级空转)',
|
|
211
|
+
pkg: 'dsh-free-search',
|
|
212
|
+
input: { version: '0.9.5', spec: '0.9.3' },
|
|
213
|
+
expected: 'dsh-free-search@0.9.5',
|
|
214
|
+
},
|
|
215
|
+
{ name: '预发布版本同样精确', pkg: 'p', input: { version: '1.0.0-rc.2' }, expected: 'p@1.0.0-rc.2' },
|
|
216
|
+
{ name: '带 build 元数据同样精确', pkg: 'p', input: { version: '1.0.0+build.7' }, expected: 'p@1.0.0+build.7' },
|
|
217
|
+
{ name: 'version 不是具体版本 → 落到 spec', pkg: 'p', input: { version: 'latest', spec: '^1.2.3' }, expected: 'p@^1.2.3' },
|
|
218
|
+
{ name: 'registry 区间 spec 带包名(手交给 pnpm 解析)', pkg: 'dshmarket', input: { spec: '^1.66.38' }, expected: 'dshmarket@^1.66.38' },
|
|
219
|
+
{ name: 'dist-tag spec 带包名', pkg: 'p', input: { spec: 'next' }, expected: 'p@next' },
|
|
220
|
+
{ name: '旧备份:version / spec 全缺 → 裸包名(改造前行为)', pkg: 'p', input: {}, expected: 'p' },
|
|
221
|
+
{ name: '空字符串按缺省处理', pkg: 'p', input: { version: '', spec: '' }, expected: 'p' },
|
|
222
|
+
];
|
|
223
|
+
for (const c of cases) {
|
|
224
|
+
assert.equal(pluginInstallTarget(c.pkg, c.input), c.expected, c.name);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
202
228
|
test('dshArgv:desktop 档案走桌面端 CLI 载体(普通 dsh CLI 对它硬拒绝)', () => {
|
|
203
229
|
const argv = dshArgv('desktop', CARRIER);
|
|
204
230
|
assert.equal(argv.file, CARRIER.execPath, '必须用 Electron 主程序(Node 模式)启动载体');
|
package/src/core/plugin-cli.ts
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
* 只依赖 node 内置模块,零 DSH 运行时依赖(M5 的 TDD 可直接单测本模块)。
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { engineMsg } from './messages-core.ts'
|
|
18
|
+
import { engineMsg } from './messages-core.ts'
|
|
19
|
+
import { isConcreteVersion } from './validator.ts';
|
|
19
20
|
import { spawn } from 'node:child_process'
|
|
20
21
|
import type { ChildProcess, SpawnOptions } from 'node:child_process'
|
|
21
22
|
import { readFileSync, rmSync } from 'node:fs'
|
|
@@ -414,13 +415,38 @@ const INSTALL_TIMEOUT_MS = Number(process.env.DSH_CONFIG_MANAGER_INSTALL_TIMEOUT
|
|
|
414
415
|
/**
|
|
415
416
|
* 非 registry 来源的依赖 spec 前缀:这些来源必须把 spec 原样交给 pnpm add,
|
|
416
417
|
* 裸包名在 registry 上查不到(fetch-404 → 幽灵依赖拖垮整个 profile 的安装)。
|
|
417
|
-
*
|
|
418
|
+
* registry 包由 `pluginInstallTarget()` 拼精确版本或「包名@spec」(issue #101)。
|
|
418
419
|
*/
|
|
419
420
|
const NON_REGISTRY_SPEC = /^(github:|gitlab:|bitbucket:|git\+|file:|link:|workspace:|https?:)/i
|
|
420
421
|
|
|
421
|
-
/**
|
|
422
|
-
export
|
|
422
|
+
/** `pluginInstallTarget()` 的输入:全部来自备份里的 `PluginEntry`(都可缺省 = 旧备份)。 */
|
|
423
|
+
export interface PluginInstallTargetInput {
|
|
424
|
+
/** 备份记录的实际安装版本(`PluginEntry.version`):具体 semver 时精确安装到它(issue #101)。 */
|
|
425
|
+
version?: string | undefined
|
|
426
|
+
/** 备份记录的声明依赖 spec(`PluginEntry.spec`):非 registry 来源原样,registry 区间带包名。 */
|
|
427
|
+
spec?: string | undefined
|
|
428
|
+
/** 备份内自带 tarball 解包后的绝对路径:优先于 spec(T1 本地源插件迁移)。 */
|
|
429
|
+
tarballPath?: string | undefined
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* 安装目标:可直接交给 `dsh plugin add` 的**完整参数**(issue #101)。
|
|
434
|
+
*
|
|
435
|
+
* 为什么不能只传裸包名:pnpm add 会尊重目标机 `package.json` 里既有的 spec —— 固定版不动、
|
|
436
|
+
* 已经满足的区间也不动,于是「备份里记着 0.9.5、目标机装着 0.9.3」的升级会**空转却报成功**。
|
|
437
|
+
* 规则按优先级排列(`src/core/plugin-cli.test.ts` 表驱动钉住):
|
|
438
|
+
* ① 备份内 tarball → `file:<abs>`(换机后原绝对路径必然失效);
|
|
439
|
+
* ② 非 registry 来源(github:/git+/file:/link:)→ spec 原样(裸包名在 registry 上查不到);
|
|
440
|
+
* ③ `version` 是具体 semver → `<pkg>@<version>`(精确版本,pnpm 无法被既有 spec 挡住);
|
|
441
|
+
* ④ 其余 spec(`^1.2.3` / dist-tag)→ `<pkg>@<spec>`(交给 pnpm 解析,至少不会退回裸包名);
|
|
442
|
+
* ⑤ 什么都没有(旧备份 / 无版本记录)→ 裸包名(npm 最新版,改造前行为)。
|
|
443
|
+
*/
|
|
444
|
+
export function pluginInstallTarget(pkg: string, input: PluginInstallTargetInput = {}): string {
|
|
445
|
+
const { version, spec, tarballPath } = input
|
|
446
|
+
if (tarballPath !== undefined && tarballPath !== '') return `file:${tarballPath}`
|
|
423
447
|
if (spec !== undefined && spec !== '' && NON_REGISTRY_SPEC.test(spec)) return spec
|
|
448
|
+
if (version !== undefined && isConcreteVersion(version)) return `${pkg}@${version}`
|
|
449
|
+
if (spec !== undefined && spec !== '') return `${pkg}@${spec}`
|
|
424
450
|
return pkg
|
|
425
451
|
}
|
|
426
452
|
|
|
@@ -16,7 +16,9 @@ import path from 'node:path';
|
|
|
16
16
|
|
|
17
17
|
import { createRecoveryOrchestrator } from './recovery-orchestrator.ts';
|
|
18
18
|
import type { RecoveryOrchestratorDeps } from './recovery-orchestrator.ts';
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
JournalStore, createJournalEntry, generateOperationId, transitionJournalState,
|
|
21
|
+
} from './journal.ts';
|
|
20
22
|
import { RunRegistry } from './run-registry.ts';
|
|
21
23
|
import { nullLogger } from '../utils/logger.ts';
|
|
22
24
|
import { zhMsg } from './messages.ts';
|
|
@@ -32,15 +34,22 @@ async function makeOrchestrator(opts: {
|
|
|
32
34
|
lockState?: string;
|
|
33
35
|
lockDetail?: string;
|
|
34
36
|
lockThrows?: boolean;
|
|
37
|
+
lockOwner?: string;
|
|
35
38
|
recoverResult?: { ok: boolean; removed: boolean; state: string; detail?: string };
|
|
36
|
-
} = {}): Promise<{
|
|
39
|
+
} = {}): Promise<{
|
|
40
|
+
orchestrator: ReturnType<typeof createRecoveryOrchestrator>;
|
|
41
|
+
spy: RecoverSpy;
|
|
42
|
+
/** 供用例预置 active journal(模拟「正在执行中的操作」)。 */
|
|
43
|
+
store: JournalStore;
|
|
44
|
+
}> {
|
|
37
45
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-recovery-orch-'));
|
|
38
46
|
const spy: RecoverSpy = {
|
|
39
47
|
calls: 0,
|
|
40
48
|
result: opts.recoverResult ?? { ok: true, removed: true, state: 'STALE_LOCK_DETECTED', detail: '已移除 stale ownership (op=autosync, pid=24140)' },
|
|
41
49
|
};
|
|
50
|
+
const store = new JournalStore({ transactionsDir: path.join(dir, 'transactions') });
|
|
42
51
|
const deps: RecoveryOrchestratorDeps = {
|
|
43
|
-
store
|
|
52
|
+
store,
|
|
44
53
|
runs: new RunRegistry(),
|
|
45
54
|
snapshotsDir: path.join(dir, 'snapshots'),
|
|
46
55
|
host: { log: nullLogger() } as unknown as HostContext,
|
|
@@ -50,14 +59,18 @@ async function makeOrchestrator(opts: {
|
|
|
50
59
|
clearSafeMode: async () => undefined,
|
|
51
60
|
inspectLockState: async () => {
|
|
52
61
|
if (opts.lockThrows === true) throw new Error('probe failed');
|
|
53
|
-
return {
|
|
62
|
+
return {
|
|
63
|
+
state: opts.lockState ?? 'FREE',
|
|
64
|
+
...(opts.lockDetail !== undefined ? { detail: opts.lockDetail } : {}),
|
|
65
|
+
...(opts.lockOwner !== undefined ? { ownerInstanceId: opts.lockOwner } : {}),
|
|
66
|
+
};
|
|
54
67
|
},
|
|
55
68
|
recoverStaleLock: async () => {
|
|
56
69
|
spy.calls += 1;
|
|
57
70
|
return spy.result;
|
|
58
71
|
},
|
|
59
72
|
};
|
|
60
|
-
return { orchestrator: createRecoveryOrchestrator(deps), spy };
|
|
73
|
+
return { orchestrator: createRecoveryOrchestrator(deps), spy, store };
|
|
61
74
|
}
|
|
62
75
|
|
|
63
76
|
// ---------- ① 纯锁残留场景:incidents 为空,但锁事项必须可见 ----------
|
|
@@ -190,3 +203,89 @@ test('源码守卫:/recovery/lock/recover 分支先于 operationId 解析,
|
|
|
190
203
|
assert.ok(ctorBody.includes('inspectLockState:'), 'createRecoveryOrchestrator 必须注入 inspectLockState');
|
|
191
204
|
assert.ok(ctorBody.includes('recoverStaleLock:'), 'createRecoveryOrchestrator 必须注入 recoverStaleLock');
|
|
192
205
|
});
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
// ---------- ④ 正在正常执行的操作 vs 未完成的操作(本轮修复的核心口径) ----------
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* 事故现场(2026-10-21,本机 0.1.78):autosync push 的 journal 在执行期间存在于
|
|
212
|
+
* `transactions/active/`(CREATED→APPLYING),status() 把它当 incident 上报 ⇒
|
|
213
|
+
* 面板显示「需要人工处理 + 无可信快照」,顶部冒出「已进入安全模式」,「执行恢复」永久灰;
|
|
214
|
+
* 唯一出口 dismiss 又要 GLOBAL 锁,而锁正被同一个操作持有 ⇒ 点一次 423 一次。
|
|
215
|
+
* 下面四条把「什么算正在跑」钉死。
|
|
216
|
+
*/
|
|
217
|
+
|
|
218
|
+
const LIVE_OWNER = '44c2454fe7c1865e8771e294a5955e20';
|
|
219
|
+
|
|
220
|
+
/** 预置一条 active journal(默认 CREATED,模拟刚开始的外部操作)。 */
|
|
221
|
+
async function seedActiveJournal(
|
|
222
|
+
store: JournalStore,
|
|
223
|
+
ownerInstanceId: string,
|
|
224
|
+
opts: { operationType?: string; needsAttention?: boolean } = {},
|
|
225
|
+
): Promise<string> {
|
|
226
|
+
await store.ensureDirs();
|
|
227
|
+
const operationId = generateOperationId();
|
|
228
|
+
const now = new Date().toISOString();
|
|
229
|
+
await store.create(createJournalEntry(opts.operationType ?? 'autosync-push', {
|
|
230
|
+
operationId, ownerInstanceId, lockId: ownerInstanceId,
|
|
231
|
+
packageVersion: '0.1.78', environmentFingerprint: 'fp-test',
|
|
232
|
+
}, now));
|
|
233
|
+
if (opts.needsAttention === true) {
|
|
234
|
+
await store.update(operationId, (j) => transitionJournalState(j, 'NEEDS_ATTENTION'));
|
|
235
|
+
}
|
|
236
|
+
return operationId;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
test('status:锁持有者 == journal.ownerInstanceId(正在跑)→ 不进 incidents,单列进 live', async () => {
|
|
240
|
+
const { orchestrator, store } = await makeOrchestrator({ lockState: 'LOCKED', lockOwner: LIVE_OWNER });
|
|
241
|
+
const opId = await seedActiveJournal(store, LIVE_OWNER);
|
|
242
|
+
const r = await orchestrator.status();
|
|
243
|
+
assert.equal(r.status, 200);
|
|
244
|
+
assert.deepEqual(r.body['incidents'], [], '正在正常执行的操作不是「未完成的操作」——进 incidents 就会伪造假事故与假 SAFE MODE 横幅');
|
|
245
|
+
const live = r.body['live'] as Array<Record<string, unknown>>;
|
|
246
|
+
assert.equal(live.length, 1);
|
|
247
|
+
assert.equal(live[0]!['operationId'], opId);
|
|
248
|
+
assert.equal(live[0]!['operationType'], 'autosync-push');
|
|
249
|
+
assert.equal(live[0]!['state'], 'CREATED');
|
|
250
|
+
assert.ok(typeof live[0]!['startedAt'] === 'string', 'live 必须带开始时间(面板要显示它跑了多久)');
|
|
251
|
+
// 锁仍是 LOCKED(活跃持有)→ attention=false,不催用户去「回收」一把活锁
|
|
252
|
+
assert.deepEqual(r.body['lock'], { state: 'LOCKED', attention: false });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('status:journal.ownerInstanceId 与锁持有者不一致 → 仍按 incident 上报(保守)', async () => {
|
|
256
|
+
const { orchestrator, store } = await makeOrchestrator({ lockState: 'LOCKED', lockOwner: 'someone-else' });
|
|
257
|
+
await seedActiveJournal(store, LIVE_OWNER);
|
|
258
|
+
const r = await orchestrator.status();
|
|
259
|
+
const incidents = r.body['incidents'] as Array<Record<string, unknown>>;
|
|
260
|
+
assert.equal(incidents.length, 1, '归属不一致就不能假定它正在跑(可能是崩溃残骸)');
|
|
261
|
+
assert.equal(incidents[0]!['operationType'], 'autosync-push');
|
|
262
|
+
assert.deepEqual(r.body['live'], []);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test('status:锁未持有(FREE)→ 同一 journal 仍按 incident 上报(无人能证明它还在跑)', async () => {
|
|
266
|
+
const { orchestrator, store } = await makeOrchestrator({ lockState: 'FREE' });
|
|
267
|
+
await seedActiveJournal(store, LIVE_OWNER);
|
|
268
|
+
const r = await orchestrator.status();
|
|
269
|
+
assert.equal((r.body['incidents'] as unknown[]).length, 1);
|
|
270
|
+
assert.deepEqual(r.body['live'], []);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test('status:NEEDS_ATTENTION 即使归属匹配也不得被吞(它是明确的「需人工处理」)', async () => {
|
|
274
|
+
const { orchestrator, store } = await makeOrchestrator({ lockState: 'LOCKED', lockOwner: LIVE_OWNER });
|
|
275
|
+
await seedActiveJournal(store, LIVE_OWNER, { needsAttention: true });
|
|
276
|
+
const r = await orchestrator.status();
|
|
277
|
+
const incidents = r.body['incidents'] as Array<Record<string, unknown>>;
|
|
278
|
+
assert.equal(incidents.length, 1);
|
|
279
|
+
assert.equal(incidents[0]!['state'], 'NEEDS_ATTENTION');
|
|
280
|
+
assert.deepEqual(r.body['live'], [], '需人工处理的事项绝不能伪装成「正在跑」');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('dismiss:正在跑的操作 → 409 operation-in-flight(不 quarantine 在跑的 journal)', async () => {
|
|
284
|
+
const { orchestrator, store } = await makeOrchestrator({ lockState: 'LOCKED', lockOwner: LIVE_OWNER });
|
|
285
|
+
const opId = await seedActiveJournal(store, LIVE_OWNER);
|
|
286
|
+
const r = await orchestrator.dismiss(opId, true);
|
|
287
|
+
assert.equal(r.status, 409);
|
|
288
|
+
assert.equal(r.body['code'], 'operation-in-flight');
|
|
289
|
+
const active = await store.scanActive();
|
|
290
|
+
assert.deepEqual(active, [opId], '拒绝时必须原地不动:把在跑的 journal 挪走会让执行方后续 transition 失败');
|
|
291
|
+
});
|
|
@@ -76,9 +76,10 @@ export interface RecoveryOrchestratorDeps {
|
|
|
76
76
|
/**
|
|
77
77
|
* 只读环境锁状态(issue #31)。**由宿主注入而非本模块 import**:本模块不依赖 env-lock,
|
|
78
78
|
* 无锁环境(测试 mock 端口)可返回保守的 UNKNOWN_STATE。
|
|
79
|
-
* detail 含 owner pid/op
|
|
79
|
+
* detail / ownerInstanceId 含 owner pid/op/实例标识等内部诊断 → 只用于日志与
|
|
80
|
+
* **本模块内部**的 journal↔锁归属匹配,绝不进响应体。
|
|
80
81
|
*/
|
|
81
|
-
inspectLockState: () => Promise<{ state: string; detail?: string }>;
|
|
82
|
+
inspectLockState: () => Promise<{ state: string; detail?: string; ownerInstanceId?: string }>;
|
|
82
83
|
/**
|
|
83
84
|
* 显式回收 stale 残留锁(EnvironmentLockManager.recoverStaleLock)。
|
|
84
85
|
* **调用方不得先 acquire 锁**:要回收的正是挡住 acquire 的那把锁,先取锁必然失败。
|
|
@@ -118,6 +119,35 @@ export function createRecoveryOrchestrator(deps: RecoveryOrchestratorDeps): Reco
|
|
|
118
119
|
const { store, runs, snapshotsDir, host, snapshotExists, getEnvironmentFingerprint, clearSafeMode, inspectLockState, recoverStaleLock } = deps;
|
|
119
120
|
const safeModeBlocked = deps.safeModeBlocked ?? ((): boolean => false);
|
|
120
121
|
|
|
122
|
+
/**
|
|
123
|
+
* 该 active journal 是否属于「正在正常执行中的外部操作」(in-flight),而不是
|
|
124
|
+
* 「一次未正常完成的操作」(incident)。
|
|
125
|
+
*
|
|
126
|
+
* 为什么必须区分:autosync push 的 journal 在整个执行期间存在于 `transactions/active/`
|
|
127
|
+
* (state=CREATED→APPLYING,成功后直接 moveToCompleted)。旧 status() 把所有非 terminal
|
|
128
|
+
* 的 active journal 一律当 incident 上报 ⇒ 正在跑的 push 被显示成「需要人工处理」的事故,
|
|
129
|
+
* 顶部冒出「配置修改已被保护」+「已进入安全模式」,而这类 journal 没有 snapshotId、
|
|
130
|
+
* decision 恒为 needs-attention ⇒ 「执行恢复」永久灰;唯一出口 dismiss 又要 GLOBAL 锁,
|
|
131
|
+
* 而锁正被同一个操作持着 ⇒ 点一次 423 一次。用户界面上是个纯粹的死胡同。
|
|
132
|
+
*
|
|
133
|
+
* 判据三合一,任一不成立就保守地仍按 incident 上报:
|
|
134
|
+
* 1. state 非 terminal 且非 NEEDS_ATTENTION(后者是明确的「需人工处理」);
|
|
135
|
+
* 2. 环境锁 state === 'LOCKED'(另一任务活跃持有;STALE/UNKNOWN 都不可当作「有人在跑」);
|
|
136
|
+
* 3. journal.ownerInstanceId === 锁持有者的 owner.instanceId —— 证明持锁的实例**就是**
|
|
137
|
+
* 写这条 journal 的实例,即它正在跑,而非上次崩溃的残骸。
|
|
138
|
+
*
|
|
139
|
+
* 不用时间窗启发式:op 执行期间 journal 本来就可能长时间不写(外部步骤无中途落盘),
|
|
140
|
+
* 任何「多久没更新算卡住」的阈值都会误伤慢速 push。
|
|
141
|
+
*/
|
|
142
|
+
const isLiveInFlight = (j: OperationJournal, lock: { state: string; ownerInstanceId?: string } | undefined): boolean => {
|
|
143
|
+
if (lock === undefined || lock.state !== 'LOCKED') return false;
|
|
144
|
+
if (isTerminalState(j.state) || j.state === 'NEEDS_ATTENTION') return false;
|
|
145
|
+
const holder = lock.ownerInstanceId;
|
|
146
|
+
if (holder === undefined || holder === '') return false;
|
|
147
|
+
const owner = j.ownerInstanceId;
|
|
148
|
+
return owner !== null && owner !== '' && owner === holder;
|
|
149
|
+
};
|
|
150
|
+
|
|
121
151
|
/**
|
|
122
152
|
* 只读 recovery decision(不修改 journal)。**不用 reconcileActive**:其 §6.5 硬门控会把
|
|
123
153
|
* RECOVERING journal 迁移到 NEEDS_ATTENTION,若 status 轮询时触发会破坏 execute→verify 流程。
|
|
@@ -214,15 +244,44 @@ export function createRecoveryOrchestrator(deps: RecoveryOrchestratorDeps): Reco
|
|
|
214
244
|
// 「解除安全模式」入口,而不是显示「暂无需要处理的恢复事项」然后继续 423。
|
|
215
245
|
const blocked = safeModeBlocked();
|
|
216
246
|
const clearable = blocked ? await resolveSafeMode(true) : false;
|
|
247
|
+
// issue #31:附带环境锁分类。**只暴露分类,不暴露 owner pid/op/hostname**(内部诊断)。
|
|
248
|
+
// attention 只对「需用户显式处理」的状态为 true:STALE_LOCK_DETECTED(残留锁,重试不会自愈)
|
|
249
|
+
// 与 UNKNOWN_STATE(无法判定)→ 引导用户回收;LOCKED(另一任务活跃持有)会自行释放,
|
|
250
|
+
// 不该催用户去「回收」,否则又是一个误导。探测失败 → 保守 UNKNOWN_STATE + attention。
|
|
251
|
+
//
|
|
252
|
+
// 探测必须**先于** incident 分类:LOCKED 且持有者 == journal.ownerInstanceId 时该 journal
|
|
253
|
+
// 是「正在跑」,不该进 incidents(解释见 isLiveInFlight)。
|
|
254
|
+
let lock: { state: string; attention: boolean } | undefined;
|
|
255
|
+
let lockInsp: { state: string; ownerInstanceId?: string } | undefined;
|
|
256
|
+
try {
|
|
257
|
+
const insp = await inspectLockState();
|
|
258
|
+
lockInsp = insp.ownerInstanceId !== undefined
|
|
259
|
+
? { state: insp.state, ownerInstanceId: insp.ownerInstanceId }
|
|
260
|
+
: { state: insp.state };
|
|
261
|
+
lock = {
|
|
262
|
+
state: insp.state,
|
|
263
|
+
attention: insp.state === 'STALE_LOCK_DETECTED' || insp.state === 'UNKNOWN_STATE',
|
|
264
|
+
};
|
|
265
|
+
} catch {
|
|
266
|
+
lock = { state: 'UNKNOWN_STATE', attention: true };
|
|
267
|
+
}
|
|
217
268
|
const activeIds = await store.scanActive();
|
|
218
269
|
const incidents: Array<{
|
|
219
270
|
operationId: string; operationType: string; state: string; decision: string;
|
|
220
271
|
snapshotId: string | null; reason: string; createdAt: string;
|
|
221
272
|
}> = [];
|
|
273
|
+
/** 正在正常执行的操作(不是 incident):面板据此解释「为什么现在什么都点不动」。 */
|
|
274
|
+
const live: Array<{ operationId: string; operationType: string; state: string; startedAt: string }> = [];
|
|
222
275
|
for (const opId of activeIds) {
|
|
223
276
|
const j = await store.loadActive(opId);
|
|
224
277
|
if (j === null) continue;
|
|
225
278
|
if (isTerminalState(j.state) && j.state !== 'NEEDS_ATTENTION') continue; // COMMITTED/ROLLED_BACK/RECOVERED → 非 incident
|
|
279
|
+
// 正在跑的操作:既不进 incidents(否则伪装成事故 + 假 SAFE MODE 横幅),
|
|
280
|
+
// 也不进 running(那是 recovery run),单列进 live 供界面如实说明。
|
|
281
|
+
if (isLiveInFlight(j, lockInsp)) {
|
|
282
|
+
live.push({ operationId: opId, operationType: j.operationType, state: j.state, startedAt: j.createdAt });
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
226
285
|
const d = await decision(j);
|
|
227
286
|
incidents.push({
|
|
228
287
|
operationId: opId,
|
|
@@ -235,23 +294,10 @@ export function createRecoveryOrchestrator(deps: RecoveryOrchestratorDeps): Reco
|
|
|
235
294
|
});
|
|
236
295
|
}
|
|
237
296
|
const running = runs.listActive().filter((r) => r.kind === 'recovery').map((r) => ({ runId: r.runId, status: r.status }));
|
|
238
|
-
// issue #31:附带环境锁分类。**只暴露分类,不暴露 owner pid/op/hostname**(内部诊断)。
|
|
239
|
-
// attention 只对「需用户显式处理」的状态为 true:STALE_LOCK_DETECTED(残留锁,重试不会自愈)
|
|
240
|
-
// 与 UNKNOWN_STATE(无法判定)→ 引导用户回收;LOCKED(另一任务活跃持有)会自行释放,
|
|
241
|
-
// 不该催用户去「回收」,否则又是一个误导。探测失败 → 保守 UNKNOWN_STATE + attention。
|
|
242
|
-
let lock: { state: string; attention: boolean } | undefined;
|
|
243
|
-
try {
|
|
244
|
-
const insp = await inspectLockState();
|
|
245
|
-
lock = {
|
|
246
|
-
state: insp.state,
|
|
247
|
-
attention: insp.state === 'STALE_LOCK_DETECTED' || insp.state === 'UNKNOWN_STATE',
|
|
248
|
-
};
|
|
249
|
-
} catch {
|
|
250
|
-
lock = { state: 'UNKNOWN_STATE', attention: true };
|
|
251
|
-
}
|
|
252
297
|
// issue #56:SAFE MODE 阻断态如实回传。`clearable` = 「结案但保护仍开着」
|
|
253
298
|
// (durable 标记在、没有任何未解决 incident)—— 界面据此渲染显式解除入口。
|
|
254
|
-
|
|
299
|
+
// live 是**加法字段**:旧客户端读不到就按「无进行中操作」处理,不影响既有渲染。
|
|
300
|
+
return { status: 200, body: { incidents, running, live, lock, safeMode: { blocked, clearable } } };
|
|
255
301
|
},
|
|
256
302
|
|
|
257
303
|
async preview(operationId) {
|
|
@@ -366,6 +412,17 @@ export function createRecoveryOrchestrator(deps: RecoveryOrchestratorDeps): Reco
|
|
|
366
412
|
if (userConfirmed !== true) return { status: 400, body: { error: 'userConfirmed required' } };
|
|
367
413
|
const j = await store.loadActive(operationId);
|
|
368
414
|
if (j === null) return { status: 404, body: { error: 'operation not found' } };
|
|
415
|
+
// 正在执行的操作不能「放弃」:它不是事故,quarantine 会把在跑的那条 journal 从 active/
|
|
416
|
+
// 抽走,让执行方后续 transition 失败。正常情况下这条路径走不到 —— 路由的 GLOBAL 锁
|
|
417
|
+
// 会先以 423 挡下;这里是防御性判据(锁端口未接线/跨进程判定差异时仍能给出准确原因,
|
|
418
|
+
// 而不是笼统的「另一个任务正在运行」)。
|
|
419
|
+
let lockForDismiss: { state: string; ownerInstanceId?: string } | undefined;
|
|
420
|
+
try {
|
|
421
|
+
lockForDismiss = await inspectLockState();
|
|
422
|
+
} catch { /* 探测失败 → 不拦(保持旧行为) */ }
|
|
423
|
+
if (isLiveInFlight(j, lockForDismiss)) {
|
|
424
|
+
return { status: 409, body: { error: engineMsg('core.recovery.opInFlight', { state: j.state }), code: 'operation-in-flight' } };
|
|
425
|
+
}
|
|
369
426
|
// dismiss → quarantine(用户放弃;不删除 snapshot/journal evidence,不强行 RECOVERED)
|
|
370
427
|
const result = await executeRecovery(store, { operationId, action: 'dismiss', snapshotId: j.snapshotId }, true);
|
|
371
428
|
if (result === 'failed') return { status: 400, body: { error: engineMsg('core.recovery.dismissFailed') } };
|