@osolmaz/pi-workflows 0.9.0 → 0.10.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/README.md +50 -16
- package/dist/builtins/autodevise.workflow.d.ts +58 -0
- package/dist/builtins/autodevise.workflow.js +190 -0
- package/dist/builtins/autodevise.workflow.js.map +1 -0
- package/dist/builtins/autoimplement.workflow.d.ts +154 -0
- package/dist/builtins/autoimplement.workflow.js +729 -0
- package/dist/builtins/autoimplement.workflow.js.map +1 -0
- package/dist/builtins/catalog.js +5 -1
- package/dist/builtins/catalog.js.map +1 -1
- package/dist/builtins/index.d.ts +3 -0
- package/dist/builtins/index.js +4 -0
- package/dist/builtins/index.js.map +1 -0
- package/dist/builtins/monitor.workflow.d.ts +25 -3
- package/dist/builtins/monitor.workflow.js +200 -13
- package/dist/builtins/monitor.workflow.js.map +1 -1
- package/dist/extension/herdr-viewer.js +2 -6
- package/dist/extension/herdr-viewer.js.map +1 -1
- package/dist/render/graph-render.js +13 -2
- package/dist/render/graph-render.js.map +1 -1
- package/dist/workflows/catalog.d.ts +1 -0
- package/dist/workflows/catalog.js +6 -0
- package/dist/workflows/catalog.js.map +1 -1
- package/dist/workflows/composition.d.ts +45 -0
- package/dist/workflows/composition.js +471 -0
- package/dist/workflows/composition.js.map +1 -0
- package/dist/workflows/decision.d.ts +11 -5
- package/dist/workflows/decision.js.map +1 -1
- package/dist/workflows/definition.d.ts +22 -3
- package/dist/workflows/definition.js +46 -3
- package/dist/workflows/definition.js.map +1 -1
- package/dist/workflows/engine.js +115 -16
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/graph.js +8 -6
- package/dist/workflows/graph.js.map +1 -1
- package/dist/workflows/index.d.ts +3 -2
- package/dist/workflows/index.js +2 -1
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/loader.d.ts +5 -4
- package/dist/workflows/loader.js +118 -18
- package/dist/workflows/loader.js.map +1 -1
- package/dist/workflows/schema.d.ts +3 -1
- package/dist/workflows/schema.js +49 -2
- package/dist/workflows/schema.js.map +1 -1
- package/dist/workflows/store.js +32 -2
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/types.d.ts +77 -2
- package/docs/CONTROLLERS.md +1 -1
- package/docs/DESIGN_PHILOSOPHY.md +1 -1
- package/docs/MONITOR.md +35 -18
- package/docs/WORKFLOW_COMPOSITION.md +326 -0
- package/docs/plans/2026-08-19-workflow-composition-plan.md +300 -0
- package/docs/run-bundles.md +24 -10
- package/docs/workflows.md +65 -12
- package/examples/workflows/autodevise.workflow.ts +1 -0
- package/examples/workflows/autoimplement.workflow.ts +1 -92
- package/herdr-plugin.toml +1 -1
- package/package.json +5 -1
- package/skills/monitor/SKILL.md +6 -1
- package/skills/pi-workflows/SKILL.md +3 -1
- package/src/builtins/autodevise.workflow.ts +231 -0
- package/src/builtins/autoimplement.workflow.ts +856 -0
- package/src/builtins/catalog.ts +5 -1
- package/src/builtins/index.ts +13 -0
- package/src/builtins/monitor.workflow.ts +242 -15
- package/src/extension/herdr-viewer.ts +1 -6
- package/src/render/graph-render.ts +14 -2
- package/src/workflows/catalog.ts +7 -0
- package/src/workflows/composition.ts +627 -0
- package/src/workflows/decision.ts +12 -5
- package/src/workflows/definition.ts +118 -8
- package/src/workflows/engine.ts +151 -18
- package/src/workflows/graph.ts +8 -6
- package/src/workflows/index.ts +20 -0
- package/src/workflows/loader.ts +186 -18
- package/src/workflows/schema.ts +62 -2
- package/src/workflows/store.ts +37 -2
- package/src/workflows/types.ts +109 -2
- package/examples/workflows/elegant-solution.workflow.ts +0 -95
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import type {
|
|
5
|
+
ActionNodeDefinition,
|
|
6
|
+
AgentNodeDefinition,
|
|
7
|
+
CheckpointNodeDefinition,
|
|
8
|
+
ComputeNodeDefinition,
|
|
9
|
+
FunctionActionNodeDefinition,
|
|
10
|
+
NotifyNodeDefinition,
|
|
11
|
+
ShellActionNodeDefinition,
|
|
12
|
+
WorkflowActionContext,
|
|
13
|
+
WorkflowCompositionSnapshot,
|
|
14
|
+
WorkflowDefinition,
|
|
15
|
+
WorkflowEdge,
|
|
16
|
+
WorkflowExitDefinition,
|
|
17
|
+
WorkflowIncludeDefinition,
|
|
18
|
+
WorkflowMountedSource,
|
|
19
|
+
WorkflowMountSnapshot,
|
|
20
|
+
WorkflowNodeContext,
|
|
21
|
+
WorkflowNodeDefinition,
|
|
22
|
+
WorkflowNodeResult,
|
|
23
|
+
WorkflowRunState,
|
|
24
|
+
WorkflowSource,
|
|
25
|
+
WorkflowStepRecord,
|
|
26
|
+
} from "./types.js";
|
|
27
|
+
|
|
28
|
+
const COMPILED_WORKFLOW_BRAND = Symbol.for("pi-workflows.compiled-definition");
|
|
29
|
+
const COMPOSITION_METADATA = Symbol.for("pi-workflows.composition-metadata");
|
|
30
|
+
|
|
31
|
+
export type WorkflowScopeMetadata = {
|
|
32
|
+
path: string;
|
|
33
|
+
workflowName: string;
|
|
34
|
+
maxSteps?: number;
|
|
35
|
+
authoredNodes: Record<string, string>;
|
|
36
|
+
childMounts: Record<string, string>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type WorkflowEntryMetadata = {
|
|
40
|
+
mountPath: string;
|
|
41
|
+
mountName: string;
|
|
42
|
+
parentPath: string;
|
|
43
|
+
workflowName: string;
|
|
44
|
+
input: WorkflowIncludeDefinition["input"];
|
|
45
|
+
normalizeInput?: WorkflowDefinition["input"];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type WorkflowExitMetadata = {
|
|
49
|
+
nodeId: string;
|
|
50
|
+
mountPath: string;
|
|
51
|
+
mountName: string;
|
|
52
|
+
parentPath: string;
|
|
53
|
+
exitName: string;
|
|
54
|
+
fromNode: string;
|
|
55
|
+
validate?: WorkflowExitDefinition["validate"];
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type WorkflowCompositionMetadata = {
|
|
59
|
+
snapshot: WorkflowCompositionSnapshot;
|
|
60
|
+
scopes: Record<string, WorkflowScopeMetadata>;
|
|
61
|
+
entries: Record<string, WorkflowEntryMetadata>;
|
|
62
|
+
exits: Record<string, WorkflowExitMetadata>;
|
|
63
|
+
sources: WorkflowMountedSource[];
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type WorkflowCompositionSourceMap = Map<WorkflowDefinition<any, any, any>, WorkflowSource>;
|
|
67
|
+
|
|
68
|
+
export type CompileWorkflowOptions = {
|
|
69
|
+
rootSource?: WorkflowSource;
|
|
70
|
+
sourceMap?: WorkflowCompositionSourceMap;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export function isCompiledWorkflow(workflow: WorkflowDefinition<any, any, any>): boolean {
|
|
74
|
+
return (workflow as Record<PropertyKey, unknown>)[COMPILED_WORKFLOW_BRAND] === true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function compositionMetadata(
|
|
78
|
+
workflow: WorkflowDefinition<any, any, any>,
|
|
79
|
+
): WorkflowCompositionMetadata | undefined {
|
|
80
|
+
return (workflow as Record<PropertyKey, unknown>)[COMPOSITION_METADATA] as
|
|
81
|
+
| WorkflowCompositionMetadata
|
|
82
|
+
| undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Compile direct includes into one qualified graph. Dynamic string references
|
|
87
|
+
* must be resolved to direct definitions before this function is called.
|
|
88
|
+
*/
|
|
89
|
+
export function compileWorkflowDefinition<TWorkflow extends WorkflowDefinition<any, any, any>>(
|
|
90
|
+
workflow: TWorkflow,
|
|
91
|
+
options: CompileWorkflowOptions = {},
|
|
92
|
+
): TWorkflow {
|
|
93
|
+
if (isCompiledWorkflow(workflow)) return workflow;
|
|
94
|
+
|
|
95
|
+
validateAuthoredWorkflow(workflow);
|
|
96
|
+
const nodes: Record<string, WorkflowNodeDefinition> = {};
|
|
97
|
+
const edges: WorkflowEdge[] = [];
|
|
98
|
+
const mounts: WorkflowMountSnapshot[] = [];
|
|
99
|
+
const scopes: Record<string, WorkflowScopeMetadata> = {};
|
|
100
|
+
const entries: Record<string, WorkflowEntryMetadata> = {};
|
|
101
|
+
const exits: Record<string, WorkflowExitMetadata> = {};
|
|
102
|
+
const sources: WorkflowMountedSource[] = [];
|
|
103
|
+
const activeDefinitions: WorkflowDefinition<any, any, any>[] = [];
|
|
104
|
+
|
|
105
|
+
const mountWorkflow = (
|
|
106
|
+
current: WorkflowDefinition<any, any, any>,
|
|
107
|
+
scopePath: string,
|
|
108
|
+
parentPath: string | null,
|
|
109
|
+
mountName: string | null,
|
|
110
|
+
): void => {
|
|
111
|
+
const cycleAt = activeDefinitions.indexOf(current);
|
|
112
|
+
if (cycleAt >= 0) {
|
|
113
|
+
const chain = [...activeDefinitions.slice(cycleAt).map((item) => item.name), current.name];
|
|
114
|
+
throw new Error(`Workflow include cycle: ${chain.join(" -> ")}`);
|
|
115
|
+
}
|
|
116
|
+
activeDefinitions.push(current);
|
|
117
|
+
validateAuthoredWorkflow(current, scopePath !== "");
|
|
118
|
+
|
|
119
|
+
const authoredNodes = Object.fromEntries(
|
|
120
|
+
Object.keys(current.nodes).map((nodeId) => [nodeId, qualify(scopePath, nodeId)]),
|
|
121
|
+
);
|
|
122
|
+
const childMounts = Object.fromEntries(
|
|
123
|
+
Object.keys(current.includes ?? {}).map((name) => [name, qualify(scopePath, name)]),
|
|
124
|
+
);
|
|
125
|
+
scopes[scopePath] = {
|
|
126
|
+
path: scopePath,
|
|
127
|
+
workflowName: current.name,
|
|
128
|
+
...(current.maxSteps !== undefined ? { maxSteps: current.maxSteps } : {}),
|
|
129
|
+
authoredNodes,
|
|
130
|
+
childMounts,
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
if (scopePath !== "" && parentPath !== null && mountName !== null) {
|
|
134
|
+
const source = options.sourceMap?.get(current) ?? sourceFromDefinition(current);
|
|
135
|
+
if (source !== undefined) {
|
|
136
|
+
sources.push({
|
|
137
|
+
mountPath: scopePath.split("/"),
|
|
138
|
+
workflowName: current.name,
|
|
139
|
+
source,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
for (const [localNodeId, node] of Object.entries(current.nodes)) {
|
|
145
|
+
const qualifiedNodeId = qualify(scopePath, localNodeId);
|
|
146
|
+
nodes[qualifiedNodeId] = wrapNode(node, scopePath, scopes, entries, exits);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
for (const [name, include] of Object.entries(current.includes ?? {}) as [
|
|
150
|
+
string,
|
|
151
|
+
WorkflowIncludeDefinition,
|
|
152
|
+
][]) {
|
|
153
|
+
const child = typeof include.workflow === "string" ? include.contract : include.workflow;
|
|
154
|
+
if (child === undefined) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`Workflow include ${qualify(scopePath, name)} is unresolved: ${include.workflow}`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
const mountPath = qualify(scopePath, name);
|
|
160
|
+
const childExits = child.exits ?? {};
|
|
161
|
+
if (Object.keys(childExits).length === 0) {
|
|
162
|
+
throw new Error(`Included workflow ${child.name} must declare at least one exit`);
|
|
163
|
+
}
|
|
164
|
+
const entryNode = mountPath;
|
|
165
|
+
entries[entryNode] = {
|
|
166
|
+
mountPath,
|
|
167
|
+
mountName: name,
|
|
168
|
+
parentPath: scopePath,
|
|
169
|
+
workflowName: child.name,
|
|
170
|
+
input: include.input,
|
|
171
|
+
normalizeInput: child.input,
|
|
172
|
+
};
|
|
173
|
+
nodes[entryNode] = {
|
|
174
|
+
nodeType: "compute",
|
|
175
|
+
statusDetail: `entering ${child.name}`,
|
|
176
|
+
run: async (context) => {
|
|
177
|
+
const parentContext = projectWorkflowContext(context, scopePath, scopes, entries, exits);
|
|
178
|
+
const mapped = include.input ? await include.input(parentContext) : parentContext.input;
|
|
179
|
+
const normalized = child.input ? await child.input(mapped) : mapped;
|
|
180
|
+
return {
|
|
181
|
+
schema: "pi-workflows.include-entry.v1",
|
|
182
|
+
invocation: countCompletedEntries(context.state, entryNode) + 1,
|
|
183
|
+
input: normalized === undefined ? null : normalized,
|
|
184
|
+
};
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
edges.push({ from: entryNode, to: qualify(mountPath, child.startAt) });
|
|
188
|
+
|
|
189
|
+
const exitNodes: Record<string, string> = {};
|
|
190
|
+
for (const [exitName, exit] of Object.entries(childExits) as [
|
|
191
|
+
string,
|
|
192
|
+
WorkflowExitDefinition,
|
|
193
|
+
][]) {
|
|
194
|
+
const exitNodeId = qualify(mountPath, `__piw_exit_${exitName}`);
|
|
195
|
+
const fromNode = qualify(mountPath, exit.from);
|
|
196
|
+
exitNodes[exitName] = exitNodeId;
|
|
197
|
+
exits[exitNodeId] = {
|
|
198
|
+
nodeId: exitNodeId,
|
|
199
|
+
mountPath,
|
|
200
|
+
mountName: name,
|
|
201
|
+
parentPath: scopePath,
|
|
202
|
+
exitName,
|
|
203
|
+
fromNode,
|
|
204
|
+
validate: exit.validate,
|
|
205
|
+
};
|
|
206
|
+
nodes[exitNodeId] = {
|
|
207
|
+
nodeType: "compute",
|
|
208
|
+
statusDetail: `leaving ${child.name} through ${exitName}`,
|
|
209
|
+
run: async (context) => {
|
|
210
|
+
const childContext = projectWorkflowContext(context, mountPath, scopes, entries, exits);
|
|
211
|
+
const raw = childContext.outputs[exit.from];
|
|
212
|
+
const output = exit.validate ? await exit.validate(raw) : raw;
|
|
213
|
+
return { exit: exitName, output: output === undefined ? null : output };
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
edges.push({ from: fromNode, to: exitNodeId });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
mounts.push({
|
|
220
|
+
mountPath: mountPath.split("/"),
|
|
221
|
+
workflowName: child.name,
|
|
222
|
+
entryNode,
|
|
223
|
+
exits: exitNodes,
|
|
224
|
+
...(child.maxSteps !== undefined ? { maxSteps: child.maxSteps } : {}),
|
|
225
|
+
});
|
|
226
|
+
mountWorkflow(child, mountPath, scopePath, name);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
for (const edge of current.edges) {
|
|
230
|
+
const from = resolveAuthoredFrom(scopePath, current, edge.from, exits);
|
|
231
|
+
if ("to" in edge) {
|
|
232
|
+
edges.push({ from, to: resolveAuthoredTo(scopePath, current, edge.to) });
|
|
233
|
+
} else {
|
|
234
|
+
edges.push({
|
|
235
|
+
from,
|
|
236
|
+
switch: {
|
|
237
|
+
on: edge.switch.on,
|
|
238
|
+
cases: Object.fromEntries(
|
|
239
|
+
Object.entries(edge.switch.cases).map(([key, target]) => [
|
|
240
|
+
key,
|
|
241
|
+
resolveAuthoredTo(scopePath, current, target),
|
|
242
|
+
]),
|
|
243
|
+
),
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
activeDefinitions.pop();
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
mountWorkflow(workflow, "", null, null);
|
|
253
|
+
const metadata: WorkflowCompositionMetadata = {
|
|
254
|
+
snapshot: { mounts: mounts.sort(compareMounts) },
|
|
255
|
+
scopes,
|
|
256
|
+
entries,
|
|
257
|
+
exits,
|
|
258
|
+
sources: sources.sort(compareSources),
|
|
259
|
+
};
|
|
260
|
+
const compiled = {
|
|
261
|
+
...workflow,
|
|
262
|
+
nodes,
|
|
263
|
+
edges,
|
|
264
|
+
} as TWorkflow;
|
|
265
|
+
delete (compiled as { includes?: unknown }).includes;
|
|
266
|
+
delete (compiled as { exits?: unknown }).exits;
|
|
267
|
+
for (const symbol of Object.getOwnPropertySymbols(workflow)) {
|
|
268
|
+
const descriptor = Object.getOwnPropertyDescriptor(workflow, symbol);
|
|
269
|
+
if (descriptor !== undefined) Object.defineProperty(compiled, symbol, descriptor);
|
|
270
|
+
}
|
|
271
|
+
Object.defineProperty(compiled, COMPILED_WORKFLOW_BRAND, {
|
|
272
|
+
value: true,
|
|
273
|
+
enumerable: false,
|
|
274
|
+
});
|
|
275
|
+
Object.defineProperty(compiled, COMPOSITION_METADATA, {
|
|
276
|
+
value: metadata,
|
|
277
|
+
enumerable: false,
|
|
278
|
+
});
|
|
279
|
+
return compiled;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function validateAuthoredWorkflow(
|
|
283
|
+
workflow: WorkflowDefinition<any, any, any>,
|
|
284
|
+
included = false,
|
|
285
|
+
): void {
|
|
286
|
+
if (!Object.hasOwn(workflow.nodes, workflow.startAt)) {
|
|
287
|
+
throw new Error(`Workflow start node is missing: ${workflow.startAt}`);
|
|
288
|
+
}
|
|
289
|
+
const includes = workflow.includes ?? {};
|
|
290
|
+
const exits = workflow.exits ?? {};
|
|
291
|
+
const outgoing = new Set<string>();
|
|
292
|
+
for (const edge of workflow.edges) {
|
|
293
|
+
if (outgoing.has(edge.from)) {
|
|
294
|
+
throw new Error(`Workflow node must not declare multiple outgoing edges: ${edge.from}`);
|
|
295
|
+
}
|
|
296
|
+
outgoing.add(edge.from);
|
|
297
|
+
assertAuthoredFrom(workflow, edge.from);
|
|
298
|
+
const targets = "to" in edge ? [edge.to] : Object.values(edge.switch.cases);
|
|
299
|
+
for (const target of targets) assertAuthoredTo(workflow, target);
|
|
300
|
+
}
|
|
301
|
+
const exitNodes = new Set<string>();
|
|
302
|
+
for (const [exitName, exit] of Object.entries(exits) as [string, WorkflowExitDefinition][]) {
|
|
303
|
+
if (!Object.hasOwn(workflow.nodes, exit.from)) {
|
|
304
|
+
throw new Error(`Workflow exit ${exitName} references unknown node: ${exit.from}`);
|
|
305
|
+
}
|
|
306
|
+
if (outgoing.has(exit.from)) {
|
|
307
|
+
throw new Error(`Workflow exit ${exitName} must reference a terminal node: ${exit.from}`);
|
|
308
|
+
}
|
|
309
|
+
if (exitNodes.has(exit.from)) {
|
|
310
|
+
throw new Error(`Workflow terminal node has more than one exit: ${exit.from}`);
|
|
311
|
+
}
|
|
312
|
+
exitNodes.add(exit.from);
|
|
313
|
+
}
|
|
314
|
+
if (included) {
|
|
315
|
+
const undeclared = Object.keys(workflow.nodes).filter(
|
|
316
|
+
(nodeId) => !outgoing.has(nodeId) && !exitNodes.has(nodeId),
|
|
317
|
+
);
|
|
318
|
+
if (undeclared.length > 0) {
|
|
319
|
+
throw new Error(
|
|
320
|
+
`Included workflow ${workflow.name} has terminal nodes without named exits: ${undeclared.join(", ")}`,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const reachable = new Set<string>([workflow.startAt]);
|
|
326
|
+
const queue = [workflow.startAt];
|
|
327
|
+
while (queue.length > 0) {
|
|
328
|
+
const from = queue.shift() as string;
|
|
329
|
+
const edge = workflow.edges.find((candidate) => candidate.from === from);
|
|
330
|
+
if (edge !== undefined) {
|
|
331
|
+
const targets = "to" in edge ? [edge.to] : Object.values(edge.switch.cases);
|
|
332
|
+
for (const target of targets) addReachable(target, reachable, queue);
|
|
333
|
+
}
|
|
334
|
+
if (Object.hasOwn(includes, from)) {
|
|
335
|
+
const include = includes[from];
|
|
336
|
+
const child =
|
|
337
|
+
include && typeof include.workflow !== "string" ? include.workflow : include?.contract;
|
|
338
|
+
for (const exitName of Object.keys(child?.exits ?? {})) {
|
|
339
|
+
addReachable(`${from}.${exitName}`, reachable, queue);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
const unreachable = [...Object.keys(workflow.nodes), ...Object.keys(includes)].filter(
|
|
344
|
+
(id) => !reachable.has(id),
|
|
345
|
+
);
|
|
346
|
+
if (unreachable.length > 0) {
|
|
347
|
+
throw new Error(`Workflow has unreachable nodes: ${unreachable.join(", ")}`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function assertAuthoredFrom(workflow: WorkflowDefinition<any, any, any>, from: string): void {
|
|
352
|
+
if (Object.hasOwn(workflow.nodes, from)) return;
|
|
353
|
+
const parsed = parseExitReference(from);
|
|
354
|
+
const include = parsed ? workflow.includes?.[parsed.mount] : undefined;
|
|
355
|
+
const child =
|
|
356
|
+
include && typeof include.workflow !== "string" ? include.workflow : include?.contract;
|
|
357
|
+
if (parsed && child?.exits && Object.hasOwn(child.exits, parsed.exit)) return;
|
|
358
|
+
throw new Error(`Workflow edge references unknown from-node or include exit: ${from}`);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function assertAuthoredTo(workflow: WorkflowDefinition<any, any, any>, to: string): void {
|
|
362
|
+
if (Object.hasOwn(workflow.nodes, to) || Object.hasOwn(workflow.includes ?? {}, to)) return;
|
|
363
|
+
throw new Error(`Workflow edge references unknown to-node: ${to}`);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function resolveAuthoredFrom(
|
|
367
|
+
scopePath: string,
|
|
368
|
+
workflow: WorkflowDefinition<any, any, any>,
|
|
369
|
+
from: string,
|
|
370
|
+
exitMetadata: Record<string, WorkflowExitMetadata>,
|
|
371
|
+
): string {
|
|
372
|
+
if (Object.hasOwn(workflow.nodes, from)) return qualify(scopePath, from);
|
|
373
|
+
const parsed = parseExitReference(from);
|
|
374
|
+
if (!parsed) throw new Error(`Invalid include exit reference: ${from}`);
|
|
375
|
+
const mountPath = qualify(scopePath, parsed.mount);
|
|
376
|
+
const match = Object.values(exitMetadata).find(
|
|
377
|
+
(exit) => exit.mountPath === mountPath && exit.exitName === parsed.exit,
|
|
378
|
+
);
|
|
379
|
+
if (!match) throw new Error(`Unknown include exit reference: ${from}`);
|
|
380
|
+
return match.nodeId;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function resolveAuthoredTo(
|
|
384
|
+
scopePath: string,
|
|
385
|
+
workflow: WorkflowDefinition<any, any, any>,
|
|
386
|
+
target: string,
|
|
387
|
+
): string {
|
|
388
|
+
if (Object.hasOwn(workflow.nodes, target) || Object.hasOwn(workflow.includes ?? {}, target)) {
|
|
389
|
+
return qualify(scopePath, target);
|
|
390
|
+
}
|
|
391
|
+
throw new Error(`Unknown workflow target: ${target}`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function parseExitReference(value: string): { mount: string; exit: string } | undefined {
|
|
395
|
+
const dot = value.indexOf(".");
|
|
396
|
+
if (dot <= 0 || dot === value.length - 1 || value.indexOf(".", dot + 1) >= 0) return undefined;
|
|
397
|
+
return { mount: value.slice(0, dot), exit: value.slice(dot + 1) };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function addReachable(value: string, reachable: Set<string>, queue: string[]): void {
|
|
401
|
+
if (reachable.has(value)) return;
|
|
402
|
+
reachable.add(value);
|
|
403
|
+
queue.push(value);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function qualify(scopePath: string, localId: string): string {
|
|
407
|
+
return scopePath === "" ? localId : `${scopePath}/${localId}`;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function compareMounts(a: WorkflowMountSnapshot, b: WorkflowMountSnapshot): number {
|
|
411
|
+
return a.mountPath.join("/").localeCompare(b.mountPath.join("/"));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function compareSources(a: WorkflowMountedSource, b: WorkflowMountedSource): number {
|
|
415
|
+
return a.mountPath.join("/").localeCompare(b.mountPath.join("/"));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function sourceFromDefinition(
|
|
419
|
+
workflow: WorkflowDefinition<any, any, any>,
|
|
420
|
+
): WorkflowSource | undefined {
|
|
421
|
+
if (workflow.source === undefined) return undefined;
|
|
422
|
+
let filePath: string;
|
|
423
|
+
try {
|
|
424
|
+
filePath = fileURLToPath(workflow.source);
|
|
425
|
+
} catch {
|
|
426
|
+
throw new Error(`Workflow ${workflow.name} source must be a file URL: ${workflow.source}`);
|
|
427
|
+
}
|
|
428
|
+
const hash = createHash("sha256").update(readFileSync(filePath)).digest("hex");
|
|
429
|
+
return { kind: "file", path: filePath, hash };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function wrapNode(
|
|
433
|
+
node: WorkflowNodeDefinition,
|
|
434
|
+
scopePath: string,
|
|
435
|
+
scopes: Record<string, WorkflowScopeMetadata>,
|
|
436
|
+
entries: Record<string, WorkflowEntryMetadata>,
|
|
437
|
+
exits: Record<string, WorkflowExitMetadata>,
|
|
438
|
+
): WorkflowNodeDefinition {
|
|
439
|
+
if (scopePath === "" && Object.keys(scopes[scopePath]?.childMounts ?? {}).length === 0) {
|
|
440
|
+
return node;
|
|
441
|
+
}
|
|
442
|
+
const project = (context: WorkflowNodeContext) =>
|
|
443
|
+
projectWorkflowContext(context, scopePath, scopes, entries, exits);
|
|
444
|
+
const timeoutResolver = node.timeoutMs;
|
|
445
|
+
const timeoutMs =
|
|
446
|
+
typeof timeoutResolver === "function"
|
|
447
|
+
? (context: WorkflowNodeContext) => timeoutResolver(project(context))
|
|
448
|
+
: timeoutResolver;
|
|
449
|
+
const common = {
|
|
450
|
+
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
451
|
+
...(node.statusDetail !== undefined ? { statusDetail: node.statusDetail } : {}),
|
|
452
|
+
};
|
|
453
|
+
switch (node.nodeType) {
|
|
454
|
+
case "agent":
|
|
455
|
+
return {
|
|
456
|
+
...common,
|
|
457
|
+
nodeType: "agent",
|
|
458
|
+
prompt: (context) => (node as AgentNodeDefinition).prompt(project(context)),
|
|
459
|
+
...(node.expectedOutput !== undefined ? { expectedOutput: node.expectedOutput } : {}),
|
|
460
|
+
...(node.validate !== undefined
|
|
461
|
+
? {
|
|
462
|
+
validate: (output: unknown, context: WorkflowNodeContext) =>
|
|
463
|
+
node.validate?.(output, project(context)),
|
|
464
|
+
}
|
|
465
|
+
: {}),
|
|
466
|
+
};
|
|
467
|
+
case "compute":
|
|
468
|
+
return {
|
|
469
|
+
...common,
|
|
470
|
+
nodeType: "compute",
|
|
471
|
+
run: (context) => (node as ComputeNodeDefinition).run(project(context)),
|
|
472
|
+
};
|
|
473
|
+
case "notify":
|
|
474
|
+
return {
|
|
475
|
+
...common,
|
|
476
|
+
nodeType: "notify",
|
|
477
|
+
message: (context) => (node as NotifyNodeDefinition).message(project(context)),
|
|
478
|
+
...(node.kind !== undefined ? { kind: node.kind } : {}),
|
|
479
|
+
};
|
|
480
|
+
case "checkpoint":
|
|
481
|
+
return {
|
|
482
|
+
...common,
|
|
483
|
+
nodeType: "checkpoint",
|
|
484
|
+
...(node.summary !== undefined ? { summary: node.summary } : {}),
|
|
485
|
+
...(node.run !== undefined
|
|
486
|
+
? { run: (context: WorkflowNodeContext) => node.run?.(project(context)) }
|
|
487
|
+
: {}),
|
|
488
|
+
} as CheckpointNodeDefinition;
|
|
489
|
+
case "action":
|
|
490
|
+
if ("exec" in node) {
|
|
491
|
+
const shell = node as ShellActionNodeDefinition;
|
|
492
|
+
return {
|
|
493
|
+
...common,
|
|
494
|
+
nodeType: "action",
|
|
495
|
+
exec: (context) => shell.exec(project(context)),
|
|
496
|
+
...(shell.parse !== undefined
|
|
497
|
+
? {
|
|
498
|
+
parse: (result, context) => shell.parse?.(result, project(context)),
|
|
499
|
+
}
|
|
500
|
+
: {}),
|
|
501
|
+
...(shell.updates !== undefined
|
|
502
|
+
? {
|
|
503
|
+
updates: {
|
|
504
|
+
...(shell.updates.streams !== undefined
|
|
505
|
+
? { streams: [...shell.updates.streams] }
|
|
506
|
+
: {}),
|
|
507
|
+
parseLine: (line, context) =>
|
|
508
|
+
shell.updates?.parseLine(
|
|
509
|
+
line,
|
|
510
|
+
projectActionContext(context, scopePath, scopes, entries, exits),
|
|
511
|
+
),
|
|
512
|
+
},
|
|
513
|
+
}
|
|
514
|
+
: {}),
|
|
515
|
+
} as ShellActionNodeDefinition;
|
|
516
|
+
}
|
|
517
|
+
return {
|
|
518
|
+
...common,
|
|
519
|
+
nodeType: "action",
|
|
520
|
+
run: (context) =>
|
|
521
|
+
(node as FunctionActionNodeDefinition).run(
|
|
522
|
+
projectActionContext(context, scopePath, scopes, entries, exits),
|
|
523
|
+
),
|
|
524
|
+
} as ActionNodeDefinition;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function projectActionContext(
|
|
529
|
+
context: WorkflowActionContext,
|
|
530
|
+
scopePath: string,
|
|
531
|
+
scopes: Record<string, WorkflowScopeMetadata>,
|
|
532
|
+
entries: Record<string, WorkflowEntryMetadata>,
|
|
533
|
+
exits: Record<string, WorkflowExitMetadata>,
|
|
534
|
+
): WorkflowActionContext {
|
|
535
|
+
return {
|
|
536
|
+
...projectWorkflowContext(context, scopePath, scopes, entries, exits),
|
|
537
|
+
publishUpdate: context.publishUpdate,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
export function projectWorkflowContext(
|
|
542
|
+
context: WorkflowNodeContext,
|
|
543
|
+
scopePath: string,
|
|
544
|
+
scopes: Record<string, WorkflowScopeMetadata>,
|
|
545
|
+
_entries: Record<string, WorkflowEntryMetadata>,
|
|
546
|
+
exits: Record<string, WorkflowExitMetadata>,
|
|
547
|
+
): WorkflowNodeContext {
|
|
548
|
+
const scope = scopes[scopePath];
|
|
549
|
+
if (!scope) throw new Error(`Workflow scope is missing: ${scopePath || "root"}`);
|
|
550
|
+
const entryIndex = scopePath === "" ? -1 : latestStepIndex(context.state.steps, scopePath);
|
|
551
|
+
if (scopePath !== "" && entryIndex < 0) {
|
|
552
|
+
throw new Error(`Workflow include entry is missing: ${scopePath}`);
|
|
553
|
+
}
|
|
554
|
+
const entry =
|
|
555
|
+
scopePath === ""
|
|
556
|
+
? undefined
|
|
557
|
+
: (context.state.steps[entryIndex]?.output as
|
|
558
|
+
| { schema?: string; input?: unknown }
|
|
559
|
+
| undefined);
|
|
560
|
+
const outputs: Record<string, unknown> = {};
|
|
561
|
+
const results: Record<string, WorkflowNodeResult> = {};
|
|
562
|
+
for (const [local, qualified] of Object.entries(scope.authoredNodes)) {
|
|
563
|
+
if (latestStepIndex(context.state.steps, qualified) <= entryIndex) continue;
|
|
564
|
+
if (Object.hasOwn(context.state.outputs, qualified))
|
|
565
|
+
outputs[local] = context.state.outputs[qualified];
|
|
566
|
+
const result = context.state.results[qualified];
|
|
567
|
+
if (result !== undefined) results[local] = result;
|
|
568
|
+
}
|
|
569
|
+
for (const [local, mountPath] of Object.entries(scope.childMounts)) {
|
|
570
|
+
const latestExit = Object.values(exits)
|
|
571
|
+
.filter((candidate) => candidate.mountPath === mountPath)
|
|
572
|
+
.map((candidate) => latestStepIndex(context.state.steps, candidate.nodeId))
|
|
573
|
+
.reduce((max, value) => Math.max(max, value), -1);
|
|
574
|
+
if (latestExit <= entryIndex) continue;
|
|
575
|
+
if (Object.hasOwn(context.state.outputs, mountPath))
|
|
576
|
+
outputs[local] = context.state.outputs[mountPath];
|
|
577
|
+
const result = context.state.results[mountPath];
|
|
578
|
+
if (result !== undefined) results[local] = result;
|
|
579
|
+
}
|
|
580
|
+
const localSteps = context.state.steps.slice(entryIndex + 1).flatMap((step) => {
|
|
581
|
+
if (Object.values(scope.authoredNodes).includes(step.nodeId)) {
|
|
582
|
+
return [{ ...step, nodeId: localNameFor(scope, step.nodeId) }];
|
|
583
|
+
}
|
|
584
|
+
const exit = exits[step.nodeId];
|
|
585
|
+
if (exit?.parentPath === scopePath) {
|
|
586
|
+
return [{ ...step, nodeId: exit.mountName }];
|
|
587
|
+
}
|
|
588
|
+
return [];
|
|
589
|
+
});
|
|
590
|
+
const currentNode = context.state.currentNode
|
|
591
|
+
? localNameFor(scope, context.state.currentNode)
|
|
592
|
+
: undefined;
|
|
593
|
+
const localInput = scopePath === "" ? context.input : (entry?.input ?? null);
|
|
594
|
+
const state: WorkflowRunState = {
|
|
595
|
+
...context.state,
|
|
596
|
+
input: localInput,
|
|
597
|
+
outputs,
|
|
598
|
+
results,
|
|
599
|
+
steps: localSteps,
|
|
600
|
+
...(currentNode !== undefined ? { currentNode } : {}),
|
|
601
|
+
};
|
|
602
|
+
return {
|
|
603
|
+
input: localInput,
|
|
604
|
+
outputs,
|
|
605
|
+
results,
|
|
606
|
+
state,
|
|
607
|
+
signal: context.signal,
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function localNameFor(scope: WorkflowScopeMetadata, qualified: string): string {
|
|
612
|
+
for (const [local, global] of Object.entries(scope.authoredNodes)) {
|
|
613
|
+
if (global === qualified) return local;
|
|
614
|
+
}
|
|
615
|
+
return qualified;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function latestStepIndex(steps: WorkflowStepRecord[], nodeId: string): number {
|
|
619
|
+
for (let index = steps.length - 1; index >= 0; index -= 1) {
|
|
620
|
+
if (steps[index]?.nodeId === nodeId) return index;
|
|
621
|
+
}
|
|
622
|
+
return -1;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function countCompletedEntries(state: WorkflowRunState, entryNode: string): number {
|
|
626
|
+
return state.steps.filter((step) => step.nodeId === entryNode && step.outcome === "ok").length;
|
|
627
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { agent } from "./definition.js";
|
|
2
2
|
import { extractJsonValue } from "./json.js";
|
|
3
|
-
import type { AgentNodeDefinition,
|
|
3
|
+
import type { AgentNodeDefinition, WorkflowNodeContext } from "./types.js";
|
|
4
4
|
|
|
5
5
|
const DEFAULT_FIELD = "route";
|
|
6
6
|
const SIMPLE_FIELD_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
@@ -64,12 +64,19 @@ export function decision<TChoice extends string>(
|
|
|
64
64
|
* Build the matching `switch` edge for a `decision` node. Typing `cases` as
|
|
65
65
|
* `Record<TChoice, string>` makes a missing case a compile error.
|
|
66
66
|
*/
|
|
67
|
-
export function decisionEdge<
|
|
68
|
-
|
|
67
|
+
export function decisionEdge<
|
|
68
|
+
TChoice extends string,
|
|
69
|
+
const TFrom extends string,
|
|
70
|
+
const TCases extends Record<TChoice, string>,
|
|
71
|
+
>(args: {
|
|
72
|
+
from: TFrom;
|
|
69
73
|
choices: readonly TChoice[];
|
|
70
74
|
field?: string;
|
|
71
|
-
cases:
|
|
72
|
-
}):
|
|
75
|
+
cases: TCases;
|
|
76
|
+
}): {
|
|
77
|
+
from: TFrom;
|
|
78
|
+
switch: { on: string; cases: TCases };
|
|
79
|
+
} {
|
|
73
80
|
const field = normalizeField(args.field);
|
|
74
81
|
assertValidChoices(args.choices);
|
|
75
82
|
for (const choice of args.choices) {
|