@osolmaz/pi-workflows 0.9.1 → 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/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/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
|
@@ -16,27 +16,137 @@ import type {
|
|
|
16
16
|
NotifyNodeDefinition,
|
|
17
17
|
ShellActionNodeDefinition,
|
|
18
18
|
WorkflowDefinition,
|
|
19
|
+
WorkflowExitMap,
|
|
20
|
+
WorkflowIncludeDefinition,
|
|
21
|
+
WorkflowIncludedResult,
|
|
22
|
+
WorkflowIncludeMap,
|
|
23
|
+
WorkflowInputOf,
|
|
24
|
+
WorkflowNodeContext,
|
|
25
|
+
WorkflowNodeDefinition,
|
|
26
|
+
WorkflowTypedEdge,
|
|
27
|
+
WorkflowValueParser,
|
|
19
28
|
} from "./types.js";
|
|
20
29
|
|
|
21
30
|
const WORKFLOW_DEFINITION_BRAND = Symbol.for("pi-workflows.definition");
|
|
22
31
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
32
|
+
type WorkflowDefinitionInput<
|
|
33
|
+
TInput,
|
|
34
|
+
TNodes extends Record<string, WorkflowNodeDefinition>,
|
|
35
|
+
TIncludes extends WorkflowIncludeMap,
|
|
36
|
+
TExits extends WorkflowExitMap,
|
|
37
|
+
> = Omit<
|
|
38
|
+
WorkflowDefinition<TInput, TExits, TIncludes>,
|
|
39
|
+
"input" | "nodes" | "includes" | "exits" | "edges"
|
|
40
|
+
> & {
|
|
41
|
+
input?: WorkflowValueParser<TInput>;
|
|
42
|
+
nodes: TNodes;
|
|
43
|
+
includes?: TIncludes;
|
|
44
|
+
exits?: TExits;
|
|
45
|
+
edges: WorkflowTypedEdge<TNodes, TIncludes>[];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export function defineWorkflow<
|
|
49
|
+
TInput = unknown,
|
|
50
|
+
const TNodes extends Record<string, WorkflowNodeDefinition> = Record<
|
|
51
|
+
string,
|
|
52
|
+
WorkflowNodeDefinition
|
|
53
|
+
>,
|
|
54
|
+
const TIncludes extends WorkflowIncludeMap = Record<never, never>,
|
|
55
|
+
const TExits extends WorkflowExitMap = Record<never, never>,
|
|
56
|
+
>(
|
|
57
|
+
definition: WorkflowDefinitionInput<TInput, TNodes, TIncludes, TExits>,
|
|
58
|
+
): WorkflowDefinition<TInput, TExits, TIncludes> & {
|
|
59
|
+
nodes: TNodes;
|
|
60
|
+
includes?: TIncludes;
|
|
61
|
+
exits?: TExits;
|
|
62
|
+
} {
|
|
63
|
+
assertValidWorkflowDefinitionShape(definition as WorkflowDefinition);
|
|
64
|
+
const typed = definition as WorkflowDefinition<TInput, TExits, TIncludes> & {
|
|
65
|
+
nodes: TNodes;
|
|
66
|
+
includes?: TIncludes;
|
|
67
|
+
exits?: TExits;
|
|
68
|
+
};
|
|
69
|
+
if (isWorkflowDefinition(typed)) {
|
|
70
|
+
return typed;
|
|
29
71
|
}
|
|
30
|
-
Object.defineProperty(
|
|
72
|
+
Object.defineProperty(typed, WORKFLOW_DEFINITION_BRAND, {
|
|
31
73
|
value: true,
|
|
32
74
|
enumerable: false,
|
|
33
75
|
configurable: false,
|
|
34
76
|
writable: false,
|
|
35
77
|
});
|
|
78
|
+
return typed;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function includeWorkflow<TWorkflow extends WorkflowDefinition<any, any, any>>(
|
|
82
|
+
workflow: TWorkflow,
|
|
83
|
+
options?: {
|
|
84
|
+
input?: (
|
|
85
|
+
context: WorkflowNodeContext,
|
|
86
|
+
) => Promise<WorkflowInputOf<TWorkflow>> | WorkflowInputOf<TWorkflow>;
|
|
87
|
+
},
|
|
88
|
+
): WorkflowIncludeDefinition<TWorkflow>;
|
|
89
|
+
export function includeWorkflow<TWorkflow extends WorkflowDefinition<any, any, any>>(
|
|
90
|
+
definition: WorkflowIncludeDefinition<TWorkflow>,
|
|
91
|
+
): WorkflowIncludeDefinition<TWorkflow>;
|
|
92
|
+
export function includeWorkflow<TWorkflow extends WorkflowDefinition<any, any, any>>(
|
|
93
|
+
workflowOrDefinition: TWorkflow | WorkflowIncludeDefinition<TWorkflow>,
|
|
94
|
+
options: {
|
|
95
|
+
input?: (
|
|
96
|
+
context: WorkflowNodeContext,
|
|
97
|
+
) => Promise<WorkflowInputOf<TWorkflow>> | WorkflowInputOf<TWorkflow>;
|
|
98
|
+
} = {},
|
|
99
|
+
): WorkflowIncludeDefinition<TWorkflow> {
|
|
100
|
+
const definition = isWorkflowDefinition(workflowOrDefinition)
|
|
101
|
+
? { workflow: workflowOrDefinition, ...options }
|
|
102
|
+
: workflowOrDefinition;
|
|
103
|
+
if (typeof definition.workflow !== "string" && !isWorkflowDefinition(definition.workflow)) {
|
|
104
|
+
throw new Error("Included workflow must be a defined workflow or a workflow reference");
|
|
105
|
+
}
|
|
106
|
+
if (definition.input !== undefined && typeof definition.input !== "function") {
|
|
107
|
+
throw new Error("Included workflow input must be a function");
|
|
108
|
+
}
|
|
109
|
+
if (definition.contract !== undefined && !isWorkflowDefinition(definition.contract)) {
|
|
110
|
+
throw new Error("Included workflow contract must be defined with defineWorkflow");
|
|
111
|
+
}
|
|
36
112
|
return definition;
|
|
37
113
|
}
|
|
38
114
|
|
|
39
|
-
export function
|
|
115
|
+
export function includedResult<TWorkflow extends WorkflowDefinition<any, any, any>>(
|
|
116
|
+
workflow: TWorkflow,
|
|
117
|
+
value: unknown,
|
|
118
|
+
): WorkflowIncludedResult<TWorkflow> {
|
|
119
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
120
|
+
throw new Error(`Included ${workflow.name} result must be an object`);
|
|
121
|
+
}
|
|
122
|
+
const exit = (value as { exit?: unknown }).exit;
|
|
123
|
+
if (typeof exit !== "string" || !Object.hasOwn(workflow.exits ?? {}, exit)) {
|
|
124
|
+
throw new Error(`Included ${workflow.name} result has unknown exit ${JSON.stringify(exit)}`);
|
|
125
|
+
}
|
|
126
|
+
if (!Object.hasOwn(value, "output")) {
|
|
127
|
+
throw new Error(`Included ${workflow.name} result requires output`);
|
|
128
|
+
}
|
|
129
|
+
return value as WorkflowIncludedResult<TWorkflow>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Preserve exact workflow types while checking duplicate registry names. */
|
|
133
|
+
export function defineWorkflowRegistry<
|
|
134
|
+
const TRegistry extends Record<string, WorkflowDefinition<any, any, any>>,
|
|
135
|
+
>(registry: TRegistry): Readonly<TRegistry> {
|
|
136
|
+
const names = new Set<string>();
|
|
137
|
+
for (const [key, workflow] of Object.entries(registry)) {
|
|
138
|
+
if (!isWorkflowDefinition(workflow)) {
|
|
139
|
+
throw new Error(`Workflow registry entry ${key} is not defined with defineWorkflow`);
|
|
140
|
+
}
|
|
141
|
+
if (names.has(workflow.name)) {
|
|
142
|
+
throw new Error(`Workflow registry has duplicate workflow name: ${workflow.name}`);
|
|
143
|
+
}
|
|
144
|
+
names.add(workflow.name);
|
|
145
|
+
}
|
|
146
|
+
return Object.freeze({ ...registry });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function isWorkflowDefinition(value: unknown): value is WorkflowDefinition<any, any, any> {
|
|
40
150
|
return (
|
|
41
151
|
value != null &&
|
|
42
152
|
typeof value === "object" &&
|
package/src/workflows/engine.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { isDeepStrictEqual } from "node:util";
|
|
3
3
|
import { resolveArtifacts } from "./artifacts.js";
|
|
4
|
+
import {
|
|
5
|
+
compileWorkflowDefinition,
|
|
6
|
+
compositionMetadata,
|
|
7
|
+
isCompiledWorkflow,
|
|
8
|
+
} from "./composition.js";
|
|
4
9
|
import {
|
|
5
10
|
CancelledError,
|
|
6
11
|
errorMessage,
|
|
@@ -14,7 +19,13 @@ import {
|
|
|
14
19
|
import { resolveNext, resolveNextForOutcome, validateWorkflowDefinition } from "./graph.js";
|
|
15
20
|
import { extractJsonValue } from "./json.js";
|
|
16
21
|
import { runShellAction, shellResultFromError } from "./shell.js";
|
|
17
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
RUN_STATE_SCHEMA,
|
|
24
|
+
WorkflowRunStore,
|
|
25
|
+
createDefinitionSnapshot,
|
|
26
|
+
createRunId,
|
|
27
|
+
readRunBundle,
|
|
28
|
+
} from "./store.js";
|
|
18
29
|
import type {
|
|
19
30
|
AgentNodeDefinition,
|
|
20
31
|
AgentStepExecutor,
|
|
@@ -215,10 +226,12 @@ export class WorkflowEngine {
|
|
|
215
226
|
input: unknown,
|
|
216
227
|
options: { workflowSource?: WorkflowSource; runId?: string } = {},
|
|
217
228
|
): Promise<WorkflowRunResult> {
|
|
229
|
+
workflow = isCompiledWorkflow(workflow) ? workflow : compileWorkflowDefinition(workflow);
|
|
218
230
|
validateWorkflowDefinition(workflow);
|
|
219
231
|
// Fail before any bundle exists so bad input cannot leave a partial run
|
|
220
232
|
// on disk or silently change shape when state.json round-trips.
|
|
221
|
-
const
|
|
233
|
+
const suppliedInput = input === undefined ? null : input;
|
|
234
|
+
const normalizedInput = workflow.input ? await workflow.input(suppliedInput) : suppliedInput;
|
|
222
235
|
assertJsonSerializable(normalizedInput, "Workflow run input");
|
|
223
236
|
if (options.runId !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(options.runId)) {
|
|
224
237
|
throw new Error(`Invalid workflow run id: ${JSON.stringify(options.runId)}`);
|
|
@@ -270,6 +283,7 @@ export class WorkflowEngine {
|
|
|
270
283
|
runId: string,
|
|
271
284
|
options: { workflowSource?: WorkflowSource; force?: boolean } = {},
|
|
272
285
|
): Promise<WorkflowRunResult> {
|
|
286
|
+
workflow = isCompiledWorkflow(workflow) ? workflow : compileWorkflowDefinition(workflow);
|
|
273
287
|
validateWorkflowDefinition(workflow);
|
|
274
288
|
// Reset before any await: a park or cancel landing during preparation
|
|
275
289
|
// must survive, or a host drain would hang while the run executes.
|
|
@@ -279,7 +293,7 @@ export class WorkflowEngine {
|
|
|
279
293
|
const bundle = await this.store.prepareRunResume(runId);
|
|
280
294
|
const { runDir } = bundle;
|
|
281
295
|
const state = bundle.state;
|
|
282
|
-
const sourceMismatch =
|
|
296
|
+
const sourceMismatch = workflowIdentityMismatch(state, workflow, options.workflowSource);
|
|
283
297
|
if (sourceMismatch && options.force !== true) {
|
|
284
298
|
throw new WorkflowSourceChangedError(runId);
|
|
285
299
|
}
|
|
@@ -330,7 +344,7 @@ export class WorkflowEngine {
|
|
|
330
344
|
state,
|
|
331
345
|
runDir,
|
|
332
346
|
point.nodeId,
|
|
333
|
-
state.steps
|
|
347
|
+
countExecutableSteps(workflow, state.steps),
|
|
334
348
|
point.lastOutput,
|
|
335
349
|
);
|
|
336
350
|
} catch (error) {
|
|
@@ -354,6 +368,7 @@ export class WorkflowEngine {
|
|
|
354
368
|
input: unknown,
|
|
355
369
|
options: { workflowSource?: WorkflowSource; runId?: string; force?: boolean } = {},
|
|
356
370
|
): Promise<WorkflowRunResult> {
|
|
371
|
+
workflow = isCompiledWorkflow(workflow) ? workflow : compileWorkflowDefinition(workflow);
|
|
357
372
|
validateWorkflowDefinition(workflow);
|
|
358
373
|
this.cancelled = false;
|
|
359
374
|
this.paused = false;
|
|
@@ -367,12 +382,13 @@ export class WorkflowEngine {
|
|
|
367
382
|
`Cannot continue workflow run ${parentRunId} with status ${parent.state.status}`,
|
|
368
383
|
);
|
|
369
384
|
}
|
|
370
|
-
const sourceMismatch =
|
|
385
|
+
const sourceMismatch = workflowIdentityMismatch(parent.state, workflow, options.workflowSource);
|
|
371
386
|
if (sourceMismatch && options.force !== true) {
|
|
372
387
|
throw new WorkflowSourceChangedError(parentRunId);
|
|
373
388
|
}
|
|
374
389
|
|
|
375
|
-
const
|
|
390
|
+
const suppliedInput = input === undefined ? null : input;
|
|
391
|
+
const normalizedInput = workflow.input ? await workflow.input(suppliedInput) : suppliedInput;
|
|
376
392
|
assertJsonSerializable(normalizedInput, "Workflow run input");
|
|
377
393
|
if (options.runId !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(options.runId)) {
|
|
378
394
|
throw new Error(`Invalid workflow run id: ${JSON.stringify(options.runId)}`);
|
|
@@ -428,7 +444,7 @@ export class WorkflowEngine {
|
|
|
428
444
|
state,
|
|
429
445
|
runDir,
|
|
430
446
|
point.nodeId,
|
|
431
|
-
state.steps
|
|
447
|
+
countExecutableSteps(workflow, state.steps),
|
|
432
448
|
point.lastOutput,
|
|
433
449
|
);
|
|
434
450
|
} catch (error) {
|
|
@@ -546,6 +562,7 @@ export class WorkflowEngine {
|
|
|
546
562
|
runId: string | undefined,
|
|
547
563
|
): Promise<WorkflowRunState> {
|
|
548
564
|
const now = new Date().toISOString();
|
|
565
|
+
const composition = compositionMetadata(workflow);
|
|
549
566
|
return {
|
|
550
567
|
schema: RUN_STATE_SCHEMA,
|
|
551
568
|
traceSeq: 0,
|
|
@@ -553,6 +570,10 @@ export class WorkflowEngine {
|
|
|
553
570
|
workflowName: workflow.name,
|
|
554
571
|
...(await this.resolveTitleBounded(workflow, input)),
|
|
555
572
|
...(workflowSource !== undefined ? { workflowSource } : {}),
|
|
573
|
+
...(composition?.sources.length ? { workflowSources: composition.sources } : {}),
|
|
574
|
+
...(composition?.snapshot.mounts.length
|
|
575
|
+
? { definitionDigest: definitionDigest(workflow) }
|
|
576
|
+
: {}),
|
|
556
577
|
startedAt: now,
|
|
557
578
|
updatedAt: now,
|
|
558
579
|
status: "running",
|
|
@@ -573,17 +594,24 @@ export class WorkflowEngine {
|
|
|
573
594
|
initialLastOutput?: unknown,
|
|
574
595
|
): Promise<void> {
|
|
575
596
|
const maxSteps = workflow.maxSteps ?? this.maxSteps;
|
|
597
|
+
const composition = compositionMetadata(workflow);
|
|
576
598
|
let currentNodeId: string | null = startNodeId;
|
|
577
599
|
let executedSteps = executedStepsBase;
|
|
578
600
|
let lastOutput: unknown = initialLastOutput;
|
|
579
601
|
|
|
580
602
|
while (currentNodeId !== null) {
|
|
581
603
|
await this.holdWhilePaused(state, runDir);
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
604
|
+
const isTransition =
|
|
605
|
+
composition?.entries[currentNodeId] !== undefined ||
|
|
606
|
+
composition?.exits[currentNodeId] !== undefined;
|
|
607
|
+
if (!isTransition) {
|
|
608
|
+
executedSteps += 1;
|
|
609
|
+
if (executedSteps > maxSteps) {
|
|
610
|
+
throw new Error(
|
|
611
|
+
`Workflow exceeded maxSteps=${maxSteps}; aborting to avoid an unbounded loop`,
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
assertInvocationStepLimit(composition, currentNodeId, state.steps);
|
|
587
615
|
}
|
|
588
616
|
|
|
589
617
|
const node = workflow.nodes[currentNodeId];
|
|
@@ -597,7 +625,7 @@ export class WorkflowEngine {
|
|
|
597
625
|
// as in-flight, and resume reruns it with a fresh attempt.
|
|
598
626
|
throw new RunParkedError();
|
|
599
627
|
}
|
|
600
|
-
this.recordAttempt(state, attempt);
|
|
628
|
+
this.recordAttempt(workflow, state, attempt);
|
|
601
629
|
// The terminal node event carries the output, receipt, and conversation
|
|
602
630
|
// linkage so the trace alone is sufficient to reconstruct the run.
|
|
603
631
|
await this.persist(runDir, state, {
|
|
@@ -622,6 +650,35 @@ export class WorkflowEngine {
|
|
|
622
650
|
continue;
|
|
623
651
|
}
|
|
624
652
|
|
|
653
|
+
const entered = composition?.entries[attempt.result.nodeId];
|
|
654
|
+
if (entered !== undefined) {
|
|
655
|
+
const value = attempt.result.output as { invocation?: number } | undefined;
|
|
656
|
+
await this.persist(runDir, state, {
|
|
657
|
+
scope: "run",
|
|
658
|
+
type: "include_entered",
|
|
659
|
+
payload: {
|
|
660
|
+
mountPath: entered.mountPath.split("/"),
|
|
661
|
+
workflowName: entered.workflowName,
|
|
662
|
+
invocation: value?.invocation ?? 1,
|
|
663
|
+
},
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
const exited = composition?.exits[attempt.result.nodeId];
|
|
667
|
+
if (exited !== undefined) {
|
|
668
|
+
const entrySteps = state.steps.filter((step) => step.nodeId === exited.mountPath);
|
|
669
|
+
await this.persist(runDir, state, {
|
|
670
|
+
scope: "run",
|
|
671
|
+
type: "include_exited",
|
|
672
|
+
payload: {
|
|
673
|
+
mountPath: exited.mountPath.split("/"),
|
|
674
|
+
workflowName: composition?.scopes[exited.mountPath]?.workflowName ?? exited.mountName,
|
|
675
|
+
invocation: entrySteps.length,
|
|
676
|
+
exit: exited.exitName,
|
|
677
|
+
output: attempt.result.output ?? null,
|
|
678
|
+
},
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
|
|
625
682
|
lastOutput = attempt.result.output;
|
|
626
683
|
if (node.nodeType === "checkpoint") {
|
|
627
684
|
await this.finishRun(runDir, state, "waiting", {
|
|
@@ -678,13 +735,13 @@ export class WorkflowEngine {
|
|
|
678
735
|
state: WorkflowRunState,
|
|
679
736
|
attempt: NodeAttempt,
|
|
680
737
|
): string | null {
|
|
738
|
+
if (attempt.result.outcome === "cancelled" || this.cancelled) {
|
|
739
|
+
throw new CancelledError();
|
|
740
|
+
}
|
|
681
741
|
const next = resolveNextForOutcome(workflow.edges, attempt.result.nodeId, attempt.result);
|
|
682
742
|
if (next !== null) {
|
|
683
743
|
return next;
|
|
684
744
|
}
|
|
685
|
-
if (attempt.result.outcome === "cancelled" || this.cancelled) {
|
|
686
|
-
throw new CancelledError();
|
|
687
|
-
}
|
|
688
745
|
if (attempt.result.outcome === "timed_out") {
|
|
689
746
|
state.status = "timed_out";
|
|
690
747
|
}
|
|
@@ -693,7 +750,11 @@ export class WorkflowEngine {
|
|
|
693
750
|
: new Error(attempt.result.error ?? `Workflow node failed: ${attempt.result.nodeId}`);
|
|
694
751
|
}
|
|
695
752
|
|
|
696
|
-
private recordAttempt(
|
|
753
|
+
private recordAttempt(
|
|
754
|
+
workflow: WorkflowDefinition,
|
|
755
|
+
state: WorkflowRunState,
|
|
756
|
+
attempt: NodeAttempt,
|
|
757
|
+
): void {
|
|
697
758
|
state.results[attempt.result.nodeId] = attempt.result;
|
|
698
759
|
if (attempt.result.outcome === "ok") {
|
|
699
760
|
state.outputs[attempt.result.nodeId] = attempt.result.output;
|
|
@@ -702,6 +763,11 @@ export class WorkflowEngine {
|
|
|
702
763
|
// must not survive next to a non-ok latest result.
|
|
703
764
|
delete state.outputs[attempt.result.nodeId];
|
|
704
765
|
}
|
|
766
|
+
const exit = compositionMetadata(workflow)?.exits[attempt.result.nodeId];
|
|
767
|
+
if (exit !== undefined && attempt.result.outcome === "ok") {
|
|
768
|
+
state.outputs[exit.mountPath] = attempt.result.output;
|
|
769
|
+
state.results[exit.mountPath] = { ...attempt.result, nodeId: exit.mountPath };
|
|
770
|
+
}
|
|
705
771
|
const step: WorkflowStepRecord = {
|
|
706
772
|
attemptId: attempt.result.attemptId,
|
|
707
773
|
nodeId: attempt.result.nodeId,
|
|
@@ -1234,6 +1300,54 @@ function abortRejection(signal: AbortSignal): Promise<never> {
|
|
|
1234
1300
|
});
|
|
1235
1301
|
}
|
|
1236
1302
|
|
|
1303
|
+
function countExecutableSteps(workflow: WorkflowDefinition, steps: WorkflowStepRecord[]): number {
|
|
1304
|
+
const metadata = compositionMetadata(workflow);
|
|
1305
|
+
if (metadata === undefined) return steps.length;
|
|
1306
|
+
return steps.filter(
|
|
1307
|
+
(step) =>
|
|
1308
|
+
metadata.entries[step.nodeId] === undefined && metadata.exits[step.nodeId] === undefined,
|
|
1309
|
+
).length;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
function assertInvocationStepLimit(
|
|
1313
|
+
metadata: ReturnType<typeof compositionMetadata>,
|
|
1314
|
+
nodeId: string,
|
|
1315
|
+
steps: WorkflowStepRecord[],
|
|
1316
|
+
): void {
|
|
1317
|
+
if (metadata === undefined) return;
|
|
1318
|
+
const scopes = Object.values(metadata.scopes)
|
|
1319
|
+
.filter(
|
|
1320
|
+
(candidate) =>
|
|
1321
|
+
candidate.path !== "" &&
|
|
1322
|
+
candidate.maxSteps !== undefined &&
|
|
1323
|
+
nodeId.startsWith(`${candidate.path}/`),
|
|
1324
|
+
)
|
|
1325
|
+
.sort((a, b) => a.path.length - b.path.length);
|
|
1326
|
+
for (const scope of scopes) {
|
|
1327
|
+
let entryIndex = -1;
|
|
1328
|
+
for (let index = steps.length - 1; index >= 0; index -= 1) {
|
|
1329
|
+
if (steps[index]?.nodeId === scope.path) {
|
|
1330
|
+
entryIndex = index;
|
|
1331
|
+
break;
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
if (entryIndex < 0) throw new Error(`Workflow include entry is missing: ${scope.path}`);
|
|
1335
|
+
const attempts = steps
|
|
1336
|
+
.slice(entryIndex + 1)
|
|
1337
|
+
.filter(
|
|
1338
|
+
(step) =>
|
|
1339
|
+
step.nodeId.startsWith(`${scope.path}/`) &&
|
|
1340
|
+
metadata.entries[step.nodeId] === undefined &&
|
|
1341
|
+
metadata.exits[step.nodeId] === undefined,
|
|
1342
|
+
).length;
|
|
1343
|
+
if (attempts >= (scope.maxSteps as number)) {
|
|
1344
|
+
throw new Error(
|
|
1345
|
+
`Included workflow ${scope.workflowName} at ${scope.path} exceeded maxSteps=${scope.maxSteps}`,
|
|
1346
|
+
);
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1237
1351
|
/**
|
|
1238
1352
|
* Outputs are persisted to the run bundle, so they must be JSON-serializable.
|
|
1239
1353
|
* Failing here turns a bad callback return value into a normal node failure
|
|
@@ -1255,6 +1369,25 @@ function workflowSourceMismatch(
|
|
|
1255
1369
|
);
|
|
1256
1370
|
}
|
|
1257
1371
|
|
|
1372
|
+
function workflowIdentityMismatch(
|
|
1373
|
+
state: WorkflowRunState,
|
|
1374
|
+
workflow: WorkflowDefinition,
|
|
1375
|
+
source: WorkflowSource | undefined,
|
|
1376
|
+
): boolean {
|
|
1377
|
+
if (workflowSourceMismatch(state, source)) return true;
|
|
1378
|
+
const metadata = compositionMetadata(workflow);
|
|
1379
|
+
const currentSources = metadata?.sources ?? [];
|
|
1380
|
+
if (!isDeepStrictEqual(state.workflowSources ?? [], currentSources)) return true;
|
|
1381
|
+
const currentDigest = metadata?.snapshot.mounts.length ? definitionDigest(workflow) : undefined;
|
|
1382
|
+
return state.definitionDigest !== currentDigest;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
function definitionDigest(workflow: WorkflowDefinition): string {
|
|
1386
|
+
return `sha256:${createHash("sha256")
|
|
1387
|
+
.update(JSON.stringify(createDefinitionSnapshot(workflow)))
|
|
1388
|
+
.digest("hex")}`;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1258
1391
|
function assertJsonSerializable(value: unknown, what: string): void {
|
|
1259
1392
|
let encoded: string | undefined;
|
|
1260
1393
|
try {
|
package/src/workflows/graph.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { compileWorkflowDefinition, isCompiledWorkflow } from "./composition.js";
|
|
1
2
|
import { assertValidWorkflowDefinitionShape } from "./schema.js";
|
|
2
3
|
import type { WorkflowDefinition, WorkflowEdge, WorkflowNodeResult } from "./types.js";
|
|
3
4
|
|
|
@@ -6,17 +7,18 @@ import type { WorkflowDefinition, WorkflowEdge, WorkflowNodeResult } from "./typ
|
|
|
6
7
|
* at most one outgoing edge per node.
|
|
7
8
|
*/
|
|
8
9
|
export function validateWorkflowDefinition(workflow: WorkflowDefinition): void {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
const executable = isCompiledWorkflow(workflow) ? workflow : compileWorkflowDefinition(workflow);
|
|
11
|
+
assertValidWorkflowDefinitionShape(executable, { compiled: true });
|
|
12
|
+
if (!Object.hasOwn(executable.nodes, executable.startAt)) {
|
|
13
|
+
throw new Error(`Workflow start node is missing: ${executable.startAt}`);
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
const outgoingEdges = new Set<string>();
|
|
15
|
-
for (const edge of
|
|
16
|
-
validateWorkflowEdge(
|
|
17
|
+
for (const edge of executable.edges) {
|
|
18
|
+
validateWorkflowEdge(executable, edge, outgoingEdges);
|
|
17
19
|
}
|
|
18
20
|
|
|
19
|
-
assertAllNodesReachable(
|
|
21
|
+
assertAllNodesReachable(executable);
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
/** Reject nodes that no path from `startAt` can ever reach. */
|
package/src/workflows/index.ts
CHANGED
|
@@ -4,11 +4,21 @@ export {
|
|
|
4
4
|
checkpoint,
|
|
5
5
|
compute,
|
|
6
6
|
defineWorkflow,
|
|
7
|
+
defineWorkflowRegistry,
|
|
8
|
+
includeWorkflow,
|
|
9
|
+
includedResult,
|
|
7
10
|
isWorkflowDefinition,
|
|
8
11
|
notify,
|
|
9
12
|
shell,
|
|
10
13
|
} from "./definition.js";
|
|
11
14
|
export { decision, decisionEdge, type DecisionDefinition } from "./decision.js";
|
|
15
|
+
export {
|
|
16
|
+
compileWorkflowDefinition,
|
|
17
|
+
compositionMetadata,
|
|
18
|
+
isCompiledWorkflow,
|
|
19
|
+
type CompileWorkflowOptions,
|
|
20
|
+
type WorkflowCompositionMetadata,
|
|
21
|
+
} from "./composition.js";
|
|
12
22
|
export { WorkflowEngine, appendStepContract } from "./engine.js";
|
|
13
23
|
export { CancelledError, TimeoutError } from "./errors.js";
|
|
14
24
|
export {
|
|
@@ -96,9 +106,19 @@ export type {
|
|
|
96
106
|
ShellActionResult,
|
|
97
107
|
WorkflowActionContext,
|
|
98
108
|
WorkflowActionReceipt,
|
|
109
|
+
WorkflowCompositionSnapshot,
|
|
99
110
|
WorkflowDefinition,
|
|
100
111
|
WorkflowDefinitionSnapshot,
|
|
101
112
|
WorkflowEdge,
|
|
113
|
+
WorkflowExitDefinition,
|
|
114
|
+
WorkflowExitMap,
|
|
115
|
+
WorkflowExitOutputs,
|
|
116
|
+
WorkflowIncludeDefinition,
|
|
117
|
+
WorkflowIncludedResult,
|
|
118
|
+
WorkflowInputOf,
|
|
119
|
+
WorkflowMountedSource,
|
|
120
|
+
WorkflowMountSnapshot,
|
|
121
|
+
WorkflowValueParser,
|
|
102
122
|
WorkflowEngineOptions,
|
|
103
123
|
WorkflowNodeCommon,
|
|
104
124
|
WorkflowNodeContext,
|