stageflow 0.16.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/README.md +2 -4
  2. package/dist/agent/activity.d.ts +4 -0
  3. package/dist/agent/claudeAdapter.js +38 -0
  4. package/dist/agent/claudeTools.d.ts +1 -4
  5. package/dist/agent/claudeTools.js +1 -6
  6. package/dist/agent/fakeAgent.js +7 -6
  7. package/dist/agent/piAdapter.js +34 -15
  8. package/dist/agent/port.d.ts +1 -1
  9. package/dist/agent/stageTimeout.d.ts +9 -0
  10. package/dist/agent/stageTimeout.js +24 -0
  11. package/dist/cli/graphCommand.d.ts +15 -0
  12. package/dist/cli/graphCommand.js +79 -0
  13. package/dist/cli/graphRender.d.ts +2 -0
  14. package/dist/cli/graphRender.js +85 -0
  15. package/dist/cli/runsCommand.d.ts +1 -1
  16. package/dist/cli/runsCommand.js +37 -0
  17. package/dist/cli.js +12 -0
  18. package/dist/config/cloneChain.d.ts +12 -0
  19. package/dist/config/cloneChain.js +322 -0
  20. package/dist/config/createPipeline.js +101 -21
  21. package/dist/config/createStage.js +7 -0
  22. package/dist/config/loadOutcome.d.ts +1 -0
  23. package/dist/config/loadPipeline.d.ts +2 -0
  24. package/dist/config/loadPipeline.js +34 -22
  25. package/dist/config/loadStage.d.ts +2 -0
  26. package/dist/config/loadStage.js +135 -125
  27. package/dist/config/migrateYaml.js +14 -3
  28. package/dist/config/normalizePipelineStageEntry.d.ts +5 -9
  29. package/dist/config/normalizePipelineStageEntry.js +133 -60
  30. package/dist/config/parsePreEmitChecks.js +2 -2
  31. package/dist/config/pipelineNeeds.d.ts +15 -2
  32. package/dist/config/pipelineNeeds.js +86 -4
  33. package/dist/config/pipelineRoute.d.ts +46 -0
  34. package/dist/config/pipelineRoute.js +215 -0
  35. package/dist/config/pipelineStageKeys.d.ts +2 -0
  36. package/dist/config/pipelineStageKeys.js +15 -4
  37. package/dist/config/printTargetYaml.js +18 -12
  38. package/dist/config/resolveForkEmitContext.d.ts +1 -1
  39. package/dist/config/resolveForkEmitContext.js +3 -26
  40. package/dist/config/resolvePipelineDag.d.ts +1 -3
  41. package/dist/config/resolvePipelineDag.js +143 -186
  42. package/dist/config/routeIf.d.ts +24 -0
  43. package/dist/config/routeIf.js +247 -0
  44. package/dist/config/validateCatalog.d.ts +1 -1
  45. package/dist/config/validateCatalog.js +5 -3
  46. package/dist/config/yamlDialect.d.ts +5 -8
  47. package/dist/config/yamlDialect.js +37 -29
  48. package/dist/envelope/check.d.ts +0 -2
  49. package/dist/envelope/check.js +1 -57
  50. package/dist/envelope/feedbackLoop.js +0 -3
  51. package/dist/envelope/forkChoice.js +1 -1
  52. package/dist/envelope/payloadSchema.d.ts +14 -0
  53. package/dist/envelope/payloadSchema.js +87 -2
  54. package/dist/mcp/catalogTools.js +1 -2
  55. package/dist/mcp/controlTools.js +17 -0
  56. package/dist/package-meta.d.ts +1 -1
  57. package/dist/package-meta.js +1 -1
  58. package/dist/projection/projectRun.d.ts +0 -2
  59. package/dist/projection/projectRun.js +0 -3
  60. package/dist/prompt/priorEnvelope.js +1 -1
  61. package/dist/runstore/port.js +2 -0
  62. package/dist/runstore/stageInstanceId.d.ts +1 -0
  63. package/dist/runstore/stageInstanceId.js +9 -0
  64. package/dist/runtime/cloneSchedule.d.ts +16 -33
  65. package/dist/runtime/cloneSchedule.js +155 -236
  66. package/dist/runtime/envelopeRouting.js +43 -94
  67. package/dist/runtime/forkGeneration.d.ts +6 -2
  68. package/dist/runtime/forkGeneration.js +45 -15
  69. package/dist/runtime/joinReadiness.d.ts +16 -0
  70. package/dist/runtime/joinReadiness.js +156 -0
  71. package/dist/runtime/pipelineScheduler.d.ts +0 -3
  72. package/dist/runtime/pipelineScheduler.js +103 -65
  73. package/dist/runtime/reopenSkipped.d.ts +13 -0
  74. package/dist/runtime/reopenSkipped.js +56 -0
  75. package/dist/runtime/resumeTimedOut.d.ts +33 -0
  76. package/dist/runtime/resumeTimedOut.js +165 -0
  77. package/dist/runtime/routeIfEval.d.ts +3 -0
  78. package/dist/runtime/routeIfEval.js +94 -0
  79. package/dist/runtime/runManager.d.ts +1 -0
  80. package/dist/runtime/runManager.js +76 -0
  81. package/dist/runtime/runRetryCoordinator.js +0 -4
  82. package/dist/runtime/stageAttemptBootstrap.js +60 -11
  83. package/dist/runtime/stageRecovery.js +2 -2
  84. package/dist/runtime/stageRunner.js +6 -2
  85. package/dist/runtime/taskInput.js +1 -13
  86. package/dist/server/http.js +18 -0
  87. package/dist/tools/askOperator.d.ts +1 -0
  88. package/dist/tools/askOperator.js +14 -11
  89. package/dist/tools/emitStageEnvelope.d.ts +3 -3
  90. package/dist/tools/emitStageEnvelope.js +4 -66
  91. package/dist/tools/prepareToolArguments.d.ts +4 -0
  92. package/dist/tools/prepareToolArguments.js +79 -0
  93. package/dist/types/envelope.d.ts +0 -2
  94. package/dist/types/forkChoice.d.ts +0 -16
  95. package/dist/types/pipeline.d.ts +86 -27
  96. package/dist/types/stage.d.ts +11 -7
  97. package/dist/ui/assets/{index-D2Wt8hwH.css → index-CSsDued1.css} +1 -1
  98. package/dist/ui/assets/index-DMVdl1qB.js +119 -0
  99. package/dist/ui/index.html +2 -2
  100. package/package.json +2 -1
  101. package/skills/stageflow-author/SKILL.md +4 -4
  102. package/skills/stageflow-author/assets/examples/branch-decision/hotfix.yaml +10 -2
  103. package/skills/stageflow-author/assets/examples/branch-decision/release-gate.pipeline.yaml +12 -4
  104. package/skills/stageflow-author/assets/examples/branch-decision/run-tests.yaml +6 -4
  105. package/skills/stageflow-author/assets/examples/branch-decision/ship.yaml +9 -1
  106. package/skills/stageflow-author/assets/examples/linear-review/draft.yaml +3 -0
  107. package/skills/stageflow-author/assets/examples/linear-review/publish.yaml +8 -0
  108. package/skills/stageflow-author/assets/examples/linear-review/review-loop.pipeline.yaml +5 -2
  109. package/skills/stageflow-author/assets/examples/linear-review/review.yaml +8 -0
  110. package/skills/stageflow-author/assets/examples/non-sdlc-digest/gather.yaml +3 -0
  111. package/skills/stageflow-author/assets/examples/non-sdlc-digest/research-digest.pipeline.yaml +5 -2
  112. package/skills/stageflow-author/assets/examples/non-sdlc-digest/send.yaml +8 -0
  113. package/skills/stageflow-author/assets/examples/non-sdlc-digest/summarize.yaml +8 -0
  114. package/skills/stageflow-author/references/catalog-mapping.md +159 -50
  115. package/skills/stageflow-author/references/catalog-write-conventions.md +3 -3
  116. package/skills/stageflow-author/references/stage-prompt-template.md +57 -17
  117. package/skills/stageflow-session-capture/SKILL.md +2 -2
  118. package/skills/stageflow-session-capture/assets/example-pipeline/example.pipeline.yaml +3 -1
  119. package/skills/stageflow-session-capture/assets/example-pipeline/implement.yaml +15 -0
  120. package/skills/stageflow-session-capture/assets/example-pipeline/research.yaml +3 -0
  121. package/skills/stageflow-session-capture/references/catalog-authoring.md +4 -2
  122. package/dist/envelope/cloneForks.d.ts +0 -2
  123. package/dist/envelope/cloneForks.js +0 -77
  124. package/dist/ui/assets/index-XWJovnp7.js +0 -118
@@ -1,7 +1,7 @@
1
- import { isAllowedPipelineStageEntryKey } from "./pipelineStageKeys.js";
2
- import { parseExecutionPolicy } from "./parseCompletionContract.js";
3
- import { parsePipelineNeeds, predecessorEdges, toNeedEdges } from "./pipelineNeeds.js";
4
- const ALLOWED_FORK_KEYS = new Set(["select", "allow_none"]);
1
+ import { BODY_KEYS, isAllowedPipelineStageEntryKey } from "./pipelineStageKeys.js";
2
+ import { invertRouteToPredecessorEdges, predecessorEdges } from "./pipelineNeeds.js";
3
+ import { normalizePipelineStageEntries, toWiringRefs, } from "./normalizePipelineStageEntry.js";
4
+ import { toRouteEdges, toRouteLoopEntries } from "./pipelineRoute.js";
5
5
  function pipelineLabel(ctx) {
6
6
  return `Pipeline ${ctx.pipelineId} (${ctx.path})`;
7
7
  }
@@ -11,148 +11,38 @@ function formatError(ctx, message) {
11
11
  function isPlainObject(value) {
12
12
  return typeof value === "object" && value !== null && !Array.isArray(value);
13
13
  }
14
- const ALLOWED_FEEDBACK_LOOP_KEYS = new Set([
15
- "target",
16
- "max_replays",
17
- "on_max_replays",
18
- "replay_session",
19
- ]);
20
- export function parseFeedbackLoopConfig(value, stageId, ctx) {
21
- if (!isPlainObject(value)) {
22
- throw new Error(formatError(ctx, `stage "${stageId}": feedback_loop must be an object`));
23
- }
24
- for (const key of Object.keys(value)) {
25
- if (!ALLOWED_FEEDBACK_LOOP_KEYS.has(key)) {
26
- throw new Error(formatError(ctx, `stage "${stageId}": feedback_loop: unknown key "${key}"`));
27
- }
28
- }
29
- if (Array.isArray(value.target)) {
30
- throw new Error(formatError(ctx, `stage "${stageId}": feedback_loop.target must be a single stage id string, not an array`));
31
- }
32
- if (typeof value.target !== "string" || value.target.trim() === "") {
33
- throw new Error(formatError(ctx, `stage "${stageId}": feedback_loop.target must be a non-empty stage id`));
34
- }
35
- if (!Number.isInteger(value.max_replays) || value.max_replays < 1) {
36
- throw new Error(formatError(ctx, `stage "${stageId}": feedback_loop.max_replays must be a positive integer`));
37
- }
38
- if (value.on_max_replays !== "require_continue" &&
39
- value.on_max_replays !== "wait_for_human") {
40
- throw new Error(formatError(ctx, `stage "${stageId}": feedback_loop.on_max_replays must be "require_continue" or "wait_for_human"`));
41
- }
42
- if (value.replay_session !== "resume" && value.replay_session !== "new_session") {
43
- throw new Error(formatError(ctx, `stage "${stageId}": feedback_loop.replay_session must be "resume" or "new_session"`));
44
- }
45
- return {
46
- target: value.target,
47
- max_replays: value.max_replays,
48
- on_max_replays: value.on_max_replays,
49
- replay_session: value.replay_session,
50
- };
51
- }
52
- export function parsePipelineStageEntries(raw, ctx) {
53
- if (!Array.isArray(raw)) {
54
- throw new Error(formatError(ctx, "stages[] is required"));
55
- }
56
- if (raw.length === 0) {
57
- throw new Error(formatError(ctx, "stages must be non-empty"));
58
- }
59
- const entries = [];
60
- for (let index = 0; index < raw.length; index++) {
61
- const entry = raw[index];
62
- if (typeof entry === "string") {
63
- const hint = entry
64
- ? `invalid stage entry at index ${index}: bare string stage refs are not supported; use { id: "${entry}", uses: "./${entry}.yaml" } or inline body`
65
- : `invalid stage entry at index ${index}: bare string stage refs are not supported; use { id: "…", uses: "./….yaml" } or inline body`;
66
- throw new Error(formatError(ctx, hint));
67
- }
68
- if (!isPlainObject(entry)) {
69
- throw new Error(formatError(ctx, `invalid stage entry at index ${index}`));
70
- }
71
- const keys = Object.keys(entry);
72
- for (const key of keys) {
73
- if (!isAllowedPipelineStageEntryKey(key)) {
74
- throw new Error(formatError(ctx, `invalid stage entry "${String(entry.id ?? index)}": unknown key "${key}"`));
75
- }
14
+ function wrapRawStagesForNormalize(rawStages, ctx) {
15
+ return rawStages.map((raw) => {
16
+ if (!isPlainObject(raw)) {
17
+ return { raw, declaringPath: ctx.path };
76
18
  }
77
- if (typeof entry.id !== "string" || !entry.id) {
78
- throw new Error(formatError(ctx, `invalid stage entry at index ${index}: id must be a non-empty string`));
19
+ const id = typeof raw.id === "string" && raw.id ? raw.id : undefined;
20
+ const hasUses = typeof raw.uses === "string";
21
+ const hasBody = Object.keys(raw).some((key) => BODY_KEYS.has(key) && key !== "skill" && key !== "mcp");
22
+ if (hasUses || hasBody || !id) {
23
+ return { raw, declaringPath: ctx.path };
79
24
  }
80
- let forkValue;
81
- if (entry.fork !== undefined) {
82
- if (!isPlainObject(entry.fork)) {
83
- throw new Error(formatError(ctx, `stage "${entry.id}": fork must be an object`));
84
- }
85
- for (const fk of Object.keys(entry.fork)) {
86
- if (!ALLOWED_FORK_KEYS.has(fk)) {
87
- throw new Error(formatError(ctx, `stage "${entry.id}": fork: unknown key "${fk}"`));
88
- }
89
- }
90
- forkValue = entry.fork;
91
- }
92
- const clonableFields = {
93
- ...(entry.clonable !== undefined ? { clonable: entry.clonable } : {}),
94
- ...(entry.clone_cap !== undefined ? { clone_cap: entry.clone_cap } : {}),
95
- };
96
- const policyOutcome = parseExecutionPolicy(entry, entry.id);
97
- if (!policyOutcome.ok) {
98
- throw new Error(formatError(ctx, policyOutcome.issues[0]?.message ?? "invalid execution policy"));
99
- }
100
- const policyFields = {
101
- ...(policyOutcome.value.completion !== undefined
102
- ? { completion: policyOutcome.value.completion }
103
- : {}),
104
- ...(policyOutcome.value.recovery !== undefined
105
- ? { recovery: policyOutcome.value.recovery }
106
- : {}),
25
+ return {
26
+ raw: { ...raw, uses: `./${id}.yaml` },
27
+ declaringPath: ctx.path,
107
28
  };
108
- const feedbackLoopFields = entry.feedback_loop !== undefined
109
- ? { feedback_loop: parseFeedbackLoopConfig(entry.feedback_loop, entry.id, ctx) }
110
- : {};
111
- if (entry.replay_safe !== undefined && typeof entry.replay_safe !== "boolean") {
112
- throw new Error(formatError(ctx, `stage "${entry.id}": replay_safe must be a boolean`));
113
- }
114
- const replaySafetyFields = entry.replay_safe !== undefined ? { replay_safe: entry.replay_safe } : {};
115
- if (entry.needs === undefined) {
116
- entries.push({
117
- id: entry.id,
118
- ...(forkValue !== undefined ? { fork: forkValue } : {}),
119
- ...clonableFields,
120
- ...policyFields,
121
- ...feedbackLoopFields,
122
- ...replaySafetyFields,
123
- });
124
- continue;
125
- }
126
- const parsedNeeds = parsePipelineNeeds(entry.needs, entry.id);
127
- if (!parsedNeeds.ok) {
128
- throw new Error(formatError(ctx, parsedNeeds.message));
129
- }
130
- entries.push({
131
- id: entry.id,
132
- needs: parsedNeeds.value,
133
- ...(forkValue !== undefined ? { fork: forkValue } : {}),
134
- ...clonableFields,
135
- ...policyFields,
136
- ...feedbackLoopFields,
137
- ...replaySafetyFields,
138
- });
139
- }
140
- return entries;
29
+ });
141
30
  }
142
31
  function normalizeToEdges(entries) {
143
32
  return entries.map((entry, index) => {
144
- const needsEdges = toNeedEdges(entry.needs);
33
+ const needsEdges = [];
34
+ const routeEdges = toRouteEdges(entry.route);
35
+ const routeLoopEntries = toRouteLoopEntries(entry.route);
145
36
  return {
146
37
  id: entry.id,
147
38
  needs: needsEdges.length === 1 ? needsEdges[0].id : null,
148
39
  needsEdges,
40
+ routeEdges,
41
+ routeLoopEntries,
149
42
  stageIndex: index,
150
- ...(entry.fork !== undefined ? { fork: entry.fork } : {}),
151
- ...(entry.clonable !== undefined ? { clonable: entry.clonable } : {}),
152
- ...(entry.clone_cap !== undefined ? { clone_cap: entry.clone_cap } : {}),
43
+ ...(entry.entry !== undefined ? { entry: entry.entry } : {}),
153
44
  ...(entry.completion !== undefined ? { completion: entry.completion } : {}),
154
45
  ...(entry.recovery !== undefined ? { recovery: entry.recovery } : {}),
155
- ...(entry.feedback_loop !== undefined ? { feedback_loop: entry.feedback_loop } : {}),
156
46
  ...(entry.replay_safe !== undefined ? { replay_safe: entry.replay_safe } : {}),
157
47
  };
158
48
  });
@@ -166,16 +56,78 @@ function detectDuplicateIds(edges, ctx) {
166
56
  seen.add(edge.id);
167
57
  }
168
58
  }
169
- function validateNeedsTargets(edges, ctx) {
59
+ function validateRouteTargets(edges, ctx) {
170
60
  const declared = new Set(edges.map((edge) => edge.id));
171
61
  for (const edge of edges) {
172
- for (const parent of edge.needsEdges) {
173
- if (!declared.has(parent.id)) {
174
- throw new Error(formatError(ctx, `stage "${edge.id}" has unknown needs "${parent.id}"`));
62
+ for (const route of edge.routeEdges) {
63
+ if (!declared.has(route.to)) {
64
+ throw new Error(formatError(ctx, `stage "${edge.id}" has unknown route target "${route.to}"`));
175
65
  }
176
66
  }
177
67
  }
178
68
  }
69
+ /**
70
+ * A stage's own resolved node can carry only one `feedback_loop`-shaped
71
+ * replay policy (`ResolvedPipelineStageNode.feedback_loop` is a single
72
+ * object, not a list — the shape the runtime already consumes and that this
73
+ * ticket must not change). A stage declaring more than one `type: loop`
74
+ * route entry has no unambiguous single replay policy to synthesize onto
75
+ * that field, so it is rejected at parse time rather than silently picking
76
+ * one and discarding the rest.
77
+ */
78
+ function validateRouteLoopEntryCount(edges, ctx) {
79
+ for (const edge of edges) {
80
+ if (edge.routeLoopEntries.length > 1) {
81
+ throw new Error(formatError(ctx, `stage "${edge.id}": route supports at most one loop entry, got ${edge.routeLoopEntries.length}`));
82
+ }
83
+ }
84
+ }
85
+ /**
86
+ * Inverts each stage's own outbound `route` entries into predecessor edges on
87
+ * their targets, stored under the same `needs`/`needsEdges` fields the
88
+ * resolver already produces from `needs` — so cycle detection, ancestor
89
+ * computation, topological sort, and childrenOf all pick route-declared
90
+ * fan-in/fan-out up for free, with no changes to that machinery.
91
+ */
92
+ function mergeRouteEdgesIntoNeeds(edges) {
93
+ const byId = new Map(edges.map((edge) => [edge.id, edge]));
94
+ const inbound = invertRouteToPredecessorEdges(edges.map((edge) => ({ id: edge.id, route: edge.routeEdges })));
95
+ for (const [targetId, incoming] of inbound) {
96
+ const target = byId.get(targetId);
97
+ if (!target || incoming.length === 0)
98
+ continue;
99
+ target.needsEdges = [...target.needsEdges, ...incoming];
100
+ target.needs = target.needsEdges.length === 1 ? target.needsEdges[0].id : null;
101
+ }
102
+ }
103
+ /**
104
+ * `entry`/unreachable-stage validation only applies to pipelines that opt into
105
+ * the `route`/`entry` vocabulary at all — a pipeline that only uses `needs`
106
+ * (and never declares `route` or `entry` anywhere) is left completely alone,
107
+ * so existing needs-based pipelines keep working unchanged.
108
+ */
109
+ function validateEntryStageUsage(edges, ctx) {
110
+ const usesRouteVocabulary = edges.some((edge) => edge.entry === true || edge.routeEdges.length > 0);
111
+ if (!usesRouteVocabulary)
112
+ return;
113
+ const hasEntry = edges.some((edge) => edge.entry === true);
114
+ if (!hasEntry) {
115
+ throw new Error(formatError(ctx, "no stage is marked entry: true"));
116
+ }
117
+ const targeted = new Set();
118
+ for (const edge of edges) {
119
+ for (const route of edge.routeEdges) {
120
+ targeted.add(route.to);
121
+ }
122
+ }
123
+ for (const edge of edges) {
124
+ if (edge.entry === true)
125
+ continue;
126
+ if (targeted.has(edge.id))
127
+ continue;
128
+ throw new Error(formatError(ctx, `stage "${edge.id}" is unreachable: not marked entry: true and not targeted by any route entry`));
129
+ }
130
+ }
179
131
  function detectCycle(edges, ctx) {
180
132
  const indegree = new Map();
181
133
  const children = new Map();
@@ -280,6 +232,23 @@ function topologicalSort(edges) {
280
232
  }
281
233
  return sorted;
282
234
  }
235
+ /**
236
+ * Ticket 03: a route loop entry carries the same replay-policy fields as
237
+ * today's `feedback_loop` (just `to` instead of `target`). Synthesizing it
238
+ * into the exact `FeedbackLoopConfig` shape here — onto the same
239
+ * `ResolvedPipelineStageNode.feedback_loop` field the legacy path populates
240
+ * — means `validateFeedbackLoopFields` below and every downstream
241
+ * runtime/executor consumer of `.feedback_loop` need zero changes to also
242
+ * support route-declared loops.
243
+ */
244
+ function toFeedbackLoopConfigFromRouteLoopEntry(entry) {
245
+ return {
246
+ target: entry.to,
247
+ max_replays: entry.max_replays,
248
+ on_max_replays: entry.on_max_replays,
249
+ replay_session: entry.replay_session,
250
+ };
251
+ }
283
252
  function buildResolvedPipelineDag(edges) {
284
253
  const ancestorsById = computeAncestors(edges);
285
254
  const sortedEdges = topologicalSort(edges);
@@ -306,55 +275,22 @@ function buildResolvedPipelineDag(edges) {
306
275
  needsEdges: edge.needsEdges,
307
276
  ancestors: ancestorsById.get(edge.id) ?? [],
308
277
  stageIndex: edge.stageIndex,
309
- ...(edge.fork !== undefined
310
- ? { fork: { select: edge.fork.select, allow_none: edge.fork.allow_none ?? false } }
311
- : {}),
312
- ...(edge.clonable === true
313
- ? { clonable: true, clone_cap: edge.clone_cap ?? 5 }
314
- : {}),
278
+ ...(edge.entry === true ? { entry: true } : {}),
315
279
  ...(edge.completion !== undefined ? { completion: edge.completion } : {}),
316
280
  ...(edge.recovery !== undefined ? { recovery: edge.recovery } : {}),
317
- ...(edge.feedback_loop !== undefined ? { feedback_loop: edge.feedback_loop } : {}),
281
+ ...(edge.routeLoopEntries.length === 1
282
+ ? { feedback_loop: toFeedbackLoopConfigFromRouteLoopEntry(edge.routeLoopEntries[0]) }
283
+ : {}),
318
284
  ...(edge.replay_safe !== undefined ? { replay_safe: edge.replay_safe } : {}),
319
285
  }));
320
286
  return { nodes, roots, childrenOf };
321
287
  }
322
- function validateForkFields(edges, dag, ctx) {
323
- for (const edge of edges) {
324
- if (!edge.fork)
325
- continue;
326
- if (edge.fork.select !== "one" && edge.fork.select !== "subset") {
327
- throw new Error(formatError(ctx, `stage "${edge.id}": fork.select must be "one" or "subset"${edge.fork.select === undefined ? " (missing)" : `, got "${String(edge.fork.select)}"`}`));
328
- }
329
- if ((dag.childrenOf[edge.id] ?? []).length === 0) {
330
- throw new Error(formatError(ctx, `fork on stage "${edge.id}": no children in the DAG`));
331
- }
332
- }
333
- }
334
- function validateClonableFields(edges, dag, ctx) {
335
- for (const edge of edges) {
336
- if (edge.clone_cap !== undefined && edge.clonable !== true) {
337
- throw new Error(formatError(ctx, `stage "${edge.id}": clone_cap requires clonable: true`));
338
- }
339
- if (edge.clonable === true && edge.clone_cap !== undefined) {
340
- if (!Number.isInteger(edge.clone_cap) || edge.clone_cap < 2) {
341
- throw new Error(formatError(ctx, `stage "${edge.id}": clone_cap must be an integer greater than or equal to 2`));
342
- }
343
- }
344
- if (edge.clonable === true && (dag.childrenOf[edge.id] ?? []).length === 0) {
345
- throw new Error(formatError(ctx, `clonable on stage "${edge.id}": no children in the DAG`));
346
- }
347
- }
348
- }
349
288
  function validateFeedbackLoopFields(dag, ctx) {
350
289
  const byId = new Map(dag.nodes.map((node) => [node.id, node]));
351
290
  for (const source of dag.nodes) {
352
291
  const policy = source.feedback_loop;
353
292
  if (!policy)
354
293
  continue;
355
- if (source.clonable === true) {
356
- throw new Error(formatError(ctx, `stage "${source.id}": feedback_loop source cannot be clonable`));
357
- }
358
294
  const targetId = policy.target;
359
295
  const target = byId.get(targetId);
360
296
  if (!target) {
@@ -363,9 +299,6 @@ function validateFeedbackLoopFields(dag, ctx) {
363
299
  if (!source.ancestors.includes(targetId)) {
364
300
  throw new Error(formatError(ctx, `stage "${source.id}": feedback_loop target "${targetId}" must be an earlier ancestor`));
365
301
  }
366
- if (target.clonable === true) {
367
- throw new Error(formatError(ctx, `stage "${source.id}": feedback_loop target "${targetId}" cannot be clonable`));
368
- }
369
302
  const targetIndex = source.ancestors.indexOf(targetId);
370
303
  const route = [...source.ancestors.slice(targetIndex), source.id];
371
304
  const unsafeStage = route.find((stageId) => byId.get(stageId)?.replay_safe === false);
@@ -377,21 +310,39 @@ function validateFeedbackLoopFields(dag, ctx) {
377
310
  export function resolvePipelineDagFromRefs(refs, ctx) {
378
311
  const edges = normalizeToEdges(refs);
379
312
  detectDuplicateIds(edges, ctx);
380
- validateNeedsTargets(edges, ctx);
313
+ validateRouteTargets(edges, ctx);
314
+ validateRouteLoopEntryCount(edges, ctx);
315
+ validateEntryStageUsage(edges, ctx);
316
+ mergeRouteEdgesIntoNeeds(edges);
381
317
  detectCycle(edges, ctx);
382
318
  const stages = edges
383
319
  .slice()
384
320
  .sort((a, b) => a.stageIndex - b.stageIndex)
385
321
  .map((edge) => edge.id);
386
322
  const dag = buildResolvedPipelineDag(edges);
387
- validateForkFields(edges, dag, ctx);
388
- validateClonableFields(edges, dag, ctx);
389
323
  validateFeedbackLoopFields(dag, ctx);
390
324
  return { stages, dag };
391
325
  }
392
326
  export function resolvePipelineDag(rawStages, ctx) {
393
- const entries = parsePipelineStageEntries(rawStages, ctx);
394
- return resolvePipelineDagFromRefs(entries, ctx);
327
+ if (!Array.isArray(rawStages)) {
328
+ throw new Error(formatError(ctx, "stages[] is required"));
329
+ }
330
+ if (rawStages.length === 0) {
331
+ throw new Error(formatError(ctx, "stages must be non-empty"));
332
+ }
333
+ const outcome = normalizePipelineStageEntries(wrapRawStagesForNormalize(rawStages, ctx), ctx);
334
+ if (!outcome.ok) {
335
+ throw new Error(outcome.issues[0]?.message ?? formatError(ctx, "invalid stage entries"));
336
+ }
337
+ const refs = toWiringRefs(outcome.value);
338
+ return resolvePipelineDagFromRefs(refs, ctx);
339
+ }
340
+ function sameNeedIf(a, b) {
341
+ if (a === undefined && b === undefined)
342
+ return true;
343
+ if (a === undefined || b === undefined)
344
+ return false;
345
+ return JSON.stringify(a) === JSON.stringify(b);
395
346
  }
396
347
  export function areResolvedDagsEquivalent(a, b) {
397
348
  const nodeIdsA = new Set(a.nodes.map((node) => node.id));
@@ -422,6 +373,8 @@ export function areResolvedDagsEquivalent(a, b) {
422
373
  if (edgesA[i].on[j] !== edgesB[i].on[j])
423
374
  return false;
424
375
  }
376
+ if (!sameNeedIf(edgesA[i].if, edgesB[i].if))
377
+ return false;
425
378
  }
426
379
  if (nodeA.ancestors.length !== nodeB.ancestors.length)
427
380
  return false;
@@ -464,10 +417,14 @@ export function extractPipelineStageIds(rawStages) {
464
417
  }
465
418
  if (typeof entry.id !== "string" || !entry.id)
466
419
  return null;
467
- if (entry.needs !== undefined) {
468
- const parsedNeeds = parsePipelineNeeds(entry.needs, entry.id);
469
- if (!parsedNeeds.ok)
470
- return null;
420
+ if (entry.needs !== undefined ||
421
+ entry.fork !== undefined ||
422
+ entry.feedback_loop !== undefined ||
423
+ entry.route_select !== undefined ||
424
+ entry.allow_none !== undefined ||
425
+ entry.clonable !== undefined ||
426
+ entry.clone_actions !== undefined) {
427
+ return null;
471
428
  }
472
429
  stageIds.push(entry.id);
473
430
  }
@@ -0,0 +1,24 @@
1
+ import type { PipelineRouteEntry, RouteIfPredicate } from "../types/pipeline.js";
2
+ import type { StageConfig } from "../types/stage.js";
3
+ import type { LoadIssue } from "./loadOutcome.js";
4
+ export type ParseRouteIfResult = {
5
+ ok: true;
6
+ value: RouteIfPredicate;
7
+ } | {
8
+ ok: false;
9
+ message: string;
10
+ code: "pipeline.route_if_invalid";
11
+ };
12
+ export declare function parseRouteIf(raw: unknown, stageId: string, targetId: string): ParseRouteIfResult;
13
+ export declare function collectRouteIfIllegalCombos(_refs: Array<{
14
+ id: string;
15
+ route?: PipelineRouteEntry[];
16
+ }>, _pipelineId: string): LoadIssue[];
17
+ export declare function collectRouteIfSchemaIssues(stages: StageConfig[], refs: Array<{
18
+ id: string;
19
+ route?: PipelineRouteEntry[];
20
+ }>, pipelineId: string): LoadIssue[];
21
+ export declare function collectRouteAllGatedWarnings(refs: Array<{
22
+ id: string;
23
+ route?: PipelineRouteEntry[];
24
+ }>, pipelineId: string): LoadIssue[];