@signalridge/pi-workflows 1.3.2 → 1.4.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 +5 -0
- package/README.md +40 -11
- package/package.json +3 -2
- package/skills/workflow-authoring/SKILL.md +166 -18
- package/skills/workflow-authoring/examples/named-task-graph.js +34 -0
- package/skills/workflow-authoring/references/capabilities.md +2 -1
- package/skills/workflow-authoring/references/capability-details.md +22 -4
- package/skills/workflow-authoring/references/focused-recipes.md +1 -0
- package/skills/workflow-authoring/references/lifecycle.md +3 -3
- package/skills/workflow-authoring/references/pattern-selection.md +1 -0
- package/skills/workflow-authoring/references/registry-ownership.md +1 -1
- package/skills/workflow-authoring/references/review.md +7 -5
- package/skills/workflow-authoring/references/runtime.md +4 -3
- package/skills/workflow-patterns/SKILL.md +72 -33
- package/skills/workflow-review/SKILL.md +70 -0
- package/src/capabilities.ts +28 -3
- package/src/engine.ts +384 -124
- package/src/index.ts +126 -9
- package/src/journal.ts +266 -27
- package/src/orchestrator.ts +478 -0
- package/src/runtime.ts +332 -123
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named, deterministic task-graph orchestration for workflow scripts.
|
|
3
|
+
*
|
|
4
|
+
* The script runtime deliberately keeps model/session policy in pi-subagents;
|
|
5
|
+
* this module only schedules script callbacks. Tasks are executed in stable
|
|
6
|
+
* declaration-order layers: all tasks whose dependencies completed form one
|
|
7
|
+
* barrier, and the next layer is discovered only after that barrier settles.
|
|
8
|
+
* That makes dynamic graphs replayable even when child agents finish in a
|
|
9
|
+
* different order.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const MAX_ORCHESTRATION_TASKS = 128;
|
|
13
|
+
export const MAX_ORCHESTRATION_TASK_ID = 128;
|
|
14
|
+
export const MAX_ORCHESTRATION_PHASE = 512;
|
|
15
|
+
export const MAX_ORCHESTRATION_DESCRIPTION = 2_000;
|
|
16
|
+
export const MAX_ORCHESTRATION_RETRIES = 3;
|
|
17
|
+
|
|
18
|
+
export type OrchestrationErrorPolicy = "skip-dependents" | "continue" | "fail-fast";
|
|
19
|
+
export type OrchestrationTaskStatus = "pending" | "running" | "completed" | "failed" | "skipped";
|
|
20
|
+
export type OrchestrationTaskTerminalStatus = Exclude<OrchestrationTaskStatus, "pending" | "running">;
|
|
21
|
+
|
|
22
|
+
export interface OrchestrationTaskContext {
|
|
23
|
+
readonly id: string;
|
|
24
|
+
/** One-based attempt number for this task callback. */
|
|
25
|
+
readonly attempt: number;
|
|
26
|
+
/** Completed values plus null for failed/skipped dependencies. */
|
|
27
|
+
readonly results: Readonly<Record<string, unknown>>;
|
|
28
|
+
/** A detached status snapshot for every declared task. */
|
|
29
|
+
readonly statuses: Readonly<Record<string, OrchestrationTaskStatus>>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface OrchestrationTask {
|
|
33
|
+
readonly id: string;
|
|
34
|
+
readonly dependsOn: readonly string[];
|
|
35
|
+
readonly phase?: string;
|
|
36
|
+
readonly description?: string;
|
|
37
|
+
readonly retries: number;
|
|
38
|
+
readonly run: (context: OrchestrationTaskContext) => unknown | Promise<unknown>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface OrchestrationOptions {
|
|
42
|
+
readonly onError: OrchestrationErrorPolicy;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface OrchestrationTaskResult {
|
|
46
|
+
readonly id: string;
|
|
47
|
+
readonly status: OrchestrationTaskTerminalStatus;
|
|
48
|
+
/** Failed and skipped tasks expose null so downstream JSON is stable. */
|
|
49
|
+
readonly value: unknown;
|
|
50
|
+
readonly attempts: number;
|
|
51
|
+
readonly error?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface OrchestrationResult {
|
|
55
|
+
/** Every task id appears once; failed/skipped values are null. */
|
|
56
|
+
readonly results: Record<string, unknown>;
|
|
57
|
+
/** Every task id appears once in declaration order. */
|
|
58
|
+
readonly tasks: Record<string, OrchestrationTaskResult>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface OrchestrationTaskEvent {
|
|
62
|
+
readonly type: "task";
|
|
63
|
+
readonly [key: string]: unknown;
|
|
64
|
+
readonly stage: "start" | "retry" | "end" | "skip";
|
|
65
|
+
readonly taskId: string;
|
|
66
|
+
readonly phase?: string;
|
|
67
|
+
readonly attempt: number;
|
|
68
|
+
readonly status?: OrchestrationTaskTerminalStatus;
|
|
69
|
+
readonly error?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface OrchestrationHooks {
|
|
73
|
+
/** Throw when the enclosing workflow has been cancelled. */
|
|
74
|
+
readonly throwIfAborted?: () => void;
|
|
75
|
+
/** Return true for infrastructure/fatal errors that must escape the graph. */
|
|
76
|
+
readonly isFatal?: (error: unknown) => boolean;
|
|
77
|
+
/** Render ordinary task failures into bounded diagnostics. */
|
|
78
|
+
readonly formatError?: (error: unknown) => string;
|
|
79
|
+
/** Observe task transitions; listener failures never affect scheduling. */
|
|
80
|
+
readonly onEvent?: (event: OrchestrationTaskEvent) => void;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface RawRecord {
|
|
84
|
+
readonly [key: string]: unknown;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface MutableTaskResult {
|
|
88
|
+
readonly id: string;
|
|
89
|
+
status: OrchestrationTaskStatus;
|
|
90
|
+
value: unknown;
|
|
91
|
+
attempts: number;
|
|
92
|
+
error?: string;
|
|
93
|
+
errorValue?: unknown;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isRecord(value: unknown): value is RawRecord {
|
|
97
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function readBoundedString(value: unknown, label: string, limit: number): string {
|
|
101
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
102
|
+
throw new TypeError(`${label} must be a non-empty string`);
|
|
103
|
+
}
|
|
104
|
+
const normalized = value.trim();
|
|
105
|
+
if (normalized.length > limit) throw new TypeError(`${label} exceeds ${limit} characters`);
|
|
106
|
+
return normalized;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function readDependencies(raw: RawRecord, label: string): string[] {
|
|
110
|
+
const hasCamel = Object.hasOwn(raw, "dependsOn");
|
|
111
|
+
const hasSnake = Object.hasOwn(raw, "depends_on");
|
|
112
|
+
if (hasCamel && hasSnake) throw new TypeError(`${label} cannot contain both dependsOn and depends_on`);
|
|
113
|
+
const value = hasCamel ? raw.dependsOn : hasSnake ? raw.depends_on : undefined;
|
|
114
|
+
if (value === undefined) return [];
|
|
115
|
+
if (!Array.isArray(value)) throw new TypeError(`${label}.dependsOn must be an array`);
|
|
116
|
+
const dependencies: string[] = [];
|
|
117
|
+
for (const [index, dependency] of value.entries()) {
|
|
118
|
+
const normalized = readBoundedString(dependency, `${label}.dependsOn[${index}]`, MAX_ORCHESTRATION_TASK_ID);
|
|
119
|
+
if (dependencies.includes(normalized)) throw new TypeError(`${label}.dependsOn repeats "${normalized}"`);
|
|
120
|
+
dependencies.push(normalized);
|
|
121
|
+
}
|
|
122
|
+
return dependencies;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function readRetries(value: unknown, label: string): number {
|
|
126
|
+
if (value === undefined) return 0;
|
|
127
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > MAX_ORCHESTRATION_RETRIES) {
|
|
128
|
+
throw new TypeError(`${label}.retries must be an integer from 0 to ${MAX_ORCHESTRATION_RETRIES}`);
|
|
129
|
+
}
|
|
130
|
+
return value;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function assertKnownKeys(raw: RawRecord, label: string): void {
|
|
134
|
+
const allowed = new Set(["id", "dependsOn", "depends_on", "phase", "description", "retries", "run"]);
|
|
135
|
+
for (const key of Object.keys(raw)) {
|
|
136
|
+
if (!allowed.has(key)) throw new TypeError(`${label} contains unsupported field "${key}"`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Validate and normalize a runtime task list without executing callbacks. */
|
|
141
|
+
export function normalizeOrchestration(
|
|
142
|
+
rawTasks: unknown,
|
|
143
|
+
rawOptions: unknown = undefined,
|
|
144
|
+
): { tasks: OrchestrationTask[]; options: OrchestrationOptions } {
|
|
145
|
+
if (!Array.isArray(rawTasks)) throw new TypeError("orchestrate() expects an array of task objects");
|
|
146
|
+
if (rawTasks.length > MAX_ORCHESTRATION_TASKS) {
|
|
147
|
+
throw new TypeError(`orchestrate() accepts at most ${MAX_ORCHESTRATION_TASKS} tasks`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const tasks: OrchestrationTask[] = [];
|
|
151
|
+
const ids = new Set<string>();
|
|
152
|
+
for (const [index, rawValue] of rawTasks.entries()) {
|
|
153
|
+
const label = `orchestrate() task[${index}]`;
|
|
154
|
+
if (!isRecord(rawValue)) throw new TypeError(`${label} must be an object`);
|
|
155
|
+
assertKnownKeys(rawValue, label);
|
|
156
|
+
const id = readBoundedString(rawValue.id, `${label}.id`, MAX_ORCHESTRATION_TASK_ID);
|
|
157
|
+
if (id === "__proto__" || id === "constructor" || id === "prototype") {
|
|
158
|
+
throw new TypeError(`${label}.id uses a reserved key name`);
|
|
159
|
+
}
|
|
160
|
+
if (ids.has(id)) throw new TypeError(`orchestrate() contains duplicate task id "${id}"`);
|
|
161
|
+
ids.add(id);
|
|
162
|
+
|
|
163
|
+
if (typeof rawValue.run !== "function") throw new TypeError(`${label}.run must be a function`);
|
|
164
|
+
const dependsOn = readDependencies(rawValue, label);
|
|
165
|
+
const phase =
|
|
166
|
+
rawValue.phase === undefined
|
|
167
|
+
? undefined
|
|
168
|
+
: readBoundedString(rawValue.phase, `${label}.phase`, MAX_ORCHESTRATION_PHASE);
|
|
169
|
+
const description =
|
|
170
|
+
rawValue.description === undefined
|
|
171
|
+
? undefined
|
|
172
|
+
: readBoundedString(rawValue.description, `${label}.description`, MAX_ORCHESTRATION_DESCRIPTION);
|
|
173
|
+
tasks.push({
|
|
174
|
+
id,
|
|
175
|
+
dependsOn,
|
|
176
|
+
...(phase === undefined ? {} : { phase }),
|
|
177
|
+
...(description === undefined ? {} : { description }),
|
|
178
|
+
retries: readRetries(rawValue.retries, label),
|
|
179
|
+
run: rawValue.run as OrchestrationTask["run"],
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const taskIds = new Set(tasks.map((task) => task.id));
|
|
184
|
+
for (const task of tasks) {
|
|
185
|
+
for (const dependency of task.dependsOn) {
|
|
186
|
+
if (!taskIds.has(dependency)) {
|
|
187
|
+
throw new TypeError(`orchestrate() task "${task.id}" depends on unknown task "${dependency}"`);
|
|
188
|
+
}
|
|
189
|
+
if (dependency === task.id) throw new TypeError(`orchestrate() task "${task.id}" cannot depend on itself`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
assertAcyclic(tasks);
|
|
193
|
+
|
|
194
|
+
let onError: OrchestrationErrorPolicy = "skip-dependents";
|
|
195
|
+
if (rawOptions !== undefined) {
|
|
196
|
+
if (!isRecord(rawOptions)) throw new TypeError("orchestrate() options must be an object");
|
|
197
|
+
for (const key of Object.keys(rawOptions)) {
|
|
198
|
+
if (key !== "onError") throw new TypeError(`orchestrate() options contains unsupported field "${key}"`);
|
|
199
|
+
}
|
|
200
|
+
if (rawOptions.onError !== undefined) {
|
|
201
|
+
if (
|
|
202
|
+
rawOptions.onError !== "skip-dependents" &&
|
|
203
|
+
rawOptions.onError !== "continue" &&
|
|
204
|
+
rawOptions.onError !== "fail-fast"
|
|
205
|
+
) {
|
|
206
|
+
throw new TypeError('orchestrate() options.onError must be "skip-dependents", "continue", or "fail-fast"');
|
|
207
|
+
}
|
|
208
|
+
onError = rawOptions.onError;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return { tasks, options: { onError } };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function assertAcyclic(tasks: readonly OrchestrationTask[]): void {
|
|
215
|
+
const indegree = new Map<string, number>();
|
|
216
|
+
const dependents = new Map<string, string[]>();
|
|
217
|
+
for (const task of tasks) {
|
|
218
|
+
indegree.set(task.id, task.dependsOn.length);
|
|
219
|
+
for (const dependency of task.dependsOn) {
|
|
220
|
+
const list = dependents.get(dependency) ?? [];
|
|
221
|
+
list.push(task.id);
|
|
222
|
+
dependents.set(dependency, list);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const queue = tasks.filter((task) => indegree.get(task.id) === 0).map((task) => task.id);
|
|
226
|
+
let visited = 0;
|
|
227
|
+
for (let cursor = 0; cursor < queue.length; cursor += 1) {
|
|
228
|
+
const id = queue[cursor];
|
|
229
|
+
visited += 1;
|
|
230
|
+
for (const dependent of dependents.get(id) ?? []) {
|
|
231
|
+
const next = (indegree.get(dependent) ?? 0) - 1;
|
|
232
|
+
indegree.set(dependent, next);
|
|
233
|
+
if (next === 0) queue.push(dependent);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (visited !== tasks.length) throw new TypeError("orchestrate() task dependencies contain a cycle");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function terminal(status: OrchestrationTaskStatus | undefined): status is OrchestrationTaskTerminalStatus {
|
|
240
|
+
return status === "completed" || status === "failed" || status === "skipped";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function defaultFormatError(error: unknown): string {
|
|
244
|
+
return error instanceof Error ? error.message : String(error);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function emit(hooks: OrchestrationHooks, event: OrchestrationTaskEvent): void {
|
|
248
|
+
try {
|
|
249
|
+
hooks.onEvent?.(event);
|
|
250
|
+
} catch {
|
|
251
|
+
// Observers are not part of execution correctness.
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function snapshotContext(
|
|
256
|
+
task: OrchestrationTask,
|
|
257
|
+
attempt: number,
|
|
258
|
+
values: Record<string, unknown>,
|
|
259
|
+
statuses: Record<string, OrchestrationTaskStatus>,
|
|
260
|
+
): OrchestrationTaskContext {
|
|
261
|
+
let detachedResults: Record<string, unknown>;
|
|
262
|
+
try {
|
|
263
|
+
detachedResults = structuredClone(values);
|
|
264
|
+
} catch (error: unknown) {
|
|
265
|
+
throw new TypeError(
|
|
266
|
+
`orchestrate() task "${task.id}" results cannot be detached: ${error instanceof Error ? error.message : String(error)}`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
return Object.freeze({
|
|
270
|
+
id: task.id,
|
|
271
|
+
attempt,
|
|
272
|
+
results: Object.freeze(detachedResults),
|
|
273
|
+
statuses: Object.freeze({ ...statuses }),
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Execute normalized tasks in deterministic dependency layers. */
|
|
278
|
+
export async function executeOrchestration(
|
|
279
|
+
tasks: readonly OrchestrationTask[],
|
|
280
|
+
options: OrchestrationOptions = { onError: "skip-dependents" },
|
|
281
|
+
hooks: OrchestrationHooks = {},
|
|
282
|
+
): Promise<OrchestrationResult> {
|
|
283
|
+
if (tasks.length > MAX_ORCHESTRATION_TASKS) {
|
|
284
|
+
throw new TypeError(`orchestrate() accepts at most ${MAX_ORCHESTRATION_TASKS} tasks`);
|
|
285
|
+
}
|
|
286
|
+
const pending = new Set(tasks.map((task) => task.id));
|
|
287
|
+
const values: Record<string, unknown> = {};
|
|
288
|
+
const statuses: Record<string, OrchestrationTaskStatus> = {};
|
|
289
|
+
const records = new Map<string, MutableTaskResult>();
|
|
290
|
+
for (const task of tasks) {
|
|
291
|
+
statuses[task.id] = "pending";
|
|
292
|
+
// Keep pending tasks out of callback snapshots. The final `results` map is
|
|
293
|
+
// filled separately so callers still receive one stable key per task.
|
|
294
|
+
records.set(task.id, { id: task.id, status: "pending", value: null, attempts: 0 });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const markSkipped = (task: OrchestrationTask, reason: string): void => {
|
|
298
|
+
const record = records.get(task.id);
|
|
299
|
+
if (!record || !pending.has(task.id)) return;
|
|
300
|
+
pending.delete(task.id);
|
|
301
|
+
record.status = "skipped";
|
|
302
|
+
record.value = null;
|
|
303
|
+
record.error = reason;
|
|
304
|
+
statuses[task.id] = "skipped";
|
|
305
|
+
values[task.id] = null;
|
|
306
|
+
emit(hooks, {
|
|
307
|
+
type: "task",
|
|
308
|
+
stage: "skip",
|
|
309
|
+
taskId: task.id,
|
|
310
|
+
...(task.phase ? { phase: task.phase } : {}),
|
|
311
|
+
attempt: 0,
|
|
312
|
+
status: "skipped",
|
|
313
|
+
error: reason,
|
|
314
|
+
});
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
while (pending.size > 0) {
|
|
318
|
+
hooks.throwIfAborted?.();
|
|
319
|
+
|
|
320
|
+
if (options.onError !== "continue") {
|
|
321
|
+
let propagatedSkip: boolean;
|
|
322
|
+
do {
|
|
323
|
+
propagatedSkip = false;
|
|
324
|
+
for (const task of tasks) {
|
|
325
|
+
if (!pending.has(task.id)) continue;
|
|
326
|
+
const failedDependency = task.dependsOn.find((dependency) => {
|
|
327
|
+
const status = statuses[dependency];
|
|
328
|
+
return status === "failed" || status === "skipped";
|
|
329
|
+
});
|
|
330
|
+
if (failedDependency !== undefined) {
|
|
331
|
+
markSkipped(task, `dependency "${failedDependency}" did not complete`);
|
|
332
|
+
propagatedSkip = true;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
} while (propagatedSkip);
|
|
336
|
+
if (pending.size === 0) break;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const ready = tasks.filter((task) => {
|
|
340
|
+
if (!pending.has(task.id)) return false;
|
|
341
|
+
return task.dependsOn.every((dependency) => {
|
|
342
|
+
const status = statuses[dependency];
|
|
343
|
+
return options.onError === "continue" ? terminal(status) : status === "completed";
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
if (ready.length === 0) {
|
|
347
|
+
throw new Error("orchestrate() could not make progress; check task dependencies and failure policy");
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
for (const task of ready) {
|
|
351
|
+
const record = records.get(task.id);
|
|
352
|
+
if (!record) continue;
|
|
353
|
+
record.status = "running";
|
|
354
|
+
statuses[task.id] = "running";
|
|
355
|
+
emit(hooks, {
|
|
356
|
+
type: "task",
|
|
357
|
+
stage: "start",
|
|
358
|
+
taskId: task.id,
|
|
359
|
+
...(task.phase ? { phase: task.phase } : {}),
|
|
360
|
+
attempt: 1,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const outcomes = await Promise.all(
|
|
365
|
+
ready.map(async (task): Promise<{ task: OrchestrationTask; fatal?: { error: unknown } }> => {
|
|
366
|
+
const record = records.get(task.id);
|
|
367
|
+
if (!record) return { task };
|
|
368
|
+
for (let attempt = 1; attempt <= task.retries + 1; attempt += 1) {
|
|
369
|
+
record.attempts = attempt;
|
|
370
|
+
try {
|
|
371
|
+
hooks.throwIfAborted?.();
|
|
372
|
+
const value = await task.run(snapshotContext(task, attempt, values, statuses));
|
|
373
|
+
hooks.throwIfAborted?.();
|
|
374
|
+
pending.delete(task.id);
|
|
375
|
+
record.status = "completed";
|
|
376
|
+
record.value = value === undefined ? null : value;
|
|
377
|
+
statuses[task.id] = "completed";
|
|
378
|
+
values[task.id] = record.value;
|
|
379
|
+
emit(hooks, {
|
|
380
|
+
type: "task",
|
|
381
|
+
stage: "end",
|
|
382
|
+
taskId: task.id,
|
|
383
|
+
...(task.phase ? { phase: task.phase } : {}),
|
|
384
|
+
attempt,
|
|
385
|
+
status: "completed",
|
|
386
|
+
});
|
|
387
|
+
return { task };
|
|
388
|
+
} catch (error: unknown) {
|
|
389
|
+
const fatal = hooks.isFatal?.(error) ?? false;
|
|
390
|
+
const message = (hooks.formatError ?? defaultFormatError)(error);
|
|
391
|
+
record.error = message;
|
|
392
|
+
record.errorValue = error;
|
|
393
|
+
if (fatal) {
|
|
394
|
+
pending.delete(task.id);
|
|
395
|
+
record.status = "failed";
|
|
396
|
+
record.value = null;
|
|
397
|
+
statuses[task.id] = "failed";
|
|
398
|
+
values[task.id] = null;
|
|
399
|
+
emit(hooks, {
|
|
400
|
+
type: "task",
|
|
401
|
+
stage: "end",
|
|
402
|
+
taskId: task.id,
|
|
403
|
+
...(task.phase ? { phase: task.phase } : {}),
|
|
404
|
+
attempt,
|
|
405
|
+
status: "failed",
|
|
406
|
+
error: message,
|
|
407
|
+
});
|
|
408
|
+
return { task, fatal: { error } };
|
|
409
|
+
}
|
|
410
|
+
if (attempt <= task.retries) {
|
|
411
|
+
emit(hooks, {
|
|
412
|
+
type: "task",
|
|
413
|
+
stage: "retry",
|
|
414
|
+
taskId: task.id,
|
|
415
|
+
...(task.phase ? { phase: task.phase } : {}),
|
|
416
|
+
attempt,
|
|
417
|
+
error: message,
|
|
418
|
+
});
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
pending.delete(task.id);
|
|
422
|
+
record.status = "failed";
|
|
423
|
+
record.value = null;
|
|
424
|
+
statuses[task.id] = "failed";
|
|
425
|
+
values[task.id] = null;
|
|
426
|
+
emit(hooks, {
|
|
427
|
+
type: "task",
|
|
428
|
+
stage: "end",
|
|
429
|
+
taskId: task.id,
|
|
430
|
+
...(task.phase ? { phase: task.phase } : {}),
|
|
431
|
+
attempt,
|
|
432
|
+
status: "failed",
|
|
433
|
+
error: message,
|
|
434
|
+
});
|
|
435
|
+
return { task };
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return { task };
|
|
439
|
+
}),
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
const fatal = outcomes.find((outcome) => outcome.fatal !== undefined)?.fatal;
|
|
443
|
+
if (fatal) throw fatal.error;
|
|
444
|
+
if (options.onError === "fail-fast") {
|
|
445
|
+
const failed = ready.find((task) => records.get(task.id)?.status === "failed");
|
|
446
|
+
if (failed) {
|
|
447
|
+
const record = records.get(failed.id);
|
|
448
|
+
throw record?.errorValue ?? new Error(record?.error ?? `orchestrate() task "${failed.id}" failed`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const taskResults: Record<string, OrchestrationTaskResult> = {};
|
|
454
|
+
for (const task of tasks) {
|
|
455
|
+
const record = records.get(task.id);
|
|
456
|
+
if (!record || !terminal(record.status)) continue;
|
|
457
|
+
taskResults[task.id] = {
|
|
458
|
+
id: task.id,
|
|
459
|
+
status: record.status,
|
|
460
|
+
value: record.value,
|
|
461
|
+
attempts: record.attempts,
|
|
462
|
+
...(record.error === undefined ? {} : { error: record.error }),
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
const outputResults: Record<string, unknown> = {};
|
|
466
|
+
for (const task of tasks) outputResults[task.id] = values[task.id] ?? null;
|
|
467
|
+
return { results: outputResults, tasks: taskResults };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Validate and execute a runtime task list in one call. */
|
|
471
|
+
export async function runOrchestration(
|
|
472
|
+
rawTasks: unknown,
|
|
473
|
+
rawOptions: unknown,
|
|
474
|
+
hooks: OrchestrationHooks = {},
|
|
475
|
+
): Promise<OrchestrationResult> {
|
|
476
|
+
const normalized = normalizeOrchestration(rawTasks, rawOptions);
|
|
477
|
+
return executeOrchestration(normalized.tasks, normalized.options, hooks);
|
|
478
|
+
}
|