@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
@@ -3,17 +3,31 @@ import assert from "node:assert/strict";
3
3
  import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
+ import { generateKeyPairSync, sign } from "node:crypto";
6
7
 
7
8
  import { FLOW_RUN_EVIDENCE_MANIFEST_PATH, runDir } from "@kontourai/flow";
8
9
  import {
10
+ archiveBuilderFlowSession,
11
+ cancelBuilderFlowSession,
12
+ pauseBuilderFlowSession,
9
13
  recoverBuilderFlowSession,
14
+ releaseBuilderFlowAssignment,
15
+ resumeBuilderFlowSession,
10
16
  startBuilderFlowSession,
11
17
  syncBuilderFlowSession,
12
18
  } from "../../build/src/builder-flow-runtime.js";
19
+ import { builderLifecycleAuthorizationPayload, loadBuilderLifecycleAuthorization, recordAuthorizationConsumed } from "../../build/src/builder-lifecycle-authority.js";
20
+ import { cancelBuilderBuildRun } from "../../build/src/builder-flow-run-adapter.js";
21
+ import { performLocalClaim, readLocalAssignmentStatus, resolveCurrentAssignmentActor } from "../../build/src/cli/assignment-provider.js";
13
22
  import { main as builderRunMain } from "../../build/src/cli/builder-run.js";
14
23
 
15
24
  const SUBJECT = "local:work-item/runtime-projection";
16
25
  const NOW = "2026-07-09T20:00:00.000Z";
26
+ const PACKAGE_VERSION = JSON.parse(fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version;
27
+ const ACTOR = { runtime: "codex", session_id: "runtime-projection", host: "test-host", human: null };
28
+ const ACTOR_KEY = "codex:runtime-projection:test-host";
29
+ const AUTHORITY_KEY_ID = "runtime-test";
30
+ const AUTHORITY_KEYS = generateKeyPairSync("ed25519");
17
31
 
18
32
  function makeSession(slug = "runtime-projection") {
19
33
  const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-builder-runtime-"));
@@ -33,6 +47,10 @@ function makeSession(slug = "runtime-projection") {
33
47
  artifact_dir: `.kontourai/flow-agents/${slug}`,
34
48
  updated_at: NOW,
35
49
  });
50
+ writeJson(path.join(projectRoot, ".flow-agents", "lifecycle-authority-keys.json"), {
51
+ schema_version: "1.0",
52
+ keys: [{ id: AUTHORITY_KEY_ID, algorithm: "ed25519", public_key_pem: AUTHORITY_KEYS.publicKey.export({ type: "spki", format: "pem" }) }],
53
+ });
36
54
  return { projectRoot, artifactRoot, sessionDir, slug };
37
55
  }
38
56
 
@@ -45,6 +63,104 @@ function readJson(file) {
45
63
  return JSON.parse(fs.readFileSync(file, "utf8"));
46
64
  }
47
65
 
66
+ function consumedAuthorizationRecords(session) {
67
+ const directory = path.join(session.artifactRoot, "lifecycle-authority", "consumed");
68
+ if (!fs.existsSync(directory)) return [];
69
+ return fs.readdirSync(directory).sort().map((name) => readJson(path.join(directory, name)));
70
+ }
71
+
72
+ function claimSessionAssignment(session) {
73
+ performLocalClaim(session.artifactRoot, session.slug, ACTOR, {
74
+ ttlSeconds: 1800,
75
+ actorKey: ACTOR_KEY,
76
+ branch: `agent/${session.slug}`,
77
+ artifactDir: session.sessionDir,
78
+ workItemRef: SUBJECT,
79
+ reason: "test",
80
+ });
81
+ }
82
+
83
+ function claimAmbientSessionAssignment(session) {
84
+ const ambient = resolveCurrentAssignmentActor();
85
+ performLocalClaim(session.artifactRoot, session.slug, ambient.actor, {
86
+ ttlSeconds: 1800,
87
+ actorKey: ambient.actorKey,
88
+ branch: `agent/${session.slug}`,
89
+ artifactDir: session.sessionDir,
90
+ workItemRef: SUBJECT,
91
+ reason: "test",
92
+ });
93
+ return ambient;
94
+ }
95
+
96
+ function lifecycleAuthorization(session, operation, name, overrides = {}) {
97
+ const file = path.join(session.projectRoot, `${name}.authorization.json`);
98
+ const unsigned = {
99
+ schema_version: "1.0",
100
+ operation,
101
+ run_id: session.slug,
102
+ subject: SUBJECT,
103
+ assignment_actor_key: ACTOR_KEY,
104
+ assignment_actor: ACTOR,
105
+ nonce: `${session.slug}:${name}`,
106
+ expires_at: "2026-07-09T21:00:00.000Z",
107
+ request: {
108
+ reason: `${name} requested by fixture`,
109
+ authority: {
110
+ kind: "user_request",
111
+ actor: "fixture-user",
112
+ request_ref: `fixture://request/${name}`,
113
+ requested_at: NOW,
114
+ },
115
+ },
116
+ ...overrides,
117
+ };
118
+ const value = {
119
+ ...unsigned,
120
+ signature: {
121
+ algorithm: "ed25519",
122
+ key_id: AUTHORITY_KEY_ID,
123
+ value: sign(null, Buffer.from(builderLifecycleAuthorizationPayload(unsigned)), AUTHORITY_KEYS.privateKey).toString("base64"),
124
+ },
125
+ };
126
+ writeJson(file, value);
127
+ return file;
128
+ }
129
+
130
+ function liveLifecycleAuthorization(session, operation, name, overrides = {}) {
131
+ const requestedAt = new Date();
132
+ return lifecycleAuthorization(session, operation, name, {
133
+ expires_at: new Date(requestedAt.getTime() + 60 * 60_000).toISOString(),
134
+ request: {
135
+ reason: `${name} requested by fixture`,
136
+ authority: {
137
+ kind: "user_request",
138
+ actor: "fixture-user",
139
+ request_ref: `fixture://request/${name}`,
140
+ requested_at: requestedAt.toISOString(),
141
+ },
142
+ },
143
+ ...overrides,
144
+ });
145
+ }
146
+
147
+ function expiredLifecycleAuthorization(session, operation, name, overrides = {}) {
148
+ const requestedAt = new Date(Date.now() - 120_000);
149
+ return lifecycleAuthorization(session, operation, name, {
150
+ expires_at: new Date(Date.now() - 60_000).toISOString(),
151
+ request: {
152
+ reason: `${name} requested by fixture`,
153
+ authority: {
154
+ kind: "user_request",
155
+ actor: "fixture-user",
156
+ request_ref: `fixture://request/${name}`,
157
+ requested_at: requestedAt.toISOString(),
158
+ },
159
+ },
160
+ ...overrides,
161
+ });
162
+ }
163
+
48
164
  function snapshotFile(file) {
49
165
  return fs.existsSync(file) ? fs.readFileSync(file).toString("base64") : null;
50
166
  }
@@ -152,7 +268,10 @@ test("small-model client can start and advance from projected actions without ch
152
268
  assert.equal(started.run.state.current_step, "pull-work");
153
269
  assert.deepEqual(started.projection.next_action.skills, ["pull-work"]);
154
270
  assert.deepEqual(started.projection.next_action.operations, []);
155
- assert.equal(started.projection.next_action.command, `flow-agents builder-run sync --session-dir .kontourai/flow-agents/${session.slug}`);
271
+ assert.match(started.projection.next_action.command, /^sh -c /);
272
+ assert.match(started.projection.next_action.command, /--prefix "\$root"/);
273
+ assert.ok(started.projection.next_action.command.includes(`'@kontourai/flow-agents@${PACKAGE_VERSION}'`));
274
+ assert.ok(started.projection.next_action.command.includes(`'workflow' 'status' '--session-dir' '.kontourai/flow-agents/${session.slug}' '--json'`));
156
275
  assert.ok(fs.existsSync(runDir(session.slug, session.projectRoot)));
157
276
  assert.ok(!fs.existsSync(path.join(session.projectRoot, ".flow", "runs")), "retired runtime path must not be created");
158
277
 
@@ -172,6 +291,282 @@ test("small-model client can start and advance from projected actions without ch
172
291
  assert.equal(duplicate.run.manifest.evidence.length, advanced.run.manifest.evidence.length);
173
292
  });
174
293
 
294
+ test("pause and resume preserve the current Flow step and active assignment", async () => {
295
+ const session = makeSession("lifecycle-pause-resume");
296
+ claimAmbientSessionAssignment(session);
297
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
298
+ const before = readJson(path.join(runDir(session.slug, session.projectRoot), "state.json"));
299
+
300
+ const paused = await pauseBuilderFlowSession({
301
+ sessionDir: session.sessionDir,
302
+ reason: "fixture pause",
303
+ });
304
+ assert.equal(paused.run.state.status, "paused");
305
+ assert.equal(paused.run.state.current_step, before.current_step);
306
+ assert.deepEqual(paused.run.state.transitions, before.transitions);
307
+ assert.equal(paused.projection.status, "blocked");
308
+ assert.equal(readLocalAssignmentStatus(session.artifactRoot, session.slug).record.status, "claimed");
309
+
310
+ const resumed = await resumeBuilderFlowSession({
311
+ sessionDir: session.sessionDir,
312
+ reason: "fixture resume",
313
+ });
314
+ assert.equal(resumed.run.state.status, "active");
315
+ assert.equal(resumed.run.state.current_step, before.current_step);
316
+ assert.deepEqual(resumed.run.state.transitions, before.transitions);
317
+ assert.equal(readLocalAssignmentStatus(session.artifactRoot, session.slug).record.status, "claimed");
318
+ });
319
+
320
+ test("authorized cancellation is canonical, terminal, and releases the assignment exactly once", async () => {
321
+ const session = makeSession("lifecycle-cancel");
322
+ claimSessionAssignment(session);
323
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
324
+ const authorizationFile = liveLifecycleAuthorization(session, "cancel", "cancel");
325
+
326
+ const canceled = await cancelBuilderFlowSession({
327
+ sessionDir: session.sessionDir,
328
+ authorizationFile,
329
+ });
330
+ assert.equal(canceled.run.state.status, "canceled");
331
+ assert.equal(canceled.projection.status, "canceled");
332
+ assert.equal(canceled.assignmentReleased, true);
333
+ assert.equal(canceled.idempotent, false);
334
+ const assignmentFile = path.join(session.artifactRoot, "assignment", `${session.slug}.json`);
335
+ assert.equal(readJson(assignmentFile).status, "released");
336
+ const firstAudit = readJson(assignmentFile).audit_trail;
337
+
338
+ await assert.rejects(() => cancelBuilderFlowSession({
339
+ sessionDir: session.sessionDir,
340
+ authorizationFile,
341
+ }), /nonce has already been consumed/);
342
+ assert.deepEqual(readJson(assignmentFile).audit_trail, firstAudit);
343
+ });
344
+
345
+ test("assignment release is independent of Flow lifecycle", async () => {
346
+ const session = makeSession("lifecycle-release-assignment");
347
+ claimAmbientSessionAssignment(session);
348
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
349
+ const beforeFlow = snapshotTree(runDir(session.slug, session.projectRoot));
350
+
351
+ const released = await releaseBuilderFlowAssignment({
352
+ sessionDir: session.sessionDir,
353
+ reason: "fixture assignment release",
354
+ });
355
+
356
+ assert.equal(released.assignmentReleased, true);
357
+ assert.deepEqual(snapshotTree(runDir(session.slug, session.projectRoot)), beforeFlow);
358
+ assert.equal(readJson(path.join(session.artifactRoot, "assignment", `${session.slug}.json`)).status, "released");
359
+ });
360
+
361
+ test("archive rejects active runs and retains canceled Flow and session artifacts", async () => {
362
+ const session = makeSession("lifecycle-archive");
363
+ claimSessionAssignment(session);
364
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
365
+ const cancelAuthorization = liveLifecycleAuthorization(session, "cancel", "archive-cancel");
366
+ const authorizationFile = liveLifecycleAuthorization(session, "archive", "archive");
367
+ const beforeReject = snapshotTree(session.sessionDir);
368
+ await assert.rejects(() => archiveBuilderFlowSession({
369
+ sessionDir: session.sessionDir,
370
+ authorizationFile,
371
+ }), /must be completed or canceled/);
372
+ assert.deepEqual(snapshotTree(session.sessionDir), beforeReject);
373
+
374
+ await cancelBuilderFlowSession({
375
+ sessionDir: session.sessionDir,
376
+ authorizationFile: cancelAuthorization,
377
+ });
378
+ const beforeFlow = snapshotTree(runDir(session.slug, session.projectRoot));
379
+ const beforeSession = snapshotTree(session.sessionDir);
380
+ const archived = await archiveBuilderFlowSession({
381
+ sessionDir: session.sessionDir,
382
+ authorizationFile,
383
+ });
384
+
385
+ assert.equal(archived.archiveDir, path.join(session.artifactRoot, "archive", session.slug));
386
+ assert.equal(fs.existsSync(session.sessionDir), false);
387
+ assert.equal(readJson(path.join(archived.archiveDir, "state.json")).status, "archived");
388
+ assert.deepEqual(snapshotTree(runDir(session.slug, session.projectRoot)), beforeFlow);
389
+ const archivedFiles = snapshotTree(archived.archiveDir).map(([name]) => name);
390
+ for (const [name] of beforeSession) assert.ok(archivedFiles.includes(name), `archive retained ${name}`);
391
+ assert.deepEqual(consumedAuthorizationRecords(session).map((record) => record.operation).sort(), ["archive", "cancel"]);
392
+ });
393
+
394
+ test("archive rejects a symlinked archive root without moving the session", async () => {
395
+ const session = makeSession("lifecycle-archive-symlink");
396
+ claimSessionAssignment(session);
397
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
398
+ const cancelAuthorization = liveLifecycleAuthorization(session, "cancel", "archive-symlink-cancel");
399
+ const authorizationFile = liveLifecycleAuthorization(session, "archive", "archive-symlink");
400
+ await cancelBuilderFlowSession({
401
+ sessionDir: session.sessionDir,
402
+ authorizationFile: cancelAuthorization,
403
+ });
404
+ const outside = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-archive-outside-"));
405
+ fs.symlinkSync(outside, path.join(session.artifactRoot, "archive"), "dir");
406
+ const beforeSession = snapshotTree(session.sessionDir);
407
+
408
+ await assert.rejects(() => archiveBuilderFlowSession({
409
+ sessionDir: session.sessionDir,
410
+ authorizationFile,
411
+ }), /archive root.*symbolic link/);
412
+
413
+ assert.deepEqual(snapshotTree(session.sessionDir), beforeSession);
414
+ assert.deepEqual(fs.readdirSync(outside), []);
415
+ });
416
+
417
+ test("archive retries the exact consumed authorization after an interrupted prepared move", async () => {
418
+ const session = makeSession("lifecycle-archive-recovery");
419
+ claimSessionAssignment(session);
420
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
421
+ await cancelBuilderFlowSession({
422
+ sessionDir: session.sessionDir,
423
+ authorizationFile: liveLifecycleAuthorization(session, "cancel", "archive-recovery-cancel"),
424
+ });
425
+ const authorizationFile = expiredLifecycleAuthorization(session, "archive", "archive-recovery");
426
+ const rawAuthorization = readJson(authorizationFile);
427
+ const authorization = loadBuilderLifecycleAuthorization(authorizationFile, {
428
+ projectRoot: session.projectRoot,
429
+ operation: "archive",
430
+ runId: session.slug,
431
+ subject: SUBJECT,
432
+ actorKey: ACTOR_KEY,
433
+ now: new Date(Date.parse(rawAuthorization.request.authority.requested_at) + 30_000).toISOString(),
434
+ });
435
+ const preparedState = readJson(path.join(session.sessionDir, "state.json"));
436
+ preparedState.status = "archived";
437
+ preparedState.phase = "done";
438
+ preparedState.next_action = { status: "done", summary: "Builder session archived; canonical Flow artifacts remain retained." };
439
+ writeJson(path.join(session.sessionDir, "state.json"), preparedState);
440
+ recordAuthorizationConsumed(session.artifactRoot, authorization);
441
+
442
+ const conflictingAuthorization = liveLifecycleAuthorization(session, "archive", "archive-recovery-conflict", {
443
+ nonce: authorization.nonce,
444
+ });
445
+ await assert.rejects(
446
+ () => archiveBuilderFlowSession({ sessionDir: session.sessionDir, authorizationFile: conflictingAuthorization }),
447
+ /does not match its integrity key/,
448
+ );
449
+ assert.equal(fs.existsSync(session.sessionDir), true);
450
+
451
+ const recovered = await archiveBuilderFlowSession({ sessionDir: session.sessionDir, authorizationFile });
452
+ assert.equal(fs.existsSync(session.sessionDir), false);
453
+ assert.equal(readJson(path.join(recovered.archiveDir, "state.json")).status, "archived");
454
+ assert.equal(consumedAuthorizationRecords(session).length, 2);
455
+ });
456
+
457
+ test("mismatched and expired cancellation authority fail before Flow or sidecar mutation", async (t) => {
458
+ for (const [name, overrides, pattern] of [
459
+ ["wrong-run", { run_id: "another-run" }, /run_id does not match/],
460
+ ["wrong-subject", { subject: "local:other" }, /subject does not match/],
461
+ ["wrong-actor", { assignment_actor_key: "another-actor" }, /assignment_actor_key does not match/],
462
+ ["wrong-actor-struct", { assignment_actor: { ...ACTOR, session_id: "another-session" } }, /assignment_actor.*active assignment holder/],
463
+ ["wrong-operation", { operation: "archive" }, /operation does not match/],
464
+ ["expired", {}, /expired/],
465
+ ]) {
466
+ await t.test(name, async () => {
467
+ const session = makeSession(`lifecycle-reject-${name}`);
468
+ claimSessionAssignment(session);
469
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
470
+ const beforeFlow = snapshotTree(runDir(session.slug, session.projectRoot));
471
+ const beforeProjection = snapshotProjectionTargets(session);
472
+ const beforeAssignment = snapshotFile(path.join(session.artifactRoot, "assignment", `${session.slug}.json`));
473
+ await assert.rejects(() => cancelBuilderFlowSession({
474
+ sessionDir: session.sessionDir,
475
+ authorizationFile: name === "expired"
476
+ ? expiredLifecycleAuthorization(session, "cancel", name, overrides)
477
+ : liveLifecycleAuthorization(session, "cancel", name, overrides),
478
+ }), pattern);
479
+ assert.deepEqual(snapshotTree(runDir(session.slug, session.projectRoot)), beforeFlow);
480
+ assert.deepEqual(snapshotProjectionTargets(session), beforeProjection);
481
+ assert.equal(snapshotFile(path.join(session.artifactRoot, "assignment", `${session.slug}.json`)), beforeAssignment);
482
+ });
483
+ }
484
+ });
485
+
486
+ test("conflicting cancellation request replay is rejected without a second transition or release", async () => {
487
+ const session = makeSession("lifecycle-conflicting-replay");
488
+ claimSessionAssignment(session);
489
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
490
+ await cancelBuilderFlowSession({
491
+ sessionDir: session.sessionDir,
492
+ authorizationFile: liveLifecycleAuthorization(session, "cancel", "cancel-original"),
493
+ });
494
+ const beforeFlow = snapshotTree(runDir(session.slug, session.projectRoot));
495
+ const assignmentFile = path.join(session.artifactRoot, "assignment", `${session.slug}.json`);
496
+ const beforeAssignment = snapshotFile(assignmentFile);
497
+ await assert.rejects(() => cancelBuilderFlowSession({
498
+ sessionDir: session.sessionDir,
499
+ authorizationFile: liveLifecycleAuthorization(session, "cancel", "cancel-conflict"),
500
+ }), /does not match the canonical cancellation/);
501
+ assert.deepEqual(snapshotTree(runDir(session.slug, session.projectRoot)), beforeFlow);
502
+ assert.equal(snapshotFile(assignmentFile), beforeAssignment);
503
+ });
504
+
505
+ test("tampered or unsigned cancellation authority fails before mutation", async () => {
506
+ const session = makeSession("lifecycle-signature-reject");
507
+ claimSessionAssignment(session);
508
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
509
+ const authorizationFile = liveLifecycleAuthorization(session, "cancel", "signature-reject");
510
+ const tampered = readJson(authorizationFile);
511
+ tampered.request.reason = "agent-authored replacement";
512
+ writeJson(authorizationFile, tampered);
513
+ const beforeFlow = snapshotTree(runDir(session.slug, session.projectRoot));
514
+ const beforeAssignment = snapshotFile(path.join(session.artifactRoot, "assignment", `${session.slug}.json`));
515
+
516
+ await assert.rejects(() => cancelBuilderFlowSession({ sessionDir: session.sessionDir, authorizationFile }), /signature is invalid/);
517
+ assert.deepEqual(snapshotTree(runDir(session.slug, session.projectRoot)), beforeFlow);
518
+ assert.equal(snapshotFile(path.join(session.artifactRoot, "assignment", `${session.slug}.json`)), beforeAssignment);
519
+ });
520
+
521
+ test("expired authority can finish side effects for its matching canonical cancellation", async () => {
522
+ const session = makeSession("lifecycle-cancel-recovery");
523
+ claimSessionAssignment(session);
524
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
525
+ const authorizationFile = expiredLifecycleAuthorization(session, "cancel", "cancel-recovery");
526
+ const authorization = readJson(authorizationFile);
527
+ await cancelBuilderBuildRun({
528
+ cwd: session.projectRoot,
529
+ runId: session.slug,
530
+ request: authorization.request,
531
+ at: new Date(Date.parse(authorization.expires_at) - 1_000).toISOString(),
532
+ });
533
+ assert.equal(readJson(path.join(runDir(session.slug, session.projectRoot), "state.json")).status, "canceled");
534
+ assert.equal(readJson(path.join(session.artifactRoot, "assignment", `${session.slug}.json`)).status, "claimed");
535
+ assert.deepEqual(consumedAuthorizationRecords(session), []);
536
+
537
+ const recovered = await cancelBuilderFlowSession({ sessionDir: session.sessionDir, authorizationFile });
538
+ assert.equal(recovered.idempotent, true);
539
+ assert.equal(recovered.assignmentReleased, true);
540
+ assert.equal(recovered.projection.status, "canceled");
541
+ assert.equal(consumedAuthorizationRecords(session).length, 1);
542
+ });
543
+
544
+ test("builder-run exposes lifecycle actions without caller-selected Flow identity", async () => {
545
+ const session = makeSession("lifecycle-cli");
546
+ const ambient = resolveCurrentAssignmentActor();
547
+ performLocalClaim(session.artifactRoot, session.slug, ambient.actor, { actorKey: ambient.actorKey, ttlSeconds: 1800, branch: `agent/${session.slug}`, artifactDir: session.sessionDir, workItemRef: SUBJECT, reason: "test" });
548
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
549
+ assert.equal(await builderRunMain([
550
+ "pause", "--session-dir", session.sessionDir,
551
+ "--reason", "cli pause",
552
+ ]), 0);
553
+ assert.equal(await builderRunMain([
554
+ "resume", "--session-dir", session.sessionDir,
555
+ "--reason", "cli resume",
556
+ ]), 0);
557
+ const cliCancel = liveLifecycleAuthorization(session, "cancel", "cli-cancel", { assignment_actor_key: ambient.actorKey, assignment_actor: ambient.actor });
558
+ assert.equal(await builderRunMain([
559
+ "cancel", "--session-dir", session.sessionDir,
560
+ "--authorization-file", cliCancel,
561
+ ]), 0);
562
+ assert.equal(readJson(path.join(runDir(session.slug, session.projectRoot), "state.json")).status, "canceled");
563
+ assert.equal(await builderRunMain([
564
+ "cancel", "--session-dir", session.sessionDir,
565
+ "--authorization-file", liveLifecycleAuthorization(session, "cancel", "cli-clock-override", { assignment_actor_key: ambient.actorKey, assignment_actor: ambient.actor }),
566
+ "--now", "2020-01-01T00:00:00.000Z",
567
+ ]), 64);
568
+ });
569
+
175
570
  test("automatic start refuses a slug-bound run for another Work Item without mutation", async () => {
176
571
  const session = makeSession("start-subject-mismatch");
177
572
  await startBuilderFlowSession({ sessionDir: session.sessionDir });
@@ -253,6 +648,7 @@ test("failed verification projects Flow-owned route-back attempt and budget", as
253
648
 
254
649
  test("verified sidecar claims drive the composed publish and learning prefix to completion", async () => {
255
650
  const session = makeSession("composed-completion");
651
+ claimSessionAssignment(session);
256
652
  await startBuilderFlowSession({ sessionDir: session.sessionDir });
257
653
  const steps = [
258
654
  [bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })],
@@ -297,6 +693,13 @@ test("verified sidecar claims drive the composed publish and learning prefix to
297
693
  assert.equal(recovered.projection.phase, "done");
298
694
  assert.deepEqual(recovered.projection.next_action, { status: "done", summary: "Canonical Flow run is complete." });
299
695
  assert.deepEqual(snapshotTree(flowDirectory), beforeFlow);
696
+
697
+ const archived = await archiveBuilderFlowSession({
698
+ sessionDir: session.sessionDir,
699
+ authorizationFile: liveLifecycleAuthorization(session, "archive", "completed-archive"),
700
+ });
701
+ assert.equal(readJson(path.join(archived.archiveDir, "state.json")).status, "archived");
702
+ assert.deepEqual(snapshotTree(flowDirectory), beforeFlow);
300
703
  });
301
704
 
302
705
  test("recovery loads the slug-bound run, restores every matching projection, and preserves every Flow byte", async () => {
@@ -1,36 +1,87 @@
1
1
  import { flagString, parseArgs } from "../lib/args.js";
2
- import { recoverBuilderFlowSession, startBuilderFlowSession, syncBuilderFlowSession } from "../builder-flow-runtime.js";
2
+ import {
3
+ cancelBuilderFlowSession,
4
+ archiveBuilderFlowSession,
5
+ pauseBuilderFlowSession,
6
+ recoverBuilderFlowSession,
7
+ releaseBuilderFlowAssignment,
8
+ resumeBuilderFlowSession,
9
+ startBuilderFlowSession,
10
+ syncBuilderFlowSession,
11
+ } from "../builder-flow-runtime.js";
12
+
13
+ const USAGE = "Usage: flow-agents builder-run <start|sync|recover|pause|resume|cancel|release-assignment|archive> --session-dir <path> [--reason <text> | --authorization-file <path>]";
3
14
 
4
15
  export async function main(argv: string[]): Promise<number> {
5
16
  const parsed = parseArgs(argv);
6
17
  const action = parsed.positionals[0];
7
18
  const sessionDir = flagString(parsed.flags, "session-dir");
19
+ const authorizationFile = flagString(parsed.flags, "authorization-file");
20
+ const reason = flagString(parsed.flags, "reason");
8
21
  const validRecoveryArguments = parsed.positionals.length === 1
9
22
  && Object.keys(parsed.flags).length === 1
10
23
  && typeof parsed.flags["session-dir"] === "string";
11
24
  if (action === "recover" && !validRecoveryArguments) {
12
- console.error("Usage: flow-agents builder-run <start|sync|recover> --session-dir <path>");
25
+ console.error(USAGE);
13
26
  return 64;
14
27
  }
15
28
  if (!sessionDir) {
16
29
  console.error("builder-run requires --session-dir .kontourai/flow-agents/<slug>");
17
30
  return 64;
18
31
  }
19
- if (action !== "start" && action !== "sync" && action !== "recover") {
20
- console.error("Usage: flow-agents builder-run <start|sync|recover> --session-dir <path>");
32
+ if (!action || !["start", "sync", "recover", "pause", "resume", "cancel", "release-assignment", "archive"].includes(action)) {
33
+ console.error(USAGE);
34
+ return 64;
35
+ }
36
+ const agentLifecycle = action === "pause" || action === "resume" || action === "release-assignment";
37
+ const authorizedLifecycle = action === "cancel" || action === "archive";
38
+ const lifecycle = agentLifecycle || authorizedLifecycle;
39
+ const allowedLifecycleFlag = (name: string) => name === "session-dir" || (agentLifecycle ? name === "reason" : name === "authorization-file");
40
+ if (lifecycle && (parsed.positionals.length !== 1 || Object.keys(parsed.flags).some((name) => !allowedLifecycleFlag(name)))) {
41
+ console.error(USAGE);
42
+ return 64;
43
+ }
44
+ if (agentLifecycle && !reason) {
45
+ console.error(`builder-run ${action} requires --reason <text>`);
46
+ return 64;
47
+ }
48
+ if (authorizedLifecycle && !authorizationFile) {
49
+ console.error(`builder-run ${action} requires a signed --authorization-file <path>`);
50
+ return 64;
51
+ }
52
+ if (!lifecycle && (authorizationFile || reason)) {
53
+ console.error(USAGE);
21
54
  return 64;
22
55
  }
23
56
  const result = action === "start"
24
57
  ? await startBuilderFlowSession({ sessionDir })
25
58
  : action === "sync"
26
59
  ? await syncBuilderFlowSession({ sessionDir })
27
- : await recoverBuilderFlowSession({ sessionDir });
60
+ : action === "recover"
61
+ ? await recoverBuilderFlowSession({ sessionDir })
62
+ : action === "pause"
63
+ ? await pauseBuilderFlowSession({ sessionDir, reason: reason! })
64
+ : action === "resume"
65
+ ? await resumeBuilderFlowSession({ sessionDir, reason: reason! })
66
+ : action === "cancel"
67
+ ? await cancelBuilderFlowSession({ sessionDir, authorizationFile: authorizationFile! })
68
+ : action === "release-assignment"
69
+ ? await releaseBuilderFlowAssignment({ sessionDir, reason: reason! })
70
+ : await archiveBuilderFlowSession({ sessionDir, authorizationFile: authorizationFile! });
28
71
  console.log(JSON.stringify({
29
72
  run_id: result.run.runId,
30
73
  definition_id: result.run.definitionId,
31
74
  current_step: result.run.state.current_step,
32
75
  status: result.run.state.status,
33
76
  attached: result.attached,
77
+ ...(action === "cancel" ? {
78
+ assignment_released: "assignmentReleased" in result ? result.assignmentReleased : false,
79
+ idempotent: "idempotent" in result ? result.idempotent : false,
80
+ } : action === "release-assignment" ? {
81
+ assignment_released: "assignmentReleased" in result ? result.assignmentReleased : false,
82
+ } : action === "archive" ? {
83
+ archive_dir: "archiveDir" in result ? result.archiveDir : null,
84
+ } : {}),
34
85
  next_action: result.projection.next_action,
35
86
  }));
36
87
  return 0;
@@ -264,6 +264,9 @@ function classifyWorkflow(slug: string, workflowPath: string): AuditItem {
264
264
  if (status === "verified" && nextStatus === "done") {
265
265
  return { ...base, classification: "cleanup_candidate", reasons: ["verified workflow has next_action.status done"] };
266
266
  }
267
+ if (status === "canceled" && phase === "done") {
268
+ return { ...base, classification: "terminal_done", reasons: ["canceled workflow retains its artifacts without requiring delivery promotion"] };
269
+ }
267
270
  if (["delivered", "accepted", "archived"].includes(status) && phase === "done") {
268
271
  if (status !== "archived" && !hasPromotionClaim(workflowPath)) {
269
272
  return { ...base, classification: "cleanup_candidate", reasons: [`${status} workflow reached phase done without a promotion claim; ${PROMOTE_REMEDY}`] };