ai-foreman 1.7.13 → 1.7.15
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 +124 -16
- package/dist/adapters/codex.d.ts +2 -0
- package/dist/adapters/codex.js +32 -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 +24 -4
- 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/roles.js +1 -0
- 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/workflowDb.js
CHANGED
|
@@ -4,6 +4,7 @@ import { hostname } from "node:os";
|
|
|
4
4
|
import { dirname, join, resolve } from "node:path";
|
|
5
5
|
import Database from "better-sqlite3";
|
|
6
6
|
import { providerSessionKey } from "./sessionIdentity.js";
|
|
7
|
+
import { initialQaReducerState, qaDigest, reduceQaState, } from "./qaProtocolV2.js";
|
|
7
8
|
export const WORKFLOW_DB_FILE = ".rafi/recovery.sqlite3";
|
|
8
9
|
/** Read the current project lease without creating or migrating recovery state. */
|
|
9
10
|
export function readCurrentWorkflowLease(projectDir) {
|
|
@@ -392,12 +393,27 @@ export class WorkflowDb {
|
|
|
392
393
|
const at = now.toISOString();
|
|
393
394
|
this.db.prepare(`INSERT INTO operation_journal(idempotency_key,run_id,kind,status,intent_json,created_at,updated_at)
|
|
394
395
|
VALUES(?,?,?,'planned',?,?,?) ON CONFLICT(idempotency_key) DO NOTHING`).run(input.idempotencyKey, input.runId, input.kind, json(input.intent), at, at);
|
|
395
|
-
|
|
396
|
+
const operation = this.operation(input.idempotencyKey);
|
|
397
|
+
if (operation.runId !== input.runId || operation.kind !== input.kind || json(operation.intent) !== json(input.intent)) {
|
|
398
|
+
throw new Error(`operation idempotency collision: ${input.idempotencyKey}`);
|
|
399
|
+
}
|
|
400
|
+
return operation;
|
|
396
401
|
}
|
|
397
402
|
updateOperation(idempotencyKey, status, details = {}, now = new Date()) {
|
|
398
403
|
const prior = this.operation(idempotencyKey);
|
|
399
404
|
if (!prior)
|
|
400
405
|
throw new Error(`operation not found: ${idempotencyKey}`);
|
|
406
|
+
const allowed = {
|
|
407
|
+
planned: ["planned", "in_progress", "failed"],
|
|
408
|
+
in_progress: ["in_progress", "confirmed", "failed", "uncertain"],
|
|
409
|
+
uncertain: ["uncertain", "confirmed", "failed"],
|
|
410
|
+
confirmed: ["confirmed"],
|
|
411
|
+
failed: ["failed"],
|
|
412
|
+
};
|
|
413
|
+
if (!allowed[prior.status].includes(status))
|
|
414
|
+
throw new Error(`invalid operation transition ${prior.status} -> ${status}: ${idempotencyKey}`);
|
|
415
|
+
if (prior.status === status && prior.status !== "planned" && json(prior.result) !== json(details.result))
|
|
416
|
+
throw new Error(`conflicting terminal operation receipt: ${idempotencyKey}`);
|
|
401
417
|
const at = now.toISOString();
|
|
402
418
|
this.db.transaction(() => {
|
|
403
419
|
this.db.prepare("UPDATE operation_journal SET status=?,result_json=?,external_id=?,error=?,updated_at=? WHERE idempotency_key=?")
|
|
@@ -415,6 +431,9 @@ export class WorkflowDb {
|
|
|
415
431
|
}
|
|
416
432
|
putEvidence(kind, value, now = new Date()) {
|
|
417
433
|
const bytes = Buffer.isBuffer(value) ? value : Buffer.from(sanitizeText(value));
|
|
434
|
+
const maximum = kind === "qa" ? 8 * 1024 * 1024 : 16 * 1024 * 1024;
|
|
435
|
+
if (bytes.length > maximum)
|
|
436
|
+
throw new Error(`${kind} evidence exceeds the durable ${maximum}-byte item limit`);
|
|
418
437
|
const digest = createHash("sha256").update(bytes).digest("hex");
|
|
419
438
|
this.db.prepare("INSERT OR IGNORE INTO content_refs(digest,kind,content,created_at) VALUES(?,?,?,?)").run(digest, kind, bytes, now.toISOString());
|
|
420
439
|
return digest;
|
|
@@ -423,6 +442,588 @@ export class WorkflowDb {
|
|
|
423
442
|
const row = this.db.prepare("SELECT content FROM content_refs WHERE digest=?").get(digest);
|
|
424
443
|
return row?.content;
|
|
425
444
|
}
|
|
445
|
+
putQaRecoveryHead(input, now = new Date()) {
|
|
446
|
+
this.ensureRun(input.runId);
|
|
447
|
+
const at = now.toISOString();
|
|
448
|
+
this.db.transaction(() => {
|
|
449
|
+
const prior = this.qaRecoveryHead(input.runId, input.ticketId);
|
|
450
|
+
if (prior) {
|
|
451
|
+
if (prior.packetId !== input.packetId && prior.pendingAction !== "resolved")
|
|
452
|
+
throw new Error(`QA recovery packet identity changed for ${input.runId}/${input.ticketId}`);
|
|
453
|
+
if (prior.packetId === input.packetId && (input.revision < prior.revision || (input.revision === prior.revision && input.packetDigest !== prior.packetDigest)))
|
|
454
|
+
throw new Error("stale or conflicting QA recovery head");
|
|
455
|
+
// The packet loader validates the complete immutable lineage before it
|
|
456
|
+
// reconciles a filesystem-ahead crash, so a head may legitimately
|
|
457
|
+
// advance across more than one already-durable revision here.
|
|
458
|
+
}
|
|
459
|
+
this.db.prepare(`INSERT INTO qa_recovery_heads(run_id,ticket_id,packet_id,packet_path,packet_digest,reviewed_state_digest,revision,correction_turns,pending_action,updated_at)
|
|
460
|
+
VALUES(?,?,?,?,?,?,?,?,?,?) ON CONFLICT(run_id,ticket_id) DO UPDATE SET packet_id=excluded.packet_id,packet_path=excluded.packet_path,packet_digest=excluded.packet_digest,reviewed_state_digest=excluded.reviewed_state_digest,revision=excluded.revision,correction_turns=excluded.correction_turns,pending_action=excluded.pending_action,updated_at=excluded.updated_at`)
|
|
461
|
+
.run(input.runId, input.ticketId, input.packetId, input.packetPath, input.packetDigest, input.reviewedStateDigest, input.revision, input.correctionTurns, input.pendingAction, at);
|
|
462
|
+
this.insertEvent(input.runId, "qa_recovery_head", `qa-recovery:${input.ticketId}:${input.revision}`, input, at);
|
|
463
|
+
})();
|
|
464
|
+
return this.qaRecoveryHead(input.runId, input.ticketId);
|
|
465
|
+
}
|
|
466
|
+
qaRecoveryHead(runId, ticketId) {
|
|
467
|
+
const row = (ticketId
|
|
468
|
+
? this.db.prepare("SELECT * FROM qa_recovery_heads WHERE run_id=? AND ticket_id=?").get(runId, ticketId)
|
|
469
|
+
: this.db.prepare("SELECT * FROM qa_recovery_heads WHERE run_id=? AND pending_action<>'resolved' ORDER BY updated_at DESC LIMIT 1").get(runId));
|
|
470
|
+
return row ? qaRecoveryHeadFromRow(row) : undefined;
|
|
471
|
+
}
|
|
472
|
+
pendingQaRecoveryHeads(runId) {
|
|
473
|
+
const rows = this.db.prepare("SELECT * FROM qa_recovery_heads WHERE run_id=? AND pending_action<>'resolved' ORDER BY updated_at,ticket_id").all(runId);
|
|
474
|
+
return rows.map(qaRecoveryHeadFromRow);
|
|
475
|
+
}
|
|
476
|
+
beginQaReviewAttempt(input, now = new Date()) {
|
|
477
|
+
this.ensureRun(input.runId);
|
|
478
|
+
const at = now.toISOString();
|
|
479
|
+
this.db.prepare(`INSERT INTO qa_review_attempts(attempt_id,run_id,ticket_id,review_number,cycle,remediation_generation,source_digest,status,record_json,created_at,updated_at) VALUES(?,?,?,?,?,?,?,'started','{}',?,?)`)
|
|
480
|
+
.run(input.attemptId, input.runId, input.ticketId, input.reviewNumber, input.cycle, input.remediationGeneration, input.sourceDigest, at, at);
|
|
481
|
+
return this.qaReviewAttempt(input.attemptId);
|
|
482
|
+
}
|
|
483
|
+
finishQaReviewAttempt(attemptId, patch, now = new Date()) {
|
|
484
|
+
const prior = this.qaReviewAttempt(attemptId);
|
|
485
|
+
if (!prior)
|
|
486
|
+
throw new Error(`QA review attempt not found: ${attemptId}`);
|
|
487
|
+
if (prior.status !== "started") {
|
|
488
|
+
const same = prior.status === patch.status && (patch.reportDigest === undefined || patch.reportDigest === prior.reportDigest);
|
|
489
|
+
if (!same)
|
|
490
|
+
throw new Error(`QA review attempt ${attemptId} is already terminal (${prior.status})`);
|
|
491
|
+
return prior;
|
|
492
|
+
}
|
|
493
|
+
const next = { ...prior, ...patch, updatedAt: now.toISOString() };
|
|
494
|
+
this.db.prepare("UPDATE qa_review_attempts SET status=?,report_digest=?,record_json=?,updated_at=? WHERE attempt_id=?")
|
|
495
|
+
.run(next.status, next.reportDigest ?? null, json({ findingIds: next.findingIds, namespacedFindingIds: next.namespacedFindingIds, detail: next.detail }), next.updatedAt, attemptId);
|
|
496
|
+
return next;
|
|
497
|
+
}
|
|
498
|
+
qaReviewAttempts(runId, ticketId) {
|
|
499
|
+
return this.db.prepare("SELECT * FROM qa_review_attempts WHERE run_id=? AND ticket_id=? ORDER BY review_number").all(runId, ticketId).map(qaReviewAttemptFromRow);
|
|
500
|
+
}
|
|
501
|
+
qaReviewAttempt(attemptId) {
|
|
502
|
+
const row = this.db.prepare("SELECT * FROM qa_review_attempts WHERE attempt_id=?").get(attemptId);
|
|
503
|
+
return row ? qaReviewAttemptFromRow(row) : undefined;
|
|
504
|
+
}
|
|
505
|
+
beginQaRemediationAttempt(input, now = new Date()) {
|
|
506
|
+
this.ensureRun(input.runId);
|
|
507
|
+
const at = now.toISOString();
|
|
508
|
+
this.db.prepare(`INSERT INTO qa_remediation_attempts(attempt_id,run_id,ticket_id,review_attempt_id,generation,mode,status,request_digest,record_json,created_at,updated_at) VALUES(?,?,?,?,?,?,'intended',?,'{}',?,?)`)
|
|
509
|
+
.run(input.attemptId, input.runId, input.ticketId, input.reviewAttemptId, input.generation, input.mode, input.requestDigest, at, at);
|
|
510
|
+
return this.qaRemediationAttempt(input.attemptId);
|
|
511
|
+
}
|
|
512
|
+
commitQaRemediationIntent(expectedRevision, recovery, remediation, now = new Date()) {
|
|
513
|
+
return this.db.transaction(() => {
|
|
514
|
+
const head = this.qaTicketHead(remediation.runId, remediation.ticketId);
|
|
515
|
+
if (head.revision !== expectedRevision || head.state !== "review-failed")
|
|
516
|
+
throw new Error(`Builder remediation intent raced for ${remediation.runId}/${remediation.ticketId}`);
|
|
517
|
+
const next = this.transitionQa(remediation.runId, remediation.ticketId, head.revision, { type: "remediation-intended" }, now);
|
|
518
|
+
if (next.remediationGeneration !== remediation.generation)
|
|
519
|
+
throw new Error("Builder remediation generation mismatch");
|
|
520
|
+
this.recordRecoveryAttempt(recovery);
|
|
521
|
+
this.updateRecoveryAttempt(recovery.attemptId, "started", undefined, now);
|
|
522
|
+
this.beginQaRemediationAttempt(remediation, now);
|
|
523
|
+
this.updateQaRemediationAttempt(remediation.attemptId, "started", {}, now);
|
|
524
|
+
return next;
|
|
525
|
+
})();
|
|
526
|
+
}
|
|
527
|
+
updateQaRemediationAttempt(attemptId, status, patch = {}, now = new Date()) {
|
|
528
|
+
const prior = this.qaRemediationAttempt(attemptId);
|
|
529
|
+
if (!prior)
|
|
530
|
+
throw new Error(`QA remediation attempt not found: ${attemptId}`);
|
|
531
|
+
const next = { ...prior, ...patch, status, updatedAt: now.toISOString() };
|
|
532
|
+
this.db.prepare("UPDATE qa_remediation_attempts SET status=?,response_digest=?,summary_digest=?,record_json=?,updated_at=? WHERE attempt_id=?")
|
|
533
|
+
.run(status, next.responseDigest ?? null, next.summaryDigest ?? null, json({ detail: next.detail }), next.updatedAt, attemptId);
|
|
534
|
+
return next;
|
|
535
|
+
}
|
|
536
|
+
qaRemediationAttempts(runId, ticketId) {
|
|
537
|
+
return this.db.prepare("SELECT * FROM qa_remediation_attempts WHERE run_id=? AND ticket_id=? ORDER BY generation,created_at").all(runId, ticketId).map(qaRemediationAttemptFromRow);
|
|
538
|
+
}
|
|
539
|
+
qaRemediationAttempt(attemptId) {
|
|
540
|
+
const row = this.db.prepare("SELECT * FROM qa_remediation_attempts WHERE attempt_id=?").get(attemptId);
|
|
541
|
+
return row ? qaRemediationAttemptFromRow(row) : undefined;
|
|
542
|
+
}
|
|
543
|
+
recordQaFailureHandoffPrepared(input, now = new Date()) {
|
|
544
|
+
const at = now.toISOString();
|
|
545
|
+
this.db.prepare(`INSERT INTO qa_failure_handoffs(
|
|
546
|
+
handoff_id,operation_id,run_id,ticket_id,review_attempt_id,report_digest,generation,reviewed_content_digest,review_basis_digest,state,
|
|
547
|
+
handoff_digest,host_instruction_digest,record_json,created_at,updated_at
|
|
548
|
+
) VALUES(?,?,?,?,?,?,?,?,?,'prepared',?,?, '{}',?,?)
|
|
549
|
+
ON CONFLICT(handoff_id) DO UPDATE SET
|
|
550
|
+
operation_id=excluded.operation_id,
|
|
551
|
+
handoff_digest=excluded.handoff_digest,
|
|
552
|
+
host_instruction_digest=excluded.host_instruction_digest,
|
|
553
|
+
updated_at=excluded.updated_at
|
|
554
|
+
WHERE qa_failure_handoffs.state='prepared'`)
|
|
555
|
+
.run(input.handoffId, input.operationId, input.runId, input.ticketId, input.reviewAttemptId, input.reportDigest, input.generation, input.reviewedContentDigest, input.reviewBasisDigest, input.handoffDigest, input.hostInstructionDigest, at, at);
|
|
556
|
+
const record = this.qaFailureHandoff(input.handoffId);
|
|
557
|
+
if (!record)
|
|
558
|
+
throw new Error(`QA failure handoff was not recorded: ${input.handoffId}`);
|
|
559
|
+
if (record.operationId !== input.operationId || record.reportDigest !== input.reportDigest || record.reviewedContentDigest !== input.reviewedContentDigest
|
|
560
|
+
|| record.reviewBasisDigest !== input.reviewBasisDigest || record.state !== "prepared") {
|
|
561
|
+
throw new Error(`QA failure handoff collision: ${input.handoffId}`);
|
|
562
|
+
}
|
|
563
|
+
return record;
|
|
564
|
+
}
|
|
565
|
+
markQaFailureHandoffDeliveryIntended(handoffId, builderSession, now = new Date()) {
|
|
566
|
+
return this.transitionQaFailureHandoff(handoffId, "delivery-intended", { builderSession }, now);
|
|
567
|
+
}
|
|
568
|
+
transitionQaFailureHandoff(handoffId, state, patch = {}, now = new Date()) {
|
|
569
|
+
const prior = this.qaFailureHandoff(handoffId);
|
|
570
|
+
if (!prior)
|
|
571
|
+
throw new Error(`QA failure handoff not found: ${handoffId}`);
|
|
572
|
+
const next = { ...prior, ...patch, state, updatedAt: now.toISOString() };
|
|
573
|
+
const priorRecordRow = this.db.prepare("SELECT record_json FROM qa_failure_handoffs WHERE handoff_id=?").get(handoffId);
|
|
574
|
+
const priorRecord = parseJson(priorRecordRow?.record_json ?? "{}");
|
|
575
|
+
const priorTransitions = Array.isArray(priorRecord.transitions) ? priorRecord.transitions : [];
|
|
576
|
+
const recordJson = json({ ...priorRecord, transitions: [...priorTransitions, { state, detail: patch.detail, at: next.updatedAt }] });
|
|
577
|
+
this.db.prepare(`UPDATE qa_failure_handoffs SET state=?,builder_session_json=?,provider_turn_id=?,receipt_digest=?,response_digest=?,parsed_response_digest=?,post_source_digest=?,detail=?,record_json=?,updated_at=? WHERE handoff_id=?`)
|
|
578
|
+
.run(state, next.builderSession ? json(next.builderSession) : null, next.providerTurnId ?? null, next.receiptDigest ?? null, next.responseDigest ?? null, next.parsedResponseDigest ?? null, next.postSourceDigest ?? null, next.detail ?? null, recordJson, next.updatedAt, handoffId);
|
|
579
|
+
return this.qaFailureHandoff(handoffId);
|
|
580
|
+
}
|
|
581
|
+
qaFailureHandoff(handoffId) {
|
|
582
|
+
const row = this.db.prepare("SELECT * FROM qa_failure_handoffs WHERE handoff_id=?").get(handoffId);
|
|
583
|
+
return row ? qaFailureHandoffFromRow(row) : undefined;
|
|
584
|
+
}
|
|
585
|
+
qaFailureHandoffs(runId, ticketId) {
|
|
586
|
+
const rows = this.db.prepare("SELECT * FROM qa_failure_handoffs WHERE run_id=? AND ticket_id=? ORDER BY generation,created_at").all(runId, ticketId);
|
|
587
|
+
return rows.map(qaFailureHandoffFromRow);
|
|
588
|
+
}
|
|
589
|
+
qaTicketHead(runId, ticketId) {
|
|
590
|
+
const row = this.db.prepare("SELECT state_json FROM qa_ticket_heads WHERE run_id=? AND ticket_id=?").get(runId, ticketId);
|
|
591
|
+
return row ? parseJson(row.state_json) : initialQaReducerState(runId, ticketId);
|
|
592
|
+
}
|
|
593
|
+
pendingQaTicketHeads(runId) {
|
|
594
|
+
const rows = this.db.prepare("SELECT state_json FROM qa_ticket_heads WHERE run_id=? AND state NOT IN ('completed','waived') ORDER BY updated_at,ticket_id").all(runId);
|
|
595
|
+
return rows.map((row) => parseJson(row.state_json));
|
|
596
|
+
}
|
|
597
|
+
/** Compare-and-swap the authoritative QA reducer head and append its event atomically. */
|
|
598
|
+
transitionQa(runId, ticketId, expectedRevision, event, now = new Date()) {
|
|
599
|
+
this.ensureRun(runId);
|
|
600
|
+
return this.db.transaction(() => {
|
|
601
|
+
const current = this.qaTicketHead(runId, ticketId);
|
|
602
|
+
if (current.revision !== expectedRevision)
|
|
603
|
+
throw new Error(`stale QA transition for ${runId}/${ticketId}: expected revision ${expectedRevision}, found ${current.revision}`);
|
|
604
|
+
const next = reduceQaState(current, event);
|
|
605
|
+
const at = now.toISOString();
|
|
606
|
+
this.db.prepare(`INSERT INTO qa_ticket_heads(run_id,ticket_id,revision,state,state_json,updated_at)
|
|
607
|
+
VALUES(?,?,?,?,?,?) ON CONFLICT(run_id,ticket_id) DO UPDATE SET revision=excluded.revision,state=excluded.state,state_json=excluded.state_json,updated_at=excluded.updated_at
|
|
608
|
+
WHERE qa_ticket_heads.revision=?`)
|
|
609
|
+
.run(runId, ticketId, next.revision, next.state, json(next), at, expectedRevision);
|
|
610
|
+
this.db.prepare("INSERT INTO qa_transitions(run_id,ticket_id,from_revision,to_revision,event_json,state_json,created_at) VALUES(?,?,?,?,?,?,?)")
|
|
611
|
+
.run(runId, ticketId, current.revision, next.revision, json(event), json(next), at);
|
|
612
|
+
return next;
|
|
613
|
+
})();
|
|
614
|
+
}
|
|
615
|
+
qaTransitions(runId, ticketId) {
|
|
616
|
+
const rows = this.db.prepare("SELECT * FROM qa_transitions WHERE run_id=? AND ticket_id=? ORDER BY sequence").all(runId, ticketId);
|
|
617
|
+
return rows.map((row) => ({ sequence: Number(row.sequence), runId: String(row.run_id), ticketId: String(row.ticket_id), fromRevision: Number(row.from_revision), toRevision: Number(row.to_revision), event: parseJson(String(row.event_json)), state: parseJson(String(row.state_json)), createdAt: String(row.created_at) }));
|
|
618
|
+
}
|
|
619
|
+
assertQaRunFinalizable(runId) {
|
|
620
|
+
const rows = this.db.prepare("SELECT ticket_id,state FROM qa_ticket_heads WHERE run_id=? AND state NOT IN ('completed','waived') ORDER BY ticket_id").all(runId);
|
|
621
|
+
if (rows.length)
|
|
622
|
+
throw new Error(`build run has non-final QA state: ${rows.map((row) => `${row.ticket_id}=${row.state}`).join(", ")}`);
|
|
623
|
+
}
|
|
624
|
+
putQaSourceState(state) {
|
|
625
|
+
this.db.prepare("INSERT OR IGNORE INTO qa_source_states(digest,run_id,ticket_id,origin_digest,content_digest,state_json,created_at) VALUES(?,?,?,?,?,?,?)")
|
|
626
|
+
.run(state.digest, state.runId, state.ticketId, state.originDigest, state.contentDigest, json(state), state.capturedAt);
|
|
627
|
+
const stored = this.db.prepare("SELECT origin_digest,content_digest,state_json FROM qa_source_states WHERE run_id=? AND ticket_id=? AND digest=?").get(state.runId, state.ticketId, state.digest);
|
|
628
|
+
const { capturedAt: _storedCapturedAt, ...storedIdentity } = parseJson(stored.state_json);
|
|
629
|
+
const { capturedAt: _currentCapturedAt, ...currentIdentity } = state;
|
|
630
|
+
if (stored.origin_digest !== state.originDigest || stored.content_digest !== state.contentDigest || json(storedIdentity) !== json(currentIdentity))
|
|
631
|
+
throw new Error(`QA source-state digest collision: ${state.digest}`);
|
|
632
|
+
}
|
|
633
|
+
putQaReviewBasis(runId, ticketId, basis, now = new Date()) {
|
|
634
|
+
this.db.prepare("INSERT OR IGNORE INTO qa_review_bases(digest,run_id,ticket_id,basis_json,created_at) VALUES(?,?,?,?,?)")
|
|
635
|
+
.run(basis.digest, runId, ticketId, json(basis), now.toISOString());
|
|
636
|
+
const stored = this.db.prepare("SELECT basis_json FROM qa_review_bases WHERE run_id=? AND ticket_id=? AND digest=?").get(runId, ticketId, basis.digest);
|
|
637
|
+
if (qaDigest("review-basis", parseJson(stored.basis_json)) !== qaDigest("review-basis", basis))
|
|
638
|
+
throw new Error(`QA review-basis digest collision: ${basis.digest}`);
|
|
639
|
+
}
|
|
640
|
+
commitQaReviewReady(source, basis, session, confinement, attempt, expectedRevision, now = new Date()) {
|
|
641
|
+
return this.db.transaction(() => {
|
|
642
|
+
this.putQaSourceState(source);
|
|
643
|
+
this.putQaReviewBasis(source.runId, source.ticketId, basis, now);
|
|
644
|
+
let head = this.qaTicketHead(source.runId, source.ticketId);
|
|
645
|
+
if (head.revision !== expectedRevision)
|
|
646
|
+
throw new Error(`stale QA review preparation for ${source.runId}/${source.ticketId}`);
|
|
647
|
+
if (head.state === "passed")
|
|
648
|
+
this.invalidateUnconsumedQaPassCertificates(source.runId, source.ticketId, "new-review", now);
|
|
649
|
+
if (head.state === "turn-intended")
|
|
650
|
+
head = this.transitionQa(source.runId, source.ticketId, head.revision, { type: "source-drift" }, now);
|
|
651
|
+
head = this.transitionQa(source.runId, source.ticketId, head.revision, { type: "source-frozen", sourceStateDigest: source.digest }, now);
|
|
652
|
+
const next = this.transitionQa(source.runId, source.ticketId, head.revision, { type: "review-ready", reviewBasisDigest: basis.digest, sessionGeneration: session.generation }, now);
|
|
653
|
+
const sessionJson = { version: 2, session, sourceStateDigest: source.digest, reviewBasisDigest: basis.digest, confinement };
|
|
654
|
+
const sessionKey = qaDigest("qa-session", { runId: source.runId, ticketId: source.ticketId, reviewNumber: next.reviewNumber, providerSessionKey: providerSessionKey(session) });
|
|
655
|
+
this.db.prepare(`INSERT INTO qa_sessions(session_key,run_id,ticket_id,generation,source_state_digest,review_basis_digest,confinement_digest,session_json,created_at)
|
|
656
|
+
VALUES(?,?,?,?,?,?,?,?,?)`).run(sessionKey, source.runId, source.ticketId, session.generation, source.digest, basis.digest, confinement.digest, json(sessionJson), now.toISOString());
|
|
657
|
+
const at = now.toISOString();
|
|
658
|
+
this.db.prepare(`UPDATE qa_review_attempts SET status='interrupted',record_json=?,updated_at=?
|
|
659
|
+
WHERE run_id=? AND ticket_id=? AND status='started'`)
|
|
660
|
+
.run(json({ detail: "superseded by a new immutable QA review basis" }), at, source.runId, source.ticketId);
|
|
661
|
+
this.db.prepare(`INSERT INTO qa_review_attempts(attempt_id,run_id,ticket_id,review_number,cycle,remediation_generation,source_digest,status,record_json,created_at,updated_at)
|
|
662
|
+
VALUES(?,?,?,?,?,?,?,'started','{}',?,?)`)
|
|
663
|
+
.run(attempt.attemptId, source.runId, source.ticketId, next.reviewNumber, attempt.cycle, attempt.remediationGeneration, source.digest, at, at);
|
|
664
|
+
return next;
|
|
665
|
+
})();
|
|
666
|
+
}
|
|
667
|
+
recordQaHandoffReceipt(receipt) {
|
|
668
|
+
this.db.transaction(() => {
|
|
669
|
+
const existing = this.db.prepare("SELECT receipt_json FROM qa_handoffs WHERE operation_id=?").get(receipt.operationId);
|
|
670
|
+
if (existing) {
|
|
671
|
+
if (!existing.receipt_json || json(parseJson(existing.receipt_json)) !== json(receipt))
|
|
672
|
+
throw new Error(`conflicting QA handoff receipt: ${receipt.operationId}`);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
this.db.prepare(`INSERT INTO qa_handoffs(operation_id,run_id,ticket_id,qa_revision,source_state_digest,review_basis_digest,status,intent_json,receipt_json,created_at,updated_at)
|
|
676
|
+
VALUES(?,?,?,?,?,?,'accepted',?,?,?,?)`).run(receipt.operationId, receipt.runId, receipt.ticketId, receipt.qaRevision, receipt.sourceStateDigest, receipt.reviewBasisDigest, json({ packetDigest: receipt.packetDigest, predecessor: receipt.predecessor,
|
|
677
|
+
confinementDigest: receipt.confinementDigest, inventoryDigest: receipt.inventoryDigest }), json(receipt), receipt.acceptedAt, receipt.acceptedAt);
|
|
678
|
+
const sessionKey = qaDigest("qa-handoff-successor-session", { operationId: receipt.operationId, successor: receipt.successor });
|
|
679
|
+
this.db.prepare(`INSERT INTO qa_sessions(session_key,run_id,ticket_id,generation,source_state_digest,review_basis_digest,confinement_digest,session_json,created_at)
|
|
680
|
+
VALUES(?,?,?,?,?,?,?,?,?)`).run(sessionKey, receipt.runId, receipt.ticketId, receipt.successor.generation, receipt.sourceStateDigest, receipt.reviewBasisDigest, receipt.confinementDigest, json({ version: 2, session: receipt.successor, acceptedHandoffReceiptDigest: qaDigest("qa-handoff-receipt", receipt) }), receipt.acceptedAt);
|
|
681
|
+
})();
|
|
682
|
+
}
|
|
683
|
+
qaHandoffReceipt(operationId) {
|
|
684
|
+
const row = this.db.prepare("SELECT receipt_json FROM qa_handoffs WHERE operation_id=? AND status='accepted'").get(operationId);
|
|
685
|
+
return row?.receipt_json ? parseJson(row.receipt_json) : undefined;
|
|
686
|
+
}
|
|
687
|
+
beginQaTurn(intent) {
|
|
688
|
+
const at = intent.intendedAt;
|
|
689
|
+
this.db.transaction(() => {
|
|
690
|
+
const existing = this.db.prepare("SELECT intent_json FROM qa_turns WHERE operation_id=?").get(intent.operationId);
|
|
691
|
+
if (existing && json(parseJson(existing.intent_json)) !== json(intent))
|
|
692
|
+
throw new Error(`QA turn operation collision: ${intent.operationId}`);
|
|
693
|
+
this.db.prepare(`INSERT OR IGNORE INTO qa_turns(operation_id,run_id,ticket_id,review_number,session_generation,retry_slot,source_state_digest,review_basis_digest,status,intent_json,created_at,updated_at)
|
|
694
|
+
VALUES(?,?,?,?,?,?,?,?, 'intended',?,?,?)`).run(intent.operationId, intent.runId, intent.ticketId, intent.reviewNumber, intent.sessionGeneration, intent.slot, intent.sourceStateDigest, intent.reviewBasisDigest, json(intent), at, at);
|
|
695
|
+
this.db.prepare("INSERT OR IGNORE INTO qa_retry_slots(run_id,ticket_id,review_number,session_generation,retry_slot,operation_id,status,updated_at) VALUES(?,?,?,?,?,?,'intended',?)")
|
|
696
|
+
.run(intent.runId, intent.ticketId, intent.reviewNumber, intent.sessionGeneration, intent.slot, intent.operationId, at);
|
|
697
|
+
})();
|
|
698
|
+
}
|
|
699
|
+
commitQaTurnIntent(intent, expectedRevision) {
|
|
700
|
+
return this.db.transaction(() => {
|
|
701
|
+
const next = this.transitionQa(intent.runId, intent.ticketId, expectedRevision, { type: "turn-intended", slot: intent.slot }, new Date(intent.intendedAt));
|
|
702
|
+
this.beginQaTurn(intent);
|
|
703
|
+
return next;
|
|
704
|
+
})();
|
|
705
|
+
}
|
|
706
|
+
finishQaTurn(receipt) {
|
|
707
|
+
this.db.transaction(() => {
|
|
708
|
+
const prior = this.db.prepare("SELECT source_state_digest,review_basis_digest,status FROM qa_turns WHERE operation_id=?").get(receipt.operationId);
|
|
709
|
+
if (!prior)
|
|
710
|
+
throw new Error(`QA turn intent not found: ${receipt.operationId}`);
|
|
711
|
+
if (prior.source_state_digest !== receipt.sourceStateDigest || prior.review_basis_digest !== receipt.reviewBasisDigest)
|
|
712
|
+
throw new Error("QA turn receipt binding mismatch");
|
|
713
|
+
if (prior.status === "completed") {
|
|
714
|
+
const existing = this.db.prepare("SELECT receipt_json FROM qa_turns WHERE operation_id=?").get(receipt.operationId);
|
|
715
|
+
if (json(parseJson(existing.receipt_json)) !== json(receipt))
|
|
716
|
+
throw new Error(`conflicting QA turn receipt: ${receipt.operationId}`);
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
this.db.prepare("UPDATE qa_turns SET status=?,receipt_json=?,updated_at=? WHERE operation_id=?")
|
|
720
|
+
.run(receipt.dispatch === "completed" ? "completed" : receipt.dispatch, json(receipt), receipt.completedAt, receipt.operationId);
|
|
721
|
+
this.db.prepare("UPDATE qa_retry_slots SET status=?,updated_at=? WHERE operation_id=?")
|
|
722
|
+
.run(receipt.dispatch === "completed" ? "consumed" : receipt.dispatch, receipt.completedAt, receipt.operationId);
|
|
723
|
+
this.db.prepare("INSERT INTO qa_turn_events(operation_id,event_index,event_json,event_digest,created_at) VALUES(?,?,?,?,?)")
|
|
724
|
+
.run(receipt.operationId, 0, json(receipt), qaDigest("turn-receipt", receipt), receipt.completedAt);
|
|
725
|
+
})();
|
|
726
|
+
}
|
|
727
|
+
recordQaReport(input, findingIds, now = new Date()) {
|
|
728
|
+
const at = now.toISOString();
|
|
729
|
+
this.db.transaction(() => {
|
|
730
|
+
this.db.prepare(`INSERT INTO qa_reports(report_digest,run_id,ticket_id,review_number,source_state_digest,review_basis_digest,disposition,report_json,created_at,updated_at)
|
|
731
|
+
VALUES(?,?,?,?,?,?,'open',?,?,?)`).run(input.reportDigest, input.runId, input.ticketId, input.reviewNumber, input.sourceStateDigest, input.reviewBasisDigest, json(input.report), at, at);
|
|
732
|
+
const insertFinding = this.db.prepare("INSERT INTO qa_findings(finding_id,report_digest,ordinal,created_at) VALUES(?,?,?,?)");
|
|
733
|
+
findingIds.forEach((id, ordinal) => insertFinding.run(id, input.reportDigest, ordinal, at));
|
|
734
|
+
this.db.prepare("INSERT INTO qa_report_dispositions(report_digest,disposition,reason,created_at) VALUES(?,'open','QA rejected reviewed state',?)").run(input.reportDigest, at);
|
|
735
|
+
})();
|
|
736
|
+
return this.qaReport(input.reportDigest);
|
|
737
|
+
}
|
|
738
|
+
commitQaFailure(input, findingIds, expectedRevision, now = new Date()) {
|
|
739
|
+
return this.db.transaction(() => {
|
|
740
|
+
const predecessors = this.unresolvedQaReports(input.runId, input.ticketId);
|
|
741
|
+
this.recordQaReport(input, findingIds, now);
|
|
742
|
+
for (const predecessor of predecessors) {
|
|
743
|
+
if (predecessor.reportDigest === input.reportDigest)
|
|
744
|
+
continue;
|
|
745
|
+
this.db.prepare(`INSERT OR IGNORE INTO qa_report_chains(predecessor_report_digest,successor_report_digest,relation,created_at)
|
|
746
|
+
VALUES(?,?,'recheck-failed',?)`).run(predecessor.reportDigest, input.reportDigest, now.toISOString());
|
|
747
|
+
}
|
|
748
|
+
return this.transitionQa(input.runId, input.ticketId, expectedRevision, { type: "review-failed", reportDigest: input.reportDigest }, now);
|
|
749
|
+
})();
|
|
750
|
+
}
|
|
751
|
+
/** Accept a failed verdict, its exact attempt outcome, report, findings, and reducer edge atomically. */
|
|
752
|
+
commitQaFailureAttempt(attemptId, input, rawFindingIds, namespacedFindingIds, detail, expectedRevision, now = new Date()) {
|
|
753
|
+
return this.db.transaction(() => {
|
|
754
|
+
const attempt = this.qaReviewAttempt(attemptId);
|
|
755
|
+
if (!attempt || attempt.runId !== input.runId || attempt.ticketId !== input.ticketId
|
|
756
|
+
|| attempt.reviewNumber !== input.reviewNumber || attempt.sourceDigest !== input.sourceStateDigest || attempt.status !== "started") {
|
|
757
|
+
throw new Error(`QA failure attempt binding mismatch: ${attemptId}`);
|
|
758
|
+
}
|
|
759
|
+
this.finishQaReviewAttempt(attemptId, { status: "failed", reportDigest: input.reportDigest, findingIds: rawFindingIds, namespacedFindingIds, detail }, now);
|
|
760
|
+
return this.commitQaFailure(input, namespacedFindingIds, expectedRevision, now);
|
|
761
|
+
})();
|
|
762
|
+
}
|
|
763
|
+
qaReportChains(reportDigest) {
|
|
764
|
+
const rows = this.db.prepare(`SELECT predecessor_report_digest,successor_report_digest,relation,created_at FROM qa_report_chains
|
|
765
|
+
WHERE predecessor_report_digest=? OR successor_report_digest=? ORDER BY created_at,predecessor_report_digest,successor_report_digest`)
|
|
766
|
+
.all(reportDigest, reportDigest);
|
|
767
|
+
return rows.map((row) => ({ predecessorReportDigest: row.predecessor_report_digest, successorReportDigest: row.successor_report_digest, relation: row.relation, createdAt: row.created_at }));
|
|
768
|
+
}
|
|
769
|
+
qaReport(reportDigest) {
|
|
770
|
+
const row = this.db.prepare("SELECT * FROM qa_reports WHERE report_digest=?").get(reportDigest);
|
|
771
|
+
return row ? qaReportV2FromRow(row) : undefined;
|
|
772
|
+
}
|
|
773
|
+
unresolvedQaReports(runId, ticketId) {
|
|
774
|
+
return this.db.prepare("SELECT * FROM qa_reports WHERE run_id=? AND ticket_id=? AND disposition IN ('open','recheck-required') ORDER BY review_number,created_at").all(runId, ticketId).map(qaReportV2FromRow);
|
|
775
|
+
}
|
|
776
|
+
setQaReportDisposition(reportDigest, disposition, reason, now = new Date()) {
|
|
777
|
+
const at = now.toISOString();
|
|
778
|
+
this.db.transaction(() => {
|
|
779
|
+
const changed = this.db.prepare("UPDATE qa_reports SET disposition=?,updated_at=? WHERE report_digest=?").run(disposition, at, reportDigest);
|
|
780
|
+
if (changed.changes !== 1)
|
|
781
|
+
throw new Error(`QA report not found: ${reportDigest}`);
|
|
782
|
+
this.db.prepare("INSERT INTO qa_report_dispositions(report_digest,disposition,reason,created_at) VALUES(?,?,?,?)").run(reportDigest, disposition, reason, at);
|
|
783
|
+
})();
|
|
784
|
+
}
|
|
785
|
+
markQaReportsRecheckRequired(runId, ticketId, reason, now = new Date()) {
|
|
786
|
+
for (const report of this.unresolvedQaReports(runId, ticketId))
|
|
787
|
+
this.setQaReportDisposition(report.reportDigest, "recheck-required", reason, now);
|
|
788
|
+
}
|
|
789
|
+
resolveQaReportsAfterPass(runId, ticketId, now = new Date()) {
|
|
790
|
+
for (const report of this.unresolvedQaReports(runId, ticketId))
|
|
791
|
+
this.setQaReportDisposition(report.reportDigest, "verified-fixed", "subsequent bound QA review passed", now);
|
|
792
|
+
}
|
|
793
|
+
commitQaWaiver(runId, ticketId, expectedRevision, reason, now = new Date()) {
|
|
794
|
+
return this.db.transaction(() => {
|
|
795
|
+
const head = this.qaTicketHead(runId, ticketId);
|
|
796
|
+
if (head.revision !== expectedRevision)
|
|
797
|
+
throw new Error(`QA waiver raced for ${runId}/${ticketId}`);
|
|
798
|
+
for (const report of this.unresolvedQaReports(runId, ticketId))
|
|
799
|
+
this.setQaReportDisposition(report.reportDigest, "waived", reason, now);
|
|
800
|
+
return this.transitionQa(runId, ticketId, head.revision, { type: "waived" }, now);
|
|
801
|
+
})();
|
|
802
|
+
}
|
|
803
|
+
recordBuilderRemediationReceipt(receipt) {
|
|
804
|
+
const text = json(receipt);
|
|
805
|
+
const existing = this.db.prepare("SELECT receipt_json FROM qa_remediation_receipts WHERE operation_id=?").get(receipt.operationId);
|
|
806
|
+
if (existing && json(parseJson(existing.receipt_json)) !== text)
|
|
807
|
+
throw new Error(`Builder remediation receipt collision: ${receipt.operationId}`);
|
|
808
|
+
this.db.prepare("INSERT OR IGNORE INTO qa_remediation_receipts(operation_id,run_id,ticket_id,report_digest,receipt_json,created_at) VALUES(?,?,?,?,?,?)")
|
|
809
|
+
.run(receipt.operationId, receipt.runId, receipt.ticketId, receipt.reportDigest, text, receipt.completedAt);
|
|
810
|
+
}
|
|
811
|
+
commitBuilderRemediationSuccess(receipt, expectedRevision, now = new Date()) {
|
|
812
|
+
return this.db.transaction(() => {
|
|
813
|
+
this.recordBuilderRemediationReceipt(receipt);
|
|
814
|
+
this.markQaReportsRecheckRequired(receipt.runId, receipt.ticketId, "Builder remediation received; QA recheck required", now);
|
|
815
|
+
return this.transitionQa(receipt.runId, receipt.ticketId, expectedRevision, { type: "remediation-received" }, now);
|
|
816
|
+
})();
|
|
817
|
+
}
|
|
818
|
+
commitQaRemediationOutcome(input, now = new Date()) {
|
|
819
|
+
return this.db.transaction(() => {
|
|
820
|
+
const remediation = this.qaRemediationAttempt(input.remediationAttemptId);
|
|
821
|
+
if (!remediation)
|
|
822
|
+
throw new Error(`QA remediation attempt not found: ${input.remediationAttemptId}`);
|
|
823
|
+
const head = this.qaTicketHead(remediation.runId, remediation.ticketId);
|
|
824
|
+
if (head.revision !== input.expectedRevision || head.state !== "remediation-intended")
|
|
825
|
+
throw new Error(`Builder remediation outcome raced for ${remediation.runId}/${remediation.ticketId}`);
|
|
826
|
+
this.updateRecoveryAttempt(input.recoveryAttemptId, input.outcome === "uncertain" ? "failed" : input.outcome, input.detail, now);
|
|
827
|
+
this.updateQaRemediationAttempt(input.remediationAttemptId, input.outcome, { responseDigest: input.responseDigest, summaryDigest: input.summaryDigest, detail: input.detail }, now);
|
|
828
|
+
if (input.outcome === "succeeded") {
|
|
829
|
+
if (!input.receipt || input.receipt.operationId !== input.remediationAttemptId)
|
|
830
|
+
throw new Error("successful Builder remediation is missing its bound receipt");
|
|
831
|
+
return this.commitBuilderRemediationSuccess(input.receipt, head.revision, now);
|
|
832
|
+
}
|
|
833
|
+
return this.transitionQa(remediation.runId, remediation.ticketId, head.revision, { type: input.outcome === "uncertain" ? "remediation-uncertain" : "remediation-failed" }, now);
|
|
834
|
+
})();
|
|
835
|
+
}
|
|
836
|
+
issueQaPassCertificate(input, now = new Date()) {
|
|
837
|
+
if (this.unresolvedQaReports(input.runId, input.ticketId).length)
|
|
838
|
+
throw new Error("cannot issue QA pass certificate while reports remain unresolved");
|
|
839
|
+
const certificate = { version: 2, certificateId: qaDigest("pass-certificate", { ...input, issuedAt: now.toISOString() }), ...input, unresolvedReportCount: 0, issuedAt: now.toISOString() };
|
|
840
|
+
this.db.prepare("INSERT INTO qa_pass_certificates(certificate_id,run_id,ticket_id,qa_revision,source_state_digest,review_basis_digest,turn_receipt_digest,certificate_json,issued_at) VALUES(?,?,?,?,?,?,?,?,?)")
|
|
841
|
+
.run(certificate.certificateId, certificate.runId, certificate.ticketId, certificate.qaRevision, certificate.sourceStateDigest, certificate.reviewBasisDigest, certificate.turnReceiptDigest, json(certificate), certificate.issuedAt);
|
|
842
|
+
return certificate;
|
|
843
|
+
}
|
|
844
|
+
commitQaPass(input, expectedRevision, now = new Date()) {
|
|
845
|
+
return this.db.transaction(() => {
|
|
846
|
+
this.resolveQaReportsAfterPass(input.runId, input.ticketId, now);
|
|
847
|
+
const certificate = this.issueQaPassCertificate(input, now);
|
|
848
|
+
this.transitionQa(input.runId, input.ticketId, expectedRevision, { type: "review-passed", passCertificateId: certificate.certificateId }, now);
|
|
849
|
+
return certificate;
|
|
850
|
+
})();
|
|
851
|
+
}
|
|
852
|
+
/** Accept a passing verdict, its review-attempt outcome, report resolution, certificate, and reducer edge atomically. */
|
|
853
|
+
commitQaPassAttempt(attemptId, input, detail, expectedRevision, now = new Date()) {
|
|
854
|
+
return this.db.transaction(() => {
|
|
855
|
+
const attempt = this.qaReviewAttempt(attemptId);
|
|
856
|
+
if (!attempt || attempt.runId !== input.runId || attempt.ticketId !== input.ticketId
|
|
857
|
+
|| attempt.reviewNumber !== this.qaTicketHead(input.runId, input.ticketId).reviewNumber
|
|
858
|
+
|| attempt.sourceDigest !== input.sourceStateDigest || attempt.status !== "started") {
|
|
859
|
+
throw new Error(`QA pass attempt binding mismatch: ${attemptId}`);
|
|
860
|
+
}
|
|
861
|
+
this.finishQaReviewAttempt(attemptId, { status: "passed", detail }, now);
|
|
862
|
+
return this.commitQaPass(input, expectedRevision, now);
|
|
863
|
+
})();
|
|
864
|
+
}
|
|
865
|
+
consumeQaPassCertificate(runId, ticketId, certificateId, consumer, now = new Date()) {
|
|
866
|
+
return this.db.transaction(() => {
|
|
867
|
+
const row = this.db.prepare("SELECT certificate_json,consumed_at FROM qa_pass_certificates WHERE certificate_id=? AND run_id=? AND ticket_id=?").get(certificateId, runId, ticketId);
|
|
868
|
+
if (!row)
|
|
869
|
+
throw new Error("QA pass certificate is missing or scoped to another run/ticket");
|
|
870
|
+
if (row.consumed_at)
|
|
871
|
+
throw new Error("QA pass certificate has already been consumed");
|
|
872
|
+
if (this.unresolvedQaReports(runId, ticketId).length)
|
|
873
|
+
throw new Error("QA pass certificate cannot be consumed with unresolved reports");
|
|
874
|
+
const at = now.toISOString();
|
|
875
|
+
const certificate = { ...parseJson(row.certificate_json), consumedAt: at, consumedBy: consumer };
|
|
876
|
+
const changed = this.db.prepare("UPDATE qa_pass_certificates SET certificate_json=?,consumed_at=?,consumed_by=? WHERE certificate_id=? AND consumed_at IS NULL").run(json(certificate), at, consumer, certificateId);
|
|
877
|
+
if (changed.changes !== 1)
|
|
878
|
+
throw new Error("QA pass certificate consumption raced with another finalizer");
|
|
879
|
+
return certificate;
|
|
880
|
+
})();
|
|
881
|
+
}
|
|
882
|
+
invalidateUnconsumedQaPassCertificates(runId, ticketId, reason, now = new Date()) {
|
|
883
|
+
const at = now.toISOString();
|
|
884
|
+
this.db.prepare("UPDATE qa_pass_certificates SET consumed_at=?,consumed_by=? WHERE run_id=? AND ticket_id=? AND consumed_at IS NULL").run(at, `invalidated:${reason}`, runId, ticketId);
|
|
885
|
+
}
|
|
886
|
+
invalidateQaPassBeforeFinalization(input, now = new Date()) {
|
|
887
|
+
return this.db.transaction(() => {
|
|
888
|
+
const head = this.qaTicketHead(input.runId, input.ticketId);
|
|
889
|
+
if (head.revision !== input.expectedRevision || head.state !== "passed")
|
|
890
|
+
throw new Error(`QA pass invalidation raced for ${input.runId}/${input.ticketId}`);
|
|
891
|
+
if (head.passCertificateId !== input.certificateId || head.sourceStateDigest !== input.expectedSourceStateDigest) {
|
|
892
|
+
throw new Error("QA pass invalidation does not match the accepted pass/source binding");
|
|
893
|
+
}
|
|
894
|
+
this.invalidateUnconsumedQaPassCertificates(input.runId, input.ticketId, input.reason, now);
|
|
895
|
+
return this.transitionQa(input.runId, input.ticketId, head.revision, { type: "pass-invalidated", reason: input.reason }, now);
|
|
896
|
+
})();
|
|
897
|
+
}
|
|
898
|
+
beginQaFinalization(input, now = new Date()) {
|
|
899
|
+
return this.db.transaction(() => {
|
|
900
|
+
const head = this.qaTicketHead(input.runId, input.ticketId);
|
|
901
|
+
if (head.revision !== input.expectedRevision || head.state !== "passed")
|
|
902
|
+
throw new Error(`QA finalization start raced for ${input.runId}/${input.ticketId}`);
|
|
903
|
+
if (head.passCertificateId !== input.certificateId || head.sourceStateDigest !== input.expectedSourceStateDigest) {
|
|
904
|
+
throw new Error("QA finalization does not match the reducer's accepted pass/source binding");
|
|
905
|
+
}
|
|
906
|
+
const certificate = this.consumeQaPassCertificate(input.runId, input.ticketId, input.certificateId, input.consumer, now);
|
|
907
|
+
if (certificate.qaRevision !== head.revision || certificate.sourceStateDigest !== input.expectedSourceStateDigest
|
|
908
|
+
|| certificate.reviewBasisDigest !== head.reviewBasisDigest) {
|
|
909
|
+
throw new Error("QA finalization certificate does not match the durable reducer head");
|
|
910
|
+
}
|
|
911
|
+
const next = this.transitionQa(input.runId, input.ticketId, head.revision, { type: "finalization-started" }, now);
|
|
912
|
+
this.planQaFinalizationStep({ operationId: input.operationId, runId: input.runId, ticketId: input.ticketId, certificateId: input.certificateId,
|
|
913
|
+
kind: input.consumer, intent: { expectedSourceStateDigest: input.expectedSourceStateDigest, expectedGitTree: input.expectedGitTree,
|
|
914
|
+
allowedProjectionPaths: [...input.allowedProjectionPaths].sort(), reducerRevision: next.revision } }, now);
|
|
915
|
+
return next;
|
|
916
|
+
})();
|
|
917
|
+
}
|
|
918
|
+
planQaFinalizationStep(input, now = new Date()) {
|
|
919
|
+
const at = now.toISOString();
|
|
920
|
+
const intentText = json(input.intent);
|
|
921
|
+
const existing = this.db.prepare("SELECT run_id,ticket_id,certificate_id,kind,intent_json FROM qa_finalization_steps WHERE operation_id=?").get(input.operationId);
|
|
922
|
+
if (existing && (existing.run_id !== input.runId || existing.ticket_id !== input.ticketId || existing.certificate_id !== input.certificateId || existing.kind !== input.kind || json(parseJson(String(existing.intent_json))) !== intentText))
|
|
923
|
+
throw new Error(`QA finalization operation collision: ${input.operationId}`);
|
|
924
|
+
this.db.prepare("INSERT OR IGNORE INTO qa_finalization_steps(operation_id,run_id,ticket_id,certificate_id,kind,status,intent_json,created_at,updated_at) VALUES(?,?,?,?,?,'intended',?,?,?)")
|
|
925
|
+
.run(input.operationId, input.runId, input.ticketId, input.certificateId, input.kind, intentText, at, at);
|
|
926
|
+
}
|
|
927
|
+
finishQaFinalizationStep(operationId, receipt, now = new Date()) {
|
|
928
|
+
const at = now.toISOString();
|
|
929
|
+
const changed = this.db.prepare("UPDATE qa_finalization_steps SET status='completed',receipt_json=?,updated_at=? WHERE operation_id=? AND status='intended'").run(json(receipt), at, operationId);
|
|
930
|
+
if (changed.changes !== 1) {
|
|
931
|
+
const prior = this.db.prepare("SELECT status,receipt_json FROM qa_finalization_steps WHERE operation_id=?").get(operationId);
|
|
932
|
+
if (!prior || prior.status !== "completed" || json(parseJson(prior.receipt_json)) !== json(receipt))
|
|
933
|
+
throw new Error(`QA finalization step cannot be completed: ${operationId}`);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
finishPendingQaFinalizationSteps(runId, ticketId, receipt, now = new Date()) {
|
|
937
|
+
const rows = this.db.prepare("SELECT operation_id FROM qa_finalization_steps WHERE run_id=? AND ticket_id=? AND status='intended' ORDER BY created_at").all(runId, ticketId);
|
|
938
|
+
if (!rows.length)
|
|
939
|
+
throw new Error(`QA finalization intent is missing for ${runId}/${ticketId}`);
|
|
940
|
+
for (const row of rows)
|
|
941
|
+
this.finishQaFinalizationStep(row.operation_id, receipt, now);
|
|
942
|
+
}
|
|
943
|
+
qaFinalizationSteps(runId, ticketId) {
|
|
944
|
+
const rows = this.db.prepare("SELECT * FROM qa_finalization_steps WHERE run_id=? AND ticket_id=? ORDER BY created_at,operation_id").all(runId, ticketId);
|
|
945
|
+
return rows.map((row) => ({
|
|
946
|
+
operationId: String(row.operation_id), runId: String(row.run_id), ticketId: String(row.ticket_id),
|
|
947
|
+
certificateId: String(row.certificate_id), kind: String(row.kind), status: String(row.status),
|
|
948
|
+
intent: parseJson(String(row.intent_json)),
|
|
949
|
+
...(typeof row.receipt_json === "string" ? { receipt: parseJson(row.receipt_json) } : {}),
|
|
950
|
+
}));
|
|
951
|
+
}
|
|
952
|
+
invalidateQaFinalization(runId, ticketId, expectedRevision, reason, branch, now = new Date()) {
|
|
953
|
+
return this.db.transaction(() => {
|
|
954
|
+
const head = this.qaTicketHead(runId, ticketId);
|
|
955
|
+
if (head.state !== "finalizing" || head.revision !== expectedRevision)
|
|
956
|
+
throw new Error("QA finalization invalidation raced with another transition");
|
|
957
|
+
const published = this.operations(runId).find((operation) => {
|
|
958
|
+
if (!["direct-merge", "push", "pr-create", "mr-create", "ticket-complete"].includes(operation.kind)
|
|
959
|
+
|| !["in_progress", "uncertain", "confirmed"].includes(operation.status))
|
|
960
|
+
return false;
|
|
961
|
+
const intent = operation.intent;
|
|
962
|
+
return intent.ticket === ticketId || Boolean(branch && (intent.branch === branch || intent.head === branch));
|
|
963
|
+
});
|
|
964
|
+
if (published)
|
|
965
|
+
throw new Error(`QA source drift requires reconciliation of existing publication ${published.idempotencyKey}; its durable evidence is preserved`);
|
|
966
|
+
const at = now.toISOString();
|
|
967
|
+
const updated = this.db.prepare("UPDATE qa_finalization_steps SET status='invalidated',receipt_json=?,updated_at=? WHERE run_id=? AND ticket_id=? AND status='intended'")
|
|
968
|
+
.run(json({ invalidatedAt: at, reason }), at, runId, ticketId);
|
|
969
|
+
if (updated.changes !== 1)
|
|
970
|
+
throw new Error("QA finalization invalidation requires exactly one pending intent");
|
|
971
|
+
return this.transitionQa(runId, ticketId, head.revision, { type: "finalization-invalidated", reason }, now);
|
|
972
|
+
})();
|
|
973
|
+
}
|
|
974
|
+
completeQaFinalization(runId, ticketId, expectedRevision, receipt, now = new Date()) {
|
|
975
|
+
return this.db.transaction(() => {
|
|
976
|
+
const head = this.qaTicketHead(runId, ticketId);
|
|
977
|
+
if (head.revision !== expectedRevision || head.state !== "finalizing")
|
|
978
|
+
throw new Error(`QA finalization completion raced for ${runId}/${ticketId}`);
|
|
979
|
+
this.finishPendingQaFinalizationSteps(runId, ticketId, receipt, now);
|
|
980
|
+
return this.transitionQa(runId, ticketId, head.revision, { type: "completed" }, now);
|
|
981
|
+
})();
|
|
982
|
+
}
|
|
983
|
+
planQaPacketProjection(input, now = new Date()) {
|
|
984
|
+
this.ensureRun(input.runId);
|
|
985
|
+
this.db.prepare("INSERT INTO qa_packet_projections(run_id,ticket_id,qa_revision,packet_digest,path,manifest_json,status,created_at) VALUES(?,?,?,?,?,?,'intended',?) ON CONFLICT(packet_digest) DO NOTHING")
|
|
986
|
+
.run(input.runId, input.ticketId, input.qaRevision, input.packetDigest, input.path, input.manifestJson, now.toISOString());
|
|
987
|
+
const row = this.db.prepare("SELECT run_id,ticket_id,qa_revision,path,manifest_json FROM qa_packet_projections WHERE packet_digest=?").get(input.packetDigest);
|
|
988
|
+
if (row.run_id !== input.runId || row.ticket_id !== input.ticketId || row.qa_revision !== input.qaRevision || row.path !== input.path
|
|
989
|
+
|| row.manifest_json !== input.manifestJson)
|
|
990
|
+
throw new Error(`QA packet projection collision: ${input.packetDigest}`);
|
|
991
|
+
}
|
|
992
|
+
finishQaPacketProjection(runId, ticketId, packetDigest) {
|
|
993
|
+
const changed = this.db.prepare("UPDATE qa_packet_projections SET status='published' WHERE run_id=? AND ticket_id=? AND packet_digest=? AND status='intended'").run(runId, ticketId, packetDigest);
|
|
994
|
+
if (changed.changes !== 1) {
|
|
995
|
+
const row = this.db.prepare("SELECT status FROM qa_packet_projections WHERE run_id=? AND ticket_id=? AND packet_digest=?").get(runId, ticketId, packetDigest);
|
|
996
|
+
if (row?.status !== "published")
|
|
997
|
+
throw new Error(`QA packet projection intent is missing: ${packetDigest}`);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
qaPacketProjection(packetDigest) {
|
|
1001
|
+
const row = this.db.prepare("SELECT * FROM qa_packet_projections WHERE packet_digest=?").get(packetDigest);
|
|
1002
|
+
return row ? {
|
|
1003
|
+
runId: String(row.run_id), ticketId: String(row.ticket_id), packetRevision: Number(row.qa_revision),
|
|
1004
|
+
packetDigest: String(row.packet_digest), path: String(row.path), status: String(row.status),
|
|
1005
|
+
...(typeof row.manifest_json === "string" ? { manifestJson: row.manifest_json } : {}),
|
|
1006
|
+
createdAt: String(row.created_at),
|
|
1007
|
+
} : undefined;
|
|
1008
|
+
}
|
|
1009
|
+
qaPacketProjectionsAtPath(path) {
|
|
1010
|
+
const rows = this.db.prepare("SELECT * FROM qa_packet_projections WHERE path=? ORDER BY created_at,packet_digest").all(path);
|
|
1011
|
+
return rows.map((row) => ({
|
|
1012
|
+
runId: String(row.run_id), ticketId: String(row.ticket_id), packetRevision: Number(row.qa_revision),
|
|
1013
|
+
packetDigest: String(row.packet_digest), path: String(row.path), status: String(row.status),
|
|
1014
|
+
...(typeof row.manifest_json === "string" ? { manifestJson: String(row.manifest_json) } : {}),
|
|
1015
|
+
createdAt: String(row.created_at),
|
|
1016
|
+
}));
|
|
1017
|
+
}
|
|
1018
|
+
pendingQaPacketProjections(runId) {
|
|
1019
|
+
const rows = this.db.prepare("SELECT * FROM qa_packet_projections WHERE run_id=? AND status='intended' ORDER BY created_at,packet_digest").all(runId);
|
|
1020
|
+
return rows.map((row) => ({
|
|
1021
|
+
runId: String(row.run_id), ticketId: String(row.ticket_id), packetRevision: Number(row.qa_revision),
|
|
1022
|
+
packetDigest: String(row.packet_digest), path: String(row.path), status: "intended",
|
|
1023
|
+
...(typeof row.manifest_json === "string" ? { manifestJson: String(row.manifest_json) } : {}),
|
|
1024
|
+
createdAt: String(row.created_at),
|
|
1025
|
+
}));
|
|
1026
|
+
}
|
|
426
1027
|
stageHandoff(manifest, markdown) {
|
|
427
1028
|
this.ensureRun(manifest.runId);
|
|
428
1029
|
const manifestText = stableJson(sanitizeContinuityValue(manifest));
|
|
@@ -450,7 +1051,7 @@ export class WorkflowDb {
|
|
|
450
1051
|
})();
|
|
451
1052
|
return this.handoff(manifest.runId, manifest.generation) ?? lineage;
|
|
452
1053
|
}
|
|
453
|
-
acceptHandoff(runId, generation, successor, now = new Date()) {
|
|
1054
|
+
acceptHandoff(runId, generation, successor, now = new Date(), receipt) {
|
|
454
1055
|
const session = sessionParts(typeof successor === "object" ? successor : undefined, typeof successor === "string" ? successor : undefined);
|
|
455
1056
|
if (!session.id?.trim())
|
|
456
1057
|
throw new Error("a validated successor session ID is required before handoff acceptance");
|
|
@@ -463,8 +1064,9 @@ export class WorkflowDb {
|
|
|
463
1064
|
if (prior.state === "failed")
|
|
464
1065
|
throw new Error("a failed handoff cannot be accepted");
|
|
465
1066
|
const at = now.toISOString();
|
|
466
|
-
|
|
467
|
-
|
|
1067
|
+
const receiptDigest = receipt ? this.putEvidence("handoff", Buffer.from(JSON.stringify(receipt))) : undefined;
|
|
1068
|
+
this.db.prepare(`UPDATE handoffs SET state='accepted',successor_session_id=?,successor_session_key=?,successor_session_ref_json=?,accepted_at=?,acceptance_receipt_digest=? WHERE run_id=? AND generation=?`)
|
|
1069
|
+
.run(session.id, session.key, session.refJson, at, receiptDigest ?? null, runId, generation);
|
|
468
1070
|
this.db.prepare(`INSERT INTO role_mutation_leases(run_id,role,generation,provider_session_id,provider_session_key,provider_session_ref_json,moved_at)
|
|
469
1071
|
SELECT run_id,role,generation,?,?,?,? FROM handoffs WHERE run_id=? AND generation=?
|
|
470
1072
|
ON CONFLICT(run_id,role) DO UPDATE SET generation=excluded.generation,provider_session_id=excluded.provider_session_id,provider_session_key=excluded.provider_session_key,provider_session_ref_json=excluded.provider_session_ref_json,moved_at=excluded.moved_at`)
|
|
@@ -810,6 +1412,28 @@ export class WorkflowDb {
|
|
|
810
1412
|
CREATE TABLE IF NOT EXISTS role_mutation_leases(run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),role TEXT NOT NULL,generation INTEGER NOT NULL,provider_session_id TEXT NOT NULL,provider_session_key TEXT,provider_session_ref_json TEXT,moved_at TEXT NOT NULL,PRIMARY KEY(run_id,role));
|
|
811
1413
|
CREATE TABLE IF NOT EXISTS compaction_attempts(idempotency_key TEXT PRIMARY KEY,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),role TEXT NOT NULL,provider_session_id TEXT,session_key TEXT,session_ref_json TEXT,crossing_key TEXT NOT NULL,status TEXT NOT NULL,before_sample_json TEXT,after_sample_json TEXT,error TEXT,created_at TEXT NOT NULL,updated_at TEXT NOT NULL);
|
|
812
1414
|
CREATE TABLE IF NOT EXISTS recovery_decisions(decision_id INTEGER PRIMARY KEY AUTOINCREMENT,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),decided_at TEXT NOT NULL,mode TEXT NOT NULL,digest TEXT NOT NULL UNIQUE,session_key TEXT,session_ref_json TEXT,receipt_json TEXT NOT NULL);
|
|
1415
|
+
CREATE TABLE IF NOT EXISTS qa_recovery_heads(run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),ticket_id TEXT NOT NULL,packet_id TEXT NOT NULL,packet_path TEXT NOT NULL,packet_digest TEXT NOT NULL,reviewed_state_digest TEXT NOT NULL,revision INTEGER NOT NULL,correction_turns INTEGER NOT NULL,pending_action TEXT NOT NULL,updated_at TEXT NOT NULL,PRIMARY KEY(run_id,ticket_id));
|
|
1416
|
+
CREATE TABLE IF NOT EXISTS qa_review_attempts(attempt_id TEXT PRIMARY KEY,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),ticket_id TEXT NOT NULL,review_number INTEGER NOT NULL,cycle INTEGER NOT NULL,remediation_generation INTEGER NOT NULL,source_digest TEXT NOT NULL,status TEXT NOT NULL,report_digest TEXT,record_json TEXT NOT NULL,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,UNIQUE(run_id,ticket_id,review_number));
|
|
1417
|
+
CREATE TABLE IF NOT EXISTS qa_remediation_attempts(attempt_id TEXT PRIMARY KEY,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),ticket_id TEXT NOT NULL,review_attempt_id TEXT NOT NULL REFERENCES qa_review_attempts(attempt_id),generation INTEGER NOT NULL,mode TEXT NOT NULL,status TEXT NOT NULL,request_digest TEXT NOT NULL,response_digest TEXT,summary_digest TEXT,record_json TEXT NOT NULL,created_at TEXT NOT NULL,updated_at TEXT NOT NULL);
|
|
1418
|
+
CREATE TABLE IF NOT EXISTS qa_ticket_heads(run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),ticket_id TEXT NOT NULL,revision INTEGER NOT NULL,state TEXT NOT NULL,state_json TEXT NOT NULL,updated_at TEXT NOT NULL,PRIMARY KEY(run_id,ticket_id));
|
|
1419
|
+
CREATE TABLE IF NOT EXISTS qa_transitions(sequence INTEGER PRIMARY KEY AUTOINCREMENT,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),ticket_id TEXT NOT NULL,from_revision INTEGER NOT NULL,to_revision INTEGER NOT NULL,event_json TEXT NOT NULL,state_json TEXT NOT NULL,created_at TEXT NOT NULL,UNIQUE(run_id,ticket_id,to_revision));
|
|
1420
|
+
CREATE TABLE IF NOT EXISTS qa_source_states(digest TEXT NOT NULL,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),ticket_id TEXT NOT NULL,origin_digest TEXT NOT NULL,content_digest TEXT NOT NULL,state_json TEXT NOT NULL,created_at TEXT NOT NULL,PRIMARY KEY(run_id,ticket_id,digest));
|
|
1421
|
+
CREATE TABLE IF NOT EXISTS qa_review_bases(digest TEXT NOT NULL,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),ticket_id TEXT NOT NULL,basis_json TEXT NOT NULL,created_at TEXT NOT NULL,PRIMARY KEY(run_id,ticket_id,digest));
|
|
1422
|
+
CREATE TABLE IF NOT EXISTS qa_sessions(session_key TEXT PRIMARY KEY,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),ticket_id TEXT NOT NULL,generation INTEGER NOT NULL,source_state_digest TEXT NOT NULL,review_basis_digest TEXT NOT NULL,confinement_digest TEXT NOT NULL,session_json TEXT NOT NULL,created_at TEXT NOT NULL);
|
|
1423
|
+
CREATE TABLE IF NOT EXISTS qa_retry_slots(run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),ticket_id TEXT NOT NULL,review_number INTEGER NOT NULL,session_generation INTEGER NOT NULL,retry_slot TEXT NOT NULL,operation_id TEXT NOT NULL UNIQUE,status TEXT NOT NULL,updated_at TEXT NOT NULL,PRIMARY KEY(run_id,ticket_id,review_number,session_generation,retry_slot));
|
|
1424
|
+
CREATE TABLE IF NOT EXISTS qa_turns(operation_id TEXT PRIMARY KEY,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),ticket_id TEXT NOT NULL,review_number INTEGER NOT NULL,session_generation INTEGER NOT NULL,retry_slot TEXT NOT NULL,source_state_digest TEXT NOT NULL,review_basis_digest TEXT NOT NULL,status TEXT NOT NULL,intent_json TEXT NOT NULL,receipt_json TEXT,created_at TEXT NOT NULL,updated_at TEXT NOT NULL);
|
|
1425
|
+
CREATE TABLE IF NOT EXISTS qa_turn_events(operation_id TEXT NOT NULL REFERENCES qa_turns(operation_id),event_index INTEGER NOT NULL,event_json TEXT NOT NULL,event_digest TEXT NOT NULL,created_at TEXT NOT NULL,PRIMARY KEY(operation_id,event_index));
|
|
1426
|
+
CREATE TABLE IF NOT EXISTS qa_reports(report_digest TEXT PRIMARY KEY,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),ticket_id TEXT NOT NULL,review_number INTEGER NOT NULL,source_state_digest TEXT NOT NULL,review_basis_digest TEXT NOT NULL,disposition TEXT NOT NULL,report_json TEXT NOT NULL,created_at TEXT NOT NULL,updated_at TEXT NOT NULL);
|
|
1427
|
+
CREATE TABLE IF NOT EXISTS qa_findings(finding_id TEXT PRIMARY KEY,report_digest TEXT NOT NULL REFERENCES qa_reports(report_digest),ordinal INTEGER NOT NULL,created_at TEXT NOT NULL);
|
|
1428
|
+
CREATE TABLE IF NOT EXISTS qa_report_dispositions(sequence INTEGER PRIMARY KEY AUTOINCREMENT,report_digest TEXT NOT NULL REFERENCES qa_reports(report_digest),disposition TEXT NOT NULL,reason TEXT NOT NULL,created_at TEXT NOT NULL);
|
|
1429
|
+
CREATE TABLE IF NOT EXISTS qa_report_chains(predecessor_report_digest TEXT NOT NULL REFERENCES qa_reports(report_digest),successor_report_digest TEXT NOT NULL REFERENCES qa_reports(report_digest),relation TEXT NOT NULL,created_at TEXT NOT NULL,PRIMARY KEY(predecessor_report_digest,successor_report_digest));
|
|
1430
|
+
CREATE TABLE IF NOT EXISTS qa_failure_handoffs(handoff_id TEXT PRIMARY KEY,operation_id TEXT NOT NULL,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),ticket_id TEXT NOT NULL,review_attempt_id TEXT NOT NULL REFERENCES qa_review_attempts(attempt_id),report_digest TEXT NOT NULL REFERENCES qa_reports(report_digest),generation INTEGER NOT NULL,reviewed_content_digest TEXT NOT NULL,review_basis_digest TEXT NOT NULL,state TEXT NOT NULL,handoff_digest TEXT NOT NULL,host_instruction_digest TEXT NOT NULL,builder_session_json TEXT,provider_turn_id TEXT,receipt_digest TEXT,response_digest TEXT,parsed_response_digest TEXT,post_source_digest TEXT,detail TEXT,record_json TEXT NOT NULL,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,UNIQUE(run_id,ticket_id,review_attempt_id,report_digest,generation));
|
|
1431
|
+
CREATE TABLE IF NOT EXISTS qa_handoffs(operation_id TEXT PRIMARY KEY,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),ticket_id TEXT NOT NULL,qa_revision INTEGER NOT NULL,source_state_digest TEXT NOT NULL,review_basis_digest TEXT NOT NULL,status TEXT NOT NULL,intent_json TEXT NOT NULL,receipt_json TEXT,created_at TEXT NOT NULL,updated_at TEXT NOT NULL);
|
|
1432
|
+
CREATE TABLE IF NOT EXISTS qa_remediation_receipts(operation_id TEXT PRIMARY KEY,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),ticket_id TEXT NOT NULL,report_digest TEXT NOT NULL REFERENCES qa_reports(report_digest),receipt_json TEXT NOT NULL,created_at TEXT NOT NULL);
|
|
1433
|
+
CREATE TABLE IF NOT EXISTS qa_pass_certificates(certificate_id TEXT PRIMARY KEY,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),ticket_id TEXT NOT NULL,qa_revision INTEGER NOT NULL,source_state_digest TEXT NOT NULL,review_basis_digest TEXT NOT NULL,turn_receipt_digest TEXT NOT NULL,certificate_json TEXT NOT NULL,issued_at TEXT NOT NULL,consumed_at TEXT,consumed_by TEXT);
|
|
1434
|
+
CREATE TABLE IF NOT EXISTS qa_finalization_steps(operation_id TEXT PRIMARY KEY,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),ticket_id TEXT NOT NULL,certificate_id TEXT NOT NULL REFERENCES qa_pass_certificates(certificate_id),kind TEXT NOT NULL,status TEXT NOT NULL,intent_json TEXT NOT NULL,receipt_json TEXT,created_at TEXT NOT NULL,updated_at TEXT NOT NULL);
|
|
1435
|
+
CREATE TABLE IF NOT EXISTS qa_operation_journal(operation_id TEXT PRIMARY KEY,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),ticket_id TEXT NOT NULL,kind TEXT NOT NULL,intent_digest TEXT NOT NULL,intent_json TEXT NOT NULL,status TEXT NOT NULL,receipt_json TEXT,created_at TEXT NOT NULL,updated_at TEXT NOT NULL);
|
|
1436
|
+
CREATE TABLE IF NOT EXISTS qa_packet_projections(run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),ticket_id TEXT NOT NULL,qa_revision INTEGER NOT NULL,packet_digest TEXT PRIMARY KEY,path TEXT NOT NULL,manifest_json TEXT,status TEXT NOT NULL,created_at TEXT NOT NULL);
|
|
813
1437
|
CREATE INDEX IF NOT EXISTS workflow_events_run ON workflow_events(run_id,sequence);
|
|
814
1438
|
CREATE INDEX IF NOT EXISTS operations_run ON operation_journal(run_id,status);
|
|
815
1439
|
CREATE INDEX IF NOT EXISTS recovery_attempt_scope ON recovery_attempts(run_id,ticket,phase,cause,operation_key,outcome);
|
|
@@ -818,6 +1442,12 @@ export class WorkflowDb {
|
|
|
818
1442
|
CREATE INDEX IF NOT EXISTS continuity_events_run ON continuity_events(run_id,sequence);
|
|
819
1443
|
CREATE INDEX IF NOT EXISTS continuity_checkpoints_run_role ON continuity_checkpoints(run_id,role,checkpoint_id);
|
|
820
1444
|
CREATE INDEX IF NOT EXISTS compaction_session ON compaction_attempts(run_id,role,provider_session_id,status);
|
|
1445
|
+
CREATE INDEX IF NOT EXISTS qa_review_history ON qa_review_attempts(run_id,ticket_id,review_number);
|
|
1446
|
+
CREATE INDEX IF NOT EXISTS qa_remediation_history ON qa_remediation_attempts(run_id,ticket_id,generation);
|
|
1447
|
+
CREATE INDEX IF NOT EXISTS qa_failure_handoff_history ON qa_failure_handoffs(run_id,ticket_id,generation,state);
|
|
1448
|
+
CREATE INDEX IF NOT EXISTS qa_transition_history ON qa_transitions(run_id,ticket_id,sequence);
|
|
1449
|
+
CREATE INDEX IF NOT EXISTS qa_report_open ON qa_reports(run_id,ticket_id,disposition,review_number);
|
|
1450
|
+
CREATE INDEX IF NOT EXISTS qa_turn_history ON qa_turns(run_id,ticket_id,review_number,session_generation);
|
|
821
1451
|
`);
|
|
822
1452
|
for (const [table, columns] of Object.entries({
|
|
823
1453
|
provider_sessions: { session_key: "TEXT", session_ref_json: "TEXT" },
|
|
@@ -826,10 +1456,11 @@ export class WorkflowDb {
|
|
|
826
1456
|
live_settings_acknowledgments: { session_key: "TEXT", session_ref_json: "TEXT" },
|
|
827
1457
|
continuity_events: { session_key: "TEXT", session_ref_json: "TEXT" },
|
|
828
1458
|
continuity_checkpoints: { session_key: "TEXT", session_ref_json: "TEXT" },
|
|
829
|
-
handoffs: { predecessor_session_key: "TEXT", predecessor_session_ref_json: "TEXT", successor_session_key: "TEXT", successor_session_ref_json: "TEXT" },
|
|
1459
|
+
handoffs: { predecessor_session_key: "TEXT", predecessor_session_ref_json: "TEXT", successor_session_key: "TEXT", successor_session_ref_json: "TEXT", acceptance_receipt_digest: "TEXT" },
|
|
830
1460
|
role_mutation_leases: { provider_session_key: "TEXT", provider_session_ref_json: "TEXT" },
|
|
831
1461
|
compaction_attempts: { session_key: "TEXT", session_ref_json: "TEXT" },
|
|
832
1462
|
recovery_decisions: { session_key: "TEXT", session_ref_json: "TEXT" },
|
|
1463
|
+
qa_packet_projections: { manifest_json: "TEXT" },
|
|
833
1464
|
})) {
|
|
834
1465
|
for (const [column, definition] of Object.entries(columns))
|
|
835
1466
|
this.ensureColumn(table, column, definition);
|
|
@@ -841,6 +1472,11 @@ export class WorkflowDb {
|
|
|
841
1472
|
CREATE INDEX IF NOT EXISTS session_usage_scoped ON session_usage_samples(run_id,role,session_key,sample_id);
|
|
842
1473
|
CREATE INDEX IF NOT EXISTS compaction_session_scoped ON compaction_attempts(run_id,role,session_key,status);
|
|
843
1474
|
`);
|
|
1475
|
+
const migration = "002_qa_protocol_v2";
|
|
1476
|
+
const migrationDigest = createHash("sha256").update(migration).update("\0qa-v2-schema-2026-09-04").digest("hex");
|
|
1477
|
+
const existingMigration = this.db.prepare("SELECT completed_at FROM recovery_schema_migrations WHERE migration=?").get(`${migration}:${migrationDigest}`);
|
|
1478
|
+
if (!existingMigration)
|
|
1479
|
+
this.db.prepare("INSERT INTO recovery_schema_migrations(migration,completed_at) VALUES(?,?)").run(`${migration}:${migrationDigest}`, new Date().toISOString());
|
|
844
1480
|
}
|
|
845
1481
|
ensureColumn(table, column, definition) {
|
|
846
1482
|
const columns = this.db.prepare(`PRAGMA table_info(${table})`).all();
|
|
@@ -854,8 +1490,13 @@ function operationFromRow(row) { return { idempotencyKey: row.idempotency_key, r
|
|
|
854
1490
|
function continuityEventFromRow(row) { return { sequence: row.sequence, runId: row.run_id, role: row.role, kind: row.kind, payload: parseJson(row.payload_json), digest: row.digest, authoritativeStateRevision: row.authoritative_state_revision, createdAt: row.created_at, ...(row.session_key ? { sessionKey: row.session_key } : {}), ...(row.session_ref_json ? { sessionRef: parseJson(row.session_ref_json) } : {}) }; }
|
|
855
1491
|
function continuityCheckpointFromRow(row) { return { checkpointId: row.checkpoint_id, runId: row.run_id, role: row.role, sequence: row.event_sequence, state: row.state, delta: parseJson(row.delta_json), digest: row.digest, ...(row.predecessor_digest ? { predecessorDigest: row.predecessor_digest } : {}), authoritativeStateRevision: row.authoritative_state_revision, createdAt: row.created_at, ...(row.session_key ? { sessionKey: row.session_key } : {}), ...(row.session_ref_json ? { sessionRef: parseJson(row.session_ref_json) } : {}) }; }
|
|
856
1492
|
function continuityHeadFromRow(row) { return { runId: row.run_id, role: row.role, state: row.state, sequence: row.event_sequence, digest: row.digest, authoritativeStateRevision: row.authoritative_state_revision, updatedAt: row.updated_at }; }
|
|
857
|
-
function handoffFromRow(row) { return { runId: row.run_id, generation: row.generation, manifestDigest: row.manifest_digest, markdownDigest: row.markdown_digest, ...(row.predecessor_session_id ? { predecessorSessionId: row.predecessor_session_id } : {}), ...(row.predecessor_session_ref_json ? { predecessorSessionRef: parseJson(row.predecessor_session_ref_json) } : {}), ...(row.successor_session_id ? { successorSessionId: row.successor_session_id } : {}), ...(row.successor_session_ref_json ? { successorSessionRef: parseJson(row.successor_session_ref_json) } : {}), state: row.state, createdAt: row.created_at, ...(row.accepted_at ? { acceptedAt: row.accepted_at } : {}) }; }
|
|
1493
|
+
function handoffFromRow(row) { return { runId: row.run_id, generation: row.generation, manifestDigest: row.manifest_digest, markdownDigest: row.markdown_digest, ...(row.predecessor_session_id ? { predecessorSessionId: row.predecessor_session_id } : {}), ...(row.predecessor_session_ref_json ? { predecessorSessionRef: parseJson(row.predecessor_session_ref_json) } : {}), ...(row.successor_session_id ? { successorSessionId: row.successor_session_id } : {}), ...(row.successor_session_ref_json ? { successorSessionRef: parseJson(row.successor_session_ref_json) } : {}), ...(row.acceptance_receipt_digest ? { acceptanceReceiptDigest: row.acceptance_receipt_digest } : {}), state: row.state, createdAt: row.created_at, ...(row.accepted_at ? { acceptedAt: row.accepted_at } : {}) }; }
|
|
858
1494
|
function compactionAttemptFromRow(row) { return { idempotencyKey: row.idempotency_key, runId: row.run_id, role: row.role, ...(row.provider_session_id ? { providerSessionId: row.provider_session_id } : {}), ...(row.session_key ? { sessionKey: row.session_key } : {}), ...(row.session_ref_json ? { sessionRef: parseJson(row.session_ref_json) } : {}), crossingKey: row.crossing_key, status: row.status, ...(row.before_sample_json ? { beforeSample: parseJson(row.before_sample_json) } : {}), ...(row.after_sample_json ? { afterSample: parseJson(row.after_sample_json) } : {}), ...(row.error ? { error: row.error } : {}), createdAt: row.created_at, updatedAt: row.updated_at }; }
|
|
1495
|
+
function qaReportV2FromRow(row) { return { reportDigest: String(row.report_digest), runId: String(row.run_id), ticketId: String(row.ticket_id), reviewNumber: Number(row.review_number), sourceStateDigest: String(row.source_state_digest), reviewBasisDigest: String(row.review_basis_digest), disposition: String(row.disposition), report: parseJson(String(row.report_json)), createdAt: String(row.created_at), updatedAt: String(row.updated_at) }; }
|
|
1496
|
+
function qaRecoveryHeadFromRow(row) { return { runId: row.run_id, ticketId: row.ticket_id, packetId: row.packet_id, packetPath: row.packet_path, packetDigest: row.packet_digest, reviewedStateDigest: row.reviewed_state_digest, revision: row.revision, correctionTurns: row.correction_turns, pendingAction: row.pending_action, updatedAt: row.updated_at }; }
|
|
1497
|
+
function qaReviewAttemptFromRow(row) { const record = parseJson(row.record_json); return { attemptId: row.attempt_id, runId: row.run_id, ticketId: row.ticket_id, reviewNumber: row.review_number, cycle: row.cycle, remediationGeneration: row.remediation_generation, sourceDigest: row.source_digest, status: row.status, ...(row.report_digest ? { reportDigest: row.report_digest } : {}), ...(record.findingIds ? { findingIds: record.findingIds } : {}), ...(record.namespacedFindingIds ? { namespacedFindingIds: record.namespacedFindingIds } : {}), ...(record.detail ? { detail: record.detail } : {}), createdAt: row.created_at, updatedAt: row.updated_at }; }
|
|
1498
|
+
function qaRemediationAttemptFromRow(row) { const record = parseJson(row.record_json); return { attemptId: row.attempt_id, runId: row.run_id, ticketId: row.ticket_id, reviewAttemptId: row.review_attempt_id, generation: row.generation, mode: row.mode, status: row.status, requestDigest: row.request_digest, ...(row.response_digest ? { responseDigest: row.response_digest } : {}), ...(row.summary_digest ? { summaryDigest: row.summary_digest } : {}), ...(record.detail ? { detail: record.detail } : {}), createdAt: row.created_at, updatedAt: row.updated_at }; }
|
|
1499
|
+
function qaFailureHandoffFromRow(row) { return { handoffId: row.handoff_id, operationId: row.operation_id, runId: row.run_id, ticketId: row.ticket_id, reviewAttemptId: row.review_attempt_id, reportDigest: row.report_digest, generation: row.generation, reviewedContentDigest: row.reviewed_content_digest, reviewBasisDigest: row.review_basis_digest, state: row.state, handoffDigest: row.handoff_digest, hostInstructionDigest: row.host_instruction_digest, ...(row.builder_session_json ? { builderSession: parseJson(row.builder_session_json) } : {}), ...(row.provider_turn_id ? { providerTurnId: row.provider_turn_id } : {}), ...(row.receipt_digest ? { receiptDigest: row.receipt_digest } : {}), ...(row.response_digest ? { responseDigest: row.response_digest } : {}), ...(row.parsed_response_digest ? { parsedResponseDigest: row.parsed_response_digest } : {}), ...(row.post_source_digest ? { postSourceDigest: row.post_source_digest } : {}), ...(row.detail ? { detail: row.detail } : {}), createdAt: row.created_at, updatedAt: row.updated_at }; }
|
|
859
1500
|
function json(value) { return JSON.stringify(value ?? null); }
|
|
860
1501
|
function parseJson(value) { return JSON.parse(value); }
|
|
861
1502
|
function sessionParts(ref, rawId, suppliedKey) {
|