@tagma/sdk 0.6.3 → 0.6.5
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 +8 -5
- package/dist/dag.test.d.ts +2 -0
- package/dist/dag.test.d.ts.map +1 -0
- package/dist/dag.test.js +42 -0
- package/dist/dag.test.js.map +1 -0
- package/dist/engine-ports.test.d.ts +2 -0
- package/dist/engine-ports.test.d.ts.map +1 -0
- package/dist/engine-ports.test.js +378 -0
- package/dist/engine-ports.test.js.map +1 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +194 -21
- package/dist/engine.js.map +1 -1
- package/dist/pipeline-runner.d.ts.map +1 -1
- package/dist/pipeline-runner.js +3 -0
- package/dist/pipeline-runner.js.map +1 -1
- package/dist/ports.d.ts +118 -0
- package/dist/ports.d.ts.map +1 -0
- package/dist/ports.js +365 -0
- package/dist/ports.js.map +1 -0
- package/dist/ports.test.d.ts +2 -0
- package/dist/ports.test.d.ts.map +1 -0
- package/dist/ports.test.js +262 -0
- package/dist/ports.test.js.map +1 -0
- package/dist/prompt-doc.d.ts +35 -1
- package/dist/prompt-doc.d.ts.map +1 -1
- package/dist/prompt-doc.js +110 -0
- package/dist/prompt-doc.js.map +1 -1
- package/dist/prompt-doc.test.d.ts +2 -0
- package/dist/prompt-doc.test.d.ts.map +1 -0
- package/dist/prompt-doc.test.js +145 -0
- package/dist/prompt-doc.test.js.map +1 -0
- package/dist/runner.d.ts +17 -0
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +171 -8
- package/dist/runner.js.map +1 -1
- package/dist/runner.test.d.ts +2 -0
- package/dist/runner.test.d.ts.map +1 -0
- package/dist/runner.test.js +119 -0
- package/dist/runner.test.js.map +1 -0
- package/dist/schema-ports.test.d.ts +2 -0
- package/dist/schema-ports.test.d.ts.map +1 -0
- package/dist/schema-ports.test.js +219 -0
- package/dist/schema-ports.test.js.map +1 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +8 -0
- package/dist/schema.js.map +1 -1
- package/dist/sdk.d.ts +3 -1
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +5 -1
- package/dist/sdk.js.map +1 -1
- package/dist/validate-raw-ports.test.d.ts +2 -0
- package/dist/validate-raw-ports.test.d.ts.map +1 -0
- package/dist/validate-raw-ports.test.js +157 -0
- package/dist/validate-raw-ports.test.js.map +1 -0
- package/dist/validate-raw.d.ts.map +1 -1
- package/dist/validate-raw.js +141 -0
- package/dist/validate-raw.js.map +1 -1
- package/package.json +2 -7
- package/src/dag.test.ts +56 -0
- package/src/engine-ports.test.ts +404 -0
- package/src/engine.ts +231 -24
- package/src/pipeline-runner.ts +3 -0
- package/src/ports.test.ts +301 -0
- package/src/ports.ts +442 -0
- package/src/prompt-doc.test.ts +174 -0
- package/src/prompt-doc.ts +121 -1
- package/src/runner.test.ts +142 -0
- package/src/runner.ts +198 -8
- package/src/schema-ports.test.ts +236 -0
- package/src/schema.ts +8 -0
- package/src/sdk.ts +14 -0
- package/src/validate-raw-ports.test.ts +198 -0
- package/src/validate-raw.ts +155 -1
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import yaml from 'js-yaml';
|
|
3
|
+
import type { PipelineConfig, RawPipelineConfig } from './types';
|
|
4
|
+
import {
|
|
5
|
+
deresolvePipeline,
|
|
6
|
+
parseYaml,
|
|
7
|
+
resolveConfig,
|
|
8
|
+
serializePipeline,
|
|
9
|
+
} from './schema';
|
|
10
|
+
|
|
11
|
+
const WORK_DIR = process.platform === 'win32' ? 'D:\\fake-work' : '/fake-work';
|
|
12
|
+
|
|
13
|
+
// ─── resolveConfig preserves ports ───────────────────────────────────
|
|
14
|
+
|
|
15
|
+
describe('resolveConfig — ports passthrough', () => {
|
|
16
|
+
test('raw ports survive onto the resolved task', () => {
|
|
17
|
+
const raw: RawPipelineConfig = {
|
|
18
|
+
name: 'p',
|
|
19
|
+
tracks: [
|
|
20
|
+
{
|
|
21
|
+
id: 't',
|
|
22
|
+
name: 'T',
|
|
23
|
+
tasks: [
|
|
24
|
+
{
|
|
25
|
+
id: 'a',
|
|
26
|
+
prompt: 'do it',
|
|
27
|
+
ports: {
|
|
28
|
+
inputs: [{ name: 'city', type: 'string', required: true }],
|
|
29
|
+
outputs: [{ name: 'temp', type: 'number', description: 'Celsius' }],
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
const resolved = resolveConfig(raw, WORK_DIR);
|
|
37
|
+
const task = resolved.tracks[0]!.tasks[0]!;
|
|
38
|
+
expect(task.ports).toBeDefined();
|
|
39
|
+
expect(task.ports!.inputs).toEqual([
|
|
40
|
+
{ name: 'city', type: 'string', required: true },
|
|
41
|
+
]);
|
|
42
|
+
expect(task.ports!.outputs).toEqual([
|
|
43
|
+
{ name: 'temp', type: 'number', description: 'Celsius' },
|
|
44
|
+
]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('tasks without ports still resolve with ports === undefined', () => {
|
|
48
|
+
const raw: RawPipelineConfig = {
|
|
49
|
+
name: 'p',
|
|
50
|
+
tracks: [
|
|
51
|
+
{ id: 't', name: 'T', tasks: [{ id: 'a', prompt: 'do it' }] },
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
const resolved = resolveConfig(raw, WORK_DIR);
|
|
55
|
+
expect(resolved.tracks[0]!.tasks[0]!.ports).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('ports is not inherited from track or pipeline', () => {
|
|
59
|
+
// Ports describe a per-task I/O contract. If we accidentally pulled
|
|
60
|
+
// them from track defaults, two tasks in the same track would share
|
|
61
|
+
// input ports and downstream data-flow would be ambiguous. Test that
|
|
62
|
+
// a track with an unrelated `middlewares` default doesn't spread
|
|
63
|
+
// anywhere unexpected — purely a regression guard for the no-inherit
|
|
64
|
+
// invariant.
|
|
65
|
+
const raw: RawPipelineConfig = {
|
|
66
|
+
name: 'p',
|
|
67
|
+
tracks: [
|
|
68
|
+
{
|
|
69
|
+
id: 't',
|
|
70
|
+
name: 'T',
|
|
71
|
+
middlewares: [{ type: 'static_context', file: './x' }],
|
|
72
|
+
tasks: [{ id: 'a', prompt: 'x' }, { id: 'b', prompt: 'y' }],
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
};
|
|
76
|
+
const resolved = resolveConfig(raw, WORK_DIR);
|
|
77
|
+
for (const task of resolved.tracks[0]!.tasks) {
|
|
78
|
+
expect(task.ports).toBeUndefined();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ─── deresolvePipeline preserves ports ───────────────────────────────
|
|
84
|
+
|
|
85
|
+
describe('deresolvePipeline — ports round-trip', () => {
|
|
86
|
+
test('ports with both inputs and outputs round-trip', () => {
|
|
87
|
+
const raw: RawPipelineConfig = {
|
|
88
|
+
name: 'p',
|
|
89
|
+
tracks: [
|
|
90
|
+
{
|
|
91
|
+
id: 't',
|
|
92
|
+
name: 'T',
|
|
93
|
+
tasks: [
|
|
94
|
+
{
|
|
95
|
+
id: 'a',
|
|
96
|
+
prompt: 'hi',
|
|
97
|
+
ports: {
|
|
98
|
+
inputs: [{ name: 'city', type: 'string', required: true }],
|
|
99
|
+
outputs: [{ name: 'temp', type: 'number' }],
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
};
|
|
106
|
+
const resolved = resolveConfig(raw, WORK_DIR);
|
|
107
|
+
const back = deresolvePipeline(resolved, WORK_DIR);
|
|
108
|
+
expect(back.tracks[0]!.tasks[0]!.ports).toEqual(raw.tracks[0]!.tasks[0]!.ports!);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('ports with only outputs round-trip', () => {
|
|
112
|
+
const raw: RawPipelineConfig = {
|
|
113
|
+
name: 'p',
|
|
114
|
+
tracks: [
|
|
115
|
+
{
|
|
116
|
+
id: 't',
|
|
117
|
+
name: 'T',
|
|
118
|
+
tasks: [
|
|
119
|
+
{
|
|
120
|
+
id: 'a',
|
|
121
|
+
command: 'echo hi',
|
|
122
|
+
ports: { outputs: [{ name: 'x', type: 'string' }] },
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
};
|
|
128
|
+
const resolved = resolveConfig(raw, WORK_DIR);
|
|
129
|
+
const back = deresolvePipeline(resolved, WORK_DIR);
|
|
130
|
+
expect(back.tracks[0]!.tasks[0]!.ports).toEqual({
|
|
131
|
+
outputs: [{ name: 'x', type: 'string' }],
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('empty ports ({}) is dropped on deresolve', () => {
|
|
136
|
+
// YAML round-trip prefers field absence over `ports: {}` so a task
|
|
137
|
+
// that once declared a port but had it cleared in the editor
|
|
138
|
+
// doesn't persist a useless empty object in the file.
|
|
139
|
+
const resolved: PipelineConfig = {
|
|
140
|
+
name: 'p',
|
|
141
|
+
tracks: [
|
|
142
|
+
{
|
|
143
|
+
id: 't',
|
|
144
|
+
name: 'T',
|
|
145
|
+
driver: 'opencode',
|
|
146
|
+
permissions: { read: true, write: false, execute: false },
|
|
147
|
+
on_failure: 'skip_downstream',
|
|
148
|
+
tasks: [
|
|
149
|
+
{
|
|
150
|
+
id: 'a',
|
|
151
|
+
name: 'a',
|
|
152
|
+
prompt: 'hi',
|
|
153
|
+
permissions: { read: true, write: false, execute: false },
|
|
154
|
+
driver: 'opencode',
|
|
155
|
+
ports: {},
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
};
|
|
161
|
+
const back = deresolvePipeline(resolved, WORK_DIR);
|
|
162
|
+
expect(back.tracks[0]!.tasks[0]!.ports).toBeUndefined();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('YAML round-trip via serializePipeline preserves the full ports shape', () => {
|
|
166
|
+
const raw: RawPipelineConfig = {
|
|
167
|
+
name: 'p',
|
|
168
|
+
tracks: [
|
|
169
|
+
{
|
|
170
|
+
id: 't',
|
|
171
|
+
name: 'T',
|
|
172
|
+
tasks: [
|
|
173
|
+
{
|
|
174
|
+
id: 'classify',
|
|
175
|
+
prompt: 'pick a bucket',
|
|
176
|
+
ports: {
|
|
177
|
+
inputs: [
|
|
178
|
+
{ name: 'doc', type: 'string', required: true, description: 'Full text' },
|
|
179
|
+
],
|
|
180
|
+
outputs: [
|
|
181
|
+
{
|
|
182
|
+
name: 'bucket',
|
|
183
|
+
type: 'enum',
|
|
184
|
+
enum: ['spam', 'ham'],
|
|
185
|
+
description: 'Classification',
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
};
|
|
194
|
+
const yamlText = serializePipeline(raw);
|
|
195
|
+
const parsed = (yaml.load(yamlText) as { pipeline: RawPipelineConfig }).pipeline;
|
|
196
|
+
expect(parsed.tracks[0]!.tasks[0]!.ports).toEqual(raw.tracks[0]!.tasks[0]!.ports!);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ─── parseYaml accepts ports ─────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
describe('parseYaml — accepts ports declarations', () => {
|
|
203
|
+
test('real-world YAML with ports parses cleanly', () => {
|
|
204
|
+
const text = `pipeline:
|
|
205
|
+
name: demo
|
|
206
|
+
tracks:
|
|
207
|
+
- id: t
|
|
208
|
+
name: Main
|
|
209
|
+
tasks:
|
|
210
|
+
- id: plan
|
|
211
|
+
prompt: Pick a city and id
|
|
212
|
+
ports:
|
|
213
|
+
outputs:
|
|
214
|
+
- name: city
|
|
215
|
+
type: string
|
|
216
|
+
description: Target city
|
|
217
|
+
- name: id
|
|
218
|
+
type: number
|
|
219
|
+
- id: fetch
|
|
220
|
+
depends_on: [plan]
|
|
221
|
+
command: 'weather.sh --city "{{inputs.city}}" --id {{inputs.id}}'
|
|
222
|
+
ports:
|
|
223
|
+
inputs:
|
|
224
|
+
- { name: city, type: string, required: true }
|
|
225
|
+
- { name: id, type: number, required: true }
|
|
226
|
+
outputs:
|
|
227
|
+
- { name: temp, type: number }
|
|
228
|
+
`;
|
|
229
|
+
const config = parseYaml(text);
|
|
230
|
+
const plan = config.tracks[0]!.tasks[0]!;
|
|
231
|
+
const fetch = config.tracks[0]!.tasks[1]!;
|
|
232
|
+
expect(plan.ports!.outputs!.map((p) => p.name)).toEqual(['city', 'id']);
|
|
233
|
+
expect(fetch.ports!.inputs!.map((p) => p.name)).toEqual(['city', 'id']);
|
|
234
|
+
expect(fetch.ports!.outputs!.map((p) => p.name)).toEqual(['temp']);
|
|
235
|
+
});
|
|
236
|
+
});
|
package/src/schema.ts
CHANGED
|
@@ -148,6 +148,9 @@ export function resolveConfig(raw: RawPipelineConfig, workDir: string): Pipeline
|
|
|
148
148
|
completion: rawTask.completion,
|
|
149
149
|
agent_profile: rawTask.agent_profile ?? rawTrack.agent_profile,
|
|
150
150
|
cwd: rawTask.cwd ? validatePath(rawTask.cwd, workDir) : trackCwd,
|
|
151
|
+
// Ports: no inheritance — they describe per-task I/O contract, not
|
|
152
|
+
// cross-task defaults. Passed through as-is (including `undefined`).
|
|
153
|
+
ports: rawTask.ports,
|
|
151
154
|
};
|
|
152
155
|
});
|
|
153
156
|
|
|
@@ -274,6 +277,11 @@ export function deresolvePipeline(config: PipelineConfig, workDir: string): RawP
|
|
|
274
277
|
...(task.permissions && !permissionsEqual(task.permissions, track.permissions)
|
|
275
278
|
? { permissions: task.permissions }
|
|
276
279
|
: {}),
|
|
280
|
+
...(task.ports &&
|
|
281
|
+
((task.ports.inputs && task.ports.inputs.length > 0) ||
|
|
282
|
+
(task.ports.outputs && task.ports.outputs.length > 0))
|
|
283
|
+
? { ports: task.ports }
|
|
284
|
+
: {}),
|
|
277
285
|
};
|
|
278
286
|
});
|
|
279
287
|
|
package/src/sdk.ts
CHANGED
|
@@ -114,7 +114,21 @@ export {
|
|
|
114
114
|
promptDocumentFromString,
|
|
115
115
|
serializePromptDocument,
|
|
116
116
|
appendContext,
|
|
117
|
+
prependContext,
|
|
118
|
+
renderInputsBlock,
|
|
119
|
+
renderOutputSchemaBlock,
|
|
117
120
|
} from './prompt-doc';
|
|
118
121
|
|
|
122
|
+
// ── Task ports (editor: substitute placeholders, resolve upstream
|
|
123
|
+
// values, extract downstream outputs; drivers that wrap the prompt
|
|
124
|
+
// may want substituteInputs on their own envelope) ──
|
|
125
|
+
export {
|
|
126
|
+
substituteInputs,
|
|
127
|
+
extractInputReferences,
|
|
128
|
+
resolveTaskInputs,
|
|
129
|
+
extractTaskOutputs,
|
|
130
|
+
} from './ports';
|
|
131
|
+
export type { SubstituteResult, InputResolution, ExtractResult } from './ports';
|
|
132
|
+
|
|
119
133
|
// ── All types from @tagma/types + runtime constants ──
|
|
120
134
|
export * from './types';
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { validateRaw } from './validate-raw';
|
|
3
|
+
import type { RawPipelineConfig, RawTaskConfig, RawTrackConfig, TaskPorts } from './types';
|
|
4
|
+
|
|
5
|
+
function task(overrides: Partial<RawTaskConfig> & { id: string }): RawTaskConfig {
|
|
6
|
+
return { prompt: 'do a thing', ...overrides };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function pipeline(tasks: RawTaskConfig[]): RawPipelineConfig {
|
|
10
|
+
const track: RawTrackConfig = { id: 't', name: 't', tasks };
|
|
11
|
+
return { name: 'test', tracks: [track] };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function errorsFor(taskConfig: RawTaskConfig): ReturnType<typeof validateRaw> {
|
|
15
|
+
return validateRaw(pipeline([taskConfig]));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Return only the errors whose path points inside the given task's
|
|
20
|
+
* `.ports` subtree, so assertions don't pick up unrelated cycle or
|
|
21
|
+
* name-validation errors that the rest of validate-raw emits.
|
|
22
|
+
*/
|
|
23
|
+
function portsErrors(errors: ReturnType<typeof validateRaw>): typeof errors {
|
|
24
|
+
return errors.filter(
|
|
25
|
+
(e) => e.path.includes('.ports.') || e.path.includes('.ports[') || /\.ports$/.test(e.path),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ─── Structural validation ───────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
describe('validateRaw — port structure', () => {
|
|
32
|
+
test('empty ports object is accepted (no-op)', () => {
|
|
33
|
+
const errors = errorsFor(task({ id: 'a', ports: {} }));
|
|
34
|
+
expect(portsErrors(errors)).toEqual([]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('rejects non-array ports.inputs', () => {
|
|
38
|
+
const ports = { inputs: 'not-an-array' as unknown as [] } as TaskPorts;
|
|
39
|
+
const errors = errorsFor(task({ id: 'a', ports }));
|
|
40
|
+
const e = portsErrors(errors);
|
|
41
|
+
expect(e.length).toBeGreaterThan(0);
|
|
42
|
+
expect(e[0]!.message).toMatch(/must be an array/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('rejects non-object port entry', () => {
|
|
46
|
+
const ports = { inputs: ['not-an-object' as unknown as never] } as TaskPorts;
|
|
47
|
+
const errors = errorsFor(task({ id: 'a', ports }));
|
|
48
|
+
expect(portsErrors(errors).some((e) => /must be an object/.test(e.message))).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('requires port.name to be a non-empty string', () => {
|
|
52
|
+
const ports: TaskPorts = { inputs: [{ name: '', type: 'string' }] };
|
|
53
|
+
const errors = errorsFor(task({ id: 'a', ports }));
|
|
54
|
+
expect(portsErrors(errors).some((e) => /port\.name is required/.test(e.message))).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('rejects invalid port name characters', () => {
|
|
58
|
+
const ports: TaskPorts = {
|
|
59
|
+
inputs: [
|
|
60
|
+
{ name: 'has-hyphen', type: 'string' },
|
|
61
|
+
{ name: '1starts-with-digit', type: 'string' },
|
|
62
|
+
{ name: 'has.dot', type: 'string' },
|
|
63
|
+
],
|
|
64
|
+
};
|
|
65
|
+
const errors = errorsFor(task({ id: 'a', ports }));
|
|
66
|
+
const msgs = portsErrors(errors).map((e) => e.message);
|
|
67
|
+
expect(msgs.filter((m) => /port name .* is invalid/.test(m)).length).toBe(3);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('flags duplicate port names within the same list', () => {
|
|
71
|
+
const ports: TaskPorts = {
|
|
72
|
+
inputs: [
|
|
73
|
+
{ name: 'x', type: 'string' },
|
|
74
|
+
{ name: 'x', type: 'number' },
|
|
75
|
+
],
|
|
76
|
+
};
|
|
77
|
+
const errors = errorsFor(task({ id: 'a', ports }));
|
|
78
|
+
expect(portsErrors(errors).some((e) => /Duplicate ports\.inputs name/.test(e.message))).toBe(
|
|
79
|
+
true,
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('rejects unknown port type', () => {
|
|
84
|
+
const ports = { inputs: [{ name: 'x', type: 'made-up' as never }] } as TaskPorts;
|
|
85
|
+
const errors = errorsFor(task({ id: 'a', ports }));
|
|
86
|
+
expect(portsErrors(errors).some((e) => /type must be one of/.test(e.message))).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('enum port requires a non-empty enum array', () => {
|
|
90
|
+
const ports: TaskPorts = { inputs: [{ name: 'x', type: 'enum' }] };
|
|
91
|
+
const errors = errorsFor(task({ id: 'a', ports }));
|
|
92
|
+
expect(portsErrors(errors).some((e) => /non-empty "enum"/.test(e.message))).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('enum values must all be strings', () => {
|
|
96
|
+
const ports = {
|
|
97
|
+
inputs: [{ name: 'x', type: 'enum' as const, enum: ['a', 1 as unknown as string] }],
|
|
98
|
+
} as TaskPorts;
|
|
99
|
+
const errors = errorsFor(task({ id: 'a', ports }));
|
|
100
|
+
expect(portsErrors(errors).some((e) => /enum values must all be strings/.test(e.message))).toBe(
|
|
101
|
+
true,
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('`from` must be a string', () => {
|
|
106
|
+
const ports = {
|
|
107
|
+
inputs: [
|
|
108
|
+
{ name: 'x', type: 'string' as const, from: 42 as unknown as string },
|
|
109
|
+
],
|
|
110
|
+
} as TaskPorts;
|
|
111
|
+
const errors = errorsFor(task({ id: 'a', ports }));
|
|
112
|
+
expect(portsErrors(errors).some((e) => /"from" must be a string/.test(e.message))).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ─── Input/output separation ─────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
describe('validateRaw — input vs output constraints', () => {
|
|
119
|
+
test('`required` on an output emits a warning (not an error)', () => {
|
|
120
|
+
const ports: TaskPorts = {
|
|
121
|
+
outputs: [{ name: 'x', type: 'string', required: true }],
|
|
122
|
+
};
|
|
123
|
+
const errors = errorsFor(task({ id: 'a', ports, prompt: 'x' }));
|
|
124
|
+
const portErrs = portsErrors(errors);
|
|
125
|
+
expect(portErrs.length).toBeGreaterThan(0);
|
|
126
|
+
expect(portErrs[0]!.severity).toBe('warning');
|
|
127
|
+
expect(portErrs[0]!.message).toMatch(/input-only/);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('`from` on an output also warns', () => {
|
|
131
|
+
const ports: TaskPorts = {
|
|
132
|
+
outputs: [{ name: 'x', type: 'string', from: 'whatever' }],
|
|
133
|
+
};
|
|
134
|
+
const errors = errorsFor(task({ id: 'a', ports }));
|
|
135
|
+
const portErrs = portsErrors(errors);
|
|
136
|
+
expect(portErrs[0]!.severity).toBe('warning');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ─── {{inputs.X}} cross-check ────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
describe('validateRaw — placeholder cross-check', () => {
|
|
143
|
+
test('references to undeclared inputs in prompt are errors', () => {
|
|
144
|
+
const errors = errorsFor(
|
|
145
|
+
task({
|
|
146
|
+
id: 'a',
|
|
147
|
+
prompt: 'city={{inputs.city}} id={{inputs.id}}',
|
|
148
|
+
ports: { inputs: [{ name: 'city', type: 'string' }] },
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
const msgs = errors.map((e) => e.message);
|
|
152
|
+
expect(msgs.some((m) => m.includes('references "{{inputs.id}}"'))).toBe(true);
|
|
153
|
+
expect(msgs.some((m) => m.includes('references "{{inputs.city}}"'))).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('references to undeclared inputs in command are errors', () => {
|
|
157
|
+
const errors = errorsFor(
|
|
158
|
+
task({
|
|
159
|
+
id: 'a',
|
|
160
|
+
prompt: undefined,
|
|
161
|
+
command: 'echo {{inputs.oops}}',
|
|
162
|
+
}),
|
|
163
|
+
);
|
|
164
|
+
expect(errors.some((e) => e.message.includes('references "{{inputs.oops}}"'))).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('declared inputs with no references emit a warning for command tasks', () => {
|
|
168
|
+
const errors = errorsFor(
|
|
169
|
+
task({
|
|
170
|
+
id: 'a',
|
|
171
|
+
prompt: undefined,
|
|
172
|
+
command: 'echo hi',
|
|
173
|
+
ports: { inputs: [{ name: 'unused', type: 'string' }] },
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
176
|
+
const warnings = errors.filter(
|
|
177
|
+
(e) => e.severity === 'warning' && /declared input is unused/.test(e.message),
|
|
178
|
+
);
|
|
179
|
+
expect(warnings.length).toBe(1);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('declared inputs with no references do NOT warn for prompt tasks', () => {
|
|
183
|
+
// Prompt tasks consume inputs through the auto-injected [Inputs]
|
|
184
|
+
// context block, so "unused" is a false alarm for them. Only command
|
|
185
|
+
// tasks should see the unused-input warning.
|
|
186
|
+
const errors = errorsFor(
|
|
187
|
+
task({
|
|
188
|
+
id: 'a',
|
|
189
|
+
prompt: 'do the thing',
|
|
190
|
+
ports: { inputs: [{ name: 'context', type: 'string' }] },
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
const warnings = errors.filter(
|
|
194
|
+
(e) => e.severity === 'warning' && /declared input is unused/.test(e.message),
|
|
195
|
+
);
|
|
196
|
+
expect(warnings.length).toBe(0);
|
|
197
|
+
});
|
|
198
|
+
});
|
package/src/validate-raw.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Returns a flat list of ValidationError objects. An empty array means valid.
|
|
8
8
|
|
|
9
|
-
import type { RawPipelineConfig } from './types';
|
|
9
|
+
import type { PortDef, PortType, RawPipelineConfig, RawTaskConfig } from './types';
|
|
10
10
|
import {
|
|
11
11
|
isValidTaskId,
|
|
12
12
|
qualifyTaskId,
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
resolveTaskRef,
|
|
15
15
|
type TaskIndex,
|
|
16
16
|
} from './task-ref';
|
|
17
|
+
import { extractInputReferences } from './ports';
|
|
17
18
|
|
|
18
19
|
const DURATION_RE = /^(\d*\.?\d+)\s*(s|m|h|d)$/;
|
|
19
20
|
function isValidDuration(input: string): boolean {
|
|
@@ -284,6 +285,9 @@ export function validateRaw(
|
|
|
284
285
|
}
|
|
285
286
|
}
|
|
286
287
|
|
|
288
|
+
// ── Port declaration checks ──
|
|
289
|
+
validateTaskPorts(task, taskPath, errors);
|
|
290
|
+
|
|
287
291
|
// ── depends_on reference checks ──
|
|
288
292
|
if (task.depends_on && task.depends_on.length > 0) {
|
|
289
293
|
for (const dep of task.depends_on) {
|
|
@@ -343,6 +347,156 @@ export function validateRaw(
|
|
|
343
347
|
return errors;
|
|
344
348
|
}
|
|
345
349
|
|
|
350
|
+
const VALID_PORT_TYPES: ReadonlySet<PortType> = new Set([
|
|
351
|
+
'string',
|
|
352
|
+
'number',
|
|
353
|
+
'boolean',
|
|
354
|
+
'enum',
|
|
355
|
+
'json',
|
|
356
|
+
]);
|
|
357
|
+
|
|
358
|
+
// Identifier pattern for port names. Deliberately narrower than task IDs —
|
|
359
|
+
// port names appear in `{{inputs.<name>}}` templates where hyphens would
|
|
360
|
+
// be parsed as subtraction, so we also forbid them here to keep the
|
|
361
|
+
// template grammar unambiguous.
|
|
362
|
+
const PORT_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
363
|
+
|
|
364
|
+
function validatePortList(
|
|
365
|
+
list: readonly PortDef[] | undefined,
|
|
366
|
+
basePath: string,
|
|
367
|
+
kind: 'inputs' | 'outputs',
|
|
368
|
+
errors: ValidationError[],
|
|
369
|
+
): void {
|
|
370
|
+
if (!list) return;
|
|
371
|
+
if (!Array.isArray(list)) {
|
|
372
|
+
errors.push({
|
|
373
|
+
path: basePath,
|
|
374
|
+
message: `ports.${kind} must be an array`,
|
|
375
|
+
});
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
const seen = new Set<string>();
|
|
379
|
+
for (let i = 0; i < list.length; i++) {
|
|
380
|
+
const port = list[i];
|
|
381
|
+
const path = `${basePath}[${i}]`;
|
|
382
|
+
if (!port || typeof port !== 'object') {
|
|
383
|
+
errors.push({ path, message: `ports.${kind}[${i}] must be an object` });
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
if (typeof port.name !== 'string' || !port.name.trim()) {
|
|
387
|
+
errors.push({ path: `${path}.name`, message: 'port.name is required' });
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
if (!PORT_NAME_RE.test(port.name)) {
|
|
391
|
+
errors.push({
|
|
392
|
+
path: `${path}.name`,
|
|
393
|
+
message: `port name "${port.name}" is invalid. Must match /^[A-Za-z_][A-Za-z0-9_]*$/ (letters, digits, underscores; starts with letter/underscore).`,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
if (seen.has(port.name)) {
|
|
397
|
+
errors.push({
|
|
398
|
+
path,
|
|
399
|
+
message: `Duplicate ports.${kind} name "${port.name}"`,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
seen.add(port.name);
|
|
403
|
+
if (!VALID_PORT_TYPES.has(port.type)) {
|
|
404
|
+
errors.push({
|
|
405
|
+
path: `${path}.type`,
|
|
406
|
+
message: `port "${port.name}": type must be one of ${[...VALID_PORT_TYPES].join(', ')} (got ${JSON.stringify(port.type)})`,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
if (port.type === 'enum') {
|
|
410
|
+
if (!Array.isArray(port.enum) || port.enum.length === 0) {
|
|
411
|
+
errors.push({
|
|
412
|
+
path: `${path}.enum`,
|
|
413
|
+
message: `port "${port.name}": enum type requires a non-empty "enum" array`,
|
|
414
|
+
});
|
|
415
|
+
} else if (port.enum.some((v: unknown) => typeof v !== 'string')) {
|
|
416
|
+
errors.push({
|
|
417
|
+
path: `${path}.enum`,
|
|
418
|
+
message: `port "${port.name}": enum values must all be strings`,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (kind === 'outputs' && (port.required === true || port.from !== undefined)) {
|
|
423
|
+
// `required` / `from` are input-only concepts — outputs are
|
|
424
|
+
// always "produced when the task succeeds". Warn softly so the
|
|
425
|
+
// YAML doesn't silently accept meaningless fields.
|
|
426
|
+
errors.push({
|
|
427
|
+
path,
|
|
428
|
+
severity: 'warning',
|
|
429
|
+
message: `port "${port.name}": "required" and "from" are input-only; ignored on outputs`,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
if (port.from !== undefined && typeof port.from !== 'string') {
|
|
433
|
+
errors.push({
|
|
434
|
+
path: `${path}.from`,
|
|
435
|
+
message: `port "${port.name}": "from" must be a string (got ${typeof port.from})`,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function validateTaskPorts(
|
|
442
|
+
task: RawTaskConfig,
|
|
443
|
+
taskPath: string,
|
|
444
|
+
errors: ValidationError[],
|
|
445
|
+
): void {
|
|
446
|
+
const ports = task.ports;
|
|
447
|
+
// Placeholder cross-checks are independent of ports being declared —
|
|
448
|
+
// a user can type `{{inputs.X}}` without declaring any ports yet, and
|
|
449
|
+
// that's always an error (the engine has no `X` to substitute, and
|
|
450
|
+
// `validate-raw` is the one place that surfaces this before a run).
|
|
451
|
+
// Running the check unconditionally catches the typo on its own.
|
|
452
|
+
const declaredInputs = new Set<string>(
|
|
453
|
+
ports && Array.isArray(ports.inputs)
|
|
454
|
+
? ports.inputs.filter((p): p is PortDef => !!p && typeof p === 'object').map((p) => p.name)
|
|
455
|
+
: [],
|
|
456
|
+
);
|
|
457
|
+
const referenced = new Set<string>();
|
|
458
|
+
if (typeof task.prompt === 'string') {
|
|
459
|
+
for (const n of extractInputReferences(task.prompt)) referenced.add(n);
|
|
460
|
+
}
|
|
461
|
+
if (typeof task.command === 'string') {
|
|
462
|
+
for (const n of extractInputReferences(task.command)) referenced.add(n);
|
|
463
|
+
}
|
|
464
|
+
for (const name of referenced) {
|
|
465
|
+
if (!declaredInputs.has(name)) {
|
|
466
|
+
errors.push({
|
|
467
|
+
path: taskPath,
|
|
468
|
+
message: `Task "${task.id}": references "{{inputs.${name}}}" but no such input port is declared`,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (!ports) return;
|
|
474
|
+
|
|
475
|
+
// Per-port structural validation runs only after we've established
|
|
476
|
+
// that `ports.inputs` / `ports.outputs` are arrays — validatePortList
|
|
477
|
+
// also re-checks Array.isArray internally, which keeps it callable
|
|
478
|
+
// from contexts that hand it stray values.
|
|
479
|
+
validatePortList(ports.inputs, `${taskPath}.ports.inputs`, 'inputs', errors);
|
|
480
|
+
validatePortList(ports.outputs, `${taskPath}.ports.outputs`, 'outputs', errors);
|
|
481
|
+
|
|
482
|
+
// Warn on declared-but-unused inputs. Not fatal — a user may want to
|
|
483
|
+
// surface an input as a data-flow hint for the editor even when the
|
|
484
|
+
// prompt/command doesn't template it explicitly (e.g. AI tasks that
|
|
485
|
+
// consume inputs through the `[Inputs]` context block).
|
|
486
|
+
if (typeof task.command === 'string' && Array.isArray(ports.inputs)) {
|
|
487
|
+
for (const port of ports.inputs) {
|
|
488
|
+
if (!port || typeof port !== 'object') continue;
|
|
489
|
+
if (!referenced.has(port.name)) {
|
|
490
|
+
errors.push({
|
|
491
|
+
path: `${taskPath}.ports.inputs`,
|
|
492
|
+
severity: 'warning',
|
|
493
|
+
message: `Task "${task.id}": command does not reference {{inputs.${port.name}}} — declared input is unused`,
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
346
500
|
function detectCycles(config: RawPipelineConfig, index: TaskIndex): ValidationError[] {
|
|
347
501
|
// Build adjacency: qualifiedId → [resolved dep qualifiedIds]
|
|
348
502
|
const adj = new Map<string, string[]>();
|