@kontourai/flow-agents 3.4.3 → 3.6.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.
Files changed (65) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/CHANGELOG.md +15 -0
  3. package/build/src/builder-flow-run-adapter.d.ts +10 -1
  4. package/build/src/builder-flow-run-adapter.js +29 -1
  5. package/build/src/builder-flow-runtime.d.ts +18 -0
  6. package/build/src/builder-flow-runtime.js +205 -13
  7. package/build/src/builder-lifecycle-authority.d.ts +35 -0
  8. package/build/src/builder-lifecycle-authority.js +219 -0
  9. package/build/src/cli/assignment-provider.d.ts +13 -0
  10. package/build/src/cli/assignment-provider.js +120 -62
  11. package/build/src/cli/builder-run.js +46 -5
  12. package/build/src/cli/workflow-artifact-cleanup-audit.js +3 -0
  13. package/build/src/cli/workflow-sidecar.d.ts +3 -0
  14. package/build/src/cli/workflow-sidecar.js +140 -30
  15. package/build/src/cli/workflow.d.ts +2 -0
  16. package/build/src/cli/workflow.js +521 -0
  17. package/build/src/cli.js +2 -0
  18. package/build/src/index.d.ts +4 -0
  19. package/build/src/index.js +2 -0
  20. package/build/src/lib/flow-resolver.js +7 -2
  21. package/build/src/lib/package-version.d.ts +2 -0
  22. package/build/src/lib/package-version.js +13 -0
  23. package/build/src/lib/pinned-cli-command.d.ts +6 -0
  24. package/build/src/lib/pinned-cli-command.js +21 -0
  25. package/context/contracts/artifact-contract.md +1 -1
  26. package/context/contracts/assignment-provider-contract.md +5 -2
  27. package/context/scripts/hooks/config-protection.js +8 -1
  28. package/context/scripts/hooks/lib/config-protection-remedies.js +3 -0
  29. package/context/scripts/hooks/stop-goal-fit.js +1 -1
  30. package/docs/context-map.md +2 -0
  31. package/docs/public-workflow-cli.md +63 -0
  32. package/docs/spec/builder-flow-runtime.md +49 -5
  33. package/docs/workflow-usage-guide.md +5 -0
  34. package/evals/ci/run-baseline.sh +2 -0
  35. package/evals/integration/test_assignment_provider_local_file.sh +54 -0
  36. package/evals/integration/test_builder_entry_enforcement.sh +241 -24
  37. package/evals/integration/test_bundle_install.sh +97 -0
  38. package/evals/integration/test_current_json_per_actor.sh +1 -0
  39. package/evals/integration/test_dual_emit_flow_step.sh +2 -0
  40. package/evals/integration/test_flowdef_session_activation.sh +6 -3
  41. package/evals/integration/test_goal_fit_escape_hatch.sh +3 -3
  42. package/evals/integration/test_phase_map_and_gate_claim.sh +4 -0
  43. package/evals/integration/test_public_workflow_cli.sh +259 -0
  44. package/evals/integration/test_workflow_sidecar_writer.sh +1 -0
  45. package/package.json +2 -2
  46. package/schemas/builder-lifecycle-authorization.schema.json +57 -0
  47. package/schemas/lifecycle-authority-keys.schema.json +25 -0
  48. package/schemas/workflow-state.schema.json +1 -1
  49. package/scripts/hooks/config-protection.js +8 -1
  50. package/scripts/hooks/lib/config-protection-remedies.js +3 -0
  51. package/scripts/hooks/stop-goal-fit.js +1 -1
  52. package/src/builder-flow-run-adapter.ts +47 -0
  53. package/src/builder-flow-runtime.ts +216 -4
  54. package/src/builder-lifecycle-authority.ts +218 -0
  55. package/src/cli/assignment-provider.ts +84 -20
  56. package/src/cli/builder-flow-runtime.test.mjs +404 -1
  57. package/src/cli/builder-run.ts +56 -5
  58. package/src/cli/workflow-artifact-cleanup-audit.ts +3 -0
  59. package/src/cli/workflow-sidecar.ts +138 -31
  60. package/src/cli/workflow.ts +471 -0
  61. package/src/cli.ts +2 -0
  62. package/src/index.ts +14 -0
  63. package/src/lib/flow-resolver.ts +6 -2
  64. package/src/lib/package-version.ts +15 -0
  65. package/src/lib/pinned-cli-command.ts +23 -0
@@ -4,7 +4,7 @@ import * as path from "node:path";
4
4
  import { createRequire } from "node:module";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { parseArgs, flagString, type ParsedArgs } from "../lib/args.js";
7
- import { readJson, writeJson, isoNow } from "../lib/fs.js";
7
+ import { atomicWriteJson, readJson, isoNow } from "../lib/fs.js";
8
8
 
9
9
  // ─── AssignmentProvider CLI (#290) ──────────────────────────────────────────
10
10
  // context/contracts/assignment-provider-contract.md is the governing vocabulary doc for this
@@ -60,6 +60,7 @@ export type AssignmentClaimRecord = {
60
60
  subject_id: string;
61
61
  actor: ActorStruct;
62
62
  actor_key?: string;
63
+ work_item_ref?: string;
63
64
  claimed_at: string;
64
65
  ttl_seconds: number;
65
66
  branch: string;
@@ -120,6 +121,8 @@ function loadActorIdentityHelper(): {
120
121
  isUnresolvedActor: (actor: string) => boolean;
121
122
  sanitizeSegment: (value: unknown) => string;
122
123
  detectRuntime: (env: NodeJS.ProcessEnv) => string;
124
+ runtimeSessionId: (env: NodeJS.ProcessEnv) => string;
125
+ ancestorActorSeed: () => string;
123
126
  detectCiActor: (env: NodeJS.ProcessEnv) => { runtime: string; session_id: string } | null;
124
127
  } {
125
128
  const _req = createRequire(import.meta.url);
@@ -130,6 +133,8 @@ function loadActorIdentityHelper(): {
130
133
  isUnresolvedActor: (actor: string) => boolean;
131
134
  sanitizeSegment: (value: unknown) => string;
132
135
  detectRuntime: (env: NodeJS.ProcessEnv) => string;
136
+ runtimeSessionId: (env: NodeJS.ProcessEnv) => string;
137
+ ancestorActorSeed: () => string;
133
138
  detectCiActor: (env: NodeJS.ProcessEnv) => { runtime: string; session_id: string } | null;
134
139
  };
135
140
  }
@@ -198,6 +203,10 @@ function loadActorStructFromFile(file: string): ActorStruct {
198
203
  function loadActorStruct(args: ParsedArgs): { actor: ActorStruct; actorKey?: string } {
199
204
  const actorJsonPath = flagString(args.flags, "actor-json");
200
205
  if (actorJsonPath) return { actor: loadActorStructFromFile(actorJsonPath) };
206
+ return resolveCurrentAssignmentActor();
207
+ }
208
+
209
+ export function resolveCurrentAssignmentActor(): { actor: ActorStruct; actorKey: string } {
201
210
  const helper = loadActorIdentityHelper();
202
211
  const resolved = helper.resolveActor(process.env);
203
212
  if (helper.isUnresolvedActor(resolved.actor)) throw new Error("could not resolve an actor identity (no --actor-json and no resolvable environment actor); pass --actor-json explicitly");
@@ -207,9 +216,15 @@ function loadActorStruct(args: ParsedArgs): { actor: ActorStruct; actorKey?: str
207
216
  // CI session — actor_key stays correct (so no false-block), but record.actor is malformed and the
208
217
  // audit-trail / `assignment-provider status` output for CI sessions would be corrupt.
209
218
  const ci = resolved.source.startsWith("ci-runtime") ? helper.detectCiActor(process.env) : null;
210
- const actor: ActorStruct = ci && ci.session_id
211
- ? { runtime: ci.runtime, session_id: ci.session_id, host: os.hostname(), human: null }
212
- : { runtime: helper.detectRuntime(process.env), session_id: resolved.actor, host: os.hostname(), human: null };
219
+ const runtimeSessionId = resolved.source.startsWith("runtime-session-id") ? helper.runtimeSessionId(process.env) : "";
220
+ const ancestrySeed = resolved.source === "process-ancestry" ? helper.ancestorActorSeed() : "";
221
+ const actor: ActorStruct = resolved.source === "explicit-override"
222
+ ? { runtime: "explicit-override", session_id: resolved.actor, host: os.hostname(), human: null }
223
+ : ci && ci.session_id
224
+ ? { runtime: ci.runtime, session_id: ci.session_id, host: os.hostname(), human: null }
225
+ : runtimeSessionId
226
+ ? { runtime: helper.detectRuntime(process.env), session_id: runtimeSessionId, host: os.hostname(), human: null }
227
+ : { runtime: helper.detectRuntime(process.env), session_id: ancestrySeed ? `anc-${ancestrySeed}` : resolved.actor, host: os.hostname(), human: null };
213
228
  return { actor, actorKey: resolved.actor };
214
229
  }
215
230
 
@@ -218,16 +233,50 @@ export function assignmentFilePath(artifactRoot: string, subjectId: string): str
218
233
  return path.join(artifactRoot, "assignment", `${sanitized}.json`);
219
234
  }
220
235
 
236
+ function localAssignmentDir(artifactRoot: string, create: boolean): string | null {
237
+ const dir = path.join(artifactRoot, "assignment");
238
+ if (create) fs.mkdirSync(dir, { recursive: true });
239
+ let stat: fs.Stats;
240
+ try {
241
+ stat = fs.lstatSync(dir);
242
+ } catch (error) {
243
+ if (!create && (error as NodeJS.ErrnoException).code === "ENOENT") return null;
244
+ throw error;
245
+ }
246
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
247
+ throw new Error(`assignment directory must be a real directory, not a symlink: ${dir}`);
248
+ }
249
+ const realRoot = fs.realpathSync(artifactRoot);
250
+ if (fs.realpathSync(dir) !== path.join(realRoot, "assignment")) {
251
+ throw new Error(`assignment directory escapes the artifact root: ${dir}`);
252
+ }
253
+ return dir;
254
+ }
255
+
221
256
  export function readLocalRecord(artifactRoot: string, subjectId: string): AssignmentClaimRecord | null {
257
+ if (!localAssignmentDir(artifactRoot, false)) return null;
222
258
  const file = assignmentFilePath(artifactRoot, subjectId);
223
- if (!fs.existsSync(file)) return null;
259
+ let stat: fs.Stats;
260
+ try {
261
+ stat = fs.lstatSync(file);
262
+ } catch (error) {
263
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
264
+ throw error;
265
+ }
266
+ if (stat.isSymbolicLink() || !stat.isFile()) {
267
+ throw new Error(`assignment record must be a regular file, not a symlink: ${file}`);
268
+ }
224
269
  let data: unknown;
270
+ let descriptor: number | null = null;
225
271
  try {
226
- data = readJson(file);
272
+ descriptor = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
273
+ data = JSON.parse(fs.readFileSync(descriptor, "utf8"));
227
274
  } catch (error) {
228
275
  // Fail loud: a corrupt claim record must never be silently treated as "no claim" — that
229
276
  // would be a fail-open path that could let a second claim silently overwrite a real one.
230
277
  throw new Error(`assignment record is corrupt, refusing to proceed: ${file}: ${(error as Error).message}`);
278
+ } finally {
279
+ if (descriptor !== null) fs.closeSync(descriptor);
231
280
  }
232
281
  if (typeof data !== "object" || data === null) throw new Error(`assignment record is not an object: ${file}`);
233
282
  const record = data as AssignmentClaimRecord;
@@ -239,7 +288,7 @@ export function writeLocalRecord(artifactRoot: string, subjectId: string, record
239
288
  // writeJson throws on any mkdir/writeFileSync failure; that error is intentionally allowed to
240
289
  // propagate to main()'s top-level try/catch and exit non-zero. Durable writes must fail loud,
241
290
  // never fail open (artifact-contract.md).
242
- writeJson(assignmentFilePath(artifactRoot, subjectId), record);
291
+ atomicWriteJson(artifactRoot, assignmentFilePath(artifactRoot, subjectId), record);
243
292
  }
244
293
 
245
294
  /**
@@ -255,8 +304,7 @@ function sleepSync(ms: number): void {
255
304
  }
256
305
 
257
306
  function subjectLockDir(artifactRoot: string, subjectId: string): string {
258
- const assignmentDir = path.join(artifactRoot, "assignment");
259
- fs.mkdirSync(assignmentDir, { recursive: true });
307
+ const assignmentDir = localAssignmentDir(artifactRoot, true)!;
260
308
  const sanitized = loadActorIdentityHelper().sanitizeSegment(subjectId);
261
309
  return path.join(assignmentDir, `.${sanitized}.lockdir`);
262
310
  }
@@ -310,11 +358,19 @@ export function withSubjectLock<T>(artifactRoot: string, subjectId: string, body
310
358
  sleepSync(20);
311
359
  }
312
360
  }
361
+ const release = (): void => fs.rmSync(lockDir, { recursive: true, force: true });
362
+ let result: T;
313
363
  try {
314
- return body();
315
- } finally {
316
- fs.rmSync(lockDir, { recursive: true, force: true });
364
+ result = body();
365
+ } catch (error) {
366
+ release();
367
+ throw error;
368
+ }
369
+ if (result && typeof (result as { then?: unknown }).then === "function") {
370
+ return Promise.resolve(result).finally(release) as T;
317
371
  }
372
+ release();
373
+ return result;
318
374
  }
319
375
 
320
376
  /**
@@ -512,7 +568,7 @@ export function performLocalClaim(
512
568
  artifactRoot: string,
513
569
  subjectId: string,
514
570
  actor: ActorStruct,
515
- opts: { ttlSeconds: number; branch: string; artifactDir: string; reason?: string; actorKey?: string },
571
+ opts: { ttlSeconds: number; branch: string; artifactDir: string; reason?: string; actorKey?: string; workItemRef?: string },
516
572
  ): AssignmentClaimRecord {
517
573
  const helper = loadActorIdentityHelper();
518
574
  const reason = opts.reason ?? "claim";
@@ -538,6 +594,7 @@ export function performLocalClaim(
538
594
  subject_id: subjectId,
539
595
  actor,
540
596
  ...(opts.actorKey ? { actor_key: opts.actorKey } : {}),
597
+ ...((opts.workItemRef ?? existing?.work_item_ref) ? { work_item_ref: opts.workItemRef ?? existing?.work_item_ref } : {}),
541
598
  claimed_at: isoNow(),
542
599
  ttl_seconds: opts.ttlSeconds,
543
600
  branch: opts.branch,
@@ -615,15 +672,22 @@ export function performLocalRelease(
615
672
  subjectId: string,
616
673
  releasedBy: ActorStruct | null,
617
674
  opts: { reason?: string; actorKey?: string; tolerateNoActiveClaim?: boolean } = {},
675
+ ): AssignmentClaimRecord | null {
676
+ return withSubjectLock(artifactRoot, subjectId, () => performLocalReleaseUnderLock(artifactRoot, subjectId, releasedBy, opts));
677
+ }
678
+
679
+ /** Caller must already hold this subject's assignment lock through withSubjectLock(). */
680
+ export function performLocalReleaseUnderLock(
681
+ artifactRoot: string,
682
+ subjectId: string,
683
+ releasedBy: ActorStruct | null,
684
+ opts: { reason?: string; actorKey?: string; tolerateNoActiveClaim?: boolean } = {},
618
685
  ): AssignmentClaimRecord | null {
619
686
  const helper = loadActorIdentityHelper();
620
687
  const reason = opts.reason ?? "released";
621
688
  const tolerateNoActiveClaim = opts.tolerateNoActiveClaim ?? false;
622
689
 
623
- // F1 fix (fix-plan iteration 1, CRITICAL): release mutates the same record file claim/supersede
624
- // do, under the same per-subject lock (see withSubjectLock()'s doc comment).
625
- return withSubjectLock(artifactRoot, subjectId, (): AssignmentClaimRecord | null => {
626
- const existing = readLocalRecord(artifactRoot, subjectId);
690
+ const existing = readLocalRecord(artifactRoot, subjectId);
627
691
  if (!existing || existing.status !== "claimed") {
628
692
  if (tolerateNoActiveClaim) return null;
629
693
  throw new Error(`no active claim to release for subject: ${subjectId}`);
@@ -675,8 +739,7 @@ export function performLocalRelease(
675
739
  audit_trail: [...(existing.audit_trail ?? []), { at: isoNow(), transition: "release", from_actor: existing.actor, to_actor: releasedBy, reason }],
676
740
  };
677
741
  writeLocalRecord(artifactRoot, subjectId, record);
678
- return record;
679
- });
742
+ return record;
680
743
  }
681
744
 
682
745
  function releaseLocalFile(argv: string[]): number {
@@ -707,7 +770,7 @@ export function performLocalSupersede(
707
770
  subjectId: string,
708
771
  fromActor: ActorStruct,
709
772
  toActor: ActorStruct,
710
- opts: { ttlSeconds?: number; branch?: string; artifactDir?: string; reason?: string; actorKey?: string } = {},
773
+ opts: { ttlSeconds?: number; branch?: string; artifactDir?: string; reason?: string; actorKey?: string; workItemRef?: string } = {},
711
774
  ): AssignmentClaimRecord {
712
775
  const helper = loadActorIdentityHelper();
713
776
  const reason = opts.reason ?? "supersede";
@@ -732,6 +795,7 @@ export function performLocalSupersede(
732
795
  subject_id: subjectId,
733
796
  actor: toActor,
734
797
  ...(opts.actorKey ? { actor_key: opts.actorKey } : {}),
798
+ ...((opts.workItemRef ?? existing.work_item_ref) ? { work_item_ref: opts.workItemRef ?? existing.work_item_ref } : {}),
735
799
  claimed_at: isoNow(),
736
800
  ttl_seconds: ttlSeconds,
737
801
  branch: opts.branch ?? existing.branch,