@osolmaz/pi-workflows 0.4.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 (52) hide show
  1. package/README.md +8 -5
  2. package/dist/builtins/catalog.js +12 -2
  3. package/dist/builtins/catalog.js.map +1 -1
  4. package/dist/builtins/monitor.workflow.d.ts +2 -2
  5. package/dist/builtins/monitor.workflow.js +11 -27
  6. package/dist/builtins/monitor.workflow.js.map +1 -1
  7. package/dist/controllers/index.d.ts +1 -1
  8. package/dist/controllers/index.js.map +1 -1
  9. package/dist/controllers/sqlite.d.ts +43 -5
  10. package/dist/controllers/sqlite.js +128 -32
  11. package/dist/controllers/sqlite.js.map +1 -1
  12. package/dist/extension/index.js +74 -80
  13. package/dist/extension/index.js.map +1 -1
  14. package/dist/host/runner.js +20 -1
  15. package/dist/host/runner.js.map +1 -1
  16. package/dist/render/graph-render.js +3 -0
  17. package/dist/render/graph-render.js.map +1 -1
  18. package/dist/workflows/definition.d.ts +2 -1
  19. package/dist/workflows/definition.js +9 -1
  20. package/dist/workflows/definition.js.map +1 -1
  21. package/dist/workflows/engine.d.ts +1 -0
  22. package/dist/workflows/engine.js +22 -0
  23. package/dist/workflows/engine.js.map +1 -1
  24. package/dist/workflows/index.d.ts +2 -2
  25. package/dist/workflows/index.js +1 -1
  26. package/dist/workflows/index.js.map +1 -1
  27. package/dist/workflows/migrate-sources.d.ts +1 -0
  28. package/dist/workflows/migrate-sources.js +4 -0
  29. package/dist/workflows/migrate-sources.js.map +1 -1
  30. package/dist/workflows/schema.d.ts +2 -1
  31. package/dist/workflows/schema.js +12 -0
  32. package/dist/workflows/schema.js.map +1 -1
  33. package/dist/workflows/store.js +3 -0
  34. package/dist/workflows/store.js.map +1 -1
  35. package/dist/workflows/types.d.ts +26 -1
  36. package/docs/plans/2026-08-13-session-addressed-workflow-notifications-plan.md +95 -0
  37. package/docs/workflows.md +23 -4
  38. package/package.json +1 -1
  39. package/src/builtins/catalog.ts +12 -2
  40. package/src/builtins/monitor.workflow.ts +11 -28
  41. package/src/controllers/index.ts +1 -0
  42. package/src/controllers/sqlite.ts +225 -33
  43. package/src/extension/index.ts +76 -104
  44. package/src/host/runner.ts +20 -1
  45. package/src/render/graph-render.ts +3 -0
  46. package/src/workflows/definition.ts +11 -0
  47. package/src/workflows/engine.ts +26 -0
  48. package/src/workflows/index.ts +5 -0
  49. package/src/workflows/migrate-sources.ts +7 -0
  50. package/src/workflows/schema.ts +14 -0
  51. package/src/workflows/store.ts +3 -0
  52. package/src/workflows/types.ts +30 -0
@@ -2,11 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { isDeepStrictEqual } from "node:util";
3
3
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import { builtinWorkflowCatalog } from "../builtins/catalog.js";
5
- import {
6
- projectControllerStorePath,
7
- type RunEventRecord,
8
- SqliteControllerStore,
9
- } from "../controllers/index.js";
5
+ import { projectControllerStorePath, SqliteControllerStore } from "../controllers/index.js";
10
6
  import type { JsonObject } from "../controllers/types.js";
11
7
  import type { WorkflowSchedulerResult } from "../controllers/workflows.js";
12
8
  import { WorkflowEngine } from "../workflows/engine.js";
@@ -46,6 +42,7 @@ import { WorkflowToolParameters, type WorkflowToolInput } from "./workflow-tool.
46
42
  const RUN_CLAIM_LEASE_MS = 30_000;
47
43
  const RUN_CLAIM_RENEW_MS = 10_000;
48
44
  const RUN_SYNC_POLL_MS = 3_000;
45
+ const NOTIFICATION_DELIVERY_LEASE_MS = 30_000;
49
46
  const WIDGET_KEY = "pi-workflows";
50
47
  const PRESENTATION_MESSAGE_TYPE = "pi-workflows-presentation";
51
48
  const FINAL_WIDGET_TTL_MS = 60_000;
@@ -217,11 +214,8 @@ export default function piWorkflows(pi: ExtensionAPI) {
217
214
  return runQueueStore;
218
215
  };
219
216
 
220
- // Session sync: a per-session watermark over the run event feed keeps this
221
- // session's context current with runs other runners drove.
222
- // The sync watermark is project-scoped: a reopened session catches up
223
- // where the last one stopped, and two open sessions share one pointer.
224
- const SYNC_WATERMARK_KEY = "project";
217
+ // Session-addressed delivery: each session polls only its durable outbox.
218
+ // Run events remain an audit feed and never enter a conversation.
225
219
  let syncArmed = false;
226
220
  let runSyncTimer: ReturnType<typeof setInterval> | null = null;
227
221
 
@@ -238,111 +232,56 @@ export default function piWorkflows(pi: ExtensionAPI) {
238
232
  }
239
233
  };
240
234
 
241
- const describeRunEvent = (event: RunEventRecord): string => {
242
- const label = `${event.workflowRef} run ${event.runId}`;
243
- switch (event.type) {
244
- case "waiting": {
245
- const waitingOn =
246
- typeof event.payload.waitingOn === "string" ? event.payload.waitingOn : "a checkpoint";
247
- return `${label} waits at checkpoint ${waitingOn} — answer with /workflow answer`;
235
+ const deliveredNotificationIds = (ctx: ExtensionContext): Set<string> => {
236
+ const ids = new Set<string>();
237
+ for (const entry of ctx.sessionManager.getBranch()) {
238
+ if (entry.type !== "custom_message" || entry.customType !== "pi-workflows-notification") {
239
+ continue;
248
240
  }
249
- case "parked":
250
- return `${label} was parked and will resume when a runner is available`;
251
- case "failed": {
252
- const detail = typeof event.payload.error === "string" ? `: ${event.payload.error}` : "";
253
- return `${label} failed${detail}`;
241
+ const details = entry.details;
242
+ if (details !== null && typeof details === "object" && !Array.isArray(details)) {
243
+ const notificationId = (details as { notificationId?: unknown }).notificationId;
244
+ if (typeof notificationId === "string") ids.add(notificationId);
254
245
  }
255
- default:
256
- return `${label} ${event.type}`;
257
246
  }
247
+ return ids;
258
248
  };
259
249
 
260
- const runSyncPass = async (ctx: ExtensionContext): Promise<void> => {
261
- if (runQueueStore === null || !syncArmed) {
262
- return;
263
- }
250
+ const runSyncPass = (ctx: ExtensionContext): void => {
251
+ if (runQueueStore === null || !syncArmed) return;
264
252
  try {
265
- const watermark = runQueueStore.getSessionWatermark(SYNC_WATERMARK_KEY);
266
- if (watermark === 0) {
267
- // First sync ever for this project: never replay the feed (stale
268
- // "waits at checkpoint" lines included). Fast-forward, then catch
269
- // up from current state instead — what is parked, resuming, or
270
- // waiting for an answer right now.
271
- const latest = runQueueStore.latestRunEventSeq();
272
- if (latest > 0) {
273
- runQueueStore.setSessionWatermark(SYNC_WATERMARK_KEY, latest);
253
+ const sessionId = ctx.sessionManager.getSessionId();
254
+ const alreadyDelivered = deliveredNotificationIds(ctx);
255
+ const claimToken = randomUUID();
256
+ for (const notification of runQueueStore.claimPendingWorkflowNotifications({
257
+ targetSessionId: sessionId,
258
+ claimToken,
259
+ leaseMs: NOTIFICATION_DELIVERY_LEASE_MS,
260
+ })) {
261
+ if (!alreadyDelivered.has(notification.notificationId)) {
262
+ pi.sendMessage({
263
+ customType: "pi-workflows-notification",
264
+ content: notification.content,
265
+ display: true,
266
+ details: {
267
+ notificationId: notification.notificationId,
268
+ runId: notification.runId,
269
+ kind: notification.kind,
270
+ },
271
+ });
272
+ alreadyDelivered.add(notification.notificationId);
274
273
  }
275
- await sendStateSnapshot(ctx);
276
- return;
277
- }
278
- const events = runQueueStore.listRunEventsAfter(watermark, { limit: 20 });
279
- if (events.length === 0) {
280
- return;
281
- }
282
- // Persist the watermark first. A crash after this point skips the
283
- // message, but snapshots recompute from the store, so no information
284
- // stays lost; a duplicated state line is the worst outcome.
285
- runQueueStore.setSessionWatermark(SYNC_WATERMARK_KEY, events[events.length - 1]?.seq ?? 0);
286
- const noteworthy = events.filter(
287
- (event) =>
288
- event.runnerId !== runnerId &&
289
- ["completed", "failed", "timed_out", "cancelled", "waiting", "parked"].includes(
290
- event.type,
291
- ),
292
- );
293
- if (noteworthy.length === 0) {
294
- return;
274
+ runQueueStore.markWorkflowNotificationDelivered({
275
+ notificationId: notification.notificationId,
276
+ targetSessionId: sessionId,
277
+ claimToken,
278
+ });
295
279
  }
296
- const content = `Workflow run update:\n${noteworthy.map(describeRunEvent).join("\n")}`;
297
- pi.sendMessage(
298
- { customType: "pi-workflows-run-sync", content, display: false },
299
- { deliverAs: "steer", triggerTurn: false },
300
- );
301
- notify(ctx, noteworthy.map(describeRunEvent).join("; "));
302
280
  } catch {
303
- // Sync is observational.
281
+ // Delivery retries on the next poll. It never affects workflow execution.
304
282
  }
305
283
  };
306
284
 
307
- // The first-use catch-up: a snapshot of runs that need attention now.
308
- const sendStateSnapshot = async (ctx: ExtensionContext) => {
309
- if (runQueueStore === null) {
310
- return;
311
- }
312
- const lines: string[] = [];
313
- const rows = runQueueStore.listWorkflowRuns();
314
- for (const row of rows) {
315
- if (row.status === "parked") {
316
- lines.push(`${row.workflowName} run ${row.runId} is parked and will resume`);
317
- }
318
- }
319
- const known = new Set(rows.map((row) => row.runId));
320
- const continued = new Set(
321
- rows.map((row) => row.parentRunId).filter((parent): parent is string => parent !== null),
322
- );
323
- const bundles = await listRunBundles(new WorkflowRunStore().outputRoot);
324
- for (const bundle of bundles) {
325
- if (
326
- bundle.state.status === "waiting" &&
327
- known.has(bundle.state.runId) &&
328
- !continued.has(bundle.state.runId)
329
- ) {
330
- lines.push(
331
- `${bundle.state.workflowName} run ${bundle.state.runId} waits at checkpoint ${bundle.state.waitingOn ?? "?"} — answer with /workflow answer`,
332
- );
333
- }
334
- }
335
- if (lines.length === 0) {
336
- return;
337
- }
338
- const content = `Workflow runs needing attention:\n${lines.join("\n")}`;
339
- pi.sendMessage(
340
- { customType: "pi-workflows-run-sync", content, display: false },
341
- { deliverAs: "steer", triggerTurn: false },
342
- );
343
- notify(ctx, lines.join("; "));
344
- };
345
-
346
285
  const startRunSync = (ctx: ExtensionContext) => {
347
286
  if (runSyncTimer !== null) {
348
287
  return;
@@ -746,6 +685,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
746
685
  runnerId,
747
686
  claimToken: token,
748
687
  leaseMs: RUN_CLAIM_LEASE_MS,
688
+ originSessionId: ctx.sessionManager.getSessionId(),
749
689
  ...(options.parentRunId !== undefined ? { parentRunId: options.parentRunId } : {}),
750
690
  });
751
691
  claimToken = token;
@@ -798,6 +738,24 @@ export default function piWorkflows(pi: ExtensionAPI) {
798
738
  const engine = new WorkflowEngine({
799
739
  executor,
800
740
  store,
741
+ notificationSink: {
742
+ notify: (request) => {
743
+ fence?.();
744
+ if (queueStore === null) throw new Error("Workflow notifications require a queued run");
745
+ const record = queueStore.getWorkflowRun(request.runId);
746
+ if (record?.originSessionId === null || record?.originSessionId === undefined) {
747
+ throw new Error(`Workflow run ${request.runId} has no origin session`);
748
+ }
749
+ const notification = queueStore.enqueueWorkflowNotification({
750
+ ...request,
751
+ targetSessionId: record.originSessionId,
752
+ });
753
+ return {
754
+ notificationId: notification.notificationId,
755
+ targetSessionId: notification.targetSessionId,
756
+ };
757
+ },
758
+ },
801
759
  // Awaited by the engine after run_started is persisted, so the session
802
760
  // binding and its trace event always precede node and terminal events.
803
761
  onRunStarted: async (runDir, state) => {
@@ -980,6 +938,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
980
938
  claimToken,
981
939
  leaseMs: RUN_CLAIM_LEASE_MS,
982
940
  excludeRunIds: [...migrationBlockedRuns],
941
+ sessionId: ctx.sessionManager.getSessionId(),
983
942
  });
984
943
  if (claimed === undefined) {
985
944
  return;
@@ -1291,7 +1250,12 @@ export default function piWorkflows(pi: ExtensionAPI) {
1291
1250
  let parentRunId = requestedRunId ?? lastWaitingRunId;
1292
1251
  if (parentRunId === null) {
1293
1252
  const rows = ensureRunQueueStore(ctx.cwd).listWorkflowRuns();
1294
- const known = new Set(rows.map((row) => row.runId));
1253
+ const sessionId = ctx.sessionManager.getSessionId();
1254
+ const known = new Set(
1255
+ rows
1256
+ .filter((row) => row.originSessionId === null || row.originSessionId === sessionId)
1257
+ .map((row) => row.runId),
1258
+ );
1295
1259
  const continued = new Set(
1296
1260
  rows.map((row) => row.parentRunId).filter((parent): parent is string => parent !== null),
1297
1261
  );
@@ -1307,6 +1271,14 @@ export default function piWorkflows(pi: ExtensionAPI) {
1307
1271
  if (parentRunId === null) {
1308
1272
  throw new Error("No workflow is waiting for an answer.");
1309
1273
  }
1274
+ const queueRecord = ensureRunQueueStore(ctx.cwd).getWorkflowRun(parentRunId);
1275
+ if (
1276
+ queueRecord?.originSessionId !== null &&
1277
+ queueRecord?.originSessionId !== undefined &&
1278
+ queueRecord.originSessionId !== ctx.sessionManager.getSessionId()
1279
+ ) {
1280
+ throw new Error(`Workflow run ${parentRunId} belongs to another Pi session.`);
1281
+ }
1310
1282
  const parent = await readRunBundle(new WorkflowRunStore().runDirFor(parentRunId));
1311
1283
  if (
1312
1284
  parent === null ||
@@ -269,7 +269,26 @@ export class WorkflowHost {
269
269
  ...(this.options.piArgs !== undefined ? { piArgs: this.options.piArgs } : {}),
270
270
  ...(this.options.env !== undefined ? { env: this.options.env } : {}),
271
271
  });
272
- 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
+ });
273
292
  const parkEngine = () => engine.park();
274
293
  this.parkedEngines.push(parkEngine);
275
294
 
@@ -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;
@@ -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;
@@ -30,6 +30,7 @@ import type {
30
30
  WorkflowNodeDefinition,
31
31
  WorkflowNodeOutcome,
32
32
  WorkflowNodeResult,
33
+ WorkflowNotificationSink,
33
34
  WorkflowRunResult,
34
35
  WorkflowRunState,
35
36
  WorkflowSource,
@@ -74,6 +75,7 @@ type NodeAttempt = {
74
75
  */
75
76
  export class WorkflowEngine {
76
77
  private readonly executor: AgentStepExecutor;
78
+ private readonly notificationSink: WorkflowNotificationSink | undefined;
77
79
  private readonly store: WorkflowRunStore;
78
80
  private readonly defaultNodeTimeoutMs: number;
79
81
  private readonly maxSteps: number;
@@ -88,6 +90,7 @@ export class WorkflowEngine {
88
90
 
89
91
  constructor(options: WorkflowEngineOptions) {
90
92
  this.executor = options.executor;
93
+ this.notificationSink = options.notificationSink;
91
94
  this.store = options.store ?? new WorkflowRunStore(options.outputRoot);
92
95
  this.defaultNodeTimeoutMs = options.defaultNodeTimeoutMs ?? DEFAULT_NODE_TIMEOUT_MS;
93
96
  this.maxSteps = options.maxSteps ?? DEFAULT_MAX_STEPS;
@@ -868,6 +871,29 @@ export class WorkflowEngine {
868
871
  );
869
872
  case "compute":
870
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
+ }
871
897
  case "action":
872
898
  return await this.runActionNode(node, context, signal, meta);
873
899
  case "checkpoint":
@@ -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";
@@ -65,6 +66,7 @@ export type {
65
66
  ComputeNodeDefinition,
66
67
  FunctionActionNodeDefinition,
67
68
  MaybePromise,
69
+ NotifyNodeDefinition,
68
70
  ShellActionExecution,
69
71
  ShellActionNodeDefinition,
70
72
  ShellActionResult,
@@ -79,6 +81,9 @@ export type {
79
81
  WorkflowNodeOutcome,
80
82
  WorkflowNodeResult,
81
83
  WorkflowNodeSnapshot,
84
+ WorkflowNotificationReceipt,
85
+ WorkflowNotificationRequest,
86
+ WorkflowNotificationSink,
82
87
  WorkflowPresentationContext,
83
88
  WorkflowRunManifest,
84
89
  WorkflowRunResult,
@@ -25,6 +25,7 @@ export type LegacySourceMigrationQueue = {
25
25
  }): boolean;
26
26
  parkWorkflowRun(options: { runId: string; claimToken: string }): boolean;
27
27
  getWorkflowRun(runId: string): { status: "claimed" | "parked" | "done" } | undefined;
28
+ setWorkflowRunOriginSession?(runId: string, originSessionId: string): boolean;
28
29
  };
29
30
 
30
31
  const MIGRATION_LEASE_MS = 30_000;
@@ -45,6 +46,12 @@ export async function migrateLegacyWorkflowSources(options: {
45
46
  for (const bundle of await listRunBundles(store.outputRoot)) {
46
47
  const state = bundle.state;
47
48
  if (state.status !== "running" && state.status !== "waiting") continue;
49
+ if (
50
+ options.queue?.setWorkflowRunOriginSession !== undefined &&
51
+ bundle.sessionBinding !== null
52
+ ) {
53
+ options.queue.setWorkflowRunOriginSession(state.runId, bundle.sessionBinding.piSessionId);
54
+ }
48
55
  if (state.workflowSource !== undefined) {
49
56
  if (options.queue === undefined) continue;
50
57
  const claimToken = randomUUID();
@@ -4,6 +4,7 @@ import type {
4
4
  CheckpointNodeDefinition,
5
5
  ComputeNodeDefinition,
6
6
  FunctionActionNodeDefinition,
7
+ NotifyNodeDefinition,
7
8
  ShellActionNodeDefinition,
8
9
  WorkflowDefinition,
9
10
  WorkflowEdge,
@@ -62,6 +63,16 @@ export function assertValidComputeNode(node: ComputeNodeDefinition, nodeId = "co
62
63
  assertCommonNodeFields(node, nodeId);
63
64
  }
64
65
 
66
+ export function assertValidNotifyNode(node: NotifyNodeDefinition, nodeId = "notify"): void {
67
+ if (typeof node.message !== "function") {
68
+ fail(`node ${nodeId} requires a message function`);
69
+ }
70
+ if (node.kind !== undefined && node.kind !== "progress" && node.kind !== "final") {
71
+ fail(`node ${nodeId} kind must be progress or final`);
72
+ }
73
+ assertCommonNodeFields(node, nodeId);
74
+ }
75
+
65
76
  export function assertValidActionNode(node: ActionNodeDefinition, nodeId = "action"): void {
66
77
  // Dispatch discriminates with `"exec" in node`, so validation must use the
67
78
  // same property semantics: a present-but-invalid `exec` is an error even
@@ -112,6 +123,9 @@ function assertValidNode(node: WorkflowNodeDefinition, nodeId: string): void {
112
123
  case "compute":
113
124
  assertValidComputeNode(node, nodeId);
114
125
  return;
126
+ case "notify":
127
+ assertValidNotifyNode(node, nodeId);
128
+ return;
115
129
  case "action":
116
130
  assertValidActionNode(node, nodeId);
117
131
  return;
@@ -1323,6 +1323,9 @@ function snapshotNode(node: WorkflowNodeDefinition): WorkflowNodeSnapshot {
1323
1323
  if (node.nodeType === "agent" && node.expectedOutput !== undefined) {
1324
1324
  common.expectedOutput = node.expectedOutput;
1325
1325
  }
1326
+ if (node.nodeType === "notify") {
1327
+ common.summary = node.kind ?? "progress";
1328
+ }
1326
1329
  if (node.nodeType === "checkpoint" && node.summary !== undefined) {
1327
1330
  common.summary = node.summary;
1328
1331
  }
@@ -68,6 +68,13 @@ export type ComputeNodeDefinition = WorkflowNodeCommon & {
68
68
  run: (context: WorkflowNodeContext) => MaybePromise<unknown>;
69
69
  };
70
70
 
71
+ /** A durable user-facing message addressed by the runtime to the run's origin session. */
72
+ export type NotifyNodeDefinition = WorkflowNodeCommon & {
73
+ nodeType: "notify";
74
+ message: (context: WorkflowNodeContext) => MaybePromise<string>;
75
+ kind?: "progress" | "final";
76
+ };
77
+
71
78
  /** A deterministic runtime-owned step implemented as a local function. */
72
79
  export type FunctionActionNodeDefinition = WorkflowNodeCommon & {
73
80
  nodeType: "action";
@@ -121,6 +128,7 @@ export type CheckpointNodeDefinition = WorkflowNodeCommon & {
121
128
  export type WorkflowNodeDefinition =
122
129
  | AgentNodeDefinition
123
130
  | ComputeNodeDefinition
131
+ | NotifyNodeDefinition
124
132
  | ActionNodeDefinition
125
133
  | CheckpointNodeDefinition;
126
134
 
@@ -444,8 +452,30 @@ export interface AgentStepExecutor {
444
452
  runAgentStep(request: AgentStepRequest, signal: AbortSignal): Promise<AgentStepSubmission>;
445
453
  }
446
454
 
455
+ export type WorkflowNotificationRequest = {
456
+ runId: string;
457
+ workflowName: string;
458
+ nodeId: string;
459
+ attemptId: string;
460
+ /** Stable one-based occurrence of this notify node within the run. */
461
+ notificationIndex: number;
462
+ kind: "progress" | "final";
463
+ content: string;
464
+ };
465
+
466
+ export type WorkflowNotificationReceipt = {
467
+ notificationId: string;
468
+ targetSessionId: string;
469
+ };
470
+
471
+ export interface WorkflowNotificationSink {
472
+ notify(request: WorkflowNotificationRequest): MaybePromise<WorkflowNotificationReceipt>;
473
+ }
474
+
447
475
  export type WorkflowEngineOptions = {
448
476
  executor: AgentStepExecutor;
477
+ /** Durable destination for notify nodes. Required when a workflow uses one. */
478
+ notificationSink?: WorkflowNotificationSink;
449
479
  /** Root directory for run bundles. Defaults to `~/.pi/agent/workflows/runs`. */
450
480
  outputRoot?: string;
451
481
  /**