@tagma/sdk 0.7.0 → 0.7.3

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 (72) hide show
  1. package/README.md +84 -44
  2. package/dist/bootstrap.d.ts +20 -0
  3. package/dist/bootstrap.d.ts.map +1 -1
  4. package/dist/bootstrap.js +21 -11
  5. package/dist/bootstrap.js.map +1 -1
  6. package/dist/core/dataflow.d.ts.map +1 -1
  7. package/dist/core/dataflow.js +45 -9
  8. package/dist/core/dataflow.js.map +1 -1
  9. package/dist/core/run-context.d.ts +3 -0
  10. package/dist/core/run-context.d.ts.map +1 -1
  11. package/dist/core/run-context.js +2 -0
  12. package/dist/core/run-context.js.map +1 -1
  13. package/dist/core/task-executor.d.ts.map +1 -1
  14. package/dist/core/task-executor.js +46 -84
  15. package/dist/core/task-executor.js.map +1 -1
  16. package/dist/engine.d.ts +6 -0
  17. package/dist/engine.d.ts.map +1 -1
  18. package/dist/engine.js +3 -0
  19. package/dist/engine.js.map +1 -1
  20. package/dist/index.d.ts +3 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/plugins.d.ts +2 -2
  25. package/dist/plugins.d.ts.map +1 -1
  26. package/dist/ports.d.ts +4 -0
  27. package/dist/ports.d.ts.map +1 -1
  28. package/dist/ports.js +27 -4
  29. package/dist/ports.js.map +1 -1
  30. package/dist/registry.d.ts +10 -4
  31. package/dist/registry.d.ts.map +1 -1
  32. package/dist/registry.js +64 -25
  33. package/dist/registry.js.map +1 -1
  34. package/dist/runtime.d.ts +9 -0
  35. package/dist/runtime.d.ts.map +1 -0
  36. package/dist/runtime.js +8 -0
  37. package/dist/runtime.js.map +1 -0
  38. package/dist/schema.d.ts.map +1 -1
  39. package/dist/schema.js +1 -7
  40. package/dist/schema.js.map +1 -1
  41. package/dist/tagma.d.ts +11 -1
  42. package/dist/tagma.d.ts.map +1 -1
  43. package/dist/tagma.js +6 -0
  44. package/dist/tagma.js.map +1 -1
  45. package/dist/validate-raw.d.ts +4 -4
  46. package/dist/validate-raw.d.ts.map +1 -1
  47. package/dist/validate-raw.js +89 -230
  48. package/dist/validate-raw.js.map +1 -1
  49. package/package.json +2 -2
  50. package/src/bootstrap.ts +23 -14
  51. package/src/core/dataflow.test.ts +8 -9
  52. package/src/core/dataflow.ts +57 -14
  53. package/src/core/run-context.test.ts +12 -0
  54. package/src/core/run-context.ts +4 -0
  55. package/src/core/task-executor.ts +75 -135
  56. package/src/engine-ports-mixed.test.ts +68 -411
  57. package/src/engine-ports.test.ts +37 -341
  58. package/src/engine.ts +8 -0
  59. package/src/index.ts +5 -0
  60. package/src/pipeline-runner.test.ts +5 -9
  61. package/src/plugin-registry.test.ts +138 -1
  62. package/src/plugins.ts +5 -2
  63. package/src/ports.test.ts +80 -0
  64. package/src/ports.ts +36 -4
  65. package/src/registry.ts +81 -26
  66. package/src/runtime.ts +20 -0
  67. package/src/schema-ports.test.ts +47 -197
  68. package/src/schema.ts +1 -7
  69. package/src/tagma.test.ts +72 -1
  70. package/src/tagma.ts +16 -1
  71. package/src/validate-raw-ports.test.ts +80 -393
  72. package/src/validate-raw.ts +90 -250
@@ -1,187 +1,89 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
+ import type { RawPipelineConfig, RawTaskConfig } from './types';
2
3
  import { validateRaw } from './validate-raw';
3
- import type { RawPipelineConfig, RawTaskConfig, RawTrackConfig, TaskPorts } from './types';
4
-
5
- // ─── Helpers ─────────────────────────────────────────────────────────
6
- //
7
- // Prompt Tasks no longer declare ports — the validator errors out when
8
- // they try. The structural port tests below therefore use Command Tasks
9
- // by default (where declared ports remain the source of truth) and
10
- // switch to Prompt Tasks only for the "must not declare ports" and the
11
- // inferred-port cross-checks.
12
4
 
13
5
  function commandTask(overrides: Partial<RawTaskConfig> & { id: string }): RawTaskConfig {
14
- return { command: 'echo hi', ...overrides };
6
+ return { command: 'echo {{inputs.city}}', ...overrides };
15
7
  }
16
8
 
17
9
  function promptTask(overrides: Partial<RawTaskConfig> & { id: string }): RawTaskConfig {
18
- return { prompt: 'do a thing', ...overrides };
19
- }
20
-
21
- function pipeline(tasks: RawTaskConfig[]): RawPipelineConfig {
22
- const track: RawTrackConfig = { id: 't', name: 't', tasks };
23
- return { name: 'test', tracks: [track] };
10
+ return { prompt: 'hello {{inputs.city}}', ...overrides };
24
11
  }
25
12
 
26
- function errorsFor(taskConfig: RawTaskConfig): ReturnType<typeof validateRaw> {
27
- return validateRaw(pipeline([taskConfig]));
13
+ function config(tasks: RawTaskConfig[]): RawPipelineConfig {
14
+ return {
15
+ name: 'p',
16
+ tracks: [{ id: 't', name: 'T', tasks }],
17
+ };
28
18
  }
29
19
 
30
- /**
31
- * Return only errors whose path points inside the given task's `.ports`
32
- * subtree. Keeps assertions focused — unrelated cycle / name-validation
33
- * errors don't pollute the match set.
34
- */
35
- function portsErrors(errors: ReturnType<typeof validateRaw>): typeof errors {
36
- return errors.filter(
37
- (e) => e.path.includes('.ports.') || e.path.includes('.ports[') || /\.ports$/.test(e.path),
38
- );
20
+ function errorsFor(task: RawTaskConfig) {
21
+ return validateRaw(config([task]));
39
22
  }
40
23
 
41
- function bindingErrors(errors: ReturnType<typeof validateRaw>): typeof errors {
42
- return errors.filter(
43
- (e) => e.path.includes('.inputs') || e.path.includes('.outputs'),
44
- );
45
- }
46
-
47
- // ─── Structural validation (Command Tasks) ───────────────────────────
48
-
49
- describe('validateRaw — port structure (command tasks)', () => {
50
- test('empty ports object is accepted (no-op)', () => {
51
- const errors = errorsFor(commandTask({ id: 'a', ports: {} }));
52
- expect(portsErrors(errors)).toEqual([]);
53
- });
54
-
55
- test('rejects non-array ports.inputs', () => {
56
- const ports = { inputs: 'not-an-array' as unknown as [] } as TaskPorts;
57
- const errors = errorsFor(commandTask({ id: 'a', ports }));
58
- const e = portsErrors(errors);
59
- expect(e.length).toBeGreaterThan(0);
60
- expect(e[0]!.message).toMatch(/must be an array/);
61
- });
62
-
63
- test('rejects non-object port entry', () => {
64
- const ports = { inputs: ['not-an-object' as unknown as never] } as TaskPorts;
65
- const errors = errorsFor(commandTask({ id: 'a', ports }));
66
- expect(portsErrors(errors).some((e) => /must be an object/.test(e.message))).toBe(true);
67
- });
68
-
69
- test('requires port.name to be a non-empty string', () => {
70
- const ports: TaskPorts = { inputs: [{ name: '', type: 'string' }] };
71
- const errors = errorsFor(commandTask({ id: 'a', ports }));
72
- expect(portsErrors(errors).some((e) => /port\.name is required/.test(e.message))).toBe(true);
73
- });
74
-
75
- test('rejects invalid port name characters', () => {
76
- const ports: TaskPorts = {
77
- inputs: [
78
- { name: 'has-hyphen', type: 'string' },
79
- { name: '1starts-with-digit', type: 'string' },
80
- { name: 'has.dot', type: 'string' },
81
- ],
82
- };
83
- const errors = errorsFor(commandTask({ id: 'a', ports }));
84
- const msgs = portsErrors(errors).map((e) => e.message);
85
- expect(msgs.filter((m) => /port name .* is invalid/.test(m)).length).toBe(3);
86
- });
87
-
88
- test('flags duplicate port names within the same list', () => {
89
- const ports: TaskPorts = {
90
- inputs: [
91
- { name: 'x', type: 'string' },
92
- { name: 'x', type: 'number' },
93
- ],
94
- };
95
- const errors = errorsFor(commandTask({ id: 'a', ports }));
96
- expect(portsErrors(errors).some((e) => /Duplicate ports\.inputs name/.test(e.message))).toBe(
97
- true,
98
- );
99
- });
100
-
101
- test('rejects unknown port type', () => {
102
- const ports = { inputs: [{ name: 'x', type: 'made-up' as never }] } as TaskPorts;
103
- const errors = errorsFor(commandTask({ id: 'a', ports }));
104
- expect(portsErrors(errors).some((e) => /type must be one of/.test(e.message))).toBe(true);
105
- });
106
-
107
- test('enum port requires a non-empty enum array', () => {
108
- const ports: TaskPorts = { inputs: [{ name: 'x', type: 'enum' }] };
109
- const errors = errorsFor(commandTask({ id: 'a', ports }));
110
- expect(portsErrors(errors).some((e) => /non-empty "enum"/.test(e.message))).toBe(true);
111
- });
112
-
113
- test('enum values must all be strings', () => {
114
- const ports = {
115
- inputs: [{ name: 'x', type: 'enum' as const, enum: ['a', 1 as unknown as string] }],
116
- } as TaskPorts;
117
- const errors = errorsFor(commandTask({ id: 'a', ports }));
118
- expect(portsErrors(errors).some((e) => /enum values must all be strings/.test(e.message))).toBe(
119
- true,
120
- );
121
- });
122
-
123
- test('`from` must be a string', () => {
124
- const ports = {
125
- inputs: [{ name: 'x', type: 'string' as const, from: 42 as unknown as string }],
126
- } as TaskPorts;
127
- const errors = errorsFor(commandTask({ id: 'a', ports }));
128
- expect(portsErrors(errors).some((e) => /"from" must be a string/.test(e.message))).toBe(true);
129
- });
130
- });
131
-
132
- // ─── Lightweight binding validation ──────────────────────────────────
133
-
134
- describe('validateRaw — lightweight task bindings', () => {
135
- test('accepts top-level inputs for command placeholder references', () => {
24
+ describe('validateRaw ports migration', () => {
25
+ test('rejects ports with a migration message', () => {
136
26
  const errors = errorsFor(
137
27
  commandTask({
138
28
  id: 'a',
139
- command: 'echo {{inputs.city}}',
140
- inputs: { city: { value: 'Shanghai' } },
29
+ ports: { inputs: [{ name: 'city', type: 'string' }] },
141
30
  }),
142
31
  );
143
- expect(errors.some((e) => e.message.includes('references "{{inputs.city}}"'))).toBe(false);
32
+ expect(errors.some((e) => e.path === 'tracks[0].tasks[0].ports')).toBe(true);
33
+ expect(errors.some((e) => /replaced by typed inputs\/outputs/.test(e.message))).toBe(true);
144
34
  });
145
35
 
146
- test('rejects non-object binding maps and entries', () => {
36
+ test('empty ports is still rejected', () => {
37
+ const errors = errorsFor(commandTask({ id: 'a', ports: {} }));
38
+ expect(errors.some((e) => /ports has been replaced/.test(e.message))).toBe(true);
39
+ });
40
+ });
41
+
42
+ describe('validateRaw — unified typed bindings', () => {
43
+ test('accepts typed command inputs and outputs', () => {
147
44
  const errors = errorsFor(
148
45
  commandTask({
149
46
  id: 'a',
150
- inputs: 'bad' as unknown as never,
151
- outputs: { ok: 'bad' as unknown as never },
47
+ inputs: { city: { type: 'string', required: true } },
48
+ outputs: { temp: { type: 'number' } },
152
49
  }),
153
50
  );
154
- const msgs = bindingErrors(errors).map((e) => e.message);
155
- expect(msgs.some((m) => /task\.inputs must be an object/.test(m))).toBe(true);
156
- expect(msgs.some((m) => /task\.outputs\.ok must be an object/.test(m))).toBe(true);
51
+ expect(errors).toEqual([]);
157
52
  });
158
53
 
159
- test('rejects invalid binding names and duplicate loose/strict names', () => {
54
+ test('rejects invalid binding maps, names, type, and enum shape', () => {
160
55
  const errors = errorsFor(
161
56
  commandTask({
162
57
  id: 'a',
163
- inputs: { 'bad-name': { value: 'x' }, city: { value: 'Shanghai' } },
164
- outputs: { report: { from: 'stdout' } },
165
- ports: {
166
- inputs: [{ name: 'city', type: 'string' }],
167
- outputs: [{ name: 'report', type: 'string' }],
58
+ command: 'echo {{inputs.city}}',
59
+ inputs: {
60
+ 'bad-name': { value: 'x' },
61
+ city: { type: 'made-up' as never },
62
+ kind: { type: 'enum' },
168
63
  },
64
+ outputs: { ok: 'bad' as never },
169
65
  }),
170
66
  );
171
67
  const msgs = errors.map((e) => e.message);
172
68
  expect(msgs.some((m) => /binding name "bad-name" is invalid/.test(m))).toBe(true);
173
- expect(msgs.some((m) => /duplicates strict ports\.inputs/.test(m))).toBe(true);
174
- expect(msgs.some((m) => /duplicates strict ports\.outputs/.test(m))).toBe(true);
69
+ expect(msgs.some((m) => /task\.inputs\.city\.type must be one of/.test(m))).toBe(true);
70
+ expect(msgs.some((m) => /task\.inputs\.kind\.enum must be a non-empty/.test(m))).toBe(true);
71
+ expect(msgs.some((m) => /task\.outputs\.ok must be an object/.test(m))).toBe(true);
175
72
  });
176
73
 
177
- test('fully-qualified binding sources must reference direct dependencies', () => {
74
+ test('command placeholders must reference task.inputs', () => {
75
+ const errors = errorsFor(commandTask({ id: 'a', command: 'echo {{inputs.missing}}' }));
76
+ expect(errors.some((e) => e.message.includes('references "{{inputs.missing}}"'))).toBe(true);
77
+ });
78
+
79
+ test('fully-qualified input sources must reference direct dependencies', () => {
178
80
  const errors = validateRaw(
179
- pipeline([
180
- commandTask({ id: 'up', outputs: { city: {} } }),
81
+ config([
82
+ commandTask({ id: 'up', command: 'echo ok', outputs: { city: {} } }),
181
83
  commandTask({
182
84
  id: 'down',
183
- depends_on: [],
184
- inputs: { city: { from: 't.up.outputs.city', required: true } },
85
+ command: 'echo {{inputs.city}}',
86
+ inputs: { city: { from: 't.up.outputs.city' } },
185
87
  }),
186
88
  ]),
187
89
  );
@@ -189,261 +91,46 @@ describe('validateRaw — lightweight task bindings', () => {
189
91
  });
190
92
  });
191
93
 
192
- // ─── Input/output separation (Command Tasks) ─────────────────────────
193
-
194
- describe('validateRaw input vs output constraints (command tasks)', () => {
195
- test('`required` on an output emits a warning (not an error)', () => {
196
- const ports: TaskPorts = {
197
- outputs: [{ name: 'x', type: 'string', required: true }],
198
- };
199
- const errors = errorsFor(commandTask({ id: 'a', ports }));
200
- const portErrs = portsErrors(errors);
201
- expect(portErrs.length).toBeGreaterThan(0);
202
- expect(portErrs[0]!.severity).toBe('warning');
203
- expect(portErrs[0]!.message).toMatch(/input-only/);
204
- });
205
-
206
- test('`from` on an output also warns', () => {
207
- const ports: TaskPorts = {
208
- outputs: [{ name: 'x', type: 'string', from: 'whatever' }],
209
- };
210
- const errors = errorsFor(commandTask({ id: 'a', ports }));
211
- const portErrs = portsErrors(errors);
212
- expect(portErrs[0]!.severity).toBe('warning');
213
- });
214
- });
215
-
216
- // ─── Prompt Tasks must not declare ports ────────────────────────────
217
-
218
- describe('validateRaw — prompt tasks reject declared ports', () => {
219
- test('declaring any ports on a prompt task is an error', () => {
220
- const errors = errorsFor(
221
- promptTask({
222
- id: 'a',
223
- ports: { inputs: [{ name: 'x', type: 'string' }] },
224
- }),
225
- );
226
- const msg = errors.find((e) => /do not declare ports/.test(e.message));
227
- expect(msg).toBeDefined();
228
- expect(msg!.path).toBe('tracks[0].tasks[0].ports');
229
- });
230
-
231
- test('empty ports object still triggers the error (design is "no ports field at all")', () => {
232
- // An empty `ports: {}` is a common state after the user deletes every
233
- // port without clearing the outer key — we still flag it so the editor
234
- // can offer a "remove ports field" fix-up.
235
- const errors = errorsFor(promptTask({ id: 'a', ports: {} }));
236
- expect(errors.some((e) => /do not declare ports/.test(e.message))).toBe(true);
237
- });
238
-
239
- test('command tasks with ports are unaffected', () => {
240
- const errors = errorsFor(
241
- commandTask({ id: 'a', ports: { outputs: [{ name: 'x', type: 'string' }] } }),
242
- );
243
- expect(errors.some((e) => /do not declare ports/.test(e.message))).toBe(false);
244
- });
245
- });
246
-
247
- // ─── {{inputs.X}} cross-check ────────────────────────────────────────
248
-
249
- describe('validateRaw — placeholder cross-check', () => {
250
- test('command task: reference to undeclared input is an error', () => {
251
- const errors = errorsFor(
252
- commandTask({
253
- id: 'a',
254
- command: 'echo {{inputs.oops}}',
255
- }),
94
+ describe('validateRaw prompt inferred bindings', () => {
95
+ test('prompt placeholders can reference direct upstream command outputs', () => {
96
+ const errors = validateRaw(
97
+ config([
98
+ commandTask({ id: 'up', command: 'echo ok', outputs: { city: { type: 'string' } } }),
99
+ promptTask({ id: 'p', depends_on: ['up'], prompt: 'city={{inputs.city}}' }),
100
+ ]),
256
101
  );
257
- expect(errors.some((e) => e.message.includes('references "{{inputs.oops}}"'))).toBe(true);
102
+ expect(errors.some((e) => e.message.includes('references "{{inputs.city}}"'))).toBe(false);
258
103
  });
259
104
 
260
- test('command task: declared-but-unreferenced input emits a warning', () => {
261
- const errors = errorsFor(
262
- commandTask({
263
- id: 'a',
264
- command: 'echo hi',
265
- ports: { inputs: [{ name: 'unused', type: 'string' }] },
266
- }),
267
- );
268
- const warnings = errors.filter(
269
- (e) => e.severity === 'warning' && /declared input is unused/.test(e.message),
105
+ test('two upstream command outputs with the same name are ambiguous for prompts', () => {
106
+ const errors = validateRaw(
107
+ config([
108
+ commandTask({ id: 'a', command: 'echo ok', outputs: { city: { type: 'string' } } }),
109
+ commandTask({ id: 'b', command: 'echo ok', outputs: { city: { type: 'string' } } }),
110
+ promptTask({ id: 'p', depends_on: ['a', 'b'], prompt: 'city={{inputs.city}}' }),
111
+ ]),
270
112
  );
271
- expect(warnings.length).toBe(1);
113
+ expect(errors.some((e) => /cannot disambiguate/.test(e.message))).toBe(true);
272
114
  });
273
115
 
274
- test('prompt task: {{inputs.X}} must reference an upstream Command output', () => {
275
- // Prompt `down` references `{{inputs.city}}` and `{{inputs.id}}`. The
276
- // upstream Command `up` exports `city` but not `id`, so only `id`
277
- // should be flagged.
278
- const config: RawPipelineConfig = {
279
- name: 'p',
280
- tracks: [
281
- {
282
- id: 't',
283
- name: 't',
284
- tasks: [
285
- {
286
- id: 'up',
287
- command: 'echo stub',
288
- ports: { outputs: [{ name: 'city', type: 'string' }] },
289
- },
290
- {
291
- id: 'down',
292
- depends_on: ['up'],
293
- prompt: 'city={{inputs.city}} id={{inputs.id}}',
294
- },
295
- ],
296
- },
297
- ],
298
- };
299
- const errors = validateRaw(config);
300
- const msgs = errors.map((e) => e.message);
301
- expect(msgs.some((m) => m.includes('references "{{inputs.id}}"'))).toBe(true);
302
- expect(msgs.some((m) => m.includes('references "{{inputs.city}}"'))).toBe(false);
303
- });
304
-
305
- test('prompt task: references without an upstream Command produce errors', () => {
306
- // Prompt with no upstream Command at all — every reference is
307
- // unresolvable because there's nothing to infer inputs from.
308
- const errors = errorsFor(
309
- promptTask({
310
- id: 'a',
311
- prompt: 'hi {{inputs.missing}}',
312
- }),
116
+ test('downstream commands with incompatible typed inputs conflict for prompt outputs', () => {
117
+ const errors = validateRaw(
118
+ config([
119
+ promptTask({ id: 'p', prompt: 'make date' }),
120
+ commandTask({
121
+ id: 'a',
122
+ depends_on: ['p'],
123
+ command: 'echo {{inputs.date}}',
124
+ inputs: { date: { type: 'string' } },
125
+ }),
126
+ commandTask({
127
+ id: 'b',
128
+ depends_on: ['p'],
129
+ command: 'echo {{inputs.date}}',
130
+ inputs: { date: { type: 'number' } },
131
+ }),
132
+ ]),
313
133
  );
314
- expect(errors.some((e) => e.message.includes('references "{{inputs.missing}}"'))).toBe(true);
315
- });
316
-
317
- test('prompt task: upstream Prompt neighbor contributes nothing (free-text only)', () => {
318
- // `up` is a Prompt (not Command) — its declared ports would be an
319
- // error anyway, but even if the user somehow declared outputs on it,
320
- // a downstream Prompt cannot reference them via {{inputs.X}}.
321
- const config: RawPipelineConfig = {
322
- name: 'p',
323
- tracks: [
324
- {
325
- id: 't',
326
- name: 't',
327
- tasks: [
328
- { id: 'up', prompt: 'pick a city' },
329
- {
330
- id: 'down',
331
- depends_on: ['up'],
332
- prompt: 'greet {{inputs.city}}',
333
- },
334
- ],
335
- },
336
- ],
337
- };
338
- const errors = validateRaw(config);
339
- expect(errors.some((e) => e.message.includes('references "{{inputs.city}}"'))).toBe(true);
340
- });
341
- });
342
-
343
- // ─── Inferred-port conflict detection (Prompt Tasks) ─────────────────
344
-
345
- describe('validateRaw — prompt inferred-port conflicts', () => {
346
- test('two upstream Commands exporting the same name → error', () => {
347
- const config: RawPipelineConfig = {
348
- name: 'p',
349
- tracks: [
350
- {
351
- id: 't',
352
- name: 't',
353
- tasks: [
354
- {
355
- id: 'a',
356
- command: 'echo a',
357
- ports: { outputs: [{ name: 'city', type: 'string' }] },
358
- },
359
- {
360
- id: 'b',
361
- command: 'echo b',
362
- ports: { outputs: [{ name: 'city', type: 'string' }] },
363
- },
364
- {
365
- id: 'down',
366
- depends_on: ['a', 'b'],
367
- prompt: 'city={{inputs.city}}',
368
- },
369
- ],
370
- },
371
- ],
372
- };
373
- const errors = validateRaw(config);
374
- expect(
375
- errors.some(
376
- (e) =>
377
- /cannot disambiguate/.test(e.message) &&
378
- e.message.includes('t.a') &&
379
- e.message.includes('t.b'),
380
- ),
381
- ).toBe(true);
382
- });
383
-
384
- test('two downstream Commands with incompatible input types → error', () => {
385
- const config: RawPipelineConfig = {
386
- name: 'p',
387
- tracks: [
388
- {
389
- id: 't',
390
- name: 't',
391
- tasks: [
392
- { id: 'middle', prompt: 'produce date' },
393
- {
394
- id: 'd1',
395
- depends_on: ['middle'],
396
- command: 'echo {{inputs.date}}',
397
- ports: { inputs: [{ name: 'date', type: 'string', required: true }] },
398
- },
399
- {
400
- id: 'd2',
401
- depends_on: ['middle'],
402
- command: 'echo {{inputs.date}}',
403
- ports: { inputs: [{ name: 'date', type: 'number', required: true }] },
404
- },
405
- ],
406
- },
407
- ],
408
- };
409
- const errors = validateRaw(config);
410
- expect(
411
- errors.some(
412
- (e) =>
413
- /disagree on the shape of inferred output "date"/.test(e.message) &&
414
- e.path === 'tracks[0].tasks[0]',
415
- ),
416
- ).toBe(true);
417
- });
418
-
419
- test('two downstream Commands with matching input types → no conflict', () => {
420
- const config: RawPipelineConfig = {
421
- name: 'p',
422
- tracks: [
423
- {
424
- id: 't',
425
- name: 't',
426
- tasks: [
427
- { id: 'middle', prompt: 'produce date' },
428
- {
429
- id: 'd1',
430
- depends_on: ['middle'],
431
- command: 'echo {{inputs.date}}',
432
- ports: { inputs: [{ name: 'date', type: 'string', required: true }] },
433
- },
434
- {
435
- id: 'd2',
436
- depends_on: ['middle'],
437
- command: 'echo {{inputs.date}}',
438
- ports: { inputs: [{ name: 'date', type: 'string', required: false }] },
439
- },
440
- ],
441
- },
442
- ],
443
- };
444
- const errors = validateRaw(config);
445
- // Error list should contain no "disagree on the shape" entry — the
446
- // two inputs agree on type and (no enum), so they merge.
447
- expect(errors.some((e) => /disagree on the shape/.test(e.message))).toBe(false);
134
+ expect(errors.some((e) => /disagree on the shape of inferred output "date"/.test(e.message))).toBe(true);
448
135
  });
449
136
  });