@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,491 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load-time graph validation for `Workflow` objects.
|
|
3
|
+
*
|
|
4
|
+
* Catches the wiring mistakes a TS type system can't reach on its own:
|
|
5
|
+
* unknown edge sources/targets, unreachable stages, missing terminals,
|
|
6
|
+
* predicate functions that return targets outside the stage set.
|
|
7
|
+
*
|
|
8
|
+
* `validateWorkflow` returns a flat array of `WorkflowValidationIssue`s — errors
|
|
9
|
+
* for problems that would crash the runner, warnings for shapes that
|
|
10
|
+
* work but probably aren't what the author intended (unreachable stages,
|
|
11
|
+
* implicit terminals via missing edges). The load pipeline can choose
|
|
12
|
+
* to halt on any error and surface warnings non-fatally.
|
|
13
|
+
*
|
|
14
|
+
* No I/O, no throws — purely a graph walk + predicate probe.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
type EdgeTarget,
|
|
19
|
+
marksReadsData,
|
|
20
|
+
ON_INVALID_VALUES,
|
|
21
|
+
SESSION_POLICIES,
|
|
22
|
+
STAGE_KINDS,
|
|
23
|
+
STOP,
|
|
24
|
+
type StageDef,
|
|
25
|
+
type Workflow,
|
|
26
|
+
} from "./api.js";
|
|
27
|
+
import type { ConfigLayer } from "./layers.js";
|
|
28
|
+
import {
|
|
29
|
+
MAX_VALIDATION_RETRIES,
|
|
30
|
+
MAX_VALIDATION_RETRY_TIMEOUT_MS,
|
|
31
|
+
MIN_VALIDATION_RETRIES,
|
|
32
|
+
MIN_VALIDATION_RETRY_TIMEOUT_MS,
|
|
33
|
+
} from "./validate-output.js";
|
|
34
|
+
|
|
35
|
+
// ===========================================================================
|
|
36
|
+
// Issue shape
|
|
37
|
+
// ===========================================================================
|
|
38
|
+
|
|
39
|
+
export interface WorkflowValidationIssue {
|
|
40
|
+
workflow: string;
|
|
41
|
+
stage?: string;
|
|
42
|
+
severity: "error" | "warning";
|
|
43
|
+
message: string;
|
|
44
|
+
/**
|
|
45
|
+
* Populated by `load.ts` after aggregation — the layer the workflow came
|
|
46
|
+
* from. `validateWorkflow` itself doesn't know about layers; the loader
|
|
47
|
+
* is the seam that has both `workflowSources` and the issue list in scope.
|
|
48
|
+
*/
|
|
49
|
+
layer?: ConfigLayer;
|
|
50
|
+
/** Source path (rpiv.config.ts) when the layer is user or project. */
|
|
51
|
+
path?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ===========================================================================
|
|
55
|
+
// Public — validateWorkflow
|
|
56
|
+
// ===========================================================================
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Validate one workflow. Aggregates all issues; never short-circuits. Caller
|
|
60
|
+
* decides what's fatal — `severity === "error"` is the runner-blocking set.
|
|
61
|
+
*/
|
|
62
|
+
export function validateWorkflow(workflow: Workflow): WorkflowValidationIssue[] {
|
|
63
|
+
const issues: WorkflowValidationIssue[] = [];
|
|
64
|
+
|
|
65
|
+
checkWorkflowName(workflow, issues);
|
|
66
|
+
|
|
67
|
+
if (!workflow.stages[workflow.start]) {
|
|
68
|
+
issues.push(error(workflow.name, undefined, `start stage "${workflow.start}" is not declared in stages`));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
checkEdgeKeys(workflow, issues);
|
|
72
|
+
checkEdgeTargets(workflow, issues);
|
|
73
|
+
checkMissingEdges(workflow, issues);
|
|
74
|
+
// Skip reachability when an EdgeFn lacks `.targets` — the BFS would emit
|
|
75
|
+
// "unreachable from start" cascades whose root cause is the upstream error
|
|
76
|
+
// already reported by checkEdgeTargets.
|
|
77
|
+
const hasUnenumerableEdge = issues.some((i) => /\.targets` metadata/.test(i.message));
|
|
78
|
+
if (!hasUnenumerableEdge) checkReachability(workflow, issues);
|
|
79
|
+
checkStageSemantics(workflow, issues);
|
|
80
|
+
checkPredicateSchemas(workflow, issues);
|
|
81
|
+
checkReadsReferences(workflow, issues);
|
|
82
|
+
|
|
83
|
+
return issues;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ===========================================================================
|
|
87
|
+
// Individual checks
|
|
88
|
+
// ===========================================================================
|
|
89
|
+
|
|
90
|
+
/** `name` is what users type as `/wf <name>` — empty string makes the workflow unreachable. */
|
|
91
|
+
function checkWorkflowName(w: Workflow, issues: WorkflowValidationIssue[]): void {
|
|
92
|
+
if (typeof w.name !== "string" || w.name.length === 0) {
|
|
93
|
+
issues.push(error("(anonymous)", undefined, "workflow name must be a non-empty string"));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Every key in `edges` must be a declared stage. */
|
|
98
|
+
function checkEdgeKeys(w: Workflow, issues: WorkflowValidationIssue[]): void {
|
|
99
|
+
for (const from of Object.keys(w.edges)) {
|
|
100
|
+
if (!w.stages[from]) {
|
|
101
|
+
issues.push(error(w.name, from, `edges["${from}"] references a stage that's not declared in stages`));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Every edge target must resolve to a declared stage or the `"stop"` sentinel.
|
|
108
|
+
* String targets are checked directly. `EdgeFn` targets are checked via the
|
|
109
|
+
* paired `checkEdgeFnTargets` (emits the no-`.targets` error) and enumerated
|
|
110
|
+
* via the pure `enumerateTargets`.
|
|
111
|
+
*/
|
|
112
|
+
function checkEdgeTargets(w: Workflow, issues: WorkflowValidationIssue[]): void {
|
|
113
|
+
for (const [from, target] of Object.entries(w.edges)) {
|
|
114
|
+
checkEdgeFnTargets(target, { workflow: w.name, from }, issues);
|
|
115
|
+
for (const candidate of enumerateTargets(target)) {
|
|
116
|
+
if (candidate === STOP) continue;
|
|
117
|
+
if (!w.stages[candidate]) {
|
|
118
|
+
issues.push(
|
|
119
|
+
error(w.name, from, `edges["${from}"] resolves to "${candidate}" which is not declared in stages`),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Stages with no outgoing edge are implicit terminals — usually a missing connection. */
|
|
127
|
+
function checkMissingEdges(w: Workflow, issues: WorkflowValidationIssue[]): void {
|
|
128
|
+
for (const name of Object.keys(w.stages)) {
|
|
129
|
+
if (!(name in w.edges)) {
|
|
130
|
+
issues.push(
|
|
131
|
+
warning(
|
|
132
|
+
w.name,
|
|
133
|
+
name,
|
|
134
|
+
`stage "${name}" has no edge — treated as terminal; declare \`${name}: "stop"\` to be explicit`,
|
|
135
|
+
),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* BFS from `start`; every declared stage should be reachable. Orphans aren't
|
|
143
|
+
* a runner error (they can't fire) but they're almost always a mistake worth
|
|
144
|
+
* surfacing.
|
|
145
|
+
*/
|
|
146
|
+
function checkReachability(w: Workflow, issues: WorkflowValidationIssue[]): void {
|
|
147
|
+
if (!w.stages[w.start]) return; // already reported by start-check
|
|
148
|
+
|
|
149
|
+
const reachable = new Set<string>();
|
|
150
|
+
const frontier: string[] = [w.start];
|
|
151
|
+
while (frontier.length > 0) {
|
|
152
|
+
const cur = frontier.shift()!;
|
|
153
|
+
if (reachable.has(cur)) continue;
|
|
154
|
+
reachable.add(cur);
|
|
155
|
+
|
|
156
|
+
const target = w.edges[cur];
|
|
157
|
+
if (target === undefined || target === STOP) continue;
|
|
158
|
+
|
|
159
|
+
for (const next of enumerateTargets(target)) {
|
|
160
|
+
if (next !== STOP && w.stages[next] && !reachable.has(next)) frontier.push(next);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
for (const name of Object.keys(w.stages)) {
|
|
165
|
+
if (!reachable.has(name)) {
|
|
166
|
+
issues.push(warning(w.name, name, `stage "${name}" is unreachable from start "${w.start}"`));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Per-stage semantic checks — bounds and enums that the TS type system narrows
|
|
173
|
+
* at edit time but jiti erases at runtime. A user-authored config can ship any
|
|
174
|
+
* numeric `maxRetries` or any string for `onInvalid`; this
|
|
175
|
+
* pass catches them at load time. Each check is a focused helper so the
|
|
176
|
+
* orchestrator reads top-down and individual rules can be exercised in
|
|
177
|
+
* isolation.
|
|
178
|
+
*/
|
|
179
|
+
function checkStageSemantics(w: Workflow, issues: WorkflowValidationIssue[]): void {
|
|
180
|
+
for (const [name, stage] of Object.entries(w.stages)) {
|
|
181
|
+
checkRetryBounds(w, name, stage, issues);
|
|
182
|
+
checkTimeoutBounds(w, name, stage, issues);
|
|
183
|
+
checkStageEnums(w, name, stage, issues);
|
|
184
|
+
checkFanoutContinueInvariant(w, name, stage, issues);
|
|
185
|
+
checkInheritsArtifactsKind(w, name, stage, issues);
|
|
186
|
+
checkScriptStageInvariants(w, name, stage, issues);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function checkRetryBounds(w: Workflow, name: string, stage: StageDef, issues: WorkflowValidationIssue[]): void {
|
|
191
|
+
if (stage.maxRetries === undefined) return;
|
|
192
|
+
if (stage.maxRetries < MIN_VALIDATION_RETRIES || stage.maxRetries > MAX_VALIDATION_RETRIES) {
|
|
193
|
+
issues.push(
|
|
194
|
+
error(
|
|
195
|
+
w.name,
|
|
196
|
+
name,
|
|
197
|
+
`maxRetries: ${stage.maxRetries} — must be in [${MIN_VALIDATION_RETRIES}, ${MAX_VALIDATION_RETRIES}]`,
|
|
198
|
+
),
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function checkTimeoutBounds(w: Workflow, name: string, stage: StageDef, issues: WorkflowValidationIssue[]): void {
|
|
204
|
+
if (stage.validateTimeoutMs === undefined) return;
|
|
205
|
+
if (
|
|
206
|
+
stage.validateTimeoutMs < MIN_VALIDATION_RETRY_TIMEOUT_MS ||
|
|
207
|
+
stage.validateTimeoutMs > MAX_VALIDATION_RETRY_TIMEOUT_MS
|
|
208
|
+
) {
|
|
209
|
+
issues.push(
|
|
210
|
+
error(
|
|
211
|
+
w.name,
|
|
212
|
+
name,
|
|
213
|
+
`validateTimeoutMs: ${stage.validateTimeoutMs} — must be in [${MIN_VALIDATION_RETRY_TIMEOUT_MS}, ${MAX_VALIDATION_RETRY_TIMEOUT_MS}]`,
|
|
214
|
+
),
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function checkStageEnums(w: Workflow, name: string, stage: StageDef, issues: WorkflowValidationIssue[]): void {
|
|
220
|
+
if (stage.onInvalid !== undefined && !(ON_INVALID_VALUES as readonly string[]).includes(stage.onInvalid)) {
|
|
221
|
+
issues.push(
|
|
222
|
+
error(w.name, name, `onInvalid: "${stage.onInvalid}" — must be one of ${ON_INVALID_VALUES.join(", ")}`),
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
if (!(STAGE_KINDS as readonly string[]).includes(stage.kind)) {
|
|
226
|
+
issues.push(error(w.name, name, `kind: "${stage.kind}" — must be one of ${STAGE_KINDS.join(", ")}`));
|
|
227
|
+
}
|
|
228
|
+
if (!(SESSION_POLICIES as readonly string[]).includes(stage.sessionPolicy)) {
|
|
229
|
+
issues.push(
|
|
230
|
+
error(w.name, name, `sessionPolicy: "${stage.sessionPolicy}" — must be one of ${SESSION_POLICIES.join(", ")}`),
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
if (stage.kind === "produces" && !stage.outcome && !stage.run) {
|
|
234
|
+
issues.push(
|
|
235
|
+
error(
|
|
236
|
+
w.name,
|
|
237
|
+
name,
|
|
238
|
+
`stage "${name}" has kind "produces" but no \`outcome\` — ` +
|
|
239
|
+
"there is no framework default for produces stages. Wire `outcome: rpivArtifactMdOutcome` " +
|
|
240
|
+
"(from @juicesharp/rpiv-pi) or supply your own `{ collector, parser? }`.",
|
|
241
|
+
),
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Fanout requires per-unit session isolation — `continue` would replay the
|
|
248
|
+
* prior unit's branch into the next unit's session. The runner enforces
|
|
249
|
+
* this at dispatch (`enforceSessionInvariants`); surfacing it at load
|
|
250
|
+
* time gives user-authored configs a targeted error instead of a generic
|
|
251
|
+
* chain-advance failure on first invocation.
|
|
252
|
+
*
|
|
253
|
+
* The invariant is keyed on the stage's `fanout` field — not on a skill
|
|
254
|
+
* name — keeping the package skill-agnostic: any stage opting into
|
|
255
|
+
* fanout must use `sessionPolicy: "fresh"` regardless of what skill it
|
|
256
|
+
* dispatches.
|
|
257
|
+
*/
|
|
258
|
+
function checkFanoutContinueInvariant(
|
|
259
|
+
w: Workflow,
|
|
260
|
+
name: string,
|
|
261
|
+
stage: StageDef,
|
|
262
|
+
issues: WorkflowValidationIssue[],
|
|
263
|
+
): void {
|
|
264
|
+
if (stage.fanout && stage.sessionPolicy === "continue") {
|
|
265
|
+
issues.push(
|
|
266
|
+
error(
|
|
267
|
+
w.name,
|
|
268
|
+
name,
|
|
269
|
+
`stage "${name}" cannot combine fanout with sessionPolicy "continue" — fanout requires per-unit session isolation`,
|
|
270
|
+
),
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* `inheritsArtifacts: false` is the `terminal()` factory's mechanism — it
|
|
277
|
+
* tells the runner to bypass upstream-artifact inheritance for a
|
|
278
|
+
* side-effect stage. Setting it on a `produces` stage is meaningless: a
|
|
279
|
+
* `produces` stage emits its own outcome and never consumes the upstream
|
|
280
|
+
* primary artifact in `inputForStage` (the first stage always uses
|
|
281
|
+
* originalInput, and inheritance only affects the prompt arg). Surface
|
|
282
|
+
* the redundancy as a warning so users don't author "off" flags they
|
|
283
|
+
* think are doing something.
|
|
284
|
+
*/
|
|
285
|
+
function checkInheritsArtifactsKind(
|
|
286
|
+
w: Workflow,
|
|
287
|
+
name: string,
|
|
288
|
+
stage: StageDef,
|
|
289
|
+
issues: WorkflowValidationIssue[],
|
|
290
|
+
): void {
|
|
291
|
+
if (stage.inheritsArtifacts === false && stage.kind === "produces") {
|
|
292
|
+
issues.push(
|
|
293
|
+
warning(
|
|
294
|
+
w.name,
|
|
295
|
+
name,
|
|
296
|
+
`stage "${name}" sets \`inheritsArtifacts: false\` on a \`produces\` stage — the flag is the \`terminal()\` factory's mechanism and is only meaningful on side-effect stages`,
|
|
297
|
+
),
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Skillless script stages: presence of `stage.run` declares "the runner
|
|
304
|
+
* calls this TS function instead of dispatching a Pi skill." Four fields
|
|
305
|
+
* are categorically incompatible with that contract — fail loudly at
|
|
306
|
+
* load time so the runner branch can assume the invariant.
|
|
307
|
+
*
|
|
308
|
+
* Mutual-exclusion rules:
|
|
309
|
+
*
|
|
310
|
+
* - `skill` — the function IS the work; a skill body would never
|
|
311
|
+
* be dispatched.
|
|
312
|
+
* - `outcome` — the function returns the `Output` envelope directly;
|
|
313
|
+
* there is no transcript / tool-use stream for a
|
|
314
|
+
* collector to scan.
|
|
315
|
+
* - `fanout` — a TS function can write its own loop; the runner's
|
|
316
|
+
* per-unit session machinery doesn't apply.
|
|
317
|
+
* - `sessionPolicy: "continue"` — there is no Pi session at all on a
|
|
318
|
+
* script stage; nothing to continue.
|
|
319
|
+
*
|
|
320
|
+
* Side-effect script stages with an `outputSchema` get a warning — the
|
|
321
|
+
* function returns `void`, so no data ever flows through the validator.
|
|
322
|
+
*
|
|
323
|
+
* The existing `produces` + `inheritsArtifacts: false` warning
|
|
324
|
+
* (`checkInheritsArtifactsKind`) fires uniformly for both skill and
|
|
325
|
+
* script variants — same author error, same message.
|
|
326
|
+
*/
|
|
327
|
+
function checkScriptStageInvariants(
|
|
328
|
+
w: Workflow,
|
|
329
|
+
name: string,
|
|
330
|
+
stage: StageDef,
|
|
331
|
+
issues: WorkflowValidationIssue[],
|
|
332
|
+
): void {
|
|
333
|
+
if (!stage.run) return;
|
|
334
|
+
|
|
335
|
+
if (stage.skill !== undefined) {
|
|
336
|
+
issues.push(
|
|
337
|
+
error(w.name, name, `stage "${name}": script stages cannot set "skill" (the run function IS the work)`),
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
if (stage.outcome) {
|
|
341
|
+
issues.push(
|
|
342
|
+
error(
|
|
343
|
+
w.name,
|
|
344
|
+
name,
|
|
345
|
+
`stage "${name}": script stages cannot set "outcome" (the run function IS the OutputSpec)`,
|
|
346
|
+
),
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
if (stage.fanout) {
|
|
350
|
+
issues.push(
|
|
351
|
+
error(w.name, name, `stage "${name}": script stages cannot fanout — write a loop inside run() instead`),
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
if (stage.sessionPolicy === "continue") {
|
|
355
|
+
issues.push(
|
|
356
|
+
error(
|
|
357
|
+
w.name,
|
|
358
|
+
name,
|
|
359
|
+
`stage "${name}": script stages cannot use sessionPolicy "continue" (no session to continue)`,
|
|
360
|
+
),
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
if (stage.kind === "side-effect" && stage.outputSchema) {
|
|
364
|
+
issues.push(
|
|
365
|
+
warning(
|
|
366
|
+
w.name,
|
|
367
|
+
name,
|
|
368
|
+
`stage "${name}": outputSchema is meaningless on side-effect script stages — no data to validate`,
|
|
369
|
+
),
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Every name in a stage's `reads:` must be filled by some `produces` stage
|
|
376
|
+
* in the workflow. The publish key is `stage.outcome?.name ?? <record-key>`
|
|
377
|
+
* — same rule the runner enforces at write time (see
|
|
378
|
+
* `resolvePublishName`). Reachability isn't checked here: validating that
|
|
379
|
+
* the producer can actually reach the consumer in the edge graph is a
|
|
380
|
+
* larger graph problem and the static check is intentionally narrow —
|
|
381
|
+
* it answers "does this name correspond to something in the workflow at
|
|
382
|
+
* all?", catching typos and renames; the runtime `ensureNamedReads`
|
|
383
|
+
* preflight handles the "haven't fired yet" case.
|
|
384
|
+
*/
|
|
385
|
+
function checkReadsReferences(w: Workflow, issues: WorkflowValidationIssue[]): void {
|
|
386
|
+
const publishedNames = new Set<string>();
|
|
387
|
+
for (const [name, stage] of Object.entries(w.stages)) {
|
|
388
|
+
if (stage.kind !== "produces") continue;
|
|
389
|
+
publishedNames.add(stage.outcome?.name ?? name);
|
|
390
|
+
}
|
|
391
|
+
for (const [name, stage] of Object.entries(w.stages)) {
|
|
392
|
+
if (!stage.reads?.length) continue;
|
|
393
|
+
for (const read of stage.reads) {
|
|
394
|
+
if (publishedNames.has(read)) continue;
|
|
395
|
+
issues.push(
|
|
396
|
+
error(
|
|
397
|
+
w.name,
|
|
398
|
+
name,
|
|
399
|
+
`stage "${name}" reads "${read}" but no produces stage in this workflow publishes it (check outcome.name or stage record key)`,
|
|
400
|
+
),
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Route edges that read `output.data[field]` (i.e. `defineRoute(...)` with
|
|
408
|
+
* the default `readsData: true`, `gate(...)`, and any future factory that
|
|
409
|
+
* auto-attaches the `READS_DATA` marker) should fire on data the source
|
|
410
|
+
* stage has validated against its `outputSchema`. If the schema is absent,
|
|
411
|
+
* the validation-retry loop never runs and the route may read an undefined
|
|
412
|
+
* field — routing decisions silently default.
|
|
413
|
+
*
|
|
414
|
+
* Routes authored via `defineRoute(targets, fn, { readsData: false })`
|
|
415
|
+
* consult only `state` or `output.meta` and carry no marker — exempt from
|
|
416
|
+
* this lint.
|
|
417
|
+
*/
|
|
418
|
+
function checkPredicateSchemas(w: Workflow, issues: WorkflowValidationIssue[]): void {
|
|
419
|
+
for (const [from, target] of Object.entries(w.edges)) {
|
|
420
|
+
if (typeof target === "string") continue;
|
|
421
|
+
if (!marksReadsData(target)) continue;
|
|
422
|
+
const stage = w.stages[from];
|
|
423
|
+
if (stage && !stage.outputSchema) {
|
|
424
|
+
issues.push(
|
|
425
|
+
warning(
|
|
426
|
+
w.name,
|
|
427
|
+
from,
|
|
428
|
+
`route edge from "${from}" reads output.data but the stage has no outputSchema — routing may fire on un-validated data`,
|
|
429
|
+
),
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ===========================================================================
|
|
436
|
+
// Edge-target enumeration
|
|
437
|
+
// ===========================================================================
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Returns the set of possible string targets an `EdgeTarget` could resolve to.
|
|
441
|
+
* Pure — no issue emission, no caller-supplied discard buffer.
|
|
442
|
+
*
|
|
443
|
+
* - String → singleton.
|
|
444
|
+
* - `EdgeFn` with `.targets` metadata → declared targets.
|
|
445
|
+
* - `EdgeFn` without `.targets` → empty list. The missing-metadata error is
|
|
446
|
+
* the responsibility of `checkEdgeFnTargets` (paired emit-only function);
|
|
447
|
+
* call it alongside `enumerateTargets` only at sites that lint edges
|
|
448
|
+
* (currently `checkEdgeTargets`). Reachability traversal calls only the
|
|
449
|
+
* pure form.
|
|
450
|
+
*/
|
|
451
|
+
function enumerateTargets(target: EdgeTarget): string[] {
|
|
452
|
+
if (typeof target === "string") return [target];
|
|
453
|
+
if (Array.isArray(target.targets) && target.targets.length > 0) return [...target.targets];
|
|
454
|
+
return [];
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Emits the "EdgeFn without `.targets` metadata" error for an `EdgeTarget`
|
|
459
|
+
* that's a hand-rolled `EdgeFn` lacking the marker. Pairs with
|
|
460
|
+
* `enumerateTargets`: lint sites call both; reachability calls only the
|
|
461
|
+
* enumerator. Users authoring routes by hand MUST go through
|
|
462
|
+
* `defineRoute(targets, fn)` so the `.targets` metadata is structurally
|
|
463
|
+
* attached.
|
|
464
|
+
*/
|
|
465
|
+
function checkEdgeFnTargets(
|
|
466
|
+
target: EdgeTarget,
|
|
467
|
+
ctx: { workflow: string; from: string },
|
|
468
|
+
issues: WorkflowValidationIssue[],
|
|
469
|
+
): void {
|
|
470
|
+
if (typeof target === "string") return;
|
|
471
|
+
if (Array.isArray(target.targets) && target.targets.length > 0) return;
|
|
472
|
+
issues.push(
|
|
473
|
+
error(
|
|
474
|
+
ctx.workflow,
|
|
475
|
+
ctx.from,
|
|
476
|
+
`edges["${ctx.from}"] is an EdgeFn without \`.targets\` metadata — use defineRoute([...], fn) or gate() so reachability can enumerate branches`,
|
|
477
|
+
),
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ===========================================================================
|
|
482
|
+
// Issue constructors
|
|
483
|
+
// ===========================================================================
|
|
484
|
+
|
|
485
|
+
function error(workflow: string, stage: string | undefined, message: string): WorkflowValidationIssue {
|
|
486
|
+
return { workflow, stage, severity: "error", message };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function warning(workflow: string, stage: string | undefined, message: string): WorkflowValidationIssue {
|
|
490
|
+
return { workflow, stage, severity: "warning", message };
|
|
491
|
+
}
|