akm-cli 0.9.0-rc.8 → 0.9.0
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 +1063 -44
- package/README.md +51 -25
- package/SECURITY.md +14 -1
- package/STABILITY.md +497 -0
- package/dist/akm +148 -35
- package/dist/{akm-migrate-storage → akm-migrate} +6 -9
- package/dist/assets/hints/cli-hints-full.md +223 -95
- package/dist/assets/hints/cli-hints-short.md +85 -22
- package/dist/assets/improve-strategies/default.json +1 -1
- package/dist/assets/improve-strategies/reflect-distill.json +1 -1
- package/dist/assets/prompts/memory-infer-user.md +2 -3
- package/dist/assets/stash-skeleton/README.md +6 -5
- package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/domains.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/organization.md +20 -9
- package/dist/assets/tasks/core/extract.yml +1 -1
- package/dist/assets/tasks/core/version-check.yml +1 -1
- package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +5 -0
- package/dist/assets/tasks/improve/akm-improve-catchup.yml +8 -0
- package/dist/assets/tasks/improve/akm-improve-consolidate.yml +5 -0
- package/dist/assets/tasks/improve/akm-improve-frequent.yml +5 -0
- package/dist/assets/tasks/improve/akm-improve-nightly.yml +5 -0
- package/dist/assets/templates/html/health.html +1 -3
- package/dist/assets/workflows/workflow-template.md +32 -15
- package/dist/cli/invocation.js +40 -15
- package/dist/cli/parse-args.js +0 -22
- package/dist/cli/retired-commands.js +121 -0
- package/dist/cli/shared.js +154 -22
- package/dist/cli/unknown-flags.js +236 -0
- package/dist/cli-node.mjs +2 -1
- package/dist/cli.js +696 -258
- package/dist/commands/agent/agent-dispatch.js +14 -3
- package/dist/commands/agent/contribute-cli.js +73 -88
- package/dist/commands/completions.js +79 -22
- package/dist/commands/config-cli.js +17 -150
- package/dist/commands/env/env-cli.js +59 -143
- package/dist/commands/env/env.js +12 -163
- package/dist/commands/env/marker-path.js +6 -0
- package/dist/commands/env/secret-cli.js +36 -66
- package/dist/commands/env/secret.js +24 -57
- package/dist/commands/feedback-cli.js +141 -87
- package/dist/commands/health/accept-rate.js +58 -0
- package/dist/commands/health/advisories.js +3 -4
- package/dist/commands/health/checks.js +85 -23
- package/dist/commands/health/html-report.js +7 -10
- package/dist/commands/health/improve-metrics.js +25 -83
- package/dist/commands/health/md-report.js +5 -9
- package/dist/commands/health/metrics.js +62 -20
- package/dist/commands/health/renderers.js +47 -0
- package/dist/commands/health/report-view-model.js +4 -5
- package/dist/commands/health/stash-exposure.js +1 -1
- package/dist/commands/health/surfaces.js +3 -48
- package/dist/commands/health/task-runs.js +3 -67
- package/dist/commands/health/types-improve.js +7 -0
- package/dist/commands/health.js +99 -28
- package/dist/commands/improve/anti-collapse.js +2 -2
- package/dist/commands/improve/autonomy-gate.js +68 -0
- package/dist/commands/improve/collapse-detector.js +41 -40
- package/dist/commands/improve/consolidate/eligibility.js +1 -23
- package/dist/commands/improve/consolidate/merge.js +4 -0
- package/dist/commands/improve/consolidate.js +140 -1000
- package/dist/commands/improve/distill/promote-memory.js +12 -12
- package/dist/commands/improve/distill/quality-gate.js +6 -6
- package/dist/commands/improve/distill.js +58 -69
- package/dist/commands/improve/eligibility.js +105 -57
- package/dist/commands/improve/extract-cli.js +14 -133
- package/dist/commands/improve/improve-cli.js +98 -114
- package/dist/commands/improve/improve-result-file.js +1 -28
- package/dist/commands/improve/improve-strategies.js +8 -5
- package/dist/commands/improve/improve.js +128 -91
- package/dist/commands/improve/loop-stages.js +182 -20
- package/dist/commands/improve/memory/derived-ref.js +45 -43
- package/dist/commands/improve/memory/memory-belief.js +1 -1
- package/dist/commands/improve/memory/memory-contradiction-detect.js +4 -12
- package/dist/commands/improve/memory/memory-improve.js +6 -5
- package/dist/commands/improve/outcome-loop.js +22 -65
- package/dist/commands/improve/preparation.js +114 -123
- package/dist/commands/improve/proactive-maintenance.js +2 -5
- package/dist/commands/improve/reflect.js +56 -160
- package/dist/commands/improve/salience.js +11 -122
- package/dist/commands/improve/source-identity.js +10 -38
- package/dist/commands/lint/base-linter.js +20 -124
- package/dist/commands/lint/env-key-rules.js +31 -47
- package/dist/commands/lint/index.js +249 -43
- package/dist/commands/{events.js → log.js} +33 -38
- package/dist/commands/migrate-cli.js +92 -12
- package/dist/commands/migration-tool.js +46 -0
- package/dist/commands/observability-cli.js +70 -209
- package/dist/commands/proposal/drain.js +101 -29
- package/dist/commands/proposal/proposal-cli.js +76 -48
- package/dist/commands/proposal/proposal.js +54 -18
- package/dist/commands/proposal/propose-cli.js +88 -0
- package/dist/commands/proposal/propose.js +23 -15
- package/dist/commands/proposal/repository.js +701 -278
- package/dist/commands/proposal/validators/proposal-quality-validators.js +2 -8
- package/dist/commands/proposal/validators/proposal-validators.js +55 -7
- package/dist/commands/proposal/validators/proposals.js +4 -7
- package/dist/commands/read/curate.js +34 -53
- package/dist/commands/read/knowledge.js +150 -95
- package/dist/commands/read/registry-search.js +2 -2
- package/dist/commands/read/remember-cli.js +42 -15
- package/dist/commands/read/search-cli.js +180 -78
- package/dist/commands/read/search.js +58 -43
- package/dist/commands/read/show.js +197 -141
- package/dist/commands/registry-cli.js +12 -51
- package/dist/commands/remember.js +14 -57
- package/dist/commands/sources/add-cli.js +100 -31
- package/dist/commands/sources/bundle-cli.js +166 -0
- package/dist/commands/sources/bundle-config-ops.js +7 -2
- package/dist/commands/sources/info.js +18 -5
- package/dist/commands/sources/init.js +12 -12
- package/dist/commands/sources/installed-stashes.js +382 -98
- package/dist/commands/sources/schema-repair.js +3 -2
- package/dist/commands/sources/self-update.js +131 -38
- package/dist/commands/sources/source-add.js +72 -17
- package/dist/commands/sources/source-clone.js +129 -45
- package/dist/commands/sources/source-manage.js +43 -23
- package/dist/commands/sources/sources-cli.js +57 -208
- package/dist/commands/sources/stash-cli.js +46 -53
- package/dist/commands/tasks/tasks-cli.js +91 -97
- package/dist/commands/tasks/tasks.js +276 -421
- package/dist/commands/workflow-cli.js +175 -450
- package/dist/core/adapter/adapters/akm-adapter.js +47 -28
- package/dist/core/adapter/adapters/akm-lint.js +42 -27
- package/dist/core/adapter/adapters/akm-metadata.js +15 -44
- package/dist/core/adapter/adapters/akm-task-adapter.js +15 -13
- package/dist/core/adapter/adapters/akm-workflow-adapter.js +55 -71
- package/dist/core/adapter/adapters/dotenv-adapter.js +1 -1
- package/dist/core/adapter/adapters/generic-files-adapter.js +2 -0
- package/dist/core/adapter/adapters/index.js +6 -6
- package/dist/core/adapter/adapters/llm-wiki-adapter.js +14 -8
- package/dist/core/adapter/adapters/okf-adapter.js +187 -19
- package/dist/core/adapter/adapters/shared.js +3 -19
- package/dist/core/adapter/adapters/tool-dir-shared.js +8 -3
- package/dist/core/adapter/adapters/website-snapshot-adapter.js +1 -0
- package/dist/core/adapter/detect-adapter.js +17 -0
- package/dist/core/adapter/recognize-match.js +6 -4
- package/dist/core/adapter/validate-context.js +214 -0
- package/dist/core/asset/akm-markdown.js +63 -0
- package/dist/core/asset/asset-placement.js +20 -6
- package/dist/core/asset/asset-ref.js +11 -9
- package/dist/core/asset/frontmatter-lint.js +30 -0
- package/dist/core/asset/frontmatter.js +37 -9
- package/dist/core/asset/markdown.js +40 -51
- package/dist/core/asset/resolve-ref.js +89 -18
- package/dist/core/asset/stash-meta.js +1 -1
- package/dist/core/bundle-id.js +51 -0
- package/dist/core/common.js +152 -38
- package/dist/core/config/config-io.js +12 -1
- package/dist/core/config/config-schema.js +35 -8
- package/dist/core/config/config-sources.js +55 -11
- package/dist/core/config/config-walker.js +25 -9
- package/dist/core/config/config.js +9 -48
- package/dist/core/config/experimental.js +21 -0
- package/dist/core/config/schema/embedding.js +5 -1
- package/dist/core/config/schema/experimental.js +30 -0
- package/dist/core/config/schema/improve-processes.js +0 -6
- package/dist/core/config/schema/improve.js +21 -3
- package/dist/core/config/schema/index-config.js +8 -15
- package/dist/core/config/schema/output.js +4 -1
- package/dist/core/config/schema/setup.js +9 -18
- package/dist/core/config/schema/sources-bundles.js +49 -33
- package/dist/core/config/schema/workflow.js +3 -3
- package/dist/core/env-secret-ref.js +76 -46
- package/dist/core/errors.js +18 -12
- package/dist/core/events.js +46 -128
- package/dist/core/file-change.js +6 -5
- package/dist/core/fs-txn.js +83 -7
- package/dist/core/git-message.js +2 -2
- package/dist/core/improve-result.js +1 -100
- package/dist/core/lesson-lint.js +1 -17
- package/dist/core/logs-db.js +2 -1
- package/dist/core/migration-operation.js +16 -0
- package/dist/core/mutation-target.js +78 -0
- package/dist/core/parse.js +4 -1
- package/dist/core/paths.js +17 -20
- package/dist/core/recognition-util.js +12 -14
- package/dist/core/redaction.js +34 -0
- package/dist/core/standards/resolve-standards-context.js +2 -14
- package/dist/core/standards/resolve-stash-standards.js +2 -2
- package/dist/core/standards/resolve-type-conventions.js +2 -2
- package/dist/core/state/migrations.js +41 -18
- package/dist/core/state-db.js +5 -14
- package/dist/core/structured.js +1 -1
- package/dist/core/subprocess.js +6 -4
- package/dist/core/text-truncation.js +9 -5
- package/dist/core/type-presentation.js +3 -3
- package/dist/core/warn.js +0 -3
- package/dist/core/write-source.js +771 -95
- package/dist/indexer/bundle-identity-guard.js +3 -2
- package/dist/indexer/db/graph-db.js +0 -24
- package/dist/indexer/ensure-index.js +1 -0
- package/dist/indexer/graph/graph-boost.js +9 -34
- package/dist/indexer/graph/graph-extraction.js +8 -5
- package/dist/indexer/index-writer-lock.js +53 -17
- package/dist/indexer/index-written-assets.js +16 -22
- package/dist/indexer/indexer.js +497 -239
- package/dist/indexer/installations.js +14 -96
- package/dist/indexer/passes/dir-staleness.js +16 -9
- package/dist/indexer/passes/memory-inference.js +11 -9
- package/dist/indexer/passes/metadata.js +113 -47
- package/dist/indexer/scan/doc-to-entry.js +38 -1
- package/dist/indexer/scan/drain-dir.js +13 -23
- package/dist/indexer/search/db-search.js +99 -54
- package/dist/indexer/search/fts-query.js +47 -24
- package/dist/indexer/search/ranking-contributors.js +42 -20
- package/dist/indexer/search/ranking.js +18 -99
- package/dist/indexer/search/search-fields.js +7 -2
- package/dist/indexer/search/search-source.js +82 -93
- package/dist/indexer/usage/usage-events.js +0 -89
- package/dist/indexer/walk/file-context.js +2 -1
- package/dist/indexer/walk/matchers.js +30 -43
- package/dist/indexer/walk/path-resolver.js +7 -2
- package/dist/indexer/walk/walker.js +38 -12
- package/dist/integrations/agent/builders.js +0 -6
- package/dist/integrations/agent/config.js +2 -2
- package/dist/integrations/agent/detect.js +49 -19
- package/dist/integrations/agent/engine-fallback.js +76 -0
- package/dist/integrations/agent/profiles.js +14 -0
- package/dist/integrations/agent/prompts.js +12 -8
- package/dist/integrations/agent/runner-dispatch.js +4 -2
- package/dist/integrations/agent/runner.js +0 -1
- package/dist/integrations/agent/spawn.js +5 -6
- package/dist/integrations/github.js +1 -1
- package/dist/integrations/harnesses/aider/agent-builder.js +6 -4
- package/dist/integrations/harnesses/amazonq/agent-builder.js +7 -4
- package/dist/integrations/harnesses/claude/session-log.js +0 -10
- package/dist/integrations/harnesses/codex/agent-builder.js +5 -2
- package/dist/integrations/harnesses/copilot/agent-builder.js +5 -3
- package/dist/integrations/harnesses/gemini/agent-builder.js +5 -3
- package/dist/integrations/harnesses/index.js +3 -7
- package/dist/integrations/harnesses/opencode/agent-builder.js +21 -2
- package/dist/integrations/harnesses/opencode/session-log.js +0 -15
- package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +13 -4
- package/dist/integrations/harnesses/openhands/agent-builder.js +9 -6
- package/dist/integrations/harnesses/pi/agent-builder.js +6 -4
- package/dist/integrations/lockfile.js +101 -6
- package/dist/integrations/session-logs/index.js +3 -28
- package/dist/llm/client.js +136 -100
- package/dist/llm/embedders/remote.js +13 -5
- package/dist/llm/feature-gate.js +4 -12
- package/dist/llm/graph-extract.js +5 -11
- package/dist/llm/memory-infer.js +144 -1
- package/dist/llm/metadata-enhance.js +5 -7
- package/dist/llm/structured-call.js +1 -1
- package/dist/llm/usage-persist.js +26 -5
- package/dist/llm/usage-telemetry.js +25 -2
- package/dist/output/cli-hints.js +1 -2
- package/dist/output/context.js +22 -7
- package/dist/output/format-exempt.js +80 -0
- package/dist/output/generic-render.js +259 -0
- package/dist/output/render-registry.js +57 -0
- package/dist/output/renderers.js +14 -36
- package/dist/output/shapes/curate.js +10 -1
- package/dist/output/shapes/events.js +12 -7
- package/dist/output/shapes/helpers.js +56 -83
- package/dist/output/shapes/migrate.js +8 -0
- package/dist/output/shapes/passthrough.js +7 -41
- package/dist/output/shapes/proposal/producer.js +15 -7
- package/dist/output/shapes.js +2 -9
- package/dist/output/text/{init.js → bundle-create.js} +3 -1
- package/dist/output/text/bundle-show.js +7 -0
- package/dist/output/text/command-format.js +164 -96
- package/dist/output/text/env.js +1 -3
- package/dist/output/text/events.js +8 -7
- package/dist/output/text/health-format.js +103 -0
- package/dist/output/text/health.js +7 -0
- package/dist/output/text/helpers.js +10 -8
- package/dist/output/text/lint-format.js +43 -0
- package/dist/output/text/{save.js → lint.js} +2 -2
- package/dist/output/text/migrate.js +88 -0
- package/dist/output/text/proposal/producer.js +4 -2
- package/dist/output/text/proposal-format.js +44 -72
- package/dist/output/text/registry-commands.js +1 -2
- package/dist/output/text/show-directives.js +15 -7
- package/dist/output/text/status-list.js +32 -0
- package/dist/output/text/sync.js +5 -0
- package/dist/output/text/workflow-format.js +24 -203
- package/dist/output/text/workflow.js +1 -7
- package/dist/output/text.js +16 -17
- package/dist/registry/factory.js +4 -6
- package/dist/registry/origin-resolve.js +16 -27
- package/dist/registry/providers/skills-sh.js +3 -3
- package/dist/registry/providers/static-index.js +13 -23
- package/dist/registry/resolve.js +42 -7
- package/dist/registry/semver.js +34 -84
- package/dist/runtime.js +2 -23
- package/dist/scripts/akm-migrate-node.js +60290 -0
- package/dist/scripts/akm-migrate.js +59628 -0
- package/dist/setup/detect.js +42 -15
- package/dist/setup/registry-stash-loader.js +2 -2
- package/dist/setup/setup.js +236 -136
- package/dist/setup/steps/connection.js +7 -9
- package/dist/setup/steps/platforms.js +9 -9
- package/dist/setup/steps/semantic.js +15 -3
- package/dist/setup/steps/sources.js +12 -13
- package/dist/setup/steps/stashdir.js +2 -3
- package/dist/setup/steps/tasks.js +237 -120
- package/dist/sources/freshness.js +1 -1
- package/dist/sources/provider-factory.js +11 -17
- package/dist/sources/providers/filesystem.js +2 -3
- package/dist/sources/providers/git-install.js +278 -34
- package/dist/sources/providers/git-provider.js +25 -23
- package/dist/sources/providers/git-stash.js +395 -106
- package/dist/sources/providers/git.js +2 -2
- package/dist/sources/providers/npm.js +16 -19
- package/dist/sources/providers/provider-utils.js +7 -4
- package/dist/sources/providers/sync-from-ref.js +3 -9
- package/dist/sources/providers/website.js +6 -1
- package/dist/sources/resolve.js +6 -5
- package/dist/sources/snapshot-fetchers/bluesky.js +146 -0
- package/dist/sources/snapshot-fetchers/content-extract.js +566 -0
- package/dist/sources/snapshot-fetchers/fetcher-util.js +41 -0
- package/dist/sources/snapshot-fetchers/github.js +100 -0
- package/dist/sources/snapshot-fetchers/host-guard.js +291 -0
- package/dist/sources/snapshot-fetchers/registry.js +17 -1
- package/dist/sources/snapshot-fetchers/robots.js +348 -0
- package/dist/sources/snapshot-fetchers/rss.js +282 -0
- package/dist/sources/snapshot-fetchers/secret-seam.js +42 -0
- package/dist/sources/snapshot-fetchers/website-ingest.js +566 -268
- package/dist/sources/snapshot-fetchers/x.js +910 -0
- package/dist/storage/database.js +7 -0
- package/dist/storage/engines/sqlite-migrations.js +23 -111
- package/dist/storage/managed-db.js +2 -2
- package/dist/storage/repositories/canaries-repository.js +1 -1
- package/dist/storage/repositories/events-repository.js +27 -11
- package/dist/storage/repositories/improve-runs-repository.js +6 -12
- package/dist/storage/repositories/index-connection.js +17 -6
- package/dist/storage/repositories/index-entries-repository.js +151 -240
- package/dist/storage/repositories/index-entry-mapper.js +15 -11
- package/dist/storage/repositories/index-fts-repository.js +5 -2
- package/dist/storage/repositories/index-llm-cache-repository.js +0 -1
- package/dist/storage/repositories/index-meta-repository.js +2 -3
- package/dist/storage/repositories/index-schema.js +10 -25
- package/dist/storage/repositories/index-utility-repository.js +15 -28
- package/dist/storage/repositories/index-vec-repository.js +6 -1
- package/dist/storage/repositories/outcome-repository.js +119 -0
- package/dist/storage/repositories/proposals-repository.js +296 -59
- package/dist/storage/repositories/registry-cache.js +19 -0
- package/dist/storage/repositories/salience-repository.js +172 -0
- package/dist/storage/repositories/task-history-repository.js +15 -13
- package/dist/storage/repositories/workflow-runs-repository.js +52 -40
- package/dist/tasks/backends/cron.js +105 -15
- package/dist/tasks/backends/index.js +1 -1
- package/dist/tasks/backends/launchd.js +85 -38
- package/dist/tasks/backends/schtasks.js +135 -15
- package/dist/tasks/embedded.js +56 -40
- package/dist/tasks/parser.js +7 -157
- package/dist/tasks/resolve-akm-bin.js +137 -59
- package/dist/tasks/runner.js +79 -42
- package/dist/tasks/scheduler-invocation.js +220 -10
- package/dist/tasks/schema.js +24 -1
- package/dist/tasks/task-id.js +1 -3
- package/dist/tasks/validator.js +20 -6
- package/dist/workflows/authoring/authoring.js +94 -143
- package/dist/workflows/authoring/scope-key.js +1 -1
- package/dist/workflows/exec/frozen-judge.js +28 -2
- package/dist/workflows/exec/native-executor.js +77 -57
- package/dist/workflows/exec/param-secrets.js +9 -9
- package/dist/workflows/exec/run-workflow.js +133 -79
- package/dist/workflows/exec/step-work.js +219 -346
- package/dist/{migrate-storage-node.mjs → workflows/exec/unit-dispatch.js} +1 -5
- package/dist/workflows/ir/compile.js +141 -270
- package/dist/workflows/ir/freeze.js +40 -30
- package/dist/workflows/ir/params.js +135 -11
- package/dist/workflows/ir/plan-hash.js +1 -1
- package/dist/workflows/ir/schema.js +25 -26
- package/dist/workflows/parser.js +872 -307
- package/dist/workflows/program/expressions.js +20 -208
- package/dist/workflows/program/schema.js +7 -10
- package/dist/workflows/renderer.js +95 -68
- package/dist/workflows/resource-limits.js +2 -0
- package/dist/workflows/runtime/checkin.js +3 -3
- package/dist/workflows/runtime/plan-classifier.js +16 -75
- package/dist/workflows/runtime/runs.js +186 -127
- package/dist/workflows/runtime/unit-checkin.js +1 -1
- package/dist/workflows/runtime/unit-phases.js +2 -2
- package/dist/workflows/runtime/workflow-asset-loader.js +232 -83
- package/dist/workflows/schema.js +1 -11
- package/dist/workflows/validate-summary.js +30 -36
- package/dist/workflows/validator.js +21 -62
- package/docs/README.md +68 -0
- package/docs/migration/README.md +8 -0
- package/docs/migration/release-notes/0.7.0.md +11 -11
- package/docs/migration/release-notes/0.9.0.md +208 -27
- package/docs/migration/v0.7-to-v0.8.md +46 -47
- package/docs/migration/v0.8-to-v0.9.md +564 -208
- package/docs/migration/v0.9.0-troubleshooting.md +561 -0
- package/docs/reference/README.md +12 -0
- package/docs/reference/cli.md +2253 -0
- package/docs/reference/configuration.md +358 -0
- package/docs/reference/data-and-telemetry.md +105 -42
- package/docs/reference/workflows.md +647 -0
- package/package.json +22 -11
- package/schemas/akm-asset-envelope.json +93 -0
- package/schemas/akm-config.json +81 -128
- package/schemas/akm-workflow.json +74 -73
- package/dist/assets/tasks/core/backup.yml +0 -5
- package/dist/assets/tasks/graph-refresh-weekly.yml +0 -10
- package/dist/cli/config-migrate.js +0 -1806
- package/dist/cli/config-validate.js +0 -41
- package/dist/commands/backup-cli.js +0 -56
- package/dist/commands/bundle/bundle-cli.js +0 -68
- package/dist/commands/bundle/bundle.js +0 -219
- package/dist/commands/graph/graph-cli.js +0 -124
- package/dist/commands/graph/graph.js +0 -489
- package/dist/commands/improve/extract-watch.js +0 -140
- package/dist/commands/mv-cli.js +0 -1221
- package/dist/commands/sources/history.js +0 -201
- package/dist/commands/tasks/default-tasks.js +0 -186
- package/dist/core/migration-backup.js +0 -1234
- package/dist/indexer/usage/unmigrated-vaults-guard.js +0 -95
- package/dist/llm/memory-infer-impl.js +0 -138
- package/dist/migrate/legacy/config-source-migration.js +0 -223
- package/dist/migrate/legacy/content-migration.js +0 -305
- package/dist/migrate/legacy/legacy-layout.js +0 -779
- package/dist/migrate/legacy/legacy-paths.js +0 -25
- package/dist/migrate/legacy/legacy-stash-json.js +0 -72
- package/dist/migrate/legacy/proposal-fs-import.js +0 -168
- package/dist/migrate/legacy/task-target-ref-migration.js +0 -272
- package/dist/migrate/legacy/three-db-cutover.js +0 -841
- package/dist/migrate/legacy/workflow-migrations-bodies.js +0 -52
- package/dist/migrate/legacy/workflow-migrations-frozen.js +0 -21
- package/dist/migrate/legacy-ref-grammar.js +0 -214
- package/dist/output/shapes/distill.js +0 -14
- package/dist/output/shapes/history.js +0 -11
- package/dist/output/text/distill.js +0 -6
- package/dist/output/text/enable-disable.js +0 -8
- package/dist/output/text/history.js +0 -6
- package/dist/registry/build-index.js +0 -382
- package/dist/schemas/akm-config.json +0 -4704
- package/dist/schemas/akm-task.json +0 -87
- package/dist/schemas/akm-workflow.json +0 -372
- package/dist/scripts/migrate-storage.js +0 -3816
- package/dist/workflows/authoring/workflow-program-template.yaml +0 -31
- package/dist/workflows/cli.js +0 -53
- package/dist/workflows/exec/brief.js +0 -481
- package/dist/workflows/exec/report.js +0 -1460
- package/dist/workflows/exec/watch.js +0 -116
- package/dist/workflows/program/parser.js +0 -813
- package/dist/workflows/program/project.js +0 -104
|
@@ -1,1806 +0,0 @@
|
|
|
1
|
-
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
-
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
-
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
-
import fs from "node:fs";
|
|
6
|
-
import os from "node:os";
|
|
7
|
-
import path from "node:path";
|
|
8
|
-
import { MAX_CONFIG_FILE_BYTES, MAX_LOCAL_METADATA_BYTES, readTextFileWithLimit, writeFileAtomic, } from "../core/common.js";
|
|
9
|
-
import { parseAndValidateConfigText, resetConfigCache, sanitizeConfigForWrite, } from "../core/config/config.js";
|
|
10
|
-
import { backupExistingConfig, parseConfigText, readConfigText, withConfigLock, writeConfigAtomic, } from "../core/config/config-io.js";
|
|
11
|
-
import { ConfigError } from "../core/errors.js";
|
|
12
|
-
import { withMaintenanceStartBarrier } from "../core/maintenance-barrier.js";
|
|
13
|
-
import { assertNoArtifactReplacementBlockers, ensureMigrationBackupWithConfigLockHeld, fingerprintMigrationGeneration, getMigrationApplyJournalPath, getMigrationBackupDir, getMigrationBackupRoot, getMigrationRestoreJournalPath, inspectMigrationState, MIGRATION_BACKUP_VERSION, recoverInterruptedRestoreWithLocksHeld, restoreMigrationBackupWithLocksHeld, sameMigrationGeneration, verifyMigrationBackup, } from "../core/migration-backup.js";
|
|
14
|
-
import { getConfigPath, getDbPath, getStateDbPathInDataDir } from "../core/paths.js";
|
|
15
|
-
import { runMigrations as runStateMigrations } from "../core/state/migrations.js";
|
|
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";
|
|
27
|
-
import { EXIT_CODES } from "./shared.js";
|
|
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
|
-
];
|
|
43
|
-
function isFileFingerprint(value) {
|
|
44
|
-
if (value === null)
|
|
45
|
-
return true;
|
|
46
|
-
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
47
|
-
return false;
|
|
48
|
-
const candidate = value;
|
|
49
|
-
return (Object.keys(candidate).sort().join(",") === "byteSize,sha256" &&
|
|
50
|
-
Number.isSafeInteger(candidate.byteSize) &&
|
|
51
|
-
candidate.byteSize >= 0 &&
|
|
52
|
-
typeof candidate.sha256 === "string" &&
|
|
53
|
-
/^[a-f0-9]{64}$/.test(candidate.sha256));
|
|
54
|
-
}
|
|
55
|
-
function isGenerationFingerprint(value) {
|
|
56
|
-
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
57
|
-
return false;
|
|
58
|
-
const generation = value;
|
|
59
|
-
if (Object.keys(generation).sort().join(",") !== "config,state,workflow")
|
|
60
|
-
return false;
|
|
61
|
-
for (const [name, expectedNullSidecars] of [
|
|
62
|
-
["config", true],
|
|
63
|
-
["state", false],
|
|
64
|
-
["workflow", false],
|
|
65
|
-
]) {
|
|
66
|
-
const artifact = generation[name];
|
|
67
|
-
if (typeof artifact !== "object" || artifact === null || Array.isArray(artifact))
|
|
68
|
-
return false;
|
|
69
|
-
const files = artifact;
|
|
70
|
-
if (Object.keys(files).sort().join(",") !== "main,shm,wal")
|
|
71
|
-
return false;
|
|
72
|
-
if (!isFileFingerprint(files.main) || !isFileFingerprint(files.wal) || !isFileFingerprint(files.shm))
|
|
73
|
-
return false;
|
|
74
|
-
if (expectedNullSidecars && (files.wal !== null || files.shm !== null))
|
|
75
|
-
return false;
|
|
76
|
-
}
|
|
77
|
-
return true;
|
|
78
|
-
}
|
|
79
|
-
function sameArtifactFingerprint(left, right) {
|
|
80
|
-
return JSON.stringify(left) === JSON.stringify(right);
|
|
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
|
-
}
|
|
129
|
-
function hasGenerationMarker(dbPath, operationId, phase) {
|
|
130
|
-
if (!fs.existsSync(dbPath))
|
|
131
|
-
return false;
|
|
132
|
-
const db = openDatabaseFinalizing(dbPath, { readonly: true });
|
|
133
|
-
try {
|
|
134
|
-
if (!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='akm_migration_generation'").get()) {
|
|
135
|
-
return false;
|
|
136
|
-
}
|
|
137
|
-
const rows = db
|
|
138
|
-
.prepare("SELECT operation_id, phase FROM akm_migration_generation WHERE singleton=1 LIMIT 2")
|
|
139
|
-
.all();
|
|
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);
|
|
502
|
-
}
|
|
503
|
-
finally {
|
|
504
|
-
db.close();
|
|
505
|
-
}
|
|
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
|
-
}
|
|
629
|
-
function generationFromBackup(manifest) {
|
|
630
|
-
const fingerprint = (name) => {
|
|
631
|
-
const artifact = manifest.artifacts[name];
|
|
632
|
-
return artifact.present ? { byteSize: artifact.byteSize, sha256: artifact.sha256 } : null;
|
|
633
|
-
};
|
|
634
|
-
return {
|
|
635
|
-
config: { main: fingerprint("config.json"), wal: null, shm: null },
|
|
636
|
-
state: { main: fingerprint("state.db"), wal: null, shm: null },
|
|
637
|
-
workflow: { main: fingerprint("workflow.db"), wal: null, shm: null },
|
|
638
|
-
};
|
|
639
|
-
}
|
|
640
|
-
function detectAdjacentGeneration(journal, manifest, live, current, workflowDbPath) {
|
|
641
|
-
if (journal.phase === "rollback-prepared") {
|
|
642
|
-
return {
|
|
643
|
-
rollbackCompleted: sameMigrationGeneration(current, generationFromBackup(manifest)),
|
|
644
|
-
};
|
|
645
|
-
}
|
|
646
|
-
const unchanged = (...names) => names.every((name) => sameArtifactFingerprint(journal.generation[name], current[name]));
|
|
647
|
-
if (journal.phase === "state-applied" &&
|
|
648
|
-
unchanged("config", "state") &&
|
|
649
|
-
hasGenerationMarker(workflowDbPath, journal.operationId, "workflow-applied")) {
|
|
650
|
-
return {
|
|
651
|
-
adjacent: {
|
|
652
|
-
phase: "workflow-applied",
|
|
653
|
-
complete: live.workflow.status === "current" || live.workflow.status === "missing",
|
|
654
|
-
generation: current,
|
|
655
|
-
},
|
|
656
|
-
};
|
|
657
|
-
}
|
|
658
|
-
const expectedTarget = `${JSON.stringify(journal.targetConfig, null, 2)}\n`;
|
|
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") &&
|
|
664
|
-
unchanged("state", "workflow") &&
|
|
665
|
-
fs.existsSync(getConfigPath()) &&
|
|
666
|
-
readTextFileWithLimit(getConfigPath(), MAX_CONFIG_FILE_BYTES, "Config file") === expectedTarget) {
|
|
667
|
-
return {
|
|
668
|
-
adjacent: { phase: "config-applied", complete: true, generation: current },
|
|
669
|
-
};
|
|
670
|
-
}
|
|
671
|
-
return {};
|
|
672
|
-
}
|
|
673
|
-
function assertRollbackTransitionAllowed(journal, current) {
|
|
674
|
-
const unchanged = journal.phase === "prepared"
|
|
675
|
-
? ["config", "workflow"]
|
|
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"];
|
|
686
|
-
for (const name of unchanged) {
|
|
687
|
-
if (!sameArtifactFingerprint(journal.generation[name], current[name])) {
|
|
688
|
-
if (name === "state" &&
|
|
689
|
-
(journal.phase === "state-applied" || journal.phase === "workflow-applied") &&
|
|
690
|
-
readSingleFileBoundStateMarker(journal)?.phase === "state-applied") {
|
|
691
|
-
// SQLite rollback preserves the logical generation but may rewrite
|
|
692
|
-
// physical pages. The operation-bound canonical digest covers the full
|
|
693
|
-
// schema and every row, so it safely authenticates that rollback.
|
|
694
|
-
continue;
|
|
695
|
-
}
|
|
696
|
-
throw new ConfigError(`Refusing migration rollback because ${name} changed outside the journaled ${journal.phase} transition.`, "INVALID_CONFIG_FILE");
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
function sameArtifactState(actual, expected) {
|
|
701
|
-
return (actual.status === expected.status &&
|
|
702
|
-
JSON.stringify(actual.migrationIds ?? []) === JSON.stringify(expected.migrationIds ?? []) &&
|
|
703
|
-
JSON.stringify(actual.migrationChecksums ?? []) === JSON.stringify(expected.migrationChecksums ?? []));
|
|
704
|
-
}
|
|
705
|
-
function configMatchesBytes(expectedSize, expectedHash) {
|
|
706
|
-
const configPath = getConfigPath();
|
|
707
|
-
if (!fs.existsSync(configPath) || expectedHash === null)
|
|
708
|
-
return false;
|
|
709
|
-
const text = readTextFileWithLimit(configPath, MAX_CONFIG_FILE_BYTES, "Config file");
|
|
710
|
-
return (fs.statSync(configPath).size === expectedSize &&
|
|
711
|
-
createHash("sha256").update(Buffer.from(text)).digest("hex") === expectedHash);
|
|
712
|
-
}
|
|
713
|
-
function validateApplyPhase(journal, manifest, inspectionPaths, inspectedState) {
|
|
714
|
-
const live = inspectedState ?? inspectMigrationState(inspectionPaths);
|
|
715
|
-
const stateOriginal = sameArtifactState(live.state, manifest.artifacts["state.db"]);
|
|
716
|
-
const workflowOriginal = sameArtifactState(live.workflow, manifest.artifacts["workflow.db"]);
|
|
717
|
-
const configOriginal = manifest.artifacts["config.json"].present
|
|
718
|
-
? configMatchesBytes(manifest.artifacts["config.json"].byteSize, manifest.artifacts["config.json"].sha256)
|
|
719
|
-
: live.config.status === "missing";
|
|
720
|
-
const stateApplied = manifest.artifacts["state.db"].present
|
|
721
|
-
? live.state.status === "current"
|
|
722
|
-
: live.state.status === "missing";
|
|
723
|
-
const workflowApplied = manifest.artifacts["workflow.db"].present
|
|
724
|
-
? live.workflow.status === "current"
|
|
725
|
-
: live.workflow.status === "missing";
|
|
726
|
-
// Chunk 8, WI-8.2: at/after the cutover, workflow.db is DELETED (its rows are
|
|
727
|
-
// merged into state.db). A backed-up-present workflow.db that is now missing is
|
|
728
|
-
// the intended post-cutover terminal state, not a failure.
|
|
729
|
-
const workflowDeleted = manifest.artifacts["workflow.db"].present && live.workflow.status === "missing";
|
|
730
|
-
const workflowFinal = workflowApplied || workflowDeleted;
|
|
731
|
-
const expectedTarget = `${JSON.stringify(journal.targetConfig, null, 2)}\n`;
|
|
732
|
-
const configApplied = live.config.status === "current" &&
|
|
733
|
-
fs.existsSync(getConfigPath()) &&
|
|
734
|
-
readTextFileWithLimit(getConfigPath(), MAX_CONFIG_FILE_BYTES, "Config file") === expectedTarget;
|
|
735
|
-
const reachable = journal.phase === "rollback-prepared"
|
|
736
|
-
? true
|
|
737
|
-
: journal.phase === "prepared"
|
|
738
|
-
? configOriginal && workflowOriginal && (stateOriginal || stateApplied)
|
|
739
|
-
: journal.phase === "state-converting" || journal.phase === "state-collapsing"
|
|
740
|
-
? configOriginal && workflowOriginal && (stateOriginal || stateApplied)
|
|
741
|
-
: journal.phase === "state-applied"
|
|
742
|
-
? stateApplied && configOriginal && (workflowOriginal || workflowApplied)
|
|
743
|
-
: journal.phase === "workflow-applied"
|
|
744
|
-
? stateApplied && workflowApplied && (configOriginal || configApplied)
|
|
745
|
-
: journal.phase === "cutover-applied"
|
|
746
|
-
? stateApplied && workflowFinal && (configOriginal || configApplied)
|
|
747
|
-
: // config-applied / tasks-* / pilot-* / committed
|
|
748
|
-
stateApplied && workflowFinal && configApplied;
|
|
749
|
-
if (!reachable) {
|
|
750
|
-
throw new ConfigError(`Migration apply journal phase ${journal.phase} does not match a reachable config/state/workflow artifact state.`, "INVALID_CONFIG_FILE");
|
|
751
|
-
}
|
|
752
|
-
return live;
|
|
753
|
-
}
|
|
754
|
-
function postCutoverArtifacts(journal, manifest, current, paths, artifacts) {
|
|
755
|
-
assertPostCutoverWorkflowAuthenticated(journal, current);
|
|
756
|
-
if (!cutoverMergeCommitted(paths.stateDbPath, journal.operationId)) {
|
|
757
|
-
throw new ConfigError(`Migration apply journal phase ${journal.phase} lacks its operation-bound cutover marker.`, "INVALID_CONFIG_FILE");
|
|
758
|
-
}
|
|
759
|
-
return validateApplyPhase(journal, manifest, paths, artifacts);
|
|
760
|
-
}
|
|
761
|
-
function readApplyJournalMetadata() {
|
|
762
|
-
const journalPath = getMigrationApplyJournalPath();
|
|
763
|
-
if (!fs.existsSync(journalPath))
|
|
764
|
-
return {};
|
|
765
|
-
let journal;
|
|
766
|
-
try {
|
|
767
|
-
const value = JSON.parse(readTextFileWithLimit(journalPath, MAX_LOCAL_METADATA_BYTES, "Migration apply journal"));
|
|
768
|
-
const phases = [
|
|
769
|
-
"prepared",
|
|
770
|
-
"state-converting",
|
|
771
|
-
"state-collapsing",
|
|
772
|
-
"state-applied",
|
|
773
|
-
"workflow-applied",
|
|
774
|
-
"cutover-applied",
|
|
775
|
-
"config-applied",
|
|
776
|
-
"tasks-prepared",
|
|
777
|
-
"tasks-applied",
|
|
778
|
-
"pilot-prepared",
|
|
779
|
-
"pilot-applied",
|
|
780
|
-
"rollback-prepared",
|
|
781
|
-
"committed",
|
|
782
|
-
];
|
|
783
|
-
if (typeof value !== "object" ||
|
|
784
|
-
value === null ||
|
|
785
|
-
Array.isArray(value) ||
|
|
786
|
-
Object.keys(value).sort().join(",") !==
|
|
787
|
-
[
|
|
788
|
-
"backupPath",
|
|
789
|
-
"backupRunId",
|
|
790
|
-
"formatVersion",
|
|
791
|
-
"generation",
|
|
792
|
-
"installationId",
|
|
793
|
-
"operationId",
|
|
794
|
-
"phase",
|
|
795
|
-
"targetConfig",
|
|
796
|
-
"version",
|
|
797
|
-
]
|
|
798
|
-
.sort()
|
|
799
|
-
.join(",")) {
|
|
800
|
-
return { error: `Invalid migration apply journal at ${journalPath}.` };
|
|
801
|
-
}
|
|
802
|
-
const candidate = value;
|
|
803
|
-
if (candidate.formatVersion !== 2 ||
|
|
804
|
-
candidate.version !== MIGRATION_BACKUP_VERSION ||
|
|
805
|
-
typeof candidate.operationId !== "string" ||
|
|
806
|
-
!/^[A-Za-z0-9._-]+$/.test(candidate.operationId) ||
|
|
807
|
-
candidate.installationId !== path.basename(getMigrationBackupRoot()) ||
|
|
808
|
-
!isGenerationFingerprint(candidate.generation) ||
|
|
809
|
-
typeof candidate.backupRunId !== "string" ||
|
|
810
|
-
!/^[A-Za-z0-9._-]+$/.test(candidate.backupRunId) ||
|
|
811
|
-
candidate.backupPath !== getMigrationBackupDir(candidate.backupRunId) ||
|
|
812
|
-
!candidate.targetConfig ||
|
|
813
|
-
typeof candidate.targetConfig !== "object" ||
|
|
814
|
-
Array.isArray(candidate.targetConfig) ||
|
|
815
|
-
!phases.includes(candidate.phase)) {
|
|
816
|
-
return { error: `Invalid or foreign migration apply journal at ${journalPath}.` };
|
|
817
|
-
}
|
|
818
|
-
journal = candidate;
|
|
819
|
-
}
|
|
820
|
-
catch (error) {
|
|
821
|
-
return {
|
|
822
|
-
error: `Unreadable migration apply journal at ${journalPath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
823
|
-
};
|
|
824
|
-
}
|
|
825
|
-
try {
|
|
826
|
-
const backupStat = fs.lstatSync(journal.backupPath);
|
|
827
|
-
if (backupStat.isSymbolicLink() ||
|
|
828
|
-
!backupStat.isDirectory() ||
|
|
829
|
-
fs.realpathSync(path.dirname(journal.backupPath)) !== fs.realpathSync(getMigrationBackupRoot())) {
|
|
830
|
-
throw new ConfigError(`Migration apply journal backup is not a canonical installation run directory.`, "INVALID_CONFIG_FILE");
|
|
831
|
-
}
|
|
832
|
-
const config = parseAndValidateConfigText(JSON.stringify(journal.targetConfig), journalPath);
|
|
833
|
-
const manifest = verifyMigrationBackup(journal.backupPath);
|
|
834
|
-
if (manifest.runId !== journal.backupRunId || manifest.installationId !== journal.installationId) {
|
|
835
|
-
throw new ConfigError(`Migration apply journal backup provenance does not match its manifest.`, "INVALID_CONFIG_FILE");
|
|
836
|
-
}
|
|
837
|
-
return { journal, config, manifest };
|
|
838
|
-
}
|
|
839
|
-
catch (error) {
|
|
840
|
-
return {
|
|
841
|
-
journal,
|
|
842
|
-
error: `Unreadable migration apply journal at ${journalPath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
843
|
-
};
|
|
844
|
-
}
|
|
845
|
-
}
|
|
846
|
-
function journalArtifactState(artifact, status = artifact.status) {
|
|
847
|
-
return {
|
|
848
|
-
status,
|
|
849
|
-
...(artifact.migrationIds ? { migrationIds: artifact.migrationIds } : {}),
|
|
850
|
-
...(artifact.migrationChecksums ? { migrationChecksums: artifact.migrationChecksums } : {}),
|
|
851
|
-
};
|
|
852
|
-
}
|
|
853
|
-
function stateBeforeCompatibilityConversion(journal, manifest) {
|
|
854
|
-
const workflowBackup = manifest.artifacts["workflow.db"];
|
|
855
|
-
const indexBackup = manifest.artifacts["index.db"];
|
|
856
|
-
return {
|
|
857
|
-
config: journalArtifactState(manifest.artifacts["config.json"]),
|
|
858
|
-
state: { status: manifest.artifacts["state.db"].present ? "current" : "missing" },
|
|
859
|
-
workflow: journal.phase === "workflow-applied" && workflowBackup.present
|
|
860
|
-
? { status: "current" }
|
|
861
|
-
: journalArtifactState(workflowBackup),
|
|
862
|
-
index: indexBackup ? journalArtifactState(indexBackup) : { status: "missing" },
|
|
863
|
-
};
|
|
864
|
-
}
|
|
865
|
-
function isPreConversionCompatiblePhase(phase) {
|
|
866
|
-
return phase === "state-applied" || phase === "workflow-applied";
|
|
867
|
-
}
|
|
868
|
-
function isPostCutoverPhase(phase) {
|
|
869
|
-
return [
|
|
870
|
-
"cutover-applied",
|
|
871
|
-
"config-applied",
|
|
872
|
-
"tasks-prepared",
|
|
873
|
-
"tasks-applied",
|
|
874
|
-
"pilot-prepared",
|
|
875
|
-
"pilot-applied",
|
|
876
|
-
"committed",
|
|
877
|
-
].includes(phase);
|
|
878
|
-
}
|
|
879
|
-
function isAuthenticatedWorkflowAdjacent(journal, current, workflowSnapshotPath) {
|
|
880
|
-
if (journal.phase !== "state-applied")
|
|
881
|
-
return false;
|
|
882
|
-
const markerPresent = workflowSnapshotPath
|
|
883
|
-
? hasGenerationMarker(workflowSnapshotPath, journal.operationId, "workflow-applied")
|
|
884
|
-
: hasGenerationMarkerFromSnapshot(getLegacyWorkflowDbPath(), journal.operationId, "workflow-applied");
|
|
885
|
-
return (sameArtifactFingerprint(journal.generation.config, current.config) &&
|
|
886
|
-
sameArtifactFingerprint(journal.generation.state, current.state) &&
|
|
887
|
-
markerPresent);
|
|
888
|
-
}
|
|
889
|
-
function isAuthenticatedCutoverAdjacent(journal, current, stateSnapshotPath) {
|
|
890
|
-
return (journal.phase === "workflow-applied" &&
|
|
891
|
-
sameArtifactFingerprint(journal.generation.config, current.config) &&
|
|
892
|
-
workflowArtifactIsDeletionSubset(journal.generation, current) &&
|
|
893
|
-
(stateSnapshotPath
|
|
894
|
-
? cutoverMergeCommitted(stateSnapshotPath, journal.operationId)
|
|
895
|
-
: cutoverMergeCommittedFromSnapshot(journal.operationId)));
|
|
896
|
-
}
|
|
897
|
-
function workflowArtifactIsDeletionSubset(expected, current) {
|
|
898
|
-
return ["main", "wal", "shm"].every((component) => {
|
|
899
|
-
const actual = current.workflow[component];
|
|
900
|
-
return actual === null || JSON.stringify(actual) === JSON.stringify(expected.workflow[component]);
|
|
901
|
-
});
|
|
902
|
-
}
|
|
903
|
-
function assertPostCutoverWorkflowAuthenticated(journal, current) {
|
|
904
|
-
if (workflowArtifactIsDeletionSubset(journal.generation, current))
|
|
905
|
-
return;
|
|
906
|
-
throw new ConfigError(`Migration apply journal phase ${journal.phase} does not authorize the live workflow.db generation.`, "INVALID_CONFIG_FILE");
|
|
907
|
-
}
|
|
908
|
-
function readApplyJournal() {
|
|
909
|
-
const metadata = readApplyJournalMetadata();
|
|
910
|
-
if (metadata.error || !metadata.journal || !metadata.config || !metadata.manifest)
|
|
911
|
-
return metadata;
|
|
912
|
-
const { journal, config, manifest } = metadata;
|
|
913
|
-
let capture;
|
|
914
|
-
try {
|
|
915
|
-
capture = captureMigrationInspection();
|
|
916
|
-
}
|
|
917
|
-
catch (error) {
|
|
918
|
-
return {
|
|
919
|
-
journal,
|
|
920
|
-
config,
|
|
921
|
-
error: `Unreadable migration apply journal at ${getMigrationApplyJournalPath()}: ${error instanceof Error ? error.message : String(error)}`,
|
|
922
|
-
};
|
|
923
|
-
}
|
|
924
|
-
const rawGeneration = capture.generation;
|
|
925
|
-
const inspectedArtifacts = capture.artifacts;
|
|
926
|
-
try {
|
|
927
|
-
if (isPostCutoverPhase(journal.phase)) {
|
|
928
|
-
if (isTaskOnlyRepair(manifest)) {
|
|
929
|
-
const artifacts = validateApplyPhase(journal, manifest, capture.paths, inspectedArtifacts);
|
|
930
|
-
if (!sameMigrationGeneration(rawGeneration, journal.generation)) {
|
|
931
|
-
throw new ConfigError(`Migration apply journal phase ${journal.phase} does not match the exact live artifact generation.`, "INVALID_CONFIG_FILE");
|
|
932
|
-
}
|
|
933
|
-
return { journal, config, artifacts };
|
|
934
|
-
}
|
|
935
|
-
return {
|
|
936
|
-
journal,
|
|
937
|
-
config,
|
|
938
|
-
artifacts: postCutoverArtifacts(journal, manifest, rawGeneration, capture.paths, inspectedArtifacts),
|
|
939
|
-
};
|
|
940
|
-
}
|
|
941
|
-
if (!sameMigrationGeneration(rawGeneration, journal.generation) &&
|
|
942
|
-
isAuthenticatedCutoverAdjacent(journal, rawGeneration, capture.paths.stateDbPath)) {
|
|
943
|
-
const artifacts = postCutoverArtifacts({ ...journal, phase: "cutover-applied" }, manifest, rawGeneration, capture.paths, inspectedArtifacts);
|
|
944
|
-
return {
|
|
945
|
-
journal,
|
|
946
|
-
config,
|
|
947
|
-
artifacts,
|
|
948
|
-
adjacent: {
|
|
949
|
-
phase: "cutover-applied",
|
|
950
|
-
complete: true,
|
|
951
|
-
generation: rawGeneration,
|
|
952
|
-
},
|
|
953
|
-
};
|
|
954
|
-
}
|
|
955
|
-
if (isPreConversionCompatiblePhase(journal.phase)) {
|
|
956
|
-
const stateMarker = readSingleFileBoundStateMarker(journal, capture.paths.stateDbPath);
|
|
957
|
-
if (stateMarker?.phase === "state-applied") {
|
|
958
|
-
if (sameMigrationGeneration(rawGeneration, journal.generation)) {
|
|
959
|
-
return { journal, config, artifacts: inspectedArtifacts };
|
|
960
|
-
}
|
|
961
|
-
if (isAuthenticatedWorkflowAdjacent(journal, rawGeneration, capture.paths.workflowDbPath)) {
|
|
962
|
-
return {
|
|
963
|
-
journal,
|
|
964
|
-
config,
|
|
965
|
-
artifacts: inspectedArtifacts,
|
|
966
|
-
adjacent: {
|
|
967
|
-
phase: "workflow-applied",
|
|
968
|
-
complete: true,
|
|
969
|
-
generation: rawGeneration,
|
|
970
|
-
},
|
|
971
|
-
};
|
|
972
|
-
}
|
|
973
|
-
throw new ConfigError(`Migration apply journal phase ${journal.phase} does not match the exact live artifact generation.`, "INVALID_CONFIG_FILE");
|
|
974
|
-
}
|
|
975
|
-
}
|
|
976
|
-
if (isPreConversionCompatiblePhase(journal.phase)) {
|
|
977
|
-
if (sameMigrationGeneration(rawGeneration, journal.generation)) {
|
|
978
|
-
return {
|
|
979
|
-
journal,
|
|
980
|
-
config,
|
|
981
|
-
artifacts: stateBeforeCompatibilityConversion(journal, manifest),
|
|
982
|
-
requiresStateConversion: true,
|
|
983
|
-
};
|
|
984
|
-
}
|
|
985
|
-
if (isAuthenticatedWorkflowAdjacent(journal, rawGeneration, capture.paths.workflowDbPath)) {
|
|
986
|
-
return {
|
|
987
|
-
journal,
|
|
988
|
-
config,
|
|
989
|
-
artifacts: stateBeforeCompatibilityConversion({ ...journal, phase: "workflow-applied" }, manifest),
|
|
990
|
-
requiresStateConversion: true,
|
|
991
|
-
adjacent: {
|
|
992
|
-
phase: "workflow-applied",
|
|
993
|
-
complete: true,
|
|
994
|
-
generation: rawGeneration,
|
|
995
|
-
},
|
|
996
|
-
};
|
|
997
|
-
}
|
|
998
|
-
throw new ConfigError(`Migration apply journal phase ${journal.phase} does not match the exact live artifact generation.`, "INVALID_CONFIG_FILE");
|
|
999
|
-
}
|
|
1000
|
-
if (journal.phase === "state-converting") {
|
|
1001
|
-
if (sameMigrationGeneration(rawGeneration, journal.generation)) {
|
|
1002
|
-
return {
|
|
1003
|
-
journal,
|
|
1004
|
-
config,
|
|
1005
|
-
artifacts: stateBeforeCompatibilityConversion(journal, manifest),
|
|
1006
|
-
requiresStateConversion: true,
|
|
1007
|
-
};
|
|
1008
|
-
}
|
|
1009
|
-
const marker = readBoundStateGenerationMarkerAtPath(capture.paths.stateDbPath, journal.operationId);
|
|
1010
|
-
if (marker?.phase === "state-converting") {
|
|
1011
|
-
return {
|
|
1012
|
-
journal,
|
|
1013
|
-
config,
|
|
1014
|
-
artifacts: stateBeforeCompatibilityConversion(journal, manifest),
|
|
1015
|
-
requiresStateConversion: true,
|
|
1016
|
-
adjacent: {
|
|
1017
|
-
phase: "state-collapsing",
|
|
1018
|
-
complete: true,
|
|
1019
|
-
generation: rawGeneration,
|
|
1020
|
-
},
|
|
1021
|
-
};
|
|
1022
|
-
}
|
|
1023
|
-
throw new ConfigError("Migration apply journal phase state-converting does not match its exact marker-bound generation.", "INVALID_CONFIG_FILE");
|
|
1024
|
-
}
|
|
1025
|
-
if (journal.phase === "state-collapsing") {
|
|
1026
|
-
if (sameMigrationGeneration(rawGeneration, journal.generation)) {
|
|
1027
|
-
return {
|
|
1028
|
-
journal,
|
|
1029
|
-
config,
|
|
1030
|
-
artifacts: stateBeforeCompatibilityConversion(journal, manifest),
|
|
1031
|
-
requiresStateConversion: true,
|
|
1032
|
-
};
|
|
1033
|
-
}
|
|
1034
|
-
const marker = readBoundStateGenerationMarkerAtPath(capture.paths.stateDbPath, journal.operationId);
|
|
1035
|
-
if (marker) {
|
|
1036
|
-
return {
|
|
1037
|
-
journal,
|
|
1038
|
-
config,
|
|
1039
|
-
artifacts: stateBeforeCompatibilityConversion(journal, manifest),
|
|
1040
|
-
requiresStateConversion: true,
|
|
1041
|
-
adjacent: {
|
|
1042
|
-
phase: marker.phase === "state-applied" ? "state-applied" : "state-collapsing",
|
|
1043
|
-
complete: marker.phase === "state-applied",
|
|
1044
|
-
generation: rawGeneration,
|
|
1045
|
-
},
|
|
1046
|
-
};
|
|
1047
|
-
}
|
|
1048
|
-
throw new ConfigError("Migration apply journal phase state-collapsing does not match its exact marker-bound generation.", "INVALID_CONFIG_FILE");
|
|
1049
|
-
}
|
|
1050
|
-
validateApplyPhase(journal, manifest, capture.paths, inspectedArtifacts);
|
|
1051
|
-
if (!sameMigrationGeneration(rawGeneration, journal.generation)) {
|
|
1052
|
-
const adjacent = detectAdjacentGeneration(journal, manifest, inspectedArtifacts, rawGeneration, capture.paths.workflowDbPath);
|
|
1053
|
-
if (adjacent.adjacent || adjacent.rollbackCompleted) {
|
|
1054
|
-
return { journal, config, artifacts: inspectedArtifacts, ...adjacent };
|
|
1055
|
-
}
|
|
1056
|
-
throw new ConfigError(`Migration apply journal phase ${journal.phase} does not match the exact live artifact generation.`, "INVALID_CONFIG_FILE");
|
|
1057
|
-
}
|
|
1058
|
-
return { journal, config, artifacts: inspectedArtifacts };
|
|
1059
|
-
}
|
|
1060
|
-
catch (error) {
|
|
1061
|
-
return {
|
|
1062
|
-
journal,
|
|
1063
|
-
config,
|
|
1064
|
-
...(isPreConversionCompatiblePhase(journal.phase) ||
|
|
1065
|
-
journal.phase === "state-converting" ||
|
|
1066
|
-
journal.phase === "state-collapsing"
|
|
1067
|
-
? {
|
|
1068
|
-
artifacts: stateBeforeCompatibilityConversion(journal, manifest),
|
|
1069
|
-
requiresStateConversion: true,
|
|
1070
|
-
}
|
|
1071
|
-
: { artifacts: inspectedArtifacts }),
|
|
1072
|
-
error: `Unreadable migration apply journal at ${getMigrationApplyJournalPath()}: ${error instanceof Error ? error.message : String(error)}`,
|
|
1073
|
-
};
|
|
1074
|
-
}
|
|
1075
|
-
finally {
|
|
1076
|
-
capture.cleanup();
|
|
1077
|
-
}
|
|
1078
|
-
}
|
|
1079
|
-
function authenticatePreConversionJournalForApply() {
|
|
1080
|
-
const metadata = readApplyJournalMetadata();
|
|
1081
|
-
if (metadata.error)
|
|
1082
|
-
throw new ConfigError(metadata.error, "INVALID_CONFIG_FILE");
|
|
1083
|
-
const journal = metadata.journal;
|
|
1084
|
-
if (!journal || !isPreConversionCompatiblePhase(journal.phase))
|
|
1085
|
-
return;
|
|
1086
|
-
const current = fingerprintMigrationGeneration();
|
|
1087
|
-
if (isAuthenticatedCutoverAdjacent(journal, current))
|
|
1088
|
-
return;
|
|
1089
|
-
if (readSingleFileBoundStateMarker(journal)?.phase === "state-applied") {
|
|
1090
|
-
if (sameMigrationGeneration(current, journal.generation) ||
|
|
1091
|
-
isAuthenticatedWorkflowAdjacent(journal, current) ||
|
|
1092
|
-
isAuthenticatedCutoverAdjacent(journal, current)) {
|
|
1093
|
-
return;
|
|
1094
|
-
}
|
|
1095
|
-
throw new ConfigError(`Migration apply journal phase ${journal.phase} does not match the exact live artifact generation.`, "INVALID_CONFIG_FILE");
|
|
1096
|
-
}
|
|
1097
|
-
if (!sameMigrationGeneration(current, journal.generation) &&
|
|
1098
|
-
!isAuthenticatedWorkflowAdjacent(journal, current) &&
|
|
1099
|
-
!isAuthenticatedCutoverAdjacent(journal, current)) {
|
|
1100
|
-
throw new ConfigError(`Migration apply journal phase ${journal.phase} does not match the exact live artifact generation.`, "INVALID_CONFIG_FILE");
|
|
1101
|
-
}
|
|
1102
|
-
}
|
|
1103
|
-
function preparePreConversionJournalForApply() {
|
|
1104
|
-
const metadata = readApplyJournalMetadata();
|
|
1105
|
-
if (metadata.error)
|
|
1106
|
-
throw new ConfigError(metadata.error, "INVALID_CONFIG_FILE");
|
|
1107
|
-
const journal = metadata.journal;
|
|
1108
|
-
if (!journal || !isPreConversionCompatiblePhase(journal.phase))
|
|
1109
|
-
return;
|
|
1110
|
-
let current = fingerprintMigrationGeneration();
|
|
1111
|
-
if (isAuthenticatedCutoverAdjacent(journal, current))
|
|
1112
|
-
return;
|
|
1113
|
-
if (readSingleFileBoundStateMarker(journal)?.phase === "state-applied")
|
|
1114
|
-
return;
|
|
1115
|
-
if (!sameMigrationGeneration(current, journal.generation)) {
|
|
1116
|
-
if (!isAuthenticatedWorkflowAdjacent(journal, current)) {
|
|
1117
|
-
throw new ConfigError(`Migration apply journal phase ${journal.phase} does not match the exact live artifact generation.`, "INVALID_CONFIG_FILE");
|
|
1118
|
-
}
|
|
1119
|
-
current = fingerprintMigrationGeneration();
|
|
1120
|
-
}
|
|
1121
|
-
journal.phase = "state-converting";
|
|
1122
|
-
journal.generation = current;
|
|
1123
|
-
writeApplyJournal(journal);
|
|
1124
|
-
}
|
|
1125
|
-
function writeApplyJournal(journal) {
|
|
1126
|
-
fs.mkdirSync(path.dirname(getMigrationApplyJournalPath()), { recursive: true, mode: 0o700 });
|
|
1127
|
-
const serialized = `${JSON.stringify(journal, null, 2)}\n`;
|
|
1128
|
-
const byteLength = Buffer.byteLength(serialized, "utf8");
|
|
1129
|
-
if (byteLength > MAX_LOCAL_METADATA_BYTES) {
|
|
1130
|
-
throw new ConfigError(`Migration apply journal would exceed the ${MAX_LOCAL_METADATA_BYTES}-byte metadata limit (${byteLength} bytes).`, "INVALID_CONFIG_FILE");
|
|
1131
|
-
}
|
|
1132
|
-
writeFileAtomic(getMigrationApplyJournalPath(), serialized, 0o600);
|
|
1133
|
-
}
|
|
1134
|
-
function advanceApplyJournal(journal, phase) {
|
|
1135
|
-
if (APPLY_PHASE_ORDER.indexOf(phase) > APPLY_PHASE_ORDER.indexOf(journal.phase))
|
|
1136
|
-
journal.phase = phase;
|
|
1137
|
-
journal.generation = fingerprintMigrationGeneration();
|
|
1138
|
-
writeApplyJournal(journal);
|
|
1139
|
-
}
|
|
1140
|
-
function clearApplyJournal() {
|
|
1141
|
-
fs.rmSync(getMigrationApplyJournalPath(), { force: true });
|
|
1142
|
-
try {
|
|
1143
|
-
const fd = fs.openSync(path.dirname(getMigrationApplyJournalPath()), "r");
|
|
1144
|
-
try {
|
|
1145
|
-
fs.fsyncSync(fd);
|
|
1146
|
-
}
|
|
1147
|
-
finally {
|
|
1148
|
-
fs.closeSync(fd);
|
|
1149
|
-
}
|
|
1150
|
-
}
|
|
1151
|
-
catch {
|
|
1152
|
-
// Directory fsync is not available on every supported filesystem.
|
|
1153
|
-
}
|
|
1154
|
-
}
|
|
1155
|
-
function crashAfterForTests(phase) {
|
|
1156
|
-
if (process.env.AKM_TEST_MIGRATION_CRASH_AFTER === phase)
|
|
1157
|
-
process.kill(process.pid, "SIGKILL");
|
|
1158
|
-
}
|
|
1159
|
-
function crashInMutationGapForTests(phase) {
|
|
1160
|
-
if (process.env.AKM_TEST_MIGRATION_CRASH_GAP === phase)
|
|
1161
|
-
process.kill(process.pid, "SIGKILL");
|
|
1162
|
-
}
|
|
1163
|
-
function isForwardRecoveryPhase(phase) {
|
|
1164
|
-
return [
|
|
1165
|
-
"state-collapsing",
|
|
1166
|
-
"cutover-applied",
|
|
1167
|
-
"config-applied",
|
|
1168
|
-
"tasks-prepared",
|
|
1169
|
-
"tasks-applied",
|
|
1170
|
-
"pilot-prepared",
|
|
1171
|
-
"pilot-applied",
|
|
1172
|
-
"committed",
|
|
1173
|
-
].includes(phase);
|
|
1174
|
-
}
|
|
1175
|
-
/** Publish a preflighted task-target batch inside the journal's forward-only region. */
|
|
1176
|
-
function runTaskTargetMigrationStep(journal, plan) {
|
|
1177
|
-
applyTaskTargetRefMigration(plan);
|
|
1178
|
-
crashInMutationGapForTests("tasks");
|
|
1179
|
-
advanceApplyJournal(journal, "tasks-applied");
|
|
1180
|
-
crashAfterForTests("tasks");
|
|
1181
|
-
}
|
|
1182
|
-
function isTaskOnlyRepair(manifest) {
|
|
1183
|
-
return (manifest.artifacts["config.json"].status === "current" &&
|
|
1184
|
-
["current", "missing"].includes(manifest.artifacts["state.db"].status) &&
|
|
1185
|
-
manifest.artifacts["workflow.db"].status === "missing");
|
|
1186
|
-
}
|
|
1187
|
-
function runTaskOnlyRepair(journal, plan) {
|
|
1188
|
-
advanceApplyJournal(journal, "tasks-prepared");
|
|
1189
|
-
if (journal.phase === "tasks-prepared")
|
|
1190
|
-
runTaskTargetMigrationStep(journal, plan);
|
|
1191
|
-
advanceApplyJournal(journal, "committed");
|
|
1192
|
-
clearApplyJournal();
|
|
1193
|
-
}
|
|
1194
|
-
function runStateMigrationStep(journal) {
|
|
1195
|
-
if (journal.phase === "prepared") {
|
|
1196
|
-
advanceApplyJournal(journal, "state-converting");
|
|
1197
|
-
crashAfterForTests("state-converting");
|
|
1198
|
-
}
|
|
1199
|
-
if (journal.phase === "state-converting") {
|
|
1200
|
-
const db = openDatabaseFinalizing(getStateDbPathInDataDir());
|
|
1201
|
-
try {
|
|
1202
|
-
const marker = readBoundStateGenerationMarker(db, journal.operationId);
|
|
1203
|
-
if (marker?.phase === "state-applied") {
|
|
1204
|
-
throw new ConfigError("state.db is ahead of its state-converting journal.", "INVALID_CONFIG_FILE");
|
|
1205
|
-
}
|
|
1206
|
-
if (!marker) {
|
|
1207
|
-
db.transaction(() => {
|
|
1208
|
-
runStateMigrations(db);
|
|
1209
|
-
bindStateConvertingMarker(db, journal.operationId);
|
|
1210
|
-
})();
|
|
1211
|
-
}
|
|
1212
|
-
else {
|
|
1213
|
-
runStateMigrations(db, { applyPending: false });
|
|
1214
|
-
}
|
|
1215
|
-
}
|
|
1216
|
-
finally {
|
|
1217
|
-
db.close();
|
|
1218
|
-
}
|
|
1219
|
-
crashInMutationGapForTests("state-marker");
|
|
1220
|
-
advanceApplyJournal(journal, "state-collapsing");
|
|
1221
|
-
crashAfterForTests("state-marker");
|
|
1222
|
-
}
|
|
1223
|
-
if (journal.phase === "state-collapsing") {
|
|
1224
|
-
if (!sameMigrationGeneration(fingerprintMigrationGeneration(), journal.generation)) {
|
|
1225
|
-
const marker = readBoundStateGenerationMarkerFromDisk(journal.operationId);
|
|
1226
|
-
if (!marker) {
|
|
1227
|
-
throw new ConfigError("state.db does not match the exact marker-bound generation recorded before conversion.", "INVALID_CONFIG_FILE");
|
|
1228
|
-
}
|
|
1229
|
-
journal.phase = marker.phase === "state-applied" ? "state-applied" : "state-collapsing";
|
|
1230
|
-
journal.generation = fingerprintMigrationGeneration();
|
|
1231
|
-
writeApplyJournal(journal);
|
|
1232
|
-
if (journal.phase === "state-applied")
|
|
1233
|
-
return;
|
|
1234
|
-
}
|
|
1235
|
-
const db = openDatabaseFinalizing(getStateDbPathInDataDir());
|
|
1236
|
-
try {
|
|
1237
|
-
if (!readBoundStateGenerationMarker(db, journal.operationId)) {
|
|
1238
|
-
throw new ConfigError("state.db lacks its exact marker-bound conversion generation.", "INVALID_CONFIG_FILE");
|
|
1239
|
-
}
|
|
1240
|
-
collapseStateDbToSingleFile(db);
|
|
1241
|
-
crashInMutationGapForTests("state-converting");
|
|
1242
|
-
advanceBoundStateMarker(db, journal.operationId);
|
|
1243
|
-
}
|
|
1244
|
-
finally {
|
|
1245
|
-
db.close();
|
|
1246
|
-
}
|
|
1247
|
-
crashInMutationGapForTests("state");
|
|
1248
|
-
advanceApplyJournal(journal, "state-applied");
|
|
1249
|
-
crashAfterForTests("state");
|
|
1250
|
-
}
|
|
1251
|
-
}
|
|
1252
|
-
function stateDatabaseHeaderUsesWal(filePath) {
|
|
1253
|
-
const fd = fs.openSync(filePath, "r");
|
|
1254
|
-
try {
|
|
1255
|
-
const header = Buffer.alloc(20);
|
|
1256
|
-
if (fs.readSync(fd, header, 0, header.byteLength, 0) !== header.byteLength) {
|
|
1257
|
-
throw new ConfigError(`Cannot verify the SQLite header for ${filePath}.`, "INVALID_CONFIG_FILE");
|
|
1258
|
-
}
|
|
1259
|
-
if (!header.subarray(0, 16).equals(Buffer.from("SQLite format 3\0"))) {
|
|
1260
|
-
throw new ConfigError(`Cannot verify a non-SQLite state database at ${filePath}.`, "INVALID_CONFIG_FILE");
|
|
1261
|
-
}
|
|
1262
|
-
return header[18] === 2 || header[19] === 2;
|
|
1263
|
-
}
|
|
1264
|
-
finally {
|
|
1265
|
-
fs.closeSync(fd);
|
|
1266
|
-
}
|
|
1267
|
-
}
|
|
1268
|
-
function assertStateReadyForCutover(journal) {
|
|
1269
|
-
const statePath = getStateDbPathInDataDir();
|
|
1270
|
-
if (!fs.existsSync(statePath)) {
|
|
1271
|
-
throw new ConfigError("Cannot run cutover without state.db.", "INVALID_CONFIG_FILE");
|
|
1272
|
-
}
|
|
1273
|
-
if (stateDatabaseHeaderUsesWal(statePath)) {
|
|
1274
|
-
throw new ConfigError("Refusing cutover because state.db still uses WAL; resume through the state-converting phase first.", "INVALID_CONFIG_FILE");
|
|
1275
|
-
}
|
|
1276
|
-
const db = openDatabaseFinalizing(statePath, { readonly: true });
|
|
1277
|
-
try {
|
|
1278
|
-
const journalMode = String(db.prepare("PRAGMA journal_mode").get()?.journal_mode ?? "").toLowerCase();
|
|
1279
|
-
const hasMarkerTable = db
|
|
1280
|
-
.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='akm_migration_generation'")
|
|
1281
|
-
.get();
|
|
1282
|
-
const marker = hasMarkerTable
|
|
1283
|
-
? db
|
|
1284
|
-
.prepare("SELECT operation_id, phase FROM akm_migration_generation WHERE singleton=1 LIMIT 2")
|
|
1285
|
-
.all()
|
|
1286
|
-
: [];
|
|
1287
|
-
if (journalMode !== "delete" ||
|
|
1288
|
-
marker.length !== 1 ||
|
|
1289
|
-
marker[0]?.operation_id !== journal.operationId ||
|
|
1290
|
-
marker[0]?.phase !== "state-applied") {
|
|
1291
|
-
throw new ConfigError("Refusing cutover because state.db lacks the operation-bound state-converting/single-file proof.", "INVALID_CONFIG_FILE");
|
|
1292
|
-
}
|
|
1293
|
-
}
|
|
1294
|
-
finally {
|
|
1295
|
-
db.close();
|
|
1296
|
-
}
|
|
1297
|
-
}
|
|
1298
|
-
function assertMigrationArtifactsComplete() {
|
|
1299
|
-
const completed = inspectMigrationState();
|
|
1300
|
-
if (completed.config.status !== "current" ||
|
|
1301
|
-
![completed.state.status, completed.workflow.status].every((status) => status === "current" || status === "missing")) {
|
|
1302
|
-
throw new ConfigError("Migration verification did not reach one current cross-artifact generation.", "INVALID_CONFIG_FILE");
|
|
1303
|
-
}
|
|
1304
|
-
}
|
|
1305
|
-
/** Expand a leading `~` against the home directory (config stashDir/source paths may use it). */
|
|
1306
|
-
function expandTilde(p) {
|
|
1307
|
-
if (p === "~")
|
|
1308
|
-
return os.homedir();
|
|
1309
|
-
if (p.startsWith("~/") || p.startsWith("~\\"))
|
|
1310
|
-
return path.join(os.homedir(), p.slice(2));
|
|
1311
|
-
return p;
|
|
1312
|
-
}
|
|
1313
|
-
/**
|
|
1314
|
-
* Stash roots for the cutover ref map's origin aliases, the
|
|
1315
|
-
* source-(b) legacy walk, AND the WI-8.5d content migration's `.stash.json`
|
|
1316
|
-
* fold + D-R6 rename walk. Derived from the TARGET config, which by this point in
|
|
1317
|
-
* the apply has already been normalized to the 0.9 `bundles` shape by
|
|
1318
|
-
* {@link parseMigrationTargetConfig}: each path-bearing bundle is a root, the
|
|
1319
|
-
* `defaultBundle` is the primary, and a bundle's `registryId` (or its key)
|
|
1320
|
-
* supplies the origin alias. The pre-cutover `stashDir`/`sources` shape is still
|
|
1321
|
-
* honored as a fallback for a transitional config that reaches here un-migrated.
|
|
1322
|
-
* Source (a) (the index `item_ref` join) is authoritative, so an unresolved root
|
|
1323
|
-
* only costs a few origin aliases.
|
|
1324
|
-
*/
|
|
1325
|
-
function cutoverStashRootsFromConfig(config) {
|
|
1326
|
-
const roots = [];
|
|
1327
|
-
const bundles = config.bundles;
|
|
1328
|
-
if (bundles && typeof bundles === "object") {
|
|
1329
|
-
for (const [id, entry] of Object.entries(bundles)) {
|
|
1330
|
-
const bundlePath = entry.path;
|
|
1331
|
-
if (typeof bundlePath !== "string" || bundlePath.length === 0)
|
|
1332
|
-
continue; // only filesystem bundles
|
|
1333
|
-
const registryId = entry.registryId ?? id;
|
|
1334
|
-
roots.push({
|
|
1335
|
-
path: path.resolve(expandTilde(bundlePath)),
|
|
1336
|
-
registryId,
|
|
1337
|
-
primary: config.defaultBundle === id,
|
|
1338
|
-
});
|
|
1339
|
-
}
|
|
1340
|
-
}
|
|
1341
|
-
return roots;
|
|
1342
|
-
}
|
|
1343
|
-
/**
|
|
1344
|
-
* Roll a pre-cutover workflow.db forward to its final ledger (010) using the
|
|
1345
|
-
* FROZEN migration bodies (`src/migrate/legacy/workflow-migrations-bodies.ts`)
|
|
1346
|
-
* through the shared engine — never the live `WORKFLOW_MIGRATIONS` array
|
|
1347
|
-
* (`src/workflows/db.ts` is deleted in WI-8.3). The roll materialises every
|
|
1348
|
-
* migration-added column + DEFAULT so the subsequent state.db merge carries
|
|
1349
|
-
* faithful data.
|
|
1350
|
-
*
|
|
1351
|
-
* Pre-versioning (0.7-era) workflow.dbs — rows present but NO `schema_migrations`
|
|
1352
|
-
* ledger — are OUT of the migrator FROM-state (the rc-train fixtures pin a
|
|
1353
|
-
* versioned ledger). We FAIL CLOSED with a clear message rather than
|
|
1354
|
-
* bootstrapping (the old `bootstrapPreVersioningDb` back-fill is retired).
|
|
1355
|
-
*/
|
|
1356
|
-
function runFrozenWorkflowRoll(operationId) {
|
|
1357
|
-
const workflowPath = getLegacyWorkflowDbPath();
|
|
1358
|
-
const db = openDatabaseFinalizing(workflowPath);
|
|
1359
|
-
try {
|
|
1360
|
-
const hasRuns = !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='workflow_runs'").get();
|
|
1361
|
-
const hasLedger = !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='schema_migrations'").get();
|
|
1362
|
-
if (hasRuns && !hasLedger) {
|
|
1363
|
-
throw new ConfigError(`Refusing to migrate a pre-versioning workflow.db at ${workflowPath} (no schema_migrations ledger). ` +
|
|
1364
|
-
"Pre-0.8 workflow databases are not a supported migrator source; upgrade through a 0.8.x release first.", "INVALID_CONFIG_FILE");
|
|
1365
|
-
}
|
|
1366
|
-
// Roll the pending frozen migrations ONLY — never the base-schema DDL. Any
|
|
1367
|
-
// real pre-cutover workflow.db already carries the base schema (its runtime
|
|
1368
|
-
// opener created it); running the baseline CREATE INDEX here would fail on a
|
|
1369
|
-
// pre-existing-but-narrower table. Matches the WI-8.2 workflow-applied path
|
|
1370
|
-
// (openDatabase + runSqliteMigrations, no pragmas/base-schema), preserving
|
|
1371
|
-
// the crash-recovery generation-fingerprint invariants that flow pins.
|
|
1372
|
-
runSqliteMigrations(db, FROZEN_WORKFLOW_MIGRATIONS, {
|
|
1373
|
-
generationMarker: { operationId, phase: "workflow-applied" },
|
|
1374
|
-
});
|
|
1375
|
-
}
|
|
1376
|
-
finally {
|
|
1377
|
-
db.close();
|
|
1378
|
-
}
|
|
1379
|
-
}
|
|
1380
|
-
/**
|
|
1381
|
-
* The `cutover-applied` phase (Chunk 8, WI-8.2). Builds + persists the
|
|
1382
|
-
* old-ref → item_ref map, runs the fail-closed three-DB merge/re-key
|
|
1383
|
-
* transaction, then the idempotent index-quarantine / workflow.db-unlink
|
|
1384
|
-
* boundary ops. A committed merge marker (from an interrupted-then-resumed
|
|
1385
|
-
* apply) short-circuits the merge so it runs exactly once.
|
|
1386
|
-
*/
|
|
1387
|
-
function runCutoverStep(journal, target) {
|
|
1388
|
-
const statePath = getStateDbPathInDataDir();
|
|
1389
|
-
const workflowPath = getLegacyWorkflowDbPath();
|
|
1390
|
-
const indexPath = getDbPath();
|
|
1391
|
-
const stashRoots = cutoverStashRootsFromConfig(target);
|
|
1392
|
-
if (!cutoverMergeCommitted(statePath, journal.operationId)) {
|
|
1393
|
-
const refMap = buildCutoverRefMap({
|
|
1394
|
-
oldIndexDbPath: indexPath,
|
|
1395
|
-
stashRoots,
|
|
1396
|
-
mapOutputPath: cutoverRefMapPath(journal),
|
|
1397
|
-
});
|
|
1398
|
-
// Fail-closed: an integrity failure (unparseable ref / row-count mismatch)
|
|
1399
|
-
// throws a CutoverIntegrityError, which the outer catch converts to a
|
|
1400
|
-
// restore-from-backup. The state txn is atomic — a throw rolls it back, so
|
|
1401
|
-
// state.db + workflow.db are unchanged going into the rollback.
|
|
1402
|
-
runThreeDbCutover({ refMap, operationId: journal.operationId, statePath, workflowPath, oldIndexPath: indexPath });
|
|
1403
|
-
crashInMutationGapForTests("cutover-commit");
|
|
1404
|
-
}
|
|
1405
|
-
else {
|
|
1406
|
-
assertPostCutoverWorkflowAuthenticated(journal, fingerprintMigrationGeneration());
|
|
1407
|
-
}
|
|
1408
|
-
// Boundary ops run AFTER the committed state txn, OUTSIDE the fail-closed gate
|
|
1409
|
-
// (cutover-design.md §2 step 5/6). Idempotent + best-effort — they log and
|
|
1410
|
-
// return, never throw, so a rename/unlink hiccup never rolls back the merge.
|
|
1411
|
-
quarantineIndexDb(journal.operationId, indexPath);
|
|
1412
|
-
deleteWorkflowDb(workflowPath);
|
|
1413
|
-
// WI-8.5d: the content migration (`.stash.json` fold + delete, D-R6 reserved-
|
|
1414
|
-
// filename conformance) is an ADDITIVE filesystem step of the same phase. It
|
|
1415
|
-
// also runs AFTER the committed state txn, is best-effort (a throw is swallowed
|
|
1416
|
-
// + logged, never aborting a committed cutover), and idempotent (a resumed
|
|
1417
|
-
// apply finds no sidecars and no mis-named concepts, so it re-runs to a no-op).
|
|
1418
|
-
runContentMigrationStep(journal, target);
|
|
1419
|
-
}
|
|
1420
|
-
function cutoverRefMapPath(journal) {
|
|
1421
|
-
return path.join(path.dirname(getMigrationApplyJournalPath()), `cutover-refmap-${journal.operationId}.json`);
|
|
1422
|
-
}
|
|
1423
|
-
/** Required forward-only filesystem step after the core config/database cutover verifies. */
|
|
1424
|
-
function runPilotTreatmentStep(journal, target) {
|
|
1425
|
-
const refMap = loadCutoverRefMap(cutoverRefMapPath(journal));
|
|
1426
|
-
migratePilotTreatmentFiles(cutoverStashRootsFromConfig(target), refMap);
|
|
1427
|
-
}
|
|
1428
|
-
/**
|
|
1429
|
-
* Persist location for the content-migration report — next to the ApplyJournal
|
|
1430
|
-
* (alongside the cutover ref map). Survives `clearApplyJournal` (which removes
|
|
1431
|
-
* only the journal file), so the operator + the WI-8.5d test can read the D-R6
|
|
1432
|
-
* rename list after a committed apply.
|
|
1433
|
-
*/
|
|
1434
|
-
function contentMigrationReportPath() {
|
|
1435
|
-
return path.join(path.dirname(getMigrationApplyJournalPath()), "content-migration-report.json");
|
|
1436
|
-
}
|
|
1437
|
-
/** Best-effort content migration + report persistence (see {@link runCutoverStep}). */
|
|
1438
|
-
function runContentMigrationStep(journal, target) {
|
|
1439
|
-
try {
|
|
1440
|
-
const roots = cutoverStashRootsFromConfig(target).map((r) => r.path);
|
|
1441
|
-
const report = runContentMigration(roots);
|
|
1442
|
-
// Fold the one-time pre-0.9 filesystem-proposal import into this same
|
|
1443
|
-
// additive step (it used to run on every proposal operation via
|
|
1444
|
-
// `withProposalsDb`). state.db has been merged + collapsed to single-file
|
|
1445
|
-
// DELETE mode by this point, so we open it raw and INSERT OR IGNORE each
|
|
1446
|
-
// legacy `proposal.json` on its UUID — idempotent, no ledger needed.
|
|
1447
|
-
//
|
|
1448
|
-
// rc-window edge: a user who already ran `akm migrate apply` on an EARLIER
|
|
1449
|
-
// rc binary (before this fold existed) AND never ran a proposal command
|
|
1450
|
-
// afterward would have their pre-0.9 fs proposals un-imported — the old
|
|
1451
|
-
// live-path import that once covered that gap is gone. Re-running this
|
|
1452
|
-
// idempotent `migrate apply` recovers them (the legacy files are left in
|
|
1453
|
-
// place on import, so they are still there to re-walk). Acceptable for an rc.
|
|
1454
|
-
report.legacyProposalsImported = importLegacyProposalsIntoState(getStateDbPathInDataDir(), roots);
|
|
1455
|
-
persistContentMigrationReport(report);
|
|
1456
|
-
if (report.sidecarsFolded > 0 || report.reservedRenames.length > 0 || report.legacyProposalsImported > 0) {
|
|
1457
|
-
console.log(JSON.stringify({
|
|
1458
|
-
event: "content-migration",
|
|
1459
|
-
operationId: journal.operationId,
|
|
1460
|
-
...report,
|
|
1461
|
-
}));
|
|
1462
|
-
}
|
|
1463
|
-
}
|
|
1464
|
-
catch (error) {
|
|
1465
|
-
console.error(`[akm] content migration skipped (${error instanceof Error ? error.message : String(error)}); the committed cutover is unaffected.`);
|
|
1466
|
-
}
|
|
1467
|
-
}
|
|
1468
|
-
function persistContentMigrationReport(report) {
|
|
1469
|
-
try {
|
|
1470
|
-
const reportPath = contentMigrationReportPath();
|
|
1471
|
-
fs.mkdirSync(path.dirname(reportPath), { recursive: true, mode: 0o700 });
|
|
1472
|
-
writeFileAtomic(reportPath, `${JSON.stringify(report, null, 2)}\n`, 0o600);
|
|
1473
|
-
}
|
|
1474
|
-
catch {
|
|
1475
|
-
// The report is auditing-only; a persistence failure never affects the cutover.
|
|
1476
|
-
}
|
|
1477
|
-
}
|
|
1478
|
-
function unsafeArtifact(name, state) {
|
|
1479
|
-
if (!["newer", "inconsistent", "corrupt"].includes(state.status))
|
|
1480
|
-
return undefined;
|
|
1481
|
-
return `${name} is ${state.status}${state.detail ? `: ${state.detail}` : ""}`;
|
|
1482
|
-
}
|
|
1483
|
-
/**
|
|
1484
|
-
* Parse + validate a migration TARGET config, applying the Chunk-8 config-shape
|
|
1485
|
-
* migration (`stashDir`/`sources[]`/`installed[]` → `bundles`/`defaultBundle`)
|
|
1486
|
-
* as a pre-validation transform. This is why a target still carrying the
|
|
1487
|
-
* pre-cutover source shape loads (and reports "current" in `migrate status`)
|
|
1488
|
-
* even though the runtime loader now rejects that shape once `bundles` exists:
|
|
1489
|
-
* the migrator normalizes it FIRST, then the strict schema gates the result.
|
|
1490
|
-
*
|
|
1491
|
-
* The transform is idempotent, so an already-migrated prepared config, or the
|
|
1492
|
-
* new-shape config re-parsed from the apply journal, passes through untouched.
|
|
1493
|
-
*/
|
|
1494
|
-
function parseMigrationTargetConfig(text, sourcePath) {
|
|
1495
|
-
const raw = parseConfigText(text, sourcePath);
|
|
1496
|
-
const migrated = migrateConfigSourcesToBundles(raw);
|
|
1497
|
-
// Re-serialize the normalized object through the canonical validator so the
|
|
1498
|
-
// version check, schema validation, and defaults merge stay in one place.
|
|
1499
|
-
return parseAndValidateConfigText(JSON.stringify(migrated), sourcePath);
|
|
1500
|
-
}
|
|
1501
|
-
function loadTargetConfig(preparedConfigPath, artifacts) {
|
|
1502
|
-
const targetPath = preparedConfigPath ?? (artifacts.config.status === "current" ? getConfigPath() : undefined);
|
|
1503
|
-
if (!targetPath) {
|
|
1504
|
-
return {
|
|
1505
|
-
state: { status: "missing", source: "none", detail: MANUAL_GUIDANCE },
|
|
1506
|
-
};
|
|
1507
|
-
}
|
|
1508
|
-
let text;
|
|
1509
|
-
try {
|
|
1510
|
-
text = readTextFileWithLimit(targetPath, MAX_CONFIG_FILE_BYTES, "Prepared migration config");
|
|
1511
|
-
}
|
|
1512
|
-
catch (error) {
|
|
1513
|
-
return {
|
|
1514
|
-
state: {
|
|
1515
|
-
status: "corrupt",
|
|
1516
|
-
source: preparedConfigPath ? "prepared" : "active",
|
|
1517
|
-
path: targetPath,
|
|
1518
|
-
detail: error instanceof Error ? error.message : String(error),
|
|
1519
|
-
},
|
|
1520
|
-
};
|
|
1521
|
-
}
|
|
1522
|
-
try {
|
|
1523
|
-
return {
|
|
1524
|
-
state: {
|
|
1525
|
-
status: "current",
|
|
1526
|
-
source: preparedConfigPath ? "prepared" : "active",
|
|
1527
|
-
path: targetPath,
|
|
1528
|
-
},
|
|
1529
|
-
config: parseMigrationTargetConfig(text, targetPath),
|
|
1530
|
-
};
|
|
1531
|
-
}
|
|
1532
|
-
catch (error) {
|
|
1533
|
-
return {
|
|
1534
|
-
state: {
|
|
1535
|
-
status: "corrupt",
|
|
1536
|
-
source: preparedConfigPath ? "prepared" : "active",
|
|
1537
|
-
path: targetPath,
|
|
1538
|
-
detail: error instanceof Error ? error.message : String(error),
|
|
1539
|
-
},
|
|
1540
|
-
};
|
|
1541
|
-
}
|
|
1542
|
-
}
|
|
1543
|
-
function buildMigrationPlan(preparedConfigPath, activeApply) {
|
|
1544
|
-
const artifacts = activeApply.artifacts ?? inspectMigrationStateFromSnapshots();
|
|
1545
|
-
const restorePending = fs.existsSync(getMigrationRestoreJournalPath());
|
|
1546
|
-
const target = activeApply.journal
|
|
1547
|
-
? {
|
|
1548
|
-
state: {
|
|
1549
|
-
status: activeApply.config ? "current" : "corrupt",
|
|
1550
|
-
source: "prepared",
|
|
1551
|
-
path: getMigrationApplyJournalPath(),
|
|
1552
|
-
...(!activeApply.config && activeApply.error ? { detail: activeApply.error } : {}),
|
|
1553
|
-
},
|
|
1554
|
-
config: activeApply.config,
|
|
1555
|
-
}
|
|
1556
|
-
: loadTargetConfig(preparedConfigPath, artifacts);
|
|
1557
|
-
const blockers = [
|
|
1558
|
-
unsafeArtifact("config.json", artifacts.config),
|
|
1559
|
-
unsafeArtifact("state.db", artifacts.state),
|
|
1560
|
-
unsafeArtifact("workflow.db", artifacts.workflow),
|
|
1561
|
-
].filter((blocker) => blocker !== undefined);
|
|
1562
|
-
if (target.state.status !== "current")
|
|
1563
|
-
blockers.push(target.state.detail ?? "A current target config is required.");
|
|
1564
|
-
if (activeApply.error && (!activeApply.journal || target.state.status === "current"))
|
|
1565
|
-
blockers.push(activeApply.error);
|
|
1566
|
-
if (restorePending)
|
|
1567
|
-
blockers.push(`Restore recovery is pending at ${getMigrationRestoreJournalPath()}.`);
|
|
1568
|
-
let taskRewrites = 0;
|
|
1569
|
-
if (blockers.length === 0 && target.config) {
|
|
1570
|
-
try {
|
|
1571
|
-
taskRewrites = planTaskTargetRefMigration(target.config).rewrites.length;
|
|
1572
|
-
}
|
|
1573
|
-
catch (error) {
|
|
1574
|
-
blockers.push(error instanceof Error ? error.message : String(error));
|
|
1575
|
-
}
|
|
1576
|
-
}
|
|
1577
|
-
const needsApply = !!activeApply.journal ||
|
|
1578
|
-
artifacts.config.status !== "current" ||
|
|
1579
|
-
artifacts.state.status === "old" ||
|
|
1580
|
-
artifacts.workflow.status === "old" ||
|
|
1581
|
-
artifacts.workflow.status === "current" ||
|
|
1582
|
-
taskRewrites > 0;
|
|
1583
|
-
return {
|
|
1584
|
-
status: blockers.length > 0 ? "blocked" : needsApply ? "ready" : "current",
|
|
1585
|
-
artifacts,
|
|
1586
|
-
targetConfig: target.state,
|
|
1587
|
-
blockers,
|
|
1588
|
-
...(restorePending
|
|
1589
|
-
? {
|
|
1590
|
-
activeOperation: {
|
|
1591
|
-
kind: "restore",
|
|
1592
|
-
phase: "pending",
|
|
1593
|
-
journalPath: getMigrationRestoreJournalPath(),
|
|
1594
|
-
},
|
|
1595
|
-
}
|
|
1596
|
-
: activeApply.journal
|
|
1597
|
-
? {
|
|
1598
|
-
activeOperation: {
|
|
1599
|
-
kind: "apply",
|
|
1600
|
-
phase: activeApply.journal.phase,
|
|
1601
|
-
journalPath: getMigrationApplyJournalPath(),
|
|
1602
|
-
},
|
|
1603
|
-
}
|
|
1604
|
-
: {}),
|
|
1605
|
-
};
|
|
1606
|
-
}
|
|
1607
|
-
export function inspectMigrationPlan(preparedConfigPath) {
|
|
1608
|
-
return buildMigrationPlan(preparedConfigPath, readApplyJournal());
|
|
1609
|
-
}
|
|
1610
|
-
function printPlan(plan) {
|
|
1611
|
-
console.log(JSON.stringify(plan));
|
|
1612
|
-
if (plan.status === "blocked")
|
|
1613
|
-
process.exitCode = EXIT_CODES.GENERAL;
|
|
1614
|
-
}
|
|
1615
|
-
export async function runMigrationStatus(options = {}) {
|
|
1616
|
-
printPlan(inspectMigrationPlan(options.preparedConfigPath));
|
|
1617
|
-
}
|
|
1618
|
-
function requireEligiblePlan(preparedConfigPath, active = readApplyJournal()) {
|
|
1619
|
-
const plan = buildMigrationPlan(preparedConfigPath, active);
|
|
1620
|
-
const loaded = active.journal ? { config: active.config } : loadTargetConfig(preparedConfigPath, plan.artifacts);
|
|
1621
|
-
if (plan.status === "blocked" || !loaded.config) {
|
|
1622
|
-
throw new ConfigError(`Migration is blocked: ${plan.blockers.join("; ")}`, "INVALID_CONFIG_FILE");
|
|
1623
|
-
}
|
|
1624
|
-
return { plan, target: loaded.config };
|
|
1625
|
-
}
|
|
1626
|
-
export async function runMigrationApply(options = {}) {
|
|
1627
|
-
if (options.dryRun) {
|
|
1628
|
-
printPlan(inspectMigrationPlan(options.preparedConfigPath));
|
|
1629
|
-
return;
|
|
1630
|
-
}
|
|
1631
|
-
const result = withConfigLock(() => withMaintenanceStartBarrier(() => {
|
|
1632
|
-
recoverInterruptedRestoreWithLocksHeld();
|
|
1633
|
-
authenticatePreConversionJournalForApply();
|
|
1634
|
-
assertNoArtifactReplacementBlockers(undefined, {
|
|
1635
|
-
skipWorkflowClaims: fs.existsSync(getMigrationApplyJournalPath()),
|
|
1636
|
-
});
|
|
1637
|
-
preparePreConversionJournalForApply();
|
|
1638
|
-
const active = readApplyJournal();
|
|
1639
|
-
if (active.error)
|
|
1640
|
-
throw new ConfigError(active.error, "INVALID_CONFIG_FILE");
|
|
1641
|
-
if (active.rollbackCompleted && active.journal) {
|
|
1642
|
-
clearApplyJournal();
|
|
1643
|
-
resetConfigCache();
|
|
1644
|
-
throw new ConfigError("Interrupted migration rollback was already committed; cleaned its apply journal. Rerun migrate apply with the prepared config.", "INVALID_CONFIG_FILE");
|
|
1645
|
-
}
|
|
1646
|
-
if (active.journal?.phase === "rollback-prepared") {
|
|
1647
|
-
restoreMigrationBackupWithLocksHeld(active.journal.backupPath);
|
|
1648
|
-
crashInMutationGapForTests("rollback");
|
|
1649
|
-
clearApplyJournal();
|
|
1650
|
-
resetConfigCache();
|
|
1651
|
-
throw new ConfigError("Interrupted migration rollback completed from its exact journaled generation; rerun migrate apply with the prepared config.", "INVALID_CONFIG_FILE");
|
|
1652
|
-
}
|
|
1653
|
-
if (active.adjacent && active.journal) {
|
|
1654
|
-
active.journal.generation = active.adjacent.generation;
|
|
1655
|
-
if (active.adjacent.complete)
|
|
1656
|
-
active.journal.phase = active.adjacent.phase;
|
|
1657
|
-
writeApplyJournal(active.journal);
|
|
1658
|
-
}
|
|
1659
|
-
const { plan, target } = requireEligiblePlan(options.preparedConfigPath, active);
|
|
1660
|
-
if (plan.status === "current")
|
|
1661
|
-
return { plan };
|
|
1662
|
-
const backup = active.journal
|
|
1663
|
-
? { path: active.journal.backupPath, manifest: verifyMigrationBackup(active.journal.backupPath) }
|
|
1664
|
-
: ensureMigrationBackupWithConfigLockHeld();
|
|
1665
|
-
const journal = active.journal ?? {
|
|
1666
|
-
formatVersion: 2,
|
|
1667
|
-
version: MIGRATION_BACKUP_VERSION,
|
|
1668
|
-
operationId: `${process.pid}-${randomUUID()}`,
|
|
1669
|
-
installationId: backup.manifest.installationId,
|
|
1670
|
-
backupRunId: backup.manifest.runId,
|
|
1671
|
-
phase: "prepared",
|
|
1672
|
-
backupPath: backup.path,
|
|
1673
|
-
targetConfig: sanitizeConfigForWrite(target),
|
|
1674
|
-
generation: fingerprintMigrationGeneration(),
|
|
1675
|
-
};
|
|
1676
|
-
if (!active.journal)
|
|
1677
|
-
writeApplyJournal(journal);
|
|
1678
|
-
const taskOnlyRepair = isTaskOnlyRepair(backup.manifest);
|
|
1679
|
-
let forwardRecoveryRequired = isForwardRecoveryPhase(journal.phase);
|
|
1680
|
-
try {
|
|
1681
|
-
const taskTargetPlan = planTaskTargetRefMigration(target);
|
|
1682
|
-
if (taskOnlyRepair) {
|
|
1683
|
-
forwardRecoveryRequired = true;
|
|
1684
|
-
runTaskOnlyRepair(journal, taskTargetPlan);
|
|
1685
|
-
return { plan: inspectMigrationPlan(), backup };
|
|
1686
|
-
}
|
|
1687
|
-
runStateMigrationStep(journal);
|
|
1688
|
-
forwardRecoveryRequired = isForwardRecoveryPhase(journal.phase);
|
|
1689
|
-
if (isPostCutoverPhase(journal.phase))
|
|
1690
|
-
runCutoverStep(journal, target);
|
|
1691
|
-
if (journal.phase === "state-applied") {
|
|
1692
|
-
// Roll the pre-cutover workflow.db through the frozen migration bodies
|
|
1693
|
-
// only while the journal is still before the committed cutover.
|
|
1694
|
-
const beforeWorkflow = inspectExactApplyJournalGeneration(journal);
|
|
1695
|
-
if (beforeWorkflow.workflow.status === "old") {
|
|
1696
|
-
runFrozenWorkflowRoll(journal.operationId);
|
|
1697
|
-
}
|
|
1698
|
-
else if (beforeWorkflow.workflow.status !== "current" && beforeWorkflow.workflow.status !== "missing") {
|
|
1699
|
-
throw new ConfigError(`Cannot resume workflow.db from ${beforeWorkflow.workflow.status} state.`, "INVALID_CONFIG_FILE");
|
|
1700
|
-
}
|
|
1701
|
-
crashInMutationGapForTests("workflow");
|
|
1702
|
-
advanceApplyJournal(journal, "workflow-applied");
|
|
1703
|
-
crashAfterForTests("workflow");
|
|
1704
|
-
}
|
|
1705
|
-
if (journal.phase === "workflow-applied") {
|
|
1706
|
-
const migrated = inspectExactApplyJournalGeneration(journal);
|
|
1707
|
-
for (const [name, state] of [
|
|
1708
|
-
["state.db", migrated.state],
|
|
1709
|
-
["workflow.db", migrated.workflow],
|
|
1710
|
-
]) {
|
|
1711
|
-
if (state.status !== "current" && state.status !== "missing") {
|
|
1712
|
-
throw new ConfigError(`Migration left ${name} in ${state.status} state.`, "INVALID_CONFIG_FILE");
|
|
1713
|
-
}
|
|
1714
|
-
}
|
|
1715
|
-
assertStateReadyForCutover(journal);
|
|
1716
|
-
runCutoverStep(journal, target);
|
|
1717
|
-
forwardRecoveryRequired = true;
|
|
1718
|
-
crashInMutationGapForTests("cutover");
|
|
1719
|
-
advanceApplyJournal(journal, "cutover-applied");
|
|
1720
|
-
crashAfterForTests("cutover");
|
|
1721
|
-
}
|
|
1722
|
-
if (journal.phase === "cutover-applied") {
|
|
1723
|
-
// The cutover is committed, so all remaining work is forward-only.
|
|
1724
|
-
try {
|
|
1725
|
-
const preCutoverText = readConfigText(getConfigPath());
|
|
1726
|
-
if (preCutoverText !== undefined) {
|
|
1727
|
-
mergeLockEntriesSync(migratedLockEntries(parseConfigText(preCutoverText, getConfigPath())));
|
|
1728
|
-
}
|
|
1729
|
-
}
|
|
1730
|
-
catch {
|
|
1731
|
-
// Advisory lock re-key only; the committed cutover is unaffected.
|
|
1732
|
-
}
|
|
1733
|
-
backupExistingConfig(getConfigPath());
|
|
1734
|
-
writeConfigAtomic(getConfigPath(), sanitizeConfigForWrite(target));
|
|
1735
|
-
resetConfigCache();
|
|
1736
|
-
crashInMutationGapForTests("config");
|
|
1737
|
-
advanceApplyJournal(journal, "config-applied");
|
|
1738
|
-
crashAfterForTests("config");
|
|
1739
|
-
}
|
|
1740
|
-
if (journal.phase === "config-applied") {
|
|
1741
|
-
assertMigrationArtifactsComplete();
|
|
1742
|
-
advanceApplyJournal(journal, "tasks-prepared");
|
|
1743
|
-
}
|
|
1744
|
-
if (journal.phase === "tasks-prepared") {
|
|
1745
|
-
forwardRecoveryRequired = true;
|
|
1746
|
-
runTaskTargetMigrationStep(journal, taskTargetPlan);
|
|
1747
|
-
}
|
|
1748
|
-
if (journal.phase === "tasks-applied")
|
|
1749
|
-
advanceApplyJournal(journal, "pilot-prepared");
|
|
1750
|
-
if (journal.phase === "pilot-prepared") {
|
|
1751
|
-
forwardRecoveryRequired = true;
|
|
1752
|
-
runPilotTreatmentStep(journal, target);
|
|
1753
|
-
crashInMutationGapForTests("pilot");
|
|
1754
|
-
advanceApplyJournal(journal, "pilot-applied");
|
|
1755
|
-
crashAfterForTests("pilot");
|
|
1756
|
-
}
|
|
1757
|
-
if (journal.phase === "pilot-applied")
|
|
1758
|
-
advanceApplyJournal(journal, "committed");
|
|
1759
|
-
clearApplyJournal();
|
|
1760
|
-
const completed = inspectMigrationPlan();
|
|
1761
|
-
return { plan: completed, backup };
|
|
1762
|
-
}
|
|
1763
|
-
catch (error) {
|
|
1764
|
-
if (error instanceof MigrationPreflightGenerationError || error instanceof MigrationSnapshotChangedError) {
|
|
1765
|
-
forwardRecoveryRequired = true;
|
|
1766
|
-
}
|
|
1767
|
-
if (isForwardRecoveryPhase(journal.phase))
|
|
1768
|
-
forwardRecoveryRequired = true;
|
|
1769
|
-
if (!forwardRecoveryRequired && cutoverMergeCommitted(getStateDbPathInDataDir(), journal.operationId)) {
|
|
1770
|
-
forwardRecoveryRequired = true;
|
|
1771
|
-
}
|
|
1772
|
-
if (forwardRecoveryRequired) {
|
|
1773
|
-
throw new ConfigError(`Migration apply requires forward recovery from ${getMigrationApplyJournalPath()}: ${error instanceof Error ? error.message : String(error)}`, "INVALID_CONFIG_FILE");
|
|
1774
|
-
}
|
|
1775
|
-
try {
|
|
1776
|
-
const rollbackGeneration = fingerprintMigrationGeneration();
|
|
1777
|
-
assertRollbackTransitionAllowed(journal, rollbackGeneration);
|
|
1778
|
-
journal.phase = "rollback-prepared";
|
|
1779
|
-
journal.generation = rollbackGeneration;
|
|
1780
|
-
writeApplyJournal(journal);
|
|
1781
|
-
if (!sameMigrationGeneration(fingerprintMigrationGeneration(), journal.generation)) {
|
|
1782
|
-
throw new ConfigError(`Refusing migration rollback because live artifacts no longer match journal phase ${journal.phase}.`, "INVALID_CONFIG_FILE");
|
|
1783
|
-
}
|
|
1784
|
-
restoreMigrationBackupWithLocksHeld(backup.path);
|
|
1785
|
-
crashInMutationGapForTests("rollback");
|
|
1786
|
-
clearApplyJournal();
|
|
1787
|
-
resetConfigCache();
|
|
1788
|
-
}
|
|
1789
|
-
catch (rollbackError) {
|
|
1790
|
-
throw new ConfigError(`Migration apply failed and rollback could not complete. Keep the current binary and recover from ${backup.path}. Apply error: ${error instanceof Error ? error.message : String(error)}. Rollback error: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, "INVALID_CONFIG_FILE");
|
|
1791
|
-
}
|
|
1792
|
-
throw new ConfigError(`Migration apply failed; config and databases were restored from ${backup.path}: ${error instanceof Error ? error.message : String(error)}`, "INVALID_CONFIG_FILE");
|
|
1793
|
-
}
|
|
1794
|
-
}));
|
|
1795
|
-
console.log(JSON.stringify({
|
|
1796
|
-
...result.plan,
|
|
1797
|
-
status: result.plan.status === "blocked" ? "blocked" : "current",
|
|
1798
|
-
...(result.backup ? { backupPath: result.backup.path, backupRunId: result.backup.manifest.runId } : {}),
|
|
1799
|
-
}));
|
|
1800
|
-
}
|
|
1801
|
-
/** Backward-compatible config subcommand routed through the canonical coordinator. */
|
|
1802
|
-
export async function runConfigMigrate(options = {}) {
|
|
1803
|
-
if (options.dryRun || !options.preparedConfigPath)
|
|
1804
|
-
return runMigrationStatus(options);
|
|
1805
|
-
return runMigrationApply(options);
|
|
1806
|
-
}
|