@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.
- package/README.md +11 -7
- package/dist/builtins/catalog.d.ts +2 -0
- package/dist/builtins/catalog.js +32 -0
- package/dist/builtins/catalog.js.map +1 -0
- package/dist/builtins/monitor.workflow.d.ts +2 -2
- package/dist/builtins/monitor.workflow.js +25 -25
- 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 +70 -9
- package/dist/controllers/sqlite.js +209 -35
- package/dist/controllers/sqlite.js.map +1 -1
- package/dist/controllers/workflow-engine-scheduler.d.ts +2 -2
- package/dist/controllers/workflow-engine-scheduler.js +3 -1
- package/dist/controllers/workflow-engine-scheduler.js.map +1 -1
- package/dist/extension/executor.d.ts +3 -0
- package/dist/extension/executor.js +11 -1
- package/dist/extension/executor.js.map +1 -1
- package/dist/extension/index.js +157 -108
- package/dist/extension/index.js.map +1 -1
- package/dist/host/runner.d.ts +1 -0
- package/dist/host/runner.js +68 -20
- 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/catalog.d.ts +43 -0
- package/dist/workflows/catalog.js +79 -0
- package/dist/workflows/catalog.js.map +1 -0
- 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 +6 -6
- package/dist/workflows/engine.js +93 -33
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/index.d.ts +3 -3
- package/dist/workflows/index.js +2 -2
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/loader.d.ts +18 -16
- package/dist/workflows/loader.js +58 -23
- package/dist/workflows/loader.js.map +1 -1
- package/dist/workflows/migrate-sources.d.ts +42 -0
- package/dist/workflows/migrate-sources.js +133 -0
- package/dist/workflows/migrate-sources.js.map +1 -0
- package/dist/workflows/schema.d.ts +2 -1
- package/dist/workflows/schema.js +14 -1
- package/dist/workflows/schema.js.map +1 -1
- package/dist/workflows/store.js +5 -2
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/types.d.ts +44 -5
- package/docs/development.md +5 -3
- package/docs/plans/2026-08-12-coordinated-workflow-timeouts-plan.md +74 -0
- package/docs/plans/2026-08-13-built-in-workflow-catalog-plan.md +97 -0
- package/docs/plans/2026-08-13-session-addressed-workflow-notifications-plan.md +95 -0
- package/docs/run-bundles.md +22 -4
- package/docs/workflows.md +57 -14
- package/package.json +1 -1
- package/src/builtins/catalog.ts +32 -0
- package/src/builtins/monitor.workflow.ts +33 -26
- package/src/controllers/index.ts +1 -0
- package/src/controllers/sqlite.ts +353 -43
- package/src/controllers/workflow-engine-scheduler.ts +5 -2
- package/src/extension/executor.ts +12 -1
- package/src/extension/index.ts +181 -140
- package/src/host/runner.ts +78 -20
- package/src/render/graph-render.ts +3 -0
- package/src/workflows/catalog.ts +135 -0
- package/src/workflows/definition.ts +11 -0
- package/src/workflows/engine.ts +128 -53
- package/src/workflows/index.ts +7 -0
- package/src/workflows/loader.ts +70 -26
- package/src/workflows/migrate-sources.ts +174 -0
- package/src/workflows/schema.ts +16 -1
- package/src/workflows/store.ts +5 -2
- package/src/workflows/types.ts +43 -4
package/dist/extension/index.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { isDeepStrictEqual } from "node:util";
|
|
3
|
+
import { builtinWorkflowCatalog } from "../builtins/catalog.js";
|
|
4
|
+
import { projectControllerStorePath, SqliteControllerStore } from "../controllers/index.js";
|
|
3
5
|
import { WorkflowEngine } from "../workflows/engine.js";
|
|
4
|
-
import { ClaimLostError, errorMessage, isClaimLostError } from "../workflows/errors.js";
|
|
5
|
-
import { discoverWorkflows,
|
|
6
|
+
import { ClaimLostError, errorMessage, isClaimLostError, TimeoutError, } from "../workflows/errors.js";
|
|
7
|
+
import { discoverWorkflows, resolveWorkflowRef } from "../workflows/loader.js";
|
|
8
|
+
import { migrateLegacyWorkflowSources } from "../workflows/migrate-sources.js";
|
|
6
9
|
import { createRunId, listRunBundles, readLastTraceEvent, readRunBundle, WorkflowRunStore, createDefinitionSnapshot, } from "../workflows/store.js";
|
|
7
10
|
import { PiControllerHost, parseControllerArgs, } from "./controller-host.js";
|
|
8
11
|
import { ConversationStepExecutor } from "./executor.js";
|
|
@@ -12,6 +15,7 @@ import { WorkflowToolParameters } from "./workflow-tool.js";
|
|
|
12
15
|
const RUN_CLAIM_LEASE_MS = 30_000;
|
|
13
16
|
const RUN_CLAIM_RENEW_MS = 10_000;
|
|
14
17
|
const RUN_SYNC_POLL_MS = 3_000;
|
|
18
|
+
const NOTIFICATION_DELIVERY_LEASE_MS = 30_000;
|
|
15
19
|
const WIDGET_KEY = "pi-workflows";
|
|
16
20
|
const PRESENTATION_MESSAGE_TYPE = "pi-workflows-presentation";
|
|
17
21
|
const FINAL_WIDGET_TTL_MS = 60_000;
|
|
@@ -115,15 +119,13 @@ export default function piWorkflows(pi) {
|
|
|
115
119
|
// One runner identity per session; it names this session in run claims.
|
|
116
120
|
const runnerId = randomUUID();
|
|
117
121
|
let runQueueStore = null;
|
|
122
|
+
const migrationBlockedRuns = new Set();
|
|
118
123
|
const ensureRunQueueStore = (cwd) => {
|
|
119
124
|
runQueueStore ??= new SqliteControllerStore(projectControllerStorePath(cwd));
|
|
120
125
|
return runQueueStore;
|
|
121
126
|
};
|
|
122
|
-
// Session
|
|
123
|
-
//
|
|
124
|
-
// The sync watermark is project-scoped: a reopened session catches up
|
|
125
|
-
// where the last one stopped, and two open sessions share one pointer.
|
|
126
|
-
const SYNC_WATERMARK_KEY = "project";
|
|
127
|
+
// Session-addressed delivery: each session polls only its durable outbox.
|
|
128
|
+
// Run events remain an audit feed and never enter a conversation.
|
|
127
129
|
let syncArmed = false;
|
|
128
130
|
let runSyncTimer = null;
|
|
129
131
|
const recordRunEvent = (event) => {
|
|
@@ -134,90 +136,56 @@ export default function piWorkflows(pi) {
|
|
|
134
136
|
// The event feed is best-effort; never fail a run for it.
|
|
135
137
|
}
|
|
136
138
|
};
|
|
137
|
-
const
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
return `${label} waits at checkpoint ${waitingOn} — answer with /workflow answer`;
|
|
139
|
+
const deliveredNotificationIds = (ctx) => {
|
|
140
|
+
const ids = new Set();
|
|
141
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
142
|
+
if (entry.type !== "custom_message" || entry.customType !== "pi-workflows-notification") {
|
|
143
|
+
continue;
|
|
143
144
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
145
|
+
const details = entry.details;
|
|
146
|
+
if (details !== null && typeof details === "object" && !Array.isArray(details)) {
|
|
147
|
+
const notificationId = details.notificationId;
|
|
148
|
+
if (typeof notificationId === "string")
|
|
149
|
+
ids.add(notificationId);
|
|
149
150
|
}
|
|
150
|
-
default:
|
|
151
|
-
return `${label} ${event.type}`;
|
|
152
151
|
}
|
|
152
|
+
return ids;
|
|
153
153
|
};
|
|
154
|
-
const runSyncPass =
|
|
155
|
-
if (runQueueStore === null || !syncArmed)
|
|
154
|
+
const runSyncPass = (ctx) => {
|
|
155
|
+
if (runQueueStore === null || !syncArmed)
|
|
156
156
|
return;
|
|
157
|
-
}
|
|
158
157
|
try {
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
158
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
159
|
+
const alreadyDelivered = deliveredNotificationIds(ctx);
|
|
160
|
+
const claimToken = randomUUID();
|
|
161
|
+
for (const notification of runQueueStore.claimPendingWorkflowNotifications({
|
|
162
|
+
targetSessionId: sessionId,
|
|
163
|
+
claimToken,
|
|
164
|
+
leaseMs: NOTIFICATION_DELIVERY_LEASE_MS,
|
|
165
|
+
})) {
|
|
166
|
+
if (!alreadyDelivered.has(notification.notificationId)) {
|
|
167
|
+
pi.sendMessage({
|
|
168
|
+
customType: "pi-workflows-notification",
|
|
169
|
+
content: notification.content,
|
|
170
|
+
display: true,
|
|
171
|
+
details: {
|
|
172
|
+
notificationId: notification.notificationId,
|
|
173
|
+
runId: notification.runId,
|
|
174
|
+
kind: notification.kind,
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
alreadyDelivered.add(notification.notificationId);
|
|
168
178
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
// Persist the watermark first. A crash after this point skips the
|
|
177
|
-
// message, but snapshots recompute from the store, so no information
|
|
178
|
-
// stays lost; a duplicated state line is the worst outcome.
|
|
179
|
-
runQueueStore.setSessionWatermark(SYNC_WATERMARK_KEY, events[events.length - 1]?.seq ?? 0);
|
|
180
|
-
const noteworthy = events.filter((event) => event.runnerId !== runnerId &&
|
|
181
|
-
["completed", "failed", "timed_out", "cancelled", "waiting", "parked"].includes(event.type));
|
|
182
|
-
if (noteworthy.length === 0) {
|
|
183
|
-
return;
|
|
179
|
+
runQueueStore.markWorkflowNotificationDelivered({
|
|
180
|
+
notificationId: notification.notificationId,
|
|
181
|
+
targetSessionId: sessionId,
|
|
182
|
+
claimToken,
|
|
183
|
+
});
|
|
184
184
|
}
|
|
185
|
-
const content = `Workflow run update:\n${noteworthy.map(describeRunEvent).join("\n")}`;
|
|
186
|
-
pi.sendMessage({ customType: "pi-workflows-run-sync", content, display: false }, { deliverAs: "steer", triggerTurn: false });
|
|
187
|
-
notify(ctx, noteworthy.map(describeRunEvent).join("; "));
|
|
188
185
|
}
|
|
189
186
|
catch {
|
|
190
|
-
//
|
|
191
|
-
}
|
|
192
|
-
};
|
|
193
|
-
// The first-use catch-up: a snapshot of runs that need attention now.
|
|
194
|
-
const sendStateSnapshot = async (ctx) => {
|
|
195
|
-
if (runQueueStore === null) {
|
|
196
|
-
return;
|
|
187
|
+
// Delivery retries on the next poll. It never affects workflow execution.
|
|
197
188
|
}
|
|
198
|
-
const lines = [];
|
|
199
|
-
const rows = runQueueStore.listWorkflowRuns();
|
|
200
|
-
for (const row of rows) {
|
|
201
|
-
if (row.status === "parked") {
|
|
202
|
-
lines.push(`${row.workflowRef} run ${row.runId} is parked and will resume`);
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
const known = new Set(rows.map((row) => row.runId));
|
|
206
|
-
const continued = new Set(rows.map((row) => row.parentRunId).filter((parent) => parent !== null));
|
|
207
|
-
const bundles = await listRunBundles(new WorkflowRunStore().outputRoot);
|
|
208
|
-
for (const bundle of bundles) {
|
|
209
|
-
if (bundle.state.status === "waiting" &&
|
|
210
|
-
known.has(bundle.state.runId) &&
|
|
211
|
-
!continued.has(bundle.state.runId)) {
|
|
212
|
-
lines.push(`${bundle.state.workflowName} run ${bundle.state.runId} waits at checkpoint ${bundle.state.waitingOn ?? "?"} — answer with /workflow answer`);
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
if (lines.length === 0) {
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
const content = `Workflow runs needing attention:\n${lines.join("\n")}`;
|
|
219
|
-
pi.sendMessage({ customType: "pi-workflows-run-sync", content, display: false }, { deliverAs: "steer", triggerTurn: false });
|
|
220
|
-
notify(ctx, lines.join("; "));
|
|
221
189
|
};
|
|
222
190
|
const startRunSync = (ctx) => {
|
|
223
191
|
if (runSyncTimer !== null) {
|
|
@@ -229,6 +197,8 @@ export default function piWorkflows(pi) {
|
|
|
229
197
|
runSyncTimer.unref?.();
|
|
230
198
|
};
|
|
231
199
|
let activeRun = null;
|
|
200
|
+
let systemTurnAbort = null;
|
|
201
|
+
let lastExpiredAttempt = null;
|
|
232
202
|
let pendingToolLaunch = null;
|
|
233
203
|
// The interactive run currently parked at a checkpoint, if any.
|
|
234
204
|
let lastWaitingRunId = null;
|
|
@@ -355,7 +325,7 @@ export default function piWorkflows(pi) {
|
|
|
355
325
|
const presentRun = async (ctx, run, state) => {
|
|
356
326
|
if (sessionClosed ||
|
|
357
327
|
run.generation !== runGeneration ||
|
|
358
|
-
state.status
|
|
328
|
+
(state.status !== "completed" && state.status !== "waiting") ||
|
|
359
329
|
run.presentationPrompt === undefined) {
|
|
360
330
|
return;
|
|
361
331
|
}
|
|
@@ -473,7 +443,7 @@ export default function piWorkflows(pi) {
|
|
|
473
443
|
}
|
|
474
444
|
const summary = state.status === "waiting" && state.waitingOn
|
|
475
445
|
? `Workflow ${state.workflowName} parked at checkpoint ${state.waitingOn} — answer with /workflow answer <json> (run ${state.runId})`
|
|
476
|
-
: `Workflow ${state.workflowName} ${state.status} (run ${state.runId})`;
|
|
446
|
+
: `Workflow ${state.workflowName} ${state.status} (run ${state.runId})${state.error !== undefined ? `: ${state.error.slice(0, MAX_STATUS_ERROR_CHARS)}` : ""}`;
|
|
477
447
|
notify(ctx, summary, state.status === "completed" ? "info" : "warning");
|
|
478
448
|
try {
|
|
479
449
|
const childResult = run.childKey !== undefined &&
|
|
@@ -510,13 +480,13 @@ export default function piWorkflows(pi) {
|
|
|
510
480
|
}
|
|
511
481
|
supersedePresentation();
|
|
512
482
|
const generation = runGeneration;
|
|
513
|
-
const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd });
|
|
514
|
-
const workflow =
|
|
483
|
+
const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd }, builtinWorkflowCatalog);
|
|
484
|
+
const workflow = resolved.definition;
|
|
515
485
|
if (options.signal?.aborted) {
|
|
516
486
|
throw options.signal.reason ?? new Error("Workflow startup aborted");
|
|
517
487
|
}
|
|
518
488
|
const snapshot = createDefinitionSnapshot(workflow);
|
|
519
|
-
const
|
|
489
|
+
const workflowSource = resolved.source;
|
|
520
490
|
const runId = options.runId ?? createRunId(workflow.name);
|
|
521
491
|
// Continuations validate the parent before touching the queue: a
|
|
522
492
|
// refused continuation (edited source, missing parent) must not consume
|
|
@@ -526,7 +496,8 @@ export default function piWorkflows(pi) {
|
|
|
526
496
|
if (parent === null || parent.state.status !== "waiting") {
|
|
527
497
|
throw new Error(`Workflow run ${options.parentRunId} is not waiting at a checkpoint`);
|
|
528
498
|
}
|
|
529
|
-
if (parent.state.
|
|
499
|
+
if (parent.state.workflowSource !== undefined &&
|
|
500
|
+
!isDeepStrictEqual(parent.state.workflowSource, workflowSource)) {
|
|
530
501
|
throw new Error(`Workflow source changed since run ${options.parentRunId} started; revert the edit to answer its checkpoint`);
|
|
531
502
|
}
|
|
532
503
|
}
|
|
@@ -542,12 +513,15 @@ export default function piWorkflows(pi) {
|
|
|
542
513
|
const token = randomUUID();
|
|
543
514
|
queueStore.enqueueWorkflowRun({
|
|
544
515
|
runId,
|
|
545
|
-
|
|
546
|
-
|
|
516
|
+
workflowName: workflow.name,
|
|
517
|
+
workflowSourceRef: workflowSource.kind === "builtin"
|
|
518
|
+
? `builtin:${workflowSource.id}`
|
|
519
|
+
: workflowSource.path,
|
|
547
520
|
input,
|
|
548
521
|
runnerId,
|
|
549
522
|
claimToken: token,
|
|
550
523
|
leaseMs: RUN_CLAIM_LEASE_MS,
|
|
524
|
+
originSessionId: ctx.sessionManager.getSessionId(),
|
|
551
525
|
...(options.parentRunId !== undefined ? { parentRunId: options.parentRunId } : {}),
|
|
552
526
|
});
|
|
553
527
|
claimToken = token;
|
|
@@ -573,6 +547,16 @@ export default function piWorkflows(pi) {
|
|
|
573
547
|
sendPrompt: ({ prompt, streaming }) => {
|
|
574
548
|
pi.sendUserMessage(prompt, streaming ? { deliverAs: "steer" } : undefined);
|
|
575
549
|
},
|
|
550
|
+
onAbort: (contract, reason) => {
|
|
551
|
+
lastExpiredAttempt = {
|
|
552
|
+
contract,
|
|
553
|
+
reason: reason instanceof TimeoutError ? "timed out" : `ended: ${errorMessage(reason)}`,
|
|
554
|
+
};
|
|
555
|
+
if (!executor.held && !ctx.isIdle()) {
|
|
556
|
+
systemTurnAbort = contract;
|
|
557
|
+
ctx.abort();
|
|
558
|
+
}
|
|
559
|
+
},
|
|
576
560
|
conversation: {
|
|
577
561
|
beginAttempt: (contract) => run.recorder?.beginAttempt(contract),
|
|
578
562
|
mark: () => run.recorder?.mark() ?? 0,
|
|
@@ -586,6 +570,25 @@ export default function piWorkflows(pi) {
|
|
|
586
570
|
const engine = new WorkflowEngine({
|
|
587
571
|
executor,
|
|
588
572
|
store,
|
|
573
|
+
notificationSink: {
|
|
574
|
+
notify: (request) => {
|
|
575
|
+
fence?.();
|
|
576
|
+
if (queueStore === null)
|
|
577
|
+
throw new Error("Workflow notifications require a queued run");
|
|
578
|
+
const record = queueStore.getWorkflowRun(request.runId);
|
|
579
|
+
if (record?.originSessionId === null || record?.originSessionId === undefined) {
|
|
580
|
+
throw new Error(`Workflow run ${request.runId} has no origin session`);
|
|
581
|
+
}
|
|
582
|
+
const notification = queueStore.enqueueWorkflowNotification({
|
|
583
|
+
...request,
|
|
584
|
+
targetSessionId: record.originSessionId,
|
|
585
|
+
});
|
|
586
|
+
return {
|
|
587
|
+
notificationId: notification.notificationId,
|
|
588
|
+
targetSessionId: notification.targetSessionId,
|
|
589
|
+
};
|
|
590
|
+
},
|
|
591
|
+
},
|
|
589
592
|
// Awaited by the engine after run_started is persisted, so the session
|
|
590
593
|
// binding and its trace event always precede node and terminal events.
|
|
591
594
|
onRunStarted: async (runDir, state) => {
|
|
@@ -651,12 +654,11 @@ export default function piWorkflows(pi) {
|
|
|
651
654
|
notify(ctx, `Workflow ${workflow.name} started. Follow it live with: pi-workflows view`);
|
|
652
655
|
}
|
|
653
656
|
run.completion = (options.resume === true
|
|
654
|
-
? engine.resumeRun(workflow, runId, {
|
|
657
|
+
? engine.resumeRun(workflow, runId, { workflowSource })
|
|
655
658
|
: options.parentRunId === undefined
|
|
656
|
-
? engine.run(workflow, input, {
|
|
659
|
+
? engine.run(workflow, input, { workflowSource, runId })
|
|
657
660
|
: engine.continueRun(workflow, options.parentRunId, input, {
|
|
658
|
-
|
|
659
|
-
workflowHash,
|
|
661
|
+
workflowSource,
|
|
660
662
|
runId,
|
|
661
663
|
}))
|
|
662
664
|
.then((result) => finishRun(ctx, run, result))
|
|
@@ -758,13 +760,21 @@ export default function piWorkflows(pi) {
|
|
|
758
760
|
runnerId,
|
|
759
761
|
claimToken,
|
|
760
762
|
leaseMs: RUN_CLAIM_LEASE_MS,
|
|
763
|
+
excludeRunIds: [...migrationBlockedRuns],
|
|
764
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
761
765
|
});
|
|
762
766
|
if (claimed === undefined) {
|
|
763
767
|
return;
|
|
764
768
|
}
|
|
765
769
|
let started;
|
|
766
770
|
try {
|
|
767
|
-
|
|
771
|
+
const bundle = await readRunBundle(new WorkflowRunStore().runDirFor(claimed.runId));
|
|
772
|
+
const sourceRef = bundle?.state.workflowSource === undefined
|
|
773
|
+
? claimed.workflowSourceRef
|
|
774
|
+
: bundle.state.workflowSource.kind === "builtin"
|
|
775
|
+
? `builtin:${bundle.state.workflowSource.id}`
|
|
776
|
+
: bundle.state.workflowSource.path;
|
|
777
|
+
started = await startRun(ctx, sourceRef, claimed.input, {
|
|
768
778
|
resume: true,
|
|
769
779
|
runId: claimed.runId,
|
|
770
780
|
claimToken,
|
|
@@ -775,7 +785,7 @@ export default function piWorkflows(pi) {
|
|
|
775
785
|
throw error;
|
|
776
786
|
}
|
|
777
787
|
if (started !== undefined) {
|
|
778
|
-
notify(ctx, `Resumed workflow run ${claimed.runId} (${claimed.
|
|
788
|
+
notify(ctx, `Resumed workflow run ${claimed.runId} (${claimed.workflowName}).`);
|
|
779
789
|
}
|
|
780
790
|
else {
|
|
781
791
|
queueStore.parkWorkflowRun({ runId: claimed.runId, claimToken });
|
|
@@ -837,7 +847,7 @@ export default function piWorkflows(pi) {
|
|
|
837
847
|
return controllerHost;
|
|
838
848
|
};
|
|
839
849
|
const listWorkflowControl = async (ctx, offset = 0) => {
|
|
840
|
-
const discovered = await discoverWorkflows({ cwd: ctx.cwd });
|
|
850
|
+
const discovered = await discoverWorkflows({ cwd: ctx.cwd }, builtinWorkflowCatalog);
|
|
841
851
|
if (discovered.length === 0) {
|
|
842
852
|
return {
|
|
843
853
|
message: "No workflows found. Put *.workflow.ts files in .pi/workflows/ or ~/.pi/agent/workflows/, or pass a path.",
|
|
@@ -1026,7 +1036,10 @@ export default function piWorkflows(pi) {
|
|
|
1026
1036
|
let parentRunId = requestedRunId ?? lastWaitingRunId;
|
|
1027
1037
|
if (parentRunId === null) {
|
|
1028
1038
|
const rows = ensureRunQueueStore(ctx.cwd).listWorkflowRuns();
|
|
1029
|
-
const
|
|
1039
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
1040
|
+
const known = new Set(rows
|
|
1041
|
+
.filter((row) => row.originSessionId === null || row.originSessionId === sessionId)
|
|
1042
|
+
.map((row) => row.runId));
|
|
1030
1043
|
const continued = new Set(rows.map((row) => row.parentRunId).filter((parent) => parent !== null));
|
|
1031
1044
|
const bundles = await listRunBundles(new WorkflowRunStore().outputRoot);
|
|
1032
1045
|
parentRunId =
|
|
@@ -1037,20 +1050,31 @@ export default function piWorkflows(pi) {
|
|
|
1037
1050
|
if (parentRunId === null) {
|
|
1038
1051
|
throw new Error("No workflow is waiting for an answer.");
|
|
1039
1052
|
}
|
|
1053
|
+
const queueRecord = ensureRunQueueStore(ctx.cwd).getWorkflowRun(parentRunId);
|
|
1054
|
+
if (queueRecord?.originSessionId !== null &&
|
|
1055
|
+
queueRecord?.originSessionId !== undefined &&
|
|
1056
|
+
queueRecord.originSessionId !== ctx.sessionManager.getSessionId()) {
|
|
1057
|
+
throw new Error(`Workflow run ${parentRunId} belongs to another Pi session.`);
|
|
1058
|
+
}
|
|
1040
1059
|
const parent = await readRunBundle(new WorkflowRunStore().runDirFor(parentRunId));
|
|
1041
1060
|
if (parent === null ||
|
|
1042
1061
|
parent.state.status !== "waiting" ||
|
|
1043
|
-
parent.state.
|
|
1062
|
+
parent.state.workflowSource === undefined) {
|
|
1044
1063
|
if (parentRunId === lastWaitingRunId) {
|
|
1045
1064
|
lastWaitingRunId = null;
|
|
1046
1065
|
}
|
|
1047
1066
|
throw new Error(`Workflow run ${parentRunId} is no longer waiting.`);
|
|
1048
1067
|
}
|
|
1049
|
-
return {
|
|
1068
|
+
return {
|
|
1069
|
+
parentRunId,
|
|
1070
|
+
workflowRef: parent.state.workflowSource.kind === "builtin"
|
|
1071
|
+
? `builtin:${parent.state.workflowSource.id}`
|
|
1072
|
+
: parent.state.workflowSource.path,
|
|
1073
|
+
};
|
|
1050
1074
|
};
|
|
1051
1075
|
const answerWorkflowControl = async (ctx, input, requestedRunId) => {
|
|
1052
1076
|
const waiting = await resolveWaitingWorkflow(ctx, requestedRunId);
|
|
1053
|
-
const continued = await startRun(ctx, waiting.
|
|
1077
|
+
const continued = await startRun(ctx, waiting.workflowRef, input, {
|
|
1054
1078
|
parentRunId: waiting.parentRunId,
|
|
1055
1079
|
});
|
|
1056
1080
|
if (continued === undefined) {
|
|
@@ -1098,8 +1122,8 @@ export default function piWorkflows(pi) {
|
|
|
1098
1122
|
const reservation = { ctx, ref, input, options };
|
|
1099
1123
|
pendingToolLaunch = reservation;
|
|
1100
1124
|
try {
|
|
1101
|
-
const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd });
|
|
1102
|
-
const workflow =
|
|
1125
|
+
const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd }, builtinWorkflowCatalog);
|
|
1126
|
+
const workflow = resolved.definition;
|
|
1103
1127
|
if (pendingToolLaunch !== reservation) {
|
|
1104
1128
|
throw new Error("The queued workflow launch was cancelled before validation finished.");
|
|
1105
1129
|
}
|
|
@@ -1123,7 +1147,7 @@ export default function piWorkflows(pi) {
|
|
|
1123
1147
|
pi.registerCommand("workflow", {
|
|
1124
1148
|
description: "Run or manage a workflow: /workflow <name-or-path> [task | --input-json {…}]; also: status, pause, resume, cancel, answer",
|
|
1125
1149
|
getArgumentCompletions: async (prefix) => {
|
|
1126
|
-
const discovered = await discoverWorkflows({ cwd: process.cwd() });
|
|
1150
|
+
const discovered = await discoverWorkflows({ cwd: process.cwd() }, builtinWorkflowCatalog);
|
|
1127
1151
|
const items = [
|
|
1128
1152
|
...discovered.map((workflow) => ({ value: workflow.name, label: workflow.name })),
|
|
1129
1153
|
{ value: "status", label: "status" },
|
|
@@ -1290,13 +1314,17 @@ export default function piWorkflows(pi) {
|
|
|
1290
1314
|
break;
|
|
1291
1315
|
case "answer": {
|
|
1292
1316
|
const waiting = await resolveWaitingWorkflow(ctx, params.runId);
|
|
1293
|
-
control = await queueToolLaunch(ctx, waiting.
|
|
1317
|
+
control = await queueToolLaunch(ctx, waiting.workflowRef, params.input, {
|
|
1294
1318
|
parentRunId: waiting.parentRunId,
|
|
1295
1319
|
});
|
|
1296
1320
|
break;
|
|
1297
1321
|
}
|
|
1298
1322
|
case "submit": {
|
|
1299
1323
|
if (!activeRun) {
|
|
1324
|
+
if (lastExpiredAttempt?.contract.attemptId === params.attempt &&
|
|
1325
|
+
lastExpiredAttempt.contract.nodeId === params.step) {
|
|
1326
|
+
throw new Error(`Workflow step ${JSON.stringify(params.step)} attempt ${JSON.stringify(params.attempt)} ${lastExpiredAttempt.reason}; its output is no longer accepted.`);
|
|
1327
|
+
}
|
|
1300
1328
|
throw new Error("No workflow step is waiting for output.");
|
|
1301
1329
|
}
|
|
1302
1330
|
// Flush the conversation into the bundle before accepting, so the
|
|
@@ -1330,6 +1358,22 @@ export default function piWorkflows(pi) {
|
|
|
1330
1358
|
pi.on("session_start", async (_event, ctx) => {
|
|
1331
1359
|
sessionClosed = false;
|
|
1332
1360
|
controllerContext = ctx;
|
|
1361
|
+
try {
|
|
1362
|
+
const queue = ensureRunQueueStore(ctx.cwd);
|
|
1363
|
+
const migration = await migrateLegacyWorkflowSources({
|
|
1364
|
+
catalog: builtinWorkflowCatalog,
|
|
1365
|
+
queue,
|
|
1366
|
+
});
|
|
1367
|
+
migrationBlockedRuns.clear();
|
|
1368
|
+
for (const blocked of migration.blocked)
|
|
1369
|
+
migrationBlockedRuns.add(blocked.runId);
|
|
1370
|
+
if (migration.blocked.length > 0) {
|
|
1371
|
+
notify(ctx, `Could not migrate ${migration.blocked.length} legacy workflow source(s).`, "warning");
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
catch (error) {
|
|
1375
|
+
notify(ctx, `Could not migrate legacy workflow sources: ${errorMessage(error)}`, "warning");
|
|
1376
|
+
}
|
|
1333
1377
|
try {
|
|
1334
1378
|
syncArmed = true;
|
|
1335
1379
|
startRunSync(ctx);
|
|
@@ -1360,6 +1404,14 @@ export default function piWorkflows(pi) {
|
|
|
1360
1404
|
activeRun?.executor.setStreaming(true);
|
|
1361
1405
|
});
|
|
1362
1406
|
pi.on("agent_end", (event, ctx) => {
|
|
1407
|
+
const aborted = event.messages.some((message) => typeof message === "object" &&
|
|
1408
|
+
message !== null &&
|
|
1409
|
+
"stopReason" in message &&
|
|
1410
|
+
message.stopReason === "aborted");
|
|
1411
|
+
if (aborted && systemTurnAbort !== null) {
|
|
1412
|
+
systemTurnAbort = null;
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1363
1415
|
const run = activeRun;
|
|
1364
1416
|
if (!run) {
|
|
1365
1417
|
return;
|
|
@@ -1367,10 +1419,6 @@ export default function piWorkflows(pi) {
|
|
|
1367
1419
|
// An aborted turn means the user hit escape to take the conversation
|
|
1368
1420
|
// back. Nudging or dispatching the next step would immediately steal it
|
|
1369
1421
|
// again, so hold the run until an explicit /workflow resume.
|
|
1370
|
-
const aborted = event.messages.some((message) => typeof message === "object" &&
|
|
1371
|
-
message !== null &&
|
|
1372
|
-
"stopReason" in message &&
|
|
1373
|
-
message.stopReason === "aborted");
|
|
1374
1422
|
if (!aborted || runHeld()) {
|
|
1375
1423
|
return;
|
|
1376
1424
|
}
|
|
@@ -1433,6 +1481,7 @@ export default function piWorkflows(pi) {
|
|
|
1433
1481
|
});
|
|
1434
1482
|
pi.on("session_shutdown", async () => {
|
|
1435
1483
|
sessionClosed = true;
|
|
1484
|
+
systemTurnAbort = null;
|
|
1436
1485
|
supersedePresentation();
|
|
1437
1486
|
const run = activeRun;
|
|
1438
1487
|
if (run !== null && run.claimToken !== undefined) {
|