@smithers-orchestrator/graph 0.20.4 → 0.22.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smithers-orchestrator/graph",
3
- "version": "0.20.4",
3
+ "version": "0.22.0",
4
4
  "description": "Framework-neutral Smithers workflow graph model and extraction helpers",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -22,7 +22,7 @@
22
22
  "dependencies": {
23
23
  "drizzle-orm": "^0.45.2",
24
24
  "zod": "^4.3.6",
25
- "@smithers-orchestrator/errors": "0.20.4"
25
+ "@smithers-orchestrator/errors": "0.22.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/bun": "latest",
@@ -398,7 +398,8 @@ export function extractFromHost(root, opts) {
398
398
  : undefined;
399
399
  const parallelGroup = nextParallelStack[nextParallelStack.length - 1];
400
400
  const topWorktree = nextWorktreeStack[nextWorktreeStack.length - 1];
401
- const runtime = raw.__smithersSandboxRuntime ?? raw.runtime ?? "bubblewrap";
401
+ const runtime = raw.__smithersSandboxRuntime ?? raw.runtime;
402
+ const provider = raw.__smithersSandboxProvider ?? raw.provider;
402
403
  const workflowDef = raw.__smithersSandboxWorkflow ??
403
404
  raw.workflow;
404
405
  const descriptor = {
@@ -431,23 +432,27 @@ export function extractFromHost(root, opts) {
431
432
  const [
432
433
  { executeSandbox },
433
434
  { executeChildWorkflow },
435
+ { applyDiffBundle },
434
436
  ] = await Promise.all([
435
437
  loadRuntimeModule("@smithers-orchestrator/sandbox/execute"),
436
438
  loadRuntimeModule("@smithers-orchestrator/engine/child-workflow"),
439
+ loadRuntimeModule("@smithers-orchestrator/engine/effect/diff-bundle"),
437
440
  ]);
438
441
  if (!workflowDef) {
439
442
  throw new SmithersError("INVALID_INPUT", `Sandbox ${nodeId} is missing workflow definition.`, { nodeId });
440
443
  }
441
444
  return executeSandbox({
442
- parentWorkflow: workflowDef && typeof workflowDef === "object" && "build" in workflowDef
443
- ? workflowDef
444
- : undefined,
445
+ parentWorkflow: undefined,
445
446
  sandboxId: nodeId,
446
- runtime: runtime === "docker" || runtime === "codeplane" || runtime === "bubblewrap"
447
+ provider,
448
+ runtime: runtime === undefined || runtime === "docker" || runtime === "codeplane" || runtime === "bubblewrap"
447
449
  ? runtime
448
- : "bubblewrap",
450
+ : (() => {
451
+ throw new SmithersError("INVALID_INPUT", `Unsupported sandbox runtime: ${String(runtime)}`, { runtime });
452
+ })(),
449
453
  workflow: workflowDef,
450
454
  executeChildWorkflow,
455
+ applyDiffBundle,
451
456
  input: raw.__smithersSandboxInput ?? raw.input,
452
457
  rootDir: topWorktree?.path ?? process.cwd(),
453
458
  allowNetwork: Boolean(raw.allowNetwork),
@@ -455,6 +460,7 @@ export function extractFromHost(root, opts) {
455
460
  toolTimeoutMs: 60_000,
456
461
  reviewDiffs: raw.reviewDiffs,
457
462
  autoAcceptDiffs: raw.autoAcceptDiffs,
463
+ allowNested: Boolean(raw.__smithersSandboxAllowNested ?? raw.allowNested),
458
464
  config: {
459
465
  image: raw.image,
460
466
  env: raw.env,
@@ -472,7 +478,23 @@ export function extractFromHost(root, opts) {
472
478
  ...raw.meta,
473
479
  __sandbox: true,
474
480
  __sandboxRuntime: runtime,
481
+ __sandboxProvider: provider,
482
+ __sandboxWorkflow: workflowDef,
475
483
  __sandboxInput: raw.__smithersSandboxInput ?? raw.input,
484
+ __sandboxAllowNetwork: Boolean(raw.allowNetwork),
485
+ __sandboxReviewDiffs: raw.reviewDiffs,
486
+ __sandboxAutoAcceptDiffs: raw.autoAcceptDiffs,
487
+ __sandboxAllowNested: raw.__smithersSandboxAllowNested ?? raw.allowNested,
488
+ __sandboxConfig: {
489
+ image: raw.image,
490
+ env: raw.env,
491
+ ports: raw.ports,
492
+ volumes: raw.volumes,
493
+ memoryLimit: raw.memoryLimit,
494
+ cpuLimit: raw.cpuLimit,
495
+ command: raw.command,
496
+ workspace: raw.workspace,
497
+ },
476
498
  },
477
499
  parallelGroupId: parallelGroup?.id,
478
500
  parallelMaxConcurrency: parallelGroup?.max,
package/src/extract.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { isAbsolute, resolve as resolvePath } from "node:path";
2
+ import { getTableName } from "drizzle-orm";
2
3
  import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
4
+ import { validateForkSources } from "./validateForkSources.js";
3
5
  /** @typedef {import("./TaskDescriptor.ts").TaskDescriptor} TaskDescriptor */
4
6
  /** @typedef {import("./XmlNode.ts").XmlNode} XmlNode */
5
7
  /** @typedef {import("./ExtractOptions.ts").ExtractOptions} ExtractOptions */
@@ -58,23 +60,18 @@ function isZodObject(value) {
58
60
  }
59
61
  /**
60
62
  * @param {unknown} value
61
- * @returns {string | undefined}
63
+ * @returns {boolean}
62
64
  */
63
- function maybeTableName(value) {
65
+ function isDrizzleTable(value) {
64
66
  if (!value || typeof value !== "object")
65
- return undefined;
66
- const symbols = Object.getOwnPropertySymbols(value);
67
- for (const symbol of symbols) {
68
- const key = String(symbol);
69
- if (key.includes("drizzle") || key.includes("Name")) {
70
- const symbolValue = value[symbol];
71
- if (typeof symbolValue === "string" && symbolValue.length > 0) {
72
- return symbolValue;
73
- }
74
- }
67
+ return false;
68
+ try {
69
+ const name = getTableName(value);
70
+ return typeof name === "string" && name.length > 0;
71
+ }
72
+ catch {
73
+ return false;
75
74
  }
76
- const named = value.name;
77
- return typeof named === "string" && named.length > 0 ? named : undefined;
78
75
  }
79
76
  /**
80
77
  * @param {Record<string, unknown>} raw
@@ -90,13 +87,17 @@ function resolveOutput(raw) {
90
87
  outputSchema: undefined,
91
88
  };
92
89
  }
93
- const outputRef = isZodObject(outputRaw) ? outputRaw : undefined;
94
- const tableName = typeof outputRaw === "string" ? outputRaw : maybeTableName(outputRaw) ?? "";
95
- const outputTable = outputRef ? null : typeof outputRaw === "string" ? null : outputRaw;
90
+ const outputTable = isDrizzleTable(outputRaw) ? outputRaw : null;
91
+ const outputTableName = outputTable
92
+ ? getTableName(outputTable)
93
+ : typeof outputRaw === "string"
94
+ ? outputRaw
95
+ : "";
96
+ const outputRef = !outputTable && isZodObject(outputRaw) ? outputRaw : undefined;
96
97
  const outputSchema = isZodObject(raw.outputSchema) ? raw.outputSchema : outputRef;
97
98
  return {
98
99
  outputTable,
99
- outputTableName: tableName,
100
+ outputTableName,
100
101
  outputRef,
101
102
  outputSchema,
102
103
  };
@@ -308,13 +309,13 @@ export function extractGraph(root, opts) {
308
309
  const seenTcf = new Set();
309
310
  let ordinal = 0;
310
311
  /**
311
- * @param {Record<string, unknown>} raw
312
312
  * @param {string} nodeId
313
+ * @param {string} kind
313
314
  * @param {Omit<TaskDescriptor, "ordinal" | "nodeId">} descriptor
314
315
  */
315
- function addDescriptor(raw, nodeId, descriptor) {
316
+ function addDescriptor(nodeId, kind, descriptor) {
316
317
  if (seen.has(nodeId)) {
317
- throw new SmithersError("DUPLICATE_ID", `Duplicate ${String(raw.__smithersKind ?? "Task")} id detected: ${nodeId}`, { id: nodeId });
318
+ throw new SmithersError("DUPLICATE_ID", `Duplicate ${kind} id detected: ${nodeId}`, { id: nodeId });
318
319
  }
319
320
  seen.add(nodeId);
320
321
  tasks.push({ nodeId, ordinal: ordinal++, ...descriptor });
@@ -399,7 +400,7 @@ export function extractGraph(root, opts) {
399
400
  requireOutput(raw, nodeId, "Subflow");
400
401
  const { retries, retryPolicy } = resolveRetryConfig(raw);
401
402
  const output = resolveOutput(raw);
402
- addDescriptor(raw, nodeId, {
403
+ addDescriptor(nodeId, "Subflow", {
403
404
  ...common,
404
405
  ...output,
405
406
  needsApproval: false,
@@ -431,8 +432,8 @@ export function extractGraph(root, opts) {
431
432
  requireOutput(raw, nodeId, "Sandbox");
432
433
  const { retries, retryPolicy } = resolveRetryConfig(raw);
433
434
  const output = resolveOutput(raw);
434
- const runtime = raw.__smithersSandboxRuntime ?? raw.runtime ?? "bubblewrap";
435
- addDescriptor(raw, nodeId, {
435
+ const runtime = raw.__smithersSandboxRuntime ?? raw.runtime;
436
+ addDescriptor(nodeId, "Sandbox", {
436
437
  ...common,
437
438
  ...output,
438
439
  needsApproval: false,
@@ -452,7 +453,23 @@ export function extractGraph(root, opts) {
452
453
  : {}),
453
454
  __sandbox: true,
454
455
  __sandboxRuntime: runtime,
456
+ __sandboxProvider: raw.__smithersSandboxProvider ?? raw.provider,
457
+ __sandboxWorkflow: raw.__smithersSandboxWorkflow ?? raw.workflow,
455
458
  __sandboxInput: raw.__smithersSandboxInput ?? raw.input,
459
+ __sandboxAllowNetwork: Boolean(raw.allowNetwork),
460
+ __sandboxReviewDiffs: raw.reviewDiffs,
461
+ __sandboxAutoAcceptDiffs: raw.autoAcceptDiffs,
462
+ __sandboxAllowNested: raw.__smithersSandboxAllowNested ?? raw.allowNested,
463
+ __sandboxConfig: {
464
+ image: raw.image,
465
+ env: raw.env,
466
+ ports: raw.ports,
467
+ volumes: raw.volumes,
468
+ memoryLimit: raw.memoryLimit,
469
+ cpuLimit: raw.cpuLimit,
470
+ command: raw.command,
471
+ workspace: raw.workspace,
472
+ },
456
473
  },
457
474
  });
458
475
  return;
@@ -463,7 +480,7 @@ export function extractGraph(root, opts) {
463
480
  requireOutput(raw, nodeId, "WaitForEvent");
464
481
  const output = resolveOutput(raw);
465
482
  const onTimeout = raw.__smithersOnTimeout ?? raw.onTimeout ?? "fail";
466
- addDescriptor(raw, nodeId, {
483
+ addDescriptor(nodeId, "WaitForEvent", {
467
484
  ...common,
468
485
  ...output,
469
486
  needsApproval: false,
@@ -508,7 +525,7 @@ export function extractGraph(root, opts) {
508
525
  if (raw.every !== undefined) {
509
526
  throw new SmithersError("INVALID_INPUT", `Timer ${nodeId} uses every=, but recurring timers are not supported yet.`, { nodeId, every: raw.every });
510
527
  }
511
- addDescriptor(raw, nodeId, {
528
+ addDescriptor(nodeId, "Timer", {
512
529
  ...common,
513
530
  outputTable: null,
514
531
  outputTableName: "",
@@ -572,9 +589,10 @@ export function extractGraph(root, opts) {
572
589
  if (prompt === "[object Object]") {
573
590
  throw new SmithersError("MDX_PRELOAD_INACTIVE", `Task "${logicalNodeId}" prompt resolved to [object Object].`);
574
591
  }
575
- addDescriptor(raw, nodeId, {
592
+ addDescriptor(nodeId, "Task", {
576
593
  ...common,
577
594
  ...output,
595
+ forkSource: typeof raw.fork === "string" && raw.fork ? raw.fork : undefined,
578
596
  needsApproval: Boolean(raw.needsApproval),
579
597
  waitAsync: Boolean(raw.waitAsync),
580
598
  approvalMode,
@@ -636,6 +654,7 @@ export function extractGraph(root, opts) {
636
654
  worktreeStack: [],
637
655
  loopStack: [],
638
656
  });
657
+ validateForkSources(tasks);
639
658
  return { xml: toXmlNode(root), tasks, mountedTaskIds };
640
659
  }
641
660
  export const extractFromHost = extractGraph;
package/src/types.ts CHANGED
@@ -133,6 +133,8 @@ export type TaskDescriptor = {
133
133
  ralphId?: string;
134
134
  dependsOn?: string[];
135
135
  needs?: Record<string, string>;
136
+ /** Logical id of the task whose final agent session this task forks. Gates execution and seeds the session. */
137
+ forkSource?: string;
136
138
  worktreeId?: string;
137
139
  worktreePath?: string;
138
140
  worktreeBranch?: string;
@@ -0,0 +1,107 @@
1
+ import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
2
+
3
+ /** @typedef {import("./TaskDescriptor.ts").TaskDescriptor} TaskDescriptor */
4
+
5
+ /**
6
+ * Strip the loop-scope suffix (`@@ralph=0,...`) from a node id to recover the
7
+ * logical task id authored in JSX. `fork` and `dependsOn` reference logical ids.
8
+ * @param {string} nodeId
9
+ * @returns {string}
10
+ */
11
+ function logicalId(nodeId) {
12
+ const atIdx = nodeId.indexOf("@@");
13
+ return atIdx === -1 ? nodeId : nodeId.slice(0, atIdx);
14
+ }
15
+
16
+ /**
17
+ * Validate `<Task fork>` references across the extracted task list. Throws a
18
+ * typed SmithersError for fork sources that can never resolve to a usable
19
+ * session snapshot, so authoring mistakes fail fast at graph-build time rather
20
+ * than deadlocking the scheduler.
21
+ *
22
+ * Detects:
23
+ * - TASK_FORK_SOURCE_NOT_FOUND — fork id absent from the graph (covers a
24
+ * source that only exists in an unselected branch).
25
+ * - TASK_FORK_SESSION_UNAVAILABLE — the forking task is not an agent task and
26
+ * therefore has no session to seed.
27
+ * - TASK_FORK_CYCLE — the fork edge closes a dependency cycle, directly or
28
+ * indirectly (via `dependsOn` and/or other fork edges).
29
+ *
30
+ * Loop semantics are intentionally not validated here: `fork` resolves to the
31
+ * latest completed snapshot for a task id at execution time, so a source inside
32
+ * a loop is valid as long as its logical id appears in the graph.
33
+ *
34
+ * @param {readonly TaskDescriptor[]} tasks
35
+ * @returns {void}
36
+ */
37
+ export function validateForkSources(tasks) {
38
+ const forks = tasks.filter((task) => typeof task.forkSource === "string" && task.forkSource);
39
+ if (forks.length === 0) {
40
+ return;
41
+ }
42
+ const presentLogicalIds = new Set(tasks.map((task) => logicalId(task.nodeId)));
43
+ // Union of dependency edges keyed by logical id: dependsOn entries plus the
44
+ // fork edge. Used only for cycle detection.
45
+ /** @type {Map<string, Set<string>>} */
46
+ const adjacency = new Map();
47
+ const addEdge = (from, to) => {
48
+ const cleanedFrom = logicalId(from);
49
+ const cleanedTo = logicalId(to);
50
+ let set = adjacency.get(cleanedFrom);
51
+ if (!set) {
52
+ set = new Set();
53
+ adjacency.set(cleanedFrom, set);
54
+ }
55
+ set.add(cleanedTo);
56
+ };
57
+ for (const task of tasks) {
58
+ const from = logicalId(task.nodeId);
59
+ for (const dep of task.dependsOn ?? []) {
60
+ addEdge(from, dep);
61
+ }
62
+ if (typeof task.forkSource === "string" && task.forkSource) {
63
+ addEdge(from, task.forkSource);
64
+ }
65
+ }
66
+ /**
67
+ * @param {string} from
68
+ * @param {string} target
69
+ * @returns {boolean}
70
+ */
71
+ const canReach = (from, target) => {
72
+ const stack = [from];
73
+ const visited = new Set();
74
+ while (stack.length > 0) {
75
+ const current = /** @type {string} */ (stack.pop());
76
+ if (current === target) {
77
+ return true;
78
+ }
79
+ if (visited.has(current)) {
80
+ continue;
81
+ }
82
+ visited.add(current);
83
+ const neighbors = adjacency.get(current);
84
+ if (neighbors) {
85
+ for (const next of neighbors) {
86
+ stack.push(next);
87
+ }
88
+ }
89
+ }
90
+ return false;
91
+ };
92
+ for (const task of forks) {
93
+ const forkSource = /** @type {string} */ (task.forkSource);
94
+ const nodeLogicalId = logicalId(task.nodeId);
95
+ if (!presentLogicalIds.has(forkSource)) {
96
+ throw new SmithersError("TASK_FORK_SOURCE_NOT_FOUND", `Task "${task.nodeId}" forks "${forkSource}", which is not present in the workflow graph.`, { nodeId: task.nodeId, forkSource });
97
+ }
98
+ if (!task.agent) {
99
+ throw new SmithersError("TASK_FORK_SESSION_UNAVAILABLE", `Task "${task.nodeId}" uses fork but is not an agent task. Only agent tasks can fork a session.`, { nodeId: task.nodeId, forkSource });
100
+ }
101
+ // The fork edge is nodeLogicalId -> forkSource. A cycle exists iff
102
+ // forkSource can already reach nodeLogicalId through the dependency graph.
103
+ if (canReach(forkSource, nodeLogicalId)) {
104
+ throw new SmithersError("TASK_FORK_CYCLE", `Task "${task.nodeId}" forks "${forkSource}", which creates a dependency cycle.`, { nodeId: task.nodeId, forkSource });
105
+ }
106
+ }
107
+ }