@mgiles/perk 2.2.0 → 3.0.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/README.md +1 -1
- package/extension/adapters/planAdapterPlannotator.ts +132 -91
- package/extension/adapters/planAdapterTombell.ts +5 -3
- package/extension/doors/address.ts +235 -37
- package/extension/doors/annotationPush.ts +924 -0
- package/extension/doors/auditWaveTools.ts +352 -0
- package/extension/doors/ciExecutor.ts +220 -55
- package/extension/doors/draftReviewWaveTools.ts +384 -0
- package/extension/doors/harvestWaveTools.ts +279 -0
- package/extension/doors/land.ts +1 -0
- package/extension/doors/learn.ts +238 -24
- package/extension/doors/learnFactory.ts +33 -7
- package/extension/doors/lifecycleGates.ts +1 -1
- package/extension/doors/objectiveReviewBrowser.ts +514 -0
- package/extension/doors/objectiveStack.ts +1143 -0
- package/extension/doors/planReviewBrowser.ts +453 -0
- package/extension/doors/plannotatorHandoff.ts +120 -49
- package/extension/doors/prReview.ts +203 -22
- package/extension/doors/prReviewBrowser.ts +31 -23
- package/extension/doors/prReviewDynamic.ts +256 -0
- package/extension/doors/prReviewTerminal.ts +6 -11
- package/extension/doors/reviewWaveTools.ts +374 -0
- package/extension/doors/selfcheck.ts +1 -1
- package/extension/doors/submit.ts +86 -10
- package/extension/factories/gistAuthor.ts +4 -4
- package/extension/factories/gistDraft.ts +1 -1
- package/extension/factories/gistSave.ts +2 -2
- package/extension/factories/objective.ts +7 -7
- package/extension/factories/objectiveAuthor.ts +5 -4
- package/extension/factories/objectiveDraft.ts +61 -6
- package/extension/factories/objectivePlan.ts +163 -8
- package/extension/factories/objectiveSave.ts +16 -1
- package/extension/factories/planMode.ts +5 -3
- package/extension/factories/planReview.ts +131 -36
- package/extension/factories/planTitle.ts +27 -9
- package/extension/hunkFeedback/inbox.ts +389 -0
- package/extension/hunkFeedback/perkFeedback.ts +364 -0
- package/extension/hunkFeedback/receiver.ts +296 -0
- package/extension/hunkFeedback/store.ts +533 -0
- package/extension/index.ts +116 -44
- package/extension/substrate/cache.ts +75 -22
- package/extension/substrate/coldDoor.ts +2 -3
- package/extension/substrate/config.ts +42 -14
- package/extension/substrate/git.ts +10 -0
- package/extension/substrate/providers.ts +4 -33
- package/extension/substrate/resources.ts +11 -0
- package/extension/substrate/result.ts +13 -5
- package/extension/substrate/sessionData.ts +3 -3
- package/extension/substrate/sessionPointers.ts +3 -3
- package/extension/substrate/structuredOutput.ts +78 -19
- package/extension/substrate/terminalLaunch.ts +1 -1
- package/extension/substrate/toolGating.ts +206 -26
- package/extension/substrate/workflowState.ts +6 -0
- package/extension/surfaces/footerProvider.ts +6 -6
- package/extension/surfaces/surfaces.ts +98 -282
- package/extension/vendor/btw/btw.ts +57 -12
- package/extension/waves/adversarialReviewWave.ts +164 -0
- package/extension/waves/auditWave.ts +312 -0
- package/extension/waves/draftReviewWave.ts +189 -0
- package/extension/waves/harvestWave.ts +399 -0
- package/extension/waves/learnWave.ts +155 -0
- package/extension/waves/memoryAdapter.ts +139 -0
- package/extension/waves/objectiveExplorerWave.ts +120 -0
- package/extension/waves/prReviewDynamicWave.ts +696 -0
- package/extension/waves/prReviewWave.ts +274 -0
- package/extension/waves/reportWave.ts +749 -0
- package/extension/waves/reviewClassifierWave.ts +109 -0
- package/extension/waves/rpcAdapter.ts +264 -0
- package/extension/worker/readOnlySession.ts +3 -3
- package/extension/worker/worker.ts +71 -93
- package/extension/workerMain.ts +4 -6
- package/package.json +20 -5
- package/prompts/README.md +19 -0
- package/prompts/_fixtures/live.yaml +71 -72
- package/prompts/common/resume-advisory.md +1 -0
- package/prompts/contexts/adapters/plannotator-gist.md +13 -0
- package/prompts/contexts/adapters/plannotator-objective.md +1 -2
- package/prompts/contexts/adapters/plannotator-plan.md +1 -2
- package/prompts/contexts/gist-authoring.md +6 -20
- package/prompts/contexts/objective-authoring.md +6 -18
- package/prompts/stages/address/action.md +4 -4
- package/prompts/stages/address/preview.md +3 -3
- package/prompts/stages/audit.md +18 -0
- package/prompts/stages/conflict-resolution.md +1 -1
- package/prompts/stages/gist-author/seed.md +4 -4
- package/prompts/stages/gist-save.md +1 -1
- package/prompts/stages/implement.md +3 -1
- package/prompts/stages/learn-code.md +1 -1
- package/prompts/stages/learn-docs.md +2 -2
- package/prompts/stages/learn-harvest.md +15 -0
- package/prompts/stages/learn-orchestrate.md +7 -5
- package/prompts/stages/objective-author/adopt.md +5 -3
- package/prompts/stages/objective-author/file.md +5 -4
- package/prompts/stages/objective-author/seed.md +5 -4
- package/prompts/stages/objective-land.md +7 -0
- package/prompts/stages/objective-plan/guidance.md +2 -2
- package/prompts/stages/objective-plan/seed.md +5 -1
- package/prompts/stages/objective-recover.md +8 -0
- package/prompts/stages/objective-replan.md +4 -2
- package/prompts/stages/objective-review-browser.md +9 -0
- package/prompts/stages/objective-sync.md +6 -0
- package/prompts/stages/plan-from/adopt.md +2 -2
- package/prompts/stages/plan-from/file.md +2 -2
- package/prompts/stages/plan-review-browser.md +9 -0
- package/prompts/stages/pr-review-browser/active.md +6 -6
- package/prompts/stages/pr-review-browser/foreign.md +6 -6
- package/prompts/stages/pr-review-dynamic.md +7 -0
- package/prompts/stages/pr-review-terminal/active.md +7 -7
- package/prompts/stages/pr-review-terminal/foreign.md +7 -7
- package/prompts/stages/pr-review.md +7 -6
- package/prompts/stages/replan.md +3 -3
- package/shared/README.md +2 -2
- package/shared/bindings.yaml +15 -0
- package/shared/contracts-history.md +11 -0
- package/shared/contracts.md +3713 -227
- package/shared/providers.yaml +27 -60
- package/shared/registry.yaml +32 -8
- package/shared/schemas/outputs/objective-doctor.schema.json +449 -0
- package/shared/schemas/outputs/objective-stack-land.schema.json +732 -0
- package/shared/schemas/outputs/objective-stack-recover.schema.json +397 -0
- package/shared/schemas/outputs/objective-stack-status.schema.json +577 -0
- package/shared/schemas/outputs/objective-stack-sync.schema.json +184 -0
- package/shared/schemas/outputs/plan-save.schema.json +12 -0
- package/shared/schemas/outputs/pr-submit.schema.json +177 -1
- package/extension/adapters/todoAdapterJuicesharp.ts +0 -102
- package/extension/checkpoints/checkpoints.ts +0 -550
- package/extension/checkpoints/planSteps.ts +0 -108
- package/extension/doors/askUser.ts +0 -198
- package/prompts/contexts/adapters/juicesharp-todo.md +0 -7
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
// The Perk-owned report-wave module: bounded sets of fresh-context, report-only children with
|
|
2
|
+
// typed outcomes under stable lane keys. Report waves were previously model-authored prompt
|
|
3
|
+
// mechanics (a script skeleton the parent model had to transcribe faithfully — the known
|
|
4
|
+
// prompt-drift risk); this module makes the mechanics CODE. It renders the complete, tested
|
|
5
|
+
// `workflowScript`, launches it through a `WaveAdapter` (async-only, `mission: false`), waits on
|
|
6
|
+
// the run's async-complete event with a module-owned timeout, reads the durable `status.json`
|
|
7
|
+
// `workflow.value` aggregate, and normalizes `{complete, reports[], failures[]}` under a
|
|
8
|
+
// flow-specific completeness policy. Each launch additionally records an OUTPUT-FREE
|
|
9
|
+
// `WaveScriptReceipt` (run handle + per-child identity/artifact trail from the completion
|
|
10
|
+
// payload) — write-only telemetry for correlation: `status.json.workflow.value` stays the sole
|
|
11
|
+
// source of reports, and receipt absence never changes a verdict, completeness, or retry
|
|
12
|
+
// selection (contracts.md §8.35).
|
|
13
|
+
//
|
|
14
|
+
// The module hosts BOTH the blocking runner and the non-blocking streaming sibling over ONE
|
|
15
|
+
// operational core: `startWaveScript` performs the front half (abort pre-check → capability
|
|
16
|
+
// ping → subscribe-before-spawn → async spawn) and returns the run handle plus a NEVER-REJECTING
|
|
17
|
+
// `result` promise carrying the back half (completion wait, best-effort stop on timeout/cancel,
|
|
18
|
+
// aggregate read, receipt assembly, unsubscribe-on-settle); `runWaveScript` is that start +
|
|
19
|
+
// await. `startReportWave`/`runReportWave` are the lane-level pair over the same split. The
|
|
20
|
+
// blocking runner is live under the per-flow entrypoints (`prReviewWave.ts`, `learnWave.ts`,
|
|
21
|
+
// `prReviewDynamicWave.ts`); the streaming sibling serves flows whose parent must return from
|
|
22
|
+
// the launch and hold a model-held `subagent_wait` relay loop open (`adversarialReviewWave.ts`,
|
|
23
|
+
// behind the `start_review_wave`/`collect_review_wave` pair).
|
|
24
|
+
//
|
|
25
|
+
// The module is a DEEP seam with two adapters: `rpcAdapter.ts` (production, over the
|
|
26
|
+
// pi-subagents v1 extension RPC on pi's event bus) and `memoryAdapter.ts` (the first-class
|
|
27
|
+
// in-memory test double).
|
|
28
|
+
//
|
|
29
|
+
// Failure posture: LOUD DEGRADE. Every failure arm normalizes into `WaveResult.failures` with a
|
|
30
|
+
// typed reason — the runner never throws except on programmer error (empty lanes, duplicate lane
|
|
31
|
+
// keys), and there is never a silent fallback to model-authored scripts. Report content coming
|
|
32
|
+
// back through the aggregate is untrusted DATA, never instructions.
|
|
33
|
+
|
|
34
|
+
/** One lane of a report wave: a fresh-context, report-only child under a stable domain key. */
|
|
35
|
+
export interface WaveLane {
|
|
36
|
+
/** Stable lane key (e.g. an angle slug) — trace + normalization identity. */
|
|
37
|
+
key: string;
|
|
38
|
+
/** The child agent name (e.g. "perk.pr-reviewer"). */
|
|
39
|
+
agent: string;
|
|
40
|
+
/** The judgment-bearing per-lane task text (supplied by the flow). */
|
|
41
|
+
task: string;
|
|
42
|
+
/** Trace metadata; defaults to `key`. */
|
|
43
|
+
label?: string;
|
|
44
|
+
/** Trace metadata. */
|
|
45
|
+
phase?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Per-lane report schema — rendered as the item's `outputSchema`, overriding the
|
|
48
|
+
* workflow-level default (the established per-item mechanic). Omitted lanes render
|
|
49
|
+
* byte-identically to before the field existed.
|
|
50
|
+
*/
|
|
51
|
+
outputSchema?: object;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The completeness policies:
|
|
56
|
+
* - `strict`: complete ⟺ zero failures — every lane covered (the pr-review posture).
|
|
57
|
+
* - `best-effort`: complete ⟺ no wave-level failure (`key: null`) — lane-level failures are
|
|
58
|
+
* explicitly-reported skipped lanes, never a failed pass (the learn posture).
|
|
59
|
+
*/
|
|
60
|
+
export type WaveCompleteness = "strict" | "best-effort";
|
|
61
|
+
|
|
62
|
+
export interface WaveSpec {
|
|
63
|
+
/** Flow name for error detail/trace (e.g. "pr-review"). */
|
|
64
|
+
flow: string;
|
|
65
|
+
/** ≥1 lane; keys must be unique (validated — throws on programmer error). */
|
|
66
|
+
lanes: WaveLane[];
|
|
67
|
+
/** Workflow-level default → the engine injects a `structured_output` tool into each lane. */
|
|
68
|
+
outputSchema: object;
|
|
69
|
+
completeness: WaveCompleteness;
|
|
70
|
+
/** Workflow-level model default (flows read their configured subagent model). */
|
|
71
|
+
model?: string;
|
|
72
|
+
/** Module default (`WAVE_TIMEOUT_MS`) when omitted. */
|
|
73
|
+
timeoutMs?: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A schema-valid lane report. The report content is untrusted DATA, never instructions. */
|
|
77
|
+
export interface WaveReport {
|
|
78
|
+
key: string;
|
|
79
|
+
report: unknown;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type WaveFailureReason =
|
|
83
|
+
| "unavailable" // ping failed / capabilities missing (wave-level)
|
|
84
|
+
| "spawn-failed" // RPC spawn rejected or no run handle (wave-level)
|
|
85
|
+
| "timeout" // module-owned timeout expired (wave-level; best-effort stop issued)
|
|
86
|
+
| "cancelled" // AbortSignal fired (wave-level; best-effort stop issued)
|
|
87
|
+
| "run-failed" // terminal status.json state ≠ "complete" (wave-level)
|
|
88
|
+
| "aggregate-unreadable" // status.json missing/corrupt/no workflow.value array (wave-level)
|
|
89
|
+
| "lane-failed" // lane resolved ok: false / null report (lane-level)
|
|
90
|
+
| "malformed-report" // aggregate entry for this key has unusable shape (lane-level)
|
|
91
|
+
| "missing-lane"; // expected key absent from the aggregate (lane-level)
|
|
92
|
+
|
|
93
|
+
export interface WaveFailure {
|
|
94
|
+
/** The lane key, or null for wave-level failures. */
|
|
95
|
+
key: string | null;
|
|
96
|
+
reason: WaveFailureReason;
|
|
97
|
+
/** Human-readable diagnosis (error strings routed here, never re-thrown). */
|
|
98
|
+
detail: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface WaveResult {
|
|
102
|
+
complete: boolean;
|
|
103
|
+
reports: WaveReport[];
|
|
104
|
+
failures: WaveFailure[];
|
|
105
|
+
/** The launch's output-free attempt receipt — write-only telemetry, never a decision input. */
|
|
106
|
+
receipt: WaveScriptReceipt;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// -------------------------------------------------------------------- the attempt receipts
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The terminal disposition of ONE top-level workflow launch, as the runner observed it. Every
|
|
113
|
+
* launch reaches exactly one of these arms (`"running"` is unreachable — the runner always
|
|
114
|
+
* settles); `"unavailable"` preserves even a pre-spawn capability failure as an attempt.
|
|
115
|
+
*/
|
|
116
|
+
export type WaveReceiptState =
|
|
117
|
+
| "unavailable" // ping failed/incomplete — nothing launched
|
|
118
|
+
| "spawn-failed" // spawn rejected/threw — no run handle
|
|
119
|
+
| "complete" // completion observed, durable state "complete"
|
|
120
|
+
| "failed" // completion observed, durable/observed failure
|
|
121
|
+
| "timed-out" // module timeout expired (handle preserved)
|
|
122
|
+
| "cancelled"; // AbortSignal honored (handle preserved when spawned)
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* One child lane's identity/artifact trail from the completion payload — OUTPUT-FREE by
|
|
126
|
+
* invariant: reports, summaries, and structured output never enter a receipt (they stay in the
|
|
127
|
+
* durable `status.json.workflow.value`, the sole report authority).
|
|
128
|
+
*/
|
|
129
|
+
export interface WaveChildReceipt {
|
|
130
|
+
/** The Perk lane key (mapped FROM the upstream row's overloaded `agent` field). */
|
|
131
|
+
key: string;
|
|
132
|
+
/** The child agent name, enriched from the Perk-owned lane spec where known. */
|
|
133
|
+
agent?: string;
|
|
134
|
+
/** The child's opaque run id — never parsed or synthesized from paths. */
|
|
135
|
+
runId?: string;
|
|
136
|
+
success?: boolean;
|
|
137
|
+
outputState?: "present" | "absent" | "unknown";
|
|
138
|
+
/** String path fields only; output-free. */
|
|
139
|
+
artifactPaths?: Record<string, string>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** One script launch's receipt: the run handle (where known) + the observed children. */
|
|
143
|
+
export interface WaveScriptReceipt {
|
|
144
|
+
/** The top-level async run id (the spawn handle's asyncId). */
|
|
145
|
+
runId?: string;
|
|
146
|
+
asyncDir?: string;
|
|
147
|
+
state: WaveReceiptState;
|
|
148
|
+
children: WaveChildReceipt[];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* A flow-attributed attempt: one receipt per top-level workflow launch, ordered by `attempt`
|
|
153
|
+
* (one-based, assigned by the flow entrypoint that owns retry policy). `requestedKeys` is the
|
|
154
|
+
* lane manifest BEFORE launch — never reconstructed from the observed children.
|
|
155
|
+
*/
|
|
156
|
+
export interface WaveAttemptReceipt extends WaveScriptReceipt {
|
|
157
|
+
flow: string;
|
|
158
|
+
attempt: number;
|
|
159
|
+
requestedKeys: string[];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Assemble one flow attempt from a script receipt (the uniform builder the flows share). */
|
|
163
|
+
export function toAttemptReceipt(
|
|
164
|
+
flow: string,
|
|
165
|
+
attempt: number,
|
|
166
|
+
requestedKeys: string[],
|
|
167
|
+
receipt: WaveScriptReceipt,
|
|
168
|
+
): WaveAttemptReceipt {
|
|
169
|
+
return { flow, attempt, requestedKeys: [...requestedKeys], ...receipt };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ------------------------------------------------------------------------- the adapter seam
|
|
173
|
+
|
|
174
|
+
/** The minimal pi event-bus surface an adapter needs (mirrors pi's EventBus, whose `on` returns an unsubscribe function). */
|
|
175
|
+
export interface WaveBus {
|
|
176
|
+
emit(channel: string, data: unknown): void;
|
|
177
|
+
on(channel: string, handler: (data: unknown) => void): () => void;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** A successful capability ping; `asyncCompleteEvent` is the ADVERTISED async-complete channel. */
|
|
181
|
+
export interface WavePing {
|
|
182
|
+
asyncCompleteEvent: string;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The detached async run a spawn launched. */
|
|
186
|
+
export interface WaveRunHandle {
|
|
187
|
+
asyncId: string;
|
|
188
|
+
asyncDir: string;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* An async-complete notification; at least one identifier is present on real payloads. The
|
|
193
|
+
* observability fields are optional — an identity-only completion stays valid (receipt absence
|
|
194
|
+
* degrades correlation, never behavior). The adapter normalizes them output-free and leaves
|
|
195
|
+
* each child's `agent` unset (enrichment happens against Perk-owned lane specs).
|
|
196
|
+
*/
|
|
197
|
+
export interface WaveCompletion {
|
|
198
|
+
asyncId?: string;
|
|
199
|
+
asyncDir?: string;
|
|
200
|
+
/** The run's raw terminal state string, when the payload carries one. */
|
|
201
|
+
state?: string;
|
|
202
|
+
success?: boolean;
|
|
203
|
+
children?: WaveChildReceipt[];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The explicit acceptance-disable every wave spawn carries. Without it, pi-subagents
|
|
208
|
+
* auto-infers a generic acceptance contract for reviewer/analyst-named or read-only children
|
|
209
|
+
* and injects a fenced `acceptance-report` completion instruction into each lane — a COMPETING
|
|
210
|
+
* completion contract observed steering children into invalid `structured_output` attempts.
|
|
211
|
+
* `{level: "none"}` is the sanctioned disable shape (pi-subagents `explicitAcceptanceCanDisable`);
|
|
212
|
+
* `formatAcceptancePrompt` emits nothing at level none, so no contract block reaches a lane.
|
|
213
|
+
* Deliberately module-wide with no opt-out: every report-wave child's sole completion contract
|
|
214
|
+
* is the engine-validated `structured_output` report.
|
|
215
|
+
*/
|
|
216
|
+
export const WAVE_ACCEPTANCE = {
|
|
217
|
+
level: "none",
|
|
218
|
+
reason: "perk report-wave lanes complete via the engine-validated structured_output report",
|
|
219
|
+
} as const;
|
|
220
|
+
|
|
221
|
+
/** The full spawn params the runner fixes: async-only, ephemeral, fresh-context by definition. */
|
|
222
|
+
export interface WaveSpawnParams {
|
|
223
|
+
workflowScript: string;
|
|
224
|
+
async: true;
|
|
225
|
+
/** Waves are ephemeral by explicit decision — never mission-attached. */
|
|
226
|
+
mission: false;
|
|
227
|
+
/** A report wave is by definition fresh-context. */
|
|
228
|
+
context: "fresh";
|
|
229
|
+
/** The fixed acceptance disable (`WAVE_ACCEPTANCE`) — pi-subagents' workflow-defaults spread
|
|
230
|
+
* delivers it onto every lane child, suppressing the auto-inferred acceptance contract. */
|
|
231
|
+
acceptance: { level: "none"; reason: string };
|
|
232
|
+
outputSchema: object;
|
|
233
|
+
model?: string;
|
|
234
|
+
/** Orphan insurance: the run enforces the same deadline even if the parent session dies. */
|
|
235
|
+
timeoutMs: number;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export interface WaveAdapter {
|
|
239
|
+
/** Capability-checked ping; null ⇒ unavailable (loud degrade upstream). Must be called first. */
|
|
240
|
+
ping(): Promise<WavePing | null>;
|
|
241
|
+
/** Launch the async workflowScript run; throws ⇒ spawn-failed. */
|
|
242
|
+
spawn(params: WaveSpawnParams): Promise<WaveRunHandle>;
|
|
243
|
+
/** Subscribe to run completions (any run — the runner matches the handle); returns unsubscribe. */
|
|
244
|
+
onComplete(handler: (completion: WaveCompletion) => void): () => void;
|
|
245
|
+
/** Best-effort stop of a live run (timeout/cancel path); never throws. */
|
|
246
|
+
stop(handle: WaveRunHandle): Promise<void>;
|
|
247
|
+
/** Read the run's durable aggregate; throws ⇒ aggregate-unreadable. */
|
|
248
|
+
readAggregate(handle: WaveRunHandle): Promise<{ state: string; error?: string; value: unknown }>;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------- the renderer
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* pi-subagents' scripted-workflow run-key contract for `runs.all` item keys: start
|
|
255
|
+
* alphanumeric, then letters/digits/`.`/`_`/`-`, ≤128 chars total. Mirrored here because the
|
|
256
|
+
* upstream pattern is enforced only inside the live workflow worker — an invalid key fails the
|
|
257
|
+
* WHOLE wave at dispatch (`run-failed`), a path no offline adapter exercises — so the renderer
|
|
258
|
+
* rejects it up front as a programmer error.
|
|
259
|
+
*/
|
|
260
|
+
export const RUN_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Render the wave `workflowScript`: an explicit-return, all-settled `runs.all` over the lane
|
|
264
|
+
* items, projected to the compact typed aggregate only (lane key, outcome, error, and the
|
|
265
|
+
* schema-validated report — children's prose never enters the aggregate beyond `error`/`output`
|
|
266
|
+
* on failure). Lane items are embedded via `JSON.stringify`, so hostile task text (quotes,
|
|
267
|
+
* newlines, backticks, `${}`) cannot escape the array literal. Throws on programmer error:
|
|
268
|
+
* empty lanes, duplicate lane keys, or a lane key outside the run-key contract.
|
|
269
|
+
*/
|
|
270
|
+
export function renderWaveScript(lanes: WaveLane[]): string {
|
|
271
|
+
if (lanes.length === 0) {
|
|
272
|
+
throw new Error("renderWaveScript: a report wave needs at least one lane");
|
|
273
|
+
}
|
|
274
|
+
const seen = new Set<string>();
|
|
275
|
+
for (const lane of lanes) {
|
|
276
|
+
if (seen.has(lane.key)) {
|
|
277
|
+
throw new Error(`renderWaveScript: duplicate lane key '${lane.key}'`);
|
|
278
|
+
}
|
|
279
|
+
if (!RUN_KEY_PATTERN.test(lane.key)) {
|
|
280
|
+
throw new Error(
|
|
281
|
+
`renderWaveScript: lane key '${lane.key}' violates the pi-subagents run-key contract`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
seen.add(lane.key);
|
|
285
|
+
}
|
|
286
|
+
const items = lanes.map((lane) => ({
|
|
287
|
+
key: lane.key,
|
|
288
|
+
agent: lane.agent,
|
|
289
|
+
task: lane.task,
|
|
290
|
+
label: lane.label ?? lane.key,
|
|
291
|
+
...(lane.phase !== undefined ? { phase: lane.phase } : {}),
|
|
292
|
+
...(lane.outputSchema !== undefined ? { outputSchema: lane.outputSchema } : {}),
|
|
293
|
+
}));
|
|
294
|
+
return (
|
|
295
|
+
`const reports = await runs.all(${JSON.stringify(items, null, 2)});\n` +
|
|
296
|
+
"return reports.map(({key, ok, error, structuredOutput}) => " +
|
|
297
|
+
"({key, ok, error: error ?? null, report: structuredOutput ?? null}));"
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ------------------------------------------------------------------------------- the runner
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The module-owned wave timeout default: a deliberate tightening vs the 30-minute foreground
|
|
305
|
+
* default the prompt-mechanics wave rode. Per-flow `spec.timeoutMs` overrides; the default is
|
|
306
|
+
* overridable for tests via PERK_WAVE_TIMEOUT_MS.
|
|
307
|
+
*/
|
|
308
|
+
export const WAVE_TIMEOUT_MS = 15 * 60_000;
|
|
309
|
+
|
|
310
|
+
function waveTimeoutMs(): number {
|
|
311
|
+
const raw = Number(process.env.PERK_WAVE_TIMEOUT_MS ?? "");
|
|
312
|
+
return Number.isFinite(raw) && raw > 0 ? raw : WAVE_TIMEOUT_MS;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function waveFailure(
|
|
316
|
+
reason: WaveFailureReason,
|
|
317
|
+
detail: string,
|
|
318
|
+
receipt: WaveScriptReceipt,
|
|
319
|
+
): WaveResult {
|
|
320
|
+
return { complete: false, reports: [], failures: [{ key: null, reason, detail }], receipt };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The judgment-bearing pieces a script run needs (the lane-free slice of `WaveSpec`). */
|
|
324
|
+
export interface WaveScriptSpec {
|
|
325
|
+
/** Flow name for error detail/trace (e.g. "pr-review-dynamic"). */
|
|
326
|
+
flow: string;
|
|
327
|
+
/** The complete, module-rendered workflowScript (never model-authored). */
|
|
328
|
+
workflowScript: string;
|
|
329
|
+
/** Workflow-level default → the engine injects a `structured_output` tool into each child. */
|
|
330
|
+
outputSchema: object;
|
|
331
|
+
/** Workflow-level model default (per-item `model` fields in the script override it). */
|
|
332
|
+
model?: string;
|
|
333
|
+
/** Module default (`WAVE_TIMEOUT_MS`) when omitted. */
|
|
334
|
+
timeoutMs?: number;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** A script run's outcome: the raw `workflow.value` on success, one wave-level failure otherwise. */
|
|
338
|
+
export type WaveScriptResult =
|
|
339
|
+
| { ok: true; value: unknown; receipt: WaveScriptReceipt }
|
|
340
|
+
| { ok: false; failure: WaveFailure; receipt: WaveScriptReceipt };
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* A launched (or launch-refused) script run. On `ok: true` the run is LIVE: `handle` is the
|
|
344
|
+
* detached async run, and `result` settles when the back half finishes (completion wait under
|
|
345
|
+
* the module-owned timeout, AbortSignal honor, best-effort stop on timeout/cancel, the durable
|
|
346
|
+
* aggregate read, receipt assembly, unsubscribe-on-settle). `result` NEVER rejects — every arm
|
|
347
|
+
* normalizes into `WaveScriptResult`, so an uncollected wave can never become an unhandled
|
|
348
|
+
* rejection. Pre-spawn failures (aborted-before-launch, ping fail/null, spawn throw) take the
|
|
349
|
+
* `ok: false` arm with the same failure/receipt values the blocking runner reports, and the
|
|
350
|
+
* completion subscription is released immediately.
|
|
351
|
+
*/
|
|
352
|
+
export type WaveScriptStart =
|
|
353
|
+
| { ok: true; handle: WaveRunHandle; result: Promise<WaveScriptResult> }
|
|
354
|
+
| { ok: false; failure: WaveFailure; receipt: WaveScriptReceipt };
|
|
355
|
+
|
|
356
|
+
function errorDetail(error: unknown): string {
|
|
357
|
+
return error instanceof Error ? error.message : String(error);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
361
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Normalize the aggregate's entries against the expected lane keys (defensive — the module
|
|
366
|
+
* rendered the script, but the aggregate crossed a process boundary). Unknown extra keys are
|
|
367
|
+
* ignored: the module owns the script, so extras cannot occur without upstream drift, and the
|
|
368
|
+
* per-lane reasons below already make the wave incomplete under `strict`. Exported for the
|
|
369
|
+
* per-flow entrypoints whose scripts produce the same compact lane projection (e.g. the
|
|
370
|
+
* dynamic-review sibling normalizing against its runtime-selected keys).
|
|
371
|
+
*/
|
|
372
|
+
export function normalizeLanes(
|
|
373
|
+
keys: string[],
|
|
374
|
+
entries: unknown[],
|
|
375
|
+
): { reports: WaveReport[]; failures: WaveFailure[] } {
|
|
376
|
+
const reports: WaveReport[] = [];
|
|
377
|
+
const failures: WaveFailure[] = [];
|
|
378
|
+
for (const key of keys) {
|
|
379
|
+
const lane = { key };
|
|
380
|
+
const entry = entries.find((e) => isRecord(e) && e.key === lane.key);
|
|
381
|
+
if (!isRecord(entry)) {
|
|
382
|
+
failures.push({
|
|
383
|
+
key: lane.key,
|
|
384
|
+
reason: "missing-lane",
|
|
385
|
+
detail: `lane '${lane.key}' is absent from the wave aggregate`,
|
|
386
|
+
});
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
if (entry.ok === true) {
|
|
390
|
+
const report = entry.report;
|
|
391
|
+
if (isRecord(report)) {
|
|
392
|
+
reports.push({ key: lane.key, report });
|
|
393
|
+
} else if (report === null || report === undefined) {
|
|
394
|
+
failures.push({
|
|
395
|
+
key: lane.key,
|
|
396
|
+
reason: "lane-failed",
|
|
397
|
+
detail:
|
|
398
|
+
typeof entry.error === "string" && entry.error !== ""
|
|
399
|
+
? entry.error
|
|
400
|
+
: `lane '${lane.key}' resolved without a schema-valid report`,
|
|
401
|
+
});
|
|
402
|
+
} else {
|
|
403
|
+
failures.push({
|
|
404
|
+
key: lane.key,
|
|
405
|
+
reason: "malformed-report",
|
|
406
|
+
detail: `lane '${lane.key}' carries a non-object report (${Array.isArray(report) ? "array" : typeof report})`,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
} else if (entry.ok === false) {
|
|
410
|
+
failures.push({
|
|
411
|
+
key: lane.key,
|
|
412
|
+
reason: "lane-failed",
|
|
413
|
+
detail:
|
|
414
|
+
typeof entry.error === "string" && entry.error !== ""
|
|
415
|
+
? entry.error
|
|
416
|
+
: `lane '${lane.key}' failed without error detail`,
|
|
417
|
+
});
|
|
418
|
+
} else {
|
|
419
|
+
failures.push({
|
|
420
|
+
key: lane.key,
|
|
421
|
+
reason: "malformed-report",
|
|
422
|
+
detail: `lane '${lane.key}' aggregate entry has no boolean 'ok'`,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return { reports, failures };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Start one module-rendered workflowScript through the adapter — the non-blocking front half:
|
|
431
|
+
* capability ping → subscribe-before-spawn (the completion-before-reply buffer) → async spawn.
|
|
432
|
+
* On success the back half (block on the async-complete event under the module-owned timeout,
|
|
433
|
+
* abortable → best-effort stop on timeout/cancel → read the durable aggregate → the
|
|
434
|
+
* `state !== "complete"` / unreadable arms) runs behind the returned `result` promise, which
|
|
435
|
+
* never rejects. The shared operational core under every runner; per-flow value normalization
|
|
436
|
+
* stays with the caller.
|
|
437
|
+
*/
|
|
438
|
+
export async function startWaveScript(
|
|
439
|
+
adapter: WaveAdapter,
|
|
440
|
+
spec: WaveScriptSpec,
|
|
441
|
+
signal?: AbortSignal,
|
|
442
|
+
): Promise<WaveScriptStart> {
|
|
443
|
+
// The receipt is assembled in EVERY terminal arm — write-only telemetry: nothing below reads
|
|
444
|
+
// it back into the ok/failure decision.
|
|
445
|
+
const receiptOf = (
|
|
446
|
+
state: WaveReceiptState,
|
|
447
|
+
spawned: WaveRunHandle | null,
|
|
448
|
+
completion?: WaveCompletion,
|
|
449
|
+
): WaveScriptReceipt => ({
|
|
450
|
+
...(spawned !== null ? { runId: spawned.asyncId, asyncDir: spawned.asyncDir } : {}),
|
|
451
|
+
state,
|
|
452
|
+
children: completion?.children ?? [],
|
|
453
|
+
});
|
|
454
|
+
const startFailure = (
|
|
455
|
+
reason: WaveFailureReason,
|
|
456
|
+
detail: string,
|
|
457
|
+
receipt: WaveScriptReceipt,
|
|
458
|
+
): WaveScriptStart => ({
|
|
459
|
+
ok: false,
|
|
460
|
+
failure: { key: null, reason, detail },
|
|
461
|
+
receipt,
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
// Read through a closure so TS's readonly-property narrowing never staples the first
|
|
465
|
+
// check's `false` onto the post-await re-check (the signal CAN flip during an await).
|
|
466
|
+
const aborted = (): boolean => signal?.aborted === true;
|
|
467
|
+
const cancelledBeforeLaunch = (): WaveScriptStart =>
|
|
468
|
+
startFailure(
|
|
469
|
+
"cancelled",
|
|
470
|
+
`wave '${spec.flow}' was cancelled before launch`,
|
|
471
|
+
receiptOf("cancelled", null),
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
if (aborted()) return cancelledBeforeLaunch();
|
|
475
|
+
|
|
476
|
+
// 1. Capability check — the loud-degrade arm: the result explicitly names the wave
|
|
477
|
+
// unavailable; callers surface it, never silently fall back to model-authored scripts.
|
|
478
|
+
let ping: WavePing | null;
|
|
479
|
+
try {
|
|
480
|
+
ping = await adapter.ping();
|
|
481
|
+
} catch (error) {
|
|
482
|
+
return startFailure(
|
|
483
|
+
"unavailable",
|
|
484
|
+
`subagent RPC ping failed: ${errorDetail(error)}`,
|
|
485
|
+
receiptOf("unavailable", null),
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
if (ping === null) {
|
|
489
|
+
return startFailure(
|
|
490
|
+
"unavailable",
|
|
491
|
+
"pi-subagents did not advertise the report-wave capabilities (ping failed or incomplete)",
|
|
492
|
+
receiptOf("unavailable", null),
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// An abort can arrive WHILE the ping await is pending — re-check before subscribe/spawn so a
|
|
497
|
+
// cancelled wave never launches (the pre-launch check alone leaves this window open).
|
|
498
|
+
if (aborted()) return cancelledBeforeLaunch();
|
|
499
|
+
|
|
500
|
+
// 2. Subscribe BEFORE spawn: a completion can arrive before the spawn reply resolves (the
|
|
501
|
+
// completion-before-reply race) — every completion is buffered and re-checked once the
|
|
502
|
+
// handle is known.
|
|
503
|
+
let handle: WaveRunHandle | null = null;
|
|
504
|
+
let notifyMatch: (() => void) | null = null;
|
|
505
|
+
const buffered: WaveCompletion[] = [];
|
|
506
|
+
const matchesHandle = (completion: WaveCompletion): boolean =>
|
|
507
|
+
handle !== null &&
|
|
508
|
+
((completion.asyncDir !== undefined && completion.asyncDir === handle.asyncDir) ||
|
|
509
|
+
(completion.asyncId !== undefined && completion.asyncId === handle.asyncId));
|
|
510
|
+
const unsubscribe = adapter.onComplete((completion) => {
|
|
511
|
+
buffered.push(completion);
|
|
512
|
+
if (matchesHandle(completion) && notifyMatch !== null) notifyMatch();
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
// 3. Spawn: async-only, ephemeral, fresh-context — the module fixes those; the flow's spec
|
|
516
|
+
// supplies the judgment-bearing pieces (lanes, schema, model, policy).
|
|
517
|
+
const timeoutMs = spec.timeoutMs ?? waveTimeoutMs();
|
|
518
|
+
try {
|
|
519
|
+
handle = await adapter.spawn({
|
|
520
|
+
workflowScript: spec.workflowScript,
|
|
521
|
+
async: true,
|
|
522
|
+
mission: false,
|
|
523
|
+
context: "fresh",
|
|
524
|
+
acceptance: WAVE_ACCEPTANCE,
|
|
525
|
+
outputSchema: spec.outputSchema,
|
|
526
|
+
...(spec.model !== undefined ? { model: spec.model } : {}),
|
|
527
|
+
timeoutMs,
|
|
528
|
+
});
|
|
529
|
+
} catch (error) {
|
|
530
|
+
unsubscribe();
|
|
531
|
+
return startFailure(
|
|
532
|
+
"spawn-failed",
|
|
533
|
+
`wave spawn failed: ${errorDetail(error)}`,
|
|
534
|
+
receiptOf("spawn-failed", null),
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
const spawned = handle;
|
|
538
|
+
|
|
539
|
+
const scriptFailure = (
|
|
540
|
+
reason: WaveFailureReason,
|
|
541
|
+
detail: string,
|
|
542
|
+
receipt: WaveScriptReceipt,
|
|
543
|
+
): WaveScriptResult => ({
|
|
544
|
+
ok: false,
|
|
545
|
+
failure: { key: null, reason, detail },
|
|
546
|
+
receipt,
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
// The back half: every arm below RETURNS a normalized `WaveScriptResult` (never throws), so
|
|
550
|
+
// `result` never rejects; the subscription is released exactly when it settles.
|
|
551
|
+
const settle = async (): Promise<WaveScriptResult> => {
|
|
552
|
+
try {
|
|
553
|
+
// 4. Block on completion with the module-owned timeout; honor the caller's AbortSignal.
|
|
554
|
+
const outcome = await new Promise<"complete" | "timeout" | "cancelled">((resolve) => {
|
|
555
|
+
if (buffered.some(matchesHandle)) {
|
|
556
|
+
resolve("complete");
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
const settleOutcome = (value: "complete" | "timeout" | "cancelled"): void => {
|
|
560
|
+
clearTimeout(timer);
|
|
561
|
+
signal?.removeEventListener("abort", onAbort);
|
|
562
|
+
notifyMatch = null;
|
|
563
|
+
resolve(value);
|
|
564
|
+
};
|
|
565
|
+
const timer = setTimeout(() => settleOutcome("timeout"), timeoutMs);
|
|
566
|
+
const onAbort = (): void => settleOutcome("cancelled");
|
|
567
|
+
notifyMatch = () => settleOutcome("complete");
|
|
568
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
569
|
+
if (signal?.aborted === true) settleOutcome("cancelled");
|
|
570
|
+
});
|
|
571
|
+
if (outcome !== "complete") {
|
|
572
|
+
// Best-effort stop — adapters never throw here by contract, but a broken adapter's error
|
|
573
|
+
// is still swallowed into the detail rather than re-thrown.
|
|
574
|
+
let stopNote = "";
|
|
575
|
+
try {
|
|
576
|
+
await adapter.stop(spawned);
|
|
577
|
+
} catch (error) {
|
|
578
|
+
stopNote = ` (stop failed: ${errorDetail(error)})`;
|
|
579
|
+
}
|
|
580
|
+
return outcome === "timeout"
|
|
581
|
+
? scriptFailure(
|
|
582
|
+
"timeout",
|
|
583
|
+
`wave '${spec.flow}' timed out after ${timeoutMs}ms${stopNote}`,
|
|
584
|
+
receiptOf("timed-out", spawned),
|
|
585
|
+
)
|
|
586
|
+
: scriptFailure(
|
|
587
|
+
"cancelled",
|
|
588
|
+
`wave '${spec.flow}' was cancelled${stopNote}`,
|
|
589
|
+
receiptOf("cancelled", spawned),
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// The MATCHED completion (retained for the receipt — its normalized children are the
|
|
594
|
+
// child-lane identity/artifact trail; an identity-only completion yields empty children).
|
|
595
|
+
const matched = buffered.find(matchesHandle);
|
|
596
|
+
|
|
597
|
+
// 5. Read the durable aggregate; surface the terminal-state arms.
|
|
598
|
+
let aggregate: { state: string; error?: string; value: unknown };
|
|
599
|
+
try {
|
|
600
|
+
aggregate = await adapter.readAggregate(spawned);
|
|
601
|
+
} catch (error) {
|
|
602
|
+
// Aggregate-unreadable: the completion identity is retained — the receipt state derives
|
|
603
|
+
// from the OBSERVED completion (a correlation label, not a verdict; the authoritative
|
|
604
|
+
// failure reason stays in the wave failure).
|
|
605
|
+
return scriptFailure(
|
|
606
|
+
"aggregate-unreadable",
|
|
607
|
+
`wave aggregate unreadable: ${errorDetail(error)}`,
|
|
608
|
+
receiptOf(matched?.success === false ? "failed" : "complete", spawned, matched),
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
if (aggregate.state !== "complete") {
|
|
612
|
+
const detail = aggregate.error !== undefined ? `: ${aggregate.error}` : "";
|
|
613
|
+
return scriptFailure(
|
|
614
|
+
"run-failed",
|
|
615
|
+
`wave run ended '${aggregate.state}'${detail}`,
|
|
616
|
+
receiptOf("failed", spawned, matched),
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
return { ok: true, value: aggregate.value, receipt: receiptOf("complete", spawned, matched) };
|
|
620
|
+
} finally {
|
|
621
|
+
unsubscribe();
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
return { ok: true, handle: spawned, result: settle() };
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Run one module-rendered workflowScript to completion — the blocking form: `startWaveScript` +
|
|
629
|
+
* await its `result` (one operational core, behavior identical to the historical blocking
|
|
630
|
+
* runner).
|
|
631
|
+
*/
|
|
632
|
+
export async function runWaveScript(
|
|
633
|
+
adapter: WaveAdapter,
|
|
634
|
+
spec: WaveScriptSpec,
|
|
635
|
+
signal?: AbortSignal,
|
|
636
|
+
): Promise<WaveScriptResult> {
|
|
637
|
+
const start = await startWaveScript(adapter, spec, signal);
|
|
638
|
+
if (!start.ok) return { ok: false, failure: start.failure, receipt: start.receipt };
|
|
639
|
+
return await start.result;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Enrich receipt children's `agent` from the Perk-owned lane specs by key. Children are never
|
|
644
|
+
* synthesized from lanes — an identity-only completion keeps its empty children (receipt absence
|
|
645
|
+
* degrades correlation, never behavior).
|
|
646
|
+
*/
|
|
647
|
+
function enrichReceipt(receipt: WaveScriptReceipt, lanes: WaveLane[]): WaveScriptReceipt {
|
|
648
|
+
return {
|
|
649
|
+
...receipt,
|
|
650
|
+
children: receipt.children.map((child) => {
|
|
651
|
+
if (child.agent !== undefined) return child;
|
|
652
|
+
const agent = lanes.find((lane) => lane.key === child.key)?.agent;
|
|
653
|
+
return agent === undefined ? child : { ...child, agent };
|
|
654
|
+
}),
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* A launched (or launch-failed) report wave — the lane-level sibling of `WaveScriptStart`. On
|
|
660
|
+
* `ok: true` the wave is LIVE: `result` settles into the normalized `WaveResult` (lane
|
|
661
|
+
* normalization + completeness policy + receipt enrichment) and never rejects. On `ok: false`
|
|
662
|
+
* the launch failure is already normalized into a `WaveResult` (receipt included) — no promise
|
|
663
|
+
* to await, nothing left running.
|
|
664
|
+
*/
|
|
665
|
+
export type ReportWaveStart =
|
|
666
|
+
| { ok: true; handle: WaveRunHandle; result: Promise<WaveResult> }
|
|
667
|
+
| { ok: false; result: WaveResult };
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Settle one script outcome into the lane-level `WaveResult`: receipt enrichment, the
|
|
671
|
+
* workflow.value array check, per-lane-key normalization, and the completeness policy — the
|
|
672
|
+
* single back half both the blocking runner and the streaming sibling apply.
|
|
673
|
+
*/
|
|
674
|
+
function settleReportWave(run: WaveScriptResult, spec: WaveSpec): WaveResult {
|
|
675
|
+
const receipt = enrichReceipt(run.receipt, spec.lanes);
|
|
676
|
+
if (!run.ok) {
|
|
677
|
+
return { complete: false, reports: [], failures: [run.failure], receipt };
|
|
678
|
+
}
|
|
679
|
+
if (!Array.isArray(run.value)) {
|
|
680
|
+
return waveFailure(
|
|
681
|
+
"aggregate-unreadable",
|
|
682
|
+
"wave aggregate carries no workflow.value array (the script's explicit return is missing)",
|
|
683
|
+
receipt,
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const { reports, failures } = normalizeLanes(
|
|
688
|
+
spec.lanes.map((lane) => lane.key),
|
|
689
|
+
run.value,
|
|
690
|
+
);
|
|
691
|
+
const complete =
|
|
692
|
+
spec.completeness === "strict"
|
|
693
|
+
? failures.length === 0
|
|
694
|
+
: failures.every((failure) => failure.key !== null);
|
|
695
|
+
return { complete, reports, failures, receipt };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Start a report wave without blocking on completion: render the all-settled lane script (the
|
|
700
|
+
* programmer-error throws — empty lanes / duplicate keys — are preserved), launch it via
|
|
701
|
+
* `startWaveScript`, and on success return the run handle plus a `result` promise that applies
|
|
702
|
+
* the shared settle (normalization + completeness + receipt enrichment) when the run finishes.
|
|
703
|
+
* A launch failure comes back as an already-settled, normalized `WaveResult`.
|
|
704
|
+
*/
|
|
705
|
+
export async function startReportWave(
|
|
706
|
+
adapter: WaveAdapter,
|
|
707
|
+
spec: WaveSpec,
|
|
708
|
+
signal?: AbortSignal,
|
|
709
|
+
): Promise<ReportWaveStart> {
|
|
710
|
+
// Programmer-error validation first (throws): the script render is spec-only.
|
|
711
|
+
const workflowScript = renderWaveScript(spec.lanes);
|
|
712
|
+
|
|
713
|
+
const start = await startWaveScript(
|
|
714
|
+
adapter,
|
|
715
|
+
{
|
|
716
|
+
flow: spec.flow,
|
|
717
|
+
workflowScript,
|
|
718
|
+
outputSchema: spec.outputSchema,
|
|
719
|
+
...(spec.model !== undefined ? { model: spec.model } : {}),
|
|
720
|
+
...(spec.timeoutMs !== undefined ? { timeoutMs: spec.timeoutMs } : {}),
|
|
721
|
+
},
|
|
722
|
+
signal,
|
|
723
|
+
);
|
|
724
|
+
if (!start.ok) {
|
|
725
|
+
return {
|
|
726
|
+
ok: false,
|
|
727
|
+
result: settleReportWave({ ok: false, failure: start.failure, receipt: start.receipt }, spec),
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
return {
|
|
731
|
+
ok: true,
|
|
732
|
+
handle: start.handle,
|
|
733
|
+
result: start.result.then((run) => settleReportWave(run, spec)),
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Run a report wave to completion — the blocking form: `startReportWave` + await its `result`
|
|
739
|
+
* (one operational core). Every operational failure normalizes into `WaveResult` — the only
|
|
740
|
+
* throws are programmer errors (empty lanes / duplicate keys, via `renderWaveScript`).
|
|
741
|
+
*/
|
|
742
|
+
export async function runReportWave(
|
|
743
|
+
adapter: WaveAdapter,
|
|
744
|
+
spec: WaveSpec,
|
|
745
|
+
signal?: AbortSignal,
|
|
746
|
+
): Promise<WaveResult> {
|
|
747
|
+
const start = await startReportWave(adapter, spec, signal);
|
|
748
|
+
return start.ok ? await start.result : start.result;
|
|
749
|
+
}
|