akm-cli 0.9.0-rc.1 → 0.9.0-rc.3

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.
Files changed (257) hide show
  1. package/CHANGELOG.md +249 -9
  2. package/SECURITY.md +23 -24
  3. package/dist/assets/help/help-improve.md +10 -10
  4. package/dist/assets/hints/cli-hints-full.md +44 -10
  5. package/dist/assets/hints/cli-hints-short.md +6 -2
  6. package/dist/assets/{profiles → improve-strategies}/catchup.json +3 -1
  7. package/dist/assets/{profiles → improve-strategies}/consolidate.json +3 -1
  8. package/dist/assets/{profiles → improve-strategies}/default.json +1 -0
  9. package/dist/assets/{profiles → improve-strategies}/frequent.json +3 -1
  10. package/dist/assets/{profiles → improve-strategies}/graph-refresh.json +4 -2
  11. package/dist/assets/{profiles → improve-strategies}/memory-focus.json +4 -1
  12. package/dist/assets/{profiles → improve-strategies}/proactive-maintenance.json +3 -3
  13. package/dist/assets/{profiles → improve-strategies}/quick.json +3 -1
  14. package/dist/assets/{profiles → improve-strategies}/recombine-only.json +2 -0
  15. package/dist/assets/{profiles → improve-strategies}/reflect-distill.json +4 -4
  16. package/dist/assets/{profiles → improve-strategies}/synthesize.json +2 -0
  17. package/dist/assets/stash-skeleton/README.md +28 -0
  18. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +6 -0
  19. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +6 -0
  20. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +12 -1
  21. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +11 -1
  22. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +6 -0
  23. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +9 -0
  24. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +7 -0
  25. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +7 -0
  26. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +6 -0
  27. package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +98 -0
  28. package/dist/assets/stash-skeleton/facts/conventions/domains.md +63 -0
  29. package/dist/assets/stash-skeleton/facts/conventions/organization.md +127 -0
  30. package/dist/assets/tasks/core/backup.yml +3 -2
  31. package/dist/assets/tasks/core/extract.yml +1 -0
  32. package/dist/assets/tasks/core/improve.yml +1 -0
  33. package/dist/assets/tasks/core/index-refresh.yml +1 -0
  34. package/dist/assets/tasks/core/sync.yml +1 -0
  35. package/dist/assets/tasks/core/version-check.yml +1 -0
  36. package/dist/assets/tasks/graph-refresh-weekly.yml +4 -4
  37. package/dist/assets/templates/html/health.html +5 -1
  38. package/dist/cli/config-migrate.js +547 -126
  39. package/dist/cli/config-validate.js +10 -8
  40. package/dist/cli.js +50 -14
  41. package/dist/commands/agent/agent-dispatch.js +17 -16
  42. package/dist/commands/agent/agent-support.js +0 -24
  43. package/dist/commands/agent/contribute-cli.js +5 -15
  44. package/dist/commands/backup-cli.js +56 -0
  45. package/dist/commands/config-cli.js +48 -155
  46. package/dist/commands/env/secret.js +8 -5
  47. package/dist/commands/feedback-cli.js +24 -7
  48. package/dist/commands/health/checks.js +200 -74
  49. package/dist/commands/health/html-report.js +4 -0
  50. package/dist/commands/health/improve-metrics.js +30 -32
  51. package/dist/commands/health/llm-usage.js +19 -19
  52. package/dist/commands/health/md-report.js +4 -0
  53. package/dist/commands/health/metrics.js +2 -1
  54. package/dist/commands/health/surfaces.js +7 -5
  55. package/dist/commands/health.js +1 -1
  56. package/dist/commands/improve/anti-collapse.js +3 -3
  57. package/dist/commands/improve/collapse-detector.js +5 -5
  58. package/dist/commands/improve/consolidate/chunking.js +2 -2
  59. package/dist/commands/improve/consolidate.js +148 -88
  60. package/dist/commands/improve/distill/promote-memory.js +13 -18
  61. package/dist/commands/improve/distill/quality-gate.js +6 -8
  62. package/dist/commands/improve/distill-guards.js +1 -1
  63. package/dist/commands/improve/distill-promotion-policy.js +32 -26
  64. package/dist/commands/improve/distill.js +68 -41
  65. package/dist/commands/improve/eligibility.js +31 -20
  66. package/dist/commands/improve/extract-cli.js +47 -19
  67. package/dist/commands/improve/extract-prompt.js +2 -2
  68. package/dist/commands/improve/extract.js +153 -73
  69. package/dist/commands/improve/improve-auto-accept.js +14 -28
  70. package/dist/commands/improve/improve-cli.js +41 -17
  71. package/dist/commands/improve/improve-result-file.js +30 -24
  72. package/dist/commands/improve/improve-strategies.js +138 -0
  73. package/dist/commands/improve/improve.js +109 -38
  74. package/dist/commands/improve/locks.js +66 -45
  75. package/dist/commands/improve/loop-stages.js +86 -39
  76. package/dist/commands/improve/memory/memory-belief.js +79 -7
  77. package/dist/commands/improve/memory/memory-contradiction-detect.js +12 -4
  78. package/dist/commands/improve/preparation.js +122 -90
  79. package/dist/commands/improve/procedural.js +13 -8
  80. package/dist/commands/improve/recombine.js +33 -8
  81. package/dist/commands/improve/reflect.js +143 -228
  82. package/dist/commands/improve/salience.js +5 -1
  83. package/dist/commands/improve/shared.js +11 -5
  84. package/dist/commands/improve/source-identity.js +43 -0
  85. package/dist/commands/lint/base-linter.js +152 -42
  86. package/dist/commands/migrate-cli.js +36 -0
  87. package/dist/commands/mv-cli.js +1198 -0
  88. package/dist/commands/proposal/drain.js +16 -4
  89. package/dist/commands/proposal/proposal-cli.js +21 -10
  90. package/dist/commands/proposal/proposal.js +16 -55
  91. package/dist/commands/proposal/propose.js +64 -69
  92. package/dist/commands/proposal/repository.js +664 -58
  93. package/dist/commands/read/curate.js +4 -2
  94. package/dist/commands/read/knowledge.js +440 -5
  95. package/dist/commands/read/remember-cli.js +39 -2
  96. package/dist/commands/read/search-cli.js +6 -1
  97. package/dist/commands/read/search.js +6 -2
  98. package/dist/commands/read/show.js +8 -7
  99. package/dist/commands/registry-cli.js +29 -14
  100. package/dist/commands/remember.js +2 -0
  101. package/dist/commands/sources/init.js +13 -14
  102. package/dist/commands/sources/schema-repair.js +2 -4
  103. package/dist/commands/sources/self-update.js +156 -112
  104. package/dist/commands/sources/source-add.js +62 -73
  105. package/dist/commands/sources/source-manage.js +50 -46
  106. package/dist/commands/sources/sources-cli.js +7 -2
  107. package/dist/commands/sources/stash-cli.js +41 -4
  108. package/dist/commands/tasks/default-tasks.js +12 -12
  109. package/dist/commands/tasks/tasks-cli.js +7 -3
  110. package/dist/commands/tasks/tasks.js +113 -18
  111. package/dist/commands/wiki-cli.js +9 -10
  112. package/dist/core/asset/frontmatter.js +12 -2
  113. package/dist/core/common.js +41 -6
  114. package/dist/core/config/config-io.js +30 -19
  115. package/dist/core/config/config-schema.js +373 -69
  116. package/dist/core/config/config-types.js +3 -3
  117. package/dist/core/config/config-version.js +29 -0
  118. package/dist/core/config/config-walker.js +98 -27
  119. package/dist/core/config/config.js +135 -268
  120. package/dist/core/config/deep-merge.js +41 -0
  121. package/dist/core/config/engine-semantics.js +32 -0
  122. package/dist/core/errors.js +2 -2
  123. package/dist/core/extra-params.js +61 -0
  124. package/dist/core/file-lock.js +202 -57
  125. package/dist/core/improve-result.js +178 -0
  126. package/dist/core/maintenance-barrier.js +119 -0
  127. package/dist/core/migration-backup.js +1061 -0
  128. package/dist/core/migration-operation.js +44 -0
  129. package/dist/core/paths.js +3 -0
  130. package/dist/core/redaction.js +358 -0
  131. package/dist/core/state/migrations.js +15 -3
  132. package/dist/core/state-db.js +60 -2
  133. package/dist/core/write-source.js +86 -18
  134. package/dist/indexer/db/db.js +212 -41
  135. package/dist/indexer/graph/graph-extraction.js +28 -16
  136. package/dist/indexer/index-writer-lock.js +59 -51
  137. package/dist/indexer/index-written-assets.js +75 -49
  138. package/dist/indexer/indexer.js +52 -3
  139. package/dist/indexer/passes/memory-inference.js +10 -6
  140. package/dist/indexer/passes/metadata.js +250 -0
  141. package/dist/indexer/search/db-search.js +111 -44
  142. package/dist/indexer/search/fts-query.js +41 -0
  143. package/dist/indexer/search/ranking-contributors.js +48 -0
  144. package/dist/indexer/search/ranking.js +36 -23
  145. package/dist/indexer/search/search-fields.js +11 -1
  146. package/dist/indexer/search/search-source.js +2 -2
  147. package/dist/indexer/usage/usage-events.js +8 -2
  148. package/dist/integrations/agent/builder-shared.js +7 -0
  149. package/dist/integrations/agent/builders.js +5 -52
  150. package/dist/integrations/agent/config.js +3 -143
  151. package/dist/integrations/agent/detect.js +17 -2
  152. package/dist/integrations/agent/engine-resolution.js +209 -0
  153. package/dist/integrations/agent/index.js +1 -2
  154. package/dist/integrations/agent/model-aliases.js +7 -2
  155. package/dist/integrations/agent/profiles.js +6 -99
  156. package/dist/integrations/agent/runner-dispatch.js +76 -13
  157. package/dist/integrations/agent/runner.js +76 -207
  158. package/dist/integrations/agent/spawn.js +4 -6
  159. package/dist/integrations/harnesses/aider/agent-builder.js +2 -3
  160. package/dist/integrations/harnesses/aider/index.js +0 -1
  161. package/dist/integrations/harnesses/amazonq/agent-builder.js +2 -3
  162. package/dist/integrations/harnesses/amazonq/index.js +0 -1
  163. package/dist/integrations/harnesses/claude/agent-builder.js +2 -3
  164. package/dist/integrations/harnesses/claude/index.js +0 -2
  165. package/dist/integrations/harnesses/codex/agent-builder.js +2 -3
  166. package/dist/integrations/harnesses/codex/index.js +0 -1
  167. package/dist/integrations/harnesses/copilot/agent-builder.js +2 -3
  168. package/dist/integrations/harnesses/copilot/index.js +0 -1
  169. package/dist/integrations/harnesses/gemini/agent-builder.js +2 -3
  170. package/dist/integrations/harnesses/gemini/index.js +0 -1
  171. package/dist/integrations/harnesses/index.js +1 -24
  172. package/dist/integrations/harnesses/opencode/agent-builder.js +2 -3
  173. package/dist/integrations/harnesses/opencode/index.js +0 -6
  174. package/dist/integrations/harnesses/opencode-sdk/harness.js +1 -6
  175. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +274 -191
  176. package/dist/integrations/harnesses/openhands/agent-builder.js +2 -3
  177. package/dist/integrations/harnesses/openhands/index.js +0 -1
  178. package/dist/integrations/harnesses/pi/agent-builder.js +2 -3
  179. package/dist/integrations/harnesses/pi/index.js +0 -1
  180. package/dist/integrations/harnesses/types.js +1 -32
  181. package/dist/integrations/lockfile.js +32 -21
  182. package/dist/llm/client.js +48 -14
  183. package/dist/llm/feature-gate.js +15 -47
  184. package/dist/llm/graph-extract.js +1 -1
  185. package/dist/llm/index-passes.js +8 -42
  186. package/dist/llm/memory-infer-impl.js +1 -1
  187. package/dist/llm/usage-persist.js +4 -0
  188. package/dist/llm/usage-telemetry.js +35 -5
  189. package/dist/output/shapes/helpers.js +2 -1
  190. package/dist/output/shapes/passthrough.js +2 -0
  191. package/dist/output/text/helpers.js +3 -1
  192. package/dist/schemas/akm-config.json +11013 -8600
  193. package/dist/schemas/akm-task.json +87 -0
  194. package/dist/schemas/akm-workflow.json +57 -13
  195. package/dist/scripts/migrate-storage.js +8502 -543
  196. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +8493 -515
  197. package/dist/setup/detected-engines.js +136 -0
  198. package/dist/setup/engine-config.js +99 -0
  199. package/dist/setup/setup.js +237 -133
  200. package/dist/setup/steps/connection.js +62 -33
  201. package/dist/setup/steps/platforms.js +4 -4
  202. package/dist/setup/steps/tasks.js +1 -1
  203. package/dist/setup/steps.js +3 -2
  204. package/dist/sources/providers/git-stash.js +58 -21
  205. package/dist/sources/providers/git.js +1 -1
  206. package/dist/storage/database.js +13 -1
  207. package/dist/storage/engines/sqlite-migrations.js +139 -3
  208. package/dist/storage/repositories/events-repository.js +24 -0
  209. package/dist/storage/repositories/improve-runs-repository.js +5 -5
  210. package/dist/storage/repositories/proposals-repository.js +14 -0
  211. package/dist/storage/repositories/task-history-repository.js +78 -0
  212. package/dist/storage/repositories/workflow-runs-repository.js +9 -8
  213. package/dist/tasks/embedded.js +2 -0
  214. package/dist/tasks/parser.js +138 -52
  215. package/dist/tasks/runner.js +71 -75
  216. package/dist/tasks/schema.js +1 -1
  217. package/dist/tasks/validator.js +11 -6
  218. package/dist/wiki/wiki.js +9 -8
  219. package/dist/workflows/authoring/workflow-program-template.yaml +3 -3
  220. package/dist/workflows/concurrency-policy.js +15 -0
  221. package/dist/workflows/db.js +78 -15
  222. package/dist/workflows/exec/brief.js +18 -24
  223. package/dist/workflows/exec/frozen-judge.js +47 -0
  224. package/dist/workflows/exec/native-executor.js +124 -65
  225. package/dist/workflows/exec/report.js +93 -33
  226. package/dist/workflows/exec/run-workflow.js +38 -25
  227. package/dist/workflows/exec/scheduler.js +12 -41
  228. package/dist/workflows/exec/step-work.js +91 -35
  229. package/dist/workflows/ir/compile.js +13 -26
  230. package/dist/workflows/ir/freeze.js +243 -0
  231. package/dist/workflows/ir/plan-hash.js +40 -5
  232. package/dist/workflows/ir/schema.js +542 -1
  233. package/dist/workflows/parser.js +7 -0
  234. package/dist/workflows/program/parser.js +132 -23
  235. package/dist/workflows/program/project.js +3 -4
  236. package/dist/workflows/program/schema.js +2 -2
  237. package/dist/workflows/resource-limits.js +20 -0
  238. package/dist/workflows/runtime/plan-classifier.js +187 -0
  239. package/dist/workflows/runtime/runs.js +54 -47
  240. package/dist/workflows/runtime/workflow-asset-loader.js +0 -22
  241. package/dist/workflows/validator.js +49 -2
  242. package/docs/data-and-telemetry.md +2 -2
  243. package/docs/migration/release-notes/0.6.0.md +1 -1
  244. package/docs/migration/release-notes/0.7.0.md +5 -4
  245. package/docs/migration/release-notes/0.9.0.md +24 -7
  246. package/docs/migration/v0.8-to-v0.9.md +506 -0
  247. package/package.json +4 -2
  248. package/schemas/akm-config.json +16638 -0
  249. package/schemas/akm-task.json +87 -0
  250. package/schemas/akm-workflow.json +372 -0
  251. package/dist/commands/improve/improve-profiles.js +0 -168
  252. package/dist/core/config/config-migration.js +0 -602
  253. package/dist/core/deep-merge.js +0 -38
  254. package/dist/llm/call-ai.js +0 -62
  255. package/dist/setup/legacy-config.js +0 -106
  256. package/docs/README.md +0 -104
  257. /package/dist/assets/{profiles → improve-strategies}/thorough.json +0 -0
@@ -1,150 +1,571 @@
1
1
  // This Source Code Form is subject to the terms of the Mozilla Public
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ import { createHash, randomUUID } from "node:crypto";
4
5
  import fs from "node:fs";
5
6
  import path from "node:path";
6
- import { stripJsonComments } from "../core/config/config.js";
7
- import { unifiedDiff, withConfigLock, writeConfigAtomic } from "../core/config/config-io.js";
8
- import { migrateConfigShape } from "../core/config/config-migration.js";
9
- import { getCacheDir, getConfigPath } from "../core/paths.js";
10
- import { warn } from "../core/warn.js";
11
- export { migrateConfigShape } from "../core/config/config-migration.js";
12
- const PROJECT_CONFIG_RELATIVE_PATH = path.join(".akm", "config.json");
13
- function backupConfigFile(configPath) {
14
- if (!fs.existsSync(configPath))
15
- return;
16
- const backupDir = path.join(getCacheDir(), "config-backups");
17
- // 08-F4: lock the backup dir owner-only (0700) see config-io.ts.
18
- fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
19
- fs.chmodSync(backupDir, 0o700);
20
- const timestamp = new Date().toISOString().replace(/[.:]/g, "-");
21
- const backupPath = path.join(backupDir, `config-${timestamp}.json`);
22
- fs.copyFileSync(configPath, backupPath);
23
- const latestPath = path.join(backupDir, "config.latest.json");
24
- fs.copyFileSync(configPath, latestPath);
25
- // 08-F4: config backups can carry secrets (endpoints/tokens) — keep them
26
- // owner-only rather than inheriting the source file's (often 0644) mode.
27
- fs.chmodSync(backupPath, 0o600);
28
- fs.chmodSync(latestPath, 0o600);
29
- }
30
- function acquireMigrateLock(lockPath, noWait) {
31
- const lockDir = path.dirname(lockPath);
32
- fs.mkdirSync(lockDir, { recursive: true });
33
- const maxAttempts = noWait ? 1 : 20;
34
- const delayMs = 200;
35
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
36
- try {
37
- fs.writeFileSync(lockPath, String(process.pid), { flag: "wx" });
38
- return () => {
39
- try {
40
- fs.unlinkSync(lockPath);
41
- }
42
- catch {
43
- // ignore
44
- }
45
- };
7
+ import { MAX_CONFIG_FILE_BYTES, MAX_LOCAL_METADATA_BYTES, readTextFileWithLimit, writeFileAtomic, } from "../core/common.js";
8
+ import { parseAndValidateConfigText, resetConfigCache, sanitizeConfigForWrite, } from "../core/config/config.js";
9
+ import { backupExistingConfig, withConfigLock, writeConfigAtomic } from "../core/config/config-io.js";
10
+ import { ConfigError } from "../core/errors.js";
11
+ import { withMaintenanceStartBarrier } from "../core/maintenance-barrier.js";
12
+ import { assertNoArtifactReplacementBlockers, ensureMigrationBackupWithConfigLockHeld, fingerprintMigrationGeneration, getMigrationApplyJournalPath, getMigrationBackupDir, getMigrationBackupRoot, getMigrationRestoreJournalPath, inspectMigrationState, MIGRATION_BACKUP_VERSION, recoverInterruptedRestoreWithLocksHeld, restoreMigrationBackupWithLocksHeld, sameMigrationGeneration, verifyMigrationBackup, } from "../core/migration-backup.js";
13
+ import { getConfigPath, getStateDbPathInDataDir, getWorkflowDbPath } from "../core/paths.js";
14
+ import { runMigrations as runStateMigrations } from "../core/state/migrations.js";
15
+ import { openDatabase } from "../storage/database.js";
16
+ import { runMigrations as runWorkflowMigrations } from "../workflows/db.js";
17
+ import { EXIT_CODES } from "./shared.js";
18
+ const MANUAL_GUIDANCE = "Provide a complete operator-prepared 0.9 config with --config. AKM does not guess profile-to-engine mappings.";
19
+ function isFileFingerprint(value) {
20
+ if (value === null)
21
+ return true;
22
+ if (typeof value !== "object" || value === null || Array.isArray(value))
23
+ return false;
24
+ const candidate = value;
25
+ return (Object.keys(candidate).sort().join(",") === "byteSize,sha256" &&
26
+ Number.isSafeInteger(candidate.byteSize) &&
27
+ candidate.byteSize >= 0 &&
28
+ typeof candidate.sha256 === "string" &&
29
+ /^[a-f0-9]{64}$/.test(candidate.sha256));
30
+ }
31
+ function isGenerationFingerprint(value) {
32
+ if (typeof value !== "object" || value === null || Array.isArray(value))
33
+ return false;
34
+ const generation = value;
35
+ if (Object.keys(generation).sort().join(",") !== "config,state,workflow")
36
+ return false;
37
+ for (const [name, expectedNullSidecars] of [
38
+ ["config", true],
39
+ ["state", false],
40
+ ["workflow", false],
41
+ ]) {
42
+ const artifact = generation[name];
43
+ if (typeof artifact !== "object" || artifact === null || Array.isArray(artifact))
44
+ return false;
45
+ const files = artifact;
46
+ if (Object.keys(files).sort().join(",") !== "main,shm,wal")
47
+ return false;
48
+ if (!isFileFingerprint(files.main) || !isFileFingerprint(files.wal) || !isFileFingerprint(files.shm))
49
+ return false;
50
+ if (expectedNullSidecars && (files.wal !== null || files.shm !== null))
51
+ return false;
52
+ }
53
+ return true;
54
+ }
55
+ function sameArtifactFingerprint(left, right) {
56
+ return JSON.stringify(left) === JSON.stringify(right);
57
+ }
58
+ function hasGenerationMarker(dbPath, operationId, phase) {
59
+ if (!fs.existsSync(dbPath))
60
+ return false;
61
+ const db = openDatabase(dbPath, { readonly: true });
62
+ try {
63
+ if (!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='akm_migration_generation'").get()) {
64
+ return false;
46
65
  }
47
- catch {
48
- if (noWait) {
49
- return null;
50
- }
51
- // Simple busy-wait — synchronous since this is a one-shot CLI action
52
- const deadline = Date.now() + delayMs;
53
- while (Date.now() < deadline) {
54
- // spin
55
- }
66
+ const rows = db
67
+ .prepare("SELECT operation_id, phase FROM akm_migration_generation WHERE singleton=1 LIMIT 2")
68
+ .all();
69
+ return rows.length === 1 && rows[0].operation_id === operationId && rows[0].phase === phase;
70
+ }
71
+ finally {
72
+ db.close();
73
+ }
74
+ }
75
+ function generationFromBackup(manifest) {
76
+ const fingerprint = (name) => {
77
+ const artifact = manifest.artifacts[name];
78
+ return artifact.present ? { byteSize: artifact.byteSize, sha256: artifact.sha256 } : null;
79
+ };
80
+ return {
81
+ config: { main: fingerprint("config.json"), wal: null, shm: null },
82
+ state: { main: fingerprint("state.db"), wal: null, shm: null },
83
+ workflow: { main: fingerprint("workflow.db"), wal: null, shm: null },
84
+ };
85
+ }
86
+ function detectAdjacentGeneration(journal, manifest) {
87
+ if (journal.phase === "rollback-prepared") {
88
+ return {
89
+ rollbackCompleted: sameMigrationGeneration(fingerprintMigrationGeneration(), generationFromBackup(manifest)),
90
+ };
91
+ }
92
+ const live = inspectMigrationState();
93
+ const markerPresent = journal.phase === "prepared"
94
+ ? hasGenerationMarker(getStateDbPathInDataDir(), journal.operationId, "state-applied")
95
+ : journal.phase === "state-applied"
96
+ ? hasGenerationMarker(getWorkflowDbPath(), journal.operationId, "workflow-applied")
97
+ : false;
98
+ const current = fingerprintMigrationGeneration();
99
+ const unchanged = (...names) => names.every((name) => sameArtifactFingerprint(journal.generation[name], current[name]));
100
+ if (journal.phase === "prepared" && unchanged("config", "workflow") && markerPresent) {
101
+ return {
102
+ adjacent: {
103
+ phase: "state-applied",
104
+ complete: live.state.status === "current" || live.state.status === "missing",
105
+ generation: current,
106
+ },
107
+ };
108
+ }
109
+ if (journal.phase === "state-applied" && unchanged("config", "state") && markerPresent) {
110
+ return {
111
+ adjacent: {
112
+ phase: "workflow-applied",
113
+ complete: live.workflow.status === "current" || live.workflow.status === "missing",
114
+ generation: current,
115
+ },
116
+ };
117
+ }
118
+ const expectedTarget = `${JSON.stringify(journal.targetConfig, null, 2)}\n`;
119
+ if (journal.phase === "workflow-applied" &&
120
+ unchanged("state", "workflow") &&
121
+ fs.existsSync(getConfigPath()) &&
122
+ readTextFileWithLimit(getConfigPath(), MAX_CONFIG_FILE_BYTES, "Config file") === expectedTarget) {
123
+ return {
124
+ adjacent: { phase: "config-applied", complete: true, generation: current },
125
+ };
126
+ }
127
+ return {};
128
+ }
129
+ function assertRollbackTransitionAllowed(journal, current) {
130
+ const unchanged = journal.phase === "prepared"
131
+ ? ["config", "workflow"]
132
+ : journal.phase === "state-applied"
133
+ ? ["config", "state"]
134
+ : journal.phase === "workflow-applied"
135
+ ? ["state", "workflow"]
136
+ : ["config", "state", "workflow"];
137
+ for (const name of unchanged) {
138
+ if (!sameArtifactFingerprint(journal.generation[name], current[name])) {
139
+ throw new ConfigError(`Refusing migration rollback because ${name} changed outside the journaled ${journal.phase} transition.`, "INVALID_CONFIG_FILE");
56
140
  }
57
141
  }
58
- return null;
59
142
  }
60
- export async function migrateConfigFile(filePath, opts) {
61
- if (!fs.existsSync(filePath)) {
62
- return { changed: false, result: {} };
143
+ function sameArtifactState(actual, expected) {
144
+ return (actual.status === expected.status &&
145
+ JSON.stringify(actual.migrationIds ?? []) === JSON.stringify(expected.migrationIds ?? []) &&
146
+ JSON.stringify(actual.migrationChecksums ?? []) === JSON.stringify(expected.migrationChecksums ?? []));
147
+ }
148
+ function configMatchesBytes(expectedSize, expectedHash) {
149
+ const configPath = getConfigPath();
150
+ if (!fs.existsSync(configPath) || expectedHash === null)
151
+ return false;
152
+ const text = readTextFileWithLimit(configPath, MAX_CONFIG_FILE_BYTES, "Config file");
153
+ return (fs.statSync(configPath).size === expectedSize &&
154
+ createHash("sha256").update(Buffer.from(text)).digest("hex") === expectedHash);
155
+ }
156
+ function validateApplyPhase(journal, manifest) {
157
+ const live = inspectMigrationState();
158
+ const stateOriginal = sameArtifactState(live.state, manifest.artifacts["state.db"]);
159
+ const workflowOriginal = sameArtifactState(live.workflow, manifest.artifacts["workflow.db"]);
160
+ const configOriginal = manifest.artifacts["config.json"].present
161
+ ? configMatchesBytes(manifest.artifacts["config.json"].byteSize, manifest.artifacts["config.json"].sha256)
162
+ : live.config.status === "missing";
163
+ const stateApplied = manifest.artifacts["state.db"].present
164
+ ? live.state.status === "current"
165
+ : live.state.status === "missing";
166
+ const workflowApplied = manifest.artifacts["workflow.db"].present
167
+ ? live.workflow.status === "current"
168
+ : live.workflow.status === "missing";
169
+ const expectedTarget = `${JSON.stringify(journal.targetConfig, null, 2)}\n`;
170
+ const configApplied = live.config.status === "current" &&
171
+ fs.existsSync(getConfigPath()) &&
172
+ readTextFileWithLimit(getConfigPath(), MAX_CONFIG_FILE_BYTES, "Config file") === expectedTarget;
173
+ const reachable = journal.phase === "rollback-prepared"
174
+ ? true
175
+ : journal.phase === "prepared"
176
+ ? configOriginal && workflowOriginal && (stateOriginal || stateApplied)
177
+ : journal.phase === "state-applied"
178
+ ? stateApplied && configOriginal && (workflowOriginal || workflowApplied)
179
+ : journal.phase === "workflow-applied"
180
+ ? stateApplied && workflowApplied && (configOriginal || configApplied)
181
+ : stateApplied && workflowApplied && configApplied;
182
+ if (!reachable) {
183
+ throw new ConfigError(`Migration apply journal phase ${journal.phase} does not match a reachable config/state/workflow artifact state.`, "INVALID_CONFIG_FILE");
63
184
  }
64
- const text = fs.readFileSync(filePath, "utf8");
65
- let raw;
185
+ }
186
+ function readApplyJournal() {
187
+ const journalPath = getMigrationApplyJournalPath();
188
+ if (!fs.existsSync(journalPath))
189
+ return {};
190
+ let journal;
66
191
  try {
67
- const stripped = stripJsonComments(text);
68
- const parsed = JSON.parse(stripped);
69
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
70
- warn(`[akm] config-migrate: ${filePath} is not a valid JSON object, skipping.`);
71
- return { changed: false, result: {} };
192
+ const value = JSON.parse(readTextFileWithLimit(journalPath, MAX_LOCAL_METADATA_BYTES, "Migration apply journal"));
193
+ const phases = [
194
+ "prepared",
195
+ "state-applied",
196
+ "workflow-applied",
197
+ "config-applied",
198
+ "rollback-prepared",
199
+ "committed",
200
+ ];
201
+ if (typeof value !== "object" ||
202
+ value === null ||
203
+ Array.isArray(value) ||
204
+ Object.keys(value).sort().join(",") !==
205
+ [
206
+ "backupPath",
207
+ "backupRunId",
208
+ "formatVersion",
209
+ "generation",
210
+ "installationId",
211
+ "operationId",
212
+ "phase",
213
+ "targetConfig",
214
+ "version",
215
+ ]
216
+ .sort()
217
+ .join(",")) {
218
+ return { error: `Invalid migration apply journal at ${journalPath}.` };
219
+ }
220
+ const candidate = value;
221
+ if (candidate.formatVersion !== 2 ||
222
+ candidate.version !== MIGRATION_BACKUP_VERSION ||
223
+ typeof candidate.operationId !== "string" ||
224
+ !/^[A-Za-z0-9._-]+$/.test(candidate.operationId) ||
225
+ candidate.installationId !== path.basename(getMigrationBackupRoot()) ||
226
+ !isGenerationFingerprint(candidate.generation) ||
227
+ typeof candidate.backupRunId !== "string" ||
228
+ !/^[A-Za-z0-9._-]+$/.test(candidate.backupRunId) ||
229
+ candidate.backupPath !== getMigrationBackupDir(candidate.backupRunId) ||
230
+ !candidate.targetConfig ||
231
+ typeof candidate.targetConfig !== "object" ||
232
+ Array.isArray(candidate.targetConfig) ||
233
+ !phases.includes(candidate.phase)) {
234
+ return { error: `Invalid or foreign migration apply journal at ${journalPath}.` };
235
+ }
236
+ journal = candidate;
237
+ }
238
+ catch (error) {
239
+ return {
240
+ error: `Unreadable migration apply journal at ${journalPath}: ${error instanceof Error ? error.message : String(error)}`,
241
+ };
242
+ }
243
+ try {
244
+ const backupStat = fs.lstatSync(journal.backupPath);
245
+ if (backupStat.isSymbolicLink() ||
246
+ !backupStat.isDirectory() ||
247
+ fs.realpathSync(path.dirname(journal.backupPath)) !== fs.realpathSync(getMigrationBackupRoot())) {
248
+ throw new ConfigError(`Migration apply journal backup is not a canonical installation run directory.`, "INVALID_CONFIG_FILE");
249
+ }
250
+ const config = parseAndValidateConfigText(JSON.stringify(journal.targetConfig), journalPath);
251
+ const manifest = verifyMigrationBackup(journal.backupPath);
252
+ if (manifest.runId !== journal.backupRunId || manifest.installationId !== journal.installationId) {
253
+ throw new ConfigError(`Migration apply journal backup provenance does not match its manifest.`, "INVALID_CONFIG_FILE");
254
+ }
255
+ validateApplyPhase(journal, manifest);
256
+ if (!sameMigrationGeneration(fingerprintMigrationGeneration(), journal.generation)) {
257
+ const adjacent = detectAdjacentGeneration(journal, manifest);
258
+ if (adjacent.adjacent || adjacent.rollbackCompleted)
259
+ return { journal, config, ...adjacent };
260
+ throw new ConfigError(`Migration apply journal phase ${journal.phase} does not match the exact live artifact generation.`, "INVALID_CONFIG_FILE");
261
+ }
262
+ return { journal, config };
263
+ }
264
+ catch (error) {
265
+ return {
266
+ journal,
267
+ error: `Unreadable migration apply journal at ${journalPath}: ${error instanceof Error ? error.message : String(error)}`,
268
+ };
269
+ }
270
+ }
271
+ function writeApplyJournal(journal) {
272
+ fs.mkdirSync(path.dirname(getMigrationApplyJournalPath()), { recursive: true, mode: 0o700 });
273
+ const serialized = `${JSON.stringify(journal, null, 2)}\n`;
274
+ const byteLength = Buffer.byteLength(serialized, "utf8");
275
+ if (byteLength > MAX_LOCAL_METADATA_BYTES) {
276
+ throw new ConfigError(`Migration apply journal would exceed the ${MAX_LOCAL_METADATA_BYTES}-byte metadata limit (${byteLength} bytes).`, "INVALID_CONFIG_FILE");
277
+ }
278
+ writeFileAtomic(getMigrationApplyJournalPath(), serialized, 0o600);
279
+ }
280
+ function advanceApplyJournal(journal, phase) {
281
+ const order = ["prepared", "state-applied", "workflow-applied", "config-applied", "committed"];
282
+ if (order.indexOf(phase) > order.indexOf(journal.phase))
283
+ journal.phase = phase;
284
+ journal.generation = fingerprintMigrationGeneration();
285
+ writeApplyJournal(journal);
286
+ }
287
+ function clearApplyJournal() {
288
+ fs.rmSync(getMigrationApplyJournalPath(), { force: true });
289
+ try {
290
+ const fd = fs.openSync(path.dirname(getMigrationApplyJournalPath()), "r");
291
+ try {
292
+ fs.fsyncSync(fd);
293
+ }
294
+ finally {
295
+ fs.closeSync(fd);
72
296
  }
73
- raw = parsed;
74
297
  }
75
298
  catch {
76
- warn(`[akm] config-migrate: failed to parse ${filePath}, skipping.`);
77
- return { changed: false, result: {} };
78
- }
79
- const { changed, result } = migrateConfigShape(raw);
80
- if (!changed) {
81
- return { changed: false, result };
82
- }
83
- const migratedText = `${JSON.stringify(result, null, 2)}\n`;
84
- // WS-2: compute a diff when requested (always computed for --print-diff;
85
- // never requires a write so safe in --dry-run).
86
- const diff = opts.printDiff ? unifiedDiff(text, migratedText, filePath) : undefined;
87
- if (opts.dryRun) {
88
- return { changed: true, result, diff };
89
- }
90
- // WS-3: acquire config write lock + use atomic write (tmp → rename).
91
- withConfigLock(() => {
92
- backupConfigFile(filePath);
93
- writeConfigAtomic(filePath, result);
94
- });
95
- return { changed: true, result, diff };
96
- }
97
- function discoverProjectConfigPaths(startDir) {
98
- const paths = [];
99
- let currentDir = path.resolve(startDir);
100
- while (true) {
101
- const configPath = path.join(currentDir, PROJECT_CONFIG_RELATIVE_PATH);
102
- if (fs.existsSync(configPath) && fs.statSync(configPath).isFile()) {
103
- paths.unshift(configPath);
299
+ // Directory fsync is not available on every supported filesystem.
300
+ }
301
+ }
302
+ function crashAfterForTests(phase) {
303
+ if (process.env.AKM_TEST_MIGRATION_CRASH_AFTER === phase)
304
+ process.kill(process.pid, "SIGKILL");
305
+ }
306
+ function crashInMutationGapForTests(phase) {
307
+ if (process.env.AKM_TEST_MIGRATION_CRASH_GAP === phase)
308
+ process.kill(process.pid, "SIGKILL");
309
+ }
310
+ function unsafeArtifact(name, state) {
311
+ if (!["newer", "inconsistent", "corrupt"].includes(state.status))
312
+ return undefined;
313
+ return `${name} is ${state.status}${state.detail ? `: ${state.detail}` : ""}`;
314
+ }
315
+ function loadTargetConfig(preparedConfigPath, artifacts) {
316
+ const targetPath = preparedConfigPath ?? (artifacts.config.status === "current" ? getConfigPath() : undefined);
317
+ if (!targetPath) {
318
+ return {
319
+ state: { status: "missing", source: "none", detail: MANUAL_GUIDANCE },
320
+ };
321
+ }
322
+ let text;
323
+ try {
324
+ text = readTextFileWithLimit(targetPath, MAX_CONFIG_FILE_BYTES, "Prepared migration config");
325
+ }
326
+ catch (error) {
327
+ return {
328
+ state: {
329
+ status: "corrupt",
330
+ source: preparedConfigPath ? "prepared" : "active",
331
+ path: targetPath,
332
+ detail: error instanceof Error ? error.message : String(error),
333
+ },
334
+ };
335
+ }
336
+ try {
337
+ return {
338
+ state: {
339
+ status: "current",
340
+ source: preparedConfigPath ? "prepared" : "active",
341
+ path: targetPath,
342
+ },
343
+ config: parseAndValidateConfigText(text, targetPath),
344
+ };
345
+ }
346
+ catch (error) {
347
+ return {
348
+ state: {
349
+ status: "corrupt",
350
+ source: preparedConfigPath ? "prepared" : "active",
351
+ path: targetPath,
352
+ detail: error instanceof Error ? error.message : String(error),
353
+ },
354
+ };
355
+ }
356
+ }
357
+ function buildMigrationPlan(preparedConfigPath, activeApply) {
358
+ const artifacts = inspectMigrationState();
359
+ const restorePending = fs.existsSync(getMigrationRestoreJournalPath());
360
+ const target = activeApply.journal
361
+ ? {
362
+ state: {
363
+ status: activeApply.config ? "current" : "corrupt",
364
+ source: "prepared",
365
+ path: getMigrationApplyJournalPath(),
366
+ ...(!activeApply.config && activeApply.error ? { detail: activeApply.error } : {}),
367
+ },
368
+ config: activeApply.config,
104
369
  }
105
- const parentDir = path.dirname(currentDir);
106
- if (parentDir === currentDir)
107
- break;
108
- currentDir = parentDir;
109
- }
110
- return paths;
111
- }
112
- export async function runConfigMigrate(opts) {
113
- const userConfigPath = getConfigPath();
114
- const projectPaths = discoverProjectConfigPaths(process.cwd());
115
- const allPaths = [userConfigPath, ...projectPaths].filter((p, i, arr) => arr.indexOf(p) === i && fs.existsSync(p));
116
- if (allPaths.length === 0) {
117
- console.log("No config files found to migrate.");
118
- return;
370
+ : loadTargetConfig(preparedConfigPath, artifacts);
371
+ const blockers = [
372
+ unsafeArtifact("config.json", artifacts.config),
373
+ unsafeArtifact("state.db", artifacts.state),
374
+ unsafeArtifact("workflow.db", artifacts.workflow),
375
+ ].filter((blocker) => blocker !== undefined);
376
+ if (target.state.status !== "current")
377
+ blockers.push(target.state.detail ?? "A current target config is required.");
378
+ if (activeApply.error && !activeApply.journal)
379
+ blockers.push(activeApply.error);
380
+ if (restorePending)
381
+ blockers.push(`Restore recovery is pending at ${getMigrationRestoreJournalPath()}.`);
382
+ const needsApply = !!activeApply.journal ||
383
+ artifacts.config.status !== "current" ||
384
+ artifacts.state.status === "old" ||
385
+ artifacts.workflow.status === "old";
386
+ return {
387
+ status: blockers.length > 0 ? "blocked" : needsApply ? "ready" : "current",
388
+ artifacts,
389
+ targetConfig: target.state,
390
+ blockers,
391
+ ...(restorePending
392
+ ? {
393
+ activeOperation: {
394
+ kind: "restore",
395
+ phase: "pending",
396
+ journalPath: getMigrationRestoreJournalPath(),
397
+ },
398
+ }
399
+ : activeApply.journal
400
+ ? {
401
+ activeOperation: {
402
+ kind: "apply",
403
+ phase: activeApply.journal.phase,
404
+ journalPath: getMigrationApplyJournalPath(),
405
+ },
406
+ }
407
+ : {}),
408
+ };
409
+ }
410
+ export function inspectMigrationPlan(preparedConfigPath) {
411
+ return buildMigrationPlan(preparedConfigPath, readApplyJournal());
412
+ }
413
+ function printPlan(plan) {
414
+ console.log(JSON.stringify(plan));
415
+ if (plan.status === "blocked")
416
+ process.exitCode = EXIT_CODES.GENERAL;
417
+ }
418
+ export async function runMigrationStatus(options = {}) {
419
+ printPlan(inspectMigrationPlan(options.preparedConfigPath));
420
+ }
421
+ function requireEligiblePlan(preparedConfigPath, active = readApplyJournal()) {
422
+ const plan = buildMigrationPlan(preparedConfigPath, active);
423
+ const loaded = active.journal ? { config: active.config } : loadTargetConfig(preparedConfigPath, plan.artifacts);
424
+ if (plan.status === "blocked" || !loaded.config) {
425
+ throw new ConfigError(`Migration is blocked: ${plan.blockers.join("; ")}`, "INVALID_CONFIG_FILE");
119
426
  }
120
- const lockPath = path.join(getCacheDir(), "config-migrate.lock");
121
- const releaseLock = acquireMigrateLock(lockPath, opts.noWait ?? false);
122
- if (!releaseLock) {
123
- warn("[akm] config-migrate: another migration is already in progress, skipping.");
427
+ return { plan, target: loaded.config };
428
+ }
429
+ export async function runMigrationApply(options = {}) {
430
+ if (options.dryRun) {
431
+ printPlan(inspectMigrationPlan(options.preparedConfigPath));
124
432
  return;
125
433
  }
126
- try {
127
- let anyChanged = false;
128
- for (const filePath of allPaths) {
129
- const { changed, diff } = await migrateConfigFile(filePath, { dryRun: opts.dryRun, printDiff: opts.printDiff });
130
- if (changed) {
131
- const action = opts.dryRun ? "would migrate" : "migrated";
132
- console.log(`[akm] ${action}: ${filePath}`);
133
- // WS-2: print the unified diff to stdout when --print-diff is set.
134
- if (diff) {
135
- console.log(diff);
434
+ const result = withConfigLock(() => withMaintenanceStartBarrier(() => {
435
+ assertNoArtifactReplacementBlockers();
436
+ recoverInterruptedRestoreWithLocksHeld();
437
+ const active = readApplyJournal();
438
+ if (active.error)
439
+ throw new ConfigError(active.error, "INVALID_CONFIG_FILE");
440
+ if (active.rollbackCompleted && active.journal) {
441
+ clearApplyJournal();
442
+ resetConfigCache();
443
+ throw new ConfigError("Interrupted migration rollback was already committed; cleaned its apply journal. Rerun migrate apply with the prepared config.", "INVALID_CONFIG_FILE");
444
+ }
445
+ if (active.journal?.phase === "rollback-prepared") {
446
+ restoreMigrationBackupWithLocksHeld(active.journal.backupPath);
447
+ crashInMutationGapForTests("rollback");
448
+ clearApplyJournal();
449
+ resetConfigCache();
450
+ throw new ConfigError("Interrupted migration rollback completed from its exact journaled generation; rerun migrate apply with the prepared config.", "INVALID_CONFIG_FILE");
451
+ }
452
+ if (active.adjacent && active.journal) {
453
+ active.journal.generation = active.adjacent.generation;
454
+ if (active.adjacent.complete)
455
+ active.journal.phase = active.adjacent.phase;
456
+ writeApplyJournal(active.journal);
457
+ }
458
+ const { plan, target } = requireEligiblePlan(options.preparedConfigPath, active);
459
+ if (plan.status === "current")
460
+ return { plan };
461
+ const backup = active.journal
462
+ ? { path: active.journal.backupPath, manifest: verifyMigrationBackup(active.journal.backupPath) }
463
+ : ensureMigrationBackupWithConfigLockHeld();
464
+ const journal = active.journal ?? {
465
+ formatVersion: 2,
466
+ version: MIGRATION_BACKUP_VERSION,
467
+ operationId: `${process.pid}-${randomUUID()}`,
468
+ installationId: backup.manifest.installationId,
469
+ backupRunId: backup.manifest.runId,
470
+ phase: "prepared",
471
+ backupPath: backup.path,
472
+ targetConfig: sanitizeConfigForWrite(target),
473
+ generation: fingerprintMigrationGeneration(),
474
+ };
475
+ if (!active.journal)
476
+ writeApplyJournal(journal);
477
+ try {
478
+ const beforeState = inspectMigrationState();
479
+ if (beforeState.state.status === "old") {
480
+ const db = openDatabase(getStateDbPathInDataDir());
481
+ try {
482
+ runStateMigrations(db, {
483
+ generationMarker: { operationId: journal.operationId, phase: "state-applied" },
484
+ });
485
+ }
486
+ finally {
487
+ db.close();
488
+ }
489
+ }
490
+ else if (beforeState.state.status !== "current" && beforeState.state.status !== "missing") {
491
+ throw new ConfigError(`Cannot resume state.db from ${beforeState.state.status} state.`, "INVALID_CONFIG_FILE");
492
+ }
493
+ crashInMutationGapForTests("state");
494
+ advanceApplyJournal(journal, "state-applied");
495
+ crashAfterForTests("state");
496
+ const beforeWorkflow = inspectMigrationState();
497
+ if (beforeWorkflow.workflow.status === "old") {
498
+ const db = openDatabase(getWorkflowDbPath());
499
+ try {
500
+ runWorkflowMigrations(db, {
501
+ generationMarker: { operationId: journal.operationId, phase: "workflow-applied" },
502
+ });
503
+ }
504
+ finally {
505
+ db.close();
136
506
  }
137
- anyChanged = true;
138
507
  }
139
- else {
140
- console.log(`[akm] already up to date: ${filePath}`);
508
+ else if (beforeWorkflow.workflow.status !== "current" && beforeWorkflow.workflow.status !== "missing") {
509
+ throw new ConfigError(`Cannot resume workflow.db from ${beforeWorkflow.workflow.status} state.`, "INVALID_CONFIG_FILE");
141
510
  }
511
+ crashInMutationGapForTests("workflow");
512
+ advanceApplyJournal(journal, "workflow-applied");
513
+ crashAfterForTests("workflow");
514
+ const migrated = inspectMigrationState();
515
+ for (const [name, state] of [
516
+ ["state.db", migrated.state],
517
+ ["workflow.db", migrated.workflow],
518
+ ]) {
519
+ if (state.status !== "current" && state.status !== "missing") {
520
+ throw new ConfigError(`Migration left ${name} in ${state.status} state.`, "INVALID_CONFIG_FILE");
521
+ }
522
+ }
523
+ backupExistingConfig(getConfigPath());
524
+ writeConfigAtomic(getConfigPath(), sanitizeConfigForWrite(target));
525
+ resetConfigCache();
526
+ crashInMutationGapForTests("config");
527
+ advanceApplyJournal(journal, "config-applied");
528
+ crashAfterForTests("config");
529
+ const completedArtifacts = inspectMigrationState();
530
+ if (completedArtifacts.config.status !== "current" ||
531
+ ![completedArtifacts.state.status, completedArtifacts.workflow.status].every((status) => status === "current" || status === "missing")) {
532
+ throw new ConfigError("Migration verification did not reach one current cross-artifact generation.", "INVALID_CONFIG_FILE");
533
+ }
534
+ advanceApplyJournal(journal, "committed");
535
+ clearApplyJournal();
536
+ const completed = inspectMigrationPlan();
537
+ return { plan: completed, backup };
142
538
  }
143
- if (!anyChanged) {
144
- console.log("All config files are already at the current version.");
539
+ catch (error) {
540
+ try {
541
+ const rollbackGeneration = fingerprintMigrationGeneration();
542
+ assertRollbackTransitionAllowed(journal, rollbackGeneration);
543
+ journal.phase = "rollback-prepared";
544
+ journal.generation = rollbackGeneration;
545
+ writeApplyJournal(journal);
546
+ if (!sameMigrationGeneration(fingerprintMigrationGeneration(), journal.generation)) {
547
+ throw new ConfigError(`Refusing migration rollback because live artifacts no longer match journal phase ${journal.phase}.`, "INVALID_CONFIG_FILE");
548
+ }
549
+ restoreMigrationBackupWithLocksHeld(backup.path);
550
+ crashInMutationGapForTests("rollback");
551
+ clearApplyJournal();
552
+ resetConfigCache();
553
+ }
554
+ catch (rollbackError) {
555
+ 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");
556
+ }
557
+ throw new ConfigError(`Migration apply failed; config and databases were restored from ${backup.path}: ${error instanceof Error ? error.message : String(error)}`, "INVALID_CONFIG_FILE");
145
558
  }
146
- }
147
- finally {
148
- releaseLock();
149
- }
559
+ }));
560
+ console.log(JSON.stringify({
561
+ ...result.plan,
562
+ status: result.plan.status === "blocked" ? "blocked" : "current",
563
+ ...(result.backup ? { backupPath: result.backup.path, backupRunId: result.backup.manifest.runId } : {}),
564
+ }));
565
+ }
566
+ /** Backward-compatible config subcommand routed through the canonical coordinator. */
567
+ export async function runConfigMigrate(options = {}) {
568
+ if (options.dryRun || !options.preparedConfigPath)
569
+ return runMigrationStatus(options);
570
+ return runMigrationApply(options);
150
571
  }