@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/src/extension/index.ts
CHANGED
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
3
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
type RunEventRecord,
|
|
6
|
-
SqliteControllerStore,
|
|
7
|
-
} from "../controllers/index.js";
|
|
4
|
+
import { builtinWorkflowCatalog } from "../builtins/catalog.js";
|
|
5
|
+
import { projectControllerStorePath, SqliteControllerStore } from "../controllers/index.js";
|
|
8
6
|
import type { JsonObject } from "../controllers/types.js";
|
|
9
7
|
import type { WorkflowSchedulerResult } from "../controllers/workflows.js";
|
|
10
8
|
import { WorkflowEngine } from "../workflows/engine.js";
|
|
11
|
-
import { ClaimLostError, errorMessage, isClaimLostError } from "../workflows/errors.js";
|
|
12
9
|
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
} from "../workflows/
|
|
10
|
+
ClaimLostError,
|
|
11
|
+
errorMessage,
|
|
12
|
+
isClaimLostError,
|
|
13
|
+
TimeoutError,
|
|
14
|
+
} from "../workflows/errors.js";
|
|
15
|
+
import { discoverWorkflows, resolveWorkflowRef } from "../workflows/loader.js";
|
|
16
|
+
import { migrateLegacyWorkflowSources } from "../workflows/migrate-sources.js";
|
|
18
17
|
import {
|
|
19
18
|
createRunId,
|
|
20
19
|
listRunBundles,
|
|
@@ -24,6 +23,7 @@ import {
|
|
|
24
23
|
createDefinitionSnapshot,
|
|
25
24
|
} from "../workflows/store.js";
|
|
26
25
|
import type {
|
|
26
|
+
AgentStepContract,
|
|
27
27
|
WorkflowDefinition,
|
|
28
28
|
WorkflowDefinitionSnapshot,
|
|
29
29
|
WorkflowRunResult,
|
|
@@ -42,6 +42,7 @@ import { WorkflowToolParameters, type WorkflowToolInput } from "./workflow-tool.
|
|
|
42
42
|
const RUN_CLAIM_LEASE_MS = 30_000;
|
|
43
43
|
const RUN_CLAIM_RENEW_MS = 10_000;
|
|
44
44
|
const RUN_SYNC_POLL_MS = 3_000;
|
|
45
|
+
const NOTIFICATION_DELIVERY_LEASE_MS = 30_000;
|
|
45
46
|
const WIDGET_KEY = "pi-workflows";
|
|
46
47
|
const PRESENTATION_MESSAGE_TYPE = "pi-workflows-presentation";
|
|
47
48
|
const FINAL_WIDGET_TTL_MS = 60_000;
|
|
@@ -207,16 +208,14 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
207
208
|
// One runner identity per session; it names this session in run claims.
|
|
208
209
|
const runnerId = randomUUID();
|
|
209
210
|
let runQueueStore: SqliteControllerStore | null = null;
|
|
211
|
+
const migrationBlockedRuns = new Set<string>();
|
|
210
212
|
const ensureRunQueueStore = (cwd: string): SqliteControllerStore => {
|
|
211
213
|
runQueueStore ??= new SqliteControllerStore(projectControllerStorePath(cwd));
|
|
212
214
|
return runQueueStore;
|
|
213
215
|
};
|
|
214
216
|
|
|
215
|
-
// Session
|
|
216
|
-
//
|
|
217
|
-
// The sync watermark is project-scoped: a reopened session catches up
|
|
218
|
-
// where the last one stopped, and two open sessions share one pointer.
|
|
219
|
-
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.
|
|
220
219
|
let syncArmed = false;
|
|
221
220
|
let runSyncTimer: ReturnType<typeof setInterval> | null = null;
|
|
222
221
|
|
|
@@ -233,109 +232,54 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
233
232
|
}
|
|
234
233
|
};
|
|
235
234
|
|
|
236
|
-
const
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
typeof event.payload.waitingOn === "string" ? event.payload.waitingOn : "a checkpoint";
|
|
242
|
-
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;
|
|
243
240
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
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);
|
|
249
245
|
}
|
|
250
|
-
default:
|
|
251
|
-
return `${label} ${event.type}`;
|
|
252
246
|
}
|
|
247
|
+
return ids;
|
|
253
248
|
};
|
|
254
249
|
|
|
255
|
-
const runSyncPass =
|
|
256
|
-
if (runQueueStore === null || !syncArmed)
|
|
257
|
-
return;
|
|
258
|
-
}
|
|
250
|
+
const runSyncPass = (ctx: ExtensionContext): void => {
|
|
251
|
+
if (runQueueStore === null || !syncArmed) return;
|
|
259
252
|
try {
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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);
|
|
269
273
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
return;
|
|
276
|
-
}
|
|
277
|
-
// Persist the watermark first. A crash after this point skips the
|
|
278
|
-
// message, but snapshots recompute from the store, so no information
|
|
279
|
-
// stays lost; a duplicated state line is the worst outcome.
|
|
280
|
-
runQueueStore.setSessionWatermark(SYNC_WATERMARK_KEY, events[events.length - 1]?.seq ?? 0);
|
|
281
|
-
const noteworthy = events.filter(
|
|
282
|
-
(event) =>
|
|
283
|
-
event.runnerId !== runnerId &&
|
|
284
|
-
["completed", "failed", "timed_out", "cancelled", "waiting", "parked"].includes(
|
|
285
|
-
event.type,
|
|
286
|
-
),
|
|
287
|
-
);
|
|
288
|
-
if (noteworthy.length === 0) {
|
|
289
|
-
return;
|
|
274
|
+
runQueueStore.markWorkflowNotificationDelivered({
|
|
275
|
+
notificationId: notification.notificationId,
|
|
276
|
+
targetSessionId: sessionId,
|
|
277
|
+
claimToken,
|
|
278
|
+
});
|
|
290
279
|
}
|
|
291
|
-
const content = `Workflow run update:\n${noteworthy.map(describeRunEvent).join("\n")}`;
|
|
292
|
-
pi.sendMessage(
|
|
293
|
-
{ customType: "pi-workflows-run-sync", content, display: false },
|
|
294
|
-
{ deliverAs: "steer", triggerTurn: false },
|
|
295
|
-
);
|
|
296
|
-
notify(ctx, noteworthy.map(describeRunEvent).join("; "));
|
|
297
280
|
} catch {
|
|
298
|
-
//
|
|
299
|
-
}
|
|
300
|
-
};
|
|
301
|
-
|
|
302
|
-
// The first-use catch-up: a snapshot of runs that need attention now.
|
|
303
|
-
const sendStateSnapshot = async (ctx: ExtensionContext) => {
|
|
304
|
-
if (runQueueStore === null) {
|
|
305
|
-
return;
|
|
281
|
+
// Delivery retries on the next poll. It never affects workflow execution.
|
|
306
282
|
}
|
|
307
|
-
const lines: string[] = [];
|
|
308
|
-
const rows = runQueueStore.listWorkflowRuns();
|
|
309
|
-
for (const row of rows) {
|
|
310
|
-
if (row.status === "parked") {
|
|
311
|
-
lines.push(`${row.workflowRef} run ${row.runId} is parked and will resume`);
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
const known = new Set(rows.map((row) => row.runId));
|
|
315
|
-
const continued = new Set(
|
|
316
|
-
rows.map((row) => row.parentRunId).filter((parent): parent is string => parent !== null),
|
|
317
|
-
);
|
|
318
|
-
const bundles = await listRunBundles(new WorkflowRunStore().outputRoot);
|
|
319
|
-
for (const bundle of bundles) {
|
|
320
|
-
if (
|
|
321
|
-
bundle.state.status === "waiting" &&
|
|
322
|
-
known.has(bundle.state.runId) &&
|
|
323
|
-
!continued.has(bundle.state.runId)
|
|
324
|
-
) {
|
|
325
|
-
lines.push(
|
|
326
|
-
`${bundle.state.workflowName} run ${bundle.state.runId} waits at checkpoint ${bundle.state.waitingOn ?? "?"} — answer with /workflow answer`,
|
|
327
|
-
);
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
if (lines.length === 0) {
|
|
331
|
-
return;
|
|
332
|
-
}
|
|
333
|
-
const content = `Workflow runs needing attention:\n${lines.join("\n")}`;
|
|
334
|
-
pi.sendMessage(
|
|
335
|
-
{ customType: "pi-workflows-run-sync", content, display: false },
|
|
336
|
-
{ deliverAs: "steer", triggerTurn: false },
|
|
337
|
-
);
|
|
338
|
-
notify(ctx, lines.join("; "));
|
|
339
283
|
};
|
|
340
284
|
|
|
341
285
|
const startRunSync = (ctx: ExtensionContext) => {
|
|
@@ -348,6 +292,8 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
348
292
|
runSyncTimer.unref?.();
|
|
349
293
|
};
|
|
350
294
|
let activeRun: ActiveRun | null = null;
|
|
295
|
+
let systemTurnAbort: AgentStepContract | null = null;
|
|
296
|
+
let lastExpiredAttempt: { contract: AgentStepContract; reason: string } | null = null;
|
|
351
297
|
let pendingToolLaunch: {
|
|
352
298
|
ctx: ExtensionContext;
|
|
353
299
|
ref: string;
|
|
@@ -506,7 +452,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
506
452
|
if (
|
|
507
453
|
sessionClosed ||
|
|
508
454
|
run.generation !== runGeneration ||
|
|
509
|
-
state.status
|
|
455
|
+
(state.status !== "completed" && state.status !== "waiting") ||
|
|
510
456
|
run.presentationPrompt === undefined
|
|
511
457
|
) {
|
|
512
458
|
return;
|
|
@@ -639,7 +585,9 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
639
585
|
const summary =
|
|
640
586
|
state.status === "waiting" && state.waitingOn
|
|
641
587
|
? `Workflow ${state.workflowName} parked at checkpoint ${state.waitingOn} — answer with /workflow answer <json> (run ${state.runId})`
|
|
642
|
-
: `Workflow ${state.workflowName} ${state.status} (run ${state.runId})
|
|
588
|
+
: `Workflow ${state.workflowName} ${state.status} (run ${state.runId})${
|
|
589
|
+
state.error !== undefined ? `: ${state.error.slice(0, MAX_STATUS_ERROR_CHARS)}` : ""
|
|
590
|
+
}`;
|
|
643
591
|
notify(ctx, summary, state.status === "completed" ? "info" : "warning");
|
|
644
592
|
try {
|
|
645
593
|
const childResult =
|
|
@@ -689,13 +637,13 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
689
637
|
}
|
|
690
638
|
supersedePresentation();
|
|
691
639
|
const generation = runGeneration;
|
|
692
|
-
const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd });
|
|
693
|
-
const workflow =
|
|
640
|
+
const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd }, builtinWorkflowCatalog);
|
|
641
|
+
const workflow = resolved.definition;
|
|
694
642
|
if (options.signal?.aborted) {
|
|
695
643
|
throw options.signal.reason ?? new Error("Workflow startup aborted");
|
|
696
644
|
}
|
|
697
645
|
const snapshot = createDefinitionSnapshot(workflow);
|
|
698
|
-
const
|
|
646
|
+
const workflowSource = resolved.source;
|
|
699
647
|
const runId = options.runId ?? createRunId(workflow.name);
|
|
700
648
|
|
|
701
649
|
// Continuations validate the parent before touching the queue: a
|
|
@@ -706,7 +654,10 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
706
654
|
if (parent === null || parent.state.status !== "waiting") {
|
|
707
655
|
throw new Error(`Workflow run ${options.parentRunId} is not waiting at a checkpoint`);
|
|
708
656
|
}
|
|
709
|
-
if (
|
|
657
|
+
if (
|
|
658
|
+
parent.state.workflowSource !== undefined &&
|
|
659
|
+
!isDeepStrictEqual(parent.state.workflowSource, workflowSource)
|
|
660
|
+
) {
|
|
710
661
|
throw new Error(
|
|
711
662
|
`Workflow source changed since run ${options.parentRunId} started; revert the edit to answer its checkpoint`,
|
|
712
663
|
);
|
|
@@ -725,12 +676,16 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
725
676
|
const token = randomUUID();
|
|
726
677
|
queueStore.enqueueWorkflowRun({
|
|
727
678
|
runId,
|
|
728
|
-
|
|
729
|
-
|
|
679
|
+
workflowName: workflow.name,
|
|
680
|
+
workflowSourceRef:
|
|
681
|
+
workflowSource.kind === "builtin"
|
|
682
|
+
? `builtin:${workflowSource.id}`
|
|
683
|
+
: workflowSource.path,
|
|
730
684
|
input,
|
|
731
685
|
runnerId,
|
|
732
686
|
claimToken: token,
|
|
733
687
|
leaseMs: RUN_CLAIM_LEASE_MS,
|
|
688
|
+
originSessionId: ctx.sessionManager.getSessionId(),
|
|
734
689
|
...(options.parentRunId !== undefined ? { parentRunId: options.parentRunId } : {}),
|
|
735
690
|
});
|
|
736
691
|
claimToken = token;
|
|
@@ -757,6 +712,16 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
757
712
|
sendPrompt: ({ prompt, streaming }) => {
|
|
758
713
|
pi.sendUserMessage(prompt, streaming ? { deliverAs: "steer" } : undefined);
|
|
759
714
|
},
|
|
715
|
+
onAbort: (contract, reason) => {
|
|
716
|
+
lastExpiredAttempt = {
|
|
717
|
+
contract,
|
|
718
|
+
reason: reason instanceof TimeoutError ? "timed out" : `ended: ${errorMessage(reason)}`,
|
|
719
|
+
};
|
|
720
|
+
if (!executor.held && !ctx.isIdle()) {
|
|
721
|
+
systemTurnAbort = contract;
|
|
722
|
+
ctx.abort();
|
|
723
|
+
}
|
|
724
|
+
},
|
|
760
725
|
conversation: {
|
|
761
726
|
beginAttempt: (contract) => run.recorder?.beginAttempt(contract),
|
|
762
727
|
mark: () => run.recorder?.mark() ?? 0,
|
|
@@ -773,6 +738,24 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
773
738
|
const engine = new WorkflowEngine({
|
|
774
739
|
executor,
|
|
775
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
|
+
},
|
|
776
759
|
// Awaited by the engine after run_started is persisted, so the session
|
|
777
760
|
// binding and its trace event always precede node and terminal events.
|
|
778
761
|
onRunStarted: async (runDir, state) => {
|
|
@@ -840,12 +823,11 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
840
823
|
|
|
841
824
|
run.completion = (
|
|
842
825
|
options.resume === true
|
|
843
|
-
? engine.resumeRun(workflow, runId, {
|
|
826
|
+
? engine.resumeRun(workflow, runId, { workflowSource })
|
|
844
827
|
: options.parentRunId === undefined
|
|
845
|
-
? engine.run(workflow, input, {
|
|
828
|
+
? engine.run(workflow, input, { workflowSource, runId })
|
|
846
829
|
: engine.continueRun(workflow, options.parentRunId, input, {
|
|
847
|
-
|
|
848
|
-
workflowHash,
|
|
830
|
+
workflowSource,
|
|
849
831
|
runId,
|
|
850
832
|
})
|
|
851
833
|
)
|
|
@@ -955,13 +937,22 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
955
937
|
runnerId,
|
|
956
938
|
claimToken,
|
|
957
939
|
leaseMs: RUN_CLAIM_LEASE_MS,
|
|
940
|
+
excludeRunIds: [...migrationBlockedRuns],
|
|
941
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
958
942
|
});
|
|
959
943
|
if (claimed === undefined) {
|
|
960
944
|
return;
|
|
961
945
|
}
|
|
962
946
|
let started: string | undefined;
|
|
963
947
|
try {
|
|
964
|
-
|
|
948
|
+
const bundle = await readRunBundle(new WorkflowRunStore().runDirFor(claimed.runId));
|
|
949
|
+
const sourceRef =
|
|
950
|
+
bundle?.state.workflowSource === undefined
|
|
951
|
+
? claimed.workflowSourceRef
|
|
952
|
+
: bundle.state.workflowSource.kind === "builtin"
|
|
953
|
+
? `builtin:${bundle.state.workflowSource.id}`
|
|
954
|
+
: bundle.state.workflowSource.path;
|
|
955
|
+
started = await startRun(ctx, sourceRef, claimed.input, {
|
|
965
956
|
resume: true,
|
|
966
957
|
runId: claimed.runId,
|
|
967
958
|
claimToken,
|
|
@@ -971,7 +962,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
971
962
|
throw error;
|
|
972
963
|
}
|
|
973
964
|
if (started !== undefined) {
|
|
974
|
-
notify(ctx, `Resumed workflow run ${claimed.runId} (${claimed.
|
|
965
|
+
notify(ctx, `Resumed workflow run ${claimed.runId} (${claimed.workflowName}).`);
|
|
975
966
|
} else {
|
|
976
967
|
queueStore.parkWorkflowRun({ runId: claimed.runId, claimToken });
|
|
977
968
|
}
|
|
@@ -1051,7 +1042,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
1051
1042
|
ctx: ExtensionContext,
|
|
1052
1043
|
offset = 0,
|
|
1053
1044
|
): Promise<WorkflowControlResult> => {
|
|
1054
|
-
const discovered = await discoverWorkflows({ cwd: ctx.cwd });
|
|
1045
|
+
const discovered = await discoverWorkflows({ cwd: ctx.cwd }, builtinWorkflowCatalog);
|
|
1055
1046
|
if (discovered.length === 0) {
|
|
1056
1047
|
return {
|
|
1057
1048
|
message:
|
|
@@ -1255,11 +1246,16 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
1255
1246
|
const resolveWaitingWorkflow = async (
|
|
1256
1247
|
ctx: ExtensionContext,
|
|
1257
1248
|
requestedRunId?: string,
|
|
1258
|
-
): Promise<{ parentRunId: string;
|
|
1249
|
+
): Promise<{ parentRunId: string; workflowRef: string }> => {
|
|
1259
1250
|
let parentRunId = requestedRunId ?? lastWaitingRunId;
|
|
1260
1251
|
if (parentRunId === null) {
|
|
1261
1252
|
const rows = ensureRunQueueStore(ctx.cwd).listWorkflowRuns();
|
|
1262
|
-
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
|
+
);
|
|
1263
1259
|
const continued = new Set(
|
|
1264
1260
|
rows.map((row) => row.parentRunId).filter((parent): parent is string => parent !== null),
|
|
1265
1261
|
);
|
|
@@ -1275,18 +1271,32 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
1275
1271
|
if (parentRunId === null) {
|
|
1276
1272
|
throw new Error("No workflow is waiting for an answer.");
|
|
1277
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
|
+
}
|
|
1278
1282
|
const parent = await readRunBundle(new WorkflowRunStore().runDirFor(parentRunId));
|
|
1279
1283
|
if (
|
|
1280
1284
|
parent === null ||
|
|
1281
1285
|
parent.state.status !== "waiting" ||
|
|
1282
|
-
parent.state.
|
|
1286
|
+
parent.state.workflowSource === undefined
|
|
1283
1287
|
) {
|
|
1284
1288
|
if (parentRunId === lastWaitingRunId) {
|
|
1285
1289
|
lastWaitingRunId = null;
|
|
1286
1290
|
}
|
|
1287
1291
|
throw new Error(`Workflow run ${parentRunId} is no longer waiting.`);
|
|
1288
1292
|
}
|
|
1289
|
-
return {
|
|
1293
|
+
return {
|
|
1294
|
+
parentRunId,
|
|
1295
|
+
workflowRef:
|
|
1296
|
+
parent.state.workflowSource.kind === "builtin"
|
|
1297
|
+
? `builtin:${parent.state.workflowSource.id}`
|
|
1298
|
+
: parent.state.workflowSource.path,
|
|
1299
|
+
};
|
|
1290
1300
|
};
|
|
1291
1301
|
|
|
1292
1302
|
const answerWorkflowControl = async (
|
|
@@ -1295,7 +1305,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
1295
1305
|
requestedRunId?: string,
|
|
1296
1306
|
): Promise<WorkflowControlResult> => {
|
|
1297
1307
|
const waiting = await resolveWaitingWorkflow(ctx, requestedRunId);
|
|
1298
|
-
const continued = await startRun(ctx, waiting.
|
|
1308
|
+
const continued = await startRun(ctx, waiting.workflowRef, input, {
|
|
1299
1309
|
parentRunId: waiting.parentRunId,
|
|
1300
1310
|
});
|
|
1301
1311
|
if (continued === undefined) {
|
|
@@ -1356,8 +1366,8 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
1356
1366
|
const reservation = { ctx, ref, input, options };
|
|
1357
1367
|
pendingToolLaunch = reservation;
|
|
1358
1368
|
try {
|
|
1359
|
-
const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd });
|
|
1360
|
-
const workflow =
|
|
1369
|
+
const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd }, builtinWorkflowCatalog);
|
|
1370
|
+
const workflow = resolved.definition;
|
|
1361
1371
|
if (pendingToolLaunch !== reservation) {
|
|
1362
1372
|
throw new Error("The queued workflow launch was cancelled before validation finished.");
|
|
1363
1373
|
}
|
|
@@ -1382,7 +1392,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
1382
1392
|
description:
|
|
1383
1393
|
"Run or manage a workflow: /workflow <name-or-path> [task | --input-json {…}]; also: status, pause, resume, cancel, answer",
|
|
1384
1394
|
getArgumentCompletions: async (prefix: string) => {
|
|
1385
|
-
const discovered = await discoverWorkflows({ cwd: process.cwd() });
|
|
1395
|
+
const discovered = await discoverWorkflows({ cwd: process.cwd() }, builtinWorkflowCatalog);
|
|
1386
1396
|
const items = [
|
|
1387
1397
|
...discovered.map((workflow) => ({ value: workflow.name, label: workflow.name })),
|
|
1388
1398
|
{ value: "status", label: "status" },
|
|
@@ -1556,13 +1566,21 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
1556
1566
|
break;
|
|
1557
1567
|
case "answer": {
|
|
1558
1568
|
const waiting = await resolveWaitingWorkflow(ctx, params.runId);
|
|
1559
|
-
control = await queueToolLaunch(ctx, waiting.
|
|
1569
|
+
control = await queueToolLaunch(ctx, waiting.workflowRef, params.input, {
|
|
1560
1570
|
parentRunId: waiting.parentRunId,
|
|
1561
1571
|
});
|
|
1562
1572
|
break;
|
|
1563
1573
|
}
|
|
1564
1574
|
case "submit": {
|
|
1565
1575
|
if (!activeRun) {
|
|
1576
|
+
if (
|
|
1577
|
+
lastExpiredAttempt?.contract.attemptId === params.attempt &&
|
|
1578
|
+
lastExpiredAttempt.contract.nodeId === params.step
|
|
1579
|
+
) {
|
|
1580
|
+
throw new Error(
|
|
1581
|
+
`Workflow step ${JSON.stringify(params.step)} attempt ${JSON.stringify(params.attempt)} ${lastExpiredAttempt.reason}; its output is no longer accepted.`,
|
|
1582
|
+
);
|
|
1583
|
+
}
|
|
1566
1584
|
throw new Error("No workflow step is waiting for output.");
|
|
1567
1585
|
}
|
|
1568
1586
|
// Flush the conversation into the bundle before accepting, so the
|
|
@@ -1603,6 +1621,24 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
1603
1621
|
pi.on("session_start", async (_event, ctx) => {
|
|
1604
1622
|
sessionClosed = false;
|
|
1605
1623
|
controllerContext = ctx;
|
|
1624
|
+
try {
|
|
1625
|
+
const queue = ensureRunQueueStore(ctx.cwd);
|
|
1626
|
+
const migration = await migrateLegacyWorkflowSources({
|
|
1627
|
+
catalog: builtinWorkflowCatalog,
|
|
1628
|
+
queue,
|
|
1629
|
+
});
|
|
1630
|
+
migrationBlockedRuns.clear();
|
|
1631
|
+
for (const blocked of migration.blocked) migrationBlockedRuns.add(blocked.runId);
|
|
1632
|
+
if (migration.blocked.length > 0) {
|
|
1633
|
+
notify(
|
|
1634
|
+
ctx,
|
|
1635
|
+
`Could not migrate ${migration.blocked.length} legacy workflow source(s).`,
|
|
1636
|
+
"warning",
|
|
1637
|
+
);
|
|
1638
|
+
}
|
|
1639
|
+
} catch (error) {
|
|
1640
|
+
notify(ctx, `Could not migrate legacy workflow sources: ${errorMessage(error)}`, "warning");
|
|
1641
|
+
}
|
|
1606
1642
|
try {
|
|
1607
1643
|
syncArmed = true;
|
|
1608
1644
|
startRunSync(ctx);
|
|
@@ -1632,13 +1668,6 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
1632
1668
|
});
|
|
1633
1669
|
|
|
1634
1670
|
pi.on("agent_end", (event, ctx) => {
|
|
1635
|
-
const run = activeRun;
|
|
1636
|
-
if (!run) {
|
|
1637
|
-
return;
|
|
1638
|
-
}
|
|
1639
|
-
// An aborted turn means the user hit escape to take the conversation
|
|
1640
|
-
// back. Nudging or dispatching the next step would immediately steal it
|
|
1641
|
-
// again, so hold the run until an explicit /workflow resume.
|
|
1642
1671
|
const aborted = event.messages.some(
|
|
1643
1672
|
(message) =>
|
|
1644
1673
|
typeof message === "object" &&
|
|
@@ -1646,6 +1675,17 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
1646
1675
|
"stopReason" in message &&
|
|
1647
1676
|
(message as { stopReason?: string }).stopReason === "aborted",
|
|
1648
1677
|
);
|
|
1678
|
+
if (aborted && systemTurnAbort !== null) {
|
|
1679
|
+
systemTurnAbort = null;
|
|
1680
|
+
return;
|
|
1681
|
+
}
|
|
1682
|
+
const run = activeRun;
|
|
1683
|
+
if (!run) {
|
|
1684
|
+
return;
|
|
1685
|
+
}
|
|
1686
|
+
// An aborted turn means the user hit escape to take the conversation
|
|
1687
|
+
// back. Nudging or dispatching the next step would immediately steal it
|
|
1688
|
+
// again, so hold the run until an explicit /workflow resume.
|
|
1649
1689
|
if (!aborted || runHeld()) {
|
|
1650
1690
|
return;
|
|
1651
1691
|
}
|
|
@@ -1719,6 +1759,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
1719
1759
|
|
|
1720
1760
|
pi.on("session_shutdown", async () => {
|
|
1721
1761
|
sessionClosed = true;
|
|
1762
|
+
systemTurnAbort = null;
|
|
1722
1763
|
supersedePresentation();
|
|
1723
1764
|
const run = activeRun;
|
|
1724
1765
|
if (run !== null && run.claimToken !== undefined) {
|