@smithers-orchestrator/graph 0.16.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.
@@ -0,0 +1,796 @@
1
+ // @smithers-type-exports-begin
2
+ /** @typedef {import("../HostElement.ts").HostElement} HostElement */
3
+ /** @typedef {import("../HostText.ts").HostText} HostText */
4
+ // @smithers-type-exports-end
5
+
6
+ import { resolveStableId } from "@smithers-orchestrator/graph/utils/tree-ids";
7
+ import { isAbsolute, resolve as resolvePath } from "node:path";
8
+ import { getTableName } from "drizzle-orm";
9
+ import { DEFAULT_MERGE_QUEUE_CONCURRENCY, WORKTREE_EMPTY_PATH_ERROR, } from "@smithers-orchestrator/graph/constants";
10
+ import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
11
+
12
+ /** @typedef {import("../ExtractOptions.ts").ExtractOptions} ExtractOptions */
13
+ /** @typedef {import("../ExtractResult.ts").ExtractResult} ExtractResult */
14
+ /** @typedef {import("../HostNode.ts").HostNode} HostNode */
15
+ /** @typedef {import("../XmlNode.ts").XmlNode} XmlNode */
16
+
17
+ // TODO(migration): Delegate extractFromHost to
18
+ // @smithers-orchestrator/graph.extractGraph once core extraction reaches full
19
+ // legacy parity. Current blockers:
20
+ // - <Subflow> and <Sandbox> descriptors here attach runtime computeFn handlers
21
+ // that call executeChildWorkflow/executeSandbox; core extractGraph currently
22
+ // emits extraction metadata only.
23
+ // - Inline <Subflow> validation and some legacy descriptor-shape details still
24
+ // differ, so replacing this implementation would not produce identical output
25
+ // for all inputs.
26
+ const loadRuntimeModule = new Function("specifier", "return import(specifier)");
27
+ // CLI agents (Claude Code, Codex, etc.) can spend minutes reading files and
28
+ // thinking without producing stdout. 60s was too aggressive and caused
29
+ // spurious aborts on complex spec/research/plan generation tasks.
30
+ const DEFAULT_LOCAL_TASK_HEARTBEAT_TIMEOUT_MS = 300_000;
31
+ const DEFAULT_SANDBOX_TASK_HEARTBEAT_TIMEOUT_MS = 300_000;
32
+ /**
33
+ * @param {unknown} value
34
+ * @returns {boolean}
35
+ */
36
+ function isDrizzleTable(value) {
37
+ if (!value || typeof value !== "object")
38
+ return false;
39
+ try {
40
+ const name = getTableName(value);
41
+ return typeof name === "string" && name.length > 0;
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ }
47
+ /**
48
+ * @param {unknown} value
49
+ * @returns {boolean}
50
+ */
51
+ function isZodObject(value) {
52
+ return Boolean(value && typeof value === "object" && "shape" in value);
53
+ }
54
+ /**
55
+ * @param {Record<string, unknown>} raw
56
+ * @returns {number | null}
57
+ */
58
+ function parseHeartbeatTimeoutMs(raw) {
59
+ const candidate = typeof raw.heartbeatTimeoutMs === "number"
60
+ ? raw.heartbeatTimeoutMs
61
+ : typeof raw.heartbeatTimeout === "number"
62
+ ? raw.heartbeatTimeout
63
+ : null;
64
+ if (candidate == null || !Number.isFinite(candidate) || candidate <= 0) {
65
+ return null;
66
+ }
67
+ return Math.floor(candidate);
68
+ }
69
+ /**
70
+ * @param {HostNode} node
71
+ * @returns {XmlNode}
72
+ */
73
+ function toXmlNode(node) {
74
+ if (node.kind === "text") {
75
+ return { kind: "text", text: node.text };
76
+ }
77
+ const element = {
78
+ kind: "element",
79
+ tag: node.tag,
80
+ props: node.props ?? {},
81
+ children: node.children.map(toXmlNode),
82
+ };
83
+ return element;
84
+ }
85
+ /**
86
+ * @param {ExtractOptions | undefined} opts
87
+ * @param {string} id
88
+ * @returns {number}
89
+ */
90
+ function getRalphIteration(opts, id) {
91
+ const map = opts?.ralphIterations;
92
+ const fallback = typeof opts?.defaultIteration === "number" ? opts.defaultIteration : 0;
93
+ if (!map)
94
+ return fallback;
95
+ if (map instanceof Map) {
96
+ return map.get(id) ?? fallback;
97
+ }
98
+ const value = map[id];
99
+ return typeof value === "number" ? value : fallback;
100
+ }
101
+ /**
102
+ * @param {Record<string, unknown>} raw
103
+ */
104
+ function resolveRetryConfig(raw) {
105
+ const noRetry = Boolean(raw.noRetry);
106
+ const continueOnFail = Boolean(raw.continueOnFail);
107
+ const hasExplicitRetries = typeof raw.retries === "number" && !Number.isNaN(raw.retries);
108
+ const hasExplicitRetryPolicy = Boolean(raw.retryPolicy && typeof raw.retryPolicy === "object");
109
+ const defaultNoRetryForContinueOnFail = continueOnFail && !hasExplicitRetries && !hasExplicitRetryPolicy;
110
+ const retries = noRetry || defaultNoRetryForContinueOnFail
111
+ ? 0
112
+ : hasExplicitRetries
113
+ ? /** @type {number} */ (raw.retries)
114
+ : Infinity;
115
+ const retryPolicy = hasExplicitRetryPolicy
116
+ ? /** @type {import("../RetryPolicy.ts").RetryPolicy} */ (raw.retryPolicy)
117
+ : retries > 0
118
+ ? { backoff: "exponential", initialDelayMs: 1000 }
119
+ : undefined;
120
+ return { retries, retryPolicy };
121
+ }
122
+ /**
123
+ * @param {HostNode | null} root
124
+ * @param {ExtractOptions} [opts]
125
+ * @returns {ExtractResult}
126
+ */
127
+ export function extractFromHost(root, opts) {
128
+ if (!root) {
129
+ return { xml: null, tasks: [], mountedTaskIds: [] };
130
+ }
131
+ const tasks = [];
132
+ const mountedTaskIds = [];
133
+ const seen = new Set();
134
+ const seenRalph = new Set();
135
+ const seenWorktree = new Set();
136
+ const seenSaga = new Set();
137
+ const seenTcf = new Set();
138
+ let ordinal = 0;
139
+ /**
140
+ * @param {"parallel" | "merge-queue"} tag
141
+ * @param {any} raw
142
+ * @param {number[]} path
143
+ * @param {{ id: string; max?: number }[]} stack
144
+ */
145
+ function pushGroup(tag, raw, path, stack) {
146
+ const id = resolveStableId(raw?.id, tag, path);
147
+ // Coerce numeric strings (e.g. from MDX) in line with scheduler.parseNum
148
+ const n = Number(raw?.maxConcurrency);
149
+ const rawMax = Number.isFinite(n) ? Math.floor(n) : undefined;
150
+ // Concurrency semantics:
151
+ // - merge-queue: default to 1 and always clamp to >= 1
152
+ // - parallel: undefined => unlimited; <= 0 => unlimited; fractional floored
153
+ let max;
154
+ if (tag === "merge-queue") {
155
+ const base = rawMax ?? DEFAULT_MERGE_QUEUE_CONCURRENCY;
156
+ max = Math.max(1, base);
157
+ }
158
+ else {
159
+ if (rawMax == null) {
160
+ max = undefined;
161
+ }
162
+ else if (rawMax <= 0) {
163
+ max = undefined; // unbounded for non-positive values
164
+ }
165
+ else {
166
+ max = rawMax; // positive integer; fractional already floored
167
+ }
168
+ }
169
+ return [...stack, { id, max }];
170
+ }
171
+ /**
172
+ * @param {{ ralphId: string; iteration: number }[]} loopStack
173
+ * @returns {string}
174
+ */
175
+ function buildLoopScope(loopStack) {
176
+ if (loopStack.length === 0)
177
+ return "";
178
+ return ("@@" +
179
+ loopStack.map((l) => `${l.ralphId}=${l.iteration}`).join(","));
180
+ }
181
+ /**
182
+ * @param {HostNode} node
183
+ * @param {{ path: number[]; iteration: number; ralphId?: string; parentIsRalph: boolean; parallelStack: { id: string; max?: number }[]; worktreeStack: { id: string; path: string; branch?: string; baseBranch?: string }[]; loopStack: { ralphId: string; iteration: number }[]; }} ctx
184
+ */
185
+ function walk(node, ctx) {
186
+ if (node.kind === "text")
187
+ return;
188
+ let iteration = ctx.iteration;
189
+ const parallelStack = ctx.parallelStack;
190
+ let ralphId = ctx.ralphId;
191
+ const worktreeStack = ctx.worktreeStack;
192
+ let loopStack = ctx.loopStack;
193
+ if (node.tag === "smithers:ralph") {
194
+ if (ctx.parentIsRalph) {
195
+ throw new SmithersError("NESTED_LOOP", "Nested <Ralph> is not supported.");
196
+ }
197
+ const logicalId = resolveStableId(node.rawProps?.id, "ralph", ctx.path);
198
+ // Scope ralph ID by ancestor loop iterations for nested loops
199
+ const scope = buildLoopScope(loopStack);
200
+ const id = logicalId + scope;
201
+ if (seenRalph.has(id)) {
202
+ throw new SmithersError("DUPLICATE_ID", `Duplicate Ralph id detected: ${id}`, { kind: "ralph", id });
203
+ }
204
+ seenRalph.add(id);
205
+ ralphId = id;
206
+ iteration = getRalphIteration(opts, id);
207
+ // Push this loop onto the stack for children
208
+ loopStack = [...loopStack, { ralphId: logicalId, iteration }];
209
+ }
210
+ let nextParallelStack = parallelStack;
211
+ if (node.tag === "smithers:parallel") {
212
+ nextParallelStack = pushGroup("parallel", node.rawProps, ctx.path, parallelStack);
213
+ }
214
+ // Treat <MergeQueue> as a parallel-concurrency group with default 1
215
+ if (node.tag === "smithers:merge-queue") {
216
+ nextParallelStack = pushGroup("merge-queue", node.rawProps, ctx.path, nextParallelStack);
217
+ }
218
+ // Entering a Worktree node: push onto the worktree stack
219
+ let nextWorktreeStack = worktreeStack;
220
+ if (node.tag === "smithers:worktree") {
221
+ const id = resolveStableId(node.rawProps?.id, "worktree", ctx.path);
222
+ if (seenWorktree.has(id)) {
223
+ throw new SmithersError("DUPLICATE_ID", `Duplicate Worktree id detected: ${id}`, { kind: "worktree", id });
224
+ }
225
+ seenWorktree.add(id);
226
+ let pathVal = String(node.rawProps?.path ?? "").trim();
227
+ if (!pathVal) {
228
+ throw new SmithersError("WORKTREE_EMPTY_PATH", WORKTREE_EMPTY_PATH_ERROR);
229
+ }
230
+ const baseRoot = opts?.baseRootDir;
231
+ const base = typeof baseRoot === "string" && baseRoot.length > 0
232
+ ? baseRoot
233
+ : process.cwd();
234
+ const normPath = isAbsolute(pathVal)
235
+ ? resolvePath(pathVal)
236
+ : resolvePath(base, pathVal);
237
+ const branch = node.rawProps?.branch ? String(node.rawProps.branch) : undefined;
238
+ const baseBranch = node.rawProps?.baseBranch ? String(node.rawProps.baseBranch) : undefined;
239
+ nextWorktreeStack = [...worktreeStack, { id, path: normPath, branch, baseBranch }];
240
+ }
241
+ if (node.tag === "smithers:subflow") {
242
+ const raw = node.rawProps || {};
243
+ const logicalNodeId = raw.id;
244
+ if (!logicalNodeId || typeof logicalNodeId !== "string") {
245
+ throw new SmithersError("TASK_ID_REQUIRED", "Subflow id is required and must be a string.");
246
+ }
247
+ const ancestorScope = loopStack.length > 1
248
+ ? buildLoopScope(loopStack.slice(0, -1))
249
+ : "";
250
+ const nodeId = logicalNodeId + ancestorScope;
251
+ if (seen.has(nodeId)) {
252
+ throw new SmithersError("DUPLICATE_ID", `Duplicate Subflow id detected: ${nodeId}`, { kind: "subflow", id: nodeId });
253
+ }
254
+ seen.add(nodeId);
255
+ const outputRaw = raw.output;
256
+ if (!outputRaw) {
257
+ throw new SmithersError("TASK_MISSING_OUTPUT", `Subflow ${nodeId} is missing output.`, { nodeId });
258
+ }
259
+ const outputTable = isDrizzleTable(outputRaw) ? outputRaw : null;
260
+ const outputTableName = outputTable
261
+ ? getTableName(outputTable)
262
+ : typeof outputRaw === "string"
263
+ ? outputRaw
264
+ : "";
265
+ const outputRef = !outputTable && isZodObject(outputRaw) ? outputRaw : undefined;
266
+ const { retries, retryPolicy } = resolveRetryConfig(raw);
267
+ const timeoutMs = typeof raw.timeoutMs === "number" ? raw.timeoutMs : null;
268
+ const heartbeatTimeoutMs = parseHeartbeatTimeoutMs(raw);
269
+ const continueOnFail = Boolean(raw.continueOnFail);
270
+ const cachePolicy = raw.cache && typeof raw.cache === "object" ? raw.cache : undefined;
271
+ const dependsOn = Array.isArray(raw.dependsOn)
272
+ ? raw.dependsOn.filter((v) => typeof v === "string")
273
+ : undefined;
274
+ const needs = raw.needs && typeof raw.needs === "object" && !Array.isArray(raw.needs)
275
+ ? Object.fromEntries(Object.entries(raw.needs).filter(([, v]) => typeof v === "string"))
276
+ : undefined;
277
+ const mode = raw.__smithersSubflowMode ?? raw.mode ?? "childRun";
278
+ if (mode === "inline") {
279
+ // Inline mode is represented structurally by the subtree itself.
280
+ // No standalone task descriptor is created for the subflow node.
281
+ // Children are visited in the generic child traversal below.
282
+ }
283
+ else {
284
+ const parallelGroup = nextParallelStack[nextParallelStack.length - 1];
285
+ const topWorktree = nextWorktreeStack[nextWorktreeStack.length - 1];
286
+ const descriptor = {
287
+ nodeId,
288
+ ordinal: ordinal++,
289
+ iteration,
290
+ ralphId,
291
+ worktreeId: topWorktree?.id,
292
+ worktreePath: topWorktree?.path,
293
+ worktreeBranch: topWorktree?.branch,
294
+ worktreeBaseBranch: topWorktree?.baseBranch,
295
+ outputTable,
296
+ outputTableName,
297
+ outputRef,
298
+ outputSchema: undefined,
299
+ dependsOn,
300
+ needs,
301
+ needsApproval: false,
302
+ skipIf: Boolean(raw.skipIf),
303
+ retries,
304
+ retryPolicy,
305
+ timeoutMs,
306
+ heartbeatTimeoutMs,
307
+ continueOnFail,
308
+ cachePolicy,
309
+ agent: undefined,
310
+ prompt: undefined,
311
+ staticPayload: undefined,
312
+ computeFn: async () => {
313
+ const { executeChildWorkflow } = await loadRuntimeModule("@smithers-orchestrator/engine/child-workflow");
314
+ const result = await executeChildWorkflow(undefined, {
315
+ workflow: raw.__smithersSubflowWorkflow,
316
+ input: raw.__smithersSubflowInput,
317
+ rootDir: opts?.baseRootDir,
318
+ workflowPath: opts?.workflowPath ?? undefined,
319
+ });
320
+ if (result.status !== "finished") {
321
+ throw new SmithersError("WORKFLOW_EXECUTION_FAILED", `Subflow ${nodeId} failed with status ${result.status}.`, { nodeId, status: result.status });
322
+ }
323
+ return result.output;
324
+ },
325
+ label: raw.label,
326
+ meta: {
327
+ ...raw.meta,
328
+ __subflow: true,
329
+ __subflowMode: mode,
330
+ __subflowInput: raw.__smithersSubflowInput,
331
+ },
332
+ parallelGroupId: parallelGroup?.id,
333
+ parallelMaxConcurrency: parallelGroup?.max,
334
+ };
335
+ tasks.push(descriptor);
336
+ mountedTaskIds.push(`${nodeId}::${iteration}`);
337
+ }
338
+ }
339
+ if (node.tag === "smithers:sandbox") {
340
+ const raw = node.rawProps || {};
341
+ const logicalNodeId = raw.id;
342
+ if (!logicalNodeId || typeof logicalNodeId !== "string") {
343
+ throw new SmithersError("TASK_ID_REQUIRED", "Sandbox id is required and must be a string.");
344
+ }
345
+ const ancestorScope = loopStack.length > 1
346
+ ? buildLoopScope(loopStack.slice(0, -1))
347
+ : "";
348
+ const nodeId = logicalNodeId + ancestorScope;
349
+ if (seen.has(nodeId)) {
350
+ throw new SmithersError("DUPLICATE_ID", `Duplicate Sandbox id detected: ${nodeId}`, { kind: "sandbox", id: nodeId });
351
+ }
352
+ seen.add(nodeId);
353
+ const outputRaw = raw.output;
354
+ if (!outputRaw) {
355
+ throw new SmithersError("TASK_MISSING_OUTPUT", `Sandbox ${nodeId} is missing output.`, { nodeId });
356
+ }
357
+ const outputTable = isDrizzleTable(outputRaw) ? outputRaw : null;
358
+ const outputTableName = outputTable
359
+ ? getTableName(outputTable)
360
+ : typeof outputRaw === "string"
361
+ ? outputRaw
362
+ : "";
363
+ const outputRef = !outputTable && isZodObject(outputRaw) ? outputRaw : undefined;
364
+ const { retries, retryPolicy } = resolveRetryConfig(raw);
365
+ const timeoutMs = typeof raw.timeoutMs === "number" ? raw.timeoutMs : null;
366
+ const heartbeatTimeoutMs = parseHeartbeatTimeoutMs(raw) ?? DEFAULT_SANDBOX_TASK_HEARTBEAT_TIMEOUT_MS;
367
+ const continueOnFail = Boolean(raw.continueOnFail);
368
+ const cachePolicy = raw.cache && typeof raw.cache === "object" ? raw.cache : undefined;
369
+ const dependsOn = Array.isArray(raw.dependsOn)
370
+ ? raw.dependsOn.filter((v) => typeof v === "string")
371
+ : undefined;
372
+ const needs = raw.needs && typeof raw.needs === "object" && !Array.isArray(raw.needs)
373
+ ? Object.fromEntries(Object.entries(raw.needs).filter(([, v]) => typeof v === "string"))
374
+ : undefined;
375
+ const parallelGroup = nextParallelStack[nextParallelStack.length - 1];
376
+ const topWorktree = nextWorktreeStack[nextWorktreeStack.length - 1];
377
+ const runtime = raw.__smithersSandboxRuntime ?? raw.runtime ?? "bubblewrap";
378
+ const workflowDef = raw.__smithersSandboxWorkflow ??
379
+ raw.workflow;
380
+ const descriptor = {
381
+ nodeId,
382
+ ordinal: ordinal++,
383
+ iteration,
384
+ ralphId,
385
+ worktreeId: topWorktree?.id,
386
+ worktreePath: topWorktree?.path,
387
+ worktreeBranch: topWorktree?.branch,
388
+ worktreeBaseBranch: topWorktree?.baseBranch,
389
+ outputTable,
390
+ outputTableName,
391
+ outputRef,
392
+ outputSchema: undefined,
393
+ dependsOn,
394
+ needs,
395
+ needsApproval: false,
396
+ skipIf: Boolean(raw.skipIf),
397
+ retries,
398
+ retryPolicy,
399
+ timeoutMs,
400
+ heartbeatTimeoutMs,
401
+ continueOnFail,
402
+ cachePolicy,
403
+ agent: undefined,
404
+ prompt: undefined,
405
+ staticPayload: undefined,
406
+ computeFn: async () => {
407
+ const { executeSandbox } = await loadRuntimeModule("@smithers-orchestrator/sandbox/execute");
408
+ if (!workflowDef) {
409
+ throw new SmithersError("INVALID_INPUT", `Sandbox ${nodeId} is missing workflow definition.`, { nodeId });
410
+ }
411
+ return executeSandbox({
412
+ parentWorkflow: workflowDef && typeof workflowDef === "object" && "build" in workflowDef
413
+ ? workflowDef
414
+ : undefined,
415
+ sandboxId: nodeId,
416
+ runtime: runtime === "docker" || runtime === "codeplane" || runtime === "bubblewrap"
417
+ ? runtime
418
+ : "bubblewrap",
419
+ workflow: workflowDef,
420
+ input: raw.__smithersSandboxInput ?? raw.input,
421
+ rootDir: topWorktree?.path ?? process.cwd(),
422
+ allowNetwork: Boolean(raw.allowNetwork),
423
+ maxOutputBytes: 200_000,
424
+ toolTimeoutMs: 60_000,
425
+ reviewDiffs: raw.reviewDiffs,
426
+ autoAcceptDiffs: raw.autoAcceptDiffs,
427
+ config: {
428
+ image: raw.image,
429
+ env: raw.env,
430
+ ports: raw.ports,
431
+ volumes: raw.volumes,
432
+ memoryLimit: raw.memoryLimit,
433
+ cpuLimit: raw.cpuLimit,
434
+ command: raw.command,
435
+ workspace: raw.workspace,
436
+ },
437
+ });
438
+ },
439
+ label: raw.label,
440
+ meta: {
441
+ ...raw.meta,
442
+ __sandbox: true,
443
+ __sandboxRuntime: runtime,
444
+ __sandboxInput: raw.__smithersSandboxInput ?? raw.input,
445
+ },
446
+ parallelGroupId: parallelGroup?.id,
447
+ parallelMaxConcurrency: parallelGroup?.max,
448
+ };
449
+ tasks.push(descriptor);
450
+ mountedTaskIds.push(`${nodeId}::${iteration}`);
451
+ // Isolated subtree: the children execute inside the sandbox child run.
452
+ return;
453
+ }
454
+ if (node.tag === "smithers:wait-for-event") {
455
+ const raw = node.rawProps || {};
456
+ const logicalNodeId = raw.id;
457
+ if (!logicalNodeId || typeof logicalNodeId !== "string") {
458
+ throw new SmithersError("TASK_ID_REQUIRED", "WaitForEvent id is required and must be a string.");
459
+ }
460
+ const ancestorScope = loopStack.length > 1
461
+ ? buildLoopScope(loopStack.slice(0, -1))
462
+ : "";
463
+ const nodeId = logicalNodeId + ancestorScope;
464
+ if (seen.has(nodeId)) {
465
+ throw new SmithersError("DUPLICATE_ID", `Duplicate WaitForEvent id detected: ${nodeId}`, { kind: "wait-for-event", id: nodeId });
466
+ }
467
+ seen.add(nodeId);
468
+ const outputRaw = raw.output;
469
+ if (!outputRaw) {
470
+ throw new SmithersError("TASK_MISSING_OUTPUT", `WaitForEvent ${nodeId} is missing output.`, { nodeId });
471
+ }
472
+ const outputTable = isDrizzleTable(outputRaw) ? outputRaw : null;
473
+ const outputTableName = outputTable
474
+ ? getTableName(outputTable)
475
+ : typeof outputRaw === "string"
476
+ ? outputRaw
477
+ : "";
478
+ const outputRef = !outputTable && isZodObject(outputRaw) ? outputRaw : undefined;
479
+ const outputSchema = raw.outputSchema ?? outputRef;
480
+ const waitAsync = Boolean(raw.waitAsync);
481
+ const timeoutMs = typeof raw.timeoutMs === "number" ? raw.timeoutMs : null;
482
+ const heartbeatTimeoutMs = parseHeartbeatTimeoutMs(raw);
483
+ const dependsOn = Array.isArray(raw.dependsOn)
484
+ ? raw.dependsOn.filter((v) => typeof v === "string")
485
+ : undefined;
486
+ const needs = raw.needs && typeof raw.needs === "object" && !Array.isArray(raw.needs)
487
+ ? Object.fromEntries(Object.entries(raw.needs).filter(([, v]) => typeof v === "string"))
488
+ : undefined;
489
+ const onTimeout = raw.__smithersOnTimeout ?? raw.onTimeout ?? "fail";
490
+ const parallelGroup = nextParallelStack[nextParallelStack.length - 1];
491
+ const topWorktree = nextWorktreeStack[nextWorktreeStack.length - 1];
492
+ const descriptor = {
493
+ nodeId,
494
+ ordinal: ordinal++,
495
+ iteration,
496
+ ralphId,
497
+ worktreeId: topWorktree?.id,
498
+ worktreePath: topWorktree?.path,
499
+ worktreeBranch: topWorktree?.branch,
500
+ worktreeBaseBranch: topWorktree?.baseBranch,
501
+ outputTable,
502
+ outputTableName,
503
+ outputRef,
504
+ outputSchema,
505
+ dependsOn,
506
+ needs,
507
+ needsApproval: false,
508
+ waitAsync,
509
+ skipIf: Boolean(raw.skipIf),
510
+ retries: 0,
511
+ timeoutMs,
512
+ heartbeatTimeoutMs,
513
+ continueOnFail: onTimeout === "continue" || onTimeout === "skip",
514
+ agent: undefined,
515
+ prompt: undefined,
516
+ staticPayload: undefined,
517
+ computeFn: undefined,
518
+ label: raw.label,
519
+ meta: {
520
+ ...raw.meta,
521
+ __waitForEvent: true,
522
+ __eventName: raw.__smithersEventName ?? raw.event,
523
+ __correlationId: raw.__smithersCorrelationId ?? raw.correlationId,
524
+ __onTimeout: onTimeout,
525
+ },
526
+ parallelGroupId: parallelGroup?.id,
527
+ parallelMaxConcurrency: parallelGroup?.max,
528
+ };
529
+ tasks.push(descriptor);
530
+ mountedTaskIds.push(`${nodeId}::${iteration}`);
531
+ }
532
+ if (node.tag === "smithers:timer") {
533
+ const raw = node.rawProps || {};
534
+ const logicalNodeId = raw.id;
535
+ if (!logicalNodeId || typeof logicalNodeId !== "string") {
536
+ throw new SmithersError("TASK_ID_REQUIRED", "Timer id is required and must be a string.");
537
+ }
538
+ if (logicalNodeId.length > 256) {
539
+ throw new SmithersError("INVALID_INPUT", `Timer id must be 256 characters or fewer (received ${logicalNodeId.length}).`, { nodeId: logicalNodeId, maxLength: 256 });
540
+ }
541
+ const ancestorScope = loopStack.length > 1
542
+ ? buildLoopScope(loopStack.slice(0, -1))
543
+ : "";
544
+ const nodeId = logicalNodeId + ancestorScope;
545
+ if (seen.has(nodeId)) {
546
+ throw new SmithersError("DUPLICATE_ID", `Duplicate Timer id detected: ${nodeId}`, { kind: "timer", id: nodeId });
547
+ }
548
+ seen.add(nodeId);
549
+ const duration = typeof (raw.__smithersTimerDuration ?? raw.duration) === "string"
550
+ ? String(raw.__smithersTimerDuration ?? raw.duration).trim()
551
+ : "";
552
+ const untilRaw = raw.__smithersTimerUntil ?? raw.until;
553
+ const until = typeof untilRaw === "string"
554
+ ? untilRaw.trim()
555
+ : untilRaw instanceof Date
556
+ ? untilRaw.toISOString()
557
+ : "";
558
+ const hasDuration = duration.length > 0;
559
+ const hasUntil = until.length > 0;
560
+ if ((hasDuration ? 1 : 0) + (hasUntil ? 1 : 0) !== 1) {
561
+ throw new SmithersError("INVALID_INPUT", `Timer ${nodeId} must define exactly one of duration or until.`, { nodeId, duration: raw.duration, until: raw.until });
562
+ }
563
+ if (raw.every !== undefined) {
564
+ throw new SmithersError("INVALID_INPUT", `Timer ${nodeId} uses every=, but recurring timers are not supported yet.`, { nodeId, every: raw.every });
565
+ }
566
+ const dependsOn = Array.isArray(raw.dependsOn)
567
+ ? raw.dependsOn.filter((v) => typeof v === "string")
568
+ : undefined;
569
+ const needs = raw.needs && typeof raw.needs === "object" && !Array.isArray(raw.needs)
570
+ ? Object.fromEntries(Object.entries(raw.needs).filter(([, v]) => typeof v === "string"))
571
+ : undefined;
572
+ const parallelGroup = nextParallelStack[nextParallelStack.length - 1];
573
+ const topWorktree = nextWorktreeStack[nextWorktreeStack.length - 1];
574
+ const descriptor = {
575
+ nodeId,
576
+ ordinal: ordinal++,
577
+ iteration,
578
+ ralphId,
579
+ worktreeId: topWorktree?.id,
580
+ worktreePath: topWorktree?.path,
581
+ worktreeBranch: topWorktree?.branch,
582
+ worktreeBaseBranch: topWorktree?.baseBranch,
583
+ outputTable: null,
584
+ outputTableName: "",
585
+ outputRef: undefined,
586
+ outputSchema: undefined,
587
+ dependsOn,
588
+ needs,
589
+ needsApproval: false,
590
+ skipIf: Boolean(raw.skipIf),
591
+ retries: 0,
592
+ timeoutMs: null,
593
+ heartbeatTimeoutMs: null,
594
+ continueOnFail: false,
595
+ cachePolicy: undefined,
596
+ agent: undefined,
597
+ prompt: undefined,
598
+ staticPayload: undefined,
599
+ computeFn: undefined,
600
+ label: raw.label ?? `timer:${nodeId}`,
601
+ meta: {
602
+ ...raw.meta,
603
+ __timer: true,
604
+ __timerType: hasDuration ? "duration" : "absolute",
605
+ ...(hasDuration ? { __timerDuration: duration } : {}),
606
+ ...(hasUntil ? { __timerUntil: until } : {}),
607
+ },
608
+ parallelGroupId: parallelGroup?.id,
609
+ parallelMaxConcurrency: parallelGroup?.max,
610
+ };
611
+ tasks.push(descriptor);
612
+ mountedTaskIds.push(`${nodeId}::${iteration}`);
613
+ }
614
+ // Track Saga nodes for duplicate detection
615
+ if (node.tag === "smithers:saga") {
616
+ const id = resolveStableId(node.rawProps?.id, "saga", ctx.path);
617
+ if (seenSaga.has(id)) {
618
+ throw new SmithersError("DUPLICATE_ID", `Duplicate Saga id detected: ${id}`, { kind: "saga", id });
619
+ }
620
+ seenSaga.add(id);
621
+ }
622
+ // Track TryCatchFinally nodes for duplicate detection
623
+ if (node.tag === "smithers:try-catch-finally") {
624
+ const id = resolveStableId(node.rawProps?.id, "tcf", ctx.path);
625
+ if (seenTcf.has(id)) {
626
+ throw new SmithersError("DUPLICATE_ID", `Duplicate TryCatchFinally id detected: ${id}`, { kind: "try-catch-finally", id });
627
+ }
628
+ seenTcf.add(id);
629
+ }
630
+ if (node.tag === "smithers:task") {
631
+ const raw = node.rawProps || {};
632
+ const logicalNodeId = raw.id;
633
+ if (!logicalNodeId || typeof logicalNodeId !== "string") {
634
+ throw new SmithersError("TASK_ID_REQUIRED", "Task id is required and must be a string.");
635
+ }
636
+ // Scope task nodeId by ancestor loops (all except the innermost, which
637
+ // is already captured by desc.iteration).
638
+ const ancestorScope = loopStack.length > 1
639
+ ? buildLoopScope(loopStack.slice(0, -1))
640
+ : "";
641
+ const nodeId = logicalNodeId + ancestorScope;
642
+ if (seen.has(nodeId)) {
643
+ throw new SmithersError("DUPLICATE_ID", `Duplicate Task id detected: ${nodeId}`, { kind: "task", id: nodeId });
644
+ }
645
+ seen.add(nodeId);
646
+ const outputRaw = raw.output;
647
+ if (!outputRaw) {
648
+ throw new SmithersError("TASK_MISSING_OUTPUT", `Task ${nodeId} is missing output.`, { nodeId });
649
+ }
650
+ const outputTable = isDrizzleTable(outputRaw) ? outputRaw : null;
651
+ const outputTableName = outputTable
652
+ ? getTableName(outputTable)
653
+ : typeof outputRaw === "string"
654
+ ? outputRaw
655
+ : "";
656
+ const outputRef = !outputTable && isZodObject(outputRaw) ? outputRaw : undefined;
657
+ const outputSchema = raw.outputSchema ?? outputRef;
658
+ const needsApproval = Boolean(raw.needsApproval);
659
+ const waitAsync = Boolean(raw.waitAsync);
660
+ const approvalMode = raw.approvalMode === "decision" ||
661
+ raw.approvalMode === "select" ||
662
+ raw.approvalMode === "rank"
663
+ ? raw.approvalMode
664
+ : "gate";
665
+ const approvalOnDeny = raw.approvalOnDeny === "continue" ||
666
+ raw.approvalOnDeny === "skip" ||
667
+ raw.approvalOnDeny === "fail"
668
+ ? raw.approvalOnDeny
669
+ : undefined;
670
+ const approvalOptions = Array.isArray(raw.approvalOptions)
671
+ ? raw.approvalOptions
672
+ .filter((value) => Boolean(value && typeof value === "object" && !Array.isArray(value)))
673
+ .map((value) => ({
674
+ key: typeof value.key === "string" ? value.key : "",
675
+ label: typeof value.label === "string" ? value.label : "",
676
+ ...(typeof value.summary === "string" ? { summary: value.summary } : {}),
677
+ ...(value.metadata && typeof value.metadata === "object" && !Array.isArray(value.metadata)
678
+ ? { metadata: value.metadata }
679
+ : {}),
680
+ }))
681
+ .filter((value) => value.key && value.label)
682
+ : undefined;
683
+ const approvalAllowedScopes = Array.isArray(raw.approvalAllowedScopes)
684
+ ? raw.approvalAllowedScopes.filter((value) => typeof value === "string")
685
+ : undefined;
686
+ const approvalAllowedUsers = Array.isArray(raw.approvalAllowedUsers)
687
+ ? raw.approvalAllowedUsers.filter((value) => typeof value === "string")
688
+ : undefined;
689
+ const approvalAutoApprove = raw.approvalAutoApprove && typeof raw.approvalAutoApprove === "object" && !Array.isArray(raw.approvalAutoApprove)
690
+ ? {
691
+ ...(typeof raw.approvalAutoApprove.after === "number"
692
+ ? { after: raw.approvalAutoApprove.after }
693
+ : {}),
694
+ ...(typeof raw.approvalAutoApprove.audit === "boolean"
695
+ ? { audit: raw.approvalAutoApprove.audit }
696
+ : {}),
697
+ ...(typeof raw.approvalAutoApprove.conditionMet === "boolean"
698
+ ? { conditionMet: raw.approvalAutoApprove.conditionMet }
699
+ : {}),
700
+ ...(typeof raw.approvalAutoApprove.revertOnMet === "boolean"
701
+ ? { revertOnMet: raw.approvalAutoApprove.revertOnMet }
702
+ : {}),
703
+ }
704
+ : undefined;
705
+ const skipIf = Boolean(raw.skipIf);
706
+ const { retries, retryPolicy } = resolveRetryConfig(raw);
707
+ const timeoutMs = typeof raw.timeoutMs === "number" ? raw.timeoutMs : null;
708
+ const parsedHeartbeatTimeoutMs = parseHeartbeatTimeoutMs(raw);
709
+ const continueOnFail = Boolean(raw.continueOnFail);
710
+ const cachePolicy = raw.cache && typeof raw.cache === "object" ? raw.cache : undefined;
711
+ const agent = raw.agent;
712
+ const kind = raw.__smithersKind;
713
+ const isAgent = kind === "agent" || Boolean(agent);
714
+ const heartbeatTimeoutMs = parsedHeartbeatTimeoutMs ??
715
+ (isAgent ? DEFAULT_LOCAL_TASK_HEARTBEAT_TIMEOUT_MS : null);
716
+ const prompt = isAgent ? String(raw.children ?? "") : undefined;
717
+ if (prompt === "[object Object]") {
718
+ throw new SmithersError("MDX_PRELOAD_INACTIVE", `Task "${raw.id ?? nodeId}" prompt resolved to [object Object] — MDX preload is likely not active.\n` +
719
+ `Check that bunfig.toml has a top-level preload (not under [run]) and mdxPlugin() is registered.`);
720
+ }
721
+ const isCompute = kind === "compute" && typeof raw.__smithersComputeFn === "function";
722
+ const computeFn = isCompute ? raw.__smithersComputeFn : undefined;
723
+ const staticPayload = isAgent || isCompute
724
+ ? undefined
725
+ : (raw.__smithersPayload ?? raw.__payload ?? raw.children);
726
+ const dependsOn = Array.isArray(raw.dependsOn)
727
+ ? raw.dependsOn.filter((value) => typeof value === "string")
728
+ : undefined;
729
+ const needs = raw.needs && typeof raw.needs === "object" && !Array.isArray(raw.needs)
730
+ ? Object.fromEntries(Object.entries(raw.needs).filter(([, value]) => typeof value === "string"))
731
+ : undefined;
732
+ const parallelGroup = nextParallelStack[nextParallelStack.length - 1];
733
+ const topWorktree = nextWorktreeStack[nextWorktreeStack.length - 1];
734
+ const descriptor = {
735
+ nodeId,
736
+ ordinal: ordinal++,
737
+ iteration,
738
+ ralphId,
739
+ worktreeId: topWorktree?.id,
740
+ worktreePath: topWorktree?.path,
741
+ worktreeBranch: topWorktree?.branch,
742
+ worktreeBaseBranch: topWorktree?.baseBranch,
743
+ outputTable,
744
+ outputTableName,
745
+ outputRef,
746
+ outputSchema,
747
+ dependsOn,
748
+ needs,
749
+ needsApproval,
750
+ waitAsync,
751
+ approvalMode,
752
+ approvalOnDeny,
753
+ approvalOptions,
754
+ approvalAllowedScopes,
755
+ approvalAllowedUsers,
756
+ approvalAutoApprove,
757
+ skipIf,
758
+ retries,
759
+ retryPolicy,
760
+ timeoutMs,
761
+ heartbeatTimeoutMs,
762
+ continueOnFail,
763
+ cachePolicy,
764
+ agent,
765
+ prompt,
766
+ staticPayload,
767
+ computeFn,
768
+ label: raw.label,
769
+ meta: raw.meta,
770
+ scorers: raw.scorers,
771
+ parallelGroupId: parallelGroup?.id,
772
+ parallelMaxConcurrency: parallelGroup?.max,
773
+ memoryConfig: raw.memory ?? undefined,
774
+ };
775
+ // Worktree path is captured in typed fields (worktreeId/worktreePath) and
776
+ // consumed by the engine; avoid attaching untyped ad-hoc properties.
777
+ tasks.push(descriptor);
778
+ mountedTaskIds.push(`${nodeId}::${iteration}`);
779
+ }
780
+ let elementIndex = 0;
781
+ for (const child of node.children) {
782
+ const nextPath = child.kind === "element" ? [...ctx.path, elementIndex++] : ctx.path;
783
+ walk(child, {
784
+ path: nextPath,
785
+ iteration,
786
+ ralphId,
787
+ parentIsRalph: node.tag === "smithers:ralph",
788
+ parallelStack: nextParallelStack,
789
+ worktreeStack: nextWorktreeStack,
790
+ loopStack,
791
+ });
792
+ }
793
+ }
794
+ walk(root, { path: [], iteration: 0, parentIsRalph: false, parallelStack: [], worktreeStack: [], loopStack: [] });
795
+ return { xml: toXmlNode(root), tasks, mountedTaskIds };
796
+ }