@chrok/braid 0.1.2 → 0.2.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 +167 -16
- package/README.md +232 -149
- package/ROADMAP.md +77 -27
- package/SECURITY.md +7 -3
- package/dist/adapters/openai.js +4 -4
- package/dist/budgets.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/merge-tools.d.ts +1 -1
- package/dist/merge-tools.js +8 -8
- package/dist/runtime.d.ts +4 -2
- package/dist/runtime.js +396 -227
- package/dist/types.d.ts +142 -19
- package/dist/validate.d.ts +7 -1
- package/dist/validate.js +143 -13
- package/dist/workspaces.d.ts +15 -2
- package/dist/workspaces.js +172 -88
- package/docs/benchmark.md +3 -0
- package/docs/compatibility.md +44 -3
- package/docs/examples.md +2 -1
- package/docs/execution-control.md +160 -0
- package/docs/releasing.md +66 -1
- package/docs/repository-settings.md +41 -2
- package/docs/resource-limits.md +15 -5
- package/examples/code-review.ts +3 -3
- package/examples/execution-control.ts +38 -0
- package/examples/failure-handling.ts +2 -2
- package/package.json +10 -4
package/dist/runtime.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { compileGraph } from "./validate.js";
|
|
2
|
-
import { GitWorkspaces } from "./workspaces.js";
|
|
2
|
+
import { GitWorkspaces, WorkspaceInputError, WorkspaceCheckpointError } from "./workspaces.js";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
class RunError extends Error {
|
|
5
5
|
code;
|
|
@@ -11,7 +11,7 @@ class RunError extends Error {
|
|
|
11
11
|
}
|
|
12
12
|
function describeError(error) {
|
|
13
13
|
return {
|
|
14
|
-
code: error instanceof RunError ? error.code : "MODEL_ERROR",
|
|
14
|
+
code: error instanceof RunError ? error.code : error instanceof WorkspaceInputError ? "WORKSPACE_MERGE_REQUIRED" : "MODEL_ERROR",
|
|
15
15
|
message: error instanceof Error
|
|
16
16
|
? error.message
|
|
17
17
|
: typeof error === "string"
|
|
@@ -39,6 +39,8 @@ function validateOptions(options) {
|
|
|
39
39
|
if (!Number.isSafeInteger(concurrency) || concurrency < 1) {
|
|
40
40
|
throw new TypeError("maxConcurrency must be a positive integer");
|
|
41
41
|
}
|
|
42
|
+
if (!Number.isSafeInteger(options.maxExecutions ?? 1000) || (options.maxExecutions ?? 1000) < 1)
|
|
43
|
+
throw new TypeError("maxExecutions must be a positive integer");
|
|
42
44
|
for (const key of ["nodeTimeoutMs", "graphTimeoutMs"]) {
|
|
43
45
|
const value = options[key];
|
|
44
46
|
if (value !== undefined &&
|
|
@@ -59,6 +61,14 @@ function createEventLog(onEvent) {
|
|
|
59
61
|
sequence: ++sequence,
|
|
60
62
|
timestamp: Date.now(),
|
|
61
63
|
});
|
|
64
|
+
const nested = [event];
|
|
65
|
+
while (nested.length) {
|
|
66
|
+
const value = nested.pop();
|
|
67
|
+
if (value && typeof value === "object") {
|
|
68
|
+
Object.freeze(value);
|
|
69
|
+
nested.push(...Object.values(value));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
62
72
|
if ("error" in event)
|
|
63
73
|
Object.freeze(event.error);
|
|
64
74
|
if ("usage" in event && event.usage)
|
|
@@ -80,46 +90,14 @@ function createEventLog(onEvent) {
|
|
|
80
90
|
},
|
|
81
91
|
};
|
|
82
92
|
}
|
|
83
|
-
function edgeState(edge,
|
|
84
|
-
|
|
93
|
+
function edgeState(edge, source) {
|
|
94
|
+
if (!source)
|
|
95
|
+
return "unresolved";
|
|
85
96
|
switch (source.status) {
|
|
86
|
-
case "completed":
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
case "failed":
|
|
91
|
-
return edge.choice === undefined ? "active" : "blocked";
|
|
92
|
-
case "skipped":
|
|
93
|
-
// Failure is not inactivity: a downstream join must not silently lose required input.
|
|
94
|
-
return source.skipReason === "inactive" ? "inactive" : "blocked";
|
|
95
|
-
default:
|
|
96
|
-
return "unresolved";
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
function skip(result, reason, log) {
|
|
100
|
-
result.status = "skipped";
|
|
101
|
-
result.skipReason = reason;
|
|
102
|
-
result.finishedAt = Date.now();
|
|
103
|
-
log.emit({ type: "node_skipped", nodeId: result.id, reason });
|
|
104
|
-
}
|
|
105
|
-
function resolveDependencies(graph, results, log) {
|
|
106
|
-
// Topological order propagates arbitrarily long skipped paths in one pass.
|
|
107
|
-
for (const node of graph.topologicalOrder) {
|
|
108
|
-
const result = results.get(node.id);
|
|
109
|
-
if (result.status !== "pending")
|
|
110
|
-
continue;
|
|
111
|
-
const incoming = graph.incoming.get(node.id);
|
|
112
|
-
const states = incoming.map((edge) => edgeState(edge, results));
|
|
113
|
-
if (states.includes("unresolved"))
|
|
114
|
-
continue;
|
|
115
|
-
if (states.includes("blocked"))
|
|
116
|
-
skip(result, "upstream_failed", log);
|
|
117
|
-
else if (incoming.length > 0 && !states.includes("active"))
|
|
118
|
-
skip(result, "inactive", log);
|
|
119
|
-
else {
|
|
120
|
-
result.status = "runnable";
|
|
121
|
-
log.emit({ type: "node_runnable", nodeId: result.id });
|
|
122
|
-
}
|
|
97
|
+
case "completed": return edge.choice === undefined || edge.choice === source.decision ? "active" : "inactive";
|
|
98
|
+
case "failed": return edge.choice === undefined ? "active" : "blocked";
|
|
99
|
+
case "skipped": return source.skipReason === "inactive" ? "inactive" : "blocked";
|
|
100
|
+
default: return "unresolved";
|
|
123
101
|
}
|
|
124
102
|
}
|
|
125
103
|
function nodeOutput(result) {
|
|
@@ -131,19 +109,6 @@ function nodeOutput(result) {
|
|
|
131
109
|
output.model = result.model;
|
|
132
110
|
return output;
|
|
133
111
|
}
|
|
134
|
-
function predecessorOutputs(graph, id, results) {
|
|
135
|
-
const activeIds = new Set(graph.incoming
|
|
136
|
-
.get(id)
|
|
137
|
-
.filter((edge) => edgeState(edge, results) === "active")
|
|
138
|
-
.map((edge) => edge.from));
|
|
139
|
-
// A predecessor appears once even if more than one edge connects the same pair.
|
|
140
|
-
return [...activeIds].map((nodeId) => ({
|
|
141
|
-
nodeId,
|
|
142
|
-
...nodeOutput(results.get(nodeId)),
|
|
143
|
-
...(results.get(nodeId).error ? { error: { ...results.get(nodeId).error } } : {}),
|
|
144
|
-
...(results.get(nodeId).workspace ? { workspace: { ...results.get(nodeId).workspace } } : {}),
|
|
145
|
-
}));
|
|
146
|
-
}
|
|
147
112
|
function checkResponse(response) {
|
|
148
113
|
if (!response ||
|
|
149
114
|
typeof response.output !== "string" ||
|
|
@@ -160,7 +125,7 @@ function checkResponse(response) {
|
|
|
160
125
|
throw new RunError("INVALID_RESPONSE", "Token usage must contain non-negative integer inputTokens/outputTokens");
|
|
161
126
|
}
|
|
162
127
|
}
|
|
163
|
-
async function runNode(request, result, runner, timeoutMs, graphSignal, graphDeadline, log, workspaces) {
|
|
128
|
+
async function runNode(request, result, runner, timeoutMs, graphSignal, graphDeadline, log, workspaces, fail) {
|
|
164
129
|
result.status = "running";
|
|
165
130
|
result.startedAt = Date.now();
|
|
166
131
|
const started = performance.now();
|
|
@@ -179,6 +144,8 @@ async function runNode(request, result, runner, timeoutMs, graphSignal, graphDea
|
|
|
179
144
|
});
|
|
180
145
|
void aborted.catch(() => { });
|
|
181
146
|
let acceptingWrites = true;
|
|
147
|
+
let runnerStarted = false;
|
|
148
|
+
let writableWorkspace = false;
|
|
182
149
|
const writes = new Set();
|
|
183
150
|
let merge;
|
|
184
151
|
let acceptingDecisions = true;
|
|
@@ -191,6 +158,8 @@ async function runNode(request, result, runner, timeoutMs, graphSignal, graphDea
|
|
|
191
158
|
signal.throwIfAborted();
|
|
192
159
|
if (!acceptingWrites)
|
|
193
160
|
throw new Error("Node has finished; further writes are unavailable");
|
|
161
|
+
if (!writableWorkspace)
|
|
162
|
+
throw new Error("Node workspace is read-only; writes are unavailable");
|
|
194
163
|
const pending = Promise.resolve().then(() => {
|
|
195
164
|
signal.throwIfAborted();
|
|
196
165
|
return operation();
|
|
@@ -240,8 +209,8 @@ async function runNode(request, result, runner, timeoutMs, graphSignal, graphDea
|
|
|
240
209
|
}
|
|
241
210
|
try {
|
|
242
211
|
checkDeadline();
|
|
243
|
-
if (request.node.type === "merge") {
|
|
244
|
-
merge = await workspaces.beginMerge(invocation, request.predecessors.map(value => value.
|
|
212
|
+
if (request.node.type === "merge" || request.node.type === "integrate") {
|
|
213
|
+
merge = await workspaces.beginMerge(invocation, request.predecessors.map(value => value.executionId));
|
|
245
214
|
invocation.merge = { sources: structuredClone(merge.sources),
|
|
246
215
|
...(merge.sourceStatus ? { sourceStatus: structuredClone(merge.sourceStatus) } : {}), finish: merge.finish };
|
|
247
216
|
}
|
|
@@ -249,6 +218,7 @@ async function runNode(request, result, runner, timeoutMs, graphSignal, graphDea
|
|
|
249
218
|
invocation.workspace = await workspaces.prepare(invocation);
|
|
250
219
|
}
|
|
251
220
|
invocation.workspace = structuredClone(invocation.workspace);
|
|
221
|
+
writableWorkspace = invocation.workspace.mode !== "read-only";
|
|
252
222
|
if (invocation.workspace?.sourceRoot) {
|
|
253
223
|
const gitRequest = { ...invocation, node: structuredClone(invocation.node), workspace: structuredClone(invocation.workspace) };
|
|
254
224
|
invocation.git = (args, input) => workspaces.git(gitRequest, args, input);
|
|
@@ -268,6 +238,7 @@ async function runNode(request, result, runner, timeoutMs, graphSignal, graphDea
|
|
|
268
238
|
log.emit({
|
|
269
239
|
type: "handoff",
|
|
270
240
|
from: predecessor.nodeId,
|
|
241
|
+
...(predecessor.executionId ? { fromExecutionId: predecessor.executionId } : {}),
|
|
271
242
|
to: result.id,
|
|
272
243
|
output: predecessor.output,
|
|
273
244
|
...(predecessor.decision !== undefined
|
|
@@ -276,6 +247,7 @@ async function runNode(request, result, runner, timeoutMs, graphSignal, graphDea
|
|
|
276
247
|
});
|
|
277
248
|
}
|
|
278
249
|
checkDeadline();
|
|
250
|
+
runnerStarted = true;
|
|
279
251
|
return runner(invocation);
|
|
280
252
|
}),
|
|
281
253
|
]);
|
|
@@ -297,20 +269,40 @@ async function runNode(request, result, runner, timeoutMs, graphSignal, graphDea
|
|
|
297
269
|
catch (error) {
|
|
298
270
|
result.status = "failed";
|
|
299
271
|
result.error = describeError(error);
|
|
272
|
+
fail(result.error, !runnerStarted && !(error instanceof WorkspaceInputError) && !signal.aborted);
|
|
300
273
|
}
|
|
301
274
|
finally {
|
|
302
275
|
acceptingDecisions = false;
|
|
303
276
|
acceptingWrites = false;
|
|
304
277
|
await Promise.allSettled(writes);
|
|
278
|
+
if (result.status === "completed") {
|
|
279
|
+
try {
|
|
280
|
+
checkDeadline();
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
result.status = "failed";
|
|
284
|
+
result.error = describeError(error);
|
|
285
|
+
fail(result.error);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
305
288
|
if (merge) {
|
|
306
289
|
try {
|
|
307
290
|
await merge.complete(result.status === "completed");
|
|
308
291
|
}
|
|
309
292
|
catch (error) {
|
|
310
293
|
result.status = "failed";
|
|
311
|
-
result.error = { code: "MERGE_FAILED", message: error instanceof Error ? error.message : String(error) };
|
|
294
|
+
result.error = { code: error instanceof WorkspaceCheckpointError ? "CHECKPOINT_FAILED" : "MERGE_FAILED", message: error instanceof Error ? error.message : String(error) };
|
|
295
|
+
fail(result.error, error instanceof WorkspaceCheckpointError);
|
|
312
296
|
}
|
|
313
297
|
}
|
|
298
|
+
try {
|
|
299
|
+
await workspaces.seal(invocation);
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
result.status = "failed";
|
|
303
|
+
result.error = { code: "CHECKPOINT_FAILED", message: error instanceof Error ? error.message : String(error) };
|
|
304
|
+
fail(result.error, true);
|
|
305
|
+
}
|
|
314
306
|
if (decision !== undefined)
|
|
315
307
|
result.decision = decision;
|
|
316
308
|
clearTimeout(timer);
|
|
@@ -330,203 +322,380 @@ async function runNode(request, result, runner, timeoutMs, graphSignal, graphDea
|
|
|
330
322
|
: { type: "node_completed", ...details, output: result.output });
|
|
331
323
|
}
|
|
332
324
|
}
|
|
333
|
-
|
|
325
|
+
const settled = (record) => ["completed", "failed", "skipped"].includes(record.status);
|
|
326
|
+
const edgeKey = (edge) => JSON.stringify([edge.from, edge.to, edge.choice, edge.feedback, edge.executionId]);
|
|
327
|
+
/** Execute a graph to completion. Use startBraid for live edits and pause/resume. */
|
|
334
328
|
export async function braid(input, options) {
|
|
329
|
+
return startBraid(input, options).result;
|
|
330
|
+
}
|
|
331
|
+
/** Definitions are mutable; each admitted execution captures its definition and inputs. */
|
|
332
|
+
export function startBraid(input, options) {
|
|
335
333
|
validateOptions(options);
|
|
336
|
-
|
|
337
|
-
|
|
334
|
+
let definition = structuredClone(input);
|
|
335
|
+
let graph = compileGraph(definition);
|
|
336
|
+
if (graph.edges.some(edge => edge.executionId))
|
|
337
|
+
throw new TypeError("Initial edges cannot reference historical executions");
|
|
338
338
|
const { runner, defaultModel, signal } = options;
|
|
339
|
-
const log = createEventLog(options.onEvent);
|
|
340
339
|
const maxConcurrency = options.maxConcurrency ?? 4;
|
|
340
|
+
const maxExecutions = options.maxExecutions ?? 1000;
|
|
341
341
|
const nodeTimeoutMs = options.nodeTimeoutMs ?? 60_000;
|
|
342
342
|
const graphTimeoutMs = options.graphTimeoutMs ?? 300_000;
|
|
343
343
|
const startedAt = Date.now();
|
|
344
344
|
const started = performance.now();
|
|
345
345
|
const graphDeadline = started + graphTimeoutMs;
|
|
346
346
|
const runId = crypto.randomUUID();
|
|
347
|
-
const
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
});
|
|
354
|
-
for (const node of graph.nodes) {
|
|
355
|
-
const model = node.model ?? defaultModel;
|
|
356
|
-
log.emit({
|
|
357
|
-
type: "node_created",
|
|
358
|
-
nodeId: node.id,
|
|
359
|
-
nodeType: node.type,
|
|
360
|
-
...(model !== undefined ? { model } : {}),
|
|
361
|
-
});
|
|
362
|
-
}
|
|
363
|
-
for (const edge of edges) {
|
|
364
|
-
log.emit({
|
|
365
|
-
type: "edge_created",
|
|
366
|
-
from: edge.from,
|
|
367
|
-
to: edge.to,
|
|
368
|
-
...(edge.choice !== undefined ? { choice: edge.choice } : {}),
|
|
369
|
-
});
|
|
370
|
-
}
|
|
371
|
-
const results = new Map(graph.nodes.map((node) => {
|
|
372
|
-
const result = { id: node.id, status: "pending" };
|
|
373
|
-
const model = node.model ?? defaultModel;
|
|
374
|
-
if (model !== undefined)
|
|
375
|
-
result.model = model;
|
|
376
|
-
return [node.id, result];
|
|
377
|
-
}));
|
|
378
|
-
const workspaces = new GitWorkspaces(resolve(options.cwd ?? process.cwd()), workspace => {
|
|
379
|
-
if (workspace.mode === "read-only")
|
|
380
|
-
return;
|
|
381
|
-
const node = results.get(workspace.nodeId);
|
|
382
|
-
if (node)
|
|
383
|
-
node.workspace = { ...workspace };
|
|
384
|
-
log.emit({ type: "workspace_updated", workspace: { ...workspace } });
|
|
385
|
-
});
|
|
347
|
+
const log = createEventLog(options.onEvent);
|
|
348
|
+
const executions = new Map();
|
|
349
|
+
const latest = new Map();
|
|
350
|
+
const roots = new Map();
|
|
351
|
+
const loops = new Map();
|
|
352
|
+
const paused = new Set();
|
|
386
353
|
const running = new Map();
|
|
387
|
-
let automaticMergeAdded = false;
|
|
388
|
-
let cleanupError;
|
|
389
354
|
const controller = new AbortController();
|
|
355
|
+
let revision = 0;
|
|
356
|
+
let status = "running";
|
|
357
|
+
let finalNodes;
|
|
358
|
+
let wake;
|
|
359
|
+
let changed;
|
|
360
|
+
function resetWake() { changed = new Promise(resolve => { wake = resolve; }); }
|
|
361
|
+
resetWake();
|
|
362
|
+
controller.signal.addEventListener("abort", () => wake());
|
|
390
363
|
const graphTimeout = new RunError("GRAPH_TIMEOUT", `Graph exceeded ${graphTimeoutMs}ms`);
|
|
391
|
-
const
|
|
392
|
-
|
|
364
|
+
const cancel = () => {
|
|
365
|
+
if (status !== "completed" && status !== "failed")
|
|
366
|
+
controller.abort(new RunError("CANCELLED", "Graph cancelled by caller"));
|
|
367
|
+
};
|
|
368
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
393
369
|
if (signal?.aborted)
|
|
394
|
-
|
|
395
|
-
const timer = Number.isFinite(graphTimeoutMs)
|
|
396
|
-
|
|
397
|
-
:
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
370
|
+
cancel();
|
|
371
|
+
const timer = Number.isFinite(graphTimeoutMs) ? setTimeout(() => controller.abort(graphTimeout), graphTimeoutMs) : undefined;
|
|
372
|
+
const context = (record) => ({ executionId: record.executionId, revision: record.revision,
|
|
373
|
+
...(record.loopId ? { loopId: record.loopId, iteration: record.iteration } : {}) });
|
|
374
|
+
const workspaces = new GitWorkspaces(resolve(options.cwd ?? process.cwd()), workspace => {
|
|
375
|
+
const record = executions.get(workspace.executionId);
|
|
376
|
+
if (record)
|
|
377
|
+
record.workspace = { ...workspace };
|
|
378
|
+
log.emit({ type: "workspace_updated", workspace, ...(record ? context(record) : {}) });
|
|
379
|
+
});
|
|
380
|
+
function snapshot() {
|
|
381
|
+
const nodes = Object.fromEntries(graph.nodes.map(node => [node.id, latest.get(node.id) ?? { id: node.id, status: "pending" }]));
|
|
382
|
+
for (const [id, record] of latest)
|
|
383
|
+
Object.defineProperty(nodes, id, { value: record, enumerable: true, writable: true, configurable: true });
|
|
384
|
+
return structuredClone({ runId, status, revision, graph: definition, nodes: finalNodes ?? nodes,
|
|
385
|
+
executions: Object.fromEntries(executions), pausedExecutionIds: [...paused],
|
|
386
|
+
...(controller.signal.aborted ? { error: describeError(controller.signal.reason) } : {}) });
|
|
387
|
+
}
|
|
388
|
+
function assertMutable(expectedRevision) {
|
|
389
|
+
if (status === "finalizing" || status === "completed" || status === "failed" || controller.signal.aborted)
|
|
390
|
+
throw new Error("This run is no longer accepting updates");
|
|
391
|
+
if (expectedRevision !== revision)
|
|
392
|
+
throw new Error(`Revision conflict: expected ${expectedRevision}, current ${revision}`);
|
|
393
|
+
}
|
|
394
|
+
function validateResume(ids) {
|
|
395
|
+
if (!Array.isArray(ids) || new Set(ids).size !== ids.length || ids.some(id => !paused.has(id)))
|
|
396
|
+
throw new Error("resume must contain unique paused execution IDs");
|
|
397
|
+
}
|
|
398
|
+
function release(ids) {
|
|
399
|
+
for (const id of ids)
|
|
400
|
+
paused.delete(id);
|
|
401
|
+
status = "running";
|
|
402
|
+
for (const id of ids) {
|
|
403
|
+
const record = executions.get(id);
|
|
404
|
+
log.emit({ type: "execution_resumed", nodeId: record.id, ...context(record) });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
function reconcileLoops(previousGraph) {
|
|
408
|
+
// Preserve the already admitted activation when an edit moves a definition
|
|
409
|
+
// between scopes. Keep removed members until their original round drains.
|
|
410
|
+
const previousRecords = new Map();
|
|
411
|
+
for (const node of previousGraph?.nodes ?? []) {
|
|
412
|
+
const scope = previousGraph.membership.get(node.id);
|
|
413
|
+
const record = scope ? loops.get(scope)?.records.get(node.id) : roots.get(node.id);
|
|
414
|
+
if (record)
|
|
415
|
+
previousRecords.set(node.id, record);
|
|
416
|
+
}
|
|
417
|
+
for (const [id, state] of loops) {
|
|
418
|
+
const current = graph.loops.get(id);
|
|
419
|
+
for (const [nodeId, record] of state.records) {
|
|
420
|
+
if (!current?.members.has(nodeId) && !graph.membership.has(nodeId))
|
|
421
|
+
roots.set(nodeId, record);
|
|
412
422
|
}
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
continue;
|
|
420
|
-
// A merge owns the source checkout and may remove predecessors. Let
|
|
421
|
-
// currently running consumers finish first, and admit no workers during it.
|
|
422
|
-
if ([...running.keys()].some(id => graph.nodes.find(value => value.id === id)?.type === "merge"))
|
|
423
|
-
break;
|
|
424
|
-
if (node.type === "merge" && running.size > 0)
|
|
425
|
-
continue;
|
|
426
|
-
const predecessors = predecessorOutputs(graph, node.id, results);
|
|
427
|
-
const request = {
|
|
428
|
-
goal: graph.goal,
|
|
429
|
-
node: structuredClone(node),
|
|
430
|
-
predecessors,
|
|
431
|
-
execution: { ...execution },
|
|
432
|
-
};
|
|
433
|
-
if (result.model !== undefined)
|
|
434
|
-
request.model = result.model;
|
|
435
|
-
const task = runNode(request, result, runner, nodeTimeoutMs, controller.signal, graphDeadline, log, workspaces).finally(() => {
|
|
436
|
-
running.delete(node.id);
|
|
437
|
-
});
|
|
438
|
-
running.set(node.id, task);
|
|
423
|
+
}
|
|
424
|
+
for (const [id, loop] of graph.loops) {
|
|
425
|
+
let state = loops.get(id);
|
|
426
|
+
if (!state) {
|
|
427
|
+
state = { iteration: 1, records: new Map(), done: false };
|
|
428
|
+
loops.set(id, state);
|
|
439
429
|
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
id += "_";
|
|
447
|
-
const node = { type: "merge", id, prompt: "Review every remaining node checkpoint, including partial work from failed nodes. Decide whether to integrate each change using git merge, cherry-pick, apply, or another available local operation. Preserve the user's existing changes. Call finish_merge to account for every source." };
|
|
448
|
-
graph.nodes.push(node);
|
|
449
|
-
graph.topologicalOrder.push(node);
|
|
450
|
-
graph.incoming.set(id, pending.map(from => ({ from, to: id })));
|
|
451
|
-
graph.outgoing.set(id, []);
|
|
452
|
-
for (const edge of graph.incoming.get(id)) {
|
|
453
|
-
graph.edges.push(edge);
|
|
454
|
-
graph.outgoing.get(edge.from).push(edge);
|
|
430
|
+
for (const nodeId of loop.members) {
|
|
431
|
+
if (!state.records.has(nodeId) && previousGraph?.membership.get(nodeId) !== id) {
|
|
432
|
+
const record = previousRecords.get(nodeId) ?? roots.get(nodeId);
|
|
433
|
+
if (record) {
|
|
434
|
+
state.records.set(nodeId, record);
|
|
435
|
+
roots.delete(nodeId);
|
|
455
436
|
}
|
|
456
|
-
results.set(id, { id, status: "pending", ...(defaultModel ? { model: defaultModel } : {}) });
|
|
457
|
-
log.emit({ type: "node_created", nodeId: id, nodeType: "merge", ...(defaultModel ? { model: defaultModel } : {}) });
|
|
458
|
-
for (const edge of graph.incoming.get(id))
|
|
459
|
-
log.emit({ type: "edge_created", ...edge });
|
|
460
|
-
continue;
|
|
461
437
|
}
|
|
462
|
-
break;
|
|
463
438
|
}
|
|
464
|
-
|
|
439
|
+
if ([...loop.members].some(nodeId => !state.records.has(nodeId)))
|
|
440
|
+
state.done = false;
|
|
465
441
|
}
|
|
466
442
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
443
|
+
reconcileLoops();
|
|
444
|
+
function update(patch) {
|
|
445
|
+
assertMutable(patch.expectedRevision);
|
|
446
|
+
const allowed = ["expectedRevision", "upsertNodes", "removeNodeIds", "addEdges", "removeEdges", "promptTemplates", "loops", "resume"];
|
|
447
|
+
if (Object.keys(patch).some(key => !allowed.includes(key)))
|
|
448
|
+
throw new TypeError("Unknown graph update field");
|
|
449
|
+
for (const key of ["upsertNodes", "removeNodeIds", "addEdges", "removeEdges", "loops", "resume"])
|
|
450
|
+
if (patch[key] !== undefined && !Array.isArray(patch[key]))
|
|
451
|
+
throw new TypeError(`${key} must be an array`);
|
|
452
|
+
if (patch.removeNodeIds?.some(id => typeof id !== "string" || !id.trim()))
|
|
453
|
+
throw new TypeError("removeNodeIds must contain non-empty node IDs");
|
|
454
|
+
for (const edge of patch.removeEdges ?? []) {
|
|
455
|
+
if (!edge || typeof edge !== "object" || !edge.from || !edge.to ||
|
|
456
|
+
Object.entries(edge).some(([key, value]) => !["from", "to", "choice", "feedback", "executionId"].includes(key) || typeof value !== "string" || !value.trim()))
|
|
457
|
+
throw new TypeError("removeEdges must contain complete edge identities");
|
|
472
458
|
}
|
|
473
|
-
|
|
474
|
-
|
|
459
|
+
validateResume(patch.resume ?? []);
|
|
460
|
+
const removed = new Set(patch.removeNodeIds ?? []);
|
|
461
|
+
const nodes = new Map(definition.nodes.filter(node => !removed.has(node.id)).map(node => [node.id, node]));
|
|
462
|
+
for (const node of patch.upsertNodes ?? [])
|
|
463
|
+
nodes.set(node.id, node);
|
|
464
|
+
const removedEdges = new Set((patch.removeEdges ?? []).map(edgeKey));
|
|
465
|
+
const edges = definition.edges.filter(edge => !removed.has(edge.from) && !removed.has(edge.to) && !removedEdges.has(edgeKey(edge)));
|
|
466
|
+
const candidate = structuredClone({ ...definition, nodes: [...nodes.values()], edges: [...edges, ...(patch.addEdges ?? [])],
|
|
467
|
+
...(patch.promptTemplates !== undefined ? { promptTemplates: patch.promptTemplates } : {}),
|
|
468
|
+
...(patch.loops !== undefined ? { loops: patch.loops } : {}) });
|
|
469
|
+
const compiled = compileGraph(candidate);
|
|
470
|
+
for (const edge of compiled.edges) {
|
|
471
|
+
if (!edge.executionId)
|
|
472
|
+
continue;
|
|
473
|
+
const record = executions.get(edge.executionId);
|
|
474
|
+
if (!record || record.id !== edge.from || !settled(record))
|
|
475
|
+
throw new TypeError(`Unknown or unfinished source execution '${edge.executionId}'`);
|
|
476
|
+
if (edge.choice && (record.node.type !== "decision" || !record.node.choices.includes(edge.choice)))
|
|
477
|
+
throw new TypeError(`Invalid choice for execution '${edge.executionId}'`);
|
|
475
478
|
}
|
|
476
|
-
|
|
477
|
-
|
|
479
|
+
const previousGraph = graph;
|
|
480
|
+
definition = candidate;
|
|
481
|
+
graph = compiled;
|
|
482
|
+
revision++;
|
|
483
|
+
reconcileLoops(previousGraph);
|
|
484
|
+
// Commit all state before publishing callbacks, which may themselves submit an update.
|
|
485
|
+
for (const id of patch.resume ?? [])
|
|
486
|
+
paused.delete(id);
|
|
487
|
+
status = "running";
|
|
488
|
+
log.emit({ type: "graph_updated", graph: definition, revision });
|
|
489
|
+
for (const [id, loop] of graph.loops)
|
|
490
|
+
if (!previousGraph.loops.has(id))
|
|
491
|
+
log.emit({ type: "loop_started", loopId: id, entry: loop.entry, iteration: loops.get(id).iteration, revision });
|
|
492
|
+
for (const id of patch.resume ?? []) {
|
|
493
|
+
const record = executions.get(id);
|
|
494
|
+
log.emit({ type: "execution_resumed", nodeId: record.id, ...context(record) });
|
|
478
495
|
}
|
|
479
|
-
|
|
480
|
-
|
|
496
|
+
wake();
|
|
497
|
+
return snapshot();
|
|
498
|
+
}
|
|
499
|
+
function frame(nodeId) {
|
|
500
|
+
const loopId = graph.membership.get(nodeId);
|
|
501
|
+
return loopId ? loops.get(loopId).records : roots;
|
|
502
|
+
}
|
|
503
|
+
function sourceFor(edge, target) {
|
|
504
|
+
if (edge.executionId)
|
|
505
|
+
return executions.get(edge.executionId);
|
|
506
|
+
const sourceLoop = graph.membership.get(edge.from);
|
|
507
|
+
if (sourceLoop && sourceLoop !== graph.membership.get(target) && !loops.get(sourceLoop).done)
|
|
508
|
+
return undefined;
|
|
509
|
+
return frame(edge.from).get(edge.from);
|
|
510
|
+
}
|
|
511
|
+
function dependencies(nodeId) {
|
|
512
|
+
const inputs = graph.incoming.get(nodeId).filter(edge => !edge.feedback).map(edge => ({ edge, record: sourceFor(edge, nodeId) }));
|
|
513
|
+
const states = inputs.map(({ edge, record }) => record && (running.has(record.executionId) || paused.has(record.executionId)) ? "unresolved" : edgeState(edge, record));
|
|
514
|
+
const predecessors = inputs.filter((_, index) => states[index] === "active").map(value => value.record);
|
|
515
|
+
const loopId = graph.membership.get(nodeId);
|
|
516
|
+
const loop = loopId ? graph.loops.get(loopId) : undefined;
|
|
517
|
+
const state = loopId ? loops.get(loopId) : undefined;
|
|
518
|
+
if (loop?.entry === nodeId && state?.previous) {
|
|
519
|
+
states.push("active");
|
|
520
|
+
predecessors.push(state.previous);
|
|
481
521
|
}
|
|
522
|
+
return { states, predecessors: [...new Map(predecessors.map(record => [record.executionId, record])).values()] };
|
|
482
523
|
}
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
.some((edge) => edgeState(edge, results) === "active"))
|
|
488
|
-
.map((node) => [node.id, nodeOutput(results.get(node.id))]));
|
|
489
|
-
const usage = { inputTokens: 0, outputTokens: 0 };
|
|
490
|
-
let usageReportedNodes = 0;
|
|
491
|
-
for (const result of results.values()) {
|
|
492
|
-
if (result.usage) {
|
|
493
|
-
usage.inputTokens += result.usage.inputTokens;
|
|
494
|
-
usage.outputTokens += result.usage.outputTokens;
|
|
495
|
-
usageReportedNodes++;
|
|
524
|
+
function admit(nodeId, predecessors, skipReason) {
|
|
525
|
+
if (executions.size >= maxExecutions) {
|
|
526
|
+
controller.abort(new RunError("EXECUTION_LIMIT", `Graph exceeded ${maxExecutions} executions`));
|
|
527
|
+
return;
|
|
496
528
|
}
|
|
529
|
+
const node = graph.nodes.find(node => node.id === nodeId);
|
|
530
|
+
const loopId = graph.membership.get(node.id);
|
|
531
|
+
const record = {
|
|
532
|
+
id: node.id, executionId: crypto.randomUUID(), node: structuredClone(node), revision,
|
|
533
|
+
predecessorExecutionIds: predecessors.map(value => value.executionId), status: skipReason ? "skipped" : "runnable",
|
|
534
|
+
...(loopId ? { loopId, iteration: loops.get(loopId).iteration } : {}),
|
|
535
|
+
...(node.model ?? defaultModel ? { model: node.model ?? defaultModel } : {}),
|
|
536
|
+
};
|
|
537
|
+
executions.set(record.executionId, record);
|
|
538
|
+
latest.set(node.id, record);
|
|
539
|
+
frame(node.id).set(node.id, record);
|
|
540
|
+
if (skipReason) {
|
|
541
|
+
record.skipReason = skipReason;
|
|
542
|
+
record.finishedAt = Date.now();
|
|
543
|
+
log.emit({ type: "node_skipped", nodeId: record.id, reason: skipReason, ...context(record) });
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
const scopedLog = { events: log.events, emit: event => log.emit({ ...event, ...context(record) }) };
|
|
547
|
+
const request = {
|
|
548
|
+
goal: graph.goal, node: structuredClone(record.node), execution: { runId, rootRunId: runId, ...context(record) },
|
|
549
|
+
predecessors: predecessors.map(value => ({ nodeId: value.id, executionId: value.executionId, ...nodeOutput(value),
|
|
550
|
+
...(value.error ? { error: { ...value.error } } : {}), ...(value.workspace ? { workspace: { ...value.workspace } } : {}) })),
|
|
551
|
+
...(record.model ? { model: record.model } : {}),
|
|
552
|
+
};
|
|
553
|
+
// Reserve the slot before publishing events or invoking asynchronous runner code.
|
|
554
|
+
const task = Promise.resolve().then(async () => {
|
|
555
|
+
scopedLog.emit({ type: "node_runnable", nodeId: record.id });
|
|
556
|
+
await runNode(request, record, runner, nodeTimeoutMs, controller.signal, graphDeadline, scopedLog, workspaces, (error, infrastructure) => {
|
|
557
|
+
if (infrastructure)
|
|
558
|
+
controller.abort(new RunError(error.code, error.message));
|
|
559
|
+
else if (record.node.requireSuccess)
|
|
560
|
+
controller.abort(new RunError("REQUIRED_NODE_FAILED", `Required node '${record.id}' (${record.executionId}) failed: ${error.message}`));
|
|
561
|
+
});
|
|
562
|
+
if (record.node.pauseAfter && !controller.signal.aborted) {
|
|
563
|
+
paused.add(record.executionId);
|
|
564
|
+
scopedLog.emit({ type: "execution_paused", nodeId: record.id });
|
|
565
|
+
}
|
|
566
|
+
}).catch(error => {
|
|
567
|
+
controller.abort(new RunError("SCHEDULING_ERROR", error instanceof Error ? error.message : String(error)));
|
|
568
|
+
}).finally(() => { running.delete(record.executionId); wake(); });
|
|
569
|
+
running.set(record.executionId, task);
|
|
497
570
|
}
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
571
|
+
function advanceLoops() {
|
|
572
|
+
for (const [id, loop] of graph.loops) {
|
|
573
|
+
const state = loops.get(id);
|
|
574
|
+
if (state.done || ![...loop.members].every(nodeId => state.records.has(nodeId)))
|
|
575
|
+
continue;
|
|
576
|
+
if ([...state.records.values()].some(record => !settled(record) || running.has(record.executionId) || paused.has(record.executionId)))
|
|
577
|
+
continue;
|
|
578
|
+
const decision = state.records.get(loop.feedback.from);
|
|
579
|
+
if (edgeState(loop.feedback, decision) === "active") {
|
|
580
|
+
if (state.iteration >= loop.maxIterations) {
|
|
581
|
+
controller.abort(new RunError("LOOP_LIMIT", `Loop '${id}' exceeded ${loop.maxIterations} iterations`));
|
|
582
|
+
return true;
|
|
583
|
+
}
|
|
584
|
+
state.iteration++;
|
|
585
|
+
state.previous = decision;
|
|
586
|
+
state.records = new Map();
|
|
587
|
+
log.emit({ type: "loop_started", loopId: id, iteration: state.iteration, entry: loop.entry, revision });
|
|
588
|
+
}
|
|
589
|
+
else {
|
|
590
|
+
state.done = true;
|
|
591
|
+
log.emit({ type: "loop_completed", loopId: id, iteration: state.iteration, entry: loop.entry, revision });
|
|
592
|
+
}
|
|
593
|
+
return true;
|
|
594
|
+
}
|
|
595
|
+
return false;
|
|
523
596
|
}
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
597
|
+
async function execute() {
|
|
598
|
+
let cleanupError;
|
|
599
|
+
try {
|
|
600
|
+
log.emit({ type: "graph_created", nodeCount: graph.nodes.length, edgeCount: graph.edges.length, revision });
|
|
601
|
+
for (const node of graph.nodes)
|
|
602
|
+
log.emit({ type: "node_created", nodeId: node.id, nodeType: node.type, ...(node.model ?? defaultModel ? { model: node.model ?? defaultModel } : {}) });
|
|
603
|
+
for (const edge of graph.edges)
|
|
604
|
+
log.emit({ type: "edge_created", ...edge });
|
|
605
|
+
await workspaces.initialize(runId);
|
|
606
|
+
for (const [id, loop] of graph.loops)
|
|
607
|
+
log.emit({ type: "loop_started", loopId: id, iteration: 1, entry: loop.entry, revision });
|
|
608
|
+
while (true) {
|
|
609
|
+
if (performance.now() >= graphDeadline)
|
|
610
|
+
controller.abort(graphTimeout);
|
|
611
|
+
if (controller.signal.aborted) {
|
|
612
|
+
await Promise.all(running.values());
|
|
613
|
+
break;
|
|
614
|
+
}
|
|
615
|
+
resetWake();
|
|
616
|
+
let progressed = advanceLoops();
|
|
617
|
+
const schedulingRevision = revision;
|
|
618
|
+
for (const node of graph.topologicalOrder) {
|
|
619
|
+
if (controller.signal.aborted || revision !== schedulingRevision)
|
|
620
|
+
break;
|
|
621
|
+
const loopId = graph.membership.get(node.id);
|
|
622
|
+
if (loopId && loops.get(loopId).done || frame(node.id).has(node.id))
|
|
623
|
+
continue;
|
|
624
|
+
const { states, predecessors } = dependencies(node.id);
|
|
625
|
+
if (states.includes("unresolved"))
|
|
626
|
+
continue;
|
|
627
|
+
const reason = states.includes("blocked") ? "upstream_failed" : states.length && !states.includes("active") ? "inactive" : undefined;
|
|
628
|
+
if (!reason && running.size >= maxConcurrency)
|
|
629
|
+
continue;
|
|
630
|
+
admit(node.id, predecessors, reason);
|
|
631
|
+
progressed = true;
|
|
632
|
+
}
|
|
633
|
+
if (controller.signal.aborted || progressed || schedulingRevision !== revision)
|
|
634
|
+
continue;
|
|
635
|
+
if (running.size || paused.size) {
|
|
636
|
+
status = running.size ? "running" : "waiting";
|
|
637
|
+
await changed;
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
const unfinished = graph.nodes.some(node => !frame(node.id).has(node.id));
|
|
641
|
+
if (unfinished)
|
|
642
|
+
controller.abort(new RunError("SCHEDULING_ERROR", "The current graph cannot make progress"));
|
|
643
|
+
break;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
catch (error) {
|
|
647
|
+
controller.abort(new RunError("SCHEDULING_ERROR", error instanceof Error ? error.message : String(error)));
|
|
648
|
+
await Promise.all(running.values());
|
|
649
|
+
}
|
|
650
|
+
finally {
|
|
651
|
+
status = "finalizing";
|
|
652
|
+
try {
|
|
653
|
+
await workspaces.archivePending("Execution checkpoint retained for inspection and future recovery");
|
|
654
|
+
}
|
|
655
|
+
catch (error) {
|
|
656
|
+
cleanupError = { code: "CLEANUP_FAILED", message: error instanceof Error ? error.message : String(error) };
|
|
657
|
+
}
|
|
658
|
+
try {
|
|
659
|
+
await workspaces.close();
|
|
660
|
+
}
|
|
661
|
+
catch (error) {
|
|
662
|
+
cleanupError ??= { code: "CLEANUP_FAILED", message: error instanceof Error ? error.message : String(error) };
|
|
663
|
+
}
|
|
664
|
+
clearTimeout(timer);
|
|
665
|
+
signal?.removeEventListener("abort", cancel);
|
|
666
|
+
}
|
|
667
|
+
const consumed = new Set([...executions.values()].flatMap(record => record.status === "skipped" ? [] : record.predecessorExecutionIds));
|
|
668
|
+
const terminals = [...executions.values()].filter(record => record.status === "completed" && !consumed.has(record.executionId));
|
|
669
|
+
const terminalOutputs = Object.fromEntries(terminals.map(record => [record.id, nodeOutput(record)]));
|
|
670
|
+
const usage = { inputTokens: 0, outputTokens: 0 };
|
|
671
|
+
let usageReportedNodes = 0;
|
|
672
|
+
for (const record of executions.values())
|
|
673
|
+
if (record.usage) {
|
|
674
|
+
usage.inputTokens += record.usage.inputTokens;
|
|
675
|
+
usage.outputTokens += record.usage.outputTokens;
|
|
676
|
+
usageReportedNodes++;
|
|
677
|
+
}
|
|
678
|
+
const error = cleanupError ?? (controller.signal.aborted ? describeError(controller.signal.reason) : undefined);
|
|
679
|
+
status = error ? "failed" : "completed";
|
|
680
|
+
const nodes = snapshot().nodes;
|
|
681
|
+
for (const node of Object.values(nodes))
|
|
682
|
+
if (node.status === "pending") {
|
|
683
|
+
node.status = "skipped";
|
|
684
|
+
node.skipReason = error?.code === "CANCELLED" ? "cancelled" : error?.code === "GRAPH_TIMEOUT" ? "graph_timeout" : "run_failed";
|
|
685
|
+
log.emit({ type: "node_skipped", nodeId: node.id, reason: node.skipReason });
|
|
686
|
+
}
|
|
687
|
+
finalNodes = structuredClone(nodes);
|
|
688
|
+
const result = {
|
|
689
|
+
status, terminalOutputs, nodes, executions: structuredClone(Object.fromEntries(executions)),
|
|
690
|
+
terminalExecutionIds: terminals.map(record => record.executionId), revision, events: [],
|
|
691
|
+
workspaces: workspaces.all(), ...(error ? { error } : {}),
|
|
692
|
+
metadata: { runId, rootRunId: runId, startedAt, finishedAt: Date.now(), latencyMs: performance.now() - started, usage, usageReportedNodes },
|
|
693
|
+
};
|
|
694
|
+
log.emit(error ? { type: "graph_failed", error, terminalNodeIds: Object.keys(terminalOutputs), revision }
|
|
695
|
+
: { type: "graph_completed", terminalNodeIds: Object.keys(terminalOutputs), revision });
|
|
696
|
+
result.events = Object.freeze(log.events);
|
|
697
|
+
return result;
|
|
529
698
|
}
|
|
530
|
-
result
|
|
531
|
-
|
|
699
|
+
return { runId, result: Promise.resolve().then(execute), snapshot, update, cancel,
|
|
700
|
+
resume(ids, expectedRevision) { assertMutable(expectedRevision); validateResume(ids); release(ids); wake(); return snapshot(); } };
|
|
532
701
|
}
|