akm-cli 0.9.0-rc.2 → 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 (88) hide show
  1. package/CHANGELOG.md +34 -8
  2. package/dist/assets/improve-strategies/catchup.json +3 -1
  3. package/dist/assets/improve-strategies/consolidate.json +3 -1
  4. package/dist/assets/improve-strategies/frequent.json +3 -1
  5. package/dist/assets/improve-strategies/graph-refresh.json +3 -1
  6. package/dist/assets/improve-strategies/memory-focus.json +4 -1
  7. package/dist/assets/improve-strategies/proactive-maintenance.json +1 -0
  8. package/dist/assets/improve-strategies/quick.json +3 -1
  9. package/dist/assets/improve-strategies/recombine-only.json +2 -0
  10. package/dist/assets/improve-strategies/reflect-distill.json +1 -0
  11. package/dist/assets/improve-strategies/synthesize.json +2 -0
  12. package/dist/assets/tasks/core/backup.yml +2 -2
  13. package/dist/cli/config-migrate.js +554 -26
  14. package/dist/cli.js +3 -1
  15. package/dist/commands/backup-cli.js +6 -4
  16. package/dist/commands/config-cli.js +10 -3
  17. package/dist/commands/feedback-cli.js +24 -7
  18. package/dist/commands/health/checks.js +94 -15
  19. package/dist/commands/health/surfaces.js +2 -1
  20. package/dist/commands/improve/anti-collapse.js +3 -3
  21. package/dist/commands/improve/collapse-detector.js +5 -5
  22. package/dist/commands/improve/consolidate.js +123 -66
  23. package/dist/commands/improve/distill/promote-memory.js +8 -6
  24. package/dist/commands/improve/distill/quality-gate.js +1 -1
  25. package/dist/commands/improve/distill-guards.js +1 -1
  26. package/dist/commands/improve/distill-promotion-policy.js +32 -26
  27. package/dist/commands/improve/distill.js +52 -36
  28. package/dist/commands/improve/eligibility.js +9 -8
  29. package/dist/commands/improve/extract-prompt.js +2 -2
  30. package/dist/commands/improve/extract.js +43 -11
  31. package/dist/commands/improve/improve-auto-accept.js +14 -28
  32. package/dist/commands/improve/improve-cli.js +4 -2
  33. package/dist/commands/improve/improve-strategies.js +2 -1
  34. package/dist/commands/improve/improve.js +49 -8
  35. package/dist/commands/improve/loop-stages.js +11 -6
  36. package/dist/commands/improve/preparation.js +51 -17
  37. package/dist/commands/improve/procedural.js +10 -6
  38. package/dist/commands/improve/recombine.js +31 -7
  39. package/dist/commands/improve/reflect.js +24 -14
  40. package/dist/commands/improve/salience.js +5 -1
  41. package/dist/commands/improve/source-identity.js +43 -0
  42. package/dist/commands/migrate-cli.js +36 -0
  43. package/dist/commands/mv-cli.js +647 -258
  44. package/dist/commands/proposal/drain.js +16 -4
  45. package/dist/commands/proposal/proposal-cli.js +3 -2
  46. package/dist/commands/proposal/proposal.js +16 -55
  47. package/dist/commands/proposal/repository.js +664 -58
  48. package/dist/commands/read/curate.js +4 -2
  49. package/dist/commands/read/knowledge.js +4 -1
  50. package/dist/commands/read/search.js +6 -2
  51. package/dist/commands/read/show.js +8 -7
  52. package/dist/commands/sources/self-update.js +156 -112
  53. package/dist/commands/sources/sources-cli.js +7 -2
  54. package/dist/core/common.js +36 -3
  55. package/dist/core/config/config-io.js +2 -2
  56. package/dist/core/config/config-schema.js +15 -3
  57. package/dist/core/config/config.js +14 -13
  58. package/dist/core/file-lock.js +2 -2
  59. package/dist/core/migration-backup.js +816 -171
  60. package/dist/core/migration-operation.js +44 -0
  61. package/dist/core/state/migrations.js +4 -7
  62. package/dist/core/state-db.js +22 -7
  63. package/dist/core/write-source.js +86 -18
  64. package/dist/indexer/db/db.js +109 -53
  65. package/dist/indexer/index-writer-lock.js +38 -37
  66. package/dist/indexer/index-written-assets.js +73 -56
  67. package/dist/indexer/indexer.js +5 -1
  68. package/dist/indexer/search/search-source.js +2 -2
  69. package/dist/indexer/usage/usage-events.js +8 -2
  70. package/dist/integrations/agent/engine-resolution.js +14 -7
  71. package/dist/scripts/migrate-storage.js +867 -990
  72. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +405 -566
  73. package/dist/setup/detected-engines.js +7 -13
  74. package/dist/setup/engine-config.js +14 -4
  75. package/dist/setup/setup.js +1 -1
  76. package/dist/setup/steps/connection.js +1 -1
  77. package/dist/setup/steps/tasks.js +1 -1
  78. package/dist/sources/providers/git-stash.js +58 -21
  79. package/dist/sources/providers/git.js +1 -1
  80. package/dist/storage/engines/sqlite-migrations.js +138 -3
  81. package/dist/storage/repositories/events-repository.js +24 -0
  82. package/dist/storage/repositories/proposals-repository.js +14 -0
  83. package/dist/tasks/embedded.js +2 -0
  84. package/dist/workflows/db.js +24 -13
  85. package/dist/workflows/validator.js +24 -2
  86. package/docs/migration/release-notes/0.9.0.md +24 -7
  87. package/docs/migration/v0.8-to-v0.9.md +124 -19
  88. package/package.json +1 -1
@@ -1,43 +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 { parseConfigText, readConfigText } from "../core/config/config-io.js";
5
- import { CURRENT_CONFIG_VERSION, validateConfigShape } from "../core/config/config-schema.js";
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import fs from "node:fs";
6
+ import path from "node:path";
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";
6
10
  import { ConfigError } from "../core/errors.js";
7
- import { getConfigPath } from "../core/paths.js";
8
- const MANUAL_GUIDANCE = "AKM 0.9 does not translate profile-based configuration. Recreate named engines, defaults.engine/defaults.llmEngine, and improve.strategies manually before retrying.";
9
- /**
10
- * Diagnose the user config without loading runtime configuration or mutating
11
- * disk. Profile-to-engine conversion is intentionally ambiguous and never
12
- * happens automatically.
13
- */
14
- export async function runConfigMigrate() {
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;
65
+ }
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");
140
+ }
141
+ }
142
+ }
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) {
15
149
  const configPath = getConfigPath();
16
- let text;
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");
184
+ }
185
+ }
186
+ function readApplyJournal() {
187
+ const journalPath = getMigrationApplyJournalPath();
188
+ if (!fs.existsSync(journalPath))
189
+ return {};
190
+ let journal;
17
191
  try {
18
- text = readConfigText(configPath);
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;
19
237
  }
20
238
  catch (error) {
21
- throw new ConfigError(`Could not read config at ${configPath}: ${error instanceof Error ? error.message : String(error)}`, "INVALID_CONFIG_FILE");
239
+ return {
240
+ error: `Unreadable migration apply journal at ${journalPath}: ${error instanceof Error ? error.message : String(error)}`,
241
+ };
22
242
  }
23
- if (text === undefined) {
24
- console.log(JSON.stringify({ status: "absent", path: configPath }));
25
- return;
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
+ };
26
269
  }
27
- let raw;
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);
296
+ }
297
+ }
298
+ catch {
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;
28
323
  try {
29
- raw = parseConfigText(text, configPath);
324
+ text = readTextFileWithLimit(targetPath, MAX_CONFIG_FILE_BYTES, "Prepared migration config");
30
325
  }
31
326
  catch (error) {
32
- throw new ConfigError(`Invalid config file at ${configPath}: ${error instanceof Error ? error.message : String(error)}`, "INVALID_CONFIG_FILE");
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
+ };
33
335
  }
34
- const version = raw.configVersion;
35
- if (version !== CURRENT_CONFIG_VERSION) {
36
- throw new ConfigError(`Unsupported configVersion at ${configPath}: expected "${CURRENT_CONFIG_VERSION}".`, "UNSUPPORTED_CONFIG_VERSION", MANUAL_GUIDANCE);
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,
369
+ }
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");
37
426
  }
38
- const result = validateConfigShape(raw);
39
- if (!result.ok) {
40
- throw new ConfigError(`Invalid config at ${configPath}: ${result.errors.map((issue) => `${issue.path}: ${issue.message}`).join("; ")}`, "INVALID_CONFIG_FILE");
427
+ return { plan, target: loaded.config };
428
+ }
429
+ export async function runMigrationApply(options = {}) {
430
+ if (options.dryRun) {
431
+ printPlan(inspectMigrationPlan(options.preparedConfigPath));
432
+ return;
41
433
  }
42
- console.log(JSON.stringify({ status: "current", path: configPath }));
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();
506
+ }
507
+ }
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");
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 };
538
+ }
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");
558
+ }
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);
43
571
  }
package/dist/cli.js CHANGED
@@ -75,6 +75,7 @@ import { renderRunsDetailMd, renderWindowCompareMd } from "./commands/health/md-
75
75
  import { parseWindowSpec } from "./commands/health/windows.js";
76
76
  import { extractCommand } from "./commands/improve/extract-cli.js";
77
77
  import { improveCommand } from "./commands/improve/improve-cli.js";
78
+ import { migrateCommand } from "./commands/migrate-cli.js";
78
79
  import { mvCommand } from "./commands/mv-cli.js";
79
80
  import { hintsCommand, lessonsCommand, logCommand } from "./commands/observability-cli.js";
80
81
  import { proposalCommand } from "./commands/proposal/proposal-cli.js";
@@ -517,6 +518,7 @@ export const main = defineCommand({
517
518
  clone: cloneCommand,
518
519
  mv: mvCommand,
519
520
  registry: registryCommand,
521
+ migrate: migrateCommand,
520
522
  backup: backupCommand,
521
523
  config: configCommand,
522
524
  feedback: feedbackCommand,
@@ -545,7 +547,7 @@ export function shouldBypassConfigStartup(argv) {
545
547
  if (args.includes("--help") || args.includes("-h") || args.includes("--version") || args.includes("-v"))
546
548
  return true;
547
549
  const command = findCittyTopLevelCommand([...args], MAIN_TOP_LEVEL_ARGS);
548
- if (command === "setup" || command === "backup")
550
+ if (command === "setup" || command === "backup" || command === "migrate")
549
551
  return true;
550
552
  if (command !== "config")
551
553
  return false;
@@ -10,10 +10,10 @@ function requireVersion(value) {
10
10
  }
11
11
  }
12
12
  export const backupCommand = defineGroupCommand({
13
- meta: { name: "backup", description: "Create or restore a versioned migration recovery bundle" },
13
+ meta: { name: "backup", description: "Create or restore a verified migration recovery run" },
14
14
  subCommands: {
15
15
  create: defineJsonCommand({
16
- meta: { name: "create", description: "Create the immutable pre-0.9 migration recovery bundle" },
16
+ meta: { name: "create", description: "Create a unique installation-scoped migration recovery run" },
17
17
  args: {
18
18
  for: { type: "string", required: true, description: "Migration target version (0.9.0)" },
19
19
  },
@@ -30,19 +30,21 @@ export const backupCommand = defineGroupCommand({
30
30
  },
31
31
  }),
32
32
  restore: defineJsonCommand({
33
- meta: { name: "restore", description: "Restore the pre-0.9 config and durable databases" },
33
+ meta: { name: "restore", description: "Restore a recovery run after preserving a rescue snapshot" },
34
34
  args: {
35
35
  for: { type: "string", required: true, description: "Migration target version (0.9.0)" },
36
+ run: { type: "string", description: "Backup run ID (defaults to the newest applicable run)" },
36
37
  confirm: { type: "boolean", default: false, description: "Confirm destructive restoration" },
37
38
  },
38
39
  run({ args }) {
39
40
  requireVersion(args.for);
40
- const result = restoreMigrationBackup(args.confirm);
41
+ const result = restoreMigrationBackup(args.confirm, args.run);
41
42
  output("backup", {
42
43
  action: "restore",
43
44
  for: MIGRATION_BACKUP_VERSION,
44
45
  path: result.path,
45
46
  restored: true,
47
+ rescuePath: result.rescuePath,
46
48
  manifest: result.manifest,
47
49
  });
48
50
  },