@kontourai/flow-agents 3.11.0 → 3.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/build/src/builder-flow-run-adapter.d.ts +1 -0
- package/build/src/builder-flow-run-adapter.js +2 -0
- package/build/src/builder-flow-runtime.d.ts +5 -0
- package/build/src/builder-flow-runtime.js +90 -68
- package/build/src/builder-gate-action-envelope.d.ts +151 -0
- package/build/src/builder-gate-action-envelope.js +367 -0
- package/build/src/cli/public-contracts.d.ts +139 -0
- package/build/src/cli/public-contracts.js +37 -0
- package/build/src/cli/workflow-sidecar.js +9 -13
- package/build/src/cli/workflow.d.ts +1 -0
- package/build/src/cli/workflow.js +49 -12
- package/build/src/continuation-driver.d.ts +40 -3
- package/build/src/continuation-driver.js +361 -412
- package/build/src/continuation-persistence.d.ts +4 -0
- package/build/src/continuation-persistence.js +296 -0
- package/build/src/continuation-validation.d.ts +9 -0
- package/build/src/continuation-validation.js +139 -0
- package/build/src/flow-kit/action-metadata.d.ts +28 -0
- package/build/src/flow-kit/action-metadata.js +162 -0
- package/build/src/flow-kit/action-repository-validation.d.ts +16 -0
- package/build/src/flow-kit/action-repository-validation.js +237 -0
- package/build/src/flow-kit/validate.d.ts +2 -14
- package/build/src/flow-kit/validate.js +18 -345
- package/build/src/index.d.ts +4 -2
- package/build/src/index.js +2 -1
- package/dist/base/build/package.json +1 -1
- package/dist/base/build/src/builder-flow-run-adapter.d.ts +1 -0
- package/dist/base/build/src/builder-flow-run-adapter.js +2 -0
- package/dist/base/build/src/builder-flow-runtime.d.ts +5 -0
- package/dist/base/build/src/builder-flow-runtime.js +90 -68
- package/dist/base/build/src/builder-gate-action-envelope.d.ts +151 -0
- package/dist/base/build/src/builder-gate-action-envelope.js +367 -0
- package/dist/base/build/src/cli/public-contracts.d.ts +139 -0
- package/dist/base/build/src/cli/public-contracts.js +37 -0
- package/dist/base/build/src/cli/workflow-sidecar.js +9 -13
- package/dist/base/build/src/cli/workflow.d.ts +1 -0
- package/dist/base/build/src/cli/workflow.js +49 -12
- package/dist/base/build/src/continuation-driver.d.ts +40 -3
- package/dist/base/build/src/continuation-driver.js +361 -412
- package/dist/base/build/src/continuation-persistence.d.ts +4 -0
- package/dist/base/build/src/continuation-persistence.js +296 -0
- package/dist/base/build/src/continuation-validation.d.ts +9 -0
- package/dist/base/build/src/continuation-validation.js +139 -0
- package/dist/base/build/src/flow-kit/action-metadata.d.ts +28 -0
- package/dist/base/build/src/flow-kit/action-metadata.js +162 -0
- package/dist/base/build/src/flow-kit/action-repository-validation.d.ts +16 -0
- package/dist/base/build/src/flow-kit/action-repository-validation.js +237 -0
- package/dist/base/build/src/flow-kit/validate.d.ts +2 -14
- package/dist/base/build/src/flow-kit/validate.js +18 -345
- package/dist/base/build/src/index.d.ts +4 -2
- package/dist/base/build/src/index.js +2 -1
- package/dist/base/docs/public-workflow-cli.md +41 -5
- package/dist/base/docs/spec/builder-flow-runtime.md +117 -0
- package/dist/base/evals/integration/test_builder_step_producers.sh +60 -21
- package/dist/base/evals/integration/test_bundle_install.sh +15 -15
- package/dist/base/evals/integration/test_phase_map_and_gate_claim.sh +9 -22
- package/dist/base/install.sh +1 -1
- package/dist/base/kits/builder/kit.json +88 -14
- package/dist/claude-code/build/package.json +1 -1
- package/dist/claude-code/build/src/builder-flow-run-adapter.d.ts +1 -0
- package/dist/claude-code/build/src/builder-flow-run-adapter.js +2 -0
- package/dist/claude-code/build/src/builder-flow-runtime.d.ts +5 -0
- package/dist/claude-code/build/src/builder-flow-runtime.js +90 -68
- package/dist/claude-code/build/src/builder-gate-action-envelope.d.ts +151 -0
- package/dist/claude-code/build/src/builder-gate-action-envelope.js +367 -0
- package/dist/claude-code/build/src/cli/public-contracts.d.ts +139 -0
- package/dist/claude-code/build/src/cli/public-contracts.js +37 -0
- package/dist/claude-code/build/src/cli/workflow-sidecar.js +9 -13
- package/dist/claude-code/build/src/cli/workflow.d.ts +1 -0
- package/dist/claude-code/build/src/cli/workflow.js +49 -12
- package/dist/claude-code/build/src/continuation-driver.d.ts +40 -3
- package/dist/claude-code/build/src/continuation-driver.js +361 -412
- package/dist/claude-code/build/src/continuation-persistence.d.ts +4 -0
- package/dist/claude-code/build/src/continuation-persistence.js +296 -0
- package/dist/claude-code/build/src/continuation-validation.d.ts +9 -0
- package/dist/claude-code/build/src/continuation-validation.js +139 -0
- package/dist/claude-code/build/src/flow-kit/action-metadata.d.ts +28 -0
- package/dist/claude-code/build/src/flow-kit/action-metadata.js +162 -0
- package/dist/claude-code/build/src/flow-kit/action-repository-validation.d.ts +16 -0
- package/dist/claude-code/build/src/flow-kit/action-repository-validation.js +237 -0
- package/dist/claude-code/build/src/flow-kit/validate.d.ts +2 -14
- package/dist/claude-code/build/src/flow-kit/validate.js +18 -345
- package/dist/claude-code/build/src/index.d.ts +4 -2
- package/dist/claude-code/build/src/index.js +2 -1
- package/dist/claude-code/docs/public-workflow-cli.md +41 -5
- package/dist/claude-code/docs/spec/builder-flow-runtime.md +117 -0
- package/dist/claude-code/evals/integration/test_builder_step_producers.sh +60 -21
- package/dist/claude-code/evals/integration/test_bundle_install.sh +15 -15
- package/dist/claude-code/evals/integration/test_phase_map_and_gate_claim.sh +9 -22
- package/dist/claude-code/install.sh +1 -1
- package/dist/claude-code/kits/builder/kit.json +88 -14
- package/dist/codex/build/package.json +1 -1
- package/dist/codex/build/src/builder-flow-run-adapter.d.ts +1 -0
- package/dist/codex/build/src/builder-flow-run-adapter.js +2 -0
- package/dist/codex/build/src/builder-flow-runtime.d.ts +5 -0
- package/dist/codex/build/src/builder-flow-runtime.js +90 -68
- package/dist/codex/build/src/builder-gate-action-envelope.d.ts +151 -0
- package/dist/codex/build/src/builder-gate-action-envelope.js +367 -0
- package/dist/codex/build/src/cli/public-contracts.d.ts +139 -0
- package/dist/codex/build/src/cli/public-contracts.js +37 -0
- package/dist/codex/build/src/cli/workflow-sidecar.js +9 -13
- package/dist/codex/build/src/cli/workflow.d.ts +1 -0
- package/dist/codex/build/src/cli/workflow.js +49 -12
- package/dist/codex/build/src/continuation-driver.d.ts +40 -3
- package/dist/codex/build/src/continuation-driver.js +361 -412
- package/dist/codex/build/src/continuation-persistence.d.ts +4 -0
- package/dist/codex/build/src/continuation-persistence.js +296 -0
- package/dist/codex/build/src/continuation-validation.d.ts +9 -0
- package/dist/codex/build/src/continuation-validation.js +139 -0
- package/dist/codex/build/src/flow-kit/action-metadata.d.ts +28 -0
- package/dist/codex/build/src/flow-kit/action-metadata.js +162 -0
- package/dist/codex/build/src/flow-kit/action-repository-validation.d.ts +16 -0
- package/dist/codex/build/src/flow-kit/action-repository-validation.js +237 -0
- package/dist/codex/build/src/flow-kit/validate.d.ts +2 -14
- package/dist/codex/build/src/flow-kit/validate.js +18 -345
- package/dist/codex/build/src/index.d.ts +4 -2
- package/dist/codex/build/src/index.js +2 -1
- package/dist/codex/docs/public-workflow-cli.md +41 -5
- package/dist/codex/docs/spec/builder-flow-runtime.md +117 -0
- package/dist/codex/evals/integration/test_builder_step_producers.sh +60 -21
- package/dist/codex/evals/integration/test_bundle_install.sh +15 -15
- package/dist/codex/evals/integration/test_phase_map_and_gate_claim.sh +9 -22
- package/dist/codex/install.sh +1 -1
- package/dist/codex/kits/builder/kit.json +88 -14
- package/dist/kiro/build/package.json +1 -1
- package/dist/kiro/build/src/builder-flow-run-adapter.d.ts +1 -0
- package/dist/kiro/build/src/builder-flow-run-adapter.js +2 -0
- package/dist/kiro/build/src/builder-flow-runtime.d.ts +5 -0
- package/dist/kiro/build/src/builder-flow-runtime.js +90 -68
- package/dist/kiro/build/src/builder-gate-action-envelope.d.ts +151 -0
- package/dist/kiro/build/src/builder-gate-action-envelope.js +367 -0
- package/dist/kiro/build/src/cli/public-contracts.d.ts +139 -0
- package/dist/kiro/build/src/cli/public-contracts.js +37 -0
- package/dist/kiro/build/src/cli/workflow-sidecar.js +9 -13
- package/dist/kiro/build/src/cli/workflow.d.ts +1 -0
- package/dist/kiro/build/src/cli/workflow.js +49 -12
- package/dist/kiro/build/src/continuation-driver.d.ts +40 -3
- package/dist/kiro/build/src/continuation-driver.js +361 -412
- package/dist/kiro/build/src/continuation-persistence.d.ts +4 -0
- package/dist/kiro/build/src/continuation-persistence.js +296 -0
- package/dist/kiro/build/src/continuation-validation.d.ts +9 -0
- package/dist/kiro/build/src/continuation-validation.js +139 -0
- package/dist/kiro/build/src/flow-kit/action-metadata.d.ts +28 -0
- package/dist/kiro/build/src/flow-kit/action-metadata.js +162 -0
- package/dist/kiro/build/src/flow-kit/action-repository-validation.d.ts +16 -0
- package/dist/kiro/build/src/flow-kit/action-repository-validation.js +237 -0
- package/dist/kiro/build/src/flow-kit/validate.d.ts +2 -14
- package/dist/kiro/build/src/flow-kit/validate.js +18 -345
- package/dist/kiro/build/src/index.d.ts +4 -2
- package/dist/kiro/build/src/index.js +2 -1
- package/dist/kiro/docs/public-workflow-cli.md +41 -5
- package/dist/kiro/docs/spec/builder-flow-runtime.md +117 -0
- package/dist/kiro/evals/integration/test_builder_step_producers.sh +60 -21
- package/dist/kiro/evals/integration/test_bundle_install.sh +15 -15
- package/dist/kiro/evals/integration/test_phase_map_and_gate_claim.sh +9 -22
- package/dist/kiro/install.sh +1 -1
- package/dist/kiro/kits/builder/kit.json +88 -14
- package/dist/opencode/build/package.json +1 -1
- package/dist/opencode/build/src/builder-flow-run-adapter.d.ts +1 -0
- package/dist/opencode/build/src/builder-flow-run-adapter.js +2 -0
- package/dist/opencode/build/src/builder-flow-runtime.d.ts +5 -0
- package/dist/opencode/build/src/builder-flow-runtime.js +90 -68
- package/dist/opencode/build/src/builder-gate-action-envelope.d.ts +151 -0
- package/dist/opencode/build/src/builder-gate-action-envelope.js +367 -0
- package/dist/opencode/build/src/cli/public-contracts.d.ts +139 -0
- package/dist/opencode/build/src/cli/public-contracts.js +37 -0
- package/dist/opencode/build/src/cli/workflow-sidecar.js +9 -13
- package/dist/opencode/build/src/cli/workflow.d.ts +1 -0
- package/dist/opencode/build/src/cli/workflow.js +49 -12
- package/dist/opencode/build/src/continuation-driver.d.ts +40 -3
- package/dist/opencode/build/src/continuation-driver.js +361 -412
- package/dist/opencode/build/src/continuation-persistence.d.ts +4 -0
- package/dist/opencode/build/src/continuation-persistence.js +296 -0
- package/dist/opencode/build/src/continuation-validation.d.ts +9 -0
- package/dist/opencode/build/src/continuation-validation.js +139 -0
- package/dist/opencode/build/src/flow-kit/action-metadata.d.ts +28 -0
- package/dist/opencode/build/src/flow-kit/action-metadata.js +162 -0
- package/dist/opencode/build/src/flow-kit/action-repository-validation.d.ts +16 -0
- package/dist/opencode/build/src/flow-kit/action-repository-validation.js +237 -0
- package/dist/opencode/build/src/flow-kit/validate.d.ts +2 -14
- package/dist/opencode/build/src/flow-kit/validate.js +18 -345
- package/dist/opencode/build/src/index.d.ts +4 -2
- package/dist/opencode/build/src/index.js +2 -1
- package/dist/opencode/docs/public-workflow-cli.md +41 -5
- package/dist/opencode/docs/spec/builder-flow-runtime.md +117 -0
- package/dist/opencode/evals/integration/test_builder_step_producers.sh +60 -21
- package/dist/opencode/evals/integration/test_bundle_install.sh +15 -15
- package/dist/opencode/evals/integration/test_phase_map_and_gate_claim.sh +9 -22
- package/dist/opencode/install.sh +1 -1
- package/dist/opencode/kits/builder/kit.json +88 -14
- package/dist/pi/build/package.json +1 -1
- package/dist/pi/build/src/builder-flow-run-adapter.d.ts +1 -0
- package/dist/pi/build/src/builder-flow-run-adapter.js +2 -0
- package/dist/pi/build/src/builder-flow-runtime.d.ts +5 -0
- package/dist/pi/build/src/builder-flow-runtime.js +90 -68
- package/dist/pi/build/src/builder-gate-action-envelope.d.ts +151 -0
- package/dist/pi/build/src/builder-gate-action-envelope.js +367 -0
- package/dist/pi/build/src/cli/public-contracts.d.ts +139 -0
- package/dist/pi/build/src/cli/public-contracts.js +37 -0
- package/dist/pi/build/src/cli/workflow-sidecar.js +9 -13
- package/dist/pi/build/src/cli/workflow.d.ts +1 -0
- package/dist/pi/build/src/cli/workflow.js +49 -12
- package/dist/pi/build/src/continuation-driver.d.ts +40 -3
- package/dist/pi/build/src/continuation-driver.js +361 -412
- package/dist/pi/build/src/continuation-persistence.d.ts +4 -0
- package/dist/pi/build/src/continuation-persistence.js +296 -0
- package/dist/pi/build/src/continuation-validation.d.ts +9 -0
- package/dist/pi/build/src/continuation-validation.js +139 -0
- package/dist/pi/build/src/flow-kit/action-metadata.d.ts +28 -0
- package/dist/pi/build/src/flow-kit/action-metadata.js +162 -0
- package/dist/pi/build/src/flow-kit/action-repository-validation.d.ts +16 -0
- package/dist/pi/build/src/flow-kit/action-repository-validation.js +237 -0
- package/dist/pi/build/src/flow-kit/validate.d.ts +2 -14
- package/dist/pi/build/src/flow-kit/validate.js +18 -345
- package/dist/pi/build/src/index.d.ts +4 -2
- package/dist/pi/build/src/index.js +2 -1
- package/dist/pi/docs/public-workflow-cli.md +41 -5
- package/dist/pi/docs/spec/builder-flow-runtime.md +117 -0
- package/dist/pi/evals/integration/test_builder_step_producers.sh +60 -21
- package/dist/pi/evals/integration/test_bundle_install.sh +15 -15
- package/dist/pi/evals/integration/test_phase_map_and_gate_claim.sh +9 -22
- package/dist/pi/install.sh +1 -1
- package/dist/pi/kits/builder/kit.json +88 -14
- package/docs/public-workflow-cli.md +41 -5
- package/docs/spec/builder-flow-runtime.md +117 -0
- package/evals/integration/test_builder_step_producers.sh +60 -21
- package/evals/integration/test_bundle_install.sh +15 -15
- package/evals/integration/test_phase_map_and_gate_claim.sh +9 -22
- package/kits/builder/kit.json +88 -14
- package/package.json +1 -1
- package/src/builder-flow-run-adapter.ts +3 -0
- package/src/builder-flow-runtime.ts +73 -46
- package/src/builder-gate-action-envelope.ts +529 -0
- package/src/cli/builder-flow-runtime.test.mjs +287 -58
- package/src/cli/continuation-driver.test.mjs +527 -24
- package/src/cli/kit-metadata-security.test.mjs +161 -8
- package/src/cli/public-contracts.ts +41 -0
- package/src/cli/workflow-sidecar.ts +9 -13
- package/src/cli/workflow.ts +48 -12
- package/src/continuation-driver.ts +466 -378
- package/src/continuation-persistence.ts +228 -0
- package/src/continuation-validation.ts +107 -0
- package/src/flow-kit/action-metadata.ts +155 -0
- package/src/flow-kit/action-repository-validation.ts +192 -0
- package/src/flow-kit/validate.ts +19 -326
- package/src/index.ts +17 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { isDeepStrictEqual } from "node:util";
|
|
6
|
+
import { atomicWriteJson, ensureSafeDirectory } from "./lib/fs.js";
|
|
7
|
+
import type { ContinuationAcceptedTurn, ContinuationAcceptedTurnJournal, ContinuationBarrier, ContinuationDriverEvent, ContinuationDriverLockLease, ContinuationDriverState, ContinuationStateStore } from "./continuation-driver.js";
|
|
8
|
+
|
|
9
|
+
export const MAX_CONTINUATION_TURNS = 100;
|
|
10
|
+
const MAX_STATE_BYTES = 1_048_576;
|
|
11
|
+
const MAX_LOCK_BYTES = 16_384;
|
|
12
|
+
const MAX_EVENT_LINE_BYTES = 16_384;
|
|
13
|
+
const MAX_EVENT_LINES = MAX_CONTINUATION_TURNS * 6 + 16;
|
|
14
|
+
const MAX_EVENT_LOG_BYTES = MAX_EVENT_LINE_BYTES * MAX_EVENT_LINES;
|
|
15
|
+
const MAX_ACCEPTED_TURN_LINE_BYTES = 196_608;
|
|
16
|
+
const MAX_ACCEPTED_TURN_LOG_BYTES = MAX_ACCEPTED_TURN_LINE_BYTES * MAX_CONTINUATION_TURNS;
|
|
17
|
+
|
|
18
|
+
export async function withContinuationDriverLock<T>(sessionDirInput: string, body: (lease: ContinuationDriverLockLease) => Promise<T>): Promise<T> {
|
|
19
|
+
const sessionDir = path.resolve(sessionDirInput);
|
|
20
|
+
const locksDir = path.join(sessionDir, "continuation-driver", "locks");
|
|
21
|
+
ensureSafeDirectory(sessionDir, locksDir);
|
|
22
|
+
const token = randomUUID();
|
|
23
|
+
const lockFile = path.join(locksDir, `${process.pid}-${token}.lock`);
|
|
24
|
+
const lease: ContinuationDriverLockLease = { pid: process.pid, token, created_at: new Date().toISOString() };
|
|
25
|
+
acquireDriverLock(locksDir, lockFile, { schema_version: "1.0", ...lease });
|
|
26
|
+
try { return await body(lease); }
|
|
27
|
+
finally { releaseDriverLock(lockFile, token); }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createFileContinuationStore(sessionDirInput: string): ContinuationStateStore & ContinuationAcceptedTurnJournal {
|
|
31
|
+
const sessionDir = path.resolve(sessionDirInput);
|
|
32
|
+
const driverDir = path.join(sessionDir, "continuation-driver");
|
|
33
|
+
const stateFile = path.join(driverDir, "state.json");
|
|
34
|
+
const eventsFile = path.join(driverDir, "events.jsonl");
|
|
35
|
+
const acceptedFile = path.join(driverDir, "accepted-turns.jsonl");
|
|
36
|
+
ensureSafeDirectory(sessionDir, driverDir);
|
|
37
|
+
return {
|
|
38
|
+
load(): ContinuationDriverState | null {
|
|
39
|
+
const stateBytes = readStableBoundedFile(stateFile, "continuation driver state", MAX_STATE_BYTES, false);
|
|
40
|
+
const events = readEventLog(eventsFile);
|
|
41
|
+
if (!stateBytes) {
|
|
42
|
+
if (events.length > 0) throw new Error("continuation driver state is missing while mission events exist");
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const state = JSON.parse(stateBytes.toString("utf8")) as ContinuationDriverState;
|
|
46
|
+
reconcileStateWithEvents(state, events);
|
|
47
|
+
return state;
|
|
48
|
+
},
|
|
49
|
+
save(state): void {
|
|
50
|
+
if (Buffer.byteLength(JSON.stringify(state), "utf8") > MAX_STATE_BYTES) throw new Error(`continuation driver state exceeds ${MAX_STATE_BYTES} bytes`);
|
|
51
|
+
atomicWriteJson(sessionDir, stateFile, state);
|
|
52
|
+
},
|
|
53
|
+
append(event): void {
|
|
54
|
+
const events = readEventLog(eventsFile);
|
|
55
|
+
if (event.turn_id && events.some((entry) => entry.type === event.type && entry.turn_id === event.turn_id)) return;
|
|
56
|
+
appendBoundedJsonLine(sessionDir, eventsFile, event, "continuation driver event log", MAX_EVENT_LINE_BYTES, MAX_EVENT_LOG_BYTES, events.length, MAX_EVENT_LINES);
|
|
57
|
+
},
|
|
58
|
+
captureAcceptedTurn(turn): void {
|
|
59
|
+
const accepted = readAcceptedTurns(acceptedFile);
|
|
60
|
+
const prior = accepted.find((entry) => entry.turn_id === turn.turn_id);
|
|
61
|
+
if (prior) {
|
|
62
|
+
if (!isDeepStrictEqual(prior, turn)) throw new Error(`continuation accepted-turn '${turn.turn_id}' conflicts with its durable capture`);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
appendBoundedJsonLine(sessionDir, acceptedFile, turn, "continuation accepted-turn log", MAX_ACCEPTED_TURN_LINE_BYTES, MAX_ACCEPTED_TURN_LOG_BYTES, accepted.length, MAX_CONTINUATION_TURNS);
|
|
66
|
+
},
|
|
67
|
+
acceptedTurns(): ContinuationAcceptedTurn[] {
|
|
68
|
+
const accepted = readAcceptedTurns(acceptedFile);
|
|
69
|
+
assertAcceptedTurnCoverage(accepted, readEventLog(eventsFile));
|
|
70
|
+
return structuredClone(accepted);
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readEventLog(file: string): ContinuationDriverEvent[] {
|
|
76
|
+
return readBoundedJsonLines<ContinuationDriverEvent>(file, "continuation driver event log", MAX_EVENT_LOG_BYTES, MAX_EVENT_LINE_BYTES, MAX_EVENT_LINES);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function readAcceptedTurns(file: string): ContinuationAcceptedTurn[] {
|
|
80
|
+
return readBoundedJsonLines<ContinuationAcceptedTurn>(file, "continuation accepted-turn log", MAX_ACCEPTED_TURN_LOG_BYTES, MAX_ACCEPTED_TURN_LINE_BYTES, MAX_CONTINUATION_TURNS);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readBoundedJsonLines<T>(file: string, label: string, maxFileBytes: number, maxLineBytes: number, maxLines: number): T[] {
|
|
84
|
+
const bytes = readStableBoundedFile(file, label, maxFileBytes, false);
|
|
85
|
+
if (!bytes || bytes.length === 0) return [];
|
|
86
|
+
const lines = bytes.toString("utf8").split("\n");
|
|
87
|
+
if (lines.at(-1) !== "") throw new Error(`${label} must end with a complete line`);
|
|
88
|
+
lines.pop();
|
|
89
|
+
if (lines.length > maxLines) throw new Error(`${label} exceeds ${maxLines} lines`);
|
|
90
|
+
return lines.map((line) => {
|
|
91
|
+
if (Buffer.byteLength(line, "utf8") > maxLineBytes) throw new Error(`${label} line exceeds ${maxLineBytes} bytes`);
|
|
92
|
+
return JSON.parse(line) as T;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function appendBoundedJsonLine(root: string, file: string, value: unknown, label: string, maxLineBytes: number, maxFileBytes: number, currentLines: number, maxLines: number): void {
|
|
97
|
+
const line = Buffer.from(`${JSON.stringify(value)}\n`, "utf8");
|
|
98
|
+
if (line.length - 1 > maxLineBytes) throw new Error(`${label} line exceeds ${maxLineBytes} bytes`);
|
|
99
|
+
if (currentLines >= maxLines) throw new Error(`${label} exceeds ${maxLines} lines`);
|
|
100
|
+
ensureSafeDirectory(root, path.dirname(file));
|
|
101
|
+
const parent = stableParent(path.dirname(file), label);
|
|
102
|
+
const fd = fs.openSync(file, fsConstants.O_WRONLY | fsConstants.O_APPEND | fsConstants.O_CREAT | noFollowFlag(), 0o600);
|
|
103
|
+
try {
|
|
104
|
+
const before = fs.fstatSync(fd);
|
|
105
|
+
if (!before.isFile() || before.size + line.length > maxFileBytes) throw new Error(`${label} exceeds ${maxFileBytes} bytes`);
|
|
106
|
+
fs.writeSync(fd, line, 0, line.length);
|
|
107
|
+
const after = fs.fstatSync(fd);
|
|
108
|
+
if (!sameIdentity(before, after) || after.size !== before.size + line.length) throw new Error(`${label} changed while appending`);
|
|
109
|
+
assertPathAndParentIdentity(file, after, parent, label);
|
|
110
|
+
} finally { fs.closeSync(fd); }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function readStableBoundedFile(file: string, label: string, maxBytes: number, required: boolean): Buffer | null {
|
|
114
|
+
let fd: number | undefined;
|
|
115
|
+
try {
|
|
116
|
+
const parent = stableParent(path.dirname(file), label);
|
|
117
|
+
fd = fs.openSync(file, fsConstants.O_RDONLY | noFollowFlag());
|
|
118
|
+
const before = fs.fstatSync(fd);
|
|
119
|
+
if (!before.isFile() || before.size > maxBytes) throw new Error(`${label} must be a regular file no larger than ${maxBytes} bytes`);
|
|
120
|
+
assertPathAndParentIdentity(file, before, parent, label);
|
|
121
|
+
const bytes = Buffer.alloc(before.size);
|
|
122
|
+
let offset = 0;
|
|
123
|
+
while (offset < bytes.length) {
|
|
124
|
+
const read = fs.readSync(fd, bytes, offset, bytes.length - offset, offset);
|
|
125
|
+
if (read === 0) throw new Error(`${label} changed while reading`);
|
|
126
|
+
offset += read;
|
|
127
|
+
}
|
|
128
|
+
const after = fs.fstatSync(fd);
|
|
129
|
+
if (!sameIdentity(before, after) || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) throw new Error(`${label} changed while reading`);
|
|
130
|
+
assertPathAndParentIdentity(file, after, parent, label);
|
|
131
|
+
return bytes;
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (!required && (error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
134
|
+
throw error;
|
|
135
|
+
} finally { if (fd !== undefined) fs.closeSync(fd); }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
type ParentIdentity = { real: string; stat: fs.Stats };
|
|
139
|
+
|
|
140
|
+
function stableParent(parentPath: string, label: string): ParentIdentity {
|
|
141
|
+
const lexical = fs.lstatSync(parentPath);
|
|
142
|
+
if (lexical.isSymbolicLink() || !lexical.isDirectory()) throw new Error(`${label} parent must be a non-symlink directory`);
|
|
143
|
+
const real = fs.realpathSync(parentPath);
|
|
144
|
+
const stat = fs.statSync(real);
|
|
145
|
+
if (!stat.isDirectory() || !sameIdentity(lexical, stat)) throw new Error(`${label} parent changed identity`);
|
|
146
|
+
return { real, stat };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function assertPathAndParentIdentity(file: string, opened: fs.Stats, parent: ParentIdentity, label: string): void {
|
|
150
|
+
const pathStat = fs.lstatSync(file);
|
|
151
|
+
if (pathStat.isSymbolicLink() || !sameIdentity(opened, pathStat)) throw new Error(`${label} changed identity while reading`);
|
|
152
|
+
const parentAfter = stableParent(path.dirname(file), label);
|
|
153
|
+
if (parentAfter.real !== parent.real || !sameIdentity(parentAfter.stat, parent.stat)) throw new Error(`${label} parent changed identity while reading`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function assertAcceptedTurnCoverage(accepted: ContinuationAcceptedTurn[], events: ContinuationDriverEvent[]): void {
|
|
157
|
+
const acceptedIds = new Set(accepted.map((entry) => entry.turn_id));
|
|
158
|
+
if (acceptedIds.size !== accepted.length) throw new Error("continuation accepted-turn log contains duplicate turn ids");
|
|
159
|
+
const terminalEvents = events.filter((event) => event.type === "turn_completed" || event.type === "parked" || (event.type === "turn_failed" && event.turn_id));
|
|
160
|
+
if (events.some((event) => (event.type === "turn_completed" || event.type === "parked") && !event.turn_id)) {
|
|
161
|
+
throw new Error("continuation attestation coverage is incomplete for legacy accepted turns");
|
|
162
|
+
}
|
|
163
|
+
if (terminalEvents.some((event) => !event.turn_id || !acceptedIds.has(event.turn_id))) throw new Error("continuation attestation coverage is missing a persisted accepted turn");
|
|
164
|
+
const eventIds = new Set(terminalEvents.flatMap((event) => event.turn_id ? [event.turn_id] : []));
|
|
165
|
+
if (accepted.some((entry) => !eventIds.has(entry.turn_id))) throw new Error("continuation persisted accepted turn has no durable completion event");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function acquireDriverLock(locksDir: string, lockFile: string, owner: { schema_version: "1.0"; pid: number; token: string; created_at: string }): void {
|
|
169
|
+
const fd = fs.openSync(lockFile, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollowFlag(), 0o600);
|
|
170
|
+
try { fs.writeFileSync(fd, `${JSON.stringify(owner)}\n`, "utf8"); } finally { fs.closeSync(fd); }
|
|
171
|
+
try { reclaimDeadDriverLocks(locksDir, lockFile); }
|
|
172
|
+
catch (error) { try { fs.unlinkSync(lockFile); } catch {} throw error; }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function reclaimDeadDriverLocks(locksDir: string, lockFile: string): void {
|
|
176
|
+
for (const name of fs.readdirSync(locksDir).sort()) {
|
|
177
|
+
const candidate = path.join(locksDir, name);
|
|
178
|
+
if (candidate === lockFile) continue;
|
|
179
|
+
let existing: { schema_version?: unknown; pid?: unknown; token?: unknown };
|
|
180
|
+
try { existing = JSON.parse(String(readStableBoundedFile(candidate, "continuation driver lock", MAX_LOCK_BYTES, true))) as typeof existing; }
|
|
181
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; }
|
|
182
|
+
if (existing.schema_version !== "1.0" || typeof existing.pid !== "number" || !Number.isSafeInteger(existing.pid) || existing.pid <= 0 || typeof existing.token !== "string") throw new Error("continuation driver lock is invalid");
|
|
183
|
+
if (processAlive(existing.pid)) throw new Error(`continuation driver is already running under pid ${existing.pid}`);
|
|
184
|
+
try { fs.unlinkSync(candidate); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function releaseDriverLock(lockFile: string, token: string): void {
|
|
189
|
+
try {
|
|
190
|
+
const owner = JSON.parse(String(readStableBoundedFile(lockFile, "continuation driver lock", MAX_LOCK_BYTES, true))) as { token?: unknown };
|
|
191
|
+
if (owner.token === token) fs.unlinkSync(lockFile);
|
|
192
|
+
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function reconcileStateWithEvents(state: ContinuationDriverState, events: ContinuationDriverEvent[]): void {
|
|
196
|
+
if (events.length === 0) {
|
|
197
|
+
if (state.turns_started > 0) throw new Error("continuation driver event history is missing for a started mission");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
let highestStartedTurn = 0;
|
|
201
|
+
let eventBarrier: ContinuationBarrier | null = null;
|
|
202
|
+
for (const event of events) {
|
|
203
|
+
if (event.run_id !== state.run_id || event.definition_id !== state.definition_id) throw new Error("continuation driver event history has a foreign mission identity");
|
|
204
|
+
if (event.type === "turn_started") {
|
|
205
|
+
if (!Number.isSafeInteger(event.turns_started) || Number(event.turns_started) < 1) throw new Error("continuation driver event history has an invalid turn count");
|
|
206
|
+
highestStartedTurn = Math.max(highestStartedTurn, Number(event.turns_started));
|
|
207
|
+
}
|
|
208
|
+
if (event.type === "parked") { validateStoredBarrier(event.barrier); eventBarrier = structuredClone(event.barrier as ContinuationBarrier); }
|
|
209
|
+
if (event.type === "resumed") eventBarrier = null;
|
|
210
|
+
}
|
|
211
|
+
if (highestStartedTurn > state.turns_started) throw new Error("continuation driver state rolled back behind its event history");
|
|
212
|
+
if (state.turns_started > highestStartedTurn + 1) throw new Error("continuation driver state is ahead of its event history");
|
|
213
|
+
if (!state.pending_barrier && eventBarrier) { state.pending_barrier = eventBarrier; state.status = "waiting"; }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function validateStoredBarrier(value: unknown): void {
|
|
217
|
+
const barrier = value as Partial<ContinuationBarrier> | null;
|
|
218
|
+
const validPid = barrier?.kind === "pid" && Number.isSafeInteger(barrier.pid) && Number(barrier.pid) > 0;
|
|
219
|
+
const validDeadline = barrier?.kind === "deadline" && typeof barrier.at === "string" && Number.isFinite(Date.parse(barrier.at));
|
|
220
|
+
if (!validPid && !validDeadline) throw new Error("continuation driver event history has an invalid barrier");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function sameIdentity(left: fs.Stats, right: fs.Stats): boolean { return left.dev === right.dev && left.ino === right.ino; }
|
|
224
|
+
function processAlive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; } }
|
|
225
|
+
function noFollowFlag(): number {
|
|
226
|
+
if (typeof fsConstants.O_NOFOLLOW !== "number") throw new Error("continuation persistence requires O_NOFOLLOW");
|
|
227
|
+
return fsConstants.O_NOFOLLOW;
|
|
228
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { MAX_CONTINUATION_TURNS } from "./continuation-persistence.js";
|
|
2
|
+
import type { GateActionEnvelope, GateActionPriorProgress, GateActionProgressSnapshot } from "./builder-gate-action-envelope.js";
|
|
3
|
+
import type { ContinuationAcceptedTurn, ContinuationBarrier, ContinuationDriverState, ContinuationSnapshot, ContinuationTurnResult } from "./continuation-driver.js";
|
|
4
|
+
|
|
5
|
+
export const MAX_CONTINUATION_ADAPTER_EVIDENCE_BYTES = 65_536;
|
|
6
|
+
export const MAX_CONTINUATION_TURN_RESULT_BYTES = 74_000;
|
|
7
|
+
|
|
8
|
+
export function validateSnapshot(value: ContinuationSnapshot): ContinuationSnapshot {
|
|
9
|
+
if (!value || typeof value !== "object") throw new Error("continuation runtime returned an invalid canonical snapshot");
|
|
10
|
+
for (const field of ["run_id", "definition_id", "status", "current_step"] as const) if (typeof value[field] !== "string" || value[field].length === 0) throw new Error(`continuation snapshot ${field} must be a non-empty string`);
|
|
11
|
+
if (!new Set(["continue", "waiting", "done", "failed"]).has(value.disposition)) throw new Error("continuation snapshot disposition is invalid");
|
|
12
|
+
if (value.next_action !== null && (typeof value.next_action !== "object" || Array.isArray(value.next_action))) throw new Error("continuation snapshot next_action must be an object or null");
|
|
13
|
+
if (value.gate_action_envelope !== undefined) validateGateActionEnvelope(value.gate_action_envelope);
|
|
14
|
+
if (value.progress_snapshot !== undefined) validateProgressSnapshot(value.progress_snapshot);
|
|
15
|
+
return structuredClone(value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function validateGateActionEnvelope(value: GateActionEnvelope): void {
|
|
19
|
+
if (!value || typeof value !== "object" || value.schema_version !== "1.0" || !value.flow || typeof value.flow.current_step !== "string"
|
|
20
|
+
|| !value.progress || !Array.isArray(value.progress.canonical_evidence) || !Array.isArray(value.progress.observed_artifacts)) throw new Error("continuation snapshot gate-action envelope is malformed");
|
|
21
|
+
if (Buffer.byteLength(JSON.stringify(value), "utf8") > 65_536) throw new Error("continuation snapshot gate-action envelope exceeds 65536 bytes");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function validateTurnResult(value: ContinuationTurnResult): ContinuationTurnResult {
|
|
25
|
+
if (!value || typeof value !== "object" || (value.status !== "completed" && value.status !== "wait")) throw new Error("continuation adapter must return status completed or wait");
|
|
26
|
+
if (value.status === "wait") validateBarrier(value.barrier);
|
|
27
|
+
if (value.summary !== undefined && typeof value.summary !== "string") throw new Error("continuation adapter summary must be a string");
|
|
28
|
+
if (value.status === "completed" && value.evidence !== undefined) validateAdapterEvidence(value.evidence);
|
|
29
|
+
if (value.status === "wait" && Object.hasOwn(value, "evidence")) throw new Error("continuation wait results must not include evidence");
|
|
30
|
+
const copy = structuredClone(value);
|
|
31
|
+
if (copy.summary) {
|
|
32
|
+
const characters = Array.from(copy.summary);
|
|
33
|
+
if (characters.length > 2_000) copy.summary = `${characters.slice(0, 1_997).join("")}...`;
|
|
34
|
+
}
|
|
35
|
+
if (Buffer.byteLength(JSON.stringify(copy), "utf8") > MAX_CONTINUATION_TURN_RESULT_BYTES) throw new Error(`continuation adapter result must not exceed ${MAX_CONTINUATION_TURN_RESULT_BYTES} bytes`);
|
|
36
|
+
return copy;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function validateAdapterEvidence(value: unknown): void {
|
|
40
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("continuation adapter evidence must be a JSON object");
|
|
41
|
+
let encoded: string;
|
|
42
|
+
try { encoded = JSON.stringify(value); }
|
|
43
|
+
catch { throw new Error("continuation adapter evidence must be JSON-serializable"); }
|
|
44
|
+
if (encoded === undefined || Buffer.byteLength(encoded, "utf8") > MAX_CONTINUATION_ADAPTER_EVIDENCE_BYTES) throw new Error(`continuation adapter evidence must not exceed ${MAX_CONTINUATION_ADAPTER_EVIDENCE_BYTES} bytes`);
|
|
45
|
+
const parsed = JSON.parse(encoded) as unknown;
|
|
46
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("continuation adapter evidence must be a JSON object");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function validateBarrier(barrier: ContinuationBarrier): void {
|
|
50
|
+
if (!barrier || typeof barrier !== "object") throw new Error("continuation wait result requires a barrier");
|
|
51
|
+
if (barrier.kind === "pid") {
|
|
52
|
+
if (!Number.isSafeInteger(barrier.pid) || barrier.pid <= 0) throw new Error("continuation pid barrier requires a positive integer pid");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (barrier.kind === "deadline") {
|
|
56
|
+
if (typeof barrier.at !== "string" || !Number.isFinite(Date.parse(barrier.at))) throw new Error("continuation deadline barrier requires an ISO date-time");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
throw new Error("continuation barrier kind must be pid or deadline");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function validateState(state: ContinuationDriverState): void {
|
|
63
|
+
if (state.schema_version !== "1.0") throw new Error("continuation driver state schema_version must be 1.0");
|
|
64
|
+
assertMaxTurns(state.max_turns);
|
|
65
|
+
if (state.adapter_command_identity !== null && (typeof state.adapter_command_identity !== "string" || state.adapter_command_identity.length === 0)) throw new Error("continuation driver adapter_command_identity must be a non-empty string or null");
|
|
66
|
+
if (!Number.isSafeInteger(state.turns_started) || state.turns_started < 0 || state.turns_started > state.max_turns) throw new Error("continuation driver turns_started is outside its mission budget");
|
|
67
|
+
if (state.active_turn_step !== undefined && state.active_turn_step !== null && (typeof state.active_turn_step !== "string" || state.active_turn_step.length === 0)) throw new Error("continuation driver active_turn_step must be a non-empty string or null");
|
|
68
|
+
if (state.active_turn_public_key_digest !== undefined && state.active_turn_public_key_digest !== null && (typeof state.active_turn_public_key_digest !== "string" || !/^[a-f0-9]{64}$/.test(state.active_turn_public_key_digest))) throw new Error("continuation driver active_turn_public_key_digest must be a SHA-256 hex digest or null");
|
|
69
|
+
if (state.active_turn_phase !== undefined && state.active_turn_phase !== null && !new Set(["prepared", "started", "measured"]).has(state.active_turn_phase)) throw new Error("continuation driver active_turn_phase must be prepared, started, measured, or null");
|
|
70
|
+
if (state.active_turn_progress) validateProgressSnapshot(state.active_turn_progress);
|
|
71
|
+
if (state.active_turn_capture) validateAcceptedTurn(state.active_turn_capture);
|
|
72
|
+
if (state.active_turn_phase === "measured" && !state.active_turn_capture) throw new Error("continuation measured turn must retain its accepted capture");
|
|
73
|
+
if (state.last_progress) validateProgressSnapshot(state.last_progress);
|
|
74
|
+
if (state.prior_progress) validatePriorProgress(state.prior_progress);
|
|
75
|
+
if (!new Set(["active", "waiting", "done", "failed", "budget_exhausted"]).has(state.status)) throw new Error("continuation driver state status is invalid");
|
|
76
|
+
if (state.status === "budget_exhausted" && state.turns_started !== state.max_turns) throw new Error("continuation driver budget_exhausted state must consume max_turns");
|
|
77
|
+
if (state.pending_barrier) validateBarrier(state.pending_barrier);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function validateAcceptedTurn(value: ContinuationAcceptedTurn): void {
|
|
81
|
+
if (!value || typeof value !== "object" || value.schema_version !== "1.0" || typeof value.turn_id !== "string" || !Number.isSafeInteger(value.iteration)
|
|
82
|
+
|| value.iteration < 1 || value.request?.iteration !== value.iteration || typeof value.captured_at !== "string" || !Number.isFinite(Date.parse(value.captured_at))) throw new Error("continuation accepted-turn capture is malformed");
|
|
83
|
+
validateTurnResult(value.result);
|
|
84
|
+
if (value.progress !== null) validatePriorProgress(value.progress);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function validateProgressSnapshot(value: GateActionProgressSnapshot): void {
|
|
88
|
+
if (!value || typeof value !== "object" || typeof value.current_step !== "string" || value.current_step.length === 0
|
|
89
|
+
|| (value.canonical_status !== undefined && (typeof value.canonical_status !== "string" || value.canonical_status.length === 0))
|
|
90
|
+
|| !Array.isArray(value.canonical_evidence) || !value.canonical_evidence.every((entry) => typeof entry === "string")
|
|
91
|
+
|| !Array.isArray(value.observed_artifacts) || !value.observed_artifacts.every((entry) => typeof entry === "string")) throw new Error("continuation driver progress snapshot is malformed");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function validatePriorProgress(value: GateActionPriorProgress): void {
|
|
95
|
+
if (!value || typeof value !== "object" || typeof value.step_advanced !== "boolean" || typeof value.no_progress !== "boolean"
|
|
96
|
+
|| !Array.isArray(value.evidence_added) || !value.evidence_added.every((entry) => typeof entry === "string")
|
|
97
|
+
|| !Array.isArray(value.artifact_changes) || !value.artifact_changes.every((entry) => typeof entry === "string")
|
|
98
|
+
|| !Number.isSafeInteger(value.consecutive_no_progress) || value.consecutive_no_progress < 0 || !new Set(["none", "possible", "stagnant"]).has(value.stagnation)) throw new Error("continuation driver prior progress is malformed");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function assertMissionIdentity(state: Pick<ContinuationDriverState, "run_id" | "definition_id">, snapshot: ContinuationSnapshot): void {
|
|
102
|
+
if (state.run_id !== snapshot.run_id || state.definition_id !== snapshot.definition_id) throw new Error("continuation driver mission identity does not match the canonical Flow run");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function assertMaxTurns(value: number): void {
|
|
106
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > MAX_CONTINUATION_TURNS) throw new Error(`continuation maxTurns must be an integer from 1 through ${MAX_CONTINUATION_TURNS}`);
|
|
107
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { PUBLIC_OPERATION_IDS } from "../cli/public-contracts.js";
|
|
3
|
+
|
|
4
|
+
export interface KitFlowStepActionEntry {
|
|
5
|
+
flow_id: string;
|
|
6
|
+
step_id: string;
|
|
7
|
+
skills: string[];
|
|
8
|
+
operations: string[];
|
|
9
|
+
expectation_ids: string[];
|
|
10
|
+
artifacts: string[];
|
|
11
|
+
implementation_allowed: boolean;
|
|
12
|
+
expectation_bindings: KitFlowStepExpectationBinding[];
|
|
13
|
+
artifact_bindings: KitFlowStepArtifactBinding[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type KitFlowStepExpectationBinding = {
|
|
17
|
+
expectation_id: string;
|
|
18
|
+
interface: "workflow.evidence" | "workflow.critique" | "operation";
|
|
19
|
+
operation?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type KitFlowStepArtifactBinding = { artifact: string; expectation_ids: string[] };
|
|
23
|
+
|
|
24
|
+
const MAX_FLOW_STEP_ACTIONS = 128;
|
|
25
|
+
const MAX_FLOW_STEP_ACTION_LIST_ITEMS = 32;
|
|
26
|
+
const MAX_FLOW_STEP_ACTION_SKILLS = 16;
|
|
27
|
+
const MAX_FLOW_OBSERVABLE_ARTIFACTS = 128;
|
|
28
|
+
const MAX_FLOW_STEP_ACTION_TEXT = 1024;
|
|
29
|
+
const WORKFLOW_TRIGGER_IDENTIFIER_RE = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/;
|
|
30
|
+
|
|
31
|
+
export function workflowTriggerIdentifier(value: unknown): value is string {
|
|
32
|
+
return typeof value === "string" && WORKFLOW_TRIGGER_IDENTIFIER_RE.test(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function parseKitFlowStepActions(manifest: Record<string, unknown>, manifestPath: string): { entries: KitFlowStepActionEntry[]; errors: string[] } {
|
|
36
|
+
const entries: KitFlowStepActionEntry[] = [];
|
|
37
|
+
const errors: string[] = [];
|
|
38
|
+
const raw = manifest.flow_step_actions;
|
|
39
|
+
if (raw === undefined) return { entries, errors };
|
|
40
|
+
if (!Array.isArray(raw)) return { entries, errors: [`${manifestPath}: .flow_step_actions must be a list`] };
|
|
41
|
+
if (raw.length > MAX_FLOW_STEP_ACTIONS) return { entries, errors: [`${manifestPath}: .flow_step_actions exceeds ${MAX_FLOW_STEP_ACTIONS} entries`] };
|
|
42
|
+
const seen = new Set<string>();
|
|
43
|
+
raw.forEach((rawEntry, index) => {
|
|
44
|
+
const entry = parseFlowStepActionEntry(rawEntry, manifestPath, index, errors);
|
|
45
|
+
if (!entry) return;
|
|
46
|
+
const key = `${entry.flow_id}/${entry.step_id}`;
|
|
47
|
+
if (seen.has(key)) errors.push(`${manifestPath}: flow_step_actions[${index}] duplicates '${key}'`);
|
|
48
|
+
else { seen.add(key); entries.push(entry); }
|
|
49
|
+
});
|
|
50
|
+
validateObservableArtifactBounds(entries, manifestPath, errors);
|
|
51
|
+
return { entries, errors };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type FlowStepActionLists = Pick<KitFlowStepActionEntry, "skills" | "operations" | "expectation_ids" | "artifacts">;
|
|
55
|
+
|
|
56
|
+
function parseFlowStepActionEntry(raw: unknown, manifestPath: string, index: number, errors: string[]): KitFlowStepActionEntry | null {
|
|
57
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
58
|
+
errors.push(`${manifestPath}: flow_step_actions[${index}] must be an object`);
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const record = raw as Record<string, unknown>;
|
|
62
|
+
const supported = new Set(["flow_id", "step_id", "skills", "operations", "expectation_ids", "artifacts", "implementation_allowed", "expectation_bindings", "artifact_bindings"]);
|
|
63
|
+
const unsupported = Object.keys(record).filter((key) => !supported.has(key));
|
|
64
|
+
if (unsupported.length) { errors.push(`${manifestPath}: flow_step_actions[${index}] has unsupported field(s): ${unsupported.join(", ")}`); return null; }
|
|
65
|
+
if (!workflowTriggerIdentifier(record.flow_id) || !workflowTriggerIdentifier(record.step_id)) {
|
|
66
|
+
errors.push(`${manifestPath}: flow_step_actions[${index}] must declare valid flow_id and step_id identifiers`);
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
for (const field of ["expectation_ids", "artifacts", "implementation_allowed", "expectation_bindings", "artifact_bindings"] as const) {
|
|
70
|
+
if (!Object.hasOwn(record, field)) errors.push(`${manifestPath}: flow_step_actions[${index}].${field} must be explicitly declared`);
|
|
71
|
+
}
|
|
72
|
+
const lists = parseFlowStepActionLists(record, manifestPath, index, errors);
|
|
73
|
+
if (!lists || typeof record.implementation_allowed !== "boolean") {
|
|
74
|
+
if (typeof record.implementation_allowed !== "boolean") errors.push(`${manifestPath}: flow_step_actions[${index}].implementation_allowed must be a boolean`);
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const expectationBindings = parseExpectationBindings(record.expectation_bindings ?? [], lists.operations, manifestPath, index, errors);
|
|
78
|
+
if (!expectationBindings || !sameStringSet(expectationBindings.map((binding) => binding.expectation_id), lists.expectation_ids)) {
|
|
79
|
+
if (expectationBindings) errors.push(`${manifestPath}: flow_step_actions[${index}].expectation_bindings must bind every expectation_id exactly once`);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
const artifactBindings = parseArtifactBindings(record.artifact_bindings ?? [], lists.artifacts, lists.expectation_ids, manifestPath, index, errors);
|
|
83
|
+
return artifactBindings ? { flow_id: record.flow_id, step_id: record.step_id, ...lists, implementation_allowed: record.implementation_allowed, expectation_bindings: expectationBindings, artifact_bindings: artifactBindings } : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseFlowStepActionLists(record: Record<string, unknown>, manifestPath: string, index: number, errors: string[]): FlowStepActionLists | null {
|
|
87
|
+
const skills = record.skills;
|
|
88
|
+
const operations = record.operations ?? [];
|
|
89
|
+
const expectationIds = record.expectation_ids ?? [];
|
|
90
|
+
const artifacts = record.artifacts ?? [];
|
|
91
|
+
const label = `${manifestPath}: flow_step_actions[${index}]`;
|
|
92
|
+
if ([skills, operations, expectationIds, artifacts].some((list) => Array.isArray(list) && list.length > MAX_FLOW_STEP_ACTION_LIST_ITEMS)) return fail(errors, `${label} list exceeds ${MAX_FLOW_STEP_ACTION_LIST_ITEMS} entries`);
|
|
93
|
+
if (!Array.isArray(skills) || !skills.every(workflowTriggerIdentifier) || new Set(skills).size !== skills.length) return fail(errors, `${label}.skills must be an identifier list without duplicates`);
|
|
94
|
+
if (skills.length > MAX_FLOW_STEP_ACTION_SKILLS) return fail(errors, `${label}.skills exceeds ${MAX_FLOW_STEP_ACTION_SKILLS} entries`);
|
|
95
|
+
if (!Array.isArray(operations) || !operations.every(workflowTriggerIdentifier) || new Set(operations).size !== operations.length || operations.some((operation) => !PUBLIC_OPERATION_IDS.has(operation))) return fail(errors, `${label}.operations must be unique identifiers from the canonical public operation catalog`);
|
|
96
|
+
if (!Array.isArray(expectationIds) || !expectationIds.every(workflowTriggerIdentifier) || new Set(expectationIds).size !== expectationIds.length) return fail(errors, `${label}.expectation_ids must be a unique identifier list`);
|
|
97
|
+
if (!Array.isArray(artifacts) || !artifacts.every(isSafeBuilderArtifactRef) || new Set(artifacts).size !== artifacts.length) return fail(errors, `${label}.artifacts must be unique safe session-relative paths or trust.bundle#<safe-id> refs`);
|
|
98
|
+
if ([...skills, ...operations, ...expectationIds, ...artifacts].some((entry) => entry.length > MAX_FLOW_STEP_ACTION_TEXT)) return fail(errors, `${label} values must not exceed ${MAX_FLOW_STEP_ACTION_TEXT} characters`);
|
|
99
|
+
return { skills, operations, expectation_ids: expectationIds, artifacts };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function fail(errors: string[], message: string): null { errors.push(message); return null; }
|
|
103
|
+
|
|
104
|
+
function validateObservableArtifactBounds(entries: KitFlowStepActionEntry[], manifestPath: string, errors: string[]): void {
|
|
105
|
+
const observableByFlow = new Map<string, Set<string>>();
|
|
106
|
+
for (const action of entries) {
|
|
107
|
+
const observed = observableByFlow.get(action.flow_id) ?? new Set<string>();
|
|
108
|
+
for (const artifact of action.artifacts) if (isObservableBuilderArtifactRef(artifact)) observed.add(artifact);
|
|
109
|
+
observableByFlow.set(action.flow_id, observed);
|
|
110
|
+
}
|
|
111
|
+
for (const [flowId, artifacts] of observableByFlow) if (artifacts.size > MAX_FLOW_OBSERVABLE_ARTIFACTS) errors.push(`${manifestPath}: flow_step_actions for '${flowId}' exceed ${MAX_FLOW_OBSERVABLE_ARTIFACTS} distinct observable file artifacts`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseExpectationBindings(value: unknown, operations: string[], manifestPath: string, actionIndex: number, errors: string[]): KitFlowStepExpectationBinding[] | null {
|
|
115
|
+
if (!Array.isArray(value) || value.length > MAX_FLOW_STEP_ACTION_LIST_ITEMS) return fail(errors, `${manifestPath}: flow_step_actions[${actionIndex}].expectation_bindings must be a bounded list`);
|
|
116
|
+
const bindings: KitFlowStepExpectationBinding[] = [];
|
|
117
|
+
for (const [bindingIndex, entry] of value.entries()) {
|
|
118
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return fail(errors, `${manifestPath}: flow_step_actions[${actionIndex}].expectation_bindings[${bindingIndex}] must be an object`);
|
|
119
|
+
const record = entry as Record<string, unknown>;
|
|
120
|
+
const unsupported = Object.keys(record).filter((key) => key !== "expectation_id" && key !== "interface" && key !== "operation");
|
|
121
|
+
if (unsupported.length) return fail(errors, `${manifestPath}: flow_step_actions[${actionIndex}].expectation_bindings[${bindingIndex}] has unsupported field(s): ${unsupported.join(", ")}`);
|
|
122
|
+
if (!workflowTriggerIdentifier(record.expectation_id) || !new Set(["workflow.evidence", "workflow.critique", "operation"]).has(String(record.interface))) return fail(errors, `${manifestPath}: flow_step_actions[${actionIndex}].expectation_bindings[${bindingIndex}] must declare a valid expectation_id and interface`);
|
|
123
|
+
if (record.interface === "operation") {
|
|
124
|
+
if (!workflowTriggerIdentifier(record.operation) || !operations.includes(record.operation) || !PUBLIC_OPERATION_IDS.has(record.operation)) return fail(errors, `${manifestPath}: flow_step_actions[${actionIndex}].expectation_bindings[${bindingIndex}].operation must name a declared canonical public operation`);
|
|
125
|
+
} else if (record.operation !== undefined) return fail(errors, `${manifestPath}: flow_step_actions[${actionIndex}].expectation_bindings[${bindingIndex}].operation is allowed only for operation bindings`);
|
|
126
|
+
bindings.push({ expectation_id: record.expectation_id as string, interface: record.interface as KitFlowStepExpectationBinding["interface"], ...(typeof record.operation === "string" ? { operation: record.operation } : {}) });
|
|
127
|
+
}
|
|
128
|
+
if (new Set(bindings.map((binding) => binding.expectation_id)).size !== bindings.length) return fail(errors, `${manifestPath}: flow_step_actions[${actionIndex}].expectation_bindings must not duplicate expectation ids`);
|
|
129
|
+
return bindings;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function parseArtifactBindings(value: unknown, artifacts: string[], expectationIds: string[], manifestPath: string, actionIndex: number, errors: string[]): KitFlowStepArtifactBinding[] | null {
|
|
133
|
+
if (!Array.isArray(value) || value.length > MAX_FLOW_STEP_ACTION_LIST_ITEMS) return fail(errors, `${manifestPath}: flow_step_actions[${actionIndex}].artifact_bindings must be a bounded list`);
|
|
134
|
+
const bindings: KitFlowStepArtifactBinding[] = [];
|
|
135
|
+
for (const [bindingIndex, entry] of value.entries()) {
|
|
136
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return fail(errors, `${manifestPath}: flow_step_actions[${actionIndex}].artifact_bindings[${bindingIndex}] must be an object`);
|
|
137
|
+
const record = entry as Record<string, unknown>;
|
|
138
|
+
if (Object.keys(record).some((key) => key !== "artifact" && key !== "expectation_ids") || typeof record.artifact !== "string" || !artifacts.includes(record.artifact)
|
|
139
|
+
|| !Array.isArray(record.expectation_ids) || !record.expectation_ids.every((id) => typeof id === "string" && expectationIds.includes(id)) || new Set(record.expectation_ids).size !== record.expectation_ids.length) return fail(errors, `${manifestPath}: flow_step_actions[${actionIndex}].artifact_bindings[${bindingIndex}] must bind one declared artifact to declared expectation_ids`);
|
|
140
|
+
bindings.push({ artifact: record.artifact, expectation_ids: record.expectation_ids as string[] });
|
|
141
|
+
}
|
|
142
|
+
if (!sameStringSet(bindings.map((binding) => binding.artifact), artifacts)) return fail(errors, `${manifestPath}: flow_step_actions[${actionIndex}].artifact_bindings must bind every artifact exactly once`);
|
|
143
|
+
return bindings;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function isSafeBuilderArtifactRef(value: unknown): value is string {
|
|
147
|
+
if (typeof value !== "string" || value.length === 0 || value.includes("\0") || value.includes("\\")) return false;
|
|
148
|
+
if (value.startsWith("trust.bundle#")) return /^trust\.bundle#[a-z0-9]+(?:[.-][a-z0-9]+)*$/.test(value);
|
|
149
|
+
if (value.includes("#") || path.posix.isAbsolute(value)) return false;
|
|
150
|
+
const expanded = value.replaceAll("<slug>", "slug");
|
|
151
|
+
return expanded !== "." && !expanded.split("/").some((part) => part === "" || part === "." || part === "..");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function isObservableBuilderArtifactRef(value: string): boolean { return !value.includes("#") && path.posix.normalize(value) !== "state.json"; }
|
|
155
|
+
export function sameStringSet(left: string[], right: string[]): boolean { return left.length === right.length && new Set(left).size === left.length && left.every((entry) => right.includes(entry)); }
|