@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.
Files changed (28) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/build/src/builder-flow-runtime.d.ts +1 -0
  3. package/build/src/builder-flow-runtime.js +192 -30
  4. package/build/src/cli/builder-run.js +13 -4
  5. package/build/src/cli/workflow-sidecar.js +90 -7
  6. package/docs/spec/builder-flow-runtime.md +35 -2
  7. package/docs/spec/runtime-hook-surface.md +2 -2
  8. package/docs/workflow-usage-guide.md +12 -0
  9. package/evals/integration/test_builder_entry_enforcement.sh +139 -5
  10. package/evals/integration/test_builder_step_producers.sh +1 -1
  11. package/evals/integration/test_bundle_install.sh +2 -1
  12. package/evals/integration/test_current_json_per_actor.sh +2 -2
  13. package/evals/integration/test_dual_emit_flow_step.sh +41 -9
  14. package/evals/integration/test_flowdef_session_activation.sh +11 -5
  15. package/evals/integration/test_install_merge.sh +24 -0
  16. package/evals/integration/test_phase_map_and_gate_claim.sh +10 -10
  17. package/evals/integration/test_resolvefirststep_security.sh +1 -1
  18. package/evals/integration/test_sidecar_field_preservation.sh +4 -2
  19. package/evals/integration/test_takeover_protocol.sh +1 -1
  20. package/evals/integration/test_workflow_sidecar_writer.sh +17 -6
  21. package/kits/builder/skills/continue-work/SKILL.md +7 -0
  22. package/package.json +1 -1
  23. package/schemas/workflow-state.schema.json +8 -0
  24. package/scripts/install-merge.js +4 -1
  25. package/src/builder-flow-runtime.ts +231 -29
  26. package/src/cli/builder-flow-runtime.test.mjs +374 -0
  27. package/src/cli/builder-run.ts +13 -4
  28. 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 sidecarState = readSidecarState(context);
50
- const subject = workflowSubject(sidecarState);
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
- return syncAndProject(context, run);
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
- return syncAndProject(context, run);
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(context: SessionContext, initial: BuilderBuildRunResult): Promise<BuilderFlowSessionResult> {
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 readSidecarState(context: SessionContext): AnyRecord {
159
- const value = JSON.parse(fs.readFileSync(context.stateFile, "utf8"));
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 = Array.isArray(state.work_item_refs)
168
- ? state.work_item_refs.filter((value: unknown): value is string => typeof value === "string" && value.length > 0)
169
- : [];
170
- if (refs.length !== 1) {
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
- fs.writeFileSync(context.stateFile, `${JSON.stringify(projection, null, 2)}\n`);
284
- const pointerFiles = [path.join(context.artifactRoot, "current.json")];
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
- if (fs.existsSync(actorRoot)) {
287
- pointerFiles.push(...fs.readdirSync(actorRoot)
288
- .filter((name) => name.endsWith(".json"))
289
- .map((name) => path.join(actorRoot, name)));
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
- if (!fs.existsSync(file)) continue;
293
- const pointer = JSON.parse(fs.readFileSync(file, "utf8"));
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
- pointer.active_flow_id = BUILDER_BUILD_FLOW_ID;
296
- pointer.active_step_id = projection.flow_run.current_step;
297
- pointer.updated_at = projection.updated_at;
298
- fs.writeFileSync(file, `${JSON.stringify(pointer, null, 2)}\n`);
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