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.
- package/CHANGELOG.md +34 -8
- package/dist/assets/improve-strategies/catchup.json +3 -1
- package/dist/assets/improve-strategies/consolidate.json +3 -1
- package/dist/assets/improve-strategies/frequent.json +3 -1
- package/dist/assets/improve-strategies/graph-refresh.json +3 -1
- package/dist/assets/improve-strategies/memory-focus.json +4 -1
- package/dist/assets/improve-strategies/proactive-maintenance.json +1 -0
- package/dist/assets/improve-strategies/quick.json +3 -1
- package/dist/assets/improve-strategies/recombine-only.json +2 -0
- package/dist/assets/improve-strategies/reflect-distill.json +1 -0
- package/dist/assets/improve-strategies/synthesize.json +2 -0
- package/dist/assets/tasks/core/backup.yml +2 -2
- package/dist/cli/config-migrate.js +554 -26
- package/dist/cli.js +3 -1
- package/dist/commands/backup-cli.js +6 -4
- package/dist/commands/config-cli.js +10 -3
- package/dist/commands/feedback-cli.js +24 -7
- package/dist/commands/health/checks.js +94 -15
- package/dist/commands/health/surfaces.js +2 -1
- package/dist/commands/improve/anti-collapse.js +3 -3
- package/dist/commands/improve/collapse-detector.js +5 -5
- package/dist/commands/improve/consolidate.js +123 -66
- package/dist/commands/improve/distill/promote-memory.js +8 -6
- package/dist/commands/improve/distill/quality-gate.js +1 -1
- package/dist/commands/improve/distill-guards.js +1 -1
- package/dist/commands/improve/distill-promotion-policy.js +32 -26
- package/dist/commands/improve/distill.js +52 -36
- package/dist/commands/improve/eligibility.js +9 -8
- package/dist/commands/improve/extract-prompt.js +2 -2
- package/dist/commands/improve/extract.js +43 -11
- package/dist/commands/improve/improve-auto-accept.js +14 -28
- package/dist/commands/improve/improve-cli.js +4 -2
- package/dist/commands/improve/improve-strategies.js +2 -1
- package/dist/commands/improve/improve.js +49 -8
- package/dist/commands/improve/loop-stages.js +11 -6
- package/dist/commands/improve/preparation.js +51 -17
- package/dist/commands/improve/procedural.js +10 -6
- package/dist/commands/improve/recombine.js +31 -7
- package/dist/commands/improve/reflect.js +24 -14
- package/dist/commands/improve/salience.js +5 -1
- package/dist/commands/improve/source-identity.js +43 -0
- package/dist/commands/migrate-cli.js +36 -0
- package/dist/commands/mv-cli.js +647 -258
- package/dist/commands/proposal/drain.js +16 -4
- package/dist/commands/proposal/proposal-cli.js +3 -2
- package/dist/commands/proposal/proposal.js +16 -55
- package/dist/commands/proposal/repository.js +664 -58
- package/dist/commands/read/curate.js +4 -2
- package/dist/commands/read/knowledge.js +4 -1
- package/dist/commands/read/search.js +6 -2
- package/dist/commands/read/show.js +8 -7
- package/dist/commands/sources/self-update.js +156 -112
- package/dist/commands/sources/sources-cli.js +7 -2
- package/dist/core/common.js +36 -3
- package/dist/core/config/config-io.js +2 -2
- package/dist/core/config/config-schema.js +15 -3
- package/dist/core/config/config.js +14 -13
- package/dist/core/file-lock.js +2 -2
- package/dist/core/migration-backup.js +816 -171
- package/dist/core/migration-operation.js +44 -0
- package/dist/core/state/migrations.js +4 -7
- package/dist/core/state-db.js +22 -7
- package/dist/core/write-source.js +86 -18
- package/dist/indexer/db/db.js +109 -53
- package/dist/indexer/index-writer-lock.js +38 -37
- package/dist/indexer/index-written-assets.js +73 -56
- package/dist/indexer/indexer.js +5 -1
- package/dist/indexer/search/search-source.js +2 -2
- package/dist/indexer/usage/usage-events.js +8 -2
- package/dist/integrations/agent/engine-resolution.js +14 -7
- package/dist/scripts/migrate-storage.js +867 -990
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +405 -566
- package/dist/setup/detected-engines.js +7 -13
- package/dist/setup/engine-config.js +14 -4
- package/dist/setup/setup.js +1 -1
- package/dist/setup/steps/connection.js +1 -1
- package/dist/setup/steps/tasks.js +1 -1
- package/dist/sources/providers/git-stash.js +58 -21
- package/dist/sources/providers/git.js +1 -1
- package/dist/storage/engines/sqlite-migrations.js +138 -3
- package/dist/storage/repositories/events-repository.js +24 -0
- package/dist/storage/repositories/proposals-repository.js +14 -0
- package/dist/tasks/embedded.js +2 -0
- package/dist/workflows/db.js +24 -13
- package/dist/workflows/validator.js +24 -2
- package/docs/migration/release-notes/0.9.0.md +24 -7
- package/docs/migration/v0.8-to-v0.9.md +124 -19
- package/package.json +1 -1
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
18
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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(
|
|
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)}
|
|
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.
|
|
69
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
363
|
+
copyFileDurable(sourcePath, destination);
|
|
239
364
|
else
|
|
240
365
|
backupSqlite(sourcePath, destination);
|
|
241
366
|
}
|
|
242
|
-
const
|
|
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 = {
|
|
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(
|
|
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
|
|
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
|
-
|
|
497
|
+
const configPaths = [getConfigPath(), ...(bundlePath ? [path.join(bundlePath, "config.json")] : [])];
|
|
498
|
+
for (const configPath of configPaths) {
|
|
297
499
|
try {
|
|
298
|
-
const raw = parseConfigText(
|
|
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
|
-
//
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
333
|
-
|
|
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 (
|
|
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
|
|
358
|
-
|
|
359
|
-
.
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
.
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
if (!
|
|
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
|
|
371
|
-
if (
|
|
563
|
+
const remaining = maxSamples - blockers.length;
|
|
564
|
+
if (remaining <= 0)
|
|
372
565
|
return blockers;
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
.
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
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
|
}
|