@fyeeme/pi-dynamic-workflows 0.1.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.
@@ -0,0 +1 @@
1
+ export { generateRunId, type RunIdInput } from "./names.ts";
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Run identity — deterministic by construction.
3
+ *
4
+ * Claude Code's workflow engine bans Date.now()/Math.random()/new Date()
5
+ * inside workflow scripts because non-deterministic id generation breaks
6
+ * resume cache hits: same script → different runId → different cache keys
7
+ * → the journal never replays. The removed pi prototype used
8
+ * `run-${Date.now()}-${Math.random()}` exactly this anti-pattern.
9
+ *
10
+ * This module is the fix: `generateRunId` is a PURE function of
11
+ * (timestamp, sequence), both supplied by the caller. The runtime obtains the
12
+ * timestamp once at run inception and passes it down; workflow bodies never
13
+ * read "now" themselves (the ast-guard in `determinism/` enforces that).
14
+ */
15
+
16
+ export interface RunIdInput {
17
+ /** Inception epoch ms. Supplied by the caller; never read from Date.now() here. */
18
+ readonly timestamp: number;
19
+ /** Monotonic per-caller counter. Disambiguates runs that share a timestamp. */
20
+ readonly sequence: number;
21
+ }
22
+
23
+ /**
24
+ * Build a run id deterministically. Same (timestamp, sequence) → identical id.
25
+ *
26
+ * Format mirrors the removed prototype's `run-<base36>-<base36>` shape, but the
27
+ * second slot is a zero-padded sequence (was `Math.random().slice(2,8)`).
28
+ */
29
+ export function generateRunId(input: RunIdInput): string {
30
+ const ts = input.timestamp.toString(36);
31
+ const seq = input.sequence.toString(36).padStart(4, "0");
32
+ return `run-${ts}-${seq}`;
33
+ }
package/src/types.ts ADDED
@@ -0,0 +1,332 @@
1
+ /**
2
+ * Core runtime types for pi-dynamic-workflows.
3
+ *
4
+ * Mirrors the OpenSpec workflow-definition / workflow-runtime specs, with
5
+ * extension points reserved for the Claude Code coordination mechanisms that
6
+ * land in later tasks (deterministic sandbox, cache-key resume, per-agent
7
+ * abort). Pure TypeScript here — no external schema dependency yet; the
8
+ * TypeBox / Standard Schema adapter is introduced when step factories need
9
+ * runtime validation.
10
+ */
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Stage primitives (10 per implementation)
14
+ // ---------------------------------------------------------------------------
15
+
16
+ import type { ErrorCategory } from "./errors.ts";
17
+
18
+ export type StageType =
19
+ | "fan_out"
20
+ | "agent"
21
+ | "code"
22
+ | "log"
23
+ | "loop_until"
24
+ | "adversarial"
25
+ | "tournament"
26
+ | "classify_route"
27
+ | "sub_workflow"
28
+ | "loop_until_dry";
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Budget
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /**
35
+ * Per-run resource limits. These are SPAWN-GATE SOFT LIMITS, not hard kills:
36
+ * the runtime checks them before committing a new agent spawn (guardSpawn /
37
+ * guardBatch) and stops spawning new agents when exhausted, but an agent
38
+ * already in flight is allowed to complete — its spend may push cumulative
39
+ * usage past the cap. `maxDurationMs` is wall-clock from run inception.
40
+ *
41
+ * Task 6 note: originally a static bag; the live tracker is `BudgetPool`. */
42
+ export interface Budget {
43
+ readonly maxTokens?: number;
44
+ readonly maxDurationMs?: number;
45
+ readonly maxAgents?: number;
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Step statistics + result
50
+ // ---------------------------------------------------------------------------
51
+
52
+ export interface StepStats {
53
+ readonly tokens: number;
54
+ readonly cost: number;
55
+ readonly durationMs: number;
56
+ readonly agents: number;
57
+ readonly failures: number;
58
+ }
59
+
60
+ export interface StepResult<T = unknown> {
61
+ readonly id: string;
62
+ readonly type: StageType;
63
+ readonly status: "done" | "failed" | "skipped" | "running";
64
+ readonly results: T;
65
+ readonly stats: StepStats;
66
+ /** Present only for loop_until / adversarial / tournament. */
67
+ readonly iterations?: number;
68
+ /** A5: category of the terminal failure (dispatch-error for agent dispatch /
69
+ * subprocess failures; propagated from nested classify_route / sub_workflow
70
+ * children). Absent when the failure must not auto-retry — a code transform
71
+ * error or a terminal category (size-limit, determinism, …). */
72
+ readonly errorCategory?: ErrorCategory;
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Step context — inter-step references (spec: ctx.input + ctx.step(id))
77
+ // ---------------------------------------------------------------------------
78
+
79
+ export interface StepContext {
80
+ readonly input: unknown;
81
+ step(id: string): { results: unknown; stats: StepStats };
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Workflow definition (defineWorkflow is a typed identity function)
86
+ // ---------------------------------------------------------------------------
87
+
88
+ /**
89
+ * Concrete step payloads — a discriminated union on `type`. All ten step
90
+ * types are implemented in the runner (src/runner/stage-executor.ts): the four
91
+ * core primitives (agent/code/fan_out/loop_until) plus three composites
92
+ * (adversarial/tournament/classify_route) expanded onto them.
93
+ */
94
+ export interface StepBase {
95
+ readonly id: string;
96
+ readonly retry?: StepRetry;
97
+ /** Budget-exhaustion policy for this step. "throw" (default) aborts the run with
98
+ * BudgetExceededError; "null" returns null for this step so siblings/downstream
99
+ * can continue (the run result records degraded steps). The atomic reserve
100
+ * still enforces the agent-count cap regardless of this setting. */
101
+ readonly onBudgetExhaust?: "throw" | "null";
102
+ }
103
+
104
+ /** agent: one LLM call (dispatched via the injectable AgentDispatch). */
105
+ export interface AgentStep extends StepBase {
106
+ readonly type: "agent";
107
+ readonly prompt: string | ((ctx: StepContext) => string | Promise<string>);
108
+ readonly model?: string;
109
+ readonly tools?: readonly string[];
110
+ readonly systemPrompt?: string;
111
+ }
112
+
113
+ /** code: a pure deterministic transform — no LLM, no dispatch, not cached. */
114
+ export interface CodeStep extends StepBase {
115
+ readonly type: "code";
116
+ readonly transform: (ctx: StepContext) => unknown | Promise<unknown>;
117
+ }
118
+
119
+ /** log: emit a free-form narrative line into the progress widget. Pure string,
120
+ * zero dispatch, zero tokens, not cached — same execution profile as `code`,
121
+ * but fires the onLog listener and renders as a distinct narrative line. */
122
+ export interface LogStep extends StepBase {
123
+ readonly type: "log";
124
+ readonly message: string | ((ctx: StepContext) => string | Promise<string>);
125
+ }
126
+
127
+ /** Per-item agent spec emitted by a fan_out step's `agent` factory. */
128
+ export interface FanOutItemSpec extends AgentOpts {
129
+ readonly prompt: string;
130
+ }
131
+
132
+ /** fan_out: parallel agents over a list, optional merge. */
133
+ export interface FanOutStep extends StepBase {
134
+ readonly type: "fan_out";
135
+ readonly over: (ctx: StepContext) => readonly unknown[];
136
+ readonly agent: (item: unknown, index: number, ctx: StepContext) => FanOutItemSpec;
137
+ /** Defaults to mapWithConcurrencyLimit's cap (MAX_CONCURRENCY). */
138
+ readonly parallelism?: number;
139
+ readonly merge?: (results: readonly unknown[], ctx: StepContext) => unknown | Promise<unknown>;
140
+ }
141
+
142
+ /** loop_until: iterate a body agent until `until` / maxIterations / budget. */
143
+ export interface LoopUntilStep extends StepBase {
144
+ readonly type: "loop_until";
145
+ readonly prompt: (ctx: StepContext, iteration: number) => string | Promise<string>;
146
+ readonly until: (ctx: StepContext, iteration: number) => boolean;
147
+ readonly maxIterations?: number;
148
+ readonly model?: string;
149
+ readonly tools?: readonly string[];
150
+ readonly systemPrompt?: string;
151
+ }
152
+
153
+ /** Shared agent options that affect output (and therefore the cache signature). */
154
+ export interface AgentOpts {
155
+ readonly model?: string;
156
+ readonly tools?: readonly string[];
157
+ readonly systemPrompt?: string;
158
+ }
159
+
160
+ /** A single agent call: prompt + opts. Used by the composite steps. */
161
+ export interface AgentCallSpec extends AgentOpts {
162
+ readonly prompt: string | ((ctx: StepContext) => string | Promise<string>);
163
+ }
164
+
165
+ /** Composite patterns — expanded onto the core primitives. */
166
+ /** adversarial: produce one candidate, N judges grade it against a rubric, tally. */
167
+ export interface AdversarialStep extends StepBase {
168
+ readonly type: "adversarial";
169
+ readonly produce: AgentCallSpec;
170
+ readonly rubric: readonly string[];
171
+ /** Number of independent judges. Default 3. */
172
+ readonly judges?: number;
173
+ readonly judge?: AgentOpts;
174
+ /** Min passing judges for the candidate to pass. Default = majority (ceil(judges/2)). */
175
+ readonly minPass?: number;
176
+ }
177
+ /** tournament: N distinct candidates, M judges rank them, pick a winner. */
178
+ export interface TournamentStep extends StepBase {
179
+ readonly type: "tournament";
180
+ readonly candidates: number;
181
+ readonly produce: AgentCallSpec;
182
+ readonly judges: number;
183
+ readonly judge?: AgentOpts;
184
+ }
185
+ /** classify_route: classify input → category, then run the matching route's steps. */
186
+ export interface ClassifyRouteStep extends StepBase {
187
+ readonly type: "classify_route";
188
+ readonly classifier: AgentCallSpec;
189
+ readonly routes: Readonly<Record<string, readonly StepDefinition[]>>;
190
+ /** Route used when the category matches no key. */
191
+ readonly fallback?: readonly StepDefinition[];
192
+ }
193
+
194
+ /** loop_until_dry: keep spawning discovery agents until K consecutive rounds
195
+ * return nothing new. CC's "loop-until-dry" pattern for unknown-size discovery.
196
+ * Each round's output is parsed via parseFirstJson and deduplicated via keyOf. */
197
+ export interface LoopUntilDryStep extends StepBase {
198
+ readonly type: "loop_until_dry";
199
+ /** Agent prompt builder — receives the known set so it can ask for new items. */
200
+ readonly prompt: (ctx: StepContext, known: unknown[]) => string | Promise<string>;
201
+ /** Stable key for deduplication. */
202
+ readonly keyOf: (item: unknown) => string;
203
+ /** Merge fresh items into the known set (default = known.concat(fresh)). */
204
+ readonly merge?: (known: unknown[], fresh: unknown[]) => unknown[];
205
+ /** Consecutive dry rounds required to stop. Default 2. */
206
+ readonly dryThreshold?: number;
207
+ /** Hard cap on rounds. Default 10. */
208
+ readonly maxRounds?: number;
209
+ /** Optional completeness critic: after dryThreshold is reached, run one
210
+ * final critic agent asking "what's missing?". If the critic returns new
211
+ * items, the loop restarts; otherwise it stops. Mirrors CC's completeness-
212
+ * critic pattern (final agent asks "what's missing — modality not run,
213
+ * claim unverified, source unread?"). */
214
+ readonly critic?: {
215
+ readonly prompt: (ctx: StepContext, known: unknown[]) => string | Promise<string>;
216
+ };
217
+ }
218
+
219
+ /** sub_workflow: run a nested child workflow inline. Shares parent journal, budget, and registry.
220
+ * CC's `workflow(nameOrRef, args)` pattern — one level of nesting supported. */
221
+ export interface SubWorkflowStep extends StepBase {
222
+ readonly type: "sub_workflow";
223
+ /** The child workflow definition (inline or referenced). */
224
+ readonly workflow: WorkflowDefinition;
225
+ /** Input passed to the child workflow's ctx.input. If a function, called with parent ctx (may be async). */
226
+ readonly input?: unknown | ((ctx: StepContext) => unknown | Promise<unknown>);
227
+ /** If true (default), child agents count against parent budget. If false, child has its own pool. */
228
+ readonly inheritBudget?: boolean;
229
+ }
230
+
231
+ export type StepDefinition =
232
+ | AgentStep
233
+ | CodeStep
234
+ | LogStep
235
+ | FanOutStep
236
+ | LoopUntilStep
237
+ | AdversarialStep
238
+ | TournamentStep
239
+ | ClassifyRouteStep
240
+ | SubWorkflowStep
241
+ | LoopUntilDryStep;
242
+
243
+ export interface StepRetry {
244
+ readonly maxRetries: number;
245
+ /** Re-executing an upstream stage as part of a retry cycle is not yet supported
246
+ * (the list-walk runner only re-runs the failing step). Tracked for a future
247
+ * spec revision; add `retryStage` back with implementation when ready. */
248
+ }
249
+
250
+ /** A phase groups related steps for UI progress-tree rendering.
251
+ * Steps not assigned to any phase render under an implicit default group. */
252
+ export interface PhaseDefinition {
253
+ readonly title: string;
254
+ readonly detail?: string;
255
+ readonly stepIds: readonly string[];
256
+ /** Optional model override for all agents in this phase. */
257
+ readonly model?: string;
258
+ }
259
+
260
+ export interface WorkflowDefinition {
261
+ readonly name: string;
262
+ readonly description?: string;
263
+ readonly steps: readonly StepDefinition[];
264
+ readonly budget?: Budget;
265
+ /** Optional phase groupings for progress-tree UI rendering. */
266
+ readonly phases?: readonly PhaseDefinition[];
267
+ }
268
+
269
+ /** Typed identity helper: gives a workflow literal full union checking. */
270
+ export function defineWorkflow(wf: WorkflowDefinition): WorkflowDefinition {
271
+ return wf;
272
+ }
273
+
274
+ // ---------------------------------------------------------------------------
275
+ // Run result (Task 7 runner surface)
276
+ // ---------------------------------------------------------------------------
277
+
278
+ export type RunStatus = "completed" | "failed" | "aborted";
279
+
280
+ export interface RunResult {
281
+ readonly runId: string;
282
+ readonly status: RunStatus;
283
+ /** One entry per executed step, in order. Shorter than workflow.steps on abort/failure. */
284
+ readonly steps: readonly StepResult[];
285
+ /** Aggregated across executed steps. */
286
+ readonly stats: StepStats;
287
+ readonly journalFile?: string;
288
+ readonly error?: string;
289
+ /** A5: category of the terminal error that ended the run (when the failure
290
+ * originated from a categorized WorkflowError — budget-exceeded, size-limit,
291
+ * control-chars, policy-gate, determinism, …). Absent when the run failed
292
+ * with an uncategorized step error or aborted. */
293
+ readonly errorCategory?: ErrorCategory;
294
+ /** A3: step ids that returned null under an `onBudgetExhaust: "null"` policy
295
+ * (present only when any step degraded). Lets callers distinguish a clean
296
+ * completion from one with holes. */
297
+ readonly degradedSteps?: readonly string[];
298
+ /** Staged-resume info: how many agents hit the cache from a previous run. */
299
+ readonly resume?: {
300
+ readonly cachedHits: number;
301
+ readonly cachedTotal: number;
302
+ readonly previousRunId?: string;
303
+ };
304
+ }
305
+
306
+ // ---------------------------------------------------------------------------
307
+ // Claude Code fusion — reserved types (land in Tasks 3–5)
308
+ // ---------------------------------------------------------------------------
309
+
310
+ /**
311
+ * Stable cache key for one agent invocation: `sha256(callKey + NUL + normalizedOpts)`.
312
+ * Normalization keeps only [schema, model, effort, isolation, agentType], drops
313
+ * functions, sorts object keys — so identical (prompt, opts) produces identical
314
+ * keys across runs (the determinism premise for resume).
315
+ *
316
+ * Task 4: the journal gains `{type:"result", key, ...}` rows and resume replays
317
+ * cached agent calls without re-dispatch.
318
+ */
319
+ export type CacheKey = string;
320
+
321
+ /** Unique id for a single agent call within a run. */
322
+ export type AgentCallId = string;
323
+
324
+ /**
325
+ * Per-agent abort registry: `Map<callId, AbortController>`.
326
+ *
327
+ * Claude Code keeps one AbortController per in-flight agent so retry/skip can
328
+ * target a single call without disturbing its batch siblings. In pi each agent
329
+ * is a subprocess, so Task 5 pairs this map with `Map<callId, ChildProcess>`
330
+ * in src/agent/dispatch.ts and translates abort → SIGTERM on exactly one process.
331
+ */
332
+ export type AgentAbortMap = Map<AgentCallId, AbortController>;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Shared phase-grouping logic (C1+D1) — used by the live progress widget
3
+ * (index.ts) and the `/wf-inspect` view (src/inspect.ts) so both render with
4
+ * identical grouping. Extracted as a pure function so it is unit-testable.
5
+ *
6
+ * Declared phases carry a header; ungrouped items collect into a header-less
7
+ * default group interleaved at declaration order. When no phases are declared
8
+ * (or none match), the whole item list is one default group (flat render).
9
+ */
10
+
11
+ export interface PhaseDef {
12
+ readonly title: string;
13
+ readonly detail?: string;
14
+ readonly stepIds: readonly string[];
15
+ }
16
+
17
+ export interface RenderGroup<T> {
18
+ readonly kind: "phase" | "default";
19
+ readonly title?: string;
20
+ readonly detail?: string;
21
+ readonly items: T[];
22
+ }
23
+
24
+ export function buildRenderGroups<T>(
25
+ items: readonly T[],
26
+ getId: (item: T) => string,
27
+ phases?: readonly PhaseDef[],
28
+ ): RenderGroup<T>[] {
29
+ const stepPhase = new Map<string, { title: string; detail?: string }>();
30
+ if (phases && phases.length > 0) {
31
+ for (const ph of phases) for (const sid of ph.stepIds) stepPhase.set(sid, { title: ph.title, detail: ph.detail });
32
+ }
33
+ const groups: RenderGroup<T>[] = [];
34
+ for (const item of items) {
35
+ const ph = stepPhase.get(getId(item));
36
+ const key = ph ? `phase:${ph.title}` : "default";
37
+ const last = groups[groups.length - 1];
38
+ const lastKey = last ? (last.kind === "phase" ? `phase:${last.title}` : "default") : "";
39
+ if (last && lastKey === key) last.items.push(item);
40
+ else groups.push(ph ? { kind: "phase", title: ph.title, detail: ph.detail, items: [item] } : { kind: "default", items: [item] });
41
+ }
42
+ return groups;
43
+ }