ai-foreman 1.7.28 → 2.0.0
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 +1 -1
- package/dist/adapters/claude.d.ts +23 -12
- package/dist/adapters/claude.js +228 -24
- package/dist/adapters/codex.d.ts +17 -7
- package/dist/adapters/codex.js +195 -17
- package/dist/adapters/recovering.d.ts +2 -0
- package/dist/adapters/recovering.js +5 -1
- package/dist/adapters/structured.d.ts +71 -0
- package/dist/adapters/structured.js +213 -0
- package/dist/adapters/types.d.ts +86 -9
- package/dist/agentRun.d.ts +2 -0
- package/dist/agentRun.js +2 -1
- package/dist/boundedTurnEvidence.d.ts +18 -0
- package/dist/boundedTurnEvidence.js +76 -0
- package/dist/branch/currentGuard.d.ts +2 -0
- package/dist/branch/currentGuard.js +2 -0
- package/dist/branch/planner.js +1 -4
- package/dist/branch/runner.d.ts +2 -1
- package/dist/branch/runner.js +10 -7
- package/dist/buildAdmission.js +4 -0
- package/dist/buildApproval.d.ts +30 -0
- package/dist/buildApproval.js +68 -0
- package/dist/buildAssignment.d.ts +1 -0
- package/dist/buildAssignment.js +82 -23
- package/dist/buildInterventions.d.ts +2 -2
- package/dist/buildInterventions.js +11 -4
- package/dist/buildLaunchGate.js +3 -2
- package/dist/builderGuidanceFollowup.js +17 -6
- package/dist/cli/manager.js +22 -20
- package/dist/cli/start.js +32 -24
- package/dist/cli/tickets.js +40 -24
- package/dist/continuity.d.ts +10 -14
- package/dist/continuity.js +198 -179
- package/dist/continuityOrigin.d.ts +13 -0
- package/dist/continuityOrigin.js +91 -0
- package/dist/foreman.d.ts +20 -5
- package/dist/foreman.js +57 -187
- package/dist/graph/boundary.d.ts +1 -1
- package/dist/graph/boundary.js +1 -1
- package/dist/graph/host.js +8 -19
- package/dist/graph/manager.d.ts +1 -1
- package/dist/graph/manager.js +1 -1
- package/dist/graph/preparation.d.ts +38 -0
- package/dist/graph/preparation.js +7 -6
- package/dist/graph/semantic.d.ts +1 -0
- package/dist/graph/semantic.js +4 -1
- package/dist/graph/session.js +7 -2
- package/dist/graph/turn.d.ts +2 -3
- package/dist/graph/turn.js +28 -40
- package/dist/handoffProtocol.js +2 -2
- package/dist/handoffs.d.ts +3 -1
- package/dist/handoffs.js +90 -59
- package/dist/hostGraphApplicability.d.ts +46 -0
- package/dist/hostGraphApplicability.js +97 -0
- package/dist/hostQaAugmentation.d.ts +18 -0
- package/dist/hostQaAugmentation.js +81 -0
- package/dist/hostQaComparison.d.ts +21 -2
- package/dist/hostQaComparison.js +43 -28
- package/dist/hostSourcePreservation.d.ts +38 -0
- package/dist/hostSourcePreservation.js +72 -0
- package/dist/interviews.d.ts +7 -0
- package/dist/interviews.js +18 -1
- package/dist/legacyCommunicationDisplay.d.ts +2 -0
- package/dist/legacyCommunicationDisplay.js +2 -0
- package/dist/managerPacket.js +3 -3
- package/dist/markers.d.ts +4 -3
- package/dist/markers.js +4 -20
- package/dist/probeIpc.d.ts +14 -0
- package/dist/probeIpc.js +29 -0
- package/dist/qaBuildGate.d.ts +21 -0
- package/dist/qaBuildGate.js +48 -12
- package/dist/qaBuilderCoverage.js +22 -13
- package/dist/qaBuilderEvidence.d.ts +16 -1
- package/dist/qaBuilderEvidence.js +48 -8
- package/dist/qaContractCoverage.d.ts +2 -2
- package/dist/qaContractCoverage.js +11 -7
- package/dist/qaContractDelivery.js +34 -20
- package/dist/qaCoverageAssembly.d.ts +22 -0
- package/dist/qaCoverageAssembly.js +16 -0
- package/dist/qaDeliveryJournal.d.ts +2 -0
- package/dist/qaDeliveryJournal.js +19 -0
- package/dist/qaEffectiveConfig.d.ts +1 -0
- package/dist/qaEffectiveConfig.js +6 -1
- package/dist/qaFailureDelivery.js +97 -85
- package/dist/qaHostTransformation.d.ts +26 -0
- package/dist/qaHostTransformation.js +100 -0
- package/dist/qaPreparation.d.ts +8 -20
- package/dist/qaPreparation.js +133 -229
- package/dist/qaPreparationInputs.js +19 -0
- package/dist/qaPreparationStore.js +34 -6
- package/dist/qaPrerequisites.d.ts +2 -1
- package/dist/qaPrerequisites.js +21 -1
- package/dist/qaProtocolV2.d.ts +15 -0
- package/dist/qaRecovery.js +21 -4
- package/dist/qaReview.js +232 -394
- package/dist/qaReviewEvidence.d.ts +7 -3
- package/dist/qaReviewEvidence.js +15 -4
- package/dist/qaRuntime.js +1 -1
- package/dist/qaSnapshot.js +71 -21
- package/dist/qaStructuredResponse.d.ts +10 -0
- package/dist/qaStructuredResponse.js +57 -0
- package/dist/qaTimeline.js +5 -3
- package/dist/qaToolRecovery.d.ts +12 -0
- package/dist/qaToolRecovery.js +44 -0
- package/dist/qaVerificationContract.d.ts +3 -2
- package/dist/qaVerificationContract.js +58 -34
- package/dist/readinessGate.js +5 -4
- package/dist/runtimeReadiness.js +23 -15
- package/dist/statusReporter.d.ts +2 -0
- package/dist/statusReporter.js +2 -0
- package/dist/structuredCutover.d.ts +31 -0
- package/dist/structuredCutover.js +510 -0
- package/dist/structuredCutoverFiles.d.ts +10 -0
- package/dist/structuredCutoverFiles.js +181 -0
- package/dist/structuredDispatchAuthority.d.ts +22 -0
- package/dist/structuredDispatchAuthority.js +110 -0
- package/dist/structuredDispatchRecord.d.ts +29 -0
- package/dist/structuredDispatchRecord.js +36 -0
- package/dist/structuredLiveBudget.d.ts +2 -0
- package/dist/structuredLiveBudget.js +23 -0
- package/dist/structuredOperation.d.ts +18 -0
- package/dist/structuredOperation.js +257 -0
- package/dist/supervisedStart.d.ts +1 -1
- package/dist/supervisedStart.js +78 -31
- package/dist/supervisor.d.ts +18 -2
- package/dist/supervisor.js +74 -17
- package/dist/ticketPlanning.d.ts +1 -373
- package/dist/ticketPlanning.js +2 -26
- package/dist/ticketPopulation.d.ts +0 -3
- package/dist/ticketPopulation.js +4 -21
- package/dist/ticketPopulationPageAuthority.d.ts +13 -0
- package/dist/ticketPopulationPageAuthority.js +30 -0
- package/dist/ticketPopulationPaging.d.ts +27 -0
- package/dist/ticketPopulationPaging.js +105 -0
- package/dist/tickets/renderMarkdown.d.ts +14 -0
- package/dist/tickets/renderMarkdown.js +39 -5
- package/dist/tickets/ticketLoader.js +10 -0
- package/dist/tickets/ticketSchema.d.ts +107 -0
- package/dist/tickets/ticketSchema.js +4 -1
- package/dist/workflowDb.d.ts +100 -2
- package/dist/workflowDb.js +781 -35
- package/dist/workflowReader.d.ts +1 -0
- package/dist/workflowReader.js +9 -0
- package/package.json +15 -7
package/dist/workflowDb.js
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
|
+
import { persistedProviderWireContract } from "rafi-spec";
|
|
2
|
+
import { verifyCheckpointOrigin, verifyContinuityHeads } from "./continuityOrigin.js";
|
|
1
3
|
import { registerQaGraphJournalWriter, ensureQaGraphJournalGuards } from "./qaDeliveryJournal.js";
|
|
2
4
|
import { canonicalJson } from "./qaProtocolV2.js";
|
|
5
|
+
import { hydrateStructuredReceipt, persistedDispatchTuple, sealedDispatchRecord, verifyPersistedDispatch } from "./structuredDispatchRecord.js";
|
|
6
|
+
import { assertStructuredCutoverComplete, cutoverOwnershipMatrix, invalidStructuredRun, purgeStructuredRun, STRUCTURED_CUTOVER_MATRIX_VERSION, STRUCTURED_CUTOVER_REASON, STRUCTURED_AUDIT_CLOSURE } from "./structuredCutover.js";
|
|
7
|
+
import { communicationFileEvidenceReferences, quarantineCommunicationFiles } from "./structuredCutoverFiles.js";
|
|
3
8
|
import { resolveEffectiveQaConfiguration } from "./qaEffectiveConfig.js";
|
|
4
9
|
import { registerGraphDerived, graphDerivedAllowed, currentGraphDerivedAccess } from "./graph/derived.js";
|
|
5
|
-
import { bindCoverageHostFields,
|
|
6
|
-
import {
|
|
10
|
+
import { bindCoverageHostFields, validateContractCoverage } from "./qaContractCoverage.js";
|
|
11
|
+
import { findHostQaCheck, hostComparisonDigestFromCoverage, hostQaComparisonV1Schema } from "./hostQaComparison.js";
|
|
12
|
+
import { findGraphQaChecks, hostGraphApplicabilityDigestFromCoverage, hostGraphApplicabilityV1Schema } from "./hostGraphApplicability.js";
|
|
13
|
+
import { assembleFinalQaCoverage } from "./qaCoverageAssembly.js";
|
|
14
|
+
import { retainedQaCommandObservations } from "./qaReviewEvidence.js";
|
|
15
|
+
import { retainedHostBuilderObservations } from "./qaBuilderEvidence.js";
|
|
7
16
|
import { QaPreparationStore, migrateQaPreparation, migrateQaPreparationGuards } from "./qaPreparationStore.js";
|
|
8
17
|
import { GraphStore, migrateGraphStore } from "./graph/storage.js";
|
|
9
18
|
import { reconcileWork } from "./buildWorkReconciliation.js";
|
|
@@ -18,9 +27,9 @@ import { hostname } from "node:os";
|
|
|
18
27
|
import { dirname, join, resolve } from "node:path";
|
|
19
28
|
import { execFileSync } from "node:child_process";
|
|
20
29
|
import Database from "better-sqlite3";
|
|
21
|
-
import { validateQaFailureReport } from "rafi-spec";
|
|
30
|
+
import { validateQaFailureReport, qaVerdictOutput, remediationOutput } from "rafi-spec";
|
|
22
31
|
import { migrateQaHandback, registerHandbackWriter, reportOccurrenceId } from "./qaHandbackMigration.js";
|
|
23
|
-
import { providerSessionKey } from "./sessionIdentity.js";
|
|
32
|
+
import { providerSessionKey, canonicalSessionPath } from "./sessionIdentity.js";
|
|
24
33
|
import { captureFrozenQaSource } from "./qaSnapshot.js";
|
|
25
34
|
import { loadTicketsConfig, resolveTicketPaths } from "./tickets/config.js";
|
|
26
35
|
import { loadTickets } from "./tickets/ticketLoader.js";
|
|
@@ -54,6 +63,7 @@ export function heartbeatCurrentWorkflowLease(projectDir, lease, now = new Date(
|
|
|
54
63
|
throw new Error("workflow recovery database not found");
|
|
55
64
|
const db = new Database(path, { fileMustExist: true });
|
|
56
65
|
db.function("rafi_protocol_v3", () => 1);
|
|
66
|
+
db.pragma("busy_timeout = 5000");
|
|
57
67
|
registerHandbackWriter(db);
|
|
58
68
|
try {
|
|
59
69
|
const at = now.toISOString();
|
|
@@ -121,6 +131,8 @@ export class WorkflowDb {
|
|
|
121
131
|
if (existsSync(path)) {
|
|
122
132
|
const preview = new Database(path, { readonly: true, fileMustExist: true });
|
|
123
133
|
try {
|
|
134
|
+
if (readinessAccess)
|
|
135
|
+
assertStructuredCutoverComplete(preview);
|
|
124
136
|
checkBuildOwnershipSchema(preview);
|
|
125
137
|
}
|
|
126
138
|
finally {
|
|
@@ -144,13 +156,20 @@ export class WorkflowDb {
|
|
|
144
156
|
registerQaGraphJournalWriter(this.db);
|
|
145
157
|
if (!readinessAccess)
|
|
146
158
|
this.db.pragma("journal_mode = WAL");
|
|
159
|
+
// Short-lived coordinator/worker writes can overlap with a heartbeat or
|
|
160
|
+
// another durable transition. Let SQLite wait for the owning transaction
|
|
161
|
+
// instead of immediately surfacing SQLITE_BUSY to the supervisor.
|
|
162
|
+
this.db.pragma("busy_timeout = 5000");
|
|
147
163
|
this.db.pragma("synchronous = FULL");
|
|
148
164
|
this.db.pragma("foreign_keys = ON");
|
|
149
165
|
try {
|
|
150
|
-
if (readinessAccess)
|
|
166
|
+
if (readinessAccess) {
|
|
167
|
+
assertStructuredCutoverComplete(this.db);
|
|
151
168
|
this.restrictReadinessConnection(readinessAccess);
|
|
169
|
+
}
|
|
152
170
|
else {
|
|
153
171
|
this.migrate();
|
|
172
|
+
this.applyStructuredCommunicationV2Cutover();
|
|
154
173
|
migrateQaPreparation(this.db);
|
|
155
174
|
migrateBuildAdmission(this.db);
|
|
156
175
|
this.importLegacyOnce();
|
|
@@ -194,6 +213,7 @@ export class WorkflowDb {
|
|
|
194
213
|
}
|
|
195
214
|
}
|
|
196
215
|
assertInstructionOwner(runId) {
|
|
216
|
+
this.assertNativeRunAdmission(runId);
|
|
197
217
|
if (this.writerAuthority) {
|
|
198
218
|
assertAdmission(this.db, this.writerAuthority);
|
|
199
219
|
if (this.writerAuthority.runId !== runId && !this.db.prepare("SELECT 1 FROM build_child_runs WHERE child=? AND parent=?").get(runId, this.writerAuthority.runId))
|
|
@@ -327,7 +347,7 @@ export class WorkflowDb {
|
|
|
327
347
|
}
|
|
328
348
|
}
|
|
329
349
|
admittedWork(runId, workId) { return admittedWork(this.db, runId, workId); }
|
|
330
|
-
assertAdmittedWork(runId, workId) { return assertAdmittedWork(this.db, runId, workId); }
|
|
350
|
+
assertAdmittedWork(runId, workId) { this.assertNativeRunAdmission(runId); return assertAdmittedWork(this.db, runId, workId); }
|
|
331
351
|
/** Atomic local changes only; never await provider or filesystem work here. */
|
|
332
352
|
atomic(work) { return this.db.transaction(work).immediate(); }
|
|
333
353
|
buildAdmission() { return readAdmission(this.db); }
|
|
@@ -410,6 +430,7 @@ export class WorkflowDb {
|
|
|
410
430
|
throw new Error("Ownership migration is pending legacy execution cleanup; use rafi resume before starting work");
|
|
411
431
|
}
|
|
412
432
|
acquireBuildAdmission(runId, phase) {
|
|
433
|
+
this.assertNativeRunAdmission(runId);
|
|
413
434
|
return this.atomic(() => {
|
|
414
435
|
this.assertNoLegacyBuildOwner();
|
|
415
436
|
const lease = this.currentLease();
|
|
@@ -440,6 +461,7 @@ export class WorkflowDb {
|
|
|
440
461
|
});
|
|
441
462
|
}
|
|
442
463
|
acquireBuildRecoveryAdmission(runId) {
|
|
464
|
+
this.assertNativeRunAdmission(runId);
|
|
443
465
|
return this.atomic(() => {
|
|
444
466
|
this.assertExecutionProtocol();
|
|
445
467
|
this.assertNoLegacyBuildOwner();
|
|
@@ -831,6 +853,11 @@ export class WorkflowDb {
|
|
|
831
853
|
originalWork: input.originalWork ?? {}, remainingWork: input.remainingWork ?? input.originalWork ?? {}, state: input.state ?? {},
|
|
832
854
|
legacy: Boolean(input.legacy), createdAt: at, updatedAt: at,
|
|
833
855
|
};
|
|
856
|
+
if (run.legacy) {
|
|
857
|
+
run.status = "superseded";
|
|
858
|
+
run.checkpoint = STRUCTURED_CUTOVER_REASON;
|
|
859
|
+
run.state = { ...run.state, structuredCommunicationCutover: { version: 2, reason: STRUCTURED_CUTOVER_REASON, at } };
|
|
860
|
+
}
|
|
834
861
|
this.db.transaction(() => {
|
|
835
862
|
if (this.writerAuthority)
|
|
836
863
|
assertAdmission(this.db, this.writerAuthority);
|
|
@@ -902,6 +929,7 @@ export class WorkflowDb {
|
|
|
902
929
|
});
|
|
903
930
|
}
|
|
904
931
|
transition(runId, update, now = new Date()) {
|
|
932
|
+
this.assertNativeRunAdmission(runId);
|
|
905
933
|
const at = now.toISOString();
|
|
906
934
|
return this.db.transaction(() => {
|
|
907
935
|
if (!this.coordinatorTransition && (this.writerAuthority || this.buildAdmission())) {
|
|
@@ -1029,11 +1057,33 @@ export class WorkflowDb {
|
|
|
1029
1057
|
this.assertGraphContinuityAccess(row.payload_json);
|
|
1030
1058
|
return rows.map(continuityEventFromRow);
|
|
1031
1059
|
}
|
|
1060
|
+
providerCheckpoint(dispatchId, publicationKind) {
|
|
1061
|
+
const row = this.db.prepare("SELECT c.* FROM continuity_checkpoints c JOIN continuity_checkpoint_origins o USING(checkpoint_id) WHERE o.dispatch_id=? AND o.publication_kind=?").get(dispatchId, publicationKind);
|
|
1062
|
+
if (row)
|
|
1063
|
+
verifyCheckpointOrigin(this.db, this.projectDir, row.checkpoint_id);
|
|
1064
|
+
return row ? continuityCheckpointFromRow(row) : undefined;
|
|
1065
|
+
}
|
|
1032
1066
|
publishContinuityCheckpoint(input, now = new Date()) {
|
|
1033
1067
|
this.ensureRun(input.runId, "build", now);
|
|
1034
1068
|
return this.db.transaction(() => {
|
|
1035
1069
|
const at = now.toISOString();
|
|
1070
|
+
if (input.origin?.kind === "provider") {
|
|
1071
|
+
const prior = this.db.prepare("SELECT c.* FROM continuity_checkpoints c JOIN continuity_checkpoint_origins o USING(checkpoint_id) WHERE c.run_id=? AND c.role=? AND o.dispatch_id=? AND o.publication_kind=?")
|
|
1072
|
+
.get(input.runId, input.role, input.origin.dispatchId, input.origin.publicationKind);
|
|
1073
|
+
if (prior) {
|
|
1074
|
+
verifyCheckpointOrigin(this.db, this.projectDir, prior.checkpoint_id);
|
|
1075
|
+
if (canonicalJson(parseJson(prior.delta_json)) !== canonicalJson(input.delta))
|
|
1076
|
+
throw new Error("Conflicting repeated checkpoint publication");
|
|
1077
|
+
return continuityCheckpointFromRow(prior);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1036
1080
|
const previous = this.continuityHead(input.runId, input.role);
|
|
1081
|
+
let origin = input.origin;
|
|
1082
|
+
if (!origin) {
|
|
1083
|
+
const event = this.appendContinuityEvent({ runId: input.runId, role: "host", kind: input.hostEventKind ?? "host_checkpoint", authoritativeStateRevision: input.authoritativeStateRevision,
|
|
1084
|
+
payload: { checkpointDeltaDigest: createHash("sha256").update(json(sanitizeContinuityValue(input.delta))).digest("hex"), role: input.role } });
|
|
1085
|
+
origin = { kind: "host", eventSequence: event.sequence, inputCheckpointDigests: input.inputCheckpointDigests ?? (previous ? [previous.digest] : []) };
|
|
1086
|
+
}
|
|
1037
1087
|
const latestEvent = this.db.prepare("SELECT COALESCE(MAX(sequence),0) AS sequence FROM continuity_events WHERE run_id=?").get(input.runId);
|
|
1038
1088
|
const safeDelta = sanitizeContinuityValue(input.delta);
|
|
1039
1089
|
const graphAccess = currentGraphDerivedAccess();
|
|
@@ -1043,6 +1093,10 @@ export class WorkflowDb {
|
|
|
1043
1093
|
const digest = digestJson({ runId: input.runId, role: input.role, sequence: latestEvent.sequence, predecessorDigest: previous?.digest, delta: safeDelta, authoritativeStateRevision: input.authoritativeStateRevision, sessionKey: session.key });
|
|
1044
1094
|
const result = this.db.prepare(`INSERT INTO continuity_checkpoints(run_id,role,event_sequence,state,delta_json,digest,predecessor_digest,authoritative_state_revision,session_key,session_ref_json,created_at)
|
|
1045
1095
|
VALUES(?,?,?,?,?,?,?,?,?,?,?)`).run(input.runId, input.role, latestEvent.sequence, input.state ?? "current", json(safeDelta), digest, previous?.digest ?? null, input.authoritativeStateRevision, session.key, session.refJson, at);
|
|
1096
|
+
const checkpointId = Number(result.lastInsertRowid);
|
|
1097
|
+
this.db.prepare("INSERT INTO continuity_checkpoint_origins(checkpoint_id,run_id,origin_json,delta_digest,dispatch_id,publication_kind) VALUES(?,?,?,?,?,?)")
|
|
1098
|
+
.run(checkpointId, input.runId, json(origin), createHash("sha256").update(json(safeDelta)).digest("hex"), origin.kind === "provider" ? origin.dispatchId : null, origin.kind === "provider" ? origin.publicationKind : null);
|
|
1099
|
+
verifyCheckpointOrigin(this.db, this.projectDir, checkpointId);
|
|
1046
1100
|
const checkpoint = {
|
|
1047
1101
|
checkpointId: Number(result.lastInsertRowid), runId: input.runId, role: input.role,
|
|
1048
1102
|
sequence: latestEvent.sequence, state: input.state ?? "current", delta: safeDelta, digest,
|
|
@@ -1060,14 +1114,18 @@ export class WorkflowDb {
|
|
|
1060
1114
|
const rows = role
|
|
1061
1115
|
? this.db.prepare("SELECT * FROM continuity_checkpoints WHERE run_id=? AND role=? ORDER BY checkpoint_id").all(runId, role)
|
|
1062
1116
|
: this.db.prepare("SELECT * FROM continuity_checkpoints WHERE run_id=? ORDER BY checkpoint_id").all(runId);
|
|
1063
|
-
for (const row of rows)
|
|
1117
|
+
for (const row of rows) {
|
|
1064
1118
|
this.assertGraphContinuityAccess(row.delta_json);
|
|
1119
|
+
verifyCheckpointOrigin(this.db, this.projectDir, row.checkpoint_id);
|
|
1120
|
+
}
|
|
1065
1121
|
return rows.map(continuityCheckpointFromRow);
|
|
1066
1122
|
}
|
|
1067
1123
|
latestContinuityCheckpoint(runId, role) {
|
|
1068
1124
|
const row = this.db.prepare("SELECT * FROM continuity_checkpoints WHERE run_id=? AND role=? ORDER BY checkpoint_id DESC LIMIT 1").get(runId, role);
|
|
1069
|
-
if (row)
|
|
1125
|
+
if (row) {
|
|
1070
1126
|
this.assertGraphContinuityAccess(row.delta_json);
|
|
1127
|
+
verifyCheckpointOrigin(this.db, this.projectDir, row.checkpoint_id);
|
|
1128
|
+
}
|
|
1071
1129
|
return row ? continuityCheckpointFromRow(row) : undefined;
|
|
1072
1130
|
}
|
|
1073
1131
|
assertGraphContinuityAccess(value) {
|
|
@@ -1077,6 +1135,8 @@ export class WorkflowDb {
|
|
|
1077
1135
|
}
|
|
1078
1136
|
continuityHead(runId, role) {
|
|
1079
1137
|
const row = this.db.prepare("SELECT * FROM continuity_heads WHERE run_id=? AND role=?").get(runId, role);
|
|
1138
|
+
if (row)
|
|
1139
|
+
verifyContinuityHeads(this.db, this.projectDir, runId);
|
|
1080
1140
|
return row ? continuityHeadFromRow(row) : undefined;
|
|
1081
1141
|
}
|
|
1082
1142
|
setContinuityHeadState(runId, role, state, now = new Date()) {
|
|
@@ -1306,7 +1366,12 @@ export class WorkflowDb {
|
|
|
1306
1366
|
return row ? parseJson(row.state_json) : undefined;
|
|
1307
1367
|
}
|
|
1308
1368
|
planOperation(input, now = new Date()) {
|
|
1369
|
+
this.assertNativeRunAdmission(input.runId);
|
|
1309
1370
|
const at = now.toISOString();
|
|
1371
|
+
const run = this.getRun(input.runId);
|
|
1372
|
+
if (input.kind === "provider-dispatch" && run?.checkpoint === "structured-communication-v2-cutover") {
|
|
1373
|
+
throw new Error("This run was fenced by the structured-communication v2 cutover; start a new native dispatch");
|
|
1374
|
+
}
|
|
1310
1375
|
if (input.kind === "provider-dispatch" && this.unresolvedPreparationProcesses(input.runId).length)
|
|
1311
1376
|
throw new Error("Readiness cleanup must be reconciled before provider dispatch");
|
|
1312
1377
|
this.db.prepare(`INSERT INTO operation_journal(idempotency_key,run_id,kind,status,intent_json,created_at,updated_at)
|
|
@@ -1317,12 +1382,294 @@ export class WorkflowDb {
|
|
|
1317
1382
|
}
|
|
1318
1383
|
return operation;
|
|
1319
1384
|
}
|
|
1385
|
+
/** Seal a previously persisted non-authoritative preparation intent. This
|
|
1386
|
+
* is intentionally legal only before provider content dispatch begins. */
|
|
1387
|
+
sealPlannedOperationIntent(idempotencyKey, intent, now = new Date()) {
|
|
1388
|
+
const prior = this.operation(idempotencyKey);
|
|
1389
|
+
if (!prior || prior.status !== "planned" || prior.kind !== "provider-dispatch")
|
|
1390
|
+
throw new Error("Only a planned provider dispatch may be sealed");
|
|
1391
|
+
const preparing = prior.intent;
|
|
1392
|
+
if (preparing.protocolVersion !== 2 || preparing.state !== "preparing")
|
|
1393
|
+
throw new Error("Provider dispatch did not begin as a v2 preparation intent");
|
|
1394
|
+
this.db.prepare("UPDATE operation_journal SET intent_json=?,updated_at=? WHERE idempotency_key=? AND status='planned'")
|
|
1395
|
+
.run(json(intent), now.toISOString(), idempotencyKey);
|
|
1396
|
+
return this.operation(idempotencyKey);
|
|
1397
|
+
}
|
|
1398
|
+
/** Called in the gateway's sealing transaction, before the send capability exists. */
|
|
1399
|
+
sealStructuredOwnerDispatch(key, tuple, renderedInstruction) {
|
|
1400
|
+
const sealedDispatch = sealedDispatchRecord(tuple);
|
|
1401
|
+
const transportSession = this.operation(key)?.intent?.sessionRef;
|
|
1402
|
+
const matchesSession = (session) => Boolean(transportSession
|
|
1403
|
+
&& ["provider", "sessionId", "role", "stream", "generation", "cwd", "configRoot"].every(field => session[field] === transportSession[field]));
|
|
1404
|
+
const review = this.db.prepare("SELECT intent_json,status FROM qa_turns WHERE operation_id=?").get(key);
|
|
1405
|
+
if (review) {
|
|
1406
|
+
const intent = parseJson(review.intent_json);
|
|
1407
|
+
if (review.status !== "intended" || intent.sealedDispatch || !matchesSession(intent.providerSession) || intent.providerSession.sessionId !== tuple.sessionId || intent.providerSession.generation !== tuple.generation)
|
|
1408
|
+
throw new Error("QA review owner cannot seal a foreign or already dispatched session");
|
|
1409
|
+
this.db.prepare("UPDATE qa_turns SET intent_json=? WHERE operation_id=?").run(json({ ...intent, sealedDispatch }), key);
|
|
1410
|
+
}
|
|
1411
|
+
const delivery = this.db.prepare("SELECT record_json,status FROM qa_delivery_turns WHERE turn_record_id=?").get(key);
|
|
1412
|
+
if (delivery) {
|
|
1413
|
+
const turn = parseJson(delivery.record_json);
|
|
1414
|
+
if (delivery.status !== "intended" || turn.sealedDispatch || !matchesSession(turn.intendedSession) || turn.intendedSession.sessionId !== tuple.sessionId || turn.intendedSession.generation !== tuple.generation)
|
|
1415
|
+
throw new Error("QA remediation owner cannot seal a foreign or already dispatched session");
|
|
1416
|
+
this.db.prepare("UPDATE qa_delivery_turns SET record_json=? WHERE turn_record_id=?").run(json({ ...turn, sealedDispatch }), key);
|
|
1417
|
+
const attempt = this.db.prepare("SELECT record_json FROM qa_remediation_attempts WHERE attempt_id=?").get(turn.operationId);
|
|
1418
|
+
if (!attempt)
|
|
1419
|
+
throw new Error("Remediation dispatch has no durable owner attempt");
|
|
1420
|
+
const fields = parseJson(attempt.record_json);
|
|
1421
|
+
const dispatches = (fields.sealedDispatches ?? {});
|
|
1422
|
+
if (dispatches[key])
|
|
1423
|
+
throw new Error("Remediation dispatch already sealed");
|
|
1424
|
+
this.db.prepare("UPDATE qa_remediation_attempts SET record_json=? WHERE attempt_id=?").run(json({ ...fields, sealedDispatches: { ...dispatches, [key]: sealedDispatch } }), turn.operationId);
|
|
1425
|
+
}
|
|
1426
|
+
this.putEvidence("handoff", Buffer.from(renderedInstruction));
|
|
1427
|
+
}
|
|
1428
|
+
structuredOwnerDispatch(key) {
|
|
1429
|
+
const transport = this.operation(key)?.intent;
|
|
1430
|
+
const verifyOwner = (record) => {
|
|
1431
|
+
persistedDispatchTuple(record);
|
|
1432
|
+
if (json(transport?.sealedDispatch) !== json(record))
|
|
1433
|
+
throw new Error("Domain owner tuple differs from its transport intent");
|
|
1434
|
+
};
|
|
1435
|
+
const review = this.db.prepare("SELECT intent_json FROM qa_turns WHERE operation_id=?").get(key);
|
|
1436
|
+
if (review) {
|
|
1437
|
+
const record = parseJson(review.intent_json).sealedDispatch;
|
|
1438
|
+
if (!record)
|
|
1439
|
+
throw new Error("QA owner has no sealed dispatch");
|
|
1440
|
+
verifyOwner(record);
|
|
1441
|
+
return record;
|
|
1442
|
+
}
|
|
1443
|
+
const delivery = this.db.prepare("SELECT record_json FROM qa_delivery_turns WHERE turn_record_id=?").get(key);
|
|
1444
|
+
if (delivery) {
|
|
1445
|
+
const turn = parseJson(delivery.record_json);
|
|
1446
|
+
const record = turn.sealedDispatch;
|
|
1447
|
+
if (!record)
|
|
1448
|
+
throw new Error("Remediation owner has no sealed dispatch");
|
|
1449
|
+
verifyOwner(record);
|
|
1450
|
+
const attempt = this.db.prepare("SELECT record_json FROM qa_remediation_attempts WHERE attempt_id=?").get(turn.operationId);
|
|
1451
|
+
const sealed = attempt && parseJson(attempt.record_json).sealedDispatches?.[key];
|
|
1452
|
+
if (json(sealed) !== json(record))
|
|
1453
|
+
throw new Error("Remediation attempt tuple differs from its delivery intent");
|
|
1454
|
+
return record;
|
|
1455
|
+
}
|
|
1456
|
+
const intent = this.operation(key)?.intent;
|
|
1457
|
+
if (!intent?.sealedDispatch)
|
|
1458
|
+
throw new Error("Generic owner has no sealed dispatch");
|
|
1459
|
+
persistedDispatchTuple(intent.sealedDispatch);
|
|
1460
|
+
if (canonicalJson(intent.tuple) !== intent.sealedDispatch.tupleJson)
|
|
1461
|
+
throw new Error("Generic intent tuple differs from its sealed canonical bytes");
|
|
1462
|
+
return intent.sealedDispatch;
|
|
1463
|
+
}
|
|
1464
|
+
assertStructuredDispatchAdmission(runId, dispatchId, parentId, operation) {
|
|
1465
|
+
const processing = this.pendingStructuredPostprocessing(runId);
|
|
1466
|
+
const transfers = this.db.prepare("SELECT * FROM structured_handoff_lineage WHERE run_id=? AND state<>'completed'").all(runId);
|
|
1467
|
+
const linked = transfers.length === 1 && transfers[0].predecessor_id === parentId &&
|
|
1468
|
+
(operation === "handoff-acceptance" && transfers[0].state === "acceptance-pending" && !transfers[0].acceptance_id ||
|
|
1469
|
+
transfers[0].state === "continuation-planned" && transfers[0].continuation_id === dispatchId);
|
|
1470
|
+
if (processing.some(id => id !== parentId) || (processing.length || transfers.length) && !linked)
|
|
1471
|
+
throw new Error("Confirmed dispatch postprocessing or handoff is pending; reconcile before unrelated work");
|
|
1472
|
+
}
|
|
1473
|
+
beginStructuredHandoff(predecessorId) {
|
|
1474
|
+
const operation = this.operation(predecessorId);
|
|
1475
|
+
if (!operation || operation.status !== "confirmed")
|
|
1476
|
+
throw new Error("Handoff predecessor must be confirmed");
|
|
1477
|
+
const changed = this.db.prepare("INSERT OR IGNORE INTO structured_handoff_lineage(predecessor_id,run_id,state) VALUES(?,?,'acceptance-pending')").run(predecessorId, operation.runId);
|
|
1478
|
+
if (changed.changes !== 1)
|
|
1479
|
+
throw new Error("Handoff acceptance already started; reconcile persisted lineage before recovery");
|
|
1480
|
+
}
|
|
1481
|
+
linkStructuredHandoffDispatch(dispatchId, parentId, operation) {
|
|
1482
|
+
if (!parentId)
|
|
1483
|
+
return;
|
|
1484
|
+
const row = this.db.prepare("SELECT * FROM structured_handoff_lineage WHERE predecessor_id=?").get(parentId);
|
|
1485
|
+
if (!row)
|
|
1486
|
+
return;
|
|
1487
|
+
if (operation === "handoff-acceptance") {
|
|
1488
|
+
if (row.state !== "acceptance-pending" || row.acceptance_id)
|
|
1489
|
+
throw new Error("Handoff acceptance already dispatched");
|
|
1490
|
+
this.db.prepare("UPDATE structured_handoff_lineage SET acceptance_id=? WHERE predecessor_id=?").run(dispatchId, parentId);
|
|
1491
|
+
}
|
|
1492
|
+
else if (row.state !== "continuation-planned" || row.continuation_id !== dispatchId)
|
|
1493
|
+
throw new Error("Foreign handoff continuation");
|
|
1494
|
+
}
|
|
1495
|
+
planStructuredContinuation(predecessorId, successor) {
|
|
1496
|
+
return this.atomic(() => {
|
|
1497
|
+
const row = this.db.prepare("SELECT * FROM structured_handoff_lineage WHERE predecessor_id=?").get(predecessorId);
|
|
1498
|
+
if (!row || row.state !== "acceptance-pending" || !row.acceptance_id || this.operation(row.acceptance_id)?.status !== "confirmed")
|
|
1499
|
+
throw new Error("Successor lacks confirmed native handoff acceptance lineage");
|
|
1500
|
+
const origin = this.db.prepare("SELECT checkpoint_id FROM continuity_checkpoint_origins WHERE dispatch_id=? AND publication_kind='handoff_acceptance'").get(row.acceptance_id);
|
|
1501
|
+
if (!origin)
|
|
1502
|
+
throw new Error("Successor acceptance checkpoint is missing");
|
|
1503
|
+
verifyCheckpointOrigin(this.db, this.projectDir, origin.checkpoint_id);
|
|
1504
|
+
this.assertAcceptedStructuredSuccessor(row.run_id, origin.checkpoint_id, successor);
|
|
1505
|
+
const accepted = this.retainedStructuredTurn(row.acceptance_id).selectedDispatch;
|
|
1506
|
+
if (accepted.sessionId !== successor.sessionId || accepted.provider !== successor.provider)
|
|
1507
|
+
throw new Error("Successor differs from the accepted provider session");
|
|
1508
|
+
const continuationId = randomUUID();
|
|
1509
|
+
this.db.prepare("UPDATE structured_handoff_lineage SET state='continuation-planned',successor_json=?,continuation_id=? WHERE predecessor_id=?")
|
|
1510
|
+
.run(json(successor), continuationId, predecessorId);
|
|
1511
|
+
return continuationId;
|
|
1512
|
+
});
|
|
1513
|
+
}
|
|
1514
|
+
structuredContinuation(runId, role) {
|
|
1515
|
+
const row = this.db.prepare("SELECT * FROM structured_handoff_lineage WHERE run_id=? AND successor_json IS NOT NULL AND json_extract(successor_json,'$.role')=? ORDER BY rowid DESC LIMIT 1").get(runId, role);
|
|
1516
|
+
if (!row)
|
|
1517
|
+
return;
|
|
1518
|
+
const origin = this.db.prepare("SELECT checkpoint_id FROM continuity_checkpoint_origins WHERE dispatch_id=? AND publication_kind='handoff_acceptance'").get(row.acceptance_id);
|
|
1519
|
+
if (!origin)
|
|
1520
|
+
throw new Error("Handoff continuation lacks its accepted checkpoint");
|
|
1521
|
+
verifyCheckpointOrigin(this.db, this.projectDir, origin.checkpoint_id);
|
|
1522
|
+
this.assertAcceptedStructuredSuccessor(runId, origin.checkpoint_id, parseJson(row.successor_json));
|
|
1523
|
+
return { predecessorId: row.predecessor_id, continuationId: row.continuation_id, state: row.state, successor: parseJson(row.successor_json) };
|
|
1524
|
+
}
|
|
1525
|
+
finishStructuredContinuation(dispatchId) {
|
|
1526
|
+
if (this.operation(dispatchId)?.status !== "confirmed")
|
|
1527
|
+
throw new Error("Unconfirmed continuation cannot complete");
|
|
1528
|
+
this.db.prepare("UPDATE structured_handoff_lineage SET state='completed' WHERE continuation_id=?").run(dispatchId);
|
|
1529
|
+
}
|
|
1530
|
+
assertAcceptedStructuredSuccessor(runId, checkpointId, successor) {
|
|
1531
|
+
const handoff = this.db.prepare("SELECT acceptance_receipt_digest FROM handoffs WHERE run_id=? AND successor_session_key=? AND state='accepted'")
|
|
1532
|
+
.get(runId, providerSessionKey(successor));
|
|
1533
|
+
const checkpoint = this.db.prepare("SELECT digest FROM continuity_checkpoints WHERE checkpoint_id=? AND run_id=?").get(checkpointId, runId);
|
|
1534
|
+
const bytes = handoff?.acceptance_receipt_digest && this.getEvidence(handoff.acceptance_receipt_digest);
|
|
1535
|
+
if (!bytes || createHash("sha256").update(bytes).digest("hex") !== handoff.acceptance_receipt_digest)
|
|
1536
|
+
throw new Error("Successor lacks retained accepted ownership evidence");
|
|
1537
|
+
const receipt = parseJson(bytes.toString());
|
|
1538
|
+
if (receipt.runId !== runId || receipt.acceptanceCheckpointDigest !== checkpoint?.digest
|
|
1539
|
+
|| providerSessionKey(receipt.successorSessionRef) !== providerSessionKey(successor))
|
|
1540
|
+
throw new Error("Successor ownership differs from accepted checkpoint lineage");
|
|
1541
|
+
}
|
|
1542
|
+
requireStructuredPostprocessing(dispatchId, context) {
|
|
1543
|
+
const operation = this.operation(dispatchId);
|
|
1544
|
+
this.db.prepare("INSERT INTO structured_dispatch_processing(dispatch_id,run_id,state,context_json) VALUES(?,?,'pending',?)")
|
|
1545
|
+
.run(dispatchId, operation.runId, json(context));
|
|
1546
|
+
}
|
|
1547
|
+
structuredProcessingContext(dispatchId) {
|
|
1548
|
+
const row = this.db.prepare("SELECT context_json FROM structured_dispatch_processing WHERE dispatch_id=?").get(dispatchId);
|
|
1549
|
+
if (!row)
|
|
1550
|
+
throw new Error("Missing persisted post-confirmation intent");
|
|
1551
|
+
return parseJson(row.context_json);
|
|
1552
|
+
}
|
|
1553
|
+
failStructuredPostprocessing(dispatchId, error) {
|
|
1554
|
+
this.db.prepare("UPDATE structured_dispatch_processing SET error=? WHERE dispatch_id=?").run(error.slice(0, 4000), dispatchId);
|
|
1555
|
+
}
|
|
1556
|
+
completeStructuredPostprocessing(dispatchId) {
|
|
1557
|
+
if (this.operation(dispatchId)?.status !== "confirmed")
|
|
1558
|
+
throw new Error("Cannot process an unconfirmed dispatch");
|
|
1559
|
+
this.db.prepare("UPDATE structured_dispatch_processing SET state='completed',error=NULL WHERE dispatch_id=?").run(dispatchId);
|
|
1560
|
+
}
|
|
1561
|
+
pendingStructuredPostprocessing(runId) {
|
|
1562
|
+
return this.db.prepare("SELECT dispatch_id FROM structured_dispatch_processing WHERE run_id=? AND state='pending'").all(runId).map(row => row.dispatch_id);
|
|
1563
|
+
}
|
|
1564
|
+
retainedStructuredTurn(dispatchId) {
|
|
1565
|
+
const owner = this.structuredOwnerDispatch(dispatchId), tuple = persistedDispatchTuple(owner);
|
|
1566
|
+
const descriptor = persistedProviderWireContract(tuple);
|
|
1567
|
+
if (!descriptor)
|
|
1568
|
+
throw new Error("Missing historic dispatch descriptor");
|
|
1569
|
+
this.replayStructuredDispatch(dispatchId, descriptor.canonical);
|
|
1570
|
+
const result = this.operation(dispatchId).result;
|
|
1571
|
+
const retained = JSON.parse(this.getEvidence(result.responseDigest).toString());
|
|
1572
|
+
return { text: result.rawDigest ? this.getEvidence(result.rawDigest)?.toString() ?? "" : "", isError: false, numTurns: 1, costUsd: 0,
|
|
1573
|
+
turnId: retained.hostTurnId, selectedDispatch: tuple, structured: hydrateStructuredReceipt(retained, digest => this.getEvidence(digest)) ?? undefined };
|
|
1574
|
+
}
|
|
1575
|
+
/** A claim is never recycled, even if transport fails before writing bytes. */
|
|
1576
|
+
claimStructuredSubmission(tuple, session) {
|
|
1577
|
+
return this.atomic(() => {
|
|
1578
|
+
const operation = this.operation(tuple.dispatchId);
|
|
1579
|
+
if (!operation || operation.status !== "in_progress" || this.structuredOwnerDispatch(tuple.dispatchId).tupleJson !== canonicalJson(tuple))
|
|
1580
|
+
throw new Error("Cannot mint a send capability without a committed sealed owner intent");
|
|
1581
|
+
this.assertNativeRunAdmission(operation.runId);
|
|
1582
|
+
const frozen = operation.intent.sessionRef;
|
|
1583
|
+
if (!session || canonicalJson(frozen) !== canonicalJson(session))
|
|
1584
|
+
throw new Error("Submission claim requires the frozen provider session");
|
|
1585
|
+
const continuation = this.db.prepare("SELECT successor_json FROM structured_handoff_lineage WHERE continuation_id=? AND state='continuation-planned'").get(tuple.dispatchId);
|
|
1586
|
+
if (continuation && canonicalJson(parseJson(continuation.successor_json)) !== canonicalJson(session))
|
|
1587
|
+
throw new Error("Continuation session differs from accepted lineage");
|
|
1588
|
+
const claimId = randomUUID();
|
|
1589
|
+
const changed = this.db.prepare(`INSERT OR IGNORE INTO structured_submission_claims(dispatch_id,run_id,tuple_digest,claim_id,session_json,state,created_at)
|
|
1590
|
+
VALUES(?,?,?,?,?,'claimed',?)`).run(tuple.dispatchId, operation.runId, tuple.digest, claimId, canonicalJson(session), new Date().toISOString());
|
|
1591
|
+
if (changed.changes !== 1)
|
|
1592
|
+
throw new Error("Structured dispatch already has a submission claim; reconcile before a new dispatch");
|
|
1593
|
+
this.db.prepare("UPDATE structured_handoff_lineage SET state='continuation-claimed' WHERE continuation_id=?").run(tuple.dispatchId);
|
|
1594
|
+
return claimId;
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
consumeStructuredSubmission(tuple, claimId, session) {
|
|
1598
|
+
this.atomic(() => {
|
|
1599
|
+
const operation = this.operation(tuple.dispatchId);
|
|
1600
|
+
if (!operation || operation.status !== "in_progress" || this.structuredOwnerDispatch(tuple.dispatchId).tupleJson !== canonicalJson(tuple))
|
|
1601
|
+
throw new Error("Submission owner is no longer in progress or sealed");
|
|
1602
|
+
this.assertNativeRunAdmission(operation.runId);
|
|
1603
|
+
const frozen = operation.intent.sessionRef;
|
|
1604
|
+
if (!session || canonicalJson(frozen) !== canonicalJson(session))
|
|
1605
|
+
throw new Error("Submission session changed after sealing");
|
|
1606
|
+
const owner = this.roleMutationLease(operation.runId, session.role);
|
|
1607
|
+
if (!(tuple.operation === "handoff-acceptance" && operation.intent.purpose === "contract-acceptance") && owner && (owner.providerSessionId !== session.sessionId || owner.sessionKey && owner.sessionKey !== providerSessionKey(session))) {
|
|
1608
|
+
this.assertFreshRecoveryValidation(tuple.dispatchId, session);
|
|
1609
|
+
}
|
|
1610
|
+
const changed = this.db.prepare(`UPDATE structured_submission_claims SET state='consumed',consumed_at=?
|
|
1611
|
+
WHERE dispatch_id=? AND run_id=? AND tuple_digest=? AND claim_id=? AND session_json=? AND state='claimed'`)
|
|
1612
|
+
.run(new Date().toISOString(), tuple.dispatchId, operation.runId, tuple.digest, claimId, canonicalJson(session));
|
|
1613
|
+
if (changed.changes !== 1)
|
|
1614
|
+
throw new Error("Structured submission claim already consumed or revoked");
|
|
1615
|
+
});
|
|
1616
|
+
}
|
|
1617
|
+
assertNativeRunAdmission(runId) {
|
|
1618
|
+
assertStructuredCutoverComplete(this.db);
|
|
1619
|
+
const run = this.getRun(runId);
|
|
1620
|
+
if (run?.legacy || run?.checkpoint === STRUCTURED_CUTOVER_REASON || run?.state.structuredCommunicationCutover)
|
|
1621
|
+
throw new Error("structured-communication-v2-cutover: start a new native dispatch; this workflow cannot resume");
|
|
1622
|
+
}
|
|
1623
|
+
recordCommunicationEvidenceRefs(ownerId, digests) {
|
|
1624
|
+
for (const digest of new Set(digests))
|
|
1625
|
+
this.db.prepare("INSERT OR IGNORE INTO communication_evidence_refs(owner_kind,owner_id,evidence_digest,created_at) VALUES('provider-dispatch',?,?,?)").run(ownerId, digest, new Date().toISOString());
|
|
1626
|
+
}
|
|
1627
|
+
replayStructuredDispatch(key, contract) {
|
|
1628
|
+
const operation = this.operation(key);
|
|
1629
|
+
const result = operation?.result;
|
|
1630
|
+
if (operation?.status !== "confirmed" || result?.validation !== "accepted" || !result.responseDigest)
|
|
1631
|
+
throw new Error("Structured dispatch has no confirmed persisted receipt");
|
|
1632
|
+
return this.verifyRetainedStructuredDispatch(key, contract, result.responseDigest);
|
|
1633
|
+
}
|
|
1634
|
+
assertStructuredProviderTurn(key, providerTurnId) {
|
|
1635
|
+
const result = this.operation(key)?.result;
|
|
1636
|
+
const bytes = result?.responseDigest ? this.getEvidence(result.responseDigest) : undefined;
|
|
1637
|
+
if (!bytes)
|
|
1638
|
+
throw new Error("Structured dispatch receipt evidence is missing");
|
|
1639
|
+
const retained = JSON.parse(bytes.toString());
|
|
1640
|
+
if (!providerTurnId || retained?.receipt?.state !== "candidate" || retained.hostTurnId !== providerTurnId)
|
|
1641
|
+
throw new Error("Domain receipt has a foreign native provider turn identity");
|
|
1642
|
+
}
|
|
1643
|
+
verifyRetainedStructuredDispatch(key, contract, responseDigest) {
|
|
1644
|
+
const readVerifiedEvidence = (digest) => {
|
|
1645
|
+
const bytes = this.getEvidence(digest);
|
|
1646
|
+
if (bytes && (!graphDerivedAllowed(this.db, this.projectDir, digest) || createHash("sha256").update(bytes).digest("hex") !== digest))
|
|
1647
|
+
throw new Error("Structured receipt evidence digest mismatch");
|
|
1648
|
+
return bytes;
|
|
1649
|
+
};
|
|
1650
|
+
const bytes = readVerifiedEvidence(responseDigest);
|
|
1651
|
+
if (!bytes)
|
|
1652
|
+
throw new Error("Structured dispatch receipt evidence is missing");
|
|
1653
|
+
const retained = JSON.parse(bytes.toString());
|
|
1654
|
+
const owner = this.structuredOwnerDispatch(key);
|
|
1655
|
+
if (retained.version !== 2 || retained.tupleDigest !== owner.tupleDigest)
|
|
1656
|
+
throw new Error("Stored receipt does not match persisted owner tuple");
|
|
1657
|
+
return verifyPersistedDispatch(contract, owner, hydrateStructuredReceipt(retained, readVerifiedEvidence));
|
|
1658
|
+
}
|
|
1320
1659
|
updateOperation(idempotencyKey, status, details = {}, now = new Date()) {
|
|
1321
1660
|
const prior = this.operation(idempotencyKey);
|
|
1322
1661
|
if (prior?.kind === "provider-dispatch" && status === "in_progress" && this.unresolvedPreparationProcesses(prior.runId).length)
|
|
1323
1662
|
throw new Error("Readiness cleanup must be reconciled before provider dispatch");
|
|
1324
1663
|
if (!prior)
|
|
1325
1664
|
throw new Error(`operation not found: ${idempotencyKey}`);
|
|
1665
|
+
if (prior.kind === "provider-dispatch" && status === "confirmed") {
|
|
1666
|
+
const owner = this.structuredOwnerDispatch(idempotencyKey), tuple = persistedDispatchTuple(owner);
|
|
1667
|
+
const descriptor = persistedProviderWireContract(tuple);
|
|
1668
|
+
const result = details.result;
|
|
1669
|
+
if (!descriptor || result?.validation !== "accepted" || !result.responseDigest)
|
|
1670
|
+
throw new Error("Provider confirmation requires verified persisted receipt evidence");
|
|
1671
|
+
this.verifyRetainedStructuredDispatch(idempotencyKey, descriptor.canonical, result.responseDigest);
|
|
1672
|
+
}
|
|
1326
1673
|
const allowed = {
|
|
1327
1674
|
planned: ["planned", "in_progress", "failed"],
|
|
1328
1675
|
in_progress: ["in_progress", "confirmed", "failed", "uncertain"],
|
|
@@ -1382,6 +1729,84 @@ export class WorkflowDb {
|
|
|
1382
1729
|
registerGraphDerived(this.db, bytes, graphAccess);
|
|
1383
1730
|
return digest;
|
|
1384
1731
|
}
|
|
1732
|
+
/** Create an append-only provider page artifact before its first dispatch. */
|
|
1733
|
+
populationPageRecords(artifactId) {
|
|
1734
|
+
return this.db.prepare("SELECT content FROM page_ledger_pages WHERE artifact_id=? ORDER BY ordinal").all(artifactId)
|
|
1735
|
+
.map(row => row.content.toString("utf8"));
|
|
1736
|
+
}
|
|
1737
|
+
populationPageLedger(artifactId) {
|
|
1738
|
+
const row = this.db.prepare("SELECT tuple_digest,state,page_count FROM page_ledgers WHERE artifact_id=?").get(artifactId);
|
|
1739
|
+
return row && { tupleDigest: row.tuple_digest, state: row.state, pageCount: row.page_count };
|
|
1740
|
+
}
|
|
1741
|
+
beginPageLedger(input, now = new Date()) {
|
|
1742
|
+
if (!/^[A-Za-z0-9_.:-]{1,256}$/.test(input.artifactId) || !/^[A-Za-z0-9_.:-]{1,256}$/.test(input.lineageId) || !/^[A-Za-z0-9_.:-]{1,256}$/.test(input.leaseId) || !/^[a-f0-9]{64}$/.test(input.tupleDigest))
|
|
1743
|
+
throw new Error("Invalid page ledger identity");
|
|
1744
|
+
this.ensureRun(input.runId);
|
|
1745
|
+
this.db.prepare(`INSERT INTO page_ledgers(artifact_id,run_id,lineage_id,tuple_digest,lease_id,state,page_count,total_bytes,created_at,updated_at)
|
|
1746
|
+
VALUES(?,?,?,?,?,'open',0,0,?,?) ON CONFLICT(artifact_id) DO NOTHING`).run(input.artifactId, input.runId, input.lineageId, input.tupleDigest, input.leaseId, now.toISOString(), now.toISOString());
|
|
1747
|
+
const row = this.db.prepare("SELECT run_id,lineage_id,tuple_digest,lease_id FROM page_ledgers WHERE artifact_id=?").get(input.artifactId);
|
|
1748
|
+
if (row.run_id !== input.runId || row.lineage_id !== input.lineageId || row.tuple_digest !== input.tupleDigest || row.lease_id !== input.leaseId)
|
|
1749
|
+
throw new Error("Page ledger identity collision");
|
|
1750
|
+
}
|
|
1751
|
+
/**
|
|
1752
|
+
* Atomically appends a page. Duplicate delivery is accepted only when every
|
|
1753
|
+
* immutable byte and linkage field agrees; a provider cannot fill gaps,
|
|
1754
|
+
* rewrite history, or append after the terminal page.
|
|
1755
|
+
*/
|
|
1756
|
+
appendPageLedger(input, now = new Date()) {
|
|
1757
|
+
if (!Number.isInteger(input.ordinal) || input.ordinal < 0 || input.ordinal >= 60)
|
|
1758
|
+
throw new Error("Page ordinal exceeds the 60-page limit");
|
|
1759
|
+
let canonical;
|
|
1760
|
+
let ticketCount;
|
|
1761
|
+
try {
|
|
1762
|
+
const parsed = JSON.parse(input.content);
|
|
1763
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
1764
|
+
throw new Error("page must be a JSON object");
|
|
1765
|
+
const tickets = parsed.tickets;
|
|
1766
|
+
if (!Array.isArray(tickets))
|
|
1767
|
+
throw new Error("page must contain tickets");
|
|
1768
|
+
ticketCount = tickets.length;
|
|
1769
|
+
canonical = canonicalJson(parsed);
|
|
1770
|
+
}
|
|
1771
|
+
catch (error) {
|
|
1772
|
+
throw new Error(`Page ledger content must be canonical ticket-page JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
1773
|
+
}
|
|
1774
|
+
if (canonical !== input.content)
|
|
1775
|
+
throw new Error("Page ledger content must use canonical JSON bytes");
|
|
1776
|
+
if (ticketCount > 150)
|
|
1777
|
+
throw new Error("Page exceeds the 150-ticket limit");
|
|
1778
|
+
const bytes = Buffer.byteLength(input.content);
|
|
1779
|
+
if (bytes > 750 * 1024)
|
|
1780
|
+
throw new Error("Page exceeds the 750 KiB limit");
|
|
1781
|
+
const digest = createHash("sha256").update(input.content).digest("hex");
|
|
1782
|
+
return this.atomic(() => {
|
|
1783
|
+
const header = this.db.prepare("SELECT lineage_id,tuple_digest,lease_id,state,page_count,total_bytes FROM page_ledgers WHERE artifact_id=?").get(input.artifactId);
|
|
1784
|
+
if (!header || header.lineage_id !== input.lineageId || header.tuple_digest !== input.tupleDigest || header.lease_id !== input.leaseId)
|
|
1785
|
+
throw new Error("Unknown or foreign page ledger");
|
|
1786
|
+
const existing = this.db.prepare("SELECT predecessor_digest,content_digest,content,terminal FROM page_ledger_pages WHERE artifact_id=? AND ordinal=?").get(input.artifactId, input.ordinal);
|
|
1787
|
+
if (existing) {
|
|
1788
|
+
if (existing.terminal)
|
|
1789
|
+
throw new Error("Duplicate terminal page");
|
|
1790
|
+
if (existing.predecessor_digest === input.predecessorDigest && existing.content_digest === digest && existing.content.equals(Buffer.from(input.content)) && Boolean(existing.terminal) === input.terminal)
|
|
1791
|
+
return { digest, duplicate: true };
|
|
1792
|
+
throw new Error("Conflicting duplicate page");
|
|
1793
|
+
}
|
|
1794
|
+
if (header.state !== "open")
|
|
1795
|
+
throw new Error("Cannot append after terminal page");
|
|
1796
|
+
if (input.ordinal !== header.page_count)
|
|
1797
|
+
throw new Error("Page ordinal is out of order or has a gap");
|
|
1798
|
+
const prior = input.ordinal === 0 ? null : this.db.prepare("SELECT content_digest FROM page_ledger_pages WHERE artifact_id=? AND ordinal=?").get(input.artifactId, input.ordinal - 1);
|
|
1799
|
+
if ((prior?.content_digest ?? null) !== input.predecessorDigest)
|
|
1800
|
+
throw new Error("Page predecessor digest does not match");
|
|
1801
|
+
if (header.total_bytes + bytes > 6 * 1024 * 1024)
|
|
1802
|
+
throw new Error("Page ledger exceeds the 6 MiB aggregate limit");
|
|
1803
|
+
this.db.prepare("INSERT INTO page_ledger_pages(artifact_id,ordinal,predecessor_digest,content_digest,content,terminal,created_at) VALUES(?,?,?,?,?,?,?)")
|
|
1804
|
+
.run(input.artifactId, input.ordinal, input.predecessorDigest, digest, Buffer.from(input.content), input.terminal ? 1 : 0, now.toISOString());
|
|
1805
|
+
this.db.prepare("UPDATE page_ledgers SET page_count=?,total_bytes=?,state=?,updated_at=? WHERE artifact_id=?")
|
|
1806
|
+
.run(header.page_count + 1, header.total_bytes + bytes, input.terminal ? "terminal" : "open", now.toISOString(), input.artifactId);
|
|
1807
|
+
return { digest, duplicate: false };
|
|
1808
|
+
});
|
|
1809
|
+
}
|
|
1385
1810
|
registerGraphEvidence(value, access) { registerGraphDerived(this.db, value, access); }
|
|
1386
1811
|
getEvidence(digest) {
|
|
1387
1812
|
if (!graphDerivedAllowed(this.db, this.projectDir, digest))
|
|
@@ -1442,7 +1867,16 @@ export class WorkflowDb {
|
|
|
1442
1867
|
.run(next.status, next.reportDigest ?? null, json({ findingIds: next.findingIds, namespacedFindingIds: next.namespacedFindingIds, detail: next.detail }), next.updatedAt, attemptId);
|
|
1443
1868
|
const admission = this.admittedWork(next.runId, next.ticketId);
|
|
1444
1869
|
const turns = this.db.prepare("SELECT receipt_json FROM qa_turns WHERE run_id=? AND ticket_id=? AND review_number=? AND status='completed'").all(next.runId, next.ticketId, next.reviewNumber);
|
|
1445
|
-
const terminalBlock = turns.some(row => {
|
|
1870
|
+
const terminalBlock = turns.some(row => {
|
|
1871
|
+
const receipt = parseJson(row.receipt_json);
|
|
1872
|
+
const response = receipt.structuredResponseDigest && this.getEvidence(receipt.structuredResponseDigest);
|
|
1873
|
+
if (!receipt.terminalEventObserved || !response)
|
|
1874
|
+
return false;
|
|
1875
|
+
// A retained response alone is not authority in v2: this legacy receipt
|
|
1876
|
+
// has no sealed tuple/descriptor record to verify. It may be inspected,
|
|
1877
|
+
// but cannot advance a review state.
|
|
1878
|
+
return false;
|
|
1879
|
+
});
|
|
1446
1880
|
if (admission && (next.status === "passed" || next.status === "failed" || terminalBlock))
|
|
1447
1881
|
this.qaPreparationStore().metric(next.runId, next.ticketId, admission.requirementsDigest, `metric:review:${attemptId}`, "substantive-review", { status: next.status === "passed" ? "passed" : next.status === "failed" ? "failed" : "blocked", at: now.toISOString(), evidenceRefs: next.reportDigest ? [next.reportDigest] : [] });
|
|
1448
1882
|
return next;
|
|
@@ -1482,8 +1916,9 @@ export class WorkflowDb {
|
|
|
1482
1916
|
if (!prior)
|
|
1483
1917
|
throw new Error(`QA remediation attempt not found: ${attemptId}`);
|
|
1484
1918
|
const next = { ...prior, ...patch, status, updatedAt: now.toISOString() };
|
|
1919
|
+
const raw = this.db.prepare("SELECT record_json FROM qa_remediation_attempts WHERE attempt_id=?").get(attemptId);
|
|
1485
1920
|
this.db.prepare("UPDATE qa_remediation_attempts SET status=?,response_digest=?,summary_digest=?,record_json=?,updated_at=? WHERE attempt_id=?")
|
|
1486
|
-
.run(status, next.responseDigest ?? null, next.summaryDigest ?? null, json({ detail: next.detail, recoveryAttemptId: next.recoveryAttemptId }), next.updatedAt, attemptId);
|
|
1921
|
+
.run(status, next.responseDigest ?? null, next.summaryDigest ?? null, json({ ...parseJson(raw.record_json), detail: next.detail, recoveryAttemptId: next.recoveryAttemptId }), next.updatedAt, attemptId);
|
|
1487
1922
|
return next;
|
|
1488
1923
|
}
|
|
1489
1924
|
qaRemediationAttempts(runId, ticketId) {
|
|
@@ -1504,6 +1939,11 @@ export class WorkflowDb {
|
|
|
1504
1939
|
ensureQaGraphJournalGuards(this.db);
|
|
1505
1940
|
const prior = this.qaDeliveryTurns(turn.operationId).find(t => t.turnRecordId === turn.turnRecordId);
|
|
1506
1941
|
if (prior) {
|
|
1942
|
+
if (prior.sealedDispatch) {
|
|
1943
|
+
if (turn.sealedDispatch && json(turn.sealedDispatch) !== json(prior.sealedDispatch))
|
|
1944
|
+
throw new Error("Delivery owner tuple is immutable");
|
|
1945
|
+
turn.sealedDispatch = prior.sealedDispatch;
|
|
1946
|
+
}
|
|
1507
1947
|
if (prior.status !== "intended") {
|
|
1508
1948
|
if (json(prior) !== json(turn))
|
|
1509
1949
|
throw new Error(`conflicting completed delivery turn ${turn.turnRecordId}`);
|
|
@@ -1514,6 +1954,14 @@ export class WorkflowDb {
|
|
|
1514
1954
|
throw new Error(`delivery turn intent mismatch: ${key}`);
|
|
1515
1955
|
}
|
|
1516
1956
|
}
|
|
1957
|
+
if (turn.status === "completed") {
|
|
1958
|
+
const transport = this.operation(turn.turnRecordId);
|
|
1959
|
+
if (!prior?.sealedDispatch || json(turn.sealedDispatch) !== json(prior.sealedDispatch)
|
|
1960
|
+
|| turn.structuredResponseDigest !== transport?.result?.responseDigest)
|
|
1961
|
+
throw new Error("Completed delivery requires its persisted sealed native receipt");
|
|
1962
|
+
this.replayStructuredDispatch(turn.turnRecordId, remediationOutput);
|
|
1963
|
+
this.assertStructuredProviderTurn(turn.turnRecordId, turn.providerTurnId);
|
|
1964
|
+
}
|
|
1517
1965
|
this.db.prepare(`INSERT INTO qa_delivery_turns(turn_record_id,operation_id,report_occurrence_id,turn_index,kind,status,record_json,created_at,updated_at)
|
|
1518
1966
|
VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(turn_record_id) DO UPDATE SET status=excluded.status,record_json=excluded.record_json,updated_at=excluded.updated_at`)
|
|
1519
1967
|
.run(turn.turnRecordId, turn.operationId, turn.reportOccurrenceId, turn.turnIndex, turn.kind, turn.status, json(turn), turn.startedAt, turn.completedAt ?? turn.startedAt);
|
|
@@ -1639,6 +2087,7 @@ export class WorkflowDb {
|
|
|
1639
2087
|
}
|
|
1640
2088
|
/** Compare-and-swap the authoritative QA reducer head and append its event atomically. */
|
|
1641
2089
|
transitionQa(runId, ticketId, expectedRevision, event, now = new Date()) {
|
|
2090
|
+
this.assertNativeRunAdmission(runId);
|
|
1642
2091
|
this.ensureRun(runId);
|
|
1643
2092
|
return this.db.transaction(() => {
|
|
1644
2093
|
const current = this.qaTicketHead(runId, ticketId);
|
|
@@ -1727,7 +2176,18 @@ export class WorkflowDb {
|
|
|
1727
2176
|
const row = this.db.prepare("SELECT receipt_json FROM qa_handoffs WHERE operation_id=? AND status='accepted'").get(operationId);
|
|
1728
2177
|
return row?.receipt_json ? parseJson(row.receipt_json) : undefined;
|
|
1729
2178
|
}
|
|
2179
|
+
/**
|
|
2180
|
+
* Structured authority is admitted only after the one-way v2 cutover has
|
|
2181
|
+
* committed. Keeping this at the common admission boundary prevents a
|
|
2182
|
+
* caller that opens WorkflowDb directly from bypassing the migration.
|
|
2183
|
+
*/
|
|
2184
|
+
enableStructuredCommunication() {
|
|
2185
|
+
this.applyStructuredCommunicationV2Cutover();
|
|
2186
|
+
ensureQaGraphJournalGuards(this.db);
|
|
2187
|
+
}
|
|
1730
2188
|
beginQaTurn(intent) {
|
|
2189
|
+
if (intent.outputContract)
|
|
2190
|
+
this.enableStructuredCommunication();
|
|
1731
2191
|
const at = intent.intendedAt;
|
|
1732
2192
|
this.db.transaction(() => {
|
|
1733
2193
|
const existing = this.db.prepare("SELECT intent_json FROM qa_turns WHERE operation_id=?").get(intent.operationId);
|
|
@@ -1832,9 +2292,24 @@ export class WorkflowDb {
|
|
|
1832
2292
|
}
|
|
1833
2293
|
finishQaTurn(receipt) {
|
|
1834
2294
|
this.db.transaction(() => {
|
|
1835
|
-
const prior = this.db.prepare("SELECT source_state_digest,review_basis_digest,status FROM qa_turns WHERE operation_id=?").get(receipt.operationId);
|
|
2295
|
+
const prior = this.db.prepare("SELECT source_state_digest,review_basis_digest,status,intent_json FROM qa_turns WHERE operation_id=?").get(receipt.operationId);
|
|
1836
2296
|
if (!prior)
|
|
1837
2297
|
throw new Error(`QA turn intent not found: ${receipt.operationId}`);
|
|
2298
|
+
const intent = parseJson(prior.intent_json);
|
|
2299
|
+
if (receipt.dispatch === "completed") {
|
|
2300
|
+
if (!intent.outputContract)
|
|
2301
|
+
throw new Error("Completed QA receipt requires its sealed native contract");
|
|
2302
|
+
const owner = this.structuredOwnerDispatch(receipt.operationId);
|
|
2303
|
+
if (json(receipt.sealedDispatch) !== json(owner))
|
|
2304
|
+
throw new Error("QA receipt differs from sealed owner intent");
|
|
2305
|
+
if (receipt.structuredResponseDigest !== this.operation(receipt.operationId)?.result?.responseDigest)
|
|
2306
|
+
throw new Error("QA receipt references foreign native evidence");
|
|
2307
|
+
const descriptor = persistedProviderWireContract(persistedDispatchTuple(owner));
|
|
2308
|
+
if (!descriptor)
|
|
2309
|
+
throw new Error("QA receipt contract is unavailable");
|
|
2310
|
+
this.replayStructuredDispatch(receipt.operationId, descriptor.canonical);
|
|
2311
|
+
this.assertStructuredProviderTurn(receipt.operationId, receipt.providerTurnId);
|
|
2312
|
+
}
|
|
1838
2313
|
if (prior.source_state_digest !== receipt.sourceStateDigest || prior.review_basis_digest !== receipt.reviewBasisDigest)
|
|
1839
2314
|
throw new Error("QA turn receipt binding mismatch");
|
|
1840
2315
|
if (prior.status === "completed") {
|
|
@@ -1993,6 +2468,13 @@ export class WorkflowDb {
|
|
|
1993
2468
|
const row = this.db.prepare("SELECT receipt_json FROM qa_remediation_receipts WHERE operation_id=?").get(operationId);
|
|
1994
2469
|
return row ? parseJson(row.receipt_json) : undefined;
|
|
1995
2470
|
}
|
|
2471
|
+
verifiedBuilderRemediationReceipt(operationId) {
|
|
2472
|
+
const receipt = this.builderRemediationReceipt(operationId);
|
|
2473
|
+
if (!receipt)
|
|
2474
|
+
return undefined;
|
|
2475
|
+
this.assertBuilderRemediationReceipt(receipt);
|
|
2476
|
+
return receipt;
|
|
2477
|
+
}
|
|
1996
2478
|
hasConfirmedQaRemediationForRecheck(runId, ticketId) {
|
|
1997
2479
|
const head = this.qaTicketHead(runId, ticketId);
|
|
1998
2480
|
const attempt = this.qaRemediationAttempts(runId, ticketId).at(-1);
|
|
@@ -2000,6 +2482,8 @@ export class WorkflowDb {
|
|
|
2000
2482
|
|| attempt.generation !== head.remediationGeneration || this.unresolvedRoleDispatches(runId, "builder").length)
|
|
2001
2483
|
return false;
|
|
2002
2484
|
const receipt = this.builderRemediationReceipt(attempt.attemptId);
|
|
2485
|
+
if (receipt)
|
|
2486
|
+
this.assertBuilderRemediationReceipt(receipt);
|
|
2003
2487
|
const review = this.qaReviewAttempt(attempt.reviewAttemptId);
|
|
2004
2488
|
const transition = this.db.prepare("SELECT to_revision,event_json FROM qa_transitions WHERE run_id=? AND ticket_id=? ORDER BY to_revision DESC LIMIT 1").get(runId, ticketId);
|
|
2005
2489
|
return Boolean(receipt && review && review.status === "failed" && receipt.runId === runId && receipt.ticketId === ticketId
|
|
@@ -2009,6 +2493,7 @@ export class WorkflowDb {
|
|
|
2009
2493
|
&& transition?.to_revision === head.revision && parseJson(transition.event_json).type === "remediation-received");
|
|
2010
2494
|
}
|
|
2011
2495
|
recordBuilderRemediationReceipt(receipt) {
|
|
2496
|
+
this.assertBuilderRemediationReceipt(receipt);
|
|
2012
2497
|
const text = json(receipt);
|
|
2013
2498
|
const existing = this.db.prepare("SELECT receipt_json FROM qa_remediation_receipts WHERE operation_id=?").get(receipt.operationId);
|
|
2014
2499
|
if (existing && json(parseJson(existing.receipt_json)) !== text)
|
|
@@ -2021,6 +2506,30 @@ export class WorkflowDb {
|
|
|
2021
2506
|
this.db.prepare("INSERT OR IGNORE INTO qa_remediation_receipts(operation_id,run_id,ticket_id,report_digest,report_occurrence_id,receipt_json,created_at) VALUES(?,?,?,?,?,?,?)")
|
|
2022
2507
|
.run(receipt.operationId, receipt.runId, receipt.ticketId, receipt.reportDigest, occurrence.reportOccurrenceId, text, receipt.completedAt);
|
|
2023
2508
|
}
|
|
2509
|
+
assertBuilderRemediationReceipt(receipt) {
|
|
2510
|
+
if (receipt.version !== 3)
|
|
2511
|
+
throw new Error("Legacy Builder receipt cannot authorize native remediation");
|
|
2512
|
+
this.assertNativeRunAdmission(receipt.runId);
|
|
2513
|
+
const attempt = this.qaRemediationAttempt(receipt.operationId);
|
|
2514
|
+
const review = attempt && this.qaReviewAttempt(attempt.reviewAttemptId);
|
|
2515
|
+
const turn = this.qaDeliveryTurns(receipt.operationId).find(value => value.turnRecordId === receipt.turnRecordId);
|
|
2516
|
+
if (!attempt || !review || !turn || turn.status !== "completed" || attempt.status !== "succeeded"
|
|
2517
|
+
|| attempt.runId !== receipt.runId || attempt.ticketId !== receipt.ticketId
|
|
2518
|
+
|| review.sourceDigest !== receipt.sourceStateDigest || review.reportDigest !== receipt.reportDigest
|
|
2519
|
+
|| attempt.requestDigest !== receipt.requestDigest || attempt.responseDigest !== receipt.responseDigest
|
|
2520
|
+
|| attempt.summaryDigest !== receipt.summaryDigest || turn.rawResponseDigest !== receipt.responseDigest
|
|
2521
|
+
|| turn.providerTurnId !== receipt.providerTurnId || turn.reportOccurrenceId !== receipt.reportOccurrenceId)
|
|
2522
|
+
throw new Error("Builder receipt differs from its persisted remediation owner");
|
|
2523
|
+
const transport = this.operation(turn.turnRecordId);
|
|
2524
|
+
if (turn.structuredResponseDigest !== transport?.result?.responseDigest)
|
|
2525
|
+
throw new Error("Builder receipt has a foreign native evidence reference");
|
|
2526
|
+
const result = this.replayStructuredDispatch(turn.turnRecordId, remediationOutput);
|
|
2527
|
+
this.assertStructuredProviderTurn(turn.turnRecordId, receipt.providerTurnId);
|
|
2528
|
+
const handoff = this.db.prepare("SELECT handoff_id FROM qa_failure_handoffs WHERE operation_id=?").get(receipt.operationId);
|
|
2529
|
+
if (result.result.kind !== "completed" || result.result.payload.status !== "done" || result.result.payload.report.handoff_id !== handoff?.handoff_id
|
|
2530
|
+
|| createHash("sha256").update(result.result.payload.report.summary).digest("hex") !== receipt.summaryDigest)
|
|
2531
|
+
throw new Error("Builder receipt requires its completed native remediation result and summary");
|
|
2532
|
+
}
|
|
2024
2533
|
commitBuilderRemediationSuccess(receipt, expectedRevision, now = new Date()) {
|
|
2025
2534
|
return this.db.transaction(() => {
|
|
2026
2535
|
this.recordBuilderRemediationReceipt(receipt);
|
|
@@ -2051,6 +2560,47 @@ export class WorkflowDb {
|
|
|
2051
2560
|
return this.transitionQa(remediation.runId, remediation.ticketId, head.revision, { type: input.outcome === "uncertain" ? "remediation-uncertain" : "remediation-failed" }, now);
|
|
2052
2561
|
})();
|
|
2053
2562
|
}
|
|
2563
|
+
assertNativeQaCertificate(input, phase, certificateId) {
|
|
2564
|
+
this.assertNativeRunAdmission(input.runId);
|
|
2565
|
+
const head = this.qaTicketHead(input.runId, input.ticketId);
|
|
2566
|
+
const rows = this.db.prepare("SELECT * FROM qa_turns WHERE run_id=? AND ticket_id=? AND source_state_digest=? AND review_basis_digest=? AND status='completed'")
|
|
2567
|
+
.all(input.runId, input.ticketId, input.sourceStateDigest, input.reviewBasisDigest);
|
|
2568
|
+
const row = rows.find(row => {
|
|
2569
|
+
try {
|
|
2570
|
+
return qaDigest("turn-receipt", parseJson(row.receipt_json)) === input.turnReceiptDigest;
|
|
2571
|
+
}
|
|
2572
|
+
catch {
|
|
2573
|
+
return false;
|
|
2574
|
+
}
|
|
2575
|
+
});
|
|
2576
|
+
if (!row)
|
|
2577
|
+
throw new Error("Pass requires the persisted sealed native QA owner receipt");
|
|
2578
|
+
const receipt = parseJson(row.receipt_json);
|
|
2579
|
+
const intent = parseJson(row.intent_json);
|
|
2580
|
+
if (!receipt.terminalEventObserved || receipt.dispatch !== "completed" || receipt.operationId !== row.operation_id || intent.operationId !== row.operation_id ||
|
|
2581
|
+
intent.runId !== input.runId || intent.ticketId !== input.ticketId || intent.reviewNumber !== head.reviewNumber || intent.reviewNumber !== row.review_number || intent.sessionGeneration !== head.sessionGeneration ||
|
|
2582
|
+
intent.sourceStateDigest !== input.sourceStateDigest || intent.reviewBasisDigest !== input.reviewBasisDigest ||
|
|
2583
|
+
receipt.sourceStateDigest !== input.sourceStateDigest || receipt.reviewBasisDigest !== input.reviewBasisDigest ||
|
|
2584
|
+
!receipt.sealedDispatch || canonicalJson(receipt.sealedDispatch) !== canonicalJson(this.structuredOwnerDispatch(receipt.operationId)))
|
|
2585
|
+
throw new Error("Native pass owner/source/review binding mismatch");
|
|
2586
|
+
this.assertStructuredProviderTurn(receipt.operationId, receipt.providerTurnId);
|
|
2587
|
+
const verdict = this.replayStructuredDispatch(receipt.operationId, qaVerdictOutput);
|
|
2588
|
+
if (verdict.result.kind !== "completed" || verdict.result.payload.kind !== "passed")
|
|
2589
|
+
throw new Error("Pass requires a verified native passed verdict");
|
|
2590
|
+
const operation = this.operation(receipt.operationId);
|
|
2591
|
+
const tuple = persistedDispatchTuple(this.structuredOwnerDispatch(receipt.operationId));
|
|
2592
|
+
if (operation.runId !== input.runId || intent.providerSession.sessionId !== tuple.sessionId || intent.providerSession.generation !== tuple.generation || intent.providerSession.provider !== tuple.provider)
|
|
2593
|
+
throw new Error("Native pass session/tuple binding mismatch");
|
|
2594
|
+
const attempt = this.qaReviewAttempt(operation.intent.logicalActionId ?? "");
|
|
2595
|
+
if (operation.intent.logicalActionId && !attempt)
|
|
2596
|
+
throw new Error("Native pass review attempt is missing");
|
|
2597
|
+
if (attempt && (attempt.runId !== input.runId || attempt.ticketId !== input.ticketId || attempt.reviewNumber !== head.reviewNumber || attempt.sourceDigest !== input.sourceStateDigest || attempt.status !== "passed"))
|
|
2598
|
+
throw new Error("Native pass review attempt is not completed and bound");
|
|
2599
|
+
if (head.sourceStateDigest !== input.sourceStateDigest || head.reviewBasisDigest !== input.reviewBasisDigest ||
|
|
2600
|
+
(phase === "issue" ? head.state !== "turn-intended" || input.qaRevision !== head.revision + 1 :
|
|
2601
|
+
head.passCertificateId !== certificateId || (phase === "consume" ? head.state !== "passed" || input.qaRevision !== head.revision : head.state !== "finalizing" || input.qaRevision + 1 !== head.revision)))
|
|
2602
|
+
throw new Error("Native pass certificate does not match the reducer phase/revision");
|
|
2603
|
+
}
|
|
2054
2604
|
assertContractCertificate(input) {
|
|
2055
2605
|
const store = this.qaPreparationStore();
|
|
2056
2606
|
if (store.policy(input.runId)?.mode !== "enforce")
|
|
@@ -2058,6 +2608,7 @@ export class WorkflowDb {
|
|
|
2058
2608
|
const binding = input.contractCoverage;
|
|
2059
2609
|
if (!binding || binding.version !== 1)
|
|
2060
2610
|
throw new Error("Enforcing pass requires tagged contract coverage authority");
|
|
2611
|
+
const verifiedBinding = binding;
|
|
2061
2612
|
const admission = this.assertAdmittedWork(input.runId, input.ticketId);
|
|
2062
2613
|
const head = store.head(input.runId, input.ticketId, admission.requirementsDigest);
|
|
2063
2614
|
if (head.state !== "ready" || head.digest !== binding.contractDigest)
|
|
@@ -2078,20 +2629,17 @@ export class WorkflowDb {
|
|
|
2078
2629
|
const configuredMake = this.getRun(input.runId)?.state?.qa?.settings?.make;
|
|
2079
2630
|
if (configuredMake && resolveEffectiveQaConfiguration(this.projectDir, { make: configuredMake }).digest !== basis.contractBinding.commonConfigDigest)
|
|
2080
2631
|
throw new Error("Effective QA configuration changed before pass authority; reconcile contract");
|
|
2081
|
-
const attempt = this.qaReviewAttempt(
|
|
2632
|
+
const attempt = this.qaReviewAttempt(verifiedBinding.attemptId);
|
|
2082
2633
|
if (!attempt || attempt.runId !== input.runId || attempt.ticketId !== input.ticketId || attempt.sourceDigest !== input.sourceStateDigest || attempt.reviewNumber !== this.qaTicketHead(input.runId, input.ticketId).reviewNumber)
|
|
2083
2634
|
throw new Error("Coverage review attempt is stale or foreign");
|
|
2084
|
-
const coverage = store.artifact(
|
|
2635
|
+
const coverage = store.artifact(verifiedBinding.coverageDigest, "final-coverage");
|
|
2085
2636
|
for (const check of contract.checks)
|
|
2086
2637
|
for (const method of check.verification)
|
|
2087
2638
|
if (method.equivalentAuthorityId && !store.equivalentResolver()(method.equivalentAuthorityId, contract, check, method, input.sourceStateDigest))
|
|
2088
2639
|
throw new Error("Equivalent verification authority is stale or foreign");
|
|
2089
|
-
const errors = validateContractCoverage(contract, coverage, { phase: "qa", sourceDigest: input.sourceStateDigest, inputBasisDigest: input.reviewBasisDigest, attemptId: binding.attemptId, sessionId: binding.sessionId });
|
|
2090
|
-
if (errors.length)
|
|
2091
|
-
throw new Error(errors.join("; "));
|
|
2092
2640
|
const initialTurns = this.db.prepare("SELECT intent_json FROM qa_turns WHERE run_id=? AND ticket_id=? AND review_number=? AND source_state_digest=? AND review_basis_digest=?").all(input.runId, input.ticketId, attempt.reviewNumber, input.sourceStateDigest, input.reviewBasisDigest);
|
|
2093
2641
|
const initial = initialTurns.map(row => parseJson(row.intent_json)).find(turn => turn.slot === "initial");
|
|
2094
|
-
if (!initial || initial.providerSession.sessionId !==
|
|
2642
|
+
if (!initial || initial.providerSession.sessionId !== verifiedBinding.sessionId)
|
|
2095
2643
|
throw new Error("Coverage lacks the actual fresh review dispatch identity");
|
|
2096
2644
|
const dispatchedInstruction = this.getEvidence(initial.instructionDigest)?.toString();
|
|
2097
2645
|
const envelope = `\nImmutable input-basis digest: ${basis.digest}. Coverage must bind this inputBasisDigest.`;
|
|
@@ -2099,31 +2647,65 @@ export class WorkflowDb {
|
|
|
2099
2647
|
throw new Error("Actual review dispatch differs from frozen base instruction and tagged input-basis envelope");
|
|
2100
2648
|
const receipts = this.db.prepare("SELECT receipt_json FROM qa_turns WHERE run_id=? AND ticket_id=? AND source_state_digest=? AND review_basis_digest=? AND status='completed'").all(input.runId, input.ticketId, input.sourceStateDigest, input.reviewBasisDigest);
|
|
2101
2649
|
const receipt = receipts.map(row => parseJson(row.receipt_json)).find(row => qaDigest("turn-receipt", row) === input.turnReceiptDigest && row.terminalEventObserved);
|
|
2102
|
-
const response = receipt?.
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2650
|
+
const response = receipt?.structuredResponseDigest ? this.getEvidence(receipt.structuredResponseDigest) : undefined;
|
|
2651
|
+
if (!response || !receipt)
|
|
2652
|
+
throw new Error("Pass requires a retained native QA verdict receipt");
|
|
2653
|
+
const owner = this.structuredOwnerDispatch(receipt.operationId);
|
|
2654
|
+
if (json(receipt.sealedDispatch) !== json(owner))
|
|
2655
|
+
throw new Error("Pass receipt lacks its persisted v2 owner tuple");
|
|
2656
|
+
if (persistedDispatchTuple(owner).sessionId !== verifiedBinding.sessionId)
|
|
2657
|
+
throw new Error("Pass native receipt has a foreign session");
|
|
2658
|
+
const verdict = this.replayStructuredDispatch(receipt.operationId, qaVerdictOutput);
|
|
2659
|
+
if (verdict.result.kind !== "completed" || verdict.result.payload.kind !== "passed" || !verdict.result.payload.coverage)
|
|
2660
|
+
throw new Error("Pass requires complete coverage in the retained native passed verdict");
|
|
2661
|
+
const retainedProviderCoverage = bindCoverageHostFields(verdict.result.payload.coverage, contract, {
|
|
2662
|
+
phase: "qa", sourceDigest: input.sourceStateDigest, inputBasisDigest: input.reviewBasisDigest,
|
|
2663
|
+
attemptId: verifiedBinding.attemptId, sessionId: verifiedBinding.sessionId,
|
|
2664
|
+
});
|
|
2112
2665
|
let retainedCoverage = retainedProviderCoverage;
|
|
2666
|
+
let retainedHostComparison;
|
|
2113
2667
|
const hostCheck = findHostQaCheck(contract);
|
|
2114
|
-
if (hostCheck) {
|
|
2668
|
+
if (hostCheck !== undefined) {
|
|
2115
2669
|
const hostDigest = hostComparisonDigestFromCoverage(coverage, hostCheck.id);
|
|
2116
2670
|
if (!hostDigest)
|
|
2117
2671
|
throw new Error("Coverage is missing the host-owned comparison artifact reference");
|
|
2118
|
-
const
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2672
|
+
const verifiedHostDigest = hostDigest;
|
|
2673
|
+
const hostResult = hostQaComparisonV1Schema.parse(store.artifact(verifiedHostDigest, "host-qa-comparison"));
|
|
2674
|
+
retainedHostComparison = { result: hostResult, resultDigest: verifiedHostDigest };
|
|
2675
|
+
}
|
|
2676
|
+
const commandObservations = [
|
|
2677
|
+
...retainedQaCommandObservations(this.projectDir, input.runId, input.ticketId, false),
|
|
2678
|
+
...retainedHostBuilderObservations(this, input.runId, input.ticketId),
|
|
2679
|
+
];
|
|
2680
|
+
const graphCheck = findGraphQaChecks(contract)[0];
|
|
2681
|
+
let retainedHostGraph;
|
|
2682
|
+
if (graphCheck) {
|
|
2683
|
+
const graphDigest = hostGraphApplicabilityDigestFromCoverage(coverage, graphCheck.id);
|
|
2684
|
+
if (!graphDigest)
|
|
2685
|
+
throw new Error("Coverage is missing the host-owned Graphify applicability artifact reference");
|
|
2686
|
+
const verifiedGraphDigest = graphDigest;
|
|
2687
|
+
const graphResult = hostGraphApplicabilityV1Schema.parse(store.artifact(verifiedGraphDigest, "host-graph-applicability"));
|
|
2688
|
+
retainedHostGraph = { result: graphResult, resultDigest: verifiedGraphDigest };
|
|
2122
2689
|
}
|
|
2690
|
+
if (retainedCoverage)
|
|
2691
|
+
retainedCoverage = assembleFinalQaCoverage({
|
|
2692
|
+
providerCoverage: retainedCoverage,
|
|
2693
|
+
contract,
|
|
2694
|
+
projectDir: this.projectDir,
|
|
2695
|
+
commandObservations,
|
|
2696
|
+
hostComparison: retainedHostComparison,
|
|
2697
|
+
hostGraph: retainedHostGraph,
|
|
2698
|
+
});
|
|
2699
|
+
if (!retainedCoverage)
|
|
2700
|
+
throw new Error("Coverage lacks the retained terminal review receipt and authorized host augmentation");
|
|
2701
|
+
const errors = validateContractCoverage(contract, retainedCoverage, { phase: "qa", sourceDigest: input.sourceStateDigest, inputBasisDigest: input.reviewBasisDigest, attemptId: verifiedBinding.attemptId, sessionId: verifiedBinding.sessionId });
|
|
2702
|
+
if (errors.length)
|
|
2703
|
+
throw new Error(errors.join("; "));
|
|
2123
2704
|
if (!receipt || !retainedCoverage || canonicalJson(retainedCoverage) !== canonicalJson(coverage))
|
|
2124
2705
|
throw new Error("Coverage is not bound to the retained terminal review receipt and authorized host augmentation");
|
|
2125
2706
|
}
|
|
2126
2707
|
issueQaPassCertificate(input, now = new Date()) {
|
|
2708
|
+
this.assertNativeQaCertificate(input, "issue");
|
|
2127
2709
|
this.assertContractCertificate(input);
|
|
2128
2710
|
if (this.unresolvedQaReports(input.runId, input.ticketId).length)
|
|
2129
2711
|
throw new Error("cannot issue QA pass certificate while reports remain unresolved");
|
|
@@ -2156,11 +2738,16 @@ export class WorkflowDb {
|
|
|
2156
2738
|
}
|
|
2157
2739
|
consumeQaPassCertificate(runId, ticketId, certificateId, consumer, now = new Date()) {
|
|
2158
2740
|
return this.db.transaction(() => {
|
|
2159
|
-
const row = this.db.prepare("SELECT
|
|
2741
|
+
const row = this.db.prepare("SELECT * FROM qa_pass_certificates WHERE certificate_id=? AND run_id=? AND ticket_id=?").get(certificateId, runId, ticketId);
|
|
2160
2742
|
if (!row)
|
|
2161
2743
|
throw new Error("QA pass certificate is missing or scoped to another run/ticket");
|
|
2162
2744
|
if (row.consumed_at)
|
|
2163
2745
|
throw new Error("QA pass certificate has already been consumed");
|
|
2746
|
+
const value = parseJson(row.certificate_json);
|
|
2747
|
+
if (value.certificateId !== row.certificate_id || value.runId !== runId || value.ticketId !== ticketId || value.qaRevision !== row.qa_revision ||
|
|
2748
|
+
value.sourceStateDigest !== row.source_state_digest || value.reviewBasisDigest !== row.review_basis_digest || value.turnReceiptDigest !== row.turn_receipt_digest || value.issuedAt !== row.issued_at)
|
|
2749
|
+
throw new Error("Pass certificate SQL identity mismatch");
|
|
2750
|
+
this.assertNativeQaCertificate(value, "consume", certificateId);
|
|
2164
2751
|
this.assertContractCertificate(parseJson(row.certificate_json));
|
|
2165
2752
|
if (this.unresolvedQaReports(runId, ticketId).length)
|
|
2166
2753
|
throw new Error("QA pass certificate cannot be consumed with unresolved reports");
|
|
@@ -2209,6 +2796,26 @@ export class WorkflowDb {
|
|
|
2209
2796
|
return next;
|
|
2210
2797
|
})();
|
|
2211
2798
|
}
|
|
2799
|
+
assertQaFinalizationAuthority(runId, ticketId) {
|
|
2800
|
+
const steps = this.qaFinalizationSteps(runId, ticketId).filter(step => step.status !== "invalidated");
|
|
2801
|
+
if (!steps.length)
|
|
2802
|
+
throw new Error("QA finalization intent is missing");
|
|
2803
|
+
for (const step of steps) {
|
|
2804
|
+
const row = this.db.prepare("SELECT * FROM qa_pass_certificates WHERE certificate_id=? AND run_id=? AND ticket_id=?")
|
|
2805
|
+
.get(step.certificateId, runId, ticketId);
|
|
2806
|
+
if (!row || !row.consumed_at || row.consumed_by !== step.kind)
|
|
2807
|
+
throw new Error("Finalization certificate consumer binding mismatch");
|
|
2808
|
+
const certificate = parseJson(row.certificate_json);
|
|
2809
|
+
if (certificate.consumedAt !== row.consumed_at || certificate.consumedBy !== row.consumed_by
|
|
2810
|
+
|| certificate.certificateId !== row.certificate_id || certificate.runId !== row.run_id || certificate.ticketId !== row.ticket_id
|
|
2811
|
+
|| certificate.qaRevision !== row.qa_revision || certificate.sourceStateDigest !== row.source_state_digest
|
|
2812
|
+
|| certificate.reviewBasisDigest !== row.review_basis_digest || certificate.turnReceiptDigest !== row.turn_receipt_digest
|
|
2813
|
+
|| certificate.issuedAt !== row.issued_at)
|
|
2814
|
+
throw new Error("Finalization certificate SQL identity mismatch");
|
|
2815
|
+
this.assertNativeQaCertificate(certificate, "resume", step.certificateId);
|
|
2816
|
+
this.assertContractCertificate(certificate);
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2212
2819
|
planQaFinalizationStep(input, now = new Date()) {
|
|
2213
2820
|
const at = now.toISOString();
|
|
2214
2821
|
const intentText = json(input.intent);
|
|
@@ -2268,6 +2875,7 @@ export class WorkflowDb {
|
|
|
2268
2875
|
completeQaFinalization(runId, ticketId, expectedRevision, receipt, now = new Date()) {
|
|
2269
2876
|
return this.db.transaction(() => {
|
|
2270
2877
|
this.assertFinalizationControls(runId, ticketId);
|
|
2878
|
+
this.assertQaFinalizationAuthority(runId, ticketId);
|
|
2271
2879
|
const head = this.qaTicketHead(runId, ticketId);
|
|
2272
2880
|
if (head.revision !== expectedRevision || head.state !== "finalizing")
|
|
2273
2881
|
throw new Error(`QA finalization completion raced for ${runId}/${ticketId}`);
|
|
@@ -2528,6 +3136,31 @@ export class WorkflowDb {
|
|
|
2528
3136
|
: this.db.prepare(`SELECT COUNT(*) AS count FROM compaction_attempts WHERE run_id=? AND role=? AND provider_session_id=? AND session_key IS NULL AND status='succeeded'`).get(runId, role, session.id);
|
|
2529
3137
|
return row.count;
|
|
2530
3138
|
}
|
|
3139
|
+
freshRecoveryValidation(runId, role) {
|
|
3140
|
+
const decision = this.recoveryDecisions(runId).at(-1);
|
|
3141
|
+
const predecessor = this.roleMutationLease(runId, role);
|
|
3142
|
+
if (!decision || decision.mode !== "fresh-recovery-only" || decision.role !== role || !predecessor)
|
|
3143
|
+
throw new Error("Fresh recovery validation requires an explicit current recovery decision and predecessor lease");
|
|
3144
|
+
return { decisionDigest: digestJson(decision), predecessor };
|
|
3145
|
+
}
|
|
3146
|
+
/** This grants one response-only initialization, never ownership or business work. */
|
|
3147
|
+
assertFreshRecoveryValidation(dispatchId, session) {
|
|
3148
|
+
const operation = this.operation(dispatchId);
|
|
3149
|
+
const intent = operation?.intent;
|
|
3150
|
+
const authorization = intent?.freshRecoveryValidation;
|
|
3151
|
+
const decision = operation && this.recoveryDecisions(operation.runId).at(-1);
|
|
3152
|
+
if (!operation || !authorization || !decision || intent?.operation !== "session-initialization" || intent.responseOnly !== true ||
|
|
3153
|
+
canonicalJson(intent.sessionRef) !== canonicalJson(session) ||
|
|
3154
|
+
(session.role !== "builder" && session.role !== "qa") || decision.mode !== "fresh-recovery-only" || decision.role !== session.role ||
|
|
3155
|
+
authorization.decisionDigest !== digestJson(decision) || dispatchId !== `fresh-recovery:${authorization.decisionDigest}` ||
|
|
3156
|
+
canonicalJson(this.roleMutationLease(operation.runId, session.role)) !== canonicalJson(authorization.predecessor) ||
|
|
3157
|
+
decision.runId !== operation.runId || canonicalSessionPath(session.cwd) !== canonicalSessionPath(decision.worktree) ||
|
|
3158
|
+
session.provider !== (decision.requestedSuccessor?.agent ?? decision.settings.make) ||
|
|
3159
|
+
session.sessionId === authorization.predecessor.providerSessionId ||
|
|
3160
|
+
decision.predecessorSessionId && decision.predecessorSessionId !== authorization.predecessor.providerSessionId ||
|
|
3161
|
+
decision.predecessorSessionRef && providerSessionKey(decision.predecessorSessionRef) !== authorization.predecessor.sessionKey)
|
|
3162
|
+
throw new Error("Submission session no longer owns its role: fresh recovery decision or predecessor changed");
|
|
3163
|
+
}
|
|
2531
3164
|
recordRecoveryDecision(receipt) {
|
|
2532
3165
|
this.ensureRun(receipt.runId);
|
|
2533
3166
|
const digest = digestJson(receipt);
|
|
@@ -2706,12 +3339,12 @@ export class WorkflowDb {
|
|
|
2706
3339
|
parsed = { unreadable: true };
|
|
2707
3340
|
}
|
|
2708
3341
|
const record = parsed && typeof parsed === "object" ? parsed : undefined;
|
|
3342
|
+
if (record?.communicationProtocolVersion === 2)
|
|
3343
|
+
continue;
|
|
2709
3344
|
const isBuild = (record?.version === 1 || record?.version === 2) && typeof record.runId === "string";
|
|
2710
3345
|
const runId = isBuild ? String(record.runId) : `legacy_${digest.slice(0, 24)}`;
|
|
2711
3346
|
if (!this.getRun(runId)) {
|
|
2712
3347
|
this.createRun({ runId, kind: isBuild ? "build" : "legacy", checkpoint: isBuild && typeof record?.checkpoint === "string" ? record.checkpoint : "legacy-imported", originalWork: isBuild ? { tickets: record?.tickets ?? [] } : { source: path }, remainingWork: isBuild ? { tickets: record?.tickets ?? [] } : {}, state: isBuild ? record : { source: path, record: parsed }, legacy: true }, now);
|
|
2713
|
-
if (!isBuild)
|
|
2714
|
-
this.transition(runId, { status: "superseded", checkpoint: "legacy-imported", remainingWork: {}, event: "legacy_record_preserved" }, now);
|
|
2715
3348
|
}
|
|
2716
3349
|
this.db.prepare("INSERT INTO legacy_imports(source_path,digest,run_id,imported_at) VALUES(?,?,?,?)").run(path, digest, runId, now.toISOString());
|
|
2717
3350
|
count += 1;
|
|
@@ -2839,6 +3472,119 @@ export class WorkflowDb {
|
|
|
2839
3472
|
this.db.prepare("INSERT INTO recovery_schema_migrations(migration,completed_at) VALUES(?,?)").run(`${migration}:${migrationDigest}`, new Date().toISOString());
|
|
2840
3473
|
migrateQaHandback(this.db);
|
|
2841
3474
|
}
|
|
3475
|
+
/**
|
|
3476
|
+
* One-way cutover fence. We deliberately do not attempt to convert an old
|
|
3477
|
+
* receipt into authority: any pre-existing run with legacy state or a
|
|
3478
|
+
* provider dispatch that lacks a sealed tuple is terminally superseded.
|
|
3479
|
+
* User/project artifacts remain in their own tables; only workflow authority
|
|
3480
|
+
* is fenced. The marker is committed atomically with the classification.
|
|
3481
|
+
*/
|
|
3482
|
+
applyStructuredCommunicationV2Cutover() {
|
|
3483
|
+
const migration = STRUCTURED_AUDIT_CLOSURE;
|
|
3484
|
+
this.db.transaction(() => {
|
|
3485
|
+
this.db.exec(`CREATE TABLE IF NOT EXISTS structured_communication_cutover(migration TEXT PRIMARY KEY,completed_at TEXT NOT NULL,details_json TEXT NOT NULL);
|
|
3486
|
+
CREATE TABLE IF NOT EXISTS structured_cutover_quarantine(run_id TEXT NOT NULL,table_name TEXT NOT NULL,row_json TEXT NOT NULL);
|
|
3487
|
+
CREATE TABLE IF NOT EXISTS structured_handoff_lineage(predecessor_id TEXT PRIMARY KEY REFERENCES operation_journal(idempotency_key),run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),state TEXT NOT NULL,acceptance_id TEXT UNIQUE,successor_json TEXT,continuation_id TEXT UNIQUE);
|
|
3488
|
+
CREATE TABLE IF NOT EXISTS continuity_checkpoint_origins(checkpoint_id INTEGER PRIMARY KEY REFERENCES continuity_checkpoints(checkpoint_id),run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),origin_json TEXT NOT NULL,delta_digest TEXT NOT NULL,dispatch_id TEXT,publication_kind TEXT,UNIQUE(run_id,dispatch_id,publication_kind));
|
|
3489
|
+
CREATE TABLE IF NOT EXISTS structured_dispatch_processing(dispatch_id TEXT PRIMARY KEY REFERENCES operation_journal(idempotency_key),run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),state TEXT NOT NULL CHECK(state IN ('pending','completed')),context_json TEXT NOT NULL,error TEXT);
|
|
3490
|
+
CREATE TABLE IF NOT EXISTS structured_submission_claims(dispatch_id TEXT PRIMARY KEY REFERENCES operation_journal(idempotency_key),run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),tuple_digest TEXT NOT NULL,claim_id TEXT NOT NULL UNIQUE,session_json TEXT NOT NULL,state TEXT NOT NULL CHECK(state IN ('claimed','consumed')),created_at TEXT NOT NULL,consumed_at TEXT);
|
|
3491
|
+
CREATE TABLE IF NOT EXISTS communication_evidence_refs(owner_kind TEXT NOT NULL,owner_id TEXT NOT NULL,evidence_digest TEXT NOT NULL,created_at TEXT NOT NULL,PRIMARY KEY(owner_kind,owner_id,evidence_digest));
|
|
3492
|
+
CREATE TABLE IF NOT EXISTS page_ledgers(artifact_id TEXT PRIMARY KEY,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),lineage_id TEXT NOT NULL,tuple_digest TEXT NOT NULL,lease_id TEXT NOT NULL,state TEXT NOT NULL CHECK(state IN ('open','terminal','cancelled')),page_count INTEGER NOT NULL,total_bytes INTEGER NOT NULL,created_at TEXT NOT NULL,updated_at TEXT NOT NULL);
|
|
3493
|
+
CREATE TABLE IF NOT EXISTS page_ledger_pages(artifact_id TEXT NOT NULL REFERENCES page_ledgers(artifact_id),ordinal INTEGER NOT NULL,predecessor_digest TEXT,content_digest TEXT NOT NULL,content BLOB NOT NULL,terminal INTEGER NOT NULL CHECK(terminal IN (0,1)),created_at TEXT NOT NULL,PRIMARY KEY(artifact_id,ordinal));`);
|
|
3494
|
+
const prior = this.db.prepare("SELECT details_json FROM structured_communication_cutover WHERE migration=?").get(migration);
|
|
3495
|
+
if (this.db.prepare("SELECT 1 FROM structured_communication_cutover WHERE migration='003_structured_communication_v2_cutover'").get() && prior && JSON.parse(prior.details_json).ownershipMatrixVersion === STRUCTURED_CUTOVER_MATRIX_VERSION)
|
|
3496
|
+
return;
|
|
3497
|
+
// Upgrade only an unambiguous host baseline. Historical provider deltas
|
|
3498
|
+
// without an exact dispatch origin remain fenced; timestamps are not proof.
|
|
3499
|
+
for (const checkpoint of this.db.prepare(`SELECT c.* FROM continuity_checkpoints c JOIN workflow_runs r USING(run_id)
|
|
3500
|
+
WHERE json_valid(r.state_json) AND c.session_ref_json IS NULL
|
|
3501
|
+
AND NOT EXISTS(SELECT 1 FROM continuity_checkpoint_origins o WHERE o.checkpoint_id=c.checkpoint_id)`).all()) {
|
|
3502
|
+
let delta;
|
|
3503
|
+
try {
|
|
3504
|
+
delta = parseJson(checkpoint.delta_json);
|
|
3505
|
+
}
|
|
3506
|
+
catch {
|
|
3507
|
+
continue;
|
|
3508
|
+
}
|
|
3509
|
+
if (!delta || delta.version !== 1 || delta.nextAction !== "Await the first role action." ||
|
|
3510
|
+
[delta.decisions, delta.constraints, delta.discoveries, delta.completedActions, delta.evidence, delta.failures, delta.blockers, delta.openWork].some(items => !Array.isArray(items) || items.length))
|
|
3511
|
+
continue;
|
|
3512
|
+
const baseline = this.db.prepare("SELECT sequence,payload_json FROM continuity_events WHERE run_id=? AND role='host' AND kind='role_baseline' AND sequence<=? ORDER BY sequence DESC LIMIT 1")
|
|
3513
|
+
.get(checkpoint.run_id, checkpoint.event_sequence);
|
|
3514
|
+
if (!baseline || JSON.parse(baseline.payload_json).role !== checkpoint.role)
|
|
3515
|
+
continue;
|
|
3516
|
+
const deltaDigest = createHash("sha256").update(checkpoint.delta_json).digest("hex");
|
|
3517
|
+
const event = this.appendContinuityEvent({ runId: checkpoint.run_id, role: "host", kind: "host_checkpoint", authoritativeStateRevision: checkpoint.authoritative_state_revision,
|
|
3518
|
+
payload: { checkpointDeltaDigest: deltaDigest, sourceBaselineSequence: baseline.sequence, role: checkpoint.role } });
|
|
3519
|
+
this.db.prepare("INSERT INTO continuity_checkpoint_origins(checkpoint_id,run_id,origin_json,delta_digest) VALUES(?,?,?,?)")
|
|
3520
|
+
.run(checkpoint.checkpoint_id, checkpoint.run_id, json({ kind: "host", eventSequence: event.sequence, inputCheckpointDigests: [] }), deltaDigest);
|
|
3521
|
+
}
|
|
3522
|
+
const matrix = cutoverOwnershipMatrix(this.db);
|
|
3523
|
+
const fileReferences = communicationFileEvidenceReferences(this.projectDir);
|
|
3524
|
+
const affected = this.db.prepare("SELECT * FROM workflow_runs").all().filter(run => invalidStructuredRun(this.db, run, this.projectDir));
|
|
3525
|
+
const lease = this.db.prepare("SELECT run_id,pid,process_start,host FROM project_lease WHERE singleton=1").get();
|
|
3526
|
+
if (lease && affected.some(run => run.run_id === lease.run_id) && classifyProcess(lease.pid, lease.process_start, lease.host).state !== "dead")
|
|
3527
|
+
throw new Error("v2-cutover-pending: stop the existing workflow owner before structured communication cutover");
|
|
3528
|
+
if (this.db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='build_owned_processes'").get()) {
|
|
3529
|
+
const processes = this.db.prepare("SELECT run_id,pid,process_start,host FROM build_owned_processes WHERE state<>'quiescent'").all();
|
|
3530
|
+
if (processes.some(process => affected.some(run => run.run_id === process.run_id)
|
|
3531
|
+
&& (!process.pid || !process.process_start || classifyProcess(process.pid, process.process_start, process.host).state !== "dead")))
|
|
3532
|
+
throw new Error("v2-cutover-pending: reconcile owned build processes before structured communication cutover");
|
|
3533
|
+
}
|
|
3534
|
+
if (this.db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='build_admission'").get()) {
|
|
3535
|
+
const row = this.db.prepare("SELECT record_json FROM build_admission WHERE singleton=1").get();
|
|
3536
|
+
if (row) {
|
|
3537
|
+
let admission;
|
|
3538
|
+
try {
|
|
3539
|
+
admission = JSON.parse(row.record_json);
|
|
3540
|
+
}
|
|
3541
|
+
catch {
|
|
3542
|
+
throw new Error("v2-cutover-pending: malformed build admission requires reconciliation");
|
|
3543
|
+
}
|
|
3544
|
+
if (affected.some(run => run.run_id === admission.runId) && classifyProcess(admission.pid, admission.processStart, admission.host).state !== "dead")
|
|
3545
|
+
throw new Error("v2-cutover-pending: stop the existing build owner before structured communication cutover");
|
|
3546
|
+
}
|
|
3547
|
+
}
|
|
3548
|
+
// Existing domain guards must not block the authorized cutover. Restore
|
|
3549
|
+
// them in this same transaction, omitting only superseded v1 comms guards.
|
|
3550
|
+
const triggers = this.db.prepare("SELECT name,sql FROM sqlite_master WHERE type='trigger' AND sql IS NOT NULL").all();
|
|
3551
|
+
for (const trigger of triggers) {
|
|
3552
|
+
if (!/^[a-zA-Z_][a-zA-Z_0-9]*$/.test(trigger.name))
|
|
3553
|
+
throw new Error("Unexpected cutover trigger identity");
|
|
3554
|
+
this.db.exec(`DROP TRIGGER "${trigger.name}"`);
|
|
3555
|
+
}
|
|
3556
|
+
const now = new Date().toISOString();
|
|
3557
|
+
for (const { run_id } of affected) {
|
|
3558
|
+
const row = this.db.prepare("SELECT state_json FROM workflow_runs WHERE run_id=?").get(run_id);
|
|
3559
|
+
const originalBytesDigest = createHash("sha256").update(row.state_json).digest("hex");
|
|
3560
|
+
this.db.prepare("INSERT INTO structured_cutover_quarantine(run_id,table_name,row_json) VALUES(?,?,?)").run(run_id, "workflow_runs.state_json", json({ originalBytes: row.state_json, originalBytesDigest }));
|
|
3561
|
+
let original = {};
|
|
3562
|
+
try {
|
|
3563
|
+
const parsed = parseJson(row.state_json);
|
|
3564
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
|
|
3565
|
+
original = parsed;
|
|
3566
|
+
}
|
|
3567
|
+
catch { /* quarantined verbatim above */ }
|
|
3568
|
+
const state = { ...original, structuredCommunicationCutover: { version: 2, reason: "structured-communication-v2-cutover", originalBytesDigest, at: now } };
|
|
3569
|
+
purgeStructuredRun(this.db, run_id, matrix, fileReferences);
|
|
3570
|
+
this.db.prepare("UPDATE workflow_runs SET status='superseded',checkpoint='structured-communication-v2-cutover',state_json=?,updated_at=? WHERE run_id=?")
|
|
3571
|
+
.run(json(state), now, run_id);
|
|
3572
|
+
}
|
|
3573
|
+
quarantineCommunicationFiles(this.projectDir, this.db);
|
|
3574
|
+
for (const trigger of triggers)
|
|
3575
|
+
if (!trigger.name.startsWith("structured_comms_v1_"))
|
|
3576
|
+
this.db.exec(trigger.sql);
|
|
3577
|
+
this.db.prepare("INSERT INTO structured_communication_cutover(migration,completed_at,details_json) VALUES(?,?,?) ON CONFLICT(migration) DO UPDATE SET completed_at=excluded.completed_at,details_json=excluded.details_json")
|
|
3578
|
+
.run(migration, now, json({ ownershipMatrixVersion: STRUCTURED_CUTOVER_MATRIX_VERSION, ownershipMatrix: matrix, fencedRuns: affected.map(row => row.run_id), policy: "legacy-or-unsealed-provider-authority-is-non-resumable" }));
|
|
3579
|
+
this.db.prepare("INSERT OR IGNORE INTO structured_communication_cutover(migration,completed_at,details_json) VALUES(?,?,?)")
|
|
3580
|
+
.run("003_structured_communication_v2_cutover", now, json({ ownershipMatrixVersion: STRUCTURED_CUTOVER_MATRIX_VERSION }));
|
|
3581
|
+
ensureQaGraphJournalGuards(this.db);
|
|
3582
|
+
this.db.exec(`CREATE TRIGGER IF NOT EXISTS structured_cutover_terminal_run BEFORE UPDATE ON workflow_runs
|
|
3583
|
+
WHEN OLD.checkpoint='structured-communication-v2-cutover'
|
|
3584
|
+
BEGIN SELECT CASE WHEN NEW.status<>'superseded' OR NEW.checkpoint<>OLD.checkpoint OR NEW.state_json<>OLD.state_json
|
|
3585
|
+
THEN RAISE(ABORT,'structured-communication-v2-cutover: workflow permanently non-resumable') END; END;`);
|
|
3586
|
+
})();
|
|
3587
|
+
}
|
|
2842
3588
|
ensureColumn(table, column, definition) {
|
|
2843
3589
|
const columns = this.db.prepare(`PRAGMA table_info(${table})`).all();
|
|
2844
3590
|
if (!columns.some((existing) => existing.name === column)) {
|