@osolmaz/pi-workflows 0.3.0 → 0.4.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 (61) hide show
  1. package/README.md +3 -2
  2. package/dist/builtins/catalog.d.ts +2 -0
  3. package/dist/builtins/catalog.js +22 -0
  4. package/dist/builtins/catalog.js.map +1 -0
  5. package/dist/builtins/monitor.workflow.js +17 -1
  6. package/dist/builtins/monitor.workflow.js.map +1 -1
  7. package/dist/controllers/sqlite.d.ts +27 -4
  8. package/dist/controllers/sqlite.js +83 -5
  9. package/dist/controllers/sqlite.js.map +1 -1
  10. package/dist/controllers/workflow-engine-scheduler.d.ts +2 -2
  11. package/dist/controllers/workflow-engine-scheduler.js +3 -1
  12. package/dist/controllers/workflow-engine-scheduler.js.map +1 -1
  13. package/dist/extension/executor.d.ts +3 -0
  14. package/dist/extension/executor.js +11 -1
  15. package/dist/extension/executor.js.map +1 -1
  16. package/dist/extension/index.js +84 -29
  17. package/dist/extension/index.js.map +1 -1
  18. package/dist/host/runner.d.ts +1 -0
  19. package/dist/host/runner.js +48 -19
  20. package/dist/host/runner.js.map +1 -1
  21. package/dist/workflows/catalog.d.ts +43 -0
  22. package/dist/workflows/catalog.js +79 -0
  23. package/dist/workflows/catalog.js.map +1 -0
  24. package/dist/workflows/engine.d.ts +5 -6
  25. package/dist/workflows/engine.js +71 -33
  26. package/dist/workflows/engine.js.map +1 -1
  27. package/dist/workflows/index.d.ts +2 -2
  28. package/dist/workflows/index.js +1 -1
  29. package/dist/workflows/index.js.map +1 -1
  30. package/dist/workflows/loader.d.ts +18 -16
  31. package/dist/workflows/loader.js +58 -23
  32. package/dist/workflows/loader.js.map +1 -1
  33. package/dist/workflows/migrate-sources.d.ts +41 -0
  34. package/dist/workflows/migrate-sources.js +129 -0
  35. package/dist/workflows/migrate-sources.js.map +1 -0
  36. package/dist/workflows/schema.js +2 -1
  37. package/dist/workflows/schema.js.map +1 -1
  38. package/dist/workflows/store.js +2 -2
  39. package/dist/workflows/store.js.map +1 -1
  40. package/dist/workflows/types.d.ts +18 -4
  41. package/docs/development.md +5 -3
  42. package/docs/plans/2026-08-12-coordinated-workflow-timeouts-plan.md +74 -0
  43. package/docs/plans/2026-08-13-built-in-workflow-catalog-plan.md +97 -0
  44. package/docs/run-bundles.md +22 -4
  45. package/docs/workflows.md +34 -10
  46. package/package.json +1 -1
  47. package/src/builtins/catalog.ts +22 -0
  48. package/src/builtins/monitor.workflow.ts +25 -1
  49. package/src/controllers/sqlite.ts +128 -10
  50. package/src/controllers/workflow-engine-scheduler.ts +5 -2
  51. package/src/extension/executor.ts +12 -1
  52. package/src/extension/index.ts +106 -37
  53. package/src/host/runner.ts +58 -19
  54. package/src/workflows/catalog.ts +135 -0
  55. package/src/workflows/engine.ts +102 -53
  56. package/src/workflows/index.ts +2 -0
  57. package/src/workflows/loader.ts +70 -26
  58. package/src/workflows/migrate-sources.ts +167 -0
  59. package/src/workflows/schema.ts +2 -1
  60. package/src/workflows/store.ts +2 -2
  61. package/src/workflows/types.ts +13 -4
@@ -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 {
@@ -0,0 +1,167 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import type { BuiltinWorkflowCatalog } from "./catalog.js";
5
+ import { WorkflowRunStore, listRunBundles } from "./store.js";
6
+ import type { WorkflowRunState, WorkflowSource } from "./types.js";
7
+
8
+ export type LegacySourceMigrationQueue = {
9
+ repairCanonicalWorkflowSourceRun(options: {
10
+ runId: string;
11
+ workflowName: string;
12
+ workflowSourceRef: string;
13
+ runnerId: string;
14
+ claimToken: string;
15
+ leaseMs: number;
16
+ }): "unchanged" | "claimed" | false;
17
+ claimLegacyWorkflowSourceRun(options: {
18
+ runId: string;
19
+ workflowName: string;
20
+ oldWorkflowPath: string;
21
+ workflowSourceRef: string;
22
+ runnerId: string;
23
+ claimToken: string;
24
+ leaseMs: number;
25
+ }): boolean;
26
+ parkWorkflowRun(options: { runId: string; claimToken: string }): boolean;
27
+ getWorkflowRun(runId: string): { status: "claimed" | "parked" | "done" } | undefined;
28
+ };
29
+
30
+ const MIGRATION_LEASE_MS = 30_000;
31
+
32
+ export type LegacySourceMigrationResult = {
33
+ migratedRunIds: string[];
34
+ blocked: { runId: string; reason: string }[];
35
+ };
36
+
37
+ /** One bounded migration for nonterminal runs created before canonical sources. */
38
+ export async function migrateLegacyWorkflowSources(options: {
39
+ catalog: BuiltinWorkflowCatalog;
40
+ store?: WorkflowRunStore;
41
+ queue?: LegacySourceMigrationQueue;
42
+ }): Promise<LegacySourceMigrationResult> {
43
+ const store = options.store ?? new WorkflowRunStore();
44
+ const result: LegacySourceMigrationResult = { migratedRunIds: [], blocked: [] };
45
+ for (const bundle of await listRunBundles(store.outputRoot)) {
46
+ const state = bundle.state;
47
+ if (state.status !== "running" && state.status !== "waiting") continue;
48
+ if (state.workflowSource !== undefined) {
49
+ if (options.queue === undefined) continue;
50
+ const claimToken = randomUUID();
51
+ const repaired = options.queue.repairCanonicalWorkflowSourceRun({
52
+ runId: state.runId,
53
+ workflowName: state.workflowName,
54
+ workflowSourceRef: sourceRef(state.workflowSource),
55
+ runnerId: `source-migration-${process.pid}`,
56
+ claimToken,
57
+ leaseMs: MIGRATION_LEASE_MS,
58
+ });
59
+ if (repaired === false) {
60
+ // Another runner can own a canonical run during startup. Its source is
61
+ // already safe; the normal queue lease will make it claimable later.
62
+ } else if (
63
+ repaired === "claimed" &&
64
+ !options.queue.parkWorkflowRun({ runId: state.runId, claimToken })
65
+ ) {
66
+ result.blocked.push({
67
+ runId: state.runId,
68
+ reason: "canonical queue repair could not release its claim",
69
+ });
70
+ }
71
+ continue;
72
+ }
73
+ if (state.workflowPath === undefined || state.workflowHash === undefined) {
74
+ result.blocked.push({ runId: state.runId, reason: "legacy workflow identity is incomplete" });
75
+ continue;
76
+ }
77
+ const legacyPath = {
78
+ workflowName: state.workflowName,
79
+ workflowPath: state.workflowPath,
80
+ };
81
+ const pathEntry = options.catalog.legacyPathEntry(legacyPath);
82
+ let workflowSource: WorkflowSource;
83
+ let workflowSourceRef: string;
84
+ if (pathEntry === undefined) {
85
+ workflowSource = {
86
+ kind: "file",
87
+ path: path.resolve(state.workflowPath),
88
+ hash: state.workflowHash,
89
+ };
90
+ workflowSourceRef = workflowSource.path;
91
+ } else {
92
+ const legacy = options.catalog.matchLegacy({
93
+ ...legacyPath,
94
+ workflowHash: state.workflowHash,
95
+ });
96
+ if (legacy === undefined) {
97
+ const reason = `legacy built-in ${pathEntry.id} has an unknown source revision`;
98
+ result.blocked.push({ runId: state.runId, reason });
99
+ continue;
100
+ }
101
+ workflowSource = {
102
+ kind: "builtin",
103
+ id: legacy.entry.id,
104
+ revision: legacy.revision,
105
+ };
106
+ workflowSourceRef = legacy.entry.ref;
107
+ }
108
+ const claimToken = randomUUID();
109
+ const queueIsDone = options.queue?.getWorkflowRun(state.runId)?.status === "done";
110
+ if (
111
+ options.queue !== undefined &&
112
+ !queueIsDone &&
113
+ !options.queue.claimLegacyWorkflowSourceRun({
114
+ runId: state.runId,
115
+ workflowName: state.workflowName,
116
+ oldWorkflowPath: state.workflowPath,
117
+ workflowSourceRef,
118
+ runnerId: `source-migration-${process.pid}`,
119
+ claimToken,
120
+ leaseMs: MIGRATION_LEASE_MS,
121
+ })
122
+ ) {
123
+ result.blocked.push({
124
+ runId: state.runId,
125
+ reason: "matching queue row is active or unavailable",
126
+ });
127
+ continue;
128
+ }
129
+ const migrated: WorkflowRunState = {
130
+ ...state,
131
+ workflowSource,
132
+ };
133
+ delete migrated.workflowPath;
134
+ delete migrated.workflowHash;
135
+ const migratedManifest = {
136
+ ...bundle.manifest,
137
+ workflowSource: migrated.workflowSource,
138
+ };
139
+ delete (migratedManifest as typeof migratedManifest & { workflowPath?: string }).workflowPath;
140
+ // State is the migration commit point. Queue and manifest updates are
141
+ // idempotent, so a crash before this write can safely retry.
142
+ await writeJsonAtomic(path.join(bundle.runDir, "manifest.json"), migratedManifest);
143
+ await writeJsonAtomic(path.join(bundle.runDir, "state.json"), migrated);
144
+ if (
145
+ options.queue !== undefined &&
146
+ !queueIsDone &&
147
+ !options.queue.parkWorkflowRun({ runId: state.runId, claimToken })
148
+ ) {
149
+ result.blocked.push({
150
+ runId: state.runId,
151
+ reason: "migration completed but its queue claim could not be released",
152
+ });
153
+ }
154
+ result.migratedRunIds.push(state.runId);
155
+ }
156
+ return result;
157
+ }
158
+
159
+ function sourceRef(source: WorkflowSource): string {
160
+ return source.kind === "builtin" ? `builtin:${source.id}` : source.path;
161
+ }
162
+
163
+ async function writeJsonAtomic(filePath: string, value: unknown): Promise<void> {
164
+ const tempPath = `${filePath}.${process.pid}.${randomUUID()}.source-migration.tmp`;
165
+ await fs.writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
166
+ await fs.rename(tempPath, filePath);
167
+ }
@@ -34,9 +34,10 @@ function assertOptionalFunction(value: unknown, description: string): void {
34
34
  function assertCommonNodeFields(node: WorkflowNodeDefinition, nodeId: string): void {
35
35
  if (
36
36
  node.timeoutMs !== undefined &&
37
+ typeof node.timeoutMs !== "function" &&
37
38
  (typeof node.timeoutMs !== "number" || !Number.isFinite(node.timeoutMs) || node.timeoutMs <= 0)
38
39
  ) {
39
- fail(`node ${nodeId} timeoutMs must be a finite positive number`);
40
+ fail(`node ${nodeId} timeoutMs must be a finite positive number or function`);
40
41
  }
41
42
  if (node.statusDetail !== undefined && typeof node.statusDetail !== "string") {
42
43
  fail(`node ${nodeId} statusDetail must be a string`);
@@ -1284,7 +1284,7 @@ function createManifest(
1284
1284
  runId: state.runId,
1285
1285
  workflowName: state.workflowName,
1286
1286
  ...(state.runTitle !== undefined ? { runTitle: state.runTitle } : {}),
1287
- ...(state.workflowPath !== undefined ? { workflowPath: state.workflowPath } : {}),
1287
+ ...(state.workflowSource !== undefined ? { workflowSource: state.workflowSource } : {}),
1288
1288
  startedAt: state.startedAt,
1289
1289
  ...(state.finishedAt !== undefined ? { finishedAt: state.finishedAt } : {}),
1290
1290
  status: state.status,
@@ -1317,7 +1317,7 @@ export function createDefinitionSnapshot(workflow: WorkflowDefinition): Workflow
1317
1317
  function snapshotNode(node: WorkflowNodeDefinition): WorkflowNodeSnapshot {
1318
1318
  const common: WorkflowNodeSnapshot = {
1319
1319
  nodeType: node.nodeType,
1320
- ...(node.timeoutMs !== undefined ? { timeoutMs: node.timeoutMs } : {}),
1320
+ ...(typeof node.timeoutMs === "number" ? { timeoutMs: node.timeoutMs } : {}),
1321
1321
  ...(node.statusDetail !== undefined ? { statusDetail: node.statusDetail } : {}),
1322
1322
  };
1323
1323
  if (node.nodeType === "agent" && node.expectedOutput !== undefined) {
@@ -20,8 +20,11 @@ export type WorkflowNodeContext<TInput = unknown> = {
20
20
  };
21
21
 
22
22
  export type WorkflowNodeCommon = {
23
- /** Per-node timeout. Falls back to the engine default (15 minutes). */
24
- timeoutMs?: number;
23
+ /**
24
+ * Per-node timeout or a callback that derives it from the run context.
25
+ * Falls back to the engine default (15 minutes).
26
+ */
27
+ timeoutMs?: number | ((context: WorkflowNodeContext) => MaybePromise<number>);
25
28
  /** Short human-readable label shown in the viewer while the node runs. */
26
29
  statusDetail?: string;
27
30
  };
@@ -230,6 +233,10 @@ export type WorkflowRunStatus =
230
233
  | "timed_out"
231
234
  | "cancelled";
232
235
 
236
+ export type WorkflowSource =
237
+ | { kind: "builtin"; id: string; revision: string }
238
+ | { kind: "file"; path: string; hash: string };
239
+
233
240
  export type WorkflowRunState = {
234
241
  schema: "pi-workflows.run-state.v1";
235
242
  /**
@@ -249,8 +256,10 @@ export type WorkflowRunState = {
249
256
  */
250
257
  carriedStepCount?: number;
251
258
  runTitle?: string;
259
+ /** Stable built-in identity or immutable file source used by this run. */
260
+ workflowSource?: WorkflowSource;
261
+ /** Legacy fields accepted only by the bounded built-in migration. */
252
262
  workflowPath?: string;
253
- /** SHA-256 of the workflow source at run start; resume refuses mismatches. */
254
263
  workflowHash?: string;
255
264
  startedAt: string;
256
265
  finishedAt?: string;
@@ -377,7 +386,7 @@ export type WorkflowRunManifest = {
377
386
  runId: string;
378
387
  workflowName: string;
379
388
  runTitle?: string;
380
- workflowPath?: string;
389
+ workflowSource?: WorkflowSource;
381
390
  startedAt: string;
382
391
  finishedAt?: string;
383
392
  status: WorkflowRunStatus;