@kontourai/flow-agents 3.4.1 → 3.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/build/src/builder-flow-runtime.d.ts +1 -0
- package/build/src/builder-flow-runtime.js +192 -30
- package/build/src/cli/builder-run.js +13 -4
- package/build/src/cli/workflow-sidecar.js +90 -7
- package/docs/spec/builder-flow-runtime.md +35 -2
- package/docs/spec/runtime-hook-surface.md +2 -2
- package/docs/workflow-usage-guide.md +12 -0
- package/evals/integration/test_builder_entry_enforcement.sh +139 -5
- package/evals/integration/test_builder_step_producers.sh +1 -1
- package/evals/integration/test_bundle_install.sh +2 -1
- package/evals/integration/test_current_json_per_actor.sh +2 -2
- package/evals/integration/test_dual_emit_flow_step.sh +41 -9
- package/evals/integration/test_flowdef_session_activation.sh +11 -5
- package/evals/integration/test_install_merge.sh +24 -0
- package/evals/integration/test_phase_map_and_gate_claim.sh +10 -10
- package/evals/integration/test_resolvefirststep_security.sh +1 -1
- package/evals/integration/test_sidecar_field_preservation.sh +4 -2
- package/evals/integration/test_takeover_protocol.sh +1 -1
- package/evals/integration/test_workflow_sidecar_writer.sh +17 -6
- package/kits/builder/skills/continue-work/SKILL.md +7 -0
- package/package.json +1 -1
- package/schemas/workflow-state.schema.json +8 -0
- package/scripts/install-merge.js +4 -1
- package/src/builder-flow-runtime.ts +231 -29
- package/src/cli/builder-flow-runtime.test.mjs +374 -0
- package/src/cli/builder-run.ts +13 -4
- package/src/cli/workflow-sidecar.ts +91 -10
|
@@ -6,9 +6,11 @@ import path from "node:path";
|
|
|
6
6
|
|
|
7
7
|
import { FLOW_RUN_EVIDENCE_MANIFEST_PATH, runDir } from "@kontourai/flow";
|
|
8
8
|
import {
|
|
9
|
+
recoverBuilderFlowSession,
|
|
9
10
|
startBuilderFlowSession,
|
|
10
11
|
syncBuilderFlowSession,
|
|
11
12
|
} from "../../build/src/builder-flow-runtime.js";
|
|
13
|
+
import { main as builderRunMain } from "../../build/src/cli/builder-run.js";
|
|
12
14
|
|
|
13
15
|
const SUBJECT = "local:work-item/runtime-projection";
|
|
14
16
|
const NOW = "2026-07-09T20:00:00.000Z";
|
|
@@ -43,6 +45,45 @@ function readJson(file) {
|
|
|
43
45
|
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
function snapshotFile(file) {
|
|
49
|
+
return fs.existsSync(file) ? fs.readFileSync(file).toString("base64") : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function snapshotTree(directory) {
|
|
53
|
+
if (!fs.existsSync(directory)) return null;
|
|
54
|
+
const files = [];
|
|
55
|
+
function visit(current) {
|
|
56
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
57
|
+
const absolute = path.join(current, entry.name);
|
|
58
|
+
if (entry.isDirectory()) visit(absolute);
|
|
59
|
+
else files.push([path.relative(directory, absolute), fs.readFileSync(absolute).toString("base64")]);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
visit(directory);
|
|
63
|
+
return files;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function snapshotProjectionTargets(session) {
|
|
67
|
+
const actorRoot = path.join(session.artifactRoot, "current");
|
|
68
|
+
const actors = fs.existsSync(actorRoot)
|
|
69
|
+
? fs.readdirSync(actorRoot).filter((name) => name.endsWith(".json")).sort()
|
|
70
|
+
: [];
|
|
71
|
+
return {
|
|
72
|
+
state: snapshotFile(path.join(session.sessionDir, "state.json")),
|
|
73
|
+
current: snapshotFile(path.join(session.artifactRoot, "current.json")),
|
|
74
|
+
actors: actors.map((name) => [name, snapshotFile(path.join(actorRoot, name))]),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function assertRecoveryRejectsWithoutWrites(session, pattern) {
|
|
79
|
+
const flowDirectory = runDir(session.slug, session.projectRoot);
|
|
80
|
+
const beforeFlow = snapshotTree(flowDirectory);
|
|
81
|
+
const beforeProjection = snapshotProjectionTargets(session);
|
|
82
|
+
await assert.rejects(() => recoverBuilderFlowSession({ sessionDir: session.sessionDir }), pattern);
|
|
83
|
+
assert.deepEqual(snapshotTree(flowDirectory), beforeFlow);
|
|
84
|
+
assert.deepEqual(snapshotProjectionTargets(session), beforeProjection);
|
|
85
|
+
}
|
|
86
|
+
|
|
46
87
|
function bundleClaim({ expectation, claimType, subjectType, status = "pass", routeReason, subject = SUBJECT }) {
|
|
47
88
|
const claimId = `claim.${expectation}`;
|
|
48
89
|
return {
|
|
@@ -131,6 +172,38 @@ test("small-model client can start and advance from projected actions without ch
|
|
|
131
172
|
assert.equal(duplicate.run.manifest.evidence.length, advanced.run.manifest.evidence.length);
|
|
132
173
|
});
|
|
133
174
|
|
|
175
|
+
test("automatic start refuses a slug-bound run for another Work Item without mutation", async () => {
|
|
176
|
+
const session = makeSession("start-subject-mismatch");
|
|
177
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
178
|
+
const sidecar = readJson(path.join(session.sessionDir, "state.json"));
|
|
179
|
+
sidecar.work_item_refs = ["local:work-item/other"];
|
|
180
|
+
writeJson(path.join(session.sessionDir, "state.json"), sidecar);
|
|
181
|
+
const beforeFlow = snapshotTree(runDir(session.slug, session.projectRoot));
|
|
182
|
+
const beforeProjection = snapshotProjectionTargets(session);
|
|
183
|
+
|
|
184
|
+
await assert.rejects(
|
|
185
|
+
() => startBuilderFlowSession({ sessionDir: session.sessionDir }),
|
|
186
|
+
/flow_run\.state\.subject.*selected Work Item/,
|
|
187
|
+
);
|
|
188
|
+
assert.deepEqual(snapshotTree(runDir(session.slug, session.projectRoot)), beforeFlow);
|
|
189
|
+
assert.deepEqual(snapshotProjectionTargets(session), beforeProjection);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("automatic start refuses a sidecar changed after its immutable subject snapshot", async () => {
|
|
193
|
+
const session = makeSession("start-sidecar-race");
|
|
194
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
195
|
+
const beforeFlow = snapshotTree(runDir(session.slug, session.projectRoot));
|
|
196
|
+
const startup = startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
197
|
+
const changed = readJson(path.join(session.sessionDir, "state.json"));
|
|
198
|
+
changed.next_action = { status: "continue", summary: "concurrent change" };
|
|
199
|
+
writeJson(path.join(session.sessionDir, "state.json"), changed);
|
|
200
|
+
const beforeProjection = snapshotProjectionTargets(session);
|
|
201
|
+
|
|
202
|
+
await assert.rejects(() => startup, /state\.json.*changed during projection/);
|
|
203
|
+
assert.deepEqual(snapshotTree(runDir(session.slug, session.projectRoot)), beforeFlow);
|
|
204
|
+
assert.deepEqual(snapshotProjectionTargets(session), beforeProjection);
|
|
205
|
+
});
|
|
206
|
+
|
|
134
207
|
test("wrong workflow subject is rejected before canonical Flow mutation", async () => {
|
|
135
208
|
const session = makeSession("wrong-subject");
|
|
136
209
|
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
@@ -210,4 +283,305 @@ test("verified sidecar claims drive the composed publish and learning prefix to
|
|
|
210
283
|
assert.equal(completed.run.state.status, "completed");
|
|
211
284
|
assert.equal(completed.projection.status, "delivered");
|
|
212
285
|
assert.deepEqual(completed.projection.next_action, { status: "done", summary: "Canonical Flow run is complete." });
|
|
286
|
+
|
|
287
|
+
const flowDirectory = runDir(session.slug, session.projectRoot);
|
|
288
|
+
const beforeFlow = snapshotTree(flowDirectory);
|
|
289
|
+
const staleState = readJson(path.join(session.sessionDir, "state.json"));
|
|
290
|
+
staleState.status = "in_progress";
|
|
291
|
+
staleState.phase = "verification";
|
|
292
|
+
staleState.next_action = { status: "continue", summary: "stale" };
|
|
293
|
+
writeJson(path.join(session.sessionDir, "state.json"), staleState);
|
|
294
|
+
const recovered = await recoverBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
295
|
+
assert.equal(recovered.attached, false);
|
|
296
|
+
assert.equal(recovered.projection.status, "delivered");
|
|
297
|
+
assert.equal(recovered.projection.phase, "done");
|
|
298
|
+
assert.deepEqual(recovered.projection.next_action, { status: "done", summary: "Canonical Flow run is complete." });
|
|
299
|
+
assert.deepEqual(snapshotTree(flowDirectory), beforeFlow);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("recovery loads the slug-bound run, restores every matching projection, and preserves every Flow byte", async () => {
|
|
303
|
+
const session = makeSession("recover-active");
|
|
304
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
305
|
+
const advanced = await writeAndSync(session, [bundleClaim({
|
|
306
|
+
expectation: "selected-work",
|
|
307
|
+
claimType: "builder.pull-work.selected",
|
|
308
|
+
subjectType: "work-item",
|
|
309
|
+
})]);
|
|
310
|
+
assert.equal(advanced.run.state.current_step, "design-probe");
|
|
311
|
+
|
|
312
|
+
writeJson(path.join(session.artifactRoot, "current", "codex.json"), {
|
|
313
|
+
active_slug: session.slug,
|
|
314
|
+
active_step_id: "stale-step",
|
|
315
|
+
updated_at: NOW,
|
|
316
|
+
});
|
|
317
|
+
writeJson(path.join(session.artifactRoot, "current", "other.json"), {
|
|
318
|
+
active_slug: "another-session",
|
|
319
|
+
active_step_id: "untouched-step",
|
|
320
|
+
updated_at: NOW,
|
|
321
|
+
});
|
|
322
|
+
const staleState = readJson(path.join(session.sessionDir, "state.json"));
|
|
323
|
+
staleState.status = "planned";
|
|
324
|
+
staleState.phase = "planning";
|
|
325
|
+
staleState.flow_run.current_step = "pull-work";
|
|
326
|
+
staleState.next_action = { status: "continue", summary: "stale" };
|
|
327
|
+
writeJson(path.join(session.sessionDir, "state.json"), staleState);
|
|
328
|
+
const flowDirectory = runDir(session.slug, session.projectRoot);
|
|
329
|
+
const beforeFlow = snapshotTree(flowDirectory);
|
|
330
|
+
const beforeOther = snapshotFile(path.join(session.artifactRoot, "current", "other.json"));
|
|
331
|
+
const bundleBefore = snapshotFile(path.join(session.sessionDir, "trust.bundle"));
|
|
332
|
+
|
|
333
|
+
const recovered = await recoverBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
334
|
+
|
|
335
|
+
assert.equal(recovered.attached, false);
|
|
336
|
+
assert.equal(recovered.run.runId, session.slug);
|
|
337
|
+
assert.equal(recovered.projection.flow_run.current_step, "design-probe");
|
|
338
|
+
assert.equal(recovered.projection.phase, advanced.projection.phase);
|
|
339
|
+
assert.deepEqual(recovered.projection.flow_run.open_gate_ids, advanced.projection.flow_run.open_gate_ids);
|
|
340
|
+
assert.deepEqual(recovered.projection.next_action.skills, ["pickup-probe"]);
|
|
341
|
+
assert.equal(readJson(path.join(session.artifactRoot, "current.json")).active_step_id, "design-probe");
|
|
342
|
+
assert.equal(readJson(path.join(session.artifactRoot, "current", "codex.json")).active_step_id, "design-probe");
|
|
343
|
+
assert.equal(snapshotFile(path.join(session.artifactRoot, "current", "other.json")), beforeOther);
|
|
344
|
+
assert.equal(snapshotFile(path.join(session.sessionDir, "trust.bundle")), bundleBefore);
|
|
345
|
+
assert.deepEqual(snapshotTree(flowDirectory), beforeFlow);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("recovery fails before any write for invalid Work Item cardinality and Flow subject bindings", async (t) => {
|
|
349
|
+
const cases = [
|
|
350
|
+
["zero refs", [], /state\.work_item_refs.*exactly one/],
|
|
351
|
+
["empty ref", [""], /state\.work_item_refs.*exactly one/],
|
|
352
|
+
["blank ref", [" "], /state\.work_item_refs.*exactly one/],
|
|
353
|
+
["two refs", [SUBJECT, "local:work-item/other"], /state\.work_item_refs.*exactly one/],
|
|
354
|
+
];
|
|
355
|
+
for (const [name, refs, pattern] of cases) {
|
|
356
|
+
await t.test(name, async () => {
|
|
357
|
+
const session = makeSession(`recover-${name.replaceAll(" ", "-")}`);
|
|
358
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
359
|
+
const state = readJson(path.join(session.sessionDir, "state.json"));
|
|
360
|
+
state.work_item_refs = refs;
|
|
361
|
+
writeJson(path.join(session.sessionDir, "state.json"), state);
|
|
362
|
+
await assertRecoveryRejectsWithoutWrites(session, pattern);
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
await t.test("persisted state subject mismatch", async () => {
|
|
367
|
+
const session = makeSession("recover-state-subject-mismatch");
|
|
368
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
369
|
+
const file = path.join(runDir(session.slug, session.projectRoot), "state.json");
|
|
370
|
+
const state = readJson(file);
|
|
371
|
+
state.subject = "local:work-item/other";
|
|
372
|
+
writeJson(file, state);
|
|
373
|
+
await assertRecoveryRejectsWithoutWrites(session, /flow_run\.state\.subject.*selected Work Item/);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
await t.test("persisted params subject mismatch", async () => {
|
|
377
|
+
const session = makeSession("recover-params-subject-mismatch");
|
|
378
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
379
|
+
const file = path.join(runDir(session.slug, session.projectRoot), "state.json");
|
|
380
|
+
const state = readJson(file);
|
|
381
|
+
state.params.subject = "local:work-item/other";
|
|
382
|
+
writeJson(file, state);
|
|
383
|
+
await assertRecoveryRejectsWithoutWrites(session, /flow_run\.state\.params\.subject.*selected Work Item/);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
await t.test("absent persisted params subject is allowed", async () => {
|
|
387
|
+
const session = makeSession("recover-params-subject-absent");
|
|
388
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
389
|
+
const flowDirectory = runDir(session.slug, session.projectRoot);
|
|
390
|
+
const file = path.join(flowDirectory, "state.json");
|
|
391
|
+
const state = readJson(file);
|
|
392
|
+
delete state.params.subject;
|
|
393
|
+
writeJson(file, state);
|
|
394
|
+
const beforeFlow = snapshotTree(flowDirectory);
|
|
395
|
+
const recovered = await recoverBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
396
|
+
assert.equal(recovered.attached, false);
|
|
397
|
+
assert.deepEqual(snapshotTree(flowDirectory), beforeFlow);
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("recovery fails closed for invalid sidecars and missing or corrupt canonical runs", async (t) => {
|
|
402
|
+
await t.test("invalid session path", async () => {
|
|
403
|
+
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-builder-invalid-session-"));
|
|
404
|
+
await assert.rejects(
|
|
405
|
+
() => recoverBuilderFlowSession({ sessionDir: path.join(projectRoot, "not-a-session") }),
|
|
406
|
+
/sessionDir.*must be \.kontourai\/flow-agents/,
|
|
407
|
+
);
|
|
408
|
+
assert.equal(fs.existsSync(path.join(projectRoot, ".kontourai")), false);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
await t.test("missing run remains missing", async () => {
|
|
412
|
+
const session = makeSession("recover-missing-run");
|
|
413
|
+
await assertRecoveryRejectsWithoutWrites(session, /not_found|not found/i);
|
|
414
|
+
assert.equal(fs.existsSync(runDir(session.slug, session.projectRoot)), false);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
await t.test("task slug mismatch", async () => {
|
|
418
|
+
const session = makeSession("recover-task-slug-mismatch");
|
|
419
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
420
|
+
const state = readJson(path.join(session.sessionDir, "state.json"));
|
|
421
|
+
state.task_slug = "other";
|
|
422
|
+
writeJson(path.join(session.sessionDir, "state.json"), state);
|
|
423
|
+
await assertRecoveryRejectsWithoutWrites(session, /task_slug.*match/);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
await t.test("missing sidecar state", async () => {
|
|
427
|
+
const session = makeSession("recover-missing-sidecar-state");
|
|
428
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
429
|
+
fs.rmSync(path.join(session.sessionDir, "state.json"));
|
|
430
|
+
await assertRecoveryRejectsWithoutWrites(session, /sessionDir.*state\.json/);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
await t.test("corrupt sidecar state", async () => {
|
|
434
|
+
const session = makeSession("recover-corrupt-sidecar-state");
|
|
435
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
436
|
+
fs.writeFileSync(path.join(session.sessionDir, "state.json"), "not-json\n");
|
|
437
|
+
await assertRecoveryRejectsWithoutWrites(session, /JSON|parse|Unexpected/i);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
for (const [name, relativeFile] of [
|
|
441
|
+
["corrupt Flow state", "state.json"],
|
|
442
|
+
["corrupt Flow manifest", FLOW_RUN_EVIDENCE_MANIFEST_PATH],
|
|
443
|
+
]) {
|
|
444
|
+
await t.test(name, async () => {
|
|
445
|
+
const session = makeSession(`recover-${name.replaceAll(" ", "-").toLowerCase()}`);
|
|
446
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
447
|
+
fs.writeFileSync(path.join(runDir(session.slug, session.projectRoot), relativeFile), "not-json\n");
|
|
448
|
+
await assertRecoveryRejectsWithoutWrites(session, /JSON|parse|invalid|Unexpected/i);
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
for (const [name, mutate] of [
|
|
453
|
+
["foreign definition identity", (definition) => { definition.id = "foreign.build"; }],
|
|
454
|
+
["foreign definition content", (definition) => { definition.steps[0].description = "foreign content"; }],
|
|
455
|
+
]) {
|
|
456
|
+
await t.test(name, async () => {
|
|
457
|
+
const session = makeSession(`recover-${name.replaceAll(" ", "-")}`);
|
|
458
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
459
|
+
const file = path.join(runDir(session.slug, session.projectRoot), "definition.json");
|
|
460
|
+
const definition = readJson(file);
|
|
461
|
+
mutate(definition);
|
|
462
|
+
writeJson(file, definition);
|
|
463
|
+
await assertRecoveryRejectsWithoutWrites(session, /definition|foreign|canonical|identity|content|invalid/i);
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test("recover and sync remain separate: recovery leaves bundle unattached and sync evaluates it", async () => {
|
|
469
|
+
const session = makeSession("recover-then-sync");
|
|
470
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
471
|
+
writeBundle(session.sessionDir, [bundleClaim({
|
|
472
|
+
expectation: "selected-work",
|
|
473
|
+
claimType: "builder.pull-work.selected",
|
|
474
|
+
subjectType: "work-item",
|
|
475
|
+
})]);
|
|
476
|
+
const manifestFile = path.join(runDir(session.slug, session.projectRoot), FLOW_RUN_EVIDENCE_MANIFEST_PATH);
|
|
477
|
+
const manifestBefore = snapshotFile(manifestFile);
|
|
478
|
+
|
|
479
|
+
const recovered = await recoverBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
480
|
+
assert.equal(recovered.attached, false);
|
|
481
|
+
assert.equal(snapshotFile(manifestFile), manifestBefore);
|
|
482
|
+
assert.equal(recovered.run.state.current_step, "pull-work");
|
|
483
|
+
|
|
484
|
+
const synced = await syncBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
485
|
+
assert.equal(synced.attached, true);
|
|
486
|
+
assert.equal(synced.run.state.current_step, "design-probe");
|
|
487
|
+
assert.notEqual(snapshotFile(manifestFile), manifestBefore);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test("builder-run recover accepts only a session directory and rejects caller-selected identity", async () => {
|
|
491
|
+
const session = makeSession("recover-cli");
|
|
492
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
493
|
+
assert.equal(await builderRunMain(["recover", "--session-dir", session.sessionDir]), 0);
|
|
494
|
+
assert.equal(await builderRunMain(["recover", "--session-dir", session.sessionDir, "--run-id", "other"]), 64);
|
|
495
|
+
assert.equal(await builderRunMain(["recover", "--session-dir", session.sessionDir, "--step-id", "verify"]), 64);
|
|
496
|
+
assert.equal(await builderRunMain(["recover", "extra", "--session-dir", session.sessionDir]), 64);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test("recovery refuses a sidecar changed after its immutable subject snapshot", async () => {
|
|
500
|
+
const session = makeSession("recover-sidecar-race");
|
|
501
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
502
|
+
const flowDirectory = runDir(session.slug, session.projectRoot);
|
|
503
|
+
const beforeFlow = snapshotTree(flowDirectory);
|
|
504
|
+
const recovery = recoverBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
505
|
+
const changed = readJson(path.join(session.sessionDir, "state.json"));
|
|
506
|
+
changed.work_item_refs = ["local:work-item/raced"];
|
|
507
|
+
writeJson(path.join(session.sessionDir, "state.json"), changed);
|
|
508
|
+
const beforeProjection = snapshotProjectionTargets(session);
|
|
509
|
+
|
|
510
|
+
await assert.rejects(() => recovery, /state\.json.*changed during recovery/);
|
|
511
|
+
assert.deepEqual(snapshotTree(flowDirectory), beforeFlow);
|
|
512
|
+
assert.deepEqual(snapshotProjectionTargets(session), beforeProjection);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test("recovery parses every projection target before writing any target", async (t) => {
|
|
516
|
+
for (const target of ["global", "actor"]) {
|
|
517
|
+
await t.test(`malformed ${target} pointer`, async () => {
|
|
518
|
+
const session = makeSession(`recover-malformed-${target}-pointer`);
|
|
519
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
520
|
+
const state = readJson(path.join(session.sessionDir, "state.json"));
|
|
521
|
+
state.next_action = { status: "continue", summary: "stale projection" };
|
|
522
|
+
writeJson(path.join(session.sessionDir, "state.json"), state);
|
|
523
|
+
const pointer = target === "global"
|
|
524
|
+
? path.join(session.artifactRoot, "current.json")
|
|
525
|
+
: path.join(session.artifactRoot, "current", "codex.json");
|
|
526
|
+
fs.mkdirSync(path.dirname(pointer), { recursive: true });
|
|
527
|
+
fs.writeFileSync(pointer, "not-json\n");
|
|
528
|
+
await assertRecoveryRejectsWithoutWrites(session, /projection target.*JSON|JSON|parse|Unexpected/i);
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test("recovery rejects symlinked session and projection targets before writes", async (t) => {
|
|
534
|
+
await t.test("session directory symlink", async () => {
|
|
535
|
+
const session = makeSession("recover-session-symlink");
|
|
536
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
537
|
+
const target = path.join(session.artifactRoot, "recover-session-symlink-target");
|
|
538
|
+
fs.renameSync(session.sessionDir, target);
|
|
539
|
+
fs.symlinkSync(target, session.sessionDir, "dir");
|
|
540
|
+
const beforeFlow = snapshotTree(runDir(session.slug, session.projectRoot));
|
|
541
|
+
const beforeState = snapshotFile(path.join(target, "state.json"));
|
|
542
|
+
await assert.rejects(() => recoverBuilderFlowSession({ sessionDir: session.sessionDir }), /sessionDir.*symbolic link/);
|
|
543
|
+
assert.deepEqual(snapshotTree(runDir(session.slug, session.projectRoot)), beforeFlow);
|
|
544
|
+
assert.equal(snapshotFile(path.join(target, "state.json")), beforeState);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
await t.test("state.json symlink", async () => {
|
|
548
|
+
const session = makeSession("recover-state-symlink");
|
|
549
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
550
|
+
const stateFile = path.join(session.sessionDir, "state.json");
|
|
551
|
+
const target = path.join(session.sessionDir, "state-target.json");
|
|
552
|
+
fs.renameSync(stateFile, target);
|
|
553
|
+
fs.symlinkSync(target, stateFile);
|
|
554
|
+
const beforeFlow = snapshotTree(runDir(session.slug, session.projectRoot));
|
|
555
|
+
const beforeTarget = snapshotFile(target);
|
|
556
|
+
await assert.rejects(() => recoverBuilderFlowSession({ sessionDir: session.sessionDir }), /state\.json.*symbolic link/);
|
|
557
|
+
assert.deepEqual(snapshotTree(runDir(session.slug, session.projectRoot)), beforeFlow);
|
|
558
|
+
assert.equal(snapshotFile(target), beforeTarget);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
for (const targetKind of ["global pointer", "actor pointer", "dangling actor pointer", "actor directory"]) {
|
|
562
|
+
await t.test(targetKind, async () => {
|
|
563
|
+
const session = makeSession(`recover-${targetKind.replaceAll(" ", "-")}-symlink`);
|
|
564
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
565
|
+
const externalRoot = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-builder-pointer-symlink-"));
|
|
566
|
+
const externalPointer = path.join(externalRoot, "current.json");
|
|
567
|
+
writeJson(externalPointer, { active_slug: session.slug, active_step_id: "stale", updated_at: NOW });
|
|
568
|
+
if (targetKind === "global pointer") {
|
|
569
|
+
fs.rmSync(path.join(session.artifactRoot, "current.json"));
|
|
570
|
+
fs.symlinkSync(externalPointer, path.join(session.artifactRoot, "current.json"));
|
|
571
|
+
} else if (targetKind === "actor pointer" || targetKind === "dangling actor pointer") {
|
|
572
|
+
const actorRoot = path.join(session.artifactRoot, "current");
|
|
573
|
+
fs.mkdirSync(actorRoot, { recursive: true });
|
|
574
|
+
fs.symlinkSync(targetKind === "dangling actor pointer" ? path.join(externalRoot, "missing.json") : externalPointer, path.join(actorRoot, "codex.json"));
|
|
575
|
+
} else {
|
|
576
|
+
fs.symlinkSync(externalRoot, path.join(session.artifactRoot, "current"), "dir");
|
|
577
|
+
}
|
|
578
|
+
const beforeFlow = snapshotTree(runDir(session.slug, session.projectRoot));
|
|
579
|
+
const beforeState = snapshotFile(path.join(session.sessionDir, "state.json"));
|
|
580
|
+
const beforeExternal = snapshotFile(externalPointer);
|
|
581
|
+
await assert.rejects(() => recoverBuilderFlowSession({ sessionDir: session.sessionDir }), /current.*symbolic link|projection target.*symbolic link/);
|
|
582
|
+
assert.deepEqual(snapshotTree(runDir(session.slug, session.projectRoot)), beforeFlow);
|
|
583
|
+
assert.equal(snapshotFile(path.join(session.sessionDir, "state.json")), beforeState);
|
|
584
|
+
assert.equal(snapshotFile(externalPointer), beforeExternal);
|
|
585
|
+
});
|
|
586
|
+
}
|
|
213
587
|
});
|
package/src/cli/builder-run.ts
CHANGED
|
@@ -1,21 +1,30 @@
|
|
|
1
1
|
import { flagString, parseArgs } from "../lib/args.js";
|
|
2
|
-
import { startBuilderFlowSession, syncBuilderFlowSession } from "../builder-flow-runtime.js";
|
|
2
|
+
import { recoverBuilderFlowSession, startBuilderFlowSession, syncBuilderFlowSession } from "../builder-flow-runtime.js";
|
|
3
3
|
|
|
4
4
|
export async function main(argv: string[]): Promise<number> {
|
|
5
5
|
const parsed = parseArgs(argv);
|
|
6
6
|
const action = parsed.positionals[0];
|
|
7
7
|
const sessionDir = flagString(parsed.flags, "session-dir");
|
|
8
|
+
const validRecoveryArguments = parsed.positionals.length === 1
|
|
9
|
+
&& Object.keys(parsed.flags).length === 1
|
|
10
|
+
&& typeof parsed.flags["session-dir"] === "string";
|
|
11
|
+
if (action === "recover" && !validRecoveryArguments) {
|
|
12
|
+
console.error("Usage: flow-agents builder-run <start|sync|recover> --session-dir <path>");
|
|
13
|
+
return 64;
|
|
14
|
+
}
|
|
8
15
|
if (!sessionDir) {
|
|
9
16
|
console.error("builder-run requires --session-dir .kontourai/flow-agents/<slug>");
|
|
10
17
|
return 64;
|
|
11
18
|
}
|
|
12
|
-
if (action !== "start" && action !== "sync") {
|
|
13
|
-
console.error("Usage: flow-agents builder-run <start|sync> --session-dir <path>");
|
|
19
|
+
if (action !== "start" && action !== "sync" && action !== "recover") {
|
|
20
|
+
console.error("Usage: flow-agents builder-run <start|sync|recover> --session-dir <path>");
|
|
14
21
|
return 64;
|
|
15
22
|
}
|
|
16
23
|
const result = action === "start"
|
|
17
24
|
? await startBuilderFlowSession({ sessionDir })
|
|
18
|
-
:
|
|
25
|
+
: action === "sync"
|
|
26
|
+
? await syncBuilderFlowSession({ sessionDir })
|
|
27
|
+
: await recoverBuilderFlowSession({ sessionDir });
|
|
19
28
|
console.log(JSON.stringify({
|
|
20
29
|
run_id: result.run.runId,
|
|
21
30
|
definition_id: result.run.definitionId,
|
|
@@ -9,7 +9,7 @@ import { fileURLToPath } from "node:url";
|
|
|
9
9
|
// ADR 0016 Abstraction A: shared FlowDefinition resolver (P-a)
|
|
10
10
|
import { resolveActiveFlowStep, resolveAllFlowGateExpects, resolveFlowFilePath, resolvePhaseMap, resolveRouteBackPolicy, type ActiveFlowStep } from "../lib/flow-resolver.js";
|
|
11
11
|
import { defaultArtifactRootForRead, flowAgentsArtifactRoot } from "../lib/local-artifact-root.js";
|
|
12
|
-
import { syncBuilderFlowSessionIfPresent } from "../builder-flow-runtime.js";
|
|
12
|
+
import { startBuilderFlowSession, syncBuilderFlowSessionIfPresent } from "../builder-flow-runtime.js";
|
|
13
13
|
// #291 Wave 1 Task 1.1 exports: ensure-session's ownership guard reuses the EXACT same
|
|
14
14
|
// assignment ⋈ liveness join / claim / supersede logic #290 already ships for the
|
|
15
15
|
// `assignment-provider` CLI, rather than reimplementing a second, parallel join (static ESM
|
|
@@ -1312,8 +1312,15 @@ async function withLock<T>(dir: string, create: boolean, command: string, body:
|
|
|
1312
1312
|
const lockDir = path.join(dir, ".workflow-sidecar.lockdir");
|
|
1313
1313
|
const staleMs = Number(process.env.FLOW_AGENTS_WORKFLOW_SIDECAR_STALE_LOCK_MS ?? 5 * 60 * 1000);
|
|
1314
1314
|
const deadline = Date.now() + 30000;
|
|
1315
|
+
let acquiredRoot: fs.Stats | null = null;
|
|
1316
|
+
let acquiredLock: fs.Stats | null = null;
|
|
1315
1317
|
while (true) {
|
|
1316
|
-
try {
|
|
1318
|
+
try {
|
|
1319
|
+
fs.mkdirSync(lockDir);
|
|
1320
|
+
acquiredRoot = fs.lstatSync(dir);
|
|
1321
|
+
acquiredLock = fs.lstatSync(lockDir);
|
|
1322
|
+
break;
|
|
1323
|
+
}
|
|
1317
1324
|
catch (error) {
|
|
1318
1325
|
const lockError = error as NodeJS.ErrnoException;
|
|
1319
1326
|
if (lockError.code !== "EEXIST") {
|
|
@@ -1338,7 +1345,28 @@ async function withLock<T>(dir: string, create: boolean, command: string, body:
|
|
|
1338
1345
|
if (delay) await new Promise((resolve) => setTimeout(resolve, Number(delay) * 1000));
|
|
1339
1346
|
return await body();
|
|
1340
1347
|
} finally {
|
|
1341
|
-
|
|
1348
|
+
try {
|
|
1349
|
+
const currentRoot = fs.lstatSync(dir);
|
|
1350
|
+
const currentLock = fs.lstatSync(lockDir);
|
|
1351
|
+
const sameIdentity = (left: fs.Stats, right: fs.Stats): boolean =>
|
|
1352
|
+
left.dev === right.dev && left.ino === right.ino;
|
|
1353
|
+
if (acquiredRoot
|
|
1354
|
+
&& acquiredLock
|
|
1355
|
+
&& !currentRoot.isSymbolicLink()
|
|
1356
|
+
&& currentRoot.isDirectory()
|
|
1357
|
+
&& !currentLock.isSymbolicLink()
|
|
1358
|
+
&& currentLock.isDirectory()
|
|
1359
|
+
&& sameIdentity(currentRoot, acquiredRoot)
|
|
1360
|
+
&& sameIdentity(currentLock, acquiredLock)) {
|
|
1361
|
+
fs.rmdirSync(lockDir);
|
|
1362
|
+
} else {
|
|
1363
|
+
process.stderr.write(`[workflow-sidecar] lock cleanup skipped because root or lock identity changed: ${lockDir}\n`);
|
|
1364
|
+
}
|
|
1365
|
+
} catch (error) {
|
|
1366
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
1367
|
+
process.stderr.write(`[workflow-sidecar] lock cleanup skipped: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1342
1370
|
}
|
|
1343
1371
|
}
|
|
1344
1372
|
|
|
@@ -1661,7 +1689,7 @@ function initSidecars(
|
|
|
1661
1689
|
slug: string,
|
|
1662
1690
|
sourceRequest: string,
|
|
1663
1691
|
summary: string,
|
|
1664
|
-
nextAction: string,
|
|
1692
|
+
nextAction: string | AnyObj,
|
|
1665
1693
|
timestamp: string,
|
|
1666
1694
|
markdown?: string,
|
|
1667
1695
|
workItemRefs: string[] = [],
|
|
@@ -1698,12 +1726,16 @@ function initSidecars(
|
|
|
1698
1726
|
// existing state.json's created_at when present; stamp only on true first-creation.
|
|
1699
1727
|
// updated_at still reflects "now" on every call — that field is intentionally mutable.
|
|
1700
1728
|
const retainedWorkItemRefs = workItemRefs.length > 0 ? workItemRefs : (Array.isArray(existingState.work_item_refs) ? existingState.work_item_refs : []);
|
|
1729
|
+
const nextActionSummary = typeof nextAction === "string" ? nextAction : String(nextAction.summary ?? summary);
|
|
1730
|
+
const nextActionPayload = typeof nextAction === "string"
|
|
1731
|
+
? { status: "continue", summary: nextAction || summary }
|
|
1732
|
+
: { status: "continue", ...nextAction, summary: nextActionSummary };
|
|
1701
1733
|
writeJson(path.join(dir, "state.json"), {
|
|
1702
1734
|
...sidecarBase(slug), status: initialLifecycle?.status ?? "planned", phase: initialLifecycle?.phase ?? "planning", created_at: existingState.created_at || timestamp, updated_at: timestamp,
|
|
1703
1735
|
...(branch ? { branch } : {}),
|
|
1704
1736
|
...(retainedWorkItemRefs.length > 0 ? { work_item_refs: retainedWorkItemRefs } : {}),
|
|
1705
1737
|
artifact_paths: relArtifacts(dir),
|
|
1706
|
-
next_action:
|
|
1738
|
+
next_action: nextActionPayload,
|
|
1707
1739
|
});
|
|
1708
1740
|
writeJson(path.join(dir, "acceptance.json"), {
|
|
1709
1741
|
...sidecarBase(slug), source_request: sourceRequest,
|
|
@@ -1711,7 +1743,7 @@ function initSidecars(
|
|
|
1711
1743
|
goal_fit: { status: "pending", summary: "Goal fit has not been verified yet." },
|
|
1712
1744
|
});
|
|
1713
1745
|
writeJson(path.join(dir, "handoff.json"), {
|
|
1714
|
-
...sidecarBase(slug), summary, current_state_ref: "state.json", next_steps:
|
|
1746
|
+
...sidecarBase(slug), summary, current_state_ref: "state.json", next_steps: nextActionSummary ? [nextActionSummary] : [], blockers: [], warnings: [],
|
|
1715
1747
|
});
|
|
1716
1748
|
}
|
|
1717
1749
|
|
|
@@ -1943,19 +1975,53 @@ function resolveEnsureSessionEntry(p: ReturnType<typeof parseArgs>, dir: string)
|
|
|
1943
1975
|
return { flowId, stepId: explicitStep || firstStep, firstStep };
|
|
1944
1976
|
}
|
|
1945
1977
|
|
|
1978
|
+
function assertCanonicalBuilderArtifactRoot(root: string): void {
|
|
1979
|
+
const kontouraiRoot = path.dirname(root);
|
|
1980
|
+
const projectRoot = path.dirname(kontouraiRoot);
|
|
1981
|
+
if (path.basename(root) !== "flow-agents" || path.basename(kontouraiRoot) !== ".kontourai") {
|
|
1982
|
+
die("ensure-session --flow-id builder.build requires --artifact-root <project>/.kontourai/flow-agents");
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
const statIfPresent = (candidate: string): fs.Stats | null => {
|
|
1986
|
+
try {
|
|
1987
|
+
return fs.lstatSync(candidate);
|
|
1988
|
+
} catch (error) {
|
|
1989
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
1990
|
+
throw error;
|
|
1991
|
+
}
|
|
1992
|
+
};
|
|
1993
|
+
const projectStat = statIfPresent(projectRoot);
|
|
1994
|
+
if (projectStat && (projectStat.isSymbolicLink() || !projectStat.isDirectory())) {
|
|
1995
|
+
die(`ensure-session --flow-id builder.build requires a non-symlink project root: ${projectRoot}`);
|
|
1996
|
+
}
|
|
1997
|
+
const realProjectRoot = projectStat ? fs.realpathSync(projectRoot) : projectRoot;
|
|
1998
|
+
for (const [candidate, expected, label] of [
|
|
1999
|
+
[kontouraiRoot, path.join(realProjectRoot, ".kontourai"), ".kontourai root"],
|
|
2000
|
+
[root, path.join(realProjectRoot, ".kontourai", "flow-agents"), "Flow Agents artifact root"],
|
|
2001
|
+
] as const) {
|
|
2002
|
+
const stat = statIfPresent(candidate);
|
|
2003
|
+
if (!stat) continue;
|
|
2004
|
+
if (stat.isSymbolicLink() || !stat.isDirectory() || fs.realpathSync(candidate) !== expected) {
|
|
2005
|
+
die(`ensure-session --flow-id builder.build requires a non-symlink ${label}: ${candidate}`);
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
|
|
1946
2010
|
function preflightEnsureSession(p: ReturnType<typeof parseArgs>): void {
|
|
1947
2011
|
const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
|
|
1948
2012
|
const slug = opt(p, "task-slug") || (opt(p, "work-item") ? workItemSlug(opt(p, "work-item")) : die("--task-slug is required (or pass --work-item to derive it)"));
|
|
1949
2013
|
const dir = sessionDirFor(root, slug);
|
|
1950
|
-
resolveEnsureSessionEntry(p, dir);
|
|
2014
|
+
const entry = resolveEnsureSessionEntry(p, dir);
|
|
2015
|
+
if (entry.flowId === "builder.build") assertCanonicalBuilderArtifactRoot(root);
|
|
1951
2016
|
sessionWorkItem(p, slug, dir);
|
|
1952
2017
|
}
|
|
1953
2018
|
|
|
1954
|
-
function ensureSession(p: ReturnType<typeof parseArgs>): number {
|
|
2019
|
+
async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
1955
2020
|
const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
|
|
1956
2021
|
const slug = opt(p, "task-slug") || (opt(p, "work-item") ? workItemSlug(opt(p, "work-item")) : die("--task-slug is required (or pass --work-item to derive it)"));
|
|
1957
2022
|
const dir = sessionDirFor(root, slug);
|
|
1958
2023
|
const entry = resolveEnsureSessionEntry(p, dir);
|
|
2024
|
+
if (entry.flowId === "builder.build") assertCanonicalBuilderArtifactRoot(root);
|
|
1959
2025
|
const workItem = sessionWorkItem(p, slug, dir);
|
|
1960
2026
|
// #291 Wave 2 Task 2.1 (§3, §4): resolve the actor ONCE, then run the ownership guard BEFORE
|
|
1961
2027
|
// any directory/file is created — a refusal must never leave a stray empty session dir. Reused
|
|
@@ -1982,9 +2048,15 @@ function ensureSession(p: ReturnType<typeof parseArgs>): number {
|
|
|
1982
2048
|
if (!fs.existsSync(path.join(dir, "state.json")) || !fs.existsSync(path.join(dir, "acceptance.json")) || !fs.existsSync(path.join(dir, "handoff.json"))) {
|
|
1983
2049
|
const phaseMap = entry.flowId ? resolvePhaseMap(entry.flowId, findRepoRootFromDir(dir)) : null;
|
|
1984
2050
|
const initialPhase = Object.entries(phaseMap ?? {}).find(([, step]) => step === entry.firstStep)?.[0];
|
|
1985
|
-
const
|
|
2051
|
+
const startCommand = `flow-agents builder-run start --session-dir .kontourai/flow-agents/${slug}`;
|
|
2052
|
+
const nextAction: string | AnyObj = entry.flowId
|
|
1986
2053
|
? entry.flowId === "builder.build"
|
|
1987
|
-
?
|
|
2054
|
+
? {
|
|
2055
|
+
status: "continue",
|
|
2056
|
+
summary: `Start the canonical Flow run; activate \`pull-work\` for work item ${JSON.stringify(workItem.ref)}, and satisfy the declared gate before advancing.`,
|
|
2057
|
+
skills: ["pull-work"],
|
|
2058
|
+
command: startCommand,
|
|
2059
|
+
}
|
|
1988
2060
|
: `Continue at Flow step ${JSON.stringify(entry.stepId)} for work item ${JSON.stringify(workItem.ref)}; satisfy its declared gate before advancing.`
|
|
1989
2061
|
: opt(p, "next-action", "Continue.");
|
|
1990
2062
|
initSidecars(
|
|
@@ -2014,6 +2086,15 @@ function ensureSession(p: ReturnType<typeof parseArgs>): number {
|
|
|
2014
2086
|
? persistedCurrent.active_step_id
|
|
2015
2087
|
: entry.stepId;
|
|
2016
2088
|
writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", entry.flowId || undefined, resumedStep || undefined, actorResolution.unresolved ? undefined : actorResolution.branchActorKey);
|
|
2089
|
+
if (entry.flowId === "builder.build") {
|
|
2090
|
+
try {
|
|
2091
|
+
await startBuilderFlowSession({ sessionDir: dir });
|
|
2092
|
+
} catch (error) {
|
|
2093
|
+
const retry = `flow-agents builder-run start --session-dir .kontourai/flow-agents/${slug}`;
|
|
2094
|
+
process.stderr.write(`[ensure-session] canonical Builder Flow run did not start; the sidecar remains retryable. Run: ${retry}\n`);
|
|
2095
|
+
throw error;
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2017
2098
|
console.log(dir);
|
|
2018
2099
|
return 0;
|
|
2019
2100
|
}
|