@tagma/sdk 0.4.14 → 0.4.15
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 +569 -569
- package/dist/dag.d.ts.map +1 -1
- package/dist/dag.js +22 -56
- package/dist/dag.js.map +1 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +63 -37
- package/dist/engine.js.map +1 -1
- package/dist/middlewares/static-context.d.ts.map +1 -1
- package/dist/middlewares/static-context.js +7 -3
- package/dist/middlewares/static-context.js.map +1 -1
- package/dist/prompt-doc.d.ts +36 -0
- package/dist/prompt-doc.d.ts.map +1 -0
- package/dist/prompt-doc.js +44 -0
- package/dist/prompt-doc.js.map +1 -0
- package/dist/sdk.d.ts +3 -0
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +4 -0
- package/dist/sdk.js.map +1 -1
- package/dist/task-ref.d.ts +55 -0
- package/dist/task-ref.d.ts.map +1 -0
- package/dist/task-ref.js +101 -0
- package/dist/task-ref.js.map +1 -0
- package/dist/templates.d.ts +20 -0
- package/dist/templates.d.ts.map +1 -0
- package/dist/templates.js +93 -0
- package/dist/templates.js.map +1 -0
- package/dist/validate-raw.d.ts.map +1 -1
- package/dist/validate-raw.js +27 -53
- package/dist/validate-raw.js.map +1 -1
- package/package.json +2 -2
- package/scripts/preinstall.js +31 -31
- package/src/adapters/stdin-approval.ts +106 -106
- package/src/adapters/websocket-approval.ts +224 -224
- package/src/approval.ts +131 -131
- package/src/bootstrap.ts +37 -37
- package/src/completions/exit-code.ts +34 -34
- package/src/completions/file-exists.ts +66 -66
- package/src/completions/output-check.ts +86 -86
- package/src/config-ops.ts +307 -307
- package/src/dag.ts +24 -54
- package/src/drivers/claude-code.ts +250 -250
- package/src/engine.ts +1137 -1098
- package/src/hooks.ts +187 -187
- package/src/logger.ts +182 -182
- package/src/middlewares/static-context.ts +49 -45
- package/src/pipeline-runner.ts +156 -156
- package/src/prompt-doc.ts +49 -0
- package/src/registry.ts +242 -242
- package/src/runner.ts +395 -395
- package/src/schema.test.ts +101 -101
- package/src/schema.ts +338 -338
- package/src/sdk.ts +111 -92
- package/src/task-ref.ts +120 -0
- package/src/triggers/file.ts +164 -164
- package/src/triggers/manual.ts +86 -86
- package/src/types.ts +18 -18
- package/src/utils.ts +203 -203
- package/src/validate-raw.ts +412 -442
package/src/config-ops.ts
CHANGED
|
@@ -1,307 +1,307 @@
|
|
|
1
|
-
// ═══ RawPipelineConfig CRUD Operations ═══
|
|
2
|
-
//
|
|
3
|
-
// Pure, immutable helper functions for building and editing pipeline configs
|
|
4
|
-
// in a visual editor. None of these functions have runtime dependencies —
|
|
5
|
-
// safe to import in any context (sidecar, renderer, tests).
|
|
6
|
-
//
|
|
7
|
-
// All operations return a new config object; inputs are never mutated.
|
|
8
|
-
|
|
9
|
-
import type { RawPipelineConfig, RawTrackConfig, RawTaskConfig } from './types';
|
|
10
|
-
|
|
11
|
-
// ── Pipeline ──
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Create a minimal empty pipeline config.
|
|
15
|
-
*/
|
|
16
|
-
export function createEmptyPipeline(name: string): RawPipelineConfig {
|
|
17
|
-
return { name, tracks: [] };
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Update a top-level pipeline field (name, driver, timeout, etc.).
|
|
22
|
-
*/
|
|
23
|
-
export function setPipelineField(
|
|
24
|
-
config: RawPipelineConfig,
|
|
25
|
-
fields: Partial<Omit<RawPipelineConfig, 'tracks'>>,
|
|
26
|
-
): RawPipelineConfig {
|
|
27
|
-
return { ...config, ...fields };
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// ── Tracks ──
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Insert or replace a track by id. Appends if the id is new.
|
|
34
|
-
*/
|
|
35
|
-
export function upsertTrack(config: RawPipelineConfig, track: RawTrackConfig): RawPipelineConfig {
|
|
36
|
-
const exists = config.tracks.some((t) => t.id === track.id);
|
|
37
|
-
return {
|
|
38
|
-
...config,
|
|
39
|
-
tracks: exists
|
|
40
|
-
? config.tracks.map((t) => (t.id === track.id ? track : t))
|
|
41
|
-
: [...config.tracks, track],
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Remove a track by id. No-op if the id is not found.
|
|
47
|
-
*/
|
|
48
|
-
export function removeTrack(config: RawPipelineConfig, trackId: string): RawPipelineConfig {
|
|
49
|
-
return { ...config, tracks: config.tracks.filter((t) => t.id !== trackId) };
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Move a track to a new index position (0-based).
|
|
54
|
-
* Clamps toIndex to valid bounds.
|
|
55
|
-
*/
|
|
56
|
-
export function moveTrack(
|
|
57
|
-
config: RawPipelineConfig,
|
|
58
|
-
trackId: string,
|
|
59
|
-
toIndex: number,
|
|
60
|
-
): RawPipelineConfig {
|
|
61
|
-
const idx = config.tracks.findIndex((t) => t.id === trackId);
|
|
62
|
-
if (idx === -1) return config;
|
|
63
|
-
const track = config.tracks[idx]!;
|
|
64
|
-
const withoutTrack = [...config.tracks.slice(0, idx), ...config.tracks.slice(idx + 1)];
|
|
65
|
-
const clamped = Math.max(0, Math.min(toIndex, withoutTrack.length));
|
|
66
|
-
const tracks = [...withoutTrack.slice(0, clamped), track, ...withoutTrack.slice(clamped)];
|
|
67
|
-
return { ...config, tracks };
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Update fields on a single track (excluding tasks list, use upsertTask / removeTask for that).
|
|
72
|
-
*/
|
|
73
|
-
export function updateTrack(
|
|
74
|
-
config: RawPipelineConfig,
|
|
75
|
-
trackId: string,
|
|
76
|
-
fields: Partial<Omit<RawTrackConfig, 'id' | 'tasks'>>,
|
|
77
|
-
): RawPipelineConfig {
|
|
78
|
-
return {
|
|
79
|
-
...config,
|
|
80
|
-
tracks: config.tracks.map((t) => (t.id === trackId ? { ...t, ...fields } : t)),
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// ── Tasks ──
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Insert or replace a task within a track, matched by task.id. Appends if new.
|
|
88
|
-
* No-op if the trackId is not found.
|
|
89
|
-
*/
|
|
90
|
-
export function upsertTask(
|
|
91
|
-
config: RawPipelineConfig,
|
|
92
|
-
trackId: string,
|
|
93
|
-
task: RawTaskConfig,
|
|
94
|
-
): RawPipelineConfig {
|
|
95
|
-
return {
|
|
96
|
-
...config,
|
|
97
|
-
tracks: config.tracks.map((t) => {
|
|
98
|
-
if (t.id !== trackId) return t;
|
|
99
|
-
const exists = t.tasks.some((tk) => tk.id === task.id);
|
|
100
|
-
return {
|
|
101
|
-
...t,
|
|
102
|
-
tasks: exists ? t.tasks.map((tk) => (tk.id === task.id ? task : tk)) : [...t.tasks, task],
|
|
103
|
-
};
|
|
104
|
-
}),
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Remove a task from a track. No-op if either id is not found.
|
|
110
|
-
*
|
|
111
|
-
* When `cleanRefs` is true, all `depends_on` and `continue_from` references to the
|
|
112
|
-
* removed task are also removed from every other task in the pipeline. This prevents
|
|
113
|
-
* validateRaw from reporting dangling-ref errors after the deletion.
|
|
114
|
-
*/
|
|
115
|
-
export function removeTask(
|
|
116
|
-
config: RawPipelineConfig,
|
|
117
|
-
trackId: string,
|
|
118
|
-
taskId: string,
|
|
119
|
-
cleanRefs = false,
|
|
120
|
-
): RawPipelineConfig {
|
|
121
|
-
const withoutTask = {
|
|
122
|
-
...config,
|
|
123
|
-
tracks: config.tracks.map((t) => {
|
|
124
|
-
if (t.id !== trackId) return t;
|
|
125
|
-
return { ...t, tasks: t.tasks.filter((tk) => tk.id !== taskId) };
|
|
126
|
-
}),
|
|
127
|
-
};
|
|
128
|
-
|
|
129
|
-
if (!cleanRefs) return withoutTask;
|
|
130
|
-
|
|
131
|
-
const qualId = `${trackId}.${taskId}`;
|
|
132
|
-
|
|
133
|
-
// After deletion, can a bare ref "taskId" still resolve to some other task globally?
|
|
134
|
-
// It can if any track in the post-deletion config still contains a task with that bare id.
|
|
135
|
-
const bareIdSurvivesGlobally = withoutTask.tracks.some((t) =>
|
|
136
|
-
t.tasks.some((tk) => tk.id === taskId),
|
|
137
|
-
);
|
|
138
|
-
|
|
139
|
-
return {
|
|
140
|
-
...withoutTask,
|
|
141
|
-
tracks: withoutTask.tracks.map((t) => {
|
|
142
|
-
// Build the set of task IDs remaining in this track (the deleted task
|
|
143
|
-
// has already been removed from its own track in withoutTask).
|
|
144
|
-
const remainingIds = new Set(t.tasks.map((tk) => tk.id));
|
|
145
|
-
|
|
146
|
-
// Resolve whether a ref in THIS track points to the deleted task:
|
|
147
|
-
// - Fully-qualified ref ("trackId.taskId") — always points to the deleted task.
|
|
148
|
-
// - Bare ref ("taskId") from the SAME track as the deleted task — always pointed
|
|
149
|
-
// to the deleted task (same-track lookup takes priority).
|
|
150
|
-
// - Bare ref from a DIFFERENT track:
|
|
151
|
-
// 1. If this track has a local task with that id → ref resolves locally, not removed.
|
|
152
|
-
// 2. Else if some other track still has a task with that id → ref will resolve
|
|
153
|
-
// there after deletion, not removed.
|
|
154
|
-
// 3. Else → ref is dangling, remove it.
|
|
155
|
-
const isRemovedFrom = (ref: string): boolean => {
|
|
156
|
-
if (ref === qualId) return true;
|
|
157
|
-
if (ref === taskId) {
|
|
158
|
-
if (t.id === trackId) return true; // same track — was pointing here
|
|
159
|
-
if (remainingIds.has(taskId)) return false; // local task shadows — ref is fine
|
|
160
|
-
return !bareIdSurvivesGlobally; // remove only if truly dangling
|
|
161
|
-
}
|
|
162
|
-
return false;
|
|
163
|
-
};
|
|
164
|
-
|
|
165
|
-
return {
|
|
166
|
-
...t,
|
|
167
|
-
tasks: t.tasks.map((tk) => cleanTaskRefs(tk, isRemovedFrom)),
|
|
168
|
-
};
|
|
169
|
-
}),
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function cleanTaskRefs(task: RawTaskConfig, isRemoved: (ref: string) => boolean): RawTaskConfig {
|
|
174
|
-
const filteredDeps = task.depends_on?.filter((d) => !isRemoved(d));
|
|
175
|
-
const dropContinueFrom = task.continue_from !== undefined && isRemoved(task.continue_from);
|
|
176
|
-
|
|
177
|
-
const depsUnchanged =
|
|
178
|
-
filteredDeps === undefined || filteredDeps.length === task.depends_on!.length;
|
|
179
|
-
if (depsUnchanged && !dropContinueFrom) return task;
|
|
180
|
-
|
|
181
|
-
const { depends_on: _depends_on, continue_from, ...rest } = task;
|
|
182
|
-
return {
|
|
183
|
-
...rest,
|
|
184
|
-
...(filteredDeps !== undefined && filteredDeps.length > 0 ? { depends_on: filteredDeps } : {}),
|
|
185
|
-
...(!dropContinueFrom && continue_from !== undefined ? { continue_from } : {}),
|
|
186
|
-
} as RawTaskConfig;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Reorder a task within its track.
|
|
191
|
-
* Clamps toIndex to valid bounds.
|
|
192
|
-
*/
|
|
193
|
-
export function moveTask(
|
|
194
|
-
config: RawPipelineConfig,
|
|
195
|
-
trackId: string,
|
|
196
|
-
taskId: string,
|
|
197
|
-
toIndex: number,
|
|
198
|
-
): RawPipelineConfig {
|
|
199
|
-
return {
|
|
200
|
-
...config,
|
|
201
|
-
tracks: config.tracks.map((t) => {
|
|
202
|
-
if (t.id !== trackId) return t;
|
|
203
|
-
const idx = t.tasks.findIndex((tk) => tk.id === taskId);
|
|
204
|
-
if (idx === -1) return t;
|
|
205
|
-
const task = t.tasks[idx]!;
|
|
206
|
-
const withoutTask = [...t.tasks.slice(0, idx), ...t.tasks.slice(idx + 1)];
|
|
207
|
-
const clamped = Math.max(0, Math.min(toIndex, withoutTask.length));
|
|
208
|
-
const tasks = [...withoutTask.slice(0, clamped), task, ...withoutTask.slice(clamped)];
|
|
209
|
-
return { ...t, tasks };
|
|
210
|
-
}),
|
|
211
|
-
};
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* Move a task from one track to another (appends to the target track).
|
|
216
|
-
* No-op if either trackId or taskId is not found.
|
|
217
|
-
*
|
|
218
|
-
* When `qualifyRefs` is true (the default), bare references (`depends_on`,
|
|
219
|
-
* `continue_from`) pointing to the moved task are converted to fully-qualified
|
|
220
|
-
* refs (`toTrackId.taskId`) so that same-track resolution doesn't silently
|
|
221
|
-
* break after the task changes tracks.
|
|
222
|
-
*/
|
|
223
|
-
export function transferTask(
|
|
224
|
-
config: RawPipelineConfig,
|
|
225
|
-
fromTrackId: string,
|
|
226
|
-
taskId: string,
|
|
227
|
-
toTrackId: string,
|
|
228
|
-
qualifyRefs = true,
|
|
229
|
-
): RawPipelineConfig {
|
|
230
|
-
if (fromTrackId === toTrackId) return config;
|
|
231
|
-
|
|
232
|
-
let task: RawTaskConfig | undefined;
|
|
233
|
-
const afterRemove = {
|
|
234
|
-
...config,
|
|
235
|
-
tracks: config.tracks.map((t) => {
|
|
236
|
-
if (t.id !== fromTrackId) return t;
|
|
237
|
-
const found = t.tasks.find((tk) => tk.id === taskId);
|
|
238
|
-
if (!found) return t;
|
|
239
|
-
task = found;
|
|
240
|
-
return { ...t, tasks: t.tasks.filter((tk) => tk.id !== taskId) };
|
|
241
|
-
}),
|
|
242
|
-
};
|
|
243
|
-
if (!task) return config;
|
|
244
|
-
const afterInsert = upsertTask(afterRemove, toTrackId, task);
|
|
245
|
-
|
|
246
|
-
if (!qualifyRefs) return afterInsert;
|
|
247
|
-
|
|
248
|
-
// Qualify bare references to the moved task. After the move, bare ref
|
|
249
|
-
// "taskId" from the old track no longer resolves via same-track priority.
|
|
250
|
-
// Convert it to the qualified form "toTrackId.taskId" so the dependency
|
|
251
|
-
// graph stays correct.
|
|
252
|
-
const qualId = `${toTrackId}.${taskId}`;
|
|
253
|
-
const oldQualId = `${fromTrackId}.${taskId}`;
|
|
254
|
-
|
|
255
|
-
// Does any track (other than the destination) still have a task with this bare id?
|
|
256
|
-
const bareIdSurvivesElsewhere = afterInsert.tracks.some(
|
|
257
|
-
(t) => t.id !== toTrackId && t.tasks.some((tk) => tk.id === taskId),
|
|
258
|
-
);
|
|
259
|
-
|
|
260
|
-
return {
|
|
261
|
-
...afterInsert,
|
|
262
|
-
tracks: afterInsert.tracks.map((t) => {
|
|
263
|
-
const localHasId = t.tasks.some((tk) => tk.id === taskId);
|
|
264
|
-
|
|
265
|
-
const qualifyRef = (ref: string): string => {
|
|
266
|
-
// Already-qualified ref to old location → rewrite to new location
|
|
267
|
-
if (ref === oldQualId) return qualId;
|
|
268
|
-
// Bare ref: only needs qualifying if it would have resolved to the
|
|
269
|
-
// moved task before the transfer
|
|
270
|
-
if (ref === taskId) {
|
|
271
|
-
if (t.id === fromTrackId) {
|
|
272
|
-
// Was same-track in the old track — now the task is gone.
|
|
273
|
-
// If no other local task shadows it, qualify to new location.
|
|
274
|
-
if (!localHasId) return qualId;
|
|
275
|
-
}
|
|
276
|
-
// From a different track: bare ref resolved globally before.
|
|
277
|
-
// If the bare id is now ambiguous or gone from this track's
|
|
278
|
-
// perspective, qualify it.
|
|
279
|
-
if (!localHasId && !bareIdSurvivesElsewhere) return qualId;
|
|
280
|
-
}
|
|
281
|
-
return ref;
|
|
282
|
-
};
|
|
283
|
-
|
|
284
|
-
return {
|
|
285
|
-
...t,
|
|
286
|
-
tasks: t.tasks.map((tk) => qualifyTaskRefs(tk, qualifyRef)),
|
|
287
|
-
};
|
|
288
|
-
}),
|
|
289
|
-
};
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/** Rewrite `depends_on` and `continue_from` refs using a mapping function. */
|
|
293
|
-
function qualifyTaskRefs(task: RawTaskConfig, rewrite: (ref: string) => string): RawTaskConfig {
|
|
294
|
-
const newDeps = task.depends_on?.map(rewrite);
|
|
295
|
-
const newContinue = task.continue_from !== undefined ? rewrite(task.continue_from) : undefined;
|
|
296
|
-
|
|
297
|
-
const depsChanged = newDeps !== undefined && newDeps.some((d, i) => d !== task.depends_on![i]);
|
|
298
|
-
const continueChanged = newContinue !== undefined && newContinue !== task.continue_from;
|
|
299
|
-
|
|
300
|
-
if (!depsChanged && !continueChanged) return task;
|
|
301
|
-
|
|
302
|
-
return {
|
|
303
|
-
...task,
|
|
304
|
-
...(newDeps !== undefined ? { depends_on: newDeps } : {}),
|
|
305
|
-
...(newContinue !== undefined ? { continue_from: newContinue } : {}),
|
|
306
|
-
};
|
|
307
|
-
}
|
|
1
|
+
// ═══ RawPipelineConfig CRUD Operations ═══
|
|
2
|
+
//
|
|
3
|
+
// Pure, immutable helper functions for building and editing pipeline configs
|
|
4
|
+
// in a visual editor. None of these functions have runtime dependencies —
|
|
5
|
+
// safe to import in any context (sidecar, renderer, tests).
|
|
6
|
+
//
|
|
7
|
+
// All operations return a new config object; inputs are never mutated.
|
|
8
|
+
|
|
9
|
+
import type { RawPipelineConfig, RawTrackConfig, RawTaskConfig } from './types';
|
|
10
|
+
|
|
11
|
+
// ── Pipeline ──
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Create a minimal empty pipeline config.
|
|
15
|
+
*/
|
|
16
|
+
export function createEmptyPipeline(name: string): RawPipelineConfig {
|
|
17
|
+
return { name, tracks: [] };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Update a top-level pipeline field (name, driver, timeout, etc.).
|
|
22
|
+
*/
|
|
23
|
+
export function setPipelineField(
|
|
24
|
+
config: RawPipelineConfig,
|
|
25
|
+
fields: Partial<Omit<RawPipelineConfig, 'tracks'>>,
|
|
26
|
+
): RawPipelineConfig {
|
|
27
|
+
return { ...config, ...fields };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Tracks ──
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Insert or replace a track by id. Appends if the id is new.
|
|
34
|
+
*/
|
|
35
|
+
export function upsertTrack(config: RawPipelineConfig, track: RawTrackConfig): RawPipelineConfig {
|
|
36
|
+
const exists = config.tracks.some((t) => t.id === track.id);
|
|
37
|
+
return {
|
|
38
|
+
...config,
|
|
39
|
+
tracks: exists
|
|
40
|
+
? config.tracks.map((t) => (t.id === track.id ? track : t))
|
|
41
|
+
: [...config.tracks, track],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Remove a track by id. No-op if the id is not found.
|
|
47
|
+
*/
|
|
48
|
+
export function removeTrack(config: RawPipelineConfig, trackId: string): RawPipelineConfig {
|
|
49
|
+
return { ...config, tracks: config.tracks.filter((t) => t.id !== trackId) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Move a track to a new index position (0-based).
|
|
54
|
+
* Clamps toIndex to valid bounds.
|
|
55
|
+
*/
|
|
56
|
+
export function moveTrack(
|
|
57
|
+
config: RawPipelineConfig,
|
|
58
|
+
trackId: string,
|
|
59
|
+
toIndex: number,
|
|
60
|
+
): RawPipelineConfig {
|
|
61
|
+
const idx = config.tracks.findIndex((t) => t.id === trackId);
|
|
62
|
+
if (idx === -1) return config;
|
|
63
|
+
const track = config.tracks[idx]!;
|
|
64
|
+
const withoutTrack = [...config.tracks.slice(0, idx), ...config.tracks.slice(idx + 1)];
|
|
65
|
+
const clamped = Math.max(0, Math.min(toIndex, withoutTrack.length));
|
|
66
|
+
const tracks = [...withoutTrack.slice(0, clamped), track, ...withoutTrack.slice(clamped)];
|
|
67
|
+
return { ...config, tracks };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Update fields on a single track (excluding tasks list, use upsertTask / removeTask for that).
|
|
72
|
+
*/
|
|
73
|
+
export function updateTrack(
|
|
74
|
+
config: RawPipelineConfig,
|
|
75
|
+
trackId: string,
|
|
76
|
+
fields: Partial<Omit<RawTrackConfig, 'id' | 'tasks'>>,
|
|
77
|
+
): RawPipelineConfig {
|
|
78
|
+
return {
|
|
79
|
+
...config,
|
|
80
|
+
tracks: config.tracks.map((t) => (t.id === trackId ? { ...t, ...fields } : t)),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Tasks ──
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Insert or replace a task within a track, matched by task.id. Appends if new.
|
|
88
|
+
* No-op if the trackId is not found.
|
|
89
|
+
*/
|
|
90
|
+
export function upsertTask(
|
|
91
|
+
config: RawPipelineConfig,
|
|
92
|
+
trackId: string,
|
|
93
|
+
task: RawTaskConfig,
|
|
94
|
+
): RawPipelineConfig {
|
|
95
|
+
return {
|
|
96
|
+
...config,
|
|
97
|
+
tracks: config.tracks.map((t) => {
|
|
98
|
+
if (t.id !== trackId) return t;
|
|
99
|
+
const exists = t.tasks.some((tk) => tk.id === task.id);
|
|
100
|
+
return {
|
|
101
|
+
...t,
|
|
102
|
+
tasks: exists ? t.tasks.map((tk) => (tk.id === task.id ? task : tk)) : [...t.tasks, task],
|
|
103
|
+
};
|
|
104
|
+
}),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Remove a task from a track. No-op if either id is not found.
|
|
110
|
+
*
|
|
111
|
+
* When `cleanRefs` is true, all `depends_on` and `continue_from` references to the
|
|
112
|
+
* removed task are also removed from every other task in the pipeline. This prevents
|
|
113
|
+
* validateRaw from reporting dangling-ref errors after the deletion.
|
|
114
|
+
*/
|
|
115
|
+
export function removeTask(
|
|
116
|
+
config: RawPipelineConfig,
|
|
117
|
+
trackId: string,
|
|
118
|
+
taskId: string,
|
|
119
|
+
cleanRefs = false,
|
|
120
|
+
): RawPipelineConfig {
|
|
121
|
+
const withoutTask = {
|
|
122
|
+
...config,
|
|
123
|
+
tracks: config.tracks.map((t) => {
|
|
124
|
+
if (t.id !== trackId) return t;
|
|
125
|
+
return { ...t, tasks: t.tasks.filter((tk) => tk.id !== taskId) };
|
|
126
|
+
}),
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
if (!cleanRefs) return withoutTask;
|
|
130
|
+
|
|
131
|
+
const qualId = `${trackId}.${taskId}`;
|
|
132
|
+
|
|
133
|
+
// After deletion, can a bare ref "taskId" still resolve to some other task globally?
|
|
134
|
+
// It can if any track in the post-deletion config still contains a task with that bare id.
|
|
135
|
+
const bareIdSurvivesGlobally = withoutTask.tracks.some((t) =>
|
|
136
|
+
t.tasks.some((tk) => tk.id === taskId),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
...withoutTask,
|
|
141
|
+
tracks: withoutTask.tracks.map((t) => {
|
|
142
|
+
// Build the set of task IDs remaining in this track (the deleted task
|
|
143
|
+
// has already been removed from its own track in withoutTask).
|
|
144
|
+
const remainingIds = new Set(t.tasks.map((tk) => tk.id));
|
|
145
|
+
|
|
146
|
+
// Resolve whether a ref in THIS track points to the deleted task:
|
|
147
|
+
// - Fully-qualified ref ("trackId.taskId") — always points to the deleted task.
|
|
148
|
+
// - Bare ref ("taskId") from the SAME track as the deleted task — always pointed
|
|
149
|
+
// to the deleted task (same-track lookup takes priority).
|
|
150
|
+
// - Bare ref from a DIFFERENT track:
|
|
151
|
+
// 1. If this track has a local task with that id → ref resolves locally, not removed.
|
|
152
|
+
// 2. Else if some other track still has a task with that id → ref will resolve
|
|
153
|
+
// there after deletion, not removed.
|
|
154
|
+
// 3. Else → ref is dangling, remove it.
|
|
155
|
+
const isRemovedFrom = (ref: string): boolean => {
|
|
156
|
+
if (ref === qualId) return true;
|
|
157
|
+
if (ref === taskId) {
|
|
158
|
+
if (t.id === trackId) return true; // same track — was pointing here
|
|
159
|
+
if (remainingIds.has(taskId)) return false; // local task shadows — ref is fine
|
|
160
|
+
return !bareIdSurvivesGlobally; // remove only if truly dangling
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
...t,
|
|
167
|
+
tasks: t.tasks.map((tk) => cleanTaskRefs(tk, isRemovedFrom)),
|
|
168
|
+
};
|
|
169
|
+
}),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function cleanTaskRefs(task: RawTaskConfig, isRemoved: (ref: string) => boolean): RawTaskConfig {
|
|
174
|
+
const filteredDeps = task.depends_on?.filter((d) => !isRemoved(d));
|
|
175
|
+
const dropContinueFrom = task.continue_from !== undefined && isRemoved(task.continue_from);
|
|
176
|
+
|
|
177
|
+
const depsUnchanged =
|
|
178
|
+
filteredDeps === undefined || filteredDeps.length === task.depends_on!.length;
|
|
179
|
+
if (depsUnchanged && !dropContinueFrom) return task;
|
|
180
|
+
|
|
181
|
+
const { depends_on: _depends_on, continue_from, ...rest } = task;
|
|
182
|
+
return {
|
|
183
|
+
...rest,
|
|
184
|
+
...(filteredDeps !== undefined && filteredDeps.length > 0 ? { depends_on: filteredDeps } : {}),
|
|
185
|
+
...(!dropContinueFrom && continue_from !== undefined ? { continue_from } : {}),
|
|
186
|
+
} as RawTaskConfig;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Reorder a task within its track.
|
|
191
|
+
* Clamps toIndex to valid bounds.
|
|
192
|
+
*/
|
|
193
|
+
export function moveTask(
|
|
194
|
+
config: RawPipelineConfig,
|
|
195
|
+
trackId: string,
|
|
196
|
+
taskId: string,
|
|
197
|
+
toIndex: number,
|
|
198
|
+
): RawPipelineConfig {
|
|
199
|
+
return {
|
|
200
|
+
...config,
|
|
201
|
+
tracks: config.tracks.map((t) => {
|
|
202
|
+
if (t.id !== trackId) return t;
|
|
203
|
+
const idx = t.tasks.findIndex((tk) => tk.id === taskId);
|
|
204
|
+
if (idx === -1) return t;
|
|
205
|
+
const task = t.tasks[idx]!;
|
|
206
|
+
const withoutTask = [...t.tasks.slice(0, idx), ...t.tasks.slice(idx + 1)];
|
|
207
|
+
const clamped = Math.max(0, Math.min(toIndex, withoutTask.length));
|
|
208
|
+
const tasks = [...withoutTask.slice(0, clamped), task, ...withoutTask.slice(clamped)];
|
|
209
|
+
return { ...t, tasks };
|
|
210
|
+
}),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Move a task from one track to another (appends to the target track).
|
|
216
|
+
* No-op if either trackId or taskId is not found.
|
|
217
|
+
*
|
|
218
|
+
* When `qualifyRefs` is true (the default), bare references (`depends_on`,
|
|
219
|
+
* `continue_from`) pointing to the moved task are converted to fully-qualified
|
|
220
|
+
* refs (`toTrackId.taskId`) so that same-track resolution doesn't silently
|
|
221
|
+
* break after the task changes tracks.
|
|
222
|
+
*/
|
|
223
|
+
export function transferTask(
|
|
224
|
+
config: RawPipelineConfig,
|
|
225
|
+
fromTrackId: string,
|
|
226
|
+
taskId: string,
|
|
227
|
+
toTrackId: string,
|
|
228
|
+
qualifyRefs = true,
|
|
229
|
+
): RawPipelineConfig {
|
|
230
|
+
if (fromTrackId === toTrackId) return config;
|
|
231
|
+
|
|
232
|
+
let task: RawTaskConfig | undefined;
|
|
233
|
+
const afterRemove = {
|
|
234
|
+
...config,
|
|
235
|
+
tracks: config.tracks.map((t) => {
|
|
236
|
+
if (t.id !== fromTrackId) return t;
|
|
237
|
+
const found = t.tasks.find((tk) => tk.id === taskId);
|
|
238
|
+
if (!found) return t;
|
|
239
|
+
task = found;
|
|
240
|
+
return { ...t, tasks: t.tasks.filter((tk) => tk.id !== taskId) };
|
|
241
|
+
}),
|
|
242
|
+
};
|
|
243
|
+
if (!task) return config;
|
|
244
|
+
const afterInsert = upsertTask(afterRemove, toTrackId, task);
|
|
245
|
+
|
|
246
|
+
if (!qualifyRefs) return afterInsert;
|
|
247
|
+
|
|
248
|
+
// Qualify bare references to the moved task. After the move, bare ref
|
|
249
|
+
// "taskId" from the old track no longer resolves via same-track priority.
|
|
250
|
+
// Convert it to the qualified form "toTrackId.taskId" so the dependency
|
|
251
|
+
// graph stays correct.
|
|
252
|
+
const qualId = `${toTrackId}.${taskId}`;
|
|
253
|
+
const oldQualId = `${fromTrackId}.${taskId}`;
|
|
254
|
+
|
|
255
|
+
// Does any track (other than the destination) still have a task with this bare id?
|
|
256
|
+
const bareIdSurvivesElsewhere = afterInsert.tracks.some(
|
|
257
|
+
(t) => t.id !== toTrackId && t.tasks.some((tk) => tk.id === taskId),
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
...afterInsert,
|
|
262
|
+
tracks: afterInsert.tracks.map((t) => {
|
|
263
|
+
const localHasId = t.tasks.some((tk) => tk.id === taskId);
|
|
264
|
+
|
|
265
|
+
const qualifyRef = (ref: string): string => {
|
|
266
|
+
// Already-qualified ref to old location → rewrite to new location
|
|
267
|
+
if (ref === oldQualId) return qualId;
|
|
268
|
+
// Bare ref: only needs qualifying if it would have resolved to the
|
|
269
|
+
// moved task before the transfer
|
|
270
|
+
if (ref === taskId) {
|
|
271
|
+
if (t.id === fromTrackId) {
|
|
272
|
+
// Was same-track in the old track — now the task is gone.
|
|
273
|
+
// If no other local task shadows it, qualify to new location.
|
|
274
|
+
if (!localHasId) return qualId;
|
|
275
|
+
}
|
|
276
|
+
// From a different track: bare ref resolved globally before.
|
|
277
|
+
// If the bare id is now ambiguous or gone from this track's
|
|
278
|
+
// perspective, qualify it.
|
|
279
|
+
if (!localHasId && !bareIdSurvivesElsewhere) return qualId;
|
|
280
|
+
}
|
|
281
|
+
return ref;
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
...t,
|
|
286
|
+
tasks: t.tasks.map((tk) => qualifyTaskRefs(tk, qualifyRef)),
|
|
287
|
+
};
|
|
288
|
+
}),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Rewrite `depends_on` and `continue_from` refs using a mapping function. */
|
|
293
|
+
function qualifyTaskRefs(task: RawTaskConfig, rewrite: (ref: string) => string): RawTaskConfig {
|
|
294
|
+
const newDeps = task.depends_on?.map(rewrite);
|
|
295
|
+
const newContinue = task.continue_from !== undefined ? rewrite(task.continue_from) : undefined;
|
|
296
|
+
|
|
297
|
+
const depsChanged = newDeps !== undefined && newDeps.some((d, i) => d !== task.depends_on![i]);
|
|
298
|
+
const continueChanged = newContinue !== undefined && newContinue !== task.continue_from;
|
|
299
|
+
|
|
300
|
+
if (!depsChanged && !continueChanged) return task;
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
...task,
|
|
304
|
+
...(newDeps !== undefined ? { depends_on: newDeps } : {}),
|
|
305
|
+
...(newContinue !== undefined ? { continue_from: newContinue } : {}),
|
|
306
|
+
};
|
|
307
|
+
}
|