@kontourai/flow-agents 3.6.0 → 3.8.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 +19 -0
- package/build/src/builder-flow-run-adapter.d.ts +22 -2
- package/build/src/builder-flow-run-adapter.js +93 -28
- package/build/src/builder-flow-runtime.d.ts +8 -3
- package/build/src/builder-flow-runtime.js +407 -51
- package/build/src/cli/assignment-provider.d.ts +4 -7
- package/build/src/cli/assignment-provider.js +80 -14
- package/build/src/cli/builder-run.js +14 -18
- package/build/src/cli/workflow-sidecar.d.ts +28 -4
- package/build/src/cli/workflow-sidecar.js +825 -103
- package/build/src/cli/workflow.js +301 -43
- package/build/src/flow-kit/validate.d.ts +17 -0
- package/build/src/flow-kit/validate.js +340 -2
- package/build/src/index.js +5 -5
- package/build/src/lib/observed-command.d.ts +7 -0
- package/build/src/lib/observed-command.js +100 -0
- package/build/src/tools/build-universal-bundles.js +53 -3
- package/build/src/tools/generate-context-map.js +5 -3
- package/context/scripts/hooks/lib/kit-catalog.js +1 -1
- package/context/scripts/hooks/stop-goal-fit.js +78 -9
- package/docs/context-map.md +22 -20
- package/docs/developer-architecture.md +1 -1
- package/docs/getting-started.md +9 -1
- package/docs/public-workflow-cli.md +76 -7
- package/docs/skills-map.md +51 -27
- package/docs/spec/builder-flow-runtime.md +75 -40
- package/docs/workflow-usage-guide.md +109 -42
- package/evals/fixtures/hook-influence/cases.json +2 -2
- package/evals/integration/test_builder_entry_enforcement.sh +52 -41
- package/evals/integration/test_builder_step_producers.sh +330 -6
- package/evals/integration/test_bundle_install.sh +318 -65
- package/evals/integration/test_bundle_lifecycle.sh +4 -6
- package/evals/integration/test_critique_supersession_roundtrip.sh +21 -8
- package/evals/integration/test_current_json_per_actor.sh +12 -0
- package/evals/integration/test_dual_emit_flow_step.sh +62 -43
- package/evals/integration/test_flowdef_session_activation.sh +145 -25
- package/evals/integration/test_flowdef_session_history_preservation.sh +23 -21
- package/evals/integration/test_gate_lockdown.sh +3 -5
- package/evals/integration/test_goal_fit_hook.sh +60 -2
- package/evals/integration/test_install_merge.sh +18 -8
- package/evals/integration/test_liveness_verdict.sh +14 -17
- package/evals/integration/test_phase_map_and_gate_claim.sh +63 -38
- package/evals/integration/test_public_workflow_cli.sh +334 -14
- package/evals/integration/test_pull_work_liveness_preflight.sh +22 -66
- package/evals/integration/test_sidecar_field_preservation.sh +36 -11
- package/evals/integration/test_workflow_sidecar_writer.sh +277 -44
- package/evals/integration/test_workflow_steering_hook.sh +15 -38
- package/evals/run.sh +2 -0
- package/evals/static/test_builder_skill_coherence.sh +247 -0
- package/evals/static/test_library_exports.sh +5 -2
- package/evals/static/test_universal_bundles.sh +44 -1
- package/evals/static/test_workflow_skills.sh +13 -325
- package/kits/builder/flows/build.flow.json +22 -0
- package/kits/builder/flows/shape.flow.json +9 -9
- package/kits/builder/kit.json +70 -16
- package/kits/builder/skills/builder-shape/SKILL.md +75 -75
- package/kits/builder/skills/continue-work/SKILL.md +45 -106
- package/kits/builder/skills/deliver/SKILL.md +96 -442
- package/kits/builder/skills/design-probe/SKILL.md +40 -139
- package/kits/builder/skills/evidence-gate/SKILL.md +59 -201
- package/kits/builder/skills/execute-plan/SKILL.md +54 -125
- package/kits/builder/skills/fix-bug/SKILL.md +42 -132
- package/kits/builder/skills/gate-review/SKILL.md +60 -211
- package/kits/builder/skills/idea-to-backlog/SKILL.md +63 -244
- package/kits/builder/skills/learning-review/SKILL.md +63 -170
- package/kits/builder/skills/pickup-probe/SKILL.md +54 -111
- package/kits/builder/skills/plan-work/SKILL.md +51 -185
- package/kits/builder/skills/pull-work/SKILL.md +136 -485
- package/kits/builder/skills/release-readiness/SKILL.md +66 -107
- package/kits/builder/skills/review-work/SKILL.md +89 -176
- package/kits/builder/skills/tdd-workflow/SKILL.md +53 -147
- package/kits/builder/skills/verify-work/SKILL.md +101 -113
- package/package.json +2 -2
- package/packaging/README.md +1 -1
- package/scripts/README.md +1 -1
- package/scripts/hooks/lib/kit-catalog.js +1 -1
- package/scripts/hooks/stop-goal-fit.js +78 -9
- package/scripts/install-codex-home.sh +63 -23
- package/scripts/install-owned-files.js +18 -2
- package/src/builder-flow-run-adapter.ts +118 -32
- package/src/builder-flow-runtime.ts +426 -64
- package/src/cli/assignment-provider-lock.test.mjs +83 -0
- package/src/cli/assignment-provider.ts +75 -14
- package/src/cli/builder-flow-run-adapter.test.mjs +4 -2
- package/src/cli/builder-flow-runtime.test.mjs +436 -18
- package/src/cli/builder-run.ts +3 -9
- package/src/cli/kit-metadata-security.test.mjs +232 -6
- package/src/cli/public-api.test.mjs +15 -0
- package/src/cli/workflow-sidecar-execution-proof.test.mjs +90 -0
- package/src/cli/workflow-sidecar.ts +746 -103
- package/src/cli/workflow.ts +288 -41
- package/src/flow-kit/validate.ts +320 -2
- package/src/index.ts +6 -5
- package/src/lib/observed-command.ts +96 -0
- package/src/tools/build-universal-bundles.ts +51 -3
- package/src/tools/generate-context-map.ts +5 -3
|
@@ -1,31 +1,38 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
4
|
import * as path from "node:path";
|
|
3
5
|
import { fileURLToPath } from "node:url";
|
|
4
6
|
import { isDeepStrictEqual } from "node:util";
|
|
5
7
|
import { flowAgentsPackageVersion } from "./lib/package-version.js";
|
|
6
8
|
import { pinnedFlowAgentsCommand } from "./lib/pinned-cli-command.js";
|
|
7
|
-
import { expectationsForGate, lifecycleRequestMatches, openGates,
|
|
9
|
+
import { expectationsForGate, lifecycleRequestMatches, openGates, } from "@kontourai/flow";
|
|
8
10
|
import { assertAuthorizationUnused, loadBuilderLifecycleAuthorization, readAuthorizationConsumption, recordAuthorizationConsumed } from "./builder-lifecycle-authority.js";
|
|
9
11
|
import { assignmentFilePath, performLocalReleaseUnderLock, readLocalAssignmentStatus, resolveCurrentAssignmentActor, withSubjectLock } from "./cli/assignment-provider.js";
|
|
10
|
-
import { BUILDER_BUILD_FLOW_ID, BuilderBuildRunInputError,
|
|
12
|
+
import { BUILDER_BUILD_FLOW_ID, BuilderBuildRunInputError, cancelBuilderFlowRun, evaluateBuilderFlowRun, loadBuilderFlowRun, pauseBuilderFlowRun, resumeBuilderFlowRun, startBuilderFlowRun, } from "./builder-flow-run-adapter.js";
|
|
11
13
|
export async function startBuilderFlowSession(input) {
|
|
12
14
|
const context = resolveSessionContext(input.sessionDir);
|
|
13
15
|
const sidecarSnapshot = readSidecarSnapshot(context);
|
|
14
16
|
const subject = workflowSubject(sidecarSnapshot.state);
|
|
17
|
+
const requestedFlowId = input.flowId ?? persistedFlowId(sidecarSnapshot.state) ?? BUILDER_BUILD_FLOW_ID;
|
|
15
18
|
let run;
|
|
16
19
|
try {
|
|
17
|
-
run = await
|
|
20
|
+
run = await loadBuilderFlowRun({
|
|
18
21
|
cwd: context.projectRoot,
|
|
19
22
|
runId: context.slug,
|
|
20
23
|
});
|
|
24
|
+
if (run.definitionId !== requestedFlowId) {
|
|
25
|
+
throw new BuilderBuildRunInputError("flowId", `requested ${requestedFlowId} does not match the existing ${run.definitionId} run; start builder.build from a provider Work Item instead of retrying a local shape session`);
|
|
26
|
+
}
|
|
21
27
|
}
|
|
22
28
|
catch (error) {
|
|
23
29
|
if (!isRunNotFound(error))
|
|
24
30
|
throw error;
|
|
25
|
-
run = await
|
|
31
|
+
run = await startBuilderFlowRun({
|
|
26
32
|
cwd: context.projectRoot,
|
|
27
33
|
runId: context.slug,
|
|
28
34
|
subject,
|
|
35
|
+
flowId: requestedFlowId,
|
|
29
36
|
params: {
|
|
30
37
|
subject,
|
|
31
38
|
},
|
|
@@ -38,7 +45,7 @@ export async function syncBuilderFlowSession(input) {
|
|
|
38
45
|
const context = resolveSessionContext(input.sessionDir);
|
|
39
46
|
const sidecarSnapshot = readSidecarSnapshot(context);
|
|
40
47
|
const subject = workflowSubject(sidecarSnapshot.state);
|
|
41
|
-
const run = await
|
|
48
|
+
const run = await loadBuilderFlowRun({
|
|
42
49
|
cwd: context.projectRoot,
|
|
43
50
|
runId: context.slug,
|
|
44
51
|
});
|
|
@@ -49,7 +56,7 @@ export async function recoverBuilderFlowSession(input) {
|
|
|
49
56
|
const context = resolveSessionContext(input.sessionDir);
|
|
50
57
|
const sidecarSnapshot = readSidecarSnapshot(context);
|
|
51
58
|
const subject = workflowSubject(sidecarSnapshot.state);
|
|
52
|
-
const run = await
|
|
59
|
+
const run = await loadBuilderFlowRun({
|
|
53
60
|
cwd: context.projectRoot,
|
|
54
61
|
runId: context.slug,
|
|
55
62
|
});
|
|
@@ -64,6 +71,20 @@ export async function recoverBuilderFlowSession(input) {
|
|
|
64
71
|
attached: false,
|
|
65
72
|
};
|
|
66
73
|
}
|
|
74
|
+
export async function inspectBuilderFlowSession(input) {
|
|
75
|
+
const context = resolveSessionContext(input.sessionDir);
|
|
76
|
+
const sidecarSnapshot = readSidecarSnapshot(context);
|
|
77
|
+
const subject = workflowSubject(sidecarSnapshot.state);
|
|
78
|
+
const run = await loadBuilderFlowRun({ cwd: context.projectRoot, runId: context.slug });
|
|
79
|
+
assertRunSubjectBinding(run, subject);
|
|
80
|
+
return {
|
|
81
|
+
sessionDir: context.sessionDir,
|
|
82
|
+
projectRoot: context.projectRoot,
|
|
83
|
+
run,
|
|
84
|
+
projection: projectFlowRun(context, run, sidecarSnapshot.state),
|
|
85
|
+
attached: false,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
67
88
|
export async function pauseBuilderFlowSession(input) {
|
|
68
89
|
return changeBuilderFlowSessionLifecycle(input, "pause");
|
|
69
90
|
}
|
|
@@ -75,7 +96,7 @@ export async function cancelBuilderFlowSession(input) {
|
|
|
75
96
|
return await withSubjectLock(context.artifactRoot, context.slug, async () => {
|
|
76
97
|
const prepared = await prepareAuthorizedLifecycleChange(input, "cancel", context);
|
|
77
98
|
assertAuthorizationUnused(prepared.context.artifactRoot, prepared.authorization);
|
|
78
|
-
const changed = await
|
|
99
|
+
const changed = await cancelBuilderFlowRun({ cwd: prepared.context.projectRoot, runId: prepared.context.slug, request: prepared.authorization.request });
|
|
79
100
|
const released = performLocalReleaseUnderLock(prepared.context.artifactRoot, prepared.context.slug, prepared.authorization.assignment_actor, {
|
|
80
101
|
actorKey: prepared.authorization.assignment_actor_key,
|
|
81
102
|
reason: `canonical Flow run canceled by ${prepared.authorization.request.authority.request_ref}`,
|
|
@@ -91,7 +112,7 @@ export async function releaseBuilderFlowAssignment(input) {
|
|
|
91
112
|
const context = resolveSessionContext(input.sessionDir);
|
|
92
113
|
return await withSubjectLock(context.artifactRoot, context.slug, async () => {
|
|
93
114
|
const prepared = prepareAgentLifecycleChange(input, context);
|
|
94
|
-
const run = await
|
|
115
|
+
const run = await loadBuilderFlowRun({ cwd: context.projectRoot, runId: context.slug });
|
|
95
116
|
const released = performLocalReleaseUnderLock(context.artifactRoot, context.slug, prepared.actor, { actorKey: prepared.actorKey, reason: input.reason });
|
|
96
117
|
return { sessionDir: context.sessionDir, projectRoot: context.projectRoot, run, projection: prepared.sidecarSnapshot.state, attached: false, assignmentReleased: released !== null };
|
|
97
118
|
});
|
|
@@ -104,7 +125,7 @@ export async function archiveBuilderFlowSession(input) {
|
|
|
104
125
|
const recoveringPreparedArchive = priorConsumption !== null && prepared.sidecarSnapshot.state.status === "archived";
|
|
105
126
|
if (priorConsumption && !recoveringPreparedArchive)
|
|
106
127
|
throw new Error("lifecycle authorization nonce has already been consumed");
|
|
107
|
-
const run = await
|
|
128
|
+
const run = await loadBuilderFlowRun({ cwd: prepared.context.projectRoot, runId: prepared.context.slug });
|
|
108
129
|
if (run.state.status !== "completed" && run.state.status !== "canceled") {
|
|
109
130
|
throw new BuilderBuildRunInputError("flow_run.status", "must be completed or canceled before archival");
|
|
110
131
|
}
|
|
@@ -145,7 +166,7 @@ async function changeBuilderFlowSessionLifecycle(input, operation) {
|
|
|
145
166
|
const context = resolveSessionContext(input.sessionDir);
|
|
146
167
|
return await withSubjectLock(context.artifactRoot, context.slug, async () => {
|
|
147
168
|
const prepared = prepareAgentLifecycleChange(input, context);
|
|
148
|
-
const change = operation === "pause" ?
|
|
169
|
+
const change = operation === "pause" ? pauseBuilderFlowRun : resumeBuilderFlowRun;
|
|
149
170
|
const at = new Date().toISOString();
|
|
150
171
|
const run = await change({
|
|
151
172
|
cwd: context.projectRoot,
|
|
@@ -171,7 +192,7 @@ async function prepareAuthorizedLifecycleChange(input, operation, context) {
|
|
|
171
192
|
const persistedAssignment = pathExistsNoFollow(assignmentFile)
|
|
172
193
|
? (assertSafeFile(assignmentFile, context.artifactRoot, "assignment record"), JSON.parse(fs.readFileSync(assignmentFile, "utf8")))
|
|
173
194
|
: null;
|
|
174
|
-
const canonicalRun = await
|
|
195
|
+
const canonicalRun = await loadBuilderFlowRun({ cwd: context.projectRoot, runId: context.slug });
|
|
175
196
|
const acceptsReleasedAssignment = (operation === "cancel" && canonicalRun.state.status === "canceled") || operation === "archive";
|
|
176
197
|
const assignment = activeAssignment ?? (acceptsReleasedAssignment && persistedAssignment?.status === "released" ? persistedAssignment : null);
|
|
177
198
|
if (!assignment || (assignment.status !== "claimed" && !acceptsReleasedAssignment) || !assignment.actor_key) {
|
|
@@ -245,20 +266,6 @@ function clearCurrentPointers(artifactRoot, slug) {
|
|
|
245
266
|
fs.unlinkSync(file);
|
|
246
267
|
}
|
|
247
268
|
}
|
|
248
|
-
export async function syncBuilderFlowSessionIfPresent(sessionDir) {
|
|
249
|
-
let context;
|
|
250
|
-
try {
|
|
251
|
-
context = resolveSessionContext(sessionDir);
|
|
252
|
-
}
|
|
253
|
-
catch (error) {
|
|
254
|
-
if (error instanceof BuilderBuildRunInputError && error.field === "sessionDir")
|
|
255
|
-
return null;
|
|
256
|
-
throw error;
|
|
257
|
-
}
|
|
258
|
-
if (!fs.existsSync(runDir(context.slug, context.projectRoot)))
|
|
259
|
-
return null;
|
|
260
|
-
return syncBuilderFlowSession({ sessionDir });
|
|
261
|
-
}
|
|
262
269
|
async function syncAndProject(context, initial, sidecarSnapshot) {
|
|
263
270
|
let run = initial;
|
|
264
271
|
let attached = false;
|
|
@@ -267,25 +274,40 @@ async function syncAndProject(context, initial, sidecarSnapshot) {
|
|
|
267
274
|
throw new BuilderBuildRunInputError("flow_run.open_gates", `expected exactly one gate for active step ${run.state.current_step}, found ${gates.length}`);
|
|
268
275
|
}
|
|
269
276
|
if (gates.length === 1 && fs.existsSync(context.bundleFile)) {
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
277
|
+
const snapshot = stageTrustBundleSnapshot(context);
|
|
278
|
+
try {
|
|
279
|
+
const rawBundle = JSON.parse(snapshot.raw.toString("utf8"));
|
|
280
|
+
const gateEvidence = await bundleGateEvidence(rawBundle, gates[0], run.state, run.state.subject, context.projectRoot, manifestEvidence(run.manifest));
|
|
281
|
+
if (gateEvidence) {
|
|
282
|
+
const alreadyAttached = manifestEvidence(run.manifest).some((entry) => entry.gate_id === gates[0].id
|
|
283
|
+
&& entry.sha256 === snapshot.sha256
|
|
284
|
+
&& typeof entry.superseded_by !== "string"
|
|
285
|
+
&& timestampAtOrAfter(entry.attached_at, gateEvidence.visitEnteredAt)
|
|
286
|
+
&& gateEvidence.expectationIds.every((expectationId) => Array.isArray(entry.expectation_ids) && entry.expectation_ids.includes(expectationId)));
|
|
287
|
+
if (!alreadyAttached) {
|
|
288
|
+
const supersede = manifestEvidence(run.manifest)
|
|
289
|
+
.filter((entry) => entry.gate_id === gates[0].id && typeof entry.superseded_by !== "string")
|
|
290
|
+
.map((entry) => String(entry.id));
|
|
291
|
+
run = await evaluateBuilderFlowRun({
|
|
292
|
+
cwd: context.projectRoot,
|
|
293
|
+
runId: context.slug,
|
|
294
|
+
evidence: {
|
|
295
|
+
gate: gates[0].id,
|
|
296
|
+
file: path.relative(context.projectRoot, snapshot.file),
|
|
297
|
+
expectedSha256: snapshot.sha256,
|
|
298
|
+
...(supersede.length > 0 ? { supersede } : {}),
|
|
299
|
+
...(gateEvidence.failed ? { status: "failed" } : {}),
|
|
300
|
+
...(gateEvidence.routeReason ? { routeReason: gateEvidence.routeReason } : {}),
|
|
301
|
+
expectationIds: gateEvidence.expectationIds,
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
attached = true;
|
|
305
|
+
}
|
|
287
306
|
}
|
|
288
307
|
}
|
|
308
|
+
finally {
|
|
309
|
+
removeTrustBundleSnapshot(snapshot);
|
|
310
|
+
}
|
|
289
311
|
}
|
|
290
312
|
const projection = projectFlowRun(context, run, sidecarSnapshot.state);
|
|
291
313
|
writeProjection(context, projection, sidecarSnapshot.raw, "projection");
|
|
@@ -352,18 +374,75 @@ function workflowSubject(state) {
|
|
|
352
374
|
}
|
|
353
375
|
return refs[0];
|
|
354
376
|
}
|
|
377
|
+
function persistedFlowId(state) {
|
|
378
|
+
const flowRun = isRecord(state.flow_run) ? state.flow_run : null;
|
|
379
|
+
const flowId = flowRun?.definition_id;
|
|
380
|
+
return flowId === "builder.build" || flowId === "builder.shape" ? flowId : null;
|
|
381
|
+
}
|
|
355
382
|
function openGatesForResult(run) {
|
|
356
383
|
return openGates(JSON.parse(fs.readFileSync(path.join(run.dir, "definition.json"), "utf8")), run.state);
|
|
357
384
|
}
|
|
358
|
-
function bundleGateEvidence(bundle, gate, subject) {
|
|
385
|
+
async function bundleGateEvidence(bundle, gate, state, subject, projectRoot, manifest) {
|
|
359
386
|
if (!isRecord(bundle) || !Array.isArray(bundle.claims))
|
|
360
387
|
return null;
|
|
361
|
-
const
|
|
388
|
+
const expectations = expectationsForGate(gate);
|
|
389
|
+
const visit = currentGateVisit(state, String(gate.step));
|
|
390
|
+
const enteredAt = visit.enteredAt;
|
|
391
|
+
const synchronizedAt = Date.now();
|
|
392
|
+
const maxClockSkewMs = 30_000;
|
|
393
|
+
const priorVisitClaimIds = new Set();
|
|
394
|
+
const priorVisitEvidenceIds = new Set();
|
|
395
|
+
for (const entry of manifest) {
|
|
396
|
+
if (entry.gate_id !== String(gate.id))
|
|
397
|
+
continue;
|
|
398
|
+
const claims = isRecord(entry.bundle) && Array.isArray(entry.bundle.claims) ? entry.bundle.claims : [];
|
|
399
|
+
for (const historical of claims) {
|
|
400
|
+
if (isRecord(historical) && typeof historical.id === "string")
|
|
401
|
+
priorVisitClaimIds.add(historical.id);
|
|
402
|
+
}
|
|
403
|
+
const evidence = isRecord(entry.bundle) && Array.isArray(entry.bundle.evidence) ? entry.bundle.evidence : [];
|
|
404
|
+
for (const historical of evidence) {
|
|
405
|
+
if (isRecord(historical) && typeof historical.id === "string")
|
|
406
|
+
priorVisitEvidenceIds.add(historical.id);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
const claimIsCurrent = (claim) => {
|
|
410
|
+
if (typeof claim.id !== "string" || priorVisitClaimIds.has(claim.id))
|
|
411
|
+
return false;
|
|
412
|
+
const timestamps = [];
|
|
413
|
+
const createdAt = parseTimestamp(claim.createdAt);
|
|
414
|
+
if (createdAt !== null)
|
|
415
|
+
timestamps.push(createdAt);
|
|
416
|
+
if (Array.isArray(bundle.evidence))
|
|
417
|
+
for (const evidence of bundle.evidence) {
|
|
418
|
+
if (!isRecord(evidence) || evidence.claimId !== claim.id)
|
|
419
|
+
continue;
|
|
420
|
+
if (typeof evidence.id !== "string" || priorVisitEvidenceIds.has(evidence.id))
|
|
421
|
+
return false;
|
|
422
|
+
const observedAt = parseTimestamp(evidence.observedAt);
|
|
423
|
+
if (observedAt !== null)
|
|
424
|
+
timestamps.push(observedAt);
|
|
425
|
+
}
|
|
426
|
+
const initialAcquisitionSkew = visit.initial && claim.claimType === "builder.pull-work.selected" ? maxClockSkewMs : 0;
|
|
427
|
+
return timestamps.some((timestamp) => timestamp >= enteredAt - initialAcquisitionSkew
|
|
428
|
+
&& timestamp <= synchronizedAt + maxClockSkewMs);
|
|
429
|
+
};
|
|
362
430
|
const relevant = bundle.claims.filter((claim) => {
|
|
363
431
|
if (!isRecord(claim))
|
|
364
432
|
return false;
|
|
365
|
-
|
|
366
|
-
|
|
433
|
+
if (claim.producerStatus === "superseded")
|
|
434
|
+
return false;
|
|
435
|
+
const metadata = isRecord(claim.metadata) ? claim.metadata : null;
|
|
436
|
+
if (metadata && typeof metadata.superseded_by === "string")
|
|
437
|
+
return false;
|
|
438
|
+
return expectations.some((expectation) => {
|
|
439
|
+
const candidate = expectation.bundle_claim;
|
|
440
|
+
return candidate
|
|
441
|
+
&& claimIsCurrent(claim)
|
|
442
|
+
&&
|
|
443
|
+
candidate.claimType === claim.claimType
|
|
444
|
+
&& (!candidate.subjectType || candidate.subjectType === claim.subjectType);
|
|
445
|
+
});
|
|
367
446
|
});
|
|
368
447
|
if (relevant.length === 0)
|
|
369
448
|
return null;
|
|
@@ -371,6 +450,11 @@ function bundleGateEvidence(bundle, gate, subject) {
|
|
|
371
450
|
throw new BuilderBuildRunInputError("evidence.claims.metadata.workflow_subject_ref", "must match the persisted run subject");
|
|
372
451
|
}
|
|
373
452
|
const failed = relevant.some((claim) => claim.value === "fail" || claim.status === "disputed");
|
|
453
|
+
const expectationIds = expectations.filter((expectation) => relevant.some((claim) => {
|
|
454
|
+
const selector = expectation.bundle_claim;
|
|
455
|
+
return selector.claimType === claim.claimType && (!selector.subjectType || selector.subjectType === claim.subjectType);
|
|
456
|
+
})).map((expectation) => expectation.id);
|
|
457
|
+
const missingRequired = expectations.filter((expectation) => expectation.required && !expectationIds.includes(expectation.id));
|
|
374
458
|
const routeReasons = [...new Set(relevant.flatMap((claim) => {
|
|
375
459
|
const metadata = isRecord(claim.metadata) ? claim.metadata : null;
|
|
376
460
|
const gateClaim = metadata && isRecord(metadata.gate_claim) ? metadata.gate_claim : null;
|
|
@@ -380,6 +464,13 @@ function bundleGateEvidence(bundle, gate, subject) {
|
|
|
380
464
|
throw new BuilderBuildRunInputError("evidence.claims.metadata.gate_claim.route_reason", "must agree across current-gate claims");
|
|
381
465
|
}
|
|
382
466
|
const routeReason = routeReasons[0] ?? null;
|
|
467
|
+
if (failed && !routeReason)
|
|
468
|
+
return null;
|
|
469
|
+
// Passing evidence waits for the complete expectation set. A failing
|
|
470
|
+
// snapshot is complete only when a gate producer explicitly declares its
|
|
471
|
+
// route reason; report-only disputed critique state remains pending.
|
|
472
|
+
if (!failed && missingRequired.length > 0)
|
|
473
|
+
return null;
|
|
383
474
|
if (routeReason && !failed) {
|
|
384
475
|
throw new BuilderBuildRunInputError("evidence.claims.metadata.gate_claim.route_reason", "requires failed current-gate evidence");
|
|
385
476
|
}
|
|
@@ -387,7 +478,272 @@ function bundleGateEvidence(bundle, gate, subject) {
|
|
|
387
478
|
if (routeReason && (!routeMap || typeof routeMap[routeReason] !== "string")) {
|
|
388
479
|
throw new BuilderBuildRunInputError("evidence.claims.metadata.gate_claim.route_reason", `is not declared by gate ${String(gate.id ?? "<unknown>")}`);
|
|
389
480
|
}
|
|
390
|
-
|
|
481
|
+
if (String(gate.id) === "verify-gate" && relevant.some((claim) => claim.claimType === "builder.verify.tests" && claim.value === "pass")) {
|
|
482
|
+
await assertVerifiedTestsTrust(bundle.claims, projectRoot);
|
|
483
|
+
}
|
|
484
|
+
return { failed, routeReason, expectationIds, visitEnteredAt: enteredAt };
|
|
485
|
+
}
|
|
486
|
+
function currentGateVisit(state, step) {
|
|
487
|
+
let enteredAt = null;
|
|
488
|
+
for (const transition of state.transitions ?? []) {
|
|
489
|
+
if (transition.to_step !== step)
|
|
490
|
+
continue;
|
|
491
|
+
const parsed = parseTimestamp(transition.at);
|
|
492
|
+
if (parsed !== null)
|
|
493
|
+
enteredAt = parsed;
|
|
494
|
+
}
|
|
495
|
+
const initial = parseTimestamp(state.updated_at);
|
|
496
|
+
if (enteredAt !== null)
|
|
497
|
+
return { enteredAt, initial: false };
|
|
498
|
+
if (initial !== null)
|
|
499
|
+
return { enteredAt: initial, initial: true };
|
|
500
|
+
throw new BuilderBuildRunInputError("flow_run.state.updated_at", "must establish the current gate visit boundary");
|
|
501
|
+
}
|
|
502
|
+
function parseTimestamp(value) {
|
|
503
|
+
if (typeof value !== "string")
|
|
504
|
+
return null;
|
|
505
|
+
const parsed = Date.parse(value);
|
|
506
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
507
|
+
}
|
|
508
|
+
function timestampAtOrAfter(value, boundary) {
|
|
509
|
+
const parsed = parseTimestamp(value);
|
|
510
|
+
return parsed !== null && parsed >= boundary;
|
|
511
|
+
}
|
|
512
|
+
async function assertVerifiedTestsTrust(claims, projectRoot) {
|
|
513
|
+
const testClaims = claims.filter((claim) => isRecord(claim)
|
|
514
|
+
&& claim.claimType === "builder.verify.tests"
|
|
515
|
+
&& claim.value === "pass"
|
|
516
|
+
&& isRecord(claim.metadata)
|
|
517
|
+
&& isRecord(claim.metadata.gate_claim)
|
|
518
|
+
&& claim.metadata.gate_claim.expectation_id === "tests-evidence");
|
|
519
|
+
if (testClaims.length === 0)
|
|
520
|
+
throw new BuilderBuildRunInputError("evidence.tests", "is missing a passing tests-evidence claim");
|
|
521
|
+
const liveCritiques = claims.filter((claim) => isRecord(claim)
|
|
522
|
+
&& isRecord(claim.metadata)
|
|
523
|
+
&& claim.metadata.origin === "critique"
|
|
524
|
+
&& typeof claim.metadata.superseded_by !== "string");
|
|
525
|
+
if (liveCritiques.length === 0 || liveCritiques.some((claim) => !isSubstantivePassingCritique(claim))) {
|
|
526
|
+
throw new BuilderBuildRunInputError("evidence.critique", "a passing tests-evidence claim requires a current clean critique");
|
|
527
|
+
}
|
|
528
|
+
const implementationActors = new Set(testClaims.map((claim) => claim.metadata.recorded_by).filter((actor) => typeof actor === "string" && actor.length > 0));
|
|
529
|
+
if (implementationActors.size !== 1 || liveCritiques.some((claim) => typeof claim.metadata.reviewer !== "string" || implementationActors.has(claim.metadata.reviewer))) {
|
|
530
|
+
throw new BuilderBuildRunInputError("evidence.critique.reviewer", "must identify a reviewer distinct from the tests-evidence implementation actor");
|
|
531
|
+
}
|
|
532
|
+
await Promise.all(liveCritiques.flatMap(async (claim) => {
|
|
533
|
+
const artifacts = reviewedArtifacts(claim);
|
|
534
|
+
await Promise.all(artifacts.map((artifact) => assertReviewedArtifactDigest(artifact, projectRoot)));
|
|
535
|
+
assertReviewedWorkspaceSnapshot(claim, artifacts, projectRoot);
|
|
536
|
+
}));
|
|
537
|
+
const criteria = claims.filter((claim) => isRecord(claim) && isRecord(claim.metadata) && claim.metadata.origin === "acceptance");
|
|
538
|
+
if (criteria.length === 0 || criteria.some((claim) => {
|
|
539
|
+
const criterion = isRecord(claim.metadata.criterion) ? claim.metadata.criterion : null;
|
|
540
|
+
return claim.value !== "pass" || !criterion || !Array.isArray(criterion.evidence_refs) || criterion.evidence_refs.length === 0;
|
|
541
|
+
})) {
|
|
542
|
+
throw new BuilderBuildRunInputError("evidence.acceptance", "a passing tests-evidence claim requires complete verified acceptance criteria");
|
|
543
|
+
}
|
|
544
|
+
for (const testClaim of testClaims)
|
|
545
|
+
assertObservedTestsEvidence(testClaim, criteria);
|
|
546
|
+
}
|
|
547
|
+
function assertObservedTestsEvidence(testClaim, criteria) {
|
|
548
|
+
const observed = testClaim.metadata.observed_commands;
|
|
549
|
+
if (!Array.isArray(observed) || observed.length === 0) {
|
|
550
|
+
throw new BuilderBuildRunInputError("evidence.tests.observed_commands", "must contain successful command observations");
|
|
551
|
+
}
|
|
552
|
+
const commands = new Set();
|
|
553
|
+
for (const entry of observed) {
|
|
554
|
+
if (!isRecord(entry) || typeof entry.command !== "string" || entry.exit_code !== 0 || !Number.isSafeInteger(entry.test_count) || Number(entry.test_count) <= 0 || typeof entry.output_sha256 !== "string" || !/^[a-f0-9]{64}$/i.test(entry.output_sha256) || commands.has(entry.command)) {
|
|
555
|
+
throw new BuilderBuildRunInputError("evidence.tests.observed_commands", "must contain unique commands with exit_code 0, a positive executed-test count, and SHA-256 output digests");
|
|
556
|
+
}
|
|
557
|
+
commands.add(entry.command);
|
|
558
|
+
}
|
|
559
|
+
const topRefs = Array.isArray(testClaim.metadata.artifact_refs) ? testClaim.metadata.artifact_refs : [];
|
|
560
|
+
const topCommands = new Set(topRefs.flatMap((ref) => isRecord(ref) && ref.kind === "command" && typeof ref.excerpt === "string" ? [ref.excerpt.trim()] : []));
|
|
561
|
+
if ([...commands].some((command) => !topCommands.has(command))) {
|
|
562
|
+
throw new BuilderBuildRunInputError("evidence.tests.artifact_refs", "must reference every successful observed command exactly");
|
|
563
|
+
}
|
|
564
|
+
const criterionCommands = new Set();
|
|
565
|
+
for (const claim of criteria) {
|
|
566
|
+
const criterion = claim.metadata.criterion;
|
|
567
|
+
const refs = Array.isArray(criterion.evidence_refs) ? criterion.evidence_refs : [];
|
|
568
|
+
const matched = refs.flatMap((ref) => isRecord(ref) && ref.kind === "command" && typeof ref.excerpt === "string" && commands.has(ref.excerpt.trim()) ? [ref.excerpt.trim()] : []);
|
|
569
|
+
if (matched.length === 0)
|
|
570
|
+
throw new BuilderBuildRunInputError("evidence.acceptance.evidence_refs", `criterion ${String(criterion.id ?? claim.id)} must reference a successful observed command`);
|
|
571
|
+
matched.forEach((command) => criterionCommands.add(command));
|
|
572
|
+
}
|
|
573
|
+
if ([...commands].some((command) => !criterionCommands.has(command))) {
|
|
574
|
+
throw new BuilderBuildRunInputError("evidence.acceptance.evidence_refs", "must bind every successful observed command to at least one criterion");
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
function isSubstantivePassingCritique(claim) {
|
|
578
|
+
if (claim.value !== "pass" || (Array.isArray(claim.metadata.findings) && claim.metadata.findings.some((finding) => isRecord(finding) && finding.status === "open")))
|
|
579
|
+
return false;
|
|
580
|
+
const lanes = claim.metadata.lanes;
|
|
581
|
+
return Array.isArray(lanes)
|
|
582
|
+
&& lanes.length > 0
|
|
583
|
+
&& lanes.every((lane) => isRecord(lane) && (lane.status === "pass" || lane.verdict === "pass"))
|
|
584
|
+
&& reviewedArtifacts(claim).length > 0;
|
|
585
|
+
}
|
|
586
|
+
function reviewedArtifacts(claim) {
|
|
587
|
+
const reviewTarget = isRecord(claim.metadata.review_target) ? claim.metadata.review_target : null;
|
|
588
|
+
const artifacts = reviewTarget?.artifacts;
|
|
589
|
+
if (!Array.isArray(artifacts) || artifacts.length === 0)
|
|
590
|
+
return [];
|
|
591
|
+
if (!artifacts.every((artifact) => isRecord(artifact) && typeof artifact.file === "string" && typeof artifact.sha256 === "string" && /^[a-f0-9]{64}$/i.test(artifact.sha256)))
|
|
592
|
+
return [];
|
|
593
|
+
return artifacts;
|
|
594
|
+
}
|
|
595
|
+
function assertReviewedWorkspaceSnapshot(claim, artifacts, projectRoot) {
|
|
596
|
+
const reviewTarget = isRecord(claim.metadata.review_target) ? claim.metadata.review_target : null;
|
|
597
|
+
const expected = reviewTarget?.workspace_snapshot;
|
|
598
|
+
if (!isRecord(expected)) {
|
|
599
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot", "is required for a passing critique");
|
|
600
|
+
}
|
|
601
|
+
const current = captureReviewWorkspaceSnapshot(projectRoot, expected.kind === "reviewed-files"
|
|
602
|
+
? reviewedWorkspaceFiles(expected)
|
|
603
|
+
: artifacts.map((artifact) => ({ file: artifact.file, sha256: artifact.sha256 })));
|
|
604
|
+
if (expected.version !== current.version || expected.kind !== current.kind || expected.algorithm !== current.algorithm) {
|
|
605
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot", "does not match the current workspace snapshot strategy");
|
|
606
|
+
}
|
|
607
|
+
if (expected.digest !== current.digest) {
|
|
608
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.digest", "does not match the current implementation workspace");
|
|
609
|
+
}
|
|
610
|
+
if (current.kind === "git-worktree" && expected.head_sha !== current.head_sha) {
|
|
611
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.head_sha", "does not match the current Git HEAD");
|
|
612
|
+
}
|
|
613
|
+
if (current.kind === "reviewed-files" && !isDeepStrictEqual(expected.files, current.files)) {
|
|
614
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.files", "does not match the explicitly reviewed files");
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
function gitWorktreeSnapshot(projectRoot) {
|
|
618
|
+
const root = fs.realpathSync(projectRoot);
|
|
619
|
+
const hasGitMarker = fs.existsSync(path.join(root, ".git"));
|
|
620
|
+
try {
|
|
621
|
+
const gitRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
622
|
+
if (!gitRoot || fs.realpathSync(gitRoot) !== root) {
|
|
623
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot", "requires the canonical project root to match the Git worktree root");
|
|
624
|
+
}
|
|
625
|
+
const headSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
626
|
+
const trackedDiff = execFileSync("git", ["diff", "--binary", "--no-ext-diff", "HEAD", "--"], { cwd: root, encoding: "buffer", stdio: ["ignore", "pipe", "ignore"] });
|
|
627
|
+
const untracked = execFileSync("git", ["ls-files", "--others", "--exclude-standard", "-z"], { cwd: root, encoding: "buffer", stdio: ["ignore", "pipe", "ignore"] })
|
|
628
|
+
.toString("utf8").split("\0").filter(Boolean).sort();
|
|
629
|
+
const hash = createHash("sha256");
|
|
630
|
+
hash.update("flow-agents:git-worktree:v1\0");
|
|
631
|
+
hash.update(headSha).update("\0");
|
|
632
|
+
hash.update(trackedDiff).update("\0");
|
|
633
|
+
for (const file of untracked) {
|
|
634
|
+
const absolute = path.resolve(root, file);
|
|
635
|
+
if (!pathIsWithin(absolute, root))
|
|
636
|
+
throw new Error("untracked file escapes repository root");
|
|
637
|
+
const stat = fs.lstatSync(absolute);
|
|
638
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
639
|
+
throw new Error("untracked entry is not a regular file");
|
|
640
|
+
hash.update(file).update("\0").update(fs.readFileSync(absolute)).update("\0");
|
|
641
|
+
}
|
|
642
|
+
return { version: 1, kind: "git-worktree", algorithm: "sha256", digest: hash.digest("hex"), head_sha: headSha };
|
|
643
|
+
}
|
|
644
|
+
catch (error) {
|
|
645
|
+
if (hasGitMarker || error instanceof BuilderBuildRunInputError) {
|
|
646
|
+
if (error instanceof BuilderBuildRunInputError)
|
|
647
|
+
throw error;
|
|
648
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot", `could not inspect the Git worktree: ${error instanceof Error ? error.message : String(error)}`);
|
|
649
|
+
}
|
|
650
|
+
return null;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
export function captureReviewWorkspaceSnapshot(projectRoot, reviewedFiles) {
|
|
654
|
+
return gitWorktreeSnapshot(projectRoot) ?? reviewedFilesSnapshot(projectRoot, reviewedFiles);
|
|
655
|
+
}
|
|
656
|
+
function reviewedWorkspaceFiles(snapshot) {
|
|
657
|
+
if (!Array.isArray(snapshot.files) || snapshot.files.length === 0
|
|
658
|
+
|| !snapshot.files.every((file) => isRecord(file) && typeof file.file === "string" && /^[a-f0-9]{64}$/i.test(String(file.sha256)))) {
|
|
659
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.files", "must list explicitly reviewed files with SHA-256 digests");
|
|
660
|
+
}
|
|
661
|
+
const files = snapshot.files.map((file) => ({ file: file.file, sha256: file.sha256 })).sort((left, right) => left.file.localeCompare(right.file));
|
|
662
|
+
if (new Set(files.map((file) => file.file)).size !== files.length) {
|
|
663
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.files", "must not contain duplicate files");
|
|
664
|
+
}
|
|
665
|
+
return files;
|
|
666
|
+
}
|
|
667
|
+
function reviewedFilesSnapshot(projectRoot, reviewedFiles) {
|
|
668
|
+
const files = reviewedFiles.map((file) => ({ ...file }));
|
|
669
|
+
const hash = createHash("sha256");
|
|
670
|
+
hash.update("flow-agents:reviewed-files:v1\0");
|
|
671
|
+
for (const artifact of files) {
|
|
672
|
+
const absolute = safeReviewedArtifactPath(projectRoot, artifact.file);
|
|
673
|
+
hash.update(artifact.file).update("\0").update(fs.readFileSync(absolute)).update("\0");
|
|
674
|
+
}
|
|
675
|
+
return { version: 1, kind: "reviewed-files", algorithm: "sha256", digest: hash.digest("hex"), files };
|
|
676
|
+
}
|
|
677
|
+
async function assertReviewedArtifactDigest(artifact, projectRoot) {
|
|
678
|
+
const canonicalArtifact = safeReviewedArtifactPath(projectRoot, artifact.file);
|
|
679
|
+
if (createHash("sha256").update(fs.readFileSync(canonicalArtifact)).digest("hex") !== artifact.sha256) {
|
|
680
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.artifacts.sha256", `does not match ${artifact.file}`);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
function safeReviewedArtifactPath(projectRoot, file) {
|
|
684
|
+
const canonicalRoot = fs.realpathSync(projectRoot);
|
|
685
|
+
const candidate = path.resolve(canonicalRoot, file);
|
|
686
|
+
const relative = path.relative(canonicalRoot, candidate);
|
|
687
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
688
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.artifacts.file", "must resolve within the canonical project root");
|
|
689
|
+
}
|
|
690
|
+
let canonicalArtifact;
|
|
691
|
+
try {
|
|
692
|
+
canonicalArtifact = fs.realpathSync(candidate);
|
|
693
|
+
}
|
|
694
|
+
catch {
|
|
695
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.artifacts.file", `is missing: ${file}`);
|
|
696
|
+
}
|
|
697
|
+
const canonicalRelative = path.relative(canonicalRoot, canonicalArtifact);
|
|
698
|
+
if (canonicalRelative === ".." || canonicalRelative.startsWith(`..${path.sep}`) || path.isAbsolute(canonicalRelative) || !fs.statSync(canonicalArtifact).isFile()) {
|
|
699
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.artifacts.file", "must be a regular file within the canonical project root");
|
|
700
|
+
}
|
|
701
|
+
return canonicalArtifact;
|
|
702
|
+
}
|
|
703
|
+
function stageTrustBundleSnapshot(context) {
|
|
704
|
+
assertSafeFile(context.bundleFile, context.sessionDir, "trust.bundle");
|
|
705
|
+
const source = fs.openSync(context.bundleFile, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
706
|
+
let raw;
|
|
707
|
+
try {
|
|
708
|
+
const stat = fs.fstatSync(source);
|
|
709
|
+
if (!stat.isFile())
|
|
710
|
+
throw new BuilderBuildRunInputError("trust.bundle", "must be a regular file");
|
|
711
|
+
raw = fs.readFileSync(source);
|
|
712
|
+
}
|
|
713
|
+
finally {
|
|
714
|
+
fs.closeSync(source);
|
|
715
|
+
}
|
|
716
|
+
const directory = path.join(context.sessionDir, ".trust-bundle-snapshots");
|
|
717
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
718
|
+
assertSafeDirectory(directory, context.sessionDir, "trust.bundle snapshot directory");
|
|
719
|
+
fs.chmodSync(directory, 0o700);
|
|
720
|
+
const file = path.join(directory, `${randomBytes(16).toString("hex")}.json`);
|
|
721
|
+
const descriptor = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
|
|
722
|
+
try {
|
|
723
|
+
fs.writeFileSync(descriptor, raw);
|
|
724
|
+
fs.fsyncSync(descriptor);
|
|
725
|
+
}
|
|
726
|
+
finally {
|
|
727
|
+
fs.closeSync(descriptor);
|
|
728
|
+
}
|
|
729
|
+
fs.chmodSync(file, 0o400);
|
|
730
|
+
return { file, raw, sha256: createHash("sha256").update(raw).digest("hex") };
|
|
731
|
+
}
|
|
732
|
+
function removeTrustBundleSnapshot(snapshot) {
|
|
733
|
+
try {
|
|
734
|
+
fs.unlinkSync(snapshot.file);
|
|
735
|
+
}
|
|
736
|
+
catch (error) {
|
|
737
|
+
if (!isRecord(error) || error.code !== "ENOENT")
|
|
738
|
+
throw error;
|
|
739
|
+
}
|
|
740
|
+
try {
|
|
741
|
+
fs.rmdirSync(path.dirname(snapshot.file));
|
|
742
|
+
}
|
|
743
|
+
catch (error) {
|
|
744
|
+
if (!isRecord(error) || (error.code !== "ENOENT" && error.code !== "ENOTEMPTY"))
|
|
745
|
+
throw error;
|
|
746
|
+
}
|
|
391
747
|
}
|
|
392
748
|
function workflowSubjectRef(claim) {
|
|
393
749
|
const metadata = isRecord(claim.metadata) ? claim.metadata : null;
|
|
@@ -404,7 +760,7 @@ function projectFlowRun(context, run, sidecar) {
|
|
|
404
760
|
const complete = run.state.status === "completed";
|
|
405
761
|
const paused = run.state.status === "paused";
|
|
406
762
|
const canceled = run.state.status === "canceled";
|
|
407
|
-
const action = complete || paused || canceled ? { skills: [], operations: [] } : stepAction(run.state.current_step);
|
|
763
|
+
const action = complete || paused || canceled ? { skills: [], operations: [] } : stepAction(run.definitionId, run.state.current_step);
|
|
408
764
|
if (!action) {
|
|
409
765
|
throw new BuilderBuildRunInputError("kit.flow_step_actions", `does not declare Builder step ${run.state.current_step}`);
|
|
410
766
|
}
|
|
@@ -505,7 +861,7 @@ function prepareProjectionWrites(context, projection, expectedStateRaw, operatio
|
|
|
505
861
|
continue;
|
|
506
862
|
const output = {
|
|
507
863
|
...pointer,
|
|
508
|
-
active_flow_id:
|
|
864
|
+
active_flow_id: projection.flow_run.definition_id,
|
|
509
865
|
active_step_id: projection.flow_run.current_step,
|
|
510
866
|
updated_at: projection.updated_at,
|
|
511
867
|
};
|
|
@@ -602,11 +958,11 @@ function writeExistingFileNoFollow(file, content) {
|
|
|
602
958
|
fs.closeSync(descriptor);
|
|
603
959
|
}
|
|
604
960
|
}
|
|
605
|
-
function stepAction(stepId) {
|
|
961
|
+
function stepAction(flowId, stepId) {
|
|
606
962
|
const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot(), "kits", "builder", "kit.json"), "utf8"));
|
|
607
963
|
const actions = Array.isArray(manifest.flow_step_actions) ? manifest.flow_step_actions : [];
|
|
608
964
|
const action = actions.find((candidate) => isRecord(candidate)
|
|
609
|
-
&& candidate.flow_id ===
|
|
965
|
+
&& candidate.flow_id === flowId
|
|
610
966
|
&& candidate.step_id === stepId);
|
|
611
967
|
if (!isRecord(action) || !Array.isArray(action.skills) || !action.skills.every((skill) => typeof skill === "string")) {
|
|
612
968
|
return null;
|
|
@@ -66,13 +66,10 @@ export declare function writeLocalRecord(artifactRoot: string, subjectId: string
|
|
|
66
66
|
* a plain read -> compare-actor -> write with no lock, so two concurrently-launched OS processes
|
|
67
67
|
* could both read "no conflicting claim" before either wrote, and the second write would silently
|
|
68
68
|
* clobber the first with zero error and zero audit-trail entry for the loser (reproduced 29/40
|
|
69
|
-
* races against the built CLI).
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* is reclaimed rather than waited on forever) and a bounded deadline, `finally` rmSync release —
|
|
74
|
-
* as a small LOCAL helper (not a cross-import of that private function, which would pull the
|
|
75
|
-
* entire workflow-sidecar module in for one primitive). Deliberately synchronous (sleepSync's
|
|
69
|
+
* races against the built CLI). Atomic directory creation establishes ownership before metadata
|
|
70
|
+
* is written; contenders treat even an ownerless directory as held. Live contention waits with a
|
|
71
|
+
* bounded deadline; stale or malformed residue fails closed for explicit operator cleanup because portable Node
|
|
72
|
+
* filesystem APIs cannot compare-and-swap a directory identity safely. Deliberately synchronous (sleepSync's
|
|
76
73
|
* Atomics.wait spin, not setTimeout/await) so claim/release/supersede can stay sync `number`
|
|
77
74
|
* -returning functions and the CLI dispatcher (src/cli.ts, `number | Promise<number>`) does not
|
|
78
75
|
* need any ripple to async. On lock-acquire failure (any error other than a live contested lock,
|