@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.
- package/README.md +8 -5
- package/dist/builtins/catalog.js +12 -2
- package/dist/builtins/catalog.js.map +1 -1
- package/dist/builtins/monitor.workflow.d.ts +2 -2
- package/dist/builtins/monitor.workflow.js +11 -27
- package/dist/builtins/monitor.workflow.js.map +1 -1
- package/dist/controllers/index.d.ts +1 -1
- package/dist/controllers/index.js.map +1 -1
- package/dist/controllers/sqlite.d.ts +43 -5
- package/dist/controllers/sqlite.js +128 -32
- package/dist/controllers/sqlite.js.map +1 -1
- package/dist/extension/index.js +74 -80
- package/dist/extension/index.js.map +1 -1
- package/dist/host/runner.js +20 -1
- package/dist/host/runner.js.map +1 -1
- package/dist/render/graph-render.js +3 -0
- package/dist/render/graph-render.js.map +1 -1
- package/dist/workflows/definition.d.ts +2 -1
- package/dist/workflows/definition.js +9 -1
- package/dist/workflows/definition.js.map +1 -1
- package/dist/workflows/engine.d.ts +1 -0
- package/dist/workflows/engine.js +22 -0
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/index.d.ts +2 -2
- package/dist/workflows/index.js +1 -1
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/migrate-sources.d.ts +1 -0
- package/dist/workflows/migrate-sources.js +4 -0
- package/dist/workflows/migrate-sources.js.map +1 -1
- package/dist/workflows/schema.d.ts +2 -1
- package/dist/workflows/schema.js +12 -0
- package/dist/workflows/schema.js.map +1 -1
- package/dist/workflows/store.js +3 -0
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/types.d.ts +26 -1
- package/docs/plans/2026-08-13-session-addressed-workflow-notifications-plan.md +95 -0
- package/docs/workflows.md +23 -4
- package/package.json +1 -1
- package/src/builtins/catalog.ts +12 -2
- package/src/builtins/monitor.workflow.ts +11 -28
- package/src/controllers/index.ts +1 -0
- package/src/controllers/sqlite.ts +225 -33
- package/src/extension/index.ts +76 -104
- package/src/host/runner.ts +20 -1
- package/src/render/graph-render.ts +3 -0
- package/src/workflows/definition.ts +11 -0
- package/src/workflows/engine.ts +26 -0
- package/src/workflows/index.ts +5 -0
- package/src/workflows/migrate-sources.ts +7 -0
- package/src/workflows/schema.ts +14 -0
- package/src/workflows/store.ts +3 -0
- package/src/workflows/types.ts +30 -0
package/src/extension/index.ts
CHANGED
|
@@ -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
|
|
221
|
-
//
|
|
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
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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 =
|
|
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
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
//
|
|
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
|
|
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 ||
|
package/src/host/runner.ts
CHANGED
|
@@ -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({
|
|
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;
|
package/src/workflows/engine.ts
CHANGED
|
@@ -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":
|
package/src/workflows/index.ts
CHANGED
|
@@ -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();
|
package/src/workflows/schema.ts
CHANGED
|
@@ -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;
|
package/src/workflows/store.ts
CHANGED
|
@@ -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
|
}
|
package/src/workflows/types.ts
CHANGED
|
@@ -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
|
/**
|