@smithers-orchestrator/graph 0.21.0 → 0.23.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 +2 -2
- package/src/extract.js +29 -26
- package/src/types.ts +2 -0
- package/src/validateForkSources.js +107 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smithers-orchestrator/graph",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.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.
|
|
25
|
+
"@smithers-orchestrator/errors": "0.23.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/bun": "latest",
|
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 {
|
|
63
|
+
* @returns {boolean}
|
|
62
64
|
*/
|
|
63
|
-
function
|
|
65
|
+
function isDrizzleTable(value) {
|
|
64
66
|
if (!value || typeof value !== "object")
|
|
65
|
-
return
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
94
|
-
const
|
|
95
|
-
|
|
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
|
|
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(
|
|
316
|
+
function addDescriptor(nodeId, kind, descriptor) {
|
|
316
317
|
if (seen.has(nodeId)) {
|
|
317
|
-
throw new SmithersError("DUPLICATE_ID", `Duplicate ${
|
|
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(
|
|
403
|
+
addDescriptor(nodeId, "Subflow", {
|
|
403
404
|
...common,
|
|
404
405
|
...output,
|
|
405
406
|
needsApproval: false,
|
|
@@ -432,7 +433,7 @@ export function extractGraph(root, opts) {
|
|
|
432
433
|
const { retries, retryPolicy } = resolveRetryConfig(raw);
|
|
433
434
|
const output = resolveOutput(raw);
|
|
434
435
|
const runtime = raw.__smithersSandboxRuntime ?? raw.runtime;
|
|
435
|
-
addDescriptor(
|
|
436
|
+
addDescriptor(nodeId, "Sandbox", {
|
|
436
437
|
...common,
|
|
437
438
|
...output,
|
|
438
439
|
needsApproval: false,
|
|
@@ -479,7 +480,7 @@ export function extractGraph(root, opts) {
|
|
|
479
480
|
requireOutput(raw, nodeId, "WaitForEvent");
|
|
480
481
|
const output = resolveOutput(raw);
|
|
481
482
|
const onTimeout = raw.__smithersOnTimeout ?? raw.onTimeout ?? "fail";
|
|
482
|
-
addDescriptor(
|
|
483
|
+
addDescriptor(nodeId, "WaitForEvent", {
|
|
483
484
|
...common,
|
|
484
485
|
...output,
|
|
485
486
|
needsApproval: false,
|
|
@@ -524,7 +525,7 @@ export function extractGraph(root, opts) {
|
|
|
524
525
|
if (raw.every !== undefined) {
|
|
525
526
|
throw new SmithersError("INVALID_INPUT", `Timer ${nodeId} uses every=, but recurring timers are not supported yet.`, { nodeId, every: raw.every });
|
|
526
527
|
}
|
|
527
|
-
addDescriptor(
|
|
528
|
+
addDescriptor(nodeId, "Timer", {
|
|
528
529
|
...common,
|
|
529
530
|
outputTable: null,
|
|
530
531
|
outputTableName: "",
|
|
@@ -588,9 +589,10 @@ export function extractGraph(root, opts) {
|
|
|
588
589
|
if (prompt === "[object Object]") {
|
|
589
590
|
throw new SmithersError("MDX_PRELOAD_INACTIVE", `Task "${logicalNodeId}" prompt resolved to [object Object].`);
|
|
590
591
|
}
|
|
591
|
-
addDescriptor(
|
|
592
|
+
addDescriptor(nodeId, "Task", {
|
|
592
593
|
...common,
|
|
593
594
|
...output,
|
|
595
|
+
forkSource: typeof raw.fork === "string" && raw.fork ? raw.fork : undefined,
|
|
594
596
|
needsApproval: Boolean(raw.needsApproval),
|
|
595
597
|
waitAsync: Boolean(raw.waitAsync),
|
|
596
598
|
approvalMode,
|
|
@@ -652,6 +654,7 @@ export function extractGraph(root, opts) {
|
|
|
652
654
|
worktreeStack: [],
|
|
653
655
|
loopStack: [],
|
|
654
656
|
});
|
|
657
|
+
validateForkSources(tasks);
|
|
655
658
|
return { xml: toXmlNode(root), tasks, mountedTaskIds };
|
|
656
659
|
}
|
|
657
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
|
+
}
|