@juicesharp/rpiv-workflow 1.14.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.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +449 -0
  3. package/api.ts +557 -0
  4. package/audit.ts +217 -0
  5. package/built-ins.ts +65 -0
  6. package/command.ts +137 -0
  7. package/docs/cover.png +0 -0
  8. package/docs/cover.svg +120 -0
  9. package/docs/workflow-authoring.md +629 -0
  10. package/docs/workflow-basics.md +122 -0
  11. package/docs-protocol.ts +106 -0
  12. package/fanout.ts +96 -0
  13. package/host.ts +97 -0
  14. package/index.ts +230 -0
  15. package/internal-utils.ts +69 -0
  16. package/internal.ts +27 -0
  17. package/layers.ts +33 -0
  18. package/lifecycle.ts +274 -0
  19. package/load/cache.test.ts +82 -0
  20. package/load/cache.ts +40 -0
  21. package/load/index.ts +159 -0
  22. package/load/merge.ts +136 -0
  23. package/load/normalize.ts +73 -0
  24. package/load/paths.ts +32 -0
  25. package/load/resolve-default.ts +43 -0
  26. package/load/shape-guards.test.ts +74 -0
  27. package/load/shape-guards.ts +42 -0
  28. package/messages.ts +185 -0
  29. package/outcomes/collectors/directory-path.test.ts +64 -0
  30. package/outcomes/collectors/directory-path.ts +40 -0
  31. package/outcomes/collectors/index.ts +21 -0
  32. package/outcomes/collectors/tool-call.test.ts +110 -0
  33. package/outcomes/collectors/tool-call.ts +63 -0
  34. package/outcomes/collectors/transcript-path.test.ts +70 -0
  35. package/outcomes/collectors/transcript-path.ts +53 -0
  36. package/outcomes/collectors/union.test.ts +59 -0
  37. package/outcomes/collectors/union.ts +55 -0
  38. package/outcomes/collectors/url.test.ts +67 -0
  39. package/outcomes/collectors/url.ts +45 -0
  40. package/outcomes/collectors/workspace-diff.test.ts +107 -0
  41. package/outcomes/collectors/workspace-diff.ts +123 -0
  42. package/outcomes/git-commit.test.ts +194 -0
  43. package/outcomes/git-commit.ts +192 -0
  44. package/outcomes/index.ts +22 -0
  45. package/outcomes/parsers/index.ts +11 -0
  46. package/outcomes/parsers/json-body.test.ts +80 -0
  47. package/outcomes/parsers/json-body.ts +50 -0
  48. package/outcomes/side-effect.ts +26 -0
  49. package/output-spec.ts +170 -0
  50. package/output.ts +98 -0
  51. package/package.json +83 -0
  52. package/preview.ts +120 -0
  53. package/routing.ts +79 -0
  54. package/runner/chain-advance.ts +185 -0
  55. package/runner/index.ts +7 -0
  56. package/runner/runner.ts +356 -0
  57. package/runner/script-stage.ts +240 -0
  58. package/runner/stage-lifecycle.ts +447 -0
  59. package/sessions/extraction.ts +297 -0
  60. package/sessions/index.ts +7 -0
  61. package/sessions/sessions.ts +269 -0
  62. package/sessions/spawn.ts +135 -0
  63. package/state/index.ts +27 -0
  64. package/state/paths.ts +46 -0
  65. package/state/reads.ts +190 -0
  66. package/state/state.ts +115 -0
  67. package/state/writes.ts +58 -0
  68. package/transcript.ts +156 -0
  69. package/triggers.ts +27 -0
  70. package/typebox-adapter.ts +48 -0
  71. package/types.ts +237 -0
  72. package/validate-output.ts +120 -0
  73. package/validate-workflow.ts +491 -0
package/transcript.ts ADDED
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Branch-entry shape + predicates. `sessionManager.getBranch()` returns a
3
+ * discriminated union from pi-coding-agent whose internal variants aren't
4
+ * all re-exported; `readBranch(ctx)` is the single boundary that applies
5
+ * the `as unknown as` cast — every consumer in the workflow module goes
6
+ * through it.
7
+ *
8
+ * No artifact-path scanning lives here — discovery is the collector's
9
+ * job (see `output-spec.ts:ArtifactCollector`). Collectors that scan the
10
+ * transcript (`transcriptPathCollector`, `urlCollector`, `toolCallCollector`,
11
+ * …) walk this shape themselves; `lastMatchInBranch` is the shared
12
+ * "find the last regex match in assistant text" helper they reuse.
13
+ */
14
+
15
+ /** Mirror of pi-ai's StopReason union — values pi attaches to AssistantMessage. */
16
+ export type StopReason = "stop" | "length" | "toolUse" | "error" | "aborted";
17
+
18
+ /**
19
+ * Normalised stop signal: `StopReason` plus `"noResponse"` for branches with
20
+ * no assistant message at all (pi never set a reason because the model never
21
+ * spoke). Pre-stopReason assistant messages collapse to `"stop"`.
22
+ */
23
+ export type StopSignal = StopReason | "noResponse";
24
+
25
+ /** Single chokepoint that maps (hasAssistantMessage, lastAssistantStopReason) → StopSignal. */
26
+ export function classifyStop(branch: BranchEntry[], offsetStart?: number): StopSignal {
27
+ if (!hasAssistantMessage(branch, offsetStart)) return "noResponse";
28
+ return lastAssistantStopReason(branch, offsetStart) ?? "stop";
29
+ }
30
+
31
+ /**
32
+ * One content part inside an assistant message. Pi's internal union
33
+ * carries more variants than these two; we model the ones collectors
34
+ * walk over and let unknown parts pass through structurally (every
35
+ * field besides `type` is optional).
36
+ *
37
+ * - `text` parts carry user-visible markdown via `text`.
38
+ * - `tool_use` parts carry a tool invocation: `name` + `input` (the
39
+ * JSON object the agent called the tool with).
40
+ */
41
+ export type BranchContentPart = {
42
+ type: string;
43
+ text?: string;
44
+ name?: string;
45
+ input?: Record<string, unknown>;
46
+ };
47
+
48
+ export type BranchEntry = {
49
+ type: string;
50
+ message?: {
51
+ role?: string;
52
+ content?: BranchContentPart[];
53
+ stopReason?: StopReason;
54
+ };
55
+ };
56
+
57
+ /**
58
+ * Read the current branch from `ctx.sessionManager`. SDK returns a discriminated
59
+ * union with private discriminators; the cast is unavoidable but must live in
60
+ * one place — calling `getBranch()` directly elsewhere bypasses the module's
61
+ * type discipline.
62
+ *
63
+ * Tracked: when `@earendil-works/pi-coding-agent` exposes a public
64
+ * `Branch.Entry` (or equivalent) type, switch to it and delete the local
65
+ * `BranchEntry` narrowing above. Until then this cast is the single
66
+ * documented coupling point to Pi's internal branch shape.
67
+ */
68
+ export function readBranch(ctx: { sessionManager: { getBranch(): unknown } }): BranchEntry[] {
69
+ return ctx.sessionManager.getBranch() as unknown as BranchEntry[];
70
+ }
71
+
72
+ export function hasAssistantMessage(branch: BranchEntry[], offsetStart?: number): boolean {
73
+ const start = Math.max(offsetStart ?? 0, 0);
74
+ for (let i = start; i < branch.length; i++) {
75
+ const e = branch[i]!;
76
+ if (e.type === "message" && e.message?.role === "assistant") return true;
77
+ }
78
+ return false;
79
+ }
80
+
81
+ /** Undefined for empty branches or pre-stopReason assistant messages. */
82
+ export function lastAssistantStopReason(branch: BranchEntry[], offsetStart?: number): StopReason | undefined {
83
+ const start = Math.max(offsetStart ?? 0, 0);
84
+ for (let i = branch.length - 1; i >= start; i--) {
85
+ const entry = branch[i]!;
86
+ if (entry.type !== "message") continue;
87
+ if (entry.message?.role !== "assistant") continue;
88
+ return entry.message.stopReason;
89
+ }
90
+ return undefined;
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Shared collector building blocks
95
+ // ---------------------------------------------------------------------------
96
+
97
+ /**
98
+ * Scan assistant text blocks in reverse for `pattern`; return the last
99
+ * match. Pure — no I/O. Used by `transcriptPathCollector`, `urlCollector`,
100
+ * and any author building a transcript-scan collector.
101
+ *
102
+ * Reverse scan because the agent's final message is usually where the
103
+ * actionable path/URL lands; iterating from the tail short-circuits on
104
+ * the first hit. Thinking/tool_use blocks are ignored — only spoken
105
+ * `text` parts count.
106
+ *
107
+ * `offsetStart` — continue-policy stages pass the prior branch length
108
+ * so prior-stage entries don't leak into the result.
109
+ *
110
+ * The pattern's flags are caller-owned. `g` makes `String.match` return
111
+ * every occurrence (this helper takes the last); without `g`, only the
112
+ * first match in each block is considered. Both shapes work.
113
+ */
114
+ export function lastMatchInBranch(branch: BranchEntry[], pattern: RegExp, offsetStart?: number): string | undefined {
115
+ const start = Math.max(offsetStart ?? 0, 0);
116
+ for (let i = branch.length - 1; i >= start; i--) {
117
+ const entry = branch[i]!;
118
+ if (entry.type !== "message" || entry.message?.role !== "assistant") continue;
119
+ const content = entry.message.content;
120
+ if (!Array.isArray(content)) continue;
121
+ for (let j = content.length - 1; j >= 0; j--) {
122
+ const part = content[j]!;
123
+ if (part.type === "text" && typeof part.text === "string") {
124
+ const matches = part.text.match(pattern);
125
+ if (matches && matches.length > 0) return matches[matches.length - 1];
126
+ }
127
+ }
128
+ }
129
+ return undefined;
130
+ }
131
+
132
+ /**
133
+ * Yield every tool_use part the assistant emitted in branch order
134
+ * (forward — the typical "what did the agent do during this stage?"
135
+ * scan direction). Pure — no I/O. `toolCallCollector` walks this to
136
+ * apply the author's match/toArtifact pair.
137
+ *
138
+ * `offsetStart` — continue-policy stages pass the prior branch length.
139
+ */
140
+ export function* iterToolUses(
141
+ branch: BranchEntry[],
142
+ offsetStart?: number,
143
+ ): Generator<{ name: string; input: Record<string, unknown> }> {
144
+ const start = Math.max(offsetStart ?? 0, 0);
145
+ for (let i = start; i < branch.length; i++) {
146
+ const entry = branch[i]!;
147
+ if (entry.type !== "message" || entry.message?.role !== "assistant") continue;
148
+ const content = entry.message.content;
149
+ if (!Array.isArray(content)) continue;
150
+ for (const part of content) {
151
+ if (part.type !== "tool_use") continue;
152
+ if (typeof part.name !== "string") continue;
153
+ yield { name: part.name, input: part.input ?? {} };
154
+ }
155
+ }
156
+ }
package/triggers.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Identifies what triggered a workflow run. Recorded in the JSONL
3
+ * header (`WorkflowHeader.trigger`), threaded into every lifecycle
4
+ * event (`LifecycleContext.trigger`), and surfaced on past-run
5
+ * enumeration (`RunSummary.trigger`).
6
+ *
7
+ * `/wf` sets `{ kind: "command", name: "wf" }`. Programmatic embedders
8
+ * default to `{ kind: "programmatic" }`. External trigger sources
9
+ * (webhook, cron, sibling-extension spawn) set `{ kind: "external",
10
+ * source, ref? }` so post-hoc readers can filter / route by origin.
11
+ *
12
+ * `meta` is an open escape hatch for trigger-specific payload (webhook
13
+ * headers, cron expression, ticket id). Kept untyped to avoid growing
14
+ * the union per consumer.
15
+ *
16
+ * Concurrency note: Pi is single-active-session. External triggers
17
+ * (cron, webhook, sibling-extension spawn) MUST gate their own
18
+ * spawning if a run is already in flight — the runtime does not
19
+ * enforce a process-wide mutex.
20
+ */
21
+ export type RunTrigger =
22
+ | { kind: "command"; name: string; meta?: Record<string, unknown> }
23
+ | { kind: "programmatic"; source?: string; meta?: Record<string, unknown> }
24
+ | { kind: "external"; source: string; ref?: string; meta?: Record<string, unknown> };
25
+
26
+ /** Default when `RunWorkflowOptions.trigger` is omitted. */
27
+ export const DEFAULT_TRIGGER: RunTrigger = { kind: "programmatic" };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Bridge between the TypeBox schemas the built-in workflows author with and
3
+ * the Standard Schema v1 interface that `validate-output.ts` consumes.
4
+ *
5
+ * Why the bridge exists: `StageDef.outputSchema` / `inputSchema` are typed as
6
+ * `StandardSchemaV1` so users can author with Zod, Valibot, ArkType, or any
7
+ * other library that implements the `~standard` property. TypeBox v1.1.38
8
+ * doesn't ship with `~standard` natively (as of this commit); when a future
9
+ * version does, this adapter can be deleted and built-in.ts can pass
10
+ * `Type.Object(...)` results directly.
11
+ *
12
+ * Validation surface kept intentionally tight: only the runtime + path
13
+ * shape `validate-output.ts` needs. `expected`/`actual` diagnostic fields from
14
+ * the legacy TypeBox failure shape become best-effort placeholders, since
15
+ * Standard Schema's `issues` only carries `message` + `path`.
16
+ */
17
+
18
+ import type { Static, TSchema } from "typebox";
19
+ import { Value } from "typebox/value";
20
+ import type { StageSchema } from "./api.js";
21
+
22
+ /**
23
+ * Wrap a TypeBox schema to satisfy `StageSchema` (Standard Schema v1). The
24
+ * returned object is structurally a Standard Schema; downstream code
25
+ * (`validateOutputData`) consults `~standard.validate` and never sees
26
+ * the underlying TypeBox value.
27
+ *
28
+ * Generic over the input schema `S` so the parsed type (`Static<S>`) flows
29
+ * through `StageSchema<unknown, Static<S>>` and into the surrounding
30
+ * `StageDef<TIn, TOut>` — predicate bodies + downstream stage consumers can
31
+ * read `output.data` with the parsed type instead of `unknown`.
32
+ */
33
+ export function typeboxSchema<S extends TSchema>(schema: S): StageSchema<unknown, Static<S>> {
34
+ return {
35
+ "~standard": {
36
+ version: 1,
37
+ vendor: "typebox",
38
+ validate: (value: unknown) => {
39
+ if (Value.Check(schema, value)) return { value };
40
+ const issues = [...Value.Errors(schema, value)].map((err) => ({
41
+ message: err.message || `${err.keyword} validation failed at ${err.instancePath || "root"}`,
42
+ path: err.instancePath ? err.instancePath.split("/").filter(Boolean) : undefined,
43
+ }));
44
+ return { issues };
45
+ },
46
+ },
47
+ };
48
+ }
package/types.ts ADDED
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Runtime types. Three nouns flow through the workflow runtime:
3
+ *
4
+ * - `RunContext` — per-run carry (cwd, runId, workflow, state, visited,
5
+ * continueHost, registeredSkills, maxBackwardJumps). Read by every
6
+ * layer; mutated only by the runner.
7
+ * - `RunState` — mutable bookkeeping (output, counters, telemetry,
8
+ * termination). Read by every layer; mutated by the runner + the audit
9
+ * layer. Always read the chain's primary artifact via
10
+ * `currentPrimaryArtifact(state)` (internal-utils.ts) — it prefers
11
+ * `output.artifacts[0]` and falls back to `fallbackPrimaryArtifact`.
12
+ * - `RunnerCtx` — Pi command ctx augmented with idle-await guarantees;
13
+ * threaded from `withSession` callbacks down through stage/phase helpers.
14
+ *
15
+ * Per-stage / per-phase sessions extend a shared `SessionContext` base
16
+ * (cwd, runId, state, prompt, skill). The audit layer pins its dependency
17
+ * on this base structurally via `AuditCtx = Pick<SessionContext, ...>`.
18
+ *
19
+ * Lives apart from runner.ts / sessions.ts so both can reference the same
20
+ * shapes without a runtime import cycle (type-only refs back via this
21
+ * module are cycle-free).
22
+ */
23
+
24
+ import type { StageDef, Workflow } from "./api.js";
25
+ import type { Artifact } from "./handle.js";
26
+ import type { WorkflowContext, WorkflowHost } from "./host.js";
27
+ import type { LifecycleDispatcher } from "./lifecycle.js";
28
+ import type { Output } from "./output.js";
29
+ import type { RunTrigger } from "./triggers.js";
30
+
31
+ /**
32
+ * Per-stage runtime ctx. Alias for `WorkflowContext` (the port) —
33
+ * kept as a domain noun ("the runner's command ctx") so consumers can
34
+ * read stage/phase code without learning the port name. Identical
35
+ * shape; rename-only.
36
+ */
37
+ export type RunnerCtx = WorkflowContext;
38
+
39
+ /** Mutable per-run bookkeeping threaded through the chain by reference. */
40
+ export interface RunState {
41
+ // ── Identity ────────────────────────────────────────────────────────
42
+ /** Frozen — the user's `/wf` argument. */
43
+ originalInput: string;
44
+
45
+ // ── Progress (hot paths — runner reads on every stage) ─────────────
46
+ /**
47
+ * Chain-input artifact — the rolling slot the next stage's prompt
48
+ * inherits as input. Updated ONLY by produces stages whose
49
+ * collector returned at least one artifact (the first becomes the new
50
+ * primary). Side-effect stages (commit, side-effect) record their own
51
+ * output but do not touch this slot — preserves the "commit
52
+ * inherits the prior chain's artifact" semantic without forcing
53
+ * side-effect collectors to re-emit the prior list.
54
+ *
55
+ * Reads must go through `currentPrimaryArtifact(state)`
56
+ * (internal-utils.ts); a direct read here is a hint of a missed
57
+ * accessor.
58
+ */
59
+ primaryArtifact: Artifact | undefined;
60
+ output: Output | undefined;
61
+ /**
62
+ * Named publish registry — `produces` stages APPEND their full `Output`
63
+ * envelope onto the slot keyed by `stage.outcome?.name ??
64
+ * stage.<record-key>` after each successful run. Slots are arrays so
65
+ * iteration history is preserved across backward-jump loops; the
66
+ * default read resolves to the most-recent entry (`array.at(-1)`).
67
+ * Multiple stages MAY share a slot on purpose — their outputs interleave
68
+ * in run order.
69
+ *
70
+ * Side-effect stages don't write to this slot. The slot is never
71
+ * cleared by `terminal()` either: it's an additive history channel
72
+ * orthogonal to the rolling `primaryArtifact`.
73
+ */
74
+ named: Record<string, Output[]>;
75
+ /** Stages whose JSONL row landed on disk. */
76
+ stagesCompleted: number;
77
+ /** Most recently allocated stageNumber. Advances on every recordStage call. */
78
+ lastAllocatedStageNumber: number;
79
+
80
+ // ── Telemetry (post-hoc only; not consulted by chain advancement) ──
81
+ telemetry: {
82
+ backwardJumps: number;
83
+ /**
84
+ * Routing rows whose JSONL append failed mid-run. The chain advanced
85
+ * past them (routing rows are write-only telemetry, not
86
+ * reconstruction inputs), but the final result envelope surfaces this
87
+ * so post-hoc readers can distinguish "deterministic edge — no row
88
+ * written by design" from "decision made — write was dropped." Empty
89
+ * in the common case.
90
+ */
91
+ droppedRoutingRows: Array<{ fromStageIndex: number; fromStage: string; decision: string }>;
92
+ };
93
+
94
+ // ── Termination (set once at end-of-run) ───────────────────────────
95
+ termination: {
96
+ success: boolean;
97
+ error: string | undefined;
98
+ };
99
+ }
100
+
101
+ /** Per-run context the chain carries from stage to stage. */
102
+ export interface RunContext {
103
+ cwd: string;
104
+ runId: string;
105
+ workflow: Workflow;
106
+ /**
107
+ * Upper bound for stage status display — count of stages reachable from
108
+ * `workflow.start`, computed once at run start. The actual stage count
109
+ * is path-dependent (a predicate edge may short-circuit), so this is
110
+ * the denominator users see; the numerator is the live stage index.
111
+ */
112
+ totalStages: number;
113
+ state: RunState;
114
+ /**
115
+ * Stage names already executed in this run. The backward-jump guard
116
+ * increments `state.telemetry.backwardJumps` on every re-entry; revise →
117
+ * implement loops legitimately revisit stages, but unbounded loops trip
118
+ * the cap.
119
+ */
120
+ visited: Set<string>;
121
+ /**
122
+ * Set of bare skill names registered with Pi at workflow start (e.g.
123
+ * "research", "blueprint" — the `skill:` prefix is stripped). Snapshot
124
+ * is taken ONCE in `runWorkflow` before any `ctx.newSession()` runs,
125
+ * because Pi invalidates `WorkflowHost` handles after a session
126
+ * replacement. `ensureSkillRegistered` consults this set instead of
127
+ * calling `host.getCommands()` mid-run.
128
+ *
129
+ * Undefined when no host was passed to `runWorkflow` (programmatic
130
+ * embedders that opt out of the skill-registration preflight — same
131
+ * fail-soft posture as the rest of the host-optional surface).
132
+ */
133
+ registeredSkills?: ReadonlySet<string>;
134
+ /**
135
+ * Pi `ExtensionAPI` handle, retained as the FALLBACK send-path for
136
+ * continue-policy stages — used only when the live inner ctx lacks
137
+ * `sendUserMessage` (i.e. the workflow's first stage is continue and
138
+ * the runtime is still on the outer command ctx). Everywhere else,
139
+ * `CONTINUE_HANDLER` prefers `ctx.sendUserMessage` because Pi marks
140
+ * this handle stale after the first `ctx.newSession()`. Touching it
141
+ * for anything other than the fallback path will throw "extension
142
+ * ctx is stale" on every workflow whose first stage is fresh.
143
+ *
144
+ * Read-only registry needs go through `registeredSkills` (snapshotted
145
+ * at workflow start). Continue-policy presence checks
146
+ * (`enforceSessionInvariants`) still gate on this field so the
147
+ * fallback path has a working host when the start-stage path needs it.
148
+ *
149
+ * Naming: deliberately NOT called `host`. Future code-readers see the
150
+ * field name and know the constraint without reading the JSDoc.
151
+ */
152
+ continueHost?: WorkflowHost;
153
+ maxBackwardJumps: number;
154
+ /** What triggered the run; defaulted at `runWorkflow` entry. */
155
+ trigger: RunTrigger;
156
+ /** Lifecycle event dispatcher — see `lifecycle.ts`. Threaded by reference. */
157
+ lifecycle: LifecycleDispatcher;
158
+ }
159
+
160
+ /**
161
+ * Per-stage / per-unit common base. Extended by `StageSession` and
162
+ * `FanoutSession`; consumed in pick form by `AuditCtx` (audit.ts) so the audit
163
+ * layer pins its dependency on this shape structurally instead of
164
+ * duplicating the field list.
165
+ *
166
+ * `stageName` is the workflow stage's record key — the value that lands
167
+ * in `WorkflowStage.stage`. `skill` is the Pi skill body the runner
168
+ * dispatches (`/skill:<skill>`). They're equal in the common case but
169
+ * diverge for aliased stages (`stages: { "implement-after-revise":
170
+ * acts({ skill: "implement" }) }` → stageName="implement-after-revise",
171
+ * skill="implement").
172
+ */
173
+ export interface SessionContext {
174
+ cwd: string;
175
+ runId: string;
176
+ state: RunState;
177
+ /** `/skill:<name> <args>`. */
178
+ prompt: string;
179
+ /** Workflow stage record key — JSONL `WorkflowStage.stage` value. */
180
+ stageName: string;
181
+ /** Pi skill body — `/skill:<skill>` dispatch + status-line label + JSONL `WorkflowStage.skill`. */
182
+ skill: string;
183
+ /** Shared lifecycle dispatcher. Threaded from `RunContext` so the audit layer can fire `onStageEnd` / `onStageError` / `onFanoutUnitEnd` without re-importing it. */
184
+ lifecycle: LifecycleDispatcher;
185
+ /**
186
+ * Read-only run identity passed to lifecycle callbacks. Captured at
187
+ * session construction (cwd + runId + workflow name + totalStages +
188
+ * trigger). Built once per run, reused.
189
+ */
190
+ runIdentity: {
191
+ workflow: string;
192
+ totalStages: number;
193
+ trigger: RunTrigger;
194
+ };
195
+ }
196
+
197
+ export interface StageSession extends SessionContext {
198
+ stage: StageDef;
199
+ /** 0-based stage index within this run — for status display + JSONL stage number. */
200
+ stageIndex: number;
201
+ /** Pre-stage snapshot value (undefined if the stage's `outcome` has no `snapshot`). */
202
+ snapshot: unknown;
203
+ /**
204
+ * Pi `ExtensionAPI` handle reserved for the continue-policy handler
205
+ * (`spawn.ts`). Required iff `stage.sessionPolicy === "continue"`.
206
+ * Same constraint as `RunContext.continueHost`: stale after any prior
207
+ * `ctx.newSession()`, so the runner MUST NOT read it for registry
208
+ * inspection. See `RunContext.continueHost` JSDoc.
209
+ */
210
+ continueHost?: WorkflowHost;
211
+ /** Only set for continue stages — branch slice offset. */
212
+ branchOffset?: number;
213
+ onFailure?: (ctx: RunnerCtx) => void;
214
+ onSuccess: (ctx: RunnerCtx, artifact: Artifact | undefined) => Promise<void>;
215
+ }
216
+
217
+ /**
218
+ * One unit of a fanout iteration. `label` is the user-supplied
219
+ * disambiguating tag from `FanoutUnit.label`; it's woven into the status
220
+ * line (`STATUS_FANOUT_UNIT`). The JSONL row's `stage` value (built by
221
+ * `fanoutRowStage`) prefixes the parent's `stageName` with `id ?? label`
222
+ * so the runner adds no implicit wording.
223
+ */
224
+ export interface FanoutSession extends SessionContext {
225
+ /** 1-based position within the run's fanout array — for halt diagnostics. */
226
+ unitIndex: number;
227
+ /** From `FanoutUnit.label` — already disambiguating, e.g. `"phase 2/5"`. */
228
+ label: string;
229
+ /**
230
+ * From `FanoutUnit.id` when set — stable audit identifier preferred
231
+ * over `label` in JSONL rows. Undefined when the user supplied none.
232
+ */
233
+ id?: string;
234
+ /** Parent stage's 0-based index. */
235
+ stageIndex: number;
236
+ onSuccess: (ctx: RunnerCtx) => Promise<void>;
237
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Output-data validation against a `StageSchema` (Standard Schema v1 under
3
+ * the hood). The schema-library boundary is `~standard.validate`; users may
4
+ * bring Zod / Valibot / ArkType / TypeBox (wrapped via
5
+ * `typebox-adapter.ts:typeboxSchema`).
6
+ */
7
+
8
+ import type { StageSchema } from "./api.js";
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Types
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export interface SchemaValidationFailure {
15
+ /** JSON-pointer-like path (instancePath); `"."` for root. */
16
+ path: string;
17
+ /** Schema keyword that failed. */
18
+ expected: string;
19
+ /** typeof / "array" / "null" / "undefined" of the offending value. */
20
+ actual: string;
21
+ message: string;
22
+ }
23
+
24
+ export interface ValidationResult {
25
+ valid: boolean;
26
+ failures: SchemaValidationFailure[];
27
+ }
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Constants
31
+ // ---------------------------------------------------------------------------
32
+
33
+ export const MIN_VALIDATION_RETRIES = 1;
34
+ export const MAX_VALIDATION_RETRIES = 3;
35
+ export const DEFAULT_VALIDATION_RETRIES = 1;
36
+
37
+ export const DEFAULT_VALIDATION_RETRY_TIMEOUT_MS = 5 * 60 * 1000;
38
+ export const MAX_VALIDATION_RETRY_TIMEOUT_MS = 30 * 60 * 1000;
39
+ export const MIN_VALIDATION_RETRY_TIMEOUT_MS = 1_000;
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Validation
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /**
46
+ * Returns the schema's verdict on `data`. Standard Schema permits `validate`
47
+ * to return synchronously or as a Promise; this function mirrors that —
48
+ * callers must `await` the result. Both seams that drive validation
49
+ * (`retryUntilValid` in extraction.ts and `ensureInputValid` in
50
+ * stage-lifecycle.ts) are async, so awaiting a sync value is free (one
51
+ * microtask) and async schemas (I/O-backed checks, async-by-default libs
52
+ * like ArkType) round-trip without a sync-only escape hatch.
53
+ */
54
+ export function validateOutputData(schema: StageSchema, data: unknown): ValidationResult | Promise<ValidationResult> {
55
+ const result = schema["~standard"].validate(data);
56
+ if (result instanceof Promise) {
57
+ return result.then((resolved) => buildResult(resolved, data));
58
+ }
59
+ return buildResult(result, data);
60
+ }
61
+
62
+ function buildResult(
63
+ result: {
64
+ readonly issues?: readonly {
65
+ readonly message: string;
66
+ readonly path?: readonly (PropertyKey | { readonly key: PropertyKey })[];
67
+ }[];
68
+ },
69
+ data: unknown,
70
+ ): ValidationResult {
71
+ if (!result.issues) {
72
+ return { valid: true, failures: [] };
73
+ }
74
+ const failures: SchemaValidationFailure[] = result.issues.map((issue) => {
75
+ const path = issue.path ? formatStandardPath(issue.path) : ".";
76
+ return {
77
+ path,
78
+ expected: "schema",
79
+ actual: describeType(resolveInstanceValue(data, path)),
80
+ message: issue.message,
81
+ };
82
+ });
83
+ return { valid: false, failures };
84
+ }
85
+
86
+ /** `["foo", 0, "bar"]` → `/foo/0/bar`; empty path → `"."`. */
87
+ function formatStandardPath(path: readonly (PropertyKey | { readonly key: PropertyKey })[]): string {
88
+ if (path.length === 0) return ".";
89
+ const segs: string[] = [];
90
+ for (const seg of path) {
91
+ if (typeof seg === "object" && seg !== null && "key" in seg) {
92
+ segs.push(String(seg.key));
93
+ } else {
94
+ segs.push(String(seg));
95
+ }
96
+ }
97
+ return `/${segs.join("/")}`;
98
+ }
99
+
100
+ function resolveInstanceValue(data: unknown, instancePath: string): unknown {
101
+ if (!instancePath || instancePath === "" || instancePath === ".") return data;
102
+ const segments = instancePath.split("/").slice(1);
103
+ let cur: unknown = data;
104
+ for (const seg of segments) {
105
+ if (cur === null || cur === undefined) return cur;
106
+ cur = (cur as Record<string, unknown>)[seg];
107
+ }
108
+ return cur;
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Helpers
113
+ // ---------------------------------------------------------------------------
114
+
115
+ function describeType(value: unknown): string {
116
+ if (value === null) return "null";
117
+ if (value === undefined) return "undefined";
118
+ if (Array.isArray(value)) return "array";
119
+ return typeof value;
120
+ }