@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/dist/core/dataflow.d.ts.map +1 -1
- package/dist/core/dataflow.js +45 -9
- package/dist/core/dataflow.js.map +1 -1
- package/dist/core/task-executor.d.ts.map +1 -1
- package/dist/core/task-executor.js +35 -51
- package/dist/core/task-executor.js.map +1 -1
- package/dist/ports.d.ts +4 -0
- package/dist/ports.d.ts.map +1 -1
- package/dist/ports.js +27 -4
- package/dist/ports.js.map +1 -1
- package/dist/validate-raw.d.ts +4 -4
- package/dist/validate-raw.js +91 -132
- package/dist/validate-raw.js.map +1 -1
- package/package.json +2 -2
- package/src/core/dataflow.test.ts +8 -9
- package/src/core/dataflow.ts +57 -14
- package/src/core/task-executor.ts +61 -95
- package/src/engine-ports-mixed.test.ts +68 -411
- package/src/engine-ports.test.ts +37 -341
- package/src/pipeline-runner.test.ts +5 -9
- package/src/ports.test.ts +80 -0
- package/src/ports.ts +36 -4
- package/src/schema-ports.test.ts +41 -214
- package/src/validate-raw-ports.test.ts +80 -393
- package/src/validate-raw.ts +93 -137
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
package/src/schema-ports.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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('
|
|
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: {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
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('
|
|
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
|
-
|
|
211
|
-
|
|
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]!.
|
|
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
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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]!.
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
});
|