ai-foreman 1.7.29 → 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 +227 -23
- 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 +85 -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 +15 -0
- package/dist/buildApproval.js +25 -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 +22 -26
- package/dist/cli/tickets.d.ts +0 -7
- package/dist/cli/tickets.js +41 -40
- 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 +10 -4
- package/dist/foreman.js +51 -193
- 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 +2 -1
- package/dist/hostGraphApplicability.js +17 -19
- package/dist/hostQaAugmentation.js +21 -4
- package/dist/hostQaComparison.d.ts +21 -2
- package/dist/hostQaComparison.js +43 -29
- 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 +47 -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 +4 -9
- 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/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 -248
- package/dist/qaPreparationInputs.js +19 -0
- 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 +220 -395
- 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/qaVerificationContract.d.ts +1 -2
- package/dist/qaVerificationContract.js +24 -35
- 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.js +35 -21
- 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 +763 -39
- package/dist/workflowReader.d.ts +1 -0
- package/dist/workflowReader.js +9 -0
- package/package.json +15 -7
package/dist/workflowDb.js
CHANGED
|
@@ -1,11 +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,
|
|
10
|
+
import { bindCoverageHostFields, validateContractCoverage } from "./qaContractCoverage.js";
|
|
6
11
|
import { findHostQaCheck, hostComparisonDigestFromCoverage, hostQaComparisonV1Schema } from "./hostQaComparison.js";
|
|
7
12
|
import { findGraphQaChecks, hostGraphApplicabilityDigestFromCoverage, hostGraphApplicabilityV1Schema } from "./hostGraphApplicability.js";
|
|
8
|
-
import {
|
|
13
|
+
import { assembleFinalQaCoverage } from "./qaCoverageAssembly.js";
|
|
14
|
+
import { retainedQaCommandObservations } from "./qaReviewEvidence.js";
|
|
15
|
+
import { retainedHostBuilderObservations } from "./qaBuilderEvidence.js";
|
|
9
16
|
import { QaPreparationStore, migrateQaPreparation, migrateQaPreparationGuards } from "./qaPreparationStore.js";
|
|
10
17
|
import { GraphStore, migrateGraphStore } from "./graph/storage.js";
|
|
11
18
|
import { reconcileWork } from "./buildWorkReconciliation.js";
|
|
@@ -20,9 +27,9 @@ import { hostname } from "node:os";
|
|
|
20
27
|
import { dirname, join, resolve } from "node:path";
|
|
21
28
|
import { execFileSync } from "node:child_process";
|
|
22
29
|
import Database from "better-sqlite3";
|
|
23
|
-
import { validateQaFailureReport } from "rafi-spec";
|
|
30
|
+
import { validateQaFailureReport, qaVerdictOutput, remediationOutput } from "rafi-spec";
|
|
24
31
|
import { migrateQaHandback, registerHandbackWriter, reportOccurrenceId } from "./qaHandbackMigration.js";
|
|
25
|
-
import { providerSessionKey } from "./sessionIdentity.js";
|
|
32
|
+
import { providerSessionKey, canonicalSessionPath } from "./sessionIdentity.js";
|
|
26
33
|
import { captureFrozenQaSource } from "./qaSnapshot.js";
|
|
27
34
|
import { loadTicketsConfig, resolveTicketPaths } from "./tickets/config.js";
|
|
28
35
|
import { loadTickets } from "./tickets/ticketLoader.js";
|
|
@@ -124,6 +131,8 @@ export class WorkflowDb {
|
|
|
124
131
|
if (existsSync(path)) {
|
|
125
132
|
const preview = new Database(path, { readonly: true, fileMustExist: true });
|
|
126
133
|
try {
|
|
134
|
+
if (readinessAccess)
|
|
135
|
+
assertStructuredCutoverComplete(preview);
|
|
127
136
|
checkBuildOwnershipSchema(preview);
|
|
128
137
|
}
|
|
129
138
|
finally {
|
|
@@ -154,10 +163,13 @@ export class WorkflowDb {
|
|
|
154
163
|
this.db.pragma("synchronous = FULL");
|
|
155
164
|
this.db.pragma("foreign_keys = ON");
|
|
156
165
|
try {
|
|
157
|
-
if (readinessAccess)
|
|
166
|
+
if (readinessAccess) {
|
|
167
|
+
assertStructuredCutoverComplete(this.db);
|
|
158
168
|
this.restrictReadinessConnection(readinessAccess);
|
|
169
|
+
}
|
|
159
170
|
else {
|
|
160
171
|
this.migrate();
|
|
172
|
+
this.applyStructuredCommunicationV2Cutover();
|
|
161
173
|
migrateQaPreparation(this.db);
|
|
162
174
|
migrateBuildAdmission(this.db);
|
|
163
175
|
this.importLegacyOnce();
|
|
@@ -201,6 +213,7 @@ export class WorkflowDb {
|
|
|
201
213
|
}
|
|
202
214
|
}
|
|
203
215
|
assertInstructionOwner(runId) {
|
|
216
|
+
this.assertNativeRunAdmission(runId);
|
|
204
217
|
if (this.writerAuthority) {
|
|
205
218
|
assertAdmission(this.db, this.writerAuthority);
|
|
206
219
|
if (this.writerAuthority.runId !== runId && !this.db.prepare("SELECT 1 FROM build_child_runs WHERE child=? AND parent=?").get(runId, this.writerAuthority.runId))
|
|
@@ -334,7 +347,7 @@ export class WorkflowDb {
|
|
|
334
347
|
}
|
|
335
348
|
}
|
|
336
349
|
admittedWork(runId, workId) { return admittedWork(this.db, runId, workId); }
|
|
337
|
-
assertAdmittedWork(runId, workId) { return assertAdmittedWork(this.db, runId, workId); }
|
|
350
|
+
assertAdmittedWork(runId, workId) { this.assertNativeRunAdmission(runId); return assertAdmittedWork(this.db, runId, workId); }
|
|
338
351
|
/** Atomic local changes only; never await provider or filesystem work here. */
|
|
339
352
|
atomic(work) { return this.db.transaction(work).immediate(); }
|
|
340
353
|
buildAdmission() { return readAdmission(this.db); }
|
|
@@ -417,6 +430,7 @@ export class WorkflowDb {
|
|
|
417
430
|
throw new Error("Ownership migration is pending legacy execution cleanup; use rafi resume before starting work");
|
|
418
431
|
}
|
|
419
432
|
acquireBuildAdmission(runId, phase) {
|
|
433
|
+
this.assertNativeRunAdmission(runId);
|
|
420
434
|
return this.atomic(() => {
|
|
421
435
|
this.assertNoLegacyBuildOwner();
|
|
422
436
|
const lease = this.currentLease();
|
|
@@ -447,6 +461,7 @@ export class WorkflowDb {
|
|
|
447
461
|
});
|
|
448
462
|
}
|
|
449
463
|
acquireBuildRecoveryAdmission(runId) {
|
|
464
|
+
this.assertNativeRunAdmission(runId);
|
|
450
465
|
return this.atomic(() => {
|
|
451
466
|
this.assertExecutionProtocol();
|
|
452
467
|
this.assertNoLegacyBuildOwner();
|
|
@@ -838,6 +853,11 @@ export class WorkflowDb {
|
|
|
838
853
|
originalWork: input.originalWork ?? {}, remainingWork: input.remainingWork ?? input.originalWork ?? {}, state: input.state ?? {},
|
|
839
854
|
legacy: Boolean(input.legacy), createdAt: at, updatedAt: at,
|
|
840
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
|
+
}
|
|
841
861
|
this.db.transaction(() => {
|
|
842
862
|
if (this.writerAuthority)
|
|
843
863
|
assertAdmission(this.db, this.writerAuthority);
|
|
@@ -909,6 +929,7 @@ export class WorkflowDb {
|
|
|
909
929
|
});
|
|
910
930
|
}
|
|
911
931
|
transition(runId, update, now = new Date()) {
|
|
932
|
+
this.assertNativeRunAdmission(runId);
|
|
912
933
|
const at = now.toISOString();
|
|
913
934
|
return this.db.transaction(() => {
|
|
914
935
|
if (!this.coordinatorTransition && (this.writerAuthority || this.buildAdmission())) {
|
|
@@ -1036,11 +1057,33 @@ export class WorkflowDb {
|
|
|
1036
1057
|
this.assertGraphContinuityAccess(row.payload_json);
|
|
1037
1058
|
return rows.map(continuityEventFromRow);
|
|
1038
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
|
+
}
|
|
1039
1066
|
publishContinuityCheckpoint(input, now = new Date()) {
|
|
1040
1067
|
this.ensureRun(input.runId, "build", now);
|
|
1041
1068
|
return this.db.transaction(() => {
|
|
1042
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
|
+
}
|
|
1043
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
|
+
}
|
|
1044
1087
|
const latestEvent = this.db.prepare("SELECT COALESCE(MAX(sequence),0) AS sequence FROM continuity_events WHERE run_id=?").get(input.runId);
|
|
1045
1088
|
const safeDelta = sanitizeContinuityValue(input.delta);
|
|
1046
1089
|
const graphAccess = currentGraphDerivedAccess();
|
|
@@ -1050,6 +1093,10 @@ export class WorkflowDb {
|
|
|
1050
1093
|
const digest = digestJson({ runId: input.runId, role: input.role, sequence: latestEvent.sequence, predecessorDigest: previous?.digest, delta: safeDelta, authoritativeStateRevision: input.authoritativeStateRevision, sessionKey: session.key });
|
|
1051
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)
|
|
1052
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);
|
|
1053
1100
|
const checkpoint = {
|
|
1054
1101
|
checkpointId: Number(result.lastInsertRowid), runId: input.runId, role: input.role,
|
|
1055
1102
|
sequence: latestEvent.sequence, state: input.state ?? "current", delta: safeDelta, digest,
|
|
@@ -1067,14 +1114,18 @@ export class WorkflowDb {
|
|
|
1067
1114
|
const rows = role
|
|
1068
1115
|
? this.db.prepare("SELECT * FROM continuity_checkpoints WHERE run_id=? AND role=? ORDER BY checkpoint_id").all(runId, role)
|
|
1069
1116
|
: this.db.prepare("SELECT * FROM continuity_checkpoints WHERE run_id=? ORDER BY checkpoint_id").all(runId);
|
|
1070
|
-
for (const row of rows)
|
|
1117
|
+
for (const row of rows) {
|
|
1071
1118
|
this.assertGraphContinuityAccess(row.delta_json);
|
|
1119
|
+
verifyCheckpointOrigin(this.db, this.projectDir, row.checkpoint_id);
|
|
1120
|
+
}
|
|
1072
1121
|
return rows.map(continuityCheckpointFromRow);
|
|
1073
1122
|
}
|
|
1074
1123
|
latestContinuityCheckpoint(runId, role) {
|
|
1075
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);
|
|
1076
|
-
if (row)
|
|
1125
|
+
if (row) {
|
|
1077
1126
|
this.assertGraphContinuityAccess(row.delta_json);
|
|
1127
|
+
verifyCheckpointOrigin(this.db, this.projectDir, row.checkpoint_id);
|
|
1128
|
+
}
|
|
1078
1129
|
return row ? continuityCheckpointFromRow(row) : undefined;
|
|
1079
1130
|
}
|
|
1080
1131
|
assertGraphContinuityAccess(value) {
|
|
@@ -1084,6 +1135,8 @@ export class WorkflowDb {
|
|
|
1084
1135
|
}
|
|
1085
1136
|
continuityHead(runId, role) {
|
|
1086
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);
|
|
1087
1140
|
return row ? continuityHeadFromRow(row) : undefined;
|
|
1088
1141
|
}
|
|
1089
1142
|
setContinuityHeadState(runId, role, state, now = new Date()) {
|
|
@@ -1313,7 +1366,12 @@ export class WorkflowDb {
|
|
|
1313
1366
|
return row ? parseJson(row.state_json) : undefined;
|
|
1314
1367
|
}
|
|
1315
1368
|
planOperation(input, now = new Date()) {
|
|
1369
|
+
this.assertNativeRunAdmission(input.runId);
|
|
1316
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
|
+
}
|
|
1317
1375
|
if (input.kind === "provider-dispatch" && this.unresolvedPreparationProcesses(input.runId).length)
|
|
1318
1376
|
throw new Error("Readiness cleanup must be reconciled before provider dispatch");
|
|
1319
1377
|
this.db.prepare(`INSERT INTO operation_journal(idempotency_key,run_id,kind,status,intent_json,created_at,updated_at)
|
|
@@ -1324,12 +1382,294 @@ export class WorkflowDb {
|
|
|
1324
1382
|
}
|
|
1325
1383
|
return operation;
|
|
1326
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
|
+
}
|
|
1327
1659
|
updateOperation(idempotencyKey, status, details = {}, now = new Date()) {
|
|
1328
1660
|
const prior = this.operation(idempotencyKey);
|
|
1329
1661
|
if (prior?.kind === "provider-dispatch" && status === "in_progress" && this.unresolvedPreparationProcesses(prior.runId).length)
|
|
1330
1662
|
throw new Error("Readiness cleanup must be reconciled before provider dispatch");
|
|
1331
1663
|
if (!prior)
|
|
1332
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
|
+
}
|
|
1333
1673
|
const allowed = {
|
|
1334
1674
|
planned: ["planned", "in_progress", "failed"],
|
|
1335
1675
|
in_progress: ["in_progress", "confirmed", "failed", "uncertain"],
|
|
@@ -1389,6 +1729,84 @@ export class WorkflowDb {
|
|
|
1389
1729
|
registerGraphDerived(this.db, bytes, graphAccess);
|
|
1390
1730
|
return digest;
|
|
1391
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
|
+
}
|
|
1392
1810
|
registerGraphEvidence(value, access) { registerGraphDerived(this.db, value, access); }
|
|
1393
1811
|
getEvidence(digest) {
|
|
1394
1812
|
if (!graphDerivedAllowed(this.db, this.projectDir, digest))
|
|
@@ -1449,7 +1867,16 @@ export class WorkflowDb {
|
|
|
1449
1867
|
.run(next.status, next.reportDigest ?? null, json({ findingIds: next.findingIds, namespacedFindingIds: next.namespacedFindingIds, detail: next.detail }), next.updatedAt, attemptId);
|
|
1450
1868
|
const admission = this.admittedWork(next.runId, next.ticketId);
|
|
1451
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);
|
|
1452
|
-
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
|
+
});
|
|
1453
1880
|
if (admission && (next.status === "passed" || next.status === "failed" || terminalBlock))
|
|
1454
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] : [] });
|
|
1455
1882
|
return next;
|
|
@@ -1489,8 +1916,9 @@ export class WorkflowDb {
|
|
|
1489
1916
|
if (!prior)
|
|
1490
1917
|
throw new Error(`QA remediation attempt not found: ${attemptId}`);
|
|
1491
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);
|
|
1492
1920
|
this.db.prepare("UPDATE qa_remediation_attempts SET status=?,response_digest=?,summary_digest=?,record_json=?,updated_at=? WHERE attempt_id=?")
|
|
1493
|
-
.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);
|
|
1494
1922
|
return next;
|
|
1495
1923
|
}
|
|
1496
1924
|
qaRemediationAttempts(runId, ticketId) {
|
|
@@ -1511,6 +1939,11 @@ export class WorkflowDb {
|
|
|
1511
1939
|
ensureQaGraphJournalGuards(this.db);
|
|
1512
1940
|
const prior = this.qaDeliveryTurns(turn.operationId).find(t => t.turnRecordId === turn.turnRecordId);
|
|
1513
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
|
+
}
|
|
1514
1947
|
if (prior.status !== "intended") {
|
|
1515
1948
|
if (json(prior) !== json(turn))
|
|
1516
1949
|
throw new Error(`conflicting completed delivery turn ${turn.turnRecordId}`);
|
|
@@ -1521,6 +1954,14 @@ export class WorkflowDb {
|
|
|
1521
1954
|
throw new Error(`delivery turn intent mismatch: ${key}`);
|
|
1522
1955
|
}
|
|
1523
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
|
+
}
|
|
1524
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)
|
|
1525
1966
|
VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(turn_record_id) DO UPDATE SET status=excluded.status,record_json=excluded.record_json,updated_at=excluded.updated_at`)
|
|
1526
1967
|
.run(turn.turnRecordId, turn.operationId, turn.reportOccurrenceId, turn.turnIndex, turn.kind, turn.status, json(turn), turn.startedAt, turn.completedAt ?? turn.startedAt);
|
|
@@ -1646,6 +2087,7 @@ export class WorkflowDb {
|
|
|
1646
2087
|
}
|
|
1647
2088
|
/** Compare-and-swap the authoritative QA reducer head and append its event atomically. */
|
|
1648
2089
|
transitionQa(runId, ticketId, expectedRevision, event, now = new Date()) {
|
|
2090
|
+
this.assertNativeRunAdmission(runId);
|
|
1649
2091
|
this.ensureRun(runId);
|
|
1650
2092
|
return this.db.transaction(() => {
|
|
1651
2093
|
const current = this.qaTicketHead(runId, ticketId);
|
|
@@ -1734,7 +2176,18 @@ export class WorkflowDb {
|
|
|
1734
2176
|
const row = this.db.prepare("SELECT receipt_json FROM qa_handoffs WHERE operation_id=? AND status='accepted'").get(operationId);
|
|
1735
2177
|
return row?.receipt_json ? parseJson(row.receipt_json) : undefined;
|
|
1736
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
|
+
}
|
|
1737
2188
|
beginQaTurn(intent) {
|
|
2189
|
+
if (intent.outputContract)
|
|
2190
|
+
this.enableStructuredCommunication();
|
|
1738
2191
|
const at = intent.intendedAt;
|
|
1739
2192
|
this.db.transaction(() => {
|
|
1740
2193
|
const existing = this.db.prepare("SELECT intent_json FROM qa_turns WHERE operation_id=?").get(intent.operationId);
|
|
@@ -1839,9 +2292,24 @@ export class WorkflowDb {
|
|
|
1839
2292
|
}
|
|
1840
2293
|
finishQaTurn(receipt) {
|
|
1841
2294
|
this.db.transaction(() => {
|
|
1842
|
-
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);
|
|
1843
2296
|
if (!prior)
|
|
1844
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
|
+
}
|
|
1845
2313
|
if (prior.source_state_digest !== receipt.sourceStateDigest || prior.review_basis_digest !== receipt.reviewBasisDigest)
|
|
1846
2314
|
throw new Error("QA turn receipt binding mismatch");
|
|
1847
2315
|
if (prior.status === "completed") {
|
|
@@ -2000,6 +2468,13 @@ export class WorkflowDb {
|
|
|
2000
2468
|
const row = this.db.prepare("SELECT receipt_json FROM qa_remediation_receipts WHERE operation_id=?").get(operationId);
|
|
2001
2469
|
return row ? parseJson(row.receipt_json) : undefined;
|
|
2002
2470
|
}
|
|
2471
|
+
verifiedBuilderRemediationReceipt(operationId) {
|
|
2472
|
+
const receipt = this.builderRemediationReceipt(operationId);
|
|
2473
|
+
if (!receipt)
|
|
2474
|
+
return undefined;
|
|
2475
|
+
this.assertBuilderRemediationReceipt(receipt);
|
|
2476
|
+
return receipt;
|
|
2477
|
+
}
|
|
2003
2478
|
hasConfirmedQaRemediationForRecheck(runId, ticketId) {
|
|
2004
2479
|
const head = this.qaTicketHead(runId, ticketId);
|
|
2005
2480
|
const attempt = this.qaRemediationAttempts(runId, ticketId).at(-1);
|
|
@@ -2007,6 +2482,8 @@ export class WorkflowDb {
|
|
|
2007
2482
|
|| attempt.generation !== head.remediationGeneration || this.unresolvedRoleDispatches(runId, "builder").length)
|
|
2008
2483
|
return false;
|
|
2009
2484
|
const receipt = this.builderRemediationReceipt(attempt.attemptId);
|
|
2485
|
+
if (receipt)
|
|
2486
|
+
this.assertBuilderRemediationReceipt(receipt);
|
|
2010
2487
|
const review = this.qaReviewAttempt(attempt.reviewAttemptId);
|
|
2011
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);
|
|
2012
2489
|
return Boolean(receipt && review && review.status === "failed" && receipt.runId === runId && receipt.ticketId === ticketId
|
|
@@ -2016,6 +2493,7 @@ export class WorkflowDb {
|
|
|
2016
2493
|
&& transition?.to_revision === head.revision && parseJson(transition.event_json).type === "remediation-received");
|
|
2017
2494
|
}
|
|
2018
2495
|
recordBuilderRemediationReceipt(receipt) {
|
|
2496
|
+
this.assertBuilderRemediationReceipt(receipt);
|
|
2019
2497
|
const text = json(receipt);
|
|
2020
2498
|
const existing = this.db.prepare("SELECT receipt_json FROM qa_remediation_receipts WHERE operation_id=?").get(receipt.operationId);
|
|
2021
2499
|
if (existing && json(parseJson(existing.receipt_json)) !== text)
|
|
@@ -2028,6 +2506,30 @@ export class WorkflowDb {
|
|
|
2028
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(?,?,?,?,?,?,?)")
|
|
2029
2507
|
.run(receipt.operationId, receipt.runId, receipt.ticketId, receipt.reportDigest, occurrence.reportOccurrenceId, text, receipt.completedAt);
|
|
2030
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
|
+
}
|
|
2031
2533
|
commitBuilderRemediationSuccess(receipt, expectedRevision, now = new Date()) {
|
|
2032
2534
|
return this.db.transaction(() => {
|
|
2033
2535
|
this.recordBuilderRemediationReceipt(receipt);
|
|
@@ -2058,6 +2560,47 @@ export class WorkflowDb {
|
|
|
2058
2560
|
return this.transitionQa(remediation.runId, remediation.ticketId, head.revision, { type: input.outcome === "uncertain" ? "remediation-uncertain" : "remediation-failed" }, now);
|
|
2059
2561
|
})();
|
|
2060
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
|
+
}
|
|
2061
2604
|
assertContractCertificate(input) {
|
|
2062
2605
|
const store = this.qaPreparationStore();
|
|
2063
2606
|
if (store.policy(input.runId)?.mode !== "enforce")
|
|
@@ -2065,6 +2608,7 @@ export class WorkflowDb {
|
|
|
2065
2608
|
const binding = input.contractCoverage;
|
|
2066
2609
|
if (!binding || binding.version !== 1)
|
|
2067
2610
|
throw new Error("Enforcing pass requires tagged contract coverage authority");
|
|
2611
|
+
const verifiedBinding = binding;
|
|
2068
2612
|
const admission = this.assertAdmittedWork(input.runId, input.ticketId);
|
|
2069
2613
|
const head = store.head(input.runId, input.ticketId, admission.requirementsDigest);
|
|
2070
2614
|
if (head.state !== "ready" || head.digest !== binding.contractDigest)
|
|
@@ -2085,17 +2629,17 @@ export class WorkflowDb {
|
|
|
2085
2629
|
const configuredMake = this.getRun(input.runId)?.state?.qa?.settings?.make;
|
|
2086
2630
|
if (configuredMake && resolveEffectiveQaConfiguration(this.projectDir, { make: configuredMake }).digest !== basis.contractBinding.commonConfigDigest)
|
|
2087
2631
|
throw new Error("Effective QA configuration changed before pass authority; reconcile contract");
|
|
2088
|
-
const attempt = this.qaReviewAttempt(
|
|
2632
|
+
const attempt = this.qaReviewAttempt(verifiedBinding.attemptId);
|
|
2089
2633
|
if (!attempt || attempt.runId !== input.runId || attempt.ticketId !== input.ticketId || attempt.sourceDigest !== input.sourceStateDigest || attempt.reviewNumber !== this.qaTicketHead(input.runId, input.ticketId).reviewNumber)
|
|
2090
2634
|
throw new Error("Coverage review attempt is stale or foreign");
|
|
2091
|
-
const coverage = store.artifact(
|
|
2635
|
+
const coverage = store.artifact(verifiedBinding.coverageDigest, "final-coverage");
|
|
2092
2636
|
for (const check of contract.checks)
|
|
2093
2637
|
for (const method of check.verification)
|
|
2094
2638
|
if (method.equivalentAuthorityId && !store.equivalentResolver()(method.equivalentAuthorityId, contract, check, method, input.sourceStateDigest))
|
|
2095
2639
|
throw new Error("Equivalent verification authority is stale or foreign");
|
|
2096
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);
|
|
2097
2641
|
const initial = initialTurns.map(row => parseJson(row.intent_json)).find(turn => turn.slot === "initial");
|
|
2098
|
-
if (!initial || initial.providerSession.sessionId !==
|
|
2642
|
+
if (!initial || initial.providerSession.sessionId !== verifiedBinding.sessionId)
|
|
2099
2643
|
throw new Error("Coverage lacks the actual fresh review dispatch identity");
|
|
2100
2644
|
const dispatchedInstruction = this.getEvidence(initial.instructionDigest)?.toString();
|
|
2101
2645
|
const envelope = `\nImmutable input-basis digest: ${basis.digest}. Coverage must bind this inputBasisDigest.`;
|
|
@@ -2103,49 +2647,65 @@ export class WorkflowDb {
|
|
|
2103
2647
|
throw new Error("Actual review dispatch differs from frozen base instruction and tagged input-basis envelope");
|
|
2104
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);
|
|
2105
2649
|
const receipt = receipts.map(row => parseJson(row.receipt_json)).find(row => qaDigest("turn-receipt", row) === input.turnReceiptDigest && row.terminalEventObserved);
|
|
2106
|
-
const response = receipt?.
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
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
|
+
});
|
|
2116
2665
|
let retainedCoverage = retainedProviderCoverage;
|
|
2117
2666
|
let retainedHostComparison;
|
|
2118
2667
|
const hostCheck = findHostQaCheck(contract);
|
|
2119
|
-
if (hostCheck) {
|
|
2668
|
+
if (hostCheck !== undefined) {
|
|
2120
2669
|
const hostDigest = hostComparisonDigestFromCoverage(coverage, hostCheck.id);
|
|
2121
2670
|
if (!hostDigest)
|
|
2122
2671
|
throw new Error("Coverage is missing the host-owned comparison artifact reference");
|
|
2123
|
-
const
|
|
2124
|
-
|
|
2672
|
+
const verifiedHostDigest = hostDigest;
|
|
2673
|
+
const hostResult = hostQaComparisonV1Schema.parse(store.artifact(verifiedHostDigest, "host-qa-comparison"));
|
|
2674
|
+
retainedHostComparison = { result: hostResult, resultDigest: verifiedHostDigest };
|
|
2125
2675
|
}
|
|
2676
|
+
const commandObservations = [
|
|
2677
|
+
...retainedQaCommandObservations(this.projectDir, input.runId, input.ticketId, false),
|
|
2678
|
+
...retainedHostBuilderObservations(this, input.runId, input.ticketId),
|
|
2679
|
+
];
|
|
2126
2680
|
const graphCheck = findGraphQaChecks(contract)[0];
|
|
2681
|
+
let retainedHostGraph;
|
|
2127
2682
|
if (graphCheck) {
|
|
2128
2683
|
const graphDigest = hostGraphApplicabilityDigestFromCoverage(coverage, graphCheck.id);
|
|
2129
2684
|
if (!graphDigest)
|
|
2130
2685
|
throw new Error("Coverage is missing the host-owned Graphify applicability artifact reference");
|
|
2131
|
-
const
|
|
2132
|
-
const
|
|
2133
|
-
|
|
2134
|
-
? applyHostQaAugmentations({ coverage: retainedCoverage, contract, projectDir: this.projectDir, hostComparison: retainedHostComparison, hostGraph: retainedHostGraph })
|
|
2135
|
-
: undefined;
|
|
2136
|
-
}
|
|
2137
|
-
else if (retainedCoverage && retainedHostComparison) {
|
|
2138
|
-
retainedCoverage = applyHostQaAugmentations({ coverage: retainedCoverage, contract, projectDir: this.projectDir, hostComparison: retainedHostComparison });
|
|
2686
|
+
const verifiedGraphDigest = graphDigest;
|
|
2687
|
+
const graphResult = hostGraphApplicabilityV1Schema.parse(store.artifact(verifiedGraphDigest, "host-graph-applicability"));
|
|
2688
|
+
retainedHostGraph = { result: graphResult, resultDigest: verifiedGraphDigest };
|
|
2139
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
|
+
});
|
|
2140
2699
|
if (!retainedCoverage)
|
|
2141
2700
|
throw new Error("Coverage lacks the retained terminal review receipt and authorized host augmentation");
|
|
2142
|
-
const errors = validateContractCoverage(contract,
|
|
2701
|
+
const errors = validateContractCoverage(contract, retainedCoverage, { phase: "qa", sourceDigest: input.sourceStateDigest, inputBasisDigest: input.reviewBasisDigest, attemptId: verifiedBinding.attemptId, sessionId: verifiedBinding.sessionId });
|
|
2143
2702
|
if (errors.length)
|
|
2144
2703
|
throw new Error(errors.join("; "));
|
|
2145
2704
|
if (!receipt || !retainedCoverage || canonicalJson(retainedCoverage) !== canonicalJson(coverage))
|
|
2146
2705
|
throw new Error("Coverage is not bound to the retained terminal review receipt and authorized host augmentation");
|
|
2147
2706
|
}
|
|
2148
2707
|
issueQaPassCertificate(input, now = new Date()) {
|
|
2708
|
+
this.assertNativeQaCertificate(input, "issue");
|
|
2149
2709
|
this.assertContractCertificate(input);
|
|
2150
2710
|
if (this.unresolvedQaReports(input.runId, input.ticketId).length)
|
|
2151
2711
|
throw new Error("cannot issue QA pass certificate while reports remain unresolved");
|
|
@@ -2178,11 +2738,16 @@ export class WorkflowDb {
|
|
|
2178
2738
|
}
|
|
2179
2739
|
consumeQaPassCertificate(runId, ticketId, certificateId, consumer, now = new Date()) {
|
|
2180
2740
|
return this.db.transaction(() => {
|
|
2181
|
-
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);
|
|
2182
2742
|
if (!row)
|
|
2183
2743
|
throw new Error("QA pass certificate is missing or scoped to another run/ticket");
|
|
2184
2744
|
if (row.consumed_at)
|
|
2185
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);
|
|
2186
2751
|
this.assertContractCertificate(parseJson(row.certificate_json));
|
|
2187
2752
|
if (this.unresolvedQaReports(runId, ticketId).length)
|
|
2188
2753
|
throw new Error("QA pass certificate cannot be consumed with unresolved reports");
|
|
@@ -2231,6 +2796,26 @@ export class WorkflowDb {
|
|
|
2231
2796
|
return next;
|
|
2232
2797
|
})();
|
|
2233
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
|
+
}
|
|
2234
2819
|
planQaFinalizationStep(input, now = new Date()) {
|
|
2235
2820
|
const at = now.toISOString();
|
|
2236
2821
|
const intentText = json(input.intent);
|
|
@@ -2290,6 +2875,7 @@ export class WorkflowDb {
|
|
|
2290
2875
|
completeQaFinalization(runId, ticketId, expectedRevision, receipt, now = new Date()) {
|
|
2291
2876
|
return this.db.transaction(() => {
|
|
2292
2877
|
this.assertFinalizationControls(runId, ticketId);
|
|
2878
|
+
this.assertQaFinalizationAuthority(runId, ticketId);
|
|
2293
2879
|
const head = this.qaTicketHead(runId, ticketId);
|
|
2294
2880
|
if (head.revision !== expectedRevision || head.state !== "finalizing")
|
|
2295
2881
|
throw new Error(`QA finalization completion raced for ${runId}/${ticketId}`);
|
|
@@ -2550,6 +3136,31 @@ export class WorkflowDb {
|
|
|
2550
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);
|
|
2551
3137
|
return row.count;
|
|
2552
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
|
+
}
|
|
2553
3164
|
recordRecoveryDecision(receipt) {
|
|
2554
3165
|
this.ensureRun(receipt.runId);
|
|
2555
3166
|
const digest = digestJson(receipt);
|
|
@@ -2728,12 +3339,12 @@ export class WorkflowDb {
|
|
|
2728
3339
|
parsed = { unreadable: true };
|
|
2729
3340
|
}
|
|
2730
3341
|
const record = parsed && typeof parsed === "object" ? parsed : undefined;
|
|
3342
|
+
if (record?.communicationProtocolVersion === 2)
|
|
3343
|
+
continue;
|
|
2731
3344
|
const isBuild = (record?.version === 1 || record?.version === 2) && typeof record.runId === "string";
|
|
2732
3345
|
const runId = isBuild ? String(record.runId) : `legacy_${digest.slice(0, 24)}`;
|
|
2733
3346
|
if (!this.getRun(runId)) {
|
|
2734
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);
|
|
2735
|
-
if (!isBuild)
|
|
2736
|
-
this.transition(runId, { status: "superseded", checkpoint: "legacy-imported", remainingWork: {}, event: "legacy_record_preserved" }, now);
|
|
2737
3348
|
}
|
|
2738
3349
|
this.db.prepare("INSERT INTO legacy_imports(source_path,digest,run_id,imported_at) VALUES(?,?,?,?)").run(path, digest, runId, now.toISOString());
|
|
2739
3350
|
count += 1;
|
|
@@ -2861,6 +3472,119 @@ export class WorkflowDb {
|
|
|
2861
3472
|
this.db.prepare("INSERT INTO recovery_schema_migrations(migration,completed_at) VALUES(?,?)").run(`${migration}:${migrationDigest}`, new Date().toISOString());
|
|
2862
3473
|
migrateQaHandback(this.db);
|
|
2863
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
|
+
}
|
|
2864
3588
|
ensureColumn(table, column, definition) {
|
|
2865
3589
|
const columns = this.db.prepare(`PRAGMA table_info(${table})`).all();
|
|
2866
3590
|
if (!columns.some((existing) => existing.name === column)) {
|