@kontourai/flow-agents 3.4.1 → 3.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/agents/dev.json +5 -0
- package/build/src/builder-flow-runtime.d.ts +1 -0
- package/build/src/builder-flow-runtime.js +183 -27
- package/build/src/cli/builder-run.js +13 -4
- package/build/src/cli/workflow-sidecar.js +14 -3
- package/build/src/tools/build-universal-bundles.js +2 -0
- package/context/scripts/hooks/workflow-steering.js +40 -2
- package/docs/spec/builder-flow-runtime.md +28 -0
- package/docs/spec/runtime-hook-surface.md +4 -4
- package/docs/workflow-usage-guide.md +12 -0
- package/evals/integration/test_builder_entry_enforcement.sh +4 -1
- package/evals/integration/test_bundle_install.sh +3 -2
- package/evals/integration/test_workflow_steering_hook.sh +72 -0
- package/kits/builder/skills/continue-work/SKILL.md +7 -0
- package/package.json +1 -1
- package/schemas/workflow-state.schema.json +7 -0
- package/scripts/hooks/workflow-steering.js +40 -2
- package/scripts/install-merge.js +2 -1
- package/src/builder-flow-runtime.ts +217 -26
- package/src/cli/builder-flow-runtime.test.mjs +342 -0
- package/src/cli/builder-run.ts +13 -4
- package/src/cli/workflow-sidecar.ts +16 -5
- package/src/tools/build-universal-bundles.ts +2 -0
|
@@ -21,6 +21,78 @@ mkdir -p "$REPO/docs"
|
|
|
21
21
|
printf '# Test Repo\n' > "$REPO/AGENTS.md"
|
|
22
22
|
printf '# Context Map\n' > "$REPO/docs/context-map.md"
|
|
23
23
|
|
|
24
|
+
ENTRY_REPO="$TMPDIR_EVAL/entry-repo"
|
|
25
|
+
ENTRY_COMMAND='flow-agents builder-run start --session-dir .kontourai/flow-agents/entry-demo'
|
|
26
|
+
mkdir -p "$ENTRY_REPO/.kontourai/flow-agents/entry-demo"
|
|
27
|
+
printf '# Entry Repo\n' > "$ENTRY_REPO/AGENTS.md"
|
|
28
|
+
cat > "$ENTRY_REPO/.kontourai/flow-agents/entry-demo/state.json" <<JSON
|
|
29
|
+
{
|
|
30
|
+
"schema_version": "1.0",
|
|
31
|
+
"task_slug": "entry-demo",
|
|
32
|
+
"status": "new",
|
|
33
|
+
"phase": "pickup",
|
|
34
|
+
"updated_at": "2026-07-10T00:00:00Z",
|
|
35
|
+
"next_action": {
|
|
36
|
+
"status": "continue",
|
|
37
|
+
"summary": "Start the canonical Flow run.",
|
|
38
|
+
"skills": ["pull-work"],
|
|
39
|
+
"command": "$ENTRY_COMMAND",
|
|
40
|
+
"enforcement": "before_tool_use"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
JSON
|
|
44
|
+
|
|
45
|
+
if node "$ROOT/scripts/hooks/workflow-steering.js" >"$TMPDIR_EVAL/entry-direct.out" 2>"$TMPDIR_EVAL/entry-direct.err" <<JSON
|
|
46
|
+
{"hook_event_name":"PreToolUse","cwd":"$ENTRY_REPO","tool_name":"Bash","tool_input":{"command":"git status --short"}}
|
|
47
|
+
JSON
|
|
48
|
+
then
|
|
49
|
+
_fail "workflow entry enforcement should block unrelated tools"
|
|
50
|
+
else
|
|
51
|
+
status=$?
|
|
52
|
+
if [[ "$status" -eq 2 ]] && rg -F -q "Run exactly: $ENTRY_COMMAND" "$TMPDIR_EVAL/entry-direct.err"; then
|
|
53
|
+
_pass "workflow entry enforcement blocks unrelated tools with the projected action"
|
|
54
|
+
else
|
|
55
|
+
_fail "workflow entry enforcement returned the wrong direct-hook decision: rc=$status $(cat "$TMPDIR_EVAL/entry-direct.err")"
|
|
56
|
+
fi
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
if node "$ROOT/scripts/hooks/workflow-steering.js" >"$TMPDIR_EVAL/entry-allow.out" 2>"$TMPDIR_EVAL/entry-allow.err" <<JSON
|
|
60
|
+
{"hook_event_name":"PreToolUse","cwd":"$ENTRY_REPO","tool_name":"Bash","tool_input":{"command":"$ENTRY_COMMAND"}}
|
|
61
|
+
JSON
|
|
62
|
+
then
|
|
63
|
+
_pass "workflow entry enforcement allows the exact projected action"
|
|
64
|
+
else
|
|
65
|
+
_fail "workflow entry enforcement blocked the projected action: $(cat "$TMPDIR_EVAL/entry-allow.err")"
|
|
66
|
+
fi
|
|
67
|
+
|
|
68
|
+
for adapter in codex claude; do
|
|
69
|
+
if [[ "$adapter" == "codex" ]]; then
|
|
70
|
+
adapter_command=(node "$ROOT/scripts/hooks/codex-hook-adapter.js" pre:workflow-entry workflow-steering.js standard,strict)
|
|
71
|
+
else
|
|
72
|
+
adapter_command=(node "$ROOT/scripts/hooks/claude-hook-adapter.js" PreToolUse pre:workflow-entry workflow-steering.js standard,strict)
|
|
73
|
+
fi
|
|
74
|
+
if "${adapter_command[@]}" >"$TMPDIR_EVAL/entry-$adapter.out" 2>"$TMPDIR_EVAL/entry-$adapter.err" <<JSON
|
|
75
|
+
{"hook_event_name":"PreToolUse","cwd":"$ENTRY_REPO","tool_name":"Bash","tool_input":{"command":"git status --short"}}
|
|
76
|
+
JSON
|
|
77
|
+
then
|
|
78
|
+
if node - "$TMPDIR_EVAL/entry-$adapter.out" "$ENTRY_COMMAND" <<'NODE'
|
|
79
|
+
const fs = require('node:fs');
|
|
80
|
+
const payload = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
|
|
81
|
+
const expected = process.argv[3];
|
|
82
|
+
if (payload.hookSpecificOutput?.hookEventName !== 'PreToolUse') process.exit(1);
|
|
83
|
+
if (payload.hookSpecificOutput?.permissionDecision !== 'deny') process.exit(1);
|
|
84
|
+
if (!String(payload.hookSpecificOutput?.permissionDecisionReason || '').includes(expected)) process.exit(1);
|
|
85
|
+
NODE
|
|
86
|
+
then
|
|
87
|
+
_pass "$adapter adapter translates projected-action enforcement into a PreToolUse denial"
|
|
88
|
+
else
|
|
89
|
+
_fail "$adapter adapter returned the wrong projected-action denial: $(cat "$TMPDIR_EVAL/entry-$adapter.out")"
|
|
90
|
+
fi
|
|
91
|
+
else
|
|
92
|
+
_fail "$adapter adapter should translate a policy block without failing: $(cat "$TMPDIR_EVAL/entry-$adapter.err")"
|
|
93
|
+
fi
|
|
94
|
+
done
|
|
95
|
+
|
|
24
96
|
cat > "$REPO/.kontourai/flow-agents/steering-demo/state.json" <<'JSON'
|
|
25
97
|
{
|
|
26
98
|
"schema_version": "1.0",
|
|
@@ -27,6 +27,13 @@ continue-work ties these together for **one job**: take a multi-slice work item
|
|
|
27
27
|
- The request is **brand-new work** with nothing landed yet — that is selection from the backlog. Route to `pull-work`.
|
|
28
28
|
- The request is to **resume the *same* interrupted slice** after a restart (same in-flight slice, mid-execution, picking up new hooks/logic) — that is the resume surface (#153), which reconstructs `state.json` + `handoff.json` + plan + `trust.bundle` for the *same* increment. continue-work advances to the *next* increment; it does not re-enter an unfinished one.
|
|
29
29
|
|
|
30
|
+
For that same-slice case, when a canonical Builder Flow run already exists, restore
|
|
31
|
+
its sidecar/current projection with `flow-agents builder-run recover --session-dir
|
|
32
|
+
.kontourai/flow-agents/<slug>` before following the resume surface. The command
|
|
33
|
+
derives run identity from the session slug and only loads, validates, and projects;
|
|
34
|
+
never invent or supply a run id or step, and use `builder-run sync` separately only
|
|
35
|
+
when recorded evidence should be attached and evaluated.
|
|
36
|
+
|
|
30
37
|
If the boundary is ambiguous (is this the next slice or the same one?), stop and ask one question before routing. Do not silently assume.
|
|
31
38
|
|
|
32
39
|
## Boundary (ADR 0014)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kontourai/flow-agents",
|
|
3
|
-
"version": "3.4.
|
|
3
|
+
"version": "3.4.2",
|
|
4
4
|
"description": "Flow Agents — a Kontour product that applies Flow and Veritas discipline as a portable process layer inside the agent tools you already use: Claude Code, Codex, Kiro, opencode, pi, and GitHub Actions — with framework adapters (AWS Strands preview) on the same policy-engine contract.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* survive context loss instead of relying on the model voluntarily re-reading
|
|
10
10
|
* the sidecar.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
12
|
+
* Advisory by default. A structured next action may explicitly require its
|
|
13
|
+
* projected command before unrelated tool use; only that PreToolUse case blocks.
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
16
|
'use strict';
|
|
@@ -282,6 +283,32 @@ function stateSteering(root) {
|
|
|
282
283
|
return parts.join(' ');
|
|
283
284
|
}
|
|
284
285
|
|
|
286
|
+
function normalizedCommand(value) {
|
|
287
|
+
return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function toolCommands(input) {
|
|
291
|
+
const toolInput = input && input.tool_input && typeof input.tool_input === 'object' ? input.tool_input : {};
|
|
292
|
+
return [
|
|
293
|
+
toolInput.command,
|
|
294
|
+
toolInput.content && toolInput.content.command,
|
|
295
|
+
toolInput.args && toolInput.args.command,
|
|
296
|
+
].map(normalizedCommand).filter(Boolean);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function beforeToolUseEnforcement(input, current) {
|
|
300
|
+
if (!input || input.hook_event_name !== 'PreToolUse' || !current) return null;
|
|
301
|
+
const next = current.payload && current.payload.next_action;
|
|
302
|
+
if (!next || next.status !== 'continue' || next.enforcement !== 'before_tool_use') return null;
|
|
303
|
+
const expected = normalizedCommand(next.command);
|
|
304
|
+
if (!expected) return null;
|
|
305
|
+
if (toolCommands(input).some(command => command === expected)) return null;
|
|
306
|
+
return {
|
|
307
|
+
exitCode: 2,
|
|
308
|
+
stderr: `[workflow-entry] Run the required projected action before using other tools. Run exactly: ${safeStateText(expected, 500)}`,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
285
312
|
function contextMapSteering(root) {
|
|
286
313
|
const mapPath = path.join(root, 'docs', 'context-map.md');
|
|
287
314
|
if (!fs.existsSync(mapPath)) return '';
|
|
@@ -572,6 +599,8 @@ function run(rawInput) {
|
|
|
572
599
|
const toolInput = input.tool_input || {};
|
|
573
600
|
const root = findRepoRoot(input.cwd || process.cwd());
|
|
574
601
|
const current = latestWorkflowState(root);
|
|
602
|
+
const enforcement = beforeToolUseEnforcement(input, current);
|
|
603
|
+
if (enforcement) return enforcement;
|
|
575
604
|
const hints = [];
|
|
576
605
|
let shouldAppendWorkflowContext = false;
|
|
577
606
|
|
|
@@ -648,7 +677,14 @@ if (require.main === module) {
|
|
|
648
677
|
data += chunk;
|
|
649
678
|
});
|
|
650
679
|
process.stdin.on('end', () => {
|
|
651
|
-
|
|
680
|
+
const result = run(data);
|
|
681
|
+
if (result && typeof result === 'object') {
|
|
682
|
+
if (result.stderr) process.stderr.write(String(result.stderr) + (String(result.stderr).endsWith('\n') ? '' : '\n'));
|
|
683
|
+
if (Object.prototype.hasOwnProperty.call(result, 'stdout')) process.stdout.write(String(result.stdout || ''));
|
|
684
|
+
process.exitCode = Number.isInteger(result.exitCode) ? result.exitCode : 0;
|
|
685
|
+
} else {
|
|
686
|
+
process.stdout.write(String(result));
|
|
687
|
+
}
|
|
652
688
|
});
|
|
653
689
|
}
|
|
654
690
|
|
|
@@ -667,4 +703,6 @@ module.exports = {
|
|
|
667
703
|
promptText,
|
|
668
704
|
looksLikeImplementationWork,
|
|
669
705
|
kitWorkflowSteering,
|
|
706
|
+
beforeToolUseEnforcement,
|
|
707
|
+
toolCommands,
|
|
670
708
|
};
|
package/scripts/install-merge.js
CHANGED
|
@@ -41,13 +41,14 @@ const path = require("node:path");
|
|
|
41
41
|
const os = require("node:os");
|
|
42
42
|
|
|
43
43
|
// ─── Marker strings ───────────────────────────────────────────────────────────
|
|
44
|
-
// These
|
|
44
|
+
// These statusMessage strings identify every flow-agents managed hook.
|
|
45
45
|
// They are the same strings used by COLLISION_MARKER in src/cli/init.ts and
|
|
46
46
|
// by exportClaudeSettings() / exportCodexHooks() in build-universal-bundles.ts.
|
|
47
47
|
const FA_MARKERS = [
|
|
48
48
|
"Recording Flow Agents telemetry",
|
|
49
49
|
"Running Flow Agents hook policy",
|
|
50
50
|
"Capturing Flow Agents command evidence",
|
|
51
|
+
"Enforcing Flow Agents projected action",
|
|
51
52
|
];
|
|
52
53
|
|
|
53
54
|
const FA_OWNERSHIP_MARKERS = [
|
|
@@ -44,10 +44,28 @@ type SessionContext = {
|
|
|
44
44
|
bundleFile: string;
|
|
45
45
|
};
|
|
46
46
|
|
|
47
|
+
type SidecarSnapshot = {
|
|
48
|
+
state: AnyRecord;
|
|
49
|
+
raw: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type ProjectionTargetSnapshot = {
|
|
53
|
+
file: string;
|
|
54
|
+
raw: string | null;
|
|
55
|
+
root: string;
|
|
56
|
+
field: string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
type PreparedProjectionWrites = {
|
|
60
|
+
targets: ProjectionTargetSnapshot[];
|
|
61
|
+
actorEntries: string[] | null;
|
|
62
|
+
writes: Array<{ file: string; content: string }>;
|
|
63
|
+
};
|
|
64
|
+
|
|
47
65
|
export async function startBuilderFlowSession(input: BuilderFlowSessionInput): Promise<BuilderFlowSessionResult> {
|
|
48
66
|
const context = resolveSessionContext(input.sessionDir);
|
|
49
|
-
const
|
|
50
|
-
const subject = workflowSubject(
|
|
67
|
+
const sidecarSnapshot = readSidecarSnapshot(context);
|
|
68
|
+
const subject = workflowSubject(sidecarSnapshot.state);
|
|
51
69
|
let run: BuilderBuildRunResult;
|
|
52
70
|
try {
|
|
53
71
|
run = await loadBuilderBuildRun({
|
|
@@ -77,6 +95,33 @@ export async function syncBuilderFlowSession(input: BuilderFlowSessionInput): Pr
|
|
|
77
95
|
return syncAndProject(context, run);
|
|
78
96
|
}
|
|
79
97
|
|
|
98
|
+
export async function recoverBuilderFlowSession(input: BuilderFlowSessionInput): Promise<BuilderFlowSessionResult> {
|
|
99
|
+
const context = resolveSessionContext(input.sessionDir);
|
|
100
|
+
const sidecarSnapshot = readSidecarSnapshot(context);
|
|
101
|
+
const subject = workflowSubject(sidecarSnapshot.state);
|
|
102
|
+
const run = await loadBuilderBuildRun({
|
|
103
|
+
cwd: context.projectRoot,
|
|
104
|
+
runId: context.slug,
|
|
105
|
+
});
|
|
106
|
+
if (run.state.subject !== subject) {
|
|
107
|
+
throw new BuilderBuildRunInputError("flow_run.state.subject", "must match the selected Work Item");
|
|
108
|
+
}
|
|
109
|
+
if (isRecord(run.state.params)
|
|
110
|
+
&& Object.prototype.hasOwnProperty.call(run.state.params, "subject")
|
|
111
|
+
&& run.state.params.subject !== subject) {
|
|
112
|
+
throw new BuilderBuildRunInputError("flow_run.state.params.subject", "must match the selected Work Item");
|
|
113
|
+
}
|
|
114
|
+
const projection = projectFlowRun(context, run, sidecarSnapshot.state);
|
|
115
|
+
writeProjection(context, projection, sidecarSnapshot.raw, "recovery");
|
|
116
|
+
return {
|
|
117
|
+
sessionDir: context.sessionDir,
|
|
118
|
+
projectRoot: context.projectRoot,
|
|
119
|
+
run,
|
|
120
|
+
projection,
|
|
121
|
+
attached: false,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
80
125
|
export async function syncBuilderFlowSessionIfPresent(sessionDir: string): Promise<BuilderFlowSessionResult | null> {
|
|
81
126
|
let context: SessionContext;
|
|
82
127
|
try {
|
|
@@ -119,8 +164,9 @@ async function syncAndProject(context: SessionContext, initial: BuilderBuildRunR
|
|
|
119
164
|
}
|
|
120
165
|
}
|
|
121
166
|
}
|
|
122
|
-
const
|
|
123
|
-
|
|
167
|
+
const sidecarSnapshot = readSidecarSnapshot(context);
|
|
168
|
+
const projection = projectFlowRun(context, run, sidecarSnapshot.state);
|
|
169
|
+
writeProjection(context, projection, sidecarSnapshot.raw, "projection");
|
|
124
170
|
return {
|
|
125
171
|
sessionDir: context.sessionDir,
|
|
126
172
|
projectRoot: context.projectRoot,
|
|
@@ -137,6 +183,7 @@ function resolveSessionContext(sessionDirInput: string): SessionContext {
|
|
|
137
183
|
if (path.basename(artifactRoot) !== "flow-agents" || path.basename(kontouraiRoot) !== ".kontourai") {
|
|
138
184
|
throw new BuilderBuildRunInputError("sessionDir", "must be .kontourai/flow-agents/<slug>");
|
|
139
185
|
}
|
|
186
|
+
assertSafeDirectory(sessionDir, artifactRoot, "sessionDir");
|
|
140
187
|
const slug = path.basename(sessionDir);
|
|
141
188
|
if (!slug || slug === "." || slug === "..") {
|
|
142
189
|
throw new BuilderBuildRunInputError("sessionDir", "must name a session");
|
|
@@ -145,6 +192,7 @@ function resolveSessionContext(sessionDirInput: string): SessionContext {
|
|
|
145
192
|
if (!fs.existsSync(stateFile)) {
|
|
146
193
|
throw new BuilderBuildRunInputError("sessionDir", "must contain state.json");
|
|
147
194
|
}
|
|
195
|
+
assertSafeFile(stateFile, sessionDir, "state.json");
|
|
148
196
|
return {
|
|
149
197
|
sessionDir,
|
|
150
198
|
artifactRoot,
|
|
@@ -155,19 +203,22 @@ function resolveSessionContext(sessionDirInput: string): SessionContext {
|
|
|
155
203
|
};
|
|
156
204
|
}
|
|
157
205
|
|
|
158
|
-
function
|
|
159
|
-
|
|
206
|
+
function readSidecarSnapshot(context: SessionContext): SidecarSnapshot {
|
|
207
|
+
assertSafeFile(context.stateFile, context.sessionDir, "state.json");
|
|
208
|
+
const raw = fs.readFileSync(context.stateFile, "utf8");
|
|
209
|
+
const value = JSON.parse(raw);
|
|
160
210
|
if (!isRecord(value) || value.task_slug !== context.slug) {
|
|
161
211
|
throw new BuilderBuildRunInputError("sessionDir", "state.json task_slug must match the session directory");
|
|
162
212
|
}
|
|
163
|
-
return value;
|
|
213
|
+
return { state: value, raw };
|
|
164
214
|
}
|
|
165
215
|
|
|
166
216
|
function workflowSubject(state: AnyRecord): string {
|
|
167
|
-
const refs =
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
217
|
+
const refs = state.work_item_refs;
|
|
218
|
+
if (!Array.isArray(refs)
|
|
219
|
+
|| refs.length !== 1
|
|
220
|
+
|| typeof refs[0] !== "string"
|
|
221
|
+
|| refs[0].trim().length === 0) {
|
|
171
222
|
throw new BuilderBuildRunInputError("state.work_item_refs", "must contain exactly one selected Work Item for builder.build");
|
|
172
223
|
}
|
|
173
224
|
return refs[0]!;
|
|
@@ -225,8 +276,7 @@ function manifestEvidence(manifest: JsonObject): AnyRecord[] {
|
|
|
225
276
|
return Array.isArray(manifest.evidence) ? manifest.evidence.filter(isRecord) : [];
|
|
226
277
|
}
|
|
227
278
|
|
|
228
|
-
function projectFlowRun(context: SessionContext, run: BuilderBuildRunResult): AnyRecord {
|
|
229
|
-
const sidecar = readSidecarState(context);
|
|
279
|
+
function projectFlowRun(context: SessionContext, run: BuilderBuildRunResult, sidecar: AnyRecord): AnyRecord {
|
|
230
280
|
const definition = JSON.parse(fs.readFileSync(path.join(run.dir, "definition.json"), "utf8"));
|
|
231
281
|
const gates = openGates(definition, run.state) as Array<FlowGate & { id: string }>;
|
|
232
282
|
const complete = run.state.status === "completed";
|
|
@@ -279,23 +329,164 @@ function projectFlowRun(context: SessionContext, run: BuilderBuildRunResult): An
|
|
|
279
329
|
};
|
|
280
330
|
}
|
|
281
331
|
|
|
282
|
-
function writeProjection(context: SessionContext, projection: AnyRecord): void {
|
|
283
|
-
|
|
284
|
-
|
|
332
|
+
function writeProjection(context: SessionContext, projection: AnyRecord, expectedStateRaw: string, operation: string): void {
|
|
333
|
+
const prepared = prepareProjectionWrites(context, projection, expectedStateRaw, operation);
|
|
334
|
+
assertProjectionTargetsUnchanged(context, prepared, operation);
|
|
335
|
+
for (const write of prepared.writes) writeExistingFileNoFollow(write.file, write.content);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function prepareProjectionWrites(
|
|
339
|
+
context: SessionContext,
|
|
340
|
+
projection: AnyRecord,
|
|
341
|
+
expectedStateRaw: string,
|
|
342
|
+
operation: string,
|
|
343
|
+
): PreparedProjectionWrites {
|
|
344
|
+
const targets: ProjectionTargetSnapshot[] = [];
|
|
345
|
+
const writes: Array<{ file: string; content: string }> = [];
|
|
346
|
+
const stateTarget = readProjectionTarget(context.stateFile, context.sessionDir, "state.json");
|
|
347
|
+
targets.push(stateTarget);
|
|
348
|
+
if (stateTarget.raw !== expectedStateRaw) {
|
|
349
|
+
throw new BuilderBuildRunInputError("state.json", `changed during ${operation}`);
|
|
350
|
+
}
|
|
351
|
+
writes.push({ file: context.stateFile, content: `${JSON.stringify(projection, null, 2)}\n` });
|
|
352
|
+
|
|
353
|
+
const pointerFiles: string[] = [];
|
|
354
|
+
const globalPointer = path.join(context.artifactRoot, "current.json");
|
|
355
|
+
const globalTarget = readOptionalProjectionTarget(globalPointer, context.artifactRoot, "current.json");
|
|
356
|
+
targets.push(globalTarget);
|
|
357
|
+
if (globalTarget.raw !== null) pointerFiles.push(globalPointer);
|
|
358
|
+
|
|
285
359
|
const actorRoot = path.join(context.artifactRoot, "current");
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
360
|
+
let actorEntries: string[] | null = null;
|
|
361
|
+
if (pathExistsNoFollow(actorRoot)) {
|
|
362
|
+
assertSafeDirectory(actorRoot, context.artifactRoot, "current directory");
|
|
363
|
+
actorEntries = fs.readdirSync(actorRoot).sort();
|
|
364
|
+
for (const name of actorEntries) {
|
|
365
|
+
const file = path.join(actorRoot, name);
|
|
366
|
+
const stat = fs.lstatSync(file);
|
|
367
|
+
if (stat.isSymbolicLink()) {
|
|
368
|
+
throw new BuilderBuildRunInputError("projection target", `current/${name} must not be a symbolic link`);
|
|
369
|
+
}
|
|
370
|
+
if (!name.endsWith(".json")) continue;
|
|
371
|
+
if (!stat.isFile()) {
|
|
372
|
+
throw new BuilderBuildRunInputError("projection target", `current/${name} must be a regular file`);
|
|
373
|
+
}
|
|
374
|
+
const target = readProjectionTarget(file, actorRoot, `current/${name}`);
|
|
375
|
+
targets.push(target);
|
|
376
|
+
pointerFiles.push(file);
|
|
377
|
+
}
|
|
290
378
|
}
|
|
379
|
+
|
|
291
380
|
for (const file of pointerFiles) {
|
|
292
|
-
|
|
293
|
-
const pointer =
|
|
381
|
+
const target = targets.find((candidate) => candidate.file === file)!;
|
|
382
|
+
const pointer = parseProjectionTarget(target);
|
|
294
383
|
if (!isRecord(pointer) || pointer.active_slug !== context.slug) continue;
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
384
|
+
const output = {
|
|
385
|
+
...pointer,
|
|
386
|
+
active_flow_id: BUILDER_BUILD_FLOW_ID,
|
|
387
|
+
active_step_id: projection.flow_run.current_step,
|
|
388
|
+
updated_at: projection.updated_at,
|
|
389
|
+
};
|
|
390
|
+
writes.push({ file, content: `${JSON.stringify(output, null, 2)}\n` });
|
|
391
|
+
}
|
|
392
|
+
return { targets, actorEntries, writes };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function assertProjectionTargetsUnchanged(
|
|
396
|
+
context: SessionContext,
|
|
397
|
+
prepared: PreparedProjectionWrites,
|
|
398
|
+
operation: string,
|
|
399
|
+
): void {
|
|
400
|
+
const actorRoot = path.join(context.artifactRoot, "current");
|
|
401
|
+
const currentActorEntries = pathExistsNoFollow(actorRoot)
|
|
402
|
+
? (assertSafeDirectory(actorRoot, context.artifactRoot, "current directory"), fs.readdirSync(actorRoot).sort())
|
|
403
|
+
: null;
|
|
404
|
+
if (JSON.stringify(currentActorEntries) !== JSON.stringify(prepared.actorEntries)) {
|
|
405
|
+
throw new BuilderBuildRunInputError("current", `directory changed during ${operation}`);
|
|
406
|
+
}
|
|
407
|
+
for (const target of prepared.targets) {
|
|
408
|
+
const current = readOptionalProjectionTarget(target.file, target.root, target.field);
|
|
409
|
+
if (current.raw !== target.raw) {
|
|
410
|
+
throw new BuilderBuildRunInputError(target.field, `changed during ${operation}`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function readOptionalProjectionTarget(file: string, root: string, field: string): ProjectionTargetSnapshot {
|
|
416
|
+
if (!pathExistsNoFollow(file)) return { file, raw: null, root, field };
|
|
417
|
+
return readProjectionTarget(file, root, field);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function readProjectionTarget(file: string, root: string, field: string): ProjectionTargetSnapshot {
|
|
421
|
+
assertSafeFile(file, root, field);
|
|
422
|
+
return { file, raw: fs.readFileSync(file, "utf8"), root, field };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function parseProjectionTarget(target: ProjectionTargetSnapshot): unknown {
|
|
426
|
+
try {
|
|
427
|
+
return JSON.parse(target.raw!);
|
|
428
|
+
} catch (error) {
|
|
429
|
+
throw new BuilderBuildRunInputError("projection target", `${target.field} must contain valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function assertSafeDirectory(directory: string, root: string, field: string): void {
|
|
434
|
+
if (!pathExistsNoFollow(directory)) {
|
|
435
|
+
throw new BuilderBuildRunInputError(field, "must exist");
|
|
436
|
+
}
|
|
437
|
+
const stat = fs.lstatSync(directory);
|
|
438
|
+
if (stat.isSymbolicLink()) {
|
|
439
|
+
throw new BuilderBuildRunInputError(field, "must not be a symbolic link");
|
|
440
|
+
}
|
|
441
|
+
if (!stat.isDirectory()) {
|
|
442
|
+
throw new BuilderBuildRunInputError(field, "must be a directory");
|
|
443
|
+
}
|
|
444
|
+
assertContainedPath(directory, root, field);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function pathExistsNoFollow(candidate: string): boolean {
|
|
448
|
+
try {
|
|
449
|
+
fs.lstatSync(candidate);
|
|
450
|
+
return true;
|
|
451
|
+
} catch (error) {
|
|
452
|
+
if (isRecord(error) && error.code === "ENOENT") return false;
|
|
453
|
+
throw error;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function assertSafeFile(file: string, root: string, field: string): void {
|
|
458
|
+
const stat = fs.lstatSync(file);
|
|
459
|
+
if (stat.isSymbolicLink()) {
|
|
460
|
+
throw new BuilderBuildRunInputError(field, "must not be a symbolic link");
|
|
461
|
+
}
|
|
462
|
+
if (!stat.isFile()) {
|
|
463
|
+
throw new BuilderBuildRunInputError(field, "must be a regular file");
|
|
464
|
+
}
|
|
465
|
+
assertContainedPath(file, root, field);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function assertContainedPath(candidate: string, root: string, field: string): void {
|
|
469
|
+
if (!pathIsWithin(candidate, root)) {
|
|
470
|
+
throw new BuilderBuildRunInputError(field, "must remain within its expected artifact root");
|
|
471
|
+
}
|
|
472
|
+
const realCandidate = fs.realpathSync(candidate);
|
|
473
|
+
const realRoot = fs.realpathSync(root);
|
|
474
|
+
if (!pathIsWithin(realCandidate, realRoot)) {
|
|
475
|
+
throw new BuilderBuildRunInputError(field, "must not escape its expected artifact root");
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function pathIsWithin(candidate: string, root: string): boolean {
|
|
480
|
+
const relative = path.relative(root, candidate);
|
|
481
|
+
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function writeExistingFileNoFollow(file: string, content: string): void {
|
|
485
|
+
const descriptor = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_TRUNC | fs.constants.O_NOFOLLOW);
|
|
486
|
+
try {
|
|
487
|
+
fs.writeFileSync(descriptor, content);
|
|
488
|
+
} finally {
|
|
489
|
+
fs.closeSync(descriptor);
|
|
299
490
|
}
|
|
300
491
|
}
|
|
301
492
|
|