stageflow 0.16.0 → 0.18.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 +2 -4
- package/dist/agent/claudeTools.d.ts +1 -4
- package/dist/agent/claudeTools.js +1 -6
- package/dist/agent/fakeAgent.js +4 -5
- package/dist/agent/piAdapter.js +1 -14
- package/dist/config/cloneChain.d.ts +12 -0
- package/dist/config/cloneChain.js +322 -0
- package/dist/config/createPipeline.js +101 -21
- package/dist/config/createStage.js +7 -0
- package/dist/config/loadOutcome.d.ts +1 -0
- package/dist/config/loadPipeline.d.ts +2 -0
- package/dist/config/loadPipeline.js +34 -22
- package/dist/config/loadStage.d.ts +2 -0
- package/dist/config/loadStage.js +135 -125
- package/dist/config/migrateYaml.js +14 -3
- package/dist/config/normalizePipelineStageEntry.d.ts +5 -9
- package/dist/config/normalizePipelineStageEntry.js +133 -60
- package/dist/config/parsePreEmitChecks.js +2 -2
- package/dist/config/pipelineNeeds.d.ts +15 -2
- package/dist/config/pipelineNeeds.js +86 -4
- package/dist/config/pipelineRoute.d.ts +46 -0
- package/dist/config/pipelineRoute.js +215 -0
- package/dist/config/pipelineStageKeys.d.ts +2 -0
- package/dist/config/pipelineStageKeys.js +15 -4
- package/dist/config/printTargetYaml.js +18 -12
- package/dist/config/resolveForkEmitContext.d.ts +1 -1
- package/dist/config/resolveForkEmitContext.js +3 -26
- package/dist/config/resolvePipelineDag.d.ts +1 -3
- package/dist/config/resolvePipelineDag.js +143 -186
- package/dist/config/routeIf.d.ts +24 -0
- package/dist/config/routeIf.js +247 -0
- package/dist/config/validateCatalog.d.ts +1 -1
- package/dist/config/validateCatalog.js +5 -3
- package/dist/config/yamlDialect.d.ts +5 -8
- package/dist/config/yamlDialect.js +37 -29
- package/dist/envelope/check.d.ts +0 -2
- package/dist/envelope/check.js +1 -57
- package/dist/envelope/feedbackLoop.js +0 -3
- package/dist/envelope/forkChoice.js +1 -1
- package/dist/envelope/payloadSchema.d.ts +14 -0
- package/dist/envelope/payloadSchema.js +87 -2
- package/dist/mcp/catalogTools.js +1 -2
- package/dist/package-meta.d.ts +1 -1
- package/dist/package-meta.js +1 -1
- package/dist/projection/projectRun.d.ts +0 -2
- package/dist/projection/projectRun.js +0 -3
- package/dist/prompt/priorEnvelope.js +1 -1
- package/dist/runstore/stageInstanceId.d.ts +1 -0
- package/dist/runstore/stageInstanceId.js +9 -0
- package/dist/runtime/cloneSchedule.d.ts +16 -33
- package/dist/runtime/cloneSchedule.js +155 -236
- package/dist/runtime/envelopeRouting.js +43 -94
- package/dist/runtime/forkGeneration.d.ts +2 -2
- package/dist/runtime/forkGeneration.js +29 -15
- package/dist/runtime/joinReadiness.d.ts +16 -0
- package/dist/runtime/joinReadiness.js +156 -0
- package/dist/runtime/pipelineScheduler.d.ts +0 -3
- package/dist/runtime/pipelineScheduler.js +81 -65
- package/dist/runtime/routeIfEval.d.ts +3 -0
- package/dist/runtime/routeIfEval.js +94 -0
- package/dist/runtime/runRetryCoordinator.js +0 -4
- package/dist/runtime/stageAttemptBootstrap.js +60 -11
- package/dist/runtime/taskInput.js +1 -13
- package/dist/tools/emitStageEnvelope.d.ts +2 -3
- package/dist/tools/emitStageEnvelope.js +1 -65
- package/dist/types/envelope.d.ts +0 -2
- package/dist/types/forkChoice.d.ts +0 -16
- package/dist/types/pipeline.d.ts +86 -27
- package/dist/types/stage.d.ts +11 -7
- package/dist/ui/assets/{index-D2Wt8hwH.css → index-DsgVjLUS.css} +1 -1
- package/dist/ui/assets/{index-XWJovnp7.js → index-DwKGaQvQ.js} +13 -12
- package/dist/ui/index.html +2 -2
- package/package.json +2 -1
- package/skills/stageflow-author/SKILL.md +4 -4
- package/skills/stageflow-author/assets/examples/branch-decision/hotfix.yaml +10 -2
- package/skills/stageflow-author/assets/examples/branch-decision/release-gate.pipeline.yaml +12 -4
- package/skills/stageflow-author/assets/examples/branch-decision/run-tests.yaml +6 -4
- package/skills/stageflow-author/assets/examples/branch-decision/ship.yaml +9 -1
- package/skills/stageflow-author/assets/examples/linear-review/draft.yaml +3 -0
- package/skills/stageflow-author/assets/examples/linear-review/publish.yaml +8 -0
- package/skills/stageflow-author/assets/examples/linear-review/review-loop.pipeline.yaml +5 -2
- package/skills/stageflow-author/assets/examples/linear-review/review.yaml +8 -0
- package/skills/stageflow-author/assets/examples/non-sdlc-digest/gather.yaml +3 -0
- package/skills/stageflow-author/assets/examples/non-sdlc-digest/research-digest.pipeline.yaml +5 -2
- package/skills/stageflow-author/assets/examples/non-sdlc-digest/send.yaml +8 -0
- package/skills/stageflow-author/assets/examples/non-sdlc-digest/summarize.yaml +8 -0
- package/skills/stageflow-author/references/catalog-mapping.md +159 -50
- package/skills/stageflow-author/references/catalog-write-conventions.md +3 -3
- package/skills/stageflow-author/references/stage-prompt-template.md +57 -17
- package/skills/stageflow-session-capture/SKILL.md +2 -2
- package/skills/stageflow-session-capture/assets/example-pipeline/example.pipeline.yaml +3 -1
- package/skills/stageflow-session-capture/assets/example-pipeline/implement.yaml +15 -0
- package/skills/stageflow-session-capture/assets/example-pipeline/research.yaml +3 -0
- package/skills/stageflow-session-capture/references/catalog-authoring.md +4 -2
- package/dist/envelope/cloneForks.d.ts +0 -2
- package/dist/envelope/cloneForks.js +0 -77
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { isAllowedPipelineStageEntryKey } from "./pipelineStageKeys.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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
|
|
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
|
|
173
|
-
if (!declared.has(
|
|
174
|
-
throw new Error(formatError(ctx, `stage "${edge.id}" has unknown
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
394
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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[];
|