@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.
Files changed (75) hide show
  1. package/README.md +50 -16
  2. package/dist/builtins/autodevise.workflow.d.ts +58 -0
  3. package/dist/builtins/autodevise.workflow.js +190 -0
  4. package/dist/builtins/autodevise.workflow.js.map +1 -0
  5. package/dist/builtins/autoimplement.workflow.d.ts +154 -0
  6. package/dist/builtins/autoimplement.workflow.js +729 -0
  7. package/dist/builtins/autoimplement.workflow.js.map +1 -0
  8. package/dist/builtins/catalog.js +5 -1
  9. package/dist/builtins/catalog.js.map +1 -1
  10. package/dist/builtins/index.d.ts +3 -0
  11. package/dist/builtins/index.js +4 -0
  12. package/dist/builtins/index.js.map +1 -0
  13. package/dist/builtins/monitor.workflow.d.ts +25 -3
  14. package/dist/builtins/monitor.workflow.js +200 -13
  15. package/dist/builtins/monitor.workflow.js.map +1 -1
  16. package/dist/render/graph-render.js +13 -2
  17. package/dist/render/graph-render.js.map +1 -1
  18. package/dist/workflows/catalog.d.ts +1 -0
  19. package/dist/workflows/catalog.js +6 -0
  20. package/dist/workflows/catalog.js.map +1 -1
  21. package/dist/workflows/composition.d.ts +45 -0
  22. package/dist/workflows/composition.js +471 -0
  23. package/dist/workflows/composition.js.map +1 -0
  24. package/dist/workflows/decision.d.ts +11 -5
  25. package/dist/workflows/decision.js.map +1 -1
  26. package/dist/workflows/definition.d.ts +22 -3
  27. package/dist/workflows/definition.js +46 -3
  28. package/dist/workflows/definition.js.map +1 -1
  29. package/dist/workflows/engine.js +115 -16
  30. package/dist/workflows/engine.js.map +1 -1
  31. package/dist/workflows/graph.js +8 -6
  32. package/dist/workflows/graph.js.map +1 -1
  33. package/dist/workflows/index.d.ts +3 -2
  34. package/dist/workflows/index.js +2 -1
  35. package/dist/workflows/index.js.map +1 -1
  36. package/dist/workflows/loader.d.ts +5 -4
  37. package/dist/workflows/loader.js +118 -18
  38. package/dist/workflows/loader.js.map +1 -1
  39. package/dist/workflows/schema.d.ts +3 -1
  40. package/dist/workflows/schema.js +49 -2
  41. package/dist/workflows/schema.js.map +1 -1
  42. package/dist/workflows/store.js +32 -2
  43. package/dist/workflows/store.js.map +1 -1
  44. package/dist/workflows/types.d.ts +77 -2
  45. package/docs/CONTROLLERS.md +1 -1
  46. package/docs/DESIGN_PHILOSOPHY.md +1 -1
  47. package/docs/MONITOR.md +35 -18
  48. package/docs/WORKFLOW_COMPOSITION.md +326 -0
  49. package/docs/plans/2026-08-19-workflow-composition-plan.md +300 -0
  50. package/docs/run-bundles.md +24 -10
  51. package/docs/workflows.md +65 -12
  52. package/examples/workflows/autodevise.workflow.ts +1 -0
  53. package/examples/workflows/autoimplement.workflow.ts +1 -92
  54. package/herdr-plugin.toml +1 -1
  55. package/package.json +5 -1
  56. package/skills/monitor/SKILL.md +6 -1
  57. package/skills/pi-workflows/SKILL.md +3 -1
  58. package/src/builtins/autodevise.workflow.ts +231 -0
  59. package/src/builtins/autoimplement.workflow.ts +856 -0
  60. package/src/builtins/catalog.ts +5 -1
  61. package/src/builtins/index.ts +13 -0
  62. package/src/builtins/monitor.workflow.ts +242 -15
  63. package/src/render/graph-render.ts +14 -2
  64. package/src/workflows/catalog.ts +7 -0
  65. package/src/workflows/composition.ts +627 -0
  66. package/src/workflows/decision.ts +12 -5
  67. package/src/workflows/definition.ts +118 -8
  68. package/src/workflows/engine.ts +151 -18
  69. package/src/workflows/graph.ts +8 -6
  70. package/src/workflows/index.ts +20 -0
  71. package/src/workflows/loader.ts +186 -18
  72. package/src/workflows/schema.ts +62 -2
  73. package/src/workflows/store.ts +37 -2
  74. package/src/workflows/types.ts +109 -2
  75. 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
- export function defineWorkflow<TWorkflow extends WorkflowDefinition>(
24
- definition: TWorkflow,
25
- ): TWorkflow {
26
- assertValidWorkflowDefinitionShape(definition);
27
- if (isWorkflowDefinition(definition)) {
28
- return definition;
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(definition, WORKFLOW_DEFINITION_BRAND, {
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 isWorkflowDefinition(value: unknown): value is WorkflowDefinition {
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" &&
@@ -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 { RUN_STATE_SCHEMA, WorkflowRunStore, createRunId, readRunBundle } from "./store.js";
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 normalizedInput = input === undefined ? null : input;
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 = workflowSourceMismatch(state, options.workflowSource);
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.length,
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 = workflowSourceMismatch(parent.state, options.workflowSource);
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 normalizedInput = input === undefined ? null : input;
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.length,
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
- executedSteps += 1;
583
- if (executedSteps > maxSteps) {
584
- throw new Error(
585
- `Workflow exceeded maxSteps=${maxSteps}; aborting to avoid an unbounded loop`,
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(state: WorkflowRunState, attempt: NodeAttempt): void {
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 {
@@ -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
- assertValidWorkflowDefinitionShape(workflow);
10
- if (!Object.hasOwn(workflow.nodes, workflow.startAt)) {
11
- throw new Error(`Workflow start node is missing: ${workflow.startAt}`);
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 workflow.edges) {
16
- validateWorkflowEdge(workflow, edge, outgoingEdges);
17
+ for (const edge of executable.edges) {
18
+ validateWorkflowEdge(executable, edge, outgoingEdges);
17
19
  }
18
20
 
19
- assertAllNodesReachable(workflow);
21
+ assertAllNodesReachable(executable);
20
22
  }
21
23
 
22
24
  /** Reject nodes that no path from `startAt` can ever reach. */
@@ -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,