@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
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { builtinWorkflowCatalog } from "../builtins/catalog.js";
4
5
  import {
5
6
  ControllerManager,
6
7
  loadDiscoveredControllers,
@@ -17,8 +18,9 @@ import {
17
18
  isClaimLostError,
18
19
  WorkflowSourceChangedError,
19
20
  } from "../workflows/errors.js";
20
- import { hashWorkflowSource, loadWorkflowFile, resolveWorkflowRef } from "../workflows/loader.js";
21
- import { WorkflowRunStore } from "../workflows/store.js";
21
+ import { resolveWorkflowRef, resolveWorkflowSource } from "../workflows/loader.js";
22
+ import { migrateLegacyWorkflowSources } from "../workflows/migrate-sources.js";
23
+ import { WorkflowRunStore, readRunBundle } from "../workflows/store.js";
22
24
  import type { WorkflowDefinition } from "../workflows/types.js";
23
25
  import { HostProcessRegistry } from "./processes.js";
24
26
  import { RpcStepExecutor } from "./rpc-executor.js";
@@ -59,6 +61,7 @@ export class WorkflowHost {
59
61
  private manager: ControllerManager | null = null;
60
62
  private pollTimer: ReturnType<typeof setInterval> | null = null;
61
63
  private readonly activeRuns = new Map<string, Promise<void>>();
64
+ private readonly migrationBlockedRuns = new Set<string>();
62
65
  private readonly schedulerExecutors = new Map<WorkflowEngine, RpcStepExecutor>();
63
66
  /** Runs whose resume refused (edited source); skipped until a host restart. */
64
67
  private readonly skippedRuns = new Set<string>();
@@ -94,14 +97,27 @@ export class WorkflowHost {
94
97
  this.log(`reaped ${reaped.length} orphaned headless session(s): ${reaped.join(", ")}`);
95
98
  }
96
99
 
100
+ const migration = await migrateLegacyWorkflowSources({
101
+ catalog: builtinWorkflowCatalog,
102
+ store: this.childRunStore,
103
+ queue: this.store,
104
+ });
105
+ for (const blocked of migration.blocked) {
106
+ this.migrationBlockedRuns.add(blocked.runId);
107
+ this.log(`run ${blocked.runId} parked: ${blocked.reason}`);
108
+ }
109
+
97
110
  const definitions = await loadDiscoveredControllers({ cwd: this.options.cwd });
98
111
  if (definitions.length > 0) {
99
112
  const scheduler = new WorkflowEngineScheduler({
100
113
  store: this.childRunStore,
101
114
  resolveWorkflow: async (name) => {
102
- const resolved = await resolveWorkflowRef(name, { cwd: this.options.cwd });
103
- const workflow = await loadWorkflowFile(resolved.path);
104
- return { workflow };
115
+ const resolved = await resolveWorkflowRef(
116
+ name,
117
+ { cwd: this.options.cwd },
118
+ builtinWorkflowCatalog,
119
+ );
120
+ return { workflow: resolved.definition, workflowSource: resolved.source };
105
121
  },
106
122
  createEngine: () => {
107
123
  const executor = new RpcStepExecutor({
@@ -176,7 +192,7 @@ export class WorkflowHost {
176
192
  runnerId: this.runnerId,
177
193
  claimToken: randomUUID(),
178
194
  leaseMs: RUN_CLAIM_LEASE_MS,
179
- excludeRunIds: [...this.skippedRuns],
195
+ excludeRunIds: [...this.skippedRuns, ...this.migrationBlockedRuns],
180
196
  });
181
197
  } catch (error) {
182
198
  // Store contention or corruption must not kill the host's loop.
@@ -195,13 +211,36 @@ export class WorkflowHost {
195
211
  private async runClaimed(record: WorkflowRunQueueRecord): Promise<void> {
196
212
  const claimToken = record.claimToken as string;
197
213
  const runId = record.runId;
198
- this.log(`resuming ${record.workflowRef} run ${runId}`);
214
+ this.log(`resuming ${record.workflowName} run ${runId}`);
199
215
  let workflow: WorkflowDefinition;
200
- let workflowHash: string;
216
+ let workflowSource: import("../workflows/types.js").WorkflowSource;
201
217
  try {
202
- workflow = await loadWorkflowFile(record.workflowPath);
203
- workflowHash = await hashWorkflowSource(record.workflowPath);
218
+ const bundle = await readRunBundle(this.childRunStore.runDirFor(runId));
219
+ if (bundle?.state.workflowSource === undefined) {
220
+ throw new Error(`Workflow run ${runId} has no canonical workflow source`);
221
+ }
222
+ workflow = await resolveWorkflowSource(
223
+ bundle.state.workflowSource,
224
+ builtinWorkflowCatalog,
225
+ runId,
226
+ );
227
+ workflowSource = bundle.state.workflowSource;
204
228
  } catch (error) {
229
+ if (error instanceof WorkflowSourceChangedError) {
230
+ try {
231
+ this.store.parkWorkflowRun({ runId, claimToken });
232
+ } catch {
233
+ // Best-effort.
234
+ }
235
+ this.skippedRuns.add(runId);
236
+ this.recordEvent(runId, record.workflowName, "parked", {
237
+ reason: "workflow source changed",
238
+ });
239
+ this.log(
240
+ `run ${runId} skipped: workflow source changed; install the matching package revision, then restart the host`,
241
+ );
242
+ return;
243
+ }
205
244
  await this.failUnresumable(record, claimToken, errorMessage(error));
206
245
  return;
207
246
  }
@@ -230,7 +269,26 @@ export class WorkflowHost {
230
269
  ...(this.options.piArgs !== undefined ? { piArgs: this.options.piArgs } : {}),
231
270
  ...(this.options.env !== undefined ? { env: this.options.env } : {}),
232
271
  });
233
- const engine = new WorkflowEngine({ executor, store: fencedStore });
272
+ const engine = new WorkflowEngine({
273
+ executor,
274
+ store: fencedStore,
275
+ notificationSink: {
276
+ notify: (request) => {
277
+ fence();
278
+ if (record.originSessionId === null) {
279
+ throw new Error(`Workflow run ${request.runId} has no origin session`);
280
+ }
281
+ const notification = store.enqueueWorkflowNotification({
282
+ ...request,
283
+ targetSessionId: record.originSessionId,
284
+ });
285
+ return {
286
+ notificationId: notification.notificationId,
287
+ targetSessionId: notification.targetSessionId,
288
+ };
289
+ },
290
+ },
291
+ });
234
292
  const parkEngine = () => engine.park();
235
293
  this.parkedEngines.push(parkEngine);
236
294
 
@@ -245,23 +303,23 @@ export class WorkflowHost {
245
303
  }, RUN_CLAIM_RENEW_MS);
246
304
  renewTimer.unref?.();
247
305
 
248
- this.recordEvent(runId, record.workflowRef, "resumed", { runnerId: this.runnerId });
306
+ this.recordEvent(runId, record.workflowName, "resumed", { runnerId: this.runnerId });
249
307
  try {
250
- const result = await engine.resumeRun(workflow, runId, { workflowHash });
308
+ const result = await engine.resumeRun(workflow, runId, { workflowSource });
251
309
  clearInterval(renewTimer);
252
310
  if (result.state.status === "running") {
253
311
  // Parked again mid-drain: leave it claimable for the next runner.
254
312
  this.store.parkWorkflowRun({ runId, claimToken });
255
- this.recordEvent(runId, record.workflowRef, "parked", {});
256
- this.log(`parked ${record.workflowRef} run ${runId}`);
313
+ this.recordEvent(runId, record.workflowName, "parked", {});
314
+ this.log(`parked ${record.workflowName} run ${runId}`);
257
315
  return;
258
316
  }
259
317
  this.store.completeWorkflowRun({ runId, claimToken });
260
- this.recordEvent(runId, record.workflowRef, result.state.status, {
318
+ this.recordEvent(runId, record.workflowName, result.state.status, {
261
319
  ...(result.state.error !== undefined ? { error: result.state.error } : {}),
262
320
  ...(result.state.waitingOn !== undefined ? { waitingOn: result.state.waitingOn } : {}),
263
321
  });
264
- this.log(`${record.workflowRef} run ${runId} ${result.state.status}`);
322
+ this.log(`${record.workflowName} run ${runId} ${result.state.status}`);
265
323
  } catch (error) {
266
324
  clearInterval(renewTimer);
267
325
  if (isClaimLostError(error)) {
@@ -277,7 +335,7 @@ export class WorkflowHost {
277
335
  // Best-effort.
278
336
  }
279
337
  this.skippedRuns.add(runId);
280
- this.recordEvent(runId, record.workflowRef, "parked", {
338
+ this.recordEvent(runId, record.workflowName, "parked", {
281
339
  reason: "workflow source changed",
282
340
  });
283
341
  this.log(
@@ -327,9 +385,9 @@ export class WorkflowHost {
327
385
  // no-op (the bundle was already waiting or completed), so the feed
328
386
  // stays truthful for sessions syncing from it.
329
387
  if (actualStatus !== undefined && actualStatus !== "failed") {
330
- this.recordEvent(record.runId, record.workflowRef, actualStatus, {});
388
+ this.recordEvent(record.runId, record.workflowName, actualStatus, {});
331
389
  } else {
332
- this.recordEvent(record.runId, record.workflowRef, "failed", { error: message });
390
+ this.recordEvent(record.runId, record.workflowName, "failed", { error: message });
333
391
  }
334
392
  this.log(`run ${record.runId} cannot resume: ${message}`);
335
393
  }
@@ -79,6 +79,7 @@ const CARD_DYNAMIC_RESERVE = "↻ 100 ◷ 9999d 23h 59m 59s";
79
79
  const NODE_TYPE_GLYPHS: Record<string, string> = {
80
80
  agent: "●",
81
81
  compute: "ƒ",
82
+ notify: "✉",
82
83
  action: "⚙",
83
84
  checkpoint: "◆",
84
85
  };
@@ -87,6 +88,8 @@ function nodeTypeStyle(nodeType: string): CanvasStyle {
87
88
  switch (nodeType) {
88
89
  case "agent":
89
90
  case "compute":
91
+ case "notify":
92
+ return nodeType === "notify" ? "action" : nodeType;
90
93
  case "action":
91
94
  case "checkpoint":
92
95
  return nodeType;
@@ -0,0 +1,135 @@
1
+ import { isWorkflowDefinition } from "./definition.js";
2
+ import { WorkflowSourceChangedError } from "./errors.js";
3
+ import type { WorkflowDefinition, WorkflowSource } from "./types.js";
4
+
5
+ const BUILTIN_ID_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
6
+ const REVISION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
7
+
8
+ export type LegacyBuiltinSource = {
9
+ workflowHash: string;
10
+ revision: string;
11
+ pathSuffixes: readonly string[];
12
+ };
13
+
14
+ export type BuiltinWorkflowRegistration = {
15
+ id: string;
16
+ revision: string;
17
+ definition: WorkflowDefinition;
18
+ legacySources?: LegacyBuiltinSource[];
19
+ };
20
+
21
+ export type BuiltinWorkflowEntry = Readonly<{
22
+ id: string;
23
+ ref: string;
24
+ revision: string;
25
+ definition: WorkflowDefinition;
26
+ legacySources: readonly LegacyBuiltinSource[];
27
+ }>;
28
+
29
+ export type LegacyBuiltinMatch = {
30
+ entry: BuiltinWorkflowEntry;
31
+ revision: string;
32
+ };
33
+
34
+ /** Process-local catalog of package-provided workflow definitions. */
35
+ export class BuiltinWorkflowCatalog {
36
+ private readonly byId = new Map<string, BuiltinWorkflowEntry>();
37
+ private readonly byName = new Map<string, BuiltinWorkflowEntry>();
38
+
39
+ constructor(registrations: BuiltinWorkflowRegistration[]) {
40
+ for (const registration of registrations) {
41
+ if (!BUILTIN_ID_PATTERN.test(registration.id)) {
42
+ throw new Error(`Invalid built-in workflow id: ${JSON.stringify(registration.id)}`);
43
+ }
44
+ if (!REVISION_PATTERN.test(registration.revision)) {
45
+ throw new Error(
46
+ `Invalid built-in workflow revision: ${JSON.stringify(registration.revision)}`,
47
+ );
48
+ }
49
+ if (!isWorkflowDefinition(registration.definition)) {
50
+ throw new Error(`Built-in workflow ${registration.id} is not defined with defineWorkflow`);
51
+ }
52
+ if (this.byId.has(registration.id)) {
53
+ throw new Error(`Duplicate built-in workflow id: ${registration.id}`);
54
+ }
55
+ if (this.byName.has(registration.definition.name)) {
56
+ throw new Error(`Duplicate built-in workflow name: ${registration.definition.name}`);
57
+ }
58
+ const entry: BuiltinWorkflowEntry = Object.freeze({
59
+ id: registration.id,
60
+ ref: `builtin:${registration.id}`,
61
+ revision: registration.revision,
62
+ definition: registration.definition,
63
+ legacySources: Object.freeze(
64
+ (registration.legacySources ?? []).map((legacy) =>
65
+ Object.freeze({ ...legacy, pathSuffixes: Object.freeze([...legacy.pathSuffixes]) }),
66
+ ),
67
+ ),
68
+ });
69
+ this.byId.set(entry.id, entry);
70
+ this.byName.set(entry.definition.name, entry);
71
+ }
72
+ }
73
+
74
+ list(): BuiltinWorkflowEntry[] {
75
+ return [...this.byId.values()];
76
+ }
77
+
78
+ get(id: string): BuiltinWorkflowEntry | undefined {
79
+ return this.byId.get(id);
80
+ }
81
+
82
+ getByName(name: string): BuiltinWorkflowEntry | undefined {
83
+ return this.byName.get(name);
84
+ }
85
+
86
+ resolve(
87
+ source: WorkflowSource,
88
+ runId = `builtin:${source.kind === "builtin" ? source.id : "unknown"}`,
89
+ ): WorkflowDefinition {
90
+ if (source.kind !== "builtin") {
91
+ throw new Error("A file workflow source cannot be resolved by the built-in catalog");
92
+ }
93
+ const entry = this.byId.get(source.id);
94
+ if (entry === undefined) {
95
+ throw new Error(`Unknown built-in workflow: ${source.id}`);
96
+ }
97
+ if (entry.revision !== source.revision) {
98
+ throw new WorkflowSourceChangedError(runId);
99
+ }
100
+ return entry.definition;
101
+ }
102
+
103
+ matchLegacy(options: {
104
+ workflowName: string;
105
+ workflowPath: string;
106
+ workflowHash: string;
107
+ }): LegacyBuiltinMatch | undefined {
108
+ const entry = this.legacyPathEntry(options);
109
+ if (entry === undefined) return undefined;
110
+ const workflowPath = options.workflowPath.replaceAll("\\", "/");
111
+ const legacy = entry.legacySources.find(
112
+ (candidate) =>
113
+ candidate.workflowHash === options.workflowHash &&
114
+ candidate.pathSuffixes.some((suffix) =>
115
+ workflowPath.endsWith(suffix.replaceAll("\\", "/")),
116
+ ),
117
+ );
118
+ return legacy === undefined ? undefined : { entry, revision: legacy.revision };
119
+ }
120
+
121
+ /** Identify a registered old built-in path without accepting its revision. */
122
+ legacyPathEntry(options: {
123
+ workflowName: string;
124
+ workflowPath: string;
125
+ }): BuiltinWorkflowEntry | undefined {
126
+ const entry = this.byName.get(options.workflowName);
127
+ if (entry === undefined) return undefined;
128
+ const workflowPath = options.workflowPath.replaceAll("\\", "/");
129
+ return entry.legacySources.some((legacy) =>
130
+ legacy.pathSuffixes.some((suffix) => workflowPath.endsWith(suffix.replaceAll("\\", "/"))),
131
+ )
132
+ ? entry
133
+ : undefined;
134
+ }
135
+ }
@@ -3,6 +3,7 @@ import {
3
3
  assertValidActionNode,
4
4
  assertValidCheckpointNode,
5
5
  assertValidComputeNode,
6
+ assertValidNotifyNode,
6
7
  assertValidShellActionNode,
7
8
  assertValidWorkflowDefinitionShape,
8
9
  } from "./schema.js";
@@ -12,6 +13,7 @@ import type {
12
13
  CheckpointNodeDefinition,
13
14
  ComputeNodeDefinition,
14
15
  FunctionActionNodeDefinition,
16
+ NotifyNodeDefinition,
15
17
  ShellActionNodeDefinition,
16
18
  WorkflowDefinition,
17
19
  } from "./types.js";
@@ -62,6 +64,15 @@ export function compute(
62
64
  return node;
63
65
  }
64
66
 
67
+ export function notify(definition: Omit<NotifyNodeDefinition, "nodeType">): NotifyNodeDefinition {
68
+ const node: NotifyNodeDefinition = {
69
+ nodeType: "notify",
70
+ ...definition,
71
+ };
72
+ assertValidNotifyNode(node);
73
+ return node;
74
+ }
75
+
65
76
  export function action(
66
77
  definition: Omit<FunctionActionNodeDefinition, "nodeType">,
67
78
  ): FunctionActionNodeDefinition;