@signalridge/pi-workflows 0.2.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/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +39 -0
- package/package.json +56 -0
- package/src/engine.ts +2760 -0
- package/src/index.ts +326 -0
- package/src/journal.ts +1584 -0
- package/src/rpc-client.ts +364 -0
- package/src/schema.ts +186 -0
- package/src/state-machine.ts +73 -0
- package/src/synthesis.ts +95 -0
- package/src/ui.ts +142 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
4
|
+
import { Type } from "@sinclair/typebox";
|
|
5
|
+
import { WorkflowEngine, type WorkflowQuiesceResult } from "./engine.js";
|
|
6
|
+
import { JOURNAL_ENTRY_TYPE, type SessionEntryLike } from "./journal.js";
|
|
7
|
+
import { createManagedSpawnClient, PROTOCOL_DIAGNOSTIC, queryChildSessionContextImmediate } from "./rpc-client.js";
|
|
8
|
+
import { validateWorkflow } from "./schema.js";
|
|
9
|
+
import { showWorkflowNavigator } from "./ui.js";
|
|
10
|
+
|
|
11
|
+
const WorkflowTierSchema = Type.Union([Type.Literal("small"), Type.Literal("medium"), Type.Literal("large")]);
|
|
12
|
+
|
|
13
|
+
const PhaseSchema = Type.Object(
|
|
14
|
+
{
|
|
15
|
+
id: Type.String({ minLength: 1, maxLength: 128 }),
|
|
16
|
+
title: Type.String({ minLength: 1, maxLength: 512 }),
|
|
17
|
+
},
|
|
18
|
+
{ additionalProperties: false },
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const TaskSchema = Type.Object(
|
|
22
|
+
{
|
|
23
|
+
id: Type.String({ minLength: 1, maxLength: 128 }),
|
|
24
|
+
phase: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
|
|
25
|
+
tier: Type.Optional(WorkflowTierSchema),
|
|
26
|
+
subagent_type: Type.String({ minLength: 1, maxLength: 128 }),
|
|
27
|
+
description: Type.String({ minLength: 1, maxLength: 512 }),
|
|
28
|
+
prompt: Type.String({ minLength: 1, maxLength: 100_000 }),
|
|
29
|
+
depends_on: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 128 }), { maxItems: 128 })),
|
|
30
|
+
},
|
|
31
|
+
{ additionalProperties: false },
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const SynthesisSchema = Type.Object(
|
|
35
|
+
{
|
|
36
|
+
subagent_type: Type.String({ minLength: 1, maxLength: 128 }),
|
|
37
|
+
tier: Type.Optional(WorkflowTierSchema),
|
|
38
|
+
prompt: Type.String({ minLength: 1, maxLength: 100_000 }),
|
|
39
|
+
},
|
|
40
|
+
{ additionalProperties: false },
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const WorkflowSchema = Type.Object(
|
|
44
|
+
{
|
|
45
|
+
name: Type.String({ minLength: 1, maxLength: 512 }),
|
|
46
|
+
description: Type.Optional(Type.String({ maxLength: 100_000 })),
|
|
47
|
+
tier: Type.Optional(WorkflowTierSchema),
|
|
48
|
+
phases: Type.Optional(Type.Array(PhaseSchema, { maxItems: 32 })),
|
|
49
|
+
tasks: Type.Array(TaskSchema, { minItems: 1, maxItems: 128 }),
|
|
50
|
+
synthesis: Type.Optional(SynthesisSchema),
|
|
51
|
+
background: Type.Optional(Type.Boolean()),
|
|
52
|
+
},
|
|
53
|
+
{ additionalProperties: false },
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const ControlSchema = Type.Object(
|
|
57
|
+
{
|
|
58
|
+
action: Type.String({ minLength: 1, maxLength: 32 }),
|
|
59
|
+
run_id: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
|
|
60
|
+
},
|
|
61
|
+
{ additionalProperties: false },
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
function textResult(text: string, details: unknown = {}) {
|
|
65
|
+
return {
|
|
66
|
+
content: [{ type: "text" as const, text }],
|
|
67
|
+
details,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function formatStart(result: {
|
|
72
|
+
runId: string;
|
|
73
|
+
status: string;
|
|
74
|
+
background: boolean;
|
|
75
|
+
result?: string;
|
|
76
|
+
error?: string;
|
|
77
|
+
waitAborted?: boolean;
|
|
78
|
+
}): string {
|
|
79
|
+
const lines = [
|
|
80
|
+
`Workflow ${result.background ? "started in background" : result.status}: ${result.runId}`,
|
|
81
|
+
`status=${result.status}`,
|
|
82
|
+
];
|
|
83
|
+
if (result.waitAborted)
|
|
84
|
+
lines.push("wait_aborted=true", "The workflow run continues; retrieve it with workflow_control.");
|
|
85
|
+
if (result.result) lines.push("", result.result.slice(0, 8_000));
|
|
86
|
+
if (result.error) lines.push("", `error=${result.error.slice(0, 2_000)}`);
|
|
87
|
+
return lines.join("\n");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export default function piWorkflows(pi: ExtensionAPI): void {
|
|
91
|
+
let engine: WorkflowEngine | undefined;
|
|
92
|
+
let protocolError: string | undefined;
|
|
93
|
+
let protocolCheck: Promise<void> = Promise.resolve();
|
|
94
|
+
let active = false;
|
|
95
|
+
let unsubscribeTreeSignal: (() => void) | undefined;
|
|
96
|
+
let branchQuiesce: Promise<WorkflowQuiesceResult> | undefined;
|
|
97
|
+
let lifecycleGeneration = 0;
|
|
98
|
+
let branchGeneration = 0;
|
|
99
|
+
let protocolAbortController: AbortController | undefined;
|
|
100
|
+
|
|
101
|
+
const currentEngine = (): WorkflowEngine => {
|
|
102
|
+
if (!engine) throw new Error("pi-workflows is not active in this session context");
|
|
103
|
+
return engine;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const registerSurface = (): void => {
|
|
107
|
+
if (active) return;
|
|
108
|
+
active = true;
|
|
109
|
+
const workflowEngine = currentEngine();
|
|
110
|
+
|
|
111
|
+
pi.registerTool(
|
|
112
|
+
defineTool({
|
|
113
|
+
name: "workflow",
|
|
114
|
+
label: "Workflow",
|
|
115
|
+
description:
|
|
116
|
+
"Run a strict declarative task DAG through pi-subagents. Workflows submit subagent_type, prompt, description, and an optional semantic tier; execution policy belongs to pi-subagents.",
|
|
117
|
+
promptSnippet: "Run independent tasks as a dependency-aware workflow",
|
|
118
|
+
promptGuidelines: [
|
|
119
|
+
"Use workflow for explicit multi-task DAG orchestration; an optional tier must be small, medium, or large. Do not include model, thinking, concurrency, retry, timeout, or turn-limit settings.",
|
|
120
|
+
"Use workflow_control to inspect, pause, resume, or stop a background workflow.",
|
|
121
|
+
],
|
|
122
|
+
parameters: WorkflowSchema,
|
|
123
|
+
renderCall(args, theme) {
|
|
124
|
+
return new Text(theme.fg("toolTitle", theme.bold(`Workflow ${args.name ?? ""}`)), 0, 0);
|
|
125
|
+
},
|
|
126
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
127
|
+
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
128
|
+
if (isPartial) return new Text(theme.fg("warning", "Workflow running…"), 0, 0);
|
|
129
|
+
const preview = expanded ? text.slice(0, 8_000) : text.split("\n").slice(0, 4).join("\n");
|
|
130
|
+
return new Text(
|
|
131
|
+
theme.fg(
|
|
132
|
+
result.details && (result.details as { status?: string }).status === "failed" ? "error" : "dim",
|
|
133
|
+
preview,
|
|
134
|
+
),
|
|
135
|
+
0,
|
|
136
|
+
0,
|
|
137
|
+
);
|
|
138
|
+
},
|
|
139
|
+
async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
|
|
140
|
+
try {
|
|
141
|
+
await protocolCheck;
|
|
142
|
+
} catch (error: unknown) {
|
|
143
|
+
return textResult(
|
|
144
|
+
protocolError ??
|
|
145
|
+
`${PROTOCOL_DIAGNOSTIC} Diagnostic: ${error instanceof Error ? error.message : String(error)}`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
const definition = validateWorkflow(params);
|
|
149
|
+
const result = await workflowEngine.start(definition, signal);
|
|
150
|
+
return textResult(formatStart(result), result);
|
|
151
|
+
},
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
pi.registerTool(
|
|
156
|
+
defineTool({
|
|
157
|
+
name: "workflow_control",
|
|
158
|
+
label: "Workflow Control",
|
|
159
|
+
description: "List, inspect, pause, resume, or stop workflow runs with bounded machine-readable output.",
|
|
160
|
+
parameters: ControlSchema,
|
|
161
|
+
renderCall(args, theme) {
|
|
162
|
+
return new Text(theme.fg("toolTitle", theme.bold(`Workflow control: ${args.action ?? ""}`)), 0, 0);
|
|
163
|
+
},
|
|
164
|
+
renderResult(result, { expanded }, theme) {
|
|
165
|
+
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
166
|
+
return new Text(theme.fg("dim", expanded ? text.slice(0, 8_000) : text.slice(0, 1_000)), 0, 0);
|
|
167
|
+
},
|
|
168
|
+
async execute(_toolCallId, params) {
|
|
169
|
+
const action = params.action as "list" | "get" | "pause" | "resume" | "stop";
|
|
170
|
+
if (!["list", "get", "pause", "resume", "stop"].includes(action)) {
|
|
171
|
+
throw new Error(`unknown workflow_control action: ${params.action}`);
|
|
172
|
+
}
|
|
173
|
+
const result = await workflowEngine.control(action, params.run_id);
|
|
174
|
+
return textResult(JSON.stringify(result), result);
|
|
175
|
+
},
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
pi.registerCommand("workflows", {
|
|
180
|
+
description: "Open the workflow run navigator",
|
|
181
|
+
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
182
|
+
await showWorkflowNavigator(ctx, workflowEngine);
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const beginBranchQuiesce = (): Promise<WorkflowQuiesceResult> => {
|
|
187
|
+
if (!branchQuiesce) {
|
|
188
|
+
branchQuiesce = workflowEngine.quiesceForBranchChange().then((result) => {
|
|
189
|
+
if (!result.settled) {
|
|
190
|
+
console.warn(
|
|
191
|
+
`[pi-workflows] ${result.diagnostic ?? "branch quiescence did not settle; stale events are quarantined"}`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
return result;
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return branchQuiesce;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
unsubscribeTreeSignal = pi.events.on("subagents:session_before_tree", () => {
|
|
201
|
+
void beginBranchQuiesce();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
pi.on("session_before_tree", async () => {
|
|
205
|
+
await beginBranchQuiesce();
|
|
206
|
+
});
|
|
207
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
208
|
+
let entries: SessionEntryLike[];
|
|
209
|
+
try {
|
|
210
|
+
entries = ctx.sessionManager.getBranch() as unknown as SessionEntryLike[];
|
|
211
|
+
} catch {
|
|
212
|
+
// A reload may invalidate the old lifecycle context before the tree
|
|
213
|
+
// callback is delivered. Do not let stale-context access escape.
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
workflowEngine.suspendLifecycle();
|
|
217
|
+
branchGeneration += 1;
|
|
218
|
+
try {
|
|
219
|
+
workflowEngine.restore(entries, branchGeneration);
|
|
220
|
+
} catch (error: unknown) {
|
|
221
|
+
// Keep the lifecycle surface alive after a failed recovery append. The
|
|
222
|
+
// engine rolls its candidate state back transactionally; a later tree
|
|
223
|
+
// event can retry the same branch without leaving workflows inert.
|
|
224
|
+
console.warn(
|
|
225
|
+
`[pi-workflows] session tree recovery failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
226
|
+
);
|
|
227
|
+
} finally {
|
|
228
|
+
workflowEngine.resumeLifecycle();
|
|
229
|
+
branchQuiesce = undefined;
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
235
|
+
if (active || engine) return;
|
|
236
|
+
const generation = ++lifecycleGeneration;
|
|
237
|
+
let initialEntries: SessionEntryLike[];
|
|
238
|
+
try {
|
|
239
|
+
initialEntries = ctx.sessionManager.getBranch() as unknown as SessionEntryLike[];
|
|
240
|
+
} catch {
|
|
241
|
+
// The lifecycle context may already be invalid during a reload race.
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Dispatch the formal child-context query synchronously. The event bus
|
|
246
|
+
// delivers the reply synchronously, which lets us filter child sessions
|
|
247
|
+
// before registering any Pi tools while keeping startup nonblocking.
|
|
248
|
+
let child: boolean | undefined;
|
|
249
|
+
try {
|
|
250
|
+
child = queryChildSessionContextImmediate(pi.events);
|
|
251
|
+
} catch {
|
|
252
|
+
child = undefined;
|
|
253
|
+
}
|
|
254
|
+
if (child === true) return;
|
|
255
|
+
|
|
256
|
+
protocolAbortController = new AbortController();
|
|
257
|
+
// Tool-call signals only cancel the foreground wait. Managed RPCs use this
|
|
258
|
+
// session-lifetime controller so a host with no ctx.signal still cancels
|
|
259
|
+
// outstanding requests during session_shutdown.
|
|
260
|
+
const client = createManagedSpawnClient(pi.events, protocolAbortController.signal, protocolAbortController.signal);
|
|
261
|
+
|
|
262
|
+
// Recovery appends can fail transiently (for example while Pi is flushing a
|
|
263
|
+
// branch). Build and restore a disposable candidate, retrying against the
|
|
264
|
+
// latest branch snapshot before exposing the workflow surface. Never leave
|
|
265
|
+
// `engine` assigned while restore is half-complete.
|
|
266
|
+
let recoveredEngine: WorkflowEngine | undefined;
|
|
267
|
+
let recoveryError: unknown;
|
|
268
|
+
for (let attempt = 0; attempt < 3 && !recoveredEngine; attempt += 1) {
|
|
269
|
+
const candidate = new WorkflowEngine(pi.events, client, {
|
|
270
|
+
append(event) {
|
|
271
|
+
pi.appendEntry(JOURNAL_ENTRY_TYPE, event);
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
try {
|
|
275
|
+
const entries =
|
|
276
|
+
attempt === 0 ? initialEntries : (ctx.sessionManager.getBranch() as unknown as SessionEntryLike[]);
|
|
277
|
+
candidate.restore(entries, branchGeneration);
|
|
278
|
+
recoveredEngine = candidate;
|
|
279
|
+
} catch (error: unknown) {
|
|
280
|
+
recoveryError = error;
|
|
281
|
+
candidate.dispose();
|
|
282
|
+
if (attempt < 2) await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (!recoveredEngine) {
|
|
286
|
+
console.warn(
|
|
287
|
+
`[pi-workflows] session recovery failed; workflow surface remains disabled: ${
|
|
288
|
+
recoveryError instanceof Error ? recoveryError.message : String(recoveryError)
|
|
289
|
+
}`,
|
|
290
|
+
);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
engine = recoveredEngine;
|
|
295
|
+
branchGeneration = 0;
|
|
296
|
+
registerSurface();
|
|
297
|
+
|
|
298
|
+
protocolError = undefined;
|
|
299
|
+
protocolCheck = client.checkProtocol?.() ?? Promise.reject(new Error("managed protocol check is unavailable"));
|
|
300
|
+
void protocolCheck.catch((error: unknown) => {
|
|
301
|
+
if (generation !== lifecycleGeneration) return;
|
|
302
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
303
|
+
// Missing pi-subagents is a valid independent-package activation state.
|
|
304
|
+
// Keep startup nonblocking and defer the bounded diagnostic to the next
|
|
305
|
+
// workflow tool call instead of logging a spurious startup error.
|
|
306
|
+
protocolError = `${PROTOCOL_DIAGNOSTIC} Diagnostic: ${detail}`;
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
pi.on("session_shutdown", () => {
|
|
311
|
+
lifecycleGeneration += 1;
|
|
312
|
+
protocolAbortController?.abort();
|
|
313
|
+
protocolAbortController = undefined;
|
|
314
|
+
unsubscribeTreeSignal?.();
|
|
315
|
+
unsubscribeTreeSignal = undefined;
|
|
316
|
+
engine?.dispose();
|
|
317
|
+
engine = undefined;
|
|
318
|
+
active = false;
|
|
319
|
+
branchQuiesce = undefined;
|
|
320
|
+
branchGeneration = 0;
|
|
321
|
+
protocolCheck = Promise.resolve();
|
|
322
|
+
protocolError = undefined;
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export { validateWorkflow, WorkflowSchema };
|