@tagma/sdk 0.1.3 → 0.1.5

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.
@@ -0,0 +1,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 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
+ }
package/src/dag.ts CHANGED
@@ -1,137 +1,137 @@
1
- import type { PipelineConfig, TaskConfig, TrackConfig } from './types';
2
-
3
- export interface DagNode {
4
- readonly taskId: string; // fully qualified: track_id.task_id or just task_id
5
- readonly task: TaskConfig;
6
- readonly track: TrackConfig;
7
- readonly dependsOn: readonly string[];
8
- }
9
-
10
- export interface Dag {
11
- readonly nodes: ReadonlyMap<string, DagNode>;
12
- readonly sorted: readonly string[]; // topological order
13
- }
14
-
15
- // Build a global task ID: for cross-track refs we use "track_id.task_id"
16
- // Within a track, bare "task_id" is also valid
17
- function qualifyId(trackId: string, taskId: string): string {
18
- return `${trackId}.${taskId}`;
19
- }
20
-
21
- export function buildDag(config: PipelineConfig): Dag {
22
- const nodes = new Map<string, DagNode>();
23
- // Map bare task IDs to qualified IDs (for resolving unqualified refs)
24
- const bareToQualified = new Map<string, string>();
25
-
26
- // 1. Register all nodes
27
- for (const track of config.tracks) {
28
- for (const task of track.tasks) {
29
- const qid = qualifyId(track.id, task.id);
30
-
31
- if (nodes.has(qid)) {
32
- throw new Error(`Duplicate task ID: "${qid}"`);
33
- }
34
-
35
- // Track bare ID → qualified. If same bare ID in multiple tracks, mark ambiguous
36
- if (bareToQualified.has(task.id)) {
37
- bareToQualified.set(task.id, '__ambiguous__');
38
- } else {
39
- bareToQualified.set(task.id, qid);
40
- }
41
-
42
- nodes.set(qid, {
43
- taskId: qid,
44
- task,
45
- track,
46
- dependsOn: [], // filled below
47
- });
48
- }
49
- }
50
-
51
- // Helper to resolve a dependency ref to a qualified ID
52
- function resolveRef(ref: string, fromTrackId: string): string {
53
- // Already qualified (contains dot)
54
- if (ref.includes('.')) {
55
- if (!nodes.has(ref)) {
56
- throw new Error(`Task reference "${ref}" not found`);
57
- }
58
- return ref;
59
- }
60
- // Try within same track first
61
- const sameTrack = qualifyId(fromTrackId, ref);
62
- if (nodes.has(sameTrack)) return sameTrack;
63
- // Try global bare lookup
64
- const global = bareToQualified.get(ref);
65
- if (global && global !== '__ambiguous__') return global;
66
- if (global === '__ambiguous__') {
67
- throw new Error(
68
- `Ambiguous task reference "${ref}" exists in multiple tracks. ` +
69
- `Use "track_id.task_id" format.`
70
- );
71
- }
72
- throw new Error(`Task reference "${ref}" not found`);
73
- }
74
-
75
- // 2. Resolve depends_on and continue_from to qualified IDs
76
- for (const track of config.tracks) {
77
- for (const task of track.tasks) {
78
- const qid = qualifyId(track.id, task.id);
79
- const deps: string[] = [];
80
-
81
- if (task.depends_on) {
82
- for (const dep of task.depends_on) {
83
- deps.push(resolveRef(dep, track.id));
84
- }
85
- }
86
- if (task.continue_from) {
87
- const resolved = resolveRef(task.continue_from, track.id);
88
- if (!deps.includes(resolved)) {
89
- deps.push(resolved); // continue_from implies dependency
90
- }
91
- }
92
-
93
- // Replace node with resolved deps
94
- const node = nodes.get(qid)!;
95
- nodes.set(qid, { ...node, dependsOn: deps });
96
- }
97
- }
98
-
99
- // 3. Topological sort + cycle detection (Kahn's algorithm)
100
- const inDegree = new Map<string, number>();
101
- const adjacency = new Map<string, string[]>(); // parent → children
102
-
103
- for (const [id] of nodes) {
104
- inDegree.set(id, 0);
105
- adjacency.set(id, []);
106
- }
107
-
108
- for (const [id, node] of nodes) {
109
- for (const dep of node.dependsOn) {
110
- adjacency.get(dep)!.push(id);
111
- inDegree.set(id, (inDegree.get(id) ?? 0) + 1);
112
- }
113
- }
114
-
115
- const queue: string[] = [];
116
- for (const [id, degree] of inDegree) {
117
- if (degree === 0) queue.push(id);
118
- }
119
-
120
- const sorted: string[] = [];
121
- while (queue.length > 0) {
122
- const current = queue.shift()!;
123
- sorted.push(current);
124
- for (const child of adjacency.get(current)!) {
125
- const newDegree = inDegree.get(child)! - 1;
126
- inDegree.set(child, newDegree);
127
- if (newDegree === 0) queue.push(child);
128
- }
129
- }
130
-
131
- if (sorted.length !== nodes.size) {
132
- const remaining = [...nodes.keys()].filter(id => !sorted.includes(id));
133
- throw new Error(`Circular dependency detected involving tasks: ${remaining.join(', ')}`);
134
- }
135
-
136
- return { nodes, sorted };
137
- }
1
+ import type { PipelineConfig, TaskConfig, TrackConfig } from './types';
2
+
3
+ export interface DagNode {
4
+ readonly taskId: string; // fully qualified: track_id.task_id or just task_id
5
+ readonly task: TaskConfig;
6
+ readonly track: TrackConfig;
7
+ readonly dependsOn: readonly string[];
8
+ }
9
+
10
+ export interface Dag {
11
+ readonly nodes: ReadonlyMap<string, DagNode>;
12
+ readonly sorted: readonly string[]; // topological order
13
+ }
14
+
15
+ // Build a global task ID: for cross-track refs we use "track_id.task_id"
16
+ // Within a track, bare "task_id" is also valid
17
+ function qualifyId(trackId: string, taskId: string): string {
18
+ return `${trackId}.${taskId}`;
19
+ }
20
+
21
+ export function buildDag(config: PipelineConfig): Dag {
22
+ const nodes = new Map<string, DagNode>();
23
+ // Map bare task IDs to qualified IDs (for resolving unqualified refs)
24
+ const bareToQualified = new Map<string, string>();
25
+
26
+ // 1. Register all nodes
27
+ for (const track of config.tracks) {
28
+ for (const task of track.tasks) {
29
+ const qid = qualifyId(track.id, task.id);
30
+
31
+ if (nodes.has(qid)) {
32
+ throw new Error(`Duplicate task ID: "${qid}"`);
33
+ }
34
+
35
+ // Track bare ID → qualified. If same bare ID in multiple tracks, mark ambiguous
36
+ if (bareToQualified.has(task.id)) {
37
+ bareToQualified.set(task.id, '__ambiguous__');
38
+ } else {
39
+ bareToQualified.set(task.id, qid);
40
+ }
41
+
42
+ nodes.set(qid, {
43
+ taskId: qid,
44
+ task,
45
+ track,
46
+ dependsOn: [], // filled below
47
+ });
48
+ }
49
+ }
50
+
51
+ // Helper to resolve a dependency ref to a qualified ID
52
+ function resolveRef(ref: string, fromTrackId: string): string {
53
+ // Already qualified (contains dot)
54
+ if (ref.includes('.')) {
55
+ if (!nodes.has(ref)) {
56
+ throw new Error(`Task reference "${ref}" not found`);
57
+ }
58
+ return ref;
59
+ }
60
+ // Try within same track first
61
+ const sameTrack = qualifyId(fromTrackId, ref);
62
+ if (nodes.has(sameTrack)) return sameTrack;
63
+ // Try global bare lookup
64
+ const global = bareToQualified.get(ref);
65
+ if (global && global !== '__ambiguous__') return global;
66
+ if (global === '__ambiguous__') {
67
+ throw new Error(
68
+ `Ambiguous task reference "${ref}" exists in multiple tracks. ` +
69
+ `Use "track_id.task_id" format.`
70
+ );
71
+ }
72
+ throw new Error(`Task reference "${ref}" not found`);
73
+ }
74
+
75
+ // 2. Resolve depends_on and continue_from to qualified IDs
76
+ for (const track of config.tracks) {
77
+ for (const task of track.tasks) {
78
+ const qid = qualifyId(track.id, task.id);
79
+ const deps: string[] = [];
80
+
81
+ if (task.depends_on) {
82
+ for (const dep of task.depends_on) {
83
+ deps.push(resolveRef(dep, track.id));
84
+ }
85
+ }
86
+ if (task.continue_from) {
87
+ const resolved = resolveRef(task.continue_from, track.id);
88
+ if (!deps.includes(resolved)) {
89
+ deps.push(resolved); // continue_from implies dependency
90
+ }
91
+ }
92
+
93
+ // Replace node with resolved deps
94
+ const node = nodes.get(qid)!;
95
+ nodes.set(qid, { ...node, dependsOn: deps });
96
+ }
97
+ }
98
+
99
+ // 3. Topological sort + cycle detection (Kahn's algorithm)
100
+ const inDegree = new Map<string, number>();
101
+ const adjacency = new Map<string, string[]>(); // parent → children
102
+
103
+ for (const [id] of nodes) {
104
+ inDegree.set(id, 0);
105
+ adjacency.set(id, []);
106
+ }
107
+
108
+ for (const [id, node] of nodes) {
109
+ for (const dep of node.dependsOn) {
110
+ adjacency.get(dep)!.push(id);
111
+ inDegree.set(id, (inDegree.get(id) ?? 0) + 1);
112
+ }
113
+ }
114
+
115
+ const queue: string[] = [];
116
+ for (const [id, degree] of inDegree) {
117
+ if (degree === 0) queue.push(id);
118
+ }
119
+
120
+ const sorted: string[] = [];
121
+ while (queue.length > 0) {
122
+ const current = queue.shift()!;
123
+ sorted.push(current);
124
+ for (const child of adjacency.get(current)!) {
125
+ const newDegree = inDegree.get(child)! - 1;
126
+ inDegree.set(child, newDegree);
127
+ if (newDegree === 0) queue.push(child);
128
+ }
129
+ }
130
+
131
+ if (sorted.length !== nodes.size) {
132
+ const remaining = [...nodes.keys()].filter(id => !sorted.includes(id));
133
+ throw new Error(`Circular dependency detected involving tasks: ${remaining.join(', ')}`);
134
+ }
135
+
136
+ return { nodes, sorted };
137
+ }