@tagma/sdk 0.4.12 → 0.4.13

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.
Files changed (88) hide show
  1. package/README.md +572 -566
  2. package/dist/adapters/websocket-approval.d.ts.map +1 -1
  3. package/dist/adapters/websocket-approval.js +3 -1
  4. package/dist/adapters/websocket-approval.js.map +1 -1
  5. package/dist/approval.d.ts.map +1 -1
  6. package/dist/approval.js.map +1 -1
  7. package/dist/completions/exit-code.d.ts.map +1 -1
  8. package/dist/completions/exit-code.js.map +1 -1
  9. package/dist/completions/file-exists.d.ts.map +1 -1
  10. package/dist/completions/file-exists.js.map +1 -1
  11. package/dist/completions/output-check.js +2 -7
  12. package/dist/completions/output-check.js.map +1 -1
  13. package/dist/config-ops.d.ts.map +1 -1
  14. package/dist/config-ops.js +24 -26
  15. package/dist/config-ops.js.map +1 -1
  16. package/dist/dag.d.ts.map +1 -1
  17. package/dist/dag.js +1 -1
  18. package/dist/dag.js.map +1 -1
  19. package/dist/drivers/claude-code.d.ts.map +1 -1
  20. package/dist/drivers/claude-code.js +10 -5
  21. package/dist/drivers/claude-code.js.map +1 -1
  22. package/dist/engine.d.ts.map +1 -1
  23. package/dist/engine.js +54 -27
  24. package/dist/engine.js.map +1 -1
  25. package/dist/hooks.d.ts.map +1 -1
  26. package/dist/hooks.js +1 -3
  27. package/dist/hooks.js.map +1 -1
  28. package/dist/logger.d.ts.map +1 -1
  29. package/dist/logger.js +4 -2
  30. package/dist/logger.js.map +1 -1
  31. package/dist/pipeline-runner.d.ts.map +1 -1
  32. package/dist/pipeline-runner.js +10 -4
  33. package/dist/pipeline-runner.js.map +1 -1
  34. package/dist/registry.d.ts +11 -1
  35. package/dist/registry.d.ts.map +1 -1
  36. package/dist/registry.js +28 -3
  37. package/dist/registry.js.map +1 -1
  38. package/dist/runner.d.ts.map +1 -1
  39. package/dist/runner.js +18 -13
  40. package/dist/runner.js.map +1 -1
  41. package/dist/schema.d.ts.map +1 -1
  42. package/dist/schema.js +14 -14
  43. package/dist/schema.js.map +1 -1
  44. package/dist/schema.test.js +5 -1
  45. package/dist/schema.test.js.map +1 -1
  46. package/dist/sdk.d.ts +2 -2
  47. package/dist/sdk.d.ts.map +1 -1
  48. package/dist/sdk.js +1 -1
  49. package/dist/sdk.js.map +1 -1
  50. package/dist/triggers/file.d.ts.map +1 -1
  51. package/dist/triggers/file.js +11 -4
  52. package/dist/triggers/file.js.map +1 -1
  53. package/dist/triggers/manual.d.ts.map +1 -1
  54. package/dist/triggers/manual.js +2 -1
  55. package/dist/triggers/manual.js.map +1 -1
  56. package/dist/utils.d.ts.map +1 -1
  57. package/dist/utils.js +13 -6
  58. package/dist/utils.js.map +1 -1
  59. package/dist/validate-raw.d.ts.map +1 -1
  60. package/dist/validate-raw.js +40 -11
  61. package/dist/validate-raw.js.map +1 -1
  62. package/package.json +2 -2
  63. package/scripts/preinstall.js +1 -1
  64. package/src/adapters/stdin-approval.ts +106 -106
  65. package/src/adapters/websocket-approval.ts +224 -220
  66. package/src/approval.ts +131 -125
  67. package/src/bootstrap.ts +37 -37
  68. package/src/completions/exit-code.ts +34 -30
  69. package/src/completions/file-exists.ts +66 -60
  70. package/src/completions/output-check.ts +86 -86
  71. package/src/config-ops.ts +307 -322
  72. package/src/dag.ts +234 -228
  73. package/src/drivers/claude-code.ts +250 -240
  74. package/src/engine.ts +1098 -935
  75. package/src/hooks.ts +187 -179
  76. package/src/logger.ts +182 -178
  77. package/src/middlewares/static-context.ts +45 -45
  78. package/src/pipeline-runner.ts +156 -150
  79. package/src/registry.ts +51 -23
  80. package/src/runner.ts +395 -397
  81. package/src/schema.test.ts +5 -1
  82. package/src/schema.ts +338 -328
  83. package/src/sdk.ts +91 -81
  84. package/src/triggers/file.ts +33 -14
  85. package/src/triggers/manual.ts +86 -81
  86. package/src/types.ts +18 -18
  87. package/src/utils.ts +202 -191
  88. package/src/validate-raw.ts +442 -409
@@ -1,409 +1,442 @@
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
- const DURATION_RE = /^(\d*\.?\d+)\s*(s|m|h|d)$/;
12
- function isValidDuration(input: string): boolean {
13
- return DURATION_RE.test(input.trim());
14
- }
15
-
16
- // D8: IDs may only contain letters, digits, underscores, and hyphens, and must
17
- // start with a letter or underscore. Dots are explicitly forbidden because the
18
- // engine uses "trackId.taskId" as the qualified separator — a dot in either
19
- // part creates an ambiguous qualified ID and breaks resolveRef.
20
- const ID_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
21
- function isValidId(id: string): boolean {
22
- return ID_RE.test(id);
23
- }
24
-
25
- const VALID_ON_FAILURE = new Set(['skip_downstream', 'stop_all', 'ignore']);
26
- const VALID_REASONING_EFFORT = new Set(['low', 'medium', 'high']);
27
-
28
- // Built-in plugin types always known to the SDK core, regardless of which
29
- // external plugin packages are installed. These MUST stay in sync with the
30
- // types that `bootstrapBuiltins()` registers, otherwise the editor will
31
- // emit false-positive "unknown type" warnings for stock pipelines.
32
- const BUILTIN_TRIGGER_TYPES: ReadonlySet<string> = new Set(['manual', 'file']);
33
- const BUILTIN_COMPLETION_TYPES: ReadonlySet<string> = new Set([
34
- 'exit_code', 'file_exists', 'output_check',
35
- ]);
36
- const BUILTIN_MIDDLEWARE_TYPES: ReadonlySet<string> = new Set(['static_context']);
37
-
38
- /**
39
- * Optional second argument to `validateRaw`: the set of plugin types currently
40
- * registered in the SDK runtime, keyed by category. Hosts (e.g. the editor
41
- * server) pass this so `validateRaw` can emit a soft warning when a task
42
- * references a type that isn't loaded otherwise the Task panel would show
43
- * no hint and the pipeline would only blow up at run time. Callers that
44
- * legitimately validate a config offline (before plugins are loaded) can omit
45
- * this argument and no plugin warnings will be produced.
46
- */
47
- export interface KnownPluginTypes {
48
- readonly triggers?: readonly string[];
49
- readonly completions?: readonly string[];
50
- readonly middlewares?: readonly string[];
51
- }
52
-
53
- export type ValidationSeverity = 'error' | 'warning';
54
-
55
- export interface ValidationError {
56
- /** JSONPath-style location, e.g. "tracks[0].tasks[1].prompt" */
57
- path: string;
58
- message: string;
59
- /**
60
- * H8: not all "errors" are equally fatal. The DAG runtime is happy to
61
- * insert implicit `continue_from → depends_on` ordering, so the matching
62
- * validate-raw check is a *style* nit, not a hard failure. Severity lets
63
- * the editor render it as a soft warning instead of blocking save / run.
64
- * Existing call sites that don't read this field still treat every entry
65
- * as fatal defaulting `severity` to undefined preserves that behaviour.
66
- */
67
- severity?: ValidationSeverity;
68
- }
69
-
70
- /**
71
- * Validate a raw pipeline config.
72
- * Checks structure, required fields, prompt/command exclusivity,
73
- * depends_on reference integrity, and circular dependencies.
74
- *
75
- * Plugin type checks: when `knownTypes` is provided, task/track references to
76
- * trigger/completion/middleware types that are neither built-in nor in the
77
- * supplied set produce a soft warning (severity: 'warning') these don't
78
- * block save/run but light up the Task panel so users discover the broken
79
- * reference in the editor instead of at run time. Omit `knownTypes` to skip
80
- * plugin checks entirely (offline/pre-load validation).
81
- */
82
- export function validateRaw(
83
- config: RawPipelineConfig,
84
- knownTypes?: KnownPluginTypes,
85
- ): ValidationError[] {
86
- const errors: ValidationError[] = [];
87
-
88
- const knownTriggers = knownTypes
89
- ? new Set<string>([...BUILTIN_TRIGGER_TYPES, ...(knownTypes.triggers ?? [])])
90
- : null;
91
- const knownCompletions = knownTypes
92
- ? new Set<string>([...BUILTIN_COMPLETION_TYPES, ...(knownTypes.completions ?? [])])
93
- : null;
94
- const knownMiddlewares = knownTypes
95
- ? new Set<string>([...BUILTIN_MIDDLEWARE_TYPES, ...(knownTypes.middlewares ?? [])])
96
- : null;
97
-
98
- // ── Top level ──
99
- if (!config.name?.trim()) {
100
- errors.push({ path: 'name', message: 'Pipeline name is required' });
101
- }
102
- if (config.reasoning_effort && !VALID_REASONING_EFFORT.has(config.reasoning_effort)) {
103
- errors.push({ path: 'reasoning_effort', message: `Invalid reasoning_effort "${config.reasoning_effort}". Expected "low", "medium", or "high".` });
104
- }
105
-
106
- if (!config.tracks || config.tracks.length === 0) {
107
- errors.push({ path: 'tracks', message: 'At least one track is required' });
108
- return errors; // No point going further without tracks
109
- }
110
-
111
- // ── Build qualified ID sets for cross-reference checks ──
112
- // Qualified ID format: "trackId.taskId" (mirrors the engine's convention)
113
- const allQualified = new Set<string>();
114
- // bare taskId → qualified ID, or "__ambiguous__" when multiple tracks share that bare name.
115
- // "__ambiguous__" signals that a bare ref is unresolvable without a track prefix.
116
- const bareToQualified = new Map<string, string>();
117
- const bareIdCount = new Map<string, number>();
118
-
119
- for (const track of config.tracks) {
120
- if (!track.id) continue;
121
- for (const task of track.tasks ?? []) {
122
- if (!task.id) continue;
123
- const qid = `${track.id}.${task.id}`;
124
- allQualified.add(qid);
125
- const count = (bareIdCount.get(task.id) ?? 0) + 1;
126
- bareIdCount.set(task.id, count);
127
- // Mark as ambiguous when a second track introduces the same bare name.
128
- bareToQualified.set(task.id, count === 1 ? qid : '__ambiguous__');
129
- }
130
- }
131
-
132
- // ── Per-track validation ──
133
- const seenTrackIds = new Set<string>();
134
- for (let ti = 0; ti < config.tracks.length; ti++) {
135
- const track = config.tracks[ti];
136
- const trackPath = `tracks[${ti}]`;
137
-
138
- if (!track.id?.trim()) {
139
- errors.push({ path: `${trackPath}.id`, message: 'Track id is required' });
140
- } else if (!isValidId(track.id)) {
141
- errors.push({
142
- path: `${trackPath}.id`,
143
- message: `Track id "${track.id}" contains invalid characters. IDs must match /^[A-Za-z_][A-Za-z0-9_-]*$/ (no dots, spaces, or special chars).`,
144
- });
145
- } else if (seenTrackIds.has(track.id)) {
146
- errors.push({ path: `${trackPath}.id`, message: `Duplicate track id "${track.id}"` });
147
- } else {
148
- seenTrackIds.add(track.id);
149
- }
150
- if (!track.name?.trim()) {
151
- errors.push({ path: `${trackPath}.name`, message: 'Track name is required' });
152
- }
153
- if (track.on_failure && !VALID_ON_FAILURE.has(track.on_failure)) {
154
- errors.push({ path: `${trackPath}.on_failure`, message: `Invalid on_failure value "${track.on_failure}". Expected "skip_downstream", "stop_all", or "ignore".` });
155
- }
156
- if (track.reasoning_effort && !VALID_REASONING_EFFORT.has(track.reasoning_effort)) {
157
- errors.push({ path: `${trackPath}.reasoning_effort`, message: `Invalid reasoning_effort "${track.reasoning_effort}". Expected "low", "medium", or "high".` });
158
- }
159
-
160
- // Track-level middlewares can reference a plugin that was uninstalled
161
- // after the YAML was written surface a warning so the user notices
162
- // before hitting Run.
163
- if (knownMiddlewares && track.middlewares) {
164
- for (let mi = 0; mi < track.middlewares.length; mi++) {
165
- const mw = track.middlewares[mi];
166
- if (mw?.type && !knownMiddlewares.has(mw.type)) {
167
- errors.push({
168
- path: `${trackPath}.middlewares[${mi}].type`,
169
- message: `Middleware type "${mw.type}" is not registered. Install the plugin (e.g. @tagma/middleware-${mw.type}) or remove the reference — the pipeline will fail at run time.`,
170
- severity: 'warning',
171
- });
172
- }
173
- }
174
- }
175
-
176
- if (!track.tasks || track.tasks.length === 0) {
177
- errors.push({ path: `${trackPath}.tasks`, message: `Track "${track.id || ti}": must have at least one task` });
178
- continue;
179
- }
180
-
181
- // ── Per-task validation ──
182
- const seenTaskIds = new Set<string>();
183
- for (let ki = 0; ki < track.tasks.length; ki++) {
184
- const task = track.tasks[ki];
185
- const taskPath = `${trackPath}.tasks[${ki}]`;
186
-
187
- if (!task.id?.trim()) {
188
- errors.push({ path: `${taskPath}.id`, message: 'Task id is required' });
189
- continue; // Can't check further without an id
190
- }
191
-
192
- if (!isValidId(task.id)) {
193
- errors.push({
194
- path: `${taskPath}.id`,
195
- message: `Task id "${task.id}" contains invalid characters. IDs must match /^[A-Za-z_][A-Za-z0-9_-]*$/ (no dots, spaces, or special chars).`,
196
- });
197
- }
198
- if (seenTaskIds.has(task.id)) {
199
- errors.push({ path: taskPath, message: `Duplicate task id "${task.id}" in track "${track.id}"` });
200
- }
201
- seenTaskIds.add(task.id);
202
-
203
- const hasPromptKey = typeof task.prompt === 'string';
204
- const hasCommandKey = typeof task.command === 'string';
205
- const promptEmpty = hasPromptKey && task.prompt!.trim().length === 0;
206
- const commandEmpty = hasCommandKey && task.command!.trim().length === 0;
207
-
208
- if (hasPromptKey && hasCommandKey) {
209
- errors.push({
210
- path: taskPath,
211
- message: `Task "${task.id}": cannot have both "prompt" and "command"`,
212
- });
213
- } else if (!hasPromptKey && !hasCommandKey) {
214
- errors.push({
215
- path: taskPath,
216
- message: `Task "${task.id}": must have "prompt" or "command"`,
217
- });
218
- } else if (promptEmpty) {
219
- errors.push({
220
- path: taskPath,
221
- message: `Task "${task.id}": prompt content cannot be empty`,
222
- });
223
- } else if (commandEmpty) {
224
- errors.push({
225
- path: taskPath,
226
- message: `Task "${task.id}": command content cannot be empty`,
227
- });
228
- }
229
-
230
- // ── Field-level validations ──
231
- if (task.timeout && !isValidDuration(task.timeout)) {
232
- errors.push({ path: `${taskPath}.timeout`, message: `Invalid duration format "${task.timeout}". Expected e.g. "30s", "5m", "1h".` });
233
- }
234
- if (task.reasoning_effort && !VALID_REASONING_EFFORT.has(task.reasoning_effort)) {
235
- errors.push({ path: `${taskPath}.reasoning_effort`, message: `Invalid reasoning_effort "${task.reasoning_effort}". Expected "low", "medium", or "high".` });
236
- }
237
-
238
- // ── Plugin type warnings (trigger / completion / middlewares) ──
239
- // Only fire when the host supplied a `knownTypes` snapshot, so offline
240
- // validation stays quiet. The messages deliberately name the npm
241
- // scope so users can copy-paste the install command.
242
- if (knownTriggers && task.trigger?.type && !knownTriggers.has(task.trigger.type)) {
243
- errors.push({
244
- path: `${taskPath}.trigger.type`,
245
- message: `Trigger type "${task.trigger.type}" is not registered. Install the plugin (e.g. @tagma/trigger-${task.trigger.type}) or the task will fail at run time.`,
246
- severity: 'warning',
247
- });
248
- }
249
- if (knownCompletions && task.completion?.type && !knownCompletions.has(task.completion.type)) {
250
- errors.push({
251
- path: `${taskPath}.completion.type`,
252
- message: `Completion type "${task.completion.type}" is not registered. Install the plugin (e.g. @tagma/completion-${task.completion.type}) or the task will fail at run time.`,
253
- severity: 'warning',
254
- });
255
- }
256
- if (knownMiddlewares && task.middlewares) {
257
- for (let mi = 0; mi < task.middlewares.length; mi++) {
258
- const mw = task.middlewares[mi];
259
- if (mw?.type && !knownMiddlewares.has(mw.type)) {
260
- errors.push({
261
- path: `${taskPath}.middlewares[${mi}].type`,
262
- message: `Middleware type "${mw.type}" is not registered. Install the plugin (e.g. @tagma/middleware-${mw.type}) or remove the reference — the pipeline will fail at run time.`,
263
- severity: 'warning',
264
- });
265
- }
266
- }
267
- }
268
-
269
- // ── depends_on reference checks ──
270
- if (task.depends_on && task.depends_on.length > 0) {
271
- for (const dep of task.depends_on) {
272
- const resolved = resolveDepRef(dep, track.id, allQualified, bareToQualified);
273
- if (!resolved) {
274
- errors.push({
275
- path: `${taskPath}.depends_on`,
276
- message: `Task "${task.id}": depends_on "${dep}" — no such task found`,
277
- });
278
- } else if (resolved === '__ambiguous__') {
279
- errors.push({
280
- path: `${taskPath}.depends_on`,
281
- message: `Task "${task.id}": depends_on "${dep}" is ambiguous — multiple tracks have a task with this id. Use the fully-qualified form "trackId.${dep}".`,
282
- });
283
- }
284
- }
285
- }
286
-
287
- // ── continue_from reference check ──
288
- if (task.continue_from) {
289
- const resolved = resolveDepRef(task.continue_from, track.id, allQualified, bareToQualified);
290
- if (!resolved) {
291
- errors.push({
292
- path: `${taskPath}.continue_from`,
293
- message: `Task "${task.id}": continue_from "${task.continue_from}" — no such task found`,
294
- });
295
- } else if (resolved === '__ambiguous__') {
296
- errors.push({
297
- path: `${taskPath}.continue_from`,
298
- 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}".`,
299
- });
300
- } else if (!task.depends_on || !task.depends_on.some(dep =>
301
- resolveDepRef(dep, track.id, allQualified, bareToQualified) === resolved
302
- )) {
303
- // H8: demote to a warning. dag.ts/buildDag inserts continue_from
304
- // as an implicit dependency at runtime, so the pipeline runs fine
305
- // without the explicit listing. Treat as a style hint rather than
306
- // blocking save / run, otherwise we frighten users with a red
307
- // "Configuration error" for code that would have run successfully.
308
- errors.push({
309
- path: `${taskPath}.continue_from`,
310
- message: `Task "${task.id}": continue_from "${task.continue_from}" should also be listed in depends_on for clarity (the runtime will add it implicitly).`,
311
- severity: 'warning',
312
- });
313
- }
314
- }
315
- }
316
- }
317
-
318
- // ── Cycle detection ──
319
- errors.push(...detectCycles(config, allQualified, bareToQualified));
320
-
321
- return errors;
322
- }
323
-
324
- // ── Helpers ──
325
-
326
- // Returns the resolved qualified ID, null (not found), or '__ambiguous__' (multiple tracks match).
327
- function resolveDepRef(
328
- ref: string,
329
- fromTrackId: string,
330
- allQualified: Set<string>,
331
- bareToQualified: Map<string, string>,
332
- ): string | null {
333
- // Fully qualified reference (trackId.taskId) always unambiguous
334
- if (allQualified.has(ref)) return ref;
335
- // Same-track shorthand always unambiguous (shadows any global bare match)
336
- const sameTrack = `${fromTrackId}.${ref}`;
337
- if (allQualified.has(sameTrack)) return sameTrack;
338
- // Global bare lookup — may be '__ambiguous__' when multiple tracks share the name
339
- return bareToQualified.get(ref) ?? null;
340
- }
341
-
342
- function detectCycles(
343
- config: RawPipelineConfig,
344
- allQualified: Set<string>,
345
- bareToQualified: Map<string, string>,
346
- ): ValidationError[] {
347
- // Build adjacency: qualifiedId → [resolved dep qualifiedIds]
348
- const adj = new Map<string, string[]>();
349
-
350
- for (const track of config.tracks) {
351
- if (!track.id) continue;
352
- for (const task of track.tasks ?? []) {
353
- if (!task.id) continue;
354
- const qid = `${track.id}.${task.id}`;
355
- const deps: string[] = [];
356
- for (const dep of task.depends_on ?? []) {
357
- const resolved = resolveDepRef(dep, track.id, allQualified, bareToQualified);
358
- if (resolved) deps.push(resolved);
359
- }
360
- if (task.continue_from) {
361
- const resolved = resolveDepRef(task.continue_from, track.id, allQualified, bareToQualified);
362
- if (resolved && !deps.includes(resolved)) deps.push(resolved);
363
- }
364
- adj.set(qid, deps);
365
- }
366
- }
367
-
368
- const errors: ValidationError[] = [];
369
- const visited = new Set<string>();
370
- const inStack = new Set<string>();
371
- // Deduplicate cycles: the same cycle can be discovered from multiple entry points.
372
- // Canonical key = sorted node list joined — order-independent fingerprint.
373
- const seenCycles = new Set<string>();
374
-
375
- // Use a mutable path array instead of copying at each level (O(n) vs O(n^2)).
376
- const pathStack: string[] = [];
377
-
378
- function dfs(id: string): void {
379
- if (inStack.has(id)) {
380
- const cycleStart = pathStack.indexOf(id);
381
- // Unique nodes in the cycle (without repeating the start node) for dedup.
382
- // Previously the duplicate start node caused different sorted keys when
383
- // the same cycle was discovered from different entry points.
384
- const uniqueNodes = pathStack.slice(cycleStart);
385
- const key = [...uniqueNodes].sort().join(',');
386
- if (!seenCycles.has(key)) {
387
- seenCycles.add(key);
388
- const display = [...uniqueNodes, id]; // include start for readable display
389
- errors.push({ path: 'tracks', message: `Circular dependency detected: ${display.join(' → ')}` });
390
- }
391
- return;
392
- }
393
- if (visited.has(id)) return;
394
- visited.add(id);
395
- inStack.add(id);
396
- pathStack.push(id);
397
- for (const dep of adj.get(id) ?? []) {
398
- dfs(dep);
399
- }
400
- pathStack.pop();
401
- inStack.delete(id);
402
- }
403
-
404
- for (const id of adj.keys()) {
405
- if (!visited.has(id)) dfs(id);
406
- }
407
-
408
- return errors;
409
- }
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
+ const DURATION_RE = /^(\d*\.?\d+)\s*(s|m|h|d)$/;
12
+ function isValidDuration(input: string): boolean {
13
+ return DURATION_RE.test(input.trim());
14
+ }
15
+
16
+ // D8: IDs may only contain letters, digits, underscores, and hyphens, and must
17
+ // start with a letter or underscore. Dots are explicitly forbidden because the
18
+ // engine uses "trackId.taskId" as the qualified separator — a dot in either
19
+ // part creates an ambiguous qualified ID and breaks resolveRef.
20
+ const ID_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
21
+ function isValidId(id: string): boolean {
22
+ return ID_RE.test(id);
23
+ }
24
+
25
+ const VALID_ON_FAILURE = new Set(['skip_downstream', 'stop_all', 'ignore']);
26
+ const VALID_REASONING_EFFORT = new Set(['low', 'medium', 'high']);
27
+
28
+ // Built-in plugin types always known to the SDK core, regardless of which
29
+ // external plugin packages are installed. These MUST stay in sync with the
30
+ // types that `bootstrapBuiltins()` registers, otherwise the editor will
31
+ // emit false-positive "unknown type" warnings for stock pipelines.
32
+ const BUILTIN_TRIGGER_TYPES: ReadonlySet<string> = new Set(['manual', 'file']);
33
+ const BUILTIN_COMPLETION_TYPES: ReadonlySet<string> = new Set([
34
+ 'exit_code',
35
+ 'file_exists',
36
+ 'output_check',
37
+ ]);
38
+ const BUILTIN_MIDDLEWARE_TYPES: ReadonlySet<string> = new Set(['static_context']);
39
+
40
+ /**
41
+ * Optional second argument to `validateRaw`: the set of plugin types currently
42
+ * registered in the SDK runtime, keyed by category. Hosts (e.g. the editor
43
+ * server) pass this so `validateRaw` can emit a soft warning when a task
44
+ * references a type that isn't loaded otherwise the Task panel would show
45
+ * no hint and the pipeline would only blow up at run time. Callers that
46
+ * legitimately validate a config offline (before plugins are loaded) can omit
47
+ * this argument and no plugin warnings will be produced.
48
+ */
49
+ export interface KnownPluginTypes {
50
+ readonly triggers?: readonly string[];
51
+ readonly completions?: readonly string[];
52
+ readonly middlewares?: readonly string[];
53
+ }
54
+
55
+ export type ValidationSeverity = 'error' | 'warning';
56
+
57
+ export interface ValidationError {
58
+ /** JSONPath-style location, e.g. "tracks[0].tasks[1].prompt" */
59
+ path: string;
60
+ message: string;
61
+ /**
62
+ * H8: not all "errors" are equally fatal. The DAG runtime is happy to
63
+ * insert implicit `continue_from depends_on` ordering, so the matching
64
+ * validate-raw check is a *style* nit, not a hard failure. Severity lets
65
+ * the editor render it as a soft warning instead of blocking save / run.
66
+ * Existing call sites that don't read this field still treat every entry
67
+ * as fatal — defaulting `severity` to undefined preserves that behaviour.
68
+ */
69
+ severity?: ValidationSeverity;
70
+ }
71
+
72
+ /**
73
+ * Validate a raw pipeline config.
74
+ * Checks structure, required fields, prompt/command exclusivity,
75
+ * depends_on reference integrity, and circular dependencies.
76
+ *
77
+ * Plugin type checks: when `knownTypes` is provided, task/track references to
78
+ * trigger/completion/middleware types that are neither built-in nor in the
79
+ * supplied set produce a soft warning (severity: 'warning') these don't
80
+ * block save/run but light up the Task panel so users discover the broken
81
+ * reference in the editor instead of at run time. Omit `knownTypes` to skip
82
+ * plugin checks entirely (offline/pre-load validation).
83
+ */
84
+ export function validateRaw(
85
+ config: RawPipelineConfig,
86
+ knownTypes?: KnownPluginTypes,
87
+ ): ValidationError[] {
88
+ const errors: ValidationError[] = [];
89
+
90
+ const knownTriggers = knownTypes
91
+ ? new Set<string>([...BUILTIN_TRIGGER_TYPES, ...(knownTypes.triggers ?? [])])
92
+ : null;
93
+ const knownCompletions = knownTypes
94
+ ? new Set<string>([...BUILTIN_COMPLETION_TYPES, ...(knownTypes.completions ?? [])])
95
+ : null;
96
+ const knownMiddlewares = knownTypes
97
+ ? new Set<string>([...BUILTIN_MIDDLEWARE_TYPES, ...(knownTypes.middlewares ?? [])])
98
+ : null;
99
+
100
+ // ── Top level ──
101
+ if (!config.name?.trim()) {
102
+ errors.push({ path: 'name', message: 'Pipeline name is required' });
103
+ }
104
+ if (config.reasoning_effort && !VALID_REASONING_EFFORT.has(config.reasoning_effort)) {
105
+ errors.push({
106
+ path: 'reasoning_effort',
107
+ message: `Invalid reasoning_effort "${config.reasoning_effort}". Expected "low", "medium", or "high".`,
108
+ });
109
+ }
110
+
111
+ if (!config.tracks || config.tracks.length === 0) {
112
+ errors.push({ path: 'tracks', message: 'At least one track is required' });
113
+ return errors; // No point going further without tracks
114
+ }
115
+
116
+ // ── Build qualified ID sets for cross-reference checks ──
117
+ // Qualified ID format: "trackId.taskId" (mirrors the engine's convention)
118
+ const allQualified = new Set<string>();
119
+ // bare taskId qualified ID, or "__ambiguous__" when multiple tracks share that bare name.
120
+ // "__ambiguous__" signals that a bare ref is unresolvable without a track prefix.
121
+ const bareToQualified = new Map<string, string>();
122
+ const bareIdCount = new Map<string, number>();
123
+
124
+ for (const track of config.tracks) {
125
+ if (!track.id) continue;
126
+ for (const task of track.tasks ?? []) {
127
+ if (!task.id) continue;
128
+ const qid = `${track.id}.${task.id}`;
129
+ allQualified.add(qid);
130
+ const count = (bareIdCount.get(task.id) ?? 0) + 1;
131
+ bareIdCount.set(task.id, count);
132
+ // Mark as ambiguous when a second track introduces the same bare name.
133
+ bareToQualified.set(task.id, count === 1 ? qid : '__ambiguous__');
134
+ }
135
+ }
136
+
137
+ // ── Per-track validation ──
138
+ const seenTrackIds = new Set<string>();
139
+ for (let ti = 0; ti < config.tracks.length; ti++) {
140
+ const track = config.tracks[ti];
141
+ const trackPath = `tracks[${ti}]`;
142
+
143
+ if (!track.id?.trim()) {
144
+ errors.push({ path: `${trackPath}.id`, message: 'Track id is required' });
145
+ } else if (!isValidId(track.id)) {
146
+ errors.push({
147
+ path: `${trackPath}.id`,
148
+ message: `Track id "${track.id}" contains invalid characters. IDs must match /^[A-Za-z_][A-Za-z0-9_-]*$/ (no dots, spaces, or special chars).`,
149
+ });
150
+ } else if (seenTrackIds.has(track.id)) {
151
+ errors.push({ path: `${trackPath}.id`, message: `Duplicate track id "${track.id}"` });
152
+ } else {
153
+ seenTrackIds.add(track.id);
154
+ }
155
+ if (!track.name?.trim()) {
156
+ errors.push({ path: `${trackPath}.name`, message: 'Track name is required' });
157
+ }
158
+ if (track.on_failure && !VALID_ON_FAILURE.has(track.on_failure)) {
159
+ errors.push({
160
+ path: `${trackPath}.on_failure`,
161
+ message: `Invalid on_failure value "${track.on_failure}". Expected "skip_downstream", "stop_all", or "ignore".`,
162
+ });
163
+ }
164
+ if (track.reasoning_effort && !VALID_REASONING_EFFORT.has(track.reasoning_effort)) {
165
+ errors.push({
166
+ path: `${trackPath}.reasoning_effort`,
167
+ message: `Invalid reasoning_effort "${track.reasoning_effort}". Expected "low", "medium", or "high".`,
168
+ });
169
+ }
170
+
171
+ // Track-level middlewares can reference a plugin that was uninstalled
172
+ // after the YAML was written — surface a warning so the user notices
173
+ // before hitting Run.
174
+ if (knownMiddlewares && track.middlewares) {
175
+ for (let mi = 0; mi < track.middlewares.length; mi++) {
176
+ const mw = track.middlewares[mi];
177
+ if (mw?.type && !knownMiddlewares.has(mw.type)) {
178
+ errors.push({
179
+ path: `${trackPath}.middlewares[${mi}].type`,
180
+ message: `Middleware type "${mw.type}" is not registered. Install the plugin (e.g. @tagma/middleware-${mw.type}) or remove the reference — the pipeline will fail at run time.`,
181
+ severity: 'warning',
182
+ });
183
+ }
184
+ }
185
+ }
186
+
187
+ if (!track.tasks || track.tasks.length === 0) {
188
+ errors.push({
189
+ path: `${trackPath}.tasks`,
190
+ message: `Track "${track.id || ti}": must have at least one task`,
191
+ });
192
+ continue;
193
+ }
194
+
195
+ // ── Per-task validation ──
196
+ const seenTaskIds = new Set<string>();
197
+ for (let ki = 0; ki < track.tasks.length; ki++) {
198
+ const task = track.tasks[ki];
199
+ const taskPath = `${trackPath}.tasks[${ki}]`;
200
+
201
+ if (!task.id?.trim()) {
202
+ errors.push({ path: `${taskPath}.id`, message: 'Task id is required' });
203
+ continue; // Can't check further without an id
204
+ }
205
+
206
+ if (!isValidId(task.id)) {
207
+ errors.push({
208
+ path: `${taskPath}.id`,
209
+ message: `Task id "${task.id}" contains invalid characters. IDs must match /^[A-Za-z_][A-Za-z0-9_-]*$/ (no dots, spaces, or special chars).`,
210
+ });
211
+ }
212
+ if (seenTaskIds.has(task.id)) {
213
+ errors.push({
214
+ path: taskPath,
215
+ message: `Duplicate task id "${task.id}" in track "${track.id}"`,
216
+ });
217
+ }
218
+ seenTaskIds.add(task.id);
219
+
220
+ const hasPromptKey = typeof task.prompt === 'string';
221
+ const hasCommandKey = typeof task.command === 'string';
222
+ const promptEmpty = hasPromptKey && task.prompt!.trim().length === 0;
223
+ const commandEmpty = hasCommandKey && task.command!.trim().length === 0;
224
+
225
+ if (hasPromptKey && hasCommandKey) {
226
+ errors.push({
227
+ path: taskPath,
228
+ message: `Task "${task.id}": cannot have both "prompt" and "command"`,
229
+ });
230
+ } else if (!hasPromptKey && !hasCommandKey) {
231
+ errors.push({
232
+ path: taskPath,
233
+ message: `Task "${task.id}": must have "prompt" or "command"`,
234
+ });
235
+ } else if (promptEmpty) {
236
+ errors.push({
237
+ path: taskPath,
238
+ message: `Task "${task.id}": prompt content cannot be empty`,
239
+ });
240
+ } else if (commandEmpty) {
241
+ errors.push({
242
+ path: taskPath,
243
+ message: `Task "${task.id}": command content cannot be empty`,
244
+ });
245
+ }
246
+
247
+ // ── Field-level validations ──
248
+ if (task.timeout && !isValidDuration(task.timeout)) {
249
+ errors.push({
250
+ path: `${taskPath}.timeout`,
251
+ message: `Invalid duration format "${task.timeout}". Expected e.g. "30s", "5m", "1h".`,
252
+ });
253
+ }
254
+ if (task.reasoning_effort && !VALID_REASONING_EFFORT.has(task.reasoning_effort)) {
255
+ errors.push({
256
+ path: `${taskPath}.reasoning_effort`,
257
+ message: `Invalid reasoning_effort "${task.reasoning_effort}". Expected "low", "medium", or "high".`,
258
+ });
259
+ }
260
+
261
+ // ── Plugin type warnings (trigger / completion / middlewares) ──
262
+ // Only fire when the host supplied a `knownTypes` snapshot, so offline
263
+ // validation stays quiet. The messages deliberately name the npm
264
+ // scope so users can copy-paste the install command.
265
+ if (knownTriggers && task.trigger?.type && !knownTriggers.has(task.trigger.type)) {
266
+ errors.push({
267
+ path: `${taskPath}.trigger.type`,
268
+ message: `Trigger type "${task.trigger.type}" is not registered. Install the plugin (e.g. @tagma/trigger-${task.trigger.type}) or the task will fail at run time.`,
269
+ severity: 'warning',
270
+ });
271
+ }
272
+ if (
273
+ knownCompletions &&
274
+ task.completion?.type &&
275
+ !knownCompletions.has(task.completion.type)
276
+ ) {
277
+ errors.push({
278
+ path: `${taskPath}.completion.type`,
279
+ message: `Completion type "${task.completion.type}" is not registered. Install the plugin (e.g. @tagma/completion-${task.completion.type}) or the task will fail at run time.`,
280
+ severity: 'warning',
281
+ });
282
+ }
283
+ if (knownMiddlewares && task.middlewares) {
284
+ for (let mi = 0; mi < task.middlewares.length; mi++) {
285
+ const mw = task.middlewares[mi];
286
+ if (mw?.type && !knownMiddlewares.has(mw.type)) {
287
+ errors.push({
288
+ path: `${taskPath}.middlewares[${mi}].type`,
289
+ message: `Middleware type "${mw.type}" is not registered. Install the plugin (e.g. @tagma/middleware-${mw.type}) or remove the reference — the pipeline will fail at run time.`,
290
+ severity: 'warning',
291
+ });
292
+ }
293
+ }
294
+ }
295
+
296
+ // ── depends_on reference checks ──
297
+ if (task.depends_on && task.depends_on.length > 0) {
298
+ for (const dep of task.depends_on) {
299
+ const resolved = resolveDepRef(dep, track.id, allQualified, bareToQualified);
300
+ if (!resolved) {
301
+ errors.push({
302
+ path: `${taskPath}.depends_on`,
303
+ message: `Task "${task.id}": depends_on "${dep}" no such task found`,
304
+ });
305
+ } else if (resolved === '__ambiguous__') {
306
+ errors.push({
307
+ path: `${taskPath}.depends_on`,
308
+ message: `Task "${task.id}": depends_on "${dep}" is ambiguous — multiple tracks have a task with this id. Use the fully-qualified form "trackId.${dep}".`,
309
+ });
310
+ }
311
+ }
312
+ }
313
+
314
+ // ── continue_from reference check ──
315
+ if (task.continue_from) {
316
+ const resolved = resolveDepRef(task.continue_from, track.id, allQualified, bareToQualified);
317
+ if (!resolved) {
318
+ errors.push({
319
+ path: `${taskPath}.continue_from`,
320
+ message: `Task "${task.id}": continue_from "${task.continue_from}" — no such task found`,
321
+ });
322
+ } else if (resolved === '__ambiguous__') {
323
+ errors.push({
324
+ path: `${taskPath}.continue_from`,
325
+ 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}".`,
326
+ });
327
+ } else if (
328
+ !task.depends_on ||
329
+ !task.depends_on.some(
330
+ (dep) => resolveDepRef(dep, track.id, allQualified, bareToQualified) === resolved,
331
+ )
332
+ ) {
333
+ // H8: demote to a warning. dag.ts/buildDag inserts continue_from
334
+ // as an implicit dependency at runtime, so the pipeline runs fine
335
+ // without the explicit listing. Treat as a style hint rather than
336
+ // blocking save / run, otherwise we frighten users with a red
337
+ // "Configuration error" for code that would have run successfully.
338
+ errors.push({
339
+ path: `${taskPath}.continue_from`,
340
+ message: `Task "${task.id}": continue_from "${task.continue_from}" should also be listed in depends_on for clarity (the runtime will add it implicitly).`,
341
+ severity: 'warning',
342
+ });
343
+ }
344
+ }
345
+ }
346
+ }
347
+
348
+ // ── Cycle detection ──
349
+ errors.push(...detectCycles(config, allQualified, bareToQualified));
350
+
351
+ return errors;
352
+ }
353
+
354
+ // ── Helpers ──
355
+
356
+ // Returns the resolved qualified ID, null (not found), or '__ambiguous__' (multiple tracks match).
357
+ function resolveDepRef(
358
+ ref: string,
359
+ fromTrackId: string,
360
+ allQualified: Set<string>,
361
+ bareToQualified: Map<string, string>,
362
+ ): string | null {
363
+ // Fully qualified reference (trackId.taskId) — always unambiguous
364
+ if (allQualified.has(ref)) return ref;
365
+ // Same-track shorthand — always unambiguous (shadows any global bare match)
366
+ const sameTrack = `${fromTrackId}.${ref}`;
367
+ if (allQualified.has(sameTrack)) return sameTrack;
368
+ // Global bare lookup — may be '__ambiguous__' when multiple tracks share the name
369
+ return bareToQualified.get(ref) ?? null;
370
+ }
371
+
372
+ function detectCycles(
373
+ config: RawPipelineConfig,
374
+ allQualified: Set<string>,
375
+ bareToQualified: Map<string, string>,
376
+ ): ValidationError[] {
377
+ // Build adjacency: qualifiedId → [resolved dep qualifiedIds]
378
+ const adj = new Map<string, string[]>();
379
+
380
+ for (const track of config.tracks) {
381
+ if (!track.id) continue;
382
+ for (const task of track.tasks ?? []) {
383
+ if (!task.id) continue;
384
+ const qid = `${track.id}.${task.id}`;
385
+ const deps: string[] = [];
386
+ for (const dep of task.depends_on ?? []) {
387
+ const resolved = resolveDepRef(dep, track.id, allQualified, bareToQualified);
388
+ if (resolved) deps.push(resolved);
389
+ }
390
+ if (task.continue_from) {
391
+ const resolved = resolveDepRef(task.continue_from, track.id, allQualified, bareToQualified);
392
+ if (resolved && !deps.includes(resolved)) deps.push(resolved);
393
+ }
394
+ adj.set(qid, deps);
395
+ }
396
+ }
397
+
398
+ const errors: ValidationError[] = [];
399
+ const visited = new Set<string>();
400
+ const inStack = new Set<string>();
401
+ // Deduplicate cycles: the same cycle can be discovered from multiple entry points.
402
+ // Canonical key = sorted node list joined — order-independent fingerprint.
403
+ const seenCycles = new Set<string>();
404
+
405
+ // Use a mutable path array instead of copying at each level (O(n) vs O(n^2)).
406
+ const pathStack: string[] = [];
407
+
408
+ function dfs(id: string): void {
409
+ if (inStack.has(id)) {
410
+ const cycleStart = pathStack.indexOf(id);
411
+ // Unique nodes in the cycle (without repeating the start node) for dedup.
412
+ // Previously the duplicate start node caused different sorted keys when
413
+ // the same cycle was discovered from different entry points.
414
+ const uniqueNodes = pathStack.slice(cycleStart);
415
+ const key = [...uniqueNodes].sort().join(',');
416
+ if (!seenCycles.has(key)) {
417
+ seenCycles.add(key);
418
+ const display = [...uniqueNodes, id]; // include start for readable display
419
+ errors.push({
420
+ path: 'tracks',
421
+ message: `Circular dependency detected: ${display.join(' → ')}`,
422
+ });
423
+ }
424
+ return;
425
+ }
426
+ if (visited.has(id)) return;
427
+ visited.add(id);
428
+ inStack.add(id);
429
+ pathStack.push(id);
430
+ for (const dep of adj.get(id) ?? []) {
431
+ dfs(dep);
432
+ }
433
+ pathStack.pop();
434
+ inStack.delete(id);
435
+ }
436
+
437
+ for (const id of adj.keys()) {
438
+ if (!visited.has(id)) dfs(id);
439
+ }
440
+
441
+ return errors;
442
+ }