@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/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, results) {
84
- const source = results.get(edge.from);
93
+ function edgeState(edge, source) {
94
+ if (!source)
95
+ return "unresolved";
85
96
  switch (source.status) {
86
- case "completed":
87
- return edge.choice === undefined || edge.choice === source.decision
88
- ? "active"
89
- : "inactive";
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.nodeId));
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
- /** Submit a complete DAG; core may append a final merge node for pending worktrees. */
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
- const graph = compileGraph(input);
337
- // Snapshot options as well as the graph before any asynchronous work.
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 execution = { runId, rootRunId: runId };
348
- const edges = graph.edges;
349
- log.emit({
350
- type: "graph_created",
351
- nodeCount: graph.nodes.length,
352
- edgeCount: edges.length,
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 onCancel = () => controller.abort(new RunError("CANCELLED", "Graph cancelled by caller"));
392
- signal?.addEventListener("abort", onCancel, { once: true });
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
- onCancel();
395
- const timer = Number.isFinite(graphTimeoutMs)
396
- ? setTimeout(() => controller.abort(graphTimeout), graphTimeoutMs)
397
- : undefined;
398
- try {
399
- while (true) {
400
- if (performance.now() >= graphDeadline)
401
- controller.abort(graphTimeout);
402
- if (controller.signal.aborted) {
403
- const reason = describeError(controller.signal.reason).code === "CANCELLED"
404
- ? "cancelled"
405
- : "graph_timeout";
406
- for (const result of results.values()) {
407
- if (result.status === "pending" || result.status === "runnable")
408
- skip(result, reason, log);
409
- }
410
- await Promise.all(running.values());
411
- break;
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
- resolveDependencies(graph, results, log);
414
- for (const node of graph.topologicalOrder) {
415
- if (running.size >= maxConcurrency)
416
- break;
417
- const result = results.get(node.id);
418
- if (result.status !== "runnable")
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
- if (running.size === 0) {
441
- const pending = workspaces.pending();
442
- if (!automaticMergeAdded && pending.length) {
443
- automaticMergeAdded = true;
444
- let id = "__braid_merge__";
445
- while (results.has(id))
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
- await Promise.race(running.values());
439
+ if ([...loop.members].some(nodeId => !state.records.has(nodeId)))
440
+ state.done = false;
465
441
  }
466
442
  }
467
- finally {
468
- clearTimeout(timer);
469
- signal?.removeEventListener("abort", onCancel);
470
- try {
471
- await workspaces.archivePending("Run ended before an agent integrated these changes; recover them from checkpointRef");
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
- catch (error) {
474
- cleanupError = { code: "CLEANUP_FAILED", message: error instanceof Error ? error.message : String(error) };
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
- try {
477
- await workspaces.close();
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
- catch (error) {
480
- cleanupError ??= { code: "CLEANUP_FAILED", message: error instanceof Error ? error.message : String(error) };
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
- const terminalOutputs = Object.fromEntries(graph.nodes
484
- .filter((node) => results.get(node.id).status === "completed" &&
485
- !graph.outgoing
486
- .get(node.id)
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
- const error = cleanupError ?? (controller.signal.aborted
499
- ? describeError(controller.signal.reason)
500
- : [...results.values()].find((result) => result.status === "failed")?.error);
501
- const result = {
502
- status: error ? "failed" : "completed",
503
- terminalOutputs,
504
- events: [],
505
- nodes: Object.fromEntries(results),
506
- ...(Object.values(workspaces.all()).some(value => value.mode !== "read-only") ? { workspaces: workspaces.all() } : {}),
507
- metadata: {
508
- ...execution,
509
- startedAt,
510
- finishedAt: Date.now(),
511
- latencyMs: performance.now() - started,
512
- usage,
513
- usageReportedNodes,
514
- },
515
- };
516
- if (error) {
517
- result.error = { ...error };
518
- log.emit({
519
- type: "graph_failed",
520
- error: { ...error },
521
- terminalNodeIds: Object.keys(terminalOutputs),
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
- else {
525
- log.emit({
526
- type: "graph_completed",
527
- terminalNodeIds: Object.keys(terminalOutputs),
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.events = Object.freeze(log.events);
531
- return result;
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
  }