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.
- package/README.md +33 -3
- package/dist/adapters/claude.d.ts +5 -0
- package/dist/adapters/claude.js +122 -16
- package/dist/adapters/codex.d.ts +2 -0
- package/dist/adapters/codex.js +30 -8
- package/dist/adapters/recovering.d.ts +2 -0
- package/dist/adapters/recovering.js +6 -0
- package/dist/adapters/types.d.ts +22 -0
- package/dist/agentRun.js +21 -3
- package/dist/branch/currentGuard.d.ts +2 -0
- package/dist/branch/currentGuard.js +9 -0
- package/dist/branch/finalization.d.ts +22 -0
- package/dist/branch/finalization.js +120 -0
- package/dist/branch/git.js +7 -2
- package/dist/branch/runner.d.ts +3 -2
- package/dist/branch/runner.js +277 -75
- package/dist/buildRuns.d.ts +1 -0
- package/dist/buildRuns.js +33 -3
- package/dist/cli/start.js +314 -86
- package/dist/cli/state.d.ts +4 -0
- package/dist/cli/state.js +69 -0
- package/dist/continuity.d.ts +4 -0
- package/dist/continuity.js +26 -3
- package/dist/foreman.d.ts +22 -4
- package/dist/foreman.js +105 -19
- package/dist/handoffs.d.ts +5 -2
- package/dist/handoffs.js +93 -12
- package/dist/index.js +2 -0
- package/dist/qaFailureDelivery.d.ts +100 -0
- package/dist/qaFailureDelivery.js +570 -0
- package/dist/qaProtocolV2.d.ts +233 -0
- package/dist/qaProtocolV2.js +114 -0
- package/dist/qaRecovery.d.ts +15 -3
- package/dist/qaRecovery.js +477 -83
- package/dist/qaReview.d.ts +42 -17
- package/dist/qaReview.js +1216 -457
- package/dist/qaRuntime.d.ts +14 -0
- package/dist/qaRuntime.js +44 -0
- package/dist/qaSnapshot.d.ts +34 -0
- package/dist/qaSnapshot.js +302 -34
- package/dist/stateTransfer.d.ts +57 -0
- package/dist/stateTransfer.js +644 -0
- package/dist/statusReporter.d.ts +2 -0
- package/dist/statusReporter.js +6 -0
- package/dist/workflowDb.d.ts +245 -2
- package/dist/workflowDb.js +647 -6
- package/package.json +15 -3
package/dist/qaRecovery.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
|
-
import { chmodSync,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
152
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
286
|
-
|
|
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}/
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
325
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
packetDigest: packet.manifest.packetDigest,
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
|
494
|
-
|
|
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
|
-
|
|
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 (
|
|
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) {
|
|
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))
|