@logictan/dsh-config-manager 0.1.60
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/LICENSE +21 -0
- package/README.md +105 -0
- package/cordis.patch.yml +21 -0
- package/lib/adapters/agent-instructions.d.ts +13 -0
- package/lib/adapters/agent-instructions.js +46 -0
- package/lib/adapters/agent-presets.d.ts +13 -0
- package/lib/adapters/agent-presets.js +14 -0
- package/lib/adapters/credentials.d.ts +37 -0
- package/lib/adapters/credentials.js +122 -0
- package/lib/adapters/file-collection.d.ts +15 -0
- package/lib/adapters/file-collection.js +127 -0
- package/lib/adapters/index.d.ts +57 -0
- package/lib/adapters/index.js +55 -0
- package/lib/adapters/link-report.d.ts +16 -0
- package/lib/adapters/link-report.js +47 -0
- package/lib/adapters/mcp.d.ts +28 -0
- package/lib/adapters/mcp.js +178 -0
- package/lib/adapters/plugin-files.d.ts +18 -0
- package/lib/adapters/plugin-files.js +142 -0
- package/lib/adapters/plugins.d.ts +41 -0
- package/lib/adapters/plugins.js +603 -0
- package/lib/adapters/pnpm-workspace.d.ts +26 -0
- package/lib/adapters/pnpm-workspace.js +147 -0
- package/lib/adapters/prompts.d.ts +30 -0
- package/lib/adapters/prompts.js +231 -0
- package/lib/adapters/providers.d.ts +34 -0
- package/lib/adapters/providers.js +155 -0
- package/lib/adapters/self.d.ts +17 -0
- package/lib/adapters/self.js +71 -0
- package/lib/adapters/sessions.d.ts +14 -0
- package/lib/adapters/sessions.js +15 -0
- package/lib/adapters/settings.d.ts +28 -0
- package/lib/adapters/settings.js +165 -0
- package/lib/adapters/skills.d.ts +13 -0
- package/lib/adapters/skills.js +14 -0
- package/lib/adapters/test-helpers.d.ts +125 -0
- package/lib/adapters/test-helpers.js +236 -0
- package/lib/adapters/ui.d.ts +27 -0
- package/lib/adapters/ui.js +89 -0
- package/lib/adapters/workspaces.d.ts +13 -0
- package/lib/adapters/workspaces.js +106 -0
- package/lib/client.d.ts +1026 -0
- package/lib/client.js +10047 -0
- package/lib/core/analyzer.d.ts +99 -0
- package/lib/core/analyzer.js +934 -0
- package/lib/core/backup.d.ts +138 -0
- package/lib/core/backup.js +513 -0
- package/lib/core/boot-rescue.d.ts +146 -0
- package/lib/core/boot-rescue.js +618 -0
- package/lib/core/cache-cleaner.d.ts +31 -0
- package/lib/core/cache-cleaner.js +95 -0
- package/lib/core/config-lifecycle.d.ts +180 -0
- package/lib/core/config-lifecycle.js +481 -0
- package/lib/core/config-snapshot.d.ts +135 -0
- package/lib/core/config-snapshot.js +424 -0
- package/lib/core/config-state.d.ts +70 -0
- package/lib/core/config-state.js +177 -0
- package/lib/core/consult-source.d.ts +58 -0
- package/lib/core/consult-source.js +260 -0
- package/lib/core/crash-report.d.ts +84 -0
- package/lib/core/crash-report.js +317 -0
- package/lib/core/exporter.d.ts +68 -0
- package/lib/core/exporter.js +375 -0
- package/lib/core/ghost-sweep.d.ts +19 -0
- package/lib/core/ghost-sweep.js +52 -0
- package/lib/core/importer.d.ts +60 -0
- package/lib/core/importer.js +29 -0
- package/lib/core/index.d.ts +14 -0
- package/lib/core/index.js +14 -0
- package/lib/core/journal.d.ts +197 -0
- package/lib/core/journal.js +418 -0
- package/lib/core/local-plugin-host.d.ts +35 -0
- package/lib/core/local-plugin-host.js +120 -0
- package/lib/core/local-plugin-pack.d.ts +135 -0
- package/lib/core/local-plugin-pack.js +278 -0
- package/lib/core/messages.d.ts +310 -0
- package/lib/core/messages.js +621 -0
- package/lib/core/migration-consult.d.ts +117 -0
- package/lib/core/migration-consult.js +363 -0
- package/lib/core/migration-history.d.ts +153 -0
- package/lib/core/migration-history.js +411 -0
- package/lib/core/model-tools.d.ts +50 -0
- package/lib/core/model-tools.js +148 -0
- package/lib/core/msg-types.d.ts +8 -0
- package/lib/core/msg-types.js +6 -0
- package/lib/core/phase3-child-crash.d.ts +1 -0
- package/lib/core/phase3-child-crash.js +87 -0
- package/lib/core/phase3-host.d.ts +138 -0
- package/lib/core/phase3-host.js +359 -0
- package/lib/core/phase3-prod-child.d.ts +1 -0
- package/lib/core/phase3-prod-child.js +35 -0
- package/lib/core/phase4-crash-child.d.ts +1 -0
- package/lib/core/phase4-crash-child.js +103 -0
- package/lib/core/plugin-cli.d.ts +150 -0
- package/lib/core/plugin-cli.js +497 -0
- package/lib/core/reconcile.d.ts +120 -0
- package/lib/core/reconcile.js +389 -0
- package/lib/core/recovery-orchestrator.d.ts +94 -0
- package/lib/core/recovery-orchestrator.js +327 -0
- package/lib/core/restore.d.ts +162 -0
- package/lib/core/restore.js +703 -0
- package/lib/core/rollback.d.ts +12 -0
- package/lib/core/rollback.js +145 -0
- package/lib/core/run-registry.d.ts +85 -0
- package/lib/core/run-registry.js +157 -0
- package/lib/core/startup-barrier.d.ts +53 -0
- package/lib/core/startup-barrier.js +77 -0
- package/lib/core/types.d.ts +515 -0
- package/lib/core/types.js +27 -0
- package/lib/core/undo.d.ts +73 -0
- package/lib/core/undo.js +87 -0
- package/lib/core/validator.d.ts +18 -0
- package/lib/core/validator.js +77 -0
- package/lib/core/verify-recovery.d.ts +21 -0
- package/lib/core/verify-recovery.js +256 -0
- package/lib/core/watcher.d.ts +101 -0
- package/lib/core/watcher.js +219 -0
- package/lib/index.d.ts +238 -0
- package/lib/index.js +2764 -0
- package/lib/migrations/index.d.ts +29 -0
- package/lib/migrations/index.js +56 -0
- package/lib/migrations/v1-to-v2.d.ts +9 -0
- package/lib/migrations/v1-to-v2.js +10 -0
- package/lib/profiles/index.d.ts +4 -0
- package/lib/profiles/index.js +5 -0
- package/lib/profiles/profile-manager.d.ts +106 -0
- package/lib/profiles/profile-manager.js +543 -0
- package/lib/schema/config.d.ts +28 -0
- package/lib/schema/config.js +165 -0
- package/lib/schema/index.d.ts +24 -0
- package/lib/schema/index.js +26 -0
- package/lib/schema/manifest.d.ts +28 -0
- package/lib/schema/manifest.js +109 -0
- package/lib/schema/tombstones.d.ts +46 -0
- package/lib/schema/tombstones.js +64 -0
- package/lib/schema/types.d.ts +232 -0
- package/lib/schema/types.js +8 -0
- package/lib/schema/versions.d.ts +27 -0
- package/lib/schema/versions.js +50 -0
- package/lib/security/encryption.d.ts +85 -0
- package/lib/security/encryption.js +279 -0
- package/lib/security/index.d.ts +16 -0
- package/lib/security/index.js +17 -0
- package/lib/security/integrity.d.ts +30 -0
- package/lib/security/integrity.js +78 -0
- package/lib/security/redaction.d.ts +26 -0
- package/lib/security/redaction.js +95 -0
- package/lib/security/secret-scanner.d.ts +86 -0
- package/lib/security/secret-scanner.js +404 -0
- package/lib/security/vault.d.ts +59 -0
- package/lib/security/vault.js +151 -0
- package/lib/security/zip-security.d.ts +27 -0
- package/lib/security/zip-security.js +154 -0
- package/lib/sync/autosync-config.d.ts +39 -0
- package/lib/sync/autosync-config.js +151 -0
- package/lib/sync/autosync-scheduler.d.ts +139 -0
- package/lib/sync/autosync-scheduler.js +564 -0
- package/lib/sync/fs.d.ts +18 -0
- package/lib/sync/fs.js +49 -0
- package/lib/sync/git/git-transport.d.ts +100 -0
- package/lib/sync/git/git-transport.js +437 -0
- package/lib/sync/github-auth.d.ts +131 -0
- package/lib/sync/github-auth.js +264 -0
- package/lib/sync/layout.d.ts +43 -0
- package/lib/sync/layout.js +203 -0
- package/lib/sync/snapshot-json.d.ts +9 -0
- package/lib/sync/snapshot-json.js +107 -0
- package/lib/sync/sync-config.d.ts +81 -0
- package/lib/sync/sync-config.js +281 -0
- package/lib/sync/sync-engine.d.ts +269 -0
- package/lib/sync/sync-engine.js +591 -0
- package/lib/sync/sync-history.d.ts +42 -0
- package/lib/sync/sync-history.js +78 -0
- package/lib/sync/sync-selection.d.ts +29 -0
- package/lib/sync/sync-selection.js +127 -0
- package/lib/sync/sync-session.d.ts +40 -0
- package/lib/sync/sync-session.js +54 -0
- package/lib/sync/sync-state.d.ts +37 -0
- package/lib/sync/sync-state.js +108 -0
- package/lib/sync/transport.d.ts +75 -0
- package/lib/sync/transport.js +55 -0
- package/lib/sync/ui-prefs.d.ts +34 -0
- package/lib/sync/ui-prefs.js +109 -0
- package/lib/sync/webdav/webdav-transport.d.ts +77 -0
- package/lib/sync/webdav/webdav-transport.js +451 -0
- package/lib/ui/errors.d.ts +19 -0
- package/lib/ui/errors.js +81 -0
- package/lib/ui/export-flow.d.ts +82 -0
- package/lib/ui/export-flow.js +98 -0
- package/lib/ui/i18n.d.ts +289 -0
- package/lib/ui/i18n.js +581 -0
- package/lib/ui/migration-consult-view.d.ts +91 -0
- package/lib/ui/migration-consult-view.js +115 -0
- package/lib/ui/progress.d.ts +38 -0
- package/lib/ui/progress.js +87 -0
- package/lib/ui/report.d.ts +23 -0
- package/lib/ui/report.js +153 -0
- package/lib/ui/test-helpers.d.ts +69 -0
- package/lib/ui/test-helpers.js +154 -0
- package/lib/ui/types.d.ts +257 -0
- package/lib/ui/types.js +21 -0
- package/lib/utils/atomic-write.d.ts +88 -0
- package/lib/utils/atomic-write.js +399 -0
- package/lib/utils/env-lock.d.ts +393 -0
- package/lib/utils/env-lock.js +930 -0
- package/lib/utils/hashing.d.ts +17 -0
- package/lib/utils/hashing.js +41 -0
- package/lib/utils/json.d.ts +24 -0
- package/lib/utils/json.js +76 -0
- package/lib/utils/logger.d.ts +39 -0
- package/lib/utils/logger.js +82 -0
- package/lib/utils/paths.d.ts +42 -0
- package/lib/utils/paths.js +189 -0
- package/lib/utils/proxy.d.ts +69 -0
- package/lib/utils/proxy.js +455 -0
- package/lib/utils/recursive-walk.d.ts +24 -0
- package/lib/utils/recursive-walk.js +134 -0
- package/lib/utils/zip.d.ts +54 -0
- package/lib/utils/zip.js +285 -0
- package/package.json +188 -0
- package/src/adapters/agent-instructions.ts +48 -0
- package/src/adapters/agent-presets.ts +14 -0
- package/src/adapters/credentials.test.ts +90 -0
- package/src/adapters/credentials.ts +143 -0
- package/src/adapters/file-collection.ts +137 -0
- package/src/adapters/files.test.ts +353 -0
- package/src/adapters/index.ts +94 -0
- package/src/adapters/link-report.test.ts +65 -0
- package/src/adapters/link-report.ts +61 -0
- package/src/adapters/mcp.test.ts +96 -0
- package/src/adapters/mcp.ts +185 -0
- package/src/adapters/plugin-files.ts +146 -0
- package/src/adapters/plugins.test.ts +326 -0
- package/src/adapters/plugins.ts +615 -0
- package/src/adapters/pnpm-workspace.test.ts +87 -0
- package/src/adapters/pnpm-workspace.ts +150 -0
- package/src/adapters/prompts.test.ts +110 -0
- package/src/adapters/prompts.ts +230 -0
- package/src/adapters/providers.test.ts +126 -0
- package/src/adapters/providers.ts +173 -0
- package/src/adapters/roundtrip.test.ts +190 -0
- package/src/adapters/self.test.ts +92 -0
- package/src/adapters/self.ts +75 -0
- package/src/adapters/sessions.ts +15 -0
- package/src/adapters/settings.test.ts +100 -0
- package/src/adapters/settings.ts +187 -0
- package/src/adapters/skills.ts +14 -0
- package/src/adapters/test-helpers.ts +248 -0
- package/src/adapters/ui.test.ts +57 -0
- package/src/adapters/ui.ts +108 -0
- package/src/adapters/workspaces.test.ts +106 -0
- package/src/adapters/workspaces.ts +109 -0
- package/src/client/ConfigManagerSection.tsx +90 -0
- package/src/client/api.ts +66 -0
- package/src/client/client-types.ts +18 -0
- package/src/client/common/ErrorBanner.tsx +73 -0
- package/src/client/common/Icon.tsx +129 -0
- package/src/client/common/Modal.tsx +173 -0
- package/src/client/common/ToastViewport.tsx +73 -0
- package/src/client/common/toast-store.test.ts +192 -0
- package/src/client/common/toast-store.ts +181 -0
- package/src/client/common/toast-types.ts +7 -0
- package/src/client/common/ui.tsx +342 -0
- package/src/client/config-manager.module.css +2971 -0
- package/src/client/css-modules.d.ts +8 -0
- package/src/client/index.ts +65 -0
- package/src/client/locales.ts +56 -0
- package/src/client/lucide-icons.d.ts +38 -0
- package/src/client/run-store.test.ts +223 -0
- package/src/client/run-store.ts +372 -0
- package/src/client/sync/SyncConfirmView.tsx +379 -0
- package/src/client/sync/SyncHistoryView.test.ts +40 -0
- package/src/client/sync/SyncHistoryView.tsx +220 -0
- package/src/client/sync/SyncSettingsView.tsx +1535 -0
- package/src/client/sync/history-model.test.ts +194 -0
- package/src/client/sync/history-model.ts +195 -0
- package/src/client/sync/sync-api.test.ts +585 -0
- package/src/client/sync/sync-api.ts +543 -0
- package/src/client/sync/sync-locales.ts +416 -0
- package/src/client/sync/sync-push-preview.test.ts +52 -0
- package/src/client/sync/sync-view-v2.test.ts +213 -0
- package/src/client/sync/sync-view.test.ts +487 -0
- package/src/client/sync/sync-view.ts +759 -0
- package/src/core/analyzer.ts +1068 -0
- package/src/core/backup.ts +605 -0
- package/src/core/boot-rescue.test.ts +847 -0
- package/src/core/boot-rescue.ts +737 -0
- package/src/core/cache-cleaner.test.ts +169 -0
- package/src/core/cache-cleaner.ts +121 -0
- package/src/core/config-lifecycle.test.ts +727 -0
- package/src/core/config-lifecycle.ts +573 -0
- package/src/core/config-snapshot.test.ts +369 -0
- package/src/core/config-snapshot.ts +522 -0
- package/src/core/config-state.test.ts +381 -0
- package/src/core/config-state.ts +207 -0
- package/src/core/consult-source.test.ts +199 -0
- package/src/core/consult-source.ts +293 -0
- package/src/core/crash-report.test.ts +380 -0
- package/src/core/crash-report.ts +369 -0
- package/src/core/exporter.ts +417 -0
- package/src/core/ghost-sweep.test.ts +94 -0
- package/src/core/ghost-sweep.ts +67 -0
- package/src/core/importer.ts +83 -0
- package/src/core/index.ts +56 -0
- package/src/core/journal.test.ts +257 -0
- package/src/core/journal.ts +518 -0
- package/src/core/local-plugin-host.ts +152 -0
- package/src/core/local-plugin-pack.test.ts +359 -0
- package/src/core/local-plugin-pack.ts +363 -0
- package/src/core/messages.test.ts +54 -0
- package/src/core/messages.ts +664 -0
- package/src/core/migration-consult.test.ts +315 -0
- package/src/core/migration-consult.ts +494 -0
- package/src/core/migration-history.test.ts +319 -0
- package/src/core/migration-history.ts +484 -0
- package/src/core/model-tools.test.ts +190 -0
- package/src/core/model-tools.ts +208 -0
- package/src/core/msg-types.ts +10 -0
- package/src/core/phase3-child-crash.ts +95 -0
- package/src/core/phase3-host.ts +411 -0
- package/src/core/phase3-p1.test.ts +212 -0
- package/src/core/phase3-p2b-fingerprint.test.ts +189 -0
- package/src/core/phase3-prod-child.ts +36 -0
- package/src/core/phase3-production-integration.test.ts +286 -0
- package/src/core/phase4-crash-child.ts +101 -0
- package/src/core/phase4-crash-injection.test.ts +166 -0
- package/src/core/plugin-cli.fs.test.ts +184 -0
- package/src/core/plugin-cli.test.ts +137 -0
- package/src/core/plugin-cli.ts +563 -0
- package/src/core/reconcile.test.ts +392 -0
- package/src/core/reconcile.ts +498 -0
- package/src/core/recovery-orchestrator.test.ts +145 -0
- package/src/core/recovery-orchestrator.ts +366 -0
- package/src/core/restore-manage.test.ts +94 -0
- package/src/core/restore.ts +849 -0
- package/src/core/rollback.ts +151 -0
- package/src/core/run-progress.test.ts +237 -0
- package/src/core/run-registry.test.ts +232 -0
- package/src/core/run-registry.ts +209 -0
- package/src/core/smoke.test.ts +842 -0
- package/src/core/startup-barrier.ts +99 -0
- package/src/core/types.ts +542 -0
- package/src/core/undo.ts +116 -0
- package/src/core/validator.ts +84 -0
- package/src/core/verify-recovery.ts +265 -0
- package/src/core/watcher.test.ts +326 -0
- package/src/core/watcher.ts +263 -0
- package/src/index.facade.test.ts +265 -0
- package/src/index.sync.test.ts +217 -0
- package/src/index.ts +2975 -0
- package/src/migrations/index.ts +71 -0
- package/src/migrations/v1-to-v2.ts +17 -0
- package/src/profiles/index.ts +8 -0
- package/src/profiles/profile-manager.test.ts +282 -0
- package/src/profiles/profile-manager.ts +630 -0
- package/src/schema/config.ts +165 -0
- package/src/schema/index.ts +71 -0
- package/src/schema/manifest-schema.test.ts +519 -0
- package/src/schema/manifest.ts +124 -0
- package/src/schema/tombstones.test.ts +131 -0
- package/src/schema/tombstones.ts +89 -0
- package/src/schema/types.ts +245 -0
- package/src/schema/versions.ts +55 -0
- package/src/security/encryption.ts +335 -0
- package/src/security/index.ts +16 -0
- package/src/security/integrity.ts +102 -0
- package/src/security/redaction.ts +101 -0
- package/src/security/secret-scanner.ts +445 -0
- package/src/security/security.test.ts +1215 -0
- package/src/security/vault.test.ts +164 -0
- package/src/security/vault.ts +198 -0
- package/src/security/zip-security.ts +180 -0
- package/src/sync/autosync-config.test.ts +147 -0
- package/src/sync/autosync-config.ts +179 -0
- package/src/sync/autosync-scheduler.test.ts +490 -0
- package/src/sync/autosync-scheduler.ts +646 -0
- package/src/sync/fs.ts +48 -0
- package/src/sync/git/git-transport.test.ts +673 -0
- package/src/sync/git/git-transport.ts +491 -0
- package/src/sync/github-auth.test.ts +280 -0
- package/src/sync/github-auth.ts +356 -0
- package/src/sync/layout.test.ts +270 -0
- package/src/sync/layout.ts +222 -0
- package/src/sync/snapshot-json.test.ts +119 -0
- package/src/sync/snapshot-json.ts +128 -0
- package/src/sync/sync-config.test.ts +310 -0
- package/src/sync/sync-config.ts +299 -0
- package/src/sync/sync-engine.test.ts +1074 -0
- package/src/sync/sync-engine.ts +754 -0
- package/src/sync/sync-history.test.ts +116 -0
- package/src/sync/sync-history.ts +120 -0
- package/src/sync/sync-selection.test.ts +186 -0
- package/src/sync/sync-selection.ts +143 -0
- package/src/sync/sync-session.test.ts +143 -0
- package/src/sync/sync-session.ts +77 -0
- package/src/sync/sync-state.test.ts +164 -0
- package/src/sync/sync-state.ts +131 -0
- package/src/sync/transport.test.ts +111 -0
- package/src/sync/transport.ts +118 -0
- package/src/sync/ui-prefs.test.ts +152 -0
- package/src/sync/ui-prefs.ts +135 -0
- package/src/sync/webdav/webdav-transport.test.ts +871 -0
- package/src/sync/webdav/webdav-transport.ts +535 -0
- package/src/ui/errors.test.ts +47 -0
- package/src/ui/errors.ts +100 -0
- package/src/ui/export-flow.test.ts +87 -0
- package/src/ui/export-flow.ts +156 -0
- package/src/ui/i18n.ts +590 -0
- package/src/ui/migration-consult-view.test.ts +151 -0
- package/src/ui/migration-consult-view.ts +178 -0
- package/src/ui/progress.test.ts +47 -0
- package/src/ui/progress.ts +102 -0
- package/src/ui/report.test.ts +101 -0
- package/src/ui/report.ts +146 -0
- package/src/ui/test-helpers.ts +184 -0
- package/src/ui/types.ts +325 -0
- package/src/utils/atomic-write.test.ts +312 -0
- package/src/utils/atomic-write.ts +422 -0
- package/src/utils/env-lock.test.ts +999 -0
- package/src/utils/env-lock.ts +1086 -0
- package/src/utils/hashing.ts +53 -0
- package/src/utils/json.ts +77 -0
- package/src/utils/logger.ts +114 -0
- package/src/utils/paths.ts +176 -0
- package/src/utils/proxy.test.ts +465 -0
- package/src/utils/proxy.ts +493 -0
- package/src/utils/recursive-walk.test.ts +148 -0
- package/src/utils/recursive-walk.ts +145 -0
- package/src/utils/zip.ts +332 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 5 Recovery Orchestration(§9.2「新增 recovery 编排层」)。
|
|
3
|
+
*
|
|
4
|
+
* 职责:把 recovery 路由的编排逻辑(decision / confirmation / authority / snapshot 校验 /
|
|
5
|
+
* journal 状态机 / 原子 verification+terminal / dismiss)提取为可测纯编排层。
|
|
6
|
+
* 路由(index.ts)只负责:loopback fence + withMutationLock + 把结果映射为 HTTP 响应。
|
|
7
|
+
*
|
|
8
|
+
* 安全不变量(§9.4 / §11):
|
|
9
|
+
* - **权威 snapshotId 只来自 j.snapshotId**(不接受请求体覆盖)。
|
|
10
|
+
* - 只消费 `TRUSTED_OPERATION_SNAPSHOT`(requireOperationBound=true);LEGACY/MANUAL/CORRUPT/
|
|
11
|
+
* INVALID/UNSAFE_PATH/WRONG_ENVIRONMENT/WRONG_OPERATION 一律拒绝。
|
|
12
|
+
* - destructive 动作必须 `userConfirmed=true`(双重校验:请求体 + journal 状态机)。
|
|
13
|
+
* - verify 的 verification 写入 + terminal state 为**单次原子 store.update**(§6.5)。
|
|
14
|
+
* - 不新建 journal / lock / transaction / snapshot-trust(复用现有 machinery)。
|
|
15
|
+
*
|
|
16
|
+
* 本模块不 import index.ts(避免循环依赖);restore/rollback 执行器由路由注入
|
|
17
|
+
* (`RecoveryExecutorFns`),测试注入 mock。
|
|
18
|
+
*/
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
import { FileSnapshotStore } from './backup.ts';
|
|
22
|
+
import { validateSnapshotForRestore, type RestoreSnapshotVerdict } from './restore.ts';
|
|
23
|
+
import { recomputeRecoveryDecision, executeRecovery } from './reconcile.ts';
|
|
24
|
+
import { verifyRecovery, recoveryTerminalState } from './verify-recovery.ts';
|
|
25
|
+
import {
|
|
26
|
+
redactJournalText, isTerminalState, transitionJournalState, isValidOperationId,
|
|
27
|
+
type JournalStore, type OperationJournal,
|
|
28
|
+
} from './journal.ts';
|
|
29
|
+
import { RunRegistry, type RunState } from './run-registry.ts';
|
|
30
|
+
import type { HostContext, Snapshot } from './types.ts';
|
|
31
|
+
import type { MsgFunc } from './messages.ts';
|
|
32
|
+
|
|
33
|
+
/** 路由注入的恢复执行器(restore / rollback 引擎;测试注入 mock)。 */
|
|
34
|
+
export interface RecoveryExecutorFns {
|
|
35
|
+
/** rollback-recommended:恢复到 trusted snapshot(restore.ts 引擎)。 */
|
|
36
|
+
performRestore: (snapshotId: string) => Promise<{ full: boolean; failed: string[] }>;
|
|
37
|
+
/** rollback-continue:续跑中断回滚(rollback.ts 引擎)。 */
|
|
38
|
+
performRollback: (snapshotId: string) => Promise<{ full: boolean; failed: string[] }>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RecoveryOrchestratorDeps {
|
|
42
|
+
store: JournalStore;
|
|
43
|
+
runs: RunRegistry;
|
|
44
|
+
snapshotsDir: string;
|
|
45
|
+
host: HostContext;
|
|
46
|
+
msg: MsgFunc;
|
|
47
|
+
/** journal↔snapshot 正向校验(phase3Recovery.recoveryHooks.snapshotExists)。 */
|
|
48
|
+
snapshotExists: (snapshotId: string | null, binding?: { operationId?: string; ownerInstanceId?: string; environmentFingerprint?: string }) => Promise<boolean>;
|
|
49
|
+
/**
|
|
50
|
+
* 当前环境指纹(**动态 getter**,非创建时捕获快照)。
|
|
51
|
+
* 原因:宿主启动 recovery 分类在 fire-and-forget 异步块中跑 `initFingerprint()`,
|
|
52
|
+
* 未在 makeRoutes 前 await 完成;若此处捕获创建时的值会拿到 'unknown' 初值,
|
|
53
|
+
* 导致后续所有 recovery API 判 WRONG_ENVIRONMENT(环境指纹不匹配)。动态读取
|
|
54
|
+
* 保证 initFingerprint 完成后,API 调用时取到真实指纹。
|
|
55
|
+
*/
|
|
56
|
+
getEnvironmentFingerprint: () => string;
|
|
57
|
+
/**
|
|
58
|
+
* 清除 SAFE MODE(解除阻断)。宿主注入 `phase3Recovery.clearSafeMode()`:
|
|
59
|
+
* 同时重置内存 `safeModeActive` 标志(isBlocked 读它)与 durable 标记。
|
|
60
|
+
* 仅当 recovery 成功且无其他未解决 incident 时调用(见 maybeClearSafeMode)。
|
|
61
|
+
*/
|
|
62
|
+
clearSafeMode: () => Promise<void>;
|
|
63
|
+
/**
|
|
64
|
+
* 只读环境锁状态(issue #31)。**由宿主注入而非本模块 import**:本模块不依赖 env-lock,
|
|
65
|
+
* 无锁环境(测试 mock 端口)可返回保守的 UNKNOWN_STATE。
|
|
66
|
+
* detail 含 owner pid/op 等内部诊断 → 只用于日志,绝不进响应体。
|
|
67
|
+
*/
|
|
68
|
+
inspectLockState: () => Promise<{ state: string; detail?: string }>;
|
|
69
|
+
/**
|
|
70
|
+
* 显式回收 stale 残留锁(EnvironmentLockManager.recoverStaleLock)。
|
|
71
|
+
* **调用方不得先 acquire 锁**:要回收的正是挡住 acquire 的那把锁,先取锁必然失败。
|
|
72
|
+
* 该方法自身保证 inspect → 证明确证死亡 → 原子 rename 捕获 → 二次验证 → unlink,
|
|
73
|
+
* 任一步不确定即拒绝(活锁绝不删)。
|
|
74
|
+
*/
|
|
75
|
+
recoverStaleLock: () => Promise<{ ok: boolean; removed: boolean; state: string; detail?: string }>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 编排结果(路由映射为 HTTP 响应)。 */
|
|
79
|
+
export type RecoveryResult =
|
|
80
|
+
| { status: 200; body: Record<string, unknown> }
|
|
81
|
+
| { status: 400; body: Record<string, unknown> }
|
|
82
|
+
| { status: 404; body: Record<string, unknown> }
|
|
83
|
+
| { status: 409; body: Record<string, unknown> }
|
|
84
|
+
| { status: 500; body: Record<string, unknown> };
|
|
85
|
+
|
|
86
|
+
export interface RecoveryOrchestrator {
|
|
87
|
+
status(): Promise<RecoveryResult>;
|
|
88
|
+
preview(operationId: string): Promise<RecoveryResult>;
|
|
89
|
+
confirm(operationId: string, userConfirmed: boolean): Promise<RecoveryResult>;
|
|
90
|
+
execute(operationId: string, userConfirmed: boolean, makeExecutors: (runId: string) => RecoveryExecutorFns): Promise<RecoveryResult>;
|
|
91
|
+
verify(operationId: string): Promise<RecoveryResult>;
|
|
92
|
+
retry(operationId: string, userConfirmed: boolean, makeExecutors: (runId: string) => RecoveryExecutorFns): Promise<RecoveryResult>;
|
|
93
|
+
dismiss(operationId: string, userConfirmed: boolean): Promise<RecoveryResult>;
|
|
94
|
+
/** issue #31:显式回收 stale 残留锁(无 operationId;非 journal 事项)。 */
|
|
95
|
+
recoverStaleLock(userConfirmed: boolean): Promise<RecoveryResult>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function createRecoveryOrchestrator(deps: RecoveryOrchestratorDeps): RecoveryOrchestrator {
|
|
99
|
+
const { store, runs, snapshotsDir, host, msg, snapshotExists, getEnvironmentFingerprint, clearSafeMode, inspectLockState, recoverStaleLock } = deps;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 只读 recovery decision(不修改 journal)。**不用 reconcileActive**:其 §6.5 硬门控会把
|
|
103
|
+
* RECOVERING journal 迁移到 NEEDS_ATTENTION,若 status 轮询时触发会破坏 execute→verify 流程。
|
|
104
|
+
*/
|
|
105
|
+
const decision = async (j: OperationJournal): Promise<'rollback-continue' | 'rollback-recommended' | 'needs-attention'> => {
|
|
106
|
+
if (j.state === 'NEEDS_ATTENTION') {
|
|
107
|
+
return recomputeRecoveryDecision(j, { snapshotExists });
|
|
108
|
+
}
|
|
109
|
+
if (j.state === 'RECOVERING') {
|
|
110
|
+
if (Object.keys(j.rollback.entryDone ?? {}).length > 0) return 'rollback-continue';
|
|
111
|
+
return 'needs-attention';
|
|
112
|
+
}
|
|
113
|
+
return 'needs-attention'; // CREATED/SNAPSHOT_CREATED/APPLYING/VALIDATING → 保守
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** 校验 trusted snapshot(requireOperationBound=true);返回 verdict。 */
|
|
117
|
+
const validateSnapshotVerdict = async (snapshotId: string): Promise<RestoreSnapshotVerdict> => {
|
|
118
|
+
const v = await validateSnapshotForRestore(join(snapshotsDir, snapshotId), snapshotsDir, { environmentFingerprint: getEnvironmentFingerprint() });
|
|
119
|
+
return v.verdict;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* recovery 成功后清除 SAFE MODE(§5.3 / §10.2「SAFE MODE 退出」)。
|
|
124
|
+
* 仅当 **不存在其他未解决 active journal**(active 全部为已解决 terminal:
|
|
125
|
+
* COMMITTED/ROLLED_BACK/RECOVERED,**NEEDS_ATTENTION 视为未解决**——它代表仍需
|
|
126
|
+
* 人工处理的 incident,必须保持 SAFE MODE 阻断)时,才清除 durable 标记与内存标志。
|
|
127
|
+
* fail-closed:扫描失败不强行清除(保守保留 SAFE MODE)。
|
|
128
|
+
*/
|
|
129
|
+
const maybeClearSafeMode = async (): Promise<void> => {
|
|
130
|
+
try {
|
|
131
|
+
const activeIds = await store.scanActive();
|
|
132
|
+
let allResolved = true;
|
|
133
|
+
for (const opId of activeIds) {
|
|
134
|
+
const j = await store.loadActive(opId);
|
|
135
|
+
if (j === null) continue;
|
|
136
|
+
// NEEDS_ATTENTION 是 terminal 但代表未解决 incident → 不视为 resolved
|
|
137
|
+
if (j.state === 'NEEDS_ATTENTION') { allResolved = false; break; }
|
|
138
|
+
if (!isTerminalState(j.state)) { allResolved = false; break; }
|
|
139
|
+
}
|
|
140
|
+
if (allResolved) {
|
|
141
|
+
await clearSafeMode();
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
// 扫描失败保守:不清除 SAFE MODE(fail-closed)
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** 执行 recovery(execute/retry 共用):登记 run → 注入执行器工厂 → executeRecovery。 */
|
|
149
|
+
const runExecution = async ( operationId: string,
|
|
150
|
+
j: OperationJournal,
|
|
151
|
+
decisionKind: 'rollback-recommended' | 'rollback-continue',
|
|
152
|
+
makeExecutors: (runId: string) => RecoveryExecutorFns,
|
|
153
|
+
): Promise<RecoveryResult> => {
|
|
154
|
+
let run: RunState;
|
|
155
|
+
try {
|
|
156
|
+
run = runs.register('recovery');
|
|
157
|
+
} catch (error) {
|
|
158
|
+
return { status: 409, body: { error: error instanceof Error ? error.message : String(error) } };
|
|
159
|
+
}
|
|
160
|
+
const runId = run.runId;
|
|
161
|
+
try {
|
|
162
|
+
const executors = makeExecutors(runId);
|
|
163
|
+
const result = await executeRecovery(store, {
|
|
164
|
+
operationId, action: 'rollback', snapshotId: j.snapshotId, decision: decisionKind,
|
|
165
|
+
performRestore: executors.performRestore, performRollback: executors.performRollback,
|
|
166
|
+
}, true);
|
|
167
|
+
if (result === 'failed') {
|
|
168
|
+
runs.fail(runId, 'recovery 执行失败');
|
|
169
|
+
return { status: 400, body: { error: 'recovery 执行失败', runId } };
|
|
170
|
+
}
|
|
171
|
+
runs.finish(runId, { decision: decisionKind, state: 'RECOVERING' });
|
|
172
|
+
return { status: 200, body: { ok: true, operationId, decision: decisionKind, state: 'RECOVERING', runId } };
|
|
173
|
+
} catch (error) {
|
|
174
|
+
runs.fail(runId, error instanceof Error ? error.message : String(error));
|
|
175
|
+
return { status: 500, body: { error: error instanceof Error ? error.message : String(error), runId } };
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
async status() {
|
|
181
|
+
const activeIds = await store.scanActive();
|
|
182
|
+
const incidents: Array<{
|
|
183
|
+
operationId: string; operationType: string; state: string; decision: string;
|
|
184
|
+
snapshotId: string | null; reason: string; createdAt: string;
|
|
185
|
+
}> = [];
|
|
186
|
+
for (const opId of activeIds) {
|
|
187
|
+
const j = await store.loadActive(opId);
|
|
188
|
+
if (j === null) continue;
|
|
189
|
+
if (isTerminalState(j.state) && j.state !== 'NEEDS_ATTENTION') continue; // COMMITTED/ROLLED_BACK/RECOVERED → 非 incident
|
|
190
|
+
const d = await decision(j);
|
|
191
|
+
incidents.push({
|
|
192
|
+
operationId: opId,
|
|
193
|
+
operationType: j.operationType,
|
|
194
|
+
state: j.state,
|
|
195
|
+
decision: d,
|
|
196
|
+
snapshotId: j.snapshotId,
|
|
197
|
+
reason: redactJournalText(j.error || j.recovery.reason || ''),
|
|
198
|
+
createdAt: j.createdAt,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
const running = runs.listActive().filter((r) => r.kind === 'recovery').map((r) => ({ runId: r.runId, status: r.status }));
|
|
202
|
+
// issue #31:附带环境锁分类。**只暴露分类,不暴露 owner pid/op/hostname**(内部诊断)。
|
|
203
|
+
// attention 只对「需用户显式处理」的状态为 true:STALE_LOCK_DETECTED(残留锁,重试不会自愈)
|
|
204
|
+
// 与 UNKNOWN_STATE(无法判定)→ 引导用户回收;LOCKED(另一任务活跃持有)会自行释放,
|
|
205
|
+
// 不该催用户去「回收」,否则又是一个误导。探测失败 → 保守 UNKNOWN_STATE + attention。
|
|
206
|
+
let lock: { state: string; attention: boolean } | undefined;
|
|
207
|
+
try {
|
|
208
|
+
const insp = await inspectLockState();
|
|
209
|
+
lock = {
|
|
210
|
+
state: insp.state,
|
|
211
|
+
attention: insp.state === 'STALE_LOCK_DETECTED' || insp.state === 'UNKNOWN_STATE',
|
|
212
|
+
};
|
|
213
|
+
} catch {
|
|
214
|
+
lock = { state: 'UNKNOWN_STATE', attention: true };
|
|
215
|
+
}
|
|
216
|
+
return { status: 200, body: { incidents, running, lock } };
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
async preview(operationId) {
|
|
220
|
+
if (!isValidOperationId(operationId)) return { status: 400, body: { error: 'invalid operationId' } };
|
|
221
|
+
const j = await store.loadActive(operationId);
|
|
222
|
+
if (j === null) return { status: 404, body: { error: 'operation not found' } };
|
|
223
|
+
const d = await decision(j);
|
|
224
|
+
let snapshotVerdict: RestoreSnapshotVerdict | null = null;
|
|
225
|
+
let snapshotMeta: { id: string; createdAt: string; operationType?: string } | null = null;
|
|
226
|
+
if (j.snapshotId !== null && j.snapshotId !== '') {
|
|
227
|
+
snapshotVerdict = await validateSnapshotVerdict(j.snapshotId);
|
|
228
|
+
try {
|
|
229
|
+
const snap = await new FileSnapshotStore({ dir: snapshotsDir }).load(j.snapshotId);
|
|
230
|
+
snapshotMeta = { id: snap.id, createdAt: snap.createdAt, operationType: snap.operationType };
|
|
231
|
+
} catch { /* 快照缺失 */ }
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
status: 200,
|
|
235
|
+
body: {
|
|
236
|
+
operationId,
|
|
237
|
+
operationType: j.operationType,
|
|
238
|
+
state: j.state,
|
|
239
|
+
decision: d,
|
|
240
|
+
snapshotId: j.snapshotId,
|
|
241
|
+
snapshotVerdict,
|
|
242
|
+
snapshotMeta,
|
|
243
|
+
environmentFingerprint: j.environmentFingerprint,
|
|
244
|
+
environmentCompatible: j.environmentFingerprint === getEnvironmentFingerprint(),
|
|
245
|
+
reason: redactJournalText(j.error || j.recovery.reason || ''),
|
|
246
|
+
createdAt: j.createdAt,
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
},
|
|
250
|
+
|
|
251
|
+
async confirm(operationId, userConfirmed) {
|
|
252
|
+
if (!isValidOperationId(operationId)) return { status: 400, body: { error: 'invalid operationId' } };
|
|
253
|
+
if (userConfirmed !== true) return { status: 400, body: { error: 'userConfirmed required' } };
|
|
254
|
+
const j = await store.loadActive(operationId);
|
|
255
|
+
if (j === null) return { status: 404, body: { error: 'operation not found' } };
|
|
256
|
+
if (j.state !== 'NEEDS_ATTENTION') return { status: 409, body: { error: `journal state ${j.state} 不允许 confirm` } };
|
|
257
|
+
if (j.snapshotId === null || j.snapshotId === '') return { status: 400, body: { error: 'journal 无 trusted snapshot,无法恢复' } };
|
|
258
|
+
const verdict = await validateSnapshotVerdict(j.snapshotId);
|
|
259
|
+
if (verdict !== 'TRUSTED_OPERATION_SNAPSHOT') return { status: 400, body: { error: `snapshot 不可信: ${verdict}` } };
|
|
260
|
+
// journal 保持 NEEDS_ATTENTION(confirm 不转 RECOVERING)
|
|
261
|
+
return { status: 200, body: { ok: true, operationId, snapshotId: j.snapshotId, verdict } };
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
async execute(operationId, userConfirmed, makeExecutors) {
|
|
265
|
+
if (!isValidOperationId(operationId)) return { status: 400, body: { error: 'invalid operationId' } };
|
|
266
|
+
if (userConfirmed !== true) return { status: 400, body: { error: 'userConfirmed required' } };
|
|
267
|
+
const j = await store.loadActive(operationId);
|
|
268
|
+
if (j === null) return { status: 404, body: { error: 'operation not found' } };
|
|
269
|
+
if (j.state !== 'NEEDS_ATTENTION' && j.state !== 'RECOVERING') return { status: 409, body: { error: `journal state ${j.state} 不允许 execute` } };
|
|
270
|
+
if (j.snapshotId === null || j.snapshotId === '') return { status: 400, body: { error: 'journal 无 trusted snapshot,无法恢复' } };
|
|
271
|
+
const d = await decision(j);
|
|
272
|
+
if (d === 'needs-attention') return { status: 400, body: { error: 'recovery decision 为 needs-attention,无法自动执行' } };
|
|
273
|
+
const verdict = await validateSnapshotVerdict(j.snapshotId);
|
|
274
|
+
if (verdict !== 'TRUSTED_OPERATION_SNAPSHOT') return { status: 400, body: { error: `snapshot 不可信: ${verdict}` } };
|
|
275
|
+
return runExecution(operationId, j, d, makeExecutors);
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
async verify(operationId) {
|
|
279
|
+
if (!isValidOperationId(operationId)) return { status: 400, body: { error: 'invalid operationId' } };
|
|
280
|
+
const j = await store.loadActive(operationId);
|
|
281
|
+
if (j === null) return { status: 404, body: { error: 'operation not found' } };
|
|
282
|
+
if (j.state !== 'RECOVERING') return { status: 409, body: { error: `journal state ${j.state} 不允许 verify` } };
|
|
283
|
+
if (j.snapshotId === null || j.snapshotId === '') return { status: 400, body: { error: 'journal 无 trusted snapshot' } };
|
|
284
|
+
let snap: Snapshot;
|
|
285
|
+
try {
|
|
286
|
+
snap = await new FileSnapshotStore({ dir: snapshotsDir }).load(j.snapshotId);
|
|
287
|
+
} catch {
|
|
288
|
+
return { status: 400, body: { error: 'snapshot 无法加载' } };
|
|
289
|
+
}
|
|
290
|
+
const verification = await verifyRecovery(snap, host, { snapshotsRoot: snapshotsDir, environmentFingerprint: getEnvironmentFingerprint(), expectedOperationId: j.operationId });
|
|
291
|
+
const terminal = recoveryTerminalState(verification.verdict);
|
|
292
|
+
// 单次原子 journal update:recoveryVerification + terminal state(§6.5)
|
|
293
|
+
await store.update(operationId, (cur) => {
|
|
294
|
+
let next = cur;
|
|
295
|
+
if (!isTerminalState(cur.state)) next = transitionJournalState(cur, terminal);
|
|
296
|
+
next = { ...next, recoveryVerification: { verdict: verification.verdict, details: verification.details.map(redactJournalText), manualHints: verification.manualHints.map(redactJournalText), at: verification.at } };
|
|
297
|
+
return next;
|
|
298
|
+
});
|
|
299
|
+
if (terminal === 'ROLLED_BACK') await store.moveToCompleted(operationId).catch(() => undefined);
|
|
300
|
+
// §5.3 / §10.2:recovery 成功后若无其他未解决 incident → 清除 SAFE MODE(解除阻断)
|
|
301
|
+
if (terminal === 'ROLLED_BACK') await maybeClearSafeMode();
|
|
302
|
+
return {
|
|
303
|
+
status: 200,
|
|
304
|
+
body: {
|
|
305
|
+
ok: true, operationId, verdict: verification.verdict, terminal,
|
|
306
|
+
details: verification.details.map(redactJournalText),
|
|
307
|
+
manualHints: verification.manualHints.map(redactJournalText),
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
},
|
|
311
|
+
|
|
312
|
+
async retry(operationId, userConfirmed, makeExecutors) {
|
|
313
|
+
if (!isValidOperationId(operationId)) return { status: 400, body: { error: 'invalid operationId' } };
|
|
314
|
+
if (userConfirmed !== true) return { status: 400, body: { error: 'userConfirmed required' } };
|
|
315
|
+
const j = await store.loadActive(operationId);
|
|
316
|
+
if (j === null) return { status: 404, body: { error: 'operation not found' } };
|
|
317
|
+
if (j.state !== 'NEEDS_ATTENTION') return { status: 409, body: { error: `journal state ${j.state} 不允许 retry` } };
|
|
318
|
+
if (j.snapshotId === null || j.snapshotId === '') return { status: 400, body: { error: 'journal 无 trusted snapshot' } };
|
|
319
|
+
const d = await decision(j);
|
|
320
|
+
if (d === 'needs-attention') return { status: 400, body: { error: 'recovery decision 为 needs-attention,无法 retry' } };
|
|
321
|
+
const verdict = await validateSnapshotVerdict(j.snapshotId);
|
|
322
|
+
if (verdict !== 'TRUSTED_OPERATION_SNAPSHOT') return { status: 400, body: { error: `snapshot 不可信: ${verdict}` } };
|
|
323
|
+
return runExecution(operationId, j, d, makeExecutors);
|
|
324
|
+
},
|
|
325
|
+
|
|
326
|
+
async dismiss(operationId, userConfirmed) {
|
|
327
|
+
if (!isValidOperationId(operationId)) return { status: 400, body: { error: 'invalid operationId' } };
|
|
328
|
+
if (userConfirmed !== true) return { status: 400, body: { error: 'userConfirmed required' } };
|
|
329
|
+
const j = await store.loadActive(operationId);
|
|
330
|
+
if (j === null) return { status: 404, body: { error: 'operation not found' } };
|
|
331
|
+
// dismiss → quarantine(用户放弃;不删除 snapshot/journal evidence,不强行 RECOVERED)
|
|
332
|
+
const result = await executeRecovery(store, { operationId, action: 'dismiss', snapshotId: j.snapshotId }, true);
|
|
333
|
+
if (result === 'failed') return { status: 400, body: { error: 'dismiss 失败' } };
|
|
334
|
+
return { status: 200, body: { ok: true, operationId, dismissed: true } };
|
|
335
|
+
},
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* issue #31:显式回收 stale 残留锁。**无 operationId**——残留锁没有 journal(这正是
|
|
339
|
+
* 「事故恢复」面板过去恒空的原因),因此不复用 confirm/execute 那套 journal 状态机。
|
|
340
|
+
*
|
|
341
|
+
* 正确性边界(不放松「绝不自动摘锁」):
|
|
342
|
+
* - **不 acquire 锁**:要回收的正是挡住 acquire 的那把锁,先取锁必然失败;
|
|
343
|
+
* - 是否真的 stale 由 recoverStaleLock 内部重做判定(inspect + 二次验证),
|
|
344
|
+
* 本层拿到的 state/detail 只用于日志,绝不作为删除依据;
|
|
345
|
+
* - 活锁/判定不确定 → 内部拒绝,ok=false,不做任何改动(不误删别人的锁)。
|
|
346
|
+
*/
|
|
347
|
+
async recoverStaleLock(userConfirmed) {
|
|
348
|
+
if (userConfirmed !== true) return { status: 400, body: { error: 'userConfirmed required' } };
|
|
349
|
+
try {
|
|
350
|
+
const res = await recoverStaleLock();
|
|
351
|
+
if (res.detail !== undefined) {
|
|
352
|
+
// 内部诊断(op/pid/路径)只进日志
|
|
353
|
+
host.log[res.ok ? 'info' : 'warn'](`stale lock recovery: ok=${res.ok} state=${res.state} detail=${res.detail}`);
|
|
354
|
+
}
|
|
355
|
+
if (!res.ok) {
|
|
356
|
+
// 拒绝是正常结果(活锁/无法证明 stale/二次验证失败),不是 500
|
|
357
|
+
return { status: 200, body: { ok: false, removed: false, state: res.state, reason: res.detail ?? '未判定为 stale,已拒绝回收' } };
|
|
358
|
+
}
|
|
359
|
+
// 回收成功后 stale 分类已消失;锁态交由客户端重新拉取 status 刷新
|
|
360
|
+
return { status: 200, body: { ok: true, removed: res.removed, state: res.state } };
|
|
361
|
+
} catch (error) {
|
|
362
|
+
return { status: 500, body: { error: error instanceof Error ? error.message : String(error) } };
|
|
363
|
+
}
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* restore.ts 快照管理纯函数测试(P1-⑧):
|
|
3
|
+
* isValidSnapshotId 防穿越校验、deleteSnapshot(合法删除/幂等/越界拒绝)、
|
|
4
|
+
* setSnapshotPinned(置顶标记读写/越界拒绝)。
|
|
5
|
+
* 使用真实临时目录(与 backup 测试同模式)。
|
|
6
|
+
*/
|
|
7
|
+
import test from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import fs from 'node:fs/promises';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
deleteSnapshot,
|
|
15
|
+
isValidSnapshotId,
|
|
16
|
+
setSnapshotPinned,
|
|
17
|
+
} from './restore.ts';
|
|
18
|
+
|
|
19
|
+
/** 建临时快照根目录 + 一个样例快照目录,返回 { dir, id, cleanup } */
|
|
20
|
+
async function makeSnapshot(): Promise<{ dir: string; id: string; cleanup: () => Promise<void> }> {
|
|
21
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-cm-restore-'));
|
|
22
|
+
const dir = path.join(root, 'snapshots');
|
|
23
|
+
await fs.mkdir(path.join(dir, 'snap-1'), { recursive: true });
|
|
24
|
+
await fs.writeFile(
|
|
25
|
+
path.join(dir, 'snap-1', 'snapshot.json'),
|
|
26
|
+
JSON.stringify({ id: 'snap-1', createdAt: '2026-08-24T00:00:00.000Z', sourceZip: 'x.zip', entries: [], status: 'done' }),
|
|
27
|
+
);
|
|
28
|
+
return { dir, id: 'snap-1', cleanup: () => fs.rm(root, { recursive: true, force: true }) };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test('isValidSnapshotId:合法 id 通过,路径穿越/非法字符拒绝(P1-⑧)', () => {
|
|
32
|
+
assert.equal(isValidSnapshotId('snap-1'), true);
|
|
33
|
+
assert.equal(isValidSnapshotId('abc_123'), true);
|
|
34
|
+
assert.equal(isValidSnapshotId('../evil'), false, '../ 穿越拒绝');
|
|
35
|
+
assert.equal(isValidSnapshotId('a/b'), false, '斜杠拒绝');
|
|
36
|
+
assert.equal(isValidSnapshotId('a\\b'), false, '反斜杠拒绝');
|
|
37
|
+
assert.equal(isValidSnapshotId('.hidden'), false, '点开头拒绝');
|
|
38
|
+
assert.equal(isValidSnapshotId(''), false);
|
|
39
|
+
assert.equal(isValidSnapshotId(undefined), false);
|
|
40
|
+
assert.equal(isValidSnapshotId('a'.repeat(100)), false, '超长拒绝');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('deleteSnapshot:删除存在快照目录;不存在幂等;越界拒绝(P1-⑧)', async () => {
|
|
44
|
+
const { dir, id, cleanup } = await makeSnapshot();
|
|
45
|
+
try {
|
|
46
|
+
assert.equal(await deleteSnapshot(dir, id), true, '删除成功');
|
|
47
|
+
await assert.rejects(() => fs.stat(path.join(dir, id)), '目录已删除');
|
|
48
|
+
assert.equal(await deleteSnapshot(dir, id), false, '不存在幂等返回 false');
|
|
49
|
+
} finally {
|
|
50
|
+
await cleanup();
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('deleteSnapshot:非法 id 抛错(防目录穿越),不触碰外部目录(P1-⑧)', async () => {
|
|
55
|
+
const { dir, cleanup } = await makeSnapshot();
|
|
56
|
+
try {
|
|
57
|
+
// 在快照根外放置哨兵文件,确认穿越删除被拒绝
|
|
58
|
+
const sentinel = path.join(path.dirname(dir), 'outside-marker');
|
|
59
|
+
await fs.writeFile(sentinel, 'keep');
|
|
60
|
+
await assert.rejects(() => deleteSnapshot(dir, '../outside-marker'), /非法快照 id/);
|
|
61
|
+
await assert.rejects(() => deleteSnapshot(dir, 'a/b'), /非法快照 id/);
|
|
62
|
+
const stillThere = await fs.readFile(sentinel, 'utf8');
|
|
63
|
+
assert.equal(stillThere, 'keep', '外部目录未被误删');
|
|
64
|
+
} finally {
|
|
65
|
+
await cleanup();
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('setSnapshotPinned:写 pinned 标记并读回(P1-⑧)', async () => {
|
|
70
|
+
const { dir, id, cleanup } = await makeSnapshot();
|
|
71
|
+
try {
|
|
72
|
+
await setSnapshotPinned(dir, id, true);
|
|
73
|
+
const raw1 = JSON.parse(await fs.readFile(path.join(dir, id, 'snapshot.json'), 'utf8'));
|
|
74
|
+
assert.equal(raw1.pinned, true, '置顶标记写入');
|
|
75
|
+
await setSnapshotPinned(dir, id, false);
|
|
76
|
+
const raw2 = JSON.parse(await fs.readFile(path.join(dir, id, 'snapshot.json'), 'utf8'));
|
|
77
|
+
assert.equal(raw2.pinned, false, '取消置顶覆盖');
|
|
78
|
+
// 保留其余字段
|
|
79
|
+
assert.equal(raw2.id, id);
|
|
80
|
+
assert.equal(raw2.status, 'done');
|
|
81
|
+
} finally {
|
|
82
|
+
await cleanup();
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('setSnapshotPinned:非法 id 抛错(P1-⑧)', async () => {
|
|
87
|
+
const { dir, cleanup } = await makeSnapshot();
|
|
88
|
+
try {
|
|
89
|
+
await assert.rejects(() => setSnapshotPinned(dir, '../x', true), /非法快照 id/);
|
|
90
|
+
await assert.rejects(() => setSnapshotPinned(dir, 'no-such-snapshot', true), /ENOENT|快照/, '不存在的快照抛错');
|
|
91
|
+
} finally {
|
|
92
|
+
await cleanup();
|
|
93
|
+
}
|
|
94
|
+
});
|