@osolmaz/pi-workflows 0.4.0 → 0.5.1
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 +109 -94
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/widget.d.ts +5 -2
- package/dist/extension/widget.js +52 -17
- package/dist/extension/widget.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-responsive-workflow-widget-plan.md +64 -0
- package/docs/plans/2026-08-13-session-addressed-workflow-notifications-plan.md +95 -0
- package/docs/workflows.md +23 -4
- package/package.json +3 -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 +123 -125
- package/src/extension/widget.ts +83 -20
- 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";
|
|
@@ -40,12 +36,13 @@ import {
|
|
|
40
36
|
} from "./controller-host.js";
|
|
41
37
|
import { ConversationStepExecutor } from "./executor.js";
|
|
42
38
|
import { SessionRecorder } from "./recorder.js";
|
|
43
|
-
import { buildWidgetView } from "./widget.js";
|
|
39
|
+
import { buildWidgetView, type WidgetLayout, type WidgetScrollState } from "./widget.js";
|
|
44
40
|
import { WorkflowToolParameters, type WorkflowToolInput } from "./workflow-tool.js";
|
|
45
41
|
|
|
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;
|
|
@@ -207,6 +204,14 @@ type WidgetSource = {
|
|
|
207
204
|
snapshot: WorkflowDefinitionSnapshot;
|
|
208
205
|
};
|
|
209
206
|
|
|
207
|
+
type WorkflowWidgetComponent = {
|
|
208
|
+
render: (width: number) => string[];
|
|
209
|
+
invalidate: () => void;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
type WorkflowWidgetFactory = () => WorkflowWidgetComponent;
|
|
213
|
+
type WorkflowWidgetContent = string[] | WorkflowWidgetFactory;
|
|
214
|
+
|
|
210
215
|
export default function piWorkflows(pi: ExtensionAPI) {
|
|
211
216
|
// One runner identity per session; it names this session in run claims.
|
|
212
217
|
const runnerId = randomUUID();
|
|
@@ -217,11 +222,8 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
217
222
|
return runQueueStore;
|
|
218
223
|
};
|
|
219
224
|
|
|
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";
|
|
225
|
+
// Session-addressed delivery: each session polls only its durable outbox.
|
|
226
|
+
// Run events remain an audit feed and never enter a conversation.
|
|
225
227
|
let syncArmed = false;
|
|
226
228
|
let runSyncTimer: ReturnType<typeof setInterval> | null = null;
|
|
227
229
|
|
|
@@ -238,109 +240,54 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
238
240
|
}
|
|
239
241
|
};
|
|
240
242
|
|
|
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`;
|
|
243
|
+
const deliveredNotificationIds = (ctx: ExtensionContext): Set<string> => {
|
|
244
|
+
const ids = new Set<string>();
|
|
245
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
246
|
+
if (entry.type !== "custom_message" || entry.customType !== "pi-workflows-notification") {
|
|
247
|
+
continue;
|
|
248
248
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
return `${label} failed${detail}`;
|
|
249
|
+
const details = entry.details;
|
|
250
|
+
if (details !== null && typeof details === "object" && !Array.isArray(details)) {
|
|
251
|
+
const notificationId = (details as { notificationId?: unknown }).notificationId;
|
|
252
|
+
if (typeof notificationId === "string") ids.add(notificationId);
|
|
254
253
|
}
|
|
255
|
-
default:
|
|
256
|
-
return `${label} ${event.type}`;
|
|
257
254
|
}
|
|
255
|
+
return ids;
|
|
258
256
|
};
|
|
259
257
|
|
|
260
|
-
const runSyncPass =
|
|
261
|
-
if (runQueueStore === null || !syncArmed)
|
|
262
|
-
return;
|
|
263
|
-
}
|
|
258
|
+
const runSyncPass = (ctx: ExtensionContext): void => {
|
|
259
|
+
if (runQueueStore === null || !syncArmed) return;
|
|
264
260
|
try {
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
261
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
262
|
+
const alreadyDelivered = deliveredNotificationIds(ctx);
|
|
263
|
+
const claimToken = randomUUID();
|
|
264
|
+
for (const notification of runQueueStore.claimPendingWorkflowNotifications({
|
|
265
|
+
targetSessionId: sessionId,
|
|
266
|
+
claimToken,
|
|
267
|
+
leaseMs: NOTIFICATION_DELIVERY_LEASE_MS,
|
|
268
|
+
})) {
|
|
269
|
+
if (!alreadyDelivered.has(notification.notificationId)) {
|
|
270
|
+
pi.sendMessage({
|
|
271
|
+
customType: "pi-workflows-notification",
|
|
272
|
+
content: notification.content,
|
|
273
|
+
display: true,
|
|
274
|
+
details: {
|
|
275
|
+
notificationId: notification.notificationId,
|
|
276
|
+
runId: notification.runId,
|
|
277
|
+
kind: notification.kind,
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
alreadyDelivered.add(notification.notificationId);
|
|
274
281
|
}
|
|
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;
|
|
282
|
+
runQueueStore.markWorkflowNotificationDelivered({
|
|
283
|
+
notificationId: notification.notificationId,
|
|
284
|
+
targetSessionId: sessionId,
|
|
285
|
+
claimToken,
|
|
286
|
+
});
|
|
295
287
|
}
|
|
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
288
|
} catch {
|
|
303
|
-
//
|
|
304
|
-
}
|
|
305
|
-
};
|
|
306
|
-
|
|
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;
|
|
289
|
+
// Delivery retries on the next poll. It never affects workflow execution.
|
|
337
290
|
}
|
|
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
291
|
};
|
|
345
292
|
|
|
346
293
|
const startRunSync = (ctx: ExtensionContext) => {
|
|
@@ -368,9 +315,10 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
368
315
|
// Manual widget scroll: null follows the active node; a number is the
|
|
369
316
|
// first visible graph row, set by shift+↑/↓ and reset on step advance.
|
|
370
317
|
let widgetSource: WidgetSource | null = null;
|
|
371
|
-
let widgetScroll:
|
|
318
|
+
let widgetScroll: WidgetScrollState = { graph: null, compact: null };
|
|
372
319
|
let widgetShownScroll = 0;
|
|
373
320
|
let widgetMaxScroll = 0;
|
|
321
|
+
let widgetLayout: WidgetLayout | null = null;
|
|
374
322
|
let widgetStepCount = 0;
|
|
375
323
|
let sessionClosed = false;
|
|
376
324
|
let runGeneration = 0;
|
|
@@ -392,10 +340,14 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
392
340
|
}
|
|
393
341
|
};
|
|
394
342
|
|
|
395
|
-
const setWidget = (ctx: ExtensionContext,
|
|
343
|
+
const setWidget = (ctx: ExtensionContext, content: WorkflowWidgetContent | undefined) => {
|
|
396
344
|
try {
|
|
397
345
|
if (ctx.hasUI) {
|
|
398
|
-
|
|
346
|
+
if (typeof content === "function") {
|
|
347
|
+
ctx.ui.setWidget(WIDGET_KEY, content);
|
|
348
|
+
} else {
|
|
349
|
+
ctx.ui.setWidget(WIDGET_KEY, content);
|
|
350
|
+
}
|
|
399
351
|
}
|
|
400
352
|
} catch {
|
|
401
353
|
// Stale ctx; the widget no longer exists.
|
|
@@ -426,20 +378,30 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
426
378
|
if (!widgetSource) {
|
|
427
379
|
return;
|
|
428
380
|
}
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
381
|
+
const render = (width = Number.POSITIVE_INFINITY): string[] => {
|
|
382
|
+
if (!widgetSource) return [];
|
|
383
|
+
const view = buildWidgetView(
|
|
384
|
+
widgetSource.state,
|
|
385
|
+
widgetSource.snapshot,
|
|
386
|
+
new Date(),
|
|
387
|
+
widgetScroll,
|
|
388
|
+
runHeld(),
|
|
389
|
+
width,
|
|
390
|
+
);
|
|
391
|
+
widgetLayout = view.layout;
|
|
392
|
+
widgetShownScroll = view.scroll;
|
|
393
|
+
widgetMaxScroll = view.maxScroll;
|
|
394
|
+
if (widgetScroll[view.layout] !== null) {
|
|
395
|
+
widgetScroll[view.layout] = view.scroll;
|
|
396
|
+
}
|
|
397
|
+
return view.lines;
|
|
398
|
+
};
|
|
399
|
+
if (ctx.mode === "tui") {
|
|
400
|
+
setWidget(ctx, () => ({ render, invalidate() {} }));
|
|
401
|
+
} else {
|
|
402
|
+
// RPC transports string widgets but cannot serialize TUI component factories.
|
|
403
|
+
setWidget(ctx, render());
|
|
441
404
|
}
|
|
442
|
-
setWidget(ctx, view.lines);
|
|
443
405
|
setStatus(ctx, footerStatus(widgetSource.state));
|
|
444
406
|
};
|
|
445
407
|
|
|
@@ -451,7 +413,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
451
413
|
if (state.steps.length !== widgetStepCount) {
|
|
452
414
|
widgetStepCount = state.steps.length;
|
|
453
415
|
// The workflow moved on; resume following the active node.
|
|
454
|
-
widgetScroll = null;
|
|
416
|
+
widgetScroll = { graph: null, compact: null };
|
|
455
417
|
}
|
|
456
418
|
widgetSource = { state, snapshot };
|
|
457
419
|
renderWidget(ctx);
|
|
@@ -459,7 +421,8 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
459
421
|
|
|
460
422
|
const clearWidget = (ctx: ExtensionContext) => {
|
|
461
423
|
widgetSource = null;
|
|
462
|
-
widgetScroll = null;
|
|
424
|
+
widgetScroll = { graph: null, compact: null };
|
|
425
|
+
widgetLayout = null;
|
|
463
426
|
setWidget(ctx, undefined);
|
|
464
427
|
setStatus(ctx, undefined);
|
|
465
428
|
};
|
|
@@ -468,7 +431,8 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
468
431
|
if (!widgetSource || widgetMaxScroll === 0) {
|
|
469
432
|
return;
|
|
470
433
|
}
|
|
471
|
-
|
|
434
|
+
if (widgetLayout === null) return;
|
|
435
|
+
widgetScroll[widgetLayout] = Math.max(0, Math.min(widgetShownScroll + delta, widgetMaxScroll));
|
|
472
436
|
renderWidget(ctx);
|
|
473
437
|
};
|
|
474
438
|
|
|
@@ -746,6 +710,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
746
710
|
runnerId,
|
|
747
711
|
claimToken: token,
|
|
748
712
|
leaseMs: RUN_CLAIM_LEASE_MS,
|
|
713
|
+
originSessionId: ctx.sessionManager.getSessionId(),
|
|
749
714
|
...(options.parentRunId !== undefined ? { parentRunId: options.parentRunId } : {}),
|
|
750
715
|
});
|
|
751
716
|
claimToken = token;
|
|
@@ -798,6 +763,24 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
798
763
|
const engine = new WorkflowEngine({
|
|
799
764
|
executor,
|
|
800
765
|
store,
|
|
766
|
+
notificationSink: {
|
|
767
|
+
notify: (request) => {
|
|
768
|
+
fence?.();
|
|
769
|
+
if (queueStore === null) throw new Error("Workflow notifications require a queued run");
|
|
770
|
+
const record = queueStore.getWorkflowRun(request.runId);
|
|
771
|
+
if (record?.originSessionId === null || record?.originSessionId === undefined) {
|
|
772
|
+
throw new Error(`Workflow run ${request.runId} has no origin session`);
|
|
773
|
+
}
|
|
774
|
+
const notification = queueStore.enqueueWorkflowNotification({
|
|
775
|
+
...request,
|
|
776
|
+
targetSessionId: record.originSessionId,
|
|
777
|
+
});
|
|
778
|
+
return {
|
|
779
|
+
notificationId: notification.notificationId,
|
|
780
|
+
targetSessionId: notification.targetSessionId,
|
|
781
|
+
};
|
|
782
|
+
},
|
|
783
|
+
},
|
|
801
784
|
// Awaited by the engine after run_started is persisted, so the session
|
|
802
785
|
// binding and its trace event always precede node and terminal events.
|
|
803
786
|
onRunStarted: async (runDir, state) => {
|
|
@@ -980,6 +963,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
980
963
|
claimToken,
|
|
981
964
|
leaseMs: RUN_CLAIM_LEASE_MS,
|
|
982
965
|
excludeRunIds: [...migrationBlockedRuns],
|
|
966
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
983
967
|
});
|
|
984
968
|
if (claimed === undefined) {
|
|
985
969
|
return;
|
|
@@ -1291,7 +1275,12 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
1291
1275
|
let parentRunId = requestedRunId ?? lastWaitingRunId;
|
|
1292
1276
|
if (parentRunId === null) {
|
|
1293
1277
|
const rows = ensureRunQueueStore(ctx.cwd).listWorkflowRuns();
|
|
1294
|
-
const
|
|
1278
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
1279
|
+
const known = new Set(
|
|
1280
|
+
rows
|
|
1281
|
+
.filter((row) => row.originSessionId === null || row.originSessionId === sessionId)
|
|
1282
|
+
.map((row) => row.runId),
|
|
1283
|
+
);
|
|
1295
1284
|
const continued = new Set(
|
|
1296
1285
|
rows.map((row) => row.parentRunId).filter((parent): parent is string => parent !== null),
|
|
1297
1286
|
);
|
|
@@ -1307,6 +1296,14 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
1307
1296
|
if (parentRunId === null) {
|
|
1308
1297
|
throw new Error("No workflow is waiting for an answer.");
|
|
1309
1298
|
}
|
|
1299
|
+
const queueRecord = ensureRunQueueStore(ctx.cwd).getWorkflowRun(parentRunId);
|
|
1300
|
+
if (
|
|
1301
|
+
queueRecord?.originSessionId !== null &&
|
|
1302
|
+
queueRecord?.originSessionId !== undefined &&
|
|
1303
|
+
queueRecord.originSessionId !== ctx.sessionManager.getSessionId()
|
|
1304
|
+
) {
|
|
1305
|
+
throw new Error(`Workflow run ${parentRunId} belongs to another Pi session.`);
|
|
1306
|
+
}
|
|
1310
1307
|
const parent = await readRunBundle(new WorkflowRunStore().runDirFor(parentRunId));
|
|
1311
1308
|
if (
|
|
1312
1309
|
parent === null ||
|
|
@@ -1816,7 +1813,8 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
1816
1813
|
clearWidgetTimer();
|
|
1817
1814
|
stopWidgetTicker();
|
|
1818
1815
|
widgetSource = null;
|
|
1819
|
-
widgetScroll = null;
|
|
1816
|
+
widgetScroll = { graph: null, compact: null };
|
|
1817
|
+
widgetLayout = null;
|
|
1820
1818
|
});
|
|
1821
1819
|
}
|
|
1822
1820
|
|
package/src/extension/widget.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
1
2
|
import { ansi, stripAnsi } from "../render/ansi.js";
|
|
2
3
|
import { renderGraphLines } from "../render/graph-render.js";
|
|
3
4
|
import { sanitizeText } from "../workflows/text.js";
|
|
@@ -42,9 +43,14 @@ export function displayNodeIds(snapshot: WorkflowDefinitionSnapshot): string[] {
|
|
|
42
43
|
return Object.keys(snapshot.nodes);
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
export type WidgetLayout = "graph" | "compact";
|
|
47
|
+
|
|
48
|
+
export type WidgetScrollState = Record<WidgetLayout, number | null>;
|
|
49
|
+
|
|
45
50
|
export type WidgetView = {
|
|
46
51
|
lines: string[];
|
|
47
|
-
|
|
52
|
+
layout: WidgetLayout;
|
|
53
|
+
/** The clamped first visible row for the selected layout. */
|
|
48
54
|
scroll: number;
|
|
49
55
|
/** Largest useful scroll value; 0 when the whole graph fits. */
|
|
50
56
|
maxScroll: number;
|
|
@@ -61,9 +67,13 @@ export function buildWidgetView(
|
|
|
61
67
|
state: WorkflowRunState,
|
|
62
68
|
snapshot: WorkflowDefinitionSnapshot,
|
|
63
69
|
now: Date = new Date(),
|
|
64
|
-
scroll:
|
|
70
|
+
scroll: WidgetScrollState = { graph: null, compact: null },
|
|
65
71
|
held = false,
|
|
72
|
+
width = Number.POSITIVE_INFINITY,
|
|
66
73
|
): WidgetView {
|
|
74
|
+
const availableWidth = Number.isFinite(width) ? Math.max(0, Math.floor(width)) : width;
|
|
75
|
+
if (availableWidth === 0) return { lines: [], layout: "compact", scroll: 0, maxScroll: 0 };
|
|
76
|
+
|
|
67
77
|
// `held` covers pauses the state cannot see yet: an escape-interrupted
|
|
68
78
|
// step or a pause requested while the current node is still finishing.
|
|
69
79
|
const paused = held || state.paused === true;
|
|
@@ -79,28 +89,94 @@ export function buildWidgetView(
|
|
|
79
89
|
footer.push(` error: ${truncate(sanitizeText(state.error), 120)}`);
|
|
80
90
|
}
|
|
81
91
|
if (state.status === "waiting" && state.waitingOn) {
|
|
82
|
-
footer.push(` waiting on checkpoint: ${state.waitingOn}`);
|
|
92
|
+
footer.push(` waiting on checkpoint: ${sanitizeText(state.waitingOn)}`);
|
|
83
93
|
}
|
|
84
94
|
|
|
85
95
|
const budget = PI_MAX_WIDGET_LINES - 1 - footer.length;
|
|
86
96
|
const graph = renderGraphLines({ state, snapshot }, state.steps.length - 1, now, {
|
|
87
97
|
nodeStyle: "box",
|
|
88
98
|
});
|
|
89
|
-
if (graph.length
|
|
99
|
+
if (graph.length > 0) {
|
|
100
|
+
const graphScroll = scroll.graph;
|
|
101
|
+
const windowed = windowLines(
|
|
102
|
+
graph,
|
|
103
|
+
budget,
|
|
104
|
+
graphScroll ?? focusLine(graph, state),
|
|
105
|
+
graphScroll !== null,
|
|
106
|
+
);
|
|
107
|
+
const graphLines = windowed.lines.map((line) => ` ${line}`);
|
|
108
|
+
if (graphLines.every((line) => visibleWidth(line) <= availableWidth)) {
|
|
109
|
+
return {
|
|
110
|
+
lines: fitLines([header, ...graphLines, ...footer], availableWidth),
|
|
111
|
+
layout: "graph",
|
|
112
|
+
scroll: windowed.scroll,
|
|
113
|
+
maxScroll: windowed.maxScroll,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return compactWidgetView(state, snapshot, header, footer, budget, availableWidth, scroll.compact);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function compactWidgetView(
|
|
122
|
+
state: WorkflowRunState,
|
|
123
|
+
snapshot: WorkflowDefinitionSnapshot,
|
|
124
|
+
header: string,
|
|
125
|
+
footer: string[],
|
|
126
|
+
budget: number,
|
|
127
|
+
width: number,
|
|
128
|
+
scroll: number | null,
|
|
129
|
+
): WidgetView {
|
|
130
|
+
const nodes = displayNodeIds(snapshot).map((nodeId) => compactNodeLine(state, snapshot, nodeId));
|
|
131
|
+
if (nodes.length === 0) {
|
|
90
132
|
return {
|
|
91
|
-
lines: [header,
|
|
133
|
+
lines: fitLines([header, ...footer], width),
|
|
134
|
+
layout: "compact",
|
|
92
135
|
scroll: 0,
|
|
93
136
|
maxScroll: 0,
|
|
94
137
|
};
|
|
95
138
|
}
|
|
96
|
-
const
|
|
139
|
+
const anchor = scroll ?? compactFocusIndex(state, snapshot);
|
|
140
|
+
const windowed = windowLines(nodes, budget, anchor, scroll !== null);
|
|
141
|
+
const indentation = width >= 3 ? " " : "";
|
|
97
142
|
return {
|
|
98
|
-
lines:
|
|
143
|
+
lines: fitLines(
|
|
144
|
+
[header, ...windowed.lines.map((line) => `${indentation}${line}`), ...footer],
|
|
145
|
+
width,
|
|
146
|
+
),
|
|
147
|
+
layout: "compact",
|
|
99
148
|
scroll: windowed.scroll,
|
|
100
149
|
maxScroll: windowed.maxScroll,
|
|
101
150
|
};
|
|
102
151
|
}
|
|
103
152
|
|
|
153
|
+
function compactFocusIndex(state: WorkflowRunState, snapshot: WorkflowDefinitionSnapshot): number {
|
|
154
|
+
const nodeIds = displayNodeIds(snapshot);
|
|
155
|
+
const focused = state.currentNode ?? state.waitingOn;
|
|
156
|
+
if (focused === undefined) return Math.max(0, nodeIds.length - 1);
|
|
157
|
+
const index = nodeIds.indexOf(focused);
|
|
158
|
+
return index === -1 ? Math.max(0, nodeIds.length - 1) : index;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function compactNodeLine(
|
|
162
|
+
state: WorkflowRunState,
|
|
163
|
+
snapshot: WorkflowDefinitionSnapshot,
|
|
164
|
+
nodeId: string,
|
|
165
|
+
): string {
|
|
166
|
+
const node = snapshot.nodes[nodeId];
|
|
167
|
+
const type = node?.nodeType === undefined ? "" : ` · ${node.nodeType}`;
|
|
168
|
+
const detail =
|
|
169
|
+
state.currentNode === nodeId && state.statusDetail
|
|
170
|
+
? ` · ${sanitizeText(state.statusDetail)}`
|
|
171
|
+
: "";
|
|
172
|
+
return `${nodeGlyph(state, nodeId)} ${sanitizeText(nodeId)}${type}${detail}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function fitLines(lines: string[], width: number): string[] {
|
|
176
|
+
if (!Number.isFinite(width)) return lines;
|
|
177
|
+
return lines.map((line) => truncateToWidth(line, width, width > 1 ? "…" : ""));
|
|
178
|
+
}
|
|
179
|
+
|
|
104
180
|
/** Back-compatible line view following the active node. */
|
|
105
181
|
export function buildWidgetLines(
|
|
106
182
|
state: WorkflowRunState,
|
|
@@ -167,19 +243,6 @@ function clampStart(anchor: number, inner: number, total: number, anchorIsStart:
|
|
|
167
243
|
return Math.max(0, Math.min(start, total - inner));
|
|
168
244
|
}
|
|
169
245
|
|
|
170
|
-
function compactNodeStrip(state: WorkflowRunState, snapshot: WorkflowDefinitionSnapshot): string {
|
|
171
|
-
return displayNodeIds(snapshot)
|
|
172
|
-
.map((nodeId) => {
|
|
173
|
-
const marker = nodeGlyph(state, nodeId);
|
|
174
|
-
const detail =
|
|
175
|
-
state.currentNode === nodeId && state.statusDetail
|
|
176
|
-
? ` (${sanitizeText(state.statusDetail)})`
|
|
177
|
-
: "";
|
|
178
|
-
return `${marker} ${nodeId}${detail}`;
|
|
179
|
-
})
|
|
180
|
-
.join(" ");
|
|
181
|
-
}
|
|
182
|
-
|
|
183
246
|
function truncate(text: string, maxLength: number): string {
|
|
184
247
|
return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`;
|
|
185
248
|
}
|
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":
|