@tagma/sdk 0.6.4 → 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,404 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { PluginRegistry } from './registry';
|
|
6
|
+
import { bootstrapBuiltins } from './bootstrap';
|
|
7
|
+
import { runPipeline, type RunEventPayload } from './engine';
|
|
8
|
+
import type { PipelineConfig, RunTaskState, TaskConfig, TaskPorts, TaskStatus } from './types';
|
|
9
|
+
|
|
10
|
+
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
const PERMS = { read: true, write: false, execute: false };
|
|
13
|
+
|
|
14
|
+
function freshRegistry(): PluginRegistry {
|
|
15
|
+
const reg = new PluginRegistry();
|
|
16
|
+
bootstrapBuiltins(reg);
|
|
17
|
+
return reg;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function makeDir(): string {
|
|
21
|
+
return mkdtempSync(join(tmpdir(), 'tagma-ports-'));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Write a small Node script to the workspace dir that emits the given
|
|
26
|
+
* payload on stdout as a single-line JSON object.
|
|
27
|
+
*
|
|
28
|
+
* Tests that rely on shell-quoted inline JSON (`echo '{"x":1}'`) are
|
|
29
|
+
* fragile across Windows cmd / PowerShell / Git Bash — quote handling
|
|
30
|
+
* differs widely. Putting the payload into a Node script instead keeps
|
|
31
|
+
* the command line a plain `node /path/to/file.js`, which survives any
|
|
32
|
+
* shell, and still exercises the engine's "last-line JSON" extraction
|
|
33
|
+
* on real child-process output.
|
|
34
|
+
*/
|
|
35
|
+
function writeEmitScript(dir: string, name: string, payload: Record<string, unknown>): string {
|
|
36
|
+
const path = join(dir, `${name}.js`);
|
|
37
|
+
const src = `process.stdout.write(${JSON.stringify(JSON.stringify(payload))});\nprocess.stdout.write('\\n');\n`;
|
|
38
|
+
writeFileSync(path, src);
|
|
39
|
+
return path;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Same as writeEmitScript but echoes args joined with `|`, so downstream
|
|
44
|
+
* tests can assert that upstream input values ended up on the command
|
|
45
|
+
* line post-substitution.
|
|
46
|
+
*/
|
|
47
|
+
function writeEchoArgsScript(dir: string, name: string): string {
|
|
48
|
+
const path = join(dir, `${name}.js`);
|
|
49
|
+
const src = `process.stdout.write(process.argv.slice(2).join('|'));\nprocess.stdout.write('\\n');\n`;
|
|
50
|
+
writeFileSync(path, src);
|
|
51
|
+
return path;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function task(overrides: Partial<TaskConfig> & { id: string }): TaskConfig {
|
|
55
|
+
return {
|
|
56
|
+
name: overrides.id,
|
|
57
|
+
permissions: PERMS,
|
|
58
|
+
driver: 'opencode',
|
|
59
|
+
...overrides,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function pipeline(tasks: TaskConfig[]): PipelineConfig {
|
|
64
|
+
return {
|
|
65
|
+
name: 'ports-test',
|
|
66
|
+
tracks: [
|
|
67
|
+
{
|
|
68
|
+
id: 't',
|
|
69
|
+
name: 'T',
|
|
70
|
+
driver: 'opencode',
|
|
71
|
+
permissions: PERMS,
|
|
72
|
+
on_failure: 'skip_downstream',
|
|
73
|
+
tasks,
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface RunResult {
|
|
80
|
+
events: RunEventPayload[];
|
|
81
|
+
states: ReadonlyMap<string, unknown>;
|
|
82
|
+
success: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function run(
|
|
86
|
+
config: PipelineConfig,
|
|
87
|
+
workDir: string,
|
|
88
|
+
registry = freshRegistry(),
|
|
89
|
+
): Promise<RunResult> {
|
|
90
|
+
const events: RunEventPayload[] = [];
|
|
91
|
+
const result = await runPipeline(config, workDir, {
|
|
92
|
+
registry,
|
|
93
|
+
skipPluginLoading: true,
|
|
94
|
+
onEvent: (e) => events.push(e),
|
|
95
|
+
});
|
|
96
|
+
return { events, states: result.states, success: result.success };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function finalUpdateFor(events: RunEventPayload[], qid: string): RunEventPayload | undefined {
|
|
100
|
+
let last: RunEventPayload | undefined;
|
|
101
|
+
for (const ev of events) {
|
|
102
|
+
if (ev.type === 'task_update' && ev.taskId === qid) last = ev;
|
|
103
|
+
}
|
|
104
|
+
return last;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function finalStatusFrom(events: RunEventPayload[], qid: string): TaskStatus | undefined {
|
|
108
|
+
const last = finalUpdateFor(events, qid);
|
|
109
|
+
return last && last.type === 'task_update' ? last.status : undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─── Tests ────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
describe('engine — ports: output extraction + input resolution', () => {
|
|
115
|
+
test('upstream outputs feed downstream inputs via name match', async () => {
|
|
116
|
+
const dir = makeDir();
|
|
117
|
+
try {
|
|
118
|
+
const emit = writeEmitScript(dir, 'emit', { city: 'Shanghai', id: 42 });
|
|
119
|
+
const echo = writeEchoArgsScript(dir, 'echo');
|
|
120
|
+
const config = pipeline([
|
|
121
|
+
task({
|
|
122
|
+
id: 'up',
|
|
123
|
+
command: `node "${emit}"`,
|
|
124
|
+
ports: {
|
|
125
|
+
outputs: [
|
|
126
|
+
{ name: 'city', type: 'string' },
|
|
127
|
+
{ name: 'id', type: 'number' },
|
|
128
|
+
],
|
|
129
|
+
} as TaskPorts,
|
|
130
|
+
}),
|
|
131
|
+
task({
|
|
132
|
+
id: 'down',
|
|
133
|
+
depends_on: ['up'],
|
|
134
|
+
command: `node "${echo}" "{{inputs.city}}" "{{inputs.id}}"`,
|
|
135
|
+
ports: {
|
|
136
|
+
inputs: [
|
|
137
|
+
{ name: 'city', type: 'string', required: true },
|
|
138
|
+
{ name: 'id', type: 'number', required: true },
|
|
139
|
+
],
|
|
140
|
+
} as TaskPorts,
|
|
141
|
+
}),
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
const { events, success } = await run(config, dir);
|
|
145
|
+
expect(success).toBe(true);
|
|
146
|
+
|
|
147
|
+
// Upstream's extracted outputs land on the final task_update event
|
|
148
|
+
// so the editor can render them on the card live.
|
|
149
|
+
const upFinal = finalUpdateFor(events, 't.up')!;
|
|
150
|
+
expect(upFinal.type).toBe('task_update');
|
|
151
|
+
if (upFinal.type !== 'task_update') return;
|
|
152
|
+
expect(upFinal.outputs).toEqual({ city: 'Shanghai', id: 42 });
|
|
153
|
+
|
|
154
|
+
// Downstream saw the values: echoed stdout is "Shanghai|42\n".
|
|
155
|
+
const downFinal = finalUpdateFor(events, 't.down')!;
|
|
156
|
+
if (downFinal.type !== 'task_update') return;
|
|
157
|
+
expect(downFinal.status).toBe('success');
|
|
158
|
+
expect((downFinal.stdout ?? '').trim()).toBe('Shanghai|42');
|
|
159
|
+
expect(downFinal.inputs).toEqual({ city: 'Shanghai', id: 42 });
|
|
160
|
+
} finally {
|
|
161
|
+
rmSync(dir, { recursive: true, force: true });
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('required input missing → downstream blocked, no spawn, upstream still succeeded', async () => {
|
|
166
|
+
const dir = makeDir();
|
|
167
|
+
try {
|
|
168
|
+
// Upstream declares `city` output but its script emits no JSON, so
|
|
169
|
+
// the engine can't extract `city` — diagnostic on stderr, no
|
|
170
|
+
// outputs. Downstream required input unresolved → blocked.
|
|
171
|
+
const noJson = join(dir, 'no-json.js');
|
|
172
|
+
writeFileSync(noJson, "process.stdout.write('hello\\n');\n");
|
|
173
|
+
const echo = writeEchoArgsScript(dir, 'echo');
|
|
174
|
+
const config = pipeline([
|
|
175
|
+
task({
|
|
176
|
+
id: 'up',
|
|
177
|
+
command: `node "${noJson}"`,
|
|
178
|
+
ports: { outputs: [{ name: 'city', type: 'string' }] } as TaskPorts,
|
|
179
|
+
}),
|
|
180
|
+
task({
|
|
181
|
+
id: 'down',
|
|
182
|
+
depends_on: ['up'],
|
|
183
|
+
command: `node "${echo}" "{{inputs.city}}"`,
|
|
184
|
+
ports: {
|
|
185
|
+
inputs: [{ name: 'city', type: 'string', required: true }],
|
|
186
|
+
} as TaskPorts,
|
|
187
|
+
}),
|
|
188
|
+
]);
|
|
189
|
+
|
|
190
|
+
const { events, success } = await run(config, dir);
|
|
191
|
+
expect(success).toBe(false);
|
|
192
|
+
expect(finalStatusFrom(events, 't.up')).toBe('success');
|
|
193
|
+
const downStatus = finalStatusFrom(events, 't.down');
|
|
194
|
+
expect(downStatus).toBe('blocked');
|
|
195
|
+
// The blocked update carries the engine's diagnostic in stderr so
|
|
196
|
+
// the editor can display it verbatim.
|
|
197
|
+
const downFinal = finalUpdateFor(events, 't.down');
|
|
198
|
+
if (downFinal?.type === 'task_update') {
|
|
199
|
+
expect(downFinal.stderr ?? '').toMatch(/missing required input.*city/i);
|
|
200
|
+
}
|
|
201
|
+
} finally {
|
|
202
|
+
rmSync(dir, { recursive: true, force: true });
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('optional input with default is applied when upstream does not supply it', async () => {
|
|
207
|
+
const dir = makeDir();
|
|
208
|
+
try {
|
|
209
|
+
const noop = join(dir, 'noop.js');
|
|
210
|
+
writeFileSync(noop, 'process.stdout.write("ok\\n");\n');
|
|
211
|
+
const echo = writeEchoArgsScript(dir, 'echo');
|
|
212
|
+
const config = pipeline([
|
|
213
|
+
task({ id: 'up', command: `node "${noop}"` }),
|
|
214
|
+
task({
|
|
215
|
+
id: 'down',
|
|
216
|
+
depends_on: ['up'],
|
|
217
|
+
command: `node "${echo}" "{{inputs.lang}}"`,
|
|
218
|
+
ports: {
|
|
219
|
+
inputs: [{ name: 'lang', type: 'string', default: 'en' }],
|
|
220
|
+
} as TaskPorts,
|
|
221
|
+
}),
|
|
222
|
+
]);
|
|
223
|
+
const { events, success } = await run(config, dir);
|
|
224
|
+
expect(success).toBe(true);
|
|
225
|
+
const downFinal = finalUpdateFor(events, 't.down');
|
|
226
|
+
if (downFinal?.type === 'task_update') {
|
|
227
|
+
expect((downFinal.stdout ?? '').trim()).toBe('en');
|
|
228
|
+
expect(downFinal.inputs).toEqual({ lang: 'en' });
|
|
229
|
+
}
|
|
230
|
+
} finally {
|
|
231
|
+
rmSync(dir, { recursive: true, force: true });
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('optional input without default or upstream → empty placeholder', async () => {
|
|
236
|
+
const dir = makeDir();
|
|
237
|
+
try {
|
|
238
|
+
const noop = join(dir, 'noop.js');
|
|
239
|
+
writeFileSync(noop, 'process.stdout.write("ok\\n");\n');
|
|
240
|
+
// Use a Node script that prints `|<arg>|` so the empty substitution
|
|
241
|
+
// shows as `||` — cross-platform argv handling.
|
|
242
|
+
const sentinel = join(dir, 'sentinel.js');
|
|
243
|
+
writeFileSync(sentinel, 'process.stdout.write("<" + (process.argv[2] || "") + ">\\n");\n');
|
|
244
|
+
const config = pipeline([
|
|
245
|
+
task({ id: 'up', command: `node "${noop}"` }),
|
|
246
|
+
task({
|
|
247
|
+
id: 'down',
|
|
248
|
+
depends_on: ['up'],
|
|
249
|
+
command: `node "${sentinel}" "{{inputs.note}}"`,
|
|
250
|
+
ports: {
|
|
251
|
+
inputs: [{ name: 'note', type: 'string' }],
|
|
252
|
+
} as TaskPorts,
|
|
253
|
+
}),
|
|
254
|
+
]);
|
|
255
|
+
const { events, success } = await run(config, dir);
|
|
256
|
+
expect(success).toBe(true);
|
|
257
|
+
const downFinal = finalUpdateFor(events, 't.down');
|
|
258
|
+
if (downFinal?.type === 'task_update') {
|
|
259
|
+
expect((downFinal.stdout ?? '').trim()).toBe('<>');
|
|
260
|
+
}
|
|
261
|
+
} finally {
|
|
262
|
+
rmSync(dir, { recursive: true, force: true });
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('tasks with no ports declared are unaffected', async () => {
|
|
267
|
+
const dir = makeDir();
|
|
268
|
+
try {
|
|
269
|
+
const config = pipeline([task({ id: 'plain', command: 'echo hello' })]);
|
|
270
|
+
const { events, success } = await run(config, dir);
|
|
271
|
+
expect(success).toBe(true);
|
|
272
|
+
const final = finalUpdateFor(events, 't.plain');
|
|
273
|
+
if (final?.type === 'task_update') {
|
|
274
|
+
expect(final.outputs).toBeFalsy();
|
|
275
|
+
expect(final.inputs).toEqual({});
|
|
276
|
+
}
|
|
277
|
+
} finally {
|
|
278
|
+
rmSync(dir, { recursive: true, force: true });
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test('ambiguous name-match blocks downstream unless disambiguated', async () => {
|
|
283
|
+
const dir = makeDir();
|
|
284
|
+
try {
|
|
285
|
+
const emitA = writeEmitScript(dir, 'emitA', { val: 'from-a' });
|
|
286
|
+
const emitB = writeEmitScript(dir, 'emitB', { val: 'from-b' });
|
|
287
|
+
const echo = writeEchoArgsScript(dir, 'echo');
|
|
288
|
+
// Two upstreams both export `val`; downstream auto-matches → ambiguous.
|
|
289
|
+
const ambigConfig = pipeline([
|
|
290
|
+
task({
|
|
291
|
+
id: 'a',
|
|
292
|
+
command: `node "${emitA}"`,
|
|
293
|
+
ports: { outputs: [{ name: 'val', type: 'string' }] } as TaskPorts,
|
|
294
|
+
}),
|
|
295
|
+
task({
|
|
296
|
+
id: 'b',
|
|
297
|
+
command: `node "${emitB}"`,
|
|
298
|
+
ports: { outputs: [{ name: 'val', type: 'string' }] } as TaskPorts,
|
|
299
|
+
}),
|
|
300
|
+
task({
|
|
301
|
+
id: 'down',
|
|
302
|
+
depends_on: ['a', 'b'],
|
|
303
|
+
command: `node "${echo}" "{{inputs.val}}"`,
|
|
304
|
+
ports: {
|
|
305
|
+
inputs: [{ name: 'val', type: 'string', required: true }],
|
|
306
|
+
} as TaskPorts,
|
|
307
|
+
}),
|
|
308
|
+
]);
|
|
309
|
+
const { events: evAmbig } = await run(ambigConfig, dir);
|
|
310
|
+
expect(finalStatusFrom(evAmbig, 't.down')).toBe('blocked');
|
|
311
|
+
const ambigFinal = finalUpdateFor(evAmbig, 't.down');
|
|
312
|
+
if (ambigFinal?.type === 'task_update') {
|
|
313
|
+
expect(ambigFinal.stderr ?? '').toMatch(/ambiguous|multiple upstreams/i);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Now add an explicit `from: "t.b.val"` → downstream should succeed.
|
|
317
|
+
const dir2 = makeDir();
|
|
318
|
+
try {
|
|
319
|
+
const emitA2 = writeEmitScript(dir2, 'emitA', { val: 'from-a' });
|
|
320
|
+
const emitB2 = writeEmitScript(dir2, 'emitB', { val: 'from-b' });
|
|
321
|
+
const echo2 = writeEchoArgsScript(dir2, 'echo');
|
|
322
|
+
const explicitConfig = pipeline([
|
|
323
|
+
task({
|
|
324
|
+
id: 'a',
|
|
325
|
+
command: `node "${emitA2}"`,
|
|
326
|
+
ports: { outputs: [{ name: 'val', type: 'string' }] } as TaskPorts,
|
|
327
|
+
}),
|
|
328
|
+
task({
|
|
329
|
+
id: 'b',
|
|
330
|
+
command: `node "${emitB2}"`,
|
|
331
|
+
ports: { outputs: [{ name: 'val', type: 'string' }] } as TaskPorts,
|
|
332
|
+
}),
|
|
333
|
+
task({
|
|
334
|
+
id: 'down',
|
|
335
|
+
depends_on: ['a', 'b'],
|
|
336
|
+
command: `node "${echo2}" "{{inputs.val}}"`,
|
|
337
|
+
ports: {
|
|
338
|
+
inputs: [
|
|
339
|
+
{ name: 'val', type: 'string', required: true, from: 't.b.val' },
|
|
340
|
+
],
|
|
341
|
+
} as TaskPorts,
|
|
342
|
+
}),
|
|
343
|
+
]);
|
|
344
|
+
const { events: evExplicit, success } = await run(explicitConfig, dir2);
|
|
345
|
+
expect(success).toBe(true);
|
|
346
|
+
const downFinal = finalUpdateFor(evExplicit, 't.down');
|
|
347
|
+
if (downFinal?.type === 'task_update') {
|
|
348
|
+
expect((downFinal.stdout ?? '').trim()).toBe('from-b');
|
|
349
|
+
}
|
|
350
|
+
} finally {
|
|
351
|
+
rmSync(dir2, { recursive: true, force: true });
|
|
352
|
+
}
|
|
353
|
+
} finally {
|
|
354
|
+
rmSync(dir, { recursive: true, force: true });
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test('string → number coercion happens during input resolution', async () => {
|
|
359
|
+
const dir = makeDir();
|
|
360
|
+
try {
|
|
361
|
+
// Emit `id` as a string; downstream declares it as `number`.
|
|
362
|
+
const emit = writeEmitScript(dir, 'emit', { id: '42' });
|
|
363
|
+
const script = join(dir, 'assert-number.js');
|
|
364
|
+
writeFileSync(
|
|
365
|
+
script,
|
|
366
|
+
`const v = process.argv[2];
|
|
367
|
+
const n = Number(v);
|
|
368
|
+
if (!Number.isFinite(n)) { process.exit(2); }
|
|
369
|
+
process.stdout.write("n=" + n + "\\n");
|
|
370
|
+
`,
|
|
371
|
+
);
|
|
372
|
+
const config = pipeline([
|
|
373
|
+
task({
|
|
374
|
+
id: 'up',
|
|
375
|
+
command: `node "${emit}"`,
|
|
376
|
+
ports: {
|
|
377
|
+
// upstream declares string — matches the emitted literal.
|
|
378
|
+
outputs: [{ name: 'id', type: 'string' }],
|
|
379
|
+
} as TaskPorts,
|
|
380
|
+
}),
|
|
381
|
+
task({
|
|
382
|
+
id: 'down',
|
|
383
|
+
depends_on: ['up'],
|
|
384
|
+
command: `node "${script}" "{{inputs.id}}"`,
|
|
385
|
+
ports: {
|
|
386
|
+
// downstream demands number — resolve should coerce "42" → 42.
|
|
387
|
+
inputs: [{ name: 'id', type: 'number', required: true }],
|
|
388
|
+
} as TaskPorts,
|
|
389
|
+
}),
|
|
390
|
+
]);
|
|
391
|
+
const { events, success } = await run(config, dir);
|
|
392
|
+
expect(success).toBe(true);
|
|
393
|
+
const downFinal = finalUpdateFor(events, 't.down');
|
|
394
|
+
if (downFinal?.type === 'task_update') {
|
|
395
|
+
expect((downFinal.stdout ?? '').trim()).toBe('n=42');
|
|
396
|
+
// Value on the wire should be the coerced number, not the raw
|
|
397
|
+
// string, so the editor renders it faithfully too.
|
|
398
|
+
expect(downFinal.inputs).toEqual({ id: 42 });
|
|
399
|
+
}
|
|
400
|
+
} finally {
|
|
401
|
+
rmSync(dir, { recursive: true, force: true });
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
});
|