@kontourai/flow-agents 3.7.0 → 3.9.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/CHANGELOG.md +19 -0
- package/build/src/builder-flow-runtime.js +118 -17
- package/build/src/cli/assignment-provider.js +13 -1
- package/build/src/cli/continuation-adapter.d.ts +24 -0
- package/build/src/cli/continuation-adapter.js +225 -0
- package/build/src/cli/workflow-sidecar.js +29 -11
- package/build/src/cli/workflow.js +67 -11
- package/build/src/continuation-driver.d.ts +92 -0
- package/build/src/continuation-driver.js +444 -0
- package/build/src/index.d.ts +2 -0
- package/build/src/index.js +1 -0
- package/build/src/tools/build-universal-bundles.js +53 -3
- package/docs/getting-started.md +9 -1
- package/docs/public-workflow-cli.md +54 -0
- package/docs/spec/builder-flow-runtime.md +36 -2
- package/docs/workflow-usage-guide.md +7 -0
- package/evals/integration/test_builder_step_producers.sh +37 -4
- package/evals/integration/test_bundle_install.sh +108 -4
- package/evals/integration/test_bundle_lifecycle.sh +4 -6
- package/evals/integration/test_install_merge.sh +18 -8
- package/evals/integration/test_public_workflow_cli.sh +11 -5
- package/evals/static/test_universal_bundles.sh +44 -1
- package/package.json +4 -4
- package/packaging/README.md +1 -1
- package/scripts/README.md +1 -1
- package/scripts/install-codex-home.sh +63 -23
- package/scripts/install-owned-files.js +18 -2
- package/src/builder-flow-runtime.ts +108 -10
- package/src/cli/assignment-provider.ts +13 -1
- package/src/cli/builder-flow-runtime.test.mjs +378 -20
- package/src/cli/continuation-adapter.ts +239 -0
- package/src/cli/continuation-driver.test.mjs +564 -0
- package/src/cli/workflow-sidecar.ts +27 -11
- package/src/cli/workflow.ts +68 -11
- package/src/continuation-driver.ts +532 -0
- package/src/index.ts +20 -0
- package/src/tools/build-universal-bundles.ts +51 -3
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { inspectBuilderFlowSession, syncBuilderFlowSession } from "./builder-flow-runtime.js";
|
|
6
|
+
import { atomicWriteJson, ensureSafeDirectory } from "./lib/fs.js";
|
|
7
|
+
export async function runContinuationDriver(input) {
|
|
8
|
+
assertMaxTurns(input.maxTurns);
|
|
9
|
+
const now = input.now ?? (() => new Date());
|
|
10
|
+
const waitForBarrier = input.waitForBarrier ?? (async () => "pending");
|
|
11
|
+
const authorizeTurn = input.authorizeTurn ?? (async () => { });
|
|
12
|
+
const adapterCommandIdentity = input.adapterCommandIdentity ?? null;
|
|
13
|
+
let snapshot = validateSnapshot(await input.runtime.inspect());
|
|
14
|
+
let state = loadOrCreateState(input.store, snapshot, input.maxTurns, adapterCommandIdentity, now);
|
|
15
|
+
if (state.max_turns !== input.maxTurns)
|
|
16
|
+
throw new Error(`continuation maxTurns ${input.maxTurns} does not match the persisted mission budget ${state.max_turns}`);
|
|
17
|
+
if (state.adapter_command_identity !== adapterCommandIdentity)
|
|
18
|
+
throw new Error("continuation adapter command identity does not match the persisted mission adapter");
|
|
19
|
+
const inspectedOutcome = canonicalOutcome(snapshot);
|
|
20
|
+
if (inspectedOutcome === "done")
|
|
21
|
+
return finishDone(input.store, state, snapshot, now);
|
|
22
|
+
if (inspectedOutcome === "failed")
|
|
23
|
+
return finishFailed(input.store, state, snapshot, now);
|
|
24
|
+
if (state.pending_barrier) {
|
|
25
|
+
const barrier = state.pending_barrier;
|
|
26
|
+
const readiness = await waitForBarrier(barrier);
|
|
27
|
+
if (readiness === "pending") {
|
|
28
|
+
state = saveState(input.store, state, { status: "waiting" }, now);
|
|
29
|
+
return { outcome: "waiting", turns_started: state.turns_started, snapshot, barrier };
|
|
30
|
+
}
|
|
31
|
+
state = saveState(input.store, state, { status: "active", pending_barrier: null }, now);
|
|
32
|
+
appendEvent(input.store, state, snapshot, "resumed", now, { barrier });
|
|
33
|
+
}
|
|
34
|
+
snapshot = validateSnapshot(await input.runtime.synchronize());
|
|
35
|
+
assertMissionIdentity(state, snapshot);
|
|
36
|
+
const initialOutcome = canonicalOutcome(snapshot);
|
|
37
|
+
if (initialOutcome === "done")
|
|
38
|
+
return finishDone(input.store, state, snapshot, now);
|
|
39
|
+
if (initialOutcome === "failed")
|
|
40
|
+
return finishFailed(input.store, state, snapshot, now);
|
|
41
|
+
if (initialOutcome === "waiting") {
|
|
42
|
+
state = saveState(input.store, state, { status: "waiting" }, now);
|
|
43
|
+
return { outcome: "waiting", turns_started: state.turns_started, snapshot };
|
|
44
|
+
}
|
|
45
|
+
while (state.turns_started < input.maxTurns) {
|
|
46
|
+
await authorizeTurn();
|
|
47
|
+
const iteration = state.turns_started + 1;
|
|
48
|
+
state = saveState(input.store, state, { status: "active", turns_started: iteration }, now);
|
|
49
|
+
appendEvent(input.store, state, snapshot, "turn_started", now);
|
|
50
|
+
let result;
|
|
51
|
+
try {
|
|
52
|
+
result = validateTurnResult(await input.runtime.execute(Object.freeze({
|
|
53
|
+
schema_version: "1.0",
|
|
54
|
+
run_id: snapshot.run_id,
|
|
55
|
+
definition_id: snapshot.definition_id,
|
|
56
|
+
current_step: snapshot.current_step,
|
|
57
|
+
iteration,
|
|
58
|
+
max_turns: input.maxTurns,
|
|
59
|
+
next_action: snapshot.next_action ? structuredClone(snapshot.next_action) : null,
|
|
60
|
+
})));
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
appendEvent(input.store, state, snapshot, "turn_failed", now, { summary: boundedErrorMessage(error) });
|
|
64
|
+
snapshot = validateSnapshot(await input.runtime.synchronize());
|
|
65
|
+
assertMissionIdentity(state, snapshot);
|
|
66
|
+
const outcome = canonicalOutcome(snapshot);
|
|
67
|
+
if (outcome === "done")
|
|
68
|
+
return finishDone(input.store, state, snapshot, now);
|
|
69
|
+
if (outcome === "failed")
|
|
70
|
+
return finishFailed(input.store, state, snapshot, now);
|
|
71
|
+
if (outcome === "waiting") {
|
|
72
|
+
state = saveState(input.store, state, { status: "waiting" }, now);
|
|
73
|
+
return { outcome: "waiting", turns_started: state.turns_started, snapshot };
|
|
74
|
+
}
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
appendEvent(input.store, state, snapshot, "turn_completed", now, { summary: result.summary });
|
|
78
|
+
if (result.status === "wait") {
|
|
79
|
+
state = saveState(input.store, state, { status: "waiting", pending_barrier: result.barrier }, now);
|
|
80
|
+
appendEvent(input.store, state, snapshot, "parked", now, { barrier: result.barrier, summary: result.summary });
|
|
81
|
+
const readiness = await waitForBarrier(result.barrier);
|
|
82
|
+
if (readiness === "pending") {
|
|
83
|
+
return { outcome: "waiting", turns_started: state.turns_started, snapshot, barrier: result.barrier };
|
|
84
|
+
}
|
|
85
|
+
state = saveState(input.store, state, { status: "active", pending_barrier: null }, now);
|
|
86
|
+
appendEvent(input.store, state, snapshot, "resumed", now, { barrier: result.barrier });
|
|
87
|
+
}
|
|
88
|
+
snapshot = validateSnapshot(await input.runtime.synchronize());
|
|
89
|
+
assertMissionIdentity(state, snapshot);
|
|
90
|
+
const outcome = canonicalOutcome(snapshot);
|
|
91
|
+
if (outcome === "done")
|
|
92
|
+
return finishDone(input.store, state, snapshot, now);
|
|
93
|
+
if (outcome === "failed")
|
|
94
|
+
return finishFailed(input.store, state, snapshot, now);
|
|
95
|
+
if (outcome === "waiting") {
|
|
96
|
+
state = saveState(input.store, state, { status: "waiting" }, now);
|
|
97
|
+
return { outcome: "waiting", turns_started: state.turns_started, snapshot };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
state = saveState(input.store, state, { status: "budget_exhausted" }, now);
|
|
101
|
+
appendEvent(input.store, state, snapshot, "budget_exhausted", now);
|
|
102
|
+
return { outcome: "budget_exhausted", turns_started: state.turns_started, snapshot };
|
|
103
|
+
}
|
|
104
|
+
export async function driveBuilderFlowSession(input) {
|
|
105
|
+
const sessionDir = path.resolve(input.sessionDir);
|
|
106
|
+
const runtime = {
|
|
107
|
+
inspect: async () => builderSessionSnapshot(await inspectBuilderFlowSession({ sessionDir })),
|
|
108
|
+
synchronize: async () => builderSessionSnapshot(await syncBuilderFlowSession({ sessionDir })),
|
|
109
|
+
execute: input.execute,
|
|
110
|
+
};
|
|
111
|
+
return runContinuationDriver({
|
|
112
|
+
maxTurns: input.maxTurns,
|
|
113
|
+
...(input.adapterCommandIdentity ? { adapterCommandIdentity: input.adapterCommandIdentity } : {}),
|
|
114
|
+
runtime,
|
|
115
|
+
store: createFileContinuationStore(sessionDir),
|
|
116
|
+
...(input.waitForBarrier ? { waitForBarrier: input.waitForBarrier } : {}),
|
|
117
|
+
...(input.authorizeTurn ? { authorizeTurn: input.authorizeTurn } : {}),
|
|
118
|
+
...(input.now ? { now: input.now } : {}),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
export async function withContinuationDriverLock(sessionDirInput, body) {
|
|
122
|
+
const sessionDir = path.resolve(sessionDirInput);
|
|
123
|
+
const driverDir = path.join(sessionDir, "continuation-driver");
|
|
124
|
+
const locksDir = path.join(driverDir, "locks");
|
|
125
|
+
ensureSafeDirectory(sessionDir, locksDir);
|
|
126
|
+
const token = randomUUID();
|
|
127
|
+
const lockFile = path.join(locksDir, `${process.pid}-${token}.lock`);
|
|
128
|
+
const owner = { schema_version: "1.0", pid: process.pid, token, created_at: new Date().toISOString() };
|
|
129
|
+
acquireDriverLock(locksDir, lockFile, owner);
|
|
130
|
+
try {
|
|
131
|
+
return await body();
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
releaseDriverLock(lockFile, token);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
export function createFileContinuationStore(sessionDirInput) {
|
|
138
|
+
const sessionDir = path.resolve(sessionDirInput);
|
|
139
|
+
const driverDir = path.join(sessionDir, "continuation-driver");
|
|
140
|
+
const stateFile = path.join(driverDir, "state.json");
|
|
141
|
+
const eventsFile = path.join(driverDir, "events.jsonl");
|
|
142
|
+
ensureSafeDirectory(sessionDir, driverDir);
|
|
143
|
+
return {
|
|
144
|
+
load() {
|
|
145
|
+
if (!fs.existsSync(stateFile)) {
|
|
146
|
+
if (fs.existsSync(eventsFile) && fs.statSync(eventsFile).size > 0)
|
|
147
|
+
throw new Error("continuation driver state is missing while mission events exist");
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
const state = JSON.parse(readRegularFileNoFollow(stateFile, "continuation driver state"));
|
|
151
|
+
reconcileStateWithEvents(state, eventsFile);
|
|
152
|
+
return state;
|
|
153
|
+
},
|
|
154
|
+
save(state) {
|
|
155
|
+
atomicWriteJson(sessionDir, stateFile, state);
|
|
156
|
+
},
|
|
157
|
+
append(event) {
|
|
158
|
+
appendJsonLineNoFollow(sessionDir, eventsFile, event);
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function builderSessionSnapshot(result) {
|
|
163
|
+
const nextAction = result.projection.next_action;
|
|
164
|
+
return validateSnapshot({
|
|
165
|
+
run_id: result.run.runId,
|
|
166
|
+
definition_id: result.run.definitionId,
|
|
167
|
+
status: result.run.state.status,
|
|
168
|
+
disposition: builderContinuationDisposition(result.projection.next_action),
|
|
169
|
+
current_step: result.run.state.current_step,
|
|
170
|
+
next_action: nextAction && typeof nextAction === "object" && !Array.isArray(nextAction)
|
|
171
|
+
? structuredClone(nextAction)
|
|
172
|
+
: null,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
function appendJsonLineNoFollow(root, file, value) {
|
|
176
|
+
ensureSafeDirectory(root, path.dirname(file));
|
|
177
|
+
if (fs.existsSync(file))
|
|
178
|
+
assertRegularFile(file, "continuation driver event log");
|
|
179
|
+
const noFollow = typeof fsConstants.O_NOFOLLOW === "number" ? fsConstants.O_NOFOLLOW : 0;
|
|
180
|
+
const fd = fs.openSync(file, fsConstants.O_WRONLY | fsConstants.O_APPEND | fsConstants.O_CREAT | noFollow, 0o600);
|
|
181
|
+
try {
|
|
182
|
+
fs.writeFileSync(fd, `${JSON.stringify(value)}\n`, "utf8");
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
fs.closeSync(fd);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function assertRegularFile(file, label) {
|
|
189
|
+
const stat = fs.lstatSync(file);
|
|
190
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
191
|
+
throw new Error(`${label} must be a regular file`);
|
|
192
|
+
}
|
|
193
|
+
function readRegularFileNoFollow(file, label) {
|
|
194
|
+
assertRegularFile(file, label);
|
|
195
|
+
const noFollow = typeof fsConstants.O_NOFOLLOW === "number" ? fsConstants.O_NOFOLLOW : 0;
|
|
196
|
+
const fd = fs.openSync(file, fsConstants.O_RDONLY | noFollow);
|
|
197
|
+
try {
|
|
198
|
+
if (!fs.fstatSync(fd).isFile())
|
|
199
|
+
throw new Error(`${label} must be a regular file`);
|
|
200
|
+
return fs.readFileSync(fd, "utf8");
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
fs.closeSync(fd);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function acquireDriverLock(locksDir, lockFile, owner) {
|
|
207
|
+
const noFollow = typeof fsConstants.O_NOFOLLOW === "number" ? fsConstants.O_NOFOLLOW : 0;
|
|
208
|
+
const fd = fs.openSync(lockFile, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollow, 0o600);
|
|
209
|
+
try {
|
|
210
|
+
fs.writeFileSync(fd, `${JSON.stringify(owner)}\n`, "utf8");
|
|
211
|
+
}
|
|
212
|
+
finally {
|
|
213
|
+
fs.closeSync(fd);
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
for (const name of fs.readdirSync(locksDir).sort()) {
|
|
217
|
+
const candidate = path.join(locksDir, name);
|
|
218
|
+
if (candidate === lockFile)
|
|
219
|
+
continue;
|
|
220
|
+
let existing;
|
|
221
|
+
try {
|
|
222
|
+
existing = JSON.parse(readRegularFileNoFollow(candidate, "continuation driver lock"));
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
if (error.code === "ENOENT")
|
|
226
|
+
continue;
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
if (existing.schema_version !== "1.0" || typeof existing.pid !== "number" || !Number.isSafeInteger(existing.pid) || existing.pid <= 0 || typeof existing.token !== "string") {
|
|
230
|
+
throw new Error("continuation driver lock is invalid");
|
|
231
|
+
}
|
|
232
|
+
if (processAlive(existing.pid))
|
|
233
|
+
throw new Error(`continuation driver is already running under pid ${existing.pid}`);
|
|
234
|
+
try {
|
|
235
|
+
fs.unlinkSync(candidate);
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
if (error.code !== "ENOENT")
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
try {
|
|
245
|
+
fs.unlinkSync(lockFile);
|
|
246
|
+
}
|
|
247
|
+
catch { }
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
function releaseDriverLock(lockFile, token) {
|
|
252
|
+
try {
|
|
253
|
+
const owner = JSON.parse(readRegularFileNoFollow(lockFile, "continuation driver lock"));
|
|
254
|
+
if (owner.token === token)
|
|
255
|
+
fs.unlinkSync(lockFile);
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
if (error.code !== "ENOENT")
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function loadOrCreateState(store, snapshot, maxTurns, adapterCommandIdentity, now) {
|
|
263
|
+
const existing = store.load();
|
|
264
|
+
if (existing) {
|
|
265
|
+
validateState(existing);
|
|
266
|
+
assertMissionIdentity(existing, snapshot);
|
|
267
|
+
return existing;
|
|
268
|
+
}
|
|
269
|
+
const state = {
|
|
270
|
+
schema_version: "1.0",
|
|
271
|
+
run_id: snapshot.run_id,
|
|
272
|
+
definition_id: snapshot.definition_id,
|
|
273
|
+
max_turns: maxTurns,
|
|
274
|
+
adapter_command_identity: adapterCommandIdentity,
|
|
275
|
+
status: "active",
|
|
276
|
+
turns_started: 0,
|
|
277
|
+
pending_barrier: null,
|
|
278
|
+
updated_at: now().toISOString(),
|
|
279
|
+
};
|
|
280
|
+
store.save(state);
|
|
281
|
+
appendEvent(store, state, snapshot, "started", now);
|
|
282
|
+
return state;
|
|
283
|
+
}
|
|
284
|
+
function canonicalOutcome(snapshot) {
|
|
285
|
+
return snapshot.disposition;
|
|
286
|
+
}
|
|
287
|
+
function finishDone(store, state, snapshot, now) {
|
|
288
|
+
const done = saveState(store, state, { status: "done", pending_barrier: null }, now);
|
|
289
|
+
appendEvent(store, done, snapshot, "done", now);
|
|
290
|
+
return { outcome: "done", turns_started: done.turns_started, snapshot };
|
|
291
|
+
}
|
|
292
|
+
function finishFailed(store, state, snapshot, now) {
|
|
293
|
+
const failed = saveState(store, state, { status: "failed", pending_barrier: null }, now);
|
|
294
|
+
return { outcome: "failed", turns_started: failed.turns_started, snapshot };
|
|
295
|
+
}
|
|
296
|
+
function saveState(store, current, patch, now) {
|
|
297
|
+
const next = { ...current, ...patch, updated_at: now().toISOString() };
|
|
298
|
+
store.save(next);
|
|
299
|
+
return next;
|
|
300
|
+
}
|
|
301
|
+
function appendEvent(store, state, snapshot, type, now, extra = {}) {
|
|
302
|
+
store.append({
|
|
303
|
+
schema_version: "1.0",
|
|
304
|
+
type,
|
|
305
|
+
run_id: state.run_id,
|
|
306
|
+
definition_id: state.definition_id,
|
|
307
|
+
current_step: snapshot.current_step,
|
|
308
|
+
turns_started: state.turns_started,
|
|
309
|
+
at: now().toISOString(),
|
|
310
|
+
...(extra.barrier ? { barrier: extra.barrier } : {}),
|
|
311
|
+
...(extra.summary ? { summary: extra.summary } : {}),
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
function validateSnapshot(value) {
|
|
315
|
+
if (!value || typeof value !== "object")
|
|
316
|
+
throw new Error("continuation runtime returned an invalid canonical snapshot");
|
|
317
|
+
for (const field of ["run_id", "definition_id", "status", "current_step"]) {
|
|
318
|
+
if (typeof value[field] !== "string" || value[field].length === 0)
|
|
319
|
+
throw new Error(`continuation snapshot ${field} must be a non-empty string`);
|
|
320
|
+
}
|
|
321
|
+
if (!new Set(["continue", "waiting", "done", "failed"]).has(value.disposition))
|
|
322
|
+
throw new Error("continuation snapshot disposition is invalid");
|
|
323
|
+
if (value.next_action !== null && (typeof value.next_action !== "object" || Array.isArray(value.next_action))) {
|
|
324
|
+
throw new Error("continuation snapshot next_action must be an object or null");
|
|
325
|
+
}
|
|
326
|
+
return structuredClone(value);
|
|
327
|
+
}
|
|
328
|
+
function validateTurnResult(value) {
|
|
329
|
+
if (!value || typeof value !== "object" || (value.status !== "completed" && value.status !== "wait")) {
|
|
330
|
+
throw new Error("continuation adapter must return status completed or wait");
|
|
331
|
+
}
|
|
332
|
+
if (value.status === "wait")
|
|
333
|
+
validateBarrier(value.barrier);
|
|
334
|
+
if (value.summary !== undefined && typeof value.summary !== "string")
|
|
335
|
+
throw new Error("continuation adapter summary must be a string");
|
|
336
|
+
const copy = structuredClone(value);
|
|
337
|
+
if (copy.summary && copy.summary.length > 2_000)
|
|
338
|
+
copy.summary = `${copy.summary.slice(0, 1_997)}...`;
|
|
339
|
+
return copy;
|
|
340
|
+
}
|
|
341
|
+
function validateBarrier(barrier) {
|
|
342
|
+
if (!barrier || typeof barrier !== "object")
|
|
343
|
+
throw new Error("continuation wait result requires a barrier");
|
|
344
|
+
if (barrier.kind === "pid") {
|
|
345
|
+
if (!Number.isSafeInteger(barrier.pid) || barrier.pid <= 0)
|
|
346
|
+
throw new Error("continuation pid barrier requires a positive integer pid");
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (barrier.kind === "deadline") {
|
|
350
|
+
if (typeof barrier.at !== "string" || !Number.isFinite(Date.parse(barrier.at)))
|
|
351
|
+
throw new Error("continuation deadline barrier requires an ISO date-time");
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
throw new Error("continuation barrier kind must be pid or deadline");
|
|
355
|
+
}
|
|
356
|
+
function validateState(state) {
|
|
357
|
+
if (state.schema_version !== "1.0")
|
|
358
|
+
throw new Error("continuation driver state schema_version must be 1.0");
|
|
359
|
+
assertMaxTurns(state.max_turns);
|
|
360
|
+
if (state.adapter_command_identity !== null && (typeof state.adapter_command_identity !== "string" || state.adapter_command_identity.length === 0)) {
|
|
361
|
+
throw new Error("continuation driver adapter_command_identity must be a non-empty string or null");
|
|
362
|
+
}
|
|
363
|
+
if (!Number.isSafeInteger(state.turns_started) || state.turns_started < 0)
|
|
364
|
+
throw new Error("continuation driver turns_started must be a non-negative integer");
|
|
365
|
+
if (state.turns_started > state.max_turns)
|
|
366
|
+
throw new Error("continuation driver turns_started cannot exceed max_turns");
|
|
367
|
+
if (!new Set(["active", "waiting", "done", "failed", "budget_exhausted"]).has(state.status))
|
|
368
|
+
throw new Error("continuation driver state status is invalid");
|
|
369
|
+
if (state.status === "budget_exhausted" && state.turns_started !== state.max_turns) {
|
|
370
|
+
throw new Error("continuation driver budget_exhausted state must consume max_turns");
|
|
371
|
+
}
|
|
372
|
+
if (state.pending_barrier)
|
|
373
|
+
validateBarrier(state.pending_barrier);
|
|
374
|
+
}
|
|
375
|
+
function assertMissionIdentity(state, snapshot) {
|
|
376
|
+
if (state.run_id !== snapshot.run_id || state.definition_id !== snapshot.definition_id) {
|
|
377
|
+
throw new Error("continuation driver mission identity does not match the canonical Flow run");
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
function assertMaxTurns(value) {
|
|
381
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > 100)
|
|
382
|
+
throw new Error("continuation maxTurns must be an integer from 1 through 100");
|
|
383
|
+
}
|
|
384
|
+
function boundedErrorMessage(error) {
|
|
385
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
386
|
+
return message.length <= 2_000 ? message : `${message.slice(0, 1_997)}...`;
|
|
387
|
+
}
|
|
388
|
+
function processAlive(pid) {
|
|
389
|
+
try {
|
|
390
|
+
process.kill(pid, 0);
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
393
|
+
catch (error) {
|
|
394
|
+
return error.code === "EPERM";
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
function reconcileStateWithEvents(state, eventsFile) {
|
|
398
|
+
if (!fs.existsSync(eventsFile)) {
|
|
399
|
+
if (state.turns_started > 0)
|
|
400
|
+
throw new Error("continuation driver event history is missing for a started mission");
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
const lines = readRegularFileNoFollow(eventsFile, "continuation driver event log").split("\n").filter((line) => line.length > 0);
|
|
404
|
+
let highestStartedTurn = 0;
|
|
405
|
+
let eventBarrier = null;
|
|
406
|
+
for (const line of lines) {
|
|
407
|
+
const event = JSON.parse(line);
|
|
408
|
+
if (event.run_id !== state.run_id || event.definition_id !== state.definition_id)
|
|
409
|
+
throw new Error("continuation driver event history has a foreign mission identity");
|
|
410
|
+
if (event.type === "turn_started") {
|
|
411
|
+
if (!Number.isSafeInteger(event.turns_started) || event.turns_started < 1)
|
|
412
|
+
throw new Error("continuation driver event history has an invalid turn count");
|
|
413
|
+
highestStartedTurn = Math.max(highestStartedTurn, event.turns_started);
|
|
414
|
+
}
|
|
415
|
+
if (event.type === "parked") {
|
|
416
|
+
validateBarrier(event.barrier);
|
|
417
|
+
eventBarrier = structuredClone(event.barrier);
|
|
418
|
+
}
|
|
419
|
+
if (event.type === "resumed")
|
|
420
|
+
eventBarrier = null;
|
|
421
|
+
}
|
|
422
|
+
if (highestStartedTurn > state.turns_started)
|
|
423
|
+
throw new Error("continuation driver state rolled back behind its event history");
|
|
424
|
+
if (state.turns_started > highestStartedTurn + 1)
|
|
425
|
+
throw new Error("continuation driver state is ahead of its event history");
|
|
426
|
+
if (!state.pending_barrier && eventBarrier) {
|
|
427
|
+
state.pending_barrier = eventBarrier;
|
|
428
|
+
state.status = "waiting";
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
function builderContinuationDisposition(nextAction) {
|
|
432
|
+
if (!nextAction || typeof nextAction !== "object" || Array.isArray(nextAction))
|
|
433
|
+
throw new Error("Builder Flow projection is missing its canonical next action");
|
|
434
|
+
const status = nextAction.status;
|
|
435
|
+
if (status === "continue")
|
|
436
|
+
return "continue";
|
|
437
|
+
if (status === "blocked")
|
|
438
|
+
return "waiting";
|
|
439
|
+
if (status === "done")
|
|
440
|
+
return "done";
|
|
441
|
+
if (status === "failed")
|
|
442
|
+
return "failed";
|
|
443
|
+
throw new Error(`Builder Flow projection has unsupported next-action status: ${String(status)}`);
|
|
444
|
+
}
|
package/build/src/index.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ export { BUILDER_BUILD_FLOW_ID, BUILDER_BUILD_FLOW_RELATIVE_PATH, BuilderBuildRu
|
|
|
2
2
|
export type { BuilderBuildRunResult, BuilderBuildRunIdentityMismatch, BuilderBuildTrustBundleEvidenceInput, EvaluateBuilderBuildRunInput, LoadBuilderBuildRunInput, StartBuilderBuildRunInput, } from "./builder-flow-run-adapter.js";
|
|
3
3
|
export { archiveBuilderFlowSession, cancelBuilderFlowSession, pauseBuilderFlowSession, recoverBuilderFlowSession, releaseBuilderFlowAssignment, resumeBuilderFlowSession, startBuilderFlowSession, syncBuilderFlowSession, } from "./builder-flow-runtime.js";
|
|
4
4
|
export type { BuilderFlowAgentLifecycleInput, BuilderFlowAuthorizedLifecycleInput, BuilderFlowSessionInput, BuilderFlowSessionResult } from "./builder-flow-runtime.js";
|
|
5
|
+
export { createFileContinuationStore, driveBuilderFlowSession, runContinuationDriver, withContinuationDriverLock, } from "./continuation-driver.js";
|
|
6
|
+
export type { ContinuationBarrier, ContinuationDriverEvent, ContinuationDriverOutcome, ContinuationDriverState, ContinuationRuntimePort, ContinuationSnapshot, ContinuationStateStore, ContinuationTurnRequest, ContinuationTurnResult, DriveBuilderFlowSessionInput, RunContinuationDriverInput, } from "./continuation-driver.js";
|
|
5
7
|
export { builderLifecycleAuthorizationPayload, loadBuilderLifecycleAuthorization } from "./builder-lifecycle-authority.js";
|
|
6
8
|
export type { BuilderLifecycleAuthorization } from "./builder-lifecycle-authority.js";
|
|
7
9
|
export { defaultArtifactRootForRead, defaultCodexHome, defaultTelemetryDirForRead, defaultTelemetryDirsForRead, durableFlowAgentsRoot, durableInstallRecordPath, DURABLE_FLOW_AGENTS_DIR, FLOW_AGENTS_RUNTIME_DIR, FLOW_AGENTS_RUNTIME_SUBDIR, firstExistingPath, flowAgentsArtifactRoot, KONTOURAI_DIR, legacyTelemetryDataDir, LEGACY_TELEMETRY_DIR, telemetryDataDir, } from "./lib/local-artifact-root.js";
|
package/build/src/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import * as path from "node:path";
|
|
|
15
15
|
import { loadJson as _loadJson, writeJson as _writeJson } from "./cli/workflow-sidecar.js";
|
|
16
16
|
export { BUILDER_BUILD_FLOW_ID, BUILDER_BUILD_FLOW_RELATIVE_PATH, BuilderBuildRunInputError, BuilderBuildRunIdentityError, evaluateBuilderBuildRun, loadBuilderBuildRun, resolveBuilderBuildFlowDefinitionPath, startBuilderBuildRun, } from "./builder-flow-run-adapter.js";
|
|
17
17
|
export { archiveBuilderFlowSession, cancelBuilderFlowSession, pauseBuilderFlowSession, recoverBuilderFlowSession, releaseBuilderFlowAssignment, resumeBuilderFlowSession, startBuilderFlowSession, syncBuilderFlowSession, } from "./builder-flow-runtime.js";
|
|
18
|
+
export { createFileContinuationStore, driveBuilderFlowSession, runContinuationDriver, withContinuationDriverLock, } from "./continuation-driver.js";
|
|
18
19
|
// Pure serialization contract used by external lifecycle authorities when
|
|
19
20
|
// signing requests. This does not load, create, or mutate a Flow run.
|
|
20
21
|
export { builderLifecycleAuthorizationPayload, loadBuilderLifecycleAuthorization } from "./builder-lifecycle-authority.js";
|
|
@@ -137,6 +137,55 @@ function copyTree(src, dest, target, rootReplacement) {
|
|
|
137
137
|
}
|
|
138
138
|
}
|
|
139
139
|
}
|
|
140
|
+
const skillResourcePattern = /(?:^|[`("'\s])(context\/contracts\/[A-Za-z0-9._/-]+\.md)(?=$|[`),"'\s])/gm;
|
|
141
|
+
/** Export a skill as a self-contained package. Shared instruction resources
|
|
142
|
+
* retain the relative paths used by SKILL.md so runtime resolution is local. */
|
|
143
|
+
function exportSkillPackage(src, dest, target) {
|
|
144
|
+
const sourceDir = path.dirname(src);
|
|
145
|
+
copyTree(sourceDir, dest, target, "<bundle-root>");
|
|
146
|
+
const skillText = readText(src);
|
|
147
|
+
const resources = new Set();
|
|
148
|
+
for (const match of skillText.matchAll(skillResourcePattern))
|
|
149
|
+
resources.add(match[1]);
|
|
150
|
+
for (const rel of [...resources].sort()) {
|
|
151
|
+
if (path.isAbsolute(rel) || rel.split("/").includes("..")) {
|
|
152
|
+
throw new Error(`skill '${path.basename(sourceDir)}': unsafe local resource '${rel}'`);
|
|
153
|
+
}
|
|
154
|
+
const source = path.resolve(root, rel);
|
|
155
|
+
const output = path.resolve(dest, rel);
|
|
156
|
+
if (!source.startsWith(`${path.resolve(root)}${path.sep}`) || !output.startsWith(`${path.resolve(dest)}${path.sep}`)) {
|
|
157
|
+
throw new Error(`skill '${path.basename(sourceDir)}': local resource escapes package '${rel}'`);
|
|
158
|
+
}
|
|
159
|
+
if (!fs.existsSync(source) || !fs.statSync(source).isFile()) {
|
|
160
|
+
throw new Error(`skill '${path.basename(sourceDir)}': missing local resource '${rel}'`);
|
|
161
|
+
}
|
|
162
|
+
if (fs.existsSync(output)) {
|
|
163
|
+
const existing = fs.readFileSync(output);
|
|
164
|
+
const incoming = fs.readFileSync(source);
|
|
165
|
+
if (!existing.equals(incoming))
|
|
166
|
+
throw new Error(`skill '${path.basename(sourceDir)}': resource collision '${rel}'`);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
writeText(output, sanitizeText(readText(source), target, "<bundle-root>"));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function validateExportedSkillPackages(skillsRoot) {
|
|
173
|
+
if (!fs.existsSync(skillsRoot))
|
|
174
|
+
return;
|
|
175
|
+
for (const skillName of fs.readdirSync(skillsRoot).sort()) {
|
|
176
|
+
const skillDir = path.join(skillsRoot, skillName);
|
|
177
|
+
const skillFile = path.join(skillDir, "SKILL.md");
|
|
178
|
+
if (!fs.existsSync(skillFile))
|
|
179
|
+
continue;
|
|
180
|
+
for (const match of readText(skillFile).matchAll(skillResourcePattern)) {
|
|
181
|
+
const rel = match[1];
|
|
182
|
+
const resolved = path.resolve(skillDir, rel);
|
|
183
|
+
if (!resolved.startsWith(`${path.resolve(skillDir)}${path.sep}`) || !fs.existsSync(resolved)) {
|
|
184
|
+
throw new Error(`skill '${skillName}': unresolved exported resource '${rel}'`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
140
189
|
function resolveSourcePath(pathText) {
|
|
141
190
|
let normalized = pathText;
|
|
142
191
|
for (const alias of manifest.source_root_aliases)
|
|
@@ -473,9 +522,10 @@ function buildCodex(agents) {
|
|
|
473
522
|
writeText(path.join(bundle, ".codex/hooks.json"), exportCodexHooks());
|
|
474
523
|
for (const spec of targetAgents)
|
|
475
524
|
writeText(path.join(bundle, ".codex/agents", `${spec.name}.toml`), exportCodexAgent(spec));
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
525
|
+
const skillsRoot = path.join(bundle, ".agents/skills");
|
|
526
|
+
for (const { name, src } of collectAllSkills())
|
|
527
|
+
exportSkillPackage(src, path.join(skillsRoot, name), "codex");
|
|
528
|
+
validateExportedSkillPackages(skillsRoot);
|
|
479
529
|
writeText(path.join(bundle, "AGENTS.md"), exportRootAgentsMd("Codex", targetAgents, manifest.codex.task_dir));
|
|
480
530
|
writeText(path.join(bundle, "README.md"), exportTargetReadme("Codex", "bash install.sh /path/to/workspace", CODEX_LIVE_HOOKS_README));
|
|
481
531
|
writeText(path.join(bundle, "install.sh"), installScript("Codex", "/path/to/workspace", undefined, undefined, { configRelPath: ".codex/hooks.json", managedConfigRelPath: ".codex/hooks.json", runtime: "codex", version: pkgVersion }));
|
package/docs/getting-started.md
CHANGED
|
@@ -30,7 +30,15 @@ For a normal Codex global install, target the Codex home instead of a project wo
|
|
|
30
30
|
npx @kontourai/flow-agents init --runtime codex --global --activate-kits --yes
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
That
|
|
33
|
+
That splits installation by ownership: Codex-only runtime assets go into `CODEX_HOME` when set (otherwise `~/.codex`), while portable skills go into Codex's documented user catalog at `$HOME/.agents/skills`. A repository bundle install similarly exposes skills at `<repo>/.agents/skills`.
|
|
34
|
+
|
|
35
|
+
Pass a positional runtime destination and `--skills-dir PATH` when both roots must be isolated, or set `FLOW_AGENTS_SKILLS_DIR` for headless environments:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
bash scripts/install-codex-home.sh /tmp/codex-home --skills-dir /tmp/agents/skills
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Installer output reports both destinations. Reinstall preserves unknown user skills and migrates only unchanged Flow Agents-owned files from the former `CODEX_HOME/skills` layout; modified legacy content is retained. Flow Agents intentionally does not create a compatibility symlink because the Codex runtime home must not own or mask a universal user catalog, and the installer refuses to write through symlinked destinations.
|
|
34
42
|
|
|
35
43
|
Keep generated Codex base config lean. Put profile-specific model, provider, and approval settings in separate `<profile>.config.toml` files and select them with `codex --profile <name>`.
|
|
36
44
|
|
|
@@ -113,6 +113,60 @@ flow_agents workflow archive --authorization-file archive.json
|
|
|
113
113
|
state, and the canonical Flow run without rewriting either store. Its `next_action` is freshly
|
|
114
114
|
derived from the canonical run, so a stale sidecar projection cannot misdirect recovery.
|
|
115
115
|
|
|
116
|
+
## Bounded Continuation Driver
|
|
117
|
+
|
|
118
|
+
`workflow drive` lets the active implementation assignment run multiple Flow steps without a
|
|
119
|
+
human continuation prompt. Flow remains authoritative: the runtime adapter receives the current
|
|
120
|
+
projected action, but its `completed` result means only that one model turn ended. The driver
|
|
121
|
+
returns `done` only after the canonical Flow run is terminal.
|
|
122
|
+
|
|
123
|
+
The adapter command is an explicit JSON argv file whose executable must be an absolute path. Flow
|
|
124
|
+
Agents invokes it directly without a shell, writes one versioned continuation-turn request to stdin, and requires exactly one
|
|
125
|
+
JSON result on stdout:
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{ "argv": ["/absolute/path/to/runtime-adapter", "--profile", "builder"] }
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
```json
|
|
132
|
+
{ "status": "completed", "summary": "Turn ended; synchronize canonical evidence." }
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
An adapter may instead park on a process or deadline. A pending barrier is persisted and does not
|
|
136
|
+
consume another turn when `workflow drive` is invoked again:
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{ "status": "wait", "barrier": { "kind": "pid", "pid": 12345 }, "summary": "Waiting for the verification process." }
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
flow_agents workflow drive \
|
|
144
|
+
--session-dir .kontourai/flow-agents/example \
|
|
145
|
+
--adapter-command-file .kontourai/flow-agents/runtime-adapter.json \
|
|
146
|
+
--max-turns 6 \
|
|
147
|
+
--turn-timeout-ms 900000 \
|
|
148
|
+
--barrier-wait-ms 300000 \
|
|
149
|
+
--json
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Driver state and its append-only event stream live under
|
|
153
|
+
`.kontourai/flow-agents/<slug>/continuation-driver/`. The mission turn count survives reinvocation;
|
|
154
|
+
subsequent invocations must use the same `--max-turns` value. The request contains the
|
|
155
|
+
canonical run id, definition id, current step, projected `next_action`, iteration, and budget. It
|
|
156
|
+
does not mutate or replace the runtime system prompt. Adapter errors are recorded as failed turns
|
|
157
|
+
and fail open to canonical resynchronization and the next bounded turn; they cannot bypass the
|
|
158
|
+
persisted mission budget. The Builder Flow projection supplies the canonical continue/wait/done/failed
|
|
159
|
+
disposition, so the generic driver does not duplicate Flow lifecycle semantics. Human-decision and
|
|
160
|
+
paused Flow states park, while remediable blocked and accepted-exception states continue; failed Flow
|
|
161
|
+
runs stop as failed. Assignment ownership is revalidated before every adapter
|
|
162
|
+
turn. A session-scoped process lock rejects concurrent drivers and safely removes unique lock files
|
|
163
|
+
left by exited owners. The adapter argv plus the content digests of its executable and absolute
|
|
164
|
+
regular-file arguments are bound to the mission on first invocation and rechecked before every turn.
|
|
165
|
+
Adapter process groups are terminated after every non-wait result. In addition,
|
|
166
|
+
state rollback or deletion is rejected when its append-only event history proves turns already
|
|
167
|
+
started. These local coordination records detect accidental or in-process rollback; they are not a
|
|
168
|
+
cryptographic boundary against a process that can rewrite the entire artifact directory.
|
|
169
|
+
|
|
116
170
|
## Compatibility Doctor
|
|
117
171
|
|
|
118
172
|
Run doctor through the exact isolated package helper defined above:
|
|
@@ -63,8 +63,36 @@ does not create a Flow run.
|
|
|
63
63
|
|
|
64
64
|
After a gate producer writes `trust.bundle`, the public evidence path
|
|
65
65
|
synchronizes the existing run while holding the assignment subject lock.
|
|
66
|
-
Synchronization
|
|
67
|
-
|
|
66
|
+
Synchronization selects only live claims: producer-superseded claims and claims
|
|
67
|
+
carrying `metadata.superseded_by` remain auditable history but never determine
|
|
68
|
+
the current outcome. Passing evidence is published atomically only when every
|
|
69
|
+
required expectation for the gate is present; this prevents sequential critique
|
|
70
|
+
writes from attaching an intermediate partial snapshot and consuming a
|
|
71
|
+
route-back attempt. Failed evidence may still synchronize immediately when it
|
|
72
|
+
carries a route reason declared by the gate; a disputed report-only critique is
|
|
73
|
+
not itself a routed gate decision and remains pending.
|
|
74
|
+
|
|
75
|
+
Attachments carry the exact expectation ids selected from the current bundle.
|
|
76
|
+
Digest idempotence applies only while an unsuperseded attachment for that gate
|
|
77
|
+
and expectation set remains live. After route-back, claims must be current for
|
|
78
|
+
the new gate visit before synchronization, and claim/evidence identities used
|
|
79
|
+
by any earlier attachment to that gate can never satisfy the later visit.
|
|
80
|
+
Gate claims, verified criteria, and critiques carry producer-recorded version
|
|
81
|
+
timestamps plus `identity_version: 2` in their identity derivation so legitimate
|
|
82
|
+
re-verification creates new identities. Unmarked pre-upgrade records retain the
|
|
83
|
+
legacy identity formula during rebuild; installing new code alone can never
|
|
84
|
+
manufacture a fresh identity. Because those identities are embedded in TrustBundle bytes, a
|
|
85
|
+
genuinely new identity necessarily changes the bundle SHA-256; byte-identical
|
|
86
|
+
replay remains pending rather than consuming another attempt.
|
|
87
|
+
|
|
88
|
+
Every gate visit has a canonical boundary: the latest Flow transition into the
|
|
89
|
+
step, or the run's initial timestamp for its entry step. Claim creation and
|
|
90
|
+
observation timestamps must fall within that visit and may not be more than 30
|
|
91
|
+
seconds ahead of synchronization. The sole pre-boundary allowance is the
|
|
92
|
+
30-second acquisition window for the assignment-backed `selected-work` claim,
|
|
93
|
+
which is intentionally produced immediately before the canonical run starts.
|
|
94
|
+
Previously attached claim and evidence identities are excluded from every later
|
|
95
|
+
visit to the same gate regardless of timestamp skew.
|
|
68
96
|
|
|
69
97
|
## Public Status And Recovery
|
|
70
98
|
|
|
@@ -119,6 +147,12 @@ Workflow steering surfaces these fields on session start and prompt submission.
|
|
|
119
147
|
Stop hook treats an unfinished canonical Flow run as active even during pickup or
|
|
120
148
|
planning, blocks a premature stop in block mode, and does not release its liveness
|
|
121
149
|
claim. A run is complete only when Flow reaches its terminal step.
|
|
150
|
+
|
|
151
|
+
Projection is always derived from the canonical run's `current_step`, including
|
|
152
|
+
composed steps that have no legacy sidecar phase (`merge-ready-ci` and `learn`).
|
|
153
|
+
Both the legacy `current.json` pointer and every matching per-actor pointer are
|
|
154
|
+
updated from that canonical step; `phase_map` is presentation metadata, not the
|
|
155
|
+
authority for pointer navigation.
|
|
122
156
|
# Builder Lifecycle Authority
|
|
123
157
|
|
|
124
158
|
The canonical Flow run owns pause, resume, and cancellation. The current assignment actor may
|