@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
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skillless script stages — the runtime counterpart to
|
|
3
|
+
* `produces.script(...)`, `acts.script(...)`, `terminal.script(...)`.
|
|
4
|
+
*
|
|
5
|
+
* Where the skill path opens a Pi session and lets the agent emit work
|
|
6
|
+
* the runner then collects + parses, the script path calls a TS
|
|
7
|
+
* function and treats its return value AS the work. No session, no
|
|
8
|
+
* skill dispatch, no collector/parser pipeline — `def.run(scriptCtx)`
|
|
9
|
+
* returns `{ kind, artifacts, data }` (`produces.script`) or `void`
|
|
10
|
+
* (`acts.script` / `terminal.script`), and the runner stamps `meta`,
|
|
11
|
+
* persists the JSONL row, advances the rolling primary slot, fires
|
|
12
|
+
* lifecycle events, and recurses through `advanceChain`.
|
|
13
|
+
*
|
|
14
|
+
* The fan-out of responsibilities mirrors `runStageSession` →
|
|
15
|
+
* `recordStageSuccess` for skill stages, deliberately so the audit row
|
|
16
|
+
* shape, the lifecycle fire order (`onStageStart` →
|
|
17
|
+
* `onStageRetry`* → `onStageEnd` | `onStageError`), and the
|
|
18
|
+
* primary-artifact advance behaviour stay aligned across the two
|
|
19
|
+
* stage kinds.
|
|
20
|
+
*
|
|
21
|
+
* Invariants this file relies on (enforced at load time by
|
|
22
|
+
* `validateWorkflow:checkScriptStageInvariants`):
|
|
23
|
+
* - `stage.skill` is unset.
|
|
24
|
+
* - `stage.outcome` is unset (no collector to run).
|
|
25
|
+
* - `stage.fanout` is unset (the runner's per-unit machinery doesn't
|
|
26
|
+
* apply; authors write their own loop inside `run()`).
|
|
27
|
+
* - `stage.sessionPolicy !== "continue"` (no session to continue).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { ScriptContext, StageDef } from "../api.js";
|
|
31
|
+
import { nowIso, recordStage, recordTerminalFailure } from "../audit.js";
|
|
32
|
+
import type { Artifact } from "./../handle.js";
|
|
33
|
+
import { resolvePublishName } from "../internal-utils.js";
|
|
34
|
+
import { scriptStageRef } from "../lifecycle.js";
|
|
35
|
+
import {
|
|
36
|
+
ERR_AUDIT_WRITE_FAILED,
|
|
37
|
+
ERR_SCRIPT_THREW,
|
|
38
|
+
ERR_VALIDATION_FAILED,
|
|
39
|
+
MSG_AUDIT_WRITE_FAILED,
|
|
40
|
+
MSG_SCRIPT_THREW,
|
|
41
|
+
MSG_STAGE_COMPLETE,
|
|
42
|
+
MSG_VALIDATION_EXHAUSTED,
|
|
43
|
+
STATUS_KEY,
|
|
44
|
+
STATUS_STAGE,
|
|
45
|
+
} from "../messages.js";
|
|
46
|
+
import { finalizeOutput, type Output } from "../output.js";
|
|
47
|
+
import type { RunContext, RunnerCtx, RunState } from "../types.js";
|
|
48
|
+
import { DEFAULT_VALIDATION_RETRIES, validateOutputData } from "../validate-output.js";
|
|
49
|
+
import { advanceChain } from "./chain-advance.js";
|
|
50
|
+
import { lifecycleCtxFor } from "./runner.js";
|
|
51
|
+
import type { ResolvedStage } from "./stage-lifecycle.js";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Drive a script stage: lifecycle-fire `onStageStart`, retry-loop the
|
|
55
|
+
* `run` body against `outputSchema`, then either persist + advance or
|
|
56
|
+
* record a terminal failure. Sole entry point — `runStage` branches
|
|
57
|
+
* here when `stage.def.run` is set.
|
|
58
|
+
*
|
|
59
|
+
* Caller pre-conditions (held by `runStage`):
|
|
60
|
+
* - `ensureInputValid` already passed (post-prompt-checks pipeline).
|
|
61
|
+
* - `tryFanout` returned `false` (fanout incompatible by validation).
|
|
62
|
+
*/
|
|
63
|
+
export async function runScript(curCtx: RunnerCtx, stage: ResolvedStage, idx: number, run: RunContext): Promise<void> {
|
|
64
|
+
curCtx.ui.setStatus(STATUS_KEY, STATUS_STAGE(stage.stageNumber, run.totalStages, stage.name));
|
|
65
|
+
|
|
66
|
+
const ref = scriptStageRef(stage.name, stage.stageNumber);
|
|
67
|
+
await run.lifecycle.fire(curCtx, "onStageStart", ref, lifecycleCtxFor(run));
|
|
68
|
+
|
|
69
|
+
const scriptCtx: ScriptContext = {
|
|
70
|
+
cwd: run.cwd,
|
|
71
|
+
input: run.state.output,
|
|
72
|
+
state: run.state,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const maxRetries = stage.def.maxRetries ?? DEFAULT_VALIDATION_RETRIES;
|
|
76
|
+
const onInvalid = stage.def.onInvalid ?? "retry";
|
|
77
|
+
|
|
78
|
+
for (let attempt = 1; attempt <= maxRetries + 1; attempt++) {
|
|
79
|
+
const invocation = await invokeRun(curCtx, stage, scriptCtx, ref, run);
|
|
80
|
+
if (!invocation.ok) return;
|
|
81
|
+
|
|
82
|
+
const output = finalizeOutput(invocation.raw, {
|
|
83
|
+
stage: stage.name,
|
|
84
|
+
stageNumber: run.state.lastAllocatedStageNumber + 1,
|
|
85
|
+
ts: nowIso(),
|
|
86
|
+
runId: run.runId,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
if (stage.def.kind === "produces" && stage.def.outputSchema) {
|
|
90
|
+
const validation = await Promise.resolve(validateOutputData(stage.def.outputSchema, output.data));
|
|
91
|
+
if (!validation.valid) {
|
|
92
|
+
const failureSummary = validation.failures.map((f) => `${f.path}: ${f.message}`).join("; ");
|
|
93
|
+
if (attempt > maxRetries || onInvalid === "halt") {
|
|
94
|
+
await recordTerminalFailure(curCtx, scriptAuditCtx(run, stage), {
|
|
95
|
+
status: "failed",
|
|
96
|
+
notifyMsg: MSG_VALIDATION_EXHAUSTED(stage.name),
|
|
97
|
+
notifyLevel: "error",
|
|
98
|
+
errMsg: ERR_VALIDATION_FAILED(stage.name, failureSummary),
|
|
99
|
+
});
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
await run.lifecycle.fire(curCtx, "onStageRetry", ref, attempt, lifecycleCtxFor(run));
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!recordScriptSuccess(curCtx, stage, output, run.state, run.cwd, run.runId)) return;
|
|
108
|
+
|
|
109
|
+
await run.lifecycle.fire(curCtx, "onStageEnd", ref, output, lifecycleCtxFor(run));
|
|
110
|
+
await advanceChain(curCtx, stage.name, idx, run);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
type ScriptInvocationResult =
|
|
116
|
+
| { ok: true; raw: { kind: string; artifacts: readonly Artifact[]; data: unknown } }
|
|
117
|
+
| { ok: false };
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Invoke `stage.def.run` once with `scriptCtx`; coerce the return into
|
|
121
|
+
* the value-channel shape `finalizeOutput` wants. Acts/terminal script
|
|
122
|
+
* stages return `void` — the runner synthesises a `"side-effect"`
|
|
123
|
+
* envelope so the chain stays uniform. A throw becomes a terminal
|
|
124
|
+
* failure attributed via `MSG_SCRIPT_THREW` + `ERR_SCRIPT_THREW`.
|
|
125
|
+
*/
|
|
126
|
+
async function invokeRun(
|
|
127
|
+
curCtx: RunnerCtx,
|
|
128
|
+
stage: ResolvedStage,
|
|
129
|
+
scriptCtx: ScriptContext,
|
|
130
|
+
ref: ReturnType<typeof scriptStageRef>,
|
|
131
|
+
run: RunContext,
|
|
132
|
+
): Promise<ScriptInvocationResult> {
|
|
133
|
+
try {
|
|
134
|
+
const result = await Promise.resolve(stage.def.run!(scriptCtx));
|
|
135
|
+
const raw =
|
|
136
|
+
stage.def.kind === "produces"
|
|
137
|
+
? (result as { kind: string; artifacts: readonly Artifact[]; data: unknown })
|
|
138
|
+
: { kind: "side-effect", artifacts: [] as readonly Artifact[], data: {} as unknown };
|
|
139
|
+
return { ok: true, raw };
|
|
140
|
+
} catch (e) {
|
|
141
|
+
const reason = e instanceof Error ? e.message : String(e);
|
|
142
|
+
await recordTerminalFailure(curCtx, scriptAuditCtx(run, stage), {
|
|
143
|
+
status: "failed",
|
|
144
|
+
notifyMsg: MSG_SCRIPT_THREW(stage.name, reason),
|
|
145
|
+
notifyLevel: "error",
|
|
146
|
+
errMsg: ERR_SCRIPT_THREW(stage.name, reason),
|
|
147
|
+
});
|
|
148
|
+
// `recordTerminalFailure` already fired `onStageError`; suppress the
|
|
149
|
+
// `_ref` arg here so the caller doesn't fire a second time.
|
|
150
|
+
void ref;
|
|
151
|
+
return { ok: false };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Persist the success row + advance the rolling primary-artifact slot.
|
|
157
|
+
* Returns `true` iff the JSONL row landed. Mirrors
|
|
158
|
+
* `tryRecordStage` + `recordStageSuccess` in `sessions/sessions.ts`,
|
|
159
|
+
* specialised for the script path (no SessionContext, no skill field,
|
|
160
|
+
* no `onStageEnd` fire — caller owns lifecycle ordering here).
|
|
161
|
+
*/
|
|
162
|
+
function recordScriptSuccess(
|
|
163
|
+
curCtx: RunnerCtx,
|
|
164
|
+
stage: ResolvedStage,
|
|
165
|
+
output: Output,
|
|
166
|
+
state: RunState,
|
|
167
|
+
cwd: string,
|
|
168
|
+
runId: string,
|
|
169
|
+
): boolean {
|
|
170
|
+
const assigned = recordStage(
|
|
171
|
+
cwd,
|
|
172
|
+
runId,
|
|
173
|
+
// `skill` is intentionally absent on script-stage rows — JSON.stringify
|
|
174
|
+
// drops `undefined` so the JSONL row carries no skill field at all.
|
|
175
|
+
{ stage: stage.name, status: "completed", ts: nowIso(), output },
|
|
176
|
+
state,
|
|
177
|
+
);
|
|
178
|
+
if (assigned === undefined) {
|
|
179
|
+
curCtx.ui.notify(MSG_AUDIT_WRITE_FAILED(stage.name), "error");
|
|
180
|
+
state.termination.error = ERR_AUDIT_WRITE_FAILED(stage.name);
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
advancePrimaryForScript(state, stage.def, stage.name, output);
|
|
184
|
+
state.output = output;
|
|
185
|
+
state.stagesCompleted++;
|
|
186
|
+
curCtx.ui.notify(MSG_STAGE_COMPLETE(stage.name), "info");
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Mirror of `sessions/sessions.ts:maybeAdvancePrimary` for the script
|
|
192
|
+
* path. Kept local instead of extracted to a shared helper: the rule
|
|
193
|
+
* is small (eight lines), and the two call sites live in different
|
|
194
|
+
* folders (`runner/` vs `sessions/`) — extracting would force a
|
|
195
|
+
* runner-imports-sessions or sessions-imports-runner crossing for one
|
|
196
|
+
* predicate.
|
|
197
|
+
*
|
|
198
|
+
* - `kind: "produces"` → first artifact wins the rolling slot.
|
|
199
|
+
* - `inheritsArtifacts: false` (`terminal.script`) → clear the slot
|
|
200
|
+
* so downstream stages start without an inherited handle.
|
|
201
|
+
* - other `side-effect` → leave the slot untouched (a downstream
|
|
202
|
+
* stage still inherits whatever the upstream produces stage set).
|
|
203
|
+
*/
|
|
204
|
+
function advancePrimaryForScript(state: RunState, def: StageDef, stageName: string, output: Output): void {
|
|
205
|
+
if (def.kind === "produces") {
|
|
206
|
+
const next = output.artifacts[0];
|
|
207
|
+
if (next) state.primaryArtifact = next;
|
|
208
|
+
const key = resolvePublishName(def, stageName);
|
|
209
|
+
const slot = state.named[key];
|
|
210
|
+
if (slot) slot.push(output);
|
|
211
|
+
else state.named[key] = [output];
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (def.inheritsArtifacts === false) {
|
|
215
|
+
state.primaryArtifact = undefined;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Build the `AuditCtx`-shaped object `recordTerminalFailure` needs for
|
|
221
|
+
* a script-stage halt. The `skill` field doubles as the lifecycle
|
|
222
|
+
* `onStageError` ref payload — using `stage.name` keeps the failure
|
|
223
|
+
* attribution aligned with the success row's `stage` identity.
|
|
224
|
+
*/
|
|
225
|
+
function scriptAuditCtx(run: RunContext, stage: ResolvedStage) {
|
|
226
|
+
return {
|
|
227
|
+
cwd: run.cwd,
|
|
228
|
+
runId: run.runId,
|
|
229
|
+
state: run.state,
|
|
230
|
+
stageName: stage.name,
|
|
231
|
+
// `skill` doubles as the notify-message subject (`MSG_VALIDATION_EXHAUSTED`,
|
|
232
|
+
// `MSG_STAGE_FAILED`); set to the stage name so the user sees the stage
|
|
233
|
+
// identity. `isScript: true` ensures the JSONL row drops the field and
|
|
234
|
+
// `onStageError` fires with `scriptStageRef` (no `skill` payload).
|
|
235
|
+
skill: stage.name,
|
|
236
|
+
lifecycle: run.lifecycle,
|
|
237
|
+
runIdentity: { workflow: run.workflow.name, totalStages: run.totalStages, trigger: run.trigger },
|
|
238
|
+
isScript: true,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-stage lifecycle: resolve the stage def, run the preflight pipeline,
|
|
3
|
+
* prepare the prompt + status + branchOffset, capture the outcome's
|
|
4
|
+
* snapshot, and hand off to `runStageSession`.
|
|
5
|
+
*
|
|
6
|
+
* Owns the typed-throw preflight machinery (`StagePreflightError`,
|
|
7
|
+
* `PreflightCheck`, `PRE_PROMPT_CHECKS`, `POST_PROMPT_CHECKS`) and the
|
|
8
|
+
* six bundled preflight checks. `runStageOrRecordFailure` (runner.ts)
|
|
9
|
+
* catches `StagePreflightError` and records the JSONL row.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { StageDef } from "../api.js";
|
|
13
|
+
import { notifyPartialArtifacts } from "../audit.js";
|
|
14
|
+
import { runFanout } from "../fanout.js";
|
|
15
|
+
import { handleToString } from "../handle.js";
|
|
16
|
+
import { currentPrimaryArtifact, withTimeout } from "../internal-utils.js";
|
|
17
|
+
import { skillStageRef } from "../lifecycle.js";
|
|
18
|
+
import {
|
|
19
|
+
ERR_INPUT_VALIDATION_FAILED,
|
|
20
|
+
ERR_MISSING_ARTIFACT,
|
|
21
|
+
ERR_MISSING_NAMED_READ,
|
|
22
|
+
ERR_SCHEMA_TIMEOUT,
|
|
23
|
+
ERR_SKILL_NOT_REGISTERED,
|
|
24
|
+
MSG_INPUT_VALIDATION_FAILED,
|
|
25
|
+
MSG_MISSING_ARTIFACT,
|
|
26
|
+
MSG_MISSING_NAMED_READ,
|
|
27
|
+
MSG_SKILL_NOT_REGISTERED,
|
|
28
|
+
MSG_STAGE_THREW,
|
|
29
|
+
STATUS_KEY,
|
|
30
|
+
STATUS_STAGE,
|
|
31
|
+
} from "../messages.js";
|
|
32
|
+
import { runFanoutSession, runStageSession } from "../sessions/index.js";
|
|
33
|
+
import { readBranch } from "../transcript.js";
|
|
34
|
+
import type { RunContext, RunnerCtx } from "../types.js";
|
|
35
|
+
import {
|
|
36
|
+
DEFAULT_VALIDATION_RETRY_TIMEOUT_MS,
|
|
37
|
+
MAX_VALIDATION_RETRY_TIMEOUT_MS,
|
|
38
|
+
MIN_VALIDATION_RETRY_TIMEOUT_MS,
|
|
39
|
+
type ValidationResult,
|
|
40
|
+
validateOutputData,
|
|
41
|
+
} from "../validate-output.js";
|
|
42
|
+
import { advanceChain } from "./chain-advance.js";
|
|
43
|
+
import { lifecycleCtxFor } from "./runner.js";
|
|
44
|
+
import { runScript } from "./script-stage.js";
|
|
45
|
+
|
|
46
|
+
export interface ResolvedStage {
|
|
47
|
+
def: StageDef;
|
|
48
|
+
name: string;
|
|
49
|
+
/** 1-based; for status line + audit row. */
|
|
50
|
+
stageNumber: number;
|
|
51
|
+
/** Label written to JSONL + the status line. */
|
|
52
|
+
skill: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Thrown by a `PreflightCheck` on failure; carries the recorded-row
|
|
57
|
+
* attribution + notify/err messages so `runStageOrRecordFailure` can land
|
|
58
|
+
* a uniform JSONL row regardless of which slot tripped.
|
|
59
|
+
*
|
|
60
|
+
* `kind` annotates the violation class for diagnostics only — control
|
|
61
|
+
* flow at the catch site is uniform:
|
|
62
|
+
* - `"halt"` — runtime-state failure (skill not registered, missing
|
|
63
|
+
* upstream artifact, schema mismatch).
|
|
64
|
+
* - `"invariant"` — authoring-time-knowable violation that
|
|
65
|
+
* `validateWorkflow` should reject at load. A throw
|
|
66
|
+
* here means validation was bypassed or the rule lives
|
|
67
|
+
* only in the runner (continue-without-pi).
|
|
68
|
+
*/
|
|
69
|
+
export class StagePreflightError extends Error {
|
|
70
|
+
constructor(
|
|
71
|
+
public readonly kind: "halt" | "invariant",
|
|
72
|
+
public readonly skill: string,
|
|
73
|
+
public readonly notifyMsg: string,
|
|
74
|
+
public readonly errMsg: string,
|
|
75
|
+
public readonly notifyPartial: boolean,
|
|
76
|
+
) {
|
|
77
|
+
super(errMsg);
|
|
78
|
+
this.name = "StagePreflightError";
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface PreflightCheck {
|
|
83
|
+
name: string;
|
|
84
|
+
kind: "halt" | "invariant";
|
|
85
|
+
/**
|
|
86
|
+
* Checks may be sync (`enforceSessionInvariants`, `ensureSkillRegistered`,
|
|
87
|
+
* `ensureUpstreamArtifact`) or async (`ensureInputValid` once schemas may
|
|
88
|
+
* be async). `runStage` awaits the return value, so sync checks pay only
|
|
89
|
+
* one microtask and async checks (filesystem-backed, registry-backed,
|
|
90
|
+
* async-by-default schema libs) round-trip cleanly.
|
|
91
|
+
*/
|
|
92
|
+
run(stage: ResolvedStage, run: RunContext): void | Promise<void>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Builds the `/skill:<name> <args>` line sent into the session. The audit
|
|
97
|
+
* label (which used to round-trip through here) is read off `stage.skill`
|
|
98
|
+
* by the caller — single source.
|
|
99
|
+
*/
|
|
100
|
+
function buildPrompt(skill: string, inputForStage: string): string {
|
|
101
|
+
return `/skill:${skill} ${inputForStage}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The arg string the stage's `/skill:<name> <args>` prompt carries. Four
|
|
106
|
+
* cases (checked in order):
|
|
107
|
+
* 1. The start stage always receives `originalInput` (the user's brief).
|
|
108
|
+
* 2. A stage opting out of inheritance (`inheritsArtifacts: false`, i.e.
|
|
109
|
+
* authored via `terminal()`) also receives `originalInput` — the
|
|
110
|
+
* `ensureUpstreamArtifact` preflight is bypassed for the same opt-out.
|
|
111
|
+
* 3. A stage with `reads: [...]` receives a labelled multi-flag form:
|
|
112
|
+
* `--<name1> <handle1> --<name2> <handle2> …`. Each name resolves
|
|
113
|
+
* against `state.named[name].at(-1)` (the latest entry). When that
|
|
114
|
+
* entry's `artifacts` array carries multiple handles, the flag
|
|
115
|
+
* repeats: `--<name> <h1> --<name> <h2>`. The `ensureNamedReads`
|
|
116
|
+
* preflight has already verified every name resolves; the `!` is safe.
|
|
117
|
+
* 4. Otherwise: the upstream primary artifact's handle string. The
|
|
118
|
+
* `ensureUpstreamArtifact` preflight guarantees the slot is set; the
|
|
119
|
+
* `!` is safe.
|
|
120
|
+
*/
|
|
121
|
+
function inputForStage(stage: ResolvedStage, run: RunContext): string {
|
|
122
|
+
const isStart = stage.name === run.workflow.start;
|
|
123
|
+
if (isStart) return run.state.originalInput;
|
|
124
|
+
if (stage.def.inheritsArtifacts === false) return run.state.originalInput;
|
|
125
|
+
if (stage.def.reads?.length) return formatNamedInputs(stage.def.reads, run);
|
|
126
|
+
return handleToString(currentPrimaryArtifact(run.state)!.handle);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Build the labelled-flag prompt body for a `reads:`-style stage. Iterates
|
|
131
|
+
* declared names in author-supplied order, resolves each to the latest
|
|
132
|
+
* `Output` accumulated in `state.named`, and emits one `--<name> <handle>`
|
|
133
|
+
* pair per artifact in that output (so multi-artifact stages get
|
|
134
|
+
* flag-repetition rather than space-collision).
|
|
135
|
+
*
|
|
136
|
+
* Pre-condition: every name resolves (enforced by `ensureNamedReads`).
|
|
137
|
+
*/
|
|
138
|
+
function formatNamedInputs(names: ReadonlyArray<string>, run: RunContext): string {
|
|
139
|
+
const parts: string[] = [];
|
|
140
|
+
for (const name of names) {
|
|
141
|
+
const latest = run.state.named[name]?.at(-1);
|
|
142
|
+
if (!latest) continue; // unreachable given preflight; defensive
|
|
143
|
+
for (const artifact of latest.artifacts) {
|
|
144
|
+
parts.push(`--${name}`, handleToString(artifact.handle));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return parts.join(" ");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Slot ordering (load-bearing):
|
|
152
|
+
*
|
|
153
|
+
* 1. tryFanout — shortcut: the stage's FanoutFn returned
|
|
154
|
+
* units, runner ran them; subsequent
|
|
155
|
+
* slots skipped for this stage.
|
|
156
|
+
* 2. PRE_PROMPT_CHECKS — preflights that don't need prompt prep.
|
|
157
|
+
* a. ensureUpstreamArtifact — halt: missing inherited artifact.
|
|
158
|
+
* b. enforceSessionInvariants — invariant: authoring-time-knowable
|
|
159
|
+
* throws (precede the registry check so the structural violation
|
|
160
|
+
* surfaces regardless of the runtime registry).
|
|
161
|
+
* c. ensureSkillRegistered — halt: skill not registered in Pi.
|
|
162
|
+
* 3. prompt + status + branchOffset prep.
|
|
163
|
+
* 4. POST_PROMPT_CHECKS — preflights gated on prompt-prep state.
|
|
164
|
+
* a. ensureInputValid — halt: upstream output fails inputSchema.
|
|
165
|
+
* 5. captureStageSnapshot — outcome.collector.snapshot hook (must run
|
|
166
|
+
* before the Pi session so post-stage diffs work).
|
|
167
|
+
*
|
|
168
|
+
* Each `PreflightCheck` throws `StagePreflightError` on failure;
|
|
169
|
+
* `runStageOrRecordFailure` catches and records the JSONL row.
|
|
170
|
+
*/
|
|
171
|
+
export async function runStage(curCtx: RunnerCtx, currentName: string, idx: number, run: RunContext): Promise<void> {
|
|
172
|
+
const stage = resolveStage(currentName, idx, run);
|
|
173
|
+
|
|
174
|
+
if (await tryFanout(curCtx, stage, idx, run)) return;
|
|
175
|
+
|
|
176
|
+
// Script stages (`stage.def.run` set) skip the entire skill pipeline —
|
|
177
|
+
// no `/skill:<name>` prompt to build, no skill-registry check, no
|
|
178
|
+
// session to open, no outcome/collector to snapshot. Input-schema
|
|
179
|
+
// validation still applies (`ensureInputValid` runs upstream output
|
|
180
|
+
// against `inputSchema` if declared); the script-stage runner owns
|
|
181
|
+
// its own status line + lifecycle fires from here.
|
|
182
|
+
if (stage.def.run) {
|
|
183
|
+
await ensureInputValid(stage, run);
|
|
184
|
+
await runScript(curCtx, stage, idx, run);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for (const check of PRE_PROMPT_CHECKS) await check.run(stage, run);
|
|
189
|
+
|
|
190
|
+
const prompt = buildPrompt(stage.skill, inputForStage(stage, run));
|
|
191
|
+
curCtx.ui.setStatus(STATUS_KEY, STATUS_STAGE(stage.stageNumber, run.totalStages, stage.skill));
|
|
192
|
+
const branchOffset = computeBranchOffset(curCtx, stage.def);
|
|
193
|
+
|
|
194
|
+
for (const check of POST_PROMPT_CHECKS) await check.run(stage, run);
|
|
195
|
+
|
|
196
|
+
const snapshot = await captureStageSnapshot(stage.def, idx, run);
|
|
197
|
+
|
|
198
|
+
// onStageStart fires after preflight, before the Pi session opens.
|
|
199
|
+
await run.lifecycle.fire(
|
|
200
|
+
curCtx,
|
|
201
|
+
"onStageStart",
|
|
202
|
+
skillStageRef(stage.name, stage.stageNumber, stage.skill),
|
|
203
|
+
lifecycleCtxFor(run),
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
await runStageSession(curCtx, {
|
|
207
|
+
cwd: run.cwd,
|
|
208
|
+
runId: run.runId,
|
|
209
|
+
state: run.state,
|
|
210
|
+
prompt,
|
|
211
|
+
stageName: stage.name,
|
|
212
|
+
skill: stage.skill,
|
|
213
|
+
lifecycle: run.lifecycle,
|
|
214
|
+
runIdentity: { workflow: run.workflow.name, totalStages: run.totalStages, trigger: run.trigger },
|
|
215
|
+
stage: stage.def,
|
|
216
|
+
stageIndex: idx,
|
|
217
|
+
snapshot,
|
|
218
|
+
continueHost: run.continueHost,
|
|
219
|
+
branchOffset,
|
|
220
|
+
onFailure: (freshCtx) => notifyPartialArtifacts(freshCtx, run.cwd, run.runId),
|
|
221
|
+
onSuccess: (freshCtx) => advanceChain(freshCtx, currentName, idx, run),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function resolveStage(currentName: string, idx: number, run: RunContext): ResolvedStage {
|
|
226
|
+
const def = run.workflow.stages[currentName];
|
|
227
|
+
if (!def) {
|
|
228
|
+
// validateWorkflow should catch this; defensive for tests bypassing validation.
|
|
229
|
+
throw new Error(`runStage: stage "${currentName}" referenced by edges but missing from workflow.stages`);
|
|
230
|
+
}
|
|
231
|
+
// `skill` defaults to the record key — the common case where stage id and
|
|
232
|
+
// Pi skill match doesn't restate the name at the call site.
|
|
233
|
+
return { def, name: currentName, stageNumber: idx + 1, skill: def.skill ?? currentName };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* A stage that opts into fanout via `StageDef.fanout` expands into one Pi
|
|
238
|
+
* session per unit returned by the user's `FanoutFn`. The runner is
|
|
239
|
+
* convention-agnostic: it never inspects the artifact, never counts
|
|
240
|
+
* headings, never names a skill — every per-unit decision lives in the
|
|
241
|
+
* FanoutFn. Returns true iff fanout fired (i.e. at least one unit was
|
|
242
|
+
* returned) — caller then returns without running the single-stage path.
|
|
243
|
+
*/
|
|
244
|
+
async function tryFanout(curCtx: RunnerCtx, stage: ResolvedStage, idx: number, run: RunContext): Promise<boolean> {
|
|
245
|
+
if (!stage.def.fanout) return false;
|
|
246
|
+
const primary = currentPrimaryArtifact(run.state);
|
|
247
|
+
const units = await stage.def.fanout({
|
|
248
|
+
cwd: run.cwd,
|
|
249
|
+
artifact: primary,
|
|
250
|
+
state: run.state,
|
|
251
|
+
});
|
|
252
|
+
if (units.length === 0) return false;
|
|
253
|
+
// Fire both onStageStart (the parent fanout stage IS starting) and
|
|
254
|
+
// onFanoutStart so listeners receive a coherent stream: every stage gets
|
|
255
|
+
// onStageStart, fanout stages additionally get onFanoutStart with the
|
|
256
|
+
// unit list.
|
|
257
|
+
const ref = skillStageRef(stage.name, stage.stageNumber, stage.skill);
|
|
258
|
+
await run.lifecycle.fire(curCtx, "onStageStart", ref, lifecycleCtxFor(run));
|
|
259
|
+
await run.lifecycle.fire(curCtx, "onFanoutStart", ref, units, lifecycleCtxFor(run));
|
|
260
|
+
await runFanout(curCtx, idx, stage.name, stage.skill, 1, units, run, {
|
|
261
|
+
runFanoutSession,
|
|
262
|
+
advanceAfter: (freshCtx, name, completedIdx, ctx) => advanceChain(freshCtx, name, completedIdx, ctx),
|
|
263
|
+
});
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Verify `stage.skill` resolves to a Pi-registered skill BEFORE the prompt
|
|
269
|
+
* is dispatched. The workflow runner emits `/skill:<name>` text via
|
|
270
|
+
* `sendUserMessage` (the programmatic path), which goes through
|
|
271
|
+
* `prompt({expandPromptTemplates: false})` — meaning Pi's built-in
|
|
272
|
+
* `_expandSkillCommand` is skipped and `rpiv-args` is the ONLY expander.
|
|
273
|
+
* If the skill isn't registered, `rpiv-args` returns `{action:"continue"}`
|
|
274
|
+
* and the raw `/skill:<name> …` text reaches the LLM as a bare user-message
|
|
275
|
+
* imperative outside the `<skill>...</skill>` contract — silent LLM-prompt
|
|
276
|
+
* corruption with no diagnostic. Catching it here turns that silent failure
|
|
277
|
+
* into a properly-attributed stage halt.
|
|
278
|
+
*
|
|
279
|
+
* Reads the snapshot in `run.registeredSkills` rather than calling
|
|
280
|
+
* `host.getCommands()` mid-run, because Pi marks the `WorkflowHost` handle
|
|
281
|
+
* stale on the first `ctx.newSession()` — a registry call after research's
|
|
282
|
+
* fresh session opens throws "extension ctx is stale". The snapshot is
|
|
283
|
+
* built once in `runWorkflow` before any session replaces the outer ctx.
|
|
284
|
+
*
|
|
285
|
+
* `registeredSkills` is undefined when the embedder didn't pass a host —
|
|
286
|
+
* skip the check (same fail-soft posture as the rest of the host-optional
|
|
287
|
+
* surface).
|
|
288
|
+
*/
|
|
289
|
+
function ensureSkillRegistered(stage: ResolvedStage, run: RunContext): void {
|
|
290
|
+
if (!run.registeredSkills) return;
|
|
291
|
+
if (run.registeredSkills.has(stage.skill)) return;
|
|
292
|
+
|
|
293
|
+
throw new StagePreflightError(
|
|
294
|
+
"halt",
|
|
295
|
+
stage.skill,
|
|
296
|
+
MSG_SKILL_NOT_REGISTERED(stage.skill),
|
|
297
|
+
ERR_SKILL_NOT_REGISTERED(stage.skill, stage.stageNumber),
|
|
298
|
+
true,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The start node consumes the user's brief; subsequent stages MUST inherit
|
|
304
|
+
* an upstream artifactPath. Falling back to originalInput past the start
|
|
305
|
+
* would silently hand a downstream skill the raw feature description.
|
|
306
|
+
*
|
|
307
|
+
* Two opt-outs skip the check:
|
|
308
|
+
* - `inheritsArtifacts: false` (authored via `terminal()`) — stage consumes
|
|
309
|
+
* `originalInput` by design.
|
|
310
|
+
* - `reads: [...]` — stage builds its prompt from the named-publish
|
|
311
|
+
* registry instead of the rolling primary slot; `ensureNamedReads`
|
|
312
|
+
* enforces its own coverage rule.
|
|
313
|
+
*/
|
|
314
|
+
function ensureUpstreamArtifact(stage: ResolvedStage, run: RunContext): void {
|
|
315
|
+
if (stage.name === run.workflow.start) return;
|
|
316
|
+
if (stage.def.inheritsArtifacts === false) return;
|
|
317
|
+
if (stage.def.reads?.length) return;
|
|
318
|
+
if (currentPrimaryArtifact(run.state)) return;
|
|
319
|
+
throw new StagePreflightError(
|
|
320
|
+
"halt",
|
|
321
|
+
stage.skill,
|
|
322
|
+
MSG_MISSING_ARTIFACT(stage.skill),
|
|
323
|
+
ERR_MISSING_ARTIFACT(stage.skill, stage.stageNumber),
|
|
324
|
+
true,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* A stage declaring `reads: [...]` must find every name filled in
|
|
330
|
+
* `state.named` before the prompt is built. `validateWorkflow` already
|
|
331
|
+
* confirms the names CAN exist (some upstream stage publishes them); this
|
|
332
|
+
* catches the runtime path where the producer hasn't fired yet — e.g.
|
|
333
|
+
* the stage was placed before its producer in the edge graph.
|
|
334
|
+
*/
|
|
335
|
+
function ensureNamedReads(stage: ResolvedStage, run: RunContext): void {
|
|
336
|
+
const reads = stage.def.reads;
|
|
337
|
+
if (!reads?.length) return;
|
|
338
|
+
for (const name of reads) {
|
|
339
|
+
if (run.state.named[name]?.length) continue;
|
|
340
|
+
throw new StagePreflightError(
|
|
341
|
+
"halt",
|
|
342
|
+
stage.skill,
|
|
343
|
+
MSG_MISSING_NAMED_READ(stage.skill, name),
|
|
344
|
+
ERR_MISSING_NAMED_READ(stage.skill, name, stage.stageNumber),
|
|
345
|
+
true,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function enforceSessionInvariants(stage: ResolvedStage, run: RunContext): void {
|
|
351
|
+
if (stage.def.fanout && stage.def.sessionPolicy === "continue") {
|
|
352
|
+
const reason =
|
|
353
|
+
`runStage: stage "${stage.name}" cannot combine fanout with sessionPolicy "continue" — ` +
|
|
354
|
+
"fanout requires per-unit session isolation";
|
|
355
|
+
throw new StagePreflightError("invariant", stage.name, MSG_STAGE_THREW(stage.name, reason), reason, false);
|
|
356
|
+
}
|
|
357
|
+
if (stage.def.sessionPolicy === "continue" && !run.continueHost) {
|
|
358
|
+
const reason = `runStage: stage "${stage.name}" uses sessionPolicy "continue" but no workflow host was provided to runWorkflow`;
|
|
359
|
+
throw new StagePreflightError("invariant", stage.name, MSG_STAGE_THREW(stage.name, reason), reason, false);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Entries before this index belong to prior stages; only meaningful for continue. */
|
|
364
|
+
function computeBranchOffset(curCtx: RunnerCtx, def: StageDef): number | undefined {
|
|
365
|
+
if (def.sessionPolicy !== "continue") return undefined;
|
|
366
|
+
return readBranch(curCtx).length;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function ensureInputValid(stage: ResolvedStage, run: RunContext): Promise<void> {
|
|
370
|
+
if (!stage.def.inputSchema || run.state.output?.data === undefined) return;
|
|
371
|
+
const timeoutMs = clampValidateTimeoutMs(stage.def.validateTimeoutMs);
|
|
372
|
+
const prevSkill = run.state.output.meta.stage || "unknown";
|
|
373
|
+
|
|
374
|
+
let result: ValidationResult;
|
|
375
|
+
try {
|
|
376
|
+
result = await withTimeout(
|
|
377
|
+
Promise.resolve(validateOutputData(stage.def.inputSchema, run.state.output.data)),
|
|
378
|
+
timeoutMs,
|
|
379
|
+
ERR_SCHEMA_TIMEOUT("inputSchema", timeoutMs),
|
|
380
|
+
);
|
|
381
|
+
} catch (e) {
|
|
382
|
+
// Async schema rejected, or schema timed out. Same fatal-extraction
|
|
383
|
+
// posture as the outputSchema seam — surface as a halt-class
|
|
384
|
+
// StagePreflightError so the row attribution and notify message
|
|
385
|
+
// match every other preflight failure.
|
|
386
|
+
const reason = e instanceof Error ? e.message : String(e);
|
|
387
|
+
throw new StagePreflightError(
|
|
388
|
+
"halt",
|
|
389
|
+
stage.skill,
|
|
390
|
+
MSG_INPUT_VALIDATION_FAILED(stage.skill, prevSkill),
|
|
391
|
+
ERR_INPUT_VALIDATION_FAILED(stage.skill, prevSkill, reason),
|
|
392
|
+
true,
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (result.valid) return;
|
|
397
|
+
|
|
398
|
+
const failureSummary = result.failures.map((f) => `${f.path}: ${f.message}`).join("; ");
|
|
399
|
+
throw new StagePreflightError(
|
|
400
|
+
"halt",
|
|
401
|
+
stage.skill,
|
|
402
|
+
MSG_INPUT_VALIDATION_FAILED(stage.skill, prevSkill),
|
|
403
|
+
ERR_INPUT_VALIDATION_FAILED(stage.skill, prevSkill, failureSummary),
|
|
404
|
+
true,
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Mirror of the clamp in extraction.ts:retryUntilValid. Same defense-in-depth
|
|
410
|
+
* posture: validateWorkflow rejects out-of-range values at load, but
|
|
411
|
+
* programmatic callers that embed runWorkflow can bypass it; clamping here
|
|
412
|
+
* means a misconfigured stage degrades to the spec-default behavior instead
|
|
413
|
+
* of firing a 100 ms timeout before a real I/O probe gets a chance to settle.
|
|
414
|
+
*/
|
|
415
|
+
function clampValidateTimeoutMs(raw: number | undefined): number {
|
|
416
|
+
return Math.max(
|
|
417
|
+
MIN_VALIDATION_RETRY_TIMEOUT_MS,
|
|
418
|
+
Math.min(raw ?? DEFAULT_VALIDATION_RETRY_TIMEOUT_MS, MAX_VALIDATION_RETRY_TIMEOUT_MS),
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function captureStageSnapshot(def: StageDef, idx: number, run: RunContext): Promise<unknown> {
|
|
423
|
+
const snapshot = def.outcome?.collector.snapshot;
|
|
424
|
+
if (!snapshot) return undefined;
|
|
425
|
+
try {
|
|
426
|
+
return await snapshot({
|
|
427
|
+
cwd: run.cwd,
|
|
428
|
+
runId: run.runId,
|
|
429
|
+
stageIndex: idx,
|
|
430
|
+
state: run.state,
|
|
431
|
+
});
|
|
432
|
+
} catch {
|
|
433
|
+
// Snapshot capture failure doesn't prevent stage execution.
|
|
434
|
+
return undefined;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const PRE_PROMPT_CHECKS: readonly PreflightCheck[] = [
|
|
439
|
+
{ name: "ensureUpstreamArtifact", kind: "halt", run: ensureUpstreamArtifact },
|
|
440
|
+
{ name: "ensureNamedReads", kind: "halt", run: ensureNamedReads },
|
|
441
|
+
{ name: "enforceSessionInvariants", kind: "invariant", run: enforceSessionInvariants },
|
|
442
|
+
{ name: "ensureSkillRegistered", kind: "halt", run: ensureSkillRegistered },
|
|
443
|
+
];
|
|
444
|
+
|
|
445
|
+
const POST_PROMPT_CHECKS: readonly PreflightCheck[] = [
|
|
446
|
+
{ name: "ensureInputValid", kind: "halt", run: ensureInputValid },
|
|
447
|
+
];
|