@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
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type {
|
|
3
|
+
ManagedSpawnResponse as ProtocolManagedSpawnResponse,
|
|
4
|
+
ManagedTerminalSnapshot as ProtocolManagedTerminalSnapshot,
|
|
5
|
+
} from "@signalridge/pi-subagents-protocol";
|
|
6
|
+
import {
|
|
7
|
+
PROTOCOL_VERSION,
|
|
8
|
+
parseManagedSpawnResponse,
|
|
9
|
+
parseProtocolPing,
|
|
10
|
+
requiredCapabilitiesMatch,
|
|
11
|
+
workflowTierCapabilityMatch,
|
|
12
|
+
} from "@signalridge/pi-subagents-protocol";
|
|
13
|
+
import type { WorkflowOwner } from "./journal.js";
|
|
14
|
+
import type { WorkflowTask } from "./schema.js";
|
|
15
|
+
|
|
16
|
+
export interface WorkflowEventBus {
|
|
17
|
+
on(event: string, handler: (data: unknown) => void): () => void;
|
|
18
|
+
emit(event: string, data: unknown): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type RpcSuccess<T> = { success: true; data?: T };
|
|
22
|
+
type RpcFailure = { success: false; error: string };
|
|
23
|
+
type RpcReply<T> = RpcSuccess<T> | RpcFailure;
|
|
24
|
+
|
|
25
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
26
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isReply<T>(value: unknown): value is RpcReply<T> {
|
|
30
|
+
return isRecord(value) && typeof value.success === "boolean";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function requestId(): string {
|
|
34
|
+
return randomUUID();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function callRpc<T>(
|
|
38
|
+
events: WorkflowEventBus,
|
|
39
|
+
channel: string,
|
|
40
|
+
payload: Record<string, unknown>,
|
|
41
|
+
timeoutMs: number,
|
|
42
|
+
signal?: AbortSignal,
|
|
43
|
+
): Promise<T> {
|
|
44
|
+
const id = typeof payload.requestId === "string" ? payload.requestId : requestId();
|
|
45
|
+
const replyChannel = `${channel}:reply:${id}`;
|
|
46
|
+
return new Promise<T>((resolve, reject) => {
|
|
47
|
+
let settled = false;
|
|
48
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
49
|
+
let unsubscribe = () => {};
|
|
50
|
+
const cleanup = () => {
|
|
51
|
+
if (timer) clearTimeout(timer);
|
|
52
|
+
timer = undefined;
|
|
53
|
+
unsubscribe();
|
|
54
|
+
signal?.removeEventListener("abort", onAbort);
|
|
55
|
+
};
|
|
56
|
+
const finish = (error: unknown, value?: T) => {
|
|
57
|
+
if (settled) return;
|
|
58
|
+
settled = true;
|
|
59
|
+
cleanup();
|
|
60
|
+
if (error) reject(error instanceof Error ? error : new Error(String(error)));
|
|
61
|
+
else resolve(value as T);
|
|
62
|
+
};
|
|
63
|
+
const onAbort = () => finish(new Error(`${channel} request aborted`));
|
|
64
|
+
try {
|
|
65
|
+
unsubscribe = events.on(replyChannel, (raw: unknown) => {
|
|
66
|
+
if (!isReply<T>(raw)) {
|
|
67
|
+
finish(new Error("Invalid subagents RPC reply"));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (!raw.success) finish(new Error(raw.error));
|
|
71
|
+
else finish(undefined, raw.data as T);
|
|
72
|
+
});
|
|
73
|
+
if (signal?.aborted) {
|
|
74
|
+
onAbort();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
78
|
+
timer = setTimeout(() => finish(new Error(`${channel} timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
79
|
+
events.emit(channel, { ...payload, requestId: id });
|
|
80
|
+
} catch (error: unknown) {
|
|
81
|
+
finish(error);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const REQUIRED_SUBAGENTS_PROTOCOL = PROTOCOL_VERSION;
|
|
87
|
+
export const PROTOCOL_DIAGNOSTIC =
|
|
88
|
+
"@signalridge/pi-workflows requires @signalridge/pi-subagents protocol v3 with managedSpawn, lifecycleOwner, ownedStop, ownedQuiescence, and workflowTiers capabilities. " +
|
|
89
|
+
"Install @signalridge/pi-subagents and @signalridge/pi-workflows exactly once; Pi loads both from their configured pi.extensions manifests.";
|
|
90
|
+
|
|
91
|
+
export const CHILD_CONTEXT_QUERY_TIMEOUT_MS = 250;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Query the factory-time child-session marker owned by pi-subagents. A missing
|
|
95
|
+
* reply means the companion extension is absent; callers must retain the root
|
|
96
|
+
* surface and let the normal protocol diagnostic explain that configuration.
|
|
97
|
+
*/
|
|
98
|
+
export async function queryChildSessionContext(
|
|
99
|
+
events: WorkflowEventBus,
|
|
100
|
+
timeoutMs = CHILD_CONTEXT_QUERY_TIMEOUT_MS,
|
|
101
|
+
): Promise<boolean | undefined> {
|
|
102
|
+
const id = requestId();
|
|
103
|
+
const channel = "subagents:rpc:context";
|
|
104
|
+
return new Promise<boolean | undefined>((resolve) => {
|
|
105
|
+
let settled = false;
|
|
106
|
+
const replyChannel = `${channel}:reply:${id}`;
|
|
107
|
+
let unsubscribe = () => {};
|
|
108
|
+
const timer = setTimeout(() => finish(undefined), timeoutMs);
|
|
109
|
+
const finish = (child: boolean | undefined) => {
|
|
110
|
+
if (settled) return;
|
|
111
|
+
settled = true;
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
unsubscribe();
|
|
114
|
+
resolve(child);
|
|
115
|
+
};
|
|
116
|
+
unsubscribe = events.on(replyChannel, (raw: unknown) => {
|
|
117
|
+
if (!isReply(raw) || !raw.success || !isRecord(raw.data)) {
|
|
118
|
+
finish(undefined);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
finish(typeof raw.data.child === "boolean" ? raw.data.child : undefined);
|
|
122
|
+
});
|
|
123
|
+
try {
|
|
124
|
+
events.emit(channel, { requestId: id });
|
|
125
|
+
} catch {
|
|
126
|
+
finish(undefined);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Synchronous companion to the bounded async query. Pi's event bus dispatches
|
|
132
|
+
* handlers synchronously, so a child marker can be consumed before lifecycle
|
|
133
|
+
* registration without blocking a later extension's session_start.
|
|
134
|
+
*/
|
|
135
|
+
export function queryChildSessionContextImmediate(events: WorkflowEventBus): boolean | undefined {
|
|
136
|
+
const id = requestId();
|
|
137
|
+
const replyChannel = `subagents:rpc:context:reply:${id}`;
|
|
138
|
+
let child: boolean | undefined;
|
|
139
|
+
const unsubscribe = events.on(replyChannel, (raw: unknown) => {
|
|
140
|
+
if (!isReply(raw) || !raw.success || !isRecord(raw.data)) return;
|
|
141
|
+
if (typeof raw.data.child === "boolean") child = raw.data.child;
|
|
142
|
+
});
|
|
143
|
+
try {
|
|
144
|
+
events.emit("subagents:rpc:context", { requestId: id });
|
|
145
|
+
} finally {
|
|
146
|
+
unsubscribe();
|
|
147
|
+
}
|
|
148
|
+
return child;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Verify the separately configured pi-subagents package before workflow execution. */
|
|
152
|
+
export async function checkManagedSpawnProtocol(events: WorkflowEventBus, signal?: AbortSignal): Promise<void> {
|
|
153
|
+
const id = requestId();
|
|
154
|
+
const reply = await new Promise<unknown>((resolve, reject) => {
|
|
155
|
+
const replyChannel = `subagents:rpc:ping:reply:${id}`;
|
|
156
|
+
let settled = false;
|
|
157
|
+
let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
|
|
158
|
+
let unsubscribeReply = () => {};
|
|
159
|
+
let unsubscribeReady = () => {};
|
|
160
|
+
const cleanup = () => {
|
|
161
|
+
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
162
|
+
unsubscribeReply();
|
|
163
|
+
unsubscribeReady();
|
|
164
|
+
signal?.removeEventListener("abort", onAbort);
|
|
165
|
+
};
|
|
166
|
+
const onAbort = () => finish(undefined, new Error("subagents protocol discovery aborted"));
|
|
167
|
+
const finish = (value: unknown, error?: Error) => {
|
|
168
|
+
if (settled) return;
|
|
169
|
+
settled = true;
|
|
170
|
+
cleanup();
|
|
171
|
+
if (error) reject(error);
|
|
172
|
+
else resolve(value);
|
|
173
|
+
};
|
|
174
|
+
unsubscribeReply = events.on(replyChannel, (raw: unknown) => {
|
|
175
|
+
if (!isReply(raw)) {
|
|
176
|
+
finish(undefined, new Error("Invalid subagents protocol ping reply"));
|
|
177
|
+
} else if (!raw.success) {
|
|
178
|
+
finish(undefined, new Error(raw.error));
|
|
179
|
+
} else {
|
|
180
|
+
finish(raw.data);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
unsubscribeReady = events.on("subagents:ready", (raw: unknown) => {
|
|
184
|
+
if (isRecord(raw) && typeof raw.version === "number") finish(raw);
|
|
185
|
+
});
|
|
186
|
+
const sendPing = () => {
|
|
187
|
+
try {
|
|
188
|
+
events.emit("subagents:rpc:ping", { requestId: id });
|
|
189
|
+
} catch (error: unknown) {
|
|
190
|
+
finish(undefined, error instanceof Error ? error : new Error(String(error)));
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
if (signal?.aborted) {
|
|
194
|
+
onAbort();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
198
|
+
timeoutTimer = setTimeout(
|
|
199
|
+
() => finish(undefined, new Error("subagents protocol ping timed out after 2000ms")),
|
|
200
|
+
2_000,
|
|
201
|
+
);
|
|
202
|
+
// The two extensions may register their session_start handlers in either
|
|
203
|
+
// order. A synchronous ping handles the already-registered case; the
|
|
204
|
+
// readiness event handles a later session_start without emitting through a
|
|
205
|
+
// stale lifecycle context.
|
|
206
|
+
sendPing();
|
|
207
|
+
});
|
|
208
|
+
let ping: ReturnType<typeof parseProtocolPing>;
|
|
209
|
+
try {
|
|
210
|
+
ping = parseProtocolPing(reply);
|
|
211
|
+
} catch {
|
|
212
|
+
if (
|
|
213
|
+
!isRecord(reply) ||
|
|
214
|
+
typeof reply.version !== "number" ||
|
|
215
|
+
!isRecord(reply.capabilities) ||
|
|
216
|
+
reply.version < REQUIRED_SUBAGENTS_PROTOCOL ||
|
|
217
|
+
reply.capabilities.managedSpawn !== true ||
|
|
218
|
+
reply.capabilities.lifecycleOwner !== true ||
|
|
219
|
+
reply.capabilities.ownedStop !== true ||
|
|
220
|
+
reply.capabilities.ownedQuiescence !== true
|
|
221
|
+
) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`subagents protocol v${REQUIRED_SUBAGENTS_PROTOCOL} with managed spawning, owned stop, and owned quiescence capability is required`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
throw new Error("subagents protocol ping did not return a valid version/capability envelope");
|
|
227
|
+
}
|
|
228
|
+
if (ping.version < REQUIRED_SUBAGENTS_PROTOCOL || !requiredCapabilitiesMatch(ping.capabilities)) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`subagents protocol v${REQUIRED_SUBAGENTS_PROTOCOL} with managed spawning, owned stop, and owned quiescence capability is required`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
if (!workflowTierCapabilityMatch(ping.capabilities)) {
|
|
234
|
+
throw new Error("subagents workflow tier capability is required before tiered managed spawning");
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export type ManagedTerminalSnapshot = ProtocolManagedTerminalSnapshot;
|
|
239
|
+
export type ManagedSpawnResponse = ProtocolManagedSpawnResponse;
|
|
240
|
+
|
|
241
|
+
export interface ManagedSpawnClient {
|
|
242
|
+
/** String remains accepted for deterministic legacy fixtures; protocol v3 returns ManagedSpawnResponse. */
|
|
243
|
+
/**
|
|
244
|
+
* The fourth argument is the current attempt for protocol-v3 clients. The
|
|
245
|
+
* optional fifth argument is retained only for old in-process test/fixture
|
|
246
|
+
* clients that used the former context, attemptId shape; it is never a
|
|
247
|
+
* runtime context and is not sent over RPC.
|
|
248
|
+
*/
|
|
249
|
+
spawn(
|
|
250
|
+
task: WorkflowTask,
|
|
251
|
+
runId: string,
|
|
252
|
+
nodeId: string,
|
|
253
|
+
attemptId?: string,
|
|
254
|
+
legacyAttemptId?: string,
|
|
255
|
+
): Promise<ManagedSpawnResponse | string>;
|
|
256
|
+
/** Unscoped v2 stop, retained for existing callers. */
|
|
257
|
+
stop(agentId: string): Promise<void>;
|
|
258
|
+
/** Owner-scoped v3 stop used by workflow lifecycle operations. */
|
|
259
|
+
stopOwned?(agentId: string, owner: WorkflowOwner): Promise<void>;
|
|
260
|
+
quiesceOwned?(
|
|
261
|
+
runId: string,
|
|
262
|
+
agentIds: string[],
|
|
263
|
+
timeoutMs?: number,
|
|
264
|
+
owners?: WorkflowOwner[],
|
|
265
|
+
): Promise<{ settled: boolean; pending: string[] }>;
|
|
266
|
+
/** Optional startup capability check; workflow execution remains event-bus-only. */
|
|
267
|
+
checkProtocol?: () => Promise<void>;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Event-bus-only client for the additive pi-subagents managed protocol. */
|
|
271
|
+
export function createManagedSpawnClient(
|
|
272
|
+
events: WorkflowEventBus,
|
|
273
|
+
protocolSignal?: AbortSignal,
|
|
274
|
+
sessionSignal?: AbortSignal,
|
|
275
|
+
): ManagedSpawnClient {
|
|
276
|
+
const call = <T>(
|
|
277
|
+
channel: string,
|
|
278
|
+
payload: Record<string, unknown>,
|
|
279
|
+
signal?: AbortSignal,
|
|
280
|
+
timeoutMs = 15_000,
|
|
281
|
+
): Promise<T> => callRpc(events, channel, payload, timeoutMs, signal);
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
async spawn(task, runId, nodeId, attemptId) {
|
|
285
|
+
const effectiveAttemptId = attemptId ?? `${runId}/${nodeId}/attempt-1`;
|
|
286
|
+
const data = await call<unknown>(
|
|
287
|
+
"subagents:rpc:spawn-managed",
|
|
288
|
+
{
|
|
289
|
+
requestId: requestId(),
|
|
290
|
+
spawnKey: `${runId}/${nodeId}/${effectiveAttemptId}`,
|
|
291
|
+
type: task.subagent_type,
|
|
292
|
+
prompt: task.prompt,
|
|
293
|
+
description: task.description,
|
|
294
|
+
...(task.tier === undefined ? {} : { tier: task.tier }),
|
|
295
|
+
owner: {
|
|
296
|
+
extension: "pi-workflows",
|
|
297
|
+
runId,
|
|
298
|
+
nodeId,
|
|
299
|
+
attemptId: effectiveAttemptId,
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
sessionSignal,
|
|
303
|
+
);
|
|
304
|
+
if (!isRecord(data) || typeof data.id !== "string" || data.id.length === 0) {
|
|
305
|
+
throw new Error("subagents managed spawn returned an invalid agent id");
|
|
306
|
+
}
|
|
307
|
+
return parseManagedSpawnResponse(data);
|
|
308
|
+
},
|
|
309
|
+
async stop(agentId) {
|
|
310
|
+
await call<void>("subagents:rpc:stop", { requestId: requestId(), agentId }, sessionSignal);
|
|
311
|
+
},
|
|
312
|
+
async stopOwned(agentId, owner) {
|
|
313
|
+
await call<void>(
|
|
314
|
+
"subagents:rpc:stop-owned",
|
|
315
|
+
{
|
|
316
|
+
requestId: requestId(),
|
|
317
|
+
agentId,
|
|
318
|
+
owner,
|
|
319
|
+
},
|
|
320
|
+
sessionSignal,
|
|
321
|
+
);
|
|
322
|
+
},
|
|
323
|
+
async quiesceOwned(runId, agentIds, timeoutMs = 5_000, owners) {
|
|
324
|
+
const data = await call<unknown>(
|
|
325
|
+
"subagents:rpc:quiesce-owned",
|
|
326
|
+
{
|
|
327
|
+
requestId: requestId(),
|
|
328
|
+
owner: { extension: "pi-workflows", runId },
|
|
329
|
+
agentIds,
|
|
330
|
+
...(owners ? { owners } : {}),
|
|
331
|
+
timeoutMs,
|
|
332
|
+
},
|
|
333
|
+
sessionSignal,
|
|
334
|
+
timeoutMs + 1_000,
|
|
335
|
+
);
|
|
336
|
+
if (!isRecord(data) || typeof data.settled !== "boolean" || !Array.isArray(data.pending)) {
|
|
337
|
+
throw new Error("subagents quiescence returned an invalid response");
|
|
338
|
+
}
|
|
339
|
+
const pending = data.pending.filter((id): id is string => typeof id === "string");
|
|
340
|
+
return { settled: data.settled, pending };
|
|
341
|
+
},
|
|
342
|
+
checkProtocol: () => checkManagedSpawnProtocol(events, protocolSignal),
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function registerLifecycleListener(
|
|
347
|
+
events: WorkflowEventBus,
|
|
348
|
+
handler: (eventName: string, data: Record<string, unknown>) => void,
|
|
349
|
+
): () => void {
|
|
350
|
+
const unsubs = [
|
|
351
|
+
"subagents:created",
|
|
352
|
+
"subagents:started",
|
|
353
|
+
"subagents:completed",
|
|
354
|
+
"subagents:failed",
|
|
355
|
+
"subagents:compacted",
|
|
356
|
+
].map((eventName) =>
|
|
357
|
+
events.on(eventName, (raw: unknown) => {
|
|
358
|
+
if (isRecord(raw)) handler(eventName, raw);
|
|
359
|
+
}),
|
|
360
|
+
);
|
|
361
|
+
return () => {
|
|
362
|
+
for (const unsubscribe of unsubs) unsubscribe();
|
|
363
|
+
};
|
|
364
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
export const WORKFLOW_SCHEMA_VERSION = 1;
|
|
2
|
+
|
|
3
|
+
import type { WorkflowTier } from "@signalridge/pi-subagents-protocol";
|
|
4
|
+
export const MAX_TASKS = 128;
|
|
5
|
+
export const MAX_PHASES = 32;
|
|
6
|
+
export const MAX_TEXT = 100_000;
|
|
7
|
+
export const MAX_SHORT_TEXT = 512;
|
|
8
|
+
|
|
9
|
+
export interface WorkflowPhase {
|
|
10
|
+
id: string;
|
|
11
|
+
title: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface WorkflowTask {
|
|
15
|
+
id: string;
|
|
16
|
+
phase?: string;
|
|
17
|
+
subagent_type: string;
|
|
18
|
+
description: string;
|
|
19
|
+
prompt: string;
|
|
20
|
+
tier?: WorkflowTier;
|
|
21
|
+
depends_on: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface WorkflowSynthesis {
|
|
25
|
+
subagent_type: string;
|
|
26
|
+
prompt: string;
|
|
27
|
+
tier?: WorkflowTier;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface WorkflowDefinition {
|
|
31
|
+
name: string;
|
|
32
|
+
description?: string;
|
|
33
|
+
/** Default semantic tier for tasks and synthesis in this run. */
|
|
34
|
+
tier?: WorkflowTier;
|
|
35
|
+
phases: WorkflowPhase[];
|
|
36
|
+
tasks: WorkflowTask[];
|
|
37
|
+
synthesis?: WorkflowSynthesis;
|
|
38
|
+
background: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
42
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function stringField(value: unknown, label: string, max: number): string {
|
|
46
|
+
if (typeof value !== "string") throw new Error(`${label} must be a string`);
|
|
47
|
+
const normalized = value.trim();
|
|
48
|
+
if (normalized.length === 0) throw new Error(`${label} must not be empty`);
|
|
49
|
+
if (normalized.length > max) throw new Error(`${label} exceeds ${max} characters`);
|
|
50
|
+
return normalized;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function optionalString(value: unknown, label: string, max: number): string | undefined {
|
|
54
|
+
if (value === undefined) return undefined;
|
|
55
|
+
return stringField(value, label, max);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function optionalTier(value: unknown, label: string): WorkflowTier | undefined {
|
|
59
|
+
if (value === undefined) return undefined;
|
|
60
|
+
if (value !== "small" && value !== "medium" && value !== "large") {
|
|
61
|
+
throw new Error(`${label} must be one of small, medium, or large`);
|
|
62
|
+
}
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function assertObjectKeys(value: Record<string, unknown>, allowed: readonly string[], label: string): void {
|
|
67
|
+
const allowedSet = new Set(allowed);
|
|
68
|
+
for (const key of Object.keys(value)) {
|
|
69
|
+
if (!allowedSet.has(key)) throw new Error(`${label} contains unsupported field "${key}"`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function stringArray(value: unknown, label: string, maxItems: number, maxText: number): string[] {
|
|
74
|
+
if (value === undefined) return [];
|
|
75
|
+
if (!Array.isArray(value)) throw new Error(`${label} must be an array`);
|
|
76
|
+
if (value.length > maxItems) throw new Error(`${label} has too many entries`);
|
|
77
|
+
return value.map((item, index) => stringField(item, `${label}[${index}]`, maxText));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function validateDag(tasks: WorkflowTask[]): void {
|
|
81
|
+
const ids = new Set(tasks.map((task) => task.id));
|
|
82
|
+
const visiting = new Set<string>();
|
|
83
|
+
const visited = new Set<string>();
|
|
84
|
+
const visit = (id: string): void => {
|
|
85
|
+
if (visiting.has(id)) throw new Error(`workflow dependencies contain a cycle at "${id}"`);
|
|
86
|
+
if (visited.has(id)) return;
|
|
87
|
+
visiting.add(id);
|
|
88
|
+
const task = tasks.find((candidate) => candidate.id === id);
|
|
89
|
+
if (!task) throw new Error(`unknown dependency "${id}"`);
|
|
90
|
+
for (const dependency of task.depends_on) visit(dependency);
|
|
91
|
+
visiting.delete(id);
|
|
92
|
+
visited.add(id);
|
|
93
|
+
};
|
|
94
|
+
for (const task of tasks) {
|
|
95
|
+
for (const dependency of task.depends_on) {
|
|
96
|
+
if (!ids.has(dependency)) throw new Error(`task "${task.id}" depends on unknown task "${dependency}"`);
|
|
97
|
+
}
|
|
98
|
+
visit(task.id);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Validate the strict declarative workflow input and return a normalized copy. */
|
|
103
|
+
export function validateWorkflow(input: unknown): WorkflowDefinition {
|
|
104
|
+
if (!isRecord(input)) throw new Error("workflow must be an object");
|
|
105
|
+
assertObjectKeys(input, ["name", "description", "tier", "phases", "tasks", "synthesis", "background"], "workflow");
|
|
106
|
+
const name = stringField(input.name, "name", MAX_SHORT_TEXT);
|
|
107
|
+
const description = optionalString(input.description, "description", MAX_TEXT);
|
|
108
|
+
const tier = optionalTier(input.tier, "tier");
|
|
109
|
+
|
|
110
|
+
const rawPhases = input.phases === undefined ? [] : input.phases;
|
|
111
|
+
if (!Array.isArray(rawPhases)) throw new Error("phases must be an array");
|
|
112
|
+
if (rawPhases.length > MAX_PHASES) throw new Error(`phases may contain at most ${MAX_PHASES} entries`);
|
|
113
|
+
const phases = rawPhases.map((raw, index) => {
|
|
114
|
+
if (!isRecord(raw)) throw new Error(`phases[${index}] must be an object`);
|
|
115
|
+
assertObjectKeys(raw, ["id", "title"], `phases[${index}]`);
|
|
116
|
+
return {
|
|
117
|
+
id: stringField(raw.id, `phases[${index}].id`, 128),
|
|
118
|
+
title: stringField(raw.title, `phases[${index}].title`, MAX_SHORT_TEXT),
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
const phaseIds = new Set<string>();
|
|
122
|
+
for (const phase of phases) {
|
|
123
|
+
if (phaseIds.has(phase.id)) throw new Error(`duplicate phase id "${phase.id}"`);
|
|
124
|
+
phaseIds.add(phase.id);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!Array.isArray(input.tasks)) throw new Error("tasks must be an array");
|
|
128
|
+
if (input.tasks.length === 0) throw new Error("tasks must contain at least one task");
|
|
129
|
+
if (input.tasks.length > MAX_TASKS) throw new Error(`tasks may contain at most ${MAX_TASKS} entries`);
|
|
130
|
+
const taskIds = new Set<string>();
|
|
131
|
+
const tasks = input.tasks.map((raw, index) => {
|
|
132
|
+
if (!isRecord(raw)) throw new Error(`tasks[${index}] must be an object`);
|
|
133
|
+
assertObjectKeys(
|
|
134
|
+
raw,
|
|
135
|
+
["id", "phase", "tier", "subagent_type", "description", "prompt", "depends_on"],
|
|
136
|
+
`tasks[${index}]`,
|
|
137
|
+
);
|
|
138
|
+
const task: WorkflowTask = {
|
|
139
|
+
id: stringField(raw.id, `tasks[${index}].id`, 128),
|
|
140
|
+
phase: optionalString(raw.phase, `tasks[${index}].phase`, 128),
|
|
141
|
+
tier: optionalTier(raw.tier, `tasks[${index}].tier`),
|
|
142
|
+
subagent_type: stringField(raw.subagent_type, `tasks[${index}].subagent_type`, 128),
|
|
143
|
+
description: stringField(raw.description, `tasks[${index}].description`, MAX_SHORT_TEXT),
|
|
144
|
+
prompt: stringField(raw.prompt, `tasks[${index}].prompt`, MAX_TEXT),
|
|
145
|
+
depends_on: stringArray(raw.depends_on, `tasks[${index}].depends_on`, MAX_TASKS, 128),
|
|
146
|
+
};
|
|
147
|
+
if (taskIds.has(task.id)) throw new Error(`duplicate task id "${task.id}"`);
|
|
148
|
+
taskIds.add(task.id);
|
|
149
|
+
if (task.phase !== undefined && !phaseIds.has(task.phase)) {
|
|
150
|
+
throw new Error(`task "${task.id}" references unknown phase "${task.phase}"`);
|
|
151
|
+
}
|
|
152
|
+
return task;
|
|
153
|
+
});
|
|
154
|
+
validateDag(tasks);
|
|
155
|
+
|
|
156
|
+
let synthesis: WorkflowSynthesis | undefined;
|
|
157
|
+
if (input.synthesis !== undefined) {
|
|
158
|
+
if (!isRecord(input.synthesis)) throw new Error("synthesis must be an object");
|
|
159
|
+
assertObjectKeys(input.synthesis, ["subagent_type", "prompt", "tier"], "synthesis");
|
|
160
|
+
const tier = optionalTier(input.synthesis.tier, "synthesis.tier");
|
|
161
|
+
synthesis = {
|
|
162
|
+
...(tier === undefined ? {} : { tier }),
|
|
163
|
+
subagent_type: stringField(input.synthesis.subagent_type, "synthesis.subagent_type", 128),
|
|
164
|
+
prompt: stringField(input.synthesis.prompt, "synthesis.prompt", MAX_TEXT),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (input.background !== undefined && typeof input.background !== "boolean") {
|
|
169
|
+
throw new Error("background must be a boolean");
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
name,
|
|
173
|
+
...(description === undefined ? {} : { description }),
|
|
174
|
+
...(tier === undefined ? {} : { tier }),
|
|
175
|
+
phases,
|
|
176
|
+
tasks,
|
|
177
|
+
...(synthesis === undefined ? {} : { synthesis }),
|
|
178
|
+
background: input.background ?? false,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function readyTaskIds(definition: WorkflowDefinition, completed: ReadonlySet<string>): string[] {
|
|
183
|
+
return definition.tasks
|
|
184
|
+
.filter((task) => !completed.has(task.id) && task.depends_on.every((dependency) => completed.has(dependency)))
|
|
185
|
+
.map((task) => task.id);
|
|
186
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export type WorkflowStatus =
|
|
2
|
+
| "pending"
|
|
3
|
+
| "running"
|
|
4
|
+
| "pausing"
|
|
5
|
+
| "paused"
|
|
6
|
+
| "synthesizing"
|
|
7
|
+
| "stopping"
|
|
8
|
+
| "completed"
|
|
9
|
+
| "failed"
|
|
10
|
+
| "stopped"
|
|
11
|
+
| "interrupted";
|
|
12
|
+
|
|
13
|
+
export type TaskStatus =
|
|
14
|
+
| "pending"
|
|
15
|
+
| "ready"
|
|
16
|
+
| "dispatching"
|
|
17
|
+
| "queued"
|
|
18
|
+
| "running"
|
|
19
|
+
| "completed"
|
|
20
|
+
| "failed"
|
|
21
|
+
| "stopped"
|
|
22
|
+
| "blocked";
|
|
23
|
+
|
|
24
|
+
const workflowTransitions: Record<WorkflowStatus, readonly WorkflowStatus[]> = {
|
|
25
|
+
pending: ["running", "stopping", "stopped"],
|
|
26
|
+
running: ["pausing", "synthesizing", "stopping", "completed", "failed", "stopped", "interrupted"],
|
|
27
|
+
pausing: ["paused", "stopping", "failed", "stopped", "interrupted"],
|
|
28
|
+
paused: ["running", "stopping", "stopped", "interrupted"],
|
|
29
|
+
synthesizing: ["stopping", "completed", "failed", "stopped", "interrupted"],
|
|
30
|
+
stopping: ["completed", "failed", "stopped"],
|
|
31
|
+
completed: [],
|
|
32
|
+
failed: [],
|
|
33
|
+
stopped: [],
|
|
34
|
+
interrupted: ["running", "stopping", "stopped"],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const taskTransitions: Record<TaskStatus, readonly TaskStatus[]> = {
|
|
38
|
+
pending: ["ready", "blocked", "stopped"],
|
|
39
|
+
ready: ["dispatching", "blocked", "stopped"],
|
|
40
|
+
dispatching: ["ready", "queued", "running", "completed", "failed", "stopped", "blocked"],
|
|
41
|
+
queued: ["ready", "running", "completed", "failed", "stopped"],
|
|
42
|
+
running: ["ready", "completed", "failed", "stopped"],
|
|
43
|
+
completed: [],
|
|
44
|
+
failed: [],
|
|
45
|
+
stopped: [],
|
|
46
|
+
blocked: [],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export function canTransitionWorkflow(from: WorkflowStatus, to: WorkflowStatus): boolean {
|
|
50
|
+
return from === to || workflowTransitions[from].includes(to);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function canTransitionTask(from: TaskStatus, to: TaskStatus): boolean {
|
|
54
|
+
return from === to || taskTransitions[from].includes(to);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function transitionWorkflow(from: WorkflowStatus, to: WorkflowStatus): WorkflowStatus {
|
|
58
|
+
if (!canTransitionWorkflow(from, to)) throw new Error(`invalid workflow transition: ${from} -> ${to}`);
|
|
59
|
+
return to;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function transitionTask(from: TaskStatus, to: TaskStatus): TaskStatus {
|
|
63
|
+
if (!canTransitionTask(from, to)) throw new Error(`invalid task transition: ${from} -> ${to}`);
|
|
64
|
+
return to;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isTerminalWorkflow(status: WorkflowStatus): boolean {
|
|
68
|
+
return status === "completed" || status === "failed" || status === "stopped";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function isTerminalTask(status: TaskStatus): boolean {
|
|
72
|
+
return status === "completed" || status === "failed" || status === "stopped" || status === "blocked";
|
|
73
|
+
}
|