@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,239 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import type { ContinuationBarrier, ContinuationTurnRequest, ContinuationTurnResult } from "../continuation-driver.js";
|
|
6
|
+
|
|
7
|
+
export type ContinuationAdapterCommand = {
|
|
8
|
+
argv: string[];
|
|
9
|
+
identity: string;
|
|
10
|
+
integrity: Array<{ file: string; sha256: string }>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function loadContinuationAdapterCommand(commandFileInput: string): ContinuationAdapterCommand {
|
|
14
|
+
const commandFile = path.resolve(commandFileInput);
|
|
15
|
+
const command = validateAdapterCommand(JSON.parse(readRegularFileNoFollow(commandFile, "continuation adapter command file")) as unknown);
|
|
16
|
+
if (!path.isAbsolute(command.argv[0]!)) throw new Error("continuation adapter executable must be an absolute path");
|
|
17
|
+
const integrity = [...new Set(command.argv.filter((entry) => path.isAbsolute(entry) && regularFileExists(entry)))]
|
|
18
|
+
.map((file) => ({ file, sha256: sha256File(file, "continuation adapter integrity file") }));
|
|
19
|
+
if (!integrity.some((entry) => entry.file === command.argv[0])) throw new Error("continuation adapter executable must be a regular file");
|
|
20
|
+
const identity = createHash("sha256").update(JSON.stringify({ ...command, integrity })).digest("hex");
|
|
21
|
+
return { ...command, identity, integrity };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function executeContinuationAdapter(
|
|
25
|
+
commandFileInput: string,
|
|
26
|
+
request: ContinuationTurnRequest,
|
|
27
|
+
options: { cwd: string; timeoutMs: number },
|
|
28
|
+
): Promise<ContinuationTurnResult> {
|
|
29
|
+
const command = loadContinuationAdapterCommand(commandFileInput);
|
|
30
|
+
return executeLoadedContinuationAdapter(command, request, options);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function executeLoadedContinuationAdapter(
|
|
34
|
+
command: ContinuationAdapterCommand,
|
|
35
|
+
request: ContinuationTurnRequest,
|
|
36
|
+
options: { cwd: string; timeoutMs: number },
|
|
37
|
+
): Promise<ContinuationTurnResult> {
|
|
38
|
+
for (const entry of command.integrity) {
|
|
39
|
+
if (sha256File(entry.file, "continuation adapter integrity file") !== entry.sha256) {
|
|
40
|
+
throw new Error(`continuation adapter integrity changed after mission binding: ${entry.file}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
assertPositiveInteger(options.timeoutMs, "continuation adapter timeoutMs", 1, 86_400_000);
|
|
44
|
+
return await spawnAdapter(command, request, options);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function waitForContinuationBarrier(
|
|
48
|
+
barrier: ContinuationBarrier,
|
|
49
|
+
options: { maxWaitMs: number; pollMs: number; now?: () => number; sleep?: (ms: number) => Promise<void> },
|
|
50
|
+
): Promise<"ready" | "pending"> {
|
|
51
|
+
assertPositiveInteger(options.maxWaitMs, "continuation barrier maxWaitMs", 0, 86_400_000);
|
|
52
|
+
assertPositiveInteger(options.pollMs, "continuation barrier pollMs", 1, 60_000);
|
|
53
|
+
const now = options.now ?? Date.now;
|
|
54
|
+
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
55
|
+
const stopAt = now() + options.maxWaitMs;
|
|
56
|
+
|
|
57
|
+
if (barrier.kind === "deadline") {
|
|
58
|
+
const deadline = Date.parse(barrier.at);
|
|
59
|
+
if (deadline <= now()) return "ready";
|
|
60
|
+
const waitMs = Math.min(deadline - now(), Math.max(0, stopAt - now()));
|
|
61
|
+
if (waitMs > 0) await sleep(waitMs);
|
|
62
|
+
return Date.parse(barrier.at) <= now() ? "ready" : "pending";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
while (pidAlive(barrier.pid)) {
|
|
66
|
+
const remaining = stopAt - now();
|
|
67
|
+
if (remaining <= 0) return "pending";
|
|
68
|
+
await sleep(Math.min(options.pollMs, remaining));
|
|
69
|
+
}
|
|
70
|
+
return "ready";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function spawnAdapter(
|
|
74
|
+
command: ContinuationAdapterCommand,
|
|
75
|
+
request: ContinuationTurnRequest,
|
|
76
|
+
options: { cwd: string; timeoutMs: number },
|
|
77
|
+
): Promise<ContinuationTurnResult> {
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
const child = spawn(command.argv[0]!, command.argv.slice(1), {
|
|
80
|
+
cwd: options.cwd,
|
|
81
|
+
env: process.env,
|
|
82
|
+
detached: process.platform !== "win32",
|
|
83
|
+
shell: false,
|
|
84
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
85
|
+
});
|
|
86
|
+
const stdout: Buffer[] = [];
|
|
87
|
+
const stderr: Buffer[] = [];
|
|
88
|
+
let stdoutBytes = 0;
|
|
89
|
+
let stderrBytes = 0;
|
|
90
|
+
let settled = false;
|
|
91
|
+
let forcedKill: NodeJS.Timeout | undefined;
|
|
92
|
+
let terminationError: Error | undefined;
|
|
93
|
+
const maxBytes = 4 * 1024 * 1024;
|
|
94
|
+
const timeout = setTimeout(() => {
|
|
95
|
+
if (settled) return;
|
|
96
|
+
terminationError = new Error(`continuation adapter timed out after ${options.timeoutMs}ms`);
|
|
97
|
+
terminateProcessGroup(child.pid, "SIGTERM");
|
|
98
|
+
forcedKill = setTimeout(() => terminateProcessGroup(child.pid, "SIGKILL"), 250);
|
|
99
|
+
}, options.timeoutMs);
|
|
100
|
+
|
|
101
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
102
|
+
stdoutBytes += chunk.length;
|
|
103
|
+
if (stdoutBytes > maxBytes) {
|
|
104
|
+
if (!terminationError) {
|
|
105
|
+
terminationError = new Error("continuation adapter stdout exceeded 4 MiB");
|
|
106
|
+
clearTimeout(timeout);
|
|
107
|
+
terminateProcessGroup(child.pid, "SIGTERM");
|
|
108
|
+
forcedKill = setTimeout(() => terminateProcessGroup(child.pid, "SIGKILL"), 250);
|
|
109
|
+
}
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
stdout.push(chunk);
|
|
113
|
+
});
|
|
114
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
115
|
+
stderrBytes += chunk.length;
|
|
116
|
+
if (stderrBytes <= maxBytes) stderr.push(chunk);
|
|
117
|
+
});
|
|
118
|
+
child.on("error", (error) => {
|
|
119
|
+
if (settled) return;
|
|
120
|
+
settled = true;
|
|
121
|
+
clearTimeout(timeout);
|
|
122
|
+
if (forcedKill) clearTimeout(forcedKill);
|
|
123
|
+
reject(error);
|
|
124
|
+
});
|
|
125
|
+
child.on("close", (code, signal) => {
|
|
126
|
+
if (settled) return;
|
|
127
|
+
settled = true;
|
|
128
|
+
clearTimeout(timeout);
|
|
129
|
+
if (forcedKill) clearTimeout(forcedKill);
|
|
130
|
+
if (terminationError) {
|
|
131
|
+
reject(terminationError);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const stderrText = Buffer.concat(stderr).toString("utf8").trim();
|
|
135
|
+
if (code !== 0) {
|
|
136
|
+
scheduleProcessGroupTermination(child.pid);
|
|
137
|
+
reject(new Error(`continuation adapter exited ${code ?? signal ?? "unknown"}${stderrText ? `: ${stderrText}` : ""}`));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const output = Buffer.concat(stdout).toString("utf8").trim();
|
|
141
|
+
try {
|
|
142
|
+
const result = JSON.parse(output) as ContinuationTurnResult;
|
|
143
|
+
if (!isValidWaitResult(result)) scheduleProcessGroupTermination(child.pid);
|
|
144
|
+
resolve(result);
|
|
145
|
+
} catch {
|
|
146
|
+
scheduleProcessGroupTermination(child.pid);
|
|
147
|
+
reject(new Error("continuation adapter must emit exactly one JSON result on stdout"));
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
child.stdin.on("error", (error) => {
|
|
151
|
+
if ((error as NodeJS.ErrnoException).code !== "EPIPE" && !settled) {
|
|
152
|
+
settled = true;
|
|
153
|
+
clearTimeout(timeout);
|
|
154
|
+
if (forcedKill) clearTimeout(forcedKill);
|
|
155
|
+
scheduleProcessGroupTermination(child.pid);
|
|
156
|
+
reject(error);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
child.stdin.end(`${JSON.stringify(request)}\n`);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function validateAdapterCommand(value: unknown): Pick<ContinuationAdapterCommand, "argv"> {
|
|
164
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("continuation adapter command file must contain an object");
|
|
165
|
+
const argv = (value as { argv?: unknown }).argv;
|
|
166
|
+
if (!Array.isArray(argv) || argv.length === 0 || argv.length > 128 || argv.some((entry) => typeof entry !== "string" || entry.length === 0 || entry.includes("\0"))) {
|
|
167
|
+
throw new Error("continuation adapter command argv must contain 1 through 128 non-empty strings");
|
|
168
|
+
}
|
|
169
|
+
return { argv: [...argv] as string[] };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function assertRegularFile(file: string, label: string): void {
|
|
173
|
+
const stat = fs.lstatSync(file);
|
|
174
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error(`${label} must be a regular file`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function readRegularFileNoFollow(file: string, label: string): string {
|
|
178
|
+
return readRegularFileBufferNoFollow(file, label).toString("utf8");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function readRegularFileBufferNoFollow(file: string, label: string): Buffer {
|
|
182
|
+
assertRegularFile(file, label);
|
|
183
|
+
const noFollow = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
|
|
184
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | noFollow);
|
|
185
|
+
try {
|
|
186
|
+
if (!fs.fstatSync(fd).isFile()) throw new Error(`${label} must be a regular file`);
|
|
187
|
+
return fs.readFileSync(fd);
|
|
188
|
+
} finally {
|
|
189
|
+
fs.closeSync(fd);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function assertPositiveInteger(value: number, label: string, min: number, max: number): void {
|
|
194
|
+
if (!Number.isSafeInteger(value) || value < min || value > max) throw new Error(`${label} must be an integer from ${min} through ${max}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function pidAlive(pid: number): boolean {
|
|
198
|
+
try {
|
|
199
|
+
process.kill(pid, 0);
|
|
200
|
+
return true;
|
|
201
|
+
} catch (error) {
|
|
202
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function terminateProcessGroup(pid: number | undefined, signal: NodeJS.Signals): void {
|
|
207
|
+
if (!pid) return;
|
|
208
|
+
try {
|
|
209
|
+
process.kill(process.platform === "win32" ? pid : -pid, signal);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function isValidWaitResult(result: ContinuationTurnResult): boolean {
|
|
216
|
+
if (result?.status !== "wait" || !result.barrier || typeof result.barrier !== "object") return false;
|
|
217
|
+
if (result.barrier.kind === "pid") return Number.isSafeInteger(result.barrier.pid) && result.barrier.pid > 0;
|
|
218
|
+
return result.barrier.kind === "deadline" && typeof result.barrier.at === "string" && Number.isFinite(Date.parse(result.barrier.at));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function scheduleProcessGroupTermination(pid: number | undefined): void {
|
|
222
|
+
terminateProcessGroup(pid, "SIGTERM");
|
|
223
|
+
const forcedKill = setTimeout(() => terminateProcessGroup(pid, "SIGKILL"), 250);
|
|
224
|
+
forcedKill.unref();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function regularFileExists(file: string): boolean {
|
|
228
|
+
try {
|
|
229
|
+
const stat = fs.lstatSync(file);
|
|
230
|
+
return !stat.isSymbolicLink() && stat.isFile();
|
|
231
|
+
} catch (error) {
|
|
232
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function sha256File(file: string, label: string): string {
|
|
238
|
+
return createHash("sha256").update(readRegularFileBufferNoFollow(file, label)).digest("hex");
|
|
239
|
+
}
|