ai-foreman 1.7.13 → 1.7.14

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 (47) hide show
  1. package/README.md +33 -3
  2. package/dist/adapters/claude.d.ts +5 -0
  3. package/dist/adapters/claude.js +122 -16
  4. package/dist/adapters/codex.d.ts +2 -0
  5. package/dist/adapters/codex.js +30 -8
  6. package/dist/adapters/recovering.d.ts +2 -0
  7. package/dist/adapters/recovering.js +6 -0
  8. package/dist/adapters/types.d.ts +22 -0
  9. package/dist/agentRun.js +21 -3
  10. package/dist/branch/currentGuard.d.ts +2 -0
  11. package/dist/branch/currentGuard.js +9 -0
  12. package/dist/branch/finalization.d.ts +22 -0
  13. package/dist/branch/finalization.js +120 -0
  14. package/dist/branch/git.js +7 -2
  15. package/dist/branch/runner.d.ts +3 -2
  16. package/dist/branch/runner.js +277 -75
  17. package/dist/buildRuns.d.ts +1 -0
  18. package/dist/buildRuns.js +33 -3
  19. package/dist/cli/start.js +314 -86
  20. package/dist/cli/state.d.ts +4 -0
  21. package/dist/cli/state.js +69 -0
  22. package/dist/continuity.d.ts +4 -0
  23. package/dist/continuity.js +26 -3
  24. package/dist/foreman.d.ts +22 -4
  25. package/dist/foreman.js +105 -19
  26. package/dist/handoffs.d.ts +5 -2
  27. package/dist/handoffs.js +93 -12
  28. package/dist/index.js +2 -0
  29. package/dist/qaFailureDelivery.d.ts +100 -0
  30. package/dist/qaFailureDelivery.js +570 -0
  31. package/dist/qaProtocolV2.d.ts +233 -0
  32. package/dist/qaProtocolV2.js +114 -0
  33. package/dist/qaRecovery.d.ts +15 -3
  34. package/dist/qaRecovery.js +477 -83
  35. package/dist/qaReview.d.ts +42 -17
  36. package/dist/qaReview.js +1216 -457
  37. package/dist/qaRuntime.d.ts +14 -0
  38. package/dist/qaRuntime.js +44 -0
  39. package/dist/qaSnapshot.d.ts +34 -0
  40. package/dist/qaSnapshot.js +302 -34
  41. package/dist/stateTransfer.d.ts +57 -0
  42. package/dist/stateTransfer.js +644 -0
  43. package/dist/statusReporter.d.ts +2 -0
  44. package/dist/statusReporter.js +6 -0
  45. package/dist/workflowDb.d.ts +245 -2
  46. package/dist/workflowDb.js +647 -6
  47. package/package.json +15 -3
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { execFileSync } from "node:child_process";
3
- import { chmodSync, copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, readdirSync, realpathSync, renameSync, writeFileSync, } from "node:fs";
3
+ import { chmodSync, closeSync, cpSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readlinkSync, readdirSync, realpathSync, renameSync, writeFileSync, } from "node:fs";
4
4
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
5
5
  import { parseQaFailureReport } from "rafi-spec";
6
6
  import { calculateFrozenQaStateDigest, captureFrozenQaSource } from "./qaSnapshot.js";
@@ -22,11 +22,13 @@ export function loadQaRecoveryPacket(directory) {
22
22
  if (!existsSync(root) || realpathSync(root) !== root)
23
23
  throw new Error(`symlink is not allowed in recovery path: ${root}`);
24
24
  assertNoSymlinkComponents(root, root);
25
- let raw = JSON.parse(readFileSync(join(root, "manifest.json"), "utf8"));
25
+ const manifestPath = join(root, "manifest.json");
26
+ if (!existsSync(manifestPath) || lstatSync(manifestPath).isSymbolicLink())
27
+ throw new Error("missing or unsafe QA recovery manifest");
28
+ let raw = JSON.parse(readFileSync(manifestPath, "utf8"));
26
29
  if (raw.version === 1)
27
30
  throw new LegacyQaRecoveryPacketError(root, raw);
28
- const publishedRevision = raw.revision;
29
- raw = recoverInterruptedPublication(root, raw);
31
+ assertPrivateTree(root);
30
32
  assertManifestShape(raw);
31
33
  for (const resource of raw.resources)
32
34
  validateResource(root, resource);
@@ -54,32 +56,7 @@ export function loadQaRecoveryPacket(directory) {
54
56
  parent = item;
55
57
  }
56
58
  const projectDir = recoveryProjectRoot(root);
57
- const packet = { directory: root, projectDir, manifest: raw };
58
- if (raw.revision !== publishedRevision)
59
- persistPendingState(packet);
60
- return packet;
61
- }
62
- function recoverInterruptedPublication(root, initial) {
63
- let current = initial;
64
- while (existsSync(join(root, "manifests", revisionName(current.revision + 1)))) {
65
- const path = join(root, "manifests", revisionName(current.revision + 1));
66
- const candidate = JSON.parse(readFileSync(path, "utf8"));
67
- assertManifestShape(candidate);
68
- if (candidate.packetId !== current.packetId || candidate.revision !== current.revision + 1 || candidate.parentPacketDigest !== current.packetDigest) {
69
- throw new Error(`invalid interrupted QA recovery publication at revision ${current.revision + 1}`);
70
- }
71
- for (const resource of candidate.resources)
72
- validateResource(root, resource);
73
- validateReviewedStates(root, candidate);
74
- if (calculatePacketDigest(root, candidate) !== candidate.packetDigest)
75
- throw new Error(`invalid interrupted QA recovery digest at revision ${candidate.revision}`);
76
- // All referenced objects and projections are durable. Completing the one
77
- // missing atomic pointer replacement is safe and preserves append-only
78
- // lineage after a process interruption.
79
- atomicWrite(join(root, "manifest.json"), readFileSync(path));
80
- current = candidate;
81
- }
82
- return current;
59
+ return { directory: root, projectDir, manifest: raw };
83
60
  }
84
61
  export function inspectLegacyQaRecoveryPacket(directory) {
85
62
  const root = resolve(directory);
@@ -130,7 +107,7 @@ export function materializeLegacyQaHistoricalContext(projectDir, legacy) {
130
107
  }
131
108
  export function compareQaRecoveryReviewedState(packet, source) {
132
109
  const current = typeof source === "string" ? captureFrozenQaSource(source) : source;
133
- const originalInventory = sourceInventory(packet, "reviewed-state/original");
110
+ const originalInventory = sourceInventory(packet, currentReviewedStatePrefix(packet));
134
111
  const currentInventory = inventoryFromFrozen(current);
135
112
  const drift = deterministicDrift(originalInventory, currentInventory);
136
113
  return {
@@ -141,18 +118,55 @@ export function compareQaRecoveryReviewedState(packet, source) {
141
118
  frozenState: current,
142
119
  };
143
120
  }
121
+ function currentReviewedStatePrefix(packet) {
122
+ const matches = packet.manifest.resources.filter((resource) => resource.path.endsWith("/integrity.json") && resource.objectPath).filter((resource) => {
123
+ try {
124
+ const declared = JSON.parse(readFileSync(join(packet.directory, resource.objectPath), "utf8"));
125
+ return declared.digest === packet.manifest.currentReviewedStateDigest;
126
+ }
127
+ catch {
128
+ return false;
129
+ }
130
+ });
131
+ const selected = matches.at(-1);
132
+ if (!selected)
133
+ throw new Error("current reviewed-state inventory is missing");
134
+ return selected.path.slice(0, -"/integrity.json".length);
135
+ }
144
136
  /** Create a unique owner-only V2 packet from the immutable state QA reviewed. */
145
137
  export function createQaRecoveryPacket(input) {
146
138
  ensureQaRecoveryExcluded(input.projectDir);
147
139
  const frozen = input.frozenState ?? captureFrozenQaSource(input.reviewedWorktree ?? input.projectDir);
148
140
  const reviewAttemptId = input.reviewAttemptId ?? randomUUID();
149
141
  const root = containedRecoveryDirectory(input.projectDir, input.runId, input.ticketId, reviewAttemptId);
150
- if (existsSync(root))
151
- throw new Error(`QA recovery packet directory already exists: ${root}`);
152
- mkdirSync(root, { recursive: true, mode: 0o700 });
142
+ if (existsSync(root)) {
143
+ assertPrivatePath(root, true);
144
+ const db = new WorkflowDb(input.projectDir);
145
+ let durableProjections;
146
+ try {
147
+ durableProjections = db.qaPacketProjectionsAtPath(root);
148
+ }
149
+ finally {
150
+ db.close();
151
+ }
152
+ if (durableProjections.length > 0) {
153
+ throw new Error(`QA recovery packet directory has a durable publication record and must be reconciled, not replaced: ${root}`);
154
+ }
155
+ const manifests = join(root, "manifests");
156
+ if (existsSync(join(root, "manifest.json")) || (existsSync(manifests) && readdirSync(manifests).some((name) => name.endsWith(".json")))) {
157
+ throw new Error(`QA recovery packet directory already exists with published content: ${root}`);
158
+ }
159
+ // A crash before publication intent may leave only staged objects. Keep
160
+ // those bytes for forensics, but move them out of the deterministic retry
161
+ // path so the same durable review attempt can be packetized safely.
162
+ const abandoned = `${root}.abandoned-${randomUUID()}`;
163
+ renameSync(root, abandoned);
164
+ fsyncDirectory(dirname(root));
165
+ }
166
+ durableMkdir(root);
153
167
  chmodRecoveryDirectoryChain(input.projectDir, root);
154
- mkdirSync(join(root, "objects"), { mode: 0o700 });
155
- mkdirSync(join(root, "manifests"), { mode: 0o700 });
168
+ durableMkdir(join(root, "objects"));
169
+ durableMkdir(join(root, "manifests"));
156
170
  const resources = [];
157
171
  for (const [name, resource] of Object.entries(input.resources)) {
158
172
  const path = join("context", `${safeSlug(name)}.${resource.exactText ? "txt" : "json"}`).replaceAll("\\", "/");
@@ -185,11 +199,153 @@ export function createQaRecoveryPacket(input) {
185
199
  };
186
200
  return publish(root, resolve(input.projectDir), draft);
187
201
  }
202
+ /**
203
+ * Finish a packet publication whose immutable files reached disk before the
204
+ * authoritative SQLite head was advanced. This is deliberately explicit:
205
+ * ordinary packet loading remains a read-only integrity check.
206
+ */
207
+ export function reconcileQaRecoveryPacketPublication(packet) {
208
+ const loaded = loadQaRecoveryPacket(packet.directory);
209
+ const db = new WorkflowDb(loaded.projectDir);
210
+ try {
211
+ const projection = db.qaPacketProjection(loaded.manifest.packetDigest);
212
+ if (!projection || resolve(projection.path) !== loaded.directory || projection.runId !== loaded.manifest.runId
213
+ || projection.ticketId !== loaded.manifest.ticketId || projection.packetRevision !== loaded.manifest.revision) {
214
+ throw new Error("QA recovery packet has no matching durable publication intent");
215
+ }
216
+ const head = db.qaRecoveryHead(loaded.manifest.runId, loaded.manifest.ticketId);
217
+ if (head?.packetDigest === loaded.manifest.packetDigest && head.revision === loaded.manifest.revision) {
218
+ if (projection.status === "intended")
219
+ db.finishQaPacketProjection(loaded.manifest.runId, loaded.manifest.ticketId, loaded.manifest.packetDigest);
220
+ return loaded;
221
+ }
222
+ if (projection.status !== "intended")
223
+ throw new Error("published QA recovery projection disagrees with its durable recovery head");
224
+ if (head) {
225
+ if (head.packetId !== loaded.manifest.packetId || loaded.manifest.revision !== head.revision + 1
226
+ || loaded.manifest.parentPacketDigest !== head.packetDigest) {
227
+ throw new Error("QA recovery packet cannot be reconciled across a non-contiguous durable head");
228
+ }
229
+ }
230
+ else if (loaded.manifest.revision !== 1 || loaded.manifest.parentPacketDigest !== undefined) {
231
+ throw new Error("initial QA recovery packet publication is not revision one");
232
+ }
233
+ }
234
+ finally {
235
+ db.close();
236
+ }
237
+ persistPendingState(loaded);
238
+ const completed = new WorkflowDb(loaded.projectDir);
239
+ try {
240
+ completed.finishQaPacketProjection(loaded.manifest.runId, loaded.manifest.ticketId, loaded.manifest.packetDigest);
241
+ }
242
+ finally {
243
+ completed.close();
244
+ }
245
+ return loaded;
246
+ }
247
+ /** Recover only packet files named by an existing SQLite publication intent. */
248
+ export function recoverPendingQaRecoveryPublications(projectDir, runId) {
249
+ const root = realpathSync(resolve(projectDir));
250
+ const db = new WorkflowDb(root);
251
+ let pending;
252
+ try {
253
+ pending = db.pendingQaPacketProjections(runId);
254
+ }
255
+ finally {
256
+ db.close();
257
+ }
258
+ const recovered = [];
259
+ for (const projection of pending) {
260
+ const directory = resolve(projection.path);
261
+ if (!directory.startsWith(`${root}${sep}`))
262
+ throw new Error("QA packet publication intent escapes the project root");
263
+ assertPublicationTree(root, directory);
264
+ const manifestPath = join(directory, "manifest.json");
265
+ if (!existsSync(manifestPath)) {
266
+ restoreIntendedRevision(directory, projection);
267
+ promoteIntendedRevision(directory, projection.packetRevision, projection.packetDigest);
268
+ }
269
+ else {
270
+ const current = loadQaRecoveryPacket(directory);
271
+ if (current.manifest.packetDigest !== projection.packetDigest) {
272
+ if (projection.packetRevision !== current.manifest.revision + 1)
273
+ throw new Error("pending QA packet revision is not contiguous with its current manifest");
274
+ restoreIntendedRevision(directory, projection, current.manifest);
275
+ promoteIntendedRevision(directory, projection.packetRevision, projection.packetDigest, current.manifest);
276
+ }
277
+ }
278
+ recovered.push(reconcileQaRecoveryPacketPublication(loadQaRecoveryPacket(directory)));
279
+ }
280
+ // A new review is committed after its frozen source/intent reaches the
281
+ // packet, and before the packet's review identity advances. Reconcile only
282
+ // that exact, still-undispatched allocation; never infer a successor merely
283
+ // because its review number is adjacent.
284
+ const reviewDb = new WorkflowDb(root);
285
+ try {
286
+ for (const pendingHead of reviewDb.pendingQaRecoveryHeads(runId)) {
287
+ const head = reviewDb.qaTicketHead(runId, pendingHead.ticketId);
288
+ if (head.state !== "review-ready")
289
+ continue;
290
+ const packet = loadQaRecoveryPacket(pendingHead.packetPath);
291
+ if (head.reviewNumber !== packet.manifest.reviewAttempt + 1 || head.sourceStateDigest !== packet.manifest.reviewedStateDigest)
292
+ continue;
293
+ const attempt = reviewDb.qaReviewAttempts(runId, pendingHead.ticketId).find((item) => item.reviewNumber === head.reviewNumber);
294
+ if (!attempt || attempt.status !== "started" || attempt.sourceDigest !== head.sourceStateDigest)
295
+ continue;
296
+ const resource = packet.manifest.resources.find((item) => item.path === `review-transitions/${attempt.attemptId}.json`);
297
+ if (!resource?.objectPath || resource.integrityPolicy !== "sealed")
298
+ continue;
299
+ const intent = JSON.parse(readFileSync(join(packet.directory, resource.objectPath), "utf8"));
300
+ if (intent.runId !== runId || intent.ticketId !== pendingHead.ticketId
301
+ || intent.predecessorReviewNumber !== packet.manifest.reviewAttempt || intent.predecessorAttemptId !== packet.manifest.reviewAttemptId
302
+ || intent.reviewNumber !== head.reviewNumber || intent.reviewAttemptId !== attempt.attemptId
303
+ || intent.sourceStateDigest !== head.sourceStateDigest || intent.cycle !== attempt.cycle) {
304
+ throw new Error("QA packet next-review intent does not match its committed successor");
305
+ }
306
+ recovered.push(updateQaRecoveryReviewIdentity(packet, head.reviewNumber, attempt.attemptId, attempt.cycle));
307
+ }
308
+ }
309
+ finally {
310
+ reviewDb.close();
311
+ }
312
+ // A pass/waiver is committed in SQLite before the derived packet projection
313
+ // is marked resolved. Complete that one-way projection after a crash so a
314
+ // terminal QA decision can never leave a resumable packet behind.
315
+ const terminalDb = new WorkflowDb(root);
316
+ let terminalPackets = [];
317
+ try {
318
+ terminalPackets = terminalDb.pendingQaRecoveryHeads(runId)
319
+ .filter((head) => ["passed", "waived", "finalizing", "completed"].includes(terminalDb.qaTicketHead(runId, head.ticketId).state))
320
+ .map((head) => ({ packetPath: head.packetPath, correctionTurns: head.correctionTurns }));
321
+ }
322
+ finally {
323
+ terminalDb.close();
324
+ }
325
+ for (const terminal of terminalPackets) {
326
+ const resolved = updateQaRecoveryPosition(loadQaRecoveryPacket(terminal.packetPath), "resolved", terminal.correctionTurns, "resolved");
327
+ recovered.push(resolved);
328
+ }
329
+ return recovered;
330
+ }
188
331
  export function appendQaRecoveryResource(packet, relativePath, value, options) {
189
332
  const requested = safeRelative(relativePath);
190
- const path = uniqueResourcePath(packet.manifest.resources, requested);
191
333
  const bytes = Buffer.isBuffer(value) ? value : encodeValue(value, Boolean(options.exact));
192
- const resource = storeSealed(packet.directory, path, bytes, options.purpose, options.mediaType ?? (options.exact ? "text/plain" : "application/json"), options.requiredForRecovery ?? true);
334
+ const mediaType = options.mediaType ?? (options.exact ? "text/plain" : "application/json");
335
+ const requiredForRecovery = options.requiredForRecovery ?? true;
336
+ const interruptedPath = join(packet.directory, "manifests", revisionName(packet.manifest.revision + 1));
337
+ if (existsSync(interruptedPath)) {
338
+ const candidate = readAndValidateRevision(packet.directory, packet.manifest.revision + 1, packet.manifest);
339
+ const added = candidate.resources.filter((resource) => !packet.manifest.resources.some((prior) => prior.path === resource.path));
340
+ const resource = added.length === 1 ? added[0] : undefined;
341
+ if (!resource || resource.path !== requested || resource.digest !== hash(bytes) || resource.bytes !== bytes.length
342
+ || resource.purpose !== options.purpose || resource.mediaType !== mediaType || resource.requiredForRecovery !== requiredForRecovery) {
343
+ throw new Error(`QA recovery revision already exists with different content: ${candidate.revision}`);
344
+ }
345
+ return recoverInterruptedRevision(packet, { resources: candidate.resources });
346
+ }
347
+ const path = uniqueResourcePath(packet.manifest.resources, requested, packet.directory);
348
+ const resource = storeSealed(packet.directory, path, bytes, options.purpose, mediaType, requiredForRecovery);
193
349
  return revise(packet, { resources: [...packet.manifest.resources, resource].sort(resourceOrder) });
194
350
  }
195
351
  export function appendQaRecoveryReviewedState(packet, source, prefix = "current-state") {
@@ -205,6 +361,33 @@ export function appendQaRecoveryReviewedState(packet, source, prefix = "current-
205
361
  export function updateQaRecoveryPosition(packet, stage, correctionTurns, pendingAction = "automatic-recovery") {
206
362
  return revise(packet, { recoveryStage: stage, correctionTurns, pendingAction });
207
363
  }
364
+ export function updateQaRecoveryReviewIdentity(packet, reviewAttempt, reviewAttemptId, cycle = packet.manifest.cycle) {
365
+ if (!Number.isSafeInteger(reviewAttempt) || reviewAttempt < 1 || !reviewAttemptId)
366
+ throw new Error("invalid QA recovery review identity");
367
+ return revise(packet, { reviewAttempt, attempt: reviewAttempt, reviewAttemptId, cycle });
368
+ }
369
+ /** Keep one unresolved packet and append the next malformed review's exact evidence. */
370
+ export function appendQaRecoveryAttempt(packet, input) {
371
+ if (packet.manifest.runId !== input.runId || packet.manifest.ticketId !== input.ticketId
372
+ || packet.manifest.reviewAttempt !== input.reviewAttempt || packet.manifest.reviewAttemptId !== input.reviewAttemptId
373
+ || !input.frozenState || packet.manifest.reviewedStateDigest !== input.frozenState.digest) {
374
+ throw new Error("QA recovery attempt does not match the active packet review/source identity");
375
+ }
376
+ let next = packet;
377
+ for (const [name, resource] of Object.entries(input.resources)) {
378
+ const path = `reviews/r${input.reviewAttempt}/context/${safeSlug(name)}.${resource.exactText ? "txt" : "json"}`;
379
+ next = appendQaRecoveryResource(next, path, resource.value, {
380
+ purpose: resource.purpose, mediaType: resource.mediaType, requiredForRecovery: resource.requiredForRecovery,
381
+ exact: Boolean(resource.exactText),
382
+ });
383
+ }
384
+ if (input.reportJson !== undefined) {
385
+ const report = writeEditableReport(packet.directory, Buffer.from(input.reportJson));
386
+ next = revise(next, { resources: [...next.manifest.resources.filter((resource) => resource.path !== "report.json"), report].sort(resourceOrder) });
387
+ }
388
+ return revise(next, { cycle: input.cycle, recoveryStage: input.recoveryStage,
389
+ correctionTurns: input.correctionTurns ?? 0, pendingAction: input.pendingAction ?? "automatic-recovery" });
390
+ }
208
391
  export function readManualQaReport(packetDirectory) { return JSON.parse(readFileSync(join(resolve(packetDirectory), "report.json"), "utf8")); }
209
392
  export function validateManualQaReport(packet) {
210
393
  const path = join(packet.directory, "report.json");
@@ -273,17 +456,29 @@ export function validateQaRecoveryAcknowledgement(text, packet, options = {}) {
273
456
  export function qaRecoveryInventory(packet) {
274
457
  return packet.manifest.resources.map((resource) => `${resource.path}\t${resource.purpose}\t${resource.mediaType}\t${resource.bytes}\t${resource.digest}\t${resource.integrityPolicy}`).join("\n");
275
458
  }
276
- export function qaReportDigest(report) { return hash(Buffer.from(JSON.stringify(report))); }
459
+ export function qaReportDigest(report) { return hash(Buffer.from(canonicalJson(report))); }
460
+ function canonicalJson(value) {
461
+ if (value === null || typeof value !== "object")
462
+ return JSON.stringify(value);
463
+ if (Array.isArray(value))
464
+ return `[${value.map(canonicalJson).join(",")}]`;
465
+ const object = value;
466
+ return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`).join(",")}}`;
467
+ }
277
468
  export function ensureQaRecoveryExcluded(projectDir) {
278
469
  let exclude;
470
+ let common;
279
471
  try {
280
- exclude = execFileSync("git", ["-C", resolve(projectDir), "rev-parse", "--git-path", "info/exclude"], { encoding: "utf8" }).trim();
472
+ exclude = execFileSync("git", ["-C", resolve(projectDir), "rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], { encoding: "utf8" }).trim();
473
+ common = execFileSync("git", ["-C", resolve(projectDir), "rev-parse", "--path-format=absolute", "--git-common-dir"], { encoding: "utf8" }).trim();
281
474
  }
282
475
  catch {
283
476
  return;
284
477
  }
285
- if (!resolve(exclude).startsWith(sep))
286
- exclude = resolve(projectDir, exclude);
478
+ exclude = resolve(exclude);
479
+ common = resolve(common);
480
+ if (exclude !== common && !exclude.startsWith(`${common}${sep}`))
481
+ throw new Error(`git exclude path escapes the target repository: ${exclude}`);
287
482
  const entries = [`/${QA_RECOVERY_ROOT}/`, `/${QA_CONTEXT_ROOT}/`];
288
483
  const current = existsSync(exclude) ? readFileSync(exclude, "utf8") : "";
289
484
  const missing = entries.filter((entry) => !current.split(/\r?\n/).includes(entry));
@@ -301,31 +496,29 @@ function storeFrozenState(root, state, prefix) {
301
496
  [`${prefix}/staged.diff`, state.stagedDiff, "application/octet-stream", "Exact staged tracked binary diff"],
302
497
  [`${prefix}/unstaged.diff`, state.unstagedDiff, "application/octet-stream", "Exact unstaged tracked binary diff"],
303
498
  [`${prefix}/change-summary.txt`, Buffer.from(`${state.changeSummary}\n`), "text/plain", "Deterministic path-level source summary"],
304
- [`${prefix}/integrity.json`, Buffer.from(`${stableJson({ digest: state.digest, capturedAt: state.capturedAt })}\n`), "application/json", "Reviewed-state integrity manifest"],
499
+ [`${prefix}/path-inventory.json`, Buffer.from(`${stableJson(state.pathInventory)}\n`), "application/json", "Structured staged, unstaged, and untracked source paths"],
500
+ [`${prefix}/repository.json`, Buffer.from(`${stableJson(state.repository)}\n`), "application/json", "Repository identity, refs, configuration, sparse-checkout, submodule, and index metadata"],
501
+ [`${prefix}/integrity.json`, Buffer.from(`${stableJson({ digest: state.digest, originDigest: state.originDigest, contentDigest: state.contentDigest, capturedAt: state.capturedAt })}\n`), "application/json", "Reviewed-state integrity manifest"],
305
502
  [`${prefix}/untracked-manifest.json`, Buffer.from(`${stableJson(inventory)}\n`), "application/json", "Untracked path, type, mode, digest, and object mapping"],
306
503
  ];
307
504
  state.untracked.forEach((item, index) => values.push([`${prefix}/untracked/${String(index).padStart(6, "0")}.bin`, item.bytes, "application/octet-stream", `Exact untracked ${item.kind} bytes for ${item.path}`]));
308
505
  return values.map(([path, bytes, mediaType, purpose]) => storeSealed(root, safeRelative(path), bytes, purpose, mediaType, true));
309
506
  }
310
507
  function sourceInventory(packet, prefix) {
311
- const resource = packet.manifest.resources.find((candidate) => candidate.path === `${prefix}/untracked-manifest.json`);
312
508
  const rows = new Map();
313
- for (const candidate of packet.manifest.resources.filter((item) => item.path.startsWith(`${prefix}/`) && !item.path.includes("/untracked/")))
314
- rows.set(candidate.path.slice(prefix.length + 1), candidate.digest);
315
- if (resource?.objectPath) {
316
- const inventory = JSON.parse(readFileSync(join(packet.directory, resource.objectPath), "utf8"));
317
- for (const item of inventory)
318
- rows.set(`untracked:${item.path}`, `${item.kind}:${item.mode}:${item.digest}`);
319
- }
509
+ const head = packet.manifest.resources.find((candidate) => candidate.path === `${prefix}/head.txt`);
510
+ if (head)
511
+ rows.set("HEAD", head.digest);
512
+ const resource = packet.manifest.resources.find((candidate) => candidate.path === `${prefix}/path-inventory.json`);
513
+ if (resource?.objectPath)
514
+ for (const item of JSON.parse(readFileSync(join(packet.directory, resource.objectPath), "utf8")))
515
+ rows.set(item.path, stableJson(item));
320
516
  return rows;
321
517
  }
322
518
  function inventoryFromFrozen(state) {
323
- const rows = new Map([
324
- ["head.txt", hash(Buffer.from(`${state.head}\n`))], ["status.bin", hash(state.status)], ["tracked-head.diff", hash(state.combinedDiff)],
325
- ["staged.diff", hash(state.stagedDiff)], ["unstaged.diff", hash(state.unstagedDiff)], ["change-summary.txt", hash(Buffer.from(`${state.changeSummary}\n`))],
326
- ]);
327
- for (const item of state.untracked)
328
- rows.set(`untracked:${item.path}`, `${item.kind}:${item.mode}:${item.digest}`);
519
+ const rows = new Map([["HEAD", hash(Buffer.from(`${state.head}\n`))]]);
520
+ for (const item of state.pathInventory)
521
+ rows.set(item.path, stableJson(item));
329
522
  return rows;
330
523
  }
331
524
  function deterministicDrift(before, after) {
@@ -334,17 +527,30 @@ function deterministicDrift(before, after) {
334
527
  function storeSealed(root, path, bytes, purpose, mediaType, requiredForRecovery) {
335
528
  const digest = hash(bytes);
336
529
  const objectPath = `objects/${digest}`;
530
+ // SQLite is authoritative. Persist the exact bytes before writing either
531
+ // the content-addressed cache object or its human-readable projection.
532
+ const evidenceDb = new WorkflowDb(recoveryProjectRoot(root));
533
+ try {
534
+ const durableDigest = evidenceDb.putEvidence("qa", bytes);
535
+ if (durableDigest !== digest)
536
+ throw new Error(`durable QA resource digest mismatch: ${path}`);
537
+ }
538
+ finally {
539
+ evidenceDb.close();
540
+ }
337
541
  const absolute = join(root, objectPath);
338
542
  if (!existsSync(absolute))
339
543
  atomicWrite(absolute, bytes);
340
544
  else if (hash(readFileSync(absolute)) !== digest)
341
545
  throw new Error(`content-addressed QA recovery object collision: ${digest}`);
342
546
  const projection = join(root, path);
343
- if (existsSync(projection))
344
- throw new Error(`QA recovery resource path already exists: ${path}`);
345
- mkdirSync(dirname(projection), { recursive: true, mode: 0o700 });
346
- copyFileSync(absolute, projection);
347
- chmodSync(projection, 0o600);
547
+ if (existsSync(projection)) {
548
+ const existing = readFileSync(projection);
549
+ if (existing.length !== bytes.length || hash(existing) !== digest)
550
+ throw new Error(`QA recovery resource path already exists with different content: ${path}`);
551
+ }
552
+ else
553
+ atomicWrite(projection, bytes);
348
554
  return { path, purpose, mediaType, bytes: bytes.length, digest, requiredForRecovery, integrityPolicy: "sealed", objectPath };
349
555
  }
350
556
  function writeEditableReport(root, bytes) {
@@ -361,6 +567,7 @@ function validateResource(root, resource) {
361
567
  const path = join(root, "report.json");
362
568
  if (!existsSync(path) || lstatSync(path).isSymbolicLink())
363
569
  throw new Error("missing or unsafe operator-editable report.json");
570
+ assertPrivatePath(path, false);
364
571
  return;
365
572
  }
366
573
  if (resource.integrityPolicy !== "sealed" || !resource.objectPath)
@@ -369,6 +576,7 @@ function validateResource(root, resource) {
369
576
  if (!objectPath.startsWith(`${root}${sep}`) || !existsSync(objectPath))
370
577
  throw new Error(`missing or unsafe QA recovery object: ${resource.path}`);
371
578
  assertNoSymlinkComponents(root, objectPath);
579
+ assertPrivatePath(objectPath, false);
372
580
  const bytes = readFileSync(objectPath);
373
581
  if (bytes.length !== resource.bytes || hash(bytes) !== resource.digest)
374
582
  throw new Error(`QA recovery resource digest mismatch: ${resource.path}`);
@@ -376,6 +584,7 @@ function validateResource(root, resource) {
376
584
  if (!projection.startsWith(`${root}${sep}`) || !existsSync(projection))
377
585
  throw new Error(`missing QA recovery resource projection: ${resource.path}`);
378
586
  assertNoSymlinkComponents(root, projection);
587
+ assertPrivatePath(projection, false);
379
588
  const projected = readFileSync(projection);
380
589
  if (projected.length !== resource.bytes || hash(projected) !== resource.digest)
381
590
  throw new Error(`QA recovery resource projection mismatch: ${resource.path}`);
@@ -394,7 +603,7 @@ function validateReviewedStates(root, manifest) {
394
603
  }
395
604
  }
396
605
  function validateReviewedState(root, manifest, prefix) {
397
- const required = ["head.txt", "status.bin", "tracked-head.diff", "staged.diff", "unstaged.diff", "change-summary.txt", "integrity.json", "untracked-manifest.json"];
606
+ const required = ["head.txt", "status.bin", "tracked-head.diff", "staged.diff", "unstaged.diff", "change-summary.txt", "path-inventory.json", "repository.json", "integrity.json", "untracked-manifest.json"];
398
607
  const byPath = new Map(manifest.resources.map((resource) => [resource.path, resource]));
399
608
  const bytes = (suffix) => {
400
609
  const resource = byPath.get(`${prefix}/${suffix}`);
@@ -420,6 +629,10 @@ function validateReviewedState(root, manifest, prefix) {
420
629
  return { path: item.path, kind: item.kind, mode, digest: item.digest, bytes: value };
421
630
  });
422
631
  const declared = JSON.parse(bytes("integrity.json").toString());
632
+ const repository = JSON.parse(bytes("repository.json").toString());
633
+ const pathInventory = JSON.parse(bytes("path-inventory.json").toString());
634
+ if (!Array.isArray(pathInventory) || pathInventory.some((item) => !item || typeof item.path !== "string" || !Array.isArray(item.staged) || !Array.isArray(item.unstaged)))
635
+ throw new Error(`invalid reviewed-state path inventory: ${prefix}`);
423
636
  const state = {
424
637
  head: bytes("head.txt").toString().trimEnd(),
425
638
  status: bytes("status.bin"),
@@ -427,7 +640,11 @@ function validateReviewedState(root, manifest, prefix) {
427
640
  stagedDiff: bytes("staged.diff"),
428
641
  unstagedDiff: bytes("unstaged.diff"),
429
642
  changeSummary: bytes("change-summary.txt").toString().replace(/\n$/, ""),
643
+ pathInventory,
430
644
  untracked,
645
+ repository,
646
+ originDigest: String(declared.originDigest ?? ""),
647
+ contentDigest: String(declared.contentDigest ?? ""),
431
648
  };
432
649
  const digest = calculateFrozenQaStateDigest(state);
433
650
  if (declared.digest !== digest)
@@ -438,6 +655,9 @@ function revise(packet, changes) {
438
655
  const current = loadQaRecoveryPacket(packet.directory);
439
656
  if (current.manifest.packetDigest !== packet.manifest.packetDigest)
440
657
  throw new Error("stale QA recovery packet revision cannot be appended");
658
+ const interrupted = recoverInterruptedRevision(current, changes);
659
+ if (interrupted)
660
+ return interrupted;
441
661
  const { packetDigest: _digest, ...base } = current.manifest;
442
662
  const draft = {
443
663
  ...base, ...changes,
@@ -447,18 +667,119 @@ function revise(packet, changes) {
447
667
  };
448
668
  return publish(packet.directory, packet.projectDir, draft);
449
669
  }
670
+ function recoverInterruptedRevision(packet, changes) {
671
+ const revision = packet.manifest.revision + 1;
672
+ const path = join(packet.directory, "manifests", revisionName(revision));
673
+ if (!existsSync(path))
674
+ return undefined;
675
+ const candidate = readAndValidateRevision(packet.directory, revision, packet.manifest);
676
+ const { packetDigest: _currentDigest, updatedAt: _currentUpdated, ...currentBase } = packet.manifest;
677
+ const { packetDigest: _candidateDigest, updatedAt: _candidateUpdated, ...candidateBase } = candidate;
678
+ const expected = { ...currentBase, ...changes, revision, parentPacketDigest: packet.manifest.packetDigest };
679
+ if (stableJson(candidateBase) !== stableJson(expected))
680
+ throw new Error(`QA recovery revision already exists with different content: ${revision}`);
681
+ promoteIntendedRevision(packet.directory, revision, candidate.packetDigest, packet.manifest);
682
+ return reconcileQaRecoveryPacketPublication(loadQaRecoveryPacket(packet.directory));
683
+ }
684
+ function promoteIntendedRevision(root, revision, expectedDigest, parent) {
685
+ assertPublicationTree(recoveryProjectRoot(root), root);
686
+ const candidate = readAndValidateRevision(root, revision, parent);
687
+ if (candidate.packetDigest !== expectedDigest)
688
+ throw new Error("QA recovery revision does not match its durable publication intent");
689
+ const projectDir = recoveryProjectRoot(root);
690
+ const db = new WorkflowDb(projectDir);
691
+ try {
692
+ const projection = db.qaPacketProjection(expectedDigest);
693
+ if (!projection || projection.status !== "intended" || resolve(projection.path) !== resolve(root)
694
+ || projection.runId !== candidate.runId || projection.ticketId !== candidate.ticketId || projection.packetRevision !== revision) {
695
+ throw new Error("QA recovery revision promotion lacks a matching durable publication intent");
696
+ }
697
+ }
698
+ finally {
699
+ db.close();
700
+ }
701
+ atomicWrite(join(root, "manifest.json"), readFileSync(join(root, "manifests", revisionName(revision))));
702
+ chmodTreeOwnerOnly(root);
703
+ }
704
+ /** Recreate a revision that was durably intended in SQLite but not yet fsynced to the packet tree. */
705
+ function restoreIntendedRevision(root, projection, parent) {
706
+ assertPublicationTree(recoveryProjectRoot(root), root);
707
+ const path = join(root, "manifests", revisionName(projection.packetRevision));
708
+ if (existsSync(path))
709
+ return;
710
+ if (!projection.manifestJson)
711
+ throw new Error("QA packet publication intent does not contain recoverable manifest bytes");
712
+ const candidate = JSON.parse(projection.manifestJson);
713
+ assertManifestShape(candidate);
714
+ if (candidate.packetDigest !== projection.packetDigest || candidate.revision !== projection.packetRevision
715
+ || candidate.runId !== projection.runId || candidate.ticketId !== projection.ticketId
716
+ || (parent ? candidate.packetId !== parent.packetId || candidate.parentPacketDigest !== parent.packetDigest
717
+ : candidate.revision !== 1 || candidate.parentPacketDigest !== undefined)) {
718
+ throw new Error("QA packet publication intent contains mismatched manifest bytes");
719
+ }
720
+ for (const resource of candidate.resources)
721
+ validateResource(root, resource);
722
+ validateReviewedStates(root, candidate);
723
+ if (calculatePacketDigest(root, candidate) !== candidate.packetDigest)
724
+ throw new Error("QA packet publication intent manifest digest mismatch");
725
+ atomicWrite(path, Buffer.from(projection.manifestJson));
726
+ }
727
+ function readAndValidateRevision(root, revision, parent) {
728
+ assertPrivateTree(root);
729
+ const path = join(root, "manifests", revisionName(revision));
730
+ if (!existsSync(path) || lstatSync(path).isSymbolicLink())
731
+ throw new Error(`QA recovery revision ${revision} is missing or unsafe`);
732
+ const candidate = JSON.parse(readFileSync(path, "utf8"));
733
+ assertManifestShape(candidate);
734
+ if (candidate.revision !== revision || (parent && candidate.packetId !== parent.packetId)
735
+ || (revision === 1 ? candidate.parentPacketDigest !== undefined : candidate.parentPacketDigest !== parent?.packetDigest)) {
736
+ throw new Error(`QA recovery revision lineage mismatch at revision ${revision}`);
737
+ }
738
+ for (const resource of candidate.resources)
739
+ validateResource(root, resource);
740
+ validateReviewedStates(root, candidate);
741
+ if (calculatePacketDigest(root, candidate) !== candidate.packetDigest)
742
+ throw new Error(`QA recovery revision digest mismatch at revision ${revision}`);
743
+ return candidate;
744
+ }
745
+ /** Validate ancestors before crash recovery writes into a persisted path. */
746
+ function assertPublicationTree(projectDir, directory) {
747
+ const project = realpathSync(resolve(projectDir));
748
+ const root = resolve(directory);
749
+ if (!root.startsWith(`${project}${sep}`) || !existsSync(root) || realpathSync(root) !== root)
750
+ throw new Error("unsafe or symlinked QA packet publication path");
751
+ assertNoSymlinkComponents(project, root);
752
+ assertPrivateTree(root);
753
+ }
450
754
  function publish(root, projectDir, draft) {
451
755
  const manifest = { ...draft, resources: draft.resources.slice().sort(resourceOrder), packetDigest: "" };
452
756
  manifest.packetDigest = calculatePacketDigest(root, manifest);
453
757
  const bytes = Buffer.from(`${stableJson(manifest)}\n`);
454
758
  const revisionPath = join(root, "manifests", revisionName(manifest.revision));
455
- if (existsSync(revisionPath))
456
- throw new Error(`QA recovery revision already exists: ${manifest.revision}`);
457
- atomicWrite(revisionPath, bytes);
759
+ const db = new WorkflowDb(projectDir);
760
+ try {
761
+ db.planQaPacketProjection({ runId: manifest.runId, ticketId: manifest.ticketId, qaRevision: manifest.revision, packetDigest: manifest.packetDigest, path: root, manifestJson: bytes.toString() });
762
+ }
763
+ finally {
764
+ db.close();
765
+ }
766
+ if (existsSync(revisionPath)) {
767
+ if (!readFileSync(revisionPath).equals(bytes))
768
+ throw new Error(`QA recovery revision already exists with different content: ${manifest.revision}`);
769
+ }
770
+ else
771
+ atomicWrite(revisionPath, bytes);
458
772
  atomicWrite(join(root, "manifest.json"), bytes);
459
773
  chmodTreeOwnerOnly(root);
460
774
  const packet = { directory: root, projectDir, manifest };
461
775
  persistPendingState(packet);
776
+ const completed = new WorkflowDb(projectDir);
777
+ try {
778
+ completed.finishQaPacketProjection(manifest.runId, manifest.ticketId, manifest.packetDigest);
779
+ }
780
+ finally {
781
+ completed.close();
782
+ }
462
783
  return packet;
463
784
  }
464
785
  function calculatePacketDigest(root, manifest) {
@@ -477,21 +798,20 @@ function persistPendingState(packet) {
477
798
  const run = db.getRun(packet.manifest.runId);
478
799
  if (!run)
479
800
  return;
480
- const qaReportRecovery = {
481
- packetPath: packet.directory,
482
- packetDigest: packet.manifest.packetDigest,
483
- reviewedStateDigest: packet.manifest.reviewedStateDigest,
484
- revision: packet.manifest.revision,
485
- ladderPosition: packet.manifest.correctionTurns,
486
- pendingAction: packet.manifest.pendingAction,
487
- ticketId: packet.manifest.ticketId,
488
- packetId: packet.manifest.packetId,
489
- retentionProtected: true,
490
- };
801
+ db.putQaRecoveryHead({
802
+ runId: packet.manifest.runId, ticketId: packet.manifest.ticketId, packetId: packet.manifest.packetId,
803
+ packetPath: packet.directory, packetDigest: packet.manifest.packetDigest, reviewedStateDigest: packet.manifest.reviewedStateDigest,
804
+ revision: packet.manifest.revision, correctionTurns: packet.manifest.correctionTurns, pendingAction: packet.manifest.pendingAction,
805
+ });
806
+ // Compatibility projection only. Readers that make recovery decisions use
807
+ // qa_recovery_heads, so saveBuildRun cannot destroy authoritative state.
491
808
  db.transition(packet.manifest.runId, {
492
809
  status: run.status, checkpoint: run.checkpoint, remainingWork: run.remainingWork,
493
- state: { ...run.state, qaReportRecovery }, event: "qa_recovery_packet_revision",
494
- payload: { packetDigest: packet.manifest.packetDigest, revision: packet.manifest.revision, pendingAction: packet.manifest.pendingAction, ticketId: packet.manifest.ticketId },
810
+ state: { ...run.state, qaReportRecovery: { packetPath: packet.directory, packetDigest: packet.manifest.packetDigest,
811
+ reviewedStateDigest: packet.manifest.reviewedStateDigest, revision: packet.manifest.revision,
812
+ ladderPosition: packet.manifest.correctionTurns, pendingAction: packet.manifest.pendingAction,
813
+ ticketId: packet.manifest.ticketId, packetId: packet.manifest.packetId, retentionProtected: true } },
814
+ event: "qa_recovery_packet_revision", payload: { ticketId: packet.manifest.ticketId, revision: packet.manifest.revision, packetDigest: packet.manifest.packetDigest, pendingAction: packet.manifest.pendingAction },
495
815
  });
496
816
  }
497
817
  finally {
@@ -507,6 +827,7 @@ function assertManifestShape(value) {
507
827
  || !Number.isSafeInteger(value.correctionTurns) || value.correctionTurns < 0
508
828
  || !/^[a-f0-9]{64}$/.test(value.packetDigest)
509
829
  || !value.packetId || !value.reviewAttemptId || !value.runId || !value.ticketId
830
+ || !["automatic-recovery", "successor-acknowledgement", "qa-correction", "qa-full-review", "operator-menu", "validated-report", "builder-remediation-pending", "builder-remediation-in-flight", "resolved"].includes(value.pendingAction)
510
831
  || !/^[a-f0-9]{64}$/.test(value.originalReviewedStateDigest)
511
832
  || !/^[a-f0-9]{64}$/.test(value.currentReviewedStateDigest)
512
833
  || !/^[a-f0-9]{64}$/.test(value.reviewedStateDigest))
@@ -537,14 +858,15 @@ function chmodRecoveryDirectoryChain(projectDir, target) {
537
858
  chmodSync(cursor, 0o700);
538
859
  }
539
860
  }
540
- function uniqueResourcePath(resources, requested) {
541
- if (!resources.some((resource) => resource.path === requested))
861
+ function uniqueResourcePath(resources, requested, root) {
862
+ const occupied = (candidate) => resources.some((resource) => resource.path === candidate) || Boolean(root && existsSync(join(root, candidate)));
863
+ if (!occupied(requested))
542
864
  return requested;
543
865
  const dot = requested.lastIndexOf(".");
544
866
  const stem = dot > requested.lastIndexOf("/") ? requested.slice(0, dot) : requested;
545
867
  const extension = dot > requested.lastIndexOf("/") ? requested.slice(dot) : "";
546
868
  let sequence = 2;
547
- while (resources.some((resource) => resource.path === `${stem}.seq-${String(sequence).padStart(4, "0")}${extension}`))
869
+ while (occupied(`${stem}.seq-${String(sequence).padStart(4, "0")}${extension}`))
548
870
  sequence++;
549
871
  return `${stem}.seq-${String(sequence).padStart(4, "0")}${extension}`;
550
872
  }
@@ -559,7 +881,79 @@ function slugWithHash(value) { return `${safeSlug(value)}-${hash(Buffer.from(val
559
881
  function revisionName(revision) { return `revision-${String(revision).padStart(8, "0")}.json`; }
560
882
  function resourceOrder(a, b) { return a.path.localeCompare(b.path); }
561
883
  function encodeValue(value, exact) { return exact ? Buffer.from(typeof value === "string" ? value : String(value)) : Buffer.from(`${stableJson(value)}\n`); }
562
- function atomicWrite(path, bytes, mode = 0o600) { mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); const temp = join(dirname(path), `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`); writeFileSync(temp, bytes, { mode }); chmodSync(temp, mode); renameSync(temp, path); chmodSync(path, mode); }
884
+ function atomicWrite(path, bytes, mode = 0o600) {
885
+ const parent = dirname(path);
886
+ durableMkdir(parent);
887
+ const temp = join(parent, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
888
+ writeFileSync(temp, bytes, { mode });
889
+ chmodSync(temp, mode);
890
+ const fd = openSync(temp, "r");
891
+ try {
892
+ fsyncSync(fd);
893
+ }
894
+ finally {
895
+ closeSync(fd);
896
+ }
897
+ renameSync(temp, path);
898
+ chmodSync(path, mode);
899
+ fsyncDirectory(parent);
900
+ }
901
+ function durableMkdir(path) {
902
+ const target = resolve(path);
903
+ let existing = target;
904
+ while (!existsSync(existing)) {
905
+ const parent = dirname(existing);
906
+ if (parent === existing)
907
+ break;
908
+ existing = parent;
909
+ }
910
+ mkdirSync(target, { recursive: true, mode: 0o700 });
911
+ chmodSync(target, 0o700);
912
+ let cursor = target;
913
+ fsyncDirectory(cursor);
914
+ while (cursor !== existing) {
915
+ cursor = dirname(cursor);
916
+ fsyncDirectory(cursor);
917
+ }
918
+ }
919
+ function fsyncDirectory(path) {
920
+ let fd;
921
+ try {
922
+ fd = openSync(path, "r");
923
+ fsyncSync(fd);
924
+ }
925
+ catch (error) {
926
+ const code = error.code;
927
+ if (code !== "EINVAL" && code !== "ENOTSUP" && code !== "EPERM")
928
+ throw error;
929
+ }
930
+ finally {
931
+ if (fd !== undefined)
932
+ closeSync(fd);
933
+ }
934
+ }
935
+ function assertPrivatePath(path, directory) {
936
+ const stat = lstatSync(path);
937
+ if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile()))
938
+ throw new Error(`unsafe QA recovery ${directory ? "directory" : "file"}: ${path}`);
939
+ if (typeof process.getuid === "function" && stat.uid !== process.getuid())
940
+ throw new Error(`QA recovery path is not owned by the current operator: ${path}`);
941
+ if (process.platform !== "win32") {
942
+ const expected = directory ? 0o700 : 0o600;
943
+ if ((stat.mode & 0o777) !== expected)
944
+ throw new Error(`QA recovery path has unsafe permissions: ${path}`);
945
+ }
946
+ }
947
+ function assertPrivateTree(root) {
948
+ assertPrivatePath(root, true);
949
+ for (const name of readdirSync(root)) {
950
+ const path = join(root, name);
951
+ const stat = lstatSync(path);
952
+ assertPrivatePath(path, stat.isDirectory());
953
+ if (stat.isDirectory())
954
+ assertPrivateTree(path);
955
+ }
956
+ }
563
957
  function hash(bytes) { return createHash("sha256").update(bytes).digest("hex"); }
564
958
  function statOwner(path) { return lstatSync(path).uid; }
565
959
  function stableJson(value) { if (Array.isArray(value))