@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,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rollback(规范 §17 / 设计 §8.2):导入失败时逆序执行快照条目的补偿动作。
|
|
3
|
+
*
|
|
4
|
+
* 诚实原则:
|
|
5
|
+
* - 尽力回滚:单项失败不停止其余补偿,最终如实报告 full / partial;
|
|
6
|
+
* - credential 值 DSH 永不回读 → existed=true 的凭据无法自动恢复原值,
|
|
7
|
+
* 如实标记 failed + manualHint(人工补录);
|
|
8
|
+
* - settings 回滚仍走 expectedRevision 乐观锁,避免覆盖导入后用户的新修改。
|
|
9
|
+
*/
|
|
10
|
+
import { resolveFileTarget } from './backup.ts';
|
|
11
|
+
import { msgOf } from './messages.ts';
|
|
12
|
+
import type {
|
|
13
|
+
ConfigAdapter, HostContext, RollbackReport, Snapshot, SnapshotEntry, SnapshotStore,
|
|
14
|
+
} from './types.ts';
|
|
15
|
+
|
|
16
|
+
export interface RollbackOptions {
|
|
17
|
+
ctx: HostContext;
|
|
18
|
+
snapshot: Snapshot;
|
|
19
|
+
store?: SnapshotStore;
|
|
20
|
+
adapters?: ConfigAdapter[];
|
|
21
|
+
/** 回滚 WAL(Phase 3):每补偿完一条 entry 调用,供 Coordinator 记录 rollback.entryDone;
|
|
22
|
+
* crash during rollback 后 reconcile 从 WAL 判定已补偿/未补偿,避免盲目从头重做。 */
|
|
23
|
+
entryDone?: (entryIndex: number) => Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 逆序补偿单条快照条目;返回 null=成功,否则为失败原因 */
|
|
27
|
+
async function compensateOne(
|
|
28
|
+
entry: SnapshotEntry,
|
|
29
|
+
ctx: HostContext,
|
|
30
|
+
store: SnapshotStore | undefined,
|
|
31
|
+
): Promise<{ reason: string; manualHint?: string } | null> {
|
|
32
|
+
switch (entry.kind) {
|
|
33
|
+
case 'settingsNamespace': {
|
|
34
|
+
try {
|
|
35
|
+
if (!entry.existed) {
|
|
36
|
+
// 原目标不存在该 namespace:若导入已创建它,DSH settings 无删除语义 → 无法恢复「不存在」
|
|
37
|
+
let current: unknown = null;
|
|
38
|
+
try {
|
|
39
|
+
current = await ctx.settings.describe(entry.ref);
|
|
40
|
+
} catch {
|
|
41
|
+
current = null;
|
|
42
|
+
}
|
|
43
|
+
if (current === null) return null; // 本来就没有且未创建 → 无需恢复
|
|
44
|
+
return {
|
|
45
|
+
reason: msgOf(ctx)('rollback.ns.createdNoDelete'),
|
|
46
|
+
manualHint: msgOf(ctx)('rollback.ns.manualHint', { ref: entry.ref }),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
// 用当前 revision 作乐观锁基准:读时即锁,冲突则如实失败(不覆盖并发修改)
|
|
50
|
+
const current = await ctx.settings.describe(entry.ref);
|
|
51
|
+
await ctx.settings.replace(entry.ref, entry.before, current.revision);
|
|
52
|
+
return null;
|
|
53
|
+
} catch (err) {
|
|
54
|
+
return { reason: err instanceof Error ? err.message : String(err), manualHint: msgOf(ctx)('rollback.ns.conflictHint', { ref: entry.ref }) };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
case 'credential': {
|
|
58
|
+
if (entry.existed) {
|
|
59
|
+
// 值不可回读:无法自动恢复原值
|
|
60
|
+
return {
|
|
61
|
+
reason: msgOf(ctx)('rollback.cred.noReadback'),
|
|
62
|
+
manualHint: msgOf(ctx)('rollback.cred.manualHint', { ref: entry.ref }),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
await ctx.credentials.unset(entry.ref);
|
|
67
|
+
return null;
|
|
68
|
+
} catch (err) {
|
|
69
|
+
return { reason: err instanceof Error ? err.message : String(err) };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
case 'patchLine': {
|
|
73
|
+
try {
|
|
74
|
+
// 引擎只管理 profile 的 cordis.patch.yml(backup.ts 的 patchLine 快照只记 lineId 作 ref,
|
|
75
|
+
// 不含文件编码)——回滚固定写回该文件;切勿把 lineId 当文件名(否则 patchPath 抛「仅支持管理」)。
|
|
76
|
+
const file = 'cordis.patch.yml';
|
|
77
|
+
const lineId = entry.ref;
|
|
78
|
+
await ctx.patchFile.applyPatchChanges(file, [
|
|
79
|
+
{ lineId, raw: entry.before, action: entry.before === null ? 'remove' : 'update' },
|
|
80
|
+
]);
|
|
81
|
+
return null;
|
|
82
|
+
} catch (err) {
|
|
83
|
+
return { reason: err instanceof Error ? err.message : String(err), manualHint: msgOf(ctx)('rollback.patch.manualHint', { ref: entry.ref }) };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
case 'file': {
|
|
87
|
+
try {
|
|
88
|
+
const abs = resolveFileTarget(ctx, entry.adapter, entry.ref);
|
|
89
|
+
if (entry.existed && entry.copiedTo && store) {
|
|
90
|
+
const data = await store.readBlob(entry.snapshotId ?? '', entry.copiedTo);
|
|
91
|
+
await ctx.fs.writeFile(abs, data);
|
|
92
|
+
} else if (!entry.existed) {
|
|
93
|
+
// 原文件不存在 → 删除导入写入的文件
|
|
94
|
+
if (await ctx.fs.exists(abs)) await ctx.fs.remove(abs);
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
} catch (err) {
|
|
98
|
+
return { reason: err instanceof Error ? err.message : String(err), manualHint: msgOf(ctx)('rollback.file.manualHint', { ref: entry.ref }) };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
case 'workspaceRecord': {
|
|
102
|
+
try {
|
|
103
|
+
if (entry.before === null) {
|
|
104
|
+
await ctx.workspace.removeRecord?.(entry.ref);
|
|
105
|
+
} else {
|
|
106
|
+
await ctx.workspace.writeRecord(entry.before as never);
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
} catch (err) {
|
|
110
|
+
return { reason: err instanceof Error ? err.message : String(err), manualHint: msgOf(ctx)('rollback.workspace.manualHint', { ref: entry.ref }) };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
default:
|
|
114
|
+
return { reason: msgOf(ctx)('rollback.unknownEntry', { kind: entry.kind }) };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 回滚:逆序补偿全部条目;返回诚实报告(full / partial) */
|
|
119
|
+
export async function rollback(opts: RollbackOptions): Promise<RollbackReport> {
|
|
120
|
+
const { ctx, snapshot, store, adapters, entryDone } = opts;
|
|
121
|
+
const restored: string[] = [];
|
|
122
|
+
const failed: RollbackReport['failed'] = [];
|
|
123
|
+
|
|
124
|
+
// 逆序遍历;adapter 自带的 rollback? 优先,否则引擎通用补偿
|
|
125
|
+
const entries = [...snapshot.entries].reverse();
|
|
126
|
+
for (let index = 0; index < entries.length; index++) {
|
|
127
|
+
const entry = entries[index]!;
|
|
128
|
+
const adapter = adapters?.find((a) => a.id === entry.adapter);
|
|
129
|
+
let error: { reason: string; manualHint?: string } | null = null;
|
|
130
|
+
if (adapter?.rollback) {
|
|
131
|
+
try {
|
|
132
|
+
await adapter.rollback([entry], ctx);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
error = { reason: err instanceof Error ? err.message : String(err) };
|
|
135
|
+
}
|
|
136
|
+
} else {
|
|
137
|
+
error = await compensateOne(entry, ctx, store);
|
|
138
|
+
}
|
|
139
|
+
// 回滚 WAL:每补偿一项记 entryDone(crash 后可判定进度)
|
|
140
|
+
try {
|
|
141
|
+
await opts.entryDone?.(index);
|
|
142
|
+
} catch { /* WAL 写失败不阻断回滚(best-effort) */ }
|
|
143
|
+
if (error === null) {
|
|
144
|
+
restored.push(`${entry.adapter}:${entry.ref}`);
|
|
145
|
+
} else {
|
|
146
|
+
failed.push({ item: `${entry.adapter}:${entry.ref}`, reason: error.reason, manualHint: error.manualHint });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { full: failed.length === 0, restored, failed };
|
|
151
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* m4 测试补齐(任务 t4):m1 真实进度埋点 —— Exporter onSection / Analyzer onItem。
|
|
3
|
+
*
|
|
4
|
+
* 背景:m1 在 core 侧加入进度埋点(exporter.ts onSection、analyzer.ts onItem,
|
|
5
|
+
* importer.ts 透传),Host 路由用它们更新 RunRegistry(/progress 轮询数据源,
|
|
6
|
+
* m2 store / m3 ProgressBar 徽章消费)。既有 tests/core/exporter.test.ts 与
|
|
7
|
+
* tests/core/importer.test.ts 未覆盖埋点的参数语义与「埋点失败不影响业务」的
|
|
8
|
+
* 容错保证,本文件补齐(仅新增测试,不改业务代码)。
|
|
9
|
+
*
|
|
10
|
+
* 覆盖(验收 m4-green):
|
|
11
|
+
* - Exporter.onSection:每导出一个选中分区前调用一次;index 从 1 起严格递增、
|
|
12
|
+
* total=选中分区总数、section=adapter id;only 过滤时只对选中分区触发;
|
|
13
|
+
* 回调抛错不得中断导出(进度是尽力而为);
|
|
14
|
+
* - Analyzer.executeImportPlan onItem(经 Importer 透传):每个计划项完成后调用;
|
|
15
|
+
* index 1-based 递增、total=将执行计划项总数(含 skip 类信息项)、
|
|
16
|
+
* status=该项实际状态(ok/skipped…)、detail=计划项 id;
|
|
17
|
+
* 冲突未解决(skipped)与缺失秘密(skipped)同样计数;
|
|
18
|
+
* 回调抛错不得中断导入执行。
|
|
19
|
+
*/
|
|
20
|
+
import test from 'node:test'
|
|
21
|
+
import assert from 'node:assert/strict'
|
|
22
|
+
import fs from 'node:fs/promises'
|
|
23
|
+
import os from 'node:os'
|
|
24
|
+
import path from 'node:path'
|
|
25
|
+
|
|
26
|
+
import { Exporter, type SectionProgress } from './exporter.ts'
|
|
27
|
+
import { Importer } from './importer.ts'
|
|
28
|
+
import type { PlanItemProgress } from './analyzer.ts'
|
|
29
|
+
import { createAdapters } from '../adapters/index.ts'
|
|
30
|
+
import { makeContext, MemSnapshotStore, type MockHostContext } from '../adapters/test-helpers.ts'
|
|
31
|
+
import type { ImportPlan } from './types.ts'
|
|
32
|
+
|
|
33
|
+
const NS = ['general', 'llm-deepseek']
|
|
34
|
+
|
|
35
|
+
async function withTmp<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|
36
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-cm-run-progress-'))
|
|
37
|
+
try {
|
|
38
|
+
return await fn(dir)
|
|
39
|
+
} finally {
|
|
40
|
+
await fs.rm(dir, { recursive: true, force: true })
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 最小源:settings(general + llm-deepseek 带 apiKeyEnv/secret 标记) + credentials
|
|
45
|
+
* (llm-deepseek 的 secret 标记使 credentialsStatus 分区产生 configured 凭据,
|
|
46
|
+
* 导入时成为 MissingSecret 补录占位 —— 与 tests/core/importer.test.ts 的 I-01 同款) */
|
|
47
|
+
async function seedSource(ctx: MockHostContext): Promise<void> {
|
|
48
|
+
ctx.settings.ns.set('general', { value: { theme: 'dark', language: 'zh-CN' }, revision: 3, secrets: [] })
|
|
49
|
+
ctx.settings.ns.set('llm-deepseek', {
|
|
50
|
+
value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://api.deepseek.com', model: 'deepseek-chat' },
|
|
51
|
+
revision: 5,
|
|
52
|
+
secrets: [{ path: ['apiKey'], set: true }],
|
|
53
|
+
})
|
|
54
|
+
ctx.credentials.values.set('DEEPSEEK_API_KEY', 'sk-super-secret-value-123')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function exportFixture(src: MockHostContext, outPath: string): Promise<void> {
|
|
58
|
+
const adapters = createAdapters({ namespaces: NS })
|
|
59
|
+
await new Exporter({ ctx: src, adapters, now: () => new Date('2026-08-14T12:00:00.000Z') })
|
|
60
|
+
.export({ includeSecrets: false, outPath })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 与 Importer 默认参数一致的目标侧 importer */
|
|
64
|
+
function makeImporter(dst: MockHostContext): Importer {
|
|
65
|
+
return new Importer({
|
|
66
|
+
ctx: dst,
|
|
67
|
+
adapters: createAdapters({ namespaces: NS }),
|
|
68
|
+
snapshotStore: new MemSnapshotStore(),
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* ================================================= Exporter onSection 埋点 */
|
|
73
|
+
|
|
74
|
+
test('m1 埋点: onSection 每导出一个选中分区前调用(index 1-based 递增、total=选中数、section=adapter id)', async () => {
|
|
75
|
+
await withTmp(async (dir) => {
|
|
76
|
+
const src = makeContext('win32', 'C:\\Users\\alice')
|
|
77
|
+
await seedSource(src)
|
|
78
|
+
const adapters = createAdapters({ namespaces: NS })
|
|
79
|
+
const selected = adapters.filter((a) => a.defaultIncluded).map((a) => a.id)
|
|
80
|
+
assert.ok(selected.length >= 2, '默认选中分区应 ≥2(本最小源至少 settings+credentialsStatus)')
|
|
81
|
+
|
|
82
|
+
const calls: SectionProgress[] = []
|
|
83
|
+
await new Exporter({
|
|
84
|
+
ctx: src,
|
|
85
|
+
adapters,
|
|
86
|
+
now: () => new Date('2026-08-14T12:00:00.000Z'),
|
|
87
|
+
onSection: (info) => { calls.push(info) },
|
|
88
|
+
}).export({ includeSecrets: false, outPath: path.join(dir, 'sections.zip') })
|
|
89
|
+
|
|
90
|
+
assert.equal(calls.length, selected.length, '每个选中分区都应触发一次 onSection')
|
|
91
|
+
calls.forEach((c, i) => {
|
|
92
|
+
assert.equal(c.index, i + 1, `index 从 1 起严格递增(第 ${i} 次调用)`)
|
|
93
|
+
assert.equal(c.total, selected.length, 'total=选中分区总数')
|
|
94
|
+
assert.equal(c.section, selected[i], 'section=当前 adapter id,顺序与导出顺序一致')
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('m1 埋点: only 过滤时只对选中分区触发(index/total 按选中集计算)', async () => {
|
|
100
|
+
await withTmp(async (dir) => {
|
|
101
|
+
const src = makeContext('linux', '/home/alice')
|
|
102
|
+
await seedSource(src)
|
|
103
|
+
const adapters = createAdapters({ namespaces: NS })
|
|
104
|
+
const only = ['settings', 'plugins'] as const
|
|
105
|
+
|
|
106
|
+
const calls: SectionProgress[] = []
|
|
107
|
+
await new Exporter({
|
|
108
|
+
ctx: src,
|
|
109
|
+
adapters,
|
|
110
|
+
now: () => new Date('2026-08-14T12:00:00.000Z'),
|
|
111
|
+
onSection: (info) => { calls.push(info) },
|
|
112
|
+
}).export({ includeSecrets: false, only: [...only], outPath: path.join(dir, 'only.zip') })
|
|
113
|
+
|
|
114
|
+
assert.equal(calls.length, only.length, '只对 only 中选中的分区触发')
|
|
115
|
+
assert.deepEqual(calls.map((c) => c.section), [...only], '触发顺序与 adapters 注册顺序一致')
|
|
116
|
+
for (const c of calls) {
|
|
117
|
+
assert.equal(c.total, only.length, 'total=only 过滤后的选中分区数')
|
|
118
|
+
}
|
|
119
|
+
assert.equal(calls[0]?.index, 1, 'only 集内 index 从 1 起')
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('m1 埋点: onSection 抛错不影响导出(进度是尽力而为)', async () => {
|
|
124
|
+
await withTmp(async (dir) => {
|
|
125
|
+
const src = makeContext('win32', 'C:\\Users\\alice')
|
|
126
|
+
await seedSource(src)
|
|
127
|
+
const adapters = createAdapters({ namespaces: NS })
|
|
128
|
+
let calls = 0
|
|
129
|
+
|
|
130
|
+
const result = await new Exporter({
|
|
131
|
+
ctx: src,
|
|
132
|
+
adapters,
|
|
133
|
+
now: () => new Date('2026-08-14T12:00:00.000Z'),
|
|
134
|
+
onSection: (info) => {
|
|
135
|
+
calls += 1
|
|
136
|
+
if (calls === 1) throw new Error('埋点回调故障')
|
|
137
|
+
},
|
|
138
|
+
}).export({ includeSecrets: false, outPath: path.join(dir, 'resilient.zip') })
|
|
139
|
+
|
|
140
|
+
assert.ok(calls > 0, '埋点被触发过')
|
|
141
|
+
assert.ok(result.zipPath.endsWith('.zip'), '导出仍成功完成')
|
|
142
|
+
const stat = await fs.stat(result.zipPath)
|
|
143
|
+
assert.ok(stat.size > 0, 'ZIP 正常落盘')
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
/* ================================================= Analyzer onItem 埋点 */
|
|
148
|
+
|
|
149
|
+
test('m1 埋点: onItem 每个计划项完成后调用(index 递增、total=计划项数、status/detail 正确)', async () => {
|
|
150
|
+
await withTmp(async (dir) => {
|
|
151
|
+
const src = makeContext('win32', 'C:\\Users\\alice')
|
|
152
|
+
await seedSource(src)
|
|
153
|
+
const zipPath = path.join(dir, 'items.zip')
|
|
154
|
+
await exportFixture(src, zipPath)
|
|
155
|
+
|
|
156
|
+
const dst = makeContext('win32', 'C:\\Users\\bob')
|
|
157
|
+
dst.settings.registered.add('general')
|
|
158
|
+
dst.settings.registered.add('llm-deepseek')
|
|
159
|
+
const importer = makeImporter(dst)
|
|
160
|
+
|
|
161
|
+
const plan: ImportPlan = await importer.createImportPlan(zipPath, { strategy: 'merge', resolutions: {}, pathMappings: [] })
|
|
162
|
+
assert.ok(plan.items.length > 0, '计划应包含待执行项')
|
|
163
|
+
|
|
164
|
+
const calls: PlanItemProgress[] = []
|
|
165
|
+
const result = await importer.executeImportPlan(zipPath, plan, {
|
|
166
|
+
confirm: true,
|
|
167
|
+
onItem: (info) => { calls.push(info) },
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
assert.equal(result.ok, true, '导入成功')
|
|
171
|
+
assert.equal(calls.length, plan.items.length, '每个计划项都应触发一次 onItem')
|
|
172
|
+
calls.forEach((c, i) => {
|
|
173
|
+
assert.equal(c.index, i + 1, 'index 从 1 起严格递增')
|
|
174
|
+
assert.equal(c.total, calls.length, 'total=将执行计划项总数(含 skip 类信息项)')
|
|
175
|
+
assert.ok(
|
|
176
|
+
c.status === 'ok' || c.status === 'skipped' || c.status === 'warning' || c.status === 'failed',
|
|
177
|
+
`status 应为合法值,实际 ${c.status}`,
|
|
178
|
+
)
|
|
179
|
+
assert.ok(typeof c.detail === 'string' && c.detail !== '', 'detail=计划项 id(非敏感)')
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('m1 埋点: 冲突未解决(skipped)与缺失秘密(skipped)同样计数', async () => {
|
|
185
|
+
await withTmp(async (dir) => {
|
|
186
|
+
const src = makeContext('win32', 'C:\\Users\\alice')
|
|
187
|
+
await seedSource(src)
|
|
188
|
+
const zipPath = path.join(dir, 'conflict.zip')
|
|
189
|
+
await exportFixture(src, zipPath)
|
|
190
|
+
|
|
191
|
+
const dst = makeContext('win32', 'C:\\Users\\bob')
|
|
192
|
+
// 目标已有同名 general → merge 无决策 → Conflict → skipped
|
|
193
|
+
dst.settings.ns.set('general', { value: { theme: 'light' }, revision: 7, secrets: [] })
|
|
194
|
+
const importer = makeImporter(dst)
|
|
195
|
+
|
|
196
|
+
const plan = await importer.createImportPlan(zipPath, { strategy: 'merge', resolutions: {}, pathMappings: [] })
|
|
197
|
+
assert.ok(plan.items.some((i) => i.id === 'settings:general' && i.kind === 'Conflict'), '应有未决策冲突项')
|
|
198
|
+
assert.ok(plan.items.some((i) => i.kind === 'MissingSecret'), '应有缺失秘密补录占位')
|
|
199
|
+
|
|
200
|
+
const calls: PlanItemProgress[] = []
|
|
201
|
+
await importer.executeImportPlan(zipPath, plan, {
|
|
202
|
+
confirm: true,
|
|
203
|
+
onItem: (info) => { calls.push(info) },
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
const conflict = calls.find((c) => c.detail === 'settings:general')
|
|
207
|
+
assert.equal(conflict?.status, 'skipped', '冲突未解决 → skipped 且上报')
|
|
208
|
+
const secret = calls.find((c) => c.detail === 'secret:DEEPSEEK_API_KEY')
|
|
209
|
+
assert.equal(secret?.status, 'skipped', '秘密未补录 → skipped 且上报(不泄露值)')
|
|
210
|
+
// skipped 项同样占用 index/total 计数(进度条整体推进)
|
|
211
|
+
assert.ok(conflict !== undefined && conflict.index > 0, '冲突项计入进度计数')
|
|
212
|
+
assert.ok(secret !== undefined && secret.index > conflict!.index, '秘密项在冲突项之后计数')
|
|
213
|
+
})
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test('m1 埋点: onItem 抛错不影响导入执行(进度是尽力而为)', async () => {
|
|
217
|
+
await withTmp(async (dir) => {
|
|
218
|
+
const src = makeContext('win32', 'C:\\Users\\alice')
|
|
219
|
+
await seedSource(src)
|
|
220
|
+
const zipPath = path.join(dir, 'resilient.zip')
|
|
221
|
+
await exportFixture(src, zipPath)
|
|
222
|
+
|
|
223
|
+
const dst = makeContext('win32', 'C:\\Users\\bob')
|
|
224
|
+
dst.settings.registered.add('general')
|
|
225
|
+
dst.settings.registered.add('llm-deepseek')
|
|
226
|
+
const importer = makeImporter(dst)
|
|
227
|
+
|
|
228
|
+
const plan = await importer.createImportPlan(zipPath, { strategy: 'merge', resolutions: {}, pathMappings: [] })
|
|
229
|
+
const result = await importer.executeImportPlan(zipPath, plan, {
|
|
230
|
+
confirm: true,
|
|
231
|
+
onItem: () => { throw new Error('埋点回调故障') },
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
assert.equal(result.ok, true, '导入不受埋点故障影响')
|
|
235
|
+
assert.deepEqual(dst.settings.ns.get('general')?.value, { theme: 'dark', language: 'zh-CN' }, '数据正常写入')
|
|
236
|
+
})
|
|
237
|
+
})
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RunRegistry 基础单测(m1 自测,m4 会补全埋点/路由/轮询测试):
|
|
3
|
+
* - register:不可猜 runId、running 初始态、同 kind 并发拒绝(不同 kind 互不阻塞)
|
|
4
|
+
* - update:进度字段落账并刷新 updatedAt;完成/失败后晚到更新被忽略
|
|
5
|
+
* - finish/fail:状态与 result/error 落账
|
|
6
|
+
* - get/listActive:过滤语义与不存在处理
|
|
7
|
+
* - 保留期清理:超过 retentionMs 后不可见(含长期 running 僵死任务)
|
|
8
|
+
*/
|
|
9
|
+
import test from 'node:test'
|
|
10
|
+
import assert from 'node:assert/strict'
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
DEFAULT_RUN_RETENTION_MS, MAX_RUN_LOG_LINES, RunConflictError, RunRegistry,
|
|
14
|
+
} from './run-registry.ts'
|
|
15
|
+
|
|
16
|
+
test('register: 生成不可猜的 32 hex runId 且初始为 running', () => {
|
|
17
|
+
const reg = new RunRegistry()
|
|
18
|
+
const run = reg.register('export')
|
|
19
|
+
assert.match(run.runId, /^[0-9a-f]{32}$/)
|
|
20
|
+
assert.equal(run.status, 'running')
|
|
21
|
+
assert.equal(run.kind, 'export')
|
|
22
|
+
assert.equal(run.section, null)
|
|
23
|
+
assert.equal(run.item, null)
|
|
24
|
+
assert.ok(run.createdAt > 0)
|
|
25
|
+
assert.equal(run.updatedAt, run.createdAt)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('register: 同 kind 进行中拒绝新 run(RunConflictError 携带既有 runId)', () => {
|
|
29
|
+
const reg = new RunRegistry()
|
|
30
|
+
const first = reg.register('export')
|
|
31
|
+
assert.throws(
|
|
32
|
+
() => reg.register('export'),
|
|
33
|
+
(err: unknown) => {
|
|
34
|
+
assert.ok(err instanceof RunConflictError)
|
|
35
|
+
assert.equal((err as RunConflictError).runId, first.runId)
|
|
36
|
+
return true
|
|
37
|
+
},
|
|
38
|
+
)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('register: 不同 kind 互不阻塞(导出进行中可导入)', () => {
|
|
42
|
+
const reg = new RunRegistry()
|
|
43
|
+
reg.register('export')
|
|
44
|
+
const imp = reg.register('import')
|
|
45
|
+
assert.equal(imp.kind, 'import')
|
|
46
|
+
assert.equal(reg.listActive().length, 2)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('register: 完成/失败后的 run 不阻塞同 kind 新 run', () => {
|
|
50
|
+
const reg = new RunRegistry()
|
|
51
|
+
const first = reg.register('export')
|
|
52
|
+
reg.finish(first.runId, { ok: true })
|
|
53
|
+
const second = reg.register('export')
|
|
54
|
+
assert.notEqual(second.runId, first.runId)
|
|
55
|
+
|
|
56
|
+
const failed = reg.register('import')
|
|
57
|
+
reg.fail(failed.runId, 'boom')
|
|
58
|
+
const retry = reg.register('import')
|
|
59
|
+
assert.notEqual(retry.runId, failed.runId)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('update: 进度字段落账并刷新 updatedAt', () => {
|
|
63
|
+
let now = 1000
|
|
64
|
+
const reg = new RunRegistry({ now: () => now })
|
|
65
|
+
const run = reg.register('import')
|
|
66
|
+
now = 1200
|
|
67
|
+
reg.update(run.runId, { section: 'plugins', item: 3, itemTotal: 10, detail: 'plugin:pkg-a' })
|
|
68
|
+
const state = reg.get(run.runId)
|
|
69
|
+
assert.equal(state?.section, 'plugins')
|
|
70
|
+
assert.equal(state?.item, 3)
|
|
71
|
+
assert.equal(state?.itemTotal, 10)
|
|
72
|
+
assert.equal(state?.detail, 'plugin:pkg-a')
|
|
73
|
+
assert.equal(state?.updatedAt, 1200, 'update 必须刷新 updatedAt')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('update: 完成/失败后的晚到更新被忽略(异步回调竞态防御)', () => {
|
|
77
|
+
const reg = new RunRegistry()
|
|
78
|
+
const run = reg.register('export')
|
|
79
|
+
reg.update(run.runId, { detail: 'settings' })
|
|
80
|
+
reg.finish(run.runId, { zipPath: 'x.zip' })
|
|
81
|
+
reg.update(run.runId, { detail: 'late-write' })
|
|
82
|
+
const state = reg.get(run.runId)
|
|
83
|
+
assert.equal(state?.status, 'done')
|
|
84
|
+
assert.equal(state?.detail, 'settings', 'finish 后的 update 不得覆盖')
|
|
85
|
+
assert.deepEqual(state?.result, { zipPath: 'x.zip' })
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('appendLog: 追加执行日志行并刷新 updatedAt;不存在/已结束的 run 忽略', () => {
|
|
89
|
+
let now = 1000
|
|
90
|
+
const reg = new RunRegistry({ now: () => now })
|
|
91
|
+
const run = reg.register('import')
|
|
92
|
+
assert.deepEqual(run.log, [], '初始 log 为空数组')
|
|
93
|
+
now = 1100
|
|
94
|
+
reg.appendLog(run.runId, '▶ settings:general')
|
|
95
|
+
reg.appendLog(run.runId, '$ dsh plugin --profile web add @scope/pkg')
|
|
96
|
+
const state = reg.get(run.runId)
|
|
97
|
+
assert.deepEqual(state?.log, ['▶ settings:general', '$ dsh plugin --profile web add @scope/pkg'])
|
|
98
|
+
assert.equal(state?.updatedAt, 1100, 'appendLog 必须刷新 updatedAt')
|
|
99
|
+
|
|
100
|
+
// 完成后晚到追加被忽略(防御异步回调竞态)
|
|
101
|
+
reg.finish(run.runId, { ok: true })
|
|
102
|
+
reg.appendLog(run.runId, 'late')
|
|
103
|
+
assert.deepEqual(reg.get(run.runId)?.log, ['▶ settings:general', '$ dsh plugin --profile web add @scope/pkg'], 'finish 后的 appendLog 不得写入')
|
|
104
|
+
|
|
105
|
+
// 不存在 → undefined
|
|
106
|
+
assert.equal(reg.appendLog('nope', 'x'), undefined)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('appendLog: 日志行数封顶 MAX_RUN_LOG_LINES(超限截断保留最新)', () => {
|
|
110
|
+
const reg = new RunRegistry()
|
|
111
|
+
const run = reg.register('import')
|
|
112
|
+
for (let i = 0; i < MAX_RUN_LOG_LINES + 10; i++) {
|
|
113
|
+
reg.appendLog(run.runId, `line-${i}`)
|
|
114
|
+
}
|
|
115
|
+
const state = reg.get(run.runId)
|
|
116
|
+
assert.equal(state?.log.length, MAX_RUN_LOG_LINES)
|
|
117
|
+
assert.equal(state?.log[0], 'line-10', '截断后保留最新行')
|
|
118
|
+
assert.equal(state?.log[state!.log.length - 1], `line-${MAX_RUN_LOG_LINES + 9}`)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('appendLog: 不可变追加——每次 append 换新数组引用(React memo 感知新行;封顶后长度恒定但引用仍变)', () => {
|
|
122
|
+
const reg = new RunRegistry()
|
|
123
|
+
const run = reg.register('import')
|
|
124
|
+
const initial = reg.get(run.runId)!.log
|
|
125
|
+
reg.appendLog(run.runId, 'line-1')
|
|
126
|
+
const second = reg.get(run.runId)!.log
|
|
127
|
+
assert.notEqual(second, initial, 'append 必须生成新数组引用(不得原地 push)')
|
|
128
|
+
assert.deepEqual(second, ['line-1'])
|
|
129
|
+
|
|
130
|
+
// 500 行封顶后:长度恒定(memo 按长度比较会漏),但每次 append 引用必变
|
|
131
|
+
const capRun = reg.register('export')
|
|
132
|
+
const refs = new Set<unknown[]>()
|
|
133
|
+
for (let i = 0; i < MAX_RUN_LOG_LINES + 5; i++) {
|
|
134
|
+
reg.appendLog(capRun.runId, `line-${i}`)
|
|
135
|
+
refs.add(reg.get(capRun.runId)!.log)
|
|
136
|
+
}
|
|
137
|
+
assert.equal(reg.get(capRun.runId)!.log.length, MAX_RUN_LOG_LINES)
|
|
138
|
+
assert.equal(refs.size, MAX_RUN_LOG_LINES + 5, '每次 append 都是新引用(含封顶后)——前端以引用比较不会漏渲染')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('register: 快照恢复(restore)同 kind 进行中拒绝(P1-1 并发恢复防护)', () => {
|
|
142
|
+
const reg = new RunRegistry()
|
|
143
|
+
const first = reg.register('restore')
|
|
144
|
+
assert.throws(
|
|
145
|
+
() => reg.register('restore'),
|
|
146
|
+
(err: unknown) => {
|
|
147
|
+
assert.ok(err instanceof RunConflictError)
|
|
148
|
+
assert.equal((err as RunConflictError).runId, first.runId)
|
|
149
|
+
return true
|
|
150
|
+
},
|
|
151
|
+
)
|
|
152
|
+
// 恢复中允许导出(不同 kind 互不阻塞)
|
|
153
|
+
const exp = reg.register('export')
|
|
154
|
+
assert.equal(exp.kind, 'export')
|
|
155
|
+
// 完成后的 restore 不阻塞新 restore
|
|
156
|
+
reg.finish(first.runId, {})
|
|
157
|
+
const second = reg.register('restore')
|
|
158
|
+
assert.notEqual(second.runId, first.runId)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('register: recovery 同 kind 进行中拒绝(并发 recovery 防护)', () => {
|
|
162
|
+
const reg = new RunRegistry()
|
|
163
|
+
const first = reg.register('recovery')
|
|
164
|
+
assert.throws(
|
|
165
|
+
() => reg.register('recovery'),
|
|
166
|
+
(err: unknown) => {
|
|
167
|
+
assert.ok(err instanceof RunConflictError)
|
|
168
|
+
assert.equal((err as RunConflictError).runId, first.runId)
|
|
169
|
+
return true
|
|
170
|
+
},
|
|
171
|
+
)
|
|
172
|
+
// 恢复中允许导出(不同 kind 互不阻塞)
|
|
173
|
+
const exp = reg.register('export')
|
|
174
|
+
assert.equal(exp.kind, 'export')
|
|
175
|
+
// 完成后的 recovery 不阻塞新 recovery
|
|
176
|
+
reg.finish(first.runId, {})
|
|
177
|
+
const second = reg.register('recovery')
|
|
178
|
+
assert.notEqual(second.runId, first.runId)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('finish/fail: 状态与 result/error 落账', () => {
|
|
182
|
+
const reg = new RunRegistry()
|
|
183
|
+
const done = reg.register('export')
|
|
184
|
+
reg.finish(done.runId, { zipPath: 'dsh-config.zip', manifest: { schemaVersion: 1 } })
|
|
185
|
+
const after = reg.get(done.runId)
|
|
186
|
+
assert.equal(after?.status, 'done')
|
|
187
|
+
assert.deepEqual(after?.result, { zipPath: 'dsh-config.zip', manifest: { schemaVersion: 1 } })
|
|
188
|
+
|
|
189
|
+
const failed = reg.register('import')
|
|
190
|
+
reg.fail(failed.runId, '安装插件失败')
|
|
191
|
+
const f = reg.get(failed.runId)
|
|
192
|
+
assert.equal(f?.status, 'failed')
|
|
193
|
+
assert.equal(f?.error, '安装插件失败')
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test('get/listActive: 只列出 running run;不存在返回 undefined', () => {
|
|
197
|
+
const reg = new RunRegistry()
|
|
198
|
+
assert.equal(reg.get('nope'), undefined)
|
|
199
|
+
const a = reg.register('export')
|
|
200
|
+
const b = reg.register('import')
|
|
201
|
+
reg.finish(a.runId, {})
|
|
202
|
+
const active = reg.listActive()
|
|
203
|
+
assert.equal(active.length, 1)
|
|
204
|
+
assert.equal(active[0]?.runId, b.runId)
|
|
205
|
+
// 返回副本:外部改动不影响内部状态
|
|
206
|
+
active[0]!.detail = 'hacked'
|
|
207
|
+
assert.notEqual(reg.get(b.runId)?.detail, 'hacked')
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test('保留期清理: 超过 retentionMs 后不可见(含长期 running 僵死任务)', () => {
|
|
211
|
+
let now = 0
|
|
212
|
+
const reg = new RunRegistry({ retentionMs: 1000, now: () => now })
|
|
213
|
+
const run = reg.register('export')
|
|
214
|
+
now = 500
|
|
215
|
+
reg.finish(run.runId, { ok: true })
|
|
216
|
+
assert.ok(reg.get(run.runId), '保留期内可见')
|
|
217
|
+
now = 1501
|
|
218
|
+
assert.equal(reg.get(run.runId), undefined, '超过保留期清理')
|
|
219
|
+
assert.equal(reg.listActive().length, 0)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
test('保留期清理: 长时间不 settle 的 running run 同样被清理', () => {
|
|
223
|
+
let now = 0
|
|
224
|
+
const reg = new RunRegistry({ retentionMs: 60000, now: () => now })
|
|
225
|
+
reg.register('import')
|
|
226
|
+
now = 60001
|
|
227
|
+
assert.equal(reg.listActive().length, 0, '僵死 running run 也要清理')
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
test('默认保留期常量为 30 分钟', () => {
|
|
231
|
+
assert.equal(DEFAULT_RUN_RETENTION_MS, 30 * 60 * 1000)
|
|
232
|
+
})
|