@tagma/sdk 0.6.0 → 0.6.1
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 -21
- package/README.md +573 -573
- package/dist/drivers/opencode.d.ts.map +1 -1
- package/dist/drivers/opencode.js +47 -17
- package/dist/drivers/opencode.js.map +1 -1
- package/package.json +2 -2
- package/src/bootstrap.ts +37 -37
- package/src/completions/output-check.ts +92 -92
- package/src/dag.ts +245 -245
- package/src/drivers/opencode.ts +410 -371
- package/src/engine.ts +1220 -1220
- package/src/hooks.ts +193 -193
- package/src/middlewares/static-context.ts +49 -49
- package/src/pipeline-runner.ts +173 -173
- package/src/prompt-doc.ts +49 -49
- package/src/registry.ts +267 -267
- package/src/runner.ts +460 -460
- package/src/schema.test.ts +101 -101
- package/src/schema.ts +338 -338
- package/src/sdk.ts +118 -118
- package/src/task-ref.test.ts +401 -401
- package/src/task-ref.ts +120 -120
- package/src/validate-raw.ts +412 -412
- package/dist/drivers/claude-code.d.ts +0 -3
- package/dist/drivers/claude-code.d.ts.map +0 -1
- package/dist/drivers/claude-code.js +0 -225
- package/dist/drivers/claude-code.js.map +0 -1
package/src/dag.ts
CHANGED
|
@@ -1,245 +1,245 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
PipelineConfig,
|
|
3
|
-
RawPipelineConfig,
|
|
4
|
-
RawTaskConfig,
|
|
5
|
-
TaskConfig,
|
|
6
|
-
TrackConfig,
|
|
7
|
-
} from './types';
|
|
8
|
-
import { buildTaskIndex, qualifyTaskId, resolveTaskRef } from './task-ref';
|
|
9
|
-
|
|
10
|
-
export interface DagNode {
|
|
11
|
-
readonly taskId: string; // fully qualified: track_id.task_id or just task_id
|
|
12
|
-
readonly task: TaskConfig;
|
|
13
|
-
readonly track: TrackConfig;
|
|
14
|
-
readonly dependsOn: readonly string[];
|
|
15
|
-
/**
|
|
16
|
-
* H1: `task.continue_from` may be written by users as a bare task id
|
|
17
|
-
* (e.g. `review`) or a same-track shorthand. The driver needs the
|
|
18
|
-
* fully-qualified upstream id to look up output/session/normalized maps
|
|
19
|
-
* deterministically — bare lookups race when two tracks happen to share
|
|
20
|
-
* a task name. dag.ts performs the qualification once, here, so the
|
|
21
|
-
* engine never has to.
|
|
22
|
-
*/
|
|
23
|
-
readonly resolvedContinueFrom?: string;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export interface Dag {
|
|
27
|
-
readonly nodes: ReadonlyMap<string, DagNode>;
|
|
28
|
-
readonly sorted: readonly string[]; // topological order
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function buildDag(config: PipelineConfig): Dag {
|
|
32
|
-
const nodes = new Map<string, DagNode>();
|
|
33
|
-
|
|
34
|
-
// 1. Register all nodes. Duplicates throw — same-track task-id collisions
|
|
35
|
-
// would otherwise silently overwrite one another in the DAG.
|
|
36
|
-
for (const track of config.tracks) {
|
|
37
|
-
for (const task of track.tasks) {
|
|
38
|
-
const qid = qualifyTaskId(track.id, task.id);
|
|
39
|
-
if (nodes.has(qid)) {
|
|
40
|
-
throw new Error(`Duplicate task ID: "${qid}"`);
|
|
41
|
-
}
|
|
42
|
-
nodes.set(qid, {
|
|
43
|
-
taskId: qid,
|
|
44
|
-
task,
|
|
45
|
-
track,
|
|
46
|
-
dependsOn: [], // filled below
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// Shared index for ref resolution — same code path validate-raw uses.
|
|
52
|
-
const index = buildTaskIndex(config);
|
|
53
|
-
|
|
54
|
-
function resolveRef(ref: string, fromTrackId: string): string {
|
|
55
|
-
const result = resolveTaskRef(ref, fromTrackId, index);
|
|
56
|
-
if (result.kind === 'ambiguous') {
|
|
57
|
-
throw new Error(
|
|
58
|
-
`Ambiguous task reference "${ref}" exists in multiple tracks. ` +
|
|
59
|
-
`Use "track_id.task_id" format.`,
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
if (result.kind === 'not_found') {
|
|
63
|
-
throw new Error(`Task reference "${ref}" not found`);
|
|
64
|
-
}
|
|
65
|
-
return result.qid;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// 2. Resolve depends_on and continue_from to qualified IDs
|
|
69
|
-
for (const track of config.tracks) {
|
|
70
|
-
for (const task of track.tasks) {
|
|
71
|
-
const qid = qualifyTaskId(track.id, task.id);
|
|
72
|
-
const deps: string[] = [];
|
|
73
|
-
let resolvedContinueFrom: string | undefined;
|
|
74
|
-
|
|
75
|
-
if (task.depends_on) {
|
|
76
|
-
for (const dep of task.depends_on) {
|
|
77
|
-
deps.push(resolveRef(dep, track.id));
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
if (task.continue_from) {
|
|
81
|
-
// Preserve the ambiguous-vs-not-found distinction in the user-facing
|
|
82
|
-
// error: rewording "ambiguous" as "no such task found" (the previous
|
|
83
|
-
// behavior) hid the real problem and sent users searching for a
|
|
84
|
-
// missing task that actually existed in two places.
|
|
85
|
-
const result = resolveTaskRef(task.continue_from, track.id, index);
|
|
86
|
-
if (result.kind === 'ambiguous') {
|
|
87
|
-
throw new Error(
|
|
88
|
-
`Task "${qid}": continue_from "${task.continue_from}" is ambiguous — ` +
|
|
89
|
-
`multiple tracks have a task with this id. Use the fully-qualified ` +
|
|
90
|
-
`form "trackId.${task.continue_from}".`,
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
if (result.kind === 'not_found') {
|
|
94
|
-
throw new Error(
|
|
95
|
-
`Task "${qid}": continue_from "${task.continue_from}" — no such task found. ` +
|
|
96
|
-
`Use a fully-qualified reference (trackId.taskId) or ensure the target task exists.`,
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
resolvedContinueFrom = result.qid;
|
|
100
|
-
if (!deps.includes(result.qid)) {
|
|
101
|
-
deps.push(result.qid); // continue_from implies dependency
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Replace node with resolved deps + qualified continue_from.
|
|
106
|
-
const node = nodes.get(qid)!;
|
|
107
|
-
nodes.set(qid, { ...node, dependsOn: deps, resolvedContinueFrom });
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// 3. Topological sort + cycle detection (Kahn's algorithm)
|
|
112
|
-
const inDegree = new Map<string, number>();
|
|
113
|
-
const adjacency = new Map<string, string[]>(); // parent → children
|
|
114
|
-
|
|
115
|
-
for (const [id] of nodes) {
|
|
116
|
-
inDegree.set(id, 0);
|
|
117
|
-
adjacency.set(id, []);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
for (const [id, node] of nodes) {
|
|
121
|
-
for (const dep of node.dependsOn) {
|
|
122
|
-
adjacency.get(dep)!.push(id);
|
|
123
|
-
inDegree.set(id, (inDegree.get(id) ?? 0) + 1);
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// D20: deterministic topo order. Kahn's algorithm dequeues in insertion
|
|
128
|
-
// order by default, which depends on map iteration order — itself a
|
|
129
|
-
// function of the (track, task) order in the YAML. Two pipelines that
|
|
130
|
-
// are DAG-equivalent but written in a different order produced different
|
|
131
|
-
// `sorted` arrays, leading to subtle run-to-run non-determinism for
|
|
132
|
-
// parallel tasks with side effects (writing the same file, touching the
|
|
133
|
-
// same repo). Break the tie by qualified id so identical DAG shapes
|
|
134
|
-
// always yield identical schedules across machines and across YAML
|
|
135
|
-
// round-trips.
|
|
136
|
-
const ready: string[] = [];
|
|
137
|
-
for (const [id, degree] of inDegree) {
|
|
138
|
-
if (degree === 0) ready.push(id);
|
|
139
|
-
}
|
|
140
|
-
ready.sort();
|
|
141
|
-
|
|
142
|
-
const sorted: string[] = [];
|
|
143
|
-
let qi = 0;
|
|
144
|
-
while (qi < ready.length) {
|
|
145
|
-
const current = ready[qi++]!;
|
|
146
|
-
sorted.push(current);
|
|
147
|
-
// Collect children whose in-degree hits zero in this step, then push
|
|
148
|
-
// them into the ready bucket in sorted order — keeps each "wave" of
|
|
149
|
-
// parallel-eligible tasks ordered by qid.
|
|
150
|
-
const newlyReady: string[] = [];
|
|
151
|
-
for (const child of adjacency.get(current)!) {
|
|
152
|
-
const newDegree = inDegree.get(child)! - 1;
|
|
153
|
-
inDegree.set(child, newDegree);
|
|
154
|
-
if (newDegree === 0) newlyReady.push(child);
|
|
155
|
-
}
|
|
156
|
-
if (newlyReady.length > 1) newlyReady.sort();
|
|
157
|
-
for (const child of newlyReady) ready.push(child);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
if (sorted.length !== nodes.size) {
|
|
161
|
-
// Only report nodes that are actually part of cycles (in-degree > 0
|
|
162
|
-
// after Kahn's algorithm), not their downstream dependents.
|
|
163
|
-
const sortedSet = new Set(sorted);
|
|
164
|
-
const cycleMembers = [...nodes.keys()].filter(
|
|
165
|
-
(id) => !sortedSet.has(id) && (inDegree.get(id) ?? 0) > 0,
|
|
166
|
-
);
|
|
167
|
-
throw new Error(`Circular dependency detected involving tasks: ${cycleMembers.join(', ')}`);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
return { nodes, sorted };
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// ═══ Raw DAG (for visual editor — no workDir required) ═══
|
|
174
|
-
|
|
175
|
-
export interface RawDagNode {
|
|
176
|
-
readonly taskId: string; // fully qualified: track_id.task_id
|
|
177
|
-
readonly trackId: string;
|
|
178
|
-
readonly rawTask: RawTaskConfig;
|
|
179
|
-
readonly dependsOn: readonly string[]; // fully qualified IDs, best-effort resolved
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
export interface RawDag {
|
|
183
|
-
readonly nodes: ReadonlyMap<string, RawDagNode>;
|
|
184
|
-
/** Directed edges: from → to means "from must complete before to starts" */
|
|
185
|
-
readonly edges: readonly { readonly from: string; readonly to: string }[];
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* Build a lightweight DAG from a raw (unresolved) pipeline config.
|
|
190
|
-
* Unlike buildDag, this function:
|
|
191
|
-
* - Does not require a workDir or resolved PipelineConfig
|
|
192
|
-
* - Is lenient: missing or ambiguous refs are silently skipped
|
|
193
|
-
*
|
|
194
|
-
* Intended for the visual editor to render the flow graph before a pipeline is run.
|
|
195
|
-
*/
|
|
196
|
-
export function buildRawDag(config: RawPipelineConfig): RawDag {
|
|
197
|
-
const nodes = new Map<string, RawDagNode>();
|
|
198
|
-
|
|
199
|
-
// 1. Register all concrete tasks. Duplicates are skipped (not thrown) so
|
|
200
|
-
// partially-typed editor state doesn't produce a hard error.
|
|
201
|
-
for (const track of config.tracks) {
|
|
202
|
-
for (const task of track.tasks) {
|
|
203
|
-
const qid = qualifyTaskId(track.id, task.id);
|
|
204
|
-
if (nodes.has(qid)) continue;
|
|
205
|
-
nodes.set(qid, { taskId: qid, trackId: track.id, rawTask: task, dependsOn: [] });
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const index = buildTaskIndex(config);
|
|
210
|
-
|
|
211
|
-
function tryResolve(ref: string, fromTrackId: string): string | null {
|
|
212
|
-
const result = resolveTaskRef(ref, fromTrackId, index);
|
|
213
|
-
return result.kind === 'resolved' ? result.qid : null;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// 2. Resolve dependency refs leniently (missing / ambiguous refs are skipped)
|
|
217
|
-
const edges: { from: string; to: string }[] = [];
|
|
218
|
-
|
|
219
|
-
for (const track of config.tracks) {
|
|
220
|
-
for (const task of track.tasks) {
|
|
221
|
-
const qid = qualifyTaskId(track.id, task.id);
|
|
222
|
-
const deps: string[] = [];
|
|
223
|
-
|
|
224
|
-
for (const ref of task.depends_on ?? []) {
|
|
225
|
-
const resolved = tryResolve(ref, track.id);
|
|
226
|
-
if (resolved && !deps.includes(resolved)) {
|
|
227
|
-
deps.push(resolved);
|
|
228
|
-
edges.push({ from: resolved, to: qid });
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
if (task.continue_from) {
|
|
232
|
-
const resolved = tryResolve(task.continue_from, track.id);
|
|
233
|
-
if (resolved && !deps.includes(resolved)) {
|
|
234
|
-
deps.push(resolved);
|
|
235
|
-
edges.push({ from: resolved, to: qid });
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
const node = nodes.get(qid)!;
|
|
240
|
-
nodes.set(qid, { ...node, dependsOn: deps });
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
return { nodes, edges };
|
|
245
|
-
}
|
|
1
|
+
import type {
|
|
2
|
+
PipelineConfig,
|
|
3
|
+
RawPipelineConfig,
|
|
4
|
+
RawTaskConfig,
|
|
5
|
+
TaskConfig,
|
|
6
|
+
TrackConfig,
|
|
7
|
+
} from './types';
|
|
8
|
+
import { buildTaskIndex, qualifyTaskId, resolveTaskRef } from './task-ref';
|
|
9
|
+
|
|
10
|
+
export interface DagNode {
|
|
11
|
+
readonly taskId: string; // fully qualified: track_id.task_id or just task_id
|
|
12
|
+
readonly task: TaskConfig;
|
|
13
|
+
readonly track: TrackConfig;
|
|
14
|
+
readonly dependsOn: readonly string[];
|
|
15
|
+
/**
|
|
16
|
+
* H1: `task.continue_from` may be written by users as a bare task id
|
|
17
|
+
* (e.g. `review`) or a same-track shorthand. The driver needs the
|
|
18
|
+
* fully-qualified upstream id to look up output/session/normalized maps
|
|
19
|
+
* deterministically — bare lookups race when two tracks happen to share
|
|
20
|
+
* a task name. dag.ts performs the qualification once, here, so the
|
|
21
|
+
* engine never has to.
|
|
22
|
+
*/
|
|
23
|
+
readonly resolvedContinueFrom?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Dag {
|
|
27
|
+
readonly nodes: ReadonlyMap<string, DagNode>;
|
|
28
|
+
readonly sorted: readonly string[]; // topological order
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function buildDag(config: PipelineConfig): Dag {
|
|
32
|
+
const nodes = new Map<string, DagNode>();
|
|
33
|
+
|
|
34
|
+
// 1. Register all nodes. Duplicates throw — same-track task-id collisions
|
|
35
|
+
// would otherwise silently overwrite one another in the DAG.
|
|
36
|
+
for (const track of config.tracks) {
|
|
37
|
+
for (const task of track.tasks) {
|
|
38
|
+
const qid = qualifyTaskId(track.id, task.id);
|
|
39
|
+
if (nodes.has(qid)) {
|
|
40
|
+
throw new Error(`Duplicate task ID: "${qid}"`);
|
|
41
|
+
}
|
|
42
|
+
nodes.set(qid, {
|
|
43
|
+
taskId: qid,
|
|
44
|
+
task,
|
|
45
|
+
track,
|
|
46
|
+
dependsOn: [], // filled below
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Shared index for ref resolution — same code path validate-raw uses.
|
|
52
|
+
const index = buildTaskIndex(config);
|
|
53
|
+
|
|
54
|
+
function resolveRef(ref: string, fromTrackId: string): string {
|
|
55
|
+
const result = resolveTaskRef(ref, fromTrackId, index);
|
|
56
|
+
if (result.kind === 'ambiguous') {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Ambiguous task reference "${ref}" exists in multiple tracks. ` +
|
|
59
|
+
`Use "track_id.task_id" format.`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
if (result.kind === 'not_found') {
|
|
63
|
+
throw new Error(`Task reference "${ref}" not found`);
|
|
64
|
+
}
|
|
65
|
+
return result.qid;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 2. Resolve depends_on and continue_from to qualified IDs
|
|
69
|
+
for (const track of config.tracks) {
|
|
70
|
+
for (const task of track.tasks) {
|
|
71
|
+
const qid = qualifyTaskId(track.id, task.id);
|
|
72
|
+
const deps: string[] = [];
|
|
73
|
+
let resolvedContinueFrom: string | undefined;
|
|
74
|
+
|
|
75
|
+
if (task.depends_on) {
|
|
76
|
+
for (const dep of task.depends_on) {
|
|
77
|
+
deps.push(resolveRef(dep, track.id));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (task.continue_from) {
|
|
81
|
+
// Preserve the ambiguous-vs-not-found distinction in the user-facing
|
|
82
|
+
// error: rewording "ambiguous" as "no such task found" (the previous
|
|
83
|
+
// behavior) hid the real problem and sent users searching for a
|
|
84
|
+
// missing task that actually existed in two places.
|
|
85
|
+
const result = resolveTaskRef(task.continue_from, track.id, index);
|
|
86
|
+
if (result.kind === 'ambiguous') {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`Task "${qid}": continue_from "${task.continue_from}" is ambiguous — ` +
|
|
89
|
+
`multiple tracks have a task with this id. Use the fully-qualified ` +
|
|
90
|
+
`form "trackId.${task.continue_from}".`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
if (result.kind === 'not_found') {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`Task "${qid}": continue_from "${task.continue_from}" — no such task found. ` +
|
|
96
|
+
`Use a fully-qualified reference (trackId.taskId) or ensure the target task exists.`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
resolvedContinueFrom = result.qid;
|
|
100
|
+
if (!deps.includes(result.qid)) {
|
|
101
|
+
deps.push(result.qid); // continue_from implies dependency
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Replace node with resolved deps + qualified continue_from.
|
|
106
|
+
const node = nodes.get(qid)!;
|
|
107
|
+
nodes.set(qid, { ...node, dependsOn: deps, resolvedContinueFrom });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 3. Topological sort + cycle detection (Kahn's algorithm)
|
|
112
|
+
const inDegree = new Map<string, number>();
|
|
113
|
+
const adjacency = new Map<string, string[]>(); // parent → children
|
|
114
|
+
|
|
115
|
+
for (const [id] of nodes) {
|
|
116
|
+
inDegree.set(id, 0);
|
|
117
|
+
adjacency.set(id, []);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for (const [id, node] of nodes) {
|
|
121
|
+
for (const dep of node.dependsOn) {
|
|
122
|
+
adjacency.get(dep)!.push(id);
|
|
123
|
+
inDegree.set(id, (inDegree.get(id) ?? 0) + 1);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// D20: deterministic topo order. Kahn's algorithm dequeues in insertion
|
|
128
|
+
// order by default, which depends on map iteration order — itself a
|
|
129
|
+
// function of the (track, task) order in the YAML. Two pipelines that
|
|
130
|
+
// are DAG-equivalent but written in a different order produced different
|
|
131
|
+
// `sorted` arrays, leading to subtle run-to-run non-determinism for
|
|
132
|
+
// parallel tasks with side effects (writing the same file, touching the
|
|
133
|
+
// same repo). Break the tie by qualified id so identical DAG shapes
|
|
134
|
+
// always yield identical schedules across machines and across YAML
|
|
135
|
+
// round-trips.
|
|
136
|
+
const ready: string[] = [];
|
|
137
|
+
for (const [id, degree] of inDegree) {
|
|
138
|
+
if (degree === 0) ready.push(id);
|
|
139
|
+
}
|
|
140
|
+
ready.sort();
|
|
141
|
+
|
|
142
|
+
const sorted: string[] = [];
|
|
143
|
+
let qi = 0;
|
|
144
|
+
while (qi < ready.length) {
|
|
145
|
+
const current = ready[qi++]!;
|
|
146
|
+
sorted.push(current);
|
|
147
|
+
// Collect children whose in-degree hits zero in this step, then push
|
|
148
|
+
// them into the ready bucket in sorted order — keeps each "wave" of
|
|
149
|
+
// parallel-eligible tasks ordered by qid.
|
|
150
|
+
const newlyReady: string[] = [];
|
|
151
|
+
for (const child of adjacency.get(current)!) {
|
|
152
|
+
const newDegree = inDegree.get(child)! - 1;
|
|
153
|
+
inDegree.set(child, newDegree);
|
|
154
|
+
if (newDegree === 0) newlyReady.push(child);
|
|
155
|
+
}
|
|
156
|
+
if (newlyReady.length > 1) newlyReady.sort();
|
|
157
|
+
for (const child of newlyReady) ready.push(child);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (sorted.length !== nodes.size) {
|
|
161
|
+
// Only report nodes that are actually part of cycles (in-degree > 0
|
|
162
|
+
// after Kahn's algorithm), not their downstream dependents.
|
|
163
|
+
const sortedSet = new Set(sorted);
|
|
164
|
+
const cycleMembers = [...nodes.keys()].filter(
|
|
165
|
+
(id) => !sortedSet.has(id) && (inDegree.get(id) ?? 0) > 0,
|
|
166
|
+
);
|
|
167
|
+
throw new Error(`Circular dependency detected involving tasks: ${cycleMembers.join(', ')}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { nodes, sorted };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ═══ Raw DAG (for visual editor — no workDir required) ═══
|
|
174
|
+
|
|
175
|
+
export interface RawDagNode {
|
|
176
|
+
readonly taskId: string; // fully qualified: track_id.task_id
|
|
177
|
+
readonly trackId: string;
|
|
178
|
+
readonly rawTask: RawTaskConfig;
|
|
179
|
+
readonly dependsOn: readonly string[]; // fully qualified IDs, best-effort resolved
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface RawDag {
|
|
183
|
+
readonly nodes: ReadonlyMap<string, RawDagNode>;
|
|
184
|
+
/** Directed edges: from → to means "from must complete before to starts" */
|
|
185
|
+
readonly edges: readonly { readonly from: string; readonly to: string }[];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Build a lightweight DAG from a raw (unresolved) pipeline config.
|
|
190
|
+
* Unlike buildDag, this function:
|
|
191
|
+
* - Does not require a workDir or resolved PipelineConfig
|
|
192
|
+
* - Is lenient: missing or ambiguous refs are silently skipped
|
|
193
|
+
*
|
|
194
|
+
* Intended for the visual editor to render the flow graph before a pipeline is run.
|
|
195
|
+
*/
|
|
196
|
+
export function buildRawDag(config: RawPipelineConfig): RawDag {
|
|
197
|
+
const nodes = new Map<string, RawDagNode>();
|
|
198
|
+
|
|
199
|
+
// 1. Register all concrete tasks. Duplicates are skipped (not thrown) so
|
|
200
|
+
// partially-typed editor state doesn't produce a hard error.
|
|
201
|
+
for (const track of config.tracks) {
|
|
202
|
+
for (const task of track.tasks) {
|
|
203
|
+
const qid = qualifyTaskId(track.id, task.id);
|
|
204
|
+
if (nodes.has(qid)) continue;
|
|
205
|
+
nodes.set(qid, { taskId: qid, trackId: track.id, rawTask: task, dependsOn: [] });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const index = buildTaskIndex(config);
|
|
210
|
+
|
|
211
|
+
function tryResolve(ref: string, fromTrackId: string): string | null {
|
|
212
|
+
const result = resolveTaskRef(ref, fromTrackId, index);
|
|
213
|
+
return result.kind === 'resolved' ? result.qid : null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 2. Resolve dependency refs leniently (missing / ambiguous refs are skipped)
|
|
217
|
+
const edges: { from: string; to: string }[] = [];
|
|
218
|
+
|
|
219
|
+
for (const track of config.tracks) {
|
|
220
|
+
for (const task of track.tasks) {
|
|
221
|
+
const qid = qualifyTaskId(track.id, task.id);
|
|
222
|
+
const deps: string[] = [];
|
|
223
|
+
|
|
224
|
+
for (const ref of task.depends_on ?? []) {
|
|
225
|
+
const resolved = tryResolve(ref, track.id);
|
|
226
|
+
if (resolved && !deps.includes(resolved)) {
|
|
227
|
+
deps.push(resolved);
|
|
228
|
+
edges.push({ from: resolved, to: qid });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (task.continue_from) {
|
|
232
|
+
const resolved = tryResolve(task.continue_from, track.id);
|
|
233
|
+
if (resolved && !deps.includes(resolved)) {
|
|
234
|
+
deps.push(resolved);
|
|
235
|
+
edges.push({ from: resolved, to: qid });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const node = nodes.get(qid)!;
|
|
240
|
+
nodes.set(qid, { ...node, dependsOn: deps });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return { nodes, edges };
|
|
245
|
+
}
|