akm-cli 0.9.0-rc.2 → 0.9.0-rc.4

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 (115) hide show
  1. package/CHANGELOG.md +40 -12
  2. package/README.md +7 -8
  3. package/SECURITY.md +5 -3
  4. package/dist/akm +44 -33
  5. package/dist/akm-migrate-storage +44 -33
  6. package/dist/assets/backends/schtasks-template.xml +2 -1
  7. package/dist/assets/improve-strategies/catchup.json +3 -1
  8. package/dist/assets/improve-strategies/consolidate.json +3 -1
  9. package/dist/assets/improve-strategies/frequent.json +3 -1
  10. package/dist/assets/improve-strategies/graph-refresh.json +3 -1
  11. package/dist/assets/improve-strategies/memory-focus.json +4 -1
  12. package/dist/assets/improve-strategies/proactive-maintenance.json +1 -0
  13. package/dist/assets/improve-strategies/quick.json +3 -1
  14. package/dist/assets/improve-strategies/recombine-only.json +2 -0
  15. package/dist/assets/improve-strategies/reflect-distill.json +1 -0
  16. package/dist/assets/improve-strategies/synthesize.json +2 -0
  17. package/dist/assets/tasks/core/backup.yml +2 -2
  18. package/dist/cli/config-migrate.js +554 -26
  19. package/dist/cli.js +28 -11
  20. package/dist/commands/backup-cli.js +6 -4
  21. package/dist/commands/config-cli.js +10 -3
  22. package/dist/commands/feedback-cli.js +24 -7
  23. package/dist/commands/health/checks.js +94 -15
  24. package/dist/commands/health/html-report.js +23 -2
  25. package/dist/commands/health/improve-metrics.js +45 -6
  26. package/dist/commands/health/md-report.js +10 -1
  27. package/dist/commands/health/surfaces.js +2 -1
  28. package/dist/commands/health/windows.js +1 -1
  29. package/dist/commands/health.js +1 -1
  30. package/dist/commands/improve/anti-collapse.js +3 -3
  31. package/dist/commands/improve/collapse-detector.js +5 -5
  32. package/dist/commands/improve/consolidate.js +123 -66
  33. package/dist/commands/improve/distill/promote-memory.js +8 -6
  34. package/dist/commands/improve/distill/quality-gate.js +1 -1
  35. package/dist/commands/improve/distill-guards.js +1 -1
  36. package/dist/commands/improve/distill-promotion-policy.js +32 -26
  37. package/dist/commands/improve/distill.js +52 -36
  38. package/dist/commands/improve/eligibility.js +9 -8
  39. package/dist/commands/improve/extract-prompt.js +2 -2
  40. package/dist/commands/improve/extract.js +43 -11
  41. package/dist/commands/improve/improve-auto-accept.js +14 -28
  42. package/dist/commands/improve/improve-cli.js +4 -2
  43. package/dist/commands/improve/improve-strategies.js +2 -1
  44. package/dist/commands/improve/improve.js +49 -8
  45. package/dist/commands/improve/loop-stages.js +11 -6
  46. package/dist/commands/improve/preparation.js +51 -17
  47. package/dist/commands/improve/procedural.js +10 -6
  48. package/dist/commands/improve/recombine.js +31 -7
  49. package/dist/commands/improve/reflect.js +24 -14
  50. package/dist/commands/improve/salience.js +5 -1
  51. package/dist/commands/improve/source-identity.js +43 -0
  52. package/dist/commands/migrate-cli.js +36 -0
  53. package/dist/commands/mv-cli.js +647 -258
  54. package/dist/commands/proposal/drain.js +16 -4
  55. package/dist/commands/proposal/proposal-cli.js +3 -2
  56. package/dist/commands/proposal/proposal.js +16 -55
  57. package/dist/commands/proposal/repository.js +664 -58
  58. package/dist/commands/read/curate.js +4 -2
  59. package/dist/commands/read/knowledge.js +4 -1
  60. package/dist/commands/read/search.js +6 -2
  61. package/dist/commands/read/show.js +8 -7
  62. package/dist/commands/sources/self-update.js +156 -112
  63. package/dist/commands/sources/sources-cli.js +7 -2
  64. package/dist/commands/tasks/default-tasks.js +5 -5
  65. package/dist/commands/tasks/tasks-cli.js +7 -3
  66. package/dist/commands/tasks/tasks.js +261 -68
  67. package/dist/core/common.js +36 -3
  68. package/dist/core/config/config-io.js +2 -2
  69. package/dist/core/config/config-schema.js +15 -3
  70. package/dist/core/config/config.js +14 -13
  71. package/dist/core/file-lock.js +2 -2
  72. package/dist/core/improve-result.js +87 -7
  73. package/dist/core/migration-backup.js +816 -171
  74. package/dist/core/migration-operation.js +44 -0
  75. package/dist/core/state/migrations.js +4 -7
  76. package/dist/core/state-db.js +23 -7
  77. package/dist/core/write-source.js +86 -18
  78. package/dist/indexer/db/db.js +109 -53
  79. package/dist/indexer/index-writer-lock.js +38 -37
  80. package/dist/indexer/index-written-assets.js +73 -56
  81. package/dist/indexer/indexer.js +5 -1
  82. package/dist/indexer/search/search-source.js +2 -2
  83. package/dist/indexer/usage/usage-events.js +8 -2
  84. package/dist/integrations/agent/engine-resolution.js +14 -7
  85. package/dist/scripts/migrate-storage.js +887 -992
  86. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +494 -574
  87. package/dist/setup/detected-engines.js +7 -13
  88. package/dist/setup/engine-config.js +14 -4
  89. package/dist/setup/setup.js +1 -1
  90. package/dist/setup/steps/connection.js +1 -1
  91. package/dist/setup/steps/tasks.js +1 -1
  92. package/dist/sources/providers/git-stash.js +58 -21
  93. package/dist/sources/providers/git.js +1 -1
  94. package/dist/storage/engines/sqlite-migrations.js +138 -3
  95. package/dist/storage/repositories/events-repository.js +24 -0
  96. package/dist/storage/repositories/proposals-repository.js +14 -0
  97. package/dist/storage/repositories/task-history-repository.js +30 -3
  98. package/dist/tasks/backends/cron.js +71 -38
  99. package/dist/tasks/backends/exec-utils.js +55 -3
  100. package/dist/tasks/backends/launchd.js +241 -50
  101. package/dist/tasks/backends/schtasks.js +434 -59
  102. package/dist/tasks/command-executable.js +93 -0
  103. package/dist/tasks/embedded.js +2 -0
  104. package/dist/tasks/parser.js +142 -6
  105. package/dist/tasks/resolve-akm-bin.js +19 -4
  106. package/dist/tasks/runner.js +258 -93
  107. package/dist/tasks/schedule.js +108 -19
  108. package/dist/tasks/scheduler-invocation.js +86 -0
  109. package/dist/tasks/task-id.js +37 -0
  110. package/dist/workflows/db.js +24 -13
  111. package/dist/workflows/validator.js +24 -2
  112. package/docs/README.md +103 -0
  113. package/docs/migration/release-notes/0.9.0.md +28 -9
  114. package/docs/migration/v0.8-to-v0.9.md +186 -26
  115. package/package.json +2 -3
@@ -5,20 +5,44 @@ import crypto from "node:crypto";
5
5
  import fs from "node:fs";
6
6
  import path from "node:path";
7
7
  import { openDatabase } from "../storage/database.js";
8
- import { writeFileAtomic } from "./common.js";
8
+ import { inspectMigrationLedger, } from "../storage/engines/sqlite-migrations.js";
9
+ import { WORKFLOW_MIGRATIONS } from "../workflows/db.js";
10
+ import { MAX_CONFIG_FILE_BYTES, MAX_LOCAL_METADATA_BYTES, readTextFileWithLimit, writeFileAtomic } from "./common.js";
9
11
  import { parseConfigText, withConfigLock } from "./config/config-io.js";
10
- import { CURRENT_CONFIG_VERSION } from "./config/config-schema.js";
12
+ import { CURRENT_CONFIG_VERSION, validateConfigShape } from "./config/config-schema.js";
13
+ import { compareConfigVersion } from "./config/config-version.js";
11
14
  import { ConfigError } from "./errors.js";
12
15
  import { createLockPayload, probeLock, reclaimStaleLock, releaseLock, tryAcquireLockSync } from "./file-lock.js";
13
16
  import { acquireMaintenanceActivitySync, withMaintenanceStartBarrier } from "./maintenance-barrier.js";
14
- import { getCacheDir, getConfigPath, getDataDir, getIndexWriterLockPath, getLockfileLockPath, getStateDbPathInDataDir, getWorkflowDbPath, } from "./paths.js";
17
+ import { getMigrationApplyJournalPath, getMigrationOperationRoot, getMigrationRestoreJournalPath, } from "./migration-operation.js";
18
+ import { getConfigPath, getDataDir, getIndexWriterLockPath, getLockfileLockPath, getStateDbPathInDataDir, getWorkflowDbPath, } from "./paths.js";
19
+ import { STATE_MIGRATIONS } from "./state/migrations.js";
15
20
  export const MIGRATION_BACKUP_VERSION = "0.9.0";
21
+ const MANIFEST_FORMAT_VERSION = 2;
22
+ const RESTORE_JOURNAL_FORMAT_VERSION = 2;
16
23
  const ARTIFACT_NAMES = ["config.json", "state.db", "workflow.db"];
17
- export function getMigrationBackupDir() {
18
- return path.join(getCacheDir(), "migration-backups", MIGRATION_BACKUP_VERSION);
24
+ const MAX_BLOCKER_DIRECTORY_SAMPLES = 100;
25
+ const MAX_WORKFLOW_BLOCKER_SAMPLES = 100;
26
+ const MAX_BLOCKER_FIELD_BYTES = 256;
27
+ const MAX_BLOCKER_ITEM_BYTES = 512;
28
+ const MAX_BLOCKER_DIAGNOSTIC_BYTES = 16 * 1024;
29
+ export function getMigrationBackupRoot() {
30
+ return getMigrationOperationRoot();
31
+ }
32
+ export { getMigrationApplyJournalPath, getMigrationRestoreJournalPath } from "./migration-operation.js";
33
+ export function getMigrationBackupDir(runId) {
34
+ if (!runId)
35
+ return getMigrationBackupRoot();
36
+ if (!/^[A-Za-z0-9._-]+$/.test(runId)) {
37
+ throw new ConfigError(`Invalid migration backup run ID ${JSON.stringify(runId)}.`, "INVALID_CONFIG_FILE");
38
+ }
39
+ return path.join(getMigrationBackupRoot(), runId);
19
40
  }
20
41
  function migrationBackupLockPath() {
21
- return path.join(getCacheDir(), "migration-backups", `${MIGRATION_BACKUP_VERSION}.lock`);
42
+ return path.join(getMigrationBackupRoot(), ".lock");
43
+ }
44
+ function restoreJournalPath() {
45
+ return getMigrationRestoreJournalPath();
22
46
  }
23
47
  function expectedSourcePaths() {
24
48
  return {
@@ -28,13 +52,30 @@ function expectedSourcePaths() {
28
52
  };
29
53
  }
30
54
  function sha256File(filePath) {
31
- return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
55
+ const hash = crypto.createHash("sha256");
56
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
57
+ const fd = fs.openSync(filePath, "r");
58
+ try {
59
+ while (true) {
60
+ const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null);
61
+ if (bytesRead === 0)
62
+ break;
63
+ hash.update(buffer.subarray(0, bytesRead));
64
+ }
65
+ }
66
+ finally {
67
+ fs.closeSync(fd);
68
+ }
69
+ return hash.digest("hex");
32
70
  }
33
- function ownerOnlyMode(filePath, directory) {
34
- if (process.platform === "win32")
35
- return true;
36
- const mode = fs.statSync(filePath).mode & 0o777;
37
- return mode === (directory ? 0o700 : 0o600);
71
+ function fsyncFile(filePath) {
72
+ const fd = fs.openSync(filePath, "r");
73
+ try {
74
+ fs.fsyncSync(fd);
75
+ }
76
+ finally {
77
+ fs.closeSync(fd);
78
+ }
38
79
  }
39
80
  function fsyncDirectory(directory) {
40
81
  try {
@@ -52,21 +93,115 @@ function fsyncDirectory(directory) {
52
93
  throw error;
53
94
  }
54
95
  }
96
+ function copyFileDurable(source, destination) {
97
+ fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 });
98
+ fs.copyFileSync(source, destination, fs.constants.COPYFILE_EXCL);
99
+ fs.chmodSync(destination, 0o600);
100
+ fsyncFile(destination);
101
+ }
102
+ function ownerOnlyMode(filePath, directory) {
103
+ if (process.platform === "win32")
104
+ return true;
105
+ const mode = fs.statSync(filePath).mode & 0o777;
106
+ return mode === (directory ? 0o700 : 0o600);
107
+ }
108
+ function mapLedgerState(state) {
109
+ return {
110
+ status: state.status,
111
+ migrationIds: state.migrationIds,
112
+ migrationChecksums: state.checksums,
113
+ ...(state.detail ? { detail: state.detail } : {}),
114
+ };
115
+ }
116
+ function inspectConfig(configPath) {
117
+ if (!fs.existsSync(configPath))
118
+ return { status: "missing" };
119
+ try {
120
+ const raw = parseConfigText(readTextFileWithLimit(configPath, MAX_CONFIG_FILE_BYTES, "Config file"), configPath);
121
+ const comparison = compareConfigVersion(raw.configVersion, CURRENT_CONFIG_VERSION);
122
+ if (comparison === undefined)
123
+ return { status: "inconsistent", detail: "configVersion is missing or invalid" };
124
+ if (comparison < 0)
125
+ return { status: "old" };
126
+ if (comparison > 0)
127
+ return { status: "newer" };
128
+ const validated = validateConfigShape(raw);
129
+ if (!validated.ok) {
130
+ return {
131
+ status: "corrupt",
132
+ detail: validated.errors.map((issue) => `${issue.path}: ${issue.message}`).join("; "),
133
+ };
134
+ }
135
+ return { status: "current" };
136
+ }
137
+ catch (error) {
138
+ return { status: "corrupt", detail: error instanceof Error ? error.message : String(error) };
139
+ }
140
+ }
141
+ function quickCheck(db, filePath) {
142
+ const rows = db.prepare("PRAGMA quick_check").all();
143
+ if (rows.length !== 1 || Object.values(rows[0])[0] !== "ok") {
144
+ throw new ConfigError(`SQLite quick_check failed for ${filePath}.`, "INVALID_CONFIG_FILE");
145
+ }
146
+ }
147
+ function inspectSqlite(filePath, migrations) {
148
+ if (!fs.existsSync(filePath))
149
+ return { status: "missing" };
150
+ let db;
151
+ try {
152
+ db = openDatabase(filePath, { readonly: true });
153
+ quickCheck(db, filePath);
154
+ return mapLedgerState(inspectMigrationLedger(db, migrations));
155
+ }
156
+ catch (error) {
157
+ return { status: "corrupt", detail: error instanceof Error ? error.message : String(error) };
158
+ }
159
+ finally {
160
+ db?.close();
161
+ }
162
+ }
163
+ export function inspectMigrationState() {
164
+ return {
165
+ config: inspectConfig(getConfigPath()),
166
+ state: inspectSqlite(getStateDbPathInDataDir(), STATE_MIGRATIONS),
167
+ workflow: inspectSqlite(getWorkflowDbPath(), WORKFLOW_MIGRATIONS),
168
+ };
169
+ }
170
+ function assertBackupEligible(state) {
171
+ const entries = [
172
+ ["config.json", state.config],
173
+ ["state.db", state.state],
174
+ ["workflow.db", state.workflow],
175
+ ];
176
+ const unsafe = entries.filter(([, artifact]) => ["newer", "inconsistent", "corrupt"].includes(artifact.status));
177
+ if (unsafe.length > 0) {
178
+ throw new ConfigError(`Refusing migration backup because artifact state is unsafe: ${unsafe
179
+ .map(([name, artifact]) => `${name}=${artifact.status}${artifact.detail ? ` (${artifact.detail})` : ""}`)
180
+ .join(", ")}.`, "INVALID_CONFIG_FILE");
181
+ }
182
+ }
55
183
  function parseManifest(bundlePath) {
56
184
  const manifestPath = path.join(bundlePath, "manifest.json");
57
185
  let value;
58
186
  try {
59
- value = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
187
+ value = JSON.parse(readTextFileWithLimit(manifestPath, MAX_LOCAL_METADATA_BYTES, "Migration manifest"));
60
188
  }
61
189
  catch (error) {
62
- throw new ConfigError(`Migration backup at ${bundlePath} is incomplete or unreadable: ${error instanceof Error ? error.message : String(error)}. Remove it only after preserving any recoverable files, then retry backup creation.`, "INVALID_CONFIG_FILE");
190
+ throw new ConfigError(`Migration backup at ${bundlePath} is incomplete or unreadable: ${error instanceof Error ? error.message : String(error)}.`, "INVALID_CONFIG_FILE");
63
191
  }
64
192
  if (!value || typeof value !== "object" || Array.isArray(value)) {
65
193
  throw new ConfigError(`Migration backup manifest at ${manifestPath} is invalid.`, "INVALID_CONFIG_FILE");
66
194
  }
67
195
  const manifest = value;
68
- if (manifest.version !== MIGRATION_BACKUP_VERSION || typeof manifest.createdAt !== "string") {
69
- throw new ConfigError(`Migration backup manifest at ${manifestPath} has an unsupported version.`, "INVALID_CONFIG_FILE");
196
+ if (manifest.formatVersion !== MANIFEST_FORMAT_VERSION ||
197
+ manifest.version !== MIGRATION_BACKUP_VERSION ||
198
+ manifest.targetVersion !== MIGRATION_BACKUP_VERSION ||
199
+ manifest.installationId !== path.basename(getMigrationOperationRoot()) ||
200
+ typeof manifest.runId !== "string" ||
201
+ path.basename(bundlePath) !== manifest.runId ||
202
+ typeof manifest.createdAt !== "string" ||
203
+ manifest.complete !== true) {
204
+ throw new ConfigError(`Migration backup manifest at ${manifestPath} is foreign or unsupported.`, "INVALID_CONFIG_FILE");
70
205
  }
71
206
  const expected = expectedSourcePaths();
72
207
  for (const name of ARTIFACT_NAMES) {
@@ -77,16 +212,40 @@ function parseManifest(bundlePath) {
77
212
  !Number.isSafeInteger(artifact.byteSize) ||
78
213
  artifact.byteSize < 0 ||
79
214
  typeof artifact.createdAt !== "string" ||
215
+ !["old", "current", "missing"].includes(artifact.status) ||
80
216
  (artifact.present
81
217
  ? typeof artifact.sha256 !== "string" || artifact.sha256.length !== 64
82
- : artifact.sha256 !== null)) {
218
+ : artifact.sha256 !== null || artifact.status !== "missing")) {
83
219
  throw new ConfigError(`Migration backup manifest has an invalid ${name} entry.`, "INVALID_CONFIG_FILE");
84
220
  }
85
221
  }
86
222
  return manifest;
87
223
  }
88
- /** Verify the complete bundle, including source-path binding, modes, sizes, and hashes. */
89
- export function verifyMigrationBackup(bundlePath = getMigrationBackupDir()) {
224
+ function sameState(actual, expected) {
225
+ return (actual.status === expected.status &&
226
+ JSON.stringify(actual.migrationIds ?? []) === JSON.stringify(expected.migrationIds ?? []) &&
227
+ JSON.stringify(actual.migrationChecksums ?? []) === JSON.stringify(expected.migrationChecksums ?? []));
228
+ }
229
+ function verifyArtifactAgainstManifest(filePath, name, artifact, context) {
230
+ if (!artifact.present) {
231
+ if (fs.existsSync(filePath)) {
232
+ throw new ConfigError(`${context} ${name} should be absent according to the selected backup.`, "INVALID_CONFIG_FILE");
233
+ }
234
+ return;
235
+ }
236
+ if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
237
+ throw new ConfigError(`${context} ${name} is missing or is not a regular file.`, "INVALID_CONFIG_FILE");
238
+ }
239
+ const stat = fs.statSync(filePath);
240
+ if (stat.size !== artifact.byteSize || sha256File(filePath) !== artifact.sha256) {
241
+ throw new ConfigError(`${context} ${name} failed size/checksum authentication.`, "INVALID_CONFIG_FILE");
242
+ }
243
+ const inspected = inspectArtifactAt(name, filePath);
244
+ if (!sameState(inspected, artifact)) {
245
+ throw new ConfigError(`${context} ${name} failed SQLite/config recoverability verification: expected ${artifact.status}, got ${inspected.status}${inspected.detail ? ` (${inspected.detail})` : ""}.`, "INVALID_CONFIG_FILE");
246
+ }
247
+ }
248
+ export function verifyMigrationBackup(bundlePath = resolveBackupRun()) {
90
249
  if (!fs.existsSync(bundlePath) || !fs.statSync(bundlePath).isDirectory()) {
91
250
  throw new ConfigError(`Migration backup does not exist at ${bundlePath}.`, "INVALID_CONFIG_FILE");
92
251
  }
@@ -105,19 +264,22 @@ export function verifyMigrationBackup(bundlePath = getMigrationBackupDir()) {
105
264
  continue;
106
265
  }
107
266
  expectedFiles.add(name);
108
- if (!fs.existsSync(artifactPath) || !fs.statSync(artifactPath).isFile()) {
109
- throw new ConfigError(`Migration backup is missing ${artifactPath}.`, "INVALID_CONFIG_FILE");
110
- }
111
- if (!ownerOnlyMode(artifactPath, false)) {
112
- throw new ConfigError(`Migration backup artifact ${artifactPath} must have mode 0600.`, "INVALID_CONFIG_FILE");
267
+ if (!fs.existsSync(artifactPath) || !fs.statSync(artifactPath).isFile() || !ownerOnlyMode(artifactPath, false)) {
268
+ throw new ConfigError(`Migration backup artifact ${artifactPath} is missing or has unsafe permissions.`, "INVALID_CONFIG_FILE");
113
269
  }
114
270
  const stat = fs.statSync(artifactPath);
115
271
  if (stat.size !== artifact.byteSize || sha256File(artifactPath) !== artifact.sha256) {
116
272
  throw new ConfigError(`Migration backup artifact ${artifactPath} failed checksum verification.`, "INVALID_CONFIG_FILE");
117
273
  }
274
+ const inspected = name === "config.json"
275
+ ? inspectConfig(artifactPath)
276
+ : inspectSqlite(artifactPath, name === "state.db" ? STATE_MIGRATIONS : WORKFLOW_MIGRATIONS);
277
+ if (!sameState(inspected, artifact)) {
278
+ 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");
279
+ }
118
280
  }
119
281
  if (!ownerOnlyMode(path.join(bundlePath, "manifest.json"), false)) {
120
- throw new ConfigError(`Migration backup manifest must have mode 0600.`, "INVALID_CONFIG_FILE");
282
+ throw new ConfigError("Migration backup manifest must have mode 0600.", "INVALID_CONFIG_FILE");
121
283
  }
122
284
  const extras = fs.readdirSync(bundlePath).filter((name) => !expectedFiles.has(name));
123
285
  if (extras.length > 0) {
@@ -134,43 +296,19 @@ function acquireMigrationBackupLock() {
134
296
  if (ownership)
135
297
  return () => releaseLock(ownership);
136
298
  const probe = probeLock(lockPath);
137
- if (probe.state === "stale" && reclaimStaleLock(lockPath, probe)) {
299
+ if (probe.state === "stale" && reclaimStaleLock(lockPath, probe))
138
300
  continue;
139
- }
140
301
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
141
302
  }
142
303
  throw new ConfigError(`Timed out waiting for migration backup lock at ${lockPath}.`, "INVALID_CONFIG_FILE");
143
304
  }
144
- function assertNotAlreadyCutOver() {
145
- const configPath = getConfigPath();
146
- let text;
147
- try {
148
- text = fs.readFileSync(configPath, "utf8");
149
- }
150
- catch (error) {
151
- if (error.code === "ENOENT")
152
- return;
153
- throw error;
154
- }
155
- const raw = parseConfigText(text, configPath);
156
- if (raw.configVersion === CURRENT_CONFIG_VERSION) {
157
- throw new ConfigError(`Refusing to create a pre-0.9 migration backup from an existing ${CURRENT_CONFIG_VERSION} config at ${configPath}. Restore the original migration bundle or pre-cutover config before retrying.`, "INVALID_CONFIG_FILE");
158
- }
159
- }
160
- function copyRegularArtifact(source, destination) {
161
- const stat = fs.statSync(source);
162
- if (!stat.isFile())
163
- throw new ConfigError(`Backup source is not a regular file: ${source}`, "INVALID_CONFIG_FILE");
164
- writeFileAtomic(destination, fs.readFileSync(source), 0o600);
165
- fs.chmodSync(destination, 0o600);
166
- }
167
305
  function sqliteQuote(value) {
168
306
  return `'${value.replaceAll("'", "''")}'`;
169
307
  }
170
308
  function backupSqlite(source, destination) {
171
- const stat = fs.statSync(source);
172
- if (!stat.isFile())
309
+ if (!fs.statSync(source).isFile()) {
173
310
  throw new ConfigError(`SQLite backup source is not a regular file: ${source}`, "INVALID_CONFIG_FILE");
311
+ }
174
312
  const resolvedSource = path.resolve(source);
175
313
  const activityName = resolvedSource === path.resolve(getWorkflowDbPath())
176
314
  ? "workflow-db"
@@ -178,14 +316,10 @@ function backupSqlite(source, destination) {
178
316
  ? "state-db"
179
317
  : undefined;
180
318
  const releaseActivity = activityName ? acquireMaintenanceActivitySync(activityName) : undefined;
181
- // The source was stat-verified above. Opening without Bun's `create:false`
182
- // avoids bun:sqlite's SQLITE_MISUSE for that unsupported false option while
183
- // retaining Node's ordinary existing-file behavior.
184
319
  let db;
185
320
  try {
186
321
  db = openDatabase(source);
187
322
  db.exec("PRAGMA busy_timeout = 10000");
188
- db.exec("PRAGMA wal_checkpoint(FULL)");
189
323
  db.exec(`VACUUM INTO ${sqliteQuote(destination)}`);
190
324
  }
191
325
  finally {
@@ -197,24 +331,23 @@ function backupSqlite(source, destination) {
197
331
  }
198
332
  }
199
333
  fs.chmodSync(destination, 0o600);
200
- const fd = fs.openSync(destination, "r");
201
- try {
202
- fs.fsyncSync(fd);
203
- }
204
- finally {
205
- fs.closeSync(fd);
206
- }
334
+ fsyncFile(destination);
335
+ }
336
+ function newRunId() {
337
+ return `${new Date().toISOString().replace(/[-:.TZ]/g, "")}-${process.pid}-${crypto.randomBytes(6).toString("hex")}`;
338
+ }
339
+ function stateForName(state, name) {
340
+ return name === "config.json" ? state.config : name === "state.db" ? state.state : state.workflow;
207
341
  }
208
342
  function createMigrationBackupUnlocked() {
209
- const bundlePath = getMigrationBackupDir();
210
- if (fs.existsSync(bundlePath)) {
211
- return { path: bundlePath, created: false, manifest: verifyMigrationBackup(bundlePath) };
212
- }
213
- assertNotAlreadyCutOver();
214
- const parent = path.dirname(bundlePath);
215
- fs.mkdirSync(parent, { recursive: true, mode: 0o700 });
216
- fs.chmodSync(parent, 0o700);
217
- const temporary = path.join(parent, `.0.9.0.tmp.${process.pid}.${crypto.randomBytes(8).toString("hex")}`);
343
+ const state = inspectMigrationState();
344
+ assertBackupEligible(state);
345
+ const root = getMigrationBackupRoot();
346
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 });
347
+ fs.chmodSync(root, 0o700);
348
+ const runId = newRunId();
349
+ const bundlePath = path.join(root, runId);
350
+ const temporary = path.join(root, `.${runId}.tmp`);
218
351
  fs.mkdirSync(temporary, { mode: 0o700 });
219
352
  const createdAt = new Date().toISOString();
220
353
  const sources = expectedSourcePaths();
@@ -222,45 +355,52 @@ function createMigrationBackupUnlocked() {
222
355
  try {
223
356
  for (const name of ARTIFACT_NAMES) {
224
357
  const sourcePath = sources[name];
358
+ const sourceState = stateForName(state, name);
225
359
  const destination = path.join(temporary, name);
226
- let present = true;
227
- try {
228
- fs.statSync(sourcePath);
229
- }
230
- catch (error) {
231
- if (error.code === "ENOENT")
232
- present = false;
233
- else
234
- throw error;
235
- }
360
+ const present = sourceState.status !== "missing";
236
361
  if (present) {
237
362
  if (name === "config.json")
238
- copyRegularArtifact(sourcePath, destination);
363
+ copyFileDurable(sourcePath, destination);
239
364
  else
240
365
  backupSqlite(sourcePath, destination);
241
366
  }
242
- const byteSize = present ? fs.statSync(destination).size : 0;
367
+ const inspected = present
368
+ ? name === "config.json"
369
+ ? inspectConfig(destination)
370
+ : inspectSqlite(destination, name === "state.db" ? STATE_MIGRATIONS : WORKFLOW_MIGRATIONS)
371
+ : { status: "missing" };
372
+ const expectedArtifact = { ...sourceState, sourcePath, present, byteSize: 0, sha256: null, createdAt };
373
+ if (!sameState(inspected, expectedArtifact)) {
374
+ throw new ConfigError(`Snapshot ${name} does not match its source migration state.`, "INVALID_CONFIG_FILE");
375
+ }
243
376
  artifacts[name] = {
377
+ ...sourceState,
244
378
  sourcePath,
245
379
  present,
246
- byteSize,
380
+ byteSize: present ? fs.statSync(destination).size : 0,
247
381
  sha256: present ? sha256File(destination) : null,
248
382
  createdAt,
249
383
  };
250
384
  }
251
- const manifest = { version: MIGRATION_BACKUP_VERSION, createdAt, artifacts };
385
+ const manifest = {
386
+ formatVersion: MANIFEST_FORMAT_VERSION,
387
+ version: MIGRATION_BACKUP_VERSION,
388
+ targetVersion: MIGRATION_BACKUP_VERSION,
389
+ installationId: path.basename(getMigrationOperationRoot()),
390
+ runId,
391
+ createdAt,
392
+ complete: true,
393
+ artifacts,
394
+ };
252
395
  writeFileAtomic(path.join(temporary, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, 0o600);
253
396
  fs.chmodSync(path.join(temporary, "manifest.json"), 0o600);
254
397
  fsyncDirectory(temporary);
255
398
  fs.renameSync(temporary, bundlePath);
256
- fsyncDirectory(parent);
399
+ fsyncDirectory(root);
257
400
  return { path: bundlePath, created: true, manifest: verifyMigrationBackup(bundlePath) };
258
401
  }
259
402
  catch (error) {
260
403
  fs.rmSync(temporary, { recursive: true, force: true });
261
- if (fs.existsSync(bundlePath)) {
262
- throw new ConfigError(`Migration backup creation raced with another writer and left ${bundlePath}. Verify or preserve it before retrying.`, "INVALID_CONFIG_FILE");
263
- }
264
404
  throw error;
265
405
  }
266
406
  }
@@ -273,35 +413,96 @@ function withMigrationBackupLock(fn) {
273
413
  release();
274
414
  }
275
415
  }
276
- /** Create or verify the immutable pre-cutover bundle. Safe for raw legacy config. */
277
416
  export function createMigrationBackup() {
278
- return withConfigLock(() => withMigrationBackupLock(createMigrationBackupUnlocked));
417
+ return withConfigLock(() => withMigrationBackupLock(() => withMaintenanceStartBarrier(createMigrationBackupUnlocked)));
279
418
  }
280
- /** Used while a config mutation already owns config.json.lck. */
281
419
  export function ensureMigrationBackupWithConfigLockHeld() {
282
- return withMigrationBackupLock(createMigrationBackupUnlocked);
420
+ return withMigrationBackupLock(() => withMaintenanceStartBarrier(createMigrationBackupUnlocked));
283
421
  }
284
- /** Used by database migration hooks before applying the first 0.9 migration. */
285
422
  export function ensureMigrationBackup() {
286
423
  return createMigrationBackup();
287
424
  }
288
- function activeRestoreLocks() {
425
+ function sanitizeDiagnosticField(value, maxBytes = MAX_BLOCKER_FIELD_BYTES) {
426
+ const source = String(value);
427
+ let output = "";
428
+ let bytes = 0;
429
+ let truncated = false;
430
+ const contentLimit = Math.max(0, maxBytes - 3);
431
+ for (const character of source) {
432
+ const codePoint = character.codePointAt(0) ?? 0;
433
+ const safe = codePoint < 0x20 || codePoint === 0x7f ? "?" : character;
434
+ const width = Buffer.byteLength(safe, "utf8");
435
+ if (bytes + width > contentLimit) {
436
+ truncated = true;
437
+ break;
438
+ }
439
+ output += safe;
440
+ bytes += width;
441
+ }
442
+ if (!truncated && bytes < Buffer.byteLength(source, "utf8"))
443
+ truncated = true;
444
+ return truncated ? `${output}...` : output;
445
+ }
446
+ function sampleLockDirectory(directory) {
447
+ let handle;
448
+ try {
449
+ handle = fs.opendirSync(directory);
450
+ }
451
+ catch (error) {
452
+ if (error.code === "ENOENT")
453
+ return { paths: [], overflow: false };
454
+ throw error;
455
+ }
456
+ const paths = [];
457
+ let inspected = 0;
458
+ let overflow = false;
459
+ try {
460
+ while (true) {
461
+ const entry = handle.readSync();
462
+ if (!entry)
463
+ break;
464
+ inspected += 1;
465
+ if (inspected > MAX_BLOCKER_DIRECTORY_SAMPLES) {
466
+ overflow = true;
467
+ break;
468
+ }
469
+ if (entry.isFile() && entry.name.endsWith(".lock"))
470
+ paths.push(path.join(directory, entry.name));
471
+ }
472
+ }
473
+ catch (error) {
474
+ if (error.code === "ENOENT")
475
+ return { paths: [], overflow: false };
476
+ throw error;
477
+ }
478
+ finally {
479
+ try {
480
+ handle.closeSync();
481
+ }
482
+ catch {
483
+ // A concurrently removed directory may already have invalidated the handle.
484
+ }
485
+ }
486
+ return { paths, overflow };
487
+ }
488
+ function activeRestoreLocks(bundlePath) {
289
489
  const lockPaths = [getLockfileLockPath(), getIndexWriterLockPath()];
490
+ const overflowBlockers = [];
290
491
  const configLock = path.join(path.dirname(getConfigPath()), "config.json.lck");
291
492
  const dataDir = getDataDir();
292
493
  for (const name of ["improve.lock", "consolidate.lock", "reflect-distill.lock", "triage.lock"]) {
293
494
  lockPaths.push(path.join(dataDir, name));
294
495
  }
295
496
  const stashDirs = new Set();
296
- for (const configPath of [getConfigPath(), path.join(getMigrationBackupDir(), "config.json")]) {
497
+ const configPaths = [getConfigPath(), ...(bundlePath ? [path.join(bundlePath, "config.json")] : [])];
498
+ for (const configPath of configPaths) {
297
499
  try {
298
- const raw = parseConfigText(fs.readFileSync(configPath, "utf8"), configPath);
500
+ const raw = parseConfigText(readTextFileWithLimit(configPath, MAX_CONFIG_FILE_BYTES, "Config file"), configPath);
299
501
  if (typeof raw.stashDir === "string" && raw.stashDir)
300
502
  stashDirs.add(raw.stashDir);
301
503
  }
302
504
  catch {
303
- // Restore still verifies the bundle. A malformed live config must not
304
- // prevent recovery; it simply cannot contribute an extra stash lock path.
505
+ // A malformed or absent config contributes no stash-scoped lock paths.
305
506
  }
306
507
  }
307
508
  for (const stashDir of stashDirs) {
@@ -311,106 +512,550 @@ function activeRestoreLocks() {
311
512
  }
312
513
  for (const baseDir of [dataDir, ...[...stashDirs].map((stashDir) => path.join(stashDir, ".akm"))]) {
313
514
  const extractLockDir = path.join(baseDir, "extract-locks");
314
- try {
315
- for (const entry of fs.readdirSync(extractLockDir, { withFileTypes: true })) {
316
- if (entry.isFile() && entry.name.endsWith(".lock"))
317
- lockPaths.push(path.join(extractLockDir, entry.name));
318
- }
319
- }
320
- catch (error) {
321
- if (error.code !== "ENOENT")
322
- throw error;
515
+ const sample = sampleLockDirectory(extractLockDir);
516
+ lockPaths.push(...sample.paths);
517
+ if (sample.overflow) {
518
+ overflowBlockers.push(`lock directory sample capped: ${sanitizeDiagnosticField(extractLockDir)}`);
323
519
  }
324
520
  }
325
521
  const activityDir = path.join(path.dirname(getLockfileLockPath()), "maintenance-activities");
326
- try {
327
- for (const entry of fs.readdirSync(activityDir, { withFileTypes: true })) {
328
- if (entry.isFile() && entry.name.endsWith(".lock"))
329
- lockPaths.push(path.join(activityDir, entry.name));
330
- }
522
+ const activitySample = sampleLockDirectory(activityDir);
523
+ lockPaths.push(...activitySample.paths);
524
+ if (activitySample.overflow) {
525
+ overflowBlockers.push(`lock directory sample capped: ${sanitizeDiagnosticField(activityDir)}`);
331
526
  }
332
- catch (error) {
333
- if (error.code !== "ENOENT")
334
- throw error;
335
- }
336
- return [configLock, ...lockPaths].filter((lockPath) => {
527
+ const active = [configLock, ...lockPaths]
528
+ .filter((lockPath) => {
337
529
  const probe = probeLock(lockPath);
338
530
  return probe.state === "held" && (lockPath !== configLock || probe.holderPid !== process.pid);
339
- });
531
+ })
532
+ .map((lockPath) => sanitizeDiagnosticField(lockPath, MAX_BLOCKER_ITEM_BYTES));
533
+ return [...active, ...overflowBlockers];
340
534
  }
341
535
  function activeWorkflowClaims() {
536
+ const maxSamples = MAX_WORKFLOW_BLOCKER_SAMPLES;
342
537
  const workflowPath = getWorkflowDbPath();
343
538
  if (!fs.existsSync(workflowPath))
344
539
  return [];
345
- // Restore already owns the maintenance barrier for this handle's complete
346
- // lifetime. Registering a normal workflow-db activity here would re-enter
347
- // that barrier and deadlock against ourselves.
348
540
  const db = openDatabase(workflowPath, { readonly: true });
349
541
  try {
350
- const runsTable = db
351
- .prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'workflow_runs'")
352
- .get();
353
542
  const blockers = [];
354
- if (runsTable) {
543
+ if (db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='workflow_runs'").get()) {
355
544
  const columns = new Set(db.prepare("PRAGMA table_info(workflow_runs)").all().map((row) => row.name));
356
545
  if (columns.has("engine_lease_holder") && columns.has("engine_lease_until")) {
357
- const now = new Date().toISOString();
358
- blockers.push(...db
359
- .prepare(`SELECT id, engine_lease_holder AS holder, engine_lease_until AS expires
360
- FROM workflow_runs
361
- WHERE engine_lease_holder IS NOT NULL AND engine_lease_until IS NOT NULL AND engine_lease_until >= ?`)
362
- .all(now).map((lease) => `${workflowPath}#run=${lease.id},holder=${lease.holder},expires=${lease.expires}`));
546
+ const leases = db
547
+ .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}`)
548
+ .all(new Date().toISOString());
549
+ blockers.push(...leases
550
+ .slice(0, maxSamples)
551
+ .map((lease) => `${sanitizeDiagnosticField(workflowPath)}#run=${sanitizeDiagnosticField(lease.id)},holder=${sanitizeDiagnosticField(lease.holder)},expires=${sanitizeDiagnosticField(lease.expires, 128)}`));
552
+ if (leases.length > maxSamples) {
553
+ blockers.push(`${workflowPath}#additional-active-workflow-blockers`);
554
+ return blockers;
555
+ }
363
556
  }
364
557
  }
365
- const unitsTable = db
366
- .prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'workflow_run_units'")
367
- .get();
368
- if (!unitsTable)
558
+ if (!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='workflow_run_units'").get())
559
+ return blockers;
560
+ const columns = new Set(db.prepare("PRAGMA table_info(workflow_run_units)").all().map((row) => row.name));
561
+ if (!columns.has("claim_holder") || !columns.has("claim_expires_at"))
369
562
  return blockers;
370
- const unitColumns = new Set(db.prepare("PRAGMA table_info(workflow_run_units)").all().map((row) => row.name));
371
- if (!unitColumns.has("claim_holder") || !unitColumns.has("claim_expires_at"))
563
+ const remaining = maxSamples - blockers.length;
564
+ if (remaining <= 0)
372
565
  return blockers;
373
- const now = new Date().toISOString();
374
- blockers.push(...db
375
- .prepare(`SELECT run_id AS runId, unit_id AS unitId, claim_holder AS holder, claim_expires_at AS expires
376
- FROM workflow_run_units
377
- WHERE status = 'running' AND claim_holder IS NOT NULL
378
- AND claim_expires_at IS NOT NULL AND claim_expires_at >= ?`)
379
- .all(now).map((claim) => `${workflowPath}#run=${claim.runId},unit=${claim.unitId},holder=${claim.holder},expires=${claim.expires}`));
566
+ const claims = db
567
+ .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}`)
568
+ .all(new Date().toISOString());
569
+ blockers.push(...claims
570
+ .slice(0, remaining)
571
+ .map((claim) => `${sanitizeDiagnosticField(workflowPath)}#run=${sanitizeDiagnosticField(claim.runId)},unit=${sanitizeDiagnosticField(claim.unitId)},holder=${sanitizeDiagnosticField(claim.holder)},expires=${sanitizeDiagnosticField(claim.expires, 128)}`));
572
+ if (claims.length > remaining)
573
+ blockers.push(`${workflowPath}#additional-active-workflow-blockers`);
380
574
  return blockers;
381
575
  }
382
576
  finally {
383
577
  db.close();
384
578
  }
385
579
  }
386
- function restoreArtifact(source, destination) {
387
- fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 });
388
- writeFileAtomic(destination, fs.readFileSync(source), 0o600);
389
- fs.chmodSync(destination, 0o600);
580
+ /** Caller must hold the maintenance start barrier while checking and replacing artifacts. */
581
+ export function assertNoArtifactReplacementBlockers(bundlePath) {
582
+ const blockers = [...activeRestoreLocks(bundlePath), ...activeWorkflowClaims()];
583
+ if (blockers.length > 0) {
584
+ const prefix = "Refusing artifact replacement while AKM locks, activities, or workflow leases are active: ";
585
+ const omission = " ... additional blockers omitted.";
586
+ const punctuation = ".";
587
+ const contentLimit = MAX_BLOCKER_DIAGNOSTIC_BYTES - Buffer.byteLength(omission, "utf8");
588
+ let message = prefix;
589
+ let included = 0;
590
+ for (const blocker of blockers) {
591
+ const item = sanitizeDiagnosticField(blocker, MAX_BLOCKER_ITEM_BYTES);
592
+ const segment = `${included === 0 ? "" : ", "}${item}`;
593
+ if (Buffer.byteLength(message, "utf8") + Buffer.byteLength(segment, "utf8") + 1 > contentLimit)
594
+ break;
595
+ message += segment;
596
+ included += 1;
597
+ }
598
+ message += included < blockers.length ? omission : punctuation;
599
+ throw new ConfigError(message, "INVALID_CONFIG_FILE");
600
+ }
390
601
  }
391
- /** Restore exactly the manifest's original presence/absence state. */
392
- export function restoreMigrationBackup(confirm) {
393
- if (!confirm) {
394
- throw new ConfigError("Migration backup restore requires --confirm.", "INVALID_CONFIG_FILE");
602
+ function fingerprintFile(filePath) {
603
+ if (!fs.existsSync(filePath))
604
+ return null;
605
+ const stat = fs.statSync(filePath);
606
+ if (!stat.isFile())
607
+ throw new ConfigError(`Fingerprint source is not a regular file: ${filePath}.`, "INVALID_CONFIG_FILE");
608
+ return { byteSize: stat.size, sha256: sha256File(filePath) };
609
+ }
610
+ function matchesFingerprint(filePath, fingerprint) {
611
+ if (fingerprint === null)
612
+ return !fs.existsSync(filePath);
613
+ if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile())
614
+ return false;
615
+ return fs.statSync(filePath).size === fingerprint.byteSize && sha256File(filePath) === fingerprint.sha256;
616
+ }
617
+ export function fingerprintMigrationGeneration() {
618
+ const statePath = getStateDbPathInDataDir();
619
+ const workflowPath = getWorkflowDbPath();
620
+ return {
621
+ config: { main: fingerprintFile(getConfigPath()), wal: null, shm: null },
622
+ state: {
623
+ main: fingerprintFile(statePath),
624
+ wal: fingerprintFile(`${statePath}-wal`),
625
+ shm: fingerprintFile(`${statePath}-shm`),
626
+ },
627
+ workflow: {
628
+ main: fingerprintFile(workflowPath),
629
+ wal: fingerprintFile(`${workflowPath}-wal`),
630
+ shm: fingerprintFile(`${workflowPath}-shm`),
631
+ },
632
+ };
633
+ }
634
+ export function sameMigrationGeneration(left, right) {
635
+ return JSON.stringify(left) === JSON.stringify(right);
636
+ }
637
+ let restoreRollbackBoundaryHook;
638
+ /** TEST-ONLY: inject a crash at a named prepared-restore rollback boundary. */
639
+ export function _setRestoreRollbackBoundaryHookForTests(hook) {
640
+ restoreRollbackBoundaryHook = hook;
641
+ }
642
+ function rollbackBoundary(boundary) {
643
+ restoreRollbackBoundaryHook?.(boundary);
644
+ }
645
+ function writeRestoreJournal(journal) {
646
+ writeFileAtomic(restoreJournalPath(), `${JSON.stringify(journal, null, 2)}\n`, 0o600);
647
+ }
648
+ function invalidRestoreJournal(journalPath, detail) {
649
+ throw new ConfigError(`Invalid restore journal ${journalPath}: ${detail}.`, "INVALID_CONFIG_FILE");
650
+ }
651
+ function isRecord(value) {
652
+ return typeof value === "object" && value !== null && !Array.isArray(value);
653
+ }
654
+ function hasExactKeys(value, required, optional = []) {
655
+ const keys = Object.keys(value);
656
+ return (required.every((key) => keys.includes(key)) && keys.every((key) => required.includes(key) || optional.includes(key)));
657
+ }
658
+ function parseFileFingerprint(value, present, journalPath) {
659
+ if (!present) {
660
+ if (value !== null)
661
+ invalidRestoreJournal(journalPath, "absent original has a non-null fingerprint");
662
+ return null;
663
+ }
664
+ if (!isRecord(value) ||
665
+ !hasExactKeys(value, ["byteSize", "sha256"]) ||
666
+ !Number.isSafeInteger(value.byteSize) ||
667
+ value.byteSize < 0 ||
668
+ typeof value.sha256 !== "string" ||
669
+ !/^[a-f0-9]{64}$/.test(value.sha256)) {
670
+ invalidRestoreJournal(journalPath, "original fingerprint is malformed");
671
+ }
672
+ return { byteSize: value.byteSize, sha256: value.sha256 };
673
+ }
674
+ function isOperationBoundPath(candidate, destination, kind, operationId) {
675
+ const expected = `${destination}.${kind}.${operationId}`;
676
+ return candidate === expected && path.dirname(path.resolve(candidate)) === path.dirname(path.resolve(destination));
677
+ }
678
+ function validateRestoreJournal(raw, journalPath) {
679
+ if (!isRecord(raw) ||
680
+ !hasExactKeys(raw, ["formatVersion", "version", "operationId", "sourceRunId", "rescueRunId", "phase", "entries"])) {
681
+ invalidRestoreJournal(journalPath, "expected the complete versioned journal shape");
682
+ }
683
+ if (raw.formatVersion !== RESTORE_JOURNAL_FORMAT_VERSION) {
684
+ invalidRestoreJournal(journalPath, `unsupported formatVersion ${JSON.stringify(raw.formatVersion)}`);
395
685
  }
396
- return withConfigLock(() => withMigrationBackupLock(() => {
397
- return withMaintenanceStartBarrier(() => {
398
- const blockers = [...activeRestoreLocks(), ...activeWorkflowClaims()];
399
- if (blockers.length > 0) {
400
- throw new ConfigError(`Refusing restore while AKM locks or workflow leases are active: ${blockers.join(", ")}.`, "INVALID_CONFIG_FILE");
686
+ if (raw.version !== MIGRATION_BACKUP_VERSION) {
687
+ invalidRestoreJournal(journalPath, `unsupported migration version ${JSON.stringify(raw.version)}`);
688
+ }
689
+ if (raw.phase !== "prepared" && raw.phase !== "committed") {
690
+ invalidRestoreJournal(journalPath, `unsupported phase ${JSON.stringify(raw.phase)}`);
691
+ }
692
+ for (const field of ["operationId", "sourceRunId", "rescueRunId"]) {
693
+ if (typeof raw[field] !== "string" || !/^[A-Za-z0-9._-]+$/.test(raw[field])) {
694
+ invalidRestoreJournal(journalPath, `${field} is not a safe operation identifier`);
695
+ }
696
+ }
697
+ if (!Array.isArray(raw.entries) || raw.entries.length !== ARTIFACT_NAMES.length) {
698
+ invalidRestoreJournal(journalPath, `expected exactly ${ARTIFACT_NAMES.length} artifact entries`);
699
+ }
700
+ const operationId = raw.operationId;
701
+ const expectedPaths = expectedSourcePaths();
702
+ const byDestination = new Map();
703
+ const allPaths = new Set();
704
+ const registerPath = (candidate, label) => {
705
+ const resolved = path.resolve(candidate);
706
+ if (allPaths.has(resolved))
707
+ invalidRestoreJournal(journalPath, `${label} path is duplicated: ${candidate}`);
708
+ allPaths.add(resolved);
709
+ };
710
+ for (const value of raw.entries) {
711
+ if (!isRecord(value) ||
712
+ !hasExactKeys(value, ["destination", "originalPresent", "originalFingerprint", "quarantine", "sidecars"], ["stage"]) ||
713
+ typeof value.destination !== "string" ||
714
+ typeof value.originalPresent !== "boolean" ||
715
+ typeof value.quarantine !== "string" ||
716
+ !Array.isArray(value.sidecars)) {
717
+ invalidRestoreJournal(journalPath, "artifact entry has an invalid shape");
718
+ }
719
+ const originalFingerprint = parseFileFingerprint(value.originalFingerprint, value.originalPresent, journalPath);
720
+ const artifactName = ARTIFACT_NAMES.find((name) => expectedPaths[name] === value.destination);
721
+ if (!artifactName || byDestination.has(value.destination)) {
722
+ invalidRestoreJournal(journalPath, `artifact destinations are not the exact unique canonical set`);
723
+ }
724
+ if (!isOperationBoundPath(value.quarantine, value.destination, "restore-quarantine", operationId)) {
725
+ invalidRestoreJournal(journalPath, `quarantine path is not bound to operation ${operationId}`);
726
+ }
727
+ if (value.stage !== undefined &&
728
+ (typeof value.stage !== "string" ||
729
+ !isOperationBoundPath(value.stage, value.destination, "restore-stage", operationId))) {
730
+ invalidRestoreJournal(journalPath, `stage path is not bound to operation ${operationId}`);
731
+ }
732
+ const expectedSidecarDestinations = artifactName === "config.json" ? [] : [`${value.destination}-wal`, `${value.destination}-shm`];
733
+ if (value.sidecars.length !== expectedSidecarDestinations.length) {
734
+ invalidRestoreJournal(journalPath, `${artifactName} has an incomplete sidecar set`);
735
+ }
736
+ const sidecars = [];
737
+ for (const sidecarValue of value.sidecars) {
738
+ if (!isRecord(sidecarValue) ||
739
+ !hasExactKeys(sidecarValue, ["destination", "originalPresent", "originalFingerprint", "quarantine"]) ||
740
+ typeof sidecarValue.destination !== "string" ||
741
+ typeof sidecarValue.originalPresent !== "boolean" ||
742
+ typeof sidecarValue.quarantine !== "string" ||
743
+ !expectedSidecarDestinations.includes(sidecarValue.destination)) {
744
+ invalidRestoreJournal(journalPath, `${artifactName} has an invalid sidecar entry`);
401
745
  }
402
- const bundlePath = getMigrationBackupDir();
403
- const manifest = verifyMigrationBackup(bundlePath);
404
- for (const name of ARTIFACT_NAMES) {
405
- const artifact = manifest.artifacts[name];
406
- if (artifact.present)
407
- restoreArtifact(path.join(bundlePath, name), artifact.sourcePath);
408
- else
409
- fs.rmSync(artifact.sourcePath, { force: true });
410
- fs.rmSync(`${artifact.sourcePath}-wal`, { force: true });
411
- fs.rmSync(`${artifact.sourcePath}-shm`, { force: true });
746
+ const sidecarFingerprint = parseFileFingerprint(sidecarValue.originalFingerprint, sidecarValue.originalPresent, journalPath);
747
+ if (sidecars.some((sidecar) => sidecar.destination === sidecarValue.destination)) {
748
+ invalidRestoreJournal(journalPath, `${artifactName} has a duplicate sidecar destination`);
749
+ }
750
+ if (!isOperationBoundPath(sidecarValue.quarantine, sidecarValue.destination, "restore-quarantine", operationId)) {
751
+ invalidRestoreJournal(journalPath, `sidecar quarantine path is not bound to operation ${operationId}`);
412
752
  }
413
- return { path: bundlePath, created: false, manifest };
753
+ sidecars.push({
754
+ destination: sidecarValue.destination,
755
+ originalPresent: sidecarValue.originalPresent,
756
+ originalFingerprint: sidecarFingerprint,
757
+ quarantine: sidecarValue.quarantine,
758
+ });
759
+ }
760
+ registerPath(value.destination, "destination");
761
+ registerPath(value.quarantine, "quarantine");
762
+ if (typeof value.stage === "string")
763
+ registerPath(value.stage, "stage");
764
+ for (const sidecar of sidecars) {
765
+ registerPath(sidecar.destination, "sidecar destination");
766
+ registerPath(sidecar.quarantine, "sidecar quarantine");
767
+ }
768
+ byDestination.set(value.destination, {
769
+ destination: value.destination,
770
+ ...(typeof value.stage === "string" ? { stage: value.stage } : {}),
771
+ originalPresent: value.originalPresent,
772
+ originalFingerprint,
773
+ quarantine: value.quarantine,
774
+ sidecars,
414
775
  });
415
- }));
776
+ }
777
+ const sourceRunId = raw.sourceRunId;
778
+ let sourceManifest;
779
+ try {
780
+ sourceManifest = verifyMigrationBackup(getMigrationBackupDir(sourceRunId));
781
+ }
782
+ catch (error) {
783
+ invalidRestoreJournal(journalPath, `source backup ${sourceRunId} is unavailable or invalid: ${error instanceof Error ? error.message : String(error)}`);
784
+ }
785
+ const entries = ARTIFACT_NAMES.map((name) => byDestination.get(expectedPaths[name]));
786
+ for (const [index, name] of ARTIFACT_NAMES.entries()) {
787
+ const entry = entries[index];
788
+ const artifact = sourceManifest.artifacts[name];
789
+ const expectedStage = artifact.present ? `${entry.destination}.restore-stage.${operationId}` : undefined;
790
+ if (entry.stage !== expectedStage) {
791
+ invalidRestoreJournal(journalPath, `${name} stage presence does not match source backup ${sourceRunId}`);
792
+ }
793
+ if (raw.phase === "committed") {
794
+ if (fs.existsSync(entry.destination) !== artifact.present || (entry.stage && fs.existsSync(entry.stage))) {
795
+ invalidRestoreJournal(journalPath, `committed ${name} publication state is stale`);
796
+ }
797
+ if (fs.existsSync(entry.quarantine) && !matchesFingerprint(entry.quarantine, entry.originalFingerprint)) {
798
+ invalidRestoreJournal(journalPath, `committed ${name} quarantine does not match the original generation`);
799
+ }
800
+ if (entry.sidecars.some((sidecar) => fs.existsSync(sidecar.destination))) {
801
+ invalidRestoreJournal(journalPath, `committed ${name} still has a live sidecar`);
802
+ }
803
+ for (const sidecar of entry.sidecars) {
804
+ if (fs.existsSync(sidecar.quarantine) && !matchesFingerprint(sidecar.quarantine, sidecar.originalFingerprint)) {
805
+ invalidRestoreJournal(journalPath, `committed ${name} sidecar quarantine is not the original generation`);
806
+ }
807
+ }
808
+ verifyArtifactAgainstManifest(entry.destination, name, artifact, "Committed restore publication");
809
+ continue;
810
+ }
811
+ const destinationPresent = fs.existsSync(entry.destination);
812
+ const quarantinePresent = fs.existsSync(entry.quarantine);
813
+ if (entry.originalPresent ? !destinationPresent && !quarantinePresent : quarantinePresent) {
814
+ invalidRestoreJournal(journalPath, `prepared ${name} original state is stale`);
815
+ }
816
+ if (quarantinePresent && !matchesFingerprint(entry.quarantine, entry.originalFingerprint)) {
817
+ invalidRestoreJournal(journalPath, `prepared ${name} quarantine does not match the original generation`);
818
+ }
819
+ if (entry.stage) {
820
+ const stagePresent = fs.existsSync(entry.stage);
821
+ const rolledBack = !quarantinePresent && !stagePresent && matchesFingerprint(entry.destination, entry.originalFingerprint);
822
+ const validPublicationState = rolledBack ||
823
+ (entry.originalPresent
824
+ ? quarantinePresent
825
+ ? destinationPresent !== stagePresent
826
+ : destinationPresent && stagePresent && matchesFingerprint(entry.destination, entry.originalFingerprint)
827
+ : destinationPresent !== stagePresent);
828
+ if (!validPublicationState) {
829
+ invalidRestoreJournal(journalPath, `prepared ${name} publication state is stale`);
830
+ }
831
+ }
832
+ else {
833
+ const rolledBack = !quarantinePresent && matchesFingerprint(entry.destination, entry.originalFingerprint);
834
+ const forwardState = entry.originalPresent
835
+ ? quarantinePresent
836
+ ? !destinationPresent
837
+ : destinationPresent && matchesFingerprint(entry.destination, entry.originalFingerprint)
838
+ : !destinationPresent;
839
+ if (!rolledBack && !forwardState) {
840
+ invalidRestoreJournal(journalPath, `prepared absent ${name} has an impossible publication state`);
841
+ }
842
+ }
843
+ for (const sidecar of entry.sidecars) {
844
+ const live = fs.existsSync(sidecar.destination);
845
+ const quarantined = fs.existsSync(sidecar.quarantine);
846
+ const authenticated = quarantined
847
+ ? matchesFingerprint(sidecar.quarantine, sidecar.originalFingerprint)
848
+ : matchesFingerprint(sidecar.destination, sidecar.originalFingerprint);
849
+ if ((sidecar.originalPresent ? live === quarantined : live || quarantined) || !authenticated) {
850
+ invalidRestoreJournal(journalPath, `prepared ${name} sidecar state is stale`);
851
+ }
852
+ }
853
+ }
854
+ return {
855
+ formatVersion: RESTORE_JOURNAL_FORMAT_VERSION,
856
+ version: MIGRATION_BACKUP_VERSION,
857
+ operationId,
858
+ sourceRunId,
859
+ rescueRunId: raw.rescueRunId,
860
+ phase: raw.phase,
861
+ entries,
862
+ };
863
+ }
864
+ function rollbackRestoreJournal(journal) {
865
+ for (const [index, entry] of journal.entries.entries()) {
866
+ if (fs.existsSync(entry.quarantine)) {
867
+ fs.rmSync(entry.destination, { force: true });
868
+ fs.renameSync(entry.quarantine, entry.destination);
869
+ }
870
+ else if (!entry.originalPresent) {
871
+ fs.rmSync(entry.destination, { force: true });
872
+ }
873
+ rollbackBoundary(`${index}:destination`);
874
+ if (entry.stage)
875
+ fs.rmSync(entry.stage, { force: true });
876
+ rollbackBoundary(`${index}:stage`);
877
+ for (const [sidecarIndex, sidecar] of entry.sidecars.entries()) {
878
+ if (fs.existsSync(sidecar.quarantine)) {
879
+ fs.rmSync(sidecar.destination, { force: true });
880
+ fs.renameSync(sidecar.quarantine, sidecar.destination);
881
+ }
882
+ else if (!sidecar.originalPresent) {
883
+ fs.rmSync(sidecar.destination, { force: true });
884
+ }
885
+ rollbackBoundary(`${index}:sidecar:${sidecarIndex}`);
886
+ }
887
+ }
888
+ for (const directory of new Set(journal.entries.map((entry) => path.dirname(entry.destination)))) {
889
+ fsyncDirectory(directory);
890
+ }
891
+ rollbackBoundary("before-journal-delete");
892
+ fs.rmSync(restoreJournalPath(), { force: true });
893
+ fsyncDirectory(path.dirname(restoreJournalPath()));
894
+ }
895
+ function cleanupCommittedRestore(journal) {
896
+ const directories = new Set();
897
+ for (const entry of journal.entries) {
898
+ directories.add(path.dirname(entry.destination));
899
+ fs.rmSync(entry.quarantine, { force: true });
900
+ if (entry.stage)
901
+ fs.rmSync(entry.stage, { force: true });
902
+ for (const sidecar of entry.sidecars)
903
+ fs.rmSync(sidecar.quarantine, { force: true });
904
+ }
905
+ for (const directory of directories)
906
+ fsyncDirectory(directory);
907
+ fs.rmSync(restoreJournalPath(), { force: true });
908
+ fsyncDirectory(path.dirname(restoreJournalPath()));
909
+ }
910
+ function recoverInterruptedRestore() {
911
+ const journalPath = restoreJournalPath();
912
+ if (!fs.existsSync(journalPath))
913
+ return;
914
+ let raw;
915
+ try {
916
+ raw = JSON.parse(readTextFileWithLimit(journalPath, MAX_LOCAL_METADATA_BYTES, "Restore journal"));
917
+ }
918
+ catch (error) {
919
+ invalidRestoreJournal(journalPath, error instanceof Error ? error.message : String(error));
920
+ }
921
+ const journal = validateRestoreJournal(raw, journalPath);
922
+ if (journal.phase === "committed")
923
+ cleanupCommittedRestore(journal);
924
+ else
925
+ rollbackRestoreJournal(journal);
926
+ }
927
+ /** Caller must hold the config lock and maintenance barrier. */
928
+ export function recoverInterruptedRestoreWithLocksHeld() {
929
+ assertNoArtifactReplacementBlockers();
930
+ recoverInterruptedRestore();
931
+ }
932
+ function resolveBackupRun(runId) {
933
+ if (runId)
934
+ return getMigrationBackupDir(runId);
935
+ const root = getMigrationBackupRoot();
936
+ const candidates = fs.existsSync(root)
937
+ ? fs
938
+ .readdirSync(root, { withFileTypes: true })
939
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
940
+ .map((entry) => ({ name: entry.name, mtime: fs.statSync(path.join(root, entry.name)).mtimeMs }))
941
+ .sort((left, right) => left.mtime - right.mtime || left.name.localeCompare(right.name))
942
+ : [];
943
+ const latest = candidates.at(-1)?.name;
944
+ if (!latest)
945
+ throw new ConfigError(`No migration backup runs exist under ${root}.`, "INVALID_CONFIG_FILE");
946
+ return getMigrationBackupDir(latest);
947
+ }
948
+ function inspectArtifactAt(name, filePath) {
949
+ return name === "config.json"
950
+ ? inspectConfig(filePath)
951
+ : inspectSqlite(filePath, name === "state.db" ? STATE_MIGRATIONS : WORKFLOW_MIGRATIONS);
952
+ }
953
+ function replaceArtifactsFromBundle(bundlePath, manifest, rescueRunId) {
954
+ const operationId = `${process.pid}-${crypto.randomBytes(6).toString("hex")}`;
955
+ const entries = [];
956
+ let journal;
957
+ let committed = false;
958
+ try {
959
+ for (const name of ARTIFACT_NAMES) {
960
+ const artifact = manifest.artifacts[name];
961
+ const destination = artifact.sourcePath;
962
+ const stage = artifact.present ? `${destination}.restore-stage.${operationId}` : undefined;
963
+ if (stage) {
964
+ fs.rmSync(stage, { force: true });
965
+ copyFileDurable(path.join(bundlePath, name), stage);
966
+ if (sha256File(stage) !== artifact.sha256 || !sameState(inspectArtifactAt(name, stage), artifact)) {
967
+ throw new ConfigError(`Staged restore artifact ${name} failed verification.`, "INVALID_CONFIG_FILE");
968
+ }
969
+ }
970
+ entries.push({
971
+ destination,
972
+ stage,
973
+ originalPresent: fs.existsSync(destination),
974
+ originalFingerprint: fingerprintFile(destination),
975
+ quarantine: `${destination}.restore-quarantine.${operationId}`,
976
+ sidecars: name === "config.json"
977
+ ? []
978
+ : ["-wal", "-shm"].map((suffix) => ({
979
+ destination: `${destination}${suffix}`,
980
+ originalPresent: fs.existsSync(`${destination}${suffix}`),
981
+ originalFingerprint: fingerprintFile(`${destination}${suffix}`),
982
+ quarantine: `${destination}${suffix}.restore-quarantine.${operationId}`,
983
+ })),
984
+ });
985
+ }
986
+ journal = {
987
+ formatVersion: RESTORE_JOURNAL_FORMAT_VERSION,
988
+ version: MIGRATION_BACKUP_VERSION,
989
+ operationId,
990
+ sourceRunId: manifest.runId,
991
+ rescueRunId,
992
+ phase: "prepared",
993
+ entries,
994
+ };
995
+ writeRestoreJournal(journal);
996
+ for (const entry of entries) {
997
+ fs.mkdirSync(path.dirname(entry.destination), { recursive: true, mode: 0o700 });
998
+ if (entry.originalPresent)
999
+ fs.renameSync(entry.destination, entry.quarantine);
1000
+ for (const sidecar of entry.sidecars) {
1001
+ if (sidecar.originalPresent)
1002
+ fs.renameSync(sidecar.destination, sidecar.quarantine);
1003
+ }
1004
+ }
1005
+ for (const entry of entries) {
1006
+ if (entry.stage) {
1007
+ fs.renameSync(entry.stage, entry.destination);
1008
+ fs.chmodSync(entry.destination, 0o600);
1009
+ fsyncDirectory(path.dirname(entry.destination));
1010
+ }
1011
+ }
1012
+ for (const name of ARTIFACT_NAMES) {
1013
+ const artifact = manifest.artifacts[name];
1014
+ const actual = artifact.present ? inspectArtifactAt(name, artifact.sourcePath) : { status: "missing" };
1015
+ if (!sameState(actual, artifact)) {
1016
+ throw new ConfigError(`Published restore artifact ${name} failed final verification.`, "INVALID_CONFIG_FILE");
1017
+ }
1018
+ }
1019
+ journal.phase = "committed";
1020
+ writeRestoreJournal(journal);
1021
+ committed = true;
1022
+ cleanupCommittedRestore(journal);
1023
+ }
1024
+ catch (error) {
1025
+ if (!committed) {
1026
+ rollbackRestoreJournal(journal ?? {
1027
+ operationId,
1028
+ formatVersion: RESTORE_JOURNAL_FORMAT_VERSION,
1029
+ version: MIGRATION_BACKUP_VERSION,
1030
+ sourceRunId: manifest.runId,
1031
+ rescueRunId,
1032
+ phase: "prepared",
1033
+ entries,
1034
+ });
1035
+ }
1036
+ throw error;
1037
+ }
1038
+ }
1039
+ /** Caller must hold the config lock and maintenance barrier. */
1040
+ export function restoreMigrationBackupWithLocksHeld(bundlePath) {
1041
+ assertNoArtifactReplacementBlockers(bundlePath);
1042
+ recoverInterruptedRestore();
1043
+ const manifest = verifyMigrationBackup(bundlePath);
1044
+ replaceArtifactsFromBundle(bundlePath, manifest, "migration-apply-rollback");
1045
+ }
1046
+ export function restoreMigrationBackup(confirm, runId) {
1047
+ if (!confirm)
1048
+ throw new ConfigError("Migration backup restore requires --confirm.", "INVALID_CONFIG_FILE");
1049
+ const bundlePath = resolveBackupRun(runId);
1050
+ return withConfigLock(() => withMigrationBackupLock(() => withMaintenanceStartBarrier(() => {
1051
+ if (fs.existsSync(getMigrationApplyJournalPath())) {
1052
+ throw new ConfigError(`Migration apply recovery is pending at ${getMigrationApplyJournalPath()}; run \`akm migrate apply\` before restore.`, "INVALID_CONFIG_FILE");
1053
+ }
1054
+ assertNoArtifactReplacementBlockers(bundlePath);
1055
+ recoverInterruptedRestore();
1056
+ const manifest = verifyMigrationBackup(bundlePath);
1057
+ const rescue = createMigrationBackupUnlocked();
1058
+ replaceArtifactsFromBundle(bundlePath, manifest, rescue.manifest.runId);
1059
+ return { path: bundlePath, created: false, manifest, rescuePath: rescue.path };
1060
+ })));
416
1061
  }