@osolmaz/pi-workflows 0.2.0 → 0.3.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 +71 -5
- package/dist/builtins/monitor.workflow.d.ts +71 -0
- package/dist/builtins/monitor.workflow.js +234 -0
- package/dist/builtins/monitor.workflow.js.map +1 -0
- package/dist/controllers/conditions.d.ts +6 -0
- package/dist/controllers/conditions.js +68 -0
- package/dist/controllers/conditions.js.map +1 -0
- package/dist/controllers/definition.d.ts +6 -0
- package/dist/controllers/definition.js +45 -0
- package/dist/controllers/definition.js.map +1 -0
- package/dist/controllers/effects.d.ts +14 -0
- package/dist/controllers/effects.js +104 -0
- package/dist/controllers/effects.js.map +1 -0
- package/dist/controllers/errors.d.ts +12 -0
- package/dist/controllers/errors.js +25 -0
- package/dist/controllers/errors.js.map +1 -0
- package/dist/controllers/index.d.ts +13 -0
- package/dist/controllers/index.js +13 -0
- package/dist/controllers/index.js.map +1 -0
- package/dist/controllers/json.d.ts +5 -0
- package/dist/controllers/json.js +57 -0
- package/dist/controllers/json.js.map +1 -0
- package/dist/controllers/loader.d.ts +23 -0
- package/dist/controllers/loader.js +74 -0
- package/dist/controllers/loader.js.map +1 -0
- package/dist/controllers/manager.d.ts +58 -0
- package/dist/controllers/manager.js +399 -0
- package/dist/controllers/manager.js.map +1 -0
- package/dist/controllers/results.d.ts +5 -0
- package/dist/controllers/results.js +32 -0
- package/dist/controllers/results.js.map +1 -0
- package/dist/controllers/sqlite.d.ts +212 -0
- package/dist/controllers/sqlite.js +1009 -0
- package/dist/controllers/sqlite.js.map +1 -0
- package/dist/controllers/store.d.ts +112 -0
- package/dist/controllers/store.js +32 -0
- package/dist/controllers/store.js.map +1 -0
- package/dist/controllers/types.d.ts +159 -0
- package/dist/controllers/types.js +2 -0
- package/dist/controllers/types.js.map +1 -0
- package/dist/controllers/workflow-engine-scheduler.d.ts +25 -0
- package/dist/controllers/workflow-engine-scheduler.js +93 -0
- package/dist/controllers/workflow-engine-scheduler.js.map +1 -0
- package/dist/controllers/workflows.d.ts +27 -0
- package/dist/controllers/workflows.js +109 -0
- package/dist/controllers/workflows.js.map +1 -0
- package/dist/extension/controller-host.d.ts +47 -0
- package/dist/extension/controller-host.js +110 -0
- package/dist/extension/controller-host.js.map +1 -0
- package/dist/extension/executor.js +1 -1
- package/dist/extension/executor.js.map +1 -1
- package/dist/extension/index.d.ts +7 -0
- package/dist/extension/index.js +1053 -96
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/recorder.d.ts +6 -0
- package/dist/extension/recorder.js +20 -8
- package/dist/extension/recorder.js.map +1 -1
- package/dist/extension/workflow-tool.d.ts +28 -0
- package/dist/extension/workflow-tool.js +33 -0
- package/dist/extension/workflow-tool.js.map +1 -0
- package/dist/host/processes.d.ts +24 -0
- package/dist/host/processes.js +114 -0
- package/dist/host/processes.js.map +1 -0
- package/dist/host/rpc-bridge.d.ts +9 -0
- package/dist/host/rpc-bridge.js +39 -0
- package/dist/host/rpc-bridge.js.map +1 -0
- package/dist/host/rpc-executor.d.ts +38 -0
- package/dist/host/rpc-executor.js +254 -0
- package/dist/host/rpc-executor.js.map +1 -0
- package/dist/host/runner.d.ts +49 -0
- package/dist/host/runner.js +350 -0
- package/dist/host/runner.js.map +1 -0
- package/dist/viewer/cli.d.ts +7 -3
- package/dist/viewer/cli.js +150 -19
- package/dist/viewer/cli.js.map +1 -1
- package/dist/workflows/engine.d.ts +36 -0
- package/dist/workflows/engine.js +244 -14
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/errors.d.ts +23 -0
- package/dist/workflows/errors.js +38 -0
- package/dist/workflows/errors.js.map +1 -1
- package/dist/workflows/graph.js +0 -5
- package/dist/workflows/graph.js.map +1 -1
- package/dist/workflows/loader.d.ts +5 -3
- package/dist/workflows/loader.js +10 -1
- package/dist/workflows/loader.js.map +1 -1
- package/dist/workflows/schema.js +1 -1
- package/dist/workflows/schema.js.map +1 -1
- package/dist/workflows/store.d.ts +50 -6
- package/dist/workflows/store.js +446 -51
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/types.d.ts +10 -0
- package/docs/CONTROLLERS.md +215 -0
- package/docs/development.md +12 -9
- package/docs/plans/2026-08-04-controller-runtime-plan.md +169 -0
- package/docs/plans/2026-08-05-always-on-workflows-plan.md +125 -0
- package/docs/plans/2026-08-10-agent-managed-monitor-workflows-plan.md +184 -0
- package/docs/run-bundles.md +66 -27
- package/docs/workflows.md +131 -11
- package/examples/controllers/pull-request.controller.ts +215 -0
- package/package.json +10 -2
- package/src/builtins/monitor.workflow.ts +278 -0
- package/src/controllers/conditions.ts +110 -0
- package/src/controllers/definition.ts +65 -0
- package/src/controllers/effects.ts +123 -0
- package/src/controllers/errors.ts +27 -0
- package/src/controllers/index.ts +90 -0
- package/src/controllers/json.ts +62 -0
- package/src/controllers/loader.ts +104 -0
- package/src/controllers/manager.ts +533 -0
- package/src/controllers/results.ts +46 -0
- package/src/controllers/sqlite.ts +1427 -0
- package/src/controllers/store.ts +160 -0
- package/src/controllers/types.ts +183 -0
- package/src/controllers/workflow-engine-scheduler.ts +145 -0
- package/src/controllers/workflows.ts +152 -0
- package/src/extension/controller-host.ts +163 -0
- package/src/extension/executor.ts +1 -1
- package/src/extension/index.ts +1243 -117
- package/src/extension/recorder.ts +65 -36
- package/src/extension/workflow-tool.ts +59 -0
- package/src/host/processes.ts +119 -0
- package/src/host/rpc-bridge.ts +44 -0
- package/src/host/rpc-executor.ts +299 -0
- package/src/host/runner.ts +406 -0
- package/src/viewer/cli.ts +167 -21
- package/src/workflows/engine.ts +327 -13
- package/src/workflows/errors.ts +45 -0
- package/src/workflows/graph.ts +0 -5
- package/src/workflows/loader.ts +13 -3
- package/src/workflows/schema.ts +1 -1
- package/src/workflows/store.ts +555 -46
- package/src/workflows/types.ts +10 -0
package/src/extension/index.ts
CHANGED
|
@@ -1,28 +1,55 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import {
|
|
4
|
+
projectControllerStorePath,
|
|
5
|
+
type RunEventRecord,
|
|
6
|
+
SqliteControllerStore,
|
|
7
|
+
} from "../controllers/index.js";
|
|
8
|
+
import type { JsonObject } from "../controllers/types.js";
|
|
9
|
+
import type { WorkflowSchedulerResult } from "../controllers/workflows.js";
|
|
7
10
|
import { WorkflowEngine } from "../workflows/engine.js";
|
|
8
|
-
import { errorMessage } from "../workflows/errors.js";
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
+
import { ClaimLostError, errorMessage, isClaimLostError } from "../workflows/errors.js";
|
|
12
|
+
import {
|
|
13
|
+
discoverWorkflows,
|
|
14
|
+
hashWorkflowSource,
|
|
15
|
+
loadWorkflowFile,
|
|
16
|
+
resolveWorkflowRef,
|
|
17
|
+
} from "../workflows/loader.js";
|
|
18
|
+
import {
|
|
19
|
+
createRunId,
|
|
20
|
+
listRunBundles,
|
|
21
|
+
readLastTraceEvent,
|
|
22
|
+
readRunBundle,
|
|
23
|
+
WorkflowRunStore,
|
|
24
|
+
createDefinitionSnapshot,
|
|
25
|
+
} from "../workflows/store.js";
|
|
11
26
|
import type {
|
|
12
27
|
WorkflowDefinition,
|
|
13
28
|
WorkflowDefinitionSnapshot,
|
|
14
29
|
WorkflowRunResult,
|
|
15
30
|
WorkflowRunState,
|
|
16
31
|
} from "../workflows/types.js";
|
|
32
|
+
import {
|
|
33
|
+
PiControllerHost,
|
|
34
|
+
parseControllerArgs,
|
|
35
|
+
type PiChildWorkflowStarter,
|
|
36
|
+
} from "./controller-host.js";
|
|
17
37
|
import { ConversationStepExecutor } from "./executor.js";
|
|
18
38
|
import { SessionRecorder } from "./recorder.js";
|
|
19
39
|
import { buildWidgetView } from "./widget.js";
|
|
40
|
+
import { WorkflowToolParameters, type WorkflowToolInput } from "./workflow-tool.js";
|
|
20
41
|
|
|
42
|
+
const RUN_CLAIM_LEASE_MS = 30_000;
|
|
43
|
+
const RUN_CLAIM_RENEW_MS = 10_000;
|
|
44
|
+
const RUN_SYNC_POLL_MS = 3_000;
|
|
21
45
|
const WIDGET_KEY = "pi-workflows";
|
|
22
46
|
const PRESENTATION_MESSAGE_TYPE = "pi-workflows-presentation";
|
|
23
47
|
const FINAL_WIDGET_TTL_MS = 60_000;
|
|
24
48
|
const WIDGET_SCROLL_STEP = 3;
|
|
25
49
|
const MAX_PRESENTATION_RESULT_CHARS = 50_000;
|
|
50
|
+
const MAX_STATUS_ERROR_CHARS = 4_000;
|
|
51
|
+
const MAX_WORKFLOW_LIST_ITEMS = 50;
|
|
52
|
+
const MAX_WORKFLOW_LIST_NAME_CHARS = 3_500;
|
|
26
53
|
const PRESENTATION_TIMEOUT_MS = 30_000;
|
|
27
54
|
|
|
28
55
|
class PresentationSupersededError extends Error {}
|
|
@@ -34,7 +61,7 @@ type PresentationPromptBuilder = Exclude<
|
|
|
34
61
|
>;
|
|
35
62
|
|
|
36
63
|
type ActiveRun = {
|
|
37
|
-
runId: string
|
|
64
|
+
runId: string;
|
|
38
65
|
workflowName: string;
|
|
39
66
|
engine: WorkflowEngine;
|
|
40
67
|
executor: ConversationStepExecutor;
|
|
@@ -43,6 +70,31 @@ type ActiveRun = {
|
|
|
43
70
|
presentationPrompt: WorkflowDefinition["presentationPrompt"];
|
|
44
71
|
generation: number;
|
|
45
72
|
lastState: WorkflowRunState | null;
|
|
73
|
+
childKey?: string;
|
|
74
|
+
onFinish?: (result: WorkflowSchedulerResult) => void;
|
|
75
|
+
completion?: Promise<void>;
|
|
76
|
+
interruptionRequested?: boolean;
|
|
77
|
+
claimToken?: string | undefined;
|
|
78
|
+
renewTimer?: ReturnType<typeof setInterval> | undefined;
|
|
79
|
+
/** True for runs this session resumed from the queue. */
|
|
80
|
+
resume?: boolean | undefined;
|
|
81
|
+
/** Set on continuation runs: the checkpointed parent run id. */
|
|
82
|
+
parentRunId?: string | undefined;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
type StartRunOptions = {
|
|
86
|
+
runId?: string;
|
|
87
|
+
childKey?: string;
|
|
88
|
+
onFinish?: (result: WorkflowSchedulerResult) => void;
|
|
89
|
+
presentation?: boolean;
|
|
90
|
+
quiet?: boolean;
|
|
91
|
+
signal?: AbortSignal;
|
|
92
|
+
/** Continue a checkpointed run: input becomes the answer payload. */
|
|
93
|
+
parentRunId?: string;
|
|
94
|
+
/** Resume a parked run at its stopped node instead of starting fresh. */
|
|
95
|
+
resume?: boolean;
|
|
96
|
+
/** An existing queue claim token, when the caller already claimed the run. */
|
|
97
|
+
claimToken?: string;
|
|
46
98
|
};
|
|
47
99
|
|
|
48
100
|
export type ParsedWorkflowArgs =
|
|
@@ -50,6 +102,8 @@ export type ParsedWorkflowArgs =
|
|
|
50
102
|
| { kind: "cancel" }
|
|
51
103
|
| { kind: "pause" }
|
|
52
104
|
| { kind: "resume" }
|
|
105
|
+
| { kind: "status"; runId?: string }
|
|
106
|
+
| { kind: "answer"; input: unknown; runId?: string | undefined }
|
|
53
107
|
| { kind: "run"; ref: string; input: unknown };
|
|
54
108
|
|
|
55
109
|
/** Parse `/workflow` arguments. Exported for tests. */
|
|
@@ -61,6 +115,52 @@ export function parseWorkflowArgs(args: string): ParsedWorkflowArgs {
|
|
|
61
115
|
if (trimmed === "cancel" || trimmed === "pause" || trimmed === "resume") {
|
|
62
116
|
return { kind: trimmed };
|
|
63
117
|
}
|
|
118
|
+
if (trimmed === "status") {
|
|
119
|
+
return { kind: "status" };
|
|
120
|
+
}
|
|
121
|
+
if (trimmed.startsWith("status ")) {
|
|
122
|
+
const runId = trimmed.slice("status".length).trim();
|
|
123
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(runId)) {
|
|
124
|
+
throw new Error("status requires one valid run id");
|
|
125
|
+
}
|
|
126
|
+
return { kind: "status", runId };
|
|
127
|
+
}
|
|
128
|
+
if (trimmed === "answer" || trimmed.startsWith("answer ")) {
|
|
129
|
+
let rest = trimmed === "answer" ? "" : trimmed.slice("answer".length).trim();
|
|
130
|
+
if (rest.length === 0) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
'answer requires a JSON value or text, e.g. /workflow answer {"approved":true}',
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
// `/workflow answer <run-id> <json>` targets a specific waiting run,
|
|
136
|
+
// for example after a restart or for host-driven runs.
|
|
137
|
+
let runId: string | undefined;
|
|
138
|
+
const firstSpace = rest.search(/\s/);
|
|
139
|
+
if (firstSpace > 0) {
|
|
140
|
+
const candidate = rest.slice(0, firstSpace);
|
|
141
|
+
const remainder = rest.slice(firstSpace).trim();
|
|
142
|
+
if (
|
|
143
|
+
/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(candidate) &&
|
|
144
|
+
remainder.length > 0 &&
|
|
145
|
+
(remainder.startsWith("{") || remainder.startsWith("["))
|
|
146
|
+
) {
|
|
147
|
+
runId = candidate;
|
|
148
|
+
rest = remainder;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
return {
|
|
153
|
+
kind: "answer",
|
|
154
|
+
input: JSON.parse(rest) as unknown,
|
|
155
|
+
...(runId !== undefined ? { runId } : {}),
|
|
156
|
+
};
|
|
157
|
+
} catch {
|
|
158
|
+
if (runId !== undefined) {
|
|
159
|
+
throw new Error("answer with a run id requires a JSON value");
|
|
160
|
+
}
|
|
161
|
+
return { kind: "answer", input: { answer: rest } };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
64
164
|
const spaceIndex = trimmed.search(/\s/);
|
|
65
165
|
const ref = spaceIndex === -1 ? trimmed : trimmed.slice(0, spaceIndex);
|
|
66
166
|
const rest = spaceIndex === -1 ? "" : trimmed.slice(spaceIndex).trim();
|
|
@@ -77,13 +177,185 @@ export function parseWorkflowArgs(args: string): ParsedWorkflowArgs {
|
|
|
77
177
|
return { kind: "run", ref, input: rest.length > 0 ? { task: rest } : {} };
|
|
78
178
|
}
|
|
79
179
|
|
|
180
|
+
function workflowStateSummary(state: WorkflowRunState): JsonObject {
|
|
181
|
+
const error =
|
|
182
|
+
state.error === undefined
|
|
183
|
+
? undefined
|
|
184
|
+
: state.error.length <= MAX_STATUS_ERROR_CHARS
|
|
185
|
+
? state.error
|
|
186
|
+
: `${state.error.slice(0, MAX_STATUS_ERROR_CHARS)}\n… [error truncated]`;
|
|
187
|
+
return {
|
|
188
|
+
active: state.status === "running",
|
|
189
|
+
runId: state.runId,
|
|
190
|
+
workflowName: state.workflowName,
|
|
191
|
+
status: state.status,
|
|
192
|
+
steps: state.steps.length,
|
|
193
|
+
...(state.currentNode !== undefined ? { currentNode: state.currentNode } : {}),
|
|
194
|
+
...(state.waitingOn !== undefined ? { waitingOn: state.waitingOn } : {}),
|
|
195
|
+
...(state.startedAt !== undefined ? { startedAt: state.startedAt } : {}),
|
|
196
|
+
...(state.finishedAt !== undefined ? { finishedAt: state.finishedAt } : {}),
|
|
197
|
+
...(error !== undefined ? { error } : {}),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
80
201
|
type WidgetSource = {
|
|
81
202
|
state: WorkflowRunState;
|
|
82
203
|
snapshot: WorkflowDefinitionSnapshot;
|
|
83
204
|
};
|
|
84
205
|
|
|
85
206
|
export default function piWorkflows(pi: ExtensionAPI) {
|
|
207
|
+
// One runner identity per session; it names this session in run claims.
|
|
208
|
+
const runnerId = randomUUID();
|
|
209
|
+
let runQueueStore: SqliteControllerStore | null = null;
|
|
210
|
+
const ensureRunQueueStore = (cwd: string): SqliteControllerStore => {
|
|
211
|
+
runQueueStore ??= new SqliteControllerStore(projectControllerStorePath(cwd));
|
|
212
|
+
return runQueueStore;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// Session sync: a per-session watermark over the run event feed keeps this
|
|
216
|
+
// session's context current with runs other runners drove.
|
|
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";
|
|
220
|
+
let syncArmed = false;
|
|
221
|
+
let runSyncTimer: ReturnType<typeof setInterval> | null = null;
|
|
222
|
+
|
|
223
|
+
const recordRunEvent = (event: {
|
|
224
|
+
runId: string;
|
|
225
|
+
workflowRef: string;
|
|
226
|
+
type: string;
|
|
227
|
+
payload?: JsonObject;
|
|
228
|
+
}) => {
|
|
229
|
+
try {
|
|
230
|
+
runQueueStore?.recordRunEvent({ ...event, runnerId });
|
|
231
|
+
} catch {
|
|
232
|
+
// The event feed is best-effort; never fail a run for it.
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const describeRunEvent = (event: RunEventRecord): string => {
|
|
237
|
+
const label = `${event.workflowRef} run ${event.runId}`;
|
|
238
|
+
switch (event.type) {
|
|
239
|
+
case "waiting": {
|
|
240
|
+
const waitingOn =
|
|
241
|
+
typeof event.payload.waitingOn === "string" ? event.payload.waitingOn : "a checkpoint";
|
|
242
|
+
return `${label} waits at checkpoint ${waitingOn} — answer with /workflow answer`;
|
|
243
|
+
}
|
|
244
|
+
case "parked":
|
|
245
|
+
return `${label} was parked and will resume when a runner is available`;
|
|
246
|
+
case "failed": {
|
|
247
|
+
const detail = typeof event.payload.error === "string" ? `: ${event.payload.error}` : "";
|
|
248
|
+
return `${label} failed${detail}`;
|
|
249
|
+
}
|
|
250
|
+
default:
|
|
251
|
+
return `${label} ${event.type}`;
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const runSyncPass = async (ctx: ExtensionContext): Promise<void> => {
|
|
256
|
+
if (runQueueStore === null || !syncArmed) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
const watermark = runQueueStore.getSessionWatermark(SYNC_WATERMARK_KEY);
|
|
261
|
+
if (watermark === 0) {
|
|
262
|
+
// First sync ever for this project: never replay the feed (stale
|
|
263
|
+
// "waits at checkpoint" lines included). Fast-forward, then catch
|
|
264
|
+
// up from current state instead — what is parked, resuming, or
|
|
265
|
+
// waiting for an answer right now.
|
|
266
|
+
const latest = runQueueStore.latestRunEventSeq();
|
|
267
|
+
if (latest > 0) {
|
|
268
|
+
runQueueStore.setSessionWatermark(SYNC_WATERMARK_KEY, latest);
|
|
269
|
+
}
|
|
270
|
+
await sendStateSnapshot(ctx);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const events = runQueueStore.listRunEventsAfter(watermark, { limit: 20 });
|
|
274
|
+
if (events.length === 0) {
|
|
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;
|
|
290
|
+
}
|
|
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
|
+
} catch {
|
|
298
|
+
// Sync is observational.
|
|
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;
|
|
306
|
+
}
|
|
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
|
+
};
|
|
340
|
+
|
|
341
|
+
const startRunSync = (ctx: ExtensionContext) => {
|
|
342
|
+
if (runSyncTimer !== null) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
ensureRunQueueStore(ctx.cwd);
|
|
346
|
+
void runSyncPass(ctx);
|
|
347
|
+
runSyncTimer = setInterval(() => void runSyncPass(ctx), RUN_SYNC_POLL_MS);
|
|
348
|
+
runSyncTimer.unref?.();
|
|
349
|
+
};
|
|
86
350
|
let activeRun: ActiveRun | null = null;
|
|
351
|
+
let pendingToolLaunch: {
|
|
352
|
+
ctx: ExtensionContext;
|
|
353
|
+
ref: string;
|
|
354
|
+
input: unknown;
|
|
355
|
+
options?: StartRunOptions;
|
|
356
|
+
} | null = null;
|
|
357
|
+
// The interactive run currently parked at a checkpoint, if any.
|
|
358
|
+
let lastWaitingRunId: string | null = null;
|
|
87
359
|
let widgetTimer: NodeJS.Timeout | null = null;
|
|
88
360
|
let widgetTicker: NodeJS.Timeout | null = null;
|
|
89
361
|
// Manual widget scroll: null follows the active node; a number is the
|
|
@@ -97,6 +369,8 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
97
369
|
let runGeneration = 0;
|
|
98
370
|
let presentationAbort: AbortController | null = null;
|
|
99
371
|
let presentationPending: number | null = null;
|
|
372
|
+
let controllerHost: PiControllerHost | undefined;
|
|
373
|
+
let controllerContext: ExtensionContext | null = null;
|
|
100
374
|
|
|
101
375
|
// UI updates are best-effort: a captured ctx becomes stale after session
|
|
102
376
|
// replacement or shutdown, and pi throws on any access (even `ctx.hasUI`).
|
|
@@ -292,10 +566,60 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
292
566
|
}
|
|
293
567
|
};
|
|
294
568
|
|
|
295
|
-
|
|
569
|
+
// Release the queue claim exactly once. "done" marks the queue row
|
|
570
|
+
// terminal, "park" leaves it claimable for another runner, and "lost"
|
|
571
|
+
// leaves it for the new claim holder.
|
|
572
|
+
const releaseClaim = (run: ActiveRun, outcome: "done" | "lost" | "park") => {
|
|
573
|
+
if (run.renewTimer !== undefined) {
|
|
574
|
+
clearInterval(run.renewTimer);
|
|
575
|
+
run.renewTimer = undefined;
|
|
576
|
+
}
|
|
577
|
+
if (run.claimToken === undefined || runQueueStore === null || outcome === "lost") {
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
try {
|
|
581
|
+
if (outcome === "park") {
|
|
582
|
+
runQueueStore.parkWorkflowRun({ runId: run.runId, claimToken: run.claimToken });
|
|
583
|
+
} else {
|
|
584
|
+
runQueueStore.completeWorkflowRun({ runId: run.runId, claimToken: run.claimToken });
|
|
585
|
+
}
|
|
586
|
+
} catch {
|
|
587
|
+
// Queue bookkeeping is best-effort next to the durable bundle.
|
|
588
|
+
} finally {
|
|
589
|
+
run.claimToken = undefined;
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
const finishRun = async (
|
|
594
|
+
ctx: ExtensionContext,
|
|
595
|
+
run: ActiveRun,
|
|
596
|
+
result: WorkflowRunResult,
|
|
597
|
+
): Promise<void> => {
|
|
296
598
|
if (activeRun === run) {
|
|
297
599
|
activeRun = null;
|
|
298
600
|
}
|
|
601
|
+
if (result.state.status === "running") {
|
|
602
|
+
// The run was parked for another runner; its bundle stays resumable
|
|
603
|
+
// and its queue row becomes claimable. The recorder drains first: its
|
|
604
|
+
// finalization writes share the fenced store, so the claim must stay
|
|
605
|
+
// valid until capture is complete. Nothing else to present.
|
|
606
|
+
await run.recorder?.stop().catch(() => undefined);
|
|
607
|
+
releaseClaim(run, "park");
|
|
608
|
+
recordRunEvent({ runId: run.runId, workflowRef: run.workflowName, type: "parked" });
|
|
609
|
+
stopWidgetTicker();
|
|
610
|
+
clearWidget(ctx);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
releaseClaim(run, "done");
|
|
614
|
+
recordRunEvent({
|
|
615
|
+
runId: run.runId,
|
|
616
|
+
workflowRef: run.workflowName,
|
|
617
|
+
type: result.state.status,
|
|
618
|
+
payload: {
|
|
619
|
+
...(result.state.error !== undefined ? { error: result.state.error } : {}),
|
|
620
|
+
...(result.state.waitingOn !== undefined ? { waitingOn: result.state.waitingOn } : {}),
|
|
621
|
+
},
|
|
622
|
+
});
|
|
299
623
|
// Normally already stopped via onRunFinishing; this covers observers of
|
|
300
624
|
// runs that ended without reaching that hook.
|
|
301
625
|
void run.recorder?.stop();
|
|
@@ -309,32 +633,125 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
309
633
|
widgetTimer = setTimeout(() => clearWidget(ctx), FINAL_WIDGET_TTL_MS);
|
|
310
634
|
widgetTimer.unref?.();
|
|
311
635
|
}
|
|
636
|
+
if (state.status === "waiting" && run.childKey === undefined) {
|
|
637
|
+
lastWaitingRunId = run.runId;
|
|
638
|
+
}
|
|
312
639
|
const summary =
|
|
313
640
|
state.status === "waiting" && state.waitingOn
|
|
314
|
-
? `Workflow ${state.workflowName} parked at checkpoint ${state.waitingOn} —
|
|
641
|
+
? `Workflow ${state.workflowName} parked at checkpoint ${state.waitingOn} — answer with /workflow answer <json> (run ${state.runId})`
|
|
315
642
|
: `Workflow ${state.workflowName} ${state.status} (run ${state.runId})`;
|
|
316
643
|
notify(ctx, summary, state.status === "completed" ? "info" : "warning");
|
|
644
|
+
try {
|
|
645
|
+
const childResult =
|
|
646
|
+
run.childKey !== undefined &&
|
|
647
|
+
(sessionClosed || run.interruptionRequested === true) &&
|
|
648
|
+
state.status === "cancelled"
|
|
649
|
+
? {
|
|
650
|
+
state: "interrupted" as const,
|
|
651
|
+
runId: state.runId,
|
|
652
|
+
...(state.error !== undefined ? { error: state.error } : {}),
|
|
653
|
+
}
|
|
654
|
+
: workflowSchedulerResult(state);
|
|
655
|
+
run.onFinish?.(childResult);
|
|
656
|
+
} catch (error) {
|
|
657
|
+
notify(ctx, `Could not record child workflow completion: ${errorMessage(error)}`, "warning");
|
|
658
|
+
}
|
|
317
659
|
void presentRun(ctx, run, state);
|
|
318
660
|
};
|
|
319
661
|
|
|
320
|
-
const startRun = async (
|
|
662
|
+
const startRun = async (
|
|
663
|
+
ctx: ExtensionContext,
|
|
664
|
+
ref: string,
|
|
665
|
+
input: unknown,
|
|
666
|
+
options: StartRunOptions = {},
|
|
667
|
+
): Promise<string | undefined> => {
|
|
668
|
+
if (options.signal?.aborted) {
|
|
669
|
+
throw options.signal.reason ?? new Error("Workflow startup aborted");
|
|
670
|
+
}
|
|
321
671
|
if (activeRun) {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
672
|
+
if (!options.quiet) {
|
|
673
|
+
notify(
|
|
674
|
+
ctx,
|
|
675
|
+
`A workflow is already running: ${activeRun.workflowName}. Use /workflow cancel first.`,
|
|
676
|
+
"error",
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
return undefined;
|
|
328
680
|
}
|
|
329
681
|
if (presentationPending !== null) {
|
|
330
|
-
|
|
331
|
-
|
|
682
|
+
if (!options.quiet) {
|
|
683
|
+
notify(
|
|
684
|
+
ctx,
|
|
685
|
+
"The previous workflow result is still being presented. Wait for it to finish.",
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
return undefined;
|
|
332
689
|
}
|
|
333
690
|
supersedePresentation();
|
|
334
691
|
const generation = runGeneration;
|
|
335
692
|
const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd });
|
|
336
693
|
const workflow = await loadWorkflowFile(resolved.path);
|
|
694
|
+
if (options.signal?.aborted) {
|
|
695
|
+
throw options.signal.reason ?? new Error("Workflow startup aborted");
|
|
696
|
+
}
|
|
337
697
|
const snapshot = createDefinitionSnapshot(workflow);
|
|
698
|
+
const workflowHash = await hashWorkflowSource(resolved.path);
|
|
699
|
+
const runId = options.runId ?? createRunId(workflow.name);
|
|
700
|
+
|
|
701
|
+
// Continuations validate the parent before touching the queue: a
|
|
702
|
+
// refused continuation (edited source, missing parent) must not consume
|
|
703
|
+
// the parent's one-continuation slot.
|
|
704
|
+
if (options.parentRunId !== undefined) {
|
|
705
|
+
const parent = await readRunBundle(new WorkflowRunStore().runDirFor(options.parentRunId));
|
|
706
|
+
if (parent === null || parent.state.status !== "waiting") {
|
|
707
|
+
throw new Error(`Workflow run ${options.parentRunId} is not waiting at a checkpoint`);
|
|
708
|
+
}
|
|
709
|
+
if (parent.state.workflowHash !== undefined && parent.state.workflowHash !== workflowHash) {
|
|
710
|
+
throw new Error(
|
|
711
|
+
`Workflow source changed since run ${options.parentRunId} started; revert the edit to answer its checkpoint`,
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Interactive runs are queued and claimed atomically, so this session
|
|
717
|
+
// owns the run from birth (origin affinity). Controller child runs keep
|
|
718
|
+
// their own scheduling and stay out of the run queue. A resume caller
|
|
719
|
+
// arrives with its claim already taken.
|
|
720
|
+
let claimToken = options.claimToken;
|
|
721
|
+
let queueStore: SqliteControllerStore | null = null;
|
|
722
|
+
if (options.childKey === undefined) {
|
|
723
|
+
queueStore = ensureRunQueueStore(ctx.cwd);
|
|
724
|
+
if (claimToken === undefined) {
|
|
725
|
+
const token = randomUUID();
|
|
726
|
+
queueStore.enqueueWorkflowRun({
|
|
727
|
+
runId,
|
|
728
|
+
workflowRef: ref,
|
|
729
|
+
workflowPath: resolved.path,
|
|
730
|
+
input,
|
|
731
|
+
runnerId,
|
|
732
|
+
claimToken: token,
|
|
733
|
+
leaseMs: RUN_CLAIM_LEASE_MS,
|
|
734
|
+
...(options.parentRunId !== undefined ? { parentRunId: options.parentRunId } : {}),
|
|
735
|
+
});
|
|
736
|
+
claimToken = token;
|
|
737
|
+
recordRunEvent({
|
|
738
|
+
runId,
|
|
739
|
+
workflowRef: ref,
|
|
740
|
+
type: "queued",
|
|
741
|
+
payload: options.parentRunId !== undefined ? { parentRunId: options.parentRunId } : {},
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
let fence: (() => void) | undefined;
|
|
746
|
+
if (queueStore !== null && claimToken !== undefined) {
|
|
747
|
+
const claimedStore = queueStore;
|
|
748
|
+
const token = claimToken;
|
|
749
|
+
fence = () => {
|
|
750
|
+
if (!claimedStore.verifyWorkflowRunClaim({ runId, claimToken: token })) {
|
|
751
|
+
throw new ClaimLostError(runId);
|
|
752
|
+
}
|
|
753
|
+
};
|
|
754
|
+
}
|
|
338
755
|
|
|
339
756
|
const executor = new ConversationStepExecutor({
|
|
340
757
|
sendPrompt: ({ prompt, streaming }) => {
|
|
@@ -347,8 +764,12 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
347
764
|
},
|
|
348
765
|
});
|
|
349
766
|
// The store is shared between the engine and the session recorder so the
|
|
350
|
-
// trace sequence stays single-writer (see docs/run-bundles.md).
|
|
351
|
-
|
|
767
|
+
// trace sequence stays single-writer (see docs/run-bundles.md). Queued
|
|
768
|
+
// runs are fenced: every write proves the claim is still ours first.
|
|
769
|
+
const store = new WorkflowRunStore(
|
|
770
|
+
undefined,
|
|
771
|
+
fence === undefined ? {} : { fenceProvider: () => fence },
|
|
772
|
+
);
|
|
352
773
|
const engine = new WorkflowEngine({
|
|
353
774
|
executor,
|
|
354
775
|
store,
|
|
@@ -371,67 +792,604 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
371
792
|
await run.recorder?.finish();
|
|
372
793
|
},
|
|
373
794
|
onEvent: (_event, state: WorkflowRunState) => {
|
|
374
|
-
if (run.runId === null) {
|
|
375
|
-
run.runId = state.runId;
|
|
376
|
-
}
|
|
377
795
|
run.lastState = state;
|
|
378
796
|
updateWidget(ctx, state, snapshot);
|
|
379
797
|
},
|
|
380
798
|
});
|
|
381
799
|
const run: ActiveRun = {
|
|
382
|
-
runId
|
|
800
|
+
runId,
|
|
383
801
|
workflowName: workflow.name,
|
|
384
802
|
engine,
|
|
385
803
|
executor,
|
|
386
804
|
recorder: null,
|
|
387
805
|
snapshot,
|
|
388
|
-
presentationPrompt: workflow.presentationPrompt,
|
|
806
|
+
presentationPrompt: options.presentation === false ? undefined : workflow.presentationPrompt,
|
|
389
807
|
generation,
|
|
390
808
|
lastState: null,
|
|
809
|
+
...(options.childKey !== undefined ? { childKey: options.childKey } : {}),
|
|
810
|
+
...(options.onFinish !== undefined ? { onFinish: options.onFinish } : {}),
|
|
811
|
+
...(claimToken !== undefined ? { claimToken } : {}),
|
|
812
|
+
...(options.resume === true ? { resume: true } : {}),
|
|
813
|
+
...(options.parentRunId !== undefined ? { parentRunId: options.parentRunId } : {}),
|
|
391
814
|
};
|
|
392
815
|
activeRun = run;
|
|
816
|
+
if (queueStore !== null && claimToken !== undefined) {
|
|
817
|
+
const store = queueStore;
|
|
818
|
+
const token = claimToken;
|
|
819
|
+
// Renew the claim while the run executes. If renewal says the claim is
|
|
820
|
+
// gone, cancel so the fence trips on the next write instead of letting
|
|
821
|
+
// this runner interleave with the new claim holder.
|
|
822
|
+
run.renewTimer = setInterval(() => {
|
|
823
|
+
try {
|
|
824
|
+
if (
|
|
825
|
+
!store.renewWorkflowRunClaim({ runId, claimToken: token, leaseMs: RUN_CLAIM_LEASE_MS })
|
|
826
|
+
) {
|
|
827
|
+
run.engine.cancel();
|
|
828
|
+
}
|
|
829
|
+
} catch {
|
|
830
|
+
// Transient store errors leave fencing to decide ownership.
|
|
831
|
+
}
|
|
832
|
+
}, RUN_CLAIM_RENEW_MS);
|
|
833
|
+
run.renewTimer.unref?.();
|
|
834
|
+
}
|
|
393
835
|
clearWidgetTimer();
|
|
394
836
|
startWidgetTicker(ctx, run);
|
|
395
|
-
|
|
837
|
+
if (!options.quiet) {
|
|
838
|
+
notify(ctx, `Workflow ${workflow.name} started. Follow it live with: pi-workflows view`);
|
|
839
|
+
}
|
|
396
840
|
|
|
397
|
-
|
|
398
|
-
.
|
|
841
|
+
run.completion = (
|
|
842
|
+
options.resume === true
|
|
843
|
+
? engine.resumeRun(workflow, runId, { workflowHash })
|
|
844
|
+
: options.parentRunId === undefined
|
|
845
|
+
? engine.run(workflow, input, { workflowPath: resolved.path, workflowHash, runId })
|
|
846
|
+
: engine.continueRun(workflow, options.parentRunId, input, {
|
|
847
|
+
workflowPath: resolved.path,
|
|
848
|
+
workflowHash,
|
|
849
|
+
runId,
|
|
850
|
+
})
|
|
851
|
+
)
|
|
399
852
|
.then((result) => finishRun(ctx, run, result))
|
|
400
|
-
.catch((error: unknown) => {
|
|
853
|
+
.catch(async (error: unknown) => {
|
|
401
854
|
if (activeRun === run) {
|
|
402
855
|
activeRun = null;
|
|
403
856
|
}
|
|
857
|
+
const message = errorMessage(error);
|
|
858
|
+
// A resume that fails before executing (for example edited workflow
|
|
859
|
+
// source) must not strand the run: park it so it stays claimable
|
|
860
|
+
// once the cause is fixed, and say so in the feed.
|
|
861
|
+
let resumeFailed = run.resume === true && !isClaimLostError(error);
|
|
862
|
+
let terminalStatus: string | undefined;
|
|
863
|
+
// Re-parking only makes sense while the bundle is still resumable.
|
|
864
|
+
// A terminal or waiting bundle closes the queue row instead, or
|
|
865
|
+
// every session start would retry it forever — and the feed reports
|
|
866
|
+
// the bundle's real state, not a bogus failure.
|
|
867
|
+
if (resumeFailed) {
|
|
868
|
+
try {
|
|
869
|
+
const bundle = await readRunBundle(new WorkflowRunStore().runDirFor(runId));
|
|
870
|
+
if (bundle === null || bundle.state.status !== "running") {
|
|
871
|
+
resumeFailed = false;
|
|
872
|
+
terminalStatus = bundle?.state.status;
|
|
873
|
+
}
|
|
874
|
+
} catch {
|
|
875
|
+
// Unreadable bundles close the row too.
|
|
876
|
+
resumeFailed = false;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
// A continuation that failed before its bundle exists frees the
|
|
880
|
+
// parent's continuation slot instead of consuming it forever.
|
|
881
|
+
let continuationSlotFreed = false;
|
|
882
|
+
if (
|
|
883
|
+
run.parentRunId !== undefined &&
|
|
884
|
+
run.claimToken !== undefined &&
|
|
885
|
+
!isClaimLostError(error)
|
|
886
|
+
) {
|
|
887
|
+
try {
|
|
888
|
+
const bundle = await readRunBundle(new WorkflowRunStore().runDirFor(runId));
|
|
889
|
+
if (bundle === null) {
|
|
890
|
+
continuationSlotFreed =
|
|
891
|
+
runQueueStore?.deleteWorkflowRun({ runId, claimToken: run.claimToken }) === true;
|
|
892
|
+
if (continuationSlotFreed) {
|
|
893
|
+
run.claimToken = undefined;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
} catch {
|
|
897
|
+
// The row may remain; a later cleanup pass can remove it.
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
releaseClaim(
|
|
901
|
+
run,
|
|
902
|
+
isClaimLostError(error) || continuationSlotFreed
|
|
903
|
+
? "lost"
|
|
904
|
+
: resumeFailed
|
|
905
|
+
? "park"
|
|
906
|
+
: "done",
|
|
907
|
+
);
|
|
404
908
|
void run.recorder?.stop();
|
|
405
909
|
stopWidgetTicker();
|
|
406
910
|
clearWidget(ctx);
|
|
407
|
-
|
|
911
|
+
if (isClaimLostError(error)) {
|
|
912
|
+
notify(ctx, `Workflow ${workflow.name} continues under another runner (run ${runId}).`);
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
if (resumeFailed) {
|
|
916
|
+
recordRunEvent({ runId, workflowRef: workflow.name, type: "parked", payload: {} });
|
|
917
|
+
notify(
|
|
918
|
+
ctx,
|
|
919
|
+
`Could not resume workflow ${workflow.name}: ${message}. The run is parked again.`,
|
|
920
|
+
"warning",
|
|
921
|
+
);
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
if (terminalStatus !== undefined) {
|
|
925
|
+
recordRunEvent({ runId, workflowRef: workflow.name, type: terminalStatus, payload: {} });
|
|
926
|
+
notify(ctx, `Workflow ${workflow.name} ${terminalStatus} (run ${runId}).`);
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
recordRunEvent({
|
|
930
|
+
runId,
|
|
931
|
+
workflowRef: workflow.name,
|
|
932
|
+
type: "failed",
|
|
933
|
+
payload: { error: message },
|
|
934
|
+
});
|
|
935
|
+
try {
|
|
936
|
+
run.onFinish?.({ state: "failed", runId, error: message });
|
|
937
|
+
} catch {
|
|
938
|
+
// The original workflow failure remains the primary error.
|
|
939
|
+
}
|
|
940
|
+
notify(ctx, `Workflow ${workflow.name} crashed: ${message}`, "error");
|
|
941
|
+
});
|
|
942
|
+
return runId;
|
|
943
|
+
};
|
|
944
|
+
|
|
945
|
+
// Reclaim and resume a parked run when this session opens without an
|
|
946
|
+
// active run. The claim comes first; the engine resumes at the stopped
|
|
947
|
+
// node only after the queue proves ownership.
|
|
948
|
+
const resumeParkedRun = async (ctx: ExtensionContext): Promise<void> => {
|
|
949
|
+
if (activeRun !== null) {
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
const queueStore = ensureRunQueueStore(ctx.cwd);
|
|
953
|
+
const claimToken = randomUUID();
|
|
954
|
+
const claimed = queueStore.claimNextWorkflowRun({
|
|
955
|
+
runnerId,
|
|
956
|
+
claimToken,
|
|
957
|
+
leaseMs: RUN_CLAIM_LEASE_MS,
|
|
958
|
+
});
|
|
959
|
+
if (claimed === undefined) {
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
let started: string | undefined;
|
|
963
|
+
try {
|
|
964
|
+
started = await startRun(ctx, claimed.workflowPath, claimed.input, {
|
|
965
|
+
resume: true,
|
|
966
|
+
runId: claimed.runId,
|
|
967
|
+
claimToken,
|
|
408
968
|
});
|
|
969
|
+
} catch (error) {
|
|
970
|
+
queueStore.parkWorkflowRun({ runId: claimed.runId, claimToken });
|
|
971
|
+
throw error;
|
|
972
|
+
}
|
|
973
|
+
if (started !== undefined) {
|
|
974
|
+
notify(ctx, `Resumed workflow run ${claimed.runId} (${claimed.workflowRef}).`);
|
|
975
|
+
} else {
|
|
976
|
+
queueStore.parkWorkflowRun({ runId: claimed.runId, claimToken });
|
|
977
|
+
}
|
|
409
978
|
};
|
|
410
979
|
|
|
411
|
-
const
|
|
980
|
+
const startChild: PiChildWorkflowStarter = async (request, signal, onComplete) => {
|
|
981
|
+
if (signal.aborted) {
|
|
982
|
+
throw signal.reason ?? new Error("Child workflow scheduling aborted");
|
|
983
|
+
}
|
|
984
|
+
const childKey = `${request.requestId}:${request.attempt}`;
|
|
985
|
+
if (activeRun !== null) {
|
|
986
|
+
if (activeRun.childKey !== childKey) {
|
|
987
|
+
return { state: "pending" };
|
|
988
|
+
}
|
|
989
|
+
return activeRun.lastState === null
|
|
990
|
+
? { state: "running", runId: activeRun.runId }
|
|
991
|
+
: workflowSchedulerResult(activeRun.lastState);
|
|
992
|
+
}
|
|
993
|
+
const store = new WorkflowRunStore();
|
|
994
|
+
const bundle = await readRunBundle(store.runDirFor(request.runId));
|
|
995
|
+
if (bundle !== null) {
|
|
996
|
+
const recovered =
|
|
997
|
+
bundle.state.status === "running" ? await store.markRunInterrupted(request.runId) : bundle;
|
|
998
|
+
if (recovered !== null) {
|
|
999
|
+
const lastTraceEvent = await readLastTraceEvent(
|
|
1000
|
+
recovered.runDir,
|
|
1001
|
+
recovered.manifest.paths.trace,
|
|
1002
|
+
);
|
|
1003
|
+
return workflowSchedulerResult(recovered.state, lastTraceEvent?.type === "run_interrupted");
|
|
1004
|
+
}
|
|
1005
|
+
return { state: "pending" };
|
|
1006
|
+
}
|
|
1007
|
+
if (controllerContext === null) {
|
|
1008
|
+
return { state: "pending" };
|
|
1009
|
+
}
|
|
1010
|
+
await store.quarantineIncompleteRun(request.runId);
|
|
1011
|
+
const runId = await startRun(controllerContext, request.workflow, request.input, {
|
|
1012
|
+
runId: request.runId,
|
|
1013
|
+
childKey,
|
|
1014
|
+
onFinish: onComplete,
|
|
1015
|
+
presentation: false,
|
|
1016
|
+
quiet: true,
|
|
1017
|
+
signal,
|
|
1018
|
+
});
|
|
1019
|
+
return runId === undefined ? { state: "pending" } : { state: "running", runId };
|
|
1020
|
+
};
|
|
1021
|
+
|
|
1022
|
+
const updateControllerStatus = (ctx: ExtensionContext) => {
|
|
1023
|
+
if (controllerHost === undefined) {
|
|
1024
|
+
ctx.ui.setStatus("pi-controllers", undefined);
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
const count = controllerHost.store.listResources().length;
|
|
1028
|
+
ctx.ui.setStatus("pi-controllers", `${count} controller resource${count === 1 ? "" : "s"}`);
|
|
1029
|
+
};
|
|
1030
|
+
|
|
1031
|
+
const ensureControllerHost = async (
|
|
1032
|
+
ctx: ExtensionContext,
|
|
1033
|
+
): Promise<PiControllerHost | undefined> => {
|
|
1034
|
+
controllerContext = ctx;
|
|
1035
|
+
if (controllerHost !== undefined) {
|
|
1036
|
+
return controllerHost;
|
|
1037
|
+
}
|
|
1038
|
+
controllerHost = await PiControllerHost.create({ cwd: ctx.cwd, startChild });
|
|
1039
|
+
controllerHost?.start();
|
|
1040
|
+
updateControllerStatus(ctx);
|
|
1041
|
+
return controllerHost;
|
|
1042
|
+
};
|
|
1043
|
+
|
|
1044
|
+
type WorkflowControlResult = {
|
|
1045
|
+
message: string;
|
|
1046
|
+
details: JsonObject;
|
|
1047
|
+
level?: "info" | "warning" | "error";
|
|
1048
|
+
};
|
|
1049
|
+
|
|
1050
|
+
const listWorkflowControl = async (
|
|
1051
|
+
ctx: ExtensionContext,
|
|
1052
|
+
offset = 0,
|
|
1053
|
+
): Promise<WorkflowControlResult> => {
|
|
412
1054
|
const discovered = await discoverWorkflows({ cwd: ctx.cwd });
|
|
413
1055
|
if (discovered.length === 0) {
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
1056
|
+
return {
|
|
1057
|
+
message:
|
|
1058
|
+
"No workflows found. Put *.workflow.ts files in .pi/workflows/ or ~/.pi/agent/workflows/, or pass a path.",
|
|
1059
|
+
details: { workflows: [], total: 0, offset: 0 },
|
|
1060
|
+
level: "warning",
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
if (!Number.isInteger(offset) || offset < 0 || offset > discovered.length) {
|
|
1064
|
+
throw new Error(
|
|
1065
|
+
`Workflow list offset must be an integer from 0 through ${discovered.length}.`,
|
|
418
1066
|
);
|
|
419
|
-
return;
|
|
420
1067
|
}
|
|
421
|
-
const
|
|
422
|
-
|
|
1068
|
+
const page = [];
|
|
1069
|
+
let nameChars = 0;
|
|
1070
|
+
for (const workflow of discovered.slice(offset)) {
|
|
1071
|
+
const renderedName = `${workflow.name} (${workflow.source})`;
|
|
1072
|
+
if (
|
|
1073
|
+
page.length >= MAX_WORKFLOW_LIST_ITEMS ||
|
|
1074
|
+
nameChars + renderedName.length > MAX_WORKFLOW_LIST_NAME_CHARS
|
|
1075
|
+
) {
|
|
1076
|
+
break;
|
|
1077
|
+
}
|
|
1078
|
+
page.push(workflow);
|
|
1079
|
+
nameChars += renderedName.length;
|
|
1080
|
+
}
|
|
1081
|
+
const names = page.map((workflow) => `${workflow.name} (${workflow.source})`).join(", ");
|
|
1082
|
+
const nextOffset = offset + page.length;
|
|
1083
|
+
const omitted = discovered.length - nextOffset;
|
|
1084
|
+
return {
|
|
1085
|
+
message: [
|
|
1086
|
+
`Workflows: ${names}.`,
|
|
1087
|
+
omitted > 0 ? `${omitted} more omitted; list again with offset ${nextOffset}.` : "",
|
|
1088
|
+
"Run one with /workflow <name> [task].",
|
|
1089
|
+
]
|
|
1090
|
+
.filter(Boolean)
|
|
1091
|
+
.join(" "),
|
|
1092
|
+
details: {
|
|
1093
|
+
workflows: page.map((workflow) => ({
|
|
1094
|
+
name: workflow.name,
|
|
1095
|
+
source: workflow.source,
|
|
1096
|
+
...(workflow.name === "monitor"
|
|
1097
|
+
? {
|
|
1098
|
+
description:
|
|
1099
|
+
"Repeatedly check a target, report requested changes, and stop on a condition.",
|
|
1100
|
+
}
|
|
1101
|
+
: {}),
|
|
1102
|
+
})),
|
|
1103
|
+
total: discovered.length,
|
|
1104
|
+
offset,
|
|
1105
|
+
omitted,
|
|
1106
|
+
...(omitted > 0 ? { nextOffset } : {}),
|
|
1107
|
+
},
|
|
1108
|
+
};
|
|
1109
|
+
};
|
|
1110
|
+
|
|
1111
|
+
const listWorkflows = async (ctx: ExtensionContext) => {
|
|
1112
|
+
const result = await listWorkflowControl(ctx);
|
|
1113
|
+
notify(ctx, result.message, result.level);
|
|
1114
|
+
};
|
|
1115
|
+
|
|
1116
|
+
const cancelWorkflowControl = (ctx: ExtensionContext): WorkflowControlResult => {
|
|
1117
|
+
if (activeRun) {
|
|
1118
|
+
const workflowName = activeRun.workflowName;
|
|
1119
|
+
const runId = activeRun.runId;
|
|
1120
|
+
activeRun.engine.cancel();
|
|
1121
|
+
return {
|
|
1122
|
+
message: `Cancelling workflow ${workflowName}…`,
|
|
1123
|
+
details: { action: "cancel", workflowName, runId },
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
if (pendingToolLaunch !== null) {
|
|
1127
|
+
const ref = pendingToolLaunch.ref;
|
|
1128
|
+
pendingToolLaunch = null;
|
|
1129
|
+
return {
|
|
1130
|
+
message: `Cancelled the queued workflow launch for ${ref}.`,
|
|
1131
|
+
details: { action: "cancel", workflow: ref, queued: false },
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
if (widgetSource) {
|
|
1135
|
+
const { state } = widgetSource;
|
|
1136
|
+
clearWidgetTimer();
|
|
1137
|
+
clearWidget(ctx);
|
|
1138
|
+
const detail =
|
|
1139
|
+
state.status === "waiting" && state.waitingOn
|
|
1140
|
+
? `already ended at checkpoint ${state.waitingOn}`
|
|
1141
|
+
: `already ${state.status}`;
|
|
1142
|
+
return {
|
|
1143
|
+
message: `Workflow ${state.workflowName} ${detail}; cleared its widget.`,
|
|
1144
|
+
details: { action: "clear", workflowName: state.workflowName, runId: state.runId },
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
return {
|
|
1148
|
+
message: "No workflow is running.",
|
|
1149
|
+
details: { action: "cancel", active: false },
|
|
1150
|
+
level: "warning",
|
|
1151
|
+
};
|
|
1152
|
+
};
|
|
1153
|
+
|
|
1154
|
+
const pauseWorkflowControl = (ctx: ExtensionContext): WorkflowControlResult => {
|
|
1155
|
+
if (!activeRun) {
|
|
1156
|
+
return {
|
|
1157
|
+
message: "No workflow is running.",
|
|
1158
|
+
details: { action: "pause", active: false },
|
|
1159
|
+
level: "warning",
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
if (runHeld()) {
|
|
1163
|
+
return {
|
|
1164
|
+
message: `Workflow ${activeRun.workflowName} is already pausing or paused.`,
|
|
1165
|
+
details: {
|
|
1166
|
+
action: "pause",
|
|
1167
|
+
workflowName: activeRun.workflowName,
|
|
1168
|
+
runId: activeRun.runId,
|
|
1169
|
+
paused: true,
|
|
1170
|
+
},
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
activeRun.engine.pause();
|
|
1174
|
+
renderWidget(ctx);
|
|
1175
|
+
return {
|
|
1176
|
+
message: `Pausing workflow ${activeRun.workflowName}; the current step will finish before the run holds.`,
|
|
1177
|
+
details: {
|
|
1178
|
+
action: "pause",
|
|
1179
|
+
workflowName: activeRun.workflowName,
|
|
1180
|
+
runId: activeRun.runId,
|
|
1181
|
+
paused: true,
|
|
1182
|
+
},
|
|
1183
|
+
};
|
|
1184
|
+
};
|
|
1185
|
+
|
|
1186
|
+
const resumeWorkflowControl = (ctx: ExtensionContext): WorkflowControlResult => {
|
|
1187
|
+
if (!activeRun) {
|
|
1188
|
+
return {
|
|
1189
|
+
message: "No workflow is running.",
|
|
1190
|
+
details: { action: "resume", active: false },
|
|
1191
|
+
level: "warning",
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
if (!runHeld()) {
|
|
1195
|
+
return {
|
|
1196
|
+
message: `Workflow ${activeRun.workflowName} is not paused.`,
|
|
1197
|
+
details: {
|
|
1198
|
+
action: "resume",
|
|
1199
|
+
workflowName: activeRun.workflowName,
|
|
1200
|
+
runId: activeRun.runId,
|
|
1201
|
+
paused: false,
|
|
1202
|
+
},
|
|
1203
|
+
level: "warning",
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
activeRun.engine.resume();
|
|
1207
|
+
activeRun.executor.release();
|
|
1208
|
+
renderWidget(ctx);
|
|
1209
|
+
return {
|
|
1210
|
+
message: `Workflow ${activeRun.workflowName} resumed.`,
|
|
1211
|
+
details: {
|
|
1212
|
+
action: "resume",
|
|
1213
|
+
workflowName: activeRun.workflowName,
|
|
1214
|
+
runId: activeRun.runId,
|
|
1215
|
+
paused: false,
|
|
1216
|
+
},
|
|
1217
|
+
};
|
|
1218
|
+
};
|
|
1219
|
+
|
|
1220
|
+
const statusWorkflowControl = async (
|
|
1221
|
+
ctx: ExtensionContext,
|
|
1222
|
+
runId?: string,
|
|
1223
|
+
): Promise<WorkflowControlResult> => {
|
|
1224
|
+
if (runId !== undefined) {
|
|
1225
|
+
const bundle = await readRunBundle(new WorkflowRunStore().runDirFor(runId));
|
|
1226
|
+
if (bundle === null) {
|
|
1227
|
+
throw new Error(`Workflow run not found: ${runId}`);
|
|
1228
|
+
}
|
|
1229
|
+
const { state } = bundle;
|
|
1230
|
+
return {
|
|
1231
|
+
message: `Workflow ${state.workflowName} is ${state.status} (run ${state.runId}).`,
|
|
1232
|
+
details: workflowStateSummary(state),
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
const state = activeRun?.lastState ?? widgetSource?.state;
|
|
1236
|
+
if ((state === undefined || state === null) && pendingToolLaunch !== null) {
|
|
1237
|
+
return {
|
|
1238
|
+
message: `Workflow ${pendingToolLaunch.ref} is queued until the current turn finishes.`,
|
|
1239
|
+
details: { active: false, queued: true, workflow: pendingToolLaunch.ref },
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
if (state === undefined || state === null) {
|
|
1243
|
+
return {
|
|
1244
|
+
message: "No workflow run is active or displayed.",
|
|
1245
|
+
details: { active: false },
|
|
1246
|
+
level: "warning",
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
return {
|
|
1250
|
+
message: `Workflow ${state.workflowName} is ${state.status} (run ${state.runId}).`,
|
|
1251
|
+
details: workflowStateSummary(state),
|
|
1252
|
+
};
|
|
1253
|
+
};
|
|
1254
|
+
|
|
1255
|
+
const resolveWaitingWorkflow = async (
|
|
1256
|
+
ctx: ExtensionContext,
|
|
1257
|
+
requestedRunId?: string,
|
|
1258
|
+
): Promise<{ parentRunId: string; workflowPath: string }> => {
|
|
1259
|
+
let parentRunId = requestedRunId ?? lastWaitingRunId;
|
|
1260
|
+
if (parentRunId === null) {
|
|
1261
|
+
const rows = ensureRunQueueStore(ctx.cwd).listWorkflowRuns();
|
|
1262
|
+
const known = new Set(rows.map((row) => row.runId));
|
|
1263
|
+
const continued = new Set(
|
|
1264
|
+
rows.map((row) => row.parentRunId).filter((parent): parent is string => parent !== null),
|
|
1265
|
+
);
|
|
1266
|
+
const bundles = await listRunBundles(new WorkflowRunStore().outputRoot);
|
|
1267
|
+
parentRunId =
|
|
1268
|
+
bundles.find(
|
|
1269
|
+
(bundle) =>
|
|
1270
|
+
bundle.state.status === "waiting" &&
|
|
1271
|
+
known.has(bundle.state.runId) &&
|
|
1272
|
+
!continued.has(bundle.state.runId),
|
|
1273
|
+
)?.state.runId ?? null;
|
|
1274
|
+
}
|
|
1275
|
+
if (parentRunId === null) {
|
|
1276
|
+
throw new Error("No workflow is waiting for an answer.");
|
|
1277
|
+
}
|
|
1278
|
+
const parent = await readRunBundle(new WorkflowRunStore().runDirFor(parentRunId));
|
|
1279
|
+
if (
|
|
1280
|
+
parent === null ||
|
|
1281
|
+
parent.state.status !== "waiting" ||
|
|
1282
|
+
parent.state.workflowPath === undefined
|
|
1283
|
+
) {
|
|
1284
|
+
if (parentRunId === lastWaitingRunId) {
|
|
1285
|
+
lastWaitingRunId = null;
|
|
1286
|
+
}
|
|
1287
|
+
throw new Error(`Workflow run ${parentRunId} is no longer waiting.`);
|
|
1288
|
+
}
|
|
1289
|
+
return { parentRunId, workflowPath: parent.state.workflowPath };
|
|
1290
|
+
};
|
|
1291
|
+
|
|
1292
|
+
const answerWorkflowControl = async (
|
|
1293
|
+
ctx: ExtensionContext,
|
|
1294
|
+
input: unknown,
|
|
1295
|
+
requestedRunId?: string,
|
|
1296
|
+
): Promise<WorkflowControlResult> => {
|
|
1297
|
+
const waiting = await resolveWaitingWorkflow(ctx, requestedRunId);
|
|
1298
|
+
const continued = await startRun(ctx, waiting.workflowPath, input, {
|
|
1299
|
+
parentRunId: waiting.parentRunId,
|
|
1300
|
+
});
|
|
1301
|
+
if (continued === undefined) {
|
|
1302
|
+
throw new Error("Could not start the checkpoint continuation.");
|
|
1303
|
+
}
|
|
1304
|
+
lastWaitingRunId = null;
|
|
1305
|
+
return {
|
|
1306
|
+
message: `Answered checkpoint ${waiting.parentRunId}; continuation ${continued} started.`,
|
|
1307
|
+
details: {
|
|
1308
|
+
action: "answer",
|
|
1309
|
+
parentRunId: waiting.parentRunId,
|
|
1310
|
+
runId: continued,
|
|
1311
|
+
},
|
|
1312
|
+
};
|
|
1313
|
+
};
|
|
1314
|
+
|
|
1315
|
+
const startWorkflowControl = async (
|
|
1316
|
+
ctx: ExtensionContext,
|
|
1317
|
+
ref: string,
|
|
1318
|
+
input: unknown,
|
|
1319
|
+
): Promise<WorkflowControlResult> => {
|
|
1320
|
+
if (activeRun !== null) {
|
|
1321
|
+
throw new Error(`A workflow is already running: ${activeRun.workflowName}.`);
|
|
1322
|
+
}
|
|
1323
|
+
if (pendingToolLaunch !== null) {
|
|
1324
|
+
throw new Error("A workflow launch is already waiting for the current turn to finish.");
|
|
1325
|
+
}
|
|
1326
|
+
if (presentationPending !== null) {
|
|
1327
|
+
throw new Error("The previous workflow result is still being presented.");
|
|
1328
|
+
}
|
|
1329
|
+
const runId = await startRun(ctx, ref, input);
|
|
1330
|
+
if (runId === undefined) {
|
|
1331
|
+
throw new Error("The workflow could not start.");
|
|
1332
|
+
}
|
|
1333
|
+
return {
|
|
1334
|
+
message: `Workflow ${ref} started (run ${runId}).`,
|
|
1335
|
+
details: { action: "start", workflow: ref, runId },
|
|
1336
|
+
};
|
|
1337
|
+
};
|
|
1338
|
+
|
|
1339
|
+
const queueToolLaunch = async (
|
|
1340
|
+
ctx: ExtensionContext,
|
|
1341
|
+
ref: string,
|
|
1342
|
+
input: unknown,
|
|
1343
|
+
options: StartRunOptions = {},
|
|
1344
|
+
): Promise<WorkflowControlResult> => {
|
|
1345
|
+
if (activeRun !== null) {
|
|
1346
|
+
throw new Error(
|
|
1347
|
+
`A workflow is already running: ${activeRun.workflowName}. Cancel it before starting another.`,
|
|
1348
|
+
);
|
|
1349
|
+
}
|
|
1350
|
+
if (pendingToolLaunch !== null) {
|
|
1351
|
+
throw new Error("A workflow launch is already waiting for the current turn to finish.");
|
|
1352
|
+
}
|
|
1353
|
+
if (presentationPending !== null) {
|
|
1354
|
+
throw new Error("The previous workflow result is still being presented.");
|
|
1355
|
+
}
|
|
1356
|
+
const reservation = { ctx, ref, input, options };
|
|
1357
|
+
pendingToolLaunch = reservation;
|
|
1358
|
+
try {
|
|
1359
|
+
const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd });
|
|
1360
|
+
const workflow = await loadWorkflowFile(resolved.path);
|
|
1361
|
+
if (pendingToolLaunch !== reservation) {
|
|
1362
|
+
throw new Error("The queued workflow launch was cancelled before validation finished.");
|
|
1363
|
+
}
|
|
1364
|
+
return {
|
|
1365
|
+
message: `Workflow ${workflow.name} will start after this turn finishes.`,
|
|
1366
|
+
details: {
|
|
1367
|
+
action: "start",
|
|
1368
|
+
workflow: workflow.name,
|
|
1369
|
+
source: resolved.source,
|
|
1370
|
+
queued: true,
|
|
1371
|
+
},
|
|
1372
|
+
};
|
|
1373
|
+
} catch (error) {
|
|
1374
|
+
if (pendingToolLaunch === reservation) {
|
|
1375
|
+
pendingToolLaunch = null;
|
|
1376
|
+
}
|
|
1377
|
+
throw error;
|
|
1378
|
+
}
|
|
423
1379
|
};
|
|
424
1380
|
|
|
425
1381
|
pi.registerCommand("workflow", {
|
|
426
1382
|
description:
|
|
427
|
-
"Run a workflow: /workflow <name-or-path> [task | --input-json {…}]; also: pause, resume, cancel",
|
|
1383
|
+
"Run or manage a workflow: /workflow <name-or-path> [task | --input-json {…}]; also: status, pause, resume, cancel, answer",
|
|
428
1384
|
getArgumentCompletions: async (prefix: string) => {
|
|
429
1385
|
const discovered = await discoverWorkflows({ cwd: process.cwd() });
|
|
430
1386
|
const items = [
|
|
431
1387
|
...discovered.map((workflow) => ({ value: workflow.name, label: workflow.name })),
|
|
1388
|
+
{ value: "status", label: "status" },
|
|
432
1389
|
{ value: "pause", label: "pause" },
|
|
433
1390
|
{ value: "resume", label: "resume" },
|
|
434
1391
|
{ value: "cancel", label: "cancel" },
|
|
1392
|
+
{ value: "answer", label: "answer" },
|
|
435
1393
|
].filter((item) => item.value.startsWith(prefix));
|
|
436
1394
|
return items.length > 0 ? items : null;
|
|
437
1395
|
},
|
|
@@ -448,102 +1406,186 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
448
1406
|
return;
|
|
449
1407
|
}
|
|
450
1408
|
if (parsed.kind === "cancel") {
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
notify(ctx, `Cancelling workflow ${activeRun.workflowName}…`);
|
|
454
|
-
return;
|
|
455
|
-
}
|
|
456
|
-
// No live run, but a parked (waiting) or recently finished run may
|
|
457
|
-
// still occupy the widget; cancel clears it.
|
|
458
|
-
if (widgetSource) {
|
|
459
|
-
const { state } = widgetSource;
|
|
460
|
-
clearWidgetTimer();
|
|
461
|
-
clearWidget(ctx);
|
|
462
|
-
const detail =
|
|
463
|
-
state.status === "waiting" && state.waitingOn
|
|
464
|
-
? `already ended at checkpoint ${state.waitingOn}`
|
|
465
|
-
: `already ${state.status}`;
|
|
466
|
-
notify(ctx, `Workflow ${state.workflowName} ${detail}; cleared its widget.`);
|
|
467
|
-
return;
|
|
468
|
-
}
|
|
469
|
-
notify(ctx, "No workflow is running.", "warning");
|
|
1409
|
+
const result = cancelWorkflowControl(ctx);
|
|
1410
|
+
notify(ctx, result.message, result.level);
|
|
470
1411
|
return;
|
|
471
1412
|
}
|
|
472
1413
|
if (parsed.kind === "pause") {
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
return;
|
|
476
|
-
}
|
|
477
|
-
if (runHeld()) {
|
|
478
|
-
notify(ctx, `Workflow ${activeRun.workflowName} is already pausing or paused.`);
|
|
479
|
-
return;
|
|
480
|
-
}
|
|
481
|
-
activeRun.engine.pause();
|
|
482
|
-
renderWidget(ctx);
|
|
483
|
-
notify(
|
|
484
|
-
ctx,
|
|
485
|
-
`Pausing workflow ${activeRun.workflowName} — the current step finishes, then the run holds. /workflow resume to continue.`,
|
|
486
|
-
);
|
|
1414
|
+
const result = pauseWorkflowControl(ctx);
|
|
1415
|
+
notify(ctx, result.message, result.level);
|
|
487
1416
|
return;
|
|
488
1417
|
}
|
|
489
1418
|
if (parsed.kind === "resume") {
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
1419
|
+
const result = resumeWorkflowControl(ctx);
|
|
1420
|
+
notify(ctx, result.message, result.level);
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
if (parsed.kind === "status") {
|
|
1424
|
+
try {
|
|
1425
|
+
const result = await statusWorkflowControl(ctx, parsed.runId);
|
|
1426
|
+
notify(ctx, result.message, result.level);
|
|
1427
|
+
} catch (error) {
|
|
1428
|
+
notify(ctx, errorMessage(error), "error");
|
|
493
1429
|
}
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
if (parsed.kind === "answer") {
|
|
1433
|
+
try {
|
|
1434
|
+
const result = await answerWorkflowControl(ctx, parsed.input, parsed.runId);
|
|
1435
|
+
notify(ctx, result.message, result.level);
|
|
1436
|
+
} catch (error) {
|
|
1437
|
+
const message = errorMessage(error);
|
|
1438
|
+
notify(
|
|
1439
|
+
ctx,
|
|
1440
|
+
/workflow_run_queue_parent/.test(message)
|
|
1441
|
+
? "That checkpoint was already answered; see its continuation run."
|
|
1442
|
+
: `Could not continue workflow: ${message}`,
|
|
1443
|
+
"error",
|
|
1444
|
+
);
|
|
497
1445
|
}
|
|
498
|
-
activeRun.engine.resume();
|
|
499
|
-
activeRun.executor.release();
|
|
500
|
-
renderWidget(ctx);
|
|
501
|
-
notify(ctx, `Workflow ${activeRun.workflowName} resumed.`);
|
|
502
1446
|
return;
|
|
503
1447
|
}
|
|
504
1448
|
try {
|
|
505
|
-
await
|
|
1449
|
+
const result = await startWorkflowControl(ctx, parsed.ref, parsed.input);
|
|
1450
|
+
notify(ctx, result.message, result.level);
|
|
506
1451
|
} catch (error) {
|
|
507
1452
|
notify(ctx, `Could not start workflow: ${errorMessage(error)}`, "error");
|
|
508
1453
|
}
|
|
509
1454
|
},
|
|
510
1455
|
});
|
|
511
1456
|
|
|
1457
|
+
pi.registerCommand("controller", {
|
|
1458
|
+
description: "Manage durable controllers: list, get, apply, reconcile, delete, start, or stop",
|
|
1459
|
+
getArgumentCompletions: async (prefix: string) => {
|
|
1460
|
+
const items = ["list", "get", "apply", "reconcile", "delete", "start", "stop"]
|
|
1461
|
+
.filter((value) => value.startsWith(prefix))
|
|
1462
|
+
.map((value) => ({ value, label: value }));
|
|
1463
|
+
return items.length > 0 ? items : null;
|
|
1464
|
+
},
|
|
1465
|
+
handler: async (args, ctx) => {
|
|
1466
|
+
let parsed;
|
|
1467
|
+
try {
|
|
1468
|
+
parsed = parseControllerArgs(args);
|
|
1469
|
+
} catch (error) {
|
|
1470
|
+
notify(ctx, errorMessage(error), "error");
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
const host = await ensureControllerHost(ctx);
|
|
1474
|
+
if (host === undefined) {
|
|
1475
|
+
notify(
|
|
1476
|
+
ctx,
|
|
1477
|
+
"No controllers found. Put *.controller.ts files in .pi/controllers/ or ~/.pi/agent/controllers/.",
|
|
1478
|
+
"warning",
|
|
1479
|
+
);
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1482
|
+
try {
|
|
1483
|
+
switch (parsed.kind) {
|
|
1484
|
+
case "list":
|
|
1485
|
+
notify(ctx, host.list());
|
|
1486
|
+
break;
|
|
1487
|
+
case "get":
|
|
1488
|
+
notify(ctx, host.get(parsed.controller, parsed.key));
|
|
1489
|
+
break;
|
|
1490
|
+
case "apply": {
|
|
1491
|
+
const resource = host.apply(parsed.controller, parsed.key, parsed.spec);
|
|
1492
|
+
updateControllerStatus(ctx);
|
|
1493
|
+
notify(
|
|
1494
|
+
ctx,
|
|
1495
|
+
`Applied ${resource.metadata.controller}/${resource.metadata.key} generation ${resource.metadata.generation}.`,
|
|
1496
|
+
);
|
|
1497
|
+
break;
|
|
1498
|
+
}
|
|
1499
|
+
case "reconcile":
|
|
1500
|
+
host.reconcile(parsed.controller, parsed.key);
|
|
1501
|
+
notify(ctx, `Queued ${parsed.controller}/${parsed.key}.`);
|
|
1502
|
+
break;
|
|
1503
|
+
case "delete":
|
|
1504
|
+
host.delete(parsed.controller, parsed.key);
|
|
1505
|
+
notify(ctx, `Requested deletion of ${parsed.controller}/${parsed.key}.`);
|
|
1506
|
+
break;
|
|
1507
|
+
case "start":
|
|
1508
|
+
host.start();
|
|
1509
|
+
notify(ctx, "Controller workers started.");
|
|
1510
|
+
break;
|
|
1511
|
+
case "stop":
|
|
1512
|
+
if (activeRun?.childKey !== undefined) {
|
|
1513
|
+
activeRun.interruptionRequested = true;
|
|
1514
|
+
activeRun.engine.cancel();
|
|
1515
|
+
}
|
|
1516
|
+
await host.stop();
|
|
1517
|
+
notify(ctx, "Controller workers stopped.");
|
|
1518
|
+
break;
|
|
1519
|
+
}
|
|
1520
|
+
} catch (error) {
|
|
1521
|
+
notify(ctx, `Controller command failed: ${errorMessage(error)}`, "error");
|
|
1522
|
+
}
|
|
1523
|
+
},
|
|
1524
|
+
});
|
|
1525
|
+
|
|
512
1526
|
pi.registerTool({
|
|
513
1527
|
name: "workflow",
|
|
514
1528
|
label: "Workflow",
|
|
515
1529
|
description: [
|
|
516
|
-
"
|
|
517
|
-
"
|
|
518
|
-
"
|
|
1530
|
+
"List, start, inspect, pause, resume, cancel, answer, or complete Pi Workflows runs.",
|
|
1531
|
+
"When the user asks to monitor, watch, poll, or check something repeatedly, start the built-in monitor workflow with input keys task, everyMinutes, reportWhen, stopWhen, and optional maxChecks.",
|
|
1532
|
+
"Use submit only when a workflow step contract asks for it, and pass the exact step and attempt ids.",
|
|
1533
|
+
"Do not start repeated work without the user's request, and keep monitoring observation-only unless the user authorizes mutations.",
|
|
519
1534
|
].join(" "),
|
|
520
|
-
parameters:
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
1535
|
+
parameters: WorkflowToolParameters,
|
|
1536
|
+
async execute(_toolCallId, params: WorkflowToolInput, _signal, _onUpdate, ctx) {
|
|
1537
|
+
let control: WorkflowControlResult;
|
|
1538
|
+
switch (params.action) {
|
|
1539
|
+
case "list":
|
|
1540
|
+
control = await listWorkflowControl(ctx, params.offset);
|
|
1541
|
+
break;
|
|
1542
|
+
case "start":
|
|
1543
|
+
control = await queueToolLaunch(ctx, params.workflow, params.input ?? {});
|
|
1544
|
+
break;
|
|
1545
|
+
case "status":
|
|
1546
|
+
control = await statusWorkflowControl(ctx, params.runId);
|
|
1547
|
+
break;
|
|
1548
|
+
case "pause":
|
|
1549
|
+
control = pauseWorkflowControl(ctx);
|
|
1550
|
+
break;
|
|
1551
|
+
case "resume":
|
|
1552
|
+
control = resumeWorkflowControl(ctx);
|
|
1553
|
+
break;
|
|
1554
|
+
case "cancel":
|
|
1555
|
+
control = cancelWorkflowControl(ctx);
|
|
1556
|
+
break;
|
|
1557
|
+
case "answer": {
|
|
1558
|
+
const waiting = await resolveWaitingWorkflow(ctx, params.runId);
|
|
1559
|
+
control = await queueToolLaunch(ctx, waiting.workflowPath, params.input, {
|
|
1560
|
+
parentRunId: waiting.parentRunId,
|
|
1561
|
+
});
|
|
1562
|
+
break;
|
|
1563
|
+
}
|
|
1564
|
+
case "submit": {
|
|
1565
|
+
if (!activeRun) {
|
|
1566
|
+
throw new Error("No workflow step is waiting for output.");
|
|
1567
|
+
}
|
|
1568
|
+
// Flush the conversation into the bundle before accepting, so the
|
|
1569
|
+
// attempt range includes the assistant message carrying this call.
|
|
1570
|
+
await activeRun.recorder?.record(ctx).catch(() => undefined);
|
|
1571
|
+
await activeRun.recorder?.synchronize(ctx).catch(() => undefined);
|
|
1572
|
+
const result = await activeRun.executor.submit(
|
|
1573
|
+
params.step,
|
|
1574
|
+
params.attempt,
|
|
1575
|
+
params.output,
|
|
1576
|
+
);
|
|
1577
|
+
if (!result.accepted) {
|
|
1578
|
+
throw new Error(result.message);
|
|
1579
|
+
}
|
|
1580
|
+
return {
|
|
1581
|
+
content: [{ type: "text", text: result.message }],
|
|
1582
|
+
details: { action: "submit", step: params.step, accepted: true },
|
|
1583
|
+
};
|
|
1584
|
+
}
|
|
543
1585
|
}
|
|
544
1586
|
return {
|
|
545
|
-
content: [{ type: "text", text:
|
|
546
|
-
details:
|
|
1587
|
+
content: [{ type: "text", text: control.message }],
|
|
1588
|
+
details: control.details,
|
|
547
1589
|
};
|
|
548
1590
|
},
|
|
549
1591
|
});
|
|
@@ -558,6 +1600,27 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
558
1600
|
handler: (ctx) => scrollWidget(ctx, WIDGET_SCROLL_STEP),
|
|
559
1601
|
});
|
|
560
1602
|
|
|
1603
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
1604
|
+
sessionClosed = false;
|
|
1605
|
+
controllerContext = ctx;
|
|
1606
|
+
try {
|
|
1607
|
+
syncArmed = true;
|
|
1608
|
+
startRunSync(ctx);
|
|
1609
|
+
} catch {
|
|
1610
|
+
// Session sync is best-effort; runs themselves never depend on it.
|
|
1611
|
+
}
|
|
1612
|
+
try {
|
|
1613
|
+
await resumeParkedRun(ctx);
|
|
1614
|
+
} catch (error) {
|
|
1615
|
+
notify(ctx, `Could not resume a parked workflow: ${errorMessage(error)}`, "warning");
|
|
1616
|
+
}
|
|
1617
|
+
try {
|
|
1618
|
+
await ensureControllerHost(ctx);
|
|
1619
|
+
} catch (error) {
|
|
1620
|
+
notify(ctx, `Could not start controller workers: ${errorMessage(error)}`, "warning");
|
|
1621
|
+
}
|
|
1622
|
+
});
|
|
1623
|
+
|
|
561
1624
|
pi.on("agent_start", () => {
|
|
562
1625
|
if (!activeRun && presentationPending === null && presentationAbort) {
|
|
563
1626
|
// A normal user turn started while an async presentation prompt was
|
|
@@ -628,6 +1691,21 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
628
1691
|
});
|
|
629
1692
|
|
|
630
1693
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
1694
|
+
if (activeRun === null && pendingToolLaunch !== null) {
|
|
1695
|
+
const launch = pendingToolLaunch;
|
|
1696
|
+
pendingToolLaunch = null;
|
|
1697
|
+
try {
|
|
1698
|
+
const runId = await startRun(launch.ctx, launch.ref, launch.input, launch.options);
|
|
1699
|
+
if (runId === undefined) {
|
|
1700
|
+
notify(launch.ctx, "The queued workflow could not start.", "error");
|
|
1701
|
+
} else if (launch.options?.parentRunId !== undefined) {
|
|
1702
|
+
lastWaitingRunId = null;
|
|
1703
|
+
}
|
|
1704
|
+
} catch (error) {
|
|
1705
|
+
notify(launch.ctx, `Could not start queued workflow: ${errorMessage(error)}`, "error");
|
|
1706
|
+
}
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
631
1709
|
const run = activeRun;
|
|
632
1710
|
if (!run) {
|
|
633
1711
|
presentationPending = null;
|
|
@@ -639,13 +1717,32 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
639
1717
|
run.executor.handleAgentSettled();
|
|
640
1718
|
});
|
|
641
1719
|
|
|
642
|
-
pi.on("session_shutdown", () => {
|
|
1720
|
+
pi.on("session_shutdown", async () => {
|
|
643
1721
|
sessionClosed = true;
|
|
644
1722
|
supersedePresentation();
|
|
645
1723
|
const run = activeRun;
|
|
646
|
-
run
|
|
647
|
-
|
|
1724
|
+
if (run !== null && run.claimToken !== undefined) {
|
|
1725
|
+
// Queued interactive runs park: no terminal event, no recorded partial
|
|
1726
|
+
// attempt, and the claim releases so another runner can resume.
|
|
1727
|
+
run.engine.park();
|
|
1728
|
+
} else {
|
|
1729
|
+
run?.engine.cancel();
|
|
1730
|
+
}
|
|
1731
|
+
await run?.recorder?.stop().catch(() => undefined);
|
|
1732
|
+
await run?.completion?.catch(() => undefined);
|
|
648
1733
|
activeRun = null;
|
|
1734
|
+
pendingToolLaunch = null;
|
|
1735
|
+
lastWaitingRunId = null;
|
|
1736
|
+
if (runSyncTimer !== null) {
|
|
1737
|
+
clearInterval(runSyncTimer);
|
|
1738
|
+
runSyncTimer = null;
|
|
1739
|
+
}
|
|
1740
|
+
syncArmed = false;
|
|
1741
|
+
await controllerHost?.close().catch(() => undefined);
|
|
1742
|
+
controllerHost = undefined;
|
|
1743
|
+
controllerContext = null;
|
|
1744
|
+
runQueueStore?.close();
|
|
1745
|
+
runQueueStore = null;
|
|
649
1746
|
presentationPending = null;
|
|
650
1747
|
clearWidgetTimer();
|
|
651
1748
|
stopWidgetTicker();
|
|
@@ -654,6 +1751,35 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
654
1751
|
});
|
|
655
1752
|
}
|
|
656
1753
|
|
|
1754
|
+
function workflowSchedulerResult(
|
|
1755
|
+
state: WorkflowRunState,
|
|
1756
|
+
interrupted = false,
|
|
1757
|
+
): WorkflowSchedulerResult {
|
|
1758
|
+
if (interrupted) {
|
|
1759
|
+
return {
|
|
1760
|
+
state: "interrupted",
|
|
1761
|
+
runId: state.runId,
|
|
1762
|
+
...(state.error !== undefined ? { error: state.error } : {}),
|
|
1763
|
+
};
|
|
1764
|
+
}
|
|
1765
|
+
switch (state.status) {
|
|
1766
|
+
case "running":
|
|
1767
|
+
return { state: "running", runId: state.runId };
|
|
1768
|
+
case "waiting":
|
|
1769
|
+
return { state: "waiting", runId: state.runId };
|
|
1770
|
+
case "completed":
|
|
1771
|
+
return { state: "succeeded", runId: state.runId };
|
|
1772
|
+
case "failed":
|
|
1773
|
+
case "timed_out":
|
|
1774
|
+
case "cancelled":
|
|
1775
|
+
return {
|
|
1776
|
+
state: "failed",
|
|
1777
|
+
runId: state.runId,
|
|
1778
|
+
...(state.error !== undefined ? { error: state.error } : {}),
|
|
1779
|
+
};
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
|
|
657
1783
|
async function resolvePresentationPrompt(
|
|
658
1784
|
buildPrompt: PresentationPromptBuilder,
|
|
659
1785
|
state: WorkflowRunState,
|