ai-foreman 1.7.29 → 2.0.1
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 +231 -26
- package/dist/adapters/codex.d.ts +19 -7
- package/dist/adapters/codex.js +202 -19
- 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/git.js +3 -8
- 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 +28 -9
- 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/buildRuns.d.ts +1 -0
- package/dist/buildRuns.js +6 -1
- package/dist/builderGuidanceFollowup.js +17 -6
- package/dist/cli/doctor.js +3 -1
- package/dist/cli/manager.js +22 -20
- package/dist/cli/start.js +85 -39
- package/dist/cli/tickets.d.ts +0 -8
- package/dist/cli/tickets.js +50 -60
- package/dist/continuity.d.ts +10 -16
- package/dist/continuity.js +223 -208
- package/dist/continuityOrigin.d.ts +13 -0
- package/dist/continuityOrigin.js +91 -0
- package/dist/dispatchNotSent.d.ts +4 -0
- package/dist/dispatchNotSent.js +10 -0
- package/dist/foreman.d.ts +15 -16
- package/dist/foreman.js +70 -278
- package/dist/gitExclude.d.ts +16 -0
- package/dist/gitExclude.js +46 -0
- 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 +3 -4
- package/dist/graph/turn.js +29 -41
- package/dist/handoffProtocol.js +2 -2
- package/dist/handoffs.d.ts +5 -3
- package/dist/handoffs.js +92 -88
- 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/humanDecision.d.ts +7 -0
- package/dist/humanDecision.js +11 -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/observability.js +3 -8
- package/dist/probeIpc.d.ts +14 -0
- package/dist/probeIpc.js +29 -0
- package/dist/qaBuildGate.d.ts +21 -0
- package/dist/qaBuildGate.js +60 -13
- 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 +153 -254
- package/dist/qaPreparationInputs.js +19 -0
- package/dist/qaPreparationStore.d.ts +11 -0
- package/dist/qaPreparationStore.js +44 -4
- package/dist/qaPrerequisites.d.ts +2 -1
- package/dist/qaPrerequisites.js +21 -1
- package/dist/qaProtocolV2.d.ts +15 -0
- package/dist/qaRecovery.d.ts +0 -3
- package/dist/qaRecovery.js +21 -27
- package/dist/qaReview.js +241 -406
- package/dist/qaReviewEvidence.d.ts +7 -3
- package/dist/qaReviewEvidence.js +15 -4
- package/dist/qaRuntime.js +1 -1
- package/dist/qaSnapshot.js +76 -21
- package/dist/qaStructuredResponse.d.ts +18 -0
- package/dist/qaStructuredResponse.js +81 -0
- package/dist/qaTimeline.js +2 -2
- package/dist/qaVerificationContract.d.ts +1 -2
- package/dist/qaVerificationContract.js +24 -35
- package/dist/readinessGate.js +5 -4
- package/dist/recoveryOwners.d.ts +32 -0
- package/dist/recoveryOwners.js +87 -0
- package/dist/runtimePreflight.d.ts +11 -0
- package/dist/runtimePreflight.js +77 -0
- package/dist/runtimeReadiness.js +23 -15
- package/dist/sources/sourceRegistry.d.ts +0 -3
- package/dist/sources/sourceRegistry.js +0 -42
- 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 +279 -0
- package/dist/supervisedStart.js +35 -21
- package/dist/ticketPlanning.d.ts +1 -376
- package/dist/ticketPlanning.js +2 -49
- 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 +127 -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 +116 -3
- package/dist/workflowDb.js +852 -61
- package/dist/workflowReader.d.ts +1 -0
- package/dist/workflowReader.js +9 -0
- package/package.json +23 -7
package/dist/workflowDb.js
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
|
+
import { ensureLocalGitExcludes } from "./gitExclude.js";
|
|
2
|
+
import { persistedProviderWireContract } from "rafi-spec";
|
|
3
|
+
import { verifyCheckpointOrigin, verifyContinuityHeads } from "./continuityOrigin.js";
|
|
1
4
|
import { registerQaGraphJournalWriter, ensureQaGraphJournalGuards } from "./qaDeliveryJournal.js";
|
|
2
5
|
import { canonicalJson } from "./qaProtocolV2.js";
|
|
6
|
+
import { hydrateStructuredReceipt, persistedDispatchTuple, sealedDispatchRecord, verifyPersistedDispatch } from "./structuredDispatchRecord.js";
|
|
7
|
+
import { assertStructuredCutoverComplete, cutoverOwnershipMatrix, invalidStructuredRun, purgeStructuredRun, STRUCTURED_CUTOVER_MATRIX_VERSION, STRUCTURED_CUTOVER_REASON, STRUCTURED_AUDIT_CLOSURE } from "./structuredCutover.js";
|
|
8
|
+
import { communicationFileEvidenceReferences, quarantineCommunicationFiles } from "./structuredCutoverFiles.js";
|
|
3
9
|
import { resolveEffectiveQaConfiguration } from "./qaEffectiveConfig.js";
|
|
4
10
|
import { registerGraphDerived, graphDerivedAllowed, currentGraphDerivedAccess } from "./graph/derived.js";
|
|
5
|
-
import { bindCoverageHostFields,
|
|
11
|
+
import { bindCoverageHostFields, validateContractCoverage } from "./qaContractCoverage.js";
|
|
6
12
|
import { findHostQaCheck, hostComparisonDigestFromCoverage, hostQaComparisonV1Schema } from "./hostQaComparison.js";
|
|
7
13
|
import { findGraphQaChecks, hostGraphApplicabilityDigestFromCoverage, hostGraphApplicabilityV1Schema } from "./hostGraphApplicability.js";
|
|
8
|
-
import {
|
|
14
|
+
import { assembleFinalQaCoverage } from "./qaCoverageAssembly.js";
|
|
15
|
+
import { retainedQaCommandObservations } from "./qaReviewEvidence.js";
|
|
16
|
+
import { retainedHostBuilderObservations } from "./qaBuilderEvidence.js";
|
|
9
17
|
import { QaPreparationStore, migrateQaPreparation, migrateQaPreparationGuards } from "./qaPreparationStore.js";
|
|
10
18
|
import { GraphStore, migrateGraphStore } from "./graph/storage.js";
|
|
11
19
|
import { reconcileWork } from "./buildWorkReconciliation.js";
|
|
@@ -15,20 +23,36 @@ import { admitWork, admittedWork, assertAdmittedWork } from "./buildWorkAdmissio
|
|
|
15
23
|
import { cleanupReadiness, inspectReadiness, readinessMetadata } from "./readinessCleanup.js";
|
|
16
24
|
import { originalBuildLease, rememberOriginalLease, forgetOriginalLease, registerLaunchChild, acknowledgeLaunchChild, reconcileLaunch, checkBuildOwnershipSchema, canonicalProject, launchDigest, localBuildAuthority, rememberBuildAuthority, forgetBuildAuthority, migrateBuildAdmission, acquireAdmission, readAdmission, assertAdmission, releaseAdmission, reserveLaunch, setLaunchState, claimLaunch, readLaunch } from "./buildAdmission.js";
|
|
17
25
|
import { createHash, randomUUID } from "node:crypto";
|
|
18
|
-
import {
|
|
26
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
19
27
|
import { hostname } from "node:os";
|
|
20
28
|
import { dirname, join, resolve } from "node:path";
|
|
21
29
|
import { execFileSync } from "node:child_process";
|
|
22
30
|
import Database from "better-sqlite3";
|
|
23
|
-
import { validateQaFailureReport } from "rafi-spec";
|
|
31
|
+
import { validateQaFailureReport, qaVerdictOutput, remediationOutput } from "rafi-spec";
|
|
24
32
|
import { migrateQaHandback, registerHandbackWriter, reportOccurrenceId } from "./qaHandbackMigration.js";
|
|
25
|
-
import { providerSessionKey } from "./sessionIdentity.js";
|
|
33
|
+
import { providerSessionKey, canonicalSessionPath } from "./sessionIdentity.js";
|
|
26
34
|
import { captureFrozenQaSource } from "./qaSnapshot.js";
|
|
27
35
|
import { loadTicketsConfig, resolveTicketPaths } from "./tickets/config.js";
|
|
28
36
|
import { loadTickets } from "./tickets/ticketLoader.js";
|
|
29
37
|
import { processGroupQuiescent, taggedProcesses, classifyProcess, processStartIdentity } from "./processIdentity.js";
|
|
30
38
|
import { initialQaReducerState, qaDigest, reduceQaState, } from "./qaProtocolV2.js";
|
|
31
39
|
export const WORKFLOW_DB_FILE = ".rafi/recovery.sqlite3";
|
|
40
|
+
/** Role steps that change no project files, so an interrupted one can run
|
|
41
|
+
* again from its beginning with its original inputs. */
|
|
42
|
+
// QA preparation runs in a disposable snapshot, so its steps are restartable too.
|
|
43
|
+
const QA_PREPARATION_STEPS = ["qa-preparation-depth", "qa-preparation-draft", "qa-preparation-assessment", "qa-preparation-challenge"];
|
|
44
|
+
const RESTARTABLE_STEPS = {
|
|
45
|
+
builder: new Set(["build-preflight", "session-initialization", "handoff-acceptance", "builder-coverage", "branch-dependency-audit", "qa-contract-acceptance", "qa-contract-segment", "status", "escalation", ...QA_PREPARATION_STEPS]),
|
|
46
|
+
qa: new Set(["session-initialization", "handoff-acceptance", "qa-recovery-acceptance", "qa-contract-acceptance", "qa-contract-segment", ...QA_PREPARATION_STEPS]),
|
|
47
|
+
};
|
|
48
|
+
/** Journal runs for agent calls outside a build use these run-id prefixes. */
|
|
49
|
+
export const NON_BUILD_RUN_PREFIXES = ["operation:", "ticket-population:", "qa-"];
|
|
50
|
+
export function isNonBuildRunId(runId) {
|
|
51
|
+
return NON_BUILD_RUN_PREFIXES.some(prefix => runId.startsWith(prefix));
|
|
52
|
+
}
|
|
53
|
+
function defaultRunKind(runId) {
|
|
54
|
+
return isNonBuildRunId(runId) ? "operation" : "build";
|
|
55
|
+
}
|
|
32
56
|
/** Read the current project lease without creating or migrating recovery state. */
|
|
33
57
|
export function readCurrentWorkflowLease(projectDir) {
|
|
34
58
|
const path = join(resolve(projectDir), WORKFLOW_DB_FILE);
|
|
@@ -124,6 +148,8 @@ export class WorkflowDb {
|
|
|
124
148
|
if (existsSync(path)) {
|
|
125
149
|
const preview = new Database(path, { readonly: true, fileMustExist: true });
|
|
126
150
|
try {
|
|
151
|
+
if (readinessAccess)
|
|
152
|
+
assertStructuredCutoverComplete(preview);
|
|
127
153
|
checkBuildOwnershipSchema(preview);
|
|
128
154
|
}
|
|
129
155
|
finally {
|
|
@@ -154,10 +180,13 @@ export class WorkflowDb {
|
|
|
154
180
|
this.db.pragma("synchronous = FULL");
|
|
155
181
|
this.db.pragma("foreign_keys = ON");
|
|
156
182
|
try {
|
|
157
|
-
if (readinessAccess)
|
|
183
|
+
if (readinessAccess) {
|
|
184
|
+
assertStructuredCutoverComplete(this.db);
|
|
158
185
|
this.restrictReadinessConnection(readinessAccess);
|
|
186
|
+
}
|
|
159
187
|
else {
|
|
160
188
|
this.migrate();
|
|
189
|
+
this.applyStructuredCommunicationV2Cutover();
|
|
161
190
|
migrateQaPreparation(this.db);
|
|
162
191
|
migrateBuildAdmission(this.db);
|
|
163
192
|
this.importLegacyOnce();
|
|
@@ -201,6 +230,7 @@ export class WorkflowDb {
|
|
|
201
230
|
}
|
|
202
231
|
}
|
|
203
232
|
assertInstructionOwner(runId) {
|
|
233
|
+
this.assertNativeRunAdmission(runId);
|
|
204
234
|
if (this.writerAuthority) {
|
|
205
235
|
assertAdmission(this.db, this.writerAuthority);
|
|
206
236
|
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 +364,7 @@ export class WorkflowDb {
|
|
|
334
364
|
}
|
|
335
365
|
}
|
|
336
366
|
admittedWork(runId, workId) { return admittedWork(this.db, runId, workId); }
|
|
337
|
-
assertAdmittedWork(runId, workId) { return assertAdmittedWork(this.db, runId, workId); }
|
|
367
|
+
assertAdmittedWork(runId, workId) { this.assertNativeRunAdmission(runId); return assertAdmittedWork(this.db, runId, workId); }
|
|
338
368
|
/** Atomic local changes only; never await provider or filesystem work here. */
|
|
339
369
|
atomic(work) { return this.db.transaction(work).immediate(); }
|
|
340
370
|
buildAdmission() { return readAdmission(this.db); }
|
|
@@ -417,6 +447,7 @@ export class WorkflowDb {
|
|
|
417
447
|
throw new Error("Ownership migration is pending legacy execution cleanup; use rafi resume before starting work");
|
|
418
448
|
}
|
|
419
449
|
acquireBuildAdmission(runId, phase) {
|
|
450
|
+
this.assertNativeRunAdmission(runId);
|
|
420
451
|
return this.atomic(() => {
|
|
421
452
|
this.assertNoLegacyBuildOwner();
|
|
422
453
|
const lease = this.currentLease();
|
|
@@ -426,7 +457,10 @@ export class WorkflowDb {
|
|
|
426
457
|
if (owner.state.pid && classifyProcess(owner.state.pid, owner.state.processStart).state !== "dead")
|
|
427
458
|
throw new Error(`supervisor already active for project (run ${owner.runId})`);
|
|
428
459
|
}
|
|
429
|
-
|
|
460
|
+
// Single agent calls outside builds ("operation:<id>" runs: planning,
|
|
461
|
+
// ticket population, uninstall proposals) are read-only proposals; an
|
|
462
|
+
// unknown outcome there cannot conflict with a build.
|
|
463
|
+
const uncertain = this.db.prepare("SELECT run_id FROM operation_journal WHERE kind='provider-dispatch' AND status IN ('in_progress','uncertain') AND run_id NOT LIKE 'operation:%' AND run_id NOT LIKE 'ticket-population:%' AND run_id NOT LIKE 'qa-%' LIMIT 1").get();
|
|
430
464
|
if (uncertain)
|
|
431
465
|
throw new Error(`Build ${uncertain.run_id} has unresolved provider dispatch; reconcile before another launch`);
|
|
432
466
|
for (const row of this.db.prepare("SELECT DISTINCT run_id FROM build_owned_processes WHERE state<>'quiescent'").all()) {
|
|
@@ -447,10 +481,11 @@ export class WorkflowDb {
|
|
|
447
481
|
});
|
|
448
482
|
}
|
|
449
483
|
acquireBuildRecoveryAdmission(runId) {
|
|
484
|
+
this.assertNativeRunAdmission(runId);
|
|
450
485
|
return this.atomic(() => {
|
|
451
486
|
this.assertExecutionProtocol();
|
|
452
487
|
this.assertNoLegacyBuildOwner();
|
|
453
|
-
const otherDispatch = this.db.prepare("SELECT run_id FROM operation_journal WHERE run_id<>? AND kind='provider-dispatch' AND status IN ('in_progress','uncertain') LIMIT 1").get(runId);
|
|
488
|
+
const otherDispatch = this.db.prepare("SELECT run_id FROM operation_journal WHERE run_id<>? AND kind='provider-dispatch' AND status IN ('in_progress','uncertain') AND run_id NOT LIKE 'operation:%' AND run_id NOT LIKE 'ticket-population:%' AND run_id NOT LIKE 'qa-%' LIMIT 1").get(runId);
|
|
454
489
|
if (otherDispatch)
|
|
455
490
|
throw new Error(`Build ${otherDispatch.run_id} has unresolved provider dispatch; reconcile before starting another recovery workflow`);
|
|
456
491
|
const current = localBuildAuthority(this.projectDir);
|
|
@@ -812,7 +847,7 @@ export class WorkflowDb {
|
|
|
812
847
|
return { eligible: reasons.length === 0, reasons };
|
|
813
848
|
}
|
|
814
849
|
/** Ensure non-workflow build records can use the same durable event store. */
|
|
815
|
-
ensureRun(runId, kind =
|
|
850
|
+
ensureRun(runId, kind = defaultRunKind(runId), now = new Date()) {
|
|
816
851
|
const existing = this.getRun(runId);
|
|
817
852
|
if (existing)
|
|
818
853
|
return existing;
|
|
@@ -838,6 +873,11 @@ export class WorkflowDb {
|
|
|
838
873
|
originalWork: input.originalWork ?? {}, remainingWork: input.remainingWork ?? input.originalWork ?? {}, state: input.state ?? {},
|
|
839
874
|
legacy: Boolean(input.legacy), createdAt: at, updatedAt: at,
|
|
840
875
|
};
|
|
876
|
+
if (run.legacy) {
|
|
877
|
+
run.status = "superseded";
|
|
878
|
+
run.checkpoint = STRUCTURED_CUTOVER_REASON;
|
|
879
|
+
run.state = { ...run.state, structuredCommunicationCutover: { version: 2, reason: STRUCTURED_CUTOVER_REASON, at } };
|
|
880
|
+
}
|
|
841
881
|
this.db.transaction(() => {
|
|
842
882
|
if (this.writerAuthority)
|
|
843
883
|
assertAdmission(this.db, this.writerAuthority);
|
|
@@ -909,6 +949,7 @@ export class WorkflowDb {
|
|
|
909
949
|
});
|
|
910
950
|
}
|
|
911
951
|
transition(runId, update, now = new Date()) {
|
|
952
|
+
this.assertNativeRunAdmission(runId);
|
|
912
953
|
const at = now.toISOString();
|
|
913
954
|
return this.db.transaction(() => {
|
|
914
955
|
if (!this.coordinatorTransition && (this.writerAuthority || this.buildAdmission())) {
|
|
@@ -1036,11 +1077,33 @@ export class WorkflowDb {
|
|
|
1036
1077
|
this.assertGraphContinuityAccess(row.payload_json);
|
|
1037
1078
|
return rows.map(continuityEventFromRow);
|
|
1038
1079
|
}
|
|
1080
|
+
providerCheckpoint(dispatchId, publicationKind) {
|
|
1081
|
+
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);
|
|
1082
|
+
if (row)
|
|
1083
|
+
verifyCheckpointOrigin(this.db, this.projectDir, row.checkpoint_id);
|
|
1084
|
+
return row ? continuityCheckpointFromRow(row) : undefined;
|
|
1085
|
+
}
|
|
1039
1086
|
publishContinuityCheckpoint(input, now = new Date()) {
|
|
1040
1087
|
this.ensureRun(input.runId, "build", now);
|
|
1041
1088
|
return this.db.transaction(() => {
|
|
1042
1089
|
const at = now.toISOString();
|
|
1090
|
+
if (input.origin?.kind === "provider") {
|
|
1091
|
+
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=?")
|
|
1092
|
+
.get(input.runId, input.role, input.origin.dispatchId, input.origin.publicationKind);
|
|
1093
|
+
if (prior) {
|
|
1094
|
+
verifyCheckpointOrigin(this.db, this.projectDir, prior.checkpoint_id);
|
|
1095
|
+
if (canonicalJson(parseJson(prior.delta_json)) !== canonicalJson(input.delta))
|
|
1096
|
+
throw new Error("Conflicting repeated checkpoint publication");
|
|
1097
|
+
return continuityCheckpointFromRow(prior);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1043
1100
|
const previous = this.continuityHead(input.runId, input.role);
|
|
1101
|
+
let origin = input.origin;
|
|
1102
|
+
if (!origin) {
|
|
1103
|
+
const event = this.appendContinuityEvent({ runId: input.runId, role: "host", kind: input.hostEventKind ?? "host_checkpoint", authoritativeStateRevision: input.authoritativeStateRevision,
|
|
1104
|
+
payload: { checkpointDeltaDigest: createHash("sha256").update(json(sanitizeContinuityValue(input.delta))).digest("hex"), role: input.role } });
|
|
1105
|
+
origin = { kind: "host", eventSequence: event.sequence, inputCheckpointDigests: input.inputCheckpointDigests ?? (previous ? [previous.digest] : []) };
|
|
1106
|
+
}
|
|
1044
1107
|
const latestEvent = this.db.prepare("SELECT COALESCE(MAX(sequence),0) AS sequence FROM continuity_events WHERE run_id=?").get(input.runId);
|
|
1045
1108
|
const safeDelta = sanitizeContinuityValue(input.delta);
|
|
1046
1109
|
const graphAccess = currentGraphDerivedAccess();
|
|
@@ -1050,6 +1113,10 @@ export class WorkflowDb {
|
|
|
1050
1113
|
const digest = digestJson({ runId: input.runId, role: input.role, sequence: latestEvent.sequence, predecessorDigest: previous?.digest, delta: safeDelta, authoritativeStateRevision: input.authoritativeStateRevision, sessionKey: session.key });
|
|
1051
1114
|
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
1115
|
VALUES(?,?,?,?,?,?,?,?,?,?,?)`).run(input.runId, input.role, latestEvent.sequence, input.state ?? "current", json(safeDelta), digest, previous?.digest ?? null, input.authoritativeStateRevision, session.key, session.refJson, at);
|
|
1116
|
+
const checkpointId = Number(result.lastInsertRowid);
|
|
1117
|
+
this.db.prepare("INSERT INTO continuity_checkpoint_origins(checkpoint_id,run_id,origin_json,delta_digest,dispatch_id,publication_kind) VALUES(?,?,?,?,?,?)")
|
|
1118
|
+
.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);
|
|
1119
|
+
verifyCheckpointOrigin(this.db, this.projectDir, checkpointId);
|
|
1053
1120
|
const checkpoint = {
|
|
1054
1121
|
checkpointId: Number(result.lastInsertRowid), runId: input.runId, role: input.role,
|
|
1055
1122
|
sequence: latestEvent.sequence, state: input.state ?? "current", delta: safeDelta, digest,
|
|
@@ -1067,14 +1134,18 @@ export class WorkflowDb {
|
|
|
1067
1134
|
const rows = role
|
|
1068
1135
|
? this.db.prepare("SELECT * FROM continuity_checkpoints WHERE run_id=? AND role=? ORDER BY checkpoint_id").all(runId, role)
|
|
1069
1136
|
: this.db.prepare("SELECT * FROM continuity_checkpoints WHERE run_id=? ORDER BY checkpoint_id").all(runId);
|
|
1070
|
-
for (const row of rows)
|
|
1137
|
+
for (const row of rows) {
|
|
1071
1138
|
this.assertGraphContinuityAccess(row.delta_json);
|
|
1139
|
+
verifyCheckpointOrigin(this.db, this.projectDir, row.checkpoint_id);
|
|
1140
|
+
}
|
|
1072
1141
|
return rows.map(continuityCheckpointFromRow);
|
|
1073
1142
|
}
|
|
1074
1143
|
latestContinuityCheckpoint(runId, role) {
|
|
1075
1144
|
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)
|
|
1145
|
+
if (row) {
|
|
1077
1146
|
this.assertGraphContinuityAccess(row.delta_json);
|
|
1147
|
+
verifyCheckpointOrigin(this.db, this.projectDir, row.checkpoint_id);
|
|
1148
|
+
}
|
|
1078
1149
|
return row ? continuityCheckpointFromRow(row) : undefined;
|
|
1079
1150
|
}
|
|
1080
1151
|
assertGraphContinuityAccess(value) {
|
|
@@ -1084,6 +1155,8 @@ export class WorkflowDb {
|
|
|
1084
1155
|
}
|
|
1085
1156
|
continuityHead(runId, role) {
|
|
1086
1157
|
const row = this.db.prepare("SELECT * FROM continuity_heads WHERE run_id=? AND role=?").get(runId, role);
|
|
1158
|
+
if (row)
|
|
1159
|
+
verifyContinuityHeads(this.db, this.projectDir, runId);
|
|
1087
1160
|
return row ? continuityHeadFromRow(row) : undefined;
|
|
1088
1161
|
}
|
|
1089
1162
|
setContinuityHeadState(runId, role, state, now = new Date()) {
|
|
@@ -1231,6 +1304,11 @@ export class WorkflowDb {
|
|
|
1231
1304
|
return this.db.prepare("SELECT decision_json FROM human_decisions WHERE run_id=? AND status='pending' ORDER BY created_at").all(runId)
|
|
1232
1305
|
.map((row) => parseJson(row.decision_json));
|
|
1233
1306
|
}
|
|
1307
|
+
/** Answered decisions for a run, oldest first (e.g. answered with build:decide while no worker waited). */
|
|
1308
|
+
answeredHumanDecisions(runId) {
|
|
1309
|
+
return this.db.prepare("SELECT decision_json FROM human_decisions WHERE run_id=? AND status='answered' ORDER BY created_at").all(runId)
|
|
1310
|
+
.map((row) => parseJson(row.decision_json));
|
|
1311
|
+
}
|
|
1234
1312
|
decisionContinuationAvailable(runId, decisionId) {
|
|
1235
1313
|
const prior = this.operations(runId).filter(op => op.kind === "decision-continuation" && op.intent.decisionId === decisionId);
|
|
1236
1314
|
return prior.every(op => op.status === "failed" && op.result?.dispatchState === "not-sent");
|
|
@@ -1313,7 +1391,12 @@ export class WorkflowDb {
|
|
|
1313
1391
|
return row ? parseJson(row.state_json) : undefined;
|
|
1314
1392
|
}
|
|
1315
1393
|
planOperation(input, now = new Date()) {
|
|
1394
|
+
this.assertNativeRunAdmission(input.runId);
|
|
1316
1395
|
const at = now.toISOString();
|
|
1396
|
+
const run = this.getRun(input.runId);
|
|
1397
|
+
if (input.kind === "provider-dispatch" && run?.checkpoint === "structured-communication-v2-cutover") {
|
|
1398
|
+
throw new Error("This run was fenced by the structured-communication v2 cutover; start a new native dispatch");
|
|
1399
|
+
}
|
|
1317
1400
|
if (input.kind === "provider-dispatch" && this.unresolvedPreparationProcesses(input.runId).length)
|
|
1318
1401
|
throw new Error("Readiness cleanup must be reconciled before provider dispatch");
|
|
1319
1402
|
this.db.prepare(`INSERT INTO operation_journal(idempotency_key,run_id,kind,status,intent_json,created_at,updated_at)
|
|
@@ -1324,12 +1407,294 @@ export class WorkflowDb {
|
|
|
1324
1407
|
}
|
|
1325
1408
|
return operation;
|
|
1326
1409
|
}
|
|
1410
|
+
/** Seal a previously persisted non-authoritative preparation intent. This
|
|
1411
|
+
* is intentionally legal only before provider content dispatch begins. */
|
|
1412
|
+
sealPlannedOperationIntent(idempotencyKey, intent, now = new Date()) {
|
|
1413
|
+
const prior = this.operation(idempotencyKey);
|
|
1414
|
+
if (!prior || prior.status !== "planned" || prior.kind !== "provider-dispatch")
|
|
1415
|
+
throw new Error("Only a planned provider dispatch may be sealed");
|
|
1416
|
+
const preparing = prior.intent;
|
|
1417
|
+
if (preparing.protocolVersion !== 2 || preparing.state !== "preparing")
|
|
1418
|
+
throw new Error("Provider dispatch did not begin as a v2 preparation intent");
|
|
1419
|
+
this.db.prepare("UPDATE operation_journal SET intent_json=?,updated_at=? WHERE idempotency_key=? AND status='planned'")
|
|
1420
|
+
.run(json(intent), now.toISOString(), idempotencyKey);
|
|
1421
|
+
return this.operation(idempotencyKey);
|
|
1422
|
+
}
|
|
1423
|
+
/** Called in the gateway's sealing transaction, before the send capability exists. */
|
|
1424
|
+
sealStructuredOwnerDispatch(key, tuple, renderedInstruction) {
|
|
1425
|
+
const sealedDispatch = sealedDispatchRecord(tuple);
|
|
1426
|
+
const transportSession = this.operation(key)?.intent?.sessionRef;
|
|
1427
|
+
const matchesSession = (session) => Boolean(transportSession
|
|
1428
|
+
&& ["provider", "sessionId", "role", "stream", "generation", "cwd", "configRoot"].every(field => session[field] === transportSession[field]));
|
|
1429
|
+
const review = this.db.prepare("SELECT intent_json,status FROM qa_turns WHERE operation_id=?").get(key);
|
|
1430
|
+
if (review) {
|
|
1431
|
+
const intent = parseJson(review.intent_json);
|
|
1432
|
+
if (review.status !== "intended" || intent.sealedDispatch || !matchesSession(intent.providerSession) || intent.providerSession.sessionId !== tuple.sessionId || intent.providerSession.generation !== tuple.generation)
|
|
1433
|
+
throw new Error("QA review owner cannot seal a foreign or already dispatched session");
|
|
1434
|
+
this.db.prepare("UPDATE qa_turns SET intent_json=? WHERE operation_id=?").run(json({ ...intent, sealedDispatch }), key);
|
|
1435
|
+
}
|
|
1436
|
+
const delivery = this.db.prepare("SELECT record_json,status FROM qa_delivery_turns WHERE turn_record_id=?").get(key);
|
|
1437
|
+
if (delivery) {
|
|
1438
|
+
const turn = parseJson(delivery.record_json);
|
|
1439
|
+
if (delivery.status !== "intended" || turn.sealedDispatch || !matchesSession(turn.intendedSession) || turn.intendedSession.sessionId !== tuple.sessionId || turn.intendedSession.generation !== tuple.generation)
|
|
1440
|
+
throw new Error("QA remediation owner cannot seal a foreign or already dispatched session");
|
|
1441
|
+
this.db.prepare("UPDATE qa_delivery_turns SET record_json=? WHERE turn_record_id=?").run(json({ ...turn, sealedDispatch }), key);
|
|
1442
|
+
const attempt = this.db.prepare("SELECT record_json FROM qa_remediation_attempts WHERE attempt_id=?").get(turn.operationId);
|
|
1443
|
+
if (!attempt)
|
|
1444
|
+
throw new Error("Remediation dispatch has no durable owner attempt");
|
|
1445
|
+
const fields = parseJson(attempt.record_json);
|
|
1446
|
+
const dispatches = (fields.sealedDispatches ?? {});
|
|
1447
|
+
if (dispatches[key])
|
|
1448
|
+
throw new Error("Remediation dispatch already sealed");
|
|
1449
|
+
this.db.prepare("UPDATE qa_remediation_attempts SET record_json=? WHERE attempt_id=?").run(json({ ...fields, sealedDispatches: { ...dispatches, [key]: sealedDispatch } }), turn.operationId);
|
|
1450
|
+
}
|
|
1451
|
+
this.putEvidence("handoff", Buffer.from(renderedInstruction));
|
|
1452
|
+
}
|
|
1453
|
+
structuredOwnerDispatch(key) {
|
|
1454
|
+
const transport = this.operation(key)?.intent;
|
|
1455
|
+
const verifyOwner = (record) => {
|
|
1456
|
+
persistedDispatchTuple(record);
|
|
1457
|
+
if (json(transport?.sealedDispatch) !== json(record))
|
|
1458
|
+
throw new Error("Domain owner tuple differs from its transport intent");
|
|
1459
|
+
};
|
|
1460
|
+
const review = this.db.prepare("SELECT intent_json FROM qa_turns WHERE operation_id=?").get(key);
|
|
1461
|
+
if (review) {
|
|
1462
|
+
const record = parseJson(review.intent_json).sealedDispatch;
|
|
1463
|
+
if (!record)
|
|
1464
|
+
throw new Error("QA owner has no sealed dispatch");
|
|
1465
|
+
verifyOwner(record);
|
|
1466
|
+
return record;
|
|
1467
|
+
}
|
|
1468
|
+
const delivery = this.db.prepare("SELECT record_json FROM qa_delivery_turns WHERE turn_record_id=?").get(key);
|
|
1469
|
+
if (delivery) {
|
|
1470
|
+
const turn = parseJson(delivery.record_json);
|
|
1471
|
+
const record = turn.sealedDispatch;
|
|
1472
|
+
if (!record)
|
|
1473
|
+
throw new Error("Remediation owner has no sealed dispatch");
|
|
1474
|
+
verifyOwner(record);
|
|
1475
|
+
const attempt = this.db.prepare("SELECT record_json FROM qa_remediation_attempts WHERE attempt_id=?").get(turn.operationId);
|
|
1476
|
+
const sealed = attempt && parseJson(attempt.record_json).sealedDispatches?.[key];
|
|
1477
|
+
if (json(sealed) !== json(record))
|
|
1478
|
+
throw new Error("Remediation attempt tuple differs from its delivery intent");
|
|
1479
|
+
return record;
|
|
1480
|
+
}
|
|
1481
|
+
const intent = this.operation(key)?.intent;
|
|
1482
|
+
if (!intent?.sealedDispatch)
|
|
1483
|
+
throw new Error("Generic owner has no sealed dispatch");
|
|
1484
|
+
persistedDispatchTuple(intent.sealedDispatch);
|
|
1485
|
+
if (canonicalJson(intent.tuple) !== intent.sealedDispatch.tupleJson)
|
|
1486
|
+
throw new Error("Generic intent tuple differs from its sealed canonical bytes");
|
|
1487
|
+
return intent.sealedDispatch;
|
|
1488
|
+
}
|
|
1489
|
+
assertStructuredDispatchAdmission(runId, dispatchId, parentId, operation) {
|
|
1490
|
+
const processing = this.pendingStructuredPostprocessing(runId);
|
|
1491
|
+
const transfers = this.db.prepare("SELECT * FROM structured_handoff_lineage WHERE run_id=? AND state<>'completed'").all(runId);
|
|
1492
|
+
const linked = transfers.length === 1 && transfers[0].predecessor_id === parentId &&
|
|
1493
|
+
(operation === "handoff-acceptance" && transfers[0].state === "acceptance-pending" && !transfers[0].acceptance_id ||
|
|
1494
|
+
transfers[0].state === "continuation-planned" && transfers[0].continuation_id === dispatchId);
|
|
1495
|
+
if (processing.some(id => id !== parentId) || (processing.length || transfers.length) && !linked)
|
|
1496
|
+
throw new Error("Confirmed dispatch postprocessing or handoff is pending; reconcile before unrelated work");
|
|
1497
|
+
}
|
|
1498
|
+
beginStructuredHandoff(predecessorId) {
|
|
1499
|
+
const operation = this.operation(predecessorId);
|
|
1500
|
+
if (!operation || operation.status !== "confirmed")
|
|
1501
|
+
throw new Error("Handoff predecessor must be confirmed");
|
|
1502
|
+
const changed = this.db.prepare("INSERT OR IGNORE INTO structured_handoff_lineage(predecessor_id,run_id,state) VALUES(?,?,'acceptance-pending')").run(predecessorId, operation.runId);
|
|
1503
|
+
if (changed.changes !== 1)
|
|
1504
|
+
throw new Error("Handoff acceptance already started; reconcile persisted lineage before recovery");
|
|
1505
|
+
}
|
|
1506
|
+
linkStructuredHandoffDispatch(dispatchId, parentId, operation) {
|
|
1507
|
+
if (!parentId)
|
|
1508
|
+
return;
|
|
1509
|
+
const row = this.db.prepare("SELECT * FROM structured_handoff_lineage WHERE predecessor_id=?").get(parentId);
|
|
1510
|
+
if (!row)
|
|
1511
|
+
return;
|
|
1512
|
+
if (operation === "handoff-acceptance") {
|
|
1513
|
+
if (row.state !== "acceptance-pending" || row.acceptance_id)
|
|
1514
|
+
throw new Error("Handoff acceptance already dispatched");
|
|
1515
|
+
this.db.prepare("UPDATE structured_handoff_lineage SET acceptance_id=? WHERE predecessor_id=?").run(dispatchId, parentId);
|
|
1516
|
+
}
|
|
1517
|
+
else if (row.state !== "continuation-planned" || row.continuation_id !== dispatchId)
|
|
1518
|
+
throw new Error("Foreign handoff continuation");
|
|
1519
|
+
}
|
|
1520
|
+
planStructuredContinuation(predecessorId, successor) {
|
|
1521
|
+
return this.atomic(() => {
|
|
1522
|
+
const row = this.db.prepare("SELECT * FROM structured_handoff_lineage WHERE predecessor_id=?").get(predecessorId);
|
|
1523
|
+
if (!row || row.state !== "acceptance-pending" || !row.acceptance_id || this.operation(row.acceptance_id)?.status !== "confirmed")
|
|
1524
|
+
throw new Error("Successor lacks confirmed native handoff acceptance lineage");
|
|
1525
|
+
const origin = this.db.prepare("SELECT checkpoint_id FROM continuity_checkpoint_origins WHERE dispatch_id=? AND publication_kind='handoff_acceptance'").get(row.acceptance_id);
|
|
1526
|
+
if (!origin)
|
|
1527
|
+
throw new Error("Successor acceptance checkpoint is missing");
|
|
1528
|
+
verifyCheckpointOrigin(this.db, this.projectDir, origin.checkpoint_id);
|
|
1529
|
+
this.assertAcceptedStructuredSuccessor(row.run_id, origin.checkpoint_id, successor);
|
|
1530
|
+
const accepted = this.retainedStructuredTurn(row.acceptance_id).selectedDispatch;
|
|
1531
|
+
if (accepted.sessionId !== successor.sessionId || accepted.provider !== successor.provider)
|
|
1532
|
+
throw new Error("Successor differs from the accepted provider session");
|
|
1533
|
+
const continuationId = randomUUID();
|
|
1534
|
+
this.db.prepare("UPDATE structured_handoff_lineage SET state='continuation-planned',successor_json=?,continuation_id=? WHERE predecessor_id=?")
|
|
1535
|
+
.run(json(successor), continuationId, predecessorId);
|
|
1536
|
+
return continuationId;
|
|
1537
|
+
});
|
|
1538
|
+
}
|
|
1539
|
+
structuredContinuation(runId, role) {
|
|
1540
|
+
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);
|
|
1541
|
+
if (!row)
|
|
1542
|
+
return;
|
|
1543
|
+
const origin = this.db.prepare("SELECT checkpoint_id FROM continuity_checkpoint_origins WHERE dispatch_id=? AND publication_kind='handoff_acceptance'").get(row.acceptance_id);
|
|
1544
|
+
if (!origin)
|
|
1545
|
+
throw new Error("Handoff continuation lacks its accepted checkpoint");
|
|
1546
|
+
verifyCheckpointOrigin(this.db, this.projectDir, origin.checkpoint_id);
|
|
1547
|
+
this.assertAcceptedStructuredSuccessor(runId, origin.checkpoint_id, parseJson(row.successor_json));
|
|
1548
|
+
return { predecessorId: row.predecessor_id, continuationId: row.continuation_id, state: row.state, successor: parseJson(row.successor_json) };
|
|
1549
|
+
}
|
|
1550
|
+
finishStructuredContinuation(dispatchId) {
|
|
1551
|
+
if (this.operation(dispatchId)?.status !== "confirmed")
|
|
1552
|
+
throw new Error("Unconfirmed continuation cannot complete");
|
|
1553
|
+
this.db.prepare("UPDATE structured_handoff_lineage SET state='completed' WHERE continuation_id=?").run(dispatchId);
|
|
1554
|
+
}
|
|
1555
|
+
assertAcceptedStructuredSuccessor(runId, checkpointId, successor) {
|
|
1556
|
+
const handoff = this.db.prepare("SELECT acceptance_receipt_digest FROM handoffs WHERE run_id=? AND successor_session_key=? AND state='accepted'")
|
|
1557
|
+
.get(runId, providerSessionKey(successor));
|
|
1558
|
+
const checkpoint = this.db.prepare("SELECT digest FROM continuity_checkpoints WHERE checkpoint_id=? AND run_id=?").get(checkpointId, runId);
|
|
1559
|
+
const bytes = handoff?.acceptance_receipt_digest && this.getEvidence(handoff.acceptance_receipt_digest);
|
|
1560
|
+
if (!bytes || createHash("sha256").update(bytes).digest("hex") !== handoff.acceptance_receipt_digest)
|
|
1561
|
+
throw new Error("Successor lacks retained accepted ownership evidence");
|
|
1562
|
+
const receipt = parseJson(bytes.toString());
|
|
1563
|
+
if (receipt.runId !== runId || receipt.acceptanceCheckpointDigest !== checkpoint?.digest
|
|
1564
|
+
|| providerSessionKey(receipt.successorSessionRef) !== providerSessionKey(successor))
|
|
1565
|
+
throw new Error("Successor ownership differs from accepted checkpoint lineage");
|
|
1566
|
+
}
|
|
1567
|
+
requireStructuredPostprocessing(dispatchId, context) {
|
|
1568
|
+
const operation = this.operation(dispatchId);
|
|
1569
|
+
this.db.prepare("INSERT INTO structured_dispatch_processing(dispatch_id,run_id,state,context_json) VALUES(?,?,'pending',?)")
|
|
1570
|
+
.run(dispatchId, operation.runId, json(context));
|
|
1571
|
+
}
|
|
1572
|
+
structuredProcessingContext(dispatchId) {
|
|
1573
|
+
const row = this.db.prepare("SELECT context_json FROM structured_dispatch_processing WHERE dispatch_id=?").get(dispatchId);
|
|
1574
|
+
if (!row)
|
|
1575
|
+
throw new Error("Missing persisted post-confirmation intent");
|
|
1576
|
+
return parseJson(row.context_json);
|
|
1577
|
+
}
|
|
1578
|
+
failStructuredPostprocessing(dispatchId, error) {
|
|
1579
|
+
this.db.prepare("UPDATE structured_dispatch_processing SET error=? WHERE dispatch_id=?").run(error.slice(0, 4000), dispatchId);
|
|
1580
|
+
}
|
|
1581
|
+
completeStructuredPostprocessing(dispatchId) {
|
|
1582
|
+
if (this.operation(dispatchId)?.status !== "confirmed")
|
|
1583
|
+
throw new Error("Cannot process an unconfirmed dispatch");
|
|
1584
|
+
this.db.prepare("UPDATE structured_dispatch_processing SET state='completed',error=NULL WHERE dispatch_id=?").run(dispatchId);
|
|
1585
|
+
}
|
|
1586
|
+
pendingStructuredPostprocessing(runId) {
|
|
1587
|
+
return this.db.prepare("SELECT dispatch_id FROM structured_dispatch_processing WHERE run_id=? AND state='pending'").all(runId).map(row => row.dispatch_id);
|
|
1588
|
+
}
|
|
1589
|
+
retainedStructuredTurn(dispatchId) {
|
|
1590
|
+
const owner = this.structuredOwnerDispatch(dispatchId), tuple = persistedDispatchTuple(owner);
|
|
1591
|
+
const descriptor = persistedProviderWireContract(tuple);
|
|
1592
|
+
if (!descriptor)
|
|
1593
|
+
throw new Error("Missing historic dispatch descriptor");
|
|
1594
|
+
this.replayStructuredDispatch(dispatchId, descriptor.canonical);
|
|
1595
|
+
const result = this.operation(dispatchId).result;
|
|
1596
|
+
const retained = JSON.parse(this.getEvidence(result.responseDigest).toString());
|
|
1597
|
+
return { text: result.rawDigest ? this.getEvidence(result.rawDigest)?.toString() ?? "" : "", isError: false, numTurns: 1, costUsd: 0,
|
|
1598
|
+
turnId: retained.hostTurnId, selectedDispatch: tuple, structured: hydrateStructuredReceipt(retained, digest => this.getEvidence(digest)) ?? undefined };
|
|
1599
|
+
}
|
|
1600
|
+
/** A claim is never recycled, even if transport fails before writing bytes. */
|
|
1601
|
+
claimStructuredSubmission(tuple, session) {
|
|
1602
|
+
return this.atomic(() => {
|
|
1603
|
+
const operation = this.operation(tuple.dispatchId);
|
|
1604
|
+
if (!operation || operation.status !== "in_progress" || this.structuredOwnerDispatch(tuple.dispatchId).tupleJson !== canonicalJson(tuple))
|
|
1605
|
+
throw new Error("Cannot mint a send capability without a committed sealed owner intent");
|
|
1606
|
+
this.assertNativeRunAdmission(operation.runId);
|
|
1607
|
+
const frozen = operation.intent.sessionRef;
|
|
1608
|
+
if (!session || canonicalJson(frozen) !== canonicalJson(session))
|
|
1609
|
+
throw new Error("Submission claim requires the frozen provider session");
|
|
1610
|
+
const continuation = this.db.prepare("SELECT successor_json FROM structured_handoff_lineage WHERE continuation_id=? AND state='continuation-planned'").get(tuple.dispatchId);
|
|
1611
|
+
if (continuation && canonicalJson(parseJson(continuation.successor_json)) !== canonicalJson(session))
|
|
1612
|
+
throw new Error("Continuation session differs from accepted lineage");
|
|
1613
|
+
const claimId = randomUUID();
|
|
1614
|
+
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)
|
|
1615
|
+
VALUES(?,?,?,?,?,'claimed',?)`).run(tuple.dispatchId, operation.runId, tuple.digest, claimId, canonicalJson(session), new Date().toISOString());
|
|
1616
|
+
if (changed.changes !== 1)
|
|
1617
|
+
throw new Error("Structured dispatch already has a submission claim; reconcile before a new dispatch");
|
|
1618
|
+
this.db.prepare("UPDATE structured_handoff_lineage SET state='continuation-claimed' WHERE continuation_id=?").run(tuple.dispatchId);
|
|
1619
|
+
return claimId;
|
|
1620
|
+
});
|
|
1621
|
+
}
|
|
1622
|
+
consumeStructuredSubmission(tuple, claimId, session) {
|
|
1623
|
+
this.atomic(() => {
|
|
1624
|
+
const operation = this.operation(tuple.dispatchId);
|
|
1625
|
+
if (!operation || operation.status !== "in_progress" || this.structuredOwnerDispatch(tuple.dispatchId).tupleJson !== canonicalJson(tuple))
|
|
1626
|
+
throw new Error("Submission owner is no longer in progress or sealed");
|
|
1627
|
+
this.assertNativeRunAdmission(operation.runId);
|
|
1628
|
+
const frozen = operation.intent.sessionRef;
|
|
1629
|
+
if (!session || canonicalJson(frozen) !== canonicalJson(session))
|
|
1630
|
+
throw new Error("Submission session changed after sealing");
|
|
1631
|
+
const owner = this.roleMutationLease(operation.runId, session.role);
|
|
1632
|
+
if (!(tuple.operation === "handoff-acceptance" && operation.intent.purpose === "contract-acceptance") && owner && (owner.providerSessionId !== session.sessionId || owner.sessionKey && owner.sessionKey !== providerSessionKey(session))) {
|
|
1633
|
+
this.assertFreshRecoveryValidation(tuple.dispatchId, session);
|
|
1634
|
+
}
|
|
1635
|
+
const changed = this.db.prepare(`UPDATE structured_submission_claims SET state='consumed',consumed_at=?
|
|
1636
|
+
WHERE dispatch_id=? AND run_id=? AND tuple_digest=? AND claim_id=? AND session_json=? AND state='claimed'`)
|
|
1637
|
+
.run(new Date().toISOString(), tuple.dispatchId, operation.runId, tuple.digest, claimId, canonicalJson(session));
|
|
1638
|
+
if (changed.changes !== 1)
|
|
1639
|
+
throw new Error("Structured submission claim already consumed or revoked");
|
|
1640
|
+
});
|
|
1641
|
+
}
|
|
1642
|
+
assertNativeRunAdmission(runId) {
|
|
1643
|
+
assertStructuredCutoverComplete(this.db);
|
|
1644
|
+
const run = this.getRun(runId);
|
|
1645
|
+
if (run?.legacy || run?.checkpoint === STRUCTURED_CUTOVER_REASON || run?.state.structuredCommunicationCutover)
|
|
1646
|
+
throw new Error("structured-communication-v2-cutover: start a new native dispatch; this workflow cannot resume");
|
|
1647
|
+
}
|
|
1648
|
+
recordCommunicationEvidenceRefs(ownerId, digests) {
|
|
1649
|
+
for (const digest of new Set(digests))
|
|
1650
|
+
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());
|
|
1651
|
+
}
|
|
1652
|
+
replayStructuredDispatch(key, contract) {
|
|
1653
|
+
const operation = this.operation(key);
|
|
1654
|
+
const result = operation?.result;
|
|
1655
|
+
if (operation?.status !== "confirmed" || result?.validation !== "accepted" || !result.responseDigest)
|
|
1656
|
+
throw new Error("Structured dispatch has no confirmed persisted receipt");
|
|
1657
|
+
return this.verifyRetainedStructuredDispatch(key, contract, result.responseDigest);
|
|
1658
|
+
}
|
|
1659
|
+
assertStructuredProviderTurn(key, providerTurnId) {
|
|
1660
|
+
const result = this.operation(key)?.result;
|
|
1661
|
+
const bytes = result?.responseDigest ? this.getEvidence(result.responseDigest) : undefined;
|
|
1662
|
+
if (!bytes)
|
|
1663
|
+
throw new Error("Structured dispatch receipt evidence is missing");
|
|
1664
|
+
const retained = JSON.parse(bytes.toString());
|
|
1665
|
+
if (!providerTurnId || retained?.receipt?.state !== "candidate" || retained.hostTurnId !== providerTurnId)
|
|
1666
|
+
throw new Error("Domain receipt has a foreign native provider turn identity");
|
|
1667
|
+
}
|
|
1668
|
+
verifyRetainedStructuredDispatch(key, contract, responseDigest) {
|
|
1669
|
+
const readVerifiedEvidence = (digest) => {
|
|
1670
|
+
const bytes = this.getEvidence(digest);
|
|
1671
|
+
if (bytes && (!graphDerivedAllowed(this.db, this.projectDir, digest) || createHash("sha256").update(bytes).digest("hex") !== digest))
|
|
1672
|
+
throw new Error("Structured receipt evidence digest mismatch");
|
|
1673
|
+
return bytes;
|
|
1674
|
+
};
|
|
1675
|
+
const bytes = readVerifiedEvidence(responseDigest);
|
|
1676
|
+
if (!bytes)
|
|
1677
|
+
throw new Error("Structured dispatch receipt evidence is missing");
|
|
1678
|
+
const retained = JSON.parse(bytes.toString());
|
|
1679
|
+
const owner = this.structuredOwnerDispatch(key);
|
|
1680
|
+
if (retained.version !== 2 || retained.tupleDigest !== owner.tupleDigest)
|
|
1681
|
+
throw new Error("Stored receipt does not match persisted owner tuple");
|
|
1682
|
+
return verifyPersistedDispatch(contract, owner, hydrateStructuredReceipt(retained, readVerifiedEvidence));
|
|
1683
|
+
}
|
|
1327
1684
|
updateOperation(idempotencyKey, status, details = {}, now = new Date()) {
|
|
1328
1685
|
const prior = this.operation(idempotencyKey);
|
|
1329
1686
|
if (prior?.kind === "provider-dispatch" && status === "in_progress" && this.unresolvedPreparationProcesses(prior.runId).length)
|
|
1330
1687
|
throw new Error("Readiness cleanup must be reconciled before provider dispatch");
|
|
1331
1688
|
if (!prior)
|
|
1332
1689
|
throw new Error(`operation not found: ${idempotencyKey}`);
|
|
1690
|
+
if (prior.kind === "provider-dispatch" && status === "confirmed") {
|
|
1691
|
+
const owner = this.structuredOwnerDispatch(idempotencyKey), tuple = persistedDispatchTuple(owner);
|
|
1692
|
+
const descriptor = persistedProviderWireContract(tuple);
|
|
1693
|
+
const result = details.result;
|
|
1694
|
+
if (!descriptor || result?.validation !== "accepted" || !result.responseDigest)
|
|
1695
|
+
throw new Error("Provider confirmation requires verified persisted receipt evidence");
|
|
1696
|
+
this.verifyRetainedStructuredDispatch(idempotencyKey, descriptor.canonical, result.responseDigest);
|
|
1697
|
+
}
|
|
1333
1698
|
const allowed = {
|
|
1334
1699
|
planned: ["planned", "in_progress", "failed"],
|
|
1335
1700
|
in_progress: ["in_progress", "confirmed", "failed", "uncertain"],
|
|
@@ -1389,6 +1754,84 @@ export class WorkflowDb {
|
|
|
1389
1754
|
registerGraphDerived(this.db, bytes, graphAccess);
|
|
1390
1755
|
return digest;
|
|
1391
1756
|
}
|
|
1757
|
+
/** Create an append-only provider page artifact before its first dispatch. */
|
|
1758
|
+
populationPageRecords(artifactId) {
|
|
1759
|
+
return this.db.prepare("SELECT content FROM page_ledger_pages WHERE artifact_id=? ORDER BY ordinal").all(artifactId)
|
|
1760
|
+
.map(row => row.content.toString("utf8"));
|
|
1761
|
+
}
|
|
1762
|
+
populationPageLedger(artifactId) {
|
|
1763
|
+
const row = this.db.prepare("SELECT tuple_digest,state,page_count FROM page_ledgers WHERE artifact_id=?").get(artifactId);
|
|
1764
|
+
return row && { tupleDigest: row.tuple_digest, state: row.state, pageCount: row.page_count };
|
|
1765
|
+
}
|
|
1766
|
+
beginPageLedger(input, now = new Date()) {
|
|
1767
|
+
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))
|
|
1768
|
+
throw new Error("Invalid page ledger identity");
|
|
1769
|
+
this.ensureRun(input.runId);
|
|
1770
|
+
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)
|
|
1771
|
+
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());
|
|
1772
|
+
const row = this.db.prepare("SELECT run_id,lineage_id,tuple_digest,lease_id FROM page_ledgers WHERE artifact_id=?").get(input.artifactId);
|
|
1773
|
+
if (row.run_id !== input.runId || row.lineage_id !== input.lineageId || row.tuple_digest !== input.tupleDigest || row.lease_id !== input.leaseId)
|
|
1774
|
+
throw new Error("Page ledger identity collision");
|
|
1775
|
+
}
|
|
1776
|
+
/**
|
|
1777
|
+
* Atomically appends a page. Duplicate delivery is accepted only when every
|
|
1778
|
+
* immutable byte and linkage field agrees; a provider cannot fill gaps,
|
|
1779
|
+
* rewrite history, or append after the terminal page.
|
|
1780
|
+
*/
|
|
1781
|
+
appendPageLedger(input, now = new Date()) {
|
|
1782
|
+
if (!Number.isInteger(input.ordinal) || input.ordinal < 0 || input.ordinal >= 60)
|
|
1783
|
+
throw new Error("Page ordinal exceeds the 60-page limit");
|
|
1784
|
+
let canonical;
|
|
1785
|
+
let ticketCount;
|
|
1786
|
+
try {
|
|
1787
|
+
const parsed = JSON.parse(input.content);
|
|
1788
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
1789
|
+
throw new Error("page must be a JSON object");
|
|
1790
|
+
const tickets = parsed.tickets;
|
|
1791
|
+
if (!Array.isArray(tickets))
|
|
1792
|
+
throw new Error("page must contain tickets");
|
|
1793
|
+
ticketCount = tickets.length;
|
|
1794
|
+
canonical = canonicalJson(parsed);
|
|
1795
|
+
}
|
|
1796
|
+
catch (error) {
|
|
1797
|
+
throw new Error(`Page ledger content must be canonical ticket-page JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
1798
|
+
}
|
|
1799
|
+
if (canonical !== input.content)
|
|
1800
|
+
throw new Error("Page ledger content must use canonical JSON bytes");
|
|
1801
|
+
if (ticketCount > 150)
|
|
1802
|
+
throw new Error("Page exceeds the 150-ticket limit");
|
|
1803
|
+
const bytes = Buffer.byteLength(input.content);
|
|
1804
|
+
if (bytes > 750 * 1024)
|
|
1805
|
+
throw new Error("Page exceeds the 750 KiB limit");
|
|
1806
|
+
const digest = createHash("sha256").update(input.content).digest("hex");
|
|
1807
|
+
return this.atomic(() => {
|
|
1808
|
+
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);
|
|
1809
|
+
if (!header || header.lineage_id !== input.lineageId || header.tuple_digest !== input.tupleDigest || header.lease_id !== input.leaseId)
|
|
1810
|
+
throw new Error("Unknown or foreign page ledger");
|
|
1811
|
+
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);
|
|
1812
|
+
if (existing) {
|
|
1813
|
+
if (existing.terminal)
|
|
1814
|
+
throw new Error("Duplicate terminal page");
|
|
1815
|
+
if (existing.predecessor_digest === input.predecessorDigest && existing.content_digest === digest && existing.content.equals(Buffer.from(input.content)) && Boolean(existing.terminal) === input.terminal)
|
|
1816
|
+
return { digest, duplicate: true };
|
|
1817
|
+
throw new Error("Conflicting duplicate page");
|
|
1818
|
+
}
|
|
1819
|
+
if (header.state !== "open")
|
|
1820
|
+
throw new Error("Cannot append after terminal page");
|
|
1821
|
+
if (input.ordinal !== header.page_count)
|
|
1822
|
+
throw new Error("Page ordinal is out of order or has a gap");
|
|
1823
|
+
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);
|
|
1824
|
+
if ((prior?.content_digest ?? null) !== input.predecessorDigest)
|
|
1825
|
+
throw new Error("Page predecessor digest does not match");
|
|
1826
|
+
if (header.total_bytes + bytes > 6 * 1024 * 1024)
|
|
1827
|
+
throw new Error("Page ledger exceeds the 6 MiB aggregate limit");
|
|
1828
|
+
this.db.prepare("INSERT INTO page_ledger_pages(artifact_id,ordinal,predecessor_digest,content_digest,content,terminal,created_at) VALUES(?,?,?,?,?,?,?)")
|
|
1829
|
+
.run(input.artifactId, input.ordinal, input.predecessorDigest, digest, Buffer.from(input.content), input.terminal ? 1 : 0, now.toISOString());
|
|
1830
|
+
this.db.prepare("UPDATE page_ledgers SET page_count=?,total_bytes=?,state=?,updated_at=? WHERE artifact_id=?")
|
|
1831
|
+
.run(header.page_count + 1, header.total_bytes + bytes, input.terminal ? "terminal" : "open", now.toISOString(), input.artifactId);
|
|
1832
|
+
return { digest, duplicate: false };
|
|
1833
|
+
});
|
|
1834
|
+
}
|
|
1392
1835
|
registerGraphEvidence(value, access) { registerGraphDerived(this.db, value, access); }
|
|
1393
1836
|
getEvidence(digest) {
|
|
1394
1837
|
if (!graphDerivedAllowed(this.db, this.projectDir, digest))
|
|
@@ -1449,7 +1892,16 @@ export class WorkflowDb {
|
|
|
1449
1892
|
.run(next.status, next.reportDigest ?? null, json({ findingIds: next.findingIds, namespacedFindingIds: next.namespacedFindingIds, detail: next.detail }), next.updatedAt, attemptId);
|
|
1450
1893
|
const admission = this.admittedWork(next.runId, next.ticketId);
|
|
1451
1894
|
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 => {
|
|
1895
|
+
const terminalBlock = turns.some(row => {
|
|
1896
|
+
const receipt = parseJson(row.receipt_json);
|
|
1897
|
+
const response = receipt.structuredResponseDigest && this.getEvidence(receipt.structuredResponseDigest);
|
|
1898
|
+
if (!receipt.terminalEventObserved || !response)
|
|
1899
|
+
return false;
|
|
1900
|
+
// A retained response alone is not authority in v2: this legacy receipt
|
|
1901
|
+
// has no sealed tuple/descriptor record to verify. It may be inspected,
|
|
1902
|
+
// but cannot advance a review state.
|
|
1903
|
+
return false;
|
|
1904
|
+
});
|
|
1453
1905
|
if (admission && (next.status === "passed" || next.status === "failed" || terminalBlock))
|
|
1454
1906
|
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
1907
|
return next;
|
|
@@ -1489,8 +1941,9 @@ export class WorkflowDb {
|
|
|
1489
1941
|
if (!prior)
|
|
1490
1942
|
throw new Error(`QA remediation attempt not found: ${attemptId}`);
|
|
1491
1943
|
const next = { ...prior, ...patch, status, updatedAt: now.toISOString() };
|
|
1944
|
+
const raw = this.db.prepare("SELECT record_json FROM qa_remediation_attempts WHERE attempt_id=?").get(attemptId);
|
|
1492
1945
|
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);
|
|
1946
|
+
.run(status, next.responseDigest ?? null, next.summaryDigest ?? null, json({ ...parseJson(raw.record_json), detail: next.detail, recoveryAttemptId: next.recoveryAttemptId }), next.updatedAt, attemptId);
|
|
1494
1947
|
return next;
|
|
1495
1948
|
}
|
|
1496
1949
|
qaRemediationAttempts(runId, ticketId) {
|
|
@@ -1511,6 +1964,11 @@ export class WorkflowDb {
|
|
|
1511
1964
|
ensureQaGraphJournalGuards(this.db);
|
|
1512
1965
|
const prior = this.qaDeliveryTurns(turn.operationId).find(t => t.turnRecordId === turn.turnRecordId);
|
|
1513
1966
|
if (prior) {
|
|
1967
|
+
if (prior.sealedDispatch) {
|
|
1968
|
+
if (turn.sealedDispatch && json(turn.sealedDispatch) !== json(prior.sealedDispatch))
|
|
1969
|
+
throw new Error("Delivery owner tuple is immutable");
|
|
1970
|
+
turn.sealedDispatch = prior.sealedDispatch;
|
|
1971
|
+
}
|
|
1514
1972
|
if (prior.status !== "intended") {
|
|
1515
1973
|
if (json(prior) !== json(turn))
|
|
1516
1974
|
throw new Error(`conflicting completed delivery turn ${turn.turnRecordId}`);
|
|
@@ -1521,6 +1979,14 @@ export class WorkflowDb {
|
|
|
1521
1979
|
throw new Error(`delivery turn intent mismatch: ${key}`);
|
|
1522
1980
|
}
|
|
1523
1981
|
}
|
|
1982
|
+
if (turn.status === "completed") {
|
|
1983
|
+
const transport = this.operation(turn.turnRecordId);
|
|
1984
|
+
if (!prior?.sealedDispatch || json(turn.sealedDispatch) !== json(prior.sealedDispatch)
|
|
1985
|
+
|| turn.structuredResponseDigest !== transport?.result?.responseDigest)
|
|
1986
|
+
throw new Error("Completed delivery requires its persisted sealed native receipt");
|
|
1987
|
+
this.replayStructuredDispatch(turn.turnRecordId, remediationOutput);
|
|
1988
|
+
this.assertStructuredProviderTurn(turn.turnRecordId, turn.providerTurnId);
|
|
1989
|
+
}
|
|
1524
1990
|
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
1991
|
VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(turn_record_id) DO UPDATE SET status=excluded.status,record_json=excluded.record_json,updated_at=excluded.updated_at`)
|
|
1526
1992
|
.run(turn.turnRecordId, turn.operationId, turn.reportOccurrenceId, turn.turnIndex, turn.kind, turn.status, json(turn), turn.startedAt, turn.completedAt ?? turn.startedAt);
|
|
@@ -1646,6 +2112,7 @@ export class WorkflowDb {
|
|
|
1646
2112
|
}
|
|
1647
2113
|
/** Compare-and-swap the authoritative QA reducer head and append its event atomically. */
|
|
1648
2114
|
transitionQa(runId, ticketId, expectedRevision, event, now = new Date()) {
|
|
2115
|
+
this.assertNativeRunAdmission(runId);
|
|
1649
2116
|
this.ensureRun(runId);
|
|
1650
2117
|
return this.db.transaction(() => {
|
|
1651
2118
|
const current = this.qaTicketHead(runId, ticketId);
|
|
@@ -1734,7 +2201,18 @@ export class WorkflowDb {
|
|
|
1734
2201
|
const row = this.db.prepare("SELECT receipt_json FROM qa_handoffs WHERE operation_id=? AND status='accepted'").get(operationId);
|
|
1735
2202
|
return row?.receipt_json ? parseJson(row.receipt_json) : undefined;
|
|
1736
2203
|
}
|
|
2204
|
+
/**
|
|
2205
|
+
* Structured authority is admitted only after the one-way v2 cutover has
|
|
2206
|
+
* committed. Keeping this at the common admission boundary prevents a
|
|
2207
|
+
* caller that opens WorkflowDb directly from bypassing the migration.
|
|
2208
|
+
*/
|
|
2209
|
+
enableStructuredCommunication() {
|
|
2210
|
+
this.applyStructuredCommunicationV2Cutover();
|
|
2211
|
+
ensureQaGraphJournalGuards(this.db);
|
|
2212
|
+
}
|
|
1737
2213
|
beginQaTurn(intent) {
|
|
2214
|
+
if (intent.outputContract)
|
|
2215
|
+
this.enableStructuredCommunication();
|
|
1738
2216
|
const at = intent.intendedAt;
|
|
1739
2217
|
this.db.transaction(() => {
|
|
1740
2218
|
const existing = this.db.prepare("SELECT intent_json FROM qa_turns WHERE operation_id=?").get(intent.operationId);
|
|
@@ -1746,6 +2224,50 @@ export class WorkflowDb {
|
|
|
1746
2224
|
.run(intent.runId, intent.ticketId, intent.reviewNumber, intent.sessionGeneration, intent.slot, intent.operationId, at);
|
|
1747
2225
|
})();
|
|
1748
2226
|
}
|
|
2227
|
+
/** Retire this run's unresolved dispatches on a provider session the host
|
|
2228
|
+
* has just closed after a failed call. The closed provider can no longer
|
|
2229
|
+
* act, so the call's outcome is final: unknown, never replayed. */
|
|
2230
|
+
retireClosedSessionDispatches(runId, sessionId, reason) {
|
|
2231
|
+
if (!sessionId)
|
|
2232
|
+
return [];
|
|
2233
|
+
const dispatches = [...this.unresolvedRoleDispatches(runId, "builder"), ...this.unresolvedRoleDispatches(runId, "qa")]
|
|
2234
|
+
.filter(dispatch => dispatch.intent.sessionRef?.sessionId === sessionId);
|
|
2235
|
+
this.atomic(() => {
|
|
2236
|
+
for (const dispatch of dispatches)
|
|
2237
|
+
this.updateOperation(dispatch.idempotencyKey, "failed", { result: { recovery: "provider-closed", outcomeUnknown: true, providerReplayAuthorized: false, priorResult: dispatch.result }, error: reason });
|
|
2238
|
+
});
|
|
2239
|
+
return dispatches.map(dispatch => dispatch.idempotencyKey);
|
|
2240
|
+
}
|
|
2241
|
+
/** Retire interrupted role turns that are not implementation or bound QA
|
|
2242
|
+
* review work (for example build preflight, coverage, or handoff
|
|
2243
|
+
* acceptance) so the step restarts from its beginning. Implementation and
|
|
2244
|
+
* review turns keep their own recovery. The interrupted outcome stays unknown and is
|
|
2245
|
+
* never replayed or treated as a result. Returns the retired dispatch IDs. */
|
|
2246
|
+
reconcileInterruptedSteps(runId, role) {
|
|
2247
|
+
// Only steps that are safe to run again from their beginning. Implementation
|
|
2248
|
+
// turns, bound QA review turns and turns of unknown kind keep their own recovery.
|
|
2249
|
+
const reviewTurn = this.db.prepare("SELECT 1 FROM qa_turns WHERE operation_id=?");
|
|
2250
|
+
const dispatches = this.unresolvedRoleDispatches(runId, role).filter(dispatch => {
|
|
2251
|
+
const operation = String(dispatch.intent.operation ?? "");
|
|
2252
|
+
return RESTARTABLE_STEPS[role].has(operation) && (role === "builder" || !reviewTurn.get(dispatch.idempotencyKey));
|
|
2253
|
+
});
|
|
2254
|
+
if (!dispatches.length)
|
|
2255
|
+
return [];
|
|
2256
|
+
const lease = this.currentLease();
|
|
2257
|
+
if (lease?.runId !== runId || lease.pid !== process.pid)
|
|
2258
|
+
throw new Error("Step recovery requires the original build recovery lease");
|
|
2259
|
+
const worker = this.events(runId).filter(event => event.type === "worker_started").at(-1)?.payload;
|
|
2260
|
+
if (!worker?.workerPid || classifyProcess(worker.workerPid, worker.processStart, worker.host).state !== "dead" || !processGroupQuiescent(worker.workerPid)
|
|
2261
|
+
|| this.readinessProcesses().length)
|
|
2262
|
+
throw new Error("The interrupted step and all owned provider processes must be verified stopped before it restarts");
|
|
2263
|
+
const reconciliationId = `${role}-step-restart:${runId}:${randomUUID()}`;
|
|
2264
|
+
this.atomic(() => {
|
|
2265
|
+
for (const dispatch of dispatches)
|
|
2266
|
+
this.updateOperation(dispatch.idempotencyKey, "failed", { result: { recovery: "step-restart", reconciliationId, outcomeUnknown: true, providerReplayAuthorized: false, operation: dispatch.intent.operation ?? null, priorResult: dispatch.result }, error: "Interrupted step outcome remains unknown; the step restarts from its beginning with its original inputs" });
|
|
2267
|
+
this.appendContinuityEvent({ runId, role: "host", kind: "interrupted_step_restarted", payload: { role, reconciliationId, dispatchIds: dispatches.map(dispatch => dispatch.idempotencyKey), outcomeUnknown: true, providerReplayAuthorized: false }, authoritativeStateRevision: this.continuityHead(runId, "run")?.authoritativeStateRevision ?? 0 });
|
|
2268
|
+
});
|
|
2269
|
+
return dispatches.map(dispatch => dispatch.idempotencyKey);
|
|
2270
|
+
}
|
|
1749
2271
|
/** Retire an interrupted, proven read-only review transport, never a QA pass
|
|
1750
2272
|
* or Builder remediation. A fresh immutable review must still run afterward. */
|
|
1751
2273
|
reconcileInterruptedQaDispatch(runId, ticketId) {
|
|
@@ -1772,17 +2294,20 @@ export class WorkflowDb {
|
|
|
1772
2294
|
// A cancellation can have a durable host terminal receipt while the
|
|
1773
2295
|
// provider dispatch is still unknown. Exact frozen-turn bindings remain
|
|
1774
2296
|
// required; the old receipt is never promoted to an accepted QA result.
|
|
2297
|
+
// A QA turn and its provider dispatch share one ID: the turn's operation ID
|
|
2298
|
+
// is the dispatch's idempotency key. Instruction digests are not comparable
|
|
2299
|
+
// across the two records (the dispatch hashes the full rendered provider
|
|
2300
|
+
// instruction), so binding is by that shared ID.
|
|
2301
|
+
// A host-completed turn is recoverable only when its dispatch recorded a
|
|
2302
|
+
// typed unknown provider outcome.
|
|
1775
2303
|
const rows = this.db.prepare("SELECT status,intent_json FROM qa_turns WHERE run_id=? AND ticket_id=? AND status IN ('intended','uncertain','unknown','completed')").all(runId, ticketId);
|
|
1776
|
-
const turns = rows.flatMap(row => {
|
|
2304
|
+
const turns = new Map(rows.flatMap(row => {
|
|
1777
2305
|
const turn = parseJson(row.intent_json);
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
return result?.isError === true && result.failure?.dispatchState === "unknown"
|
|
1781
|
-
&& dispatch.intent.instructionDigest === turn.instructionDigest;
|
|
1782
|
-
}))
|
|
2306
|
+
const result = dispatches.find(dispatch => dispatch.idempotencyKey === turn.operationId)?.result;
|
|
2307
|
+
if (row.status === "completed" && !(result?.isError === true && result.failure?.dispatchState === "unknown"))
|
|
1783
2308
|
return [];
|
|
1784
|
-
return [turn];
|
|
1785
|
-
});
|
|
2309
|
+
return [[turn.operationId, turn]];
|
|
2310
|
+
}));
|
|
1786
2311
|
const bindings = dispatches.map(dispatch => {
|
|
1787
2312
|
const intent = dispatch.intent;
|
|
1788
2313
|
const ref = intent.sessionRef;
|
|
@@ -1790,9 +2315,10 @@ export class WorkflowDb {
|
|
|
1790
2315
|
|| !ref.configRoot || canonicalProject(ref.configRoot) !== canonicalProject(this.projectDir) || !ref.validatedAt || Number.isNaN(Date.parse(ref.validatedAt))
|
|
1791
2316
|
|| ref.source !== "observed" || providerSessionKey(ref) !== owner.sessionKey)
|
|
1792
2317
|
throw new Error("QA dispatch has no validated owner binding");
|
|
1793
|
-
const
|
|
1794
|
-
|
|
1795
|
-
&&
|
|
2318
|
+
const candidate = turns.get(dispatch.idempotencyKey);
|
|
2319
|
+
const turn = candidate && candidate.reviewNumber === head.reviewNumber
|
|
2320
|
+
&& candidate.providerSession.sessionId === ref.sessionId && candidate.providerSession.provider === ref.provider && candidate.providerSession.generation === ref.generation
|
|
2321
|
+
&& candidate.providerSession.cwd === ref.cwd && candidate.providerSession.configRoot === ref.configRoot ? candidate : undefined;
|
|
1796
2322
|
if (!turn || turn.sourceStateDigest !== head.sourceStateDigest || turn.reviewBasisDigest !== head.reviewBasisDigest)
|
|
1797
2323
|
throw new Error("QA dispatch does not match the frozen review turn");
|
|
1798
2324
|
const sessions = this.db.prepare("SELECT session_json FROM qa_sessions WHERE run_id=? AND ticket_id=? AND generation=? AND source_state_digest=? AND review_basis_digest=?").all(runId, ticketId, ref.generation, turn.sourceStateDigest, turn.reviewBasisDigest);
|
|
@@ -1839,9 +2365,24 @@ export class WorkflowDb {
|
|
|
1839
2365
|
}
|
|
1840
2366
|
finishQaTurn(receipt) {
|
|
1841
2367
|
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);
|
|
2368
|
+
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
2369
|
if (!prior)
|
|
1844
2370
|
throw new Error(`QA turn intent not found: ${receipt.operationId}`);
|
|
2371
|
+
const intent = parseJson(prior.intent_json);
|
|
2372
|
+
if (receipt.dispatch === "completed") {
|
|
2373
|
+
if (!intent.outputContract)
|
|
2374
|
+
throw new Error("Completed QA receipt requires its sealed native contract");
|
|
2375
|
+
const owner = this.structuredOwnerDispatch(receipt.operationId);
|
|
2376
|
+
if (json(receipt.sealedDispatch) !== json(owner))
|
|
2377
|
+
throw new Error("QA receipt differs from sealed owner intent");
|
|
2378
|
+
if (receipt.structuredResponseDigest !== this.operation(receipt.operationId)?.result?.responseDigest)
|
|
2379
|
+
throw new Error("QA receipt references foreign native evidence");
|
|
2380
|
+
const descriptor = persistedProviderWireContract(persistedDispatchTuple(owner));
|
|
2381
|
+
if (!descriptor)
|
|
2382
|
+
throw new Error("QA receipt contract is unavailable");
|
|
2383
|
+
this.replayStructuredDispatch(receipt.operationId, descriptor.canonical);
|
|
2384
|
+
this.assertStructuredProviderTurn(receipt.operationId, receipt.providerTurnId);
|
|
2385
|
+
}
|
|
1845
2386
|
if (prior.source_state_digest !== receipt.sourceStateDigest || prior.review_basis_digest !== receipt.reviewBasisDigest)
|
|
1846
2387
|
throw new Error("QA turn receipt binding mismatch");
|
|
1847
2388
|
if (prior.status === "completed") {
|
|
@@ -2000,6 +2541,13 @@ export class WorkflowDb {
|
|
|
2000
2541
|
const row = this.db.prepare("SELECT receipt_json FROM qa_remediation_receipts WHERE operation_id=?").get(operationId);
|
|
2001
2542
|
return row ? parseJson(row.receipt_json) : undefined;
|
|
2002
2543
|
}
|
|
2544
|
+
verifiedBuilderRemediationReceipt(operationId) {
|
|
2545
|
+
const receipt = this.builderRemediationReceipt(operationId);
|
|
2546
|
+
if (!receipt)
|
|
2547
|
+
return undefined;
|
|
2548
|
+
this.assertBuilderRemediationReceipt(receipt);
|
|
2549
|
+
return receipt;
|
|
2550
|
+
}
|
|
2003
2551
|
hasConfirmedQaRemediationForRecheck(runId, ticketId) {
|
|
2004
2552
|
const head = this.qaTicketHead(runId, ticketId);
|
|
2005
2553
|
const attempt = this.qaRemediationAttempts(runId, ticketId).at(-1);
|
|
@@ -2007,6 +2555,8 @@ export class WorkflowDb {
|
|
|
2007
2555
|
|| attempt.generation !== head.remediationGeneration || this.unresolvedRoleDispatches(runId, "builder").length)
|
|
2008
2556
|
return false;
|
|
2009
2557
|
const receipt = this.builderRemediationReceipt(attempt.attemptId);
|
|
2558
|
+
if (receipt)
|
|
2559
|
+
this.assertBuilderRemediationReceipt(receipt);
|
|
2010
2560
|
const review = this.qaReviewAttempt(attempt.reviewAttemptId);
|
|
2011
2561
|
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
2562
|
return Boolean(receipt && review && review.status === "failed" && receipt.runId === runId && receipt.ticketId === ticketId
|
|
@@ -2016,6 +2566,7 @@ export class WorkflowDb {
|
|
|
2016
2566
|
&& transition?.to_revision === head.revision && parseJson(transition.event_json).type === "remediation-received");
|
|
2017
2567
|
}
|
|
2018
2568
|
recordBuilderRemediationReceipt(receipt) {
|
|
2569
|
+
this.assertBuilderRemediationReceipt(receipt);
|
|
2019
2570
|
const text = json(receipt);
|
|
2020
2571
|
const existing = this.db.prepare("SELECT receipt_json FROM qa_remediation_receipts WHERE operation_id=?").get(receipt.operationId);
|
|
2021
2572
|
if (existing && json(parseJson(existing.receipt_json)) !== text)
|
|
@@ -2028,6 +2579,30 @@ export class WorkflowDb {
|
|
|
2028
2579
|
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
2580
|
.run(receipt.operationId, receipt.runId, receipt.ticketId, receipt.reportDigest, occurrence.reportOccurrenceId, text, receipt.completedAt);
|
|
2030
2581
|
}
|
|
2582
|
+
assertBuilderRemediationReceipt(receipt) {
|
|
2583
|
+
if (receipt.version !== 3)
|
|
2584
|
+
throw new Error("Legacy Builder receipt cannot authorize native remediation");
|
|
2585
|
+
this.assertNativeRunAdmission(receipt.runId);
|
|
2586
|
+
const attempt = this.qaRemediationAttempt(receipt.operationId);
|
|
2587
|
+
const review = attempt && this.qaReviewAttempt(attempt.reviewAttemptId);
|
|
2588
|
+
const turn = this.qaDeliveryTurns(receipt.operationId).find(value => value.turnRecordId === receipt.turnRecordId);
|
|
2589
|
+
if (!attempt || !review || !turn || turn.status !== "completed" || attempt.status !== "succeeded"
|
|
2590
|
+
|| attempt.runId !== receipt.runId || attempt.ticketId !== receipt.ticketId
|
|
2591
|
+
|| review.sourceDigest !== receipt.sourceStateDigest || review.reportDigest !== receipt.reportDigest
|
|
2592
|
+
|| attempt.requestDigest !== receipt.requestDigest || attempt.responseDigest !== receipt.responseDigest
|
|
2593
|
+
|| attempt.summaryDigest !== receipt.summaryDigest || turn.rawResponseDigest !== receipt.responseDigest
|
|
2594
|
+
|| turn.providerTurnId !== receipt.providerTurnId || turn.reportOccurrenceId !== receipt.reportOccurrenceId)
|
|
2595
|
+
throw new Error("Builder receipt differs from its persisted remediation owner");
|
|
2596
|
+
const transport = this.operation(turn.turnRecordId);
|
|
2597
|
+
if (turn.structuredResponseDigest !== transport?.result?.responseDigest)
|
|
2598
|
+
throw new Error("Builder receipt has a foreign native evidence reference");
|
|
2599
|
+
const result = this.replayStructuredDispatch(turn.turnRecordId, remediationOutput);
|
|
2600
|
+
this.assertStructuredProviderTurn(turn.turnRecordId, receipt.providerTurnId);
|
|
2601
|
+
const handoff = this.db.prepare("SELECT handoff_id FROM qa_failure_handoffs WHERE operation_id=?").get(receipt.operationId);
|
|
2602
|
+
if (result.result.kind !== "completed" || result.result.payload.status !== "done" || result.result.payload.report.handoff_id !== handoff?.handoff_id
|
|
2603
|
+
|| createHash("sha256").update(result.result.payload.report.summary).digest("hex") !== receipt.summaryDigest)
|
|
2604
|
+
throw new Error("Builder receipt requires its completed native remediation result and summary");
|
|
2605
|
+
}
|
|
2031
2606
|
commitBuilderRemediationSuccess(receipt, expectedRevision, now = new Date()) {
|
|
2032
2607
|
return this.db.transaction(() => {
|
|
2033
2608
|
this.recordBuilderRemediationReceipt(receipt);
|
|
@@ -2058,6 +2633,47 @@ export class WorkflowDb {
|
|
|
2058
2633
|
return this.transitionQa(remediation.runId, remediation.ticketId, head.revision, { type: input.outcome === "uncertain" ? "remediation-uncertain" : "remediation-failed" }, now);
|
|
2059
2634
|
})();
|
|
2060
2635
|
}
|
|
2636
|
+
assertNativeQaCertificate(input, phase, certificateId) {
|
|
2637
|
+
this.assertNativeRunAdmission(input.runId);
|
|
2638
|
+
const head = this.qaTicketHead(input.runId, input.ticketId);
|
|
2639
|
+
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'")
|
|
2640
|
+
.all(input.runId, input.ticketId, input.sourceStateDigest, input.reviewBasisDigest);
|
|
2641
|
+
const row = rows.find(row => {
|
|
2642
|
+
try {
|
|
2643
|
+
return qaDigest("turn-receipt", parseJson(row.receipt_json)) === input.turnReceiptDigest;
|
|
2644
|
+
}
|
|
2645
|
+
catch {
|
|
2646
|
+
return false;
|
|
2647
|
+
}
|
|
2648
|
+
});
|
|
2649
|
+
if (!row)
|
|
2650
|
+
throw new Error("Pass requires the persisted sealed native QA owner receipt");
|
|
2651
|
+
const receipt = parseJson(row.receipt_json);
|
|
2652
|
+
const intent = parseJson(row.intent_json);
|
|
2653
|
+
if (!receipt.terminalEventObserved || receipt.dispatch !== "completed" || receipt.operationId !== row.operation_id || intent.operationId !== row.operation_id ||
|
|
2654
|
+
intent.runId !== input.runId || intent.ticketId !== input.ticketId || intent.reviewNumber !== head.reviewNumber || intent.reviewNumber !== row.review_number || intent.sessionGeneration !== head.sessionGeneration ||
|
|
2655
|
+
intent.sourceStateDigest !== input.sourceStateDigest || intent.reviewBasisDigest !== input.reviewBasisDigest ||
|
|
2656
|
+
receipt.sourceStateDigest !== input.sourceStateDigest || receipt.reviewBasisDigest !== input.reviewBasisDigest ||
|
|
2657
|
+
!receipt.sealedDispatch || canonicalJson(receipt.sealedDispatch) !== canonicalJson(this.structuredOwnerDispatch(receipt.operationId)))
|
|
2658
|
+
throw new Error("Native pass owner/source/review binding mismatch");
|
|
2659
|
+
this.assertStructuredProviderTurn(receipt.operationId, receipt.providerTurnId);
|
|
2660
|
+
const verdict = this.replayStructuredDispatch(receipt.operationId, qaVerdictOutput);
|
|
2661
|
+
if (verdict.result.kind !== "completed" || verdict.result.payload.kind !== "passed")
|
|
2662
|
+
throw new Error("Pass requires a verified native passed verdict");
|
|
2663
|
+
const operation = this.operation(receipt.operationId);
|
|
2664
|
+
const tuple = persistedDispatchTuple(this.structuredOwnerDispatch(receipt.operationId));
|
|
2665
|
+
if (operation.runId !== input.runId || intent.providerSession.sessionId !== tuple.sessionId || intent.providerSession.generation !== tuple.generation || intent.providerSession.provider !== tuple.provider)
|
|
2666
|
+
throw new Error("Native pass session/tuple binding mismatch");
|
|
2667
|
+
const attempt = this.qaReviewAttempt(operation.intent.logicalActionId ?? "");
|
|
2668
|
+
if (operation.intent.logicalActionId && !attempt)
|
|
2669
|
+
throw new Error("Native pass review attempt is missing");
|
|
2670
|
+
if (attempt && (attempt.runId !== input.runId || attempt.ticketId !== input.ticketId || attempt.reviewNumber !== head.reviewNumber || attempt.sourceDigest !== input.sourceStateDigest || attempt.status !== "passed"))
|
|
2671
|
+
throw new Error("Native pass review attempt is not completed and bound");
|
|
2672
|
+
if (head.sourceStateDigest !== input.sourceStateDigest || head.reviewBasisDigest !== input.reviewBasisDigest ||
|
|
2673
|
+
(phase === "issue" ? head.state !== "turn-intended" || input.qaRevision !== head.revision + 1 :
|
|
2674
|
+
head.passCertificateId !== certificateId || (phase === "consume" ? head.state !== "passed" || input.qaRevision !== head.revision : head.state !== "finalizing" || input.qaRevision + 1 !== head.revision)))
|
|
2675
|
+
throw new Error("Native pass certificate does not match the reducer phase/revision");
|
|
2676
|
+
}
|
|
2061
2677
|
assertContractCertificate(input) {
|
|
2062
2678
|
const store = this.qaPreparationStore();
|
|
2063
2679
|
if (store.policy(input.runId)?.mode !== "enforce")
|
|
@@ -2065,6 +2681,7 @@ export class WorkflowDb {
|
|
|
2065
2681
|
const binding = input.contractCoverage;
|
|
2066
2682
|
if (!binding || binding.version !== 1)
|
|
2067
2683
|
throw new Error("Enforcing pass requires tagged contract coverage authority");
|
|
2684
|
+
const verifiedBinding = binding;
|
|
2068
2685
|
const admission = this.assertAdmittedWork(input.runId, input.ticketId);
|
|
2069
2686
|
const head = store.head(input.runId, input.ticketId, admission.requirementsDigest);
|
|
2070
2687
|
if (head.state !== "ready" || head.digest !== binding.contractDigest)
|
|
@@ -2085,17 +2702,17 @@ export class WorkflowDb {
|
|
|
2085
2702
|
const configuredMake = this.getRun(input.runId)?.state?.qa?.settings?.make;
|
|
2086
2703
|
if (configuredMake && resolveEffectiveQaConfiguration(this.projectDir, { make: configuredMake }).digest !== basis.contractBinding.commonConfigDigest)
|
|
2087
2704
|
throw new Error("Effective QA configuration changed before pass authority; reconcile contract");
|
|
2088
|
-
const attempt = this.qaReviewAttempt(
|
|
2705
|
+
const attempt = this.qaReviewAttempt(verifiedBinding.attemptId);
|
|
2089
2706
|
if (!attempt || attempt.runId !== input.runId || attempt.ticketId !== input.ticketId || attempt.sourceDigest !== input.sourceStateDigest || attempt.reviewNumber !== this.qaTicketHead(input.runId, input.ticketId).reviewNumber)
|
|
2090
2707
|
throw new Error("Coverage review attempt is stale or foreign");
|
|
2091
|
-
const coverage = store.artifact(
|
|
2708
|
+
const coverage = store.artifact(verifiedBinding.coverageDigest, "final-coverage");
|
|
2092
2709
|
for (const check of contract.checks)
|
|
2093
2710
|
for (const method of check.verification)
|
|
2094
2711
|
if (method.equivalentAuthorityId && !store.equivalentResolver()(method.equivalentAuthorityId, contract, check, method, input.sourceStateDigest))
|
|
2095
2712
|
throw new Error("Equivalent verification authority is stale or foreign");
|
|
2096
2713
|
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
2714
|
const initial = initialTurns.map(row => parseJson(row.intent_json)).find(turn => turn.slot === "initial");
|
|
2098
|
-
if (!initial || initial.providerSession.sessionId !==
|
|
2715
|
+
if (!initial || initial.providerSession.sessionId !== verifiedBinding.sessionId)
|
|
2099
2716
|
throw new Error("Coverage lacks the actual fresh review dispatch identity");
|
|
2100
2717
|
const dispatchedInstruction = this.getEvidence(initial.instructionDigest)?.toString();
|
|
2101
2718
|
const envelope = `\nImmutable input-basis digest: ${basis.digest}. Coverage must bind this inputBasisDigest.`;
|
|
@@ -2103,49 +2720,65 @@ export class WorkflowDb {
|
|
|
2103
2720
|
throw new Error("Actual review dispatch differs from frozen base instruction and tagged input-basis envelope");
|
|
2104
2721
|
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
2722
|
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
|
-
|
|
2723
|
+
const response = receipt?.structuredResponseDigest ? this.getEvidence(receipt.structuredResponseDigest) : undefined;
|
|
2724
|
+
if (!response || !receipt)
|
|
2725
|
+
throw new Error("Pass requires a retained native QA verdict receipt");
|
|
2726
|
+
const owner = this.structuredOwnerDispatch(receipt.operationId);
|
|
2727
|
+
if (json(receipt.sealedDispatch) !== json(owner))
|
|
2728
|
+
throw new Error("Pass receipt lacks its persisted v2 owner tuple");
|
|
2729
|
+
if (persistedDispatchTuple(owner).sessionId !== verifiedBinding.sessionId)
|
|
2730
|
+
throw new Error("Pass native receipt has a foreign session");
|
|
2731
|
+
const verdict = this.replayStructuredDispatch(receipt.operationId, qaVerdictOutput);
|
|
2732
|
+
if (verdict.result.kind !== "completed" || verdict.result.payload.kind !== "passed" || !verdict.result.payload.coverage)
|
|
2733
|
+
throw new Error("Pass requires complete coverage in the retained native passed verdict");
|
|
2734
|
+
const retainedProviderCoverage = bindCoverageHostFields(verdict.result.payload.coverage, contract, {
|
|
2735
|
+
phase: "qa", sourceDigest: input.sourceStateDigest, inputBasisDigest: input.reviewBasisDigest,
|
|
2736
|
+
attemptId: verifiedBinding.attemptId, sessionId: verifiedBinding.sessionId,
|
|
2737
|
+
});
|
|
2116
2738
|
let retainedCoverage = retainedProviderCoverage;
|
|
2117
2739
|
let retainedHostComparison;
|
|
2118
2740
|
const hostCheck = findHostQaCheck(contract);
|
|
2119
|
-
if (hostCheck) {
|
|
2741
|
+
if (hostCheck !== undefined) {
|
|
2120
2742
|
const hostDigest = hostComparisonDigestFromCoverage(coverage, hostCheck.id);
|
|
2121
2743
|
if (!hostDigest)
|
|
2122
2744
|
throw new Error("Coverage is missing the host-owned comparison artifact reference");
|
|
2123
|
-
const
|
|
2124
|
-
|
|
2745
|
+
const verifiedHostDigest = hostDigest;
|
|
2746
|
+
const hostResult = hostQaComparisonV1Schema.parse(store.artifact(verifiedHostDigest, "host-qa-comparison"));
|
|
2747
|
+
retainedHostComparison = { result: hostResult, resultDigest: verifiedHostDigest };
|
|
2125
2748
|
}
|
|
2749
|
+
const commandObservations = [
|
|
2750
|
+
...retainedQaCommandObservations(this.projectDir, input.runId, input.ticketId, false),
|
|
2751
|
+
...retainedHostBuilderObservations(this, input.runId, input.ticketId),
|
|
2752
|
+
];
|
|
2126
2753
|
const graphCheck = findGraphQaChecks(contract)[0];
|
|
2754
|
+
let retainedHostGraph;
|
|
2127
2755
|
if (graphCheck) {
|
|
2128
2756
|
const graphDigest = hostGraphApplicabilityDigestFromCoverage(coverage, graphCheck.id);
|
|
2129
2757
|
if (!graphDigest)
|
|
2130
2758
|
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 });
|
|
2759
|
+
const verifiedGraphDigest = graphDigest;
|
|
2760
|
+
const graphResult = hostGraphApplicabilityV1Schema.parse(store.artifact(verifiedGraphDigest, "host-graph-applicability"));
|
|
2761
|
+
retainedHostGraph = { result: graphResult, resultDigest: verifiedGraphDigest };
|
|
2139
2762
|
}
|
|
2763
|
+
if (retainedCoverage)
|
|
2764
|
+
retainedCoverage = assembleFinalQaCoverage({
|
|
2765
|
+
providerCoverage: retainedCoverage,
|
|
2766
|
+
contract,
|
|
2767
|
+
projectDir: this.projectDir,
|
|
2768
|
+
commandObservations,
|
|
2769
|
+
hostComparison: retainedHostComparison,
|
|
2770
|
+
hostGraph: retainedHostGraph,
|
|
2771
|
+
});
|
|
2140
2772
|
if (!retainedCoverage)
|
|
2141
2773
|
throw new Error("Coverage lacks the retained terminal review receipt and authorized host augmentation");
|
|
2142
|
-
const errors = validateContractCoverage(contract,
|
|
2774
|
+
const errors = validateContractCoverage(contract, retainedCoverage, { phase: "qa", sourceDigest: input.sourceStateDigest, inputBasisDigest: input.reviewBasisDigest, attemptId: verifiedBinding.attemptId, sessionId: verifiedBinding.sessionId });
|
|
2143
2775
|
if (errors.length)
|
|
2144
2776
|
throw new Error(errors.join("; "));
|
|
2145
2777
|
if (!receipt || !retainedCoverage || canonicalJson(retainedCoverage) !== canonicalJson(coverage))
|
|
2146
2778
|
throw new Error("Coverage is not bound to the retained terminal review receipt and authorized host augmentation");
|
|
2147
2779
|
}
|
|
2148
2780
|
issueQaPassCertificate(input, now = new Date()) {
|
|
2781
|
+
this.assertNativeQaCertificate(input, "issue");
|
|
2149
2782
|
this.assertContractCertificate(input);
|
|
2150
2783
|
if (this.unresolvedQaReports(input.runId, input.ticketId).length)
|
|
2151
2784
|
throw new Error("cannot issue QA pass certificate while reports remain unresolved");
|
|
@@ -2178,11 +2811,16 @@ export class WorkflowDb {
|
|
|
2178
2811
|
}
|
|
2179
2812
|
consumeQaPassCertificate(runId, ticketId, certificateId, consumer, now = new Date()) {
|
|
2180
2813
|
return this.db.transaction(() => {
|
|
2181
|
-
const row = this.db.prepare("SELECT
|
|
2814
|
+
const row = this.db.prepare("SELECT * FROM qa_pass_certificates WHERE certificate_id=? AND run_id=? AND ticket_id=?").get(certificateId, runId, ticketId);
|
|
2182
2815
|
if (!row)
|
|
2183
2816
|
throw new Error("QA pass certificate is missing or scoped to another run/ticket");
|
|
2184
2817
|
if (row.consumed_at)
|
|
2185
2818
|
throw new Error("QA pass certificate has already been consumed");
|
|
2819
|
+
const value = parseJson(row.certificate_json);
|
|
2820
|
+
if (value.certificateId !== row.certificate_id || value.runId !== runId || value.ticketId !== ticketId || value.qaRevision !== row.qa_revision ||
|
|
2821
|
+
value.sourceStateDigest !== row.source_state_digest || value.reviewBasisDigest !== row.review_basis_digest || value.turnReceiptDigest !== row.turn_receipt_digest || value.issuedAt !== row.issued_at)
|
|
2822
|
+
throw new Error("Pass certificate SQL identity mismatch");
|
|
2823
|
+
this.assertNativeQaCertificate(value, "consume", certificateId);
|
|
2186
2824
|
this.assertContractCertificate(parseJson(row.certificate_json));
|
|
2187
2825
|
if (this.unresolvedQaReports(runId, ticketId).length)
|
|
2188
2826
|
throw new Error("QA pass certificate cannot be consumed with unresolved reports");
|
|
@@ -2231,6 +2869,26 @@ export class WorkflowDb {
|
|
|
2231
2869
|
return next;
|
|
2232
2870
|
})();
|
|
2233
2871
|
}
|
|
2872
|
+
assertQaFinalizationAuthority(runId, ticketId) {
|
|
2873
|
+
const steps = this.qaFinalizationSteps(runId, ticketId).filter(step => step.status !== "invalidated");
|
|
2874
|
+
if (!steps.length)
|
|
2875
|
+
throw new Error("QA finalization intent is missing");
|
|
2876
|
+
for (const step of steps) {
|
|
2877
|
+
const row = this.db.prepare("SELECT * FROM qa_pass_certificates WHERE certificate_id=? AND run_id=? AND ticket_id=?")
|
|
2878
|
+
.get(step.certificateId, runId, ticketId);
|
|
2879
|
+
if (!row || !row.consumed_at || row.consumed_by !== step.kind)
|
|
2880
|
+
throw new Error("Finalization certificate consumer binding mismatch");
|
|
2881
|
+
const certificate = parseJson(row.certificate_json);
|
|
2882
|
+
if (certificate.consumedAt !== row.consumed_at || certificate.consumedBy !== row.consumed_by
|
|
2883
|
+
|| certificate.certificateId !== row.certificate_id || certificate.runId !== row.run_id || certificate.ticketId !== row.ticket_id
|
|
2884
|
+
|| certificate.qaRevision !== row.qa_revision || certificate.sourceStateDigest !== row.source_state_digest
|
|
2885
|
+
|| certificate.reviewBasisDigest !== row.review_basis_digest || certificate.turnReceiptDigest !== row.turn_receipt_digest
|
|
2886
|
+
|| certificate.issuedAt !== row.issued_at)
|
|
2887
|
+
throw new Error("Finalization certificate SQL identity mismatch");
|
|
2888
|
+
this.assertNativeQaCertificate(certificate, "resume", step.certificateId);
|
|
2889
|
+
this.assertContractCertificate(certificate);
|
|
2890
|
+
}
|
|
2891
|
+
}
|
|
2234
2892
|
planQaFinalizationStep(input, now = new Date()) {
|
|
2235
2893
|
const at = now.toISOString();
|
|
2236
2894
|
const intentText = json(input.intent);
|
|
@@ -2290,6 +2948,7 @@ export class WorkflowDb {
|
|
|
2290
2948
|
completeQaFinalization(runId, ticketId, expectedRevision, receipt, now = new Date()) {
|
|
2291
2949
|
return this.db.transaction(() => {
|
|
2292
2950
|
this.assertFinalizationControls(runId, ticketId);
|
|
2951
|
+
this.assertQaFinalizationAuthority(runId, ticketId);
|
|
2293
2952
|
const head = this.qaTicketHead(runId, ticketId);
|
|
2294
2953
|
if (head.revision !== expectedRevision || head.state !== "finalizing")
|
|
2295
2954
|
throw new Error(`QA finalization completion raced for ${runId}/${ticketId}`);
|
|
@@ -2550,6 +3209,31 @@ export class WorkflowDb {
|
|
|
2550
3209
|
: 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
3210
|
return row.count;
|
|
2552
3211
|
}
|
|
3212
|
+
freshRecoveryValidation(runId, role) {
|
|
3213
|
+
const decision = this.recoveryDecisions(runId).at(-1);
|
|
3214
|
+
const predecessor = this.roleMutationLease(runId, role);
|
|
3215
|
+
if (!decision || decision.mode !== "fresh-recovery-only" || decision.role !== role || !predecessor)
|
|
3216
|
+
throw new Error("Fresh recovery validation requires an explicit current recovery decision and predecessor lease");
|
|
3217
|
+
return { decisionDigest: digestJson(decision), predecessor };
|
|
3218
|
+
}
|
|
3219
|
+
/** This grants one response-only initialization, never ownership or business work. */
|
|
3220
|
+
assertFreshRecoveryValidation(dispatchId, session) {
|
|
3221
|
+
const operation = this.operation(dispatchId);
|
|
3222
|
+
const intent = operation?.intent;
|
|
3223
|
+
const authorization = intent?.freshRecoveryValidation;
|
|
3224
|
+
const decision = operation && this.recoveryDecisions(operation.runId).at(-1);
|
|
3225
|
+
if (!operation || !authorization || !decision || intent?.operation !== "session-initialization" || intent.responseOnly !== true ||
|
|
3226
|
+
canonicalJson(intent.sessionRef) !== canonicalJson(session) ||
|
|
3227
|
+
(session.role !== "builder" && session.role !== "qa") || decision.mode !== "fresh-recovery-only" || decision.role !== session.role ||
|
|
3228
|
+
authorization.decisionDigest !== digestJson(decision) || dispatchId !== `fresh-recovery:${authorization.decisionDigest}` ||
|
|
3229
|
+
canonicalJson(this.roleMutationLease(operation.runId, session.role)) !== canonicalJson(authorization.predecessor) ||
|
|
3230
|
+
decision.runId !== operation.runId || canonicalSessionPath(session.cwd) !== canonicalSessionPath(decision.worktree) ||
|
|
3231
|
+
session.provider !== (decision.requestedSuccessor?.agent ?? decision.settings.make) ||
|
|
3232
|
+
session.sessionId === authorization.predecessor.providerSessionId ||
|
|
3233
|
+
decision.predecessorSessionId && decision.predecessorSessionId !== authorization.predecessor.providerSessionId ||
|
|
3234
|
+
decision.predecessorSessionRef && providerSessionKey(decision.predecessorSessionRef) !== authorization.predecessor.sessionKey)
|
|
3235
|
+
throw new Error("Submission session no longer owns its role: fresh recovery decision or predecessor changed");
|
|
3236
|
+
}
|
|
2553
3237
|
recordRecoveryDecision(receipt) {
|
|
2554
3238
|
this.ensureRun(receipt.runId);
|
|
2555
3239
|
const digest = digestJson(receipt);
|
|
@@ -2728,12 +3412,12 @@ export class WorkflowDb {
|
|
|
2728
3412
|
parsed = { unreadable: true };
|
|
2729
3413
|
}
|
|
2730
3414
|
const record = parsed && typeof parsed === "object" ? parsed : undefined;
|
|
3415
|
+
if (record?.communicationProtocolVersion === 2)
|
|
3416
|
+
continue;
|
|
2731
3417
|
const isBuild = (record?.version === 1 || record?.version === 2) && typeof record.runId === "string";
|
|
2732
3418
|
const runId = isBuild ? String(record.runId) : `legacy_${digest.slice(0, 24)}`;
|
|
2733
3419
|
if (!this.getRun(runId)) {
|
|
2734
3420
|
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
3421
|
}
|
|
2738
3422
|
this.db.prepare("INSERT INTO legacy_imports(source_path,digest,run_id,imported_at) VALUES(?,?,?,?)").run(path, digest, runId, now.toISOString());
|
|
2739
3423
|
count += 1;
|
|
@@ -2861,6 +3545,119 @@ export class WorkflowDb {
|
|
|
2861
3545
|
this.db.prepare("INSERT INTO recovery_schema_migrations(migration,completed_at) VALUES(?,?)").run(`${migration}:${migrationDigest}`, new Date().toISOString());
|
|
2862
3546
|
migrateQaHandback(this.db);
|
|
2863
3547
|
}
|
|
3548
|
+
/**
|
|
3549
|
+
* One-way cutover fence. We deliberately do not attempt to convert an old
|
|
3550
|
+
* receipt into authority: any pre-existing run with legacy state or a
|
|
3551
|
+
* provider dispatch that lacks a sealed tuple is terminally superseded.
|
|
3552
|
+
* User/project artifacts remain in their own tables; only workflow authority
|
|
3553
|
+
* is fenced. The marker is committed atomically with the classification.
|
|
3554
|
+
*/
|
|
3555
|
+
applyStructuredCommunicationV2Cutover() {
|
|
3556
|
+
const migration = STRUCTURED_AUDIT_CLOSURE;
|
|
3557
|
+
this.db.transaction(() => {
|
|
3558
|
+
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);
|
|
3559
|
+
CREATE TABLE IF NOT EXISTS structured_cutover_quarantine(run_id TEXT NOT NULL,table_name TEXT NOT NULL,row_json TEXT NOT NULL);
|
|
3560
|
+
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);
|
|
3561
|
+
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));
|
|
3562
|
+
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);
|
|
3563
|
+
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);
|
|
3564
|
+
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));
|
|
3565
|
+
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);
|
|
3566
|
+
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));`);
|
|
3567
|
+
const prior = this.db.prepare("SELECT details_json FROM structured_communication_cutover WHERE migration=?").get(migration);
|
|
3568
|
+
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)
|
|
3569
|
+
return;
|
|
3570
|
+
// Upgrade only an unambiguous host baseline. Historical provider deltas
|
|
3571
|
+
// without an exact dispatch origin remain fenced; timestamps are not proof.
|
|
3572
|
+
for (const checkpoint of this.db.prepare(`SELECT c.* FROM continuity_checkpoints c JOIN workflow_runs r USING(run_id)
|
|
3573
|
+
WHERE json_valid(r.state_json) AND c.session_ref_json IS NULL
|
|
3574
|
+
AND NOT EXISTS(SELECT 1 FROM continuity_checkpoint_origins o WHERE o.checkpoint_id=c.checkpoint_id)`).all()) {
|
|
3575
|
+
let delta;
|
|
3576
|
+
try {
|
|
3577
|
+
delta = parseJson(checkpoint.delta_json);
|
|
3578
|
+
}
|
|
3579
|
+
catch {
|
|
3580
|
+
continue;
|
|
3581
|
+
}
|
|
3582
|
+
if (!delta || delta.version !== 1 || delta.nextAction !== "Await the first role action." ||
|
|
3583
|
+
[delta.decisions, delta.constraints, delta.discoveries, delta.completedActions, delta.evidence, delta.failures, delta.blockers, delta.openWork].some(items => !Array.isArray(items) || items.length))
|
|
3584
|
+
continue;
|
|
3585
|
+
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")
|
|
3586
|
+
.get(checkpoint.run_id, checkpoint.event_sequence);
|
|
3587
|
+
if (!baseline || JSON.parse(baseline.payload_json).role !== checkpoint.role)
|
|
3588
|
+
continue;
|
|
3589
|
+
const deltaDigest = createHash("sha256").update(checkpoint.delta_json).digest("hex");
|
|
3590
|
+
const event = this.appendContinuityEvent({ runId: checkpoint.run_id, role: "host", kind: "host_checkpoint", authoritativeStateRevision: checkpoint.authoritative_state_revision,
|
|
3591
|
+
payload: { checkpointDeltaDigest: deltaDigest, sourceBaselineSequence: baseline.sequence, role: checkpoint.role } });
|
|
3592
|
+
this.db.prepare("INSERT INTO continuity_checkpoint_origins(checkpoint_id,run_id,origin_json,delta_digest) VALUES(?,?,?,?)")
|
|
3593
|
+
.run(checkpoint.checkpoint_id, checkpoint.run_id, json({ kind: "host", eventSequence: event.sequence, inputCheckpointDigests: [] }), deltaDigest);
|
|
3594
|
+
}
|
|
3595
|
+
const matrix = cutoverOwnershipMatrix(this.db);
|
|
3596
|
+
const fileReferences = communicationFileEvidenceReferences(this.projectDir);
|
|
3597
|
+
const affected = this.db.prepare("SELECT * FROM workflow_runs").all().filter(run => invalidStructuredRun(this.db, run, this.projectDir));
|
|
3598
|
+
const lease = this.db.prepare("SELECT run_id,pid,process_start,host FROM project_lease WHERE singleton=1").get();
|
|
3599
|
+
if (lease && affected.some(run => run.run_id === lease.run_id) && classifyProcess(lease.pid, lease.process_start, lease.host).state !== "dead")
|
|
3600
|
+
throw new Error("v2-cutover-pending: stop the existing workflow owner before structured communication cutover");
|
|
3601
|
+
if (this.db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='build_owned_processes'").get()) {
|
|
3602
|
+
const processes = this.db.prepare("SELECT run_id,pid,process_start,host FROM build_owned_processes WHERE state<>'quiescent'").all();
|
|
3603
|
+
if (processes.some(process => affected.some(run => run.run_id === process.run_id)
|
|
3604
|
+
&& (!process.pid || !process.process_start || classifyProcess(process.pid, process.process_start, process.host).state !== "dead")))
|
|
3605
|
+
throw new Error("v2-cutover-pending: reconcile owned build processes before structured communication cutover");
|
|
3606
|
+
}
|
|
3607
|
+
if (this.db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='build_admission'").get()) {
|
|
3608
|
+
const row = this.db.prepare("SELECT record_json FROM build_admission WHERE singleton=1").get();
|
|
3609
|
+
if (row) {
|
|
3610
|
+
let admission;
|
|
3611
|
+
try {
|
|
3612
|
+
admission = JSON.parse(row.record_json);
|
|
3613
|
+
}
|
|
3614
|
+
catch {
|
|
3615
|
+
throw new Error("v2-cutover-pending: malformed build admission requires reconciliation");
|
|
3616
|
+
}
|
|
3617
|
+
if (affected.some(run => run.run_id === admission.runId) && classifyProcess(admission.pid, admission.processStart, admission.host).state !== "dead")
|
|
3618
|
+
throw new Error("v2-cutover-pending: stop the existing build owner before structured communication cutover");
|
|
3619
|
+
}
|
|
3620
|
+
}
|
|
3621
|
+
// Existing domain guards must not block the authorized cutover. Restore
|
|
3622
|
+
// them in this same transaction, omitting only superseded v1 comms guards.
|
|
3623
|
+
const triggers = this.db.prepare("SELECT name,sql FROM sqlite_master WHERE type='trigger' AND sql IS NOT NULL").all();
|
|
3624
|
+
for (const trigger of triggers) {
|
|
3625
|
+
if (!/^[a-zA-Z_][a-zA-Z_0-9]*$/.test(trigger.name))
|
|
3626
|
+
throw new Error("Unexpected cutover trigger identity");
|
|
3627
|
+
this.db.exec(`DROP TRIGGER "${trigger.name}"`);
|
|
3628
|
+
}
|
|
3629
|
+
const now = new Date().toISOString();
|
|
3630
|
+
for (const { run_id } of affected) {
|
|
3631
|
+
const row = this.db.prepare("SELECT state_json FROM workflow_runs WHERE run_id=?").get(run_id);
|
|
3632
|
+
const originalBytesDigest = createHash("sha256").update(row.state_json).digest("hex");
|
|
3633
|
+
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 }));
|
|
3634
|
+
let original = {};
|
|
3635
|
+
try {
|
|
3636
|
+
const parsed = parseJson(row.state_json);
|
|
3637
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
|
|
3638
|
+
original = parsed;
|
|
3639
|
+
}
|
|
3640
|
+
catch { /* quarantined verbatim above */ }
|
|
3641
|
+
const state = { ...original, structuredCommunicationCutover: { version: 2, reason: "structured-communication-v2-cutover", originalBytesDigest, at: now } };
|
|
3642
|
+
purgeStructuredRun(this.db, run_id, matrix, fileReferences);
|
|
3643
|
+
this.db.prepare("UPDATE workflow_runs SET status='superseded',checkpoint='structured-communication-v2-cutover',state_json=?,updated_at=? WHERE run_id=?")
|
|
3644
|
+
.run(json(state), now, run_id);
|
|
3645
|
+
}
|
|
3646
|
+
quarantineCommunicationFiles(this.projectDir, this.db);
|
|
3647
|
+
for (const trigger of triggers)
|
|
3648
|
+
if (!trigger.name.startsWith("structured_comms_v1_"))
|
|
3649
|
+
this.db.exec(trigger.sql);
|
|
3650
|
+
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")
|
|
3651
|
+
.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" }));
|
|
3652
|
+
this.db.prepare("INSERT OR IGNORE INTO structured_communication_cutover(migration,completed_at,details_json) VALUES(?,?,?)")
|
|
3653
|
+
.run("003_structured_communication_v2_cutover", now, json({ ownershipMatrixVersion: STRUCTURED_CUTOVER_MATRIX_VERSION }));
|
|
3654
|
+
ensureQaGraphJournalGuards(this.db);
|
|
3655
|
+
this.db.exec(`CREATE TRIGGER IF NOT EXISTS structured_cutover_terminal_run BEFORE UPDATE ON workflow_runs
|
|
3656
|
+
WHEN OLD.checkpoint='structured-communication-v2-cutover'
|
|
3657
|
+
BEGIN SELECT CASE WHEN NEW.status<>'superseded' OR NEW.checkpoint<>OLD.checkpoint OR NEW.state_json<>OLD.state_json
|
|
3658
|
+
THEN RAISE(ABORT,'structured-communication-v2-cutover: workflow permanently non-resumable') END; END;`);
|
|
3659
|
+
})();
|
|
3660
|
+
}
|
|
2864
3661
|
ensureColumn(table, column, definition) {
|
|
2865
3662
|
const columns = this.db.prepare(`PRAGMA table_info(${table})`).all();
|
|
2866
3663
|
if (!columns.some((existing) => existing.name === column)) {
|
|
@@ -2912,11 +3709,5 @@ function leaseVerifiedLive(lease, now, staleMs) {
|
|
|
2912
3709
|
return classifyProcess(lease.pid, lease.processStart, lease.host).state !== "dead";
|
|
2913
3710
|
}
|
|
2914
3711
|
function ensureRecoveryGitignore(projectDir) {
|
|
2915
|
-
|
|
2916
|
-
const path = existsSync(localExclude) ? localExclude : join(projectDir, ".gitignore");
|
|
2917
|
-
const entries = [WORKFLOW_DB_FILE, `${WORKFLOW_DB_FILE}-wal`, `${WORKFLOW_DB_FILE}-shm`, ".rafi/cache/handoffs/", ".rafi/backups/work-admission-v4/"];
|
|
2918
|
-
const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
2919
|
-
const missing = entries.filter((entry) => !existing.split(/\r?\n/).includes(entry));
|
|
2920
|
-
if (missing.length)
|
|
2921
|
-
appendFileSync(path, `${existing && !existing.endsWith("\n") ? "\n" : ""}${missing.join("\n")}\n`, "utf8");
|
|
3712
|
+
ensureLocalGitExcludes(projectDir, [WORKFLOW_DB_FILE, `${WORKFLOW_DB_FILE}-wal`, `${WORKFLOW_DB_FILE}-shm`, ".rafi/cache/handoffs/", ".rafi/backups/work-admission-v4/"], { fallbackToGitignore: true });
|
|
2922
3713
|
}
|