pi-workflow-engine 0.9.0 → 0.10.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.
@@ -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
  *
@@ -188,6 +360,16 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
188
360
 
189
361
  return await rc.perf.time("agent.total_ms", async () => {
190
362
  throwIfAborted(rc.signal);
363
+ const journalKey = agentJournalKey(prompt, opts);
364
+ const cached = rc.journal.lookup(journalKey);
365
+ if (cached.hit) {
366
+ rc.progress.log(`${label}: using cached result from workflow journal`);
367
+ rc.perf.counter("agent.cache_hit", 1, tags);
368
+ await recordJournalResult(rc, label, journalKey, cached.value);
369
+ return cached.value;
370
+ }
371
+ // Stop spending the moment the run is over budget — before queueing.
372
+ ensureWithinBudget(rc.budget);
191
373
  // Track queued agents before acquiring a global concurrency slot.
192
374
  const rowId = rc.progress.agentQueued(opts.phase, label);
193
375
  let failureHandled = false;
@@ -195,6 +377,8 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
195
377
  return await rc.semaphore.run(
196
378
  async () => {
197
379
  throwIfAborted(rc.signal);
380
+ // Re-check after waiting for a slot; earlier agents may have exhausted the budget.
381
+ ensureWithinBudget(rc.budget);
198
382
  rc.progress.agentStart(opts.phase, label, rowId);
199
383
 
200
384
  let captured: unknown = null;
@@ -202,6 +386,7 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
202
386
  let session: AgentRunnerSession | undefined;
203
387
  let usageRecorded = false;
204
388
  let unsubscribe: (() => void) | undefined;
389
+ let workspace: AgentWorkspace | undefined;
205
390
  const recordUsage = (activeSession: AgentRunnerSession): void => {
206
391
  if (usageRecorded) return;
207
392
  usageRecorded = true;
@@ -224,13 +409,16 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
224
409
  : [];
225
410
 
226
411
  try {
412
+ workspace = await createAgentWorkspace(rc, opts, label);
413
+ const agentCwd = workspace.cwd;
414
+
227
415
  const { model } = resolveAgentModel(opts.model, rc.modelRegistry, rc.hostModel);
228
416
  const skillSetup = shouldCreateSkillResourceLoader(rc, prompt, opts)
229
417
  ? await rc.perf.time(
230
418
  "agent.skills_ms",
231
419
  () =>
232
420
  createAgentSkillResourceLoader({
233
- cwd: rc.cwd,
421
+ cwd: agentCwd,
234
422
  prompt,
235
423
  skills: opts.skills,
236
424
  log: (message) => rc.progress.log(`${label}: ${message}`),
@@ -239,30 +427,44 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
239
427
  )
240
428
  : undefined;
241
429
  const selectedSkills = skillSetup?.selectedSkills ?? [];
242
- // When the workflow restricts built-in tools, keep the terminating tool visible.
430
+ // When the workflow restricts tools, keep the terminating tool visible.
243
431
  // Skill opt-ins also need read so the subagent can load SKILL.md on demand.
244
- const allow = buildToolAllowlist(opts, selectedSkills.length > 0);
432
+ const toolSelection = buildToolSelection(opts, selectedSkills.length > 0);
245
433
 
246
434
  const createSessionForRun = rc.createSession ?? defaultCreateSession;
435
+ const createSubagentSession = (sessionOptions: ToolSessionOptions) =>
436
+ rc.perf.time(
437
+ "agent.create_session_ms",
438
+ () =>
439
+ createSessionForRun({
440
+ cwd: agentCwd,
441
+ model,
442
+ modelRegistry: rc.modelRegistry as ModelRegistry,
443
+ thinkingLevel: opts.thinkingLevel,
444
+ tools: sessionOptions.tools,
445
+ excludeTools: sessionOptions.excludeTools,
446
+ customTools,
447
+ resourceLoader: skillSetup?.resourceLoader,
448
+ sessionManager: SessionManager.inMemory(agentCwd),
449
+ }),
450
+ tags,
451
+ );
247
452
 
248
453
  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
- );
454
+ let created = await createSubagentSession(toolSelection.sessionOptions);
264
455
  session = created.session;
265
- const activeSession = session;
456
+ let activeSession = session;
457
+ if (!applyDynamicToolHints(activeSession, toolSelection)) {
458
+ rc.progress.log(`${label}: dynamic tool hints unavailable; falling back to concrete tools only`);
459
+ rc.perf.counter("agent.tool_hint_fallback", 1, tags);
460
+ const dynamicSession = activeSession;
461
+ session = undefined;
462
+ rc.perf.timeSync("agent.dispose_ms", () => dynamicSession.dispose(), tags);
463
+ throwIfAborted(rc.signal);
464
+ created = await createSubagentSession(toolSelection.fallbackSessionOptions);
465
+ session = created.session;
466
+ activeSession = session;
467
+ }
266
468
  throwIfAborted(rc.signal);
267
469
 
268
470
  unsubscribe = activeSession.subscribe((event) => {
@@ -279,13 +481,21 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
279
481
  const unlinkPromptAbort = linkSessionAbort(rc.signal, activeSession);
280
482
  try {
281
483
  await rc.perf.time("agent.prompt_ms", () => activeSession.prompt(finalPrompt), tags);
484
+ if (opts.schema) {
485
+ for (let attempt = 0; captured === null && attempt < SCHEMA_REPROMPT_ATTEMPTS; attempt++) {
486
+ throwIfAborted(rc.signal);
487
+ rc.progress.log(`${label}: no final answer; re-prompting (${attempt + 1}/${SCHEMA_REPROMPT_ATTEMPTS})`);
488
+ rc.perf.counter("agent.structured_reprompt", 1, tags);
489
+ await rc.perf.time("agent.prompt_ms", () => activeSession.prompt(SCHEMA_REPROMPT), tags);
490
+ }
491
+ }
282
492
  } finally {
283
493
  unlinkPromptAbort();
284
494
  }
285
495
  recordUsage(activeSession);
286
496
  throwIfAborted(rc.signal);
287
497
 
288
- return rc.perf.timeSync(
498
+ const rawResult = rc.perf.timeSync(
289
499
  "agent.extract_result_ms",
290
500
  () => {
291
501
  if (opts.schema) {
@@ -299,6 +509,9 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
299
509
  },
300
510
  tags,
301
511
  );
512
+ const result = await workspace.wrapResult(rawResult);
513
+ await recordJournalResult(rc, label, journalKey, result);
514
+ return result;
302
515
  } catch (error) {
303
516
  failed = true;
304
517
  failureHandled = true;
@@ -318,6 +531,7 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
318
531
  tags,
319
532
  );
320
533
  }
534
+ await disposeAgentWorkspace(rc, label, workspace);
321
535
  if (!failed) rc.progress.agentDone(label, rowId);
322
536
  }
323
537
  },
@@ -332,3 +546,13 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
332
546
  }
333
547
  }, tags);
334
548
  }
549
+
550
+ async function isolatedResult(rc: RunContext, worktreePath: string, result: unknown): Promise<{ readonly result: unknown; readonly patch: string; readonly changed: boolean }> {
551
+ const patch = await rc.worktrees.capturePatch(worktreePath, rc.signal);
552
+ if ("error" in patch) throw new Error(`Failed to capture isolated worktree patch: ${patch.error}`);
553
+ return { result, patch: patch.patch, changed: patch.changed };
554
+ }
555
+
556
+ function errorMessage(error: unknown): string {
557
+ return error instanceof Error ? error.message : String(error);
558
+ }
@@ -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
  }