@tagma/sdk 0.7.0 → 0.7.1

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/src/ports.ts CHANGED
@@ -270,6 +270,7 @@ export type BindingInputResolution =
270
270
  readonly kind: 'blocked';
271
271
  readonly missingRequired: readonly string[];
272
272
  readonly ambiguous: readonly { input: string; producers: readonly string[] }[];
273
+ readonly typeErrors: readonly { input: string; reason: string }[];
273
274
  readonly reason: string;
274
275
  };
275
276
 
@@ -287,6 +288,7 @@ export function resolveTaskBindingInputs(
287
288
  const missingRequired: string[] = [];
288
289
  const missingOptional: string[] = [];
289
290
  const ambiguous: { input: string; producers: string[] }[] = [];
291
+ const typeErrors: { input: string; reason: string }[] = [];
290
292
 
291
293
  for (const [name, binding] of Object.entries(bindings)) {
292
294
  let value: unknown;
@@ -321,10 +323,16 @@ export function resolveTaskBindingInputs(
321
323
  continue;
322
324
  }
323
325
 
324
- inputs[name] = value;
326
+ const coerced = coerceBindingValue(binding, value);
327
+ if (coerced.kind === 'error') {
328
+ typeErrors.push({ input: name, reason: coerced.reason });
329
+ continue;
330
+ }
331
+
332
+ inputs[name] = coerced.value;
325
333
  }
326
334
 
327
- if (missingRequired.length > 0 || ambiguous.length > 0) {
335
+ if (missingRequired.length > 0 || ambiguous.length > 0 || typeErrors.length > 0) {
328
336
  const lines: string[] = [];
329
337
  if (missingRequired.length > 0) {
330
338
  lines.push(`missing required binding input(s): ${missingRequired.join(', ')}`);
@@ -335,7 +343,10 @@ export function resolveTaskBindingInputs(
335
343
  `(${amb.producers.join(', ')}) — use "taskId.outputs.${amb.input}"`,
336
344
  );
337
345
  }
338
- return { kind: 'blocked', missingRequired, ambiguous, reason: lines.join('\n') };
346
+ for (const te of typeErrors) {
347
+ lines.push(`binding input "${te.input}": ${te.reason}`);
348
+ }
349
+ return { kind: 'blocked', missingRequired, ambiguous, typeErrors, reason: lines.join('\n') };
339
350
  }
340
351
 
341
352
  return { kind: 'ready', inputs, missingOptional };
@@ -494,6 +505,21 @@ function coerceValue(port: PortDef, raw: unknown): Coercion {
494
505
  }
495
506
  }
496
507
 
508
+ function coerceBindingValue(
509
+ binding: { readonly type?: PortType; readonly enum?: readonly string[] },
510
+ raw: unknown,
511
+ ): Coercion {
512
+ if (!binding.type) return { kind: 'ok', value: raw };
513
+ return coerceValue(
514
+ {
515
+ name: 'binding',
516
+ type: binding.type,
517
+ ...(binding.enum ? { enum: binding.enum } : {}),
518
+ },
519
+ raw,
520
+ );
521
+ }
522
+
497
523
  function describe(v: unknown): string {
498
524
  if (v === null) return 'null';
499
525
  if (Array.isArray(v)) return 'array';
@@ -630,7 +656,13 @@ export function extractTaskBindingOutputs(
630
656
  continue;
631
657
  }
632
658
 
633
- outputs[name] = value;
659
+ const coerced = coerceBindingValue(binding, value);
660
+ if (coerced.kind === 'error') {
661
+ missing.push(`"${name}": ${coerced.reason}`);
662
+ continue;
663
+ }
664
+
665
+ outputs[name] = coerced.value;
634
666
  }
635
667
 
636
668
  return {
@@ -1,19 +1,12 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
2
  import yaml from 'js-yaml';
3
3
  import type { PipelineConfig, RawPipelineConfig } from './types';
4
- import {
5
- deresolvePipeline,
6
- parseYaml,
7
- resolveConfig,
8
- serializePipeline,
9
- } from './schema';
4
+ import { deresolvePipeline, parseYaml, resolveConfig, serializePipeline } from './schema';
10
5
 
11
6
  const WORK_DIR = process.platform === 'win32' ? 'D:\\fake-work' : '/fake-work';
12
7
 
13
- // ─── resolveConfig preserves ports ───────────────────────────────────
14
-
15
- describe('resolveConfig — ports passthrough', () => {
16
- test('raw lightweight bindings survive onto the resolved task', () => {
8
+ describe('schema unified bindings passthrough', () => {
9
+ test('typed inputs and outputs survive onto the resolved task', () => {
17
10
  const raw: RawPipelineConfig = {
18
11
  name: 'p',
19
12
  tracks: [
@@ -24,94 +17,19 @@ describe('resolveConfig — ports passthrough', () => {
24
17
  {
25
18
  id: 'a',
26
19
  command: 'echo "{{inputs.city}}"',
27
- inputs: {
28
- city: { from: 't.plan.outputs.city', required: true },
29
- },
30
- outputs: {
31
- report: { from: 'json.reportPath' },
32
- },
20
+ inputs: { city: { from: 't.plan.outputs.city', type: 'string', required: true } },
21
+ outputs: { report: { from: 'json.reportPath', type: 'string' } },
33
22
  },
34
23
  ],
35
24
  },
36
25
  ],
37
26
  };
38
- const resolved = resolveConfig(raw, WORK_DIR);
39
- const task = resolved.tracks[0]!.tasks[0]!;
27
+ const task = resolveConfig(raw, WORK_DIR).tracks[0]!.tasks[0]!;
40
28
  expect(task.inputs).toEqual(raw.tracks[0]!.tasks[0]!.inputs!);
41
29
  expect(task.outputs).toEqual(raw.tracks[0]!.tasks[0]!.outputs!);
42
30
  });
43
31
 
44
- test('raw ports survive onto the resolved task', () => {
45
- const raw: RawPipelineConfig = {
46
- name: 'p',
47
- tracks: [
48
- {
49
- id: 't',
50
- name: 'T',
51
- tasks: [
52
- {
53
- id: 'a',
54
- prompt: 'do it',
55
- ports: {
56
- inputs: [{ name: 'city', type: 'string', required: true }],
57
- outputs: [{ name: 'temp', type: 'number', description: 'Celsius' }],
58
- },
59
- },
60
- ],
61
- },
62
- ],
63
- };
64
- const resolved = resolveConfig(raw, WORK_DIR);
65
- const task = resolved.tracks[0]!.tasks[0]!;
66
- expect(task.ports).toBeDefined();
67
- expect(task.ports!.inputs).toEqual([
68
- { name: 'city', type: 'string', required: true },
69
- ]);
70
- expect(task.ports!.outputs).toEqual([
71
- { name: 'temp', type: 'number', description: 'Celsius' },
72
- ]);
73
- });
74
-
75
- test('tasks without ports still resolve with ports === undefined', () => {
76
- const raw: RawPipelineConfig = {
77
- name: 'p',
78
- tracks: [
79
- { id: 't', name: 'T', tasks: [{ id: 'a', prompt: 'do it' }] },
80
- ],
81
- };
82
- const resolved = resolveConfig(raw, WORK_DIR);
83
- expect(resolved.tracks[0]!.tasks[0]!.ports).toBeUndefined();
84
- });
85
-
86
- test('ports is not inherited from track or pipeline', () => {
87
- // Ports describe a per-task I/O contract. If we accidentally pulled
88
- // them from track defaults, two tasks in the same track would share
89
- // input ports and downstream data-flow would be ambiguous. Test that
90
- // a track with an unrelated `middlewares` default doesn't spread
91
- // anywhere unexpected — purely a regression guard for the no-inherit
92
- // invariant.
93
- const raw: RawPipelineConfig = {
94
- name: 'p',
95
- tracks: [
96
- {
97
- id: 't',
98
- name: 'T',
99
- middlewares: [{ type: 'static_context', file: './x' }],
100
- tasks: [{ id: 'a', prompt: 'x' }, { id: 'b', prompt: 'y' }],
101
- },
102
- ],
103
- };
104
- const resolved = resolveConfig(raw, WORK_DIR);
105
- for (const task of resolved.tracks[0]!.tasks) {
106
- expect(task.ports).toBeUndefined();
107
- }
108
- });
109
- });
110
-
111
- // ─── deresolvePipeline preserves ports ───────────────────────────────
112
-
113
- describe('deresolvePipeline — ports round-trip', () => {
114
- test('lightweight bindings round-trip', () => {
32
+ test('typed inputs and outputs round-trip through deresolve', () => {
115
33
  const raw: RawPipelineConfig = {
116
34
  name: 'p',
117
35
  tracks: [
@@ -123,103 +41,49 @@ describe('deresolvePipeline — ports round-trip', () => {
123
41
  id: 'a',
124
42
  command: 'echo "{{inputs.city}}"',
125
43
  inputs: {
126
- city: { from: 't.plan.outputs.city', required: true },
127
- mode: { default: 'quick' },
128
- },
129
- outputs: {
130
- raw: { from: 'stdout' },
44
+ city: {
45
+ from: 't.plan.outputs.city',
46
+ type: 'enum',
47
+ enum: ['Shanghai', 'Paris'],
48
+ required: true,
49
+ },
131
50
  },
51
+ outputs: { raw: { from: 'stdout' } },
132
52
  },
133
53
  ],
134
54
  },
135
55
  ],
136
56
  };
137
- const resolved = resolveConfig(raw, WORK_DIR);
138
- const back = deresolvePipeline(resolved, WORK_DIR);
57
+ const back = deresolvePipeline(resolveConfig(raw, WORK_DIR), WORK_DIR);
139
58
  expect(back.tracks[0]!.tasks[0]!.inputs).toEqual(raw.tracks[0]!.tasks[0]!.inputs!);
140
59
  expect(back.tracks[0]!.tasks[0]!.outputs).toEqual(raw.tracks[0]!.tasks[0]!.outputs!);
141
60
  });
142
61
 
143
- test('ports with both inputs and outputs round-trip', () => {
144
- const raw: RawPipelineConfig = {
145
- name: 'p',
146
- tracks: [
147
- {
148
- id: 't',
149
- name: 'T',
150
- tasks: [
151
- {
152
- id: 'a',
153
- prompt: 'hi',
154
- ports: {
155
- inputs: [{ name: 'city', type: 'string', required: true }],
156
- outputs: [{ name: 'temp', type: 'number' }],
157
- },
158
- },
159
- ],
160
- },
161
- ],
162
- };
163
- const resolved = resolveConfig(raw, WORK_DIR);
164
- const back = deresolvePipeline(resolved, WORK_DIR);
165
- expect(back.tracks[0]!.tasks[0]!.ports).toEqual(raw.tracks[0]!.tasks[0]!.ports!);
166
- });
167
-
168
- test('ports with only outputs round-trip', () => {
169
- const raw: RawPipelineConfig = {
170
- name: 'p',
171
- tracks: [
172
- {
173
- id: 't',
174
- name: 'T',
175
- tasks: [
176
- {
177
- id: 'a',
178
- command: 'echo hi',
179
- ports: { outputs: [{ name: 'x', type: 'string' }] },
180
- },
181
- ],
182
- },
183
- ],
184
- };
185
- const resolved = resolveConfig(raw, WORK_DIR);
186
- const back = deresolvePipeline(resolved, WORK_DIR);
187
- expect(back.tracks[0]!.tasks[0]!.ports).toEqual({
188
- outputs: [{ name: 'x', type: 'string' }],
189
- });
190
- });
191
-
192
- test('empty ports ({}) is dropped on deresolve', () => {
193
- // YAML round-trip prefers field absence over `ports: {}` so a task
194
- // that once declared a port but had it cleared in the editor
195
- // doesn't persist a useless empty object in the file.
62
+ test('empty binding maps are dropped on deresolve', () => {
196
63
  const resolved: PipelineConfig = {
197
64
  name: 'p',
198
65
  tracks: [
199
66
  {
200
67
  id: 't',
201
68
  name: 'T',
202
- driver: 'opencode',
203
- permissions: { read: true, write: false, execute: false },
204
- on_failure: 'skip_downstream',
205
69
  tasks: [
206
70
  {
207
71
  id: 'a',
208
72
  name: 'a',
209
73
  prompt: 'hi',
210
- permissions: { read: true, write: false, execute: false },
211
- driver: 'opencode',
212
- ports: {},
74
+ inputs: {},
75
+ outputs: {},
213
76
  },
214
77
  ],
215
78
  },
216
79
  ],
217
80
  };
218
81
  const back = deresolvePipeline(resolved, WORK_DIR);
219
- expect(back.tracks[0]!.tasks[0]!.ports).toBeUndefined();
82
+ expect(back.tracks[0]!.tasks[0]!.inputs).toBeUndefined();
83
+ expect(back.tracks[0]!.tasks[0]!.outputs).toBeUndefined();
220
84
  });
221
85
 
222
- test('YAML round-trip via serializePipeline preserves the full ports shape', () => {
86
+ test('YAML round-trip preserves typed unified binding shape', () => {
223
87
  const raw: RawPipelineConfig = {
224
88
  name: 'p',
225
89
  tracks: [
@@ -230,18 +94,13 @@ describe('deresolvePipeline — ports round-trip', () => {
230
94
  {
231
95
  id: 'classify',
232
96
  prompt: 'pick a bucket',
233
- ports: {
234
- inputs: [
235
- { name: 'doc', type: 'string', required: true, description: 'Full text' },
236
- ],
237
- outputs: [
238
- {
239
- name: 'bucket',
240
- type: 'enum',
241
- enum: ['spam', 'ham'],
242
- description: 'Classification',
243
- },
244
- ],
97
+ inputs: { doc: { type: 'string', required: true, description: 'Full text' } },
98
+ outputs: {
99
+ bucket: {
100
+ type: 'enum',
101
+ enum: ['spam', 'ham'],
102
+ description: 'Classification',
103
+ },
245
104
  },
246
105
  },
247
106
  ],
@@ -250,14 +109,11 @@ describe('deresolvePipeline — ports round-trip', () => {
250
109
  };
251
110
  const yamlText = serializePipeline(raw);
252
111
  const parsed = (yaml.load(yamlText) as { pipeline: RawPipelineConfig }).pipeline;
253
- expect(parsed.tracks[0]!.tasks[0]!.ports).toEqual(raw.tracks[0]!.tasks[0]!.ports!);
112
+ expect(parsed.tracks[0]!.tasks[0]!.inputs).toEqual(raw.tracks[0]!.tasks[0]!.inputs!);
113
+ expect(parsed.tracks[0]!.tasks[0]!.outputs).toEqual(raw.tracks[0]!.tasks[0]!.outputs!);
254
114
  });
255
- });
256
-
257
- // ─── parseYaml accepts ports ─────────────────────────────────────────
258
115
 
259
- describe('parseYaml accepts ports declarations', () => {
260
- test('real-world YAML with lightweight bindings parses cleanly', () => {
116
+ test('real-world YAML with typed bindings parses cleanly', () => {
261
117
  const text = `pipeline:
262
118
  name: demo
263
119
  tracks:
@@ -267,56 +123,27 @@ describe('parseYaml — accepts ports declarations', () => {
267
123
  - id: build
268
124
  command: bun run build
269
125
  outputs:
270
- bundlePath: { from: json.bundlePath }
126
+ bundlePath:
127
+ from: json.bundlePath
128
+ type: string
271
129
  - id: test
272
130
  depends_on: [build]
273
131
  command: 'bun test "{{inputs.bundlePath}}"'
274
132
  inputs:
275
133
  bundlePath:
276
134
  from: t.build.outputs.bundlePath
135
+ type: string
277
136
  required: true
278
137
  `;
279
138
  const config = parseYaml(text);
280
- const build = config.tracks[0]!.tasks[0]!;
281
- const testTask = config.tracks[0]!.tasks[1]!;
282
- expect(build.outputs!.bundlePath).toEqual({ from: 'json.bundlePath' });
283
- expect(testTask.inputs!.bundlePath).toEqual({
139
+ expect(config.tracks[0]!.tasks[0]!.outputs!.bundlePath).toEqual({
140
+ from: 'json.bundlePath',
141
+ type: 'string',
142
+ });
143
+ expect(config.tracks[0]!.tasks[1]!.inputs!.bundlePath).toEqual({
284
144
  from: 't.build.outputs.bundlePath',
145
+ type: 'string',
285
146
  required: true,
286
147
  });
287
148
  });
288
-
289
- test('real-world YAML with ports parses cleanly', () => {
290
- const text = `pipeline:
291
- name: demo
292
- tracks:
293
- - id: t
294
- name: Main
295
- tasks:
296
- - id: plan
297
- prompt: Pick a city and id
298
- ports:
299
- outputs:
300
- - name: city
301
- type: string
302
- description: Target city
303
- - name: id
304
- type: number
305
- - id: fetch
306
- depends_on: [plan]
307
- command: 'weather.sh --city "{{inputs.city}}" --id {{inputs.id}}'
308
- ports:
309
- inputs:
310
- - { name: city, type: string, required: true }
311
- - { name: id, type: number, required: true }
312
- outputs:
313
- - { name: temp, type: number }
314
- `;
315
- const config = parseYaml(text);
316
- const plan = config.tracks[0]!.tasks[0]!;
317
- const fetch = config.tracks[0]!.tasks[1]!;
318
- expect(plan.ports!.outputs!.map((p) => p.name)).toEqual(['city', 'id']);
319
- expect(fetch.ports!.inputs!.map((p) => p.name)).toEqual(['city', 'id']);
320
- expect(fetch.ports!.outputs!.map((p) => p.name)).toEqual(['temp']);
321
- });
322
149
  });