@tagma/sdk 0.6.9 → 0.6.11

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 (43) hide show
  1. package/README.md +58 -10
  2. package/dist/config-ops.d.ts.map +1 -1
  3. package/dist/config-ops.js +14 -0
  4. package/dist/config-ops.js.map +1 -1
  5. package/dist/pipeline-runner.d.ts +1 -1
  6. package/dist/pipeline-runner.d.ts.map +1 -1
  7. package/dist/pipeline-runner.js +20 -0
  8. package/dist/pipeline-runner.js.map +1 -1
  9. package/dist/registry.d.ts.map +1 -1
  10. package/dist/registry.js +20 -1
  11. package/dist/registry.js.map +1 -1
  12. package/dist/schema.d.ts.map +1 -1
  13. package/dist/schema.js +40 -8
  14. package/dist/schema.js.map +1 -1
  15. package/dist/task-ref.d.ts.map +1 -1
  16. package/dist/task-ref.js +2 -0
  17. package/dist/task-ref.js.map +1 -1
  18. package/dist/utils.js +3 -3
  19. package/dist/utils.js.map +1 -1
  20. package/dist/validate-raw.d.ts +1 -0
  21. package/dist/validate-raw.d.ts.map +1 -1
  22. package/dist/validate-raw.js +74 -3
  23. package/dist/validate-raw.js.map +1 -1
  24. package/dist/yaml-compiler.d.ts.map +1 -1
  25. package/dist/yaml-compiler.js +23 -5
  26. package/dist/yaml-compiler.js.map +1 -1
  27. package/package.json +2 -2
  28. package/src/completions/output-check.test.ts +50 -0
  29. package/src/config-ops.test.ts +70 -0
  30. package/src/config-ops.ts +11 -0
  31. package/src/pipeline-runner.test.ts +95 -0
  32. package/src/pipeline-runner.ts +18 -1
  33. package/src/plugin-registry.test.ts +18 -0
  34. package/src/registry.ts +25 -1
  35. package/src/schema.test.ts +113 -1
  36. package/src/schema.ts +45 -10
  37. package/src/task-ref.ts +1 -0
  38. package/src/utils.test.ts +28 -0
  39. package/src/utils.ts +3 -3
  40. package/src/validate-raw-plugin-types.test.ts +60 -0
  41. package/src/validate-raw.ts +78 -4
  42. package/src/yaml-compiler.test.ts +108 -0
  43. package/src/yaml-compiler.ts +32 -5
@@ -32,6 +32,7 @@ function buildQidIndex(config: RawPipelineConfig): Map<string, QidEntry> {
32
32
  const idx = new Map<string, QidEntry>();
33
33
  for (const track of config.tracks ?? []) {
34
34
  if (!track.id) continue;
35
+ if (!Array.isArray(track.tasks)) continue;
35
36
  for (const task of track.tasks ?? []) {
36
37
  if (!task.id) continue;
37
38
  idx.set(qualifyTaskId(track.id, task.id), { track, task });
@@ -55,6 +56,7 @@ const isValidId = isValidTaskId;
55
56
 
56
57
  const VALID_ON_FAILURE = new Set(['skip_downstream', 'stop_all', 'ignore']);
57
58
  const VALID_REASONING_EFFORT = new Set(['low', 'medium', 'high']);
59
+ const PERMISSION_FIELDS = ['read', 'write', 'execute'] as const;
58
60
 
59
61
  // Built-in plugin types always known to the SDK core, regardless of which
60
62
  // external plugin packages are installed. These MUST stay in sync with the
@@ -67,6 +69,7 @@ const BUILTIN_COMPLETION_TYPES: ReadonlySet<string> = new Set([
67
69
  'output_check',
68
70
  ]);
69
71
  const BUILTIN_MIDDLEWARE_TYPES: ReadonlySet<string> = new Set(['static_context']);
72
+ const BUILTIN_DRIVER_TYPES: ReadonlySet<string> = new Set(['opencode']);
70
73
 
71
74
  /**
72
75
  * Optional second argument to `validateRaw`: the set of plugin types currently
@@ -78,6 +81,7 @@ const BUILTIN_MIDDLEWARE_TYPES: ReadonlySet<string> = new Set(['static_context']
78
81
  * this argument and no plugin warnings will be produced.
79
82
  */
80
83
  export interface KnownPluginTypes {
84
+ readonly drivers?: readonly string[];
81
85
  readonly triggers?: readonly string[];
82
86
  readonly completions?: readonly string[];
83
87
  readonly middlewares?: readonly string[];
@@ -121,6 +125,9 @@ export function validateRaw(
121
125
  const knownTriggers = knownTypes
122
126
  ? new Set<string>([...BUILTIN_TRIGGER_TYPES, ...(knownTypes.triggers ?? [])])
123
127
  : null;
128
+ const knownDrivers = knownTypes
129
+ ? new Set<string>([...BUILTIN_DRIVER_TYPES, ...(knownTypes.drivers ?? [])])
130
+ : null;
124
131
  const knownCompletions = knownTypes
125
132
  ? new Set<string>([...BUILTIN_COMPLETION_TYPES, ...(knownTypes.completions ?? [])])
126
133
  : null;
@@ -138,8 +145,20 @@ export function validateRaw(
138
145
  message: `Invalid reasoning_effort "${config.reasoning_effort}". Expected "low", "medium", or "high".`,
139
146
  });
140
147
  }
148
+ if (knownDrivers && config.driver && !knownDrivers.has(config.driver)) {
149
+ errors.push({
150
+ path: 'driver',
151
+ message: `Unknown driver type "${config.driver}"`,
152
+ severity: 'warning',
153
+ });
154
+ }
155
+ validatePermissions(config.permissions, 'permissions', errors);
141
156
 
142
- if (!config.tracks || config.tracks.length === 0) {
157
+ if (!Array.isArray(config.tracks)) {
158
+ errors.push({ path: 'tracks', message: 'pipeline.tracks must be an array' });
159
+ return errors;
160
+ }
161
+ if (config.tracks.length === 0) {
143
162
  errors.push({ path: 'tracks', message: 'At least one track is required' });
144
163
  return errors; // No point going further without tracks
145
164
  }
@@ -156,8 +175,13 @@ export function validateRaw(
156
175
  // ── Per-track validation ──
157
176
  const seenTrackIds = new Set<string>();
158
177
  for (let ti = 0; ti < config.tracks.length; ti++) {
159
- const track = config.tracks[ti];
178
+ const maybeTrack = config.tracks[ti] as unknown;
160
179
  const trackPath = `tracks[${ti}]`;
180
+ if (!maybeTrack || typeof maybeTrack !== 'object' || Array.isArray(maybeTrack)) {
181
+ errors.push({ path: trackPath, message: `Track ${ti} must be an object` });
182
+ continue;
183
+ }
184
+ const track = maybeTrack as RawTrackConfig;
161
185
 
162
186
  if (!track.id?.trim()) {
163
187
  errors.push({ path: `${trackPath}.id`, message: 'Track id is required' });
@@ -186,6 +210,14 @@ export function validateRaw(
186
210
  message: `Invalid reasoning_effort "${track.reasoning_effort}". Expected "low", "medium", or "high".`,
187
211
  });
188
212
  }
213
+ if (knownDrivers && track.driver && !knownDrivers.has(track.driver)) {
214
+ errors.push({
215
+ path: `${trackPath}.driver`,
216
+ message: `Unknown driver type "${track.driver}"`,
217
+ severity: 'warning',
218
+ });
219
+ }
220
+ validatePermissions(track.permissions, `${trackPath}.permissions`, errors);
189
221
 
190
222
  // Track-level middlewares can reference a plugin that was uninstalled
191
223
  // after the YAML was written — surface a warning so the user notices
@@ -203,7 +235,14 @@ export function validateRaw(
203
235
  }
204
236
  }
205
237
 
206
- if (!track.tasks || track.tasks.length === 0) {
238
+ if (!Array.isArray(track.tasks)) {
239
+ errors.push({
240
+ path: `${trackPath}.tasks`,
241
+ message: `Track "${track.id || ti}": tasks must be an array`,
242
+ });
243
+ continue;
244
+ }
245
+ if (track.tasks.length === 0) {
207
246
  errors.push({
208
247
  path: `${trackPath}.tasks`,
209
248
  message: `Track "${track.id || ti}": must have at least one task`,
@@ -276,6 +315,14 @@ export function validateRaw(
276
315
  message: `Invalid reasoning_effort "${task.reasoning_effort}". Expected "low", "medium", or "high".`,
277
316
  });
278
317
  }
318
+ if (knownDrivers && task.driver && !knownDrivers.has(task.driver)) {
319
+ errors.push({
320
+ path: `${taskPath}.driver`,
321
+ message: `Unknown driver type "${task.driver}"`,
322
+ severity: 'warning',
323
+ });
324
+ }
325
+ validatePermissions(task.permissions, `${taskPath}.permissions`, errors);
279
326
 
280
327
  // ── Plugin type warnings (trigger / completion / middlewares) ──
281
328
  // Only fire when the host supplied a `knownTypes` snapshot, so offline
@@ -348,7 +395,7 @@ export function validateRaw(
348
395
  });
349
396
  } else if (
350
397
  !task.depends_on ||
351
- !task.depends_on.some((dep) => {
398
+ !task.depends_on.some((dep: string) => {
352
399
  const depResolved = resolveTaskRef(dep, track.id, index);
353
400
  return depResolved.kind === 'resolved' && depResolved.qid === resolved.qid;
354
401
  })
@@ -374,6 +421,32 @@ export function validateRaw(
374
421
  return errors;
375
422
  }
376
423
 
424
+ function validatePermissions(
425
+ value: unknown,
426
+ basePath: string,
427
+ errors: ValidationError[],
428
+ ): void {
429
+ if (value === undefined) return;
430
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
431
+ errors.push({
432
+ path: basePath,
433
+ message: 'permissions must be an object with read/write/execute booleans',
434
+ });
435
+ return;
436
+ }
437
+ const p = value as Record<string, unknown>;
438
+ for (const field of PERMISSION_FIELDS) {
439
+ const path = `${basePath}.${field}`;
440
+ if (!(field in p)) {
441
+ errors.push({ path, message: `permissions.${field} is required` });
442
+ continue;
443
+ }
444
+ if (typeof p[field] !== 'boolean') {
445
+ errors.push({ path, message: `permissions.${field} must be a boolean` });
446
+ }
447
+ }
448
+ }
449
+
377
450
  const VALID_PORT_TYPES: ReadonlySet<PortType> = new Set([
378
451
  'string',
379
452
  'number',
@@ -738,6 +811,7 @@ function detectCycles(config: RawPipelineConfig, index: TaskIndex): ValidationEr
738
811
 
739
812
  for (const track of config.tracks) {
740
813
  if (!track.id) continue;
814
+ if (!Array.isArray(track.tasks)) continue;
741
815
  for (const task of track.tasks ?? []) {
742
816
  if (!task.id) continue;
743
817
  const qid = qualifyTaskId(track.id, task.id);
@@ -0,0 +1,108 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { compileYamlContent } from './yaml-compiler';
3
+
4
+ describe('compileYamlContent', () => {
5
+ test('reports YAML syntax failures as parse errors', () => {
6
+ const result = compileYamlContent('pipeline:\n name: [');
7
+
8
+ expect(result.parseOk).toBe(false);
9
+ expect(result.success).toBe(false);
10
+ expect(result.validation.errors).toEqual([]);
11
+ expect(result.summary).toMatch(/^YAML parse error:/);
12
+ });
13
+
14
+ test('reports missing top-level pipeline as a validation error', () => {
15
+ const result = compileYamlContent('name: Missing Pipeline\n');
16
+
17
+ expect(result.parseOk).toBe(true);
18
+ expect(result.success).toBe(false);
19
+ expect(result.validation.errors).toEqual([
20
+ { path: 'pipeline', message: 'Top-level "pipeline" key is required' },
21
+ ]);
22
+ expect(result.summary).toBe('Invalid: 1 error(s), 0 warning(s)');
23
+ });
24
+
25
+ test('reports non-array tracks as a validation error, not a parse failure', () => {
26
+ const result = compileYamlContent(`
27
+ pipeline:
28
+ name: Bad
29
+ tracks:
30
+ id: not-an-array
31
+ `);
32
+
33
+ expect(result.parseOk).toBe(true);
34
+ expect(result.success).toBe(false);
35
+ expect(result.validation.errors).toEqual([
36
+ { path: 'tracks', message: 'pipeline.tracks must be an array' },
37
+ ]);
38
+ });
39
+
40
+ test('reports non-array task lists as validation errors, not validation crashes', () => {
41
+ const result = compileYamlContent(`
42
+ pipeline:
43
+ name: Bad Tasks
44
+ tracks:
45
+ - id: t
46
+ name: T
47
+ tasks:
48
+ id: not-an-array
49
+ `);
50
+
51
+ expect(result.parseOk).toBe(true);
52
+ expect(result.success).toBe(false);
53
+ expect(result.validation.errors).toEqual([
54
+ { path: 'tracks[0].tasks', message: 'Track "t": tasks must be an array' },
55
+ ]);
56
+ expect(result.summary).not.toMatch(/Validation crashed/);
57
+ });
58
+
59
+ test('routes schema errors through validation when YAML syntax is valid', () => {
60
+ const result = compileYamlContent(`
61
+ pipeline:
62
+ name: Missing Track Name
63
+ tracks:
64
+ - id: main
65
+ tasks:
66
+ - id: task
67
+ prompt: hello
68
+ `);
69
+
70
+ expect(result.parseOk).toBe(true);
71
+ expect(result.success).toBe(false);
72
+ expect(result.validation.errors).toContainEqual({
73
+ path: 'tracks[0].name',
74
+ message: 'Track name is required',
75
+ });
76
+ });
77
+
78
+ test('validates pipeline, track, and task permissions shape', () => {
79
+ const result = compileYamlContent(`
80
+ pipeline:
81
+ name: Bad Permissions
82
+ permissions: { read: true, write: "yes", execute: false }
83
+ tracks:
84
+ - id: main
85
+ name: Main
86
+ permissions: { read: true, execute: false }
87
+ tasks:
88
+ - id: task
89
+ prompt: hello
90
+ permissions: nope
91
+ `);
92
+
93
+ expect(result.parseOk).toBe(true);
94
+ expect(result.success).toBe(false);
95
+ expect(result.validation.errors).toContainEqual({
96
+ path: 'permissions.write',
97
+ message: 'permissions.write must be a boolean',
98
+ });
99
+ expect(result.validation.errors).toContainEqual({
100
+ path: 'tracks[0].permissions.write',
101
+ message: 'permissions.write is required',
102
+ });
103
+ expect(result.validation.errors).toContainEqual({
104
+ path: 'tracks[0].tasks[0].permissions',
105
+ message: 'permissions must be an object with read/write/execute booleans',
106
+ });
107
+ });
108
+ });
@@ -1,4 +1,4 @@
1
- import { parseYaml } from './schema';
1
+ import yaml from 'js-yaml';
2
2
  import { validateRaw } from './validate-raw';
3
3
  import type { ValidationError, KnownPluginTypes } from './validate-raw';
4
4
  import type { RawPipelineConfig } from './types';
@@ -27,9 +27,9 @@ export function compileYamlContent(
27
27
  const timestamp = new Date().toISOString();
28
28
  const sourceName = opts.sourceName ?? 'untitled';
29
29
 
30
- let config: RawPipelineConfig;
30
+ let doc: unknown;
31
31
  try {
32
- config = parseYaml(content);
32
+ doc = yaml.load(content);
33
33
  } catch (err) {
34
34
  return {
35
35
  timestamp,
@@ -41,6 +41,12 @@ export function compileYamlContent(
41
41
  };
42
42
  }
43
43
 
44
+ const envelopeErrors = validateEnvelope(doc);
45
+ if (envelopeErrors.length > 0) {
46
+ return buildValidationResult(timestamp, sourceName, envelopeErrors);
47
+ }
48
+ const config = (doc as { pipeline: RawPipelineConfig }).pipeline;
49
+
44
50
  let errors: ValidationError[];
45
51
  try {
46
52
  errors = validateRaw(config, opts.knownTypes);
@@ -55,8 +61,29 @@ export function compileYamlContent(
55
61
  };
56
62
  }
57
63
 
58
- const validationErrors = errors.filter((e) => e.severity === 'error' || e.severity == null);
59
- const validationWarnings = errors.filter((e) => e.severity === 'warning');
64
+ return buildValidationResult(timestamp, sourceName, errors);
65
+ }
66
+
67
+ function validateEnvelope(doc: unknown): ValidationError[] {
68
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc) || !('pipeline' in doc)) {
69
+ return [{ path: 'pipeline', message: 'Top-level "pipeline" key is required' }];
70
+ }
71
+ const pipeline = (doc as Record<string, unknown>).pipeline;
72
+ if (!pipeline || typeof pipeline !== 'object' || Array.isArray(pipeline)) {
73
+ return [{ path: 'pipeline', message: 'pipeline must be an object' }];
74
+ }
75
+ return [];
76
+ }
77
+
78
+ function buildValidationResult(
79
+ timestamp: string,
80
+ sourceName: string,
81
+ diagnostics: readonly ValidationError[],
82
+ ): YamlCompileResult {
83
+ const validationErrors = diagnostics.filter(
84
+ (e) => e.severity === 'error' || e.severity == null,
85
+ );
86
+ const validationWarnings = diagnostics.filter((e) => e.severity === 'warning');
60
87
 
61
88
  return {
62
89
  timestamp,