test(harness): support gated live investigation continuation

This commit is contained in:
2026-08-18 06:48:40 +01:00
parent 7dd4a956fb
commit 600b07d820
4 changed files with 693 additions and 15 deletions
+262 -1
View File
@@ -37,6 +37,53 @@ const config = {
// ── Mode selector ────────────────────────────────────────
const fixtureMode = process.env.FIXTURE_MODE;
/**
* Resolve the continuation state file path.
* CONTINUATION_FILE (env) overrides the default location.
*/
function resolveContinuationPath() {
const envPath = process.env.CONTINUATION_FILE;
if (envPath && String(envPath).trim() !== "") {
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", envPath);
}
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".evidence-temp", "continuation-start-only.json");
}
/**
* Persist the exact Start response fields needed by the Update request path.
*/
async function writeContinuationState(continuationPath, startResponse) {
const state = {
version: 1,
situationGraph: startResponse.situationGraph,
selectedQuestion: startResponse.selectedQuestion,
};
// Ensure parent directory exists
const dir = path.dirname(continuationPath);
await fs.promises.mkdir(dir, { recursive: true });
await fs.promises.writeFile(continuationPath, JSON.stringify(state, null, 2), "utf-8");
return state;
}
/**
* Load the persisted Start continuation state.
*/
function readContinuationState(continuationPath) {
const raw = fs.readFileSync(continuationPath, "utf-8");
const state = JSON.parse(raw);
if (!state.situationGraph) {
throw new Error("Invalid continuation file: missing situationGraph");
}
if (!state.selectedQuestion) {
throw new Error("Invalid continuation file: missing selectedQuestion");
}
return state;
}
// ── Call accounting (reflects actual API calls, not successes) ──
const calls = { startCalls: 0, updateCalls: 0 };
@@ -66,8 +113,20 @@ async function main() {
return;
}
if (fixtureMode === "startOnly") {
await runStartOnlyMode();
reportCallAccounting();
return;
}
if (fixtureMode === "continueOneUpdate") {
await runContinueOneUpdateMode();
reportCallAccounting();
return;
}
if (fixtureMode !== undefined) {
console.error(`ERROR: Unsupported FIXTURE_MODE="${fixtureMode}". Use "updateOnly" or unset.`);
console.error(`ERROR: Unsupported FIXTURE_MODE="${fixtureMode}". Use "updateOnly", "startOnly", "continueOneUpdate" or unset.`);
process.exitCode = 1;
return;
}
@@ -367,6 +426,208 @@ async function runUpdateOnlyMode() {
// ── UPDATE-ONLY EXIT — no retry, no additional calls ───
}
// ── Gated apparatus: start-only mode ─────────────────────
/**
* Phase 1 of the gated investigation apparatus.
* Makes exactly one Start call, persists the continuation state to disk,
* and exits with zero Update requests issued.
*/
async function runStartOnlyMode() {
const continuationPath = resolveContinuationPath();
if (!config.scenario || String(config.scenario).trim() === "") {
console.log("BLOCKED - missing scenario");
return; // zero live calls made
}
// ── Exactly one Start ────────────────────────────────
const startResult = await postJson("/api/cases/start", { scenario: config.scenario });
calls.startCalls++;
if (!startResult.json?.success) {
console.log("=== START ===");
console.log(`HTTP status: ${startResult.status}`);
console.log(`errors: ${JSON.stringify(startResult.json?.errors ?? startResult.json?.message ?? null)}`);
return;
}
const situationGraph = startResult.json.situationGraph;
let selectedQuestion = startResult.json.selectedQuestion?.question ?? null;
console.log("=== START (startOnly mode) ===");
console.log(`HTTP status: ${startResult.status}`);
console.log(`stage: ${startResult.json.stage ?? "unknown"}`);
console.log(`selected question: ${JSON.stringify(selectedQuestion)}`);
console.log(`node count: ${nodeCount(situationGraph)}`);
console.log(`edge count: ${edgeCount(situationGraph)}`);
if (!selectedQuestion) {
console.log("ERROR: No selected question returned from start. Cannot persist continuation state.");
process.exitCode = 1;
return;
}
// ── Persist exact continuation state ─────────────────
const state = await writeContinuationState(continuationPath, {
situationGraph,
selectedQuestion: startResult.json.selectedQuestion,
});
console.log(`\n=== CONTINUATION STATE WRITTEN ===`);
console.log(`path: ${continuationPath}`);
console.log(`graph nodes: ${state.situationGraph.nodes.length}`);
console.log(`graph edges: ${state.situationGraph.edges.length}`);
console.log(`selectedQuestion: ${JSON.stringify(state.selectedQuestion)}`);
if (state.situationGraph.activeUnknownNodeId !== undefined && state.situationGraph.activeUnknownNodeId !== null) {
console.log(`activeUnknownNodeId: ${state.situationGraph.activeUnknownNodeId}`);
}
// No Updates issued — this is the gate boundary.
}
// ── Gated apparatus: continue one update ─────────────────
/**
* Phase 2 of the gated investigation apparatus.
* Loads a persisted Start continuation file, requires an explicit answer,
* and makes exactly one Update against that preserved state.
*/
async function runContinueOneUpdateMode() {
const continuationPath = resolveContinuationPath();
const answer = process.env.CONTINUATION_ANSWER;
// ── Block on missing explicit answer before any file/HTTP work ──
if (!answer || String(answer).trim() === "") {
console.log("BLOCKED - missing CONTINUATION_ANSWER");
return; // zero live calls made
}
// ── Load persisted continuation state ────────────────
let continuationState;
try {
continuationState = readContinuationState(continuationPath);
} catch (err) {
console.error(`ERROR: Cannot load continuation state from ${continuationPath}: ${err.message}`);
process.exitCode = 1;
return; // zero live calls made
}
const situationGraph = JSON.parse(JSON.stringify(continuationState.situationGraph));
const selectedQuestion = continuationState.selectedQuestion;
console.log(`\n=== CONTINUE-ONE-UPDATE MODE ===`);
console.log(`continuation file: ${continuationPath}`);
console.log(`graph nodes (from captured Start): ${situationGraph.nodes.length}`);
console.log(`graph edges (from captured Start): ${situationGraph.edges.length}`);
// Verify persisted state integrity
if (!selectedQuestion) {
console.log("ERROR: Continuation file has no selectedQuestion — cannot determine the investigation gate.");
process.exitCode = 1;
return;
}
// ── Exactly one Update using preserved state + explicit answer ──
const updateNum = 1;
let updateResult = await postJson("/api/cases/update", {
situationGraph,
previousQuestion: selectedQuestion?.question ?? null,
answer: String(answer),
});
calls.updateCalls++;
if (!updateResult.json?.success) {
console.log(`\n=== UPDATE ${updateNum} ===`);
console.log(`HTTP status: ${updateResult.status}`);
console.log(`stage: ${updateResult.json?.stage ?? "unknown"}`);
console.log(`selected question: null`);
console.log(`node count: ${nodeCount(situationGraph)}`);
console.log(`edge count: ${edgeCount(situationGraph)}`);
console.log(
`error/validation summary: ${JSON.stringify(updateResult.json?.errors ?? updateResult.json?.proposalErrors ?? updateResult.json?.graphValidationErrors ?? updateResult.json?.validationErrors ?? updateResult.json?.message ?? null)}`,
);
if (updateResult.json?.diagnostics?.rejectedProposalSnapshot) {
console.log(
`\ndiagnostics.rejectedProposalSnapshot: ${JSON.stringify(updateResult.json.diagnostics.rejectedProposalSnapshot, null, 2)}`,
);
}
const rejectedSnapshot = updateResult.json?.diagnostics?.rejectedProposalSnapshot ?? null;
if (rejectedSnapshot && "structuralActionRequired" in rejectedSnapshot) {
console.log(
`structuralActionRequired (from rejected proposal snapshot): ${JSON.stringify(rejectedSnapshot.structuralActionRequired)}`,
);
} else {
console.log(`structuralActionRequired: UNAVAILABLE`);
}
console.log("\n*** UPDATE REJECTED — STOPPING (no retry). ***");
process.exitCode = 1;
return;
}
const updatedGraph = updateResult.json.updatedSituationGraph;
selectedQuestion = updateResult.json.selectedQuestion?.question ?? null;
// ── Capture accepted answer meaning ───────────────────
const am = updateResult.json.answerMeaning ?? null;
if (am) {
console.log(`answerMeaning.userSupportedMeaning: ${JSON.stringify(am.userSupportedMeaning ?? null)}`);
console.log(`answerMeaning.possibleInference: ${JSON.stringify(am.possibleInference ?? null)}`);
console.log(`answerMeaning.supportCategory: ${JSON.stringify(am.supportCategory ?? null)}`);
console.log(`answerMeaning.resolutionGuidance: ${JSON.stringify(am.resolutionGuidance ?? null)}`);
}
// ── Capture accepted structural mutation fields ──────
const proposal = updateResult.json.updatedProposal ?? updateResult.json.proposal ?? null;
if (proposal) {
console.log(`updatedNodes: ${JSON.stringify(proposal.updatedNodes ?? [])}`);
console.log(`resolvedUnknownNodeIds: ${JSON.stringify(proposal.resolvedUnknownNodeIds ?? [])}`);
console.log(`addedNodes: ${JSON.stringify(proposal.addedNodes ?? [])}`);
console.log(`addedEdges: ${JSON.stringify(proposal.addedEdges ?? [])}`);
}
// ── Capture structuralActionRequired from accepted update ─
const sar = updateResult.json.structuralActionRequired;
if (sar === undefined || sar === null) {
console.log(`structuralActionRequired: null`);
} else {
console.log(`structuralActionRequired: ${JSON.stringify(sar)}`);
}
// ── Capture selectedQuestion node reference ───────────
const sq = updateResult.json.selectedQuestion ?? null;
if (sq && typeof sq === "object") {
console.log(`selectedQuestion: ${JSON.stringify(sq.question ?? null)}`);
if (sq.nodeId) {
console.log(`selectedQuestion.nodeId: ${JSON.stringify(sq.nodeId)}`);
}
}
const finalActiveUnknownNodeId =
updatedGraph?.activeUnknownNodeId === undefined
? null
: updatedGraph.activeUnknownNodeId;
console.log(
`finalActiveUnknownNodeId: ${JSON.stringify(finalActiveUnknownNodeId)}`,
);
// ── Capture persistent graph after update ─────────────
const pNodes = updatedGraph?.nodes ?? [];
const pEdges = updatedGraph?.edges ?? [];
console.log(`\nresulting persistent graph (${pNodes.length} nodes, ${pEdges.length} edges):`);
for (const n of pNodes) {
console.log(` node: id=${n.id ?? n.nodeId}, kind=${n.kind}, label=${n.label ?? n.description ?? ""}, status=${n.status}`);
}
for (const e of pEdges) {
console.log(` edge: from=${e.fromNodeId ?? e.from}, to=${e.toNodeId ?? e.to}, relationship=${e.relationship}`);
}
// ── CONTINUE-ONE-UPDATE EXIT — no retry, no additional calls ──
}
function reportCallAccounting() {
const totalCalls = calls.startCalls + calls.updateCalls;
console.log("\n=== CALL ACCOUNTING ===");