@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.
- package/LICENSE +21 -0
- package/README.md +449 -0
- package/api.ts +557 -0
- package/audit.ts +217 -0
- package/built-ins.ts +65 -0
- package/command.ts +137 -0
- package/docs/cover.png +0 -0
- package/docs/cover.svg +120 -0
- package/docs/workflow-authoring.md +629 -0
- package/docs/workflow-basics.md +122 -0
- package/docs-protocol.ts +106 -0
- package/fanout.ts +96 -0
- package/host.ts +97 -0
- package/index.ts +230 -0
- package/internal-utils.ts +69 -0
- package/internal.ts +27 -0
- package/layers.ts +33 -0
- package/lifecycle.ts +274 -0
- package/load/cache.test.ts +82 -0
- package/load/cache.ts +40 -0
- package/load/index.ts +159 -0
- package/load/merge.ts +136 -0
- package/load/normalize.ts +73 -0
- package/load/paths.ts +32 -0
- package/load/resolve-default.ts +43 -0
- package/load/shape-guards.test.ts +74 -0
- package/load/shape-guards.ts +42 -0
- package/messages.ts +185 -0
- package/outcomes/collectors/directory-path.test.ts +64 -0
- package/outcomes/collectors/directory-path.ts +40 -0
- package/outcomes/collectors/index.ts +21 -0
- package/outcomes/collectors/tool-call.test.ts +110 -0
- package/outcomes/collectors/tool-call.ts +63 -0
- package/outcomes/collectors/transcript-path.test.ts +70 -0
- package/outcomes/collectors/transcript-path.ts +53 -0
- package/outcomes/collectors/union.test.ts +59 -0
- package/outcomes/collectors/union.ts +55 -0
- package/outcomes/collectors/url.test.ts +67 -0
- package/outcomes/collectors/url.ts +45 -0
- package/outcomes/collectors/workspace-diff.test.ts +107 -0
- package/outcomes/collectors/workspace-diff.ts +123 -0
- package/outcomes/git-commit.test.ts +194 -0
- package/outcomes/git-commit.ts +192 -0
- package/outcomes/index.ts +22 -0
- package/outcomes/parsers/index.ts +11 -0
- package/outcomes/parsers/json-body.test.ts +80 -0
- package/outcomes/parsers/json-body.ts +50 -0
- package/outcomes/side-effect.ts +26 -0
- package/output-spec.ts +170 -0
- package/output.ts +98 -0
- package/package.json +83 -0
- package/preview.ts +120 -0
- package/routing.ts +79 -0
- package/runner/chain-advance.ts +185 -0
- package/runner/index.ts +7 -0
- package/runner/runner.ts +356 -0
- package/runner/script-stage.ts +240 -0
- package/runner/stage-lifecycle.ts +447 -0
- package/sessions/extraction.ts +297 -0
- package/sessions/index.ts +7 -0
- package/sessions/sessions.ts +269 -0
- package/sessions/spawn.ts +135 -0
- package/state/index.ts +27 -0
- package/state/paths.ts +46 -0
- package/state/reads.ts +190 -0
- package/state/state.ts +115 -0
- package/state/writes.ts +58 -0
- package/transcript.ts +156 -0
- package/triggers.ts +27 -0
- package/typebox-adapter.ts +48 -0
- package/types.ts +237 -0
- package/validate-output.ts +120 -0
- 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
|
+
}
|