@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.
- package/.github/workflows/ci.yml +4 -0
- package/CHANGELOG.md +15 -0
- package/build/src/builder-flow-run-adapter.d.ts +10 -1
- package/build/src/builder-flow-run-adapter.js +29 -1
- package/build/src/builder-flow-runtime.d.ts +18 -0
- package/build/src/builder-flow-runtime.js +205 -13
- package/build/src/builder-lifecycle-authority.d.ts +35 -0
- package/build/src/builder-lifecycle-authority.js +219 -0
- package/build/src/cli/assignment-provider.d.ts +13 -0
- package/build/src/cli/assignment-provider.js +120 -62
- package/build/src/cli/builder-run.js +46 -5
- package/build/src/cli/workflow-artifact-cleanup-audit.js +3 -0
- package/build/src/cli/workflow-sidecar.d.ts +3 -0
- package/build/src/cli/workflow-sidecar.js +140 -30
- package/build/src/cli/workflow.d.ts +2 -0
- package/build/src/cli/workflow.js +521 -0
- package/build/src/cli.js +2 -0
- package/build/src/index.d.ts +4 -0
- package/build/src/index.js +2 -0
- package/build/src/lib/flow-resolver.js +7 -2
- package/build/src/lib/package-version.d.ts +2 -0
- package/build/src/lib/package-version.js +13 -0
- package/build/src/lib/pinned-cli-command.d.ts +6 -0
- package/build/src/lib/pinned-cli-command.js +21 -0
- package/context/contracts/artifact-contract.md +1 -1
- package/context/contracts/assignment-provider-contract.md +5 -2
- package/context/scripts/hooks/config-protection.js +8 -1
- package/context/scripts/hooks/lib/config-protection-remedies.js +3 -0
- package/context/scripts/hooks/stop-goal-fit.js +1 -1
- package/docs/context-map.md +2 -0
- package/docs/public-workflow-cli.md +63 -0
- package/docs/spec/builder-flow-runtime.md +49 -5
- package/docs/workflow-usage-guide.md +5 -0
- package/evals/ci/run-baseline.sh +2 -0
- package/evals/integration/test_assignment_provider_local_file.sh +54 -0
- package/evals/integration/test_builder_entry_enforcement.sh +241 -24
- package/evals/integration/test_bundle_install.sh +97 -0
- package/evals/integration/test_current_json_per_actor.sh +1 -0
- package/evals/integration/test_dual_emit_flow_step.sh +2 -0
- package/evals/integration/test_flowdef_session_activation.sh +6 -3
- package/evals/integration/test_goal_fit_escape_hatch.sh +3 -3
- package/evals/integration/test_phase_map_and_gate_claim.sh +4 -0
- package/evals/integration/test_public_workflow_cli.sh +259 -0
- package/evals/integration/test_workflow_sidecar_writer.sh +1 -0
- package/package.json +2 -2
- package/schemas/builder-lifecycle-authorization.schema.json +57 -0
- package/schemas/lifecycle-authority-keys.schema.json +25 -0
- package/schemas/workflow-state.schema.json +1 -1
- package/scripts/hooks/config-protection.js +8 -1
- package/scripts/hooks/lib/config-protection-remedies.js +3 -0
- package/scripts/hooks/stop-goal-fit.js +1 -1
- package/src/builder-flow-run-adapter.ts +47 -0
- package/src/builder-flow-runtime.ts +216 -4
- package/src/builder-lifecycle-authority.ts +218 -0
- package/src/cli/assignment-provider.ts +84 -20
- package/src/cli/builder-flow-runtime.test.mjs +404 -1
- package/src/cli/builder-run.ts +56 -5
- package/src/cli/workflow-artifact-cleanup-audit.ts +3 -0
- package/src/cli/workflow-sidecar.ts +138 -31
- package/src/cli/workflow.ts +471 -0
- package/src/cli.ts +2 -0
- package/src/index.ts +14 -0
- package/src/lib/flow-resolver.ts +6 -2
- package/src/lib/package-version.ts +15 -0
- 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.
|
|
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 () => {
|
package/src/cli/builder-run.ts
CHANGED
|
@@ -1,36 +1,87 @@
|
|
|
1
1
|
import { flagString, parseArgs } from "../lib/args.js";
|
|
2
|
-
import {
|
|
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(
|
|
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
|
|
20
|
-
console.error(
|
|
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
|
-
:
|
|
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}`] };
|