akm-cli 0.9.0-rc.4 → 0.9.0-rc.6
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/CHANGELOG.md +122 -26
- package/README.md +7 -7
- package/SECURITY.md +11 -10
- package/dist/akm +2 -2
- package/dist/akm-migrate-storage +2 -2
- package/dist/assets/hints/cli-hints-full.md +60 -73
- package/dist/assets/hints/cli-hints-short.md +8 -12
- package/dist/assets/improve-strategies/default.json +5 -7
- package/dist/assets/improve-strategies/frequent.json +2 -2
- package/dist/assets/improve-strategies/proactive-maintenance.json +2 -2
- package/dist/assets/improve-strategies/quick.json +1 -1
- package/dist/assets/improve-strategies/reflect-distill.json +3 -3
- package/dist/assets/improve-strategies/thorough.json +1 -1
- package/dist/assets/prompts/consolidate-system.md +5 -5
- package/dist/assets/prompts/extract-session.md +2 -6
- package/dist/assets/prompts/reflect-llm-framed-contract.md +11 -0
- package/dist/assets/prompts/reflect-llm-schema-contract.md +3 -0
- package/dist/assets/prompts/reflect-output-repair.md +3 -0
- package/dist/assets/stash-skeleton/README.md +10 -11
- package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +1 -1
- package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +1 -1
- package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +1 -1
- package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +2 -2
- package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +2 -2
- package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +1 -1
- package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +2 -2
- package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +1 -1
- package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +18 -18
- package/dist/assets/stash-skeleton/facts/conventions/domains.md +8 -9
- package/dist/assets/stash-skeleton/facts/conventions/organization.md +13 -15
- package/dist/assets/tasks/core/extract.yml +2 -2
- package/dist/assets/tasks/core/improve.yml +1 -1
- package/dist/assets/templates/html/health.html +2 -3
- package/dist/cli/config-migrate.js +1328 -101
- package/dist/cli/invocation.js +255 -0
- package/dist/cli/parse-args.js +20 -83
- package/dist/cli/shared.js +15 -37
- package/dist/cli.js +18 -49
- package/dist/commands/agent/agent-dispatch.js +2 -2
- package/dist/commands/agent/contribute-cli.js +5 -4
- package/dist/commands/bundle/bundle-cli.js +68 -0
- package/dist/commands/bundle/bundle.js +219 -0
- package/dist/commands/completions.js +2 -2
- package/dist/commands/config-cli.js +6 -3
- package/dist/commands/env/env-binding.js +13 -9
- package/dist/commands/env/env-cli.js +49 -48
- package/dist/commands/env/secret-cli.js +24 -10
- package/dist/commands/events.js +2 -2
- package/dist/commands/feedback-cli.js +59 -33
- package/dist/commands/graph/graph.js +6 -4
- package/dist/commands/health/advisories.js +4 -4
- package/dist/commands/health/html-report.js +190 -593
- package/dist/commands/health/improve-metrics.js +240 -188
- package/dist/commands/health/metrics.js +9 -17
- package/dist/commands/health/report-view-model.js +509 -0
- package/dist/commands/health/surfaces.js +6 -3
- package/dist/commands/health/types-checks.js +4 -0
- package/dist/commands/health/types-improve.js +22 -0
- package/dist/commands/health/types-metrics.js +4 -0
- package/dist/commands/health/types-result.js +7 -0
- package/dist/commands/health/types-runs.js +4 -0
- package/dist/commands/health/types-session-log.js +4 -0
- package/dist/commands/health/types-windows.js +4 -0
- package/dist/commands/health/types.js +26 -21
- package/dist/commands/health/windows.js +1 -2
- package/dist/commands/health.js +219 -161
- package/dist/commands/improve/collapse-detector.js +25 -14
- package/dist/commands/improve/consolidate/chunking.js +7 -5
- package/dist/commands/improve/consolidate.js +640 -740
- package/dist/commands/improve/content-hash.js +39 -0
- package/dist/commands/improve/distill/content-repair.js +4 -10
- package/dist/commands/improve/distill/promote-memory.js +79 -49
- package/dist/commands/improve/distill/quality-gate.js +113 -35
- package/dist/commands/improve/distill-promotion-policy.js +24 -885
- package/dist/commands/improve/distill.js +565 -337
- package/dist/commands/improve/eligibility.js +75 -36
- package/dist/commands/improve/extract-cli.js +6 -6
- package/dist/commands/improve/extract-prompt.js +5 -34
- package/dist/commands/improve/extract.js +526 -357
- package/dist/commands/improve/feedback-valence.js +2 -12
- package/dist/commands/improve/improve-cli.js +11 -20
- package/dist/commands/improve/improve-result-file.js +7 -6
- package/dist/commands/improve/improve-run-types.js +4 -0
- package/dist/commands/improve/improve-strategies.js +3 -9
- package/dist/commands/improve/improve.js +822 -727
- package/dist/commands/improve/locks.js +27 -95
- package/dist/commands/improve/loop-stages.js +941 -963
- package/dist/commands/improve/memory/derived-ref.js +122 -0
- package/dist/commands/improve/memory/memory-contradiction-detect.js +35 -38
- package/dist/commands/improve/memory/memory-improve.js +24 -37
- package/dist/commands/improve/outcome-loop.js +13 -33
- package/dist/commands/improve/preparation.js +889 -691
- package/dist/commands/improve/proactive-maintenance.js +37 -9
- package/dist/commands/improve/proposal-envelope.js +31 -0
- package/dist/commands/improve/reflect.js +974 -588
- package/dist/commands/improve/run-context.js +119 -0
- package/dist/commands/improve/salience.js +8 -4
- package/dist/commands/improve/session-asset.js +7 -3
- package/dist/commands/improve/shared.js +14 -40
- package/dist/commands/improve/source-identity.js +39 -26
- package/dist/commands/improve/triage.js +20 -17
- package/dist/commands/lint/base-linter.js +288 -295
- package/dist/commands/lint/index.js +132 -19
- package/dist/commands/mv-cli.js +257 -234
- package/dist/commands/observability-cli.js +2 -1
- package/dist/commands/proposal/diff-format.js +50 -0
- package/dist/commands/proposal/drain-policies.js +0 -6
- package/dist/commands/proposal/drain.js +17 -16
- package/dist/commands/proposal/proposal-cli.js +40 -77
- package/dist/commands/proposal/proposal-types.js +56 -0
- package/dist/commands/proposal/proposal.js +39 -1
- package/dist/commands/proposal/propose.js +41 -19
- package/dist/commands/proposal/repository.js +566 -486
- package/dist/commands/proposal/validators/proposal-quality-validators.js +22 -6
- package/dist/commands/proposal/validators/proposal-validators.js +6 -5
- package/dist/commands/proposal/validators/proposals.js +5 -4
- package/dist/commands/read/curate.js +50 -28
- package/dist/commands/read/knowledge.js +25 -16
- package/dist/commands/read/remember-cli.js +12 -4
- package/dist/commands/read/search-cli.js +33 -33
- package/dist/commands/read/search.js +68 -55
- package/dist/commands/read/show.js +54 -135
- package/dist/commands/remember.js +7 -5
- package/dist/commands/sources/add-cli.js +10 -24
- package/dist/commands/sources/bundle-config-ops.js +58 -0
- package/dist/commands/sources/history.js +13 -8
- package/dist/commands/sources/info.js +10 -11
- package/dist/commands/sources/init.js +12 -21
- package/dist/commands/sources/installed-stashes.js +151 -138
- package/dist/commands/sources/schema-repair.js +5 -3
- package/dist/commands/sources/self-update.js +9 -7
- package/dist/commands/sources/source-add.js +116 -154
- package/dist/commands/sources/source-clone.js +33 -14
- package/dist/commands/sources/source-manage.js +43 -32
- package/dist/commands/sources/sources-cli.js +12 -7
- package/dist/commands/sources/stash-cli.js +12 -5
- package/dist/commands/sources/stash-skeleton.js +1 -1
- package/dist/commands/tasks/default-tasks.js +13 -13
- package/dist/commands/tasks/tasks-cli.js +3 -3
- package/dist/commands/tasks/tasks.js +71 -31
- package/dist/commands/workflow-cli.js +22 -26
- package/dist/core/action-contributors.js +1 -1
- package/dist/core/activation-policy.js +49 -0
- package/dist/core/adapter/adapters/agent-skills-adapter.js +181 -0
- package/dist/core/adapter/adapters/akm-adapter.js +509 -0
- package/dist/core/adapter/adapters/akm-lint.js +381 -0
- package/dist/core/adapter/adapters/akm-metadata.js +416 -0
- package/dist/core/adapter/adapters/akm-task-adapter.js +150 -0
- package/dist/core/adapter/adapters/akm-workflow-adapter.js +196 -0
- package/dist/core/adapter/adapters/claude-adapter.js +61 -0
- package/dist/core/adapter/adapters/dotenv-adapter.js +187 -0
- package/dist/core/adapter/adapters/generic-files-adapter.js +117 -0
- package/dist/core/adapter/adapters/index.js +80 -0
- package/dist/core/adapter/adapters/llm-wiki-adapter.js +413 -0
- package/dist/core/adapter/adapters/okf-adapter.js +223 -0
- package/dist/core/adapter/adapters/opencode-adapter.js +68 -0
- package/dist/core/adapter/adapters/shared.js +286 -0
- package/dist/core/adapter/adapters/tool-dir-shared.js +212 -0
- package/dist/core/adapter/adapters/website-snapshot-adapter.js +154 -0
- package/dist/core/adapter/bundle-adapter.js +4 -0
- package/dist/core/adapter/recognize-match.js +42 -0
- package/dist/core/adapter/registry.js +56 -0
- package/dist/core/adapter/types.js +4 -0
- package/dist/core/asset/asset-placement.js +229 -0
- package/dist/core/asset/asset-ref.js +107 -78
- package/dist/core/asset/asset-serialize.js +20 -0
- package/dist/core/asset/frontmatter.js +1 -1
- package/dist/core/asset/resolve-ref.js +223 -0
- package/dist/core/asset/stash-meta.js +1 -1
- package/dist/core/common.js +141 -49
- package/dist/core/config/config-io.js +0 -108
- package/dist/core/config/config-schema.js +94 -1039
- package/dist/core/config/config-sources.js +115 -36
- package/dist/core/config/config-types.js +16 -11
- package/dist/core/config/config-walker.js +4 -2
- package/dist/core/config/config.js +21 -26
- package/dist/core/config/engine-semantics.js +0 -4
- package/dist/core/config/schema/embedding.js +38 -0
- package/dist/core/config/schema/engines.js +116 -0
- package/dist/core/config/schema/feedback.js +31 -0
- package/dist/core/config/schema/improve-processes.js +395 -0
- package/dist/core/config/schema/improve.js +76 -0
- package/dist/core/config/schema/index-config.js +183 -0
- package/dist/core/config/schema/output.js +15 -0
- package/dist/core/config/schema/primitives.js +94 -0
- package/dist/core/config/schema/search.js +30 -0
- package/dist/core/config/schema/setup.js +27 -0
- package/dist/core/config/schema/sources-bundles.js +164 -0
- package/dist/core/config/schema/workflow.js +29 -0
- package/dist/core/env-secret-ref.js +113 -8
- package/dist/core/errors.js +1 -1
- package/dist/core/events-types.js +4 -0
- package/dist/core/extra-params.js +1 -0
- package/dist/core/file-change.js +16 -0
- package/dist/core/fs-txn.js +316 -0
- package/dist/core/git-message.js +59 -0
- package/dist/core/improve-result.js +9 -1
- package/dist/core/logs-db.js +1 -1
- package/dist/core/maintenance-barrier.js +16 -0
- package/dist/core/migration-backup.js +332 -160
- package/dist/core/paths.js +3 -6
- package/dist/core/platform.js +10 -0
- package/dist/core/recognition-util.js +130 -0
- package/dist/core/redaction.js +2 -2
- package/dist/core/standards/resolve-standards-context.js +47 -64
- package/dist/core/standards/resolve-type-conventions.js +3 -3
- package/dist/core/state/migrations.js +201 -8
- package/dist/core/state-db.js +38 -2
- package/dist/core/subprocess.js +303 -0
- package/dist/core/time.js +20 -0
- package/dist/core/type-presentation.js +130 -0
- package/dist/core/write-source.js +39 -67
- package/dist/indexer/bundle-identity-guard.js +91 -0
- package/dist/indexer/db/graph-db.js +1 -1
- package/dist/indexer/db/llm-cache.js +1 -1
- package/dist/indexer/ensure-index.js +29 -9
- package/dist/indexer/graph/graph-boost.js +28 -24
- package/dist/indexer/graph/graph-extraction.js +5 -6
- package/dist/indexer/graph/graph-types.js +4 -0
- package/dist/indexer/index-written-assets.js +44 -17
- package/dist/indexer/indexer.js +289 -153
- package/dist/indexer/init.js +18 -25
- package/dist/indexer/installations.js +224 -0
- package/dist/indexer/passes/dir-staleness.js +2 -1
- package/dist/indexer/passes/memory-inference.js +8 -4
- package/dist/indexer/passes/metadata.js +96 -243
- package/dist/indexer/scan/doc-to-entry.js +123 -0
- package/dist/indexer/scan/drain-dir.js +144 -0
- package/dist/indexer/search/db-search.js +171 -99
- package/dist/indexer/search/fts-query.js +1 -1
- package/dist/indexer/search/ranking-contributors.js +57 -7
- package/dist/indexer/search/ranking-types.js +4 -0
- package/dist/indexer/search/ranking.js +31 -6
- package/dist/indexer/search/search-attribution.js +67 -0
- package/dist/indexer/search/search-hit-enrichers.js +30 -40
- package/dist/indexer/search/search-source.js +109 -52
- package/dist/indexer/search/semantic-status.js +4 -1
- package/dist/indexer/usage/unmigrated-vaults-guard.js +2 -1
- package/dist/indexer/usage/usage-events.js +72 -9
- package/dist/indexer/walk/file-context.js +1 -44
- package/dist/indexer/walk/matchers.js +16 -32
- package/dist/indexer/walk/path-resolver.js +6 -5
- package/dist/indexer/walk/walker.js +4 -2
- package/dist/integrations/agent/engine-resolution.js +22 -0
- package/dist/integrations/agent/model-aliases.js +1 -1
- package/dist/integrations/agent/prompts.js +35 -11
- package/dist/integrations/agent/spawn.js +41 -270
- package/dist/integrations/harnesses/aider/agent-builder.js +2 -2
- package/dist/integrations/harnesses/aider/index.js +2 -11
- package/dist/integrations/harnesses/amazonq/agent-builder.js +1 -9
- package/dist/integrations/harnesses/amazonq/index.js +3 -16
- package/dist/integrations/harnesses/claude/config-import.js +1 -3
- package/dist/integrations/harnesses/claude/index.js +1 -12
- package/dist/integrations/harnesses/claude/session-log.js +27 -65
- package/dist/integrations/harnesses/codex/agent-builder.js +1 -2
- package/dist/integrations/harnesses/codex/index.js +2 -12
- package/dist/integrations/harnesses/copilot/agent-builder.js +2 -3
- package/dist/integrations/harnesses/copilot/index.js +1 -12
- package/dist/integrations/harnesses/gemini/agent-builder.js +1 -3
- package/dist/integrations/harnesses/gemini/index.js +1 -12
- package/dist/integrations/harnesses/ids.js +24 -0
- package/dist/integrations/harnesses/index.js +27 -2
- package/dist/integrations/harnesses/opencode/config-import.js +1 -3
- package/dist/integrations/harnesses/opencode/index.js +1 -12
- package/dist/integrations/harnesses/opencode/session-log.js +67 -110
- package/dist/integrations/harnesses/opencode-sdk/harness.js +2 -11
- package/dist/integrations/harnesses/openhands/agent-builder.js +3 -3
- package/dist/integrations/harnesses/openhands/index.js +2 -11
- package/dist/integrations/harnesses/pi/agent-builder.js +3 -11
- package/dist/integrations/harnesses/pi/index.js +3 -15
- package/dist/integrations/harnesses/shared.js +17 -0
- package/dist/integrations/harnesses/types.js +37 -2
- package/dist/integrations/lockfile.js +36 -0
- package/dist/integrations/session-logs/index.js +21 -12
- package/dist/integrations/session-logs/provider-base.js +113 -0
- package/dist/llm/client.js +7 -5
- package/dist/llm/embedders/deterministic.js +2 -2
- package/dist/llm/embedders/remote.js +8 -4
- package/dist/llm/graph-extract.js +7 -2
- package/dist/llm/metadata-enhance.js +43 -26
- package/dist/llm/structured-call.js +15 -7
- package/dist/migrate/legacy/config-source-migration.js +223 -0
- package/dist/migrate/legacy/content-migration.js +305 -0
- package/dist/migrate/legacy/legacy-layout.js +779 -0
- package/dist/migrate/legacy/legacy-paths.js +25 -0
- package/dist/migrate/legacy/legacy-stash-json.js +72 -0
- package/dist/migrate/legacy/proposal-fs-import.js +168 -0
- package/dist/migrate/legacy/task-target-ref-migration.js +272 -0
- package/dist/migrate/legacy/three-db-cutover.js +840 -0
- package/dist/migrate/legacy/workflow-migrations-bodies.js +52 -0
- package/dist/migrate/legacy/workflow-migrations-frozen.js +21 -0
- package/dist/migrate/legacy-ref-grammar.js +209 -0
- package/dist/output/command-registry.js +27 -0
- package/dist/output/html-render.js +11 -16
- package/dist/output/renderers.js +32 -276
- package/dist/output/shapes/passthrough.js +3 -9
- package/dist/output/shapes/registry.js +12 -6
- package/dist/output/text/command-format.js +547 -0
- package/dist/output/text/helpers.js +15 -1377
- package/dist/output/text/proposal-format.js +230 -0
- package/dist/output/text/registry.js +12 -6
- package/dist/output/text/show-directives.js +107 -0
- package/dist/output/text/show-format.js +103 -0
- package/dist/output/text/workflow-format.js +345 -0
- package/dist/output/text.js +1 -3
- package/dist/registry/build-index.js +13 -17
- package/dist/registry/providers/static-index.js +2 -2
- package/dist/registry/resolve.js +4 -90
- package/dist/registry/semver.js +93 -0
- package/dist/runtime.js +90 -0
- package/dist/schemas/akm-config.json +2956 -14890
- package/dist/schemas/akm-task.json +2 -2
- package/dist/schemas/akm-workflow.json +2 -2
- package/dist/scripts/migrate-storage.js +2312 -19642
- package/dist/setup/detect.js +5 -7
- package/dist/setup/engine-config.js +2 -1
- package/dist/setup/registry-stash-loader.js +1 -1
- package/dist/setup/semantic-assets.js +12 -9
- package/dist/setup/setup.js +109 -39
- package/dist/setup/steps/connection-shared.js +120 -0
- package/dist/setup/steps/connection.js +50 -274
- package/dist/setup/steps/platforms.js +1 -0
- package/dist/setup/steps/sources.js +13 -6
- package/dist/setup/steps/stashdir.js +5 -2
- package/dist/sources/freshness.js +39 -0
- package/dist/sources/providers/git-provider.js +29 -33
- package/dist/sources/providers/git-stash.js +10 -5
- package/dist/sources/providers/provider-utils.js +40 -18
- package/dist/sources/providers/website.js +1 -1
- package/dist/sources/resolve.js +5 -5
- package/dist/sources/snapshot-fetchers/types.js +4 -0
- package/dist/sources/{website-ingest.js → snapshot-fetchers/website-ingest.js} +105 -39
- package/dist/storage/database.js +47 -3
- package/dist/storage/engines/sqlite-migrations.js +34 -16
- package/dist/storage/locations.js +1 -2
- package/dist/storage/repositories/improve-runs-repository.js +0 -20
- package/dist/storage/repositories/index-connection.js +80 -0
- package/dist/storage/repositories/index-db.js +4 -3
- package/dist/storage/repositories/index-entries-repository.js +952 -0
- package/dist/{indexer/db/entry-mapper.js → storage/repositories/index-entry-mapper.js} +15 -2
- package/dist/storage/repositories/index-entry-types.js +4 -0
- package/dist/storage/repositories/index-fts-repository.js +164 -0
- package/dist/storage/repositories/index-llm-cache-repository.js +109 -0
- package/dist/storage/repositories/index-meta-repository.js +50 -0
- package/dist/{indexer/db/schema.js → storage/repositories/index-schema.js} +241 -100
- package/dist/storage/repositories/index-sql.js +12 -0
- package/dist/storage/repositories/index-utility-repository.js +369 -0
- package/dist/storage/repositories/index-vec-repository.js +245 -0
- package/dist/storage/repositories/proposals-repository.js +41 -50
- package/dist/storage/repositories/registry-cache.js +1 -1
- package/dist/storage/repositories/workflow-runs-repository.js +17 -11
- package/dist/tasks/backends/cron.js +5 -5
- package/dist/tasks/backends/exec-utils.js +21 -0
- package/dist/tasks/backends/index.js +6 -9
- package/dist/tasks/backends/launchd.js +16 -17
- package/dist/tasks/backends/schtasks.js +33 -41
- package/dist/tasks/backends/types.js +4 -0
- package/dist/tasks/parser.js +25 -5
- package/dist/tasks/runner.js +33 -39
- package/dist/tasks/validator.js +5 -6
- package/dist/workflows/authoring/authoring.js +8 -11
- package/dist/workflows/exec/brief.js +10 -7
- package/dist/workflows/exec/report.js +261 -156
- package/dist/workflows/exec/run-workflow.js +2 -1
- package/dist/workflows/exec/step-work.js +3 -9
- package/dist/workflows/program/parser.js +4 -60
- package/dist/workflows/renderer.js +11 -60
- package/dist/workflows/runtime/checkin.js +1 -1
- package/dist/workflows/runtime/plan-classifier.js +7 -4
- package/dist/workflows/runtime/runs.js +11 -10
- package/dist/workflows/runtime/unit-checkin.js +1 -1
- package/dist/workflows/runtime/unit-phases.js +20 -0
- package/dist/workflows/runtime/workflow-asset-loader.js +27 -16
- package/dist/workflows/validate-summary.js +2 -2
- package/dist/workflows/validator.js +18 -2
- package/docs/migration/release-notes/0.9.0.md +45 -3
- package/docs/migration/v0.8-to-v0.9.md +466 -262
- package/docs/{data-and-telemetry.md → reference/data-and-telemetry.md} +61 -17
- package/package.json +10 -9
- package/schemas/akm-config.json +2956 -14890
- package/schemas/akm-task.json +2 -2
- package/schemas/akm-workflow.json +2 -2
- package/dist/assets/help/help-accept.md +0 -12
- package/dist/assets/help/help-improve.md +0 -84
- package/dist/assets/help/help-proposals.md +0 -17
- package/dist/assets/help/help-propose.md +0 -17
- package/dist/assets/help/help-reject.md +0 -11
- package/dist/assets/improve-strategies/recombine-only.json +0 -23
- package/dist/assets/improve-strategies/synthesize.json +0 -17
- package/dist/assets/prompts/procedural-system.md +0 -44
- package/dist/assets/prompts/recombine-system.md +0 -40
- package/dist/assets/prompts/staleness-detect-system.md +0 -6
- package/dist/assets/templates/html/default.html +0 -78
- package/dist/assets/templates/html/vendor/echarts.min.js +0 -45
- package/dist/assets/wiki/index-template.md +0 -12
- package/dist/assets/wiki/ingest-workflow-template.md +0 -83
- package/dist/assets/wiki/log-template.md +0 -8
- package/dist/assets/wiki/schema-template.md +0 -61
- package/dist/commands/improve/calibration.js +0 -161
- package/dist/commands/improve/dedup.js +0 -482
- package/dist/commands/improve/hot-probation.js +0 -45
- package/dist/commands/improve/improve-auto-accept.js +0 -262
- package/dist/commands/improve/procedural.js +0 -403
- package/dist/commands/improve/recombine.js +0 -843
- package/dist/commands/improve/schema-similarity-gate.js +0 -168
- package/dist/commands/lint/agent-linter.js +0 -44
- package/dist/commands/lint/command-linter.js +0 -44
- package/dist/commands/lint/default-linter.js +0 -16
- package/dist/commands/lint/fact-linter.js +0 -39
- package/dist/commands/lint/knowledge-linter.js +0 -16
- package/dist/commands/lint/memory-linter.js +0 -61
- package/dist/commands/lint/registry.js +0 -41
- package/dist/commands/lint/skill-linter.js +0 -45
- package/dist/commands/lint/task-linter.js +0 -50
- package/dist/commands/lint/workflow-linter.js +0 -81
- package/dist/commands/proposal/legacy-import.js +0 -115
- package/dist/commands/wiki-cli.js +0 -291
- package/dist/core/asset/asset-registry.js +0 -76
- package/dist/core/asset/asset-spec.js +0 -316
- package/dist/core/eval/rank-metrics.js +0 -113
- package/dist/core/ripgrep/install.js +0 -163
- package/dist/core/ripgrep/resolve.js +0 -81
- package/dist/indexer/db/db.js +0 -1585
- package/dist/indexer/manifest.js +0 -170
- package/dist/indexer/passes/metadata-contributors.js +0 -31
- package/dist/integrations/harnesses/opencode-sdk/index.js +0 -25
- package/dist/output/text/wiki.js +0 -16
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +0 -18228
- package/dist/scripts/migrations/v16-to-v17.js +0 -141
- package/dist/storage/repositories/consolidation-repository.js +0 -38
- package/dist/storage/repositories/recombine-repository.js +0 -213
- package/dist/wiki/wiki-templates.js +0 -15
- package/dist/wiki/wiki.js +0 -1013
- package/dist/workflows/db.js +0 -413
- package/docs/README.md +0 -103
- package/docs/migration/release-notes/0.9.0-beta.60.md +0 -19
- /package/dist/sources/{wiki-fetchers → snapshot-fetchers}/registry.js +0 -0
- /package/dist/sources/{wiki-fetchers → snapshot-fetchers}/youtube.js +0 -0
|
@@ -3,19 +3,43 @@
|
|
|
3
3
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
4
|
import { createHash, randomUUID } from "node:crypto";
|
|
5
5
|
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
6
7
|
import path from "node:path";
|
|
7
8
|
import { MAX_CONFIG_FILE_BYTES, MAX_LOCAL_METADATA_BYTES, readTextFileWithLimit, writeFileAtomic, } from "../core/common.js";
|
|
8
9
|
import { parseAndValidateConfigText, resetConfigCache, sanitizeConfigForWrite, } from "../core/config/config.js";
|
|
9
|
-
import { backupExistingConfig, withConfigLock, writeConfigAtomic } from "../core/config/config-io.js";
|
|
10
|
+
import { backupExistingConfig, parseConfigText, readConfigText, withConfigLock, writeConfigAtomic, } from "../core/config/config-io.js";
|
|
10
11
|
import { ConfigError } from "../core/errors.js";
|
|
11
12
|
import { withMaintenanceStartBarrier } from "../core/maintenance-barrier.js";
|
|
12
13
|
import { assertNoArtifactReplacementBlockers, ensureMigrationBackupWithConfigLockHeld, fingerprintMigrationGeneration, getMigrationApplyJournalPath, getMigrationBackupDir, getMigrationBackupRoot, getMigrationRestoreJournalPath, inspectMigrationState, MIGRATION_BACKUP_VERSION, recoverInterruptedRestoreWithLocksHeld, restoreMigrationBackupWithLocksHeld, sameMigrationGeneration, verifyMigrationBackup, } from "../core/migration-backup.js";
|
|
13
|
-
import { getConfigPath,
|
|
14
|
+
import { getConfigPath, getDbPath, getStateDbPathInDataDir } from "../core/paths.js";
|
|
14
15
|
import { runMigrations as runStateMigrations } from "../core/state/migrations.js";
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
16
|
+
import { mergeLockEntriesSync } from "../integrations/lockfile.js";
|
|
17
|
+
import { migrateConfigSourcesToBundles, migratedLockEntries } from "../migrate/legacy/config-source-migration.js";
|
|
18
|
+
import { runContentMigration } from "../migrate/legacy/content-migration.js";
|
|
19
|
+
import { getLegacyWorkflowDbPath } from "../migrate/legacy/legacy-paths.js";
|
|
20
|
+
import { importLegacyProposalsIntoState } from "../migrate/legacy/proposal-fs-import.js";
|
|
21
|
+
import { applyTaskTargetRefMigration, planTaskTargetRefMigration } from "../migrate/legacy/task-target-ref-migration.js";
|
|
22
|
+
import { buildCutoverRefMap, cutoverMergeCommitted, deleteWorkflowDb, loadCutoverRefMap, migratePilotTreatmentFiles, quarantineIndexDb, runThreeDbCutover, } from "../migrate/legacy/three-db-cutover.js";
|
|
23
|
+
import { FROZEN_WORKFLOW_MIGRATIONS } from "../migrate/legacy/workflow-migrations-bodies.js";
|
|
24
|
+
import { requestGc } from "../runtime.js";
|
|
25
|
+
import { openDatabaseFinalizing } from "../storage/database.js";
|
|
26
|
+
import { runMigrations as runSqliteMigrations } from "../storage/engines/sqlite-migrations.js";
|
|
17
27
|
import { EXIT_CODES } from "./shared.js";
|
|
18
28
|
const MANUAL_GUIDANCE = "Provide a complete operator-prepared 0.9 config with --config. AKM does not guess profile-to-engine mappings.";
|
|
29
|
+
const APPLY_PHASE_ORDER = [
|
|
30
|
+
"prepared",
|
|
31
|
+
"state-converting",
|
|
32
|
+
"state-collapsing",
|
|
33
|
+
"state-applied",
|
|
34
|
+
"workflow-applied",
|
|
35
|
+
"cutover-applied",
|
|
36
|
+
"config-applied",
|
|
37
|
+
"tasks-prepared",
|
|
38
|
+
"tasks-applied",
|
|
39
|
+
"pilot-prepared",
|
|
40
|
+
"pilot-applied",
|
|
41
|
+
"committed",
|
|
42
|
+
];
|
|
19
43
|
function isFileFingerprint(value) {
|
|
20
44
|
if (value === null)
|
|
21
45
|
return true;
|
|
@@ -55,10 +79,57 @@ function isGenerationFingerprint(value) {
|
|
|
55
79
|
function sameArtifactFingerprint(left, right) {
|
|
56
80
|
return JSON.stringify(left) === JSON.stringify(right);
|
|
57
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Collapse state.db to a SINGLE FILE (DELETE journal) for the rest of the
|
|
84
|
+
* apply. A WAL-mode state.db carries `-wal`/`-shm` sidecars that the migration
|
|
85
|
+
* generation fingerprint tracks; a later read-only inspect (or a rolled-back
|
|
86
|
+
* cutover transaction) mutates them, which would trip the "state changed
|
|
87
|
+
* outside the journaled transition" rollback guard and REFUSE the fail-closed
|
|
88
|
+
* restore. In single-file mode a rolled-back transaction leaves state.db
|
|
89
|
+
* byte-identical, so the cutover's fail-closed rollback works. The runtime
|
|
90
|
+
* restores WAL on its next openStateDatabase.
|
|
91
|
+
*
|
|
92
|
+
* The resulting journal_mode is AUTHORITATIVE, not best-effort (issue #720):
|
|
93
|
+
* if state.db could not leave WAL mode — another PROCESS holds it open
|
|
94
|
+
* (same-process zombie closes are prevented by openDatabaseFinalizing's
|
|
95
|
+
* finalize-on-close guard) — the later cutover's rolled-back transaction
|
|
96
|
+
* would mutate `-wal` and the restore would be silently refused. Fail EARLY
|
|
97
|
+
* instead: journal.phase is still "state-converting" at the call site, so the
|
|
98
|
+
* outer catch restores config+state+workflow from the backup with a clear
|
|
99
|
+
* retry message.
|
|
100
|
+
*/
|
|
101
|
+
function collapseStateDbToSingleFile(db) {
|
|
102
|
+
const attempt = () => {
|
|
103
|
+
try {
|
|
104
|
+
const checkpoint = db.prepare("PRAGMA wal_checkpoint(TRUNCATE)").get();
|
|
105
|
+
if (checkpoint?.busy === 0)
|
|
106
|
+
crashInMutationGapForTests("state-checkpoint");
|
|
107
|
+
db.exec("PRAGMA journal_mode = DELETE");
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// Already single-file / nothing to checkpoint, or blocked — the
|
|
111
|
+
// read-back below is the authoritative signal either way.
|
|
112
|
+
}
|
|
113
|
+
return String(db.prepare("PRAGMA journal_mode").get()?.journal_mode ?? "").toLowerCase();
|
|
114
|
+
};
|
|
115
|
+
let journalMode = attempt();
|
|
116
|
+
if (journalMode === "wal") {
|
|
117
|
+
// A zombie-closed sibling connection ANYWHERE in this process (a close()
|
|
118
|
+
// with unfinalized prepare() statements outside the openDatabaseFinalizing
|
|
119
|
+
// set) also blocks the switch until GC finalizes it. Force a collection
|
|
120
|
+
// and retry once before concluding another PROCESS holds the database.
|
|
121
|
+
requestGc();
|
|
122
|
+
journalMode = attempt();
|
|
123
|
+
}
|
|
124
|
+
if (journalMode === "wal") {
|
|
125
|
+
throw new ConfigError("Cannot convert state.db out of WAL mode for migration — another akm process is holding it open. " +
|
|
126
|
+
"Close other akm processes and re-run `akm migrate apply`.", "INVALID_CONFIG_FILE");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
58
129
|
function hasGenerationMarker(dbPath, operationId, phase) {
|
|
59
130
|
if (!fs.existsSync(dbPath))
|
|
60
131
|
return false;
|
|
61
|
-
const db =
|
|
132
|
+
const db = openDatabaseFinalizing(dbPath, { readonly: true });
|
|
62
133
|
try {
|
|
63
134
|
if (!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='akm_migration_generation'").get()) {
|
|
64
135
|
return false;
|
|
@@ -66,12 +137,495 @@ function hasGenerationMarker(dbPath, operationId, phase) {
|
|
|
66
137
|
const rows = db
|
|
67
138
|
.prepare("SELECT operation_id, phase FROM akm_migration_generation WHERE singleton=1 LIMIT 2")
|
|
68
139
|
.all();
|
|
69
|
-
return rows.length === 1 && rows[0]
|
|
140
|
+
return rows.length === 1 && rows[0]?.operation_id === operationId && rows[0]?.phase === phase;
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
db.close();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function hasGenerationMarkerFromSnapshot(dbPath, operationId, phase) {
|
|
147
|
+
if (!fs.existsSync(dbPath))
|
|
148
|
+
return false;
|
|
149
|
+
const snapshot = createSqliteReadSnapshot(dbPath, "akm-migration-marker-");
|
|
150
|
+
try {
|
|
151
|
+
return hasGenerationMarker(snapshot.databasePath, operationId, phase);
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
snapshot.cleanup();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const STATE_SNAPSHOT_BUFFER_BYTES = 1024 * 1024;
|
|
158
|
+
class MigrationSnapshotChangedError extends ConfigError {
|
|
159
|
+
}
|
|
160
|
+
let migrationSnapshotHookForTests;
|
|
161
|
+
export function _setMigrationSnapshotHookForTests(hook) {
|
|
162
|
+
migrationSnapshotHookForTests = hook;
|
|
163
|
+
}
|
|
164
|
+
function updateCanonicalValue(hash, type, value) {
|
|
165
|
+
hash.update(`${Buffer.byteLength(type)}:`);
|
|
166
|
+
hash.update(type);
|
|
167
|
+
if (value === null) {
|
|
168
|
+
hash.update("-1:");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const byteLength = typeof value === "string" ? Buffer.byteLength(value) : value.byteLength;
|
|
172
|
+
hash.update(`${byteLength}:`);
|
|
173
|
+
hash.update(value);
|
|
174
|
+
}
|
|
175
|
+
export function canonicalStateGenerationSha256(db) {
|
|
176
|
+
const quoteIdentifier = (value) => `"${value.replaceAll('"', '""')}"`;
|
|
177
|
+
const hash = createHash("sha256");
|
|
178
|
+
for (const [name, query] of [
|
|
179
|
+
["application_id", "SELECT CAST(application_id AS TEXT) AS value FROM pragma_application_id"],
|
|
180
|
+
["encoding", "SELECT encoding AS value FROM pragma_encoding"],
|
|
181
|
+
["user_version", "SELECT CAST(user_version AS TEXT) AS value FROM pragma_user_version"],
|
|
182
|
+
]) {
|
|
183
|
+
const row = db.prepare(query).get();
|
|
184
|
+
if (!row)
|
|
185
|
+
throw new ConfigError(`Cannot fingerprint SQLite PRAGMA ${name}.`, "INVALID_CONFIG_FILE");
|
|
186
|
+
updateCanonicalValue(hash, "pragma-name", name);
|
|
187
|
+
updateCanonicalValue(hash, "pragma-value", row.value);
|
|
188
|
+
}
|
|
189
|
+
const schemaRows = db
|
|
190
|
+
.prepare("SELECT type, name, tbl_name, sql FROM sqlite_schema WHERE name <> 'akm_migration_generation' AND tbl_name <> 'akm_migration_generation' ORDER BY type COLLATE BINARY, name COLLATE BINARY, tbl_name COLLATE BINARY, sql COLLATE BINARY")
|
|
191
|
+
.iterate();
|
|
192
|
+
for (const row of schemaRows) {
|
|
193
|
+
updateCanonicalValue(hash, "schema-type", row.type);
|
|
194
|
+
updateCanonicalValue(hash, "schema-name", row.name);
|
|
195
|
+
updateCanonicalValue(hash, "schema-table", row.tbl_name);
|
|
196
|
+
updateCanonicalValue(hash, "schema-sql", row.sql);
|
|
197
|
+
}
|
|
198
|
+
const firstTable = db.prepare("SELECT name FROM sqlite_schema WHERE type='table' AND name <> 'akm_migration_generation' AND tbl_name <> 'akm_migration_generation' ORDER BY name COLLATE BINARY LIMIT 1");
|
|
199
|
+
const nextTable = db.prepare("SELECT name FROM sqlite_schema WHERE type='table' AND name <> 'akm_migration_generation' AND tbl_name <> 'akm_migration_generation' AND name COLLATE BINARY > ? ORDER BY name COLLATE BINARY LIMIT 1");
|
|
200
|
+
let tableRow = firstTable.get();
|
|
201
|
+
while (tableRow) {
|
|
202
|
+
const table = tableRow.name;
|
|
203
|
+
updateCanonicalValue(hash, "table", table);
|
|
204
|
+
const columns = [];
|
|
205
|
+
const columnRows = db
|
|
206
|
+
.prepare('SELECT CAST(cid AS TEXT) AS cid, name, type, CAST("notnull" AS TEXT) AS not_null, dflt_value, CAST(pk AS TEXT) AS pk, CAST(hidden AS TEXT) AS hidden FROM pragma_table_xinfo(?) ORDER BY cid')
|
|
207
|
+
.iterate(table);
|
|
208
|
+
for (const column of columnRows) {
|
|
209
|
+
columns.push(column.name);
|
|
210
|
+
updateCanonicalValue(hash, "column-cid", column.cid);
|
|
211
|
+
updateCanonicalValue(hash, "column-name", column.name);
|
|
212
|
+
updateCanonicalValue(hash, "column-type", column.type);
|
|
213
|
+
updateCanonicalValue(hash, "column-notnull", column.not_null);
|
|
214
|
+
updateCanonicalValue(hash, "column-default", column.dflt_value);
|
|
215
|
+
updateCanonicalValue(hash, "column-pk", column.pk);
|
|
216
|
+
updateCanonicalValue(hash, "column-hidden", column.hidden);
|
|
217
|
+
}
|
|
218
|
+
const projections = [];
|
|
219
|
+
const ordering = [];
|
|
220
|
+
const tableFlags = db
|
|
221
|
+
.prepare("SELECT CAST(wr AS TEXT) AS without_rowid FROM pragma_table_list WHERE schema='main' AND name=?")
|
|
222
|
+
.get(table);
|
|
223
|
+
if (!tableFlags) {
|
|
224
|
+
throw new ConfigError(`Cannot determine whether SQLite table ${JSON.stringify(table)} has a rowid.`, "INVALID_CONFIG_FILE");
|
|
225
|
+
}
|
|
226
|
+
const hasImplicitRowid = tableFlags.without_rowid === "0";
|
|
227
|
+
const declaredNames = new Set(columns.map((column) => column.toLowerCase()));
|
|
228
|
+
const rowidAlias = hasImplicitRowid
|
|
229
|
+
? ["rowid", "_rowid_", "oid"].find((alias) => !declaredNames.has(alias))
|
|
230
|
+
: undefined;
|
|
231
|
+
if (hasImplicitRowid && !rowidAlias) {
|
|
232
|
+
throw new ConfigError(`Cannot fingerprint SQLite table ${JSON.stringify(table)} because all implicit rowid aliases are shadowed.`, "INVALID_CONFIG_FILE");
|
|
233
|
+
}
|
|
234
|
+
if (rowidAlias) {
|
|
235
|
+
const rowidTypeAlias = quoteIdentifier("__akm_implicit_rowid_type");
|
|
236
|
+
const rowidValueAlias = quoteIdentifier("__akm_implicit_rowid_value");
|
|
237
|
+
projections.push(`'integer' AS ${rowidTypeAlias}`, `CAST(${quoteIdentifier(rowidAlias)} AS TEXT) AS ${rowidValueAlias}`);
|
|
238
|
+
ordering.push(`${rowidTypeAlias} COLLATE BINARY`, `${rowidValueAlias} COLLATE BINARY`);
|
|
239
|
+
}
|
|
240
|
+
for (const [index, column] of columns.entries()) {
|
|
241
|
+
const quotedColumn = quoteIdentifier(column);
|
|
242
|
+
const typeAlias = quoteIdentifier(`__akm_type_${index}`);
|
|
243
|
+
const valueAlias = quoteIdentifier(`__akm_value_${index}`);
|
|
244
|
+
projections.push(`typeof(${quotedColumn}) AS ${typeAlias}`, `CASE typeof(${quotedColumn}) WHEN 'integer' THEN CAST(${quotedColumn} AS TEXT) WHEN 'real' THEN printf('%!.17g', ${quotedColumn}) WHEN 'text' THEN CAST(${quotedColumn} AS BLOB) WHEN 'blob' THEN ${quotedColumn} ELSE NULL END AS ${valueAlias}`);
|
|
245
|
+
ordering.push(`${typeAlias} COLLATE BINARY`, `${valueAlias} COLLATE BINARY`);
|
|
246
|
+
}
|
|
247
|
+
const rowStatement = db.prepare(`SELECT ${projections.join(", ")} FROM ${quoteIdentifier(table)} ORDER BY ${ordering.join(", ")}`);
|
|
248
|
+
for (const row of rowStatement.iterate()) {
|
|
249
|
+
updateCanonicalValue(hash, "row", "");
|
|
250
|
+
if (rowidAlias) {
|
|
251
|
+
const rowidType = row.__akm_implicit_rowid_type;
|
|
252
|
+
const rowidValue = row.__akm_implicit_rowid_value;
|
|
253
|
+
if (rowidType !== "integer" || typeof rowidValue !== "string") {
|
|
254
|
+
throw new ConfigError("Cannot fingerprint an exact SQLite implicit rowid.", "INVALID_CONFIG_FILE");
|
|
255
|
+
}
|
|
256
|
+
updateCanonicalValue(hash, rowidType, rowidValue);
|
|
257
|
+
}
|
|
258
|
+
for (const [index] of columns.entries()) {
|
|
259
|
+
const type = row[`__akm_type_${index}`];
|
|
260
|
+
const value = row[`__akm_value_${index}`];
|
|
261
|
+
if (typeof type !== "string" ||
|
|
262
|
+
(value !== null && typeof value !== "string" && !(value instanceof Uint8Array))) {
|
|
263
|
+
throw new ConfigError("Cannot fingerprint an unsupported SQLite value.", "INVALID_CONFIG_FILE");
|
|
264
|
+
}
|
|
265
|
+
updateCanonicalValue(hash, type, value);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
tableRow = nextTable.get(table);
|
|
269
|
+
}
|
|
270
|
+
return hash.digest("hex");
|
|
271
|
+
}
|
|
272
|
+
function migrationSnapshotChangedError() {
|
|
273
|
+
return new MigrationSnapshotChangedError("A SQLite migration artifact changed while creating a private status snapshot; retry when writes are idle.", "INVALID_CONFIG_FILE");
|
|
274
|
+
}
|
|
275
|
+
function isSnapshotPathRaceError(error) {
|
|
276
|
+
const code = error.code;
|
|
277
|
+
return code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP" || code === "EISDIR";
|
|
278
|
+
}
|
|
279
|
+
function sameOpenStateSnapshotStat(left, right) {
|
|
280
|
+
return (left.dev === right.dev &&
|
|
281
|
+
left.ino === right.ino &&
|
|
282
|
+
left.size === right.size &&
|
|
283
|
+
left.mtimeNs === right.mtimeNs &&
|
|
284
|
+
left.ctimeNs === right.ctimeNs);
|
|
285
|
+
}
|
|
286
|
+
function assertOpenStateSnapshotIdentity(source) {
|
|
287
|
+
let stat;
|
|
288
|
+
try {
|
|
289
|
+
stat = fs.lstatSync(source.path, { bigint: true });
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
if (isSnapshotPathRaceError(error))
|
|
293
|
+
throw migrationSnapshotChangedError();
|
|
294
|
+
throw error;
|
|
295
|
+
}
|
|
296
|
+
if (!stat.isFile() || stat.dev !== source.dev || stat.ino !== source.ino)
|
|
297
|
+
throw migrationSnapshotChangedError();
|
|
298
|
+
}
|
|
299
|
+
function assertStateSnapshotPathAbsent(filePath) {
|
|
300
|
+
try {
|
|
301
|
+
fs.lstatSync(filePath);
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
if (error.code === "ENOENT")
|
|
305
|
+
return;
|
|
306
|
+
throw error;
|
|
307
|
+
}
|
|
308
|
+
throw migrationSnapshotChangedError();
|
|
309
|
+
}
|
|
310
|
+
function openStateSnapshotSource(filePath, optional = false) {
|
|
311
|
+
let pathStat;
|
|
312
|
+
try {
|
|
313
|
+
pathStat = fs.lstatSync(filePath, { bigint: true });
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
if (optional && error.code === "ENOENT")
|
|
317
|
+
return undefined;
|
|
318
|
+
if (isSnapshotPathRaceError(error))
|
|
319
|
+
throw migrationSnapshotChangedError();
|
|
320
|
+
throw error;
|
|
321
|
+
}
|
|
322
|
+
if (!pathStat.isFile()) {
|
|
323
|
+
throw new ConfigError(`Migration snapshot source is not a regular file: ${filePath}`, "INVALID_CONFIG_FILE");
|
|
324
|
+
}
|
|
325
|
+
let fd;
|
|
326
|
+
try {
|
|
327
|
+
fd = fs.openSync(filePath, "r");
|
|
328
|
+
}
|
|
329
|
+
catch (error) {
|
|
330
|
+
if (isSnapshotPathRaceError(error))
|
|
331
|
+
throw migrationSnapshotChangedError();
|
|
332
|
+
throw error;
|
|
333
|
+
}
|
|
334
|
+
try {
|
|
335
|
+
const fdStat = fs.fstatSync(fd, { bigint: true });
|
|
336
|
+
if (!fdStat.isFile() || fdStat.dev !== pathStat.dev || fdStat.ino !== pathStat.ino) {
|
|
337
|
+
throw migrationSnapshotChangedError();
|
|
338
|
+
}
|
|
339
|
+
return { path: filePath, fd, dev: fdStat.dev, ino: fdStat.ino };
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
fs.closeSync(fd);
|
|
343
|
+
throw error;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
function readStateSnapshotSource(source, destinationFd) {
|
|
347
|
+
assertOpenStateSnapshotIdentity(source);
|
|
348
|
+
const before = fs.fstatSync(source.fd, { bigint: true });
|
|
349
|
+
const hash = createHash("sha256");
|
|
350
|
+
const buffer = Buffer.allocUnsafe(STATE_SNAPSHOT_BUFFER_BYTES);
|
|
351
|
+
let position = 0;
|
|
352
|
+
while (true) {
|
|
353
|
+
const bytesRead = fs.readSync(source.fd, buffer, 0, buffer.length, position);
|
|
354
|
+
if (bytesRead === 0)
|
|
355
|
+
break;
|
|
356
|
+
hash.update(buffer.subarray(0, bytesRead));
|
|
357
|
+
if (destinationFd !== undefined) {
|
|
358
|
+
let written = 0;
|
|
359
|
+
while (written < bytesRead) {
|
|
360
|
+
written += fs.writeSync(destinationFd, buffer, written, bytesRead - written);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
position += bytesRead;
|
|
364
|
+
}
|
|
365
|
+
const after = fs.fstatSync(source.fd, { bigint: true });
|
|
366
|
+
assertOpenStateSnapshotIdentity(source);
|
|
367
|
+
if (!sameOpenStateSnapshotStat(before, after) || BigInt(position) !== after.size) {
|
|
368
|
+
throw migrationSnapshotChangedError();
|
|
369
|
+
}
|
|
370
|
+
return { bytes: position, sha256: hash.digest("hex") };
|
|
371
|
+
}
|
|
372
|
+
function fingerprintStateSnapshotSources(main, wal, walPath) {
|
|
373
|
+
const mainFingerprint = readStateSnapshotSource(main);
|
|
374
|
+
const walFingerprint = wal ? readStateSnapshotSource(wal) : null;
|
|
375
|
+
if (!wal)
|
|
376
|
+
assertStateSnapshotPathAbsent(walPath);
|
|
377
|
+
return { main: mainFingerprint, wal: walFingerprint };
|
|
378
|
+
}
|
|
379
|
+
function copyStateSnapshotSource(source, destination) {
|
|
380
|
+
const destinationFd = fs.openSync(destination, "wx", 0o600);
|
|
381
|
+
try {
|
|
382
|
+
const fingerprint = readStateSnapshotSource(source, destinationFd);
|
|
383
|
+
fs.fsyncSync(destinationFd);
|
|
384
|
+
return fingerprint;
|
|
385
|
+
}
|
|
386
|
+
finally {
|
|
387
|
+
fs.closeSync(destinationFd);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
function copyStateSnapshotSources(main, wal, walPath, databasePath) {
|
|
391
|
+
const mainFingerprint = copyStateSnapshotSource(main, databasePath);
|
|
392
|
+
const walFingerprint = wal ? copyStateSnapshotSource(wal, `${databasePath}-wal`) : null;
|
|
393
|
+
if (!wal)
|
|
394
|
+
assertStateSnapshotPathAbsent(walPath);
|
|
395
|
+
return { main: mainFingerprint, wal: walFingerprint };
|
|
396
|
+
}
|
|
397
|
+
function sameStateSnapshotFingerprint(left, right) {
|
|
398
|
+
return left === null || right === null ? left === right : left.bytes === right.bytes && left.sha256 === right.sha256;
|
|
399
|
+
}
|
|
400
|
+
function sameStateSnapshotGeneration(left, right) {
|
|
401
|
+
return sameStateSnapshotFingerprint(left.main, right.main) && sameStateSnapshotFingerprint(left.wal, right.wal);
|
|
402
|
+
}
|
|
403
|
+
function createSqliteReadSnapshot(sourcePath, tempPrefix, applyPhase) {
|
|
404
|
+
const walPath = `${sourcePath}-wal`;
|
|
405
|
+
const main = openStateSnapshotSource(sourcePath);
|
|
406
|
+
if (!main)
|
|
407
|
+
throw new ConfigError(`SQLite database not found: ${sourcePath}`, "INVALID_CONFIG_FILE");
|
|
408
|
+
let wal;
|
|
409
|
+
let snapshotDir;
|
|
410
|
+
let mainOpen = true;
|
|
411
|
+
let walOpen = false;
|
|
412
|
+
try {
|
|
413
|
+
wal = openStateSnapshotSource(walPath, true);
|
|
414
|
+
walOpen = wal !== undefined;
|
|
415
|
+
snapshotDir = fs.mkdtempSync(path.join(os.tmpdir(), tempPrefix));
|
|
416
|
+
fs.chmodSync(snapshotDir, 0o700);
|
|
417
|
+
const databasePath = path.join(snapshotDir, "state.db");
|
|
418
|
+
const before = fingerprintStateSnapshotSources(main, wal, walPath);
|
|
419
|
+
migrationSnapshotHookForTests?.({ sourcePath, ...(applyPhase ? { applyPhase } : {}) });
|
|
420
|
+
const copied = copyStateSnapshotSources(main, wal, walPath, databasePath);
|
|
421
|
+
const after = fingerprintStateSnapshotSources(main, wal, walPath);
|
|
422
|
+
if (!sameStateSnapshotGeneration(before, copied) || !sameStateSnapshotGeneration(before, after)) {
|
|
423
|
+
throw migrationSnapshotChangedError();
|
|
424
|
+
}
|
|
425
|
+
const completedDir = snapshotDir;
|
|
426
|
+
fs.closeSync(main.fd);
|
|
427
|
+
mainOpen = false;
|
|
428
|
+
if (wal) {
|
|
429
|
+
fs.closeSync(wal.fd);
|
|
430
|
+
walOpen = false;
|
|
431
|
+
}
|
|
432
|
+
snapshotDir = undefined;
|
|
433
|
+
return {
|
|
434
|
+
databasePath,
|
|
435
|
+
cleanup: () => fs.rmSync(completedDir, { recursive: true, force: true }),
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
finally {
|
|
439
|
+
if (mainOpen) {
|
|
440
|
+
try {
|
|
441
|
+
fs.closeSync(main.fd);
|
|
442
|
+
}
|
|
443
|
+
catch {
|
|
444
|
+
// The original snapshot failure remains authoritative.
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (walOpen && wal) {
|
|
448
|
+
try {
|
|
449
|
+
fs.closeSync(wal.fd);
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
// The original snapshot failure remains authoritative.
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (snapshotDir)
|
|
456
|
+
fs.rmSync(snapshotDir, { recursive: true, force: true });
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
function bindStateConvertingMarker(db, operationId) {
|
|
460
|
+
db.exec(`
|
|
461
|
+
CREATE TABLE IF NOT EXISTS akm_migration_generation (
|
|
462
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
463
|
+
operation_id TEXT NOT NULL,
|
|
464
|
+
phase TEXT NOT NULL,
|
|
465
|
+
generation_sha256 TEXT
|
|
466
|
+
)
|
|
467
|
+
`);
|
|
468
|
+
const columns = new Set(db.prepare("PRAGMA table_info(akm_migration_generation)").all().map((column) => column.name));
|
|
469
|
+
if (!columns.has("generation_sha256")) {
|
|
470
|
+
db.exec("ALTER TABLE akm_migration_generation ADD COLUMN generation_sha256 TEXT");
|
|
471
|
+
}
|
|
472
|
+
const generationSha256 = canonicalStateGenerationSha256(db);
|
|
473
|
+
db.prepare("INSERT INTO akm_migration_generation(singleton, operation_id, phase, generation_sha256) VALUES (1, ?, 'state-converting', ?) ON CONFLICT(singleton) DO UPDATE SET operation_id=excluded.operation_id, phase=excluded.phase, generation_sha256=excluded.generation_sha256").run(operationId, generationSha256);
|
|
474
|
+
}
|
|
475
|
+
function readBoundStateGenerationMarker(db, operationId) {
|
|
476
|
+
if (!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='akm_migration_generation'").get()) {
|
|
477
|
+
return undefined;
|
|
478
|
+
}
|
|
479
|
+
const columns = new Set(db.prepare("PRAGMA table_info(akm_migration_generation)").all().map((column) => column.name));
|
|
480
|
+
if (!columns.has("generation_sha256"))
|
|
481
|
+
return undefined;
|
|
482
|
+
const marker = db
|
|
483
|
+
.prepare("SELECT operation_id, phase, generation_sha256 FROM akm_migration_generation WHERE singleton=1")
|
|
484
|
+
.get();
|
|
485
|
+
const generationSha256 = marker?.generation_sha256;
|
|
486
|
+
if (!marker || marker.operation_id !== operationId || !generationSha256 || !/^[a-f0-9]{64}$/.test(generationSha256)) {
|
|
487
|
+
return undefined;
|
|
488
|
+
}
|
|
489
|
+
if (marker.phase !== "state-converting" && marker.phase !== "state-applied")
|
|
490
|
+
return undefined;
|
|
491
|
+
if (canonicalStateGenerationSha256(db) !== generationSha256) {
|
|
492
|
+
throw new ConfigError("state.db no longer matches the exact logical generation bound to its state-converting marker.", "INVALID_CONFIG_FILE");
|
|
493
|
+
}
|
|
494
|
+
return { phase: marker.phase, generationSha256 };
|
|
495
|
+
}
|
|
496
|
+
function readBoundStateGenerationMarkerAtPath(statePath, operationId) {
|
|
497
|
+
if (!fs.existsSync(statePath))
|
|
498
|
+
return undefined;
|
|
499
|
+
const db = openDatabaseFinalizing(statePath, { readonly: true, create: false });
|
|
500
|
+
try {
|
|
501
|
+
return readBoundStateGenerationMarker(db, operationId);
|
|
70
502
|
}
|
|
71
503
|
finally {
|
|
72
504
|
db.close();
|
|
73
505
|
}
|
|
74
506
|
}
|
|
507
|
+
function readBoundStateGenerationMarkerFromDisk(operationId) {
|
|
508
|
+
const statePath = getStateDbPathInDataDir();
|
|
509
|
+
if (!fs.existsSync(statePath))
|
|
510
|
+
return undefined;
|
|
511
|
+
const snapshot = createSqliteReadSnapshot(statePath, "akm-migration-state-");
|
|
512
|
+
try {
|
|
513
|
+
return readBoundStateGenerationMarkerAtPath(snapshot.databasePath, operationId);
|
|
514
|
+
}
|
|
515
|
+
finally {
|
|
516
|
+
snapshot.cleanup();
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
function cutoverMergeCommittedFromSnapshot(operationId) {
|
|
520
|
+
const statePath = getStateDbPathInDataDir();
|
|
521
|
+
if (!fs.existsSync(statePath))
|
|
522
|
+
return false;
|
|
523
|
+
const snapshot = createSqliteReadSnapshot(statePath, "akm-migration-state-");
|
|
524
|
+
try {
|
|
525
|
+
return cutoverMergeCommitted(snapshot.databasePath, operationId);
|
|
526
|
+
}
|
|
527
|
+
finally {
|
|
528
|
+
snapshot.cleanup();
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
function createMigrationInspectionSnapshots(applyPhase) {
|
|
532
|
+
const missingRoot = fs.mkdtempSync(path.join(os.tmpdir(), "akm-migration-inspect-"));
|
|
533
|
+
fs.chmodSync(missingRoot, 0o700);
|
|
534
|
+
const snapshots = [];
|
|
535
|
+
const capture = (sourcePath, prefix, name) => {
|
|
536
|
+
if (!fs.existsSync(sourcePath))
|
|
537
|
+
return path.join(missingRoot, name);
|
|
538
|
+
const snapshot = createSqliteReadSnapshot(sourcePath, prefix, applyPhase);
|
|
539
|
+
snapshots.push(snapshot);
|
|
540
|
+
return snapshot.databasePath;
|
|
541
|
+
};
|
|
542
|
+
try {
|
|
543
|
+
const paths = {
|
|
544
|
+
stateDbPath: capture(getStateDbPathInDataDir(), "akm-migration-state-", "state.db"),
|
|
545
|
+
workflowDbPath: capture(getLegacyWorkflowDbPath(), "akm-migration-workflow-", "workflow.db"),
|
|
546
|
+
indexDbPath: capture(getDbPath(), "akm-migration-index-", "index.db"),
|
|
547
|
+
};
|
|
548
|
+
return {
|
|
549
|
+
paths,
|
|
550
|
+
cleanup: () => {
|
|
551
|
+
for (const snapshot of snapshots)
|
|
552
|
+
snapshot.cleanup();
|
|
553
|
+
fs.rmSync(missingRoot, { recursive: true, force: true });
|
|
554
|
+
},
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
catch (error) {
|
|
558
|
+
for (const snapshot of snapshots)
|
|
559
|
+
snapshot.cleanup();
|
|
560
|
+
fs.rmSync(missingRoot, { recursive: true, force: true });
|
|
561
|
+
throw error;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
function captureMigrationInspection(applyPhase) {
|
|
565
|
+
const before = fingerprintMigrationGeneration();
|
|
566
|
+
const snapshots = createMigrationInspectionSnapshots(applyPhase);
|
|
567
|
+
try {
|
|
568
|
+
const after = fingerprintMigrationGeneration();
|
|
569
|
+
if (!sameMigrationGeneration(before, after))
|
|
570
|
+
throw migrationSnapshotChangedError();
|
|
571
|
+
return {
|
|
572
|
+
paths: snapshots.paths,
|
|
573
|
+
artifacts: inspectMigrationState(snapshots.paths),
|
|
574
|
+
generation: after,
|
|
575
|
+
cleanup: snapshots.cleanup,
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
catch (error) {
|
|
579
|
+
snapshots.cleanup();
|
|
580
|
+
throw error;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
function inspectMigrationStateFromSnapshots() {
|
|
584
|
+
const capture = captureMigrationInspection();
|
|
585
|
+
try {
|
|
586
|
+
return capture.artifacts;
|
|
587
|
+
}
|
|
588
|
+
finally {
|
|
589
|
+
capture.cleanup();
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
class MigrationPreflightGenerationError extends ConfigError {
|
|
593
|
+
}
|
|
594
|
+
let applyPreflightHookForTests;
|
|
595
|
+
export function _setApplyPreflightHookForTests(hook) {
|
|
596
|
+
applyPreflightHookForTests = hook;
|
|
597
|
+
}
|
|
598
|
+
function inspectExactApplyJournalGeneration(journal) {
|
|
599
|
+
if (journal.phase !== "state-applied" && journal.phase !== "workflow-applied") {
|
|
600
|
+
throw new ConfigError(`Cannot preflight migration journal phase ${journal.phase}.`, "INVALID_CONFIG_FILE");
|
|
601
|
+
}
|
|
602
|
+
applyPreflightHookForTests?.(journal.phase);
|
|
603
|
+
const capture = captureMigrationInspection(journal.phase);
|
|
604
|
+
try {
|
|
605
|
+
if (!sameMigrationGeneration(capture.generation, journal.generation)) {
|
|
606
|
+
throw new MigrationPreflightGenerationError(`Migration apply journal phase ${journal.phase} changed before its next mutation; the external generation was preserved.`, "INVALID_CONFIG_FILE");
|
|
607
|
+
}
|
|
608
|
+
return capture.artifacts;
|
|
609
|
+
}
|
|
610
|
+
finally {
|
|
611
|
+
capture.cleanup();
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
function readSingleFileBoundStateMarker(journal, stateSnapshotPath) {
|
|
615
|
+
const statePath = stateSnapshotPath ?? getStateDbPathInDataDir();
|
|
616
|
+
if (!fs.existsSync(statePath) || stateDatabaseHeaderUsesWal(statePath))
|
|
617
|
+
return undefined;
|
|
618
|
+
return stateSnapshotPath
|
|
619
|
+
? readBoundStateGenerationMarkerAtPath(statePath, journal.operationId)
|
|
620
|
+
: readBoundStateGenerationMarkerFromDisk(journal.operationId);
|
|
621
|
+
}
|
|
622
|
+
function advanceBoundStateMarker(db, operationId) {
|
|
623
|
+
const marker = readBoundStateGenerationMarker(db, operationId);
|
|
624
|
+
if (!marker) {
|
|
625
|
+
throw new ConfigError("state.db lacks its exact marker-bound conversion generation.", "INVALID_CONFIG_FILE");
|
|
626
|
+
}
|
|
627
|
+
db.prepare("UPDATE akm_migration_generation SET phase='state-applied' WHERE singleton=1 AND operation_id=?").run(operationId);
|
|
628
|
+
}
|
|
75
629
|
function generationFromBackup(manifest) {
|
|
76
630
|
const fingerprint = (name) => {
|
|
77
631
|
const artifact = manifest.artifacts[name];
|
|
@@ -83,30 +637,16 @@ function generationFromBackup(manifest) {
|
|
|
83
637
|
workflow: { main: fingerprint("workflow.db"), wal: null, shm: null },
|
|
84
638
|
};
|
|
85
639
|
}
|
|
86
|
-
function detectAdjacentGeneration(journal, manifest) {
|
|
640
|
+
function detectAdjacentGeneration(journal, manifest, live, current, workflowDbPath) {
|
|
87
641
|
if (journal.phase === "rollback-prepared") {
|
|
88
642
|
return {
|
|
89
|
-
rollbackCompleted: sameMigrationGeneration(
|
|
643
|
+
rollbackCompleted: sameMigrationGeneration(current, generationFromBackup(manifest)),
|
|
90
644
|
};
|
|
91
645
|
}
|
|
92
|
-
const live = inspectMigrationState();
|
|
93
|
-
const markerPresent = journal.phase === "prepared"
|
|
94
|
-
? hasGenerationMarker(getStateDbPathInDataDir(), journal.operationId, "state-applied")
|
|
95
|
-
: journal.phase === "state-applied"
|
|
96
|
-
? hasGenerationMarker(getWorkflowDbPath(), journal.operationId, "workflow-applied")
|
|
97
|
-
: false;
|
|
98
|
-
const current = fingerprintMigrationGeneration();
|
|
99
646
|
const unchanged = (...names) => names.every((name) => sameArtifactFingerprint(journal.generation[name], current[name]));
|
|
100
|
-
if (journal.phase === "
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
phase: "state-applied",
|
|
104
|
-
complete: live.state.status === "current" || live.state.status === "missing",
|
|
105
|
-
generation: current,
|
|
106
|
-
},
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
if (journal.phase === "state-applied" && unchanged("config", "state") && markerPresent) {
|
|
647
|
+
if (journal.phase === "state-applied" &&
|
|
648
|
+
unchanged("config", "state") &&
|
|
649
|
+
hasGenerationMarker(workflowDbPath, journal.operationId, "workflow-applied")) {
|
|
110
650
|
return {
|
|
111
651
|
adjacent: {
|
|
112
652
|
phase: "workflow-applied",
|
|
@@ -116,7 +656,11 @@ function detectAdjacentGeneration(journal, manifest) {
|
|
|
116
656
|
};
|
|
117
657
|
}
|
|
118
658
|
const expectedTarget = `${JSON.stringify(journal.targetConfig, null, 2)}\n`;
|
|
119
|
-
|
|
659
|
+
// Chunk 8, WI-8.2: config is written in the phase AFTER the cutover, so a crash
|
|
660
|
+
// in the config mutation gap leaves the journal at `cutover-applied` with the
|
|
661
|
+
// config already on disk. state (merged) + workflow (deleted) are unchanged
|
|
662
|
+
// since the cutover-applied advance — detect the config-applied adjacent.
|
|
663
|
+
if ((journal.phase === "workflow-applied" || journal.phase === "cutover-applied") &&
|
|
120
664
|
unchanged("state", "workflow") &&
|
|
121
665
|
fs.existsSync(getConfigPath()) &&
|
|
122
666
|
readTextFileWithLimit(getConfigPath(), MAX_CONFIG_FILE_BYTES, "Config file") === expectedTarget) {
|
|
@@ -129,11 +673,16 @@ function detectAdjacentGeneration(journal, manifest) {
|
|
|
129
673
|
function assertRollbackTransitionAllowed(journal, current) {
|
|
130
674
|
const unchanged = journal.phase === "prepared"
|
|
131
675
|
? ["config", "workflow"]
|
|
132
|
-
: journal.phase === "state-
|
|
133
|
-
? ["config", "
|
|
134
|
-
: journal.phase === "
|
|
135
|
-
? ["
|
|
136
|
-
:
|
|
676
|
+
: journal.phase === "state-converting"
|
|
677
|
+
? ["config", "workflow"]
|
|
678
|
+
: journal.phase === "state-applied"
|
|
679
|
+
? ["config", "state"]
|
|
680
|
+
: journal.phase === "workflow-applied" || journal.phase === "cutover-applied"
|
|
681
|
+
? // config is applied in the phase after the cutover, so a rollback from
|
|
682
|
+
// either only needs state + workflow unchanged (workflow=deleted is
|
|
683
|
+
// recorded in the journal's own generation and compares equal).
|
|
684
|
+
["state", "workflow"]
|
|
685
|
+
: ["config", "state", "workflow"];
|
|
137
686
|
for (const name of unchanged) {
|
|
138
687
|
if (!sameArtifactFingerprint(journal.generation[name], current[name])) {
|
|
139
688
|
throw new ConfigError(`Refusing migration rollback because ${name} changed outside the journaled ${journal.phase} transition.`, "INVALID_CONFIG_FILE");
|
|
@@ -153,8 +702,8 @@ function configMatchesBytes(expectedSize, expectedHash) {
|
|
|
153
702
|
return (fs.statSync(configPath).size === expectedSize &&
|
|
154
703
|
createHash("sha256").update(Buffer.from(text)).digest("hex") === expectedHash);
|
|
155
704
|
}
|
|
156
|
-
function validateApplyPhase(journal, manifest) {
|
|
157
|
-
const live = inspectMigrationState();
|
|
705
|
+
function validateApplyPhase(journal, manifest, inspectionPaths, inspectedState) {
|
|
706
|
+
const live = inspectedState ?? inspectMigrationState(inspectionPaths);
|
|
158
707
|
const stateOriginal = sameArtifactState(live.state, manifest.artifacts["state.db"]);
|
|
159
708
|
const workflowOriginal = sameArtifactState(live.workflow, manifest.artifacts["workflow.db"]);
|
|
160
709
|
const configOriginal = manifest.artifacts["config.json"].present
|
|
@@ -166,6 +715,11 @@ function validateApplyPhase(journal, manifest) {
|
|
|
166
715
|
const workflowApplied = manifest.artifacts["workflow.db"].present
|
|
167
716
|
? live.workflow.status === "current"
|
|
168
717
|
: live.workflow.status === "missing";
|
|
718
|
+
// Chunk 8, WI-8.2: at/after the cutover, workflow.db is DELETED (its rows are
|
|
719
|
+
// merged into state.db). A backed-up-present workflow.db that is now missing is
|
|
720
|
+
// the intended post-cutover terminal state, not a failure.
|
|
721
|
+
const workflowDeleted = manifest.artifacts["workflow.db"].present && live.workflow.status === "missing";
|
|
722
|
+
const workflowFinal = workflowApplied || workflowDeleted;
|
|
169
723
|
const expectedTarget = `${JSON.stringify(journal.targetConfig, null, 2)}\n`;
|
|
170
724
|
const configApplied = live.config.status === "current" &&
|
|
171
725
|
fs.existsSync(getConfigPath()) &&
|
|
@@ -174,16 +728,29 @@ function validateApplyPhase(journal, manifest) {
|
|
|
174
728
|
? true
|
|
175
729
|
: journal.phase === "prepared"
|
|
176
730
|
? configOriginal && workflowOriginal && (stateOriginal || stateApplied)
|
|
177
|
-
: journal.phase === "state-
|
|
178
|
-
?
|
|
179
|
-
: journal.phase === "
|
|
180
|
-
? stateApplied &&
|
|
181
|
-
:
|
|
731
|
+
: journal.phase === "state-converting" || journal.phase === "state-collapsing"
|
|
732
|
+
? configOriginal && workflowOriginal && (stateOriginal || stateApplied)
|
|
733
|
+
: journal.phase === "state-applied"
|
|
734
|
+
? stateApplied && configOriginal && (workflowOriginal || workflowApplied)
|
|
735
|
+
: journal.phase === "workflow-applied"
|
|
736
|
+
? stateApplied && workflowApplied && (configOriginal || configApplied)
|
|
737
|
+
: journal.phase === "cutover-applied"
|
|
738
|
+
? stateApplied && workflowFinal && (configOriginal || configApplied)
|
|
739
|
+
: // config-applied / tasks-* / pilot-* / committed
|
|
740
|
+
stateApplied && workflowFinal && configApplied;
|
|
182
741
|
if (!reachable) {
|
|
183
742
|
throw new ConfigError(`Migration apply journal phase ${journal.phase} does not match a reachable config/state/workflow artifact state.`, "INVALID_CONFIG_FILE");
|
|
184
743
|
}
|
|
744
|
+
return live;
|
|
185
745
|
}
|
|
186
|
-
function
|
|
746
|
+
function postCutoverArtifacts(journal, manifest, current, paths, artifacts) {
|
|
747
|
+
assertPostCutoverWorkflowAuthenticated(journal, current);
|
|
748
|
+
if (!cutoverMergeCommitted(paths.stateDbPath, journal.operationId)) {
|
|
749
|
+
throw new ConfigError(`Migration apply journal phase ${journal.phase} lacks its operation-bound cutover marker.`, "INVALID_CONFIG_FILE");
|
|
750
|
+
}
|
|
751
|
+
return validateApplyPhase(journal, manifest, paths, artifacts);
|
|
752
|
+
}
|
|
753
|
+
function readApplyJournalMetadata() {
|
|
187
754
|
const journalPath = getMigrationApplyJournalPath();
|
|
188
755
|
if (!fs.existsSync(journalPath))
|
|
189
756
|
return {};
|
|
@@ -192,9 +759,16 @@ function readApplyJournal() {
|
|
|
192
759
|
const value = JSON.parse(readTextFileWithLimit(journalPath, MAX_LOCAL_METADATA_BYTES, "Migration apply journal"));
|
|
193
760
|
const phases = [
|
|
194
761
|
"prepared",
|
|
762
|
+
"state-converting",
|
|
763
|
+
"state-collapsing",
|
|
195
764
|
"state-applied",
|
|
196
765
|
"workflow-applied",
|
|
766
|
+
"cutover-applied",
|
|
197
767
|
"config-applied",
|
|
768
|
+
"tasks-prepared",
|
|
769
|
+
"tasks-applied",
|
|
770
|
+
"pilot-prepared",
|
|
771
|
+
"pilot-applied",
|
|
198
772
|
"rollback-prepared",
|
|
199
773
|
"committed",
|
|
200
774
|
];
|
|
@@ -252,21 +826,293 @@ function readApplyJournal() {
|
|
|
252
826
|
if (manifest.runId !== journal.backupRunId || manifest.installationId !== journal.installationId) {
|
|
253
827
|
throw new ConfigError(`Migration apply journal backup provenance does not match its manifest.`, "INVALID_CONFIG_FILE");
|
|
254
828
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
829
|
+
return { journal, config, manifest };
|
|
830
|
+
}
|
|
831
|
+
catch (error) {
|
|
832
|
+
return {
|
|
833
|
+
journal,
|
|
834
|
+
error: `Unreadable migration apply journal at ${journalPath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
function journalArtifactState(artifact, status = artifact.status) {
|
|
839
|
+
return {
|
|
840
|
+
status,
|
|
841
|
+
...(artifact.migrationIds ? { migrationIds: artifact.migrationIds } : {}),
|
|
842
|
+
...(artifact.migrationChecksums ? { migrationChecksums: artifact.migrationChecksums } : {}),
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
function stateBeforeCompatibilityConversion(journal, manifest) {
|
|
846
|
+
const workflowBackup = manifest.artifacts["workflow.db"];
|
|
847
|
+
const indexBackup = manifest.artifacts["index.db"];
|
|
848
|
+
return {
|
|
849
|
+
config: journalArtifactState(manifest.artifacts["config.json"]),
|
|
850
|
+
state: { status: manifest.artifacts["state.db"].present ? "current" : "missing" },
|
|
851
|
+
workflow: journal.phase === "workflow-applied" && workflowBackup.present
|
|
852
|
+
? { status: "current" }
|
|
853
|
+
: journalArtifactState(workflowBackup),
|
|
854
|
+
index: indexBackup ? journalArtifactState(indexBackup) : { status: "missing" },
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
function isPreConversionCompatiblePhase(phase) {
|
|
858
|
+
return phase === "state-applied" || phase === "workflow-applied";
|
|
859
|
+
}
|
|
860
|
+
function isPostCutoverPhase(phase) {
|
|
861
|
+
return [
|
|
862
|
+
"cutover-applied",
|
|
863
|
+
"config-applied",
|
|
864
|
+
"tasks-prepared",
|
|
865
|
+
"tasks-applied",
|
|
866
|
+
"pilot-prepared",
|
|
867
|
+
"pilot-applied",
|
|
868
|
+
"committed",
|
|
869
|
+
].includes(phase);
|
|
870
|
+
}
|
|
871
|
+
function isAuthenticatedWorkflowAdjacent(journal, current, workflowSnapshotPath) {
|
|
872
|
+
if (journal.phase !== "state-applied")
|
|
873
|
+
return false;
|
|
874
|
+
const markerPresent = workflowSnapshotPath
|
|
875
|
+
? hasGenerationMarker(workflowSnapshotPath, journal.operationId, "workflow-applied")
|
|
876
|
+
: hasGenerationMarkerFromSnapshot(getLegacyWorkflowDbPath(), journal.operationId, "workflow-applied");
|
|
877
|
+
return (sameArtifactFingerprint(journal.generation.config, current.config) &&
|
|
878
|
+
sameArtifactFingerprint(journal.generation.state, current.state) &&
|
|
879
|
+
markerPresent);
|
|
880
|
+
}
|
|
881
|
+
function isAuthenticatedCutoverAdjacent(journal, current, stateSnapshotPath) {
|
|
882
|
+
return (journal.phase === "workflow-applied" &&
|
|
883
|
+
sameArtifactFingerprint(journal.generation.config, current.config) &&
|
|
884
|
+
workflowArtifactIsDeletionSubset(journal.generation, current) &&
|
|
885
|
+
(stateSnapshotPath
|
|
886
|
+
? cutoverMergeCommitted(stateSnapshotPath, journal.operationId)
|
|
887
|
+
: cutoverMergeCommittedFromSnapshot(journal.operationId)));
|
|
888
|
+
}
|
|
889
|
+
function workflowArtifactIsDeletionSubset(expected, current) {
|
|
890
|
+
return ["main", "wal", "shm"].every((component) => {
|
|
891
|
+
const actual = current.workflow[component];
|
|
892
|
+
return actual === null || JSON.stringify(actual) === JSON.stringify(expected.workflow[component]);
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
function assertPostCutoverWorkflowAuthenticated(journal, current) {
|
|
896
|
+
if (workflowArtifactIsDeletionSubset(journal.generation, current))
|
|
897
|
+
return;
|
|
898
|
+
throw new ConfigError(`Migration apply journal phase ${journal.phase} does not authorize the live workflow.db generation.`, "INVALID_CONFIG_FILE");
|
|
899
|
+
}
|
|
900
|
+
function readApplyJournal() {
|
|
901
|
+
const metadata = readApplyJournalMetadata();
|
|
902
|
+
if (metadata.error || !metadata.journal || !metadata.config || !metadata.manifest)
|
|
903
|
+
return metadata;
|
|
904
|
+
const { journal, config, manifest } = metadata;
|
|
905
|
+
let capture;
|
|
906
|
+
try {
|
|
907
|
+
capture = captureMigrationInspection();
|
|
908
|
+
}
|
|
909
|
+
catch (error) {
|
|
910
|
+
return {
|
|
911
|
+
journal,
|
|
912
|
+
config,
|
|
913
|
+
error: `Unreadable migration apply journal at ${getMigrationApplyJournalPath()}: ${error instanceof Error ? error.message : String(error)}`,
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
const rawGeneration = capture.generation;
|
|
917
|
+
const inspectedArtifacts = capture.artifacts;
|
|
918
|
+
try {
|
|
919
|
+
if (isPostCutoverPhase(journal.phase)) {
|
|
920
|
+
if (isTaskOnlyRepair(manifest)) {
|
|
921
|
+
const artifacts = validateApplyPhase(journal, manifest, capture.paths, inspectedArtifacts);
|
|
922
|
+
if (!sameMigrationGeneration(rawGeneration, journal.generation)) {
|
|
923
|
+
throw new ConfigError(`Migration apply journal phase ${journal.phase} does not match the exact live artifact generation.`, "INVALID_CONFIG_FILE");
|
|
924
|
+
}
|
|
925
|
+
return { journal, config, artifacts };
|
|
926
|
+
}
|
|
927
|
+
return {
|
|
928
|
+
journal,
|
|
929
|
+
config,
|
|
930
|
+
artifacts: postCutoverArtifacts(journal, manifest, rawGeneration, capture.paths, inspectedArtifacts),
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
if (!sameMigrationGeneration(rawGeneration, journal.generation) &&
|
|
934
|
+
isAuthenticatedCutoverAdjacent(journal, rawGeneration, capture.paths.stateDbPath)) {
|
|
935
|
+
const artifacts = postCutoverArtifacts({ ...journal, phase: "cutover-applied" }, manifest, rawGeneration, capture.paths, inspectedArtifacts);
|
|
936
|
+
return {
|
|
937
|
+
journal,
|
|
938
|
+
config,
|
|
939
|
+
artifacts,
|
|
940
|
+
adjacent: {
|
|
941
|
+
phase: "cutover-applied",
|
|
942
|
+
complete: true,
|
|
943
|
+
generation: rawGeneration,
|
|
944
|
+
},
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
if (isPreConversionCompatiblePhase(journal.phase)) {
|
|
948
|
+
const stateMarker = readSingleFileBoundStateMarker(journal, capture.paths.stateDbPath);
|
|
949
|
+
if (stateMarker?.phase === "state-applied") {
|
|
950
|
+
if (sameMigrationGeneration(rawGeneration, journal.generation)) {
|
|
951
|
+
return { journal, config, artifacts: inspectedArtifacts };
|
|
952
|
+
}
|
|
953
|
+
if (isAuthenticatedWorkflowAdjacent(journal, rawGeneration, capture.paths.workflowDbPath)) {
|
|
954
|
+
return {
|
|
955
|
+
journal,
|
|
956
|
+
config,
|
|
957
|
+
artifacts: inspectedArtifacts,
|
|
958
|
+
adjacent: {
|
|
959
|
+
phase: "workflow-applied",
|
|
960
|
+
complete: true,
|
|
961
|
+
generation: rawGeneration,
|
|
962
|
+
},
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
throw new ConfigError(`Migration apply journal phase ${journal.phase} does not match the exact live artifact generation.`, "INVALID_CONFIG_FILE");
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
if (isPreConversionCompatiblePhase(journal.phase)) {
|
|
969
|
+
if (sameMigrationGeneration(rawGeneration, journal.generation)) {
|
|
970
|
+
return {
|
|
971
|
+
journal,
|
|
972
|
+
config,
|
|
973
|
+
artifacts: stateBeforeCompatibilityConversion(journal, manifest),
|
|
974
|
+
requiresStateConversion: true,
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
if (isAuthenticatedWorkflowAdjacent(journal, rawGeneration, capture.paths.workflowDbPath)) {
|
|
978
|
+
return {
|
|
979
|
+
journal,
|
|
980
|
+
config,
|
|
981
|
+
artifacts: stateBeforeCompatibilityConversion({ ...journal, phase: "workflow-applied" }, manifest),
|
|
982
|
+
requiresStateConversion: true,
|
|
983
|
+
adjacent: {
|
|
984
|
+
phase: "workflow-applied",
|
|
985
|
+
complete: true,
|
|
986
|
+
generation: rawGeneration,
|
|
987
|
+
},
|
|
988
|
+
};
|
|
989
|
+
}
|
|
260
990
|
throw new ConfigError(`Migration apply journal phase ${journal.phase} does not match the exact live artifact generation.`, "INVALID_CONFIG_FILE");
|
|
261
991
|
}
|
|
262
|
-
|
|
992
|
+
if (journal.phase === "state-converting") {
|
|
993
|
+
if (sameMigrationGeneration(rawGeneration, journal.generation)) {
|
|
994
|
+
return {
|
|
995
|
+
journal,
|
|
996
|
+
config,
|
|
997
|
+
artifacts: stateBeforeCompatibilityConversion(journal, manifest),
|
|
998
|
+
requiresStateConversion: true,
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
const marker = readBoundStateGenerationMarkerAtPath(capture.paths.stateDbPath, journal.operationId);
|
|
1002
|
+
if (marker?.phase === "state-converting") {
|
|
1003
|
+
return {
|
|
1004
|
+
journal,
|
|
1005
|
+
config,
|
|
1006
|
+
artifacts: stateBeforeCompatibilityConversion(journal, manifest),
|
|
1007
|
+
requiresStateConversion: true,
|
|
1008
|
+
adjacent: {
|
|
1009
|
+
phase: "state-collapsing",
|
|
1010
|
+
complete: true,
|
|
1011
|
+
generation: rawGeneration,
|
|
1012
|
+
},
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
throw new ConfigError("Migration apply journal phase state-converting does not match its exact marker-bound generation.", "INVALID_CONFIG_FILE");
|
|
1016
|
+
}
|
|
1017
|
+
if (journal.phase === "state-collapsing") {
|
|
1018
|
+
if (sameMigrationGeneration(rawGeneration, journal.generation)) {
|
|
1019
|
+
return {
|
|
1020
|
+
journal,
|
|
1021
|
+
config,
|
|
1022
|
+
artifacts: stateBeforeCompatibilityConversion(journal, manifest),
|
|
1023
|
+
requiresStateConversion: true,
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
const marker = readBoundStateGenerationMarkerAtPath(capture.paths.stateDbPath, journal.operationId);
|
|
1027
|
+
if (marker) {
|
|
1028
|
+
return {
|
|
1029
|
+
journal,
|
|
1030
|
+
config,
|
|
1031
|
+
artifacts: stateBeforeCompatibilityConversion(journal, manifest),
|
|
1032
|
+
requiresStateConversion: true,
|
|
1033
|
+
adjacent: {
|
|
1034
|
+
phase: marker.phase === "state-applied" ? "state-applied" : "state-collapsing",
|
|
1035
|
+
complete: marker.phase === "state-applied",
|
|
1036
|
+
generation: rawGeneration,
|
|
1037
|
+
},
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
throw new ConfigError("Migration apply journal phase state-collapsing does not match its exact marker-bound generation.", "INVALID_CONFIG_FILE");
|
|
1041
|
+
}
|
|
1042
|
+
validateApplyPhase(journal, manifest, capture.paths, inspectedArtifacts);
|
|
1043
|
+
if (!sameMigrationGeneration(rawGeneration, journal.generation)) {
|
|
1044
|
+
const adjacent = detectAdjacentGeneration(journal, manifest, inspectedArtifacts, rawGeneration, capture.paths.workflowDbPath);
|
|
1045
|
+
if (adjacent.adjacent || adjacent.rollbackCompleted) {
|
|
1046
|
+
return { journal, config, artifacts: inspectedArtifacts, ...adjacent };
|
|
1047
|
+
}
|
|
1048
|
+
throw new ConfigError(`Migration apply journal phase ${journal.phase} does not match the exact live artifact generation.`, "INVALID_CONFIG_FILE");
|
|
1049
|
+
}
|
|
1050
|
+
return { journal, config, artifacts: inspectedArtifacts };
|
|
263
1051
|
}
|
|
264
1052
|
catch (error) {
|
|
265
1053
|
return {
|
|
266
1054
|
journal,
|
|
267
|
-
|
|
1055
|
+
config,
|
|
1056
|
+
...(isPreConversionCompatiblePhase(journal.phase) ||
|
|
1057
|
+
journal.phase === "state-converting" ||
|
|
1058
|
+
journal.phase === "state-collapsing"
|
|
1059
|
+
? {
|
|
1060
|
+
artifacts: stateBeforeCompatibilityConversion(journal, manifest),
|
|
1061
|
+
requiresStateConversion: true,
|
|
1062
|
+
}
|
|
1063
|
+
: { artifacts: inspectedArtifacts }),
|
|
1064
|
+
error: `Unreadable migration apply journal at ${getMigrationApplyJournalPath()}: ${error instanceof Error ? error.message : String(error)}`,
|
|
268
1065
|
};
|
|
269
1066
|
}
|
|
1067
|
+
finally {
|
|
1068
|
+
capture.cleanup();
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
function authenticatePreConversionJournalForApply() {
|
|
1072
|
+
const metadata = readApplyJournalMetadata();
|
|
1073
|
+
if (metadata.error)
|
|
1074
|
+
throw new ConfigError(metadata.error, "INVALID_CONFIG_FILE");
|
|
1075
|
+
const journal = metadata.journal;
|
|
1076
|
+
if (!journal || !isPreConversionCompatiblePhase(journal.phase))
|
|
1077
|
+
return;
|
|
1078
|
+
const current = fingerprintMigrationGeneration();
|
|
1079
|
+
if (isAuthenticatedCutoverAdjacent(journal, current))
|
|
1080
|
+
return;
|
|
1081
|
+
if (readSingleFileBoundStateMarker(journal)?.phase === "state-applied") {
|
|
1082
|
+
if (sameMigrationGeneration(current, journal.generation) ||
|
|
1083
|
+
isAuthenticatedWorkflowAdjacent(journal, current) ||
|
|
1084
|
+
isAuthenticatedCutoverAdjacent(journal, current)) {
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
throw new ConfigError(`Migration apply journal phase ${journal.phase} does not match the exact live artifact generation.`, "INVALID_CONFIG_FILE");
|
|
1088
|
+
}
|
|
1089
|
+
if (!sameMigrationGeneration(current, journal.generation) &&
|
|
1090
|
+
!isAuthenticatedWorkflowAdjacent(journal, current) &&
|
|
1091
|
+
!isAuthenticatedCutoverAdjacent(journal, current)) {
|
|
1092
|
+
throw new ConfigError(`Migration apply journal phase ${journal.phase} does not match the exact live artifact generation.`, "INVALID_CONFIG_FILE");
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
function preparePreConversionJournalForApply() {
|
|
1096
|
+
const metadata = readApplyJournalMetadata();
|
|
1097
|
+
if (metadata.error)
|
|
1098
|
+
throw new ConfigError(metadata.error, "INVALID_CONFIG_FILE");
|
|
1099
|
+
const journal = metadata.journal;
|
|
1100
|
+
if (!journal || !isPreConversionCompatiblePhase(journal.phase))
|
|
1101
|
+
return;
|
|
1102
|
+
let current = fingerprintMigrationGeneration();
|
|
1103
|
+
if (isAuthenticatedCutoverAdjacent(journal, current))
|
|
1104
|
+
return;
|
|
1105
|
+
if (readSingleFileBoundStateMarker(journal)?.phase === "state-applied")
|
|
1106
|
+
return;
|
|
1107
|
+
if (!sameMigrationGeneration(current, journal.generation)) {
|
|
1108
|
+
if (!isAuthenticatedWorkflowAdjacent(journal, current)) {
|
|
1109
|
+
throw new ConfigError(`Migration apply journal phase ${journal.phase} does not match the exact live artifact generation.`, "INVALID_CONFIG_FILE");
|
|
1110
|
+
}
|
|
1111
|
+
current = fingerprintMigrationGeneration();
|
|
1112
|
+
}
|
|
1113
|
+
journal.phase = "state-converting";
|
|
1114
|
+
journal.generation = current;
|
|
1115
|
+
writeApplyJournal(journal);
|
|
270
1116
|
}
|
|
271
1117
|
function writeApplyJournal(journal) {
|
|
272
1118
|
fs.mkdirSync(path.dirname(getMigrationApplyJournalPath()), { recursive: true, mode: 0o700 });
|
|
@@ -278,8 +1124,7 @@ function writeApplyJournal(journal) {
|
|
|
278
1124
|
writeFileAtomic(getMigrationApplyJournalPath(), serialized, 0o600);
|
|
279
1125
|
}
|
|
280
1126
|
function advanceApplyJournal(journal, phase) {
|
|
281
|
-
|
|
282
|
-
if (order.indexOf(phase) > order.indexOf(journal.phase))
|
|
1127
|
+
if (APPLY_PHASE_ORDER.indexOf(phase) > APPLY_PHASE_ORDER.indexOf(journal.phase))
|
|
283
1128
|
journal.phase = phase;
|
|
284
1129
|
journal.generation = fingerprintMigrationGeneration();
|
|
285
1130
|
writeApplyJournal(journal);
|
|
@@ -307,11 +1152,344 @@ function crashInMutationGapForTests(phase) {
|
|
|
307
1152
|
if (process.env.AKM_TEST_MIGRATION_CRASH_GAP === phase)
|
|
308
1153
|
process.kill(process.pid, "SIGKILL");
|
|
309
1154
|
}
|
|
1155
|
+
function isForwardRecoveryPhase(phase) {
|
|
1156
|
+
return [
|
|
1157
|
+
"state-collapsing",
|
|
1158
|
+
"cutover-applied",
|
|
1159
|
+
"config-applied",
|
|
1160
|
+
"tasks-prepared",
|
|
1161
|
+
"tasks-applied",
|
|
1162
|
+
"pilot-prepared",
|
|
1163
|
+
"pilot-applied",
|
|
1164
|
+
"committed",
|
|
1165
|
+
].includes(phase);
|
|
1166
|
+
}
|
|
1167
|
+
/** Publish a preflighted task-target batch inside the journal's forward-only region. */
|
|
1168
|
+
function runTaskTargetMigrationStep(journal, plan) {
|
|
1169
|
+
applyTaskTargetRefMigration(plan);
|
|
1170
|
+
crashInMutationGapForTests("tasks");
|
|
1171
|
+
advanceApplyJournal(journal, "tasks-applied");
|
|
1172
|
+
crashAfterForTests("tasks");
|
|
1173
|
+
}
|
|
1174
|
+
function isTaskOnlyRepair(manifest) {
|
|
1175
|
+
return (manifest.artifacts["config.json"].status === "current" &&
|
|
1176
|
+
["current", "missing"].includes(manifest.artifacts["state.db"].status) &&
|
|
1177
|
+
manifest.artifacts["workflow.db"].status === "missing");
|
|
1178
|
+
}
|
|
1179
|
+
function runTaskOnlyRepair(journal, plan) {
|
|
1180
|
+
advanceApplyJournal(journal, "tasks-prepared");
|
|
1181
|
+
if (journal.phase === "tasks-prepared")
|
|
1182
|
+
runTaskTargetMigrationStep(journal, plan);
|
|
1183
|
+
advanceApplyJournal(journal, "committed");
|
|
1184
|
+
clearApplyJournal();
|
|
1185
|
+
}
|
|
1186
|
+
function runStateMigrationStep(journal) {
|
|
1187
|
+
if (journal.phase === "prepared") {
|
|
1188
|
+
advanceApplyJournal(journal, "state-converting");
|
|
1189
|
+
crashAfterForTests("state-converting");
|
|
1190
|
+
}
|
|
1191
|
+
if (journal.phase === "state-converting") {
|
|
1192
|
+
const db = openDatabaseFinalizing(getStateDbPathInDataDir());
|
|
1193
|
+
try {
|
|
1194
|
+
const marker = readBoundStateGenerationMarker(db, journal.operationId);
|
|
1195
|
+
if (marker?.phase === "state-applied") {
|
|
1196
|
+
throw new ConfigError("state.db is ahead of its state-converting journal.", "INVALID_CONFIG_FILE");
|
|
1197
|
+
}
|
|
1198
|
+
if (!marker) {
|
|
1199
|
+
db.transaction(() => {
|
|
1200
|
+
runStateMigrations(db);
|
|
1201
|
+
bindStateConvertingMarker(db, journal.operationId);
|
|
1202
|
+
})();
|
|
1203
|
+
}
|
|
1204
|
+
else {
|
|
1205
|
+
runStateMigrations(db, { applyPending: false });
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
finally {
|
|
1209
|
+
db.close();
|
|
1210
|
+
}
|
|
1211
|
+
crashInMutationGapForTests("state-marker");
|
|
1212
|
+
advanceApplyJournal(journal, "state-collapsing");
|
|
1213
|
+
crashAfterForTests("state-marker");
|
|
1214
|
+
}
|
|
1215
|
+
if (journal.phase === "state-collapsing") {
|
|
1216
|
+
if (!sameMigrationGeneration(fingerprintMigrationGeneration(), journal.generation)) {
|
|
1217
|
+
const marker = readBoundStateGenerationMarkerFromDisk(journal.operationId);
|
|
1218
|
+
if (!marker) {
|
|
1219
|
+
throw new ConfigError("state.db does not match the exact marker-bound generation recorded before conversion.", "INVALID_CONFIG_FILE");
|
|
1220
|
+
}
|
|
1221
|
+
journal.phase = marker.phase === "state-applied" ? "state-applied" : "state-collapsing";
|
|
1222
|
+
journal.generation = fingerprintMigrationGeneration();
|
|
1223
|
+
writeApplyJournal(journal);
|
|
1224
|
+
if (journal.phase === "state-applied")
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
const db = openDatabaseFinalizing(getStateDbPathInDataDir());
|
|
1228
|
+
try {
|
|
1229
|
+
if (!readBoundStateGenerationMarker(db, journal.operationId)) {
|
|
1230
|
+
throw new ConfigError("state.db lacks its exact marker-bound conversion generation.", "INVALID_CONFIG_FILE");
|
|
1231
|
+
}
|
|
1232
|
+
collapseStateDbToSingleFile(db);
|
|
1233
|
+
crashInMutationGapForTests("state-converting");
|
|
1234
|
+
advanceBoundStateMarker(db, journal.operationId);
|
|
1235
|
+
}
|
|
1236
|
+
finally {
|
|
1237
|
+
db.close();
|
|
1238
|
+
}
|
|
1239
|
+
crashInMutationGapForTests("state");
|
|
1240
|
+
advanceApplyJournal(journal, "state-applied");
|
|
1241
|
+
crashAfterForTests("state");
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
function stateDatabaseHeaderUsesWal(filePath) {
|
|
1245
|
+
const fd = fs.openSync(filePath, "r");
|
|
1246
|
+
try {
|
|
1247
|
+
const header = Buffer.alloc(20);
|
|
1248
|
+
if (fs.readSync(fd, header, 0, header.byteLength, 0) !== header.byteLength) {
|
|
1249
|
+
throw new ConfigError(`Cannot verify the SQLite header for ${filePath}.`, "INVALID_CONFIG_FILE");
|
|
1250
|
+
}
|
|
1251
|
+
if (!header.subarray(0, 16).equals(Buffer.from("SQLite format 3\0"))) {
|
|
1252
|
+
throw new ConfigError(`Cannot verify a non-SQLite state database at ${filePath}.`, "INVALID_CONFIG_FILE");
|
|
1253
|
+
}
|
|
1254
|
+
return header[18] === 2 || header[19] === 2;
|
|
1255
|
+
}
|
|
1256
|
+
finally {
|
|
1257
|
+
fs.closeSync(fd);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
function assertStateReadyForCutover(journal) {
|
|
1261
|
+
const statePath = getStateDbPathInDataDir();
|
|
1262
|
+
if (!fs.existsSync(statePath)) {
|
|
1263
|
+
throw new ConfigError("Cannot run cutover without state.db.", "INVALID_CONFIG_FILE");
|
|
1264
|
+
}
|
|
1265
|
+
if (stateDatabaseHeaderUsesWal(statePath)) {
|
|
1266
|
+
throw new ConfigError("Refusing cutover because state.db still uses WAL; resume through the state-converting phase first.", "INVALID_CONFIG_FILE");
|
|
1267
|
+
}
|
|
1268
|
+
const db = openDatabaseFinalizing(statePath, { readonly: true });
|
|
1269
|
+
try {
|
|
1270
|
+
const journalMode = String(db.prepare("PRAGMA journal_mode").get()?.journal_mode ?? "").toLowerCase();
|
|
1271
|
+
const hasMarkerTable = db
|
|
1272
|
+
.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='akm_migration_generation'")
|
|
1273
|
+
.get();
|
|
1274
|
+
const marker = hasMarkerTable
|
|
1275
|
+
? db
|
|
1276
|
+
.prepare("SELECT operation_id, phase FROM akm_migration_generation WHERE singleton=1 LIMIT 2")
|
|
1277
|
+
.all()
|
|
1278
|
+
: [];
|
|
1279
|
+
if (journalMode !== "delete" ||
|
|
1280
|
+
marker.length !== 1 ||
|
|
1281
|
+
marker[0]?.operation_id !== journal.operationId ||
|
|
1282
|
+
marker[0]?.phase !== "state-applied") {
|
|
1283
|
+
throw new ConfigError("Refusing cutover because state.db lacks the operation-bound state-converting/single-file proof.", "INVALID_CONFIG_FILE");
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
finally {
|
|
1287
|
+
db.close();
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
function assertMigrationArtifactsComplete() {
|
|
1291
|
+
const completed = inspectMigrationState();
|
|
1292
|
+
if (completed.config.status !== "current" ||
|
|
1293
|
+
![completed.state.status, completed.workflow.status].every((status) => status === "current" || status === "missing")) {
|
|
1294
|
+
throw new ConfigError("Migration verification did not reach one current cross-artifact generation.", "INVALID_CONFIG_FILE");
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
/** Expand a leading `~` against the home directory (config stashDir/source paths may use it). */
|
|
1298
|
+
function expandTilde(p) {
|
|
1299
|
+
if (p === "~")
|
|
1300
|
+
return os.homedir();
|
|
1301
|
+
if (p.startsWith("~/") || p.startsWith("~\\"))
|
|
1302
|
+
return path.join(os.homedir(), p.slice(2));
|
|
1303
|
+
return p;
|
|
1304
|
+
}
|
|
1305
|
+
/**
|
|
1306
|
+
* Stash roots for the cutover ref map's origin aliases, the
|
|
1307
|
+
* source-(b) legacy walk, AND the WI-8.5d content migration's `.stash.json`
|
|
1308
|
+
* fold + D-R6 rename walk. Derived from the TARGET config, which by this point in
|
|
1309
|
+
* the apply has already been normalized to the 0.9 `bundles` shape by
|
|
1310
|
+
* {@link parseMigrationTargetConfig}: each path-bearing bundle is a root, the
|
|
1311
|
+
* `defaultBundle` is the primary, and a bundle's `registryId` (or its key)
|
|
1312
|
+
* supplies the origin alias. The pre-cutover `stashDir`/`sources` shape is still
|
|
1313
|
+
* honored as a fallback for a transitional config that reaches here un-migrated.
|
|
1314
|
+
* Source (a) (the index `item_ref` join) is authoritative, so an unresolved root
|
|
1315
|
+
* only costs a few origin aliases.
|
|
1316
|
+
*/
|
|
1317
|
+
function cutoverStashRootsFromConfig(config) {
|
|
1318
|
+
const roots = [];
|
|
1319
|
+
const bundles = config.bundles;
|
|
1320
|
+
if (bundles && typeof bundles === "object") {
|
|
1321
|
+
for (const [id, entry] of Object.entries(bundles)) {
|
|
1322
|
+
const bundlePath = entry.path;
|
|
1323
|
+
if (typeof bundlePath !== "string" || bundlePath.length === 0)
|
|
1324
|
+
continue; // only filesystem bundles
|
|
1325
|
+
const registryId = entry.registryId ?? id;
|
|
1326
|
+
roots.push({
|
|
1327
|
+
path: path.resolve(expandTilde(bundlePath)),
|
|
1328
|
+
registryId,
|
|
1329
|
+
primary: config.defaultBundle === id,
|
|
1330
|
+
});
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
return roots;
|
|
1334
|
+
}
|
|
1335
|
+
/**
|
|
1336
|
+
* Roll a pre-cutover workflow.db forward to its final ledger (010) using the
|
|
1337
|
+
* FROZEN migration bodies (`src/migrate/legacy/workflow-migrations-bodies.ts`)
|
|
1338
|
+
* through the shared engine — never the live `WORKFLOW_MIGRATIONS` array
|
|
1339
|
+
* (`src/workflows/db.ts` is deleted in WI-8.3). The roll materialises every
|
|
1340
|
+
* migration-added column + DEFAULT so the subsequent state.db merge carries
|
|
1341
|
+
* faithful data.
|
|
1342
|
+
*
|
|
1343
|
+
* Pre-versioning (0.7-era) workflow.dbs — rows present but NO `schema_migrations`
|
|
1344
|
+
* ledger — are OUT of the migrator FROM-state (the rc-train fixtures pin a
|
|
1345
|
+
* versioned ledger). We FAIL CLOSED with a clear message rather than
|
|
1346
|
+
* bootstrapping (the old `bootstrapPreVersioningDb` back-fill is retired).
|
|
1347
|
+
*/
|
|
1348
|
+
function runFrozenWorkflowRoll(operationId) {
|
|
1349
|
+
const workflowPath = getLegacyWorkflowDbPath();
|
|
1350
|
+
const db = openDatabaseFinalizing(workflowPath);
|
|
1351
|
+
try {
|
|
1352
|
+
const hasRuns = !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='workflow_runs'").get();
|
|
1353
|
+
const hasLedger = !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='schema_migrations'").get();
|
|
1354
|
+
if (hasRuns && !hasLedger) {
|
|
1355
|
+
throw new ConfigError(`Refusing to migrate a pre-versioning workflow.db at ${workflowPath} (no schema_migrations ledger). ` +
|
|
1356
|
+
"Pre-0.8 workflow databases are not a supported migrator source; upgrade through a 0.8.x release first.", "INVALID_CONFIG_FILE");
|
|
1357
|
+
}
|
|
1358
|
+
// Roll the pending frozen migrations ONLY — never the base-schema DDL. Any
|
|
1359
|
+
// real pre-cutover workflow.db already carries the base schema (its runtime
|
|
1360
|
+
// opener created it); running the baseline CREATE INDEX here would fail on a
|
|
1361
|
+
// pre-existing-but-narrower table. Matches the WI-8.2 workflow-applied path
|
|
1362
|
+
// (openDatabase + runSqliteMigrations, no pragmas/base-schema), preserving
|
|
1363
|
+
// the crash-recovery generation-fingerprint invariants that flow pins.
|
|
1364
|
+
runSqliteMigrations(db, FROZEN_WORKFLOW_MIGRATIONS, {
|
|
1365
|
+
generationMarker: { operationId, phase: "workflow-applied" },
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
finally {
|
|
1369
|
+
db.close();
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* The `cutover-applied` phase (Chunk 8, WI-8.2). Builds + persists the
|
|
1374
|
+
* old-ref → item_ref map, runs the fail-closed three-DB merge/re-key
|
|
1375
|
+
* transaction, then the idempotent index-quarantine / workflow.db-unlink
|
|
1376
|
+
* boundary ops. A committed merge marker (from an interrupted-then-resumed
|
|
1377
|
+
* apply) short-circuits the merge so it runs exactly once.
|
|
1378
|
+
*/
|
|
1379
|
+
function runCutoverStep(journal, target) {
|
|
1380
|
+
const statePath = getStateDbPathInDataDir();
|
|
1381
|
+
const workflowPath = getLegacyWorkflowDbPath();
|
|
1382
|
+
const indexPath = getDbPath();
|
|
1383
|
+
const stashRoots = cutoverStashRootsFromConfig(target);
|
|
1384
|
+
if (!cutoverMergeCommitted(statePath, journal.operationId)) {
|
|
1385
|
+
const refMap = buildCutoverRefMap({
|
|
1386
|
+
oldIndexDbPath: indexPath,
|
|
1387
|
+
stashRoots,
|
|
1388
|
+
mapOutputPath: cutoverRefMapPath(journal),
|
|
1389
|
+
});
|
|
1390
|
+
// Fail-closed: an integrity failure (unparseable ref / row-count mismatch)
|
|
1391
|
+
// throws a CutoverIntegrityError, which the outer catch converts to a
|
|
1392
|
+
// restore-from-backup. The state txn is atomic — a throw rolls it back, so
|
|
1393
|
+
// state.db + workflow.db are unchanged going into the rollback.
|
|
1394
|
+
runThreeDbCutover({ refMap, operationId: journal.operationId, statePath, workflowPath, oldIndexPath: indexPath });
|
|
1395
|
+
crashInMutationGapForTests("cutover-commit");
|
|
1396
|
+
}
|
|
1397
|
+
else {
|
|
1398
|
+
assertPostCutoverWorkflowAuthenticated(journal, fingerprintMigrationGeneration());
|
|
1399
|
+
}
|
|
1400
|
+
// Boundary ops run AFTER the committed state txn, OUTSIDE the fail-closed gate
|
|
1401
|
+
// (cutover-design.md §2 step 5/6). Idempotent + best-effort — they log and
|
|
1402
|
+
// return, never throw, so a rename/unlink hiccup never rolls back the merge.
|
|
1403
|
+
quarantineIndexDb(journal.operationId, indexPath);
|
|
1404
|
+
deleteWorkflowDb(workflowPath);
|
|
1405
|
+
// WI-8.5d: the content migration (`.stash.json` fold + delete, D-R6 reserved-
|
|
1406
|
+
// filename conformance) is an ADDITIVE filesystem step of the same phase. It
|
|
1407
|
+
// also runs AFTER the committed state txn, is best-effort (a throw is swallowed
|
|
1408
|
+
// + logged, never aborting a committed cutover), and idempotent (a resumed
|
|
1409
|
+
// apply finds no sidecars and no mis-named concepts, so it re-runs to a no-op).
|
|
1410
|
+
runContentMigrationStep(journal, target);
|
|
1411
|
+
}
|
|
1412
|
+
function cutoverRefMapPath(journal) {
|
|
1413
|
+
return path.join(path.dirname(getMigrationApplyJournalPath()), `cutover-refmap-${journal.operationId}.json`);
|
|
1414
|
+
}
|
|
1415
|
+
/** Required forward-only filesystem step after the core config/database cutover verifies. */
|
|
1416
|
+
function runPilotTreatmentStep(journal, target) {
|
|
1417
|
+
const refMap = loadCutoverRefMap(cutoverRefMapPath(journal));
|
|
1418
|
+
migratePilotTreatmentFiles(cutoverStashRootsFromConfig(target), refMap);
|
|
1419
|
+
}
|
|
1420
|
+
/**
|
|
1421
|
+
* Persist location for the content-migration report — next to the ApplyJournal
|
|
1422
|
+
* (alongside the cutover ref map). Survives `clearApplyJournal` (which removes
|
|
1423
|
+
* only the journal file), so the operator + the WI-8.5d test can read the D-R6
|
|
1424
|
+
* rename list after a committed apply.
|
|
1425
|
+
*/
|
|
1426
|
+
function contentMigrationReportPath() {
|
|
1427
|
+
return path.join(path.dirname(getMigrationApplyJournalPath()), "content-migration-report.json");
|
|
1428
|
+
}
|
|
1429
|
+
/** Best-effort content migration + report persistence (see {@link runCutoverStep}). */
|
|
1430
|
+
function runContentMigrationStep(journal, target) {
|
|
1431
|
+
try {
|
|
1432
|
+
const roots = cutoverStashRootsFromConfig(target).map((r) => r.path);
|
|
1433
|
+
const report = runContentMigration(roots);
|
|
1434
|
+
// Fold the one-time pre-0.9 filesystem-proposal import into this same
|
|
1435
|
+
// additive step (it used to run on every proposal operation via
|
|
1436
|
+
// `withProposalsDb`). state.db has been merged + collapsed to single-file
|
|
1437
|
+
// DELETE mode by this point, so we open it raw and INSERT OR IGNORE each
|
|
1438
|
+
// legacy `proposal.json` on its UUID — idempotent, no ledger needed.
|
|
1439
|
+
//
|
|
1440
|
+
// rc-window edge: a user who already ran `akm migrate apply` on an EARLIER
|
|
1441
|
+
// rc binary (before this fold existed) AND never ran a proposal command
|
|
1442
|
+
// afterward would have their pre-0.9 fs proposals un-imported — the old
|
|
1443
|
+
// live-path import that once covered that gap is gone. Re-running this
|
|
1444
|
+
// idempotent `migrate apply` recovers them (the legacy files are left in
|
|
1445
|
+
// place on import, so they are still there to re-walk). Acceptable for an rc.
|
|
1446
|
+
report.legacyProposalsImported = importLegacyProposalsIntoState(getStateDbPathInDataDir(), roots);
|
|
1447
|
+
persistContentMigrationReport(report);
|
|
1448
|
+
if (report.sidecarsFolded > 0 || report.reservedRenames.length > 0 || report.legacyProposalsImported > 0) {
|
|
1449
|
+
console.log(JSON.stringify({
|
|
1450
|
+
event: "content-migration",
|
|
1451
|
+
operationId: journal.operationId,
|
|
1452
|
+
...report,
|
|
1453
|
+
}));
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
catch (error) {
|
|
1457
|
+
console.error(`[akm] content migration skipped (${error instanceof Error ? error.message : String(error)}); the committed cutover is unaffected.`);
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
function persistContentMigrationReport(report) {
|
|
1461
|
+
try {
|
|
1462
|
+
const reportPath = contentMigrationReportPath();
|
|
1463
|
+
fs.mkdirSync(path.dirname(reportPath), { recursive: true, mode: 0o700 });
|
|
1464
|
+
writeFileAtomic(reportPath, `${JSON.stringify(report, null, 2)}\n`, 0o600);
|
|
1465
|
+
}
|
|
1466
|
+
catch {
|
|
1467
|
+
// The report is auditing-only; a persistence failure never affects the cutover.
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
310
1470
|
function unsafeArtifact(name, state) {
|
|
311
1471
|
if (!["newer", "inconsistent", "corrupt"].includes(state.status))
|
|
312
1472
|
return undefined;
|
|
313
1473
|
return `${name} is ${state.status}${state.detail ? `: ${state.detail}` : ""}`;
|
|
314
1474
|
}
|
|
1475
|
+
/**
|
|
1476
|
+
* Parse + validate a migration TARGET config, applying the Chunk-8 config-shape
|
|
1477
|
+
* migration (`stashDir`/`sources[]`/`installed[]` → `bundles`/`defaultBundle`)
|
|
1478
|
+
* as a pre-validation transform. This is why a target still carrying the
|
|
1479
|
+
* pre-cutover source shape loads (and reports "current" in `migrate status`)
|
|
1480
|
+
* even though the runtime loader now rejects that shape once `bundles` exists:
|
|
1481
|
+
* the migrator normalizes it FIRST, then the strict schema gates the result.
|
|
1482
|
+
*
|
|
1483
|
+
* The transform is idempotent, so an already-migrated prepared config, or the
|
|
1484
|
+
* new-shape config re-parsed from the apply journal, passes through untouched.
|
|
1485
|
+
*/
|
|
1486
|
+
function parseMigrationTargetConfig(text, sourcePath) {
|
|
1487
|
+
const raw = parseConfigText(text, sourcePath);
|
|
1488
|
+
const migrated = migrateConfigSourcesToBundles(raw);
|
|
1489
|
+
// Re-serialize the normalized object through the canonical validator so the
|
|
1490
|
+
// version check, schema validation, and defaults merge stay in one place.
|
|
1491
|
+
return parseAndValidateConfigText(JSON.stringify(migrated), sourcePath);
|
|
1492
|
+
}
|
|
315
1493
|
function loadTargetConfig(preparedConfigPath, artifacts) {
|
|
316
1494
|
const targetPath = preparedConfigPath ?? (artifacts.config.status === "current" ? getConfigPath() : undefined);
|
|
317
1495
|
if (!targetPath) {
|
|
@@ -340,7 +1518,7 @@ function loadTargetConfig(preparedConfigPath, artifacts) {
|
|
|
340
1518
|
source: preparedConfigPath ? "prepared" : "active",
|
|
341
1519
|
path: targetPath,
|
|
342
1520
|
},
|
|
343
|
-
config:
|
|
1521
|
+
config: parseMigrationTargetConfig(text, targetPath),
|
|
344
1522
|
};
|
|
345
1523
|
}
|
|
346
1524
|
catch (error) {
|
|
@@ -355,7 +1533,7 @@ function loadTargetConfig(preparedConfigPath, artifacts) {
|
|
|
355
1533
|
}
|
|
356
1534
|
}
|
|
357
1535
|
function buildMigrationPlan(preparedConfigPath, activeApply) {
|
|
358
|
-
const artifacts =
|
|
1536
|
+
const artifacts = activeApply.artifacts ?? inspectMigrationStateFromSnapshots();
|
|
359
1537
|
const restorePending = fs.existsSync(getMigrationRestoreJournalPath());
|
|
360
1538
|
const target = activeApply.journal
|
|
361
1539
|
? {
|
|
@@ -375,14 +1553,25 @@ function buildMigrationPlan(preparedConfigPath, activeApply) {
|
|
|
375
1553
|
].filter((blocker) => blocker !== undefined);
|
|
376
1554
|
if (target.state.status !== "current")
|
|
377
1555
|
blockers.push(target.state.detail ?? "A current target config is required.");
|
|
378
|
-
if (activeApply.error && !activeApply.journal)
|
|
1556
|
+
if (activeApply.error && (!activeApply.journal || target.state.status === "current"))
|
|
379
1557
|
blockers.push(activeApply.error);
|
|
380
1558
|
if (restorePending)
|
|
381
1559
|
blockers.push(`Restore recovery is pending at ${getMigrationRestoreJournalPath()}.`);
|
|
1560
|
+
let taskRewrites = 0;
|
|
1561
|
+
if (blockers.length === 0 && target.config) {
|
|
1562
|
+
try {
|
|
1563
|
+
taskRewrites = planTaskTargetRefMigration(target.config).rewrites.length;
|
|
1564
|
+
}
|
|
1565
|
+
catch (error) {
|
|
1566
|
+
blockers.push(error instanceof Error ? error.message : String(error));
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
382
1569
|
const needsApply = !!activeApply.journal ||
|
|
383
1570
|
artifacts.config.status !== "current" ||
|
|
384
1571
|
artifacts.state.status === "old" ||
|
|
385
|
-
artifacts.workflow.status === "old"
|
|
1572
|
+
artifacts.workflow.status === "old" ||
|
|
1573
|
+
artifacts.workflow.status === "current" ||
|
|
1574
|
+
taskRewrites > 0;
|
|
386
1575
|
return {
|
|
387
1576
|
status: blockers.length > 0 ? "blocked" : needsApply ? "ready" : "current",
|
|
388
1577
|
artifacts,
|
|
@@ -432,8 +1621,12 @@ export async function runMigrationApply(options = {}) {
|
|
|
432
1621
|
return;
|
|
433
1622
|
}
|
|
434
1623
|
const result = withConfigLock(() => withMaintenanceStartBarrier(() => {
|
|
435
|
-
assertNoArtifactReplacementBlockers();
|
|
436
1624
|
recoverInterruptedRestoreWithLocksHeld();
|
|
1625
|
+
authenticatePreConversionJournalForApply();
|
|
1626
|
+
assertNoArtifactReplacementBlockers(undefined, {
|
|
1627
|
+
skipWorkflowClaims: fs.existsSync(getMigrationApplyJournalPath()),
|
|
1628
|
+
});
|
|
1629
|
+
preparePreConversionJournalForApply();
|
|
437
1630
|
const active = readApplyJournal();
|
|
438
1631
|
if (active.error)
|
|
439
1632
|
throw new ConfigError(active.error, "INVALID_CONFIG_FILE");
|
|
@@ -474,69 +1667,103 @@ export async function runMigrationApply(options = {}) {
|
|
|
474
1667
|
};
|
|
475
1668
|
if (!active.journal)
|
|
476
1669
|
writeApplyJournal(journal);
|
|
1670
|
+
const taskOnlyRepair = isTaskOnlyRepair(backup.manifest);
|
|
1671
|
+
let forwardRecoveryRequired = isForwardRecoveryPhase(journal.phase);
|
|
477
1672
|
try {
|
|
478
|
-
const
|
|
479
|
-
if (
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
1673
|
+
const taskTargetPlan = planTaskTargetRefMigration(target);
|
|
1674
|
+
if (taskOnlyRepair) {
|
|
1675
|
+
forwardRecoveryRequired = true;
|
|
1676
|
+
runTaskOnlyRepair(journal, taskTargetPlan);
|
|
1677
|
+
return { plan: inspectMigrationPlan(), backup };
|
|
1678
|
+
}
|
|
1679
|
+
runStateMigrationStep(journal);
|
|
1680
|
+
forwardRecoveryRequired = isForwardRecoveryPhase(journal.phase);
|
|
1681
|
+
if (isPostCutoverPhase(journal.phase))
|
|
1682
|
+
runCutoverStep(journal, target);
|
|
1683
|
+
if (journal.phase === "state-applied") {
|
|
1684
|
+
// Roll the pre-cutover workflow.db through the frozen migration bodies
|
|
1685
|
+
// only while the journal is still before the committed cutover.
|
|
1686
|
+
const beforeWorkflow = inspectExactApplyJournalGeneration(journal);
|
|
1687
|
+
if (beforeWorkflow.workflow.status === "old") {
|
|
1688
|
+
runFrozenWorkflowRoll(journal.operationId);
|
|
485
1689
|
}
|
|
486
|
-
|
|
487
|
-
db.
|
|
1690
|
+
else if (beforeWorkflow.workflow.status !== "current" && beforeWorkflow.workflow.status !== "missing") {
|
|
1691
|
+
throw new ConfigError(`Cannot resume workflow.db from ${beforeWorkflow.workflow.status} state.`, "INVALID_CONFIG_FILE");
|
|
488
1692
|
}
|
|
1693
|
+
crashInMutationGapForTests("workflow");
|
|
1694
|
+
advanceApplyJournal(journal, "workflow-applied");
|
|
1695
|
+
crashAfterForTests("workflow");
|
|
489
1696
|
}
|
|
490
|
-
|
|
491
|
-
|
|
1697
|
+
if (journal.phase === "workflow-applied") {
|
|
1698
|
+
const migrated = inspectExactApplyJournalGeneration(journal);
|
|
1699
|
+
for (const [name, state] of [
|
|
1700
|
+
["state.db", migrated.state],
|
|
1701
|
+
["workflow.db", migrated.workflow],
|
|
1702
|
+
]) {
|
|
1703
|
+
if (state.status !== "current" && state.status !== "missing") {
|
|
1704
|
+
throw new ConfigError(`Migration left ${name} in ${state.status} state.`, "INVALID_CONFIG_FILE");
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
assertStateReadyForCutover(journal);
|
|
1708
|
+
runCutoverStep(journal, target);
|
|
1709
|
+
forwardRecoveryRequired = true;
|
|
1710
|
+
crashInMutationGapForTests("cutover");
|
|
1711
|
+
advanceApplyJournal(journal, "cutover-applied");
|
|
1712
|
+
crashAfterForTests("cutover");
|
|
492
1713
|
}
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
crashAfterForTests("state");
|
|
496
|
-
const beforeWorkflow = inspectMigrationState();
|
|
497
|
-
if (beforeWorkflow.workflow.status === "old") {
|
|
498
|
-
const db = openDatabase(getWorkflowDbPath());
|
|
1714
|
+
if (journal.phase === "cutover-applied") {
|
|
1715
|
+
// The cutover is committed, so all remaining work is forward-only.
|
|
499
1716
|
try {
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
1717
|
+
const preCutoverText = readConfigText(getConfigPath());
|
|
1718
|
+
if (preCutoverText !== undefined) {
|
|
1719
|
+
mergeLockEntriesSync(migratedLockEntries(parseConfigText(preCutoverText, getConfigPath())));
|
|
1720
|
+
}
|
|
503
1721
|
}
|
|
504
|
-
|
|
505
|
-
|
|
1722
|
+
catch {
|
|
1723
|
+
// Advisory lock re-key only; the committed cutover is unaffected.
|
|
506
1724
|
}
|
|
1725
|
+
backupExistingConfig(getConfigPath());
|
|
1726
|
+
writeConfigAtomic(getConfigPath(), sanitizeConfigForWrite(target));
|
|
1727
|
+
resetConfigCache();
|
|
1728
|
+
crashInMutationGapForTests("config");
|
|
1729
|
+
advanceApplyJournal(journal, "config-applied");
|
|
1730
|
+
crashAfterForTests("config");
|
|
507
1731
|
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
crashInMutationGapForTests("workflow");
|
|
512
|
-
advanceApplyJournal(journal, "workflow-applied");
|
|
513
|
-
crashAfterForTests("workflow");
|
|
514
|
-
const migrated = inspectMigrationState();
|
|
515
|
-
for (const [name, state] of [
|
|
516
|
-
["state.db", migrated.state],
|
|
517
|
-
["workflow.db", migrated.workflow],
|
|
518
|
-
]) {
|
|
519
|
-
if (state.status !== "current" && state.status !== "missing") {
|
|
520
|
-
throw new ConfigError(`Migration left ${name} in ${state.status} state.`, "INVALID_CONFIG_FILE");
|
|
521
|
-
}
|
|
1732
|
+
if (journal.phase === "config-applied") {
|
|
1733
|
+
assertMigrationArtifactsComplete();
|
|
1734
|
+
advanceApplyJournal(journal, "tasks-prepared");
|
|
522
1735
|
}
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
1736
|
+
if (journal.phase === "tasks-prepared") {
|
|
1737
|
+
forwardRecoveryRequired = true;
|
|
1738
|
+
runTaskTargetMigrationStep(journal, taskTargetPlan);
|
|
1739
|
+
}
|
|
1740
|
+
if (journal.phase === "tasks-applied")
|
|
1741
|
+
advanceApplyJournal(journal, "pilot-prepared");
|
|
1742
|
+
if (journal.phase === "pilot-prepared") {
|
|
1743
|
+
forwardRecoveryRequired = true;
|
|
1744
|
+
runPilotTreatmentStep(journal, target);
|
|
1745
|
+
crashInMutationGapForTests("pilot");
|
|
1746
|
+
advanceApplyJournal(journal, "pilot-applied");
|
|
1747
|
+
crashAfterForTests("pilot");
|
|
1748
|
+
}
|
|
1749
|
+
if (journal.phase === "pilot-applied")
|
|
1750
|
+
advanceApplyJournal(journal, "committed");
|
|
535
1751
|
clearApplyJournal();
|
|
536
1752
|
const completed = inspectMigrationPlan();
|
|
537
1753
|
return { plan: completed, backup };
|
|
538
1754
|
}
|
|
539
1755
|
catch (error) {
|
|
1756
|
+
if (error instanceof MigrationPreflightGenerationError || error instanceof MigrationSnapshotChangedError) {
|
|
1757
|
+
forwardRecoveryRequired = true;
|
|
1758
|
+
}
|
|
1759
|
+
if (isForwardRecoveryPhase(journal.phase))
|
|
1760
|
+
forwardRecoveryRequired = true;
|
|
1761
|
+
if (!forwardRecoveryRequired && cutoverMergeCommitted(getStateDbPathInDataDir(), journal.operationId)) {
|
|
1762
|
+
forwardRecoveryRequired = true;
|
|
1763
|
+
}
|
|
1764
|
+
if (forwardRecoveryRequired) {
|
|
1765
|
+
throw new ConfigError(`Migration apply requires forward recovery from ${getMigrationApplyJournalPath()}: ${error instanceof Error ? error.message : String(error)}`, "INVALID_CONFIG_FILE");
|
|
1766
|
+
}
|
|
540
1767
|
try {
|
|
541
1768
|
const rollbackGeneration = fingerprintMigrationGeneration();
|
|
542
1769
|
assertRollbackTransitionAllowed(journal, rollbackGeneration);
|