pi-workflow-engine 0.8.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.
@@ -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
  }
@@ -1,6 +1,5 @@
1
- import { spawn } from "node:child_process";
2
- import { performance } from "node:perf_hooks";
3
1
  import type { PerfSink } from "./perf.ts";
2
+ import { runBoundedProcess } from "./process-runner.ts";
4
3
 
5
4
  export interface AllowedDiffCommand {
6
5
  readonly file: "git" | "gh";
@@ -75,76 +74,23 @@ export async function captureDiff(command: string, options: DiffCaptureOptions):
75
74
  return { ok: false, stdout: "", durationMs: 0, bytes: 0, error: parsed.error };
76
75
  }
77
76
 
78
- const start = performance.now();
79
- let stdout = "";
80
- let stderr = "";
81
- let bytes = 0;
82
- let error: string | undefined;
83
- const child = spawn(parsed.file, [...parsed.args], {
77
+ const result = await runBoundedProcess({
78
+ file: parsed.file,
79
+ args: parsed.args,
84
80
  cwd: options.cwd,
85
81
  env: diffCaptureEnv(options.env),
86
- stdio: ["ignore", "pipe", "pipe"],
87
- });
88
-
89
- return await new Promise<DiffCaptureResult>((resolve) => {
90
- let settled = false;
91
- let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
92
- const killGraceMs = Math.max(1, options.killGraceMs ?? 100);
93
- const finish = (ok: boolean, finishError?: string) => {
94
- if (settled) return;
95
- settled = true;
96
- cleanup();
97
- const durationMs = performance.now() - start;
98
- options.perf?.observe("diff.capture_ms", durationMs);
99
- options.perf?.observe("diff.bytes", bytes);
100
- resolve({ ok, stdout, durationMs, bytes, error: finishError });
101
- };
102
- const kill = (message: string) => {
103
- error = error ?? message;
104
- child.kill("SIGTERM");
105
- forceKillTimer ??= setTimeout(() => {
106
- child.kill("SIGKILL");
107
- finish(false, error);
108
- }, killGraceMs);
109
- };
110
- const onAbort = () => kill("diff capture aborted");
111
- const timeout = setTimeout(() => kill(`diff capture timed out after ${options.timeoutMs}ms`), options.timeoutMs);
112
- const cleanup = () => {
113
- clearTimeout(timeout);
114
- if (forceKillTimer) clearTimeout(forceKillTimer);
115
- options.signal?.removeEventListener("abort", onAbort);
116
- };
117
-
118
- if (options.signal?.aborted) {
119
- kill("diff capture aborted");
120
- } else {
121
- options.signal?.addEventListener("abort", onAbort, { once: true });
122
- }
123
-
124
- child.stdout?.on("data", (chunk: Buffer) => {
125
- bytes += chunk.length;
126
- if (bytes > options.maxBufferBytes) {
127
- kill(`diff capture exceeded ${options.maxBufferBytes} bytes`);
128
- return;
129
- }
130
- stdout += chunk.toString("utf8");
131
- });
132
- child.stderr?.on("data", (chunk: Buffer) => {
133
- stderr += chunk.toString("utf8");
134
- });
135
- child.on("error", (spawnError) => finish(false, spawnError.message));
136
- child.on("close", (code, signal) => {
137
- if (error) {
138
- finish(false, error);
139
- return;
140
- }
141
- if (code === 0) {
142
- finish(true);
143
- return;
144
- }
145
- finish(false, stderr.trim() || `diff command exited with code ${code ?? `signal ${signal ?? "unknown"}`}`);
146
- });
82
+ signal: options.signal,
83
+ timeoutMs: options.timeoutMs,
84
+ maxBufferBytes: options.maxBufferBytes,
85
+ killGraceMs: options.killGraceMs,
86
+ abortError: "diff capture aborted",
87
+ timeoutError: `diff capture timed out after ${options.timeoutMs}ms`,
88
+ maxBufferError: `diff capture exceeded ${options.maxBufferBytes} bytes`,
89
+ exitError: (stderr, code, signal) => stderr.trim() || `diff command exited with code ${code ?? `signal ${signal ?? "unknown"}`}`,
147
90
  });
91
+ options.perf?.observe("diff.capture_ms", result.durationMs);
92
+ options.perf?.observe("diff.bytes", result.bytes);
93
+ return result;
148
94
  }
149
95
 
150
96
  function diffCaptureEnv(env: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv {
@@ -32,6 +32,7 @@ Inline workflow rules:
32
32
  - Use the injected Type object for schemas, for example Type.Object({ ok: Type.Boolean() }); do not import typebox.
33
33
  - Do not use import statements or dynamic import() in inline workflow scripts.
34
34
  - Set thinkingLevel explicitly on each agent() call so fan-out remains bounded.
35
+ - Subagents receive no skills by default. Add \`skills: ["skill-name"]\` per agent only when that stage should load that skill; grant the smallest useful set.
35
36
  - Provide exactly one of workflow.name or workflow.script.
36
37
  `;
37
38
 
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { parallel, pipeline, Semaphore } from "./concurrency.ts";
2
+ import { bindPipeline, parallel, Semaphore } from "./concurrency.ts";
3
3
  import { linkAbortSignal } from "./cancellation.ts";
4
+ import { createBudget } from "./budget.ts";
4
5
  import { runAgent, type RunContext } from "./agent-runner.ts";
5
6
  import { ProgressTracker } from "./progress.ts";
6
7
  import { createPerfRecorder, type PerfSnapshot } from "./perf.ts";
@@ -8,6 +9,8 @@ import { createWorkflowUsageRecorder } from "./usage.ts";
8
9
  import { defaultConcurrency, resolveWorkflowRunOptions } from "./options.ts";
9
10
  import type { AgentOptions, WorkflowApi, WorkflowModule, WorkflowProgressEvent, WorkflowRef, WorkflowRunOptions } from "./types.ts";
10
11
  import { WorkflowInspector } from "./ui/workflow-inspector.ts";
12
+ import { createWorkflowJournal, createWorkflowRunId, pruneWorkflowJournals, workflowJournalPath } from "./journal.ts";
13
+ import { WorktreeRegistry } from "./worktree.ts";
11
14
 
12
15
  /** Default global cap on concurrent agents per run. */
13
16
  const DEFAULT_CONCURRENCY = defaultConcurrency();
@@ -60,6 +63,14 @@ export async function runWorkflow(
60
63
 
61
64
  const perf = resolvedOptions.perfRecorder ?? createPerfRecorder(resolvedOptions.perf);
62
65
  const usage = createWorkflowUsageRecorder();
66
+ const budget = createBudget(resolvedOptions.budget ?? null, usage);
67
+ const runId = resolvedOptions.runId ?? createWorkflowRunId();
68
+ const journalPath = workflowJournalPath(ctx.cwd, runId);
69
+ const resumePath = resolvedOptions.resumeFromRunId ? workflowJournalPath(ctx.cwd, resolvedOptions.resumeFromRunId) : undefined;
70
+ const journal = await createWorkflowJournal({ resumePath, writePath: journalPath });
71
+ const worktrees = new WorktreeRegistry(ctx.cwd);
72
+ resolvedOptions.onRunMetadata?.({ runId, resumedFromRunId: resolvedOptions.resumeFromRunId, journalPath });
73
+ progress.log(resolvedOptions.resumeFromRunId ? `run id: ${runId} (resuming from ${resolvedOptions.resumeFromRunId})` : `run id: ${runId}`);
63
74
  const runAbortController = new AbortController();
64
75
  const unlinkContextAbortSignal = linkAbortSignal(ctx.signal, runAbortController);
65
76
  const unlinkOptionAbortSignal = linkAbortSignal(resolvedOptions.signal, runAbortController);
@@ -72,6 +83,9 @@ export async function runWorkflow(
72
83
  signal: runAbortController.signal,
73
84
  perf,
74
85
  usage,
86
+ budget,
87
+ journal,
88
+ worktrees,
75
89
  };
76
90
 
77
91
  try {
@@ -99,6 +113,13 @@ export async function runWorkflow(
99
113
  resolvedOptions.onProgressSource?.(undefined);
100
114
  unlinkContextAbortSignal();
101
115
  unlinkOptionAbortSignal();
116
+ const cleanupResults = await worktrees.removeAll();
117
+ for (const result of cleanupResults) {
118
+ if (!result.ok) {
119
+ progress.log(`failed to remove isolated worktree ${result.path} (${result.error ?? (result.stderr.trim() || "unknown error")})`);
120
+ }
121
+ }
122
+ await pruneWorkflowJournals(ctx.cwd);
102
123
  }
103
124
  }
104
125
  }
@@ -156,12 +177,13 @@ export async function runWorkflowWithContext(
156
177
  abortController: opts.abortController,
157
178
  limit: opts.submissionLimit,
158
179
  }),
159
- pipeline,
180
+ pipeline: bindPipeline({ signal: rc.signal, abortController: opts.abortController }),
160
181
  phase: scope.phase,
161
182
  log: scope.log,
162
183
  progress: scope.event,
163
184
  args,
164
185
  cwd: rc.cwd,
186
+ budget: rc.budget,
165
187
  signal: rc.signal,
166
188
  };
167
189
 
@@ -0,0 +1,200 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, readdir, readFile, rm, stat, appendFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import type { AgentOptions } from "./types.ts";
5
+
6
+ export const WORKFLOW_RUNS_DIR = join(".pi", ".workflow-runs");
7
+ export const WORKFLOW_JOURNAL_KEEP = 50;
8
+
9
+ export interface JournalEntry {
10
+ readonly key: string;
11
+ readonly value: unknown;
12
+ }
13
+
14
+ export type JournalLookup = { readonly hit: true; readonly value: unknown } | { readonly hit: false };
15
+ export type JournalRecordResult = { readonly ok: true } | { readonly ok: false; readonly error: string };
16
+
17
+ export interface WorkflowJournal {
18
+ lookup(key: string): JournalLookup;
19
+ record(key: string, value: unknown): Promise<JournalRecordResult>;
20
+ }
21
+
22
+ export class WorkflowJournalLoadError extends Error {
23
+ override readonly name = "WorkflowJournalLoadError";
24
+ }
25
+
26
+ export function createWorkflowRunId(): string {
27
+ return randomUUID();
28
+ }
29
+
30
+ export function workflowJournalPath(cwd: string, runId: string): string {
31
+ return join(cwd, WORKFLOW_RUNS_DIR, `${validateRunId(runId)}.jsonl`);
32
+ }
33
+
34
+ export function agentJournalKey(prompt: string, opts: AgentOptions = {}): string {
35
+ const behaviorHash = hashAgentCall(prompt, opts);
36
+ const cacheKey = opts.cacheKey?.trim();
37
+ if (!cacheKey) return `agent:${behaviorHash}`;
38
+ const cacheKeyHash = createHash("sha256").update(cacheKey).digest("hex");
39
+ return `agent:${cacheKeyHash}:${behaviorHash}`;
40
+ }
41
+
42
+ export function hashAgentCall(prompt: string, opts: AgentOptions = {}): string {
43
+ const behavior = {
44
+ prompt,
45
+ opts: {
46
+ model: opts.model,
47
+ thinkingLevel: opts.thinkingLevel,
48
+ tools: sortedArray(opts.tools),
49
+ toolHints: sortedArray(opts.toolHints),
50
+ skills: sortedArray(opts.skills),
51
+ schema: opts.schema,
52
+ isolation: opts.isolation,
53
+ },
54
+ };
55
+ return createHash("sha256").update(stableStringify(behavior)).digest("hex");
56
+ }
57
+
58
+ export async function loadJournalEntries(path: string, options: { readonly required?: boolean } = {}): Promise<JournalEntry[]> {
59
+ let content: string;
60
+ try {
61
+ content = await readFile(path, "utf8");
62
+ } catch (error) {
63
+ if (options.required) {
64
+ throw new WorkflowJournalLoadError(`Could not load workflow resume journal at ${path}: ${formatError(error)}`);
65
+ }
66
+ return [];
67
+ }
68
+
69
+ const entries: JournalEntry[] = [];
70
+ let lineNumber = 0;
71
+ for (const line of content.split(/\r?\n/)) {
72
+ lineNumber += 1;
73
+ if (line.trim() === "") continue;
74
+ try {
75
+ const parsed = JSON.parse(line) as unknown;
76
+ if (isJournalEntry(parsed)) {
77
+ entries.push(parsed);
78
+ continue;
79
+ }
80
+ if (options.required) {
81
+ throw new WorkflowJournalLoadError(`Workflow resume journal at ${path}:${lineNumber} has an incompatible entry format.`);
82
+ }
83
+ } catch (error) {
84
+ if (options.required) {
85
+ if (error instanceof WorkflowJournalLoadError) throw error;
86
+ throw new WorkflowJournalLoadError(`Workflow resume journal at ${path}:${lineNumber} is not valid JSON.`);
87
+ }
88
+ }
89
+ }
90
+ return entries;
91
+ }
92
+
93
+ export async function createWorkflowJournal(options: {
94
+ readonly resumePath?: string;
95
+ readonly writePath: string;
96
+ }): Promise<WorkflowJournal> {
97
+ const priorEntries = options.resumePath ? await loadJournalEntries(options.resumePath, { required: true }) : [];
98
+ return createMemoryBackedJournal(priorEntries, options.writePath);
99
+ }
100
+
101
+ export function createMemoryBackedJournal(priorEntries: readonly JournalEntry[] = [], writePath?: string): WorkflowJournal {
102
+ const priorByKey = new Map<string, JournalEntry | "ambiguous">();
103
+ for (const entry of priorEntries) {
104
+ priorByKey.set(entry.key, priorByKey.has(entry.key) ? "ambiguous" : entry);
105
+ }
106
+
107
+ return {
108
+ lookup(key) {
109
+ const entry = priorByKey.get(key);
110
+ if (!entry || entry === "ambiguous") return { hit: false };
111
+ return { hit: true, value: entry.value };
112
+ },
113
+ async record(key, value) {
114
+ const entry = { key, value };
115
+ if (writePath) {
116
+ try {
117
+ await mkdir(dirname(writePath), { recursive: true });
118
+ await appendFile(writePath, `${JSON.stringify(entry)}\n`, "utf8");
119
+ } catch (error) {
120
+ return { ok: false, error: formatError(error) };
121
+ }
122
+ }
123
+ return { ok: true };
124
+ },
125
+ };
126
+ }
127
+
128
+ export async function pruneWorkflowJournals(cwd: string, keep = WORKFLOW_JOURNAL_KEEP): Promise<void> {
129
+ const dir = join(cwd, WORKFLOW_RUNS_DIR);
130
+ let files: string[];
131
+ try {
132
+ files = await readdir(dir);
133
+ } catch {
134
+ return;
135
+ }
136
+
137
+ const candidates = await Promise.all(
138
+ files
139
+ .filter((file) => file.endsWith(".jsonl"))
140
+ .map(async (file) => {
141
+ const path = join(dir, file);
142
+ try {
143
+ const info = await stat(path);
144
+ return { path, mtimeMs: info.mtimeMs };
145
+ } catch {
146
+ return undefined;
147
+ }
148
+ }),
149
+ );
150
+
151
+ const stale = candidates
152
+ .filter((entry): entry is { readonly path: string; readonly mtimeMs: number } => entry !== undefined)
153
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)
154
+ .slice(Math.max(0, keep));
155
+
156
+ await Promise.all(stale.map((entry) => rm(entry.path, { force: true }).catch(() => undefined)));
157
+ }
158
+
159
+ function validateRunId(runId: string): string {
160
+ const trimmed = runId.trim();
161
+ if (!/^[A-Za-z0-9_.-]+$/.test(trimmed)) {
162
+ throw new Error("Workflow run id must contain only letters, numbers, dots, underscores, or hyphens.");
163
+ }
164
+ return trimmed;
165
+ }
166
+
167
+ function sortedArray(values: readonly string[] | undefined): readonly string[] | undefined {
168
+ return values ? [...values].sort() : undefined;
169
+ }
170
+
171
+ function stableStringify(value: unknown): string {
172
+ return JSON.stringify(normalizeStable(value));
173
+ }
174
+
175
+ function normalizeStable(value: unknown): unknown {
176
+ if (value === undefined) return undefined;
177
+ if (value === null) return null;
178
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
179
+ if (Array.isArray(value)) return value.map(normalizeStable);
180
+ if (typeof value === "object") {
181
+ const record = value as Record<string, unknown>;
182
+ const out: Record<string, unknown> = {};
183
+ for (const key of Object.keys(record).sort()) {
184
+ const normalized = normalizeStable(record[key]);
185
+ if (normalized !== undefined) out[key] = normalized;
186
+ }
187
+ return out;
188
+ }
189
+ return String(value);
190
+ }
191
+
192
+ function isJournalEntry(value: unknown): value is JournalEntry {
193
+ if (typeof value !== "object" || value === null) return false;
194
+ const entry = value as { readonly key?: unknown; readonly value?: unknown };
195
+ return typeof entry.key === "string" && "value" in entry;
196
+ }
197
+
198
+ function formatError(error: unknown): string {
199
+ return error instanceof Error ? error.message : String(error);
200
+ }
@@ -1,10 +1,14 @@
1
1
  import { cpus } from "node:os";
2
2
  import type { WorkflowRunOptions } from "./types.ts";
3
3
 
4
+ export const WORKFLOW_BUDGET_MIN = 1;
5
+ export const WORKFLOW_BUDGET_MAX = 1_000_000_000;
6
+
4
7
  export interface ResolvedWorkflowRunOptions extends WorkflowRunOptions {
5
8
  readonly perf: boolean;
6
9
  readonly concurrency: number;
7
10
  readonly parallelSubmissionLimit?: number;
11
+ readonly budget?: number;
8
12
  }
9
13
 
10
14
  export function defaultConcurrency(cpuCount = cpus().length): number {
@@ -21,16 +25,18 @@ export function resolveWorkflowRunOptions(
21
25
  1,
22
26
  10_000,
23
27
  );
28
+ const budget = resolveBudget(input.budget, env.PI_WORKFLOW_BUDGET);
24
29
  return {
25
30
  ...input,
26
31
  perf: input.perf ?? env.PI_WORKFLOW_PERF === "1",
27
32
  concurrency,
28
33
  parallelSubmissionLimit,
34
+ budget,
29
35
  };
30
36
  }
31
37
 
32
38
  function optionalClampedInteger(value: number | undefined, min: number, max: number): number | undefined {
33
- if (value === undefined) return undefined;
39
+ if (value === undefined || !Number.isFinite(value)) return undefined;
34
40
  return clampInteger(value, min, max, value);
35
41
  }
36
42
 
@@ -44,3 +50,23 @@ function parseInteger(value: string | undefined): number | undefined {
44
50
  const parsed = Number(value);
45
51
  return Number.isFinite(parsed) ? Math.trunc(parsed) : undefined;
46
52
  }
53
+
54
+ function resolveBudget(inputBudget: number | undefined, envBudget: string | undefined): number | undefined {
55
+ if (inputBudget !== undefined) return normalizeExplicitBudget(inputBudget);
56
+ return envBudget === undefined ? undefined : parseWorkflowBudgetString(envBudget);
57
+ }
58
+
59
+ function normalizeExplicitBudget(value: number): number {
60
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value < WORKFLOW_BUDGET_MIN || value > WORKFLOW_BUDGET_MAX) {
61
+ throw new RangeError(`Workflow budget must be an integer between ${WORKFLOW_BUDGET_MIN} and ${WORKFLOW_BUDGET_MAX}.`);
62
+ }
63
+ return value;
64
+ }
65
+
66
+ export function parseWorkflowBudgetString(value: string): number | undefined {
67
+ const trimmed = value.trim();
68
+ if (!/^\d+$/.test(trimmed)) return undefined;
69
+ const parsed = Number(trimmed);
70
+ if (!Number.isSafeInteger(parsed) || parsed < WORKFLOW_BUDGET_MIN || parsed > WORKFLOW_BUDGET_MAX) return undefined;
71
+ return parsed;
72
+ }