@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.
- package/README.md +23 -5
- package/package.json +1 -1
- package/src/adapters/stdin-approval.ts +117 -117
- package/src/adapters/websocket-approval.ts +175 -144
- package/src/approval.ts +4 -1
- package/src/completions/exit-code.ts +19 -19
- package/src/completions/file-exists.ts +39 -39
- package/src/completions/output-check.ts +57 -57
- package/src/config-ops.ts +239 -183
- package/src/dag.ts +222 -137
- package/src/drivers/claude-code.ts +207 -207
- package/src/engine.ts +743 -698
- package/src/hooks.ts +147 -138
- package/src/logger.ts +112 -107
- package/src/middlewares/static-context.ts +29 -29
- package/src/pipeline-runner.ts +126 -113
- package/src/runner.ts +213 -195
- package/src/schema.ts +386 -358
- package/src/sdk.ts +2 -2
- package/src/triggers/file.ts +105 -94
- package/src/triggers/manual.ts +61 -61
- package/src/utils.ts +154 -147
- package/src/validate-raw.ts +223 -203
|
@@ -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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
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
|
|
70
|
-
const
|
|
71
|
-
const clamped = Math.max(0, Math.min(toIndex,
|
|
72
|
-
tracks.
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
trackId
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
)
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if (!
|
|
182
|
-
|
|
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
|
+
}
|