@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,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 3 Host Recovery 集成助手。
|
|
3
|
+
*
|
|
4
|
+
* 职责(最小、保守):
|
|
5
|
+
* - 持有 JournalStore + 内存 SAFE MODE 标志(isBlocked 同步谓词,供 env-lock 注入)。
|
|
6
|
+
* - `startup(lockState)`:启动时**只读**扫描 + 锁状态判定 → 设 SAFE MODE / RECOVERY_REQUIRED(durable)。
|
|
7
|
+
* **不自动 recover stale lock**(Rev 3 P1-NEW-2)。
|
|
8
|
+
* - 保守 reconcile hooks:任何无法证明的 step → needs-attention → SAFE MODE(绝不自动恢复/回滚)。
|
|
9
|
+
*
|
|
10
|
+
* 引擎级 WAL / 指纹(import/restore 逐 step 插桩)为 Phase 3 v1 之外(§33 不要临时扩大)——
|
|
11
|
+
* 本助手提供保守的「operation intent + crash 判定」基础,供宿主在各 destructive 入口包 Coordinator。
|
|
12
|
+
*/
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import fs from 'node:fs/promises';
|
|
15
|
+
import fssync from 'node:fs';
|
|
16
|
+
import {
|
|
17
|
+
JournalStore, environmentFingerprint as computeFingerprint, SAFE_MODE_MARKER,
|
|
18
|
+
generateOperationId, createJournalEntry, transitionJournalState, isTerminalState,
|
|
19
|
+
} from './journal.ts';
|
|
20
|
+
import type { OperationJournal } from './journal.ts';
|
|
21
|
+
import { inspectStartup, type ReconcileProbeHooks, type ReconcileEnv } from './reconcile.ts';
|
|
22
|
+
import { OWNERSHIP_FILE, type LockState, type MutationLockContext } from '../utils/env-lock.ts';
|
|
23
|
+
import { sha256Hex } from '../utils/hashing.ts';
|
|
24
|
+
import type { JournalStepRecord } from './types.ts';
|
|
25
|
+
|
|
26
|
+
/** 存在未收敛的 active transaction → 拒绝创建第二个(active≤1)。 */
|
|
27
|
+
export class TransactionRecoveryRequiredError extends Error {
|
|
28
|
+
constructor(opId: string) {
|
|
29
|
+
super(`active transaction ${opId} 未收敛(待 recover / reconcile),拒绝创建新 transaction`)
|
|
30
|
+
this.name = 'TransactionRecoveryRequiredError'
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Phase 4 生产 journal↔snapshot 绑定 API(deferred 模式):
|
|
36
|
+
* fn 收到此 ctx,在【首个 destructive side effect 前】:
|
|
37
|
+
* - 用 plan 创建 op-bound snapshot(operationId/operationType/environmentFingerprint/ownerInstanceId)
|
|
38
|
+
* - `bindSnapshot(id)`:journal 记录 snapshotId,CREATED→SNAPSHOT_CREATED
|
|
39
|
+
* - 首个 mutation 前 `markApplying()`:SNAPSHOT_CREATED→APPLYING
|
|
40
|
+
* 保证「快照 durable+verified 且 journal 已知」先于任何写。显式 ctx,无 process-global reentrancy。
|
|
41
|
+
*/
|
|
42
|
+
export interface JournalRunContext {
|
|
43
|
+
operationId: string;
|
|
44
|
+
operationType: string;
|
|
45
|
+
environmentFingerprint: string;
|
|
46
|
+
ownerInstanceId: string;
|
|
47
|
+
/** 记录 journal.snapshotId 并推进 CREATED→SNAPSHOT_CREATED(或幂等)。 */
|
|
48
|
+
bindSnapshot: (snapshotId: string) => Promise<void>;
|
|
49
|
+
/** 首个 destructive side effect 前调用:SNAPSHOT_CREATED/CREATED→APPLYING。 */
|
|
50
|
+
markApplying: () => Promise<void>;
|
|
51
|
+
/** 逐计划项 WAL step 记录(P2-B,Phase 8):文件类带 fp,非文件类 external=true。 */
|
|
52
|
+
recordStep: (step: JournalStepRecord) => Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 把环境锁分类映射到 startup 判定:ACQUIRED 视同 LOCKED(有活跃 owner),IO/PERMISSION 保守 LOCKED。 */
|
|
56
|
+
export function mapLockStateForStartup(state: LockState): 'LOCKED' | 'STALE_LOCK_DETECTED' | 'UNKNOWN_STATE' | 'FREE' {
|
|
57
|
+
if (state === 'STALE_LOCK_DETECTED') return 'STALE_LOCK_DETECTED';
|
|
58
|
+
if (state === 'UNKNOWN_STATE') return 'UNKNOWN_STATE';
|
|
59
|
+
return 'LOCKED'; // ACQUIRED / LOCKED / LOCK_IO_ERROR / PERMISSION_ERROR → 保守 LOCKED
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface Phase3RecoveryOptions {
|
|
63
|
+
dataDir: string;
|
|
64
|
+
packageVersion: string;
|
|
65
|
+
/** 覆盖环境指纹(测试) */
|
|
66
|
+
environmentFingerprint?: string;
|
|
67
|
+
/** 环境指纹持久化 token 目录(缺省 dataDir) */
|
|
68
|
+
fingerprintDataDir?: string;
|
|
69
|
+
/**
|
|
70
|
+
* Phase 4 F21/F11 生产正向校验:校验 journal 引用的 snapshot 是否「存在 + READY + verified +
|
|
71
|
+
* op/env/owner binding 匹配」。缺省 = 保守 false(无法证明存在 → 不强推回滚)。
|
|
72
|
+
* 宿主注入 FileSnapshotStore + verifySnapshot + manifest binding 校验实现。
|
|
73
|
+
*/
|
|
74
|
+
snapshotExists?: (snapshotId: string | null, binding?: SnapshotBindingRef) => Promise<boolean>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** journal↔snapshot binding 引用(与 reconcile ReconcileProbeHooks.snapshotExists 的 binding 一致)。 */
|
|
78
|
+
export interface SnapshotBindingRef {
|
|
79
|
+
operationId?: string;
|
|
80
|
+
ownerInstanceId?: string;
|
|
81
|
+
environmentFingerprint?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class Phase3Recovery {
|
|
85
|
+
readonly store: JournalStore;
|
|
86
|
+
readonly packageVersion: string;
|
|
87
|
+
private readonly dataDir: string;
|
|
88
|
+
private readonly fingerprintDataDir: string;
|
|
89
|
+
private environmentFingerprint: string;
|
|
90
|
+
private readonly snapshotExistsFn: Phase3RecoveryOptions['snapshotExists'];
|
|
91
|
+
|
|
92
|
+
/** 内存 SAFE MODE 标志(isBlocked 同步谓词用) */
|
|
93
|
+
safeModeActive = false;
|
|
94
|
+
|
|
95
|
+
constructor(opts: Phase3RecoveryOptions) {
|
|
96
|
+
this.dataDir = opts.dataDir;
|
|
97
|
+
this.fingerprintDataDir = opts.fingerprintDataDir ?? opts.dataDir;
|
|
98
|
+
this.store = new JournalStore({ transactionsDir: path.join(opts.dataDir, 'transactions') });
|
|
99
|
+
this.packageVersion = opts.packageVersion;
|
|
100
|
+
this.environmentFingerprint = opts.environmentFingerprint ?? 'unknown';
|
|
101
|
+
this.snapshotExistsFn = opts.snapshotExists;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 计算环境指纹(持久化 token;跨启动稳定)。在 startup 前调用一次。 */
|
|
105
|
+
async initFingerprint(): Promise<string> {
|
|
106
|
+
try {
|
|
107
|
+
await fs.mkdir(this.fingerprintDataDir, { recursive: true });
|
|
108
|
+
this.environmentFingerprint = await computeFingerprint(this.fingerprintDataDir);
|
|
109
|
+
} catch {
|
|
110
|
+
this.environmentFingerprint = 'unknown';
|
|
111
|
+
}
|
|
112
|
+
return this.environmentFingerprint;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** isBlocked 同步谓词(供 withMutationLock / runWithMutationLock 注入;env-lock 只问 blocked?) */
|
|
116
|
+
isBlocked(): boolean {
|
|
117
|
+
return this.safeModeActive;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 刷新 SAFE MODE 标志(读 durable 标记) */
|
|
121
|
+
async refreshSafeMode(): Promise<void> {
|
|
122
|
+
this.safeModeActive = await this.store.readSafeMode().catch(() => false);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 同步探测 durable SAFE MODE 标记(宿主 apply() 同步阶段、scheduler.start() 前调用,保证先阻断)。 */
|
|
126
|
+
probeSafeModeSync(): boolean {
|
|
127
|
+
const p = path.join(this.dataDir, 'transactions', SAFE_MODE_MARKER);
|
|
128
|
+
try {
|
|
129
|
+
const text = fssync.existsSync(p) ? fssync.readFileSync(p, 'utf8') : '';
|
|
130
|
+
const blocked = /blocked|true/i.test(text);
|
|
131
|
+
this.safeModeActive = blocked;
|
|
132
|
+
return blocked;
|
|
133
|
+
} catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 保守 hooks:无法证明默认 needs-attention(绝不自动恢复/回滚)。snapshotExists 用宿主注入的正向校验(若提供)。 */
|
|
139
|
+
private conservativeHooks(): ReconcileProbeHooks {
|
|
140
|
+
const snapshotExists = this.snapshotExistsFn;
|
|
141
|
+
return {
|
|
142
|
+
// P2-B(Phase 8):对带 afterFp/beforeFp 的本地文件 step 做真实磁盘指纹判定。
|
|
143
|
+
// 这使「可安全判定已应用」的整文件项在 crash 后判 recovered;无指纹项(external /
|
|
144
|
+
// before/after 均 null)→ unable(保守)。**不放松**「不可信/不可指纹 → needs-attention」边界。
|
|
145
|
+
verifyStepFingerprint: async (step) => {
|
|
146
|
+
if (step.external === true) return 'unable';
|
|
147
|
+
if (step.beforeFp === null && step.afterFp === null) return 'unable';
|
|
148
|
+
if (step.ref === '') return 'unable';
|
|
149
|
+
let data: Buffer;
|
|
150
|
+
try {
|
|
151
|
+
data = await fs.readFile(step.ref);
|
|
152
|
+
} catch {
|
|
153
|
+
// 目标不存在:若已记录 afterFp(期望存在)→ 未应用;否则无法判定。
|
|
154
|
+
return step.afterFp !== null && step.beforeFp === null ? 'before-match' : 'none';
|
|
155
|
+
}
|
|
156
|
+
const fp = sha256Hex(data);
|
|
157
|
+
if (step.afterFp !== null && fp === step.afterFp) return 'after-match';
|
|
158
|
+
if (step.beforeFp !== null && fp === step.beforeFp) return 'before-match';
|
|
159
|
+
return 'none';
|
|
160
|
+
},
|
|
161
|
+
probeExternal: async () => 'unknown',
|
|
162
|
+
snapshotExists: async (snapshotId, binding) => {
|
|
163
|
+
if (snapshotExists === undefined) return false; // 未注入 → 保守 false
|
|
164
|
+
return snapshotExists(snapshotId, binding);
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 启动只读 reconcile:返回是否需 SAFE MODE / RECOVERY_REQUIRED,并写 durable 标记。
|
|
171
|
+
* @param lockState 宿主 EnvironmentLockManager 的 inspectLockState 结果(只分类,不自动 recover)
|
|
172
|
+
* @param expectedOwnershipInstanceId 若在显式 recovery 前已捕获 stale ownership 的 owner.instanceId,传入以做 P1-A binding 校验
|
|
173
|
+
*/
|
|
174
|
+
async startup(lockState: LockState, expectedOwnershipInstanceId?: string | null): Promise<{ safeModeRequired: boolean; recoveryRequired: boolean }> {
|
|
175
|
+
const env: ReconcileEnv = {
|
|
176
|
+
environmentFingerprint: this.environmentFingerprint || 'unknown',
|
|
177
|
+
isLiveOwner: async () => false,
|
|
178
|
+
...(expectedOwnershipInstanceId ? { expectedOwnershipInstanceId } : {}),
|
|
179
|
+
};
|
|
180
|
+
const insp = await inspectStartup(this.store, this.conservativeHooks(), env, {}, mapLockStateForStartup(lockState));
|
|
181
|
+
if (insp.safeModeRequired) this.safeModeActive = true;
|
|
182
|
+
return { safeModeRequired: insp.safeModeRequired, recoveryRequired: insp.recoveryRequired };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** P1-A:读取 crashed stale ownership 的 owner.instanceId(environment.lock 的 owner 证据;不可用返回 null)。 */
|
|
186
|
+
async captureStaleOwnershipInstanceId(): Promise<string | null> {
|
|
187
|
+
try {
|
|
188
|
+
const p = path.join(this.dataDir, 'locks', OWNERSHIP_FILE);
|
|
189
|
+
const text = await fs.readFile(p, 'utf8');
|
|
190
|
+
const rec = JSON.parse(text) as { owner?: { instanceId?: unknown } };
|
|
191
|
+
return typeof rec?.owner?.instanceId === 'string' && rec.owner.instanceId !== ''
|
|
192
|
+
? rec.owner.instanceId
|
|
193
|
+
: null;
|
|
194
|
+
} catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** 用户确认恢复后清除 SAFE MODE(清空 durable 标记 + 内存标志) */
|
|
200
|
+
async clearSafeMode(): Promise<void> {
|
|
201
|
+
this.safeModeActive = false;
|
|
202
|
+
await this.store.writeSafeMode(false);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 保守 reconcile hooks(供启动 barrier / inspect 用) */
|
|
206
|
+
get recoveryHooks(): ReconcileProbeHooks { return this.conservativeHooks(); }
|
|
207
|
+
/** 环境指纹(供启动 barrier 用) */
|
|
208
|
+
get recoveryEnvFingerprint(): string { return this.environmentFingerprint || 'unknown'; }
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* 生产 journal 包装(关闭 P0-A):在【已持 GLOBAL 锁】下,为该 destructive operation
|
|
212
|
+
* 创建 durable journal(CREATED → snapshot → APPLYING → 执行真实引擎 → COMMITTED → 规整)。
|
|
213
|
+
*
|
|
214
|
+
* - active≤1(§14):创建前扫描 active/;存在非 terminal 残留(非当前 live owner)→ 抛错阻断,不建第二个 journal。
|
|
215
|
+
* - Journal→Lock 绑定(§15/P1-A):ownerInstanceId = lockCtx.token.instanceId(= Phase 2 activeInstanceId,
|
|
216
|
+
* acquisition-specific);lockId = 同一 ownership epoch identity(ownerInstanceId)。recovery 时强制校验。
|
|
217
|
+
* - 不 double-acquire(§6):调用方(host gate)已持锁,本方法只负责 journal 生命周期,不 re-acquire、不 release(release 由 gate 负责)。
|
|
218
|
+
* - 异常 → NEEDS_ATTENTION + durable SAFE MODE + rethrow(不破坏既有错误/响应流)。
|
|
219
|
+
* - 返回 { operationId, result }。
|
|
220
|
+
*
|
|
221
|
+
* Phase 4 snapshot 两种模式:
|
|
222
|
+
* - `snapshotProvider`(pre-fn):journal CREATED → 调用 provider 创建并 verify snapshot → 绑定 snapshotId
|
|
223
|
+
* → SNAPSHOT_CREATED → APPLYING → fn(适合 provider 不依赖请求体 plan 的场景)。
|
|
224
|
+
* - `deferredSnapshot`(推荐,F20 生产接线):plan 只在 handler 解析请求体后可用,因此 journal 停留在 CREATED,
|
|
225
|
+
* fn 收到 `ctx`(含 `bindSnapshot` / `markApplying`)。引擎在【首个 destructive side effect 前】用 plan 创建
|
|
226
|
+
* op-bound snapshot → ctx.bindSnapshot(id)(CREATED→SNAPSHOT_CREATED,记录 snapshotId)→ 首个 mutation 前
|
|
227
|
+
* ctx.markApplying()(SNAPSHOT_CREATED→APPLYING)。保证「快照 durable+verified 且 journal 已知」先于任何写。
|
|
228
|
+
*/
|
|
229
|
+
async runJournaled<T>(opts: {
|
|
230
|
+
operationType: string;
|
|
231
|
+
lockCtx: MutationLockContext;
|
|
232
|
+
/** 可选:真实 pre-operation snapshot(回滚点),返回 snapshotId(pre-fn 模式)。 */
|
|
233
|
+
snapshotProvider?(): Promise<string | null>;
|
|
234
|
+
/** 可选:deferred 绑定模式——journal 停留 CREATED,fn 收到 ctx 自行 bindSnapshot/markApplying。 */
|
|
235
|
+
deferredSnapshot?: boolean;
|
|
236
|
+
fn: (ctx?: JournalRunContext) => Promise<T>;
|
|
237
|
+
}): Promise<{ operationId: string; result: T }> {
|
|
238
|
+
const { operationType, lockCtx, snapshotProvider, deferredSnapshot, fn } = opts;
|
|
239
|
+
const ownerInstanceId = (lockCtx?.token?.instanceId ?? 'unknown').toString();
|
|
240
|
+
// P1-A:ownership epoch identity = 真实 acquisition-specific ownerInstanceId(来自 lockCtx,
|
|
241
|
+
// 即 Phase 2 activeInstanceId;跨进程/跨持有不同)。不再用环境稳定合成串。
|
|
242
|
+
const ownershipIdentity = ownerInstanceId;
|
|
243
|
+
|
|
244
|
+
// active≤1:存在非 terminal 残留 → 阻断(不创建第二个 journal)
|
|
245
|
+
const activeIds = await this.store.scanActive();
|
|
246
|
+
for (const opId of activeIds) {
|
|
247
|
+
const j = await this.store.loadActive(opId);
|
|
248
|
+
if (j === null) continue;
|
|
249
|
+
const ts = ['COMMITTED', 'ROLLED_BACK', 'RECOVERED', 'NEEDS_ATTENTION'] as const;
|
|
250
|
+
if (!(ts as readonly string[]).includes(j.state)) {
|
|
251
|
+
throw new TransactionRecoveryRequiredError(opId);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const opId = generateOperationId();
|
|
256
|
+
const base = {
|
|
257
|
+
operationId: opId, ownerInstanceId, lockId: ownershipIdentity,
|
|
258
|
+
packageVersion: this.packageVersion, environmentFingerprint: this.environmentFingerprint || 'unknown',
|
|
259
|
+
};
|
|
260
|
+
await this.store.create(createJournalEntry(operationType, base, new Date().toISOString()));
|
|
261
|
+
let fnCompleted = false;
|
|
262
|
+
let terminalPersistAttempted = false;
|
|
263
|
+
|
|
264
|
+
// Phase 4 deferred 模式:把 journal 绑定 API 暴露给 fn(引擎在第一个 destructive side effect 前调用)。
|
|
265
|
+
// bindSnapshot:记录 snapshotId 并 CREATED→SNAPSHOT_CREATED;markApplying:SNAPSHOT_CREATED→APPLYING。
|
|
266
|
+
// 这些只在该 op 的同一 journal 上生效,杜绝 process-global reentrancy。
|
|
267
|
+
const journalCtx: JournalRunContext = {
|
|
268
|
+
operationId: opId,
|
|
269
|
+
operationType,
|
|
270
|
+
environmentFingerprint: this.environmentFingerprint || 'unknown',
|
|
271
|
+
ownerInstanceId,
|
|
272
|
+
bindSnapshot: async (snapshotId: string) => {
|
|
273
|
+
if (snapshotId === null || snapshotId === '') throw new Error('bindSnapshot: snapshotId 为空');
|
|
274
|
+
// Reviewer A P2①:持久化失败必须传播(fail-closed)——若 journal 无法 durable 绑定 SNAPSHOT_CREATED,
|
|
275
|
+
// 引擎的 await bindSnapshot 抛错 → fn 抛错 → runJournaled catch(NEEDS_ATTENTION),mutation 绝不在无绑定下继续。
|
|
276
|
+
await this.store.update(opId, (j) => {
|
|
277
|
+
let next = { ...j, snapshotId } as OperationJournal;
|
|
278
|
+
if (next.state === 'CREATED') next = transitionJournalState(next, 'SNAPSHOT_CREATED');
|
|
279
|
+
else if (next.state === 'SNAPSHOT_CREATED') { /* 幂等:已绑定 */ }
|
|
280
|
+
else if (next.state === 'APPLYING') { /* 允许:绑定已晚但幂等记录 */ }
|
|
281
|
+
else throw new Error(`bindSnapshot 非法 state: ${next.state}`);
|
|
282
|
+
return next;
|
|
283
|
+
});
|
|
284
|
+
},
|
|
285
|
+
markApplying: async () => {
|
|
286
|
+
// Reviewer A P2①:同 fail-closed——SNAPSHOT_CREATED→APPLYING 持久化失败须传播,不得在未 APPLYING durable 下 mutation。
|
|
287
|
+
await this.store.update(opId, (j) => {
|
|
288
|
+
let next = j;
|
|
289
|
+
if (next.state === 'SNAPSHOT_CREATED') next = transitionJournalState(next, 'APPLYING');
|
|
290
|
+
else if (next.state === 'CREATED') next = transitionJournalState(next, 'APPLYING');
|
|
291
|
+
return next;
|
|
292
|
+
});
|
|
293
|
+
},
|
|
294
|
+
// P2-B(Phase 8):逐计划项 WAL step 记录(文件类带 fp;非文件类 external=true)。
|
|
295
|
+
// 与 bindSnapshot/markApplying 同一 fail-closed:持久化失败传播 → fn 抛错 → NEEDS_ATTENTION。
|
|
296
|
+
recordStep: async (rec) => {
|
|
297
|
+
const step: import('./journal.ts').JournalStep = {
|
|
298
|
+
adapter: rec.adapter,
|
|
299
|
+
ref: rec.ref,
|
|
300
|
+
kind: rec.kind,
|
|
301
|
+
external: rec.external === true,
|
|
302
|
+
beforeFp: rec.beforeFp ?? null,
|
|
303
|
+
afterFp: rec.afterFp ?? null,
|
|
304
|
+
status: rec.status ?? (rec.external === true ? 'attention' : 'planned'),
|
|
305
|
+
appliedAt: new Date().toISOString(),
|
|
306
|
+
message: rec.message ?? null,
|
|
307
|
+
};
|
|
308
|
+
await this.store.update(opId, (j) => {
|
|
309
|
+
const plannedSteps = j.plannedSteps.includes(rec.id)
|
|
310
|
+
? j.plannedSteps
|
|
311
|
+
: [...j.plannedSteps, rec.id];
|
|
312
|
+
return { ...j, plannedSteps, steps: { ...j.steps, [rec.id]: step } };
|
|
313
|
+
});
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
if (deferredSnapshot !== true && snapshotProvider !== undefined) {
|
|
319
|
+
const snapId = await snapshotProvider();
|
|
320
|
+
// P1-2 修复:snapshotProvider 返回 null 表示快照创建失败 → 显式 abort(不得在无快照下继续 mutation)
|
|
321
|
+
if (snapId === null) {
|
|
322
|
+
throw new Error(`snapshotProvider 返回 null(快照创建失败),abort operation ${operationType}`);
|
|
323
|
+
}
|
|
324
|
+
await journalCtx.bindSnapshot(snapId);
|
|
325
|
+
} else if (deferredSnapshot !== true) {
|
|
326
|
+
await this.store.update(opId, (j) => transitionJournalState(j, 'APPLYING'));
|
|
327
|
+
}
|
|
328
|
+
// deferred 模式:保持 CREATED,fn 自行 bindSnapshot + markApplying(首个 destructive side effect 前)
|
|
329
|
+
const result = await fn(journalCtx);
|
|
330
|
+
fnCompleted = true;
|
|
331
|
+
// 尾操作:从任意 pre-commit 状态推进到 COMMITTED(合法链 CREATED/SNAPSHOT_CREATED→APPLYING→VALIDATING→COMMITTED)
|
|
332
|
+
await this.store.update(opId, (j) => {
|
|
333
|
+
let next = j;
|
|
334
|
+
if (next.state === 'CREATED') next = transitionJournalState(next, 'APPLYING');
|
|
335
|
+
if (next.state === 'SNAPSHOT_CREATED') next = transitionJournalState(next, 'APPLYING');
|
|
336
|
+
if (next.state === 'APPLYING') next = transitionJournalState(next, 'VALIDATING');
|
|
337
|
+
next = transitionJournalState(next, 'COMMITTED');
|
|
338
|
+
return { ...next, commit: { at: new Date().toISOString(), validated: true, validationWarnings: [] } };
|
|
339
|
+
});
|
|
340
|
+
terminalPersistAttempted = true;
|
|
341
|
+
await this.store.moveToCompleted(opId).catch(() => undefined);
|
|
342
|
+
return { operationId: opId, result };
|
|
343
|
+
} catch (err) {
|
|
344
|
+
this.safeModeActive = true;
|
|
345
|
+
await this.store.writeSafeMode(true).catch(() => undefined);
|
|
346
|
+
if (fnCompleted && !terminalPersistAttempted) {
|
|
347
|
+
// 真实事务副作用已完成,但 terminal 持久化失败 → RECOVERY_REQUIRED(不伪造终态,保持非终态 journal)
|
|
348
|
+
// (此处保持 journal 非终态:apply 已成功、COMMITTED 未 durable —— 由下一轮显式 recovery 决定。
|
|
349
|
+
// SAFE MODE 已设;不额外写 NEEDS_ATTENTION,避免把「无法 durable 记录 outcome」误报成「已分类 NEEDS_ATTENTION」。)
|
|
350
|
+
} else if (!fnCompleted) {
|
|
351
|
+
// fn / side effect 阶段失败:op 已开始但 outcome 不确定 → NEEDS_ATTENTION(durable 分类)
|
|
352
|
+
const j = await this.store.loadActive(opId);
|
|
353
|
+
if (j !== null && !isTerminalState(j.state)) {
|
|
354
|
+
await this.store.update(opId, (cur) => transitionJournalState(cur, 'NEEDS_ATTENTION')).catch(() => undefined);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
throw err;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* 轻量 operation 包装(意图 journal,供外部/不可证明操作如 sync-push / reinstall 用):
|
|
363
|
+
* 创建 CREATED → APPLYING(含 external intent step)→ 执行 → COMMITTED;异常 → NEEDS_ATTENTION + SAFE MODE。
|
|
364
|
+
*/
|
|
365
|
+
async runExternalIntent<T>(opts: {
|
|
366
|
+
operationType: string;
|
|
367
|
+
lockCtx: MutationLockContext;
|
|
368
|
+
intent: { adapter: string; ref: string; kind: string };
|
|
369
|
+
fn: (ctx?: { operationId: string }) => Promise<T>;
|
|
370
|
+
}): Promise<{ operationId: string; result: T }> {
|
|
371
|
+
const { operationType, lockCtx, intent, fn } = opts;
|
|
372
|
+
const ownerInstanceId = (lockCtx?.token?.instanceId ?? 'unknown').toString();
|
|
373
|
+
// P1-A:ownership epoch identity = 真实 acquisition-specific ownerInstanceId(非环境稳定串)
|
|
374
|
+
const ownershipIdentity = ownerInstanceId;
|
|
375
|
+
const opId = generateOperationId();
|
|
376
|
+
const base = {
|
|
377
|
+
operationId: opId, ownerInstanceId, lockId: ownershipIdentity,
|
|
378
|
+
packageVersion: this.packageVersion, environmentFingerprint: this.environmentFingerprint || 'unknown',
|
|
379
|
+
};
|
|
380
|
+
await this.store.create(createJournalEntry(operationType, base, new Date().toISOString()));
|
|
381
|
+
try {
|
|
382
|
+
await this.store.update(opId, (j) => {
|
|
383
|
+
const extStep: import('./journal.ts').JournalStep = { adapter: intent.adapter, ref: intent.ref, kind: intent.kind, external: true, beforeFp: null, afterFp: null, status: 'planned', appliedAt: null };
|
|
384
|
+
const withStep = {
|
|
385
|
+
...j, plannedSteps: ['ext'], steps: { ext: extStep },
|
|
386
|
+
};
|
|
387
|
+
return transitionJournalState(withStep, 'APPLYING');
|
|
388
|
+
});
|
|
389
|
+
// Phase 4 F29/F30:把 operationId 暴露给 fn,使调用方(如 CLI reinstall)能在首个 destructive
|
|
390
|
+
// side effect 前写 durably-bound recovery point(operationId + 环境/版本元数据)。
|
|
391
|
+
const result = await fn({ operationId: opId });
|
|
392
|
+
await this.store.update(opId, (j) => {
|
|
393
|
+
let next = j;
|
|
394
|
+
if (next.state === 'SNAPSHOT_CREATED') next = transitionJournalState(next, 'APPLYING');
|
|
395
|
+
if (next.state === 'APPLYING') next = transitionJournalState(next, 'VALIDATING');
|
|
396
|
+
next = transitionJournalState(next, 'COMMITTED');
|
|
397
|
+
return { ...next, commit: { at: new Date().toISOString(), validated: true, validationWarnings: [] } };
|
|
398
|
+
});
|
|
399
|
+
await this.store.moveToCompleted(opId).catch(() => undefined);
|
|
400
|
+
return { operationId: opId, result };
|
|
401
|
+
} catch (err) {
|
|
402
|
+
const j = await this.store.loadActive(opId);
|
|
403
|
+
if (j !== null && !isTerminalState(j.state)) {
|
|
404
|
+
await this.store.update(opId, (cur) => transitionJournalState(cur, 'NEEDS_ATTENTION')).catch(() => undefined);
|
|
405
|
+
}
|
|
406
|
+
this.safeModeActive = true;
|
|
407
|
+
await this.store.writeSafeMode(true).catch(() => undefined);
|
|
408
|
+
throw err;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 3 P1-A(ownership binding 强制校验)+ P1-B(startup recovery barrier)的 targeted tests。
|
|
3
|
+
*/
|
|
4
|
+
import test from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import fssync from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { JournalStore, createJournalEntry, type OperationJournal } from './journal.ts';
|
|
10
|
+
import { reconcileActive } from './reconcile.ts';
|
|
11
|
+
import { StartupRecoveryController, classifyStartup } from './startup-barrier.ts';
|
|
12
|
+
import { Phase3Recovery } from './phase3-host.ts';
|
|
13
|
+
import type { ReconcileProbeHooks, ReconcileEnv } from './reconcile.ts';
|
|
14
|
+
import type { JournalStore as JS } from './journal.ts';
|
|
15
|
+
import fs from 'node:fs/promises';
|
|
16
|
+
|
|
17
|
+
function tmp(t: test.TestContext): string {
|
|
18
|
+
const dir = fssync.mkdtempSync(path.join(os.tmpdir(), 'p1-'));
|
|
19
|
+
t.after(() => fssync.rmSync(dir, { recursive: true, force: true }));
|
|
20
|
+
return dir;
|
|
21
|
+
}
|
|
22
|
+
function mkStore(dir: string): JS { return new JournalStore({ transactionsDir: path.join(dir, 'transactions') }); }
|
|
23
|
+
|
|
24
|
+
function incompleteJournal(id: string, ownerInstanceId: string, lockId: string): OperationJournal {
|
|
25
|
+
const j = createJournalEntry('import-apply', { operationId: id, ownerInstanceId, lockId, packageVersion: '0.1.54', environmentFingerprint: 'fp' }, 'x');
|
|
26
|
+
j.state = 'APPLYING';
|
|
27
|
+
j.plannedSteps = ['s1'];
|
|
28
|
+
j.steps = { s1: { adapter: 'settings', ref: 'ns', kind: 'Update', external: false, beforeFp: 'b', afterFp: null, status: 'planned', appliedAt: null } };
|
|
29
|
+
return j;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const hooks: ReconcileProbeHooks = {
|
|
33
|
+
verifyStepFingerprint: async () => 'after-match',
|
|
34
|
+
probeExternal: async () => 'not-installed',
|
|
35
|
+
snapshotExists: async () => true,
|
|
36
|
+
};
|
|
37
|
+
function env(over: Partial<ReconcileEnv> = {}): ReconcileEnv {
|
|
38
|
+
return { environmentFingerprint: 'fp', isLiveOwner: async () => false, ...over };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------- P1-A:ownership binding 强制校验 ----------
|
|
42
|
+
|
|
43
|
+
test('P1-A Test A:journal.ownerInstanceId != stale ownership → needs-attention,不作 trusted 恢复', async (t) => {
|
|
44
|
+
const dir = tmp(t); const store = mkStore(dir); const id = '00000000-0000-4000-8000-00000000a001';
|
|
45
|
+
await store.create(incompleteJournal(id, 'owner-A', 'owner-A'));
|
|
46
|
+
const out = await reconcileActive(store, hooks, env({ expectedOwnershipInstanceId: 'owner-B' }));
|
|
47
|
+
assert.equal(out.decisions[0]!.kind, 'needs-attention');
|
|
48
|
+
assert.equal(out.safeModeRequired, true);
|
|
49
|
+
assert.ok(await store.loadActive(id) !== null, 'binding mismatch 不得规整/回滚');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('P1-A Test B:journal.lockId != expected(伪造/跨 epoch)→ needs-attention', async (t) => {
|
|
53
|
+
const dir = tmp(t); const store = mkStore(dir); const id = '00000000-0000-4000-8000-00000000a002';
|
|
54
|
+
await store.create(incompleteJournal(id, 'owner-A', 'lock-forged')); // ownerInstanceId 对,lockId 伪装
|
|
55
|
+
const out = await reconcileActive(store, hooks, env({ expectedOwnershipInstanceId: 'owner-A' }));
|
|
56
|
+
assert.equal(out.decisions[0]!.kind, 'needs-attention', 'lockId 与 ownerInstanceId 不一致(epoch identity 不符)→ 拒绝');
|
|
57
|
+
assert.equal(out.safeModeRequired, true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('P1-A Test C:ownerInstanceId + lockId 都 match → 允许 reconcile(explicit recovery 可继续)', async (t) => {
|
|
61
|
+
const dir = tmp(t); const store = mkStore(dir); const id = '00000000-0000-4000-8000-00000000a003';
|
|
62
|
+
await store.create(incompleteJournal(id, 'owner-A', 'owner-A'));
|
|
63
|
+
const out = await reconcileActive(store, hooks, env({ expectedOwnershipInstanceId: 'owner-A' }));
|
|
64
|
+
// binding 通过 → 不被 binding 阻断(非 binding-mismatch needs-attention);据步证据决定 recovered/noop/rollback-recommended
|
|
65
|
+
assert.ok(['recovered', 'noop', 'rollback-recommended', 'needs-attention'].includes(out.decisions[0]!.kind));
|
|
66
|
+
if (out.decisions[0]!.kind === 'needs-attention') {
|
|
67
|
+
assert.ok(!out.decisions[0]!.reason.includes('binding'), 'binding 匹配时不得因 binding mismatch 判 needs-attention');
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('P1-A Test D:新 recovery 锁 instanceId != 旧 journal instanceId 是预期(不算 mismatch)', async (t) => {
|
|
72
|
+
// classifyStartup(lockState='STALE') + expectedOwnership=旧 journal 的 instanceId;
|
|
73
|
+
// 即便当前 recovery 进程的锁 instanceId 是新值,只要 journal 匹配 crashed 旧 instanceId 即通过。
|
|
74
|
+
const dir = tmp(t); const store = mkStore(dir); const id = '00000000-0000-4000-8000-00000000a004';
|
|
75
|
+
await store.create(incompleteJournal(id, 'crashed-owner', 'crashed-owner'));
|
|
76
|
+
const cls = classifyStartup({ store, hooks, env: env({ expectedOwnershipInstanceId: 'crashed-owner' }), lockState: 'STALE_LOCK_DETECTED' });
|
|
77
|
+
const { state } = await cls.classify();
|
|
78
|
+
// crashed journal 与 stale 匹配 → 不因「recovery 锁是新 instance」而误 rejects;有 unresolved → RECOVERY_REQUIRED
|
|
79
|
+
// (binding OK,只是待用户显式 recovery)
|
|
80
|
+
assert.ok(state.kind === 'RECOVERY_REQUIRED' || state.kind === 'NEEDS_ATTENTION', `binding 通过但 unresolved,实际=${state.kind}`);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('P1-A 跨 transaction mixup / forged ownership → 拒绝(不同 ownerInstanceId/lockId)', async (t) => {
|
|
84
|
+
const dir = tmp(t); const store = mkStore(dir); const id = '00000000-0000-4000-8000-00000000a005';
|
|
85
|
+
// 伪造 journal 指向「另一个 transaction B」的 owner
|
|
86
|
+
await store.create(incompleteJournal(id, 'owner-B', 'owner-B'));
|
|
87
|
+
const out = await reconcileActive(store, hooks, env({ expectedOwnershipInstanceId: 'owner-A' }));
|
|
88
|
+
assert.equal(out.decisions[0]!.kind, 'needs-attention', '跨 transaction 的 journal 不作 trusted 恢复');
|
|
89
|
+
assert.equal(out.safeModeRequired, true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ---------- P1-A 生产级:captureStaleOwnershipInstanceId 读取真实 environment.lock + classifyStartup binding ----------
|
|
93
|
+
|
|
94
|
+
async function writeStaleOwnership(dataDir: string, instanceId: string): Promise<void> {
|
|
95
|
+
await fs.mkdir(path.join(dataDir, 'locks'), { recursive: true });
|
|
96
|
+
await fs.writeFile(path.join(dataDir, 'locks', 'environment.lock'), JSON.stringify({ schemaVersion: 1, owner: { instanceId }, acquiredAt: Date.now(), op: 'crashed', target: 'x', lockVersion: '0.1.54', journalId: null }));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
test('P1-A 生产:stale ownership 捕获 + 匹配 journal → binding 通过(非 binding-mismatch needs-attention)', async (t) => {
|
|
100
|
+
const dir = tmp(t); const store = mkStore(dir); const id = '00000000-0000-4000-8000-00000000a006';
|
|
101
|
+
await writeStaleOwnership(dir, 'stale-owner');
|
|
102
|
+
await store.create(incompleteJournal(id, 'stale-owner', 'stale-owner'));
|
|
103
|
+
const recovery = new Phase3Recovery({ dataDir: dir, packageVersion: '0.1.54', environmentFingerprint: 'fp' });
|
|
104
|
+
const captured = await recovery.captureStaleOwnershipInstanceId();
|
|
105
|
+
assert.equal(captured, 'stale-owner', 'captureStaleOwnership 读到真实 environment.lock 的 owner.instanceId');
|
|
106
|
+
const cls = classifyStartup({ store, hooks, env: env({ expectedOwnershipInstanceId: captured! }), lockState: 'STALE_LOCK_DETECTED' });
|
|
107
|
+
const { state, safeModeRequired } = await cls.classify();
|
|
108
|
+
// binding 匹配 → 不被 binding 拒绝(RECOVERY_REQUIRED 是「待用户显式恢复」,非 binding-mismatch)
|
|
109
|
+
assert.ok(state.kind === 'RECOVERY_REQUIRED', `binding 通过 → RECOVERY_REQUIRED,实际=${state.kind}`);
|
|
110
|
+
void safeModeRequired;
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('P1-A 生产:stale ownerInstanceId mismatch → binding 拒绝(needs-attention)', async (t) => {
|
|
114
|
+
const dir = tmp(t); const store = mkStore(dir); const id = '00000000-0000-4000-8000-00000000a007';
|
|
115
|
+
await writeStaleOwnership(dir, 'stale-owner');
|
|
116
|
+
await store.create(incompleteJournal(id, 'other-owner', 'other-owner')); // journal 不属于该 stale ownership
|
|
117
|
+
const recovery = new Phase3Recovery({ dataDir: dir, packageVersion: '0.1.54', environmentFingerprint: 'fp' });
|
|
118
|
+
const captured = await recovery.captureStaleOwnershipInstanceId();
|
|
119
|
+
assert.equal(captured, 'stale-owner');
|
|
120
|
+
const cls = classifyStartup({ store, hooks, env: env({ expectedOwnershipInstanceId: captured! }), lockState: 'STALE_LOCK_DETECTED' });
|
|
121
|
+
const { state, safeModeRequired } = await cls.classify();
|
|
122
|
+
// binding 拒绝 → journal 不被 trusted(unresolved 残留);STALE 下 classify 归为 RECOVERY_REQUIRED + SAFE MODE(不自动恢复/不 trusted)
|
|
123
|
+
assert.ok(state.kind === 'RECOVERY_REQUIRED' || state.kind === 'NEEDS_ATTENTION', `ownership mismatch → 非 trusted 恢复态,实际=${state.kind}`);
|
|
124
|
+
assert.equal(safeModeRequired, true, 'binding 拒绝 → SAFE MODE');
|
|
125
|
+
// 该 journal 确为 binding-mismatch 决策(非 trusted rollback/resume)
|
|
126
|
+
const out = await reconcileActive(store, hooks, env({ expectedOwnershipInstanceId: captured! }));
|
|
127
|
+
const dec = out.decisions.find((d) => d.operationId === id);
|
|
128
|
+
assert.equal(dec!.kind, 'needs-attention');
|
|
129
|
+
assert.ok(dec!.reason.includes('binding'), '拒绝原因为 ownership binding 不匹配');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ---------- P1-B:startup recovery barrier ----------
|
|
133
|
+
|
|
134
|
+
test('P1-B race:classify pending → schedulers 未启动;NORMAL → start 恰一次', async (t) => {
|
|
135
|
+
let startCount = 0;
|
|
136
|
+
let release: () => void = () => {};
|
|
137
|
+
const gate = new Promise<void>((r) => { release = r; });
|
|
138
|
+
let started = false;
|
|
139
|
+
const controller = new StartupRecoveryController({
|
|
140
|
+
async classify() {
|
|
141
|
+
await gate; // injectable barrier:挂起
|
|
142
|
+
return { state: { kind: 'NORMAL' }, safeModeRequired: false, recoveryRequired: false };
|
|
143
|
+
},
|
|
144
|
+
}, { start: () => { startCount++; } });
|
|
145
|
+
started = controller.startSchedulersIfAllowed();
|
|
146
|
+
assert.equal(started, false, 'classification 完成前调度器不得启动');
|
|
147
|
+
assert.equal(startCount, 0, '等待期间 start 计数=0');
|
|
148
|
+
release();
|
|
149
|
+
const st = await controller.run();
|
|
150
|
+
assert.equal(st.kind, 'NORMAL');
|
|
151
|
+
assert.equal(controller.startSchedulersIfAllowed(), true, 'NORMAL 下启动');
|
|
152
|
+
assert.equal(controller.startSchedulersIfAllowed(), false, '幂等:二次调用不重复启动');
|
|
153
|
+
assert.equal(startCount, 1, 'start 恰一次');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('P1-B:RECOVERY_REQUIRED → schedulers 永不启动', async () => {
|
|
157
|
+
let startCount = 0;
|
|
158
|
+
const controller = new StartupRecoveryController({
|
|
159
|
+
async classify() { return { state: { kind: 'RECOVERY_REQUIRED', operationId: 'x' }, safeModeRequired: true, recoveryRequired: true }; },
|
|
160
|
+
}, { start: () => { startCount++; } });
|
|
161
|
+
const st = await controller.run();
|
|
162
|
+
assert.equal(st.kind, 'RECOVERY_REQUIRED');
|
|
163
|
+
assert.equal(controller.startSchedulersIfAllowed(), false);
|
|
164
|
+
assert.equal(startCount, 0);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('P1-B:UNKNOWN_STATE → schedulers 永不启动', async () => {
|
|
168
|
+
let startCount = 0;
|
|
169
|
+
const controller = new StartupRecoveryController({
|
|
170
|
+
async classify() { return { state: { kind: 'UNKNOWN_STATE', reason: 'no identity' }, safeModeRequired: true, recoveryRequired: false }; },
|
|
171
|
+
}, { start: () => { startCount++; } });
|
|
172
|
+
await controller.run();
|
|
173
|
+
assert.equal(controller.startSchedulersIfAllowed(), false);
|
|
174
|
+
assert.equal(startCount, 0);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('P1-B fail-closed:classify 抛错 → RECOVERY_REQUIRED,调度器不启动(不默认 NORMAL)', async () => {
|
|
178
|
+
let startCount = 0;
|
|
179
|
+
const controller = new StartupRecoveryController({
|
|
180
|
+
async classify() { throw new Error('inspect boom'); },
|
|
181
|
+
}, { start: () => { startCount++; } });
|
|
182
|
+
const st = await controller.run();
|
|
183
|
+
assert.equal(st.kind, 'RECOVERY_REQUIRED', 'inspect 抛错不得默认 NORMAL');
|
|
184
|
+
assert.equal(controller.startSchedulersIfAllowed(), false);
|
|
185
|
+
assert.equal(startCount, 0);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('P1-B:已有 safe-mode marker → startup 观察 → 调度器不启动(destructive blocked)', async (t) => {
|
|
189
|
+
const dir = tmp(t); const store = mkStore(dir);
|
|
190
|
+
await store.writeSafeMode(true);
|
|
191
|
+
const cls = classifyStartup({ store, hooks, env: env(), lockState: 'FREE' });
|
|
192
|
+
const { state } = await cls.classify();
|
|
193
|
+
let startCount = 0;
|
|
194
|
+
const controller = new StartupRecoveryController(cls, { start: () => { startCount++; } });
|
|
195
|
+
await controller.run();
|
|
196
|
+
assert.equal(startCount, 0, 'safe-mode marker 已存在 → destructive scheduler 不启动');
|
|
197
|
+
void state;
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('P1-B:corrupt journal startup → 分类为 needs-attention/quarantine,调度器不启动', async (t) => {
|
|
201
|
+
const dir = tmp(t); const store = mkStore(dir);
|
|
202
|
+
await store.ensureDirs();
|
|
203
|
+
const badId = '00000000-0000-4000-8000-00000000f0a0';
|
|
204
|
+
await import('node:fs/promises').then((fs) => fs.writeFile(path.join(dir, 'transactions', 'active', `${badId}.json`), 'not-json'));
|
|
205
|
+
const cls = classifyStartup({ store, hooks, env: env(), lockState: 'FREE' });
|
|
206
|
+
const { state } = await cls.classify();
|
|
207
|
+
assert.ok(state.kind === 'NEEDS_ATTENTION' || state.kind === 'RECOVERY_REQUIRED', `corrupt → ${state.kind}`);
|
|
208
|
+
let startCount = 0;
|
|
209
|
+
const controller = new StartupRecoveryController(cls, { start: () => { startCount++; } });
|
|
210
|
+
await controller.run();
|
|
211
|
+
assert.equal(startCount, 0, 'corrupt journal 分类完成前调度器不得启动(不 race classification)');
|
|
212
|
+
});
|