@osolmaz/pi-workflows 0.9.1 → 0.11.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 (147) hide show
  1. package/README.md +64 -19
  2. package/dist/builtins/autodoc.workflow.d.ts +58 -0
  3. package/dist/builtins/autodoc.workflow.js +266 -0
  4. package/dist/builtins/autodoc.workflow.js.map +1 -0
  5. package/dist/builtins/autoimplement.workflow.d.ts +353 -0
  6. package/dist/builtins/autoimplement.workflow.js +961 -0
  7. package/dist/builtins/autoimplement.workflow.js.map +1 -0
  8. package/dist/builtins/autoplan.workflow.d.ts +58 -0
  9. package/dist/builtins/autoplan.workflow.js +190 -0
  10. package/dist/builtins/autoplan.workflow.js.map +1 -0
  11. package/dist/builtins/catalog.js +9 -1
  12. package/dist/builtins/catalog.js.map +1 -1
  13. package/dist/builtins/index.d.ts +6 -0
  14. package/dist/builtins/index.js +7 -0
  15. package/dist/builtins/index.js.map +1 -0
  16. package/dist/builtins/monitor.workflow.d.ts +29 -3
  17. package/dist/builtins/monitor.workflow.js +325 -13
  18. package/dist/builtins/monitor.workflow.js.map +1 -1
  19. package/dist/builtins/plan-approval.workflow.d.ts +83 -0
  20. package/dist/builtins/plan-approval.workflow.js +148 -0
  21. package/dist/builtins/plan-approval.workflow.js.map +1 -0
  22. package/dist/builtins/plan-presentation.d.ts +7 -0
  23. package/dist/builtins/plan-presentation.js +44 -0
  24. package/dist/builtins/plan-presentation.js.map +1 -0
  25. package/dist/extension/decision-channels.d.ts +132 -0
  26. package/dist/extension/decision-channels.js +1082 -0
  27. package/dist/extension/decision-channels.js.map +1 -0
  28. package/dist/extension/index.d.ts +1 -0
  29. package/dist/extension/index.js +426 -11
  30. package/dist/extension/index.js.map +1 -1
  31. package/dist/extension/widget.js +26 -2
  32. package/dist/extension/widget.js.map +1 -1
  33. package/dist/extension/workflow-tool.d.ts +1 -37
  34. package/dist/extension/workflow-tool.js +1 -42
  35. package/dist/extension/workflow-tool.js.map +1 -1
  36. package/dist/host/rpc-bridge.js +4 -21
  37. package/dist/host/rpc-bridge.js.map +1 -1
  38. package/dist/render/graph-render.js +46 -10
  39. package/dist/render/graph-render.js.map +1 -1
  40. package/dist/viewer/render.js +36 -2
  41. package/dist/viewer/render.js.map +1 -1
  42. package/dist/workflows/catalog.d.ts +1 -0
  43. package/dist/workflows/catalog.js +6 -0
  44. package/dist/workflows/catalog.js.map +1 -1
  45. package/dist/workflows/composition.d.ts +45 -0
  46. package/dist/workflows/composition.js +485 -0
  47. package/dist/workflows/composition.js.map +1 -0
  48. package/dist/workflows/decision-presentation.d.ts +18 -0
  49. package/dist/workflows/decision-presentation.js +417 -0
  50. package/dist/workflows/decision-presentation.js.map +1 -0
  51. package/dist/workflows/decision.d.ts +11 -5
  52. package/dist/workflows/decision.js.map +1 -1
  53. package/dist/workflows/definition.d.ts +22 -3
  54. package/dist/workflows/definition.js +46 -3
  55. package/dist/workflows/definition.js.map +1 -1
  56. package/dist/workflows/engine.d.ts +2 -1
  57. package/dist/workflows/engine.js +199 -18
  58. package/dist/workflows/engine.js.map +1 -1
  59. package/dist/workflows/graph.js +8 -6
  60. package/dist/workflows/graph.js.map +1 -1
  61. package/dist/workflows/human-decision.d.ts +87 -0
  62. package/dist/workflows/human-decision.js +583 -0
  63. package/dist/workflows/human-decision.js.map +1 -0
  64. package/dist/workflows/index.d.ts +5 -2
  65. package/dist/workflows/index.js +4 -1
  66. package/dist/workflows/index.js.map +1 -1
  67. package/dist/workflows/loader.d.ts +5 -4
  68. package/dist/workflows/loader.js +118 -18
  69. package/dist/workflows/loader.js.map +1 -1
  70. package/dist/workflows/schema.d.ts +3 -1
  71. package/dist/workflows/schema.js +64 -2
  72. package/dist/workflows/schema.js.map +1 -1
  73. package/dist/workflows/store.js +39 -2
  74. package/dist/workflows/store.js.map +1 -1
  75. package/dist/workflows/tool-input.d.ts +72 -0
  76. package/dist/workflows/tool-input.js +141 -0
  77. package/dist/workflows/tool-input.js.map +1 -0
  78. package/dist/workflows/types.d.ts +300 -2
  79. package/docs/CONTROLLERS.md +1 -1
  80. package/docs/DESIGN_PHILOSOPHY.md +1 -1
  81. package/docs/HUMAN_DECISIONS.md +371 -0
  82. package/docs/HUMAN_DECISION_PRESENTATIONS.md +323 -0
  83. package/docs/MONITOR.md +46 -18
  84. package/docs/WORKFLOW_COMPOSITION.md +333 -0
  85. package/docs/plans/2026-08-19-human-decision-gates-plan.md +296 -0
  86. package/docs/plans/2026-08-19-human-decision-presentations-plan.md +172 -0
  87. package/docs/plans/2026-08-19-provider-compatible-workflow-tool-schema-plan.md +45 -0
  88. package/docs/plans/2026-08-19-workflow-composition-plan.md +308 -0
  89. package/docs/run-bundles.md +45 -10
  90. package/docs/workflows.md +106 -15
  91. package/examples/workflows/approved-plan.workflow.ts +58 -0
  92. package/examples/workflows/autoimplement.workflow.ts +1 -92
  93. package/examples/workflows/autoplan.workflow.ts +1 -0
  94. package/examples/workflows/human-decision.workflow.ts +62 -0
  95. package/herdr-plugin.toml +1 -1
  96. package/package.json +6 -1
  97. package/schemas/decision-presentation-v1.schema.json +83 -0
  98. package/schemas/human-decision-accepted-v1.schema.json +44 -0
  99. package/schemas/human-decision-accepted-v2.schema.json +50 -0
  100. package/schemas/human-decision-answer-attempt-v1.schema.json +37 -0
  101. package/schemas/human-decision-cancellation-v1.schema.json +15 -0
  102. package/schemas/human-decision-continuation-v1.schema.json +16 -0
  103. package/schemas/human-decision-delivery-v1.schema.json +28 -0
  104. package/schemas/human-decision-delivery-v2.schema.json +36 -0
  105. package/schemas/human-decision-receipt-v1.schema.json +33 -0
  106. package/schemas/human-decision-receipt-v2.schema.json +39 -0
  107. package/schemas/human-decision-request-v1.schema.json +59 -0
  108. package/schemas/human-decision-request-v2.schema.json +69 -0
  109. package/schemas/human-decision-resolution-v1.schema.json +27 -0
  110. package/schemas/human-decision-resolution-v2.schema.json +27 -0
  111. package/schemas/human-decision-settlement-v1.schema.json +28 -0
  112. package/skills/autodoc/SKILL.md +43 -0
  113. package/skills/autoimplement/SKILL.md +58 -0
  114. package/skills/autoimplement/agents/openai.yaml +4 -0
  115. package/skills/autoplan/SKILL.md +25 -0
  116. package/skills/monitor/SKILL.md +8 -1
  117. package/skills/pi-workflows/SKILL.md +5 -3
  118. package/src/builtins/autodoc.workflow.ts +325 -0
  119. package/src/builtins/autoimplement.workflow.ts +1123 -0
  120. package/src/builtins/autoplan.workflow.ts +231 -0
  121. package/src/builtins/catalog.ts +9 -1
  122. package/src/builtins/index.ts +32 -0
  123. package/src/builtins/monitor.workflow.ts +396 -15
  124. package/src/builtins/plan-approval.workflow.ts +190 -0
  125. package/src/builtins/plan-presentation.ts +57 -0
  126. package/src/extension/decision-channels.ts +1533 -0
  127. package/src/extension/index.ts +499 -9
  128. package/src/extension/widget.ts +39 -2
  129. package/src/extension/workflow-tool.ts +5 -81
  130. package/src/host/rpc-bridge.ts +7 -30
  131. package/src/render/graph-render.ts +54 -5
  132. package/src/viewer/render.ts +56 -3
  133. package/src/workflows/catalog.ts +7 -0
  134. package/src/workflows/composition.ts +642 -0
  135. package/src/workflows/decision-presentation.ts +501 -0
  136. package/src/workflows/decision.ts +12 -5
  137. package/src/workflows/definition.ts +118 -8
  138. package/src/workflows/engine.ts +257 -20
  139. package/src/workflows/graph.ts +8 -6
  140. package/src/workflows/human-decision.ts +819 -0
  141. package/src/workflows/index.ts +89 -0
  142. package/src/workflows/loader.ts +186 -18
  143. package/src/workflows/schema.ts +79 -2
  144. package/src/workflows/store.ts +45 -2
  145. package/src/workflows/tool-input.ts +202 -0
  146. package/src/workflows/types.ts +374 -2
  147. package/examples/workflows/elegant-solution.workflow.ts +0 -95
@@ -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,
@@ -12,10 +17,22 @@ import {
12
17
  WorkflowSourceChangedError,
13
18
  } from "./errors.js";
14
19
  import { resolveNext, resolveNextForOutcome, validateWorkflowDefinition } from "./graph.js";
20
+ import {
21
+ HumanDecisionStore,
22
+ createHumanDecisionRequest,
23
+ validateHumanDecisionResponse,
24
+ } from "./human-decision.js";
15
25
  import { extractJsonValue } from "./json.js";
16
26
  import { runShellAction, shellResultFromError } from "./shell.js";
17
- import { RUN_STATE_SCHEMA, WorkflowRunStore, createRunId, readRunBundle } from "./store.js";
27
+ import {
28
+ RUN_STATE_SCHEMA,
29
+ WorkflowRunStore,
30
+ createDefinitionSnapshot,
31
+ createRunId,
32
+ readRunBundle,
33
+ } from "./store.js";
18
34
  import type {
35
+ AcceptedHumanDecision,
19
36
  AgentNodeDefinition,
20
37
  AgentStepExecutor,
21
38
  ActionNodeDefinition,
@@ -32,6 +49,7 @@ import type {
32
49
  WorkflowNodeOutcome,
33
50
  WorkflowNodeResult,
34
51
  WorkflowNotificationSink,
52
+ HumanDecisionRequest,
35
53
  WorkflowRunResult,
36
54
  WorkflowRunState,
37
55
  WorkflowSource,
@@ -215,10 +233,12 @@ export class WorkflowEngine {
215
233
  input: unknown,
216
234
  options: { workflowSource?: WorkflowSource; runId?: string } = {},
217
235
  ): Promise<WorkflowRunResult> {
236
+ workflow = isCompiledWorkflow(workflow) ? workflow : compileWorkflowDefinition(workflow);
218
237
  validateWorkflowDefinition(workflow);
219
238
  // Fail before any bundle exists so bad input cannot leave a partial run
220
239
  // on disk or silently change shape when state.json round-trips.
221
- const normalizedInput = input === undefined ? null : input;
240
+ const suppliedInput = input === undefined ? null : input;
241
+ const normalizedInput = workflow.input ? await workflow.input(suppliedInput) : suppliedInput;
222
242
  assertJsonSerializable(normalizedInput, "Workflow run input");
223
243
  if (options.runId !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(options.runId)) {
224
244
  throw new Error(`Invalid workflow run id: ${JSON.stringify(options.runId)}`);
@@ -270,6 +290,7 @@ export class WorkflowEngine {
270
290
  runId: string,
271
291
  options: { workflowSource?: WorkflowSource; force?: boolean } = {},
272
292
  ): Promise<WorkflowRunResult> {
293
+ workflow = isCompiledWorkflow(workflow) ? workflow : compileWorkflowDefinition(workflow);
273
294
  validateWorkflowDefinition(workflow);
274
295
  // Reset before any await: a park or cancel landing during preparation
275
296
  // must survive, or a host drain would hang while the run executes.
@@ -279,7 +300,7 @@ export class WorkflowEngine {
279
300
  const bundle = await this.store.prepareRunResume(runId);
280
301
  const { runDir } = bundle;
281
302
  const state = bundle.state;
282
- const sourceMismatch = workflowSourceMismatch(state, options.workflowSource);
303
+ const sourceMismatch = workflowIdentityMismatch(state, workflow, options.workflowSource);
283
304
  if (sourceMismatch && options.force !== true) {
284
305
  throw new WorkflowSourceChangedError(runId);
285
306
  }
@@ -330,7 +351,7 @@ export class WorkflowEngine {
330
351
  state,
331
352
  runDir,
332
353
  point.nodeId,
333
- state.steps.length,
354
+ countExecutableSteps(workflow, state.steps),
334
355
  point.lastOutput,
335
356
  );
336
357
  } catch (error) {
@@ -352,8 +373,14 @@ export class WorkflowEngine {
352
373
  workflow: WorkflowDefinition,
353
374
  parentRunId: string,
354
375
  input: unknown,
355
- options: { workflowSource?: WorkflowSource; runId?: string; force?: boolean } = {},
376
+ options: {
377
+ workflowSource?: WorkflowSource;
378
+ runId?: string;
379
+ force?: boolean;
380
+ humanDecision?: AcceptedHumanDecision;
381
+ } = {},
356
382
  ): Promise<WorkflowRunResult> {
383
+ workflow = isCompiledWorkflow(workflow) ? workflow : compileWorkflowDefinition(workflow);
357
384
  validateWorkflowDefinition(workflow);
358
385
  this.cancelled = false;
359
386
  this.paused = false;
@@ -367,12 +394,44 @@ export class WorkflowEngine {
367
394
  `Cannot continue workflow run ${parentRunId} with status ${parent.state.status}`,
368
395
  );
369
396
  }
370
- const sourceMismatch = workflowSourceMismatch(parent.state, options.workflowSource);
397
+ const sourceMismatch = workflowIdentityMismatch(parent.state, workflow, options.workflowSource);
371
398
  if (sourceMismatch && options.force !== true) {
372
399
  throw new WorkflowSourceChangedError(parentRunId);
373
400
  }
374
401
 
375
- const normalizedInput = input === undefined ? null : input;
402
+ const waitingNodeId = parent.state.waitingOn;
403
+ const waitingNode = workflow.nodes[waitingNodeId];
404
+ const humanContract =
405
+ waitingNode?.nodeType === "checkpoint" ? waitingNode.humanDecision : undefined;
406
+ let acceptedResponse: unknown;
407
+ let acceptedNodeId: string | undefined;
408
+ let normalizedInput: unknown;
409
+ if (humanContract !== undefined) {
410
+ if (options.humanDecision === undefined) {
411
+ throw new Error(`Checkpoint ${waitingNodeId} requires an accepted verified human decision`);
412
+ }
413
+ const request = parent.state.finalOutput as HumanDecisionRequest;
414
+ if (
415
+ (request?.schema !== "pi-workflows.human-decision-request.v1" &&
416
+ request?.schema !== "pi-workflows.human-decision-request.v2") ||
417
+ request.decisionId !== options.humanDecision.decisionId ||
418
+ request.requestDigest !== options.humanDecision.requestDigest
419
+ ) {
420
+ throw new Error("Accepted human decision does not match the waiting request");
421
+ }
422
+ const durableDecision = await new HumanDecisionStore(this.store.outputRoot).readAccepted(
423
+ request.decisionId,
424
+ );
425
+ if (durableDecision === null || !isDeepStrictEqual(durableDecision, options.humanDecision)) {
426
+ throw new Error("Accepted human decision does not match the durable decision record");
427
+ }
428
+ acceptedResponse = validateHumanDecisionResponse(request, durableDecision.response);
429
+ acceptedNodeId = request.nodeId;
430
+ normalizedInput = await resolveArtifacts(parent.state.input, parent.runDir);
431
+ } else {
432
+ const suppliedInput = input === undefined ? null : input;
433
+ normalizedInput = workflow.input ? await workflow.input(suppliedInput) : suppliedInput;
434
+ }
376
435
  assertJsonSerializable(normalizedInput, "Workflow run input");
377
436
  if (options.runId !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(options.runId)) {
378
437
  throw new Error(`Invalid workflow run id: ${JSON.stringify(options.runId)}`);
@@ -399,6 +458,39 @@ export class WorkflowEngine {
399
458
  parent.state.steps,
400
459
  parent.runDir,
401
460
  )) as WorkflowRunState["steps"];
461
+ if (humanContract !== undefined && options.humanDecision !== undefined) {
462
+ const receipt = {
463
+ decisionId: options.humanDecision.decisionId,
464
+ requestDigest: options.humanDecision.requestDigest,
465
+ nodeId: acceptedNodeId ?? waitingNodeId,
466
+ response: options.humanDecision.response,
467
+ acceptedAt: options.humanDecision.acceptedAt,
468
+ answerDigest: options.humanDecision.answerDigest,
469
+ };
470
+ state.humanDecision =
471
+ options.humanDecision.schema === "pi-workflows.human-decision-accepted.v2"
472
+ ? {
473
+ schema: "pi-workflows.human-decision-receipt.v2",
474
+ ...receipt,
475
+ subjectDigest: options.humanDecision.subjectDigest,
476
+ presentationDigest: options.humanDecision.presentationDigest,
477
+ revision: options.humanDecision.revision,
478
+ }
479
+ : { schema: "pi-workflows.human-decision-receipt.v1", ...receipt };
480
+ state.outputs[waitingNodeId] = acceptedResponse;
481
+ const priorResult = state.results[waitingNodeId];
482
+ if (priorResult === undefined) {
483
+ throw new Error(`Waiting human decision result is missing for ${waitingNodeId}`);
484
+ }
485
+ state.results[waitingNodeId] = { ...priorResult, output: acceptedResponse };
486
+ const stepIndex = state.steps.findLastIndex((step) => step.nodeId === waitingNodeId);
487
+ if (stepIndex < 0)
488
+ throw new Error(`Waiting human decision step is missing for ${waitingNodeId}`);
489
+ const priorStep = state.steps[stepIndex];
490
+ if (priorStep === undefined)
491
+ throw new Error("Waiting human decision step became unavailable");
492
+ state.steps[stepIndex] = { ...priorStep, output: acceptedResponse };
493
+ }
402
494
  state.carriedStepCount = state.steps.length;
403
495
 
404
496
  const runDir = await this.store.initializeRunBundle(workflow, state);
@@ -428,7 +520,7 @@ export class WorkflowEngine {
428
520
  state,
429
521
  runDir,
430
522
  point.nodeId,
431
- state.steps.length,
523
+ countExecutableSteps(workflow, state.steps),
432
524
  point.lastOutput,
433
525
  );
434
526
  } catch (error) {
@@ -546,6 +638,7 @@ export class WorkflowEngine {
546
638
  runId: string | undefined,
547
639
  ): Promise<WorkflowRunState> {
548
640
  const now = new Date().toISOString();
641
+ const composition = compositionMetadata(workflow);
549
642
  return {
550
643
  schema: RUN_STATE_SCHEMA,
551
644
  traceSeq: 0,
@@ -553,6 +646,10 @@ export class WorkflowEngine {
553
646
  workflowName: workflow.name,
554
647
  ...(await this.resolveTitleBounded(workflow, input)),
555
648
  ...(workflowSource !== undefined ? { workflowSource } : {}),
649
+ ...(composition?.sources.length ? { workflowSources: composition.sources } : {}),
650
+ ...(composition?.snapshot.mounts.length
651
+ ? { definitionDigest: definitionDigest(workflow) }
652
+ : {}),
556
653
  startedAt: now,
557
654
  updatedAt: now,
558
655
  status: "running",
@@ -573,17 +670,24 @@ export class WorkflowEngine {
573
670
  initialLastOutput?: unknown,
574
671
  ): Promise<void> {
575
672
  const maxSteps = workflow.maxSteps ?? this.maxSteps;
673
+ const composition = compositionMetadata(workflow);
576
674
  let currentNodeId: string | null = startNodeId;
577
675
  let executedSteps = executedStepsBase;
578
676
  let lastOutput: unknown = initialLastOutput;
579
677
 
580
678
  while (currentNodeId !== null) {
581
679
  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
- );
680
+ const isTransition =
681
+ composition?.entries[currentNodeId] !== undefined ||
682
+ composition?.exits[currentNodeId] !== undefined;
683
+ if (!isTransition) {
684
+ executedSteps += 1;
685
+ if (executedSteps > maxSteps) {
686
+ throw new Error(
687
+ `Workflow exceeded maxSteps=${maxSteps}; aborting to avoid an unbounded loop`,
688
+ );
689
+ }
690
+ assertInvocationStepLimit(composition, currentNodeId, state.steps);
587
691
  }
588
692
 
589
693
  const node = workflow.nodes[currentNodeId];
@@ -597,7 +701,7 @@ export class WorkflowEngine {
597
701
  // as in-flight, and resume reruns it with a fresh attempt.
598
702
  throw new RunParkedError();
599
703
  }
600
- this.recordAttempt(state, attempt);
704
+ this.recordAttempt(workflow, state, attempt);
601
705
  // The terminal node event carries the output, receipt, and conversation
602
706
  // linkage so the trace alone is sufficient to reconstruct the run.
603
707
  await this.persist(runDir, state, {
@@ -622,6 +726,35 @@ export class WorkflowEngine {
622
726
  continue;
623
727
  }
624
728
 
729
+ const entered = composition?.entries[attempt.result.nodeId];
730
+ if (entered !== undefined) {
731
+ const value = attempt.result.output as { invocation?: number } | undefined;
732
+ await this.persist(runDir, state, {
733
+ scope: "run",
734
+ type: "include_entered",
735
+ payload: {
736
+ mountPath: entered.mountPath.split("/"),
737
+ workflowName: entered.workflowName,
738
+ invocation: value?.invocation ?? 1,
739
+ },
740
+ });
741
+ }
742
+ const exited = composition?.exits[attempt.result.nodeId];
743
+ if (exited !== undefined) {
744
+ const entrySteps = state.steps.filter((step) => step.nodeId === exited.mountPath);
745
+ await this.persist(runDir, state, {
746
+ scope: "run",
747
+ type: "include_exited",
748
+ payload: {
749
+ mountPath: exited.mountPath.split("/"),
750
+ workflowName: composition?.scopes[exited.mountPath]?.workflowName ?? exited.mountName,
751
+ invocation: entrySteps.length,
752
+ exit: exited.exitName,
753
+ output: attempt.result.output ?? null,
754
+ },
755
+ });
756
+ }
757
+
625
758
  lastOutput = attempt.result.output;
626
759
  if (node.nodeType === "checkpoint") {
627
760
  await this.finishRun(runDir, state, "waiting", {
@@ -678,13 +811,13 @@ export class WorkflowEngine {
678
811
  state: WorkflowRunState,
679
812
  attempt: NodeAttempt,
680
813
  ): string | null {
814
+ if (attempt.result.outcome === "cancelled" || this.cancelled) {
815
+ throw new CancelledError();
816
+ }
681
817
  const next = resolveNextForOutcome(workflow.edges, attempt.result.nodeId, attempt.result);
682
818
  if (next !== null) {
683
819
  return next;
684
820
  }
685
- if (attempt.result.outcome === "cancelled" || this.cancelled) {
686
- throw new CancelledError();
687
- }
688
821
  if (attempt.result.outcome === "timed_out") {
689
822
  state.status = "timed_out";
690
823
  }
@@ -693,7 +826,11 @@ export class WorkflowEngine {
693
826
  : new Error(attempt.result.error ?? `Workflow node failed: ${attempt.result.nodeId}`);
694
827
  }
695
828
 
696
- private recordAttempt(state: WorkflowRunState, attempt: NodeAttempt): void {
829
+ private recordAttempt(
830
+ workflow: WorkflowDefinition,
831
+ state: WorkflowRunState,
832
+ attempt: NodeAttempt,
833
+ ): void {
697
834
  state.results[attempt.result.nodeId] = attempt.result;
698
835
  if (attempt.result.outcome === "ok") {
699
836
  state.outputs[attempt.result.nodeId] = attempt.result.output;
@@ -702,6 +839,11 @@ export class WorkflowEngine {
702
839
  // must not survive next to a non-ok latest result.
703
840
  delete state.outputs[attempt.result.nodeId];
704
841
  }
842
+ const exit = compositionMetadata(workflow)?.exits[attempt.result.nodeId];
843
+ if (exit !== undefined && attempt.result.outcome === "ok") {
844
+ state.outputs[exit.mountPath] = attempt.result.output;
845
+ state.results[exit.mountPath] = { ...attempt.result, nodeId: exit.mountPath };
846
+ }
705
847
  const step: WorkflowStepRecord = {
706
848
  attemptId: attempt.result.attemptId,
707
849
  nodeId: attempt.result.nodeId,
@@ -977,7 +1119,12 @@ export class WorkflowEngine {
977
1119
  case "action":
978
1120
  return await this.runActionNode(node, context, nodeId, attemptId, signal, meta);
979
1121
  case "checkpoint":
980
- return await runCheckpointNode(node, context);
1122
+ return await runCheckpointNode(node, context, {
1123
+ store: this.store,
1124
+ workflowName: workflow.name,
1125
+ nodeId,
1126
+ attemptId,
1127
+ });
981
1128
  }
982
1129
  }
983
1130
 
@@ -1154,7 +1301,30 @@ export class WorkflowEngine {
1154
1301
  async function runCheckpointNode(
1155
1302
  node: CheckpointNodeDefinition,
1156
1303
  context: WorkflowNodeContext,
1304
+ execution: {
1305
+ store: WorkflowRunStore;
1306
+ workflowName: string;
1307
+ nodeId: string;
1308
+ attemptId: string;
1309
+ },
1157
1310
  ): Promise<NodeExecution> {
1311
+ if (node.humanDecision !== undefined) {
1312
+ const prompt = await node.humanDecision.request(context);
1313
+ const audience =
1314
+ typeof node.humanDecision.audience === "function"
1315
+ ? await node.humanDecision.audience(context)
1316
+ : node.humanDecision.audience;
1317
+ const request = createHumanDecisionRequest({
1318
+ runId: context.state.runId,
1319
+ workflowName: execution.workflowName,
1320
+ nodeId: execution.nodeId,
1321
+ attemptId: execution.attemptId,
1322
+ contract: { audience, choices: node.humanDecision.choices },
1323
+ prompt,
1324
+ });
1325
+ await new HumanDecisionStore(execution.store.outputRoot).createRequest(request);
1326
+ return { output: request, promptText: null };
1327
+ }
1158
1328
  const output = node.run ? await node.run(context) : { summary: node.summary ?? "checkpoint" };
1159
1329
  return { output, promptText: null };
1160
1330
  }
@@ -1234,6 +1404,54 @@ function abortRejection(signal: AbortSignal): Promise<never> {
1234
1404
  });
1235
1405
  }
1236
1406
 
1407
+ function countExecutableSteps(workflow: WorkflowDefinition, steps: WorkflowStepRecord[]): number {
1408
+ const metadata = compositionMetadata(workflow);
1409
+ if (metadata === undefined) return steps.length;
1410
+ return steps.filter(
1411
+ (step) =>
1412
+ metadata.entries[step.nodeId] === undefined && metadata.exits[step.nodeId] === undefined,
1413
+ ).length;
1414
+ }
1415
+
1416
+ function assertInvocationStepLimit(
1417
+ metadata: ReturnType<typeof compositionMetadata>,
1418
+ nodeId: string,
1419
+ steps: WorkflowStepRecord[],
1420
+ ): void {
1421
+ if (metadata === undefined) return;
1422
+ const scopes = Object.values(metadata.scopes)
1423
+ .filter(
1424
+ (candidate) =>
1425
+ candidate.path !== "" &&
1426
+ candidate.maxSteps !== undefined &&
1427
+ nodeId.startsWith(`${candidate.path}/`),
1428
+ )
1429
+ .sort((a, b) => a.path.length - b.path.length);
1430
+ for (const scope of scopes) {
1431
+ let entryIndex = -1;
1432
+ for (let index = steps.length - 1; index >= 0; index -= 1) {
1433
+ if (steps[index]?.nodeId === scope.path) {
1434
+ entryIndex = index;
1435
+ break;
1436
+ }
1437
+ }
1438
+ if (entryIndex < 0) throw new Error(`Workflow include entry is missing: ${scope.path}`);
1439
+ const attempts = steps
1440
+ .slice(entryIndex + 1)
1441
+ .filter(
1442
+ (step) =>
1443
+ step.nodeId.startsWith(`${scope.path}/`) &&
1444
+ metadata.entries[step.nodeId] === undefined &&
1445
+ metadata.exits[step.nodeId] === undefined,
1446
+ ).length;
1447
+ if (attempts >= (scope.maxSteps as number)) {
1448
+ throw new Error(
1449
+ `Included workflow ${scope.workflowName} at ${scope.path} exceeded maxSteps=${scope.maxSteps}`,
1450
+ );
1451
+ }
1452
+ }
1453
+ }
1454
+
1237
1455
  /**
1238
1456
  * Outputs are persisted to the run bundle, so they must be JSON-serializable.
1239
1457
  * Failing here turns a bad callback return value into a normal node failure
@@ -1255,6 +1473,25 @@ function workflowSourceMismatch(
1255
1473
  );
1256
1474
  }
1257
1475
 
1476
+ function workflowIdentityMismatch(
1477
+ state: WorkflowRunState,
1478
+ workflow: WorkflowDefinition,
1479
+ source: WorkflowSource | undefined,
1480
+ ): boolean {
1481
+ if (workflowSourceMismatch(state, source)) return true;
1482
+ const metadata = compositionMetadata(workflow);
1483
+ const currentSources = metadata?.sources ?? [];
1484
+ if (!isDeepStrictEqual(state.workflowSources ?? [], currentSources)) return true;
1485
+ const currentDigest = metadata?.snapshot.mounts.length ? definitionDigest(workflow) : undefined;
1486
+ return state.definitionDigest !== currentDigest;
1487
+ }
1488
+
1489
+ function definitionDigest(workflow: WorkflowDefinition): string {
1490
+ return `sha256:${createHash("sha256")
1491
+ .update(JSON.stringify(createDefinitionSnapshot(workflow)))
1492
+ .digest("hex")}`;
1493
+ }
1494
+
1258
1495
  function assertJsonSerializable(value: unknown, what: string): void {
1259
1496
  let encoded: string | undefined;
1260
1497
  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. */