@tagma/sdk 0.1.8 → 0.2.0

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,203 +1,223 @@
1
- // ═══ Raw Pipeline Config Validation ═══
2
- //
3
- // Validates a RawPipelineConfig without resolving inheritance or executing
4
- // anything — intended for real-time feedback in a visual editor (e.g. drag
5
- // to add a task, live error highlighting).
6
- //
7
- // Returns a flat list of ValidationError objects. An empty array means valid.
8
-
9
- import type { RawPipelineConfig } from './types';
10
-
11
- export interface ValidationError {
12
- /** JSONPath-style location, e.g. "tracks[0].tasks[1].prompt" */
13
- path: string;
14
- message: string;
15
- }
16
-
17
- /**
18
- * Validate a raw pipeline config.
19
- * Checks structure, required fields, prompt/command exclusivity,
20
- * depends_on reference integrity, and circular dependencies.
21
- *
22
- * Does NOT check plugin registration — plugins may not be loaded yet
23
- * when the frontend is editing a config offline.
24
- */
25
- export function validateRaw(config: RawPipelineConfig): ValidationError[] {
26
- const errors: ValidationError[] = [];
27
-
28
- // ── Top level ──
29
- if (!config.name?.trim()) {
30
- errors.push({ path: 'name', message: 'Pipeline name is required' });
31
- }
32
-
33
- if (!config.tracks || config.tracks.length === 0) {
34
- errors.push({ path: 'tracks', message: 'At least one track is required' });
35
- return errors; // No point going further without tracks
36
- }
37
-
38
- // ── Build qualified ID sets for cross-reference checks ──
39
- // Qualified ID format: "trackId.taskId" (mirrors the engine's convention)
40
- const allQualified = new Set<string>();
41
- // For bare depends_on references: bare taskId → first qualified ID found
42
- const bareToQualified = new Map<string, string>();
43
-
44
- for (const track of config.tracks) {
45
- if (!track.id) continue;
46
- for (const task of track.tasks ?? []) {
47
- if (!task.id) continue;
48
- const qid = `${track.id}.${task.id}`;
49
- allQualified.add(qid);
50
- if (!bareToQualified.has(task.id)) {
51
- bareToQualified.set(task.id, qid);
52
- }
53
- }
54
- }
55
-
56
- // ── Per-track validation ──
57
- for (let ti = 0; ti < config.tracks.length; ti++) {
58
- const track = config.tracks[ti];
59
- const trackPath = `tracks[${ti}]`;
60
-
61
- if (!track.id?.trim()) {
62
- errors.push({ path: `${trackPath}.id`, message: 'Track id is required' });
63
- }
64
- if (!track.name?.trim()) {
65
- errors.push({ path: `${trackPath}.name`, message: 'Track name is required' });
66
- }
67
-
68
- if (!track.tasks || track.tasks.length === 0) {
69
- errors.push({ path: `${trackPath}.tasks`, message: `Track "${track.id || ti}": must have at least one task` });
70
- continue;
71
- }
72
-
73
- // ── Per-task validation ──
74
- for (let ki = 0; ki < track.tasks.length; ki++) {
75
- const task = track.tasks[ki];
76
- const taskPath = `${trackPath}.tasks[${ki}]`;
77
-
78
- if (!task.id?.trim()) {
79
- errors.push({ path: `${taskPath}.id`, message: 'Task id is required' });
80
- continue; // Can't check further without an id
81
- }
82
-
83
- // Template-based tasks: skip prompt/command checks (params validated at runtime)
84
- if (task.use) continue;
85
-
86
- const hasPrompt = typeof task.prompt === 'string' && task.prompt.trim().length > 0;
87
- const hasCommand = typeof task.command === 'string' && task.command.trim().length > 0;
88
-
89
- if (!hasPrompt && !hasCommand) {
90
- errors.push({
91
- path: taskPath,
92
- message: `Task "${task.id}": must have "prompt" or "command"`,
93
- });
94
- }
95
- if (hasPrompt && hasCommand) {
96
- errors.push({
97
- path: taskPath,
98
- message: `Task "${task.id}": cannot have both "prompt" and "command"`,
99
- });
100
- }
101
-
102
- // ── depends_on reference checks ──
103
- if (task.depends_on && task.depends_on.length > 0) {
104
- for (const dep of task.depends_on) {
105
- const resolved = resolveDepRef(dep, track.id, allQualified, bareToQualified);
106
- if (!resolved) {
107
- errors.push({
108
- path: `${taskPath}.depends_on`,
109
- message: `Task "${task.id}": depends_on "${dep}" — no such task found`,
110
- });
111
- }
112
- }
113
- }
114
-
115
- // ── continue_from reference check ──
116
- if (task.continue_from) {
117
- const resolved = resolveDepRef(task.continue_from, track.id, allQualified, bareToQualified);
118
- if (!resolved) {
119
- errors.push({
120
- path: `${taskPath}.continue_from`,
121
- message: `Task "${task.id}": continue_from "${task.continue_from}" — no such task found`,
122
- });
123
- }
124
- }
125
- }
126
- }
127
-
128
- // ── Cycle detection ──
129
- errors.push(...detectCycles(config, allQualified, bareToQualified));
130
-
131
- return errors;
132
- }
133
-
134
- // ── Helpers ──
135
-
136
- function resolveDepRef(
137
- ref: string,
138
- fromTrackId: string,
139
- allQualified: Set<string>,
140
- bareToQualified: Map<string, string>,
141
- ): string | null {
142
- // Fully qualified reference (trackId.taskId)
143
- if (allQualified.has(ref)) return ref;
144
- // Same-track shorthand (just taskId)
145
- const sameTrack = `${fromTrackId}.${ref}`;
146
- if (allQualified.has(sameTrack)) return sameTrack;
147
- // Global bare lookup (first match across all tracks)
148
- return bareToQualified.get(ref) ?? null;
149
- }
150
-
151
- function detectCycles(
152
- config: RawPipelineConfig,
153
- allQualified: Set<string>,
154
- bareToQualified: Map<string, string>,
155
- ): ValidationError[] {
156
- // Build adjacency: qualifiedId [resolved dep qualifiedIds]
157
- const adj = new Map<string, string[]>();
158
-
159
- for (const track of config.tracks) {
160
- if (!track.id) continue;
161
- for (const task of track.tasks ?? []) {
162
- if (!task.id || task.use) continue;
163
- const qid = `${track.id}.${task.id}`;
164
- const deps: string[] = [];
165
- for (const dep of task.depends_on ?? []) {
166
- const resolved = resolveDepRef(dep, track.id, allQualified, bareToQualified);
167
- if (resolved) deps.push(resolved);
168
- }
169
- if (task.continue_from) {
170
- const resolved = resolveDepRef(task.continue_from, track.id, allQualified, bareToQualified);
171
- if (resolved && !deps.includes(resolved)) deps.push(resolved);
172
- }
173
- adj.set(qid, deps);
174
- }
175
- }
176
-
177
- const errors: ValidationError[] = [];
178
- const visited = new Set<string>();
179
- const inStack = new Set<string>();
180
-
181
- function dfs(id: string, path: string[]): void {
182
- if (inStack.has(id)) {
183
- // Trim path to just the cycle portion
184
- const cycleStart = path.indexOf(id);
185
- const cycle = [...path.slice(cycleStart), id].join(' → ');
186
- errors.push({ path: 'tracks', message: `Circular dependency detected: ${cycle}` });
187
- return;
188
- }
189
- if (visited.has(id)) return;
190
- visited.add(id);
191
- inStack.add(id);
192
- for (const dep of adj.get(id) ?? []) {
193
- dfs(dep, [...path, id]);
194
- }
195
- inStack.delete(id);
196
- }
197
-
198
- for (const id of adj.keys()) {
199
- if (!visited.has(id)) dfs(id, []);
200
- }
201
-
202
- return errors;
203
- }
1
+ // ═══ Raw Pipeline Config Validation ═══
2
+ //
3
+ // Validates a RawPipelineConfig without resolving inheritance or executing
4
+ // anything — intended for real-time feedback in a visual editor (e.g. drag
5
+ // to add a task, live error highlighting).
6
+ //
7
+ // Returns a flat list of ValidationError objects. An empty array means valid.
8
+
9
+ import type { RawPipelineConfig } from './types';
10
+
11
+ export interface ValidationError {
12
+ /** JSONPath-style location, e.g. "tracks[0].tasks[1].prompt" */
13
+ path: string;
14
+ message: string;
15
+ }
16
+
17
+ /**
18
+ * Validate a raw pipeline config.
19
+ * Checks structure, required fields, prompt/command exclusivity,
20
+ * depends_on reference integrity, and circular dependencies.
21
+ *
22
+ * Does NOT check plugin registration — plugins may not be loaded yet
23
+ * when the frontend is editing a config offline.
24
+ */
25
+ export function validateRaw(config: RawPipelineConfig): ValidationError[] {
26
+ const errors: ValidationError[] = [];
27
+
28
+ // ── Top level ──
29
+ if (!config.name?.trim()) {
30
+ errors.push({ path: 'name', message: 'Pipeline name is required' });
31
+ }
32
+
33
+ if (!config.tracks || config.tracks.length === 0) {
34
+ errors.push({ path: 'tracks', message: 'At least one track is required' });
35
+ return errors; // No point going further without tracks
36
+ }
37
+
38
+ // ── Build qualified ID sets for cross-reference checks ──
39
+ // Qualified ID format: "trackId.taskId" (mirrors the engine's convention)
40
+ const allQualified = new Set<string>();
41
+ // bare taskId → qualified ID, or "__ambiguous__" when multiple tracks share that bare name.
42
+ // "__ambiguous__" signals that a bare ref is unresolvable without a track prefix.
43
+ const bareToQualified = new Map<string, string>();
44
+ const bareIdCount = new Map<string, number>();
45
+
46
+ for (const track of config.tracks) {
47
+ if (!track.id) continue;
48
+ for (const task of track.tasks ?? []) {
49
+ if (!task.id) continue;
50
+ const qid = `${track.id}.${task.id}`;
51
+ allQualified.add(qid);
52
+ const count = (bareIdCount.get(task.id) ?? 0) + 1;
53
+ bareIdCount.set(task.id, count);
54
+ // Mark as ambiguous when a second track introduces the same bare name.
55
+ bareToQualified.set(task.id, count === 1 ? qid : '__ambiguous__');
56
+ }
57
+ }
58
+
59
+ // ── Per-track validation ──
60
+ for (let ti = 0; ti < config.tracks.length; ti++) {
61
+ const track = config.tracks[ti];
62
+ const trackPath = `tracks[${ti}]`;
63
+
64
+ if (!track.id?.trim()) {
65
+ errors.push({ path: `${trackPath}.id`, message: 'Track id is required' });
66
+ }
67
+ if (!track.name?.trim()) {
68
+ errors.push({ path: `${trackPath}.name`, message: 'Track name is required' });
69
+ }
70
+
71
+ if (!track.tasks || track.tasks.length === 0) {
72
+ errors.push({ path: `${trackPath}.tasks`, message: `Track "${track.id || ti}": must have at least one task` });
73
+ continue;
74
+ }
75
+
76
+ // ── Per-task validation ──
77
+ for (let ki = 0; ki < track.tasks.length; ki++) {
78
+ const task = track.tasks[ki];
79
+ const taskPath = `${trackPath}.tasks[${ki}]`;
80
+
81
+ if (!task.id?.trim()) {
82
+ errors.push({ path: `${taskPath}.id`, message: 'Task id is required' });
83
+ continue; // Can't check further without an id
84
+ }
85
+
86
+ // Template-based tasks: skip prompt/command checks (params validated at runtime)
87
+ if (task.use) continue;
88
+
89
+ const hasPrompt = typeof task.prompt === 'string' && task.prompt.trim().length > 0;
90
+ const hasCommand = typeof task.command === 'string' && task.command.trim().length > 0;
91
+
92
+ if (!hasPrompt && !hasCommand) {
93
+ errors.push({
94
+ path: taskPath,
95
+ message: `Task "${task.id}": must have "prompt" or "command"`,
96
+ });
97
+ }
98
+ if (hasPrompt && hasCommand) {
99
+ errors.push({
100
+ path: taskPath,
101
+ message: `Task "${task.id}": cannot have both "prompt" and "command"`,
102
+ });
103
+ }
104
+
105
+ // ── depends_on reference checks ──
106
+ if (task.depends_on && task.depends_on.length > 0) {
107
+ for (const dep of task.depends_on) {
108
+ const resolved = resolveDepRef(dep, track.id, allQualified, bareToQualified);
109
+ if (!resolved) {
110
+ errors.push({
111
+ path: `${taskPath}.depends_on`,
112
+ message: `Task "${task.id}": depends_on "${dep}" — no such task found`,
113
+ });
114
+ } else if (resolved === '__ambiguous__') {
115
+ errors.push({
116
+ path: `${taskPath}.depends_on`,
117
+ message: `Task "${task.id}": depends_on "${dep}" is ambiguous — multiple tracks have a task with this id. Use the fully-qualified form "trackId.${dep}".`,
118
+ });
119
+ }
120
+ }
121
+ }
122
+
123
+ // ── continue_from reference check ──
124
+ if (task.continue_from) {
125
+ const resolved = resolveDepRef(task.continue_from, track.id, allQualified, bareToQualified);
126
+ if (!resolved) {
127
+ errors.push({
128
+ path: `${taskPath}.continue_from`,
129
+ message: `Task "${task.id}": continue_from "${task.continue_from}" — no such task found`,
130
+ });
131
+ } else if (resolved === '__ambiguous__') {
132
+ errors.push({
133
+ path: `${taskPath}.continue_from`,
134
+ message: `Task "${task.id}": continue_from "${task.continue_from}" is ambiguous — multiple tracks have a task with this id. Use the fully-qualified form "trackId.${task.continue_from}".`,
135
+ });
136
+ }
137
+ }
138
+ }
139
+ }
140
+
141
+ // ── Cycle detection ──
142
+ errors.push(...detectCycles(config, allQualified, bareToQualified));
143
+
144
+ return errors;
145
+ }
146
+
147
+ // ── Helpers ──
148
+
149
+ // Returns the resolved qualified ID, null (not found), or '__ambiguous__' (multiple tracks match).
150
+ function resolveDepRef(
151
+ ref: string,
152
+ fromTrackId: string,
153
+ allQualified: Set<string>,
154
+ bareToQualified: Map<string, string>,
155
+ ): string | null {
156
+ // Fully qualified reference (trackId.taskId) always unambiguous
157
+ if (allQualified.has(ref)) return ref;
158
+ // Same-track shorthand — always unambiguous (shadows any global bare match)
159
+ const sameTrack = `${fromTrackId}.${ref}`;
160
+ if (allQualified.has(sameTrack)) return sameTrack;
161
+ // Global bare lookup may be '__ambiguous__' when multiple tracks share the name
162
+ return bareToQualified.get(ref) ?? null;
163
+ }
164
+
165
+ function detectCycles(
166
+ config: RawPipelineConfig,
167
+ allQualified: Set<string>,
168
+ bareToQualified: Map<string, string>,
169
+ ): ValidationError[] {
170
+ // Build adjacency: qualifiedId [resolved dep qualifiedIds]
171
+ const adj = new Map<string, string[]>();
172
+
173
+ for (const track of config.tracks) {
174
+ if (!track.id) continue;
175
+ for (const task of track.tasks ?? []) {
176
+ if (!task.id || task.use) continue;
177
+ const qid = `${track.id}.${task.id}`;
178
+ const deps: string[] = [];
179
+ for (const dep of task.depends_on ?? []) {
180
+ const resolved = resolveDepRef(dep, track.id, allQualified, bareToQualified);
181
+ if (resolved) deps.push(resolved);
182
+ }
183
+ if (task.continue_from) {
184
+ const resolved = resolveDepRef(task.continue_from, track.id, allQualified, bareToQualified);
185
+ if (resolved && !deps.includes(resolved)) deps.push(resolved);
186
+ }
187
+ adj.set(qid, deps);
188
+ }
189
+ }
190
+
191
+ const errors: ValidationError[] = [];
192
+ const visited = new Set<string>();
193
+ const inStack = new Set<string>();
194
+ // Deduplicate cycles: the same cycle can be discovered from multiple entry points.
195
+ // Canonical key = sorted node list joined — order-independent fingerprint.
196
+ const seenCycles = new Set<string>();
197
+
198
+ function dfs(id: string, path: string[]): void {
199
+ if (inStack.has(id)) {
200
+ const cycleStart = path.indexOf(id);
201
+ const cycleNodes = [...path.slice(cycleStart), id];
202
+ const key = [...cycleNodes].sort().join(',');
203
+ if (!seenCycles.has(key)) {
204
+ seenCycles.add(key);
205
+ errors.push({ path: 'tracks', message: `Circular dependency detected: ${cycleNodes.join(' → ')}` });
206
+ }
207
+ return;
208
+ }
209
+ if (visited.has(id)) return;
210
+ visited.add(id);
211
+ inStack.add(id);
212
+ for (const dep of adj.get(id) ?? []) {
213
+ dfs(dep, [...path, id]);
214
+ }
215
+ inStack.delete(id);
216
+ }
217
+
218
+ for (const id of adj.keys()) {
219
+ if (!visited.has(id)) dfs(id, []);
220
+ }
221
+
222
+ return errors;
223
+ }