@tagma/sdk 0.1.7 → 0.1.9

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.
@@ -1,19 +1,19 @@
1
- import type { CompletionPlugin, CompletionContext, TaskResult } from '../types';
2
-
3
- export const ExitCodeCompletion: CompletionPlugin = {
4
- name: 'exit_code',
5
-
6
- async check(config: Record<string, unknown>, result: TaskResult, _ctx: CompletionContext): Promise<boolean> {
7
- const expected = config.expect ?? 0;
8
-
9
- if (typeof expected === 'number') {
10
- return result.exitCode === expected;
11
- }
12
- if (Array.isArray(expected) && expected.every((v) => typeof v === 'number')) {
13
- return expected.includes(result.exitCode);
14
- }
15
- throw new Error(
16
- `exit_code completion: "expect" must be a number or number[], got ${typeof expected}`
17
- );
18
- },
19
- };
1
+ import type { CompletionPlugin, CompletionContext, TaskResult } from '../types';
2
+
3
+ export const ExitCodeCompletion: CompletionPlugin = {
4
+ name: 'exit_code',
5
+
6
+ async check(config: Record<string, unknown>, result: TaskResult, _ctx: CompletionContext): Promise<boolean> {
7
+ const expected = config.expect ?? 0;
8
+
9
+ if (typeof expected === 'number') {
10
+ return result.exitCode === expected;
11
+ }
12
+ if (Array.isArray(expected) && expected.every((v) => typeof v === 'number')) {
13
+ return expected.includes(result.exitCode);
14
+ }
15
+ throw new Error(
16
+ `exit_code completion: "expect" must be a number or number[], got ${typeof expected}`
17
+ );
18
+ },
19
+ };
@@ -1,39 +1,39 @@
1
- import { stat } from 'node:fs/promises';
2
- import type { CompletionPlugin, CompletionContext, TaskResult } from '../types';
3
- import { validatePath } from '../utils';
4
-
5
- type Kind = 'file' | 'dir' | 'any';
6
-
7
- export const FileExistsCompletion: CompletionPlugin = {
8
- name: 'file_exists',
9
-
10
- async check(config: Record<string, unknown>, _result: TaskResult, ctx: CompletionContext): Promise<boolean> {
11
- const filePath = config.path as string;
12
- if (!filePath) throw new Error('file_exists completion: "path" is required');
13
-
14
- const safePath = validatePath(filePath, ctx.workDir);
15
-
16
- const kind = (config.kind as Kind | undefined) ?? 'any';
17
- if (kind !== 'file' && kind !== 'dir' && kind !== 'any') {
18
- throw new Error(`file_exists completion: "kind" must be "file" | "dir" | "any", got "${kind}"`);
19
- }
20
-
21
- const minSize = config.min_size;
22
- if (minSize != null && (typeof minSize !== 'number' || minSize < 0)) {
23
- throw new Error(`file_exists completion: "min_size" must be a non-negative number`);
24
- }
25
-
26
- try {
27
- const st = await stat(safePath);
28
- if (kind === 'file' && !st.isFile()) return false;
29
- if (kind === 'dir' && !st.isDirectory()) return false;
30
- if (typeof minSize === 'number' && st.isFile() && st.size < minSize) return false;
31
- return true;
32
- } catch (err: unknown) {
33
- const code = (err as NodeJS.ErrnoException).code;
34
- if (code === 'ENOENT' || code === 'ENOTDIR') return false;
35
- // Permission / IO errors should surface, not silently mean "missing"
36
- throw err;
37
- }
38
- },
39
- };
1
+ import { stat } from 'node:fs/promises';
2
+ import type { CompletionPlugin, CompletionContext, TaskResult } from '../types';
3
+ import { validatePath } from '../utils';
4
+
5
+ type Kind = 'file' | 'dir' | 'any';
6
+
7
+ export const FileExistsCompletion: CompletionPlugin = {
8
+ name: 'file_exists',
9
+
10
+ async check(config: Record<string, unknown>, _result: TaskResult, ctx: CompletionContext): Promise<boolean> {
11
+ const filePath = config.path as string;
12
+ if (!filePath) throw new Error('file_exists completion: "path" is required');
13
+
14
+ const safePath = validatePath(filePath, ctx.workDir);
15
+
16
+ const kind = (config.kind as Kind | undefined) ?? 'any';
17
+ if (kind !== 'file' && kind !== 'dir' && kind !== 'any') {
18
+ throw new Error(`file_exists completion: "kind" must be "file" | "dir" | "any", got "${kind}"`);
19
+ }
20
+
21
+ const minSize = config.min_size;
22
+ if (minSize != null && (typeof minSize !== 'number' || minSize < 0)) {
23
+ throw new Error(`file_exists completion: "min_size" must be a non-negative number`);
24
+ }
25
+
26
+ try {
27
+ const st = await stat(safePath);
28
+ if (kind === 'file' && !st.isFile()) return false;
29
+ if (kind === 'dir' && !st.isDirectory()) return false;
30
+ if (typeof minSize === 'number' && st.isFile() && st.size < minSize) return false;
31
+ return true;
32
+ } catch (err: unknown) {
33
+ const code = (err as NodeJS.ErrnoException).code;
34
+ if (code === 'ENOENT' || code === 'ENOTDIR') return false;
35
+ // Permission / IO errors should surface, not silently mean "missing"
36
+ throw err;
37
+ }
38
+ },
39
+ };
@@ -1,57 +1,57 @@
1
- import type { CompletionPlugin, CompletionContext, TaskResult } from '../types';
2
- import { shellArgs, parseDuration } from '../utils';
3
-
4
- const DEFAULT_TIMEOUT_MS = 30_000;
5
-
6
- export const OutputCheckCompletion: CompletionPlugin = {
7
- name: 'output_check',
8
-
9
- async check(config: Record<string, unknown>, result: TaskResult, ctx: CompletionContext): Promise<boolean> {
10
- const checkCmd = config.check as string;
11
- if (!checkCmd) throw new Error('output_check completion: "check" is required');
12
-
13
- const timeoutMs = config.timeout != null
14
- ? parseDuration(String(config.timeout))
15
- : DEFAULT_TIMEOUT_MS;
16
-
17
- const controller = new AbortController();
18
- const timer = setTimeout(() => controller.abort(), timeoutMs);
19
-
20
- const proc = Bun.spawn(shellArgs(checkCmd) as string[], {
21
- cwd: ctx.workDir,
22
- stdin: 'pipe',
23
- stdout: 'pipe',
24
- stderr: 'pipe',
25
- signal: controller.signal,
26
- });
27
-
28
- try {
29
- if (proc.stdin) {
30
- try {
31
- proc.stdin.write(result.stdout);
32
- await proc.stdin.end();
33
- } catch (err: unknown) {
34
- // EPIPE is expected when the check process exits before reading all of stdin
35
- // (e.g. `grep -q` exits on first match). Anything else is a real failure.
36
- const code = (err as NodeJS.ErrnoException)?.code;
37
- if (code !== 'EPIPE') throw err;
38
- }
39
- }
40
-
41
- const exitCode = await proc.exited;
42
-
43
- if (exitCode !== 0) {
44
- try {
45
- const stderr = await new Response(proc.stderr).text();
46
- if (stderr.trim()) {
47
- console.warn(`[output_check] "${checkCmd}" exit=${exitCode}: ${stderr.trim()}`);
48
- }
49
- } catch { /* ignore stderr read failures */ }
50
- }
51
-
52
- return exitCode === 0;
53
- } finally {
54
- clearTimeout(timer);
55
- }
56
- },
57
- };
1
+ import type { CompletionPlugin, CompletionContext, TaskResult } from '../types';
2
+ import { shellArgs, parseDuration } from '../utils';
3
+
4
+ const DEFAULT_TIMEOUT_MS = 30_000;
5
+
6
+ export const OutputCheckCompletion: CompletionPlugin = {
7
+ name: 'output_check',
8
+
9
+ async check(config: Record<string, unknown>, result: TaskResult, ctx: CompletionContext): Promise<boolean> {
10
+ const checkCmd = config.check as string;
11
+ if (!checkCmd) throw new Error('output_check completion: "check" is required');
12
+
13
+ const timeoutMs = config.timeout != null
14
+ ? parseDuration(String(config.timeout))
15
+ : DEFAULT_TIMEOUT_MS;
16
+
17
+ const controller = new AbortController();
18
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
19
+
20
+ const proc = Bun.spawn(shellArgs(checkCmd) as string[], {
21
+ cwd: ctx.workDir,
22
+ stdin: 'pipe',
23
+ stdout: 'pipe',
24
+ stderr: 'pipe',
25
+ signal: controller.signal,
26
+ });
27
+
28
+ try {
29
+ if (proc.stdin) {
30
+ try {
31
+ proc.stdin.write(result.stdout);
32
+ proc.stdin.end(); // no await — consistent with runner.ts; proc.exited handles sync
33
+ } catch (err: unknown) {
34
+ // EPIPE is expected when the check process exits before reading all of stdin
35
+ // (e.g. `grep -q` exits on first match). Anything else is a real failure.
36
+ const code = (err as NodeJS.ErrnoException)?.code;
37
+ if (code !== 'EPIPE') throw err;
38
+ }
39
+ }
40
+
41
+ // Consume stderr concurrently with waiting for exit to prevent pipe-buffer
42
+ // deadlock when check script emits more than ~64 KB of stderr output.
43
+ const [exitCode, stderr] = await Promise.all([
44
+ proc.exited,
45
+ new Response(proc.stderr).text(),
46
+ ]);
47
+
48
+ if (exitCode !== 0 && stderr.trim()) {
49
+ console.warn(`[output_check] "${checkCmd}" exit=${exitCode}: ${stderr.trim()}`);
50
+ }
51
+
52
+ return exitCode === 0;
53
+ } finally {
54
+ clearTimeout(timer);
55
+ }
56
+ },
57
+ };
package/src/config-ops.ts CHANGED
@@ -1,183 +1,239 @@
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(
36
- config: RawPipelineConfig,
37
- track: RawTrackConfig,
38
- ): RawPipelineConfig {
39
- const exists = config.tracks.some(t => t.id === track.id);
40
- return {
41
- ...config,
42
- tracks: exists
43
- ? config.tracks.map(t => (t.id === track.id ? track : t))
44
- : [...config.tracks, track],
45
- };
46
- }
47
-
48
- /**
49
- * Remove a track by id. No-op if the id is not found.
50
- */
51
- export function removeTrack(
52
- config: RawPipelineConfig,
53
- trackId: string,
54
- ): RawPipelineConfig {
55
- return { ...config, tracks: config.tracks.filter(t => t.id !== trackId) };
56
- }
57
-
58
- /**
59
- * Move a track to a new index position (0-based).
60
- * Clamps toIndex to valid bounds.
61
- */
62
- export function moveTrack(
63
- config: RawPipelineConfig,
64
- trackId: string,
65
- toIndex: number,
66
- ): RawPipelineConfig {
67
- const idx = config.tracks.findIndex(t => t.id === trackId);
68
- if (idx === -1) return config;
69
- const tracks = [...config.tracks];
70
- const [track] = tracks.splice(idx, 1);
71
- const clamped = Math.max(0, Math.min(toIndex, tracks.length));
72
- tracks.splice(clamped, 0, track);
73
- return { ...config, tracks };
74
- }
75
-
76
- /**
77
- * Update fields on a single track (excluding tasks list, use upsertTask / removeTask for that).
78
- */
79
- export function updateTrack(
80
- config: RawPipelineConfig,
81
- trackId: string,
82
- fields: Partial<Omit<RawTrackConfig, 'id' | 'tasks'>>,
83
- ): RawPipelineConfig {
84
- return {
85
- ...config,
86
- tracks: config.tracks.map(t =>
87
- t.id === trackId ? { ...t, ...fields } : t,
88
- ),
89
- };
90
- }
91
-
92
- // ── Tasks ──
93
-
94
- /**
95
- * Insert or replace a task within a track, matched by task.id. Appends if new.
96
- * No-op if the trackId is not found.
97
- */
98
- export function upsertTask(
99
- config: RawPipelineConfig,
100
- trackId: string,
101
- task: RawTaskConfig,
102
- ): RawPipelineConfig {
103
- return {
104
- ...config,
105
- tracks: config.tracks.map(t => {
106
- if (t.id !== trackId) return t;
107
- const exists = t.tasks.some(tk => tk.id === task.id);
108
- return {
109
- ...t,
110
- tasks: exists
111
- ? t.tasks.map(tk => (tk.id === task.id ? task : tk))
112
- : [...t.tasks, task],
113
- };
114
- }),
115
- };
116
- }
117
-
118
- /**
119
- * Remove a task from a track. No-op if either id is not found.
120
- */
121
- export function removeTask(
122
- config: RawPipelineConfig,
123
- trackId: string,
124
- taskId: string,
125
- ): RawPipelineConfig {
126
- return {
127
- ...config,
128
- tracks: config.tracks.map(t => {
129
- if (t.id !== trackId) return t;
130
- return { ...t, tasks: t.tasks.filter(tk => tk.id !== taskId) };
131
- }),
132
- };
133
- }
134
-
135
- /**
136
- * Reorder a task within its track.
137
- * Clamps toIndex to valid bounds.
138
- */
139
- export function moveTask(
140
- config: RawPipelineConfig,
141
- trackId: string,
142
- taskId: string,
143
- toIndex: number,
144
- ): RawPipelineConfig {
145
- return {
146
- ...config,
147
- tracks: config.tracks.map(t => {
148
- if (t.id !== trackId) return t;
149
- const idx = t.tasks.findIndex(tk => tk.id === taskId);
150
- if (idx === -1) return t;
151
- const tasks = [...t.tasks];
152
- const [task] = tasks.splice(idx, 1);
153
- const clamped = Math.max(0, Math.min(toIndex, tasks.length));
154
- tasks.splice(clamped, 0, task);
155
- return { ...t, tasks };
156
- }),
157
- };
158
- }
159
-
160
- /**
161
- * Move a task from one track to another (appends to the target track).
162
- * No-op if either trackId or taskId is not found.
163
- */
164
- export function transferTask(
165
- config: RawPipelineConfig,
166
- fromTrackId: string,
167
- taskId: string,
168
- toTrackId: string,
169
- ): RawPipelineConfig {
170
- let task: RawTaskConfig | undefined;
171
- const afterRemove = {
172
- ...config,
173
- tracks: config.tracks.map(t => {
174
- if (t.id !== fromTrackId) return t;
175
- const found = t.tasks.find(tk => tk.id === taskId);
176
- if (!found) return t;
177
- task = found;
178
- return { ...t, tasks: t.tasks.filter(tk => tk.id !== taskId) };
179
- }),
180
- };
181
- if (!task) return config;
182
- return upsertTask(afterRemove, toTrackId, task);
183
- }
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(
36
+ config: RawPipelineConfig,
37
+ track: RawTrackConfig,
38
+ ): RawPipelineConfig {
39
+ const exists = config.tracks.some(t => t.id === track.id);
40
+ return {
41
+ ...config,
42
+ tracks: exists
43
+ ? config.tracks.map(t => (t.id === track.id ? track : t))
44
+ : [...config.tracks, track],
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Remove a track by id. No-op if the id is not found.
50
+ */
51
+ export function removeTrack(
52
+ config: RawPipelineConfig,
53
+ trackId: string,
54
+ ): RawPipelineConfig {
55
+ return { ...config, tracks: config.tracks.filter(t => t.id !== trackId) };
56
+ }
57
+
58
+ /**
59
+ * Move a track to a new index position (0-based).
60
+ * Clamps toIndex to valid bounds.
61
+ */
62
+ export function moveTrack(
63
+ config: RawPipelineConfig,
64
+ trackId: string,
65
+ toIndex: number,
66
+ ): RawPipelineConfig {
67
+ const idx = config.tracks.findIndex(t => t.id === trackId);
68
+ if (idx === -1) return config;
69
+ const track = config.tracks[idx]!;
70
+ const withoutTrack = [...config.tracks.slice(0, idx), ...config.tracks.slice(idx + 1)];
71
+ const clamped = Math.max(0, Math.min(toIndex, withoutTrack.length));
72
+ const tracks = [...withoutTrack.slice(0, clamped), track, ...withoutTrack.slice(clamped)];
73
+ return { ...config, tracks };
74
+ }
75
+
76
+ /**
77
+ * Update fields on a single track (excluding tasks list, use upsertTask / removeTask for that).
78
+ */
79
+ export function updateTrack(
80
+ config: RawPipelineConfig,
81
+ trackId: string,
82
+ fields: Partial<Omit<RawTrackConfig, 'id' | 'tasks'>>,
83
+ ): RawPipelineConfig {
84
+ return {
85
+ ...config,
86
+ tracks: config.tracks.map(t =>
87
+ t.id === trackId ? { ...t, ...fields } : t,
88
+ ),
89
+ };
90
+ }
91
+
92
+ // ── Tasks ──
93
+
94
+ /**
95
+ * Insert or replace a task within a track, matched by task.id. Appends if new.
96
+ * No-op if the trackId is not found.
97
+ */
98
+ export function upsertTask(
99
+ config: RawPipelineConfig,
100
+ trackId: string,
101
+ task: RawTaskConfig,
102
+ ): RawPipelineConfig {
103
+ return {
104
+ ...config,
105
+ tracks: config.tracks.map(t => {
106
+ if (t.id !== trackId) return t;
107
+ const exists = t.tasks.some(tk => tk.id === task.id);
108
+ return {
109
+ ...t,
110
+ tasks: exists
111
+ ? t.tasks.map(tk => (tk.id === task.id ? task : tk))
112
+ : [...t.tasks, task],
113
+ };
114
+ }),
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Remove a task from a track. No-op if either id is not found.
120
+ *
121
+ * When `cleanRefs` is true, all `depends_on` and `continue_from` references to the
122
+ * removed task are also removed from every other task in the pipeline. This prevents
123
+ * validateRaw from reporting dangling-ref errors after the deletion.
124
+ */
125
+ export function removeTask(
126
+ config: RawPipelineConfig,
127
+ trackId: string,
128
+ taskId: string,
129
+ cleanRefs = false,
130
+ ): RawPipelineConfig {
131
+ const withoutTask = {
132
+ ...config,
133
+ tracks: config.tracks.map(t => {
134
+ if (t.id !== trackId) return t;
135
+ return { ...t, tasks: t.tasks.filter(tk => tk.id !== taskId) };
136
+ }),
137
+ };
138
+
139
+ if (!cleanRefs) return withoutTask;
140
+
141
+ const qualId = `${trackId}.${taskId}`;
142
+
143
+ return {
144
+ ...withoutTask,
145
+ tracks: withoutTask.tracks.map(t => {
146
+ // Build the set of task IDs remaining in this track (the deleted task
147
+ // has already been removed from its own track in withoutTask).
148
+ const remainingIds = new Set(t.tasks.map(tk => tk.id));
149
+
150
+ // Resolve whether a ref in THIS track points to the deleted task:
151
+ // - Fully-qualified ref ("trackId.taskId") — always points to the deleted task.
152
+ // - Bare ref ("taskId") from the same track — always pointed to the deleted task
153
+ // (same-track lookup takes priority, and the task was in this track).
154
+ // - Bare ref from a different track — points to the deleted task only if this
155
+ // track has no task with that same id (no local task to shadow it).
156
+ const isRemovedFrom = (ref: string): boolean => {
157
+ if (ref === qualId) return true;
158
+ if (ref === taskId) {
159
+ if (t.id === trackId) return true; // same track — was pointing here
160
+ return !remainingIds.has(taskId); // cross-track — only if no local override
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(
174
+ task: RawTaskConfig,
175
+ isRemoved: (ref: string) => boolean,
176
+ ): RawTaskConfig {
177
+ const filteredDeps = task.depends_on?.filter(d => !isRemoved(d));
178
+ const dropContinueFrom = task.continue_from !== undefined && isRemoved(task.continue_from);
179
+
180
+ const depsUnchanged = filteredDeps === undefined || filteredDeps.length === task.depends_on!.length;
181
+ if (depsUnchanged && !dropContinueFrom) return task;
182
+
183
+ const { depends_on, continue_from, ...rest } = task;
184
+ return {
185
+ ...rest,
186
+ ...(filteredDeps !== undefined && filteredDeps.length > 0 ? { depends_on: filteredDeps } : {}),
187
+ ...(!dropContinueFrom && continue_from !== undefined ? { continue_from } : {}),
188
+ } as RawTaskConfig;
189
+ }
190
+
191
+ /**
192
+ * Reorder a task within its track.
193
+ * Clamps toIndex to valid bounds.
194
+ */
195
+ export function moveTask(
196
+ config: RawPipelineConfig,
197
+ trackId: string,
198
+ taskId: string,
199
+ toIndex: number,
200
+ ): RawPipelineConfig {
201
+ return {
202
+ ...config,
203
+ tracks: config.tracks.map(t => {
204
+ if (t.id !== trackId) return t;
205
+ const idx = t.tasks.findIndex(tk => tk.id === taskId);
206
+ if (idx === -1) return t;
207
+ const task = t.tasks[idx]!;
208
+ const withoutTask = [...t.tasks.slice(0, idx), ...t.tasks.slice(idx + 1)];
209
+ const clamped = Math.max(0, Math.min(toIndex, withoutTask.length));
210
+ const tasks = [...withoutTask.slice(0, clamped), task, ...withoutTask.slice(clamped)];
211
+ return { ...t, tasks };
212
+ }),
213
+ };
214
+ }
215
+
216
+ /**
217
+ * Move a task from one track to another (appends to the target track).
218
+ * No-op if either trackId or taskId is not found.
219
+ */
220
+ export function transferTask(
221
+ config: RawPipelineConfig,
222
+ fromTrackId: string,
223
+ taskId: string,
224
+ toTrackId: string,
225
+ ): RawPipelineConfig {
226
+ let task: RawTaskConfig | undefined;
227
+ const afterRemove = {
228
+ ...config,
229
+ tracks: config.tracks.map(t => {
230
+ if (t.id !== fromTrackId) return t;
231
+ const found = t.tasks.find(tk => tk.id === taskId);
232
+ if (!found) return t;
233
+ task = found;
234
+ return { ...t, tasks: t.tasks.filter(tk => tk.id !== taskId) };
235
+ }),
236
+ };
237
+ if (!task) return config;
238
+ return upsertTask(afterRemove, toTrackId, task);
239
+ }