@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
@@ -2,12 +2,10 @@ import { describe, expect, test } from 'bun:test';
2
2
  import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
- import { PluginRegistry } from './registry';
6
5
  import { bootstrapBuiltins } from './bootstrap';
7
6
  import { runPipeline, type RunEventPayload } from './engine';
8
- import type { PipelineConfig, TaskConfig, TaskPorts, TaskStatus } from './types';
9
-
10
- // ─── Helpers ─────────────────────────────────────────────────────────
7
+ import { PluginRegistry } from './registry';
8
+ import type { PipelineConfig, TaskConfig, TaskStatus } from './types';
11
9
 
12
10
  const PERMS = { read: true, write: false, execute: false };
13
11
 
@@ -18,36 +16,21 @@ function freshRegistry(): PluginRegistry {
18
16
  }
19
17
 
20
18
  function makeDir(): string {
21
- return mkdtempSync(join(tmpdir(), 'tagma-ports-'));
19
+ return mkdtempSync(join(tmpdir(), 'tagma-bindings-'));
22
20
  }
23
21
 
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
22
  function writeEmitScript(dir: string, name: string, payload: Record<string, unknown>): string {
36
23
  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);
24
+ writeFileSync(
25
+ path,
26
+ `process.stdout.write(${JSON.stringify(JSON.stringify(payload))});\nprocess.stdout.write('\\n');\n`,
27
+ );
39
28
  return path;
40
29
  }
41
30
 
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
31
  function writeEchoArgsScript(dir: string, name: string): string {
48
32
  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);
33
+ writeFileSync(path, `process.stdout.write(process.argv.slice(2).join('|'));\n`);
51
34
  return path;
52
35
  }
53
36
 
@@ -62,7 +45,7 @@ function task(overrides: Partial<TaskConfig> & { id: string }): TaskConfig {
62
45
 
63
46
  function pipeline(tasks: TaskConfig[]): PipelineConfig {
64
47
  return {
65
- name: 'ports-test',
48
+ name: 'bindings-test',
66
49
  tracks: [
67
50
  {
68
51
  id: 't',
@@ -76,24 +59,14 @@ function pipeline(tasks: TaskConfig[]): PipelineConfig {
76
59
  };
77
60
  }
78
61
 
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> {
62
+ async function run(config: PipelineConfig, workDir: string) {
90
63
  const events: RunEventPayload[] = [];
91
64
  const result = await runPipeline(config, workDir, {
92
- registry,
65
+ registry: freshRegistry(),
93
66
  skipPluginLoading: true,
94
67
  onEvent: (e) => events.push(e),
95
68
  });
96
- return { events, states: result.states, success: result.success };
69
+ return { events, success: result.success };
97
70
  }
98
71
 
99
72
  function finalUpdateFor(events: RunEventPayload[], qid: string): RunEventPayload | undefined {
@@ -109,360 +82,83 @@ function finalStatusFrom(events: RunEventPayload[], qid: string): TaskStatus | u
109
82
  return last && last.type === 'task_update' ? last.status : undefined;
110
83
  }
111
84
 
112
- // ─── Tests ────────────────────────────────────────────────────────────
113
-
114
- describe('engine — ports: output extraction + input resolution', () => {
115
- test('lightweight task inputs substitute command placeholders without ports', async () => {
85
+ describe('engine unified inputs and outputs', () => {
86
+ test('typed outputs feed typed inputs and command placeholders', async () => {
116
87
  const dir = makeDir();
117
88
  try {
118
- const echo = writeEchoArgsScript(dir, 'echo');
119
- const config = pipeline([
120
- task({
121
- id: 'down',
122
- command: `node "${echo}" "{{inputs.city}}" "{{inputs.mode}}"`,
123
- inputs: {
124
- city: { value: 'Shanghai' },
125
- mode: { default: 'quick' },
126
- },
127
- }),
128
- ]);
129
-
130
- const { events, success } = await run(config, dir);
131
- expect(success).toBe(true);
132
- const downFinal = finalUpdateFor(events, 't.down');
133
- if (downFinal?.type === 'task_update') {
134
- expect((downFinal.stdout ?? '').trim()).toBe('Shanghai|quick');
135
- expect(downFinal.inputs).toEqual({ city: 'Shanghai', mode: 'quick' });
136
- }
137
- } finally {
138
- rmSync(dir, { recursive: true, force: true });
139
- }
140
- });
141
-
142
- test('lightweight task outputs publish named values for downstream bindings', async () => {
143
- const dir = makeDir();
144
- try {
145
- const emit = writeEmitScript(dir, 'emit', { bundlePath: 'dist/app.js' });
146
- const echo = writeEchoArgsScript(dir, 'echo');
147
- const config = pipeline([
148
- task({
149
- id: 'build',
150
- command: `node "${emit}"`,
151
- outputs: {
152
- bundlePath: {},
153
- },
154
- }),
155
- task({
156
- id: 'test',
157
- depends_on: ['build'],
158
- command: `node "${echo}" "{{inputs.bundlePath}}"`,
159
- inputs: {
160
- bundlePath: { from: 't.build.outputs.bundlePath', required: true },
161
- },
162
- }),
163
- ]);
164
-
165
- const { events, success } = await run(config, dir);
166
- expect(success).toBe(true);
167
- const buildFinal = finalUpdateFor(events, 't.build');
168
- if (buildFinal?.type === 'task_update') {
169
- expect(buildFinal.outputs).toEqual({ bundlePath: 'dist/app.js' });
170
- }
171
- const testFinal = finalUpdateFor(events, 't.test');
172
- if (testFinal?.type === 'task_update') {
173
- expect((testFinal.stdout ?? '').trim()).toBe('dist/app.js');
174
- expect(testFinal.inputs).toEqual({ bundlePath: 'dist/app.js' });
175
- }
176
- } finally {
177
- rmSync(dir, { recursive: true, force: true });
178
- }
179
- });
180
-
181
- test('upstream outputs feed downstream inputs via name match', async () => {
182
- const dir = makeDir();
183
- try {
184
- const emit = writeEmitScript(dir, 'emit', { city: 'Shanghai', id: 42 });
89
+ const emit = writeEmitScript(dir, 'emit', { id: '42', city: 'Shanghai' });
185
90
  const echo = writeEchoArgsScript(dir, 'echo');
186
91
  const config = pipeline([
187
92
  task({
188
93
  id: 'up',
189
94
  command: `node "${emit}"`,
190
- ports: {
191
- outputs: [
192
- { name: 'city', type: 'string' },
193
- { name: 'id', type: 'number' },
194
- ],
195
- } as TaskPorts,
95
+ outputs: { id: { type: 'number' }, city: { type: 'string' } },
196
96
  }),
197
97
  task({
198
98
  id: 'down',
199
99
  depends_on: ['up'],
200
100
  command: `node "${echo}" "{{inputs.city}}" "{{inputs.id}}"`,
201
- ports: {
202
- inputs: [
203
- { name: 'city', type: 'string', required: true },
204
- { name: 'id', type: 'number', required: true },
205
- ],
206
- } as TaskPorts,
101
+ inputs: {
102
+ city: { from: 't.up.outputs.city', type: 'string', required: true },
103
+ id: { from: 't.up.outputs.id', type: 'number', required: true },
104
+ },
207
105
  }),
208
106
  ]);
209
107
 
210
108
  const { events, success } = await run(config, dir);
211
109
  expect(success).toBe(true);
212
-
213
- // Upstream's extracted outputs land on the final task_update event
214
- // so the editor can render them on the card live.
215
- const upFinal = finalUpdateFor(events, 't.up')!;
216
- expect(upFinal.type).toBe('task_update');
217
- if (upFinal.type !== 'task_update') return;
218
- expect(upFinal.outputs).toEqual({ city: 'Shanghai', id: 42 });
219
-
220
- // Downstream saw the values: echoed stdout is "Shanghai|42\n".
221
- const downFinal = finalUpdateFor(events, 't.down')!;
222
- if (downFinal.type !== 'task_update') return;
223
- expect(downFinal.status).toBe('success');
224
- expect((downFinal.stdout ?? '').trim()).toBe('Shanghai|42');
225
- expect(downFinal.inputs).toEqual({ city: 'Shanghai', id: 42 });
110
+ expect(finalUpdateFor(events, 't.up')?.outputs).toEqual({ id: 42, city: 'Shanghai' });
111
+ expect(finalUpdateFor(events, 't.down')?.inputs).toEqual({ city: 'Shanghai', id: 42 });
112
+ expect(finalUpdateFor(events, 't.down')?.stdout).toContain('Shanghai|42');
226
113
  } finally {
227
114
  rmSync(dir, { recursive: true, force: true });
228
115
  }
229
116
  });
230
117
 
231
- test('required input missing downstream blocked, no spawn, upstream still succeeded', async () => {
118
+ test('missing required unified input blocks without spawning downstream', async () => {
232
119
  const dir = makeDir();
233
120
  try {
234
- // Upstream declares `city` output but its script emits no JSON, so
235
- // the engine can't extract `city` — diagnostic on stderr, no
236
- // outputs. Downstream required input unresolved → blocked.
237
- const noJson = join(dir, 'no-json.js');
238
- writeFileSync(noJson, "process.stdout.write('hello\\n');\n");
121
+ const emit = writeEmitScript(dir, 'emit', { other: 'x' });
239
122
  const echo = writeEchoArgsScript(dir, 'echo');
240
123
  const config = pipeline([
241
- task({
242
- id: 'up',
243
- command: `node "${noJson}"`,
244
- ports: { outputs: [{ name: 'city', type: 'string' }] } as TaskPorts,
245
- }),
124
+ task({ id: 'up', command: `node "${emit}"`, outputs: { city: { type: 'string' } } }),
246
125
  task({
247
126
  id: 'down',
248
127
  depends_on: ['up'],
249
128
  command: `node "${echo}" "{{inputs.city}}"`,
250
- ports: {
251
- inputs: [{ name: 'city', type: 'string', required: true }],
252
- } as TaskPorts,
129
+ inputs: { city: { from: 't.up.outputs.city', type: 'string', required: true } },
253
130
  }),
254
131
  ]);
255
132
 
256
133
  const { events, success } = await run(config, dir);
257
134
  expect(success).toBe(false);
258
135
  expect(finalStatusFrom(events, 't.up')).toBe('success');
259
- const downStatus = finalStatusFrom(events, 't.down');
260
- expect(downStatus).toBe('blocked');
261
- // The blocked update carries the engine's diagnostic in stderr so
262
- // the editor can display it verbatim.
263
- const downFinal = finalUpdateFor(events, 't.down');
264
- if (downFinal?.type === 'task_update') {
265
- expect(downFinal.stderr ?? '').toMatch(/missing required input.*city/i);
266
- }
136
+ expect(finalStatusFrom(events, 't.down')).toBe('blocked');
267
137
  } finally {
268
138
  rmSync(dir, { recursive: true, force: true });
269
139
  }
270
140
  });
271
141
 
272
- test('optional input with default is applied when upstream does not supply it', async () => {
142
+ test('typed output coercion diagnostics leave missing downstream input', async () => {
273
143
  const dir = makeDir();
274
144
  try {
275
- const noop = join(dir, 'noop.js');
276
- writeFileSync(noop, 'process.stdout.write("ok\\n");\n');
145
+ const emit = writeEmitScript(dir, 'emit', { id: 'not-a-number' });
277
146
  const echo = writeEchoArgsScript(dir, 'echo');
278
147
  const config = pipeline([
279
- task({ id: 'up', command: `node "${noop}"` }),
280
- task({
281
- id: 'down',
282
- depends_on: ['up'],
283
- command: `node "${echo}" "{{inputs.lang}}"`,
284
- ports: {
285
- inputs: [{ name: 'lang', type: 'string', default: 'en' }],
286
- } as TaskPorts,
287
- }),
288
- ]);
289
- const { events, success } = await run(config, dir);
290
- expect(success).toBe(true);
291
- const downFinal = finalUpdateFor(events, 't.down');
292
- if (downFinal?.type === 'task_update') {
293
- expect((downFinal.stdout ?? '').trim()).toBe('en');
294
- expect(downFinal.inputs).toEqual({ lang: 'en' });
295
- }
296
- } finally {
297
- rmSync(dir, { recursive: true, force: true });
298
- }
299
- });
300
-
301
- test('optional input without default or upstream → empty placeholder', async () => {
302
- const dir = makeDir();
303
- try {
304
- const noop = join(dir, 'noop.js');
305
- writeFileSync(noop, 'process.stdout.write("ok\\n");\n');
306
- // Use a Node script that prints `|<arg>|` so the empty substitution
307
- // shows as `||` — cross-platform argv handling.
308
- const sentinel = join(dir, 'sentinel.js');
309
- writeFileSync(sentinel, 'process.stdout.write("<" + (process.argv[2] || "") + ">\\n");\n');
310
- const config = pipeline([
311
- task({ id: 'up', command: `node "${noop}"` }),
148
+ task({ id: 'up', command: `node "${emit}"`, outputs: { id: { type: 'number' } } }),
312
149
  task({
313
150
  id: 'down',
314
151
  depends_on: ['up'],
315
- command: `node "${sentinel}" "{{inputs.note}}"`,
316
- ports: {
317
- inputs: [{ name: 'note', type: 'string' }],
318
- } as TaskPorts,
152
+ command: `node "${echo}" "{{inputs.id}}"`,
153
+ inputs: { id: { from: 't.up.outputs.id', type: 'number', required: true } },
319
154
  }),
320
155
  ]);
321
- const { events, success } = await run(config, dir);
322
- expect(success).toBe(true);
323
- const downFinal = finalUpdateFor(events, 't.down');
324
- if (downFinal?.type === 'task_update') {
325
- expect((downFinal.stdout ?? '').trim()).toBe('<>');
326
- }
327
- } finally {
328
- rmSync(dir, { recursive: true, force: true });
329
- }
330
- });
331
156
 
332
- test('tasks with no ports declared are unaffected', async () => {
333
- const dir = makeDir();
334
- try {
335
- const config = pipeline([task({ id: 'plain', command: 'echo hello' })]);
336
157
  const { events, success } = await run(config, dir);
337
- expect(success).toBe(true);
338
- const final = finalUpdateFor(events, 't.plain');
339
- if (final?.type === 'task_update') {
340
- expect(final.outputs).toBeFalsy();
341
- expect(final.inputs).toEqual({});
342
- }
343
- } finally {
344
- rmSync(dir, { recursive: true, force: true });
345
- }
346
- });
347
-
348
- test('ambiguous name-match blocks downstream unless disambiguated', async () => {
349
- const dir = makeDir();
350
- try {
351
- const emitA = writeEmitScript(dir, 'emitA', { val: 'from-a' });
352
- const emitB = writeEmitScript(dir, 'emitB', { val: 'from-b' });
353
- const echo = writeEchoArgsScript(dir, 'echo');
354
- // Two upstreams both export `val`; downstream auto-matches → ambiguous.
355
- const ambigConfig = pipeline([
356
- task({
357
- id: 'a',
358
- command: `node "${emitA}"`,
359
- ports: { outputs: [{ name: 'val', type: 'string' }] } as TaskPorts,
360
- }),
361
- task({
362
- id: 'b',
363
- command: `node "${emitB}"`,
364
- ports: { outputs: [{ name: 'val', type: 'string' }] } as TaskPorts,
365
- }),
366
- task({
367
- id: 'down',
368
- depends_on: ['a', 'b'],
369
- command: `node "${echo}" "{{inputs.val}}"`,
370
- ports: {
371
- inputs: [{ name: 'val', type: 'string', required: true }],
372
- } as TaskPorts,
373
- }),
374
- ]);
375
- const { events: evAmbig } = await run(ambigConfig, dir);
376
- expect(finalStatusFrom(evAmbig, 't.down')).toBe('blocked');
377
- const ambigFinal = finalUpdateFor(evAmbig, 't.down');
378
- if (ambigFinal?.type === 'task_update') {
379
- expect(ambigFinal.stderr ?? '').toMatch(/ambiguous|multiple upstreams/i);
380
- }
381
-
382
- // Now add an explicit `from: "t.b.val"` → downstream should succeed.
383
- const dir2 = makeDir();
384
- try {
385
- const emitA2 = writeEmitScript(dir2, 'emitA', { val: 'from-a' });
386
- const emitB2 = writeEmitScript(dir2, 'emitB', { val: 'from-b' });
387
- const echo2 = writeEchoArgsScript(dir2, 'echo');
388
- const explicitConfig = pipeline([
389
- task({
390
- id: 'a',
391
- command: `node "${emitA2}"`,
392
- ports: { outputs: [{ name: 'val', type: 'string' }] } as TaskPorts,
393
- }),
394
- task({
395
- id: 'b',
396
- command: `node "${emitB2}"`,
397
- ports: { outputs: [{ name: 'val', type: 'string' }] } as TaskPorts,
398
- }),
399
- task({
400
- id: 'down',
401
- depends_on: ['a', 'b'],
402
- command: `node "${echo2}" "{{inputs.val}}"`,
403
- ports: {
404
- inputs: [
405
- { name: 'val', type: 'string', required: true, from: 't.b.val' },
406
- ],
407
- } as TaskPorts,
408
- }),
409
- ]);
410
- const { events: evExplicit, success } = await run(explicitConfig, dir2);
411
- expect(success).toBe(true);
412
- const downFinal = finalUpdateFor(evExplicit, 't.down');
413
- if (downFinal?.type === 'task_update') {
414
- expect((downFinal.stdout ?? '').trim()).toBe('from-b');
415
- }
416
- } finally {
417
- rmSync(dir2, { recursive: true, force: true });
418
- }
419
- } finally {
420
- rmSync(dir, { recursive: true, force: true });
421
- }
422
- });
423
-
424
- test('string → number coercion happens during input resolution', async () => {
425
- const dir = makeDir();
426
- try {
427
- // Emit `id` as a string; downstream declares it as `number`.
428
- const emit = writeEmitScript(dir, 'emit', { id: '42' });
429
- const script = join(dir, 'assert-number.js');
430
- writeFileSync(
431
- script,
432
- `const v = process.argv[2];
433
- const n = Number(v);
434
- if (!Number.isFinite(n)) { process.exit(2); }
435
- process.stdout.write("n=" + n + "\\n");
436
- `,
437
- );
438
- const config = pipeline([
439
- task({
440
- id: 'up',
441
- command: `node "${emit}"`,
442
- ports: {
443
- // upstream declares string — matches the emitted literal.
444
- outputs: [{ name: 'id', type: 'string' }],
445
- } as TaskPorts,
446
- }),
447
- task({
448
- id: 'down',
449
- depends_on: ['up'],
450
- command: `node "${script}" "{{inputs.id}}"`,
451
- ports: {
452
- // downstream demands number — resolve should coerce "42" → 42.
453
- inputs: [{ name: 'id', type: 'number', required: true }],
454
- } as TaskPorts,
455
- }),
456
- ]);
457
- const { events, success } = await run(config, dir);
458
- expect(success).toBe(true);
459
- const downFinal = finalUpdateFor(events, 't.down');
460
- if (downFinal?.type === 'task_update') {
461
- expect((downFinal.stdout ?? '').trim()).toBe('n=42');
462
- // Value on the wire should be the coerced number, not the raw
463
- // string, so the editor renders it faithfully too.
464
- expect(downFinal.inputs).toEqual({ id: 42 });
465
- }
158
+ expect(success).toBe(false);
159
+ expect(finalStatusFrom(events, 't.up')).toBe('success');
160
+ expect(finalUpdateFor(events, 't.up')?.stderr).toContain('expected number');
161
+ expect(finalStatusFrom(events, 't.down')).toBe('blocked');
466
162
  } finally {
467
163
  rmSync(dir, { recursive: true, force: true });
468
164
  }
package/src/engine.ts CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  skipNonTerminalTasks,
33
33
  } from './core/scheduler';
34
34
  import { executeTask } from './core/task-executor';
35
+ import { bunRuntime, type TagmaRuntime } from './runtime';
35
36
  export { TriggerBlockedError, TriggerTimeoutError } from './core/trigger-errors';
36
37
 
37
38
  function isPromptTaskConfig(
@@ -104,6 +105,11 @@ export interface RunPipelineOptions {
104
105
  * do not share handler state.
105
106
  */
106
107
  readonly registry: PluginRegistry;
108
+ /**
109
+ * Runtime implementation for command and driver process execution.
110
+ * Defaults to the SDK's Bun runtime.
111
+ */
112
+ readonly runtime?: TagmaRuntime;
107
113
  }
108
114
 
109
115
  // Poll interval when no tasks are in-flight but non-terminal tasks remain
@@ -123,6 +129,7 @@ export async function runPipeline(
123
129
  const approvalGateway = options.approvalGateway ?? new InMemoryApprovalGateway();
124
130
  const maxLogRuns = options.maxLogRuns ?? 20;
125
131
  const registry = options.registry;
132
+ const runtime = options.runtime ?? bunRuntime();
126
133
  if (!registry) {
127
134
  throw new Error(
128
135
  'runPipeline requires options.registry. Use createTagma().run(...) for the public SDK API.',
@@ -192,6 +199,7 @@ export async function runPipeline(
192
199
  workDir,
193
200
  pipelineInfo,
194
201
  onEvent: options.onEvent,
202
+ runtime,
195
203
  });
196
204
 
197
205
  // Pipeline start hook (gate). Runs BEFORE the engine emits run_start so
package/src/index.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export { createTagma } from './tagma';
2
2
  export type { CreateTagmaOptions, Tagma, TagmaRunOptions } from './tagma';
3
+ export { bunRuntime } from './runtime';
4
+ export type { TagmaRuntime, RunOptions as RuntimeRunOptions } from './runtime';
3
5
  export { definePipeline } from './pipeline-definition';
4
6
  export { PluginRegistry } from './registry';
5
7
  export { TriggerBlockedError, TriggerTimeoutError } from './engine';
@@ -20,6 +22,9 @@ export type {
20
22
  TaskStatus,
21
23
  ApprovalRequest,
22
24
  PluginCategory,
25
+ PluginCapabilities,
26
+ PluginSetupContext,
27
+ TagmaPlugin,
23
28
  DriverPlugin,
24
29
  TriggerPlugin,
25
30
  CompletionPlugin,
@@ -11,7 +11,7 @@ function makeDir(): string {
11
11
  return mkdtempSync(join(tmpdir(), 'tagma-pipeline-runner-'));
12
12
  }
13
13
 
14
- function portsPipeline(dir: string): PipelineConfig {
14
+ function bindingsPipeline(dir: string): PipelineConfig {
15
15
  const emit = join(dir, 'emit.js');
16
16
  writeFileSync(
17
17
  emit,
@@ -31,18 +31,14 @@ function portsPipeline(dir: string): PipelineConfig {
31
31
  id: 'up',
32
32
  name: 'up',
33
33
  command: `node "${emit}"`,
34
- ports: {
35
- outputs: [{ name: 'city', type: 'string' }],
36
- },
34
+ outputs: { city: { type: 'string' } },
37
35
  },
38
36
  {
39
37
  id: 'down',
40
38
  name: 'down',
41
39
  depends_on: ['up'],
42
40
  command: `node "${echo}" "{{inputs.city}}"`,
43
- ports: {
44
- inputs: [{ name: 'city', type: 'string', required: true }],
45
- },
41
+ inputs: { city: { from: 't.up.outputs.city', type: 'string', required: true } },
46
42
  },
47
43
  ],
48
44
  },
@@ -67,7 +63,7 @@ describe('PipelineRunner task snapshot', () => {
67
63
  test('getTasks reflects task_update inputs and outputs', async () => {
68
64
  const dir = makeDir();
69
65
  try {
70
- const runner = await run(portsPipeline(dir), dir);
66
+ const runner = await run(bindingsPipeline(dir), dir);
71
67
 
72
68
  const tasks = runner.getTasks();
73
69
  const up = tasks.get('t.up');
@@ -82,7 +78,7 @@ describe('PipelineRunner task snapshot', () => {
82
78
  test('getTasks folds streamed task logs into the task snapshot', async () => {
83
79
  const dir = makeDir();
84
80
  try {
85
- const runner = await run(portsPipeline(dir), dir);
81
+ const runner = await run(bindingsPipeline(dir), dir);
86
82
 
87
83
  const tasks = runner.getTasks();
88
84
  const up = tasks.get('t.up');