@kontourai/flow-agents 3.5.0 → 3.7.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/.github/workflows/ci.yml +4 -0
- package/CHANGELOG.md +15 -0
- package/build/src/builder-flow-run-adapter.d.ts +32 -3
- package/build/src/builder-flow-run-adapter.js +113 -20
- package/build/src/builder-flow-runtime.d.ts +26 -3
- package/build/src/builder-flow-runtime.js +502 -49
- package/build/src/builder-lifecycle-authority.d.ts +35 -0
- package/build/src/builder-lifecycle-authority.js +219 -0
- package/build/src/cli/assignment-provider.d.ts +14 -7
- package/build/src/cli/assignment-provider.js +128 -65
- package/build/src/cli/builder-run.js +46 -9
- package/build/src/cli/workflow-artifact-cleanup-audit.js +3 -0
- package/build/src/cli/workflow-sidecar.d.ts +30 -3
- package/build/src/cli/workflow-sidecar.js +825 -99
- package/build/src/cli/workflow.d.ts +2 -0
- package/build/src/cli/workflow.js +769 -0
- package/build/src/cli.js +2 -0
- package/build/src/flow-kit/validate.d.ts +17 -0
- package/build/src/flow-kit/validate.js +340 -2
- package/build/src/index.d.ts +4 -0
- package/build/src/index.js +7 -5
- package/build/src/lib/observed-command.d.ts +7 -0
- package/build/src/lib/observed-command.js +100 -0
- package/build/src/lib/package-version.d.ts +2 -0
- package/build/src/lib/package-version.js +13 -0
- package/build/src/lib/pinned-cli-command.d.ts +6 -0
- package/build/src/lib/pinned-cli-command.js +21 -0
- package/build/src/tools/generate-context-map.js +5 -3
- package/context/contracts/artifact-contract.md +1 -1
- package/context/scripts/hooks/config-protection.js +8 -1
- package/context/scripts/hooks/lib/config-protection-remedies.js +3 -0
- package/context/scripts/hooks/lib/kit-catalog.js +1 -1
- package/context/scripts/hooks/stop-goal-fit.js +79 -10
- package/docs/context-map.md +24 -20
- package/docs/developer-architecture.md +1 -1
- package/docs/public-workflow-cli.md +132 -0
- package/docs/skills-map.md +51 -27
- package/docs/spec/builder-flow-runtime.md +78 -40
- package/docs/workflow-usage-guide.md +110 -38
- package/evals/ci/run-baseline.sh +2 -0
- package/evals/fixtures/hook-influence/cases.json +2 -2
- package/evals/integration/test_builder_entry_enforcement.sh +57 -45
- package/evals/integration/test_builder_step_producers.sh +297 -6
- package/evals/integration/test_bundle_install.sh +258 -55
- 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_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 +573 -0
- 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_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/schemas/builder-lifecycle-authorization.schema.json +57 -0
- package/schemas/lifecycle-authority-keys.schema.json +25 -0
- package/schemas/workflow-state.schema.json +1 -1
- package/scripts/hooks/config-protection.js +8 -1
- package/scripts/hooks/lib/config-protection-remedies.js +3 -0
- package/scripts/hooks/lib/kit-catalog.js +1 -1
- package/scripts/hooks/stop-goal-fit.js +79 -10
- package/src/builder-flow-run-adapter.ts +156 -23
- package/src/builder-flow-runtime.ts +535 -53
- package/src/builder-lifecycle-authority.ts +218 -0
- package/src/cli/assignment-provider-lock.test.mjs +83 -0
- package/src/cli/assignment-provider.ts +91 -22
- package/src/cli/builder-flow-run-adapter.test.mjs +4 -2
- package/src/cli/builder-flow-runtime.test.mjs +597 -8
- package/src/cli/builder-run.ts +54 -9
- package/src/cli/kit-metadata-security.test.mjs +232 -6
- package/src/cli/public-api.test.mjs +15 -0
- package/src/cli/workflow-artifact-cleanup-audit.ts +3 -0
- package/src/cli/workflow-sidecar-execution-proof.test.mjs +90 -0
- package/src/cli/workflow-sidecar.ts +748 -99
- package/src/cli/workflow.ts +708 -0
- package/src/cli.ts +2 -0
- package/src/flow-kit/validate.ts +320 -2
- package/src/index.ts +20 -5
- package/src/lib/observed-command.ts +96 -0
- package/src/lib/package-version.ts +15 -0
- package/src/lib/pinned-cli-command.ts +23 -0
- package/src/tools/generate-context-map.ts +5 -3
|
@@ -1,26 +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
|
-
import {
|
|
5
|
-
import {
|
|
6
|
+
import { isDeepStrictEqual } from "node:util";
|
|
7
|
+
import { flowAgentsPackageVersion } from "./lib/package-version.js";
|
|
8
|
+
import { pinnedFlowAgentsCommand } from "./lib/pinned-cli-command.js";
|
|
9
|
+
import { expectationsForGate, lifecycleRequestMatches, openGates, } from "@kontourai/flow";
|
|
10
|
+
import { assertAuthorizationUnused, loadBuilderLifecycleAuthorization, readAuthorizationConsumption, recordAuthorizationConsumed } from "./builder-lifecycle-authority.js";
|
|
11
|
+
import { assignmentFilePath, performLocalReleaseUnderLock, readLocalAssignmentStatus, resolveCurrentAssignmentActor, withSubjectLock } from "./cli/assignment-provider.js";
|
|
12
|
+
import { BUILDER_BUILD_FLOW_ID, BuilderBuildRunInputError, cancelBuilderFlowRun, evaluateBuilderFlowRun, loadBuilderFlowRun, pauseBuilderFlowRun, resumeBuilderFlowRun, startBuilderFlowRun, } from "./builder-flow-run-adapter.js";
|
|
6
13
|
export async function startBuilderFlowSession(input) {
|
|
7
14
|
const context = resolveSessionContext(input.sessionDir);
|
|
8
15
|
const sidecarSnapshot = readSidecarSnapshot(context);
|
|
9
16
|
const subject = workflowSubject(sidecarSnapshot.state);
|
|
17
|
+
const requestedFlowId = input.flowId ?? persistedFlowId(sidecarSnapshot.state) ?? BUILDER_BUILD_FLOW_ID;
|
|
10
18
|
let run;
|
|
11
19
|
try {
|
|
12
|
-
run = await
|
|
20
|
+
run = await loadBuilderFlowRun({
|
|
13
21
|
cwd: context.projectRoot,
|
|
14
22
|
runId: context.slug,
|
|
15
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
|
+
}
|
|
16
27
|
}
|
|
17
28
|
catch (error) {
|
|
18
29
|
if (!isRunNotFound(error))
|
|
19
30
|
throw error;
|
|
20
|
-
run = await
|
|
31
|
+
run = await startBuilderFlowRun({
|
|
21
32
|
cwd: context.projectRoot,
|
|
22
33
|
runId: context.slug,
|
|
23
34
|
subject,
|
|
35
|
+
flowId: requestedFlowId,
|
|
24
36
|
params: {
|
|
25
37
|
subject,
|
|
26
38
|
},
|
|
@@ -33,7 +45,7 @@ export async function syncBuilderFlowSession(input) {
|
|
|
33
45
|
const context = resolveSessionContext(input.sessionDir);
|
|
34
46
|
const sidecarSnapshot = readSidecarSnapshot(context);
|
|
35
47
|
const subject = workflowSubject(sidecarSnapshot.state);
|
|
36
|
-
const run = await
|
|
48
|
+
const run = await loadBuilderFlowRun({
|
|
37
49
|
cwd: context.projectRoot,
|
|
38
50
|
runId: context.slug,
|
|
39
51
|
});
|
|
@@ -44,7 +56,7 @@ export async function recoverBuilderFlowSession(input) {
|
|
|
44
56
|
const context = resolveSessionContext(input.sessionDir);
|
|
45
57
|
const sidecarSnapshot = readSidecarSnapshot(context);
|
|
46
58
|
const subject = workflowSubject(sidecarSnapshot.state);
|
|
47
|
-
const run = await
|
|
59
|
+
const run = await loadBuilderFlowRun({
|
|
48
60
|
cwd: context.projectRoot,
|
|
49
61
|
runId: context.slug,
|
|
50
62
|
});
|
|
@@ -59,19 +71,200 @@ export async function recoverBuilderFlowSession(input) {
|
|
|
59
71
|
attached: false,
|
|
60
72
|
};
|
|
61
73
|
}
|
|
62
|
-
export async function
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
+
}
|
|
88
|
+
export async function pauseBuilderFlowSession(input) {
|
|
89
|
+
return changeBuilderFlowSessionLifecycle(input, "pause");
|
|
90
|
+
}
|
|
91
|
+
export async function resumeBuilderFlowSession(input) {
|
|
92
|
+
return changeBuilderFlowSessionLifecycle(input, "resume");
|
|
93
|
+
}
|
|
94
|
+
export async function cancelBuilderFlowSession(input) {
|
|
95
|
+
const context = resolveSessionContext(input.sessionDir);
|
|
96
|
+
return await withSubjectLock(context.artifactRoot, context.slug, async () => {
|
|
97
|
+
const prepared = await prepareAuthorizedLifecycleChange(input, "cancel", context);
|
|
98
|
+
assertAuthorizationUnused(prepared.context.artifactRoot, prepared.authorization);
|
|
99
|
+
const changed = await cancelBuilderFlowRun({ cwd: prepared.context.projectRoot, runId: prepared.context.slug, request: prepared.authorization.request });
|
|
100
|
+
const released = performLocalReleaseUnderLock(prepared.context.artifactRoot, prepared.context.slug, prepared.authorization.assignment_actor, {
|
|
101
|
+
actorKey: prepared.authorization.assignment_actor_key,
|
|
102
|
+
reason: `canonical Flow run canceled by ${prepared.authorization.request.authority.request_ref}`,
|
|
103
|
+
tolerateNoActiveClaim: true,
|
|
104
|
+
});
|
|
105
|
+
const projection = projectFlowRun(prepared.context, changed, prepared.sidecarSnapshot.state);
|
|
106
|
+
writeProjection(prepared.context, projection, prepared.sidecarSnapshot.raw, "cancellation projection");
|
|
107
|
+
recordAuthorizationConsumed(prepared.context.artifactRoot, prepared.authorization);
|
|
108
|
+
return { sessionDir: prepared.context.sessionDir, projectRoot: prepared.context.projectRoot, run: changed, projection, attached: false, assignmentReleased: released !== null, idempotent: changed.idempotent };
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
export async function releaseBuilderFlowAssignment(input) {
|
|
112
|
+
const context = resolveSessionContext(input.sessionDir);
|
|
113
|
+
return await withSubjectLock(context.artifactRoot, context.slug, async () => {
|
|
114
|
+
const prepared = prepareAgentLifecycleChange(input, context);
|
|
115
|
+
const run = await loadBuilderFlowRun({ cwd: context.projectRoot, runId: context.slug });
|
|
116
|
+
const released = performLocalReleaseUnderLock(context.artifactRoot, context.slug, prepared.actor, { actorKey: prepared.actorKey, reason: input.reason });
|
|
117
|
+
return { sessionDir: context.sessionDir, projectRoot: context.projectRoot, run, projection: prepared.sidecarSnapshot.state, attached: false, assignmentReleased: released !== null };
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
export async function archiveBuilderFlowSession(input) {
|
|
121
|
+
const context = resolveSessionContext(input.sessionDir);
|
|
122
|
+
return await withSubjectLock(context.artifactRoot, context.slug, async () => {
|
|
123
|
+
const prepared = await prepareAuthorizedLifecycleChange(input, "archive", context);
|
|
124
|
+
const priorConsumption = readAuthorizationConsumption(prepared.context.artifactRoot, prepared.authorization);
|
|
125
|
+
const recoveringPreparedArchive = priorConsumption !== null && prepared.sidecarSnapshot.state.status === "archived";
|
|
126
|
+
if (priorConsumption && !recoveringPreparedArchive)
|
|
127
|
+
throw new Error("lifecycle authorization nonce has already been consumed");
|
|
128
|
+
const run = await loadBuilderFlowRun({ cwd: prepared.context.projectRoot, runId: prepared.context.slug });
|
|
129
|
+
if (run.state.status !== "completed" && run.state.status !== "canceled") {
|
|
130
|
+
throw new BuilderBuildRunInputError("flow_run.status", "must be completed or canceled before archival");
|
|
131
|
+
}
|
|
132
|
+
const archiveRoot = path.join(prepared.context.artifactRoot, "archive");
|
|
133
|
+
const archiveDir = path.join(archiveRoot, prepared.context.slug);
|
|
134
|
+
if (pathExistsNoFollow(archiveDir))
|
|
135
|
+
throw new BuilderBuildRunInputError("archive", "destination already exists");
|
|
136
|
+
fs.mkdirSync(archiveRoot, { recursive: true });
|
|
137
|
+
assertSafeDirectory(archiveRoot, prepared.context.artifactRoot, "archive root");
|
|
138
|
+
assertSafeDirectory(prepared.context.sessionDir, prepared.context.artifactRoot, "sessionDir");
|
|
139
|
+
if (!recoveringPreparedArchive && fs.readFileSync(prepared.context.stateFile, "utf8") !== prepared.sidecarSnapshot.raw) {
|
|
140
|
+
throw new BuilderBuildRunInputError("state.json", "changed during archive preparation");
|
|
141
|
+
}
|
|
142
|
+
const archivedState = recoveringPreparedArchive ? prepared.sidecarSnapshot.state : {
|
|
143
|
+
...prepared.sidecarSnapshot.state,
|
|
144
|
+
status: "archived",
|
|
145
|
+
phase: "done",
|
|
146
|
+
updated_at: new Date().toISOString(),
|
|
147
|
+
next_action: { status: "done", summary: "Builder session archived; canonical Flow artifacts remain retained." },
|
|
148
|
+
};
|
|
149
|
+
if (!recoveringPreparedArchive) {
|
|
150
|
+
writeExistingFileNoFollow(prepared.context.stateFile, `${JSON.stringify(archivedState, null, 2)}\n`);
|
|
151
|
+
clearCurrentPointers(prepared.context.artifactRoot, prepared.context.slug);
|
|
152
|
+
recordAuthorizationConsumed(prepared.context.artifactRoot, prepared.authorization);
|
|
153
|
+
}
|
|
154
|
+
fs.renameSync(prepared.context.sessionDir, archiveDir);
|
|
155
|
+
return {
|
|
156
|
+
sessionDir: archiveDir,
|
|
157
|
+
projectRoot: prepared.context.projectRoot,
|
|
158
|
+
run,
|
|
159
|
+
projection: archivedState,
|
|
160
|
+
attached: false,
|
|
161
|
+
archiveDir,
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
async function changeBuilderFlowSessionLifecycle(input, operation) {
|
|
166
|
+
const context = resolveSessionContext(input.sessionDir);
|
|
167
|
+
return await withSubjectLock(context.artifactRoot, context.slug, async () => {
|
|
168
|
+
const prepared = prepareAgentLifecycleChange(input, context);
|
|
169
|
+
const change = operation === "pause" ? pauseBuilderFlowRun : resumeBuilderFlowRun;
|
|
170
|
+
const at = new Date().toISOString();
|
|
171
|
+
const run = await change({
|
|
172
|
+
cwd: context.projectRoot,
|
|
173
|
+
runId: context.slug,
|
|
174
|
+
request: { reason: input.reason, authority: { kind: "operator_request", actor: prepared.actorKey, request_ref: `flow-agents://assignment/${context.slug}/${operation}/${at}`, requested_at: at } },
|
|
175
|
+
});
|
|
176
|
+
const projection = projectFlowRun(context, run, prepared.sidecarSnapshot.state);
|
|
177
|
+
writeProjection(context, projection, prepared.sidecarSnapshot.raw, `${operation} projection`);
|
|
178
|
+
return {
|
|
179
|
+
sessionDir: context.sessionDir,
|
|
180
|
+
projectRoot: context.projectRoot,
|
|
181
|
+
run,
|
|
182
|
+
projection,
|
|
183
|
+
attached: false,
|
|
184
|
+
};
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
async function prepareAuthorizedLifecycleChange(input, operation, context) {
|
|
188
|
+
const sidecarSnapshot = readSidecarSnapshot(context);
|
|
189
|
+
const subject = workflowSubject(sidecarSnapshot.state);
|
|
190
|
+
const activeAssignment = readLocalAssignmentStatus(context.artifactRoot, context.slug).record;
|
|
191
|
+
const assignmentFile = assignmentFilePath(context.artifactRoot, context.slug);
|
|
192
|
+
const persistedAssignment = pathExistsNoFollow(assignmentFile)
|
|
193
|
+
? (assertSafeFile(assignmentFile, context.artifactRoot, "assignment record"), JSON.parse(fs.readFileSync(assignmentFile, "utf8")))
|
|
194
|
+
: null;
|
|
195
|
+
const canonicalRun = await loadBuilderFlowRun({ cwd: context.projectRoot, runId: context.slug });
|
|
196
|
+
const acceptsReleasedAssignment = (operation === "cancel" && canonicalRun.state.status === "canceled") || operation === "archive";
|
|
197
|
+
const assignment = activeAssignment ?? (acceptsReleasedAssignment && persistedAssignment?.status === "released" ? persistedAssignment : null);
|
|
198
|
+
if (!assignment || (assignment.status !== "claimed" && !acceptsReleasedAssignment) || !assignment.actor_key) {
|
|
199
|
+
throw new BuilderBuildRunInputError("assignment", "must be actively held by a canonical actor before a lifecycle change");
|
|
66
200
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
201
|
+
if (assignment.work_item_ref && assignment.work_item_ref !== subject) {
|
|
202
|
+
throw new BuilderBuildRunInputError("assignment.work_item_ref", "must match the selected Work Item");
|
|
203
|
+
}
|
|
204
|
+
const authorization = loadBuilderLifecycleAuthorization(input.authorizationFile, {
|
|
205
|
+
projectRoot: context.projectRoot,
|
|
206
|
+
operation,
|
|
207
|
+
runId: context.slug,
|
|
208
|
+
subject,
|
|
209
|
+
actorKey: assignment.actor_key,
|
|
210
|
+
...(operation === "cancel" && canonicalRun.state.status === "canceled" ? { allowExpired: true } : {}),
|
|
211
|
+
...(operation === "archive" && sidecarSnapshot.state.status === "archived" ? { allowExpired: true } : {}),
|
|
212
|
+
});
|
|
213
|
+
if (operation === "cancel" && canonicalRun.state.status === "canceled") {
|
|
214
|
+
const terminalEvent = canonicalRun.state.lifecycle?.at(-1);
|
|
215
|
+
if (!terminalEvent || terminalEvent.action !== "cancel" || !lifecycleRequestMatches(terminalEvent, authorization.request)) {
|
|
216
|
+
throw new BuilderBuildRunInputError("authorization.request", "does not match the canonical cancellation being recovered");
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (!sameActor(authorization.assignment_actor, assignment.actor)) {
|
|
220
|
+
throw new BuilderBuildRunInputError("authorization.assignment_actor", "must match the active assignment holder");
|
|
221
|
+
}
|
|
222
|
+
return { context, sidecarSnapshot, authorization };
|
|
223
|
+
}
|
|
224
|
+
function prepareAgentLifecycleChange(input, context) {
|
|
225
|
+
if (!input.reason.trim())
|
|
226
|
+
throw new BuilderBuildRunInputError("reason", "must be non-empty");
|
|
227
|
+
const resolved = resolveCurrentAssignmentActor();
|
|
228
|
+
const sidecarSnapshot = readSidecarSnapshot(context);
|
|
229
|
+
const subject = workflowSubject(sidecarSnapshot.state);
|
|
230
|
+
const assignment = readLocalAssignmentStatus(context.artifactRoot, context.slug).record;
|
|
231
|
+
if (!assignment || assignment.status !== "claimed" || assignment.actor_key !== resolved.actorKey || !sameActor(assignment.actor, resolved.actor)) {
|
|
232
|
+
throw new BuilderBuildRunInputError("assignment", "must be actively held by the current workflow actor");
|
|
233
|
+
}
|
|
234
|
+
if (assignment.work_item_ref && assignment.work_item_ref !== subject)
|
|
235
|
+
throw new BuilderBuildRunInputError("assignment.work_item_ref", "must match the selected Work Item");
|
|
236
|
+
return { sidecarSnapshot, actor: resolved.actor, actorKey: resolved.actorKey };
|
|
237
|
+
}
|
|
238
|
+
function sameActor(left, right) {
|
|
239
|
+
return isDeepStrictEqual({ ...left, human: left.human ?? null }, { ...right, human: right.human ?? null });
|
|
240
|
+
}
|
|
241
|
+
function clearCurrentPointers(artifactRoot, slug) {
|
|
242
|
+
const candidates = [path.join(artifactRoot, "current.json")];
|
|
243
|
+
const actorRoot = path.join(artifactRoot, "current");
|
|
244
|
+
if (pathExistsNoFollow(actorRoot)) {
|
|
245
|
+
assertSafeDirectory(actorRoot, artifactRoot, "current directory");
|
|
246
|
+
candidates.push(...fs.readdirSync(actorRoot).filter((name) => name.endsWith(".json")).map((name) => path.join(actorRoot, name)));
|
|
247
|
+
}
|
|
248
|
+
for (const file of candidates) {
|
|
249
|
+
if (!pathExistsNoFollow(file) || !fs.lstatSync(file).isFile())
|
|
250
|
+
continue;
|
|
251
|
+
const root = file === candidates[0] ? artifactRoot : actorRoot;
|
|
252
|
+
if (root === actorRoot)
|
|
253
|
+
assertSafeDirectory(actorRoot, artifactRoot, "current directory");
|
|
254
|
+
assertSafeFile(file, root, "current pointer");
|
|
255
|
+
let pointer;
|
|
256
|
+
try {
|
|
257
|
+
pointer = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
if (!(error instanceof SyntaxError))
|
|
261
|
+
throw error;
|
|
262
|
+
// Archival retains malformed unrelated pointers for explicit repair.
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (pointer.active_slug === slug)
|
|
266
|
+
fs.unlinkSync(file);
|
|
71
267
|
}
|
|
72
|
-
if (!fs.existsSync(runDir(context.slug, context.projectRoot)))
|
|
73
|
-
return null;
|
|
74
|
-
return syncBuilderFlowSession({ sessionDir });
|
|
75
268
|
}
|
|
76
269
|
async function syncAndProject(context, initial, sidecarSnapshot) {
|
|
77
270
|
let run = initial;
|
|
@@ -81,25 +274,35 @@ async function syncAndProject(context, initial, sidecarSnapshot) {
|
|
|
81
274
|
throw new BuilderBuildRunInputError("flow_run.open_gates", `expected exactly one gate for active step ${run.state.current_step}, found ${gates.length}`);
|
|
82
275
|
}
|
|
83
276
|
if (gates.length === 1 && fs.existsSync(context.bundleFile)) {
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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.subject, context.projectRoot);
|
|
281
|
+
if (gateEvidence) {
|
|
282
|
+
const alreadyAttached = manifestEvidence(run.manifest).some((entry) => entry.gate_id === gates[0].id && entry.sha256 === snapshot.sha256);
|
|
283
|
+
if (!alreadyAttached) {
|
|
284
|
+
const supersede = manifestEvidence(run.manifest)
|
|
285
|
+
.filter((entry) => entry.gate_id === gates[0].id && typeof entry.superseded_by !== "string")
|
|
286
|
+
.map((entry) => String(entry.id));
|
|
287
|
+
run = await evaluateBuilderFlowRun({
|
|
288
|
+
cwd: context.projectRoot,
|
|
289
|
+
runId: context.slug,
|
|
290
|
+
evidence: {
|
|
291
|
+
gate: gates[0].id,
|
|
292
|
+
file: path.relative(context.projectRoot, snapshot.file),
|
|
293
|
+
expectedSha256: snapshot.sha256,
|
|
294
|
+
...(supersede.length > 0 ? { supersede } : {}),
|
|
295
|
+
...(gateEvidence.failed ? { status: "failed" } : {}),
|
|
296
|
+
...(gateEvidence.routeReason ? { routeReason: gateEvidence.routeReason } : {}),
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
attached = true;
|
|
300
|
+
}
|
|
101
301
|
}
|
|
102
302
|
}
|
|
303
|
+
finally {
|
|
304
|
+
removeTrustBundleSnapshot(snapshot);
|
|
305
|
+
}
|
|
103
306
|
}
|
|
104
307
|
const projection = projectFlowRun(context, run, sidecarSnapshot.state);
|
|
105
308
|
writeProjection(context, projection, sidecarSnapshot.raw, "projection");
|
|
@@ -166,10 +369,15 @@ function workflowSubject(state) {
|
|
|
166
369
|
}
|
|
167
370
|
return refs[0];
|
|
168
371
|
}
|
|
372
|
+
function persistedFlowId(state) {
|
|
373
|
+
const flowRun = isRecord(state.flow_run) ? state.flow_run : null;
|
|
374
|
+
const flowId = flowRun?.definition_id;
|
|
375
|
+
return flowId === "builder.build" || flowId === "builder.shape" ? flowId : null;
|
|
376
|
+
}
|
|
169
377
|
function openGatesForResult(run) {
|
|
170
378
|
return openGates(JSON.parse(fs.readFileSync(path.join(run.dir, "definition.json"), "utf8")), run.state);
|
|
171
379
|
}
|
|
172
|
-
function bundleGateEvidence(bundle, gate, subject) {
|
|
380
|
+
async function bundleGateEvidence(bundle, gate, subject, projectRoot) {
|
|
173
381
|
if (!isRecord(bundle) || !Array.isArray(bundle.claims))
|
|
174
382
|
return null;
|
|
175
383
|
const selectors = expectationsForGate(gate).map((expectation) => expectation.bundle_claim);
|
|
@@ -201,8 +409,247 @@ function bundleGateEvidence(bundle, gate, subject) {
|
|
|
201
409
|
if (routeReason && (!routeMap || typeof routeMap[routeReason] !== "string")) {
|
|
202
410
|
throw new BuilderBuildRunInputError("evidence.claims.metadata.gate_claim.route_reason", `is not declared by gate ${String(gate.id ?? "<unknown>")}`);
|
|
203
411
|
}
|
|
412
|
+
if (String(gate.id) === "verify-gate" && relevant.some((claim) => claim.claimType === "builder.verify.tests" && claim.value === "pass")) {
|
|
413
|
+
await assertVerifiedTestsTrust(bundle.claims, projectRoot);
|
|
414
|
+
}
|
|
204
415
|
return { failed, routeReason };
|
|
205
416
|
}
|
|
417
|
+
async function assertVerifiedTestsTrust(claims, projectRoot) {
|
|
418
|
+
const testClaims = claims.filter((claim) => isRecord(claim)
|
|
419
|
+
&& claim.claimType === "builder.verify.tests"
|
|
420
|
+
&& claim.value === "pass"
|
|
421
|
+
&& isRecord(claim.metadata)
|
|
422
|
+
&& isRecord(claim.metadata.gate_claim)
|
|
423
|
+
&& claim.metadata.gate_claim.expectation_id === "tests-evidence");
|
|
424
|
+
if (testClaims.length === 0)
|
|
425
|
+
throw new BuilderBuildRunInputError("evidence.tests", "is missing a passing tests-evidence claim");
|
|
426
|
+
const liveCritiques = claims.filter((claim) => isRecord(claim)
|
|
427
|
+
&& isRecord(claim.metadata)
|
|
428
|
+
&& claim.metadata.origin === "critique"
|
|
429
|
+
&& typeof claim.metadata.superseded_by !== "string");
|
|
430
|
+
if (liveCritiques.length === 0 || liveCritiques.some((claim) => !isSubstantivePassingCritique(claim))) {
|
|
431
|
+
throw new BuilderBuildRunInputError("evidence.critique", "a passing tests-evidence claim requires a current clean critique");
|
|
432
|
+
}
|
|
433
|
+
const implementationActors = new Set(testClaims.map((claim) => claim.metadata.recorded_by).filter((actor) => typeof actor === "string" && actor.length > 0));
|
|
434
|
+
if (implementationActors.size !== 1 || liveCritiques.some((claim) => typeof claim.metadata.reviewer !== "string" || implementationActors.has(claim.metadata.reviewer))) {
|
|
435
|
+
throw new BuilderBuildRunInputError("evidence.critique.reviewer", "must identify a reviewer distinct from the tests-evidence implementation actor");
|
|
436
|
+
}
|
|
437
|
+
await Promise.all(liveCritiques.flatMap(async (claim) => {
|
|
438
|
+
const artifacts = reviewedArtifacts(claim);
|
|
439
|
+
await Promise.all(artifacts.map((artifact) => assertReviewedArtifactDigest(artifact, projectRoot)));
|
|
440
|
+
assertReviewedWorkspaceSnapshot(claim, artifacts, projectRoot);
|
|
441
|
+
}));
|
|
442
|
+
const criteria = claims.filter((claim) => isRecord(claim) && isRecord(claim.metadata) && claim.metadata.origin === "acceptance");
|
|
443
|
+
if (criteria.length === 0 || criteria.some((claim) => {
|
|
444
|
+
const criterion = isRecord(claim.metadata.criterion) ? claim.metadata.criterion : null;
|
|
445
|
+
return claim.value !== "pass" || !criterion || !Array.isArray(criterion.evidence_refs) || criterion.evidence_refs.length === 0;
|
|
446
|
+
})) {
|
|
447
|
+
throw new BuilderBuildRunInputError("evidence.acceptance", "a passing tests-evidence claim requires complete verified acceptance criteria");
|
|
448
|
+
}
|
|
449
|
+
for (const testClaim of testClaims)
|
|
450
|
+
assertObservedTestsEvidence(testClaim, criteria);
|
|
451
|
+
}
|
|
452
|
+
function assertObservedTestsEvidence(testClaim, criteria) {
|
|
453
|
+
const observed = testClaim.metadata.observed_commands;
|
|
454
|
+
if (!Array.isArray(observed) || observed.length === 0) {
|
|
455
|
+
throw new BuilderBuildRunInputError("evidence.tests.observed_commands", "must contain successful command observations");
|
|
456
|
+
}
|
|
457
|
+
const commands = new Set();
|
|
458
|
+
for (const entry of observed) {
|
|
459
|
+
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)) {
|
|
460
|
+
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");
|
|
461
|
+
}
|
|
462
|
+
commands.add(entry.command);
|
|
463
|
+
}
|
|
464
|
+
const topRefs = Array.isArray(testClaim.metadata.artifact_refs) ? testClaim.metadata.artifact_refs : [];
|
|
465
|
+
const topCommands = new Set(topRefs.flatMap((ref) => isRecord(ref) && ref.kind === "command" && typeof ref.excerpt === "string" ? [ref.excerpt.trim()] : []));
|
|
466
|
+
if ([...commands].some((command) => !topCommands.has(command))) {
|
|
467
|
+
throw new BuilderBuildRunInputError("evidence.tests.artifact_refs", "must reference every successful observed command exactly");
|
|
468
|
+
}
|
|
469
|
+
const criterionCommands = new Set();
|
|
470
|
+
for (const claim of criteria) {
|
|
471
|
+
const criterion = claim.metadata.criterion;
|
|
472
|
+
const refs = Array.isArray(criterion.evidence_refs) ? criterion.evidence_refs : [];
|
|
473
|
+
const matched = refs.flatMap((ref) => isRecord(ref) && ref.kind === "command" && typeof ref.excerpt === "string" && commands.has(ref.excerpt.trim()) ? [ref.excerpt.trim()] : []);
|
|
474
|
+
if (matched.length === 0)
|
|
475
|
+
throw new BuilderBuildRunInputError("evidence.acceptance.evidence_refs", `criterion ${String(criterion.id ?? claim.id)} must reference a successful observed command`);
|
|
476
|
+
matched.forEach((command) => criterionCommands.add(command));
|
|
477
|
+
}
|
|
478
|
+
if ([...commands].some((command) => !criterionCommands.has(command))) {
|
|
479
|
+
throw new BuilderBuildRunInputError("evidence.acceptance.evidence_refs", "must bind every successful observed command to at least one criterion");
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
function isSubstantivePassingCritique(claim) {
|
|
483
|
+
if (claim.value !== "pass" || (Array.isArray(claim.metadata.findings) && claim.metadata.findings.some((finding) => isRecord(finding) && finding.status === "open")))
|
|
484
|
+
return false;
|
|
485
|
+
const lanes = claim.metadata.lanes;
|
|
486
|
+
return Array.isArray(lanes)
|
|
487
|
+
&& lanes.length > 0
|
|
488
|
+
&& lanes.every((lane) => isRecord(lane) && (lane.status === "pass" || lane.verdict === "pass"))
|
|
489
|
+
&& reviewedArtifacts(claim).length > 0;
|
|
490
|
+
}
|
|
491
|
+
function reviewedArtifacts(claim) {
|
|
492
|
+
const reviewTarget = isRecord(claim.metadata.review_target) ? claim.metadata.review_target : null;
|
|
493
|
+
const artifacts = reviewTarget?.artifacts;
|
|
494
|
+
if (!Array.isArray(artifacts) || artifacts.length === 0)
|
|
495
|
+
return [];
|
|
496
|
+
if (!artifacts.every((artifact) => isRecord(artifact) && typeof artifact.file === "string" && typeof artifact.sha256 === "string" && /^[a-f0-9]{64}$/i.test(artifact.sha256)))
|
|
497
|
+
return [];
|
|
498
|
+
return artifacts;
|
|
499
|
+
}
|
|
500
|
+
function assertReviewedWorkspaceSnapshot(claim, artifacts, projectRoot) {
|
|
501
|
+
const reviewTarget = isRecord(claim.metadata.review_target) ? claim.metadata.review_target : null;
|
|
502
|
+
const expected = reviewTarget?.workspace_snapshot;
|
|
503
|
+
if (!isRecord(expected)) {
|
|
504
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot", "is required for a passing critique");
|
|
505
|
+
}
|
|
506
|
+
const current = captureReviewWorkspaceSnapshot(projectRoot, expected.kind === "reviewed-files"
|
|
507
|
+
? reviewedWorkspaceFiles(expected)
|
|
508
|
+
: artifacts.map((artifact) => ({ file: artifact.file, sha256: artifact.sha256 })));
|
|
509
|
+
if (expected.version !== current.version || expected.kind !== current.kind || expected.algorithm !== current.algorithm) {
|
|
510
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot", "does not match the current workspace snapshot strategy");
|
|
511
|
+
}
|
|
512
|
+
if (expected.digest !== current.digest) {
|
|
513
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.digest", "does not match the current implementation workspace");
|
|
514
|
+
}
|
|
515
|
+
if (current.kind === "git-worktree" && expected.head_sha !== current.head_sha) {
|
|
516
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.head_sha", "does not match the current Git HEAD");
|
|
517
|
+
}
|
|
518
|
+
if (current.kind === "reviewed-files" && !isDeepStrictEqual(expected.files, current.files)) {
|
|
519
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.files", "does not match the explicitly reviewed files");
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
function gitWorktreeSnapshot(projectRoot) {
|
|
523
|
+
const root = fs.realpathSync(projectRoot);
|
|
524
|
+
const hasGitMarker = fs.existsSync(path.join(root, ".git"));
|
|
525
|
+
try {
|
|
526
|
+
const gitRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
527
|
+
if (!gitRoot || fs.realpathSync(gitRoot) !== root) {
|
|
528
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot", "requires the canonical project root to match the Git worktree root");
|
|
529
|
+
}
|
|
530
|
+
const headSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
531
|
+
const trackedDiff = execFileSync("git", ["diff", "--binary", "--no-ext-diff", "HEAD", "--"], { cwd: root, encoding: "buffer", stdio: ["ignore", "pipe", "ignore"] });
|
|
532
|
+
const untracked = execFileSync("git", ["ls-files", "--others", "--exclude-standard", "-z"], { cwd: root, encoding: "buffer", stdio: ["ignore", "pipe", "ignore"] })
|
|
533
|
+
.toString("utf8").split("\0").filter(Boolean).sort();
|
|
534
|
+
const hash = createHash("sha256");
|
|
535
|
+
hash.update("flow-agents:git-worktree:v1\0");
|
|
536
|
+
hash.update(headSha).update("\0");
|
|
537
|
+
hash.update(trackedDiff).update("\0");
|
|
538
|
+
for (const file of untracked) {
|
|
539
|
+
const absolute = path.resolve(root, file);
|
|
540
|
+
if (!pathIsWithin(absolute, root))
|
|
541
|
+
throw new Error("untracked file escapes repository root");
|
|
542
|
+
const stat = fs.lstatSync(absolute);
|
|
543
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
544
|
+
throw new Error("untracked entry is not a regular file");
|
|
545
|
+
hash.update(file).update("\0").update(fs.readFileSync(absolute)).update("\0");
|
|
546
|
+
}
|
|
547
|
+
return { version: 1, kind: "git-worktree", algorithm: "sha256", digest: hash.digest("hex"), head_sha: headSha };
|
|
548
|
+
}
|
|
549
|
+
catch (error) {
|
|
550
|
+
if (hasGitMarker || error instanceof BuilderBuildRunInputError) {
|
|
551
|
+
if (error instanceof BuilderBuildRunInputError)
|
|
552
|
+
throw error;
|
|
553
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot", `could not inspect the Git worktree: ${error instanceof Error ? error.message : String(error)}`);
|
|
554
|
+
}
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
export function captureReviewWorkspaceSnapshot(projectRoot, reviewedFiles) {
|
|
559
|
+
return gitWorktreeSnapshot(projectRoot) ?? reviewedFilesSnapshot(projectRoot, reviewedFiles);
|
|
560
|
+
}
|
|
561
|
+
function reviewedWorkspaceFiles(snapshot) {
|
|
562
|
+
if (!Array.isArray(snapshot.files) || snapshot.files.length === 0
|
|
563
|
+
|| !snapshot.files.every((file) => isRecord(file) && typeof file.file === "string" && /^[a-f0-9]{64}$/i.test(String(file.sha256)))) {
|
|
564
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.files", "must list explicitly reviewed files with SHA-256 digests");
|
|
565
|
+
}
|
|
566
|
+
const files = snapshot.files.map((file) => ({ file: file.file, sha256: file.sha256 })).sort((left, right) => left.file.localeCompare(right.file));
|
|
567
|
+
if (new Set(files.map((file) => file.file)).size !== files.length) {
|
|
568
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.files", "must not contain duplicate files");
|
|
569
|
+
}
|
|
570
|
+
return files;
|
|
571
|
+
}
|
|
572
|
+
function reviewedFilesSnapshot(projectRoot, reviewedFiles) {
|
|
573
|
+
const files = reviewedFiles.map((file) => ({ ...file }));
|
|
574
|
+
const hash = createHash("sha256");
|
|
575
|
+
hash.update("flow-agents:reviewed-files:v1\0");
|
|
576
|
+
for (const artifact of files) {
|
|
577
|
+
const absolute = safeReviewedArtifactPath(projectRoot, artifact.file);
|
|
578
|
+
hash.update(artifact.file).update("\0").update(fs.readFileSync(absolute)).update("\0");
|
|
579
|
+
}
|
|
580
|
+
return { version: 1, kind: "reviewed-files", algorithm: "sha256", digest: hash.digest("hex"), files };
|
|
581
|
+
}
|
|
582
|
+
async function assertReviewedArtifactDigest(artifact, projectRoot) {
|
|
583
|
+
const canonicalArtifact = safeReviewedArtifactPath(projectRoot, artifact.file);
|
|
584
|
+
if (createHash("sha256").update(fs.readFileSync(canonicalArtifact)).digest("hex") !== artifact.sha256) {
|
|
585
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.artifacts.sha256", `does not match ${artifact.file}`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
function safeReviewedArtifactPath(projectRoot, file) {
|
|
589
|
+
const canonicalRoot = fs.realpathSync(projectRoot);
|
|
590
|
+
const candidate = path.resolve(canonicalRoot, file);
|
|
591
|
+
const relative = path.relative(canonicalRoot, candidate);
|
|
592
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
593
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.artifacts.file", "must resolve within the canonical project root");
|
|
594
|
+
}
|
|
595
|
+
let canonicalArtifact;
|
|
596
|
+
try {
|
|
597
|
+
canonicalArtifact = fs.realpathSync(candidate);
|
|
598
|
+
}
|
|
599
|
+
catch {
|
|
600
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.artifacts.file", `is missing: ${file}`);
|
|
601
|
+
}
|
|
602
|
+
const canonicalRelative = path.relative(canonicalRoot, canonicalArtifact);
|
|
603
|
+
if (canonicalRelative === ".." || canonicalRelative.startsWith(`..${path.sep}`) || path.isAbsolute(canonicalRelative) || !fs.statSync(canonicalArtifact).isFile()) {
|
|
604
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.artifacts.file", "must be a regular file within the canonical project root");
|
|
605
|
+
}
|
|
606
|
+
return canonicalArtifact;
|
|
607
|
+
}
|
|
608
|
+
function stageTrustBundleSnapshot(context) {
|
|
609
|
+
assertSafeFile(context.bundleFile, context.sessionDir, "trust.bundle");
|
|
610
|
+
const source = fs.openSync(context.bundleFile, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
611
|
+
let raw;
|
|
612
|
+
try {
|
|
613
|
+
const stat = fs.fstatSync(source);
|
|
614
|
+
if (!stat.isFile())
|
|
615
|
+
throw new BuilderBuildRunInputError("trust.bundle", "must be a regular file");
|
|
616
|
+
raw = fs.readFileSync(source);
|
|
617
|
+
}
|
|
618
|
+
finally {
|
|
619
|
+
fs.closeSync(source);
|
|
620
|
+
}
|
|
621
|
+
const directory = path.join(context.sessionDir, ".trust-bundle-snapshots");
|
|
622
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
623
|
+
assertSafeDirectory(directory, context.sessionDir, "trust.bundle snapshot directory");
|
|
624
|
+
fs.chmodSync(directory, 0o700);
|
|
625
|
+
const file = path.join(directory, `${randomBytes(16).toString("hex")}.json`);
|
|
626
|
+
const descriptor = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
|
|
627
|
+
try {
|
|
628
|
+
fs.writeFileSync(descriptor, raw);
|
|
629
|
+
fs.fsyncSync(descriptor);
|
|
630
|
+
}
|
|
631
|
+
finally {
|
|
632
|
+
fs.closeSync(descriptor);
|
|
633
|
+
}
|
|
634
|
+
fs.chmodSync(file, 0o400);
|
|
635
|
+
return { file, raw, sha256: createHash("sha256").update(raw).digest("hex") };
|
|
636
|
+
}
|
|
637
|
+
function removeTrustBundleSnapshot(snapshot) {
|
|
638
|
+
try {
|
|
639
|
+
fs.unlinkSync(snapshot.file);
|
|
640
|
+
}
|
|
641
|
+
catch (error) {
|
|
642
|
+
if (!isRecord(error) || error.code !== "ENOENT")
|
|
643
|
+
throw error;
|
|
644
|
+
}
|
|
645
|
+
try {
|
|
646
|
+
fs.rmdirSync(path.dirname(snapshot.file));
|
|
647
|
+
}
|
|
648
|
+
catch (error) {
|
|
649
|
+
if (!isRecord(error) || (error.code !== "ENOENT" && error.code !== "ENOTEMPTY"))
|
|
650
|
+
throw error;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
206
653
|
function workflowSubjectRef(claim) {
|
|
207
654
|
const metadata = isRecord(claim.metadata) ? claim.metadata : null;
|
|
208
655
|
return metadata && typeof metadata.workflow_subject_ref === "string"
|
|
@@ -216,7 +663,9 @@ function projectFlowRun(context, run, sidecar) {
|
|
|
216
663
|
const definition = JSON.parse(fs.readFileSync(path.join(run.dir, "definition.json"), "utf8"));
|
|
217
664
|
const gates = openGates(definition, run.state);
|
|
218
665
|
const complete = run.state.status === "completed";
|
|
219
|
-
const
|
|
666
|
+
const paused = run.state.status === "paused";
|
|
667
|
+
const canceled = run.state.status === "canceled";
|
|
668
|
+
const action = complete || paused || canceled ? { skills: [], operations: [] } : stepAction(run.definitionId, run.state.current_step);
|
|
220
669
|
if (!action) {
|
|
221
670
|
throw new BuilderBuildRunInputError("kit.flow_step_actions", `does not declare Builder step ${run.state.current_step}`);
|
|
222
671
|
}
|
|
@@ -225,7 +674,7 @@ function projectFlowRun(context, run, sidecar) {
|
|
|
225
674
|
.map((expectation) => `${expectation.id} (${expectation.bundle_claim.claimType}/${expectation.bundle_claim.subjectType ?? "any"})`));
|
|
226
675
|
const skills = action?.skills ?? [];
|
|
227
676
|
const operations = action?.operations ?? [];
|
|
228
|
-
const syncCommand =
|
|
677
|
+
const syncCommand = pinnedFlowAgentsCommand(flowAgentsPackageVersion(), ["workflow", "status", "--session-dir", `.kontourai/flow-agents/${context.slug}`, "--json"]);
|
|
229
678
|
const routeBack = latestRouteBack(run.state);
|
|
230
679
|
const skillText = skills.length ? `Activate ${skills.map((skill) => `\`${skill}\``).join(" then ")}.` : "No Builder skill is required.";
|
|
231
680
|
const operationText = operations.length ? ` Perform ${operations.map((operation) => `\`${operation}\``).join(" then ")}.` : "";
|
|
@@ -237,18 +686,22 @@ function projectFlowRun(context, run, sidecar) {
|
|
|
237
686
|
: "";
|
|
238
687
|
const nextAction = complete
|
|
239
688
|
? { status: "done", summary: "Canonical Flow run is complete." }
|
|
240
|
-
:
|
|
241
|
-
status: "
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
689
|
+
: canceled
|
|
690
|
+
? { status: "done", summary: "Canonical Flow run was canceled by an authorized external request. Artifacts are retained until separately archived." }
|
|
691
|
+
: paused
|
|
692
|
+
? { status: "blocked", summary: "Canonical Flow run is paused. The current assignment actor may resume it with a reason." }
|
|
693
|
+
: {
|
|
694
|
+
status: "continue",
|
|
695
|
+
summary: `Flow step \`${run.state.current_step}\`: ${skillText}${operationText} ${gateText}${routeText} Then synchronize the recorded evidence.`,
|
|
696
|
+
skills,
|
|
697
|
+
operations,
|
|
698
|
+
command: syncCommand,
|
|
699
|
+
};
|
|
247
700
|
const phase = phaseForStep(definition.phase_map, run.state.current_step) ?? sidecar.phase;
|
|
248
701
|
return {
|
|
249
702
|
...sidecar,
|
|
250
|
-
status: complete ? "delivered" : (run.state.transitions.length > 0 ? "in_progress" : sidecar.status),
|
|
251
|
-
phase: complete ? "done" : phase,
|
|
703
|
+
status: complete ? "delivered" : canceled ? "canceled" : paused ? "blocked" : (run.state.transitions.length > 0 ? "in_progress" : sidecar.status),
|
|
704
|
+
phase: complete || canceled ? "done" : phase,
|
|
252
705
|
updated_at: run.state.updated_at,
|
|
253
706
|
flow_run: {
|
|
254
707
|
run_id: run.runId,
|
|
@@ -313,7 +766,7 @@ function prepareProjectionWrites(context, projection, expectedStateRaw, operatio
|
|
|
313
766
|
continue;
|
|
314
767
|
const output = {
|
|
315
768
|
...pointer,
|
|
316
|
-
active_flow_id:
|
|
769
|
+
active_flow_id: projection.flow_run.definition_id,
|
|
317
770
|
active_step_id: projection.flow_run.current_step,
|
|
318
771
|
updated_at: projection.updated_at,
|
|
319
772
|
};
|
|
@@ -410,11 +863,11 @@ function writeExistingFileNoFollow(file, content) {
|
|
|
410
863
|
fs.closeSync(descriptor);
|
|
411
864
|
}
|
|
412
865
|
}
|
|
413
|
-
function stepAction(stepId) {
|
|
866
|
+
function stepAction(flowId, stepId) {
|
|
414
867
|
const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot(), "kits", "builder", "kit.json"), "utf8"));
|
|
415
868
|
const actions = Array.isArray(manifest.flow_step_actions) ? manifest.flow_step_actions : [];
|
|
416
869
|
const action = actions.find((candidate) => isRecord(candidate)
|
|
417
|
-
&& candidate.flow_id ===
|
|
870
|
+
&& candidate.flow_id === flowId
|
|
418
871
|
&& candidate.step_id === stepId);
|
|
419
872
|
if (!isRecord(action) || !Array.isArray(action.skills) || !action.skills.every((skill) => typeof skill === "string")) {
|
|
420
873
|
return null;
|