@kontourai/flow-agents 3.4.1 → 3.4.3
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 +14 -0
- package/build/src/builder-flow-runtime.d.ts +1 -0
- package/build/src/builder-flow-runtime.js +192 -30
- package/build/src/cli/builder-run.js +13 -4
- package/build/src/cli/workflow-sidecar.js +90 -7
- package/docs/spec/builder-flow-runtime.md +35 -2
- package/docs/spec/runtime-hook-surface.md +2 -2
- package/docs/workflow-usage-guide.md +12 -0
- package/evals/integration/test_builder_entry_enforcement.sh +139 -5
- package/evals/integration/test_builder_step_producers.sh +1 -1
- package/evals/integration/test_bundle_install.sh +2 -1
- package/evals/integration/test_current_json_per_actor.sh +2 -2
- package/evals/integration/test_dual_emit_flow_step.sh +41 -9
- package/evals/integration/test_flowdef_session_activation.sh +11 -5
- package/evals/integration/test_install_merge.sh +24 -0
- package/evals/integration/test_phase_map_and_gate_claim.sh +10 -10
- package/evals/integration/test_resolvefirststep_security.sh +1 -1
- package/evals/integration/test_sidecar_field_preservation.sh +4 -2
- package/evals/integration/test_takeover_protocol.sh +1 -1
- package/evals/integration/test_workflow_sidecar_writer.sh +17 -6
- package/kits/builder/skills/continue-work/SKILL.md +7 -0
- package/package.json +1 -1
- package/schemas/workflow-state.schema.json +8 -0
- package/scripts/install-merge.js +4 -1
- package/src/builder-flow-runtime.ts +231 -29
- package/src/cli/builder-flow-runtime.test.mjs +374 -0
- package/src/cli/builder-run.ts +13 -4
- package/src/cli/workflow-sidecar.ts +91 -10
|
@@ -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({
|
|
@@ -65,16 +83,40 @@ export async function startBuilderFlowSession(input: BuilderFlowSessionInput): P
|
|
|
65
83
|
},
|
|
66
84
|
});
|
|
67
85
|
}
|
|
68
|
-
|
|
86
|
+
assertRunSubjectBinding(run, subject);
|
|
87
|
+
return syncAndProject(context, run, sidecarSnapshot);
|
|
69
88
|
}
|
|
70
89
|
|
|
71
90
|
export async function syncBuilderFlowSession(input: BuilderFlowSessionInput): Promise<BuilderFlowSessionResult> {
|
|
72
91
|
const context = resolveSessionContext(input.sessionDir);
|
|
92
|
+
const sidecarSnapshot = readSidecarSnapshot(context);
|
|
93
|
+
const subject = workflowSubject(sidecarSnapshot.state);
|
|
73
94
|
const run = await loadBuilderBuildRun({
|
|
74
95
|
cwd: context.projectRoot,
|
|
75
96
|
runId: context.slug,
|
|
76
97
|
});
|
|
77
|
-
|
|
98
|
+
assertRunSubjectBinding(run, subject);
|
|
99
|
+
return syncAndProject(context, run, sidecarSnapshot);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function recoverBuilderFlowSession(input: BuilderFlowSessionInput): Promise<BuilderFlowSessionResult> {
|
|
103
|
+
const context = resolveSessionContext(input.sessionDir);
|
|
104
|
+
const sidecarSnapshot = readSidecarSnapshot(context);
|
|
105
|
+
const subject = workflowSubject(sidecarSnapshot.state);
|
|
106
|
+
const run = await loadBuilderBuildRun({
|
|
107
|
+
cwd: context.projectRoot,
|
|
108
|
+
runId: context.slug,
|
|
109
|
+
});
|
|
110
|
+
assertRunSubjectBinding(run, subject);
|
|
111
|
+
const projection = projectFlowRun(context, run, sidecarSnapshot.state);
|
|
112
|
+
writeProjection(context, projection, sidecarSnapshot.raw, "recovery");
|
|
113
|
+
return {
|
|
114
|
+
sessionDir: context.sessionDir,
|
|
115
|
+
projectRoot: context.projectRoot,
|
|
116
|
+
run,
|
|
117
|
+
projection,
|
|
118
|
+
attached: false,
|
|
119
|
+
};
|
|
78
120
|
}
|
|
79
121
|
|
|
80
122
|
export async function syncBuilderFlowSessionIfPresent(sessionDir: string): Promise<BuilderFlowSessionResult | null> {
|
|
@@ -89,7 +131,11 @@ export async function syncBuilderFlowSessionIfPresent(sessionDir: string): Promi
|
|
|
89
131
|
return syncBuilderFlowSession({ sessionDir });
|
|
90
132
|
}
|
|
91
133
|
|
|
92
|
-
async function syncAndProject(
|
|
134
|
+
async function syncAndProject(
|
|
135
|
+
context: SessionContext,
|
|
136
|
+
initial: BuilderBuildRunResult,
|
|
137
|
+
sidecarSnapshot: SidecarSnapshot,
|
|
138
|
+
): Promise<BuilderFlowSessionResult> {
|
|
93
139
|
let run = initial;
|
|
94
140
|
let attached = false;
|
|
95
141
|
const gates = openGatesForResult(run);
|
|
@@ -119,8 +165,8 @@ async function syncAndProject(context: SessionContext, initial: BuilderBuildRunR
|
|
|
119
165
|
}
|
|
120
166
|
}
|
|
121
167
|
}
|
|
122
|
-
const projection = projectFlowRun(context, run);
|
|
123
|
-
writeProjection(context, projection);
|
|
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,
|
|
@@ -130,6 +176,17 @@ async function syncAndProject(context: SessionContext, initial: BuilderBuildRunR
|
|
|
130
176
|
};
|
|
131
177
|
}
|
|
132
178
|
|
|
179
|
+
function assertRunSubjectBinding(run: BuilderBuildRunResult, subject: string): void {
|
|
180
|
+
if (run.state.subject !== subject) {
|
|
181
|
+
throw new BuilderBuildRunInputError("flow_run.state.subject", "must match the selected Work Item");
|
|
182
|
+
}
|
|
183
|
+
if (isRecord(run.state.params)
|
|
184
|
+
&& Object.prototype.hasOwnProperty.call(run.state.params, "subject")
|
|
185
|
+
&& run.state.params.subject !== subject) {
|
|
186
|
+
throw new BuilderBuildRunInputError("flow_run.state.params.subject", "must match the selected Work Item");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
133
190
|
function resolveSessionContext(sessionDirInput: string): SessionContext {
|
|
134
191
|
const sessionDir = path.resolve(sessionDirInput);
|
|
135
192
|
const artifactRoot = path.dirname(sessionDir);
|
|
@@ -137,6 +194,7 @@ function resolveSessionContext(sessionDirInput: string): SessionContext {
|
|
|
137
194
|
if (path.basename(artifactRoot) !== "flow-agents" || path.basename(kontouraiRoot) !== ".kontourai") {
|
|
138
195
|
throw new BuilderBuildRunInputError("sessionDir", "must be .kontourai/flow-agents/<slug>");
|
|
139
196
|
}
|
|
197
|
+
assertSafeDirectory(sessionDir, artifactRoot, "sessionDir");
|
|
140
198
|
const slug = path.basename(sessionDir);
|
|
141
199
|
if (!slug || slug === "." || slug === "..") {
|
|
142
200
|
throw new BuilderBuildRunInputError("sessionDir", "must name a session");
|
|
@@ -145,6 +203,7 @@ function resolveSessionContext(sessionDirInput: string): SessionContext {
|
|
|
145
203
|
if (!fs.existsSync(stateFile)) {
|
|
146
204
|
throw new BuilderBuildRunInputError("sessionDir", "must contain state.json");
|
|
147
205
|
}
|
|
206
|
+
assertSafeFile(stateFile, sessionDir, "state.json");
|
|
148
207
|
return {
|
|
149
208
|
sessionDir,
|
|
150
209
|
artifactRoot,
|
|
@@ -155,19 +214,22 @@ function resolveSessionContext(sessionDirInput: string): SessionContext {
|
|
|
155
214
|
};
|
|
156
215
|
}
|
|
157
216
|
|
|
158
|
-
function
|
|
159
|
-
|
|
217
|
+
function readSidecarSnapshot(context: SessionContext): SidecarSnapshot {
|
|
218
|
+
assertSafeFile(context.stateFile, context.sessionDir, "state.json");
|
|
219
|
+
const raw = fs.readFileSync(context.stateFile, "utf8");
|
|
220
|
+
const value = JSON.parse(raw);
|
|
160
221
|
if (!isRecord(value) || value.task_slug !== context.slug) {
|
|
161
222
|
throw new BuilderBuildRunInputError("sessionDir", "state.json task_slug must match the session directory");
|
|
162
223
|
}
|
|
163
|
-
return value;
|
|
224
|
+
return { state: value, raw };
|
|
164
225
|
}
|
|
165
226
|
|
|
166
227
|
function workflowSubject(state: AnyRecord): string {
|
|
167
|
-
const refs =
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
228
|
+
const refs = state.work_item_refs;
|
|
229
|
+
if (!Array.isArray(refs)
|
|
230
|
+
|| refs.length !== 1
|
|
231
|
+
|| typeof refs[0] !== "string"
|
|
232
|
+
|| refs[0].trim().length === 0) {
|
|
171
233
|
throw new BuilderBuildRunInputError("state.work_item_refs", "must contain exactly one selected Work Item for builder.build");
|
|
172
234
|
}
|
|
173
235
|
return refs[0]!;
|
|
@@ -225,8 +287,7 @@ function manifestEvidence(manifest: JsonObject): AnyRecord[] {
|
|
|
225
287
|
return Array.isArray(manifest.evidence) ? manifest.evidence.filter(isRecord) : [];
|
|
226
288
|
}
|
|
227
289
|
|
|
228
|
-
function projectFlowRun(context: SessionContext, run: BuilderBuildRunResult): AnyRecord {
|
|
229
|
-
const sidecar = readSidecarState(context);
|
|
290
|
+
function projectFlowRun(context: SessionContext, run: BuilderBuildRunResult, sidecar: AnyRecord): AnyRecord {
|
|
230
291
|
const definition = JSON.parse(fs.readFileSync(path.join(run.dir, "definition.json"), "utf8"));
|
|
231
292
|
const gates = openGates(definition, run.state) as Array<FlowGate & { id: string }>;
|
|
232
293
|
const complete = run.state.status === "completed";
|
|
@@ -279,23 +340,164 @@ function projectFlowRun(context: SessionContext, run: BuilderBuildRunResult): An
|
|
|
279
340
|
};
|
|
280
341
|
}
|
|
281
342
|
|
|
282
|
-
function writeProjection(context: SessionContext, projection: AnyRecord): void {
|
|
283
|
-
|
|
284
|
-
|
|
343
|
+
function writeProjection(context: SessionContext, projection: AnyRecord, expectedStateRaw: string, operation: string): void {
|
|
344
|
+
const prepared = prepareProjectionWrites(context, projection, expectedStateRaw, operation);
|
|
345
|
+
assertProjectionTargetsUnchanged(context, prepared, operation);
|
|
346
|
+
for (const write of prepared.writes) writeExistingFileNoFollow(write.file, write.content);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function prepareProjectionWrites(
|
|
350
|
+
context: SessionContext,
|
|
351
|
+
projection: AnyRecord,
|
|
352
|
+
expectedStateRaw: string,
|
|
353
|
+
operation: string,
|
|
354
|
+
): PreparedProjectionWrites {
|
|
355
|
+
const targets: ProjectionTargetSnapshot[] = [];
|
|
356
|
+
const writes: Array<{ file: string; content: string }> = [];
|
|
357
|
+
const stateTarget = readProjectionTarget(context.stateFile, context.sessionDir, "state.json");
|
|
358
|
+
targets.push(stateTarget);
|
|
359
|
+
if (stateTarget.raw !== expectedStateRaw) {
|
|
360
|
+
throw new BuilderBuildRunInputError("state.json", `changed during ${operation}`);
|
|
361
|
+
}
|
|
362
|
+
writes.push({ file: context.stateFile, content: `${JSON.stringify(projection, null, 2)}\n` });
|
|
363
|
+
|
|
364
|
+
const pointerFiles: string[] = [];
|
|
365
|
+
const globalPointer = path.join(context.artifactRoot, "current.json");
|
|
366
|
+
const globalTarget = readOptionalProjectionTarget(globalPointer, context.artifactRoot, "current.json");
|
|
367
|
+
targets.push(globalTarget);
|
|
368
|
+
if (globalTarget.raw !== null) pointerFiles.push(globalPointer);
|
|
369
|
+
|
|
285
370
|
const actorRoot = path.join(context.artifactRoot, "current");
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
371
|
+
let actorEntries: string[] | null = null;
|
|
372
|
+
if (pathExistsNoFollow(actorRoot)) {
|
|
373
|
+
assertSafeDirectory(actorRoot, context.artifactRoot, "current directory");
|
|
374
|
+
actorEntries = fs.readdirSync(actorRoot).sort();
|
|
375
|
+
for (const name of actorEntries) {
|
|
376
|
+
const file = path.join(actorRoot, name);
|
|
377
|
+
const stat = fs.lstatSync(file);
|
|
378
|
+
if (stat.isSymbolicLink()) {
|
|
379
|
+
throw new BuilderBuildRunInputError("projection target", `current/${name} must not be a symbolic link`);
|
|
380
|
+
}
|
|
381
|
+
if (!name.endsWith(".json")) continue;
|
|
382
|
+
if (!stat.isFile()) {
|
|
383
|
+
throw new BuilderBuildRunInputError("projection target", `current/${name} must be a regular file`);
|
|
384
|
+
}
|
|
385
|
+
const target = readProjectionTarget(file, actorRoot, `current/${name}`);
|
|
386
|
+
targets.push(target);
|
|
387
|
+
pointerFiles.push(file);
|
|
388
|
+
}
|
|
290
389
|
}
|
|
390
|
+
|
|
291
391
|
for (const file of pointerFiles) {
|
|
292
|
-
|
|
293
|
-
const pointer =
|
|
392
|
+
const target = targets.find((candidate) => candidate.file === file)!;
|
|
393
|
+
const pointer = parseProjectionTarget(target);
|
|
294
394
|
if (!isRecord(pointer) || pointer.active_slug !== context.slug) continue;
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
395
|
+
const output = {
|
|
396
|
+
...pointer,
|
|
397
|
+
active_flow_id: BUILDER_BUILD_FLOW_ID,
|
|
398
|
+
active_step_id: projection.flow_run.current_step,
|
|
399
|
+
updated_at: projection.updated_at,
|
|
400
|
+
};
|
|
401
|
+
writes.push({ file, content: `${JSON.stringify(output, null, 2)}\n` });
|
|
402
|
+
}
|
|
403
|
+
return { targets, actorEntries, writes };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function assertProjectionTargetsUnchanged(
|
|
407
|
+
context: SessionContext,
|
|
408
|
+
prepared: PreparedProjectionWrites,
|
|
409
|
+
operation: string,
|
|
410
|
+
): void {
|
|
411
|
+
const actorRoot = path.join(context.artifactRoot, "current");
|
|
412
|
+
const currentActorEntries = pathExistsNoFollow(actorRoot)
|
|
413
|
+
? (assertSafeDirectory(actorRoot, context.artifactRoot, "current directory"), fs.readdirSync(actorRoot).sort())
|
|
414
|
+
: null;
|
|
415
|
+
if (JSON.stringify(currentActorEntries) !== JSON.stringify(prepared.actorEntries)) {
|
|
416
|
+
throw new BuilderBuildRunInputError("current", `directory changed during ${operation}`);
|
|
417
|
+
}
|
|
418
|
+
for (const target of prepared.targets) {
|
|
419
|
+
const current = readOptionalProjectionTarget(target.file, target.root, target.field);
|
|
420
|
+
if (current.raw !== target.raw) {
|
|
421
|
+
throw new BuilderBuildRunInputError(target.field, `changed during ${operation}`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function readOptionalProjectionTarget(file: string, root: string, field: string): ProjectionTargetSnapshot {
|
|
427
|
+
if (!pathExistsNoFollow(file)) return { file, raw: null, root, field };
|
|
428
|
+
return readProjectionTarget(file, root, field);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function readProjectionTarget(file: string, root: string, field: string): ProjectionTargetSnapshot {
|
|
432
|
+
assertSafeFile(file, root, field);
|
|
433
|
+
return { file, raw: fs.readFileSync(file, "utf8"), root, field };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function parseProjectionTarget(target: ProjectionTargetSnapshot): unknown {
|
|
437
|
+
try {
|
|
438
|
+
return JSON.parse(target.raw!);
|
|
439
|
+
} catch (error) {
|
|
440
|
+
throw new BuilderBuildRunInputError("projection target", `${target.field} must contain valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function assertSafeDirectory(directory: string, root: string, field: string): void {
|
|
445
|
+
if (!pathExistsNoFollow(directory)) {
|
|
446
|
+
throw new BuilderBuildRunInputError(field, "must exist");
|
|
447
|
+
}
|
|
448
|
+
const stat = fs.lstatSync(directory);
|
|
449
|
+
if (stat.isSymbolicLink()) {
|
|
450
|
+
throw new BuilderBuildRunInputError(field, "must not be a symbolic link");
|
|
451
|
+
}
|
|
452
|
+
if (!stat.isDirectory()) {
|
|
453
|
+
throw new BuilderBuildRunInputError(field, "must be a directory");
|
|
454
|
+
}
|
|
455
|
+
assertContainedPath(directory, root, field);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function pathExistsNoFollow(candidate: string): boolean {
|
|
459
|
+
try {
|
|
460
|
+
fs.lstatSync(candidate);
|
|
461
|
+
return true;
|
|
462
|
+
} catch (error) {
|
|
463
|
+
if (isRecord(error) && error.code === "ENOENT") return false;
|
|
464
|
+
throw error;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function assertSafeFile(file: string, root: string, field: string): void {
|
|
469
|
+
const stat = fs.lstatSync(file);
|
|
470
|
+
if (stat.isSymbolicLink()) {
|
|
471
|
+
throw new BuilderBuildRunInputError(field, "must not be a symbolic link");
|
|
472
|
+
}
|
|
473
|
+
if (!stat.isFile()) {
|
|
474
|
+
throw new BuilderBuildRunInputError(field, "must be a regular file");
|
|
475
|
+
}
|
|
476
|
+
assertContainedPath(file, root, field);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function assertContainedPath(candidate: string, root: string, field: string): void {
|
|
480
|
+
if (!pathIsWithin(candidate, root)) {
|
|
481
|
+
throw new BuilderBuildRunInputError(field, "must remain within its expected artifact root");
|
|
482
|
+
}
|
|
483
|
+
const realCandidate = fs.realpathSync(candidate);
|
|
484
|
+
const realRoot = fs.realpathSync(root);
|
|
485
|
+
if (!pathIsWithin(realCandidate, realRoot)) {
|
|
486
|
+
throw new BuilderBuildRunInputError(field, "must not escape its expected artifact root");
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function pathIsWithin(candidate: string, root: string): boolean {
|
|
491
|
+
const relative = path.relative(root, candidate);
|
|
492
|
+
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function writeExistingFileNoFollow(file: string, content: string): void {
|
|
496
|
+
const descriptor = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_TRUNC | fs.constants.O_NOFOLLOW);
|
|
497
|
+
try {
|
|
498
|
+
fs.writeFileSync(descriptor, content);
|
|
499
|
+
} finally {
|
|
500
|
+
fs.closeSync(descriptor);
|
|
299
501
|
}
|
|
300
502
|
}
|
|
301
503
|
|