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,1234 +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 crypto from "node:crypto";
|
|
5
|
-
import fs from "node:fs";
|
|
6
|
-
import path from "node:path";
|
|
7
|
-
import { migrateConfigSourcesToBundles } from "../migrate/legacy/config-source-migration.js";
|
|
8
|
-
import { getLegacyWorkflowDbPath } from "../migrate/legacy/legacy-paths.js";
|
|
9
|
-
import { WORKFLOW_MIGRATIONS_CHECKSUMS } from "../migrate/legacy/workflow-migrations-frozen.js";
|
|
10
|
-
import { openDatabaseFinalizing } from "../storage/database.js";
|
|
11
|
-
import { inspectMigrationLedger, inspectSealedMigrationLedger, } from "../storage/engines/sqlite-migrations.js";
|
|
12
|
-
import { MAX_CONFIG_FILE_BYTES, MAX_LOCAL_METADATA_BYTES, readTextFileWithLimit, writeFileAtomic } from "./common.js";
|
|
13
|
-
import { parseConfigText, withConfigLock } from "./config/config-io.js";
|
|
14
|
-
import { CURRENT_CONFIG_VERSION, validateConfigShape } from "./config/config-schema.js";
|
|
15
|
-
import { compareConfigVersion } from "./config/config-version.js";
|
|
16
|
-
import { ConfigError } from "./errors.js";
|
|
17
|
-
import { createLockPayload, probeLock, reclaimStaleLock, releaseLock, tryAcquireLockSync } from "./file-lock.js";
|
|
18
|
-
import { acquireMaintenanceActivitySync, withMaintenanceStartBarrier } from "./maintenance-barrier.js";
|
|
19
|
-
import { getMigrationApplyJournalPath, getMigrationOperationRoot, getMigrationRestoreJournalPath, } from "./migration-operation.js";
|
|
20
|
-
import { getConfigPath, getDataDir, getDbPath, getIndexWriterLockPath, getLockfileLockPath, getStateDbPathInDataDir, } from "./paths.js";
|
|
21
|
-
import { STATE_MIGRATIONS } from "./state/migrations.js";
|
|
22
|
-
export const MIGRATION_BACKUP_VERSION = "0.9.0";
|
|
23
|
-
const MANIFEST_FORMAT_VERSION = 3;
|
|
24
|
-
/** Pre-cutover manifests (three artifacts, no index.db) stay readable and restorable (plan §3.3 item 1). */
|
|
25
|
-
const LEGACY_MANIFEST_FORMAT_VERSION = 2;
|
|
26
|
-
const RESTORE_JOURNAL_FORMAT_VERSION = 2;
|
|
27
|
-
const CORE_ARTIFACT_NAMES = ["config.json", "state.db", "workflow.db"];
|
|
28
|
-
// index.db joined the backup set at manifest v3 (chunk-8 WI-8.1): it is a
|
|
29
|
-
// regenerable cache backed up ONLY as the pre-rescue home of usage_events,
|
|
30
|
-
// which the three-DB cutover moves into state.db (plan §3.2/§3.3).
|
|
31
|
-
const ARTIFACT_NAMES = [...CORE_ARTIFACT_NAMES, "index.db"];
|
|
32
|
-
const MAX_BLOCKER_DIRECTORY_SAMPLES = 100;
|
|
33
|
-
const MAX_WORKFLOW_BLOCKER_SAMPLES = 100;
|
|
34
|
-
const MAX_BLOCKER_FIELD_BYTES = 256;
|
|
35
|
-
const MAX_BLOCKER_ITEM_BYTES = 512;
|
|
36
|
-
const MAX_BLOCKER_DIAGNOSTIC_BYTES = 16 * 1024;
|
|
37
|
-
/** The artifact set a manifest of the given format version records (v2 = pre-cutover three-artifact shape). */
|
|
38
|
-
function artifactNamesFor(formatVersion) {
|
|
39
|
-
return formatVersion === LEGACY_MANIFEST_FORMAT_VERSION ? CORE_ARTIFACT_NAMES : ARTIFACT_NAMES;
|
|
40
|
-
}
|
|
41
|
-
/** Artifact lookup that enforces the per-version presence parseManifest guarantees. */
|
|
42
|
-
function manifestArtifact(manifest, name) {
|
|
43
|
-
const artifact = manifest.artifacts[name];
|
|
44
|
-
if (!artifact) {
|
|
45
|
-
throw new ConfigError(`Migration backup manifest is missing its ${name} entry.`, "INVALID_CONFIG_FILE");
|
|
46
|
-
}
|
|
47
|
-
return artifact;
|
|
48
|
-
}
|
|
49
|
-
export function getMigrationBackupRoot() {
|
|
50
|
-
return getMigrationOperationRoot();
|
|
51
|
-
}
|
|
52
|
-
export { getMigrationApplyJournalPath, getMigrationRestoreJournalPath } from "./migration-operation.js";
|
|
53
|
-
export function getMigrationBackupDir(runId) {
|
|
54
|
-
if (!runId)
|
|
55
|
-
return getMigrationBackupRoot();
|
|
56
|
-
if (!/^[A-Za-z0-9._-]+$/.test(runId)) {
|
|
57
|
-
throw new ConfigError(`Invalid migration backup run ID ${JSON.stringify(runId)}.`, "INVALID_CONFIG_FILE");
|
|
58
|
-
}
|
|
59
|
-
return path.join(getMigrationBackupRoot(), runId);
|
|
60
|
-
}
|
|
61
|
-
function migrationBackupLockPath() {
|
|
62
|
-
return path.join(getMigrationBackupRoot(), ".lock");
|
|
63
|
-
}
|
|
64
|
-
function restoreJournalPath() {
|
|
65
|
-
return getMigrationRestoreJournalPath();
|
|
66
|
-
}
|
|
67
|
-
function expectedSourcePaths() {
|
|
68
|
-
return {
|
|
69
|
-
"config.json": getConfigPath(),
|
|
70
|
-
"state.db": getStateDbPathInDataDir(),
|
|
71
|
-
"workflow.db": getLegacyWorkflowDbPath(),
|
|
72
|
-
"index.db": getDbPath(),
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
function sha256File(filePath) {
|
|
76
|
-
const hash = crypto.createHash("sha256");
|
|
77
|
-
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
|
78
|
-
const fd = fs.openSync(filePath, "r");
|
|
79
|
-
try {
|
|
80
|
-
while (true) {
|
|
81
|
-
const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null);
|
|
82
|
-
if (bytesRead === 0)
|
|
83
|
-
break;
|
|
84
|
-
hash.update(buffer.subarray(0, bytesRead));
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
finally {
|
|
88
|
-
fs.closeSync(fd);
|
|
89
|
-
}
|
|
90
|
-
return hash.digest("hex");
|
|
91
|
-
}
|
|
92
|
-
function fsyncFile(filePath) {
|
|
93
|
-
const fd = fs.openSync(filePath, "r");
|
|
94
|
-
try {
|
|
95
|
-
fs.fsyncSync(fd);
|
|
96
|
-
}
|
|
97
|
-
finally {
|
|
98
|
-
fs.closeSync(fd);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
function fsyncDirectory(directory) {
|
|
102
|
-
try {
|
|
103
|
-
const fd = fs.openSync(directory, "r");
|
|
104
|
-
try {
|
|
105
|
-
fs.fsyncSync(fd);
|
|
106
|
-
}
|
|
107
|
-
finally {
|
|
108
|
-
fs.closeSync(fd);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
catch (error) {
|
|
112
|
-
const code = error.code;
|
|
113
|
-
if (code !== "EINVAL" && code !== "ENOTSUP" && code !== "EISDIR" && code !== "EPERM")
|
|
114
|
-
throw error;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
function copyFileDurable(source, destination) {
|
|
118
|
-
fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 });
|
|
119
|
-
fs.copyFileSync(source, destination, fs.constants.COPYFILE_EXCL);
|
|
120
|
-
fs.chmodSync(destination, 0o600);
|
|
121
|
-
fsyncFile(destination);
|
|
122
|
-
}
|
|
123
|
-
function ownerOnlyMode(filePath, directory) {
|
|
124
|
-
if (process.platform === "win32")
|
|
125
|
-
return true;
|
|
126
|
-
const mode = fs.statSync(filePath).mode & 0o777;
|
|
127
|
-
return mode === (directory ? 0o700 : 0o600);
|
|
128
|
-
}
|
|
129
|
-
function mapLedgerState(state) {
|
|
130
|
-
return {
|
|
131
|
-
status: state.status,
|
|
132
|
-
migrationIds: state.migrationIds,
|
|
133
|
-
migrationChecksums: state.checksums,
|
|
134
|
-
...(state.detail ? { detail: state.detail } : {}),
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
/**
|
|
138
|
-
* True when a raw config carries a WELL-FORMED pre-cutover source shape
|
|
139
|
-
* (a non-empty `stashDir` string, or a `sources`/`installed` array) and has NOT
|
|
140
|
-
* yet grown the 0.9.0 `bundles` map. Such a config is the version-current-but-
|
|
141
|
-
* OLD-SHAPE case that the config-shape migration (WI-8.4) rewrites; the
|
|
142
|
-
* post-cutover strict schema hard-rejects those keys, so {@link inspectConfig}
|
|
143
|
-
* must probe them through the migrator-normalizing validator (NOT raw
|
|
144
|
-
* `validateConfigShape`) to keep classifying it "old" (migration-eligible)
|
|
145
|
-
* rather than "corrupt".
|
|
146
|
-
*
|
|
147
|
-
* Well-formedness matters: a MALFORMED old key (e.g. `sources: "not-an-array"`)
|
|
148
|
-
* is NOT a valid old shape, so it falls through to `validateConfigShape` and is
|
|
149
|
-
* correctly reported "corrupt". A config carrying BOTH `bundles` and an old key
|
|
150
|
-
* is half-migrated — also not this shape — and `validateConfigShape` rejects it.
|
|
151
|
-
*/
|
|
152
|
-
function isPreCutoverSourceShape(raw) {
|
|
153
|
-
if (raw.bundles !== undefined)
|
|
154
|
-
return false;
|
|
155
|
-
return ((typeof raw.stashDir === "string" && raw.stashDir.length > 0) ||
|
|
156
|
-
Array.isArray(raw.sources) ||
|
|
157
|
-
Array.isArray(raw.installed));
|
|
158
|
-
}
|
|
159
|
-
function inspectConfig(configPath) {
|
|
160
|
-
if (!fs.existsSync(configPath))
|
|
161
|
-
return { status: "missing" };
|
|
162
|
-
try {
|
|
163
|
-
const raw = parseConfigText(readTextFileWithLimit(configPath, MAX_CONFIG_FILE_BYTES, "Config file"), configPath);
|
|
164
|
-
const comparison = compareConfigVersion(raw.configVersion, CURRENT_CONFIG_VERSION);
|
|
165
|
-
if (comparison === undefined)
|
|
166
|
-
return { status: "inconsistent", detail: "configVersion is missing or invalid" };
|
|
167
|
-
if (comparison < 0)
|
|
168
|
-
return { status: "old" };
|
|
169
|
-
if (comparison > 0)
|
|
170
|
-
return { status: "newer" };
|
|
171
|
-
// 0.9.0 config-shape cutover: a well-formed pre-cutover config (no `bundles`)
|
|
172
|
-
// is migration-eligible → "old". The strict schema hard-rejects the retired
|
|
173
|
-
// keys, so normalize old→bundles via the migrator FIRST, then validate the
|
|
174
|
-
// RESULT: a valid normalization is "old"; an old shape that stays invalid
|
|
175
|
-
// after normalization (or a malformed/half-migrated config) is "corrupt".
|
|
176
|
-
if (isPreCutoverSourceShape(raw)) {
|
|
177
|
-
const validatedMigrated = validateConfigShape(migrateConfigSourcesToBundles(raw));
|
|
178
|
-
if (validatedMigrated.ok)
|
|
179
|
-
return { status: "old" };
|
|
180
|
-
return {
|
|
181
|
-
status: "corrupt",
|
|
182
|
-
detail: validatedMigrated.errors.map((issue) => `${issue.path}: ${issue.message}`).join("; "),
|
|
183
|
-
};
|
|
184
|
-
}
|
|
185
|
-
const validated = validateConfigShape(raw);
|
|
186
|
-
if (!validated.ok) {
|
|
187
|
-
// Malformed at 0.9.0 — including a half-migrated config carrying `bundles`
|
|
188
|
-
// alongside a retired source key, or a malformed old key (rejected by superRefine).
|
|
189
|
-
return {
|
|
190
|
-
status: "corrupt",
|
|
191
|
-
detail: validated.errors.map((issue) => `${issue.path}: ${issue.message}`).join("; "),
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
return { status: "current" };
|
|
195
|
-
}
|
|
196
|
-
catch (error) {
|
|
197
|
-
return { status: "corrupt", detail: error instanceof Error ? error.message : String(error) };
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
function quickCheck(db, filePath) {
|
|
201
|
-
const rows = db.prepare("PRAGMA quick_check").all();
|
|
202
|
-
if (rows.length !== 1 || Object.values(rows[0])[0] !== "ok") {
|
|
203
|
-
throw new ConfigError(`SQLite quick_check failed for ${filePath}.`, "INVALID_CONFIG_FILE");
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
function inspectSqlite(filePath, migrations) {
|
|
207
|
-
if (!fs.existsSync(filePath))
|
|
208
|
-
return { status: "missing" };
|
|
209
|
-
let db;
|
|
210
|
-
try {
|
|
211
|
-
db = openDatabaseFinalizing(filePath, { readonly: true });
|
|
212
|
-
quickCheck(db, filePath);
|
|
213
|
-
return mapLedgerState(inspectMigrationLedger(db, migrations));
|
|
214
|
-
}
|
|
215
|
-
catch (error) {
|
|
216
|
-
return { status: "corrupt", detail: error instanceof Error ? error.message : String(error) };
|
|
217
|
-
}
|
|
218
|
-
finally {
|
|
219
|
-
db?.close();
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
/**
|
|
223
|
-
* Ledger inspection against a FROZEN `{ id, checksum }` copy. Used for
|
|
224
|
-
* workflow.db, whose live `WORKFLOW_MIGRATIONS` array is deleted at the
|
|
225
|
-
* three-DB cutover (WI-8.3) but whose pre-cutover backups must stay verifiable
|
|
226
|
-
* (plan §3.3 item 1). Behaviourally identical to {@link inspectSqlite}.
|
|
227
|
-
*/
|
|
228
|
-
function inspectSqliteSealed(filePath, sealed) {
|
|
229
|
-
if (!fs.existsSync(filePath))
|
|
230
|
-
return { status: "missing" };
|
|
231
|
-
let db;
|
|
232
|
-
try {
|
|
233
|
-
db = openDatabaseFinalizing(filePath, { readonly: true });
|
|
234
|
-
quickCheck(db, filePath);
|
|
235
|
-
return mapLedgerState(inspectSealedMigrationLedger(db, sealed));
|
|
236
|
-
}
|
|
237
|
-
catch (error) {
|
|
238
|
-
return { status: "corrupt", detail: error instanceof Error ? error.message : String(error) };
|
|
239
|
-
}
|
|
240
|
-
finally {
|
|
241
|
-
db?.close();
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
/**
|
|
245
|
-
* index.db is NOT ledger-migrated (it uses the index-schema.ts DB_VERSION
|
|
246
|
-
* scheme, rebuilt rather than migrated), so its recoverability inspection is
|
|
247
|
-
* presence + SQLite quick_check only: "current" means readable and
|
|
248
|
-
* integrity-clean. It is backed up solely as the pre-rescue usage_events home.
|
|
249
|
-
*/
|
|
250
|
-
function inspectIndexDbArtifact(filePath) {
|
|
251
|
-
if (!fs.existsSync(filePath))
|
|
252
|
-
return { status: "missing" };
|
|
253
|
-
let db;
|
|
254
|
-
try {
|
|
255
|
-
db = openDatabaseFinalizing(filePath, { readonly: true });
|
|
256
|
-
quickCheck(db, filePath);
|
|
257
|
-
return { status: "current" };
|
|
258
|
-
}
|
|
259
|
-
catch (error) {
|
|
260
|
-
return { status: "corrupt", detail: error instanceof Error ? error.message : String(error) };
|
|
261
|
-
}
|
|
262
|
-
finally {
|
|
263
|
-
db?.close();
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
/** Recoverability inspection per artifact, optionally against caller-owned SQLite snapshots. */
|
|
267
|
-
function inspectLedgerArtifact(name, filePath) {
|
|
268
|
-
if (name === "state.db")
|
|
269
|
-
return inspectSqlite(filePath, STATE_MIGRATIONS);
|
|
270
|
-
if (name === "workflow.db")
|
|
271
|
-
return inspectSqliteSealed(filePath, WORKFLOW_MIGRATIONS_CHECKSUMS);
|
|
272
|
-
return inspectIndexDbArtifact(filePath);
|
|
273
|
-
}
|
|
274
|
-
export function inspectMigrationState(paths = {}) {
|
|
275
|
-
return {
|
|
276
|
-
config: inspectConfig(getConfigPath()),
|
|
277
|
-
state: inspectSqlite(paths.stateDbPath ?? getStateDbPathInDataDir(), STATE_MIGRATIONS),
|
|
278
|
-
workflow: inspectSqliteSealed(paths.workflowDbPath ?? getLegacyWorkflowDbPath(), WORKFLOW_MIGRATIONS_CHECKSUMS),
|
|
279
|
-
index: inspectIndexDbArtifact(paths.indexDbPath ?? getDbPath()),
|
|
280
|
-
};
|
|
281
|
-
}
|
|
282
|
-
function assertBackupEligible(state) {
|
|
283
|
-
// index.db is deliberately absent here: an unreadable index.db is excluded
|
|
284
|
-
// from the backup (regenerable cache; the cutover's usage_events rescue
|
|
285
|
-
// reports an empty result) rather than blocking migration entirely.
|
|
286
|
-
const entries = [
|
|
287
|
-
["config.json", state.config],
|
|
288
|
-
["state.db", state.state],
|
|
289
|
-
["workflow.db", state.workflow],
|
|
290
|
-
];
|
|
291
|
-
const unsafe = entries.filter(([, artifact]) => ["newer", "inconsistent", "corrupt"].includes(artifact.status));
|
|
292
|
-
if (unsafe.length > 0) {
|
|
293
|
-
throw new ConfigError(`Refusing migration backup because artifact state is unsafe: ${unsafe
|
|
294
|
-
.map(([name, artifact]) => `${name}=${artifact.status}${artifact.detail ? ` (${artifact.detail})` : ""}`)
|
|
295
|
-
.join(", ")}.`, "INVALID_CONFIG_FILE");
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
function parseManifest(bundlePath) {
|
|
299
|
-
const manifestPath = path.join(bundlePath, "manifest.json");
|
|
300
|
-
let value;
|
|
301
|
-
try {
|
|
302
|
-
value = JSON.parse(readTextFileWithLimit(manifestPath, MAX_LOCAL_METADATA_BYTES, "Migration manifest"));
|
|
303
|
-
}
|
|
304
|
-
catch (error) {
|
|
305
|
-
throw new ConfigError(`Migration backup at ${bundlePath} is incomplete or unreadable: ${error instanceof Error ? error.message : String(error)}.`, "INVALID_CONFIG_FILE");
|
|
306
|
-
}
|
|
307
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
308
|
-
throw new ConfigError(`Migration backup manifest at ${manifestPath} is invalid.`, "INVALID_CONFIG_FILE");
|
|
309
|
-
}
|
|
310
|
-
const manifest = value;
|
|
311
|
-
if ((manifest.formatVersion !== MANIFEST_FORMAT_VERSION && manifest.formatVersion !== LEGACY_MANIFEST_FORMAT_VERSION) ||
|
|
312
|
-
manifest.version !== MIGRATION_BACKUP_VERSION ||
|
|
313
|
-
manifest.targetVersion !== MIGRATION_BACKUP_VERSION ||
|
|
314
|
-
manifest.installationId !== path.basename(getMigrationOperationRoot()) ||
|
|
315
|
-
typeof manifest.runId !== "string" ||
|
|
316
|
-
path.basename(bundlePath) !== manifest.runId ||
|
|
317
|
-
typeof manifest.createdAt !== "string" ||
|
|
318
|
-
manifest.complete !== true) {
|
|
319
|
-
throw new ConfigError(`Migration backup manifest at ${manifestPath} is foreign or unsupported.`, "INVALID_CONFIG_FILE");
|
|
320
|
-
}
|
|
321
|
-
const expected = expectedSourcePaths();
|
|
322
|
-
for (const name of artifactNamesFor(manifest.formatVersion)) {
|
|
323
|
-
const artifact = manifest.artifacts?.[name];
|
|
324
|
-
// index.db is never ledger-classified: "current" (readable) or "missing" only.
|
|
325
|
-
const allowedStatuses = name === "index.db" ? ["current", "missing"] : ["old", "current", "missing"];
|
|
326
|
-
if (!artifact ||
|
|
327
|
-
artifact.sourcePath !== expected[name] ||
|
|
328
|
-
typeof artifact.present !== "boolean" ||
|
|
329
|
-
!Number.isSafeInteger(artifact.byteSize) ||
|
|
330
|
-
artifact.byteSize < 0 ||
|
|
331
|
-
typeof artifact.createdAt !== "string" ||
|
|
332
|
-
!allowedStatuses.includes(artifact.status) ||
|
|
333
|
-
(artifact.present
|
|
334
|
-
? typeof artifact.sha256 !== "string" || artifact.sha256.length !== 64
|
|
335
|
-
: artifact.sha256 !== null || artifact.status !== "missing")) {
|
|
336
|
-
throw new ConfigError(`Migration backup manifest has an invalid ${name} entry.`, "INVALID_CONFIG_FILE");
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
return manifest;
|
|
340
|
-
}
|
|
341
|
-
function sameState(actual, expected) {
|
|
342
|
-
return (actual.status === expected.status &&
|
|
343
|
-
JSON.stringify(actual.migrationIds ?? []) === JSON.stringify(expected.migrationIds ?? []) &&
|
|
344
|
-
JSON.stringify(actual.migrationChecksums ?? []) === JSON.stringify(expected.migrationChecksums ?? []));
|
|
345
|
-
}
|
|
346
|
-
function verifyArtifactAgainstManifest(filePath, name, artifact, context) {
|
|
347
|
-
if (!artifact.present) {
|
|
348
|
-
if (fs.existsSync(filePath)) {
|
|
349
|
-
throw new ConfigError(`${context} ${name} should be absent according to the selected backup.`, "INVALID_CONFIG_FILE");
|
|
350
|
-
}
|
|
351
|
-
return;
|
|
352
|
-
}
|
|
353
|
-
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
|
354
|
-
throw new ConfigError(`${context} ${name} is missing or is not a regular file.`, "INVALID_CONFIG_FILE");
|
|
355
|
-
}
|
|
356
|
-
const stat = fs.statSync(filePath);
|
|
357
|
-
if (stat.size !== artifact.byteSize || sha256File(filePath) !== artifact.sha256) {
|
|
358
|
-
throw new ConfigError(`${context} ${name} failed size/checksum authentication.`, "INVALID_CONFIG_FILE");
|
|
359
|
-
}
|
|
360
|
-
const inspected = inspectArtifactAt(name, filePath);
|
|
361
|
-
if (!sameState(inspected, artifact)) {
|
|
362
|
-
throw new ConfigError(`${context} ${name} failed SQLite/config recoverability verification: expected ${artifact.status}, got ${inspected.status}${inspected.detail ? ` (${inspected.detail})` : ""}.`, "INVALID_CONFIG_FILE");
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
export function verifyMigrationBackup(bundlePath = resolveBackupRun()) {
|
|
366
|
-
if (!fs.existsSync(bundlePath) || !fs.statSync(bundlePath).isDirectory()) {
|
|
367
|
-
throw new ConfigError(`Migration backup does not exist at ${bundlePath}.`, "INVALID_CONFIG_FILE");
|
|
368
|
-
}
|
|
369
|
-
if (!ownerOnlyMode(bundlePath, true)) {
|
|
370
|
-
throw new ConfigError(`Migration backup directory ${bundlePath} must have mode 0700.`, "INVALID_CONFIG_FILE");
|
|
371
|
-
}
|
|
372
|
-
const manifest = parseManifest(bundlePath);
|
|
373
|
-
const expectedFiles = new Set(["manifest.json"]);
|
|
374
|
-
for (const name of artifactNamesFor(manifest.formatVersion)) {
|
|
375
|
-
const artifact = manifestArtifact(manifest, name);
|
|
376
|
-
const artifactPath = path.join(bundlePath, name);
|
|
377
|
-
if (!artifact.present) {
|
|
378
|
-
if (fs.existsSync(artifactPath)) {
|
|
379
|
-
throw new ConfigError(`Migration backup contains ${name}, but its manifest records it absent.`, "INVALID_CONFIG_FILE");
|
|
380
|
-
}
|
|
381
|
-
continue;
|
|
382
|
-
}
|
|
383
|
-
expectedFiles.add(name);
|
|
384
|
-
if (!fs.existsSync(artifactPath) || !fs.statSync(artifactPath).isFile() || !ownerOnlyMode(artifactPath, false)) {
|
|
385
|
-
throw new ConfigError(`Migration backup artifact ${artifactPath} is missing or has unsafe permissions.`, "INVALID_CONFIG_FILE");
|
|
386
|
-
}
|
|
387
|
-
const stat = fs.statSync(artifactPath);
|
|
388
|
-
if (stat.size !== artifact.byteSize || sha256File(artifactPath) !== artifact.sha256) {
|
|
389
|
-
throw new ConfigError(`Migration backup artifact ${artifactPath} failed checksum verification.`, "INVALID_CONFIG_FILE");
|
|
390
|
-
}
|
|
391
|
-
const inspected = inspectArtifactAt(name, artifactPath);
|
|
392
|
-
if (!sameState(inspected, artifact)) {
|
|
393
|
-
throw new ConfigError(`Migration backup artifact ${artifactPath} failed SQLite/config recoverability verification: expected ${artifact.status}, got ${inspected.status}${inspected.detail ? ` (${inspected.detail})` : ""}.`, "INVALID_CONFIG_FILE");
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
if (!ownerOnlyMode(path.join(bundlePath, "manifest.json"), false)) {
|
|
397
|
-
throw new ConfigError("Migration backup manifest must have mode 0600.", "INVALID_CONFIG_FILE");
|
|
398
|
-
}
|
|
399
|
-
const extras = fs.readdirSync(bundlePath).filter((name) => !expectedFiles.has(name));
|
|
400
|
-
if (extras.length > 0) {
|
|
401
|
-
throw new ConfigError(`Migration backup contains unexpected files: ${extras.join(", ")}.`, "INVALID_CONFIG_FILE");
|
|
402
|
-
}
|
|
403
|
-
return manifest;
|
|
404
|
-
}
|
|
405
|
-
function acquireMigrationBackupLock() {
|
|
406
|
-
const lockPath = migrationBackupLockPath();
|
|
407
|
-
fs.mkdirSync(path.dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
408
|
-
fs.chmodSync(path.dirname(lockPath), 0o700);
|
|
409
|
-
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
410
|
-
const ownership = tryAcquireLockSync(lockPath, createLockPayload());
|
|
411
|
-
if (ownership)
|
|
412
|
-
return () => releaseLock(ownership);
|
|
413
|
-
const probe = probeLock(lockPath);
|
|
414
|
-
if (probe.state === "stale" && reclaimStaleLock(lockPath, probe))
|
|
415
|
-
continue;
|
|
416
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
|
|
417
|
-
}
|
|
418
|
-
throw new ConfigError(`Timed out waiting for migration backup lock at ${lockPath}.`, "INVALID_CONFIG_FILE");
|
|
419
|
-
}
|
|
420
|
-
function sqliteQuote(value) {
|
|
421
|
-
return `'${value.replaceAll("'", "''")}'`;
|
|
422
|
-
}
|
|
423
|
-
function backupSqlite(source, destination) {
|
|
424
|
-
if (!fs.statSync(source).isFile()) {
|
|
425
|
-
throw new ConfigError(`SQLite backup source is not a regular file: ${source}`, "INVALID_CONFIG_FILE");
|
|
426
|
-
}
|
|
427
|
-
const resolvedSource = path.resolve(source);
|
|
428
|
-
const activityName = resolvedSource === path.resolve(getLegacyWorkflowDbPath())
|
|
429
|
-
? "workflow-db"
|
|
430
|
-
: resolvedSource === path.resolve(getStateDbPathInDataDir())
|
|
431
|
-
? "state-db"
|
|
432
|
-
: undefined;
|
|
433
|
-
const releaseActivity = activityName ? acquireMaintenanceActivitySync(activityName) : undefined;
|
|
434
|
-
let db;
|
|
435
|
-
try {
|
|
436
|
-
db = openDatabaseFinalizing(source);
|
|
437
|
-
db.exec("PRAGMA busy_timeout = 10000");
|
|
438
|
-
db.exec(`VACUUM INTO ${sqliteQuote(destination)}`);
|
|
439
|
-
}
|
|
440
|
-
finally {
|
|
441
|
-
try {
|
|
442
|
-
db?.close();
|
|
443
|
-
}
|
|
444
|
-
finally {
|
|
445
|
-
releaseActivity?.();
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
fs.chmodSync(destination, 0o600);
|
|
449
|
-
fsyncFile(destination);
|
|
450
|
-
}
|
|
451
|
-
function newRunId() {
|
|
452
|
-
return `${new Date().toISOString().replace(/[-:.TZ]/g, "")}-${process.pid}-${crypto.randomBytes(6).toString("hex")}`;
|
|
453
|
-
}
|
|
454
|
-
function stateForName(state, name) {
|
|
455
|
-
if (name === "config.json")
|
|
456
|
-
return state.config;
|
|
457
|
-
if (name === "state.db")
|
|
458
|
-
return state.state;
|
|
459
|
-
if (name === "workflow.db")
|
|
460
|
-
return state.workflow;
|
|
461
|
-
return state.index;
|
|
462
|
-
}
|
|
463
|
-
function createMigrationBackupUnlocked() {
|
|
464
|
-
const state = inspectMigrationState();
|
|
465
|
-
assertBackupEligible(state);
|
|
466
|
-
const root = getMigrationBackupRoot();
|
|
467
|
-
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
468
|
-
fs.chmodSync(root, 0o700);
|
|
469
|
-
const runId = newRunId();
|
|
470
|
-
const bundlePath = path.join(root, runId);
|
|
471
|
-
const temporary = path.join(root, `.${runId}.tmp`);
|
|
472
|
-
fs.mkdirSync(temporary, { mode: 0o700 });
|
|
473
|
-
const createdAt = new Date().toISOString();
|
|
474
|
-
const sources = expectedSourcePaths();
|
|
475
|
-
const artifacts = {};
|
|
476
|
-
try {
|
|
477
|
-
for (const name of ARTIFACT_NAMES) {
|
|
478
|
-
const sourcePath = sources[name];
|
|
479
|
-
const sourceState = stateForName(state, name);
|
|
480
|
-
// index.db never blocks a backup: anything short of a clean read
|
|
481
|
-
// (corrupt cache) is recorded as absent — it is regenerable, and the
|
|
482
|
-
// cutover's usage_events rescue reports the empty result.
|
|
483
|
-
const effectiveState = name === "index.db" && sourceState.status !== "current" ? { status: "missing" } : sourceState;
|
|
484
|
-
const destination = path.join(temporary, name);
|
|
485
|
-
const present = effectiveState.status !== "missing";
|
|
486
|
-
if (present) {
|
|
487
|
-
if (name === "config.json")
|
|
488
|
-
copyFileDurable(sourcePath, destination);
|
|
489
|
-
else
|
|
490
|
-
backupSqlite(sourcePath, destination);
|
|
491
|
-
}
|
|
492
|
-
const inspected = present ? inspectArtifactAt(name, destination) : { status: "missing" };
|
|
493
|
-
const expectedArtifact = { ...effectiveState, sourcePath, present, byteSize: 0, sha256: null, createdAt };
|
|
494
|
-
if (!sameState(inspected, expectedArtifact)) {
|
|
495
|
-
throw new ConfigError(`Snapshot ${name} does not match its source migration state.`, "INVALID_CONFIG_FILE");
|
|
496
|
-
}
|
|
497
|
-
artifacts[name] = {
|
|
498
|
-
...effectiveState,
|
|
499
|
-
sourcePath,
|
|
500
|
-
present,
|
|
501
|
-
byteSize: present ? fs.statSync(destination).size : 0,
|
|
502
|
-
sha256: present ? sha256File(destination) : null,
|
|
503
|
-
createdAt,
|
|
504
|
-
};
|
|
505
|
-
}
|
|
506
|
-
const manifest = {
|
|
507
|
-
formatVersion: MANIFEST_FORMAT_VERSION,
|
|
508
|
-
version: MIGRATION_BACKUP_VERSION,
|
|
509
|
-
targetVersion: MIGRATION_BACKUP_VERSION,
|
|
510
|
-
installationId: path.basename(getMigrationOperationRoot()),
|
|
511
|
-
runId,
|
|
512
|
-
createdAt,
|
|
513
|
-
complete: true,
|
|
514
|
-
artifacts,
|
|
515
|
-
};
|
|
516
|
-
writeFileAtomic(path.join(temporary, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, 0o600);
|
|
517
|
-
fs.chmodSync(path.join(temporary, "manifest.json"), 0o600);
|
|
518
|
-
fsyncDirectory(temporary);
|
|
519
|
-
fs.renameSync(temporary, bundlePath);
|
|
520
|
-
fsyncDirectory(root);
|
|
521
|
-
return { path: bundlePath, created: true, manifest: verifyMigrationBackup(bundlePath) };
|
|
522
|
-
}
|
|
523
|
-
catch (error) {
|
|
524
|
-
fs.rmSync(temporary, { recursive: true, force: true });
|
|
525
|
-
throw error;
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
function withMigrationBackupLock(fn) {
|
|
529
|
-
const release = acquireMigrationBackupLock();
|
|
530
|
-
try {
|
|
531
|
-
return fn();
|
|
532
|
-
}
|
|
533
|
-
finally {
|
|
534
|
-
release();
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
export function createMigrationBackup() {
|
|
538
|
-
return withConfigLock(() => withMigrationBackupLock(() => withMaintenanceStartBarrier(createMigrationBackupUnlocked)));
|
|
539
|
-
}
|
|
540
|
-
export function ensureMigrationBackupWithConfigLockHeld() {
|
|
541
|
-
return withMigrationBackupLock(() => withMaintenanceStartBarrier(createMigrationBackupUnlocked));
|
|
542
|
-
}
|
|
543
|
-
export function ensureMigrationBackup() {
|
|
544
|
-
return createMigrationBackup();
|
|
545
|
-
}
|
|
546
|
-
function sanitizeDiagnosticField(value, maxBytes = MAX_BLOCKER_FIELD_BYTES) {
|
|
547
|
-
const source = String(value);
|
|
548
|
-
let output = "";
|
|
549
|
-
let bytes = 0;
|
|
550
|
-
let truncated = false;
|
|
551
|
-
const contentLimit = Math.max(0, maxBytes - 3);
|
|
552
|
-
for (const character of source) {
|
|
553
|
-
const codePoint = character.codePointAt(0) ?? 0;
|
|
554
|
-
const safe = codePoint < 0x20 || codePoint === 0x7f ? "?" : character;
|
|
555
|
-
const width = Buffer.byteLength(safe, "utf8");
|
|
556
|
-
if (bytes + width > contentLimit) {
|
|
557
|
-
truncated = true;
|
|
558
|
-
break;
|
|
559
|
-
}
|
|
560
|
-
output += safe;
|
|
561
|
-
bytes += width;
|
|
562
|
-
}
|
|
563
|
-
if (!truncated && bytes < Buffer.byteLength(source, "utf8"))
|
|
564
|
-
truncated = true;
|
|
565
|
-
return truncated ? `${output}...` : output;
|
|
566
|
-
}
|
|
567
|
-
function sampleLockDirectory(directory) {
|
|
568
|
-
let handle;
|
|
569
|
-
try {
|
|
570
|
-
handle = fs.opendirSync(directory);
|
|
571
|
-
}
|
|
572
|
-
catch (error) {
|
|
573
|
-
if (error.code === "ENOENT")
|
|
574
|
-
return { paths: [], overflow: false };
|
|
575
|
-
throw error;
|
|
576
|
-
}
|
|
577
|
-
const paths = [];
|
|
578
|
-
let inspected = 0;
|
|
579
|
-
let overflow = false;
|
|
580
|
-
try {
|
|
581
|
-
while (true) {
|
|
582
|
-
const entry = handle.readSync();
|
|
583
|
-
if (!entry)
|
|
584
|
-
break;
|
|
585
|
-
if (!entry.isFile() || !entry.name.endsWith(".lock"))
|
|
586
|
-
continue;
|
|
587
|
-
inspected += 1;
|
|
588
|
-
if (inspected > MAX_BLOCKER_DIRECTORY_SAMPLES) {
|
|
589
|
-
overflow = true;
|
|
590
|
-
break;
|
|
591
|
-
}
|
|
592
|
-
paths.push(path.join(directory, entry.name));
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
catch (error) {
|
|
596
|
-
if (error.code === "ENOENT")
|
|
597
|
-
return { paths: [], overflow: false };
|
|
598
|
-
throw error;
|
|
599
|
-
}
|
|
600
|
-
finally {
|
|
601
|
-
try {
|
|
602
|
-
handle.closeSync();
|
|
603
|
-
}
|
|
604
|
-
catch {
|
|
605
|
-
// A concurrently removed directory may already have invalidated the handle.
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
return { paths, overflow };
|
|
609
|
-
}
|
|
610
|
-
function activeRestoreLocks(bundlePath) {
|
|
611
|
-
const lockPaths = [getLockfileLockPath(), getIndexWriterLockPath()];
|
|
612
|
-
const overflowBlockers = [];
|
|
613
|
-
const configLock = path.join(path.dirname(getConfigPath()), "config.json.lck");
|
|
614
|
-
const dataDir = getDataDir();
|
|
615
|
-
for (const name of ["improve.lock", "consolidate.lock", "reflect-distill.lock", "triage.lock"]) {
|
|
616
|
-
lockPaths.push(path.join(dataDir, name));
|
|
617
|
-
}
|
|
618
|
-
const stashDirs = new Set();
|
|
619
|
-
const configPaths = [getConfigPath(), ...(bundlePath ? [path.join(bundlePath, "config.json")] : [])];
|
|
620
|
-
for (const configPath of configPaths) {
|
|
621
|
-
try {
|
|
622
|
-
const raw = parseConfigText(readTextFileWithLimit(configPath, MAX_CONFIG_FILE_BYTES, "Config file"), configPath);
|
|
623
|
-
if (typeof raw.stashDir === "string" && raw.stashDir)
|
|
624
|
-
stashDirs.add(raw.stashDir);
|
|
625
|
-
}
|
|
626
|
-
catch {
|
|
627
|
-
// A malformed or absent config contributes no stash-scoped lock paths.
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
for (const stashDir of stashDirs) {
|
|
631
|
-
for (const name of ["improve.lock", "consolidate.lock", "reflect-distill.lock", "triage.lock"]) {
|
|
632
|
-
lockPaths.push(path.join(stashDir, ".akm", name));
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
for (const baseDir of [dataDir, ...[...stashDirs].map((stashDir) => path.join(stashDir, ".akm"))]) {
|
|
636
|
-
const extractLockDir = path.join(baseDir, "extract-locks");
|
|
637
|
-
const sample = sampleLockDirectory(extractLockDir);
|
|
638
|
-
lockPaths.push(...sample.paths);
|
|
639
|
-
if (sample.overflow) {
|
|
640
|
-
overflowBlockers.push(`lock directory sample capped: ${sanitizeDiagnosticField(extractLockDir)}`);
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
const activityDir = path.join(path.dirname(getLockfileLockPath()), "maintenance-activities");
|
|
644
|
-
const activitySample = sampleLockDirectory(activityDir);
|
|
645
|
-
lockPaths.push(...activitySample.paths);
|
|
646
|
-
if (activitySample.overflow) {
|
|
647
|
-
overflowBlockers.push(`lock directory sample capped: ${sanitizeDiagnosticField(activityDir)}`);
|
|
648
|
-
}
|
|
649
|
-
const active = [configLock, ...lockPaths]
|
|
650
|
-
.filter((lockPath) => {
|
|
651
|
-
const probe = probeLock(lockPath);
|
|
652
|
-
return probe.state === "held" && (lockPath !== configLock || probe.holderPid !== process.pid);
|
|
653
|
-
})
|
|
654
|
-
.map((lockPath) => sanitizeDiagnosticField(lockPath, MAX_BLOCKER_ITEM_BYTES));
|
|
655
|
-
return [...active, ...overflowBlockers];
|
|
656
|
-
}
|
|
657
|
-
/**
|
|
658
|
-
* Scan ONE open workflow-table home (state.db post-cutover, or a pre-cutover
|
|
659
|
-
* workflow.db) for active engine leases + unit claims, tagging each blocker with
|
|
660
|
-
* `dbPath` so the operator can tell which artifact holds the lock. Same blocker
|
|
661
|
-
* semantics for both sources — the tables/columns are byte-identical between the
|
|
662
|
-
* pre-cutover workflow.db and the merged state.db (state migration 020 folds the
|
|
663
|
-
* final shape).
|
|
664
|
-
*/
|
|
665
|
-
function scanWorkflowClaimsFrom(db, dbPath, maxSamples) {
|
|
666
|
-
const blockers = [];
|
|
667
|
-
if (db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='workflow_runs'").get()) {
|
|
668
|
-
const columns = new Set(db.prepare("PRAGMA table_info(workflow_runs)").all().map((row) => row.name));
|
|
669
|
-
if (columns.has("engine_lease_holder") && columns.has("engine_lease_until")) {
|
|
670
|
-
const leases = db
|
|
671
|
-
.prepare(`SELECT substr(CAST(id AS TEXT), 1, 257) AS id, substr(CAST(engine_lease_holder AS TEXT), 1, 257) AS holder, substr(CAST(engine_lease_until AS TEXT), 1, 129) AS expires FROM workflow_runs WHERE engine_lease_holder IS NOT NULL AND engine_lease_until >= ? LIMIT ${maxSamples + 1}`)
|
|
672
|
-
.all(new Date().toISOString());
|
|
673
|
-
blockers.push(...leases
|
|
674
|
-
.slice(0, maxSamples)
|
|
675
|
-
.map((lease) => `${sanitizeDiagnosticField(dbPath)}#run=${sanitizeDiagnosticField(lease.id)},holder=${sanitizeDiagnosticField(lease.holder)},expires=${sanitizeDiagnosticField(lease.expires, 128)}`));
|
|
676
|
-
if (leases.length > maxSamples) {
|
|
677
|
-
blockers.push(`${dbPath}#additional-active-workflow-blockers`);
|
|
678
|
-
return blockers;
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
if (!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='workflow_run_units'").get())
|
|
683
|
-
return blockers;
|
|
684
|
-
const columns = new Set(db.prepare("PRAGMA table_info(workflow_run_units)").all().map((row) => row.name));
|
|
685
|
-
if (!columns.has("claim_holder") || !columns.has("claim_expires_at"))
|
|
686
|
-
return blockers;
|
|
687
|
-
const remaining = maxSamples - blockers.length;
|
|
688
|
-
if (remaining <= 0)
|
|
689
|
-
return blockers;
|
|
690
|
-
const claims = db
|
|
691
|
-
.prepare(`SELECT substr(CAST(run_id AS TEXT), 1, 257) AS runId, substr(CAST(unit_id AS TEXT), 1, 257) AS unitId, substr(CAST(claim_holder AS TEXT), 1, 257) AS holder, substr(CAST(claim_expires_at AS TEXT), 1, 129) AS expires FROM workflow_run_units WHERE status='running' AND claim_holder IS NOT NULL AND claim_expires_at >= ? LIMIT ${remaining + 1}`)
|
|
692
|
-
.all(new Date().toISOString());
|
|
693
|
-
blockers.push(...claims
|
|
694
|
-
.slice(0, remaining)
|
|
695
|
-
.map((claim) => `${sanitizeDiagnosticField(dbPath)}#run=${sanitizeDiagnosticField(claim.runId)},unit=${sanitizeDiagnosticField(claim.unitId)},holder=${sanitizeDiagnosticField(claim.holder)},expires=${sanitizeDiagnosticField(claim.expires, 128)}`));
|
|
696
|
-
if (claims.length > remaining)
|
|
697
|
-
blockers.push(`${dbPath}#additional-active-workflow-blockers`);
|
|
698
|
-
return blockers;
|
|
699
|
-
}
|
|
700
|
-
/**
|
|
701
|
-
* Active engine leases / unit claims that must block artifact replacement.
|
|
702
|
-
*
|
|
703
|
-
* Chunk-8 dual source: the durable home of the workflow tables is now state.db
|
|
704
|
-
* (the three-DB cutover merged workflow.db into it via state migration 020), so
|
|
705
|
-
* the primary probe reads state.db. But a PRE-CUTOVER generation still has a
|
|
706
|
-
* physical workflow.db (not yet merged/deleted), so the read-only workflow.db
|
|
707
|
-
* file probe is RETAINED — if that file is present it is scanned too. Both use
|
|
708
|
-
* the same frozen-copy inspection and the same blocker semantics. A read-only
|
|
709
|
-
* open of each avoids ledger assertions / maintenance-activity acquisition
|
|
710
|
-
* during the blocker check.
|
|
711
|
-
*/
|
|
712
|
-
function activeWorkflowClaims() {
|
|
713
|
-
const maxSamples = MAX_WORKFLOW_BLOCKER_SAMPLES;
|
|
714
|
-
const blockers = [];
|
|
715
|
-
for (const dbPath of [getStateDbPathInDataDir(), getLegacyWorkflowDbPath()]) {
|
|
716
|
-
if (!fs.existsSync(dbPath))
|
|
717
|
-
continue;
|
|
718
|
-
const db = openDatabaseFinalizing(dbPath, { readonly: true });
|
|
719
|
-
try {
|
|
720
|
-
blockers.push(...scanWorkflowClaimsFrom(db, dbPath, maxSamples));
|
|
721
|
-
}
|
|
722
|
-
finally {
|
|
723
|
-
db.close();
|
|
724
|
-
}
|
|
725
|
-
}
|
|
726
|
-
return blockers;
|
|
727
|
-
}
|
|
728
|
-
/** Caller must hold the maintenance start barrier while checking and replacing artifacts. */
|
|
729
|
-
export function assertNoArtifactReplacementBlockers(bundlePath, options) {
|
|
730
|
-
const blockers = [...activeRestoreLocks(bundlePath), ...(options?.skipWorkflowClaims ? [] : activeWorkflowClaims())];
|
|
731
|
-
if (blockers.length > 0) {
|
|
732
|
-
const prefix = "Refusing artifact replacement while AKM locks, activities, or workflow leases are active: ";
|
|
733
|
-
const omission = " ... additional blockers omitted.";
|
|
734
|
-
const punctuation = ".";
|
|
735
|
-
const contentLimit = MAX_BLOCKER_DIAGNOSTIC_BYTES - Buffer.byteLength(omission, "utf8");
|
|
736
|
-
let message = prefix;
|
|
737
|
-
let included = 0;
|
|
738
|
-
for (const blocker of blockers) {
|
|
739
|
-
const item = sanitizeDiagnosticField(blocker, MAX_BLOCKER_ITEM_BYTES);
|
|
740
|
-
const segment = `${included === 0 ? "" : ", "}${item}`;
|
|
741
|
-
if (Buffer.byteLength(message, "utf8") + Buffer.byteLength(segment, "utf8") + 1 > contentLimit)
|
|
742
|
-
break;
|
|
743
|
-
message += segment;
|
|
744
|
-
included += 1;
|
|
745
|
-
}
|
|
746
|
-
message += included < blockers.length ? omission : punctuation;
|
|
747
|
-
throw new ConfigError(message, "INVALID_CONFIG_FILE");
|
|
748
|
-
}
|
|
749
|
-
}
|
|
750
|
-
function fingerprintFile(filePath) {
|
|
751
|
-
if (!fs.existsSync(filePath))
|
|
752
|
-
return null;
|
|
753
|
-
const stat = fs.statSync(filePath);
|
|
754
|
-
if (!stat.isFile())
|
|
755
|
-
throw new ConfigError(`Fingerprint source is not a regular file: ${filePath}.`, "INVALID_CONFIG_FILE");
|
|
756
|
-
return { byteSize: stat.size, sha256: sha256File(filePath) };
|
|
757
|
-
}
|
|
758
|
-
function matchesFingerprint(filePath, fingerprint) {
|
|
759
|
-
if (fingerprint === null)
|
|
760
|
-
return !fs.existsSync(filePath);
|
|
761
|
-
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile())
|
|
762
|
-
return false;
|
|
763
|
-
return fs.statSync(filePath).size === fingerprint.byteSize && sha256File(filePath) === fingerprint.sha256;
|
|
764
|
-
}
|
|
765
|
-
export function fingerprintMigrationGeneration() {
|
|
766
|
-
const statePath = getStateDbPathInDataDir();
|
|
767
|
-
const workflowPath = getLegacyWorkflowDbPath();
|
|
768
|
-
return {
|
|
769
|
-
config: { main: fingerprintFile(getConfigPath()), wal: null, shm: null },
|
|
770
|
-
state: {
|
|
771
|
-
main: fingerprintFile(statePath),
|
|
772
|
-
wal: fingerprintFile(`${statePath}-wal`),
|
|
773
|
-
shm: fingerprintFile(`${statePath}-shm`),
|
|
774
|
-
},
|
|
775
|
-
workflow: {
|
|
776
|
-
main: fingerprintFile(workflowPath),
|
|
777
|
-
wal: fingerprintFile(`${workflowPath}-wal`),
|
|
778
|
-
shm: fingerprintFile(`${workflowPath}-shm`),
|
|
779
|
-
},
|
|
780
|
-
};
|
|
781
|
-
}
|
|
782
|
-
export function sameMigrationGeneration(left, right) {
|
|
783
|
-
return JSON.stringify(left) === JSON.stringify(right);
|
|
784
|
-
}
|
|
785
|
-
let restoreRollbackBoundaryHook;
|
|
786
|
-
/** TEST-ONLY: inject a crash at a named prepared-restore rollback boundary. */
|
|
787
|
-
export function _setRestoreRollbackBoundaryHookForTests(hook) {
|
|
788
|
-
restoreRollbackBoundaryHook = hook;
|
|
789
|
-
}
|
|
790
|
-
function rollbackBoundary(boundary) {
|
|
791
|
-
restoreRollbackBoundaryHook?.(boundary);
|
|
792
|
-
}
|
|
793
|
-
function writeRestoreJournal(journal) {
|
|
794
|
-
writeFileAtomic(restoreJournalPath(), `${JSON.stringify(journal, null, 2)}\n`, 0o600);
|
|
795
|
-
}
|
|
796
|
-
function invalidRestoreJournal(journalPath, detail) {
|
|
797
|
-
throw new ConfigError(`Invalid restore journal ${journalPath}: ${detail}.`, "INVALID_CONFIG_FILE");
|
|
798
|
-
}
|
|
799
|
-
function isRecord(value) {
|
|
800
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
801
|
-
}
|
|
802
|
-
function hasExactKeys(value, required, optional = []) {
|
|
803
|
-
const keys = Object.keys(value);
|
|
804
|
-
return (required.every((key) => keys.includes(key)) && keys.every((key) => required.includes(key) || optional.includes(key)));
|
|
805
|
-
}
|
|
806
|
-
function parseFileFingerprint(value, present, journalPath) {
|
|
807
|
-
if (!present) {
|
|
808
|
-
if (value !== null)
|
|
809
|
-
invalidRestoreJournal(journalPath, "absent original has a non-null fingerprint");
|
|
810
|
-
return null;
|
|
811
|
-
}
|
|
812
|
-
if (!isRecord(value) ||
|
|
813
|
-
!hasExactKeys(value, ["byteSize", "sha256"]) ||
|
|
814
|
-
!Number.isSafeInteger(value.byteSize) ||
|
|
815
|
-
value.byteSize < 0 ||
|
|
816
|
-
typeof value.sha256 !== "string" ||
|
|
817
|
-
!/^[a-f0-9]{64}$/.test(value.sha256)) {
|
|
818
|
-
invalidRestoreJournal(journalPath, "original fingerprint is malformed");
|
|
819
|
-
}
|
|
820
|
-
return { byteSize: value.byteSize, sha256: value.sha256 };
|
|
821
|
-
}
|
|
822
|
-
function isOperationBoundPath(candidate, destination, kind, operationId) {
|
|
823
|
-
const expected = `${destination}.${kind}.${operationId}`;
|
|
824
|
-
return candidate === expected && path.dirname(path.resolve(candidate)) === path.dirname(path.resolve(destination));
|
|
825
|
-
}
|
|
826
|
-
function validateRestoreJournal(raw, journalPath) {
|
|
827
|
-
if (!isRecord(raw) ||
|
|
828
|
-
!hasExactKeys(raw, ["formatVersion", "version", "operationId", "sourceRunId", "rescueRunId", "phase", "entries"])) {
|
|
829
|
-
invalidRestoreJournal(journalPath, "expected the complete versioned journal shape");
|
|
830
|
-
}
|
|
831
|
-
if (raw.formatVersion !== RESTORE_JOURNAL_FORMAT_VERSION) {
|
|
832
|
-
invalidRestoreJournal(journalPath, `unsupported formatVersion ${JSON.stringify(raw.formatVersion)}`);
|
|
833
|
-
}
|
|
834
|
-
if (raw.version !== MIGRATION_BACKUP_VERSION) {
|
|
835
|
-
invalidRestoreJournal(journalPath, `unsupported migration version ${JSON.stringify(raw.version)}`);
|
|
836
|
-
}
|
|
837
|
-
if (raw.phase !== "prepared" && raw.phase !== "committed") {
|
|
838
|
-
invalidRestoreJournal(journalPath, `unsupported phase ${JSON.stringify(raw.phase)}`);
|
|
839
|
-
}
|
|
840
|
-
for (const field of ["operationId", "sourceRunId", "rescueRunId"]) {
|
|
841
|
-
if (typeof raw[field] !== "string" || !/^[A-Za-z0-9._-]+$/.test(raw[field])) {
|
|
842
|
-
invalidRestoreJournal(journalPath, `${field} is not a safe operation identifier`);
|
|
843
|
-
}
|
|
844
|
-
}
|
|
845
|
-
// The exact entry count depends on the SOURCE manifest's format version
|
|
846
|
-
// (v2 = three artifacts, v3 adds index.db); the precise check happens after
|
|
847
|
-
// the source backup is verified below. Here: bound the shape.
|
|
848
|
-
if (!Array.isArray(raw.entries) ||
|
|
849
|
-
(raw.entries.length !== ARTIFACT_NAMES.length && raw.entries.length !== CORE_ARTIFACT_NAMES.length)) {
|
|
850
|
-
invalidRestoreJournal(journalPath, `expected ${CORE_ARTIFACT_NAMES.length} or ${ARTIFACT_NAMES.length} artifact entries`);
|
|
851
|
-
}
|
|
852
|
-
const operationId = raw.operationId;
|
|
853
|
-
const expectedPaths = expectedSourcePaths();
|
|
854
|
-
const byDestination = parseRestoreJournalEntries(raw.entries, operationId, expectedPaths, journalPath);
|
|
855
|
-
const sourceRunId = raw.sourceRunId;
|
|
856
|
-
let sourceManifest;
|
|
857
|
-
try {
|
|
858
|
-
sourceManifest = verifyMigrationBackup(getMigrationBackupDir(sourceRunId));
|
|
859
|
-
}
|
|
860
|
-
catch (error) {
|
|
861
|
-
invalidRestoreJournal(journalPath, `source backup ${sourceRunId} is unavailable or invalid: ${error instanceof Error ? error.message : String(error)}`);
|
|
862
|
-
}
|
|
863
|
-
const journalNames = artifactNamesFor(sourceManifest.formatVersion);
|
|
864
|
-
if (raw.entries.length !== journalNames.length) {
|
|
865
|
-
invalidRestoreJournal(journalPath, `expected exactly ${journalNames.length} artifact entries for source backup format ${sourceManifest.formatVersion}`);
|
|
866
|
-
}
|
|
867
|
-
const entries = journalNames.map((name) => byDestination.get(expectedPaths[name]));
|
|
868
|
-
for (const [index, name] of journalNames.entries()) {
|
|
869
|
-
const entry = entries[index];
|
|
870
|
-
if (!entry) {
|
|
871
|
-
invalidRestoreJournal(journalPath, `journal has no entry for ${name}`);
|
|
872
|
-
}
|
|
873
|
-
const artifact = manifestArtifact(sourceManifest, name);
|
|
874
|
-
const expectedStage = artifact.present ? `${entry.destination}.restore-stage.${operationId}` : undefined;
|
|
875
|
-
if (entry.stage !== expectedStage) {
|
|
876
|
-
invalidRestoreJournal(journalPath, `${name} stage presence does not match source backup ${sourceRunId}`);
|
|
877
|
-
}
|
|
878
|
-
if (raw.phase === "committed") {
|
|
879
|
-
if (fs.existsSync(entry.destination) !== artifact.present || (entry.stage && fs.existsSync(entry.stage))) {
|
|
880
|
-
invalidRestoreJournal(journalPath, `committed ${name} publication state is stale`);
|
|
881
|
-
}
|
|
882
|
-
if (fs.existsSync(entry.quarantine) && !matchesFingerprint(entry.quarantine, entry.originalFingerprint)) {
|
|
883
|
-
invalidRestoreJournal(journalPath, `committed ${name} quarantine does not match the original generation`);
|
|
884
|
-
}
|
|
885
|
-
if (entry.sidecars.some((sidecar) => fs.existsSync(sidecar.destination))) {
|
|
886
|
-
invalidRestoreJournal(journalPath, `committed ${name} still has a live sidecar`);
|
|
887
|
-
}
|
|
888
|
-
for (const sidecar of entry.sidecars) {
|
|
889
|
-
if (fs.existsSync(sidecar.quarantine) && !matchesFingerprint(sidecar.quarantine, sidecar.originalFingerprint)) {
|
|
890
|
-
invalidRestoreJournal(journalPath, `committed ${name} sidecar quarantine is not the original generation`);
|
|
891
|
-
}
|
|
892
|
-
}
|
|
893
|
-
verifyArtifactAgainstManifest(entry.destination, name, artifact, "Committed restore publication");
|
|
894
|
-
continue;
|
|
895
|
-
}
|
|
896
|
-
const destinationPresent = fs.existsSync(entry.destination);
|
|
897
|
-
const quarantinePresent = fs.existsSync(entry.quarantine);
|
|
898
|
-
if (entry.originalPresent ? !destinationPresent && !quarantinePresent : quarantinePresent) {
|
|
899
|
-
invalidRestoreJournal(journalPath, `prepared ${name} original state is stale`);
|
|
900
|
-
}
|
|
901
|
-
if (quarantinePresent && !matchesFingerprint(entry.quarantine, entry.originalFingerprint)) {
|
|
902
|
-
invalidRestoreJournal(journalPath, `prepared ${name} quarantine does not match the original generation`);
|
|
903
|
-
}
|
|
904
|
-
if (entry.stage) {
|
|
905
|
-
const stagePresent = fs.existsSync(entry.stage);
|
|
906
|
-
const rolledBack = !quarantinePresent && !stagePresent && matchesFingerprint(entry.destination, entry.originalFingerprint);
|
|
907
|
-
const validPublicationState = rolledBack ||
|
|
908
|
-
(entry.originalPresent
|
|
909
|
-
? quarantinePresent
|
|
910
|
-
? destinationPresent !== stagePresent
|
|
911
|
-
: destinationPresent && stagePresent && matchesFingerprint(entry.destination, entry.originalFingerprint)
|
|
912
|
-
: destinationPresent !== stagePresent);
|
|
913
|
-
if (!validPublicationState) {
|
|
914
|
-
invalidRestoreJournal(journalPath, `prepared ${name} publication state is stale`);
|
|
915
|
-
}
|
|
916
|
-
}
|
|
917
|
-
else {
|
|
918
|
-
const rolledBack = !quarantinePresent && matchesFingerprint(entry.destination, entry.originalFingerprint);
|
|
919
|
-
const forwardState = entry.originalPresent
|
|
920
|
-
? quarantinePresent
|
|
921
|
-
? !destinationPresent
|
|
922
|
-
: destinationPresent && matchesFingerprint(entry.destination, entry.originalFingerprint)
|
|
923
|
-
: !destinationPresent;
|
|
924
|
-
if (!rolledBack && !forwardState) {
|
|
925
|
-
invalidRestoreJournal(journalPath, `prepared absent ${name} has an impossible publication state`);
|
|
926
|
-
}
|
|
927
|
-
}
|
|
928
|
-
for (const sidecar of entry.sidecars) {
|
|
929
|
-
const live = fs.existsSync(sidecar.destination);
|
|
930
|
-
const quarantined = fs.existsSync(sidecar.quarantine);
|
|
931
|
-
const authenticated = quarantined
|
|
932
|
-
? matchesFingerprint(sidecar.quarantine, sidecar.originalFingerprint)
|
|
933
|
-
: matchesFingerprint(sidecar.destination, sidecar.originalFingerprint);
|
|
934
|
-
if ((sidecar.originalPresent ? live === quarantined : live || quarantined) || !authenticated) {
|
|
935
|
-
invalidRestoreJournal(journalPath, `prepared ${name} sidecar state is stale`);
|
|
936
|
-
}
|
|
937
|
-
}
|
|
938
|
-
}
|
|
939
|
-
return {
|
|
940
|
-
formatVersion: RESTORE_JOURNAL_FORMAT_VERSION,
|
|
941
|
-
version: MIGRATION_BACKUP_VERSION,
|
|
942
|
-
operationId,
|
|
943
|
-
sourceRunId,
|
|
944
|
-
rescueRunId: raw.rescueRunId,
|
|
945
|
-
phase: raw.phase,
|
|
946
|
-
entries,
|
|
947
|
-
};
|
|
948
|
-
}
|
|
949
|
-
/**
|
|
950
|
-
* Parse + validate the journal's per-artifact entries (shape, operation-bound
|
|
951
|
-
* stage/quarantine paths, sidecar sets, global path uniqueness). Returns the
|
|
952
|
-
* entries keyed by destination; the caller checks the set against the SOURCE
|
|
953
|
-
* manifest's per-version artifact list.
|
|
954
|
-
*/
|
|
955
|
-
function parseRestoreJournalEntries(rawEntries, operationId, expectedPaths, journalPath) {
|
|
956
|
-
const byDestination = new Map();
|
|
957
|
-
const allPaths = new Set();
|
|
958
|
-
const registerPath = (candidate, label) => {
|
|
959
|
-
const resolved = path.resolve(candidate);
|
|
960
|
-
if (allPaths.has(resolved))
|
|
961
|
-
invalidRestoreJournal(journalPath, `${label} path is duplicated: ${candidate}`);
|
|
962
|
-
allPaths.add(resolved);
|
|
963
|
-
};
|
|
964
|
-
for (const value of rawEntries) {
|
|
965
|
-
if (!isRecord(value) ||
|
|
966
|
-
!hasExactKeys(value, ["destination", "originalPresent", "originalFingerprint", "quarantine", "sidecars"], ["stage"]) ||
|
|
967
|
-
typeof value.destination !== "string" ||
|
|
968
|
-
typeof value.originalPresent !== "boolean" ||
|
|
969
|
-
typeof value.quarantine !== "string" ||
|
|
970
|
-
!Array.isArray(value.sidecars)) {
|
|
971
|
-
invalidRestoreJournal(journalPath, "artifact entry has an invalid shape");
|
|
972
|
-
}
|
|
973
|
-
const originalFingerprint = parseFileFingerprint(value.originalFingerprint, value.originalPresent, journalPath);
|
|
974
|
-
const artifactName = ARTIFACT_NAMES.find((name) => expectedPaths[name] === value.destination);
|
|
975
|
-
if (!artifactName || byDestination.has(value.destination)) {
|
|
976
|
-
invalidRestoreJournal(journalPath, `artifact destinations are not the exact unique canonical set`);
|
|
977
|
-
}
|
|
978
|
-
if (!isOperationBoundPath(value.quarantine, value.destination, "restore-quarantine", operationId)) {
|
|
979
|
-
invalidRestoreJournal(journalPath, `quarantine path is not bound to operation ${operationId}`);
|
|
980
|
-
}
|
|
981
|
-
if (value.stage !== undefined &&
|
|
982
|
-
(typeof value.stage !== "string" ||
|
|
983
|
-
!isOperationBoundPath(value.stage, value.destination, "restore-stage", operationId))) {
|
|
984
|
-
invalidRestoreJournal(journalPath, `stage path is not bound to operation ${operationId}`);
|
|
985
|
-
}
|
|
986
|
-
const expectedSidecarDestinations = artifactName === "config.json" ? [] : [`${value.destination}-wal`, `${value.destination}-shm`];
|
|
987
|
-
if (value.sidecars.length !== expectedSidecarDestinations.length) {
|
|
988
|
-
invalidRestoreJournal(journalPath, `${artifactName} has an incomplete sidecar set`);
|
|
989
|
-
}
|
|
990
|
-
const sidecars = [];
|
|
991
|
-
for (const sidecarValue of value.sidecars) {
|
|
992
|
-
if (!isRecord(sidecarValue) ||
|
|
993
|
-
!hasExactKeys(sidecarValue, ["destination", "originalPresent", "originalFingerprint", "quarantine"]) ||
|
|
994
|
-
typeof sidecarValue.destination !== "string" ||
|
|
995
|
-
typeof sidecarValue.originalPresent !== "boolean" ||
|
|
996
|
-
typeof sidecarValue.quarantine !== "string" ||
|
|
997
|
-
!expectedSidecarDestinations.includes(sidecarValue.destination)) {
|
|
998
|
-
invalidRestoreJournal(journalPath, `${artifactName} has an invalid sidecar entry`);
|
|
999
|
-
}
|
|
1000
|
-
const sidecarFingerprint = parseFileFingerprint(sidecarValue.originalFingerprint, sidecarValue.originalPresent, journalPath);
|
|
1001
|
-
if (sidecars.some((sidecar) => sidecar.destination === sidecarValue.destination)) {
|
|
1002
|
-
invalidRestoreJournal(journalPath, `${artifactName} has a duplicate sidecar destination`);
|
|
1003
|
-
}
|
|
1004
|
-
if (!isOperationBoundPath(sidecarValue.quarantine, sidecarValue.destination, "restore-quarantine", operationId)) {
|
|
1005
|
-
invalidRestoreJournal(journalPath, `sidecar quarantine path is not bound to operation ${operationId}`);
|
|
1006
|
-
}
|
|
1007
|
-
sidecars.push({
|
|
1008
|
-
destination: sidecarValue.destination,
|
|
1009
|
-
originalPresent: sidecarValue.originalPresent,
|
|
1010
|
-
originalFingerprint: sidecarFingerprint,
|
|
1011
|
-
quarantine: sidecarValue.quarantine,
|
|
1012
|
-
});
|
|
1013
|
-
}
|
|
1014
|
-
registerPath(value.destination, "destination");
|
|
1015
|
-
registerPath(value.quarantine, "quarantine");
|
|
1016
|
-
if (typeof value.stage === "string")
|
|
1017
|
-
registerPath(value.stage, "stage");
|
|
1018
|
-
for (const sidecar of sidecars) {
|
|
1019
|
-
registerPath(sidecar.destination, "sidecar destination");
|
|
1020
|
-
registerPath(sidecar.quarantine, "sidecar quarantine");
|
|
1021
|
-
}
|
|
1022
|
-
byDestination.set(value.destination, {
|
|
1023
|
-
destination: value.destination,
|
|
1024
|
-
...(typeof value.stage === "string" ? { stage: value.stage } : {}),
|
|
1025
|
-
originalPresent: value.originalPresent,
|
|
1026
|
-
originalFingerprint,
|
|
1027
|
-
quarantine: value.quarantine,
|
|
1028
|
-
sidecars,
|
|
1029
|
-
});
|
|
1030
|
-
}
|
|
1031
|
-
return byDestination;
|
|
1032
|
-
}
|
|
1033
|
-
function rollbackRestoreJournal(journal) {
|
|
1034
|
-
for (const [index, entry] of journal.entries.entries()) {
|
|
1035
|
-
if (fs.existsSync(entry.quarantine)) {
|
|
1036
|
-
fs.rmSync(entry.destination, { force: true });
|
|
1037
|
-
fs.renameSync(entry.quarantine, entry.destination);
|
|
1038
|
-
}
|
|
1039
|
-
else if (!entry.originalPresent) {
|
|
1040
|
-
fs.rmSync(entry.destination, { force: true });
|
|
1041
|
-
}
|
|
1042
|
-
rollbackBoundary(`${index}:destination`);
|
|
1043
|
-
if (entry.stage)
|
|
1044
|
-
fs.rmSync(entry.stage, { force: true });
|
|
1045
|
-
rollbackBoundary(`${index}:stage`);
|
|
1046
|
-
for (const [sidecarIndex, sidecar] of entry.sidecars.entries()) {
|
|
1047
|
-
if (fs.existsSync(sidecar.quarantine)) {
|
|
1048
|
-
fs.rmSync(sidecar.destination, { force: true });
|
|
1049
|
-
fs.renameSync(sidecar.quarantine, sidecar.destination);
|
|
1050
|
-
}
|
|
1051
|
-
else if (!sidecar.originalPresent) {
|
|
1052
|
-
fs.rmSync(sidecar.destination, { force: true });
|
|
1053
|
-
}
|
|
1054
|
-
rollbackBoundary(`${index}:sidecar:${sidecarIndex}`);
|
|
1055
|
-
}
|
|
1056
|
-
}
|
|
1057
|
-
for (const directory of new Set(journal.entries.map((entry) => path.dirname(entry.destination)))) {
|
|
1058
|
-
fsyncDirectory(directory);
|
|
1059
|
-
}
|
|
1060
|
-
rollbackBoundary("before-journal-delete");
|
|
1061
|
-
fs.rmSync(restoreJournalPath(), { force: true });
|
|
1062
|
-
fsyncDirectory(path.dirname(restoreJournalPath()));
|
|
1063
|
-
}
|
|
1064
|
-
function cleanupCommittedRestore(journal) {
|
|
1065
|
-
const directories = new Set();
|
|
1066
|
-
for (const entry of journal.entries) {
|
|
1067
|
-
directories.add(path.dirname(entry.destination));
|
|
1068
|
-
fs.rmSync(entry.quarantine, { force: true });
|
|
1069
|
-
if (entry.stage)
|
|
1070
|
-
fs.rmSync(entry.stage, { force: true });
|
|
1071
|
-
for (const sidecar of entry.sidecars)
|
|
1072
|
-
fs.rmSync(sidecar.quarantine, { force: true });
|
|
1073
|
-
}
|
|
1074
|
-
for (const directory of directories)
|
|
1075
|
-
fsyncDirectory(directory);
|
|
1076
|
-
fs.rmSync(restoreJournalPath(), { force: true });
|
|
1077
|
-
fsyncDirectory(path.dirname(restoreJournalPath()));
|
|
1078
|
-
}
|
|
1079
|
-
function recoverInterruptedRestore() {
|
|
1080
|
-
const journalPath = restoreJournalPath();
|
|
1081
|
-
if (!fs.existsSync(journalPath))
|
|
1082
|
-
return;
|
|
1083
|
-
let raw;
|
|
1084
|
-
try {
|
|
1085
|
-
raw = JSON.parse(readTextFileWithLimit(journalPath, MAX_LOCAL_METADATA_BYTES, "Restore journal"));
|
|
1086
|
-
}
|
|
1087
|
-
catch (error) {
|
|
1088
|
-
invalidRestoreJournal(journalPath, error instanceof Error ? error.message : String(error));
|
|
1089
|
-
}
|
|
1090
|
-
const journal = validateRestoreJournal(raw, journalPath);
|
|
1091
|
-
if (journal.phase === "committed")
|
|
1092
|
-
cleanupCommittedRestore(journal);
|
|
1093
|
-
else
|
|
1094
|
-
rollbackRestoreJournal(journal);
|
|
1095
|
-
}
|
|
1096
|
-
/** Caller must hold the config lock and maintenance barrier. */
|
|
1097
|
-
export function recoverInterruptedRestoreWithLocksHeld() {
|
|
1098
|
-
if (!fs.existsSync(restoreJournalPath()))
|
|
1099
|
-
return;
|
|
1100
|
-
assertNoArtifactReplacementBlockers();
|
|
1101
|
-
recoverInterruptedRestore();
|
|
1102
|
-
}
|
|
1103
|
-
function resolveBackupRun(runId) {
|
|
1104
|
-
if (runId)
|
|
1105
|
-
return getMigrationBackupDir(runId);
|
|
1106
|
-
const root = getMigrationBackupRoot();
|
|
1107
|
-
const candidates = fs.existsSync(root)
|
|
1108
|
-
? fs
|
|
1109
|
-
.readdirSync(root, { withFileTypes: true })
|
|
1110
|
-
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
|
|
1111
|
-
.map((entry) => ({ name: entry.name, mtime: fs.statSync(path.join(root, entry.name)).mtimeMs }))
|
|
1112
|
-
.sort((left, right) => left.mtime - right.mtime || left.name.localeCompare(right.name))
|
|
1113
|
-
: [];
|
|
1114
|
-
const latest = candidates.at(-1)?.name;
|
|
1115
|
-
if (!latest)
|
|
1116
|
-
throw new ConfigError(`No migration backup runs exist under ${root}.`, "INVALID_CONFIG_FILE");
|
|
1117
|
-
return getMigrationBackupDir(latest);
|
|
1118
|
-
}
|
|
1119
|
-
function inspectArtifactAt(name, filePath) {
|
|
1120
|
-
return name === "config.json" ? inspectConfig(filePath) : inspectLedgerArtifact(name, filePath);
|
|
1121
|
-
}
|
|
1122
|
-
// backupSqlite note: index.db takes no maintenance-activity lease (only
|
|
1123
|
-
// state.db/workflow.db have activity names) — VACUUM INTO under the 10s
|
|
1124
|
-
// busy_timeout suffices for the regenerable cache snapshot.
|
|
1125
|
-
function replaceArtifactsFromBundle(bundlePath, manifest, rescueRunId) {
|
|
1126
|
-
const operationId = `${process.pid}-${crypto.randomBytes(6).toString("hex")}`;
|
|
1127
|
-
const entries = [];
|
|
1128
|
-
let journal;
|
|
1129
|
-
let committed = false;
|
|
1130
|
-
const restoreNames = artifactNamesFor(manifest.formatVersion);
|
|
1131
|
-
try {
|
|
1132
|
-
for (const name of restoreNames) {
|
|
1133
|
-
const artifact = manifestArtifact(manifest, name);
|
|
1134
|
-
const destination = artifact.sourcePath;
|
|
1135
|
-
const stage = artifact.present ? `${destination}.restore-stage.${operationId}` : undefined;
|
|
1136
|
-
if (stage) {
|
|
1137
|
-
fs.rmSync(stage, { force: true });
|
|
1138
|
-
copyFileDurable(path.join(bundlePath, name), stage);
|
|
1139
|
-
if (sha256File(stage) !== artifact.sha256 || !sameState(inspectArtifactAt(name, stage), artifact)) {
|
|
1140
|
-
throw new ConfigError(`Staged restore artifact ${name} failed verification.`, "INVALID_CONFIG_FILE");
|
|
1141
|
-
}
|
|
1142
|
-
}
|
|
1143
|
-
entries.push({
|
|
1144
|
-
destination,
|
|
1145
|
-
stage,
|
|
1146
|
-
originalPresent: fs.existsSync(destination),
|
|
1147
|
-
originalFingerprint: fingerprintFile(destination),
|
|
1148
|
-
quarantine: `${destination}.restore-quarantine.${operationId}`,
|
|
1149
|
-
sidecars: name === "config.json"
|
|
1150
|
-
? []
|
|
1151
|
-
: ["-wal", "-shm"].map((suffix) => ({
|
|
1152
|
-
destination: `${destination}${suffix}`,
|
|
1153
|
-
originalPresent: fs.existsSync(`${destination}${suffix}`),
|
|
1154
|
-
originalFingerprint: fingerprintFile(`${destination}${suffix}`),
|
|
1155
|
-
quarantine: `${destination}${suffix}.restore-quarantine.${operationId}`,
|
|
1156
|
-
})),
|
|
1157
|
-
});
|
|
1158
|
-
}
|
|
1159
|
-
journal = {
|
|
1160
|
-
formatVersion: RESTORE_JOURNAL_FORMAT_VERSION,
|
|
1161
|
-
version: MIGRATION_BACKUP_VERSION,
|
|
1162
|
-
operationId,
|
|
1163
|
-
sourceRunId: manifest.runId,
|
|
1164
|
-
rescueRunId,
|
|
1165
|
-
phase: "prepared",
|
|
1166
|
-
entries,
|
|
1167
|
-
};
|
|
1168
|
-
writeRestoreJournal(journal);
|
|
1169
|
-
for (const entry of entries) {
|
|
1170
|
-
fs.mkdirSync(path.dirname(entry.destination), { recursive: true, mode: 0o700 });
|
|
1171
|
-
if (entry.originalPresent)
|
|
1172
|
-
fs.renameSync(entry.destination, entry.quarantine);
|
|
1173
|
-
for (const sidecar of entry.sidecars) {
|
|
1174
|
-
if (sidecar.originalPresent)
|
|
1175
|
-
fs.renameSync(sidecar.destination, sidecar.quarantine);
|
|
1176
|
-
}
|
|
1177
|
-
}
|
|
1178
|
-
for (const entry of entries) {
|
|
1179
|
-
if (entry.stage) {
|
|
1180
|
-
fs.renameSync(entry.stage, entry.destination);
|
|
1181
|
-
fs.chmodSync(entry.destination, 0o600);
|
|
1182
|
-
fsyncDirectory(path.dirname(entry.destination));
|
|
1183
|
-
}
|
|
1184
|
-
}
|
|
1185
|
-
for (const name of restoreNames) {
|
|
1186
|
-
const artifact = manifestArtifact(manifest, name);
|
|
1187
|
-
const actual = artifact.present ? inspectArtifactAt(name, artifact.sourcePath) : { status: "missing" };
|
|
1188
|
-
if (!sameState(actual, artifact)) {
|
|
1189
|
-
throw new ConfigError(`Published restore artifact ${name} failed final verification.`, "INVALID_CONFIG_FILE");
|
|
1190
|
-
}
|
|
1191
|
-
}
|
|
1192
|
-
journal.phase = "committed";
|
|
1193
|
-
writeRestoreJournal(journal);
|
|
1194
|
-
committed = true;
|
|
1195
|
-
cleanupCommittedRestore(journal);
|
|
1196
|
-
}
|
|
1197
|
-
catch (error) {
|
|
1198
|
-
if (!committed) {
|
|
1199
|
-
rollbackRestoreJournal(journal ?? {
|
|
1200
|
-
operationId,
|
|
1201
|
-
formatVersion: RESTORE_JOURNAL_FORMAT_VERSION,
|
|
1202
|
-
version: MIGRATION_BACKUP_VERSION,
|
|
1203
|
-
sourceRunId: manifest.runId,
|
|
1204
|
-
rescueRunId,
|
|
1205
|
-
phase: "prepared",
|
|
1206
|
-
entries,
|
|
1207
|
-
});
|
|
1208
|
-
}
|
|
1209
|
-
throw error;
|
|
1210
|
-
}
|
|
1211
|
-
}
|
|
1212
|
-
/** Caller must hold the config lock and maintenance barrier. */
|
|
1213
|
-
export function restoreMigrationBackupWithLocksHeld(bundlePath) {
|
|
1214
|
-
assertNoArtifactReplacementBlockers(bundlePath);
|
|
1215
|
-
recoverInterruptedRestore();
|
|
1216
|
-
const manifest = verifyMigrationBackup(bundlePath);
|
|
1217
|
-
replaceArtifactsFromBundle(bundlePath, manifest, "migration-apply-rollback");
|
|
1218
|
-
}
|
|
1219
|
-
export function restoreMigrationBackup(confirm, runId) {
|
|
1220
|
-
if (!confirm)
|
|
1221
|
-
throw new ConfigError("Migration backup restore requires --confirm.", "INVALID_CONFIG_FILE");
|
|
1222
|
-
const bundlePath = resolveBackupRun(runId);
|
|
1223
|
-
return withConfigLock(() => withMigrationBackupLock(() => withMaintenanceStartBarrier(() => {
|
|
1224
|
-
if (fs.existsSync(getMigrationApplyJournalPath())) {
|
|
1225
|
-
throw new ConfigError(`Migration apply recovery is pending at ${getMigrationApplyJournalPath()}; run \`akm migrate apply\` before restore.`, "INVALID_CONFIG_FILE");
|
|
1226
|
-
}
|
|
1227
|
-
assertNoArtifactReplacementBlockers(bundlePath);
|
|
1228
|
-
recoverInterruptedRestore();
|
|
1229
|
-
const manifest = verifyMigrationBackup(bundlePath);
|
|
1230
|
-
const rescue = createMigrationBackupUnlocked();
|
|
1231
|
-
replaceArtifactsFromBundle(bundlePath, manifest, rescue.manifest.runId);
|
|
1232
|
-
return { path: bundlePath, created: false, manifest, rescuePath: rescue.path };
|
|
1233
|
-
})));
|
|
1234
|
-
}
|