@osolmaz/pi-workflows 0.3.0 → 0.5.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 (74) hide show
  1. package/README.md +11 -7
  2. package/dist/builtins/catalog.d.ts +2 -0
  3. package/dist/builtins/catalog.js +32 -0
  4. package/dist/builtins/catalog.js.map +1 -0
  5. package/dist/builtins/monitor.workflow.d.ts +2 -2
  6. package/dist/builtins/monitor.workflow.js +25 -25
  7. package/dist/builtins/monitor.workflow.js.map +1 -1
  8. package/dist/controllers/index.d.ts +1 -1
  9. package/dist/controllers/index.js.map +1 -1
  10. package/dist/controllers/sqlite.d.ts +70 -9
  11. package/dist/controllers/sqlite.js +209 -35
  12. package/dist/controllers/sqlite.js.map +1 -1
  13. package/dist/controllers/workflow-engine-scheduler.d.ts +2 -2
  14. package/dist/controllers/workflow-engine-scheduler.js +3 -1
  15. package/dist/controllers/workflow-engine-scheduler.js.map +1 -1
  16. package/dist/extension/executor.d.ts +3 -0
  17. package/dist/extension/executor.js +11 -1
  18. package/dist/extension/executor.js.map +1 -1
  19. package/dist/extension/index.js +157 -108
  20. package/dist/extension/index.js.map +1 -1
  21. package/dist/host/runner.d.ts +1 -0
  22. package/dist/host/runner.js +68 -20
  23. package/dist/host/runner.js.map +1 -1
  24. package/dist/render/graph-render.js +3 -0
  25. package/dist/render/graph-render.js.map +1 -1
  26. package/dist/workflows/catalog.d.ts +43 -0
  27. package/dist/workflows/catalog.js +79 -0
  28. package/dist/workflows/catalog.js.map +1 -0
  29. package/dist/workflows/definition.d.ts +2 -1
  30. package/dist/workflows/definition.js +9 -1
  31. package/dist/workflows/definition.js.map +1 -1
  32. package/dist/workflows/engine.d.ts +6 -6
  33. package/dist/workflows/engine.js +93 -33
  34. package/dist/workflows/engine.js.map +1 -1
  35. package/dist/workflows/index.d.ts +3 -3
  36. package/dist/workflows/index.js +2 -2
  37. package/dist/workflows/index.js.map +1 -1
  38. package/dist/workflows/loader.d.ts +18 -16
  39. package/dist/workflows/loader.js +58 -23
  40. package/dist/workflows/loader.js.map +1 -1
  41. package/dist/workflows/migrate-sources.d.ts +42 -0
  42. package/dist/workflows/migrate-sources.js +133 -0
  43. package/dist/workflows/migrate-sources.js.map +1 -0
  44. package/dist/workflows/schema.d.ts +2 -1
  45. package/dist/workflows/schema.js +14 -1
  46. package/dist/workflows/schema.js.map +1 -1
  47. package/dist/workflows/store.js +5 -2
  48. package/dist/workflows/store.js.map +1 -1
  49. package/dist/workflows/types.d.ts +44 -5
  50. package/docs/development.md +5 -3
  51. package/docs/plans/2026-08-12-coordinated-workflow-timeouts-plan.md +74 -0
  52. package/docs/plans/2026-08-13-built-in-workflow-catalog-plan.md +97 -0
  53. package/docs/plans/2026-08-13-session-addressed-workflow-notifications-plan.md +95 -0
  54. package/docs/run-bundles.md +22 -4
  55. package/docs/workflows.md +57 -14
  56. package/package.json +1 -1
  57. package/src/builtins/catalog.ts +32 -0
  58. package/src/builtins/monitor.workflow.ts +33 -26
  59. package/src/controllers/index.ts +1 -0
  60. package/src/controllers/sqlite.ts +353 -43
  61. package/src/controllers/workflow-engine-scheduler.ts +5 -2
  62. package/src/extension/executor.ts +12 -1
  63. package/src/extension/index.ts +181 -140
  64. package/src/host/runner.ts +78 -20
  65. package/src/render/graph-render.ts +3 -0
  66. package/src/workflows/catalog.ts +135 -0
  67. package/src/workflows/definition.ts +11 -0
  68. package/src/workflows/engine.ts +128 -53
  69. package/src/workflows/index.ts +7 -0
  70. package/src/workflows/loader.ts +70 -26
  71. package/src/workflows/migrate-sources.ts +174 -0
  72. package/src/workflows/schema.ts +16 -1
  73. package/src/workflows/store.ts +5 -2
  74. package/src/workflows/types.ts +43 -4
@@ -30,8 +30,10 @@ import type {
30
30
  WorkflowNodeDefinition,
31
31
  WorkflowNodeOutcome,
32
32
  WorkflowNodeResult,
33
+ WorkflowNotificationSink,
33
34
  WorkflowRunResult,
34
35
  WorkflowRunState,
36
+ WorkflowSource,
35
37
  WorkflowStepRecord,
36
38
  WorkflowTraceEventDraft,
37
39
  } from "./types.js";
@@ -39,6 +41,7 @@ import type {
39
41
  const DEFAULT_NODE_TIMEOUT_MS = 15 * 60_000;
40
42
  const DEFAULT_MAX_STEPS = 100;
41
43
  const TITLE_TIMEOUT_MS = 30_000;
44
+ const TIMEOUT_RESOLUTION_TIMEOUT_MS = 30_000;
42
45
  // Covers the shell SIGTERM → SIGKILL escalation (1s) plus stdio close.
43
46
  const ABORT_CLEANUP_GRACE_MS = 2_000;
44
47
 
@@ -72,6 +75,7 @@ type NodeAttempt = {
72
75
  */
73
76
  export class WorkflowEngine {
74
77
  private readonly executor: AgentStepExecutor;
78
+ private readonly notificationSink: WorkflowNotificationSink | undefined;
75
79
  private readonly store: WorkflowRunStore;
76
80
  private readonly defaultNodeTimeoutMs: number;
77
81
  private readonly maxSteps: number;
@@ -86,6 +90,7 @@ export class WorkflowEngine {
86
90
 
87
91
  constructor(options: WorkflowEngineOptions) {
88
92
  this.executor = options.executor;
93
+ this.notificationSink = options.notificationSink;
89
94
  this.store = options.store ?? new WorkflowRunStore(options.outputRoot);
90
95
  this.defaultNodeTimeoutMs = options.defaultNodeTimeoutMs ?? DEFAULT_NODE_TIMEOUT_MS;
91
96
  this.maxSteps = options.maxSteps ?? DEFAULT_MAX_STEPS;
@@ -140,7 +145,7 @@ export class WorkflowEngine {
140
145
  async run(
141
146
  workflow: WorkflowDefinition,
142
147
  input: unknown,
143
- options: { workflowPath?: string; workflowHash?: string; runId?: string } = {},
148
+ options: { workflowSource?: WorkflowSource; runId?: string } = {},
144
149
  ): Promise<WorkflowRunResult> {
145
150
  validateWorkflowDefinition(workflow);
146
151
  // Fail before any bundle exists so bad input cannot leave a partial run
@@ -157,8 +162,7 @@ export class WorkflowEngine {
157
162
  const state = await this.createRunState(
158
163
  workflow,
159
164
  normalizedInput,
160
- options.workflowPath,
161
- options.workflowHash,
165
+ options.workflowSource,
162
166
  options.runId,
163
167
  );
164
168
  const runDir = await this.store.initializeRunBundle(workflow, state);
@@ -196,7 +200,7 @@ export class WorkflowEngine {
196
200
  async resumeRun(
197
201
  workflow: WorkflowDefinition,
198
202
  runId: string,
199
- options: { workflowHash?: string; force?: boolean } = {},
203
+ options: { workflowSource?: WorkflowSource; force?: boolean } = {},
200
204
  ): Promise<WorkflowRunResult> {
201
205
  validateWorkflowDefinition(workflow);
202
206
  // Reset before any await: a park or cancel landing during preparation
@@ -207,11 +211,8 @@ export class WorkflowEngine {
207
211
  const bundle = await this.store.prepareRunResume(runId);
208
212
  const { runDir } = bundle;
209
213
  const state = bundle.state;
210
- const hashMismatch =
211
- state.workflowHash !== undefined &&
212
- options.workflowHash !== undefined &&
213
- state.workflowHash !== options.workflowHash;
214
- if (hashMismatch && options.force !== true) {
214
+ const sourceMismatch = workflowSourceMismatch(state, options.workflowSource);
215
+ if (sourceMismatch && options.force !== true) {
215
216
  throw new WorkflowSourceChangedError(runId);
216
217
  }
217
218
 
@@ -230,7 +231,7 @@ export class WorkflowEngine {
230
231
  payload: {
231
232
  ...(point.nodeId !== null ? { resumeAt: point.nodeId } : {}),
232
233
  replayedSteps: state.steps.length,
233
- ...(hashMismatch ? { workflowHashMismatch: true, forced: true } : {}),
234
+ ...(sourceMismatch ? { workflowSourceMismatch: true, forced: true } : {}),
234
235
  },
235
236
  });
236
237
  await this.onRunStarted?.(runDir, state);
@@ -283,7 +284,7 @@ export class WorkflowEngine {
283
284
  workflow: WorkflowDefinition,
284
285
  parentRunId: string,
285
286
  input: unknown,
286
- options: { workflowPath?: string; workflowHash?: string; runId?: string; force?: boolean } = {},
287
+ options: { workflowSource?: WorkflowSource; runId?: string; force?: boolean } = {},
287
288
  ): Promise<WorkflowRunResult> {
288
289
  validateWorkflowDefinition(workflow);
289
290
  this.cancelled = false;
@@ -298,11 +299,8 @@ export class WorkflowEngine {
298
299
  `Cannot continue workflow run ${parentRunId} with status ${parent.state.status}`,
299
300
  );
300
301
  }
301
- const hashMismatch =
302
- parent.state.workflowHash !== undefined &&
303
- options.workflowHash !== undefined &&
304
- parent.state.workflowHash !== options.workflowHash;
305
- if (hashMismatch && options.force !== true) {
302
+ const sourceMismatch = workflowSourceMismatch(parent.state, options.workflowSource);
303
+ if (sourceMismatch && options.force !== true) {
306
304
  throw new WorkflowSourceChangedError(parentRunId);
307
305
  }
308
306
 
@@ -315,8 +313,7 @@ export class WorkflowEngine {
315
313
  const state = await this.createRunState(
316
314
  workflow,
317
315
  normalizedInput,
318
- options.workflowPath,
319
- options.workflowHash,
316
+ options.workflowSource,
320
317
  options.runId,
321
318
  );
322
319
  state.parentRunId = parentRunId;
@@ -477,8 +474,7 @@ export class WorkflowEngine {
477
474
  private async createRunState(
478
475
  workflow: WorkflowDefinition,
479
476
  input: unknown,
480
- workflowPath: string | undefined,
481
- workflowHash: string | undefined,
477
+ workflowSource: WorkflowSource | undefined,
482
478
  runId: string | undefined,
483
479
  ): Promise<WorkflowRunState> {
484
480
  const now = new Date().toISOString();
@@ -488,8 +484,7 @@ export class WorkflowEngine {
488
484
  runId: runId ?? createRunId(workflow.name),
489
485
  workflowName: workflow.name,
490
486
  ...(await this.resolveTitleBounded(workflow, input)),
491
- ...(workflowPath !== undefined ? { workflowPath } : {}),
492
- ...(workflowHash !== undefined ? { workflowHash } : {}),
487
+ ...(workflowSource !== undefined ? { workflowSource } : {}),
493
488
  startedAt: now,
494
489
  updatedAt: now,
495
490
  status: "running",
@@ -758,36 +753,43 @@ export class WorkflowEngine {
758
753
  node: WorkflowNodeDefinition,
759
754
  meta: NodeExecutionMeta,
760
755
  ): Promise<NodeExecution> {
761
- const timeoutMs = node.timeoutMs ?? this.defaultNodeTimeoutMs;
762
756
  const abort = new AbortController();
757
+ const context = this.createNodeContext(state, abort.signal);
758
+ let timer: NodeJS.Timeout | undefined;
759
+ let dispatchSettled: Promise<void> | undefined;
763
760
  this.activeAbort = abort;
764
- if (this.parked) {
765
- // A park that landed during the node_started persist must not let the
766
- // node dispatch: its discarded side effects would rerun on resume.
767
- throw new RunParkedError();
768
- }
769
- if (this.cancelled) {
770
- throw new CancelledError();
771
- }
772
-
773
- const timer = setTimeout(() => {
774
- abort.abort(new TimeoutError(timeoutMs));
775
- }, timeoutMs);
776
- const dispatched = this.dispatchNode(
777
- workflow,
778
- state,
779
- runDir,
780
- nodeId,
781
- attemptId,
782
- node,
783
- abort.signal,
784
- meta,
785
- );
786
- const dispatchSettled = dispatched.then(
787
- () => undefined,
788
- () => undefined,
789
- );
790
761
  try {
762
+ if (this.parked) {
763
+ // A park that landed during the node_started persist must not let the
764
+ // node dispatch: its discarded side effects would rerun on resume.
765
+ throw new RunParkedError();
766
+ }
767
+ if (this.cancelled) {
768
+ throw new CancelledError();
769
+ }
770
+
771
+ const timeoutMs = await this.resolveNodeTimeout(node, context, abort);
772
+ if (abort.signal.aborted) {
773
+ throw abortError(abort.signal);
774
+ }
775
+ timer = setTimeout(() => {
776
+ abort.abort(new TimeoutError(timeoutMs));
777
+ }, timeoutMs);
778
+ const dispatched = this.dispatchNode(
779
+ workflow,
780
+ state,
781
+ runDir,
782
+ nodeId,
783
+ attemptId,
784
+ node,
785
+ context,
786
+ abort.signal,
787
+ meta,
788
+ );
789
+ dispatchSettled = dispatched.then(
790
+ () => undefined,
791
+ () => undefined,
792
+ );
791
793
  // Race the dispatch against the abort signal so timeouts and cancel
792
794
  // take effect even for node callbacks that never observe the signal.
793
795
  const execution = await Promise.race([dispatched, abortRejection(abort.signal)]);
@@ -799,7 +801,7 @@ export class WorkflowEngine {
799
801
  assertJsonSerializable(execution.output, `Node ${nodeId} output`);
800
802
  return execution;
801
803
  } catch (error) {
802
- if (node.nodeType === "action" && "exec" in node) {
804
+ if (node.nodeType === "action" && "exec" in node && dispatchSettled !== undefined) {
803
805
  // Give the killed shell command a short grace period to close so its
804
806
  // action receipt lands in `meta` before the failed attempt persists.
805
807
  await Promise.race([
@@ -809,9 +811,37 @@ export class WorkflowEngine {
809
811
  }
810
812
  const reason: unknown = abort.signal.aborted ? abort.signal.reason : undefined;
811
813
  throw reason instanceof TimeoutError || reason instanceof CancelledError ? reason : error;
814
+ } finally {
815
+ if (timer !== undefined) {
816
+ clearTimeout(timer);
817
+ }
818
+ if (this.activeAbort === abort) {
819
+ this.activeAbort = null;
820
+ }
821
+ }
822
+ }
823
+
824
+ private async resolveNodeTimeout(
825
+ node: WorkflowNodeDefinition,
826
+ context: WorkflowNodeContext,
827
+ abort: AbortController,
828
+ ): Promise<number> {
829
+ const configured = node.timeoutMs;
830
+ if (typeof configured !== "function") {
831
+ return assertValidTimeout(configured ?? this.defaultNodeTimeoutMs);
832
+ }
833
+ const timer = setTimeout(
834
+ () => abort.abort(new TimeoutError(TIMEOUT_RESOLUTION_TIMEOUT_MS)),
835
+ TIMEOUT_RESOLUTION_TIMEOUT_MS,
836
+ );
837
+ try {
838
+ const resolved = await Promise.race([
839
+ Promise.resolve(configured(context)),
840
+ abortRejection(abort.signal),
841
+ ]);
842
+ return assertValidTimeout(resolved);
812
843
  } finally {
813
844
  clearTimeout(timer);
814
- this.activeAbort = null;
815
845
  }
816
846
  }
817
847
 
@@ -822,10 +852,10 @@ export class WorkflowEngine {
822
852
  nodeId: string,
823
853
  attemptId: string,
824
854
  node: WorkflowNodeDefinition,
855
+ context: WorkflowNodeContext,
825
856
  signal: AbortSignal,
826
857
  meta: NodeExecutionMeta,
827
858
  ): Promise<NodeExecution> {
828
- const context = this.createNodeContext(state, signal);
829
859
  switch (node.nodeType) {
830
860
  case "agent":
831
861
  return await this.runAgentNode(
@@ -841,6 +871,29 @@ export class WorkflowEngine {
841
871
  );
842
872
  case "compute":
843
873
  return { output: await node.run(context), promptText: null };
874
+ case "notify": {
875
+ if (this.notificationSink === undefined) {
876
+ throw new Error(`Workflow node ${nodeId} requires a notification sink`);
877
+ }
878
+ const content = await node.message(context);
879
+ if (typeof content !== "string" || content.trim().length === 0) {
880
+ throw new Error(`Workflow node ${nodeId} notification must be a non-empty string`);
881
+ }
882
+ const notificationIndex =
883
+ state.steps.filter(
884
+ (step) => step.nodeId === nodeId && step.nodeType === "notify" && step.outcome === "ok",
885
+ ).length + 1;
886
+ const receipt = await this.notificationSink.notify({
887
+ runId: state.runId,
888
+ workflowName: workflow.name,
889
+ nodeId,
890
+ attemptId,
891
+ notificationIndex,
892
+ kind: node.kind ?? "progress",
893
+ content: content.trim(),
894
+ });
895
+ return { output: receipt, promptText: null };
896
+ }
844
897
  case "action":
845
898
  return await this.runActionNode(node, context, signal, meta);
846
899
  case "checkpoint":
@@ -1044,7 +1097,13 @@ async function runShellActionNode(
1044
1097
  return { output, promptText: null, action: shellReceipt(result) };
1045
1098
  }
1046
1099
 
1047
- /** Rejects with the abort reason once the signal fires; never resolves. */
1100
+ function assertValidTimeout(value: number): number {
1101
+ if (!Number.isFinite(value) || value <= 0) {
1102
+ throw new Error("Node timeoutMs must resolve to a finite positive number");
1103
+ }
1104
+ return value;
1105
+ }
1106
+
1048
1107
  /** The error carried by an aborted signal, normalized to an Error. */
1049
1108
  function abortError(signal: AbortSignal): Error {
1050
1109
  const reason: unknown = signal.reason ?? new CancelledError();
@@ -1069,6 +1128,22 @@ function abortRejection(signal: AbortSignal): Promise<never> {
1069
1128
  * Failing here turns a bad callback return value into a normal node failure
1070
1129
  * instead of corrupting the run state.
1071
1130
  */
1131
+ function workflowSourceMismatch(
1132
+ state: WorkflowRunState,
1133
+ source: WorkflowSource | undefined,
1134
+ ): boolean {
1135
+ if (source === undefined) return false;
1136
+ if (state.workflowSource !== undefined) {
1137
+ return !isDeepStrictEqual(state.workflowSource, source);
1138
+ }
1139
+ // Bounded compatibility check for pre-catalog file runs. Startup normally
1140
+ // converts these records with migrateLegacyWorkflowSources first.
1141
+ return (
1142
+ state.workflowHash !== undefined &&
1143
+ (source.kind !== "file" || state.workflowHash !== source.hash)
1144
+ );
1145
+ }
1146
+
1072
1147
  function assertJsonSerializable(value: unknown, what: string): void {
1073
1148
  let encoded: string | undefined;
1074
1149
  try {
@@ -5,6 +5,7 @@ export {
5
5
  compute,
6
6
  defineWorkflow,
7
7
  isWorkflowDefinition,
8
+ notify,
8
9
  shell,
9
10
  } from "./definition.js";
10
11
  export { decision, decisionEdge, type DecisionDefinition } from "./decision.js";
@@ -21,6 +22,7 @@ export {
21
22
  discoverWorkflows,
22
23
  loadWorkflowFile,
23
24
  resolveWorkflowRef,
25
+ resolveWorkflowSource,
24
26
  workflowFileStem,
25
27
  workflowSearchDirs,
26
28
  type DiscoveredWorkflow,
@@ -64,6 +66,7 @@ export type {
64
66
  ComputeNodeDefinition,
65
67
  FunctionActionNodeDefinition,
66
68
  MaybePromise,
69
+ NotifyNodeDefinition,
67
70
  ShellActionExecution,
68
71
  ShellActionNodeDefinition,
69
72
  ShellActionResult,
@@ -78,11 +81,15 @@ export type {
78
81
  WorkflowNodeOutcome,
79
82
  WorkflowNodeResult,
80
83
  WorkflowNodeSnapshot,
84
+ WorkflowNotificationReceipt,
85
+ WorkflowNotificationRequest,
86
+ WorkflowNotificationSink,
81
87
  WorkflowPresentationContext,
82
88
  WorkflowRunManifest,
83
89
  WorkflowRunResult,
84
90
  WorkflowRunState,
85
91
  WorkflowRunStatus,
92
+ WorkflowSource,
86
93
  WorkflowSessionBinding,
87
94
  WorkflowSessionEntryRecord,
88
95
  WorkflowStepRecord,
@@ -4,14 +4,16 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
6
  import { createJiti } from "jiti";
7
+ import type { BuiltinWorkflowCatalog } from "./catalog.js";
7
8
  import { isWorkflowDefinition } from "./definition.js";
8
- import type { WorkflowDefinition } from "./types.js";
9
+ import { WorkflowSourceChangedError } from "./errors.js";
10
+ import type { WorkflowDefinition, WorkflowSource } from "./types.js";
9
11
 
10
12
  const WORKFLOW_FILE_SUFFIXES = [".workflow.ts", ".workflow.js", ".workflow.mts", ".workflow.mjs"];
11
13
 
12
14
  export type DiscoveredWorkflow = {
13
15
  name: string;
14
- path: string;
16
+ ref: string;
15
17
  source: "project" | "global" | "builtin" | "path";
16
18
  };
17
19
 
@@ -20,23 +22,27 @@ export type WorkflowSearchPaths = {
20
22
  homeDir?: string;
21
23
  };
22
24
 
23
- /** Directories scanned for workflow files, in precedence order. */
25
+ export type ResolvedWorkflow = {
26
+ definition: WorkflowDefinition;
27
+ source: WorkflowSource;
28
+ sourceKind: DiscoveredWorkflow["source"];
29
+ };
30
+
31
+ /** Directories scanned for user workflow files, in precedence order. */
24
32
  export function workflowSearchDirs(
25
33
  options: WorkflowSearchPaths,
26
- ): { dir: string; source: "project" | "global" | "builtin" }[] {
34
+ ): { dir: string; source: "project" | "global" }[] {
27
35
  const homeDir = options.homeDir ?? os.homedir();
28
- const builtinDir = fileURLToPath(new URL("../builtins/", import.meta.url));
29
36
  return [
30
37
  { dir: path.join(options.cwd, ".pi", "workflows"), source: "project" },
31
38
  { dir: path.join(homeDir, ".pi", "agent", "workflows"), source: "global" },
32
- { dir: builtinDir, source: "builtin" },
33
39
  ];
34
40
  }
35
41
 
36
- /** SHA-256 of a workflow source file, recorded in run state for resume pinning. */
42
+ /** SHA-256 of a user workflow source file. */
37
43
  export async function hashWorkflowSource(filePath: string): Promise<string> {
38
44
  return createHash("sha256")
39
- .update(await fs.readFile(filePath))
45
+ .update(await fs.readFile(path.resolve(filePath)))
40
46
  .digest("hex");
41
47
  }
42
48
 
@@ -50,12 +56,11 @@ export function workflowFileStem(filePath: string): string {
50
56
  return suffix ? base.slice(0, -suffix.length) : base;
51
57
  }
52
58
 
53
- // Alias the package name to this module's own entry so workflow files can
54
- // `import { agent } from "@osolmaz/pi-workflows"` whether the engine runs from src
55
- // (tests, tsx) or from the built dist inside the installed package.
59
+ // Alias package imports to this process's workflow API. User files can reload,
60
+ // but their node constructors and validators remain from one engine version.
56
61
  const SELF_ENTRY = path.join(path.dirname(fileURLToPath(import.meta.url)), "index");
57
62
 
58
- /** Load a workflow module from disk. The default export must be `defineWorkflow(...)`. */
63
+ /** Load a user workflow module from disk. */
59
64
  export async function loadWorkflowFile(filePath: string): Promise<WorkflowDefinition> {
60
65
  const absolutePath = path.resolve(filePath);
61
66
  const jiti = createJiti(pathToFileURL(absolutePath).href, {
@@ -70,22 +75,26 @@ export async function loadWorkflowFile(filePath: string): Promise<WorkflowDefini
70
75
  return loaded;
71
76
  }
72
77
 
73
- /** Discover named workflows in the project and global workflow directories. */
78
+ /** Discover user workflows first, then unshadowed catalog built-ins. */
74
79
  export async function discoverWorkflows(
75
80
  options: WorkflowSearchPaths,
81
+ catalog?: BuiltinWorkflowCatalog,
76
82
  ): Promise<DiscoveredWorkflow[]> {
77
83
  const discovered: DiscoveredWorkflow[] = [];
78
84
  const seenNames = new Set<string>();
79
85
  for (const { dir, source } of workflowSearchDirs(options)) {
80
86
  for (const filePath of await listWorkflowFiles(dir)) {
81
87
  const name = workflowFileStem(filePath);
82
- if (seenNames.has(name)) {
83
- continue;
84
- }
88
+ if (seenNames.has(name)) continue;
85
89
  seenNames.add(name);
86
- discovered.push({ name, path: filePath, source });
90
+ discovered.push({ name, ref: filePath, source });
87
91
  }
88
92
  }
93
+ for (const builtin of catalog?.list() ?? []) {
94
+ if (seenNames.has(builtin.definition.name)) continue;
95
+ seenNames.add(builtin.definition.name);
96
+ discovered.push({ name: builtin.definition.name, ref: builtin.ref, source: "builtin" });
97
+ }
89
98
  return discovered;
90
99
  }
91
100
 
@@ -102,26 +111,61 @@ async function listWorkflowFiles(dir: string): Promise<string[]> {
102
111
  .sort();
103
112
  }
104
113
 
105
- /**
106
- * Resolve a `/workflow` argument to a workflow file. Accepts a discovered
107
- * workflow name or a direct path to a `*.workflow.ts` file.
108
- */
114
+ /** Resolve a workflow name, stable built-in ref, or direct user file path. */
109
115
  export async function resolveWorkflowRef(
110
116
  ref: string,
111
117
  options: WorkflowSearchPaths,
112
- ): Promise<{ path: string; source: DiscoveredWorkflow["source"] }> {
118
+ catalog?: BuiltinWorkflowCatalog,
119
+ ): Promise<ResolvedWorkflow> {
120
+ if (ref.startsWith("builtin:")) {
121
+ const id = ref.slice("builtin:".length);
122
+ const builtin = catalog?.get(id);
123
+ if (builtin === undefined) throw new Error(`Unknown built-in workflow ${JSON.stringify(ref)}`);
124
+ return {
125
+ definition: builtin.definition,
126
+ source: { kind: "builtin", id: builtin.id, revision: builtin.revision },
127
+ sourceKind: "builtin",
128
+ };
129
+ }
113
130
  if (looksLikePath(ref)) {
114
131
  const absolutePath = path.resolve(options.cwd, ref);
115
132
  await fs.access(absolutePath);
116
- return { path: absolutePath, source: "path" };
133
+ return {
134
+ definition: await loadWorkflowFile(absolutePath),
135
+ source: { kind: "file", path: absolutePath, hash: await hashWorkflowSource(absolutePath) },
136
+ sourceKind: "path",
137
+ };
117
138
  }
118
- const discovered = await discoverWorkflows(options);
139
+ const discovered = await discoverWorkflows(options, catalog);
119
140
  const match = discovered.find((workflow) => workflow.name === ref);
120
- if (!match) {
141
+ if (match === undefined) {
121
142
  const available = discovered.map((workflow) => workflow.name).join(", ") || "(none)";
122
143
  throw new Error(`Unknown workflow ${JSON.stringify(ref)}. Available workflows: ${available}`);
123
144
  }
124
- return { path: match.path, source: match.source };
145
+ if (match.source === "builtin") return await resolveWorkflowRef(match.ref, options, catalog);
146
+ const absolutePath = path.resolve(match.ref);
147
+ return {
148
+ definition: await loadWorkflowFile(absolutePath),
149
+ source: { kind: "file", path: absolutePath, hash: await hashWorkflowSource(absolutePath) },
150
+ sourceKind: match.source,
151
+ };
152
+ }
153
+
154
+ /** Resolve an already persisted canonical source. */
155
+ export async function resolveWorkflowSource(
156
+ source: WorkflowSource,
157
+ catalog?: BuiltinWorkflowCatalog,
158
+ runId = source.kind === "builtin" ? `builtin:${source.id}` : source.path,
159
+ ): Promise<WorkflowDefinition> {
160
+ if (source.kind === "builtin") {
161
+ if (catalog === undefined) throw new Error(`No built-in workflow catalog for ${source.id}`);
162
+ return catalog.resolve(source, runId);
163
+ }
164
+ const actualHash = await hashWorkflowSource(source.path);
165
+ if (actualHash !== source.hash) {
166
+ throw new WorkflowSourceChangedError(runId);
167
+ }
168
+ return await loadWorkflowFile(source.path);
125
169
  }
126
170
 
127
171
  function looksLikePath(ref: string): boolean {