@chrok/braid 0.1.3 → 0.2.1
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 +172 -20
- package/CONTRIBUTING.md +9 -0
- package/README.md +232 -191
- package/ROADMAP.md +77 -27
- package/SECURITY.md +30 -4
- 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 +392 -242
- package/dist/types.d.ts +139 -20
- package/dist/validate.d.ts +7 -1
- package/dist/validate.js +140 -15
- package/dist/workspaces.d.ts +14 -4
- package/dist/workspaces.js +161 -116
- package/docs/benchmark.md +3 -0
- package/docs/compatibility.md +47 -11
- package/docs/examples.md +2 -1
- package/docs/execution-control.md +160 -0
- package/docs/releasing.md +66 -1
- package/docs/repository-settings.md +34 -0
- package/docs/resource-limits.md +13 -5
- package/examples/execution-control.ts +38 -0
- package/examples/failure-handling.ts +2 -2
- package/package.json +11 -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,7 @@ 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;
|
|
182
148
|
let writableWorkspace = false;
|
|
183
149
|
const writes = new Set();
|
|
184
150
|
let merge;
|
|
@@ -243,8 +209,8 @@ async function runNode(request, result, runner, timeoutMs, graphSignal, graphDea
|
|
|
243
209
|
}
|
|
244
210
|
try {
|
|
245
211
|
checkDeadline();
|
|
246
|
-
if (request.node.type === "merge") {
|
|
247
|
-
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));
|
|
248
214
|
invocation.merge = { sources: structuredClone(merge.sources),
|
|
249
215
|
...(merge.sourceStatus ? { sourceStatus: structuredClone(merge.sourceStatus) } : {}), finish: merge.finish };
|
|
250
216
|
}
|
|
@@ -272,6 +238,7 @@ async function runNode(request, result, runner, timeoutMs, graphSignal, graphDea
|
|
|
272
238
|
log.emit({
|
|
273
239
|
type: "handoff",
|
|
274
240
|
from: predecessor.nodeId,
|
|
241
|
+
...(predecessor.executionId ? { fromExecutionId: predecessor.executionId } : {}),
|
|
275
242
|
to: result.id,
|
|
276
243
|
output: predecessor.output,
|
|
277
244
|
...(predecessor.decision !== undefined
|
|
@@ -280,6 +247,7 @@ async function runNode(request, result, runner, timeoutMs, graphSignal, graphDea
|
|
|
280
247
|
});
|
|
281
248
|
}
|
|
282
249
|
checkDeadline();
|
|
250
|
+
runnerStarted = true;
|
|
283
251
|
return runner(invocation);
|
|
284
252
|
}),
|
|
285
253
|
]);
|
|
@@ -301,20 +269,40 @@ async function runNode(request, result, runner, timeoutMs, graphSignal, graphDea
|
|
|
301
269
|
catch (error) {
|
|
302
270
|
result.status = "failed";
|
|
303
271
|
result.error = describeError(error);
|
|
272
|
+
fail(result.error, !runnerStarted && !(error instanceof WorkspaceInputError) && !signal.aborted);
|
|
304
273
|
}
|
|
305
274
|
finally {
|
|
306
275
|
acceptingDecisions = false;
|
|
307
276
|
acceptingWrites = false;
|
|
308
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
|
+
}
|
|
309
288
|
if (merge) {
|
|
310
289
|
try {
|
|
311
290
|
await merge.complete(result.status === "completed");
|
|
312
291
|
}
|
|
313
292
|
catch (error) {
|
|
314
293
|
result.status = "failed";
|
|
315
|
-
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);
|
|
316
296
|
}
|
|
317
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
|
+
}
|
|
318
306
|
if (decision !== undefined)
|
|
319
307
|
result.decision = decision;
|
|
320
308
|
clearTimeout(timer);
|
|
@@ -334,218 +322,380 @@ async function runNode(request, result, runner, timeoutMs, graphSignal, graphDea
|
|
|
334
322
|
: { type: "node_completed", ...details, output: result.output });
|
|
335
323
|
}
|
|
336
324
|
}
|
|
337
|
-
|
|
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. */
|
|
338
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) {
|
|
339
333
|
validateOptions(options);
|
|
340
|
-
|
|
341
|
-
|
|
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");
|
|
342
338
|
const { runner, defaultModel, signal } = options;
|
|
343
|
-
const log = createEventLog(options.onEvent);
|
|
344
339
|
const maxConcurrency = options.maxConcurrency ?? 4;
|
|
340
|
+
const maxExecutions = options.maxExecutions ?? 1000;
|
|
345
341
|
const nodeTimeoutMs = options.nodeTimeoutMs ?? 60_000;
|
|
346
342
|
const graphTimeoutMs = options.graphTimeoutMs ?? 300_000;
|
|
347
343
|
const startedAt = Date.now();
|
|
348
344
|
const started = performance.now();
|
|
349
345
|
const graphDeadline = started + graphTimeoutMs;
|
|
350
346
|
const runId = crypto.randomUUID();
|
|
351
|
-
const
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
});
|
|
358
|
-
for (const node of graph.nodes) {
|
|
359
|
-
const model = node.model ?? defaultModel;
|
|
360
|
-
log.emit({
|
|
361
|
-
type: "node_created",
|
|
362
|
-
nodeId: node.id,
|
|
363
|
-
nodeType: node.type,
|
|
364
|
-
...(model !== undefined ? { model } : {}),
|
|
365
|
-
});
|
|
366
|
-
}
|
|
367
|
-
for (const edge of edges) {
|
|
368
|
-
log.emit({
|
|
369
|
-
type: "edge_created",
|
|
370
|
-
from: edge.from,
|
|
371
|
-
to: edge.to,
|
|
372
|
-
...(edge.choice !== undefined ? { choice: edge.choice } : {}),
|
|
373
|
-
});
|
|
374
|
-
}
|
|
375
|
-
const results = new Map(graph.nodes.map((node) => {
|
|
376
|
-
const result = { id: node.id, status: "pending" };
|
|
377
|
-
const model = node.model ?? defaultModel;
|
|
378
|
-
if (model !== undefined)
|
|
379
|
-
result.model = model;
|
|
380
|
-
return [node.id, result];
|
|
381
|
-
}));
|
|
382
|
-
const explicitReadOnly = new Set(graph.nodes.filter(node => node.type !== "merge" && node.workspace === "read-only").map(node => node.id));
|
|
383
|
-
// Preserve the existing event/result shape for implicit non-Git read-only runs.
|
|
384
|
-
const reportWorkspace = (workspace) => workspace.mode !== "read-only" || explicitReadOnly.has(workspace.nodeId);
|
|
385
|
-
const workspaces = new GitWorkspaces(resolve(options.cwd ?? process.cwd()), workspace => {
|
|
386
|
-
if (!reportWorkspace(workspace))
|
|
387
|
-
return;
|
|
388
|
-
const node = results.get(workspace.nodeId);
|
|
389
|
-
if (node)
|
|
390
|
-
node.workspace = { ...workspace };
|
|
391
|
-
log.emit({ type: "workspace_updated", workspace: { ...workspace } });
|
|
392
|
-
});
|
|
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();
|
|
393
353
|
const running = new Map();
|
|
394
|
-
let automaticMergeAdded = false;
|
|
395
|
-
let cleanupError;
|
|
396
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());
|
|
397
363
|
const graphTimeout = new RunError("GRAPH_TIMEOUT", `Graph exceeded ${graphTimeoutMs}ms`);
|
|
398
|
-
const
|
|
399
|
-
|
|
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 });
|
|
400
369
|
if (signal?.aborted)
|
|
401
|
-
|
|
402
|
-
const timer = Number.isFinite(graphTimeoutMs)
|
|
403
|
-
|
|
404
|
-
:
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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);
|
|
419
422
|
}
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
continue;
|
|
427
|
-
// A merge owns the source checkout and may remove predecessors. Let
|
|
428
|
-
// currently running consumers finish first, and admit no workers during it.
|
|
429
|
-
if ([...running.keys()].some(id => graph.nodes.find(value => value.id === id)?.type === "merge"))
|
|
430
|
-
break;
|
|
431
|
-
if (node.type === "merge" && running.size > 0)
|
|
432
|
-
continue;
|
|
433
|
-
const predecessors = predecessorOutputs(graph, node.id, results);
|
|
434
|
-
const request = {
|
|
435
|
-
goal: graph.goal,
|
|
436
|
-
node: structuredClone(node),
|
|
437
|
-
predecessors,
|
|
438
|
-
execution: { ...execution },
|
|
439
|
-
};
|
|
440
|
-
if (result.model !== undefined)
|
|
441
|
-
request.model = result.model;
|
|
442
|
-
const task = runNode(request, result, runner, nodeTimeoutMs, controller.signal, graphDeadline, log, workspaces).finally(() => {
|
|
443
|
-
running.delete(node.id);
|
|
444
|
-
});
|
|
445
|
-
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);
|
|
446
429
|
}
|
|
447
|
-
|
|
448
|
-
if (!
|
|
449
|
-
|
|
450
|
-
|
|
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);
|
|
451
436
|
}
|
|
452
|
-
catch (error) {
|
|
453
|
-
cleanupError = { code: "CLEANUP_FAILED", message: error instanceof Error ? error.message : String(error) };
|
|
454
|
-
break;
|
|
455
|
-
}
|
|
456
|
-
// Checkpointing/cleanup can outlast the deadline or trigger cancellation.
|
|
457
|
-
if (controller.signal.aborted || performance.now() >= graphDeadline)
|
|
458
|
-
continue;
|
|
459
|
-
}
|
|
460
|
-
const pending = workspaces.pending();
|
|
461
|
-
if (!automaticMergeAdded && pending.length) {
|
|
462
|
-
automaticMergeAdded = true;
|
|
463
|
-
let id = "__braid_merge__";
|
|
464
|
-
while (results.has(id))
|
|
465
|
-
id += "_";
|
|
466
|
-
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." };
|
|
467
|
-
graph.nodes.push(node);
|
|
468
|
-
graph.topologicalOrder.push(node);
|
|
469
|
-
graph.incoming.set(id, pending.map(from => ({ from, to: id })));
|
|
470
|
-
graph.outgoing.set(id, []);
|
|
471
|
-
for (const edge of graph.incoming.get(id)) {
|
|
472
|
-
graph.edges.push(edge);
|
|
473
|
-
graph.outgoing.get(edge.from).push(edge);
|
|
474
|
-
}
|
|
475
|
-
results.set(id, { id, status: "pending", ...(defaultModel ? { model: defaultModel } : {}) });
|
|
476
|
-
log.emit({ type: "node_created", nodeId: id, nodeType: "merge", ...(defaultModel ? { model: defaultModel } : {}) });
|
|
477
|
-
for (const edge of graph.incoming.get(id))
|
|
478
|
-
log.emit({ type: "edge_created", ...edge });
|
|
479
|
-
continue;
|
|
480
437
|
}
|
|
481
|
-
break;
|
|
482
438
|
}
|
|
483
|
-
|
|
439
|
+
if ([...loop.members].some(nodeId => !state.records.has(nodeId)))
|
|
440
|
+
state.done = false;
|
|
484
441
|
}
|
|
485
442
|
}
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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");
|
|
491
458
|
}
|
|
492
|
-
|
|
493
|
-
|
|
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}'`);
|
|
494
478
|
}
|
|
495
|
-
|
|
496
|
-
|
|
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) });
|
|
497
495
|
}
|
|
498
|
-
|
|
499
|
-
|
|
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);
|
|
500
521
|
}
|
|
522
|
+
return { states, predecessors: [...new Map(predecessors.map(record => [record.executionId, record])).values()] };
|
|
501
523
|
}
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
.
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
524
|
+
function admit(nodeId, predecessors, skipReason) {
|
|
525
|
+
if (executions.size >= maxExecutions) {
|
|
526
|
+
controller.abort(new RunError("EXECUTION_LIMIT", `Graph exceeded ${maxExecutions} executions`));
|
|
527
|
+
return;
|
|
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;
|
|
515
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);
|
|
516
570
|
}
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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;
|
|
542
596
|
}
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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;
|
|
548
698
|
}
|
|
549
|
-
result
|
|
550
|
-
|
|
699
|
+
return { runId, result: Promise.resolve().then(execute), snapshot, update, cancel,
|
|
700
|
+
resume(ids, expectedRevision) { assertMutable(expectedRevision); validateResume(ids); release(ids); wake(); return snapshot(); } };
|
|
551
701
|
}
|