pi-workflow-engine 0.9.0 → 0.10.1

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.
@@ -5,23 +5,39 @@ import type { AgentOptions } from "./types.ts";
5
5
  import type { Semaphore } from "./concurrency.ts";
6
6
  import type { PerfSink } from "./perf.ts";
7
7
  import type { WorkflowUsageSink } from "./usage.ts";
8
+ import { WorkflowBudgetExceededError, type WorkflowBudget } from "./budget.ts";
8
9
  import { throwIfAborted } from "./cancellation.ts";
9
10
  import { appendSkillReminder, createAgentSkillResourceLoader, extractSkillSelectorsFromText } from "./agent-skills.ts";
11
+ import { agentJournalKey, type WorkflowJournal } from "./journal.ts";
12
+ import type { WorktreeRegistry } from "./worktree.ts";
10
13
 
11
14
  /** Name of the synthetic terminating tool that carries structured output. */
12
15
  const FINAL_TOOL = "final_answer";
16
+ const SCHEMA_REPROMPT_ATTEMPTS = 1;
17
+ const SCHEMA_REPROMPT =
18
+ `You ended your turn without calling the ${FINAL_TOOL} tool, so no result was recorded. ` +
19
+ `Call ${FINAL_TOOL} now with your final answer as its arguments. Do not reply with plain text.`;
20
+ const BUILTIN_TOOL_NAMES = ["read", "bash", "edit", "write", "grep", "find", "ls"] as const;
21
+ const DEFAULT_SEARCH_BASE_TOOLS = ["read", "bash", "grep", "find", "ls"];
13
22
 
14
23
  export interface AgentRunnerEvent {
15
24
  readonly type: string;
16
25
  readonly toolName?: string;
17
26
  }
18
27
 
28
+ export interface AgentRunnerToolInfo {
29
+ readonly name: string;
30
+ readonly description?: string;
31
+ }
32
+
19
33
  export interface AgentRunnerSession {
20
34
  readonly state: { readonly messages: readonly unknown[] };
21
35
  prompt(text: string): Promise<void>;
22
36
  subscribe(listener: (event: AgentRunnerEvent) => void): () => void;
23
37
  dispose(): void;
24
38
  abort(): Promise<void>;
39
+ getAllTools?(): readonly AgentRunnerToolInfo[];
40
+ setActiveToolsByName?(toolNames: readonly string[]): void;
25
41
  }
26
42
 
27
43
  export type CreateAgentSession = (options: CreateAgentSessionOptions) => Promise<{ session: AgentRunnerSession }>;
@@ -45,6 +61,9 @@ export interface RunContext {
45
61
  signal: AbortSignal | undefined;
46
62
  perf: PerfSink;
47
63
  usage: WorkflowUsageSink;
64
+ budget: WorkflowBudget;
65
+ journal: WorkflowJournal;
66
+ worktrees: WorktreeRegistry;
48
67
  createSession?: CreateAgentSession;
49
68
  }
50
69
 
@@ -157,20 +176,173 @@ function linkSessionAbort(signal: AbortSignal | undefined, session: AgentRunnerS
157
176
  return () => signal.removeEventListener("abort", onAbort);
158
177
  }
159
178
 
160
- function buildToolAllowlist(opts: AgentOptions, skillsEnabled: boolean): string[] | undefined {
161
- if (!opts.tools) return undefined;
162
- const allow = [...opts.tools];
179
+ type ToolSessionOptions = Pick<CreateAgentSessionOptions, "tools" | "excludeTools">;
180
+
181
+ interface ToolSelection {
182
+ readonly sessionOptions: ToolSessionOptions;
183
+ readonly fallbackSessionOptions: ToolSessionOptions;
184
+ readonly activeTools?: readonly string[];
185
+ readonly toolHints: NonNullable<AgentOptions["toolHints"]>;
186
+ }
187
+
188
+ interface DynamicToolSession extends AgentRunnerSession {
189
+ getAllTools(): readonly AgentRunnerToolInfo[];
190
+ setActiveToolsByName(toolNames: readonly string[]): void;
191
+ }
192
+
193
+ function buildToolList(opts: AgentOptions, skillsEnabled: boolean, fallback?: readonly string[]): string[] | undefined {
194
+ const allow = opts.tools ? [...opts.tools] : fallback ? [...fallback] : undefined;
195
+ if (!allow) return undefined;
163
196
  if (skillsEnabled && !allow.includes("read")) allow.push("read");
164
197
  if (opts.schema && !allow.includes(FINAL_TOOL)) allow.push(FINAL_TOOL);
165
198
  return allow;
166
199
  }
167
200
 
201
+ function buildToolSelection(opts: AgentOptions, skillsEnabled: boolean): ToolSelection {
202
+ const toolHints = opts.toolHints ?? [];
203
+ const fallback = toolHints.includes("search") ? DEFAULT_SEARCH_BASE_TOOLS : undefined;
204
+ const activeTools = buildToolList(opts, skillsEnabled, fallback);
205
+ const strictSessionOptions = { tools: activeTools ? [...activeTools] : undefined };
206
+
207
+ if (toolHints.length === 0) {
208
+ return { sessionOptions: strictSessionOptions, fallbackSessionOptions: strictSessionOptions, activeTools, toolHints };
209
+ }
210
+
211
+ const explicitlyActive = new Set(activeTools ?? []);
212
+ return {
213
+ sessionOptions: { excludeTools: BUILTIN_TOOL_NAMES.filter((name) => !explicitlyActive.has(name)) },
214
+ // If dynamic tool APIs are unavailable, fail closed to the concrete tools we
215
+ // already know instead of keeping every extension/custom tool active.
216
+ fallbackSessionOptions: { tools: activeTools ? [...activeTools] : [] },
217
+ activeTools,
218
+ toolHints,
219
+ };
220
+ }
221
+
222
+ function hasDynamicToolApis(session: AgentRunnerSession): session is DynamicToolSession {
223
+ return typeof session.getAllTools === "function" && typeof session.setActiveToolsByName === "function";
224
+ }
225
+
226
+ function applyDynamicToolHints(session: AgentRunnerSession, selection: ToolSelection): boolean {
227
+ if (selection.toolHints.length === 0) return true;
228
+ if (!hasDynamicToolApis(session)) return false;
229
+
230
+ const active = new Set(selection.activeTools ?? []);
231
+ for (const tool of session.getAllTools()) {
232
+ if (matchesToolHints(tool, selection.toolHints)) active.add(tool.name);
233
+ }
234
+ session.setActiveToolsByName([...active]);
235
+ return true;
236
+ }
237
+
238
+ function matchesToolHints(tool: AgentRunnerToolInfo, toolHints: readonly string[]): boolean {
239
+ return toolHints.some((hint) => hint === "search" && isSearchLikeTool(tool));
240
+ }
241
+
242
+ function isSearchLikeTool(tool: AgentRunnerToolInfo): boolean {
243
+ if (isClearlyMutatingToolName(tool.name)) return false;
244
+ const name = tool.name.toLowerCase();
245
+ if (name.includes("grep") || name.includes("find") || name.includes("search") || name === "rg" || name.includes("ripgrep")) return true;
246
+ const description = tool.description?.toLowerCase() ?? "";
247
+ return /\b(?:grep|find|search|ripgrep|rg|structural search|code search)\b/.test(description);
248
+ }
249
+
250
+ function isClearlyMutatingToolName(name: string): boolean {
251
+ return /(?:^|[-_])(?:edit|write|replace|patch|apply|delete|remove|move|rename|create|commit|push)(?:$|[-_])/.test(name.toLowerCase());
252
+ }
253
+
168
254
  function shouldCreateSkillResourceLoader(rc: RunContext, prompt: string, opts: AgentOptions): boolean {
169
255
  if (!rc.createSession) return true;
170
256
  if (opts.skills !== undefined) return true;
171
257
  return extractSkillSelectorsFromText(prompt).length > 0;
172
258
  }
173
259
 
260
+ /**
261
+ * Refuse to start a new subagent once the run is over budget. Called before queueing and
262
+ * again after a queued agent acquires a concurrency slot, so an exhausted run stops spending
263
+ * immediately rather than starting stale queued work.
264
+ *
265
+ * Policy: accept overshoot (matches the built-in Workflow tool). `budget.spent()` counts
266
+ * only COMPLETED agents (usage is recorded on session dispose), so agents admitted together
267
+ * can each pass this check and then collectively overshoot `budget.total` — we tolerate that
268
+ * rather than reserving per-agent estimates. An uncapped run (`budget.total === null`,
269
+ * `remaining()` is Infinity) never throws.
270
+ *
271
+ * Heads-up: a throw here is a recoverable branch failure inside `parallel`/`pipeline`, so the
272
+ * over-budget slot becomes null while already-started siblings can finish. The intended primary
273
+ * usage is the loop guard `while (budget.total && budget.remaining() > N) { await agent(...) }`,
274
+ * where this throw is only a backstop.
275
+ */
276
+ function ensureWithinBudget(budget: WorkflowBudget): void {
277
+ if (budget.total !== null && budget.remaining() <= 0) {
278
+ throw new WorkflowBudgetExceededError(budget.total, budget.spent());
279
+ }
280
+ }
281
+
282
+ interface AgentWorkspace {
283
+ readonly cwd: string;
284
+ wrapResult(result: unknown): Promise<unknown>;
285
+ dispose(): Promise<void>;
286
+ }
287
+
288
+ async function createAgentWorkspace(rc: RunContext, opts: AgentOptions, label: string): Promise<AgentWorkspace> {
289
+ if (opts.isolation !== "worktree") {
290
+ return {
291
+ cwd: rc.cwd,
292
+ async wrapResult(result) {
293
+ return result;
294
+ },
295
+ async dispose() {},
296
+ };
297
+ }
298
+
299
+ const probe = await rc.worktrees.probe(rc.signal);
300
+ if (!probe.ok) {
301
+ throw new Error(`Failed to check git worktree availability for isolated agent: ${probe.error ?? "unknown git error"}`);
302
+ }
303
+ if (!probe.inside) {
304
+ throw new Error("Agent requested worktree isolation, but the workflow cwd is not inside a git work tree.");
305
+ }
306
+
307
+ const added = await rc.worktrees.add(rc.signal);
308
+ if ("error" in added) throw new Error(`Failed to create isolated worktree: ${added.error}`);
309
+ const worktreePath = added.path;
310
+ rc.progress.log(`${label}: using isolated worktree ${worktreePath}`);
311
+
312
+ return {
313
+ cwd: worktreePath,
314
+ async wrapResult(result) {
315
+ return await isolatedResult(rc, worktreePath, result);
316
+ },
317
+ async dispose() {
318
+ const removed = await rc.worktrees.remove(worktreePath);
319
+ if (!removed.ok) {
320
+ rc.progress.log(`${label}: failed to remove isolated worktree (${removed.error ?? (removed.stderr.trim() || "unknown error")})`);
321
+ }
322
+ },
323
+ };
324
+ }
325
+
326
+ async function disposeAgentWorkspace(rc: RunContext, label: string, workspace: AgentWorkspace | undefined): Promise<void> {
327
+ if (!workspace) return;
328
+ try {
329
+ await workspace.dispose();
330
+ } catch (error) {
331
+ rc.progress.log(`${label}: failed to dispose isolated workspace (${errorMessage(error)})`);
332
+ }
333
+ }
334
+
335
+ async function recordJournalResult(rc: RunContext, label: string, key: string, value: unknown): Promise<void> {
336
+ try {
337
+ const recorded = await rc.journal.record(key, value);
338
+ if (!recorded.ok) {
339
+ rc.progress.log(`${label}: workflow journal write failed (${recorded.error}); future resume may be incomplete`);
340
+ }
341
+ } catch (error) {
342
+ rc.progress.log(`${label}: workflow journal write failed (${errorMessage(error)}); future resume may be incomplete`);
343
+ }
344
+ }
345
+
174
346
  /**
175
347
  * Run one subagent to completion in an isolated in-memory session.
176
348
  *
@@ -182,12 +354,26 @@ function shouldCreateSkillResourceLoader(rc: RunContext, prompt: string, opts: A
182
354
  * no event-stream parsing is needed.
183
355
  */
184
356
  export async function runAgent(rc: RunContext, prompt: string, opts: AgentOptions = {}): Promise<unknown> {
357
+ if (typeof prompt !== "string") {
358
+ throw new Error(`agent() prompt must be a string; received ${describeAgentPrompt(prompt)}`);
359
+ }
360
+
185
361
  const label = opts.label ?? "agent";
186
362
  const phase = opts.phase ?? "Workflow";
187
363
  const tags = { label, phase };
188
364
 
189
365
  return await rc.perf.time("agent.total_ms", async () => {
190
366
  throwIfAborted(rc.signal);
367
+ const journalKey = agentJournalKey(prompt, opts);
368
+ const cached = rc.journal.lookup(journalKey);
369
+ if (cached.hit) {
370
+ rc.progress.log(`${label}: using cached result from workflow journal`);
371
+ rc.perf.counter("agent.cache_hit", 1, tags);
372
+ await recordJournalResult(rc, label, journalKey, cached.value);
373
+ return cached.value;
374
+ }
375
+ // Stop spending the moment the run is over budget — before queueing.
376
+ ensureWithinBudget(rc.budget);
191
377
  // Track queued agents before acquiring a global concurrency slot.
192
378
  const rowId = rc.progress.agentQueued(opts.phase, label);
193
379
  let failureHandled = false;
@@ -195,6 +381,8 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
195
381
  return await rc.semaphore.run(
196
382
  async () => {
197
383
  throwIfAborted(rc.signal);
384
+ // Re-check after waiting for a slot; earlier agents may have exhausted the budget.
385
+ ensureWithinBudget(rc.budget);
198
386
  rc.progress.agentStart(opts.phase, label, rowId);
199
387
 
200
388
  let captured: unknown = null;
@@ -202,6 +390,7 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
202
390
  let session: AgentRunnerSession | undefined;
203
391
  let usageRecorded = false;
204
392
  let unsubscribe: (() => void) | undefined;
393
+ let workspace: AgentWorkspace | undefined;
205
394
  const recordUsage = (activeSession: AgentRunnerSession): void => {
206
395
  if (usageRecorded) return;
207
396
  usageRecorded = true;
@@ -224,13 +413,16 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
224
413
  : [];
225
414
 
226
415
  try {
416
+ workspace = await createAgentWorkspace(rc, opts, label);
417
+ const agentCwd = workspace.cwd;
418
+
227
419
  const { model } = resolveAgentModel(opts.model, rc.modelRegistry, rc.hostModel);
228
420
  const skillSetup = shouldCreateSkillResourceLoader(rc, prompt, opts)
229
421
  ? await rc.perf.time(
230
422
  "agent.skills_ms",
231
423
  () =>
232
424
  createAgentSkillResourceLoader({
233
- cwd: rc.cwd,
425
+ cwd: agentCwd,
234
426
  prompt,
235
427
  skills: opts.skills,
236
428
  log: (message) => rc.progress.log(`${label}: ${message}`),
@@ -239,30 +431,44 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
239
431
  )
240
432
  : undefined;
241
433
  const selectedSkills = skillSetup?.selectedSkills ?? [];
242
- // When the workflow restricts built-in tools, keep the terminating tool visible.
434
+ // When the workflow restricts tools, keep the terminating tool visible.
243
435
  // Skill opt-ins also need read so the subagent can load SKILL.md on demand.
244
- const allow = buildToolAllowlist(opts, selectedSkills.length > 0);
436
+ const toolSelection = buildToolSelection(opts, selectedSkills.length > 0);
245
437
 
246
438
  const createSessionForRun = rc.createSession ?? defaultCreateSession;
439
+ const createSubagentSession = (sessionOptions: ToolSessionOptions) =>
440
+ rc.perf.time(
441
+ "agent.create_session_ms",
442
+ () =>
443
+ createSessionForRun({
444
+ cwd: agentCwd,
445
+ model,
446
+ modelRegistry: rc.modelRegistry as ModelRegistry,
447
+ thinkingLevel: opts.thinkingLevel,
448
+ tools: sessionOptions.tools,
449
+ excludeTools: sessionOptions.excludeTools,
450
+ customTools,
451
+ resourceLoader: skillSetup?.resourceLoader,
452
+ sessionManager: SessionManager.inMemory(agentCwd),
453
+ }),
454
+ tags,
455
+ );
247
456
 
248
457
  throwIfAborted(rc.signal);
249
- const created = await rc.perf.time(
250
- "agent.create_session_ms",
251
- () =>
252
- createSessionForRun({
253
- cwd: rc.cwd,
254
- model,
255
- modelRegistry: rc.modelRegistry as ModelRegistry,
256
- thinkingLevel: opts.thinkingLevel,
257
- tools: allow,
258
- customTools,
259
- resourceLoader: skillSetup?.resourceLoader,
260
- sessionManager: SessionManager.inMemory(rc.cwd),
261
- }),
262
- tags,
263
- );
458
+ let created = await createSubagentSession(toolSelection.sessionOptions);
264
459
  session = created.session;
265
- const activeSession = session;
460
+ let activeSession = session;
461
+ if (!applyDynamicToolHints(activeSession, toolSelection)) {
462
+ rc.progress.log(`${label}: dynamic tool hints unavailable; falling back to concrete tools only`);
463
+ rc.perf.counter("agent.tool_hint_fallback", 1, tags);
464
+ const dynamicSession = activeSession;
465
+ session = undefined;
466
+ rc.perf.timeSync("agent.dispose_ms", () => dynamicSession.dispose(), tags);
467
+ throwIfAborted(rc.signal);
468
+ created = await createSubagentSession(toolSelection.fallbackSessionOptions);
469
+ session = created.session;
470
+ activeSession = session;
471
+ }
266
472
  throwIfAborted(rc.signal);
267
473
 
268
474
  unsubscribe = activeSession.subscribe((event) => {
@@ -279,13 +485,21 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
279
485
  const unlinkPromptAbort = linkSessionAbort(rc.signal, activeSession);
280
486
  try {
281
487
  await rc.perf.time("agent.prompt_ms", () => activeSession.prompt(finalPrompt), tags);
488
+ if (opts.schema) {
489
+ for (let attempt = 0; captured === null && attempt < SCHEMA_REPROMPT_ATTEMPTS; attempt++) {
490
+ throwIfAborted(rc.signal);
491
+ rc.progress.log(`${label}: no final answer; re-prompting (${attempt + 1}/${SCHEMA_REPROMPT_ATTEMPTS})`);
492
+ rc.perf.counter("agent.structured_reprompt", 1, tags);
493
+ await rc.perf.time("agent.prompt_ms", () => activeSession.prompt(SCHEMA_REPROMPT), tags);
494
+ }
495
+ }
282
496
  } finally {
283
497
  unlinkPromptAbort();
284
498
  }
285
499
  recordUsage(activeSession);
286
500
  throwIfAborted(rc.signal);
287
501
 
288
- return rc.perf.timeSync(
502
+ const rawResult = rc.perf.timeSync(
289
503
  "agent.extract_result_ms",
290
504
  () => {
291
505
  if (opts.schema) {
@@ -299,6 +513,9 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
299
513
  },
300
514
  tags,
301
515
  );
516
+ const result = await workspace.wrapResult(rawResult);
517
+ await recordJournalResult(rc, label, journalKey, result);
518
+ return result;
302
519
  } catch (error) {
303
520
  failed = true;
304
521
  failureHandled = true;
@@ -318,6 +535,7 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
318
535
  tags,
319
536
  );
320
537
  }
538
+ await disposeAgentWorkspace(rc, label, workspace);
321
539
  if (!failed) rc.progress.agentDone(label, rowId);
322
540
  }
323
541
  },
@@ -332,3 +550,19 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
332
550
  }
333
551
  }, tags);
334
552
  }
553
+
554
+ async function isolatedResult(rc: RunContext, worktreePath: string, result: unknown): Promise<{ readonly result: unknown; readonly patch: string; readonly changed: boolean }> {
555
+ const patch = await rc.worktrees.capturePatch(worktreePath, rc.signal);
556
+ if ("error" in patch) throw new Error(`Failed to capture isolated worktree patch: ${patch.error}`);
557
+ return { result, patch: patch.patch, changed: patch.changed };
558
+ }
559
+
560
+ function describeAgentPrompt(value: unknown): string {
561
+ if (value === null) return "null";
562
+ if (Array.isArray(value)) return "array";
563
+ return typeof value;
564
+ }
565
+
566
+ function errorMessage(error: unknown): string {
567
+ return error instanceof Error ? error.message : String(error);
568
+ }
@@ -0,0 +1,46 @@
1
+ import type { WorkflowUsageSink } from "./usage.ts";
2
+
3
+ /**
4
+ * Thrown by `agent()` when a run has consumed its token budget. Carries the
5
+ * configured ceiling and the output tokens spent so callers can report both.
6
+ */
7
+ export class WorkflowBudgetExceededError extends Error {
8
+ constructor(
9
+ readonly total: number,
10
+ readonly spent: number,
11
+ ) {
12
+ super(`Workflow token budget exhausted: spent ${spent} output tokens of ${total}.`);
13
+ this.name = "WorkflowBudgetExceededError";
14
+ }
15
+ }
16
+
17
+ /**
18
+ * The budget handle exposed on `WorkflowApi`. Mirrors the built-in Workflow tool:
19
+ * `total` is the output-token ceiling (or `null` when unset), `spent()` is the
20
+ * output tokens consumed by completed agents this run, and `remaining()` is the
21
+ * headroom left (`Infinity` when there is no ceiling).
22
+ *
23
+ * `spent()`/`remaining()` are LIVE — they re-read usage on every call so that
24
+ * `while (budget.total && budget.remaining() > N) { await agent(...) }` loops and
25
+ * `Math.floor(budget.total / N)` fleet-scaling see up-to-date numbers.
26
+ */
27
+ export interface WorkflowBudget {
28
+ readonly total: number | null;
29
+ /** Output tokens consumed by completed agents this run (sub-workflows included). */
30
+ spent(): number;
31
+ /** `total - spent()` clamped at 0, or `Infinity` when `total` is null. */
32
+ remaining(): number;
33
+ }
34
+
35
+ /**
36
+ * Build a budget backed by the run's usage sink. `spent()` reads
37
+ * `usage.snapshot().totals.output` — the same output-token sum surfaced in the
38
+ * usage line — so no separate token accounting is needed.
39
+ */
40
+ export function createBudget(total: number | null, usage: WorkflowUsageSink): WorkflowBudget {
41
+ return {
42
+ total,
43
+ spent: () => usage.snapshot().totals.output,
44
+ remaining: () => (total === null ? Infinity : Math.max(0, total - usage.snapshot().totals.output)),
45
+ };
46
+ }
@@ -16,6 +16,13 @@ export function throwIfAborted(signal: AbortSignal | undefined): void {
16
16
  if (signal?.aborted) throw abortReason(signal);
17
17
  }
18
18
 
19
+ export function isFatalWorkflowError(error: unknown, signal: AbortSignal | undefined): boolean {
20
+ if (signal?.aborted) return true;
21
+ if (error instanceof WorkflowAbortError) return true;
22
+ if (typeof DOMException !== "undefined" && error instanceof DOMException && error.name === "AbortError") return true;
23
+ return false;
24
+ }
25
+
19
26
  export function linkAbortSignal(parent: AbortSignal | undefined, controller: AbortController): () => void {
20
27
  if (!parent) return () => {};
21
28
  if (parent.aborted) {
@@ -1,5 +1,5 @@
1
1
  import { performance } from "node:perf_hooks";
2
- import { abortReason, throwIfAborted } from "./cancellation.ts";
2
+ import { abortReason, isFatalWorkflowError, throwIfAborted } from "./cancellation.ts";
3
3
 
4
4
  /**
5
5
  * A counting semaphore: bounds how many async tasks run at once.
@@ -68,15 +68,24 @@ export interface ParallelOptions {
68
68
  readonly limit?: number;
69
69
  }
70
70
 
71
- /** Run every thunk concurrently and wait for all results (a barrier). */
72
- export async function parallel<T>(thunks: Array<() => Promise<T>>, options: ParallelOptions = {}): Promise<T[]> {
71
+ export interface PipelineOptions {
72
+ readonly signal?: AbortSignal;
73
+ readonly abortController?: AbortController;
74
+ }
75
+
76
+ export function compactResults<T>(values: ReadonlyArray<T | null | undefined>): T[] {
77
+ return values.filter((value): value is T => value != null);
78
+ }
79
+
80
+ /** Run every thunk concurrently and wait for all results; recoverable failures become null slots. */
81
+ export async function parallel<T>(thunks: Array<() => Promise<T>>, options: ParallelOptions = {}): Promise<Array<T | null>> {
73
82
  throwIfAborted(options.signal);
74
- const results = new Array<T>(thunks.length);
83
+ const results = new Array<T | null>(thunks.length);
75
84
  let remaining = thunks.length;
76
85
  if (remaining === 0) return results;
77
86
 
78
87
  const limit = normalizeLimit(options.limit, thunks.length);
79
- return await new Promise<T[]>((resolve, reject) => {
88
+ return await new Promise<Array<T | null>>((resolve, reject) => {
80
89
  let rejected = false;
81
90
  let active = 0;
82
91
  let next = 0;
@@ -88,6 +97,18 @@ export async function parallel<T>(thunks: Array<() => Promise<T>>, options: Para
88
97
  cleanup();
89
98
  reject(error);
90
99
  };
100
+ const settleSlot = (index: number, value: T | null) => {
101
+ if (rejected) return;
102
+ active--;
103
+ results[index] = value;
104
+ remaining--;
105
+ if (remaining === 0) {
106
+ cleanup();
107
+ resolve(results);
108
+ return;
109
+ }
110
+ launchMore();
111
+ };
91
112
  const onAbort = () => rejectOnce(abortReason(options.signal));
92
113
  const launchMore = () => {
93
114
  while (!rejected && active < limit && next < thunks.length) {
@@ -99,19 +120,14 @@ export async function parallel<T>(thunks: Array<() => Promise<T>>, options: Para
99
120
  return thunks[index]();
100
121
  })
101
122
  .then(
102
- (value) => {
103
- if (rejected) return;
104
- active--;
105
- results[index] = value;
106
- remaining--;
107
- if (remaining === 0) {
108
- cleanup();
109
- resolve(results);
123
+ (value) => settleSlot(index, value),
124
+ (error: unknown) => {
125
+ if (isFatalWorkflowError(error, options.signal)) {
126
+ rejectOnce(error);
110
127
  return;
111
128
  }
112
- launchMore();
129
+ settleSlot(index, null);
113
130
  },
114
- (error: unknown) => rejectOnce(error),
115
131
  );
116
132
  }
117
133
  };
@@ -137,33 +153,77 @@ function normalizeLimit(limit: number | undefined, itemCount: number): number {
137
153
  export async function pipeline<Item, A>(
138
154
  items: readonly Item[],
139
155
  stage1: (prev: Item, item: Item, index: number) => Promise<A>,
140
- ): Promise<A[]>;
156
+ ): Promise<Array<A | null>>;
141
157
  export async function pipeline<Item, A, B>(
142
158
  items: readonly Item[],
143
159
  stage1: (prev: Item, item: Item, index: number) => Promise<A>,
144
160
  stage2: (prev: A, item: Item, index: number) => Promise<B>,
145
- ): Promise<B[]>;
161
+ ): Promise<Array<B | null>>;
146
162
  export async function pipeline<Item, A, B, C>(
147
163
  items: readonly Item[],
148
164
  stage1: (prev: Item, item: Item, index: number) => Promise<A>,
149
165
  stage2: (prev: A, item: Item, index: number) => Promise<B>,
150
166
  stage3: (prev: B, item: Item, index: number) => Promise<C>,
151
- ): Promise<C[]>;
167
+ ): Promise<Array<C | null>>;
152
168
  export async function pipeline(
153
169
  items: readonly unknown[],
154
170
  ...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>
155
- ): Promise<unknown[]>;
171
+ ): Promise<Array<unknown | null>>;
156
172
  export async function pipeline(
157
173
  items: readonly unknown[],
158
174
  ...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>
159
- ): Promise<unknown[]> {
175
+ ): Promise<Array<unknown | null>> {
176
+ return pipelineWithOptions(items, stages);
177
+ }
178
+
179
+ export function bindPipeline(options: PipelineOptions): Pipeline {
180
+ async function boundPipeline<Item, A>(
181
+ items: readonly Item[],
182
+ stage1: (prev: Item, item: Item, index: number) => Promise<A>,
183
+ ): Promise<Array<A | null>>;
184
+ async function boundPipeline<Item, A, B>(
185
+ items: readonly Item[],
186
+ stage1: (prev: Item, item: Item, index: number) => Promise<A>,
187
+ stage2: (prev: A, item: Item, index: number) => Promise<B>,
188
+ ): Promise<Array<B | null>>;
189
+ async function boundPipeline<Item, A, B, C>(
190
+ items: readonly Item[],
191
+ stage1: (prev: Item, item: Item, index: number) => Promise<A>,
192
+ stage2: (prev: A, item: Item, index: number) => Promise<B>,
193
+ stage3: (prev: B, item: Item, index: number) => Promise<C>,
194
+ ): Promise<Array<C | null>>;
195
+ async function boundPipeline(
196
+ items: readonly unknown[],
197
+ ...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>
198
+ ): Promise<Array<unknown | null>> {
199
+ return pipelineWithOptions(items, stages, options);
200
+ }
201
+ return boundPipeline;
202
+ }
203
+
204
+ export async function pipelineWithOptions(
205
+ items: readonly unknown[],
206
+ stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>,
207
+ options: PipelineOptions = {},
208
+ ): Promise<Array<unknown | null>> {
209
+ throwIfAborted(options.signal);
160
210
  return Promise.all(
161
211
  items.map(async (item, index) => {
212
+ throwIfAborted(options.signal);
162
213
  let acc: unknown = item;
163
- for (const stage of stages) {
164
- acc = await stage(acc, item, index);
214
+ try {
215
+ for (const stage of stages) {
216
+ throwIfAborted(options.signal);
217
+ acc = await stage(acc, item, index);
218
+ }
219
+ return acc;
220
+ } catch (error) {
221
+ if (isFatalWorkflowError(error, options.signal)) {
222
+ options.abortController?.abort(error);
223
+ throw error;
224
+ }
225
+ return null;
165
226
  }
166
- return acc;
167
227
  }),
168
228
  );
169
229
  }