@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.
- package/README.md +84 -44
- package/dist/bootstrap.d.ts +20 -0
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/bootstrap.js +21 -11
- package/dist/bootstrap.js.map +1 -1
- 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/run-context.d.ts +3 -0
- package/dist/core/run-context.d.ts.map +1 -1
- package/dist/core/run-context.js +2 -0
- package/dist/core/run-context.js.map +1 -1
- package/dist/core/task-executor.d.ts.map +1 -1
- package/dist/core/task-executor.js +46 -84
- package/dist/core/task-executor.js.map +1 -1
- package/dist/engine.d.ts +6 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +3 -0
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/plugins.d.ts +2 -2
- package/dist/plugins.d.ts.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/registry.d.ts +10 -4
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +64 -25
- package/dist/registry.js.map +1 -1
- package/dist/runtime.d.ts +9 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +8 -0
- package/dist/runtime.js.map +1 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +1 -7
- package/dist/schema.js.map +1 -1
- package/dist/tagma.d.ts +11 -1
- package/dist/tagma.d.ts.map +1 -1
- package/dist/tagma.js +6 -0
- package/dist/tagma.js.map +1 -1
- package/dist/validate-raw.d.ts +4 -4
- package/dist/validate-raw.d.ts.map +1 -1
- package/dist/validate-raw.js +89 -230
- package/dist/validate-raw.js.map +1 -1
- package/package.json +2 -2
- package/src/bootstrap.ts +23 -14
- package/src/core/dataflow.test.ts +8 -9
- package/src/core/dataflow.ts +57 -14
- package/src/core/run-context.test.ts +12 -0
- package/src/core/run-context.ts +4 -0
- package/src/core/task-executor.ts +75 -135
- package/src/engine-ports-mixed.test.ts +68 -411
- package/src/engine-ports.test.ts +37 -341
- package/src/engine.ts +8 -0
- package/src/index.ts +5 -0
- package/src/pipeline-runner.test.ts +5 -9
- package/src/plugin-registry.test.ts +138 -1
- package/src/plugins.ts +5 -2
- package/src/ports.test.ts +80 -0
- package/src/ports.ts +36 -4
- package/src/registry.ts +81 -26
- package/src/runtime.ts +20 -0
- package/src/schema-ports.test.ts +47 -197
- package/src/schema.ts +1 -7
- package/src/tagma.test.ts +72 -1
- package/src/tagma.ts +16 -1
- package/src/validate-raw-ports.test.ts +80 -393
- 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
|
|
6
|
+
return { command: 'echo {{inputs.city}}', ...overrides };
|
|
15
7
|
}
|
|
16
8
|
|
|
17
9
|
function promptTask(overrides: Partial<RawTaskConfig> & { id: string }): RawTaskConfig {
|
|
18
|
-
return { prompt: '
|
|
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
|
|
27
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
140
|
-
inputs: { city: { value: 'Shanghai' } },
|
|
29
|
+
ports: { inputs: [{ name: 'city', type: 'string' }] },
|
|
141
30
|
}),
|
|
142
31
|
);
|
|
143
|
-
expect(errors.some((e) => e.
|
|
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('
|
|
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: '
|
|
151
|
-
outputs: {
|
|
47
|
+
inputs: { city: { type: 'string', required: true } },
|
|
48
|
+
outputs: { temp: { type: 'number' } },
|
|
152
49
|
}),
|
|
153
50
|
);
|
|
154
|
-
|
|
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
|
|
54
|
+
test('rejects invalid binding maps, names, type, and enum shape', () => {
|
|
160
55
|
const errors = errorsFor(
|
|
161
56
|
commandTask({
|
|
162
57
|
id: 'a',
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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) => /
|
|
174
|
-
expect(msgs.some((m) => /
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
inputs: { city: { from: 't.up.outputs.city'
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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.
|
|
102
|
+
expect(errors.some((e) => e.message.includes('references "{{inputs.city}}"'))).toBe(false);
|
|
258
103
|
});
|
|
259
104
|
|
|
260
|
-
test('command
|
|
261
|
-
const errors =
|
|
262
|
-
|
|
263
|
-
id: 'a',
|
|
264
|
-
command: 'echo
|
|
265
|
-
|
|
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(
|
|
113
|
+
expect(errors.some((e) => /cannot disambiguate/.test(e.message))).toBe(true);
|
|
272
114
|
});
|
|
273
115
|
|
|
274
|
-
test('
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
|
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
|
});
|