@tagma/sdk 0.7.1 → 0.7.4

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 (108) hide show
  1. package/README.md +109 -48
  2. package/dist/adapters/stdin-approval.d.ts +1 -5
  3. package/dist/adapters/stdin-approval.d.ts.map +1 -1
  4. package/dist/adapters/stdin-approval.js +1 -89
  5. package/dist/adapters/stdin-approval.js.map +1 -1
  6. package/dist/adapters/websocket-approval.d.ts +1 -27
  7. package/dist/adapters/websocket-approval.d.ts.map +1 -1
  8. package/dist/adapters/websocket-approval.js +1 -146
  9. package/dist/adapters/websocket-approval.js.map +1 -1
  10. package/dist/approval.d.ts +2 -12
  11. package/dist/approval.d.ts.map +1 -1
  12. package/dist/approval.js +1 -90
  13. package/dist/approval.js.map +1 -1
  14. package/dist/bootstrap.d.ts +21 -1
  15. package/dist/bootstrap.d.ts.map +1 -1
  16. package/dist/bootstrap.js +21 -11
  17. package/dist/bootstrap.js.map +1 -1
  18. package/dist/core/run-context.d.ts +3 -0
  19. package/dist/core/run-context.d.ts.map +1 -1
  20. package/dist/core/run-context.js +2 -0
  21. package/dist/core/run-context.js.map +1 -1
  22. package/dist/core/task-executor.d.ts.map +1 -1
  23. package/dist/core/task-executor.js +24 -37
  24. package/dist/core/task-executor.js.map +1 -1
  25. package/dist/engine.d.ts +8 -53
  26. package/dist/engine.d.ts.map +1 -1
  27. package/dist/engine.js +7 -294
  28. package/dist/engine.js.map +1 -1
  29. package/dist/index.d.ts +5 -5
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +2 -3
  32. package/dist/index.js.map +1 -1
  33. package/dist/logger.d.ts +2 -60
  34. package/dist/logger.d.ts.map +1 -1
  35. package/dist/logger.js +1 -153
  36. package/dist/logger.js.map +1 -1
  37. package/dist/plugins.d.ts +3 -3
  38. package/dist/plugins.d.ts.map +1 -1
  39. package/dist/plugins.js +1 -1
  40. package/dist/plugins.js.map +1 -1
  41. package/dist/registry.d.ts +2 -60
  42. package/dist/registry.d.ts.map +1 -1
  43. package/dist/registry.js +1 -253
  44. package/dist/registry.js.map +1 -1
  45. package/dist/runner.d.ts +1 -35
  46. package/dist/runner.d.ts.map +1 -1
  47. package/dist/runner.js +1 -610
  48. package/dist/runner.js.map +1 -1
  49. package/dist/runtime/adapters/stdin-approval.d.ts +2 -0
  50. package/dist/runtime/adapters/stdin-approval.d.ts.map +1 -0
  51. package/dist/runtime/adapters/stdin-approval.js +2 -0
  52. package/dist/runtime/adapters/stdin-approval.js.map +1 -0
  53. package/dist/runtime/adapters/websocket-approval.d.ts +2 -0
  54. package/dist/runtime/adapters/websocket-approval.d.ts.map +1 -0
  55. package/dist/runtime/adapters/websocket-approval.js +2 -0
  56. package/dist/runtime/adapters/websocket-approval.js.map +1 -0
  57. package/dist/runtime/bun-process-runner.d.ts +2 -0
  58. package/dist/runtime/bun-process-runner.d.ts.map +1 -0
  59. package/dist/runtime/bun-process-runner.js +2 -0
  60. package/dist/runtime/bun-process-runner.js.map +1 -0
  61. package/dist/runtime.d.ts +3 -0
  62. package/dist/runtime.d.ts.map +1 -0
  63. package/dist/runtime.js +2 -0
  64. package/dist/runtime.js.map +1 -0
  65. package/dist/schema.d.ts.map +1 -1
  66. package/dist/schema.js +1 -7
  67. package/dist/schema.js.map +1 -1
  68. package/dist/tagma.d.ts +13 -4
  69. package/dist/tagma.d.ts.map +1 -1
  70. package/dist/tagma.js +7 -2
  71. package/dist/tagma.js.map +1 -1
  72. package/dist/triggers/file.d.ts.map +1 -1
  73. package/dist/triggers/file.js +74 -107
  74. package/dist/triggers/file.js.map +1 -1
  75. package/dist/validate-raw.d.ts.map +1 -1
  76. package/dist/validate-raw.js +1 -101
  77. package/dist/validate-raw.js.map +1 -1
  78. package/package.json +15 -4
  79. package/src/adapters/stdin-approval.ts +1 -106
  80. package/src/adapters/websocket-approval.ts +1 -224
  81. package/src/approval.ts +5 -127
  82. package/src/bootstrap.ts +24 -15
  83. package/src/core/run-context.test.ts +47 -0
  84. package/src/core/run-context.ts +4 -0
  85. package/src/core/task-executor.ts +28 -45
  86. package/src/engine-ports-mixed.test.ts +70 -44
  87. package/src/engine-ports.test.ts +77 -33
  88. package/src/engine.ts +21 -439
  89. package/src/index.ts +7 -4
  90. package/src/logger.ts +2 -182
  91. package/src/package-split.test.ts +15 -0
  92. package/src/pipeline-runner.test.ts +65 -12
  93. package/src/plugin-registry.test.ts +207 -4
  94. package/src/plugins.ts +6 -3
  95. package/src/registry.ts +7 -298
  96. package/src/runner.ts +1 -666
  97. package/src/runtime/adapters/stdin-approval.ts +1 -0
  98. package/src/runtime/adapters/websocket-approval.ts +1 -0
  99. package/src/runtime/bun-process-runner.ts +1 -0
  100. package/src/runtime-adapters.test.ts +10 -0
  101. package/src/runtime.ts +12 -0
  102. package/src/schema-ports.test.ts +23 -0
  103. package/src/schema.ts +1 -7
  104. package/src/tagma.test.ts +234 -1
  105. package/src/tagma.ts +24 -4
  106. package/src/triggers/file.test.ts +79 -0
  107. package/src/triggers/file.ts +85 -118
  108. package/src/validate-raw.ts +1 -117
package/src/bootstrap.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { PluginRegistry } from './registry';
1
+ import type { PluginRegistry } from '@tagma/core';
2
+ import type { TagmaPlugin } from './types';
2
3
 
3
4
  // Built-in Drivers
4
5
  // Only opencode is built in. Other drivers (codex, claude-code) ship as
@@ -19,6 +20,27 @@ import { OutputCheckCompletion } from './completions/output-check';
19
20
  // Built-in Middleware
20
21
  import { StaticContextMiddleware } from './middlewares/static-context';
21
22
 
23
+ export const BuiltinTagmaPlugin = {
24
+ name: '@tagma/sdk/builtins',
25
+ capabilities: {
26
+ drivers: {
27
+ opencode: OpenCodeDriver,
28
+ },
29
+ triggers: {
30
+ file: FileTrigger,
31
+ manual: ManualTrigger,
32
+ },
33
+ completions: {
34
+ exit_code: ExitCodeCompletion,
35
+ file_exists: FileExistsCompletion,
36
+ output_check: OutputCheckCompletion,
37
+ },
38
+ middlewares: {
39
+ static_context: StaticContextMiddleware,
40
+ },
41
+ },
42
+ } satisfies TagmaPlugin;
43
+
22
44
  /**
23
45
  * Register every built-in plugin into `target`. Hosts instantiate one
24
46
  * PluginRegistry per workspace or SDK instance and call this once per
@@ -29,18 +51,5 @@ import { StaticContextMiddleware } from './middlewares/static-context';
29
51
  * handler object into N registries is cheap and safe; no cloning is needed.
30
52
  */
31
53
  export function bootstrapBuiltins(target: PluginRegistry): void {
32
- // Drivers
33
- target.registerPlugin('drivers', 'opencode', OpenCodeDriver);
34
-
35
- // Triggers
36
- target.registerPlugin('triggers', 'file', FileTrigger);
37
- target.registerPlugin('triggers', 'manual', ManualTrigger);
38
-
39
- // Completions
40
- target.registerPlugin('completions', 'exit_code', ExitCodeCompletion);
41
- target.registerPlugin('completions', 'file_exists', FileExistsCompletion);
42
- target.registerPlugin('completions', 'output_check', OutputCheckCompletion);
43
-
44
- // Middlewares
45
- target.registerPlugin('middlewares', 'static_context', StaticContextMiddleware);
54
+ target.registerTagmaPlugin(BuiltinTagmaPlugin);
46
55
  }
@@ -3,6 +3,51 @@ import { RunContext } from './run-context';
3
3
  import { buildDag } from '../dag';
4
4
  import type { PipelineConfig, RunEventPayload } from '../types';
5
5
  import type { PipelineInfo } from '../hooks';
6
+ import type { TagmaRuntime } from '../runtime';
7
+
8
+ const fakeRuntime: TagmaRuntime = {
9
+ async runCommand() {
10
+ throw new Error('fakeRuntime.runCommand should not be called by RunContext tests');
11
+ },
12
+ async runSpawn() {
13
+ throw new Error('fakeRuntime.runSpawn should not be called by RunContext tests');
14
+ },
15
+ async ensureDir() {
16
+ /* no-op */
17
+ },
18
+ async fileExists() {
19
+ return false;
20
+ },
21
+ async *watch() {
22
+ /* no-op */
23
+ },
24
+ logStore: {
25
+ openRunLog() {
26
+ return {
27
+ path: 'mem://pipeline.log',
28
+ dir: 'mem://run',
29
+ append() {
30
+ /* no-op */
31
+ },
32
+ close() {
33
+ /* no-op */
34
+ },
35
+ };
36
+ },
37
+ taskOutputPath({ taskId, stream }) {
38
+ return `mem://${taskId}.${stream}`;
39
+ },
40
+ logsDir() {
41
+ return 'mem://logs';
42
+ },
43
+ },
44
+ now() {
45
+ return new Date('2026-04-26T00:00:00.000Z');
46
+ },
47
+ sleep() {
48
+ return Promise.resolve();
49
+ },
50
+ };
6
51
 
7
52
  function makeContext(overrides: Partial<{
8
53
  config: PipelineConfig;
@@ -30,6 +75,7 @@ function makeContext(overrides: Partial<{
30
75
  workDir: '/tmp/wd',
31
76
  pipelineInfo: { name: config.name, run_id: 'run_test', started_at: '2026-04-26T00:00:00Z' } as PipelineInfo,
32
77
  onEvent,
78
+ runtime: fakeRuntime,
33
79
  });
34
80
  return { ctx, events };
35
81
  }
@@ -83,6 +129,7 @@ describe('RunContext.emit', () => {
83
129
  config,
84
130
  workDir: '/tmp/wd',
85
131
  pipelineInfo: { name: 'p', run_id: 'run_test', started_at: 'now' } as PipelineInfo,
132
+ runtime: fakeRuntime,
86
133
  });
87
134
  expect(() => ctx.emit({ type: 'run_end', runId: 'run_test', success: true, abortReason: null })).not.toThrow();
88
135
  });
@@ -17,6 +17,7 @@ import {
17
17
  type TaskInfo,
18
18
  type TrackInfo,
19
19
  } from '../hooks';
20
+ import type { TagmaRuntime } from '../runtime';
20
21
  import { isTerminal } from './run-state';
21
22
  import { nowISO } from '../utils';
22
23
 
@@ -33,6 +34,7 @@ export interface RunContextOptions {
33
34
  readonly workDir: string;
34
35
  readonly pipelineInfo: PipelineInfo;
35
36
  readonly onEvent?: (event: RunEventPayload) => void;
37
+ readonly runtime: TagmaRuntime;
36
38
  }
37
39
 
38
40
  /**
@@ -49,6 +51,7 @@ export class RunContext {
49
51
  readonly workDir: string;
50
52
  readonly pipelineInfo: PipelineInfo;
51
53
  readonly onEvent?: (event: RunEventPayload) => void;
54
+ readonly runtime: TagmaRuntime;
52
55
 
53
56
  readonly states = new Map<string, TaskState>();
54
57
  readonly sessionMap = new Map<string, string>();
@@ -67,6 +70,7 @@ export class RunContext {
67
70
  this.workDir = options.workDir;
68
71
  this.pipelineInfo = options.pipelineInfo;
69
72
  this.onEvent = options.onEvent;
73
+ this.runtime = options.runtime;
70
74
 
71
75
  for (const [id, node] of this.dag.nodes) {
72
76
  this.states.set(id, {
@@ -1,4 +1,3 @@
1
- import { resolve } from 'path';
2
1
  import type {
3
2
  CompletionPlugin,
4
3
  DriverContext,
@@ -12,7 +11,6 @@ import type {
12
11
  TriggerPlugin,
13
12
  } from '../types';
14
13
  import type { PluginRegistry } from '../registry';
15
- import { runCommand, runSpawn } from '../runner';
16
14
  import { parseDuration, nowISO } from '../utils';
17
15
  import {
18
16
  promptDocumentFromString,
@@ -137,6 +135,7 @@ export async function executeTask(options: ExecuteTaskOptions): Promise<void> {
137
135
  workDir: task.cwd ?? workDir,
138
136
  signal: ctx.abortController.signal,
139
137
  approvalGateway,
138
+ runtime: ctx.runtime,
140
139
  })
141
140
  .then(
142
141
  (v) => {
@@ -396,9 +395,18 @@ export async function executeTask(options: ExecuteTaskOptions): Promise<void> {
396
395
  // and keep only a bounded tail in the returned TaskResult. Filenames
397
396
  // mirror the existing `.stderr` naming — dots in task ids are replaced
398
397
  // so hierarchical ids (e.g. `track1.task2`) map cleanly to a flat dir.
399
- const fsSafeTaskId = taskId.replace(/\./g, '_');
400
- const stdoutPath = resolve(log.dir, `${fsSafeTaskId}.stdout`);
401
- const stderrPath = resolve(log.dir, `${fsSafeTaskId}.stderr`);
398
+ const stdoutPath = ctx.runtime.logStore.taskOutputPath({
399
+ workDir,
400
+ runId: ctx.runId,
401
+ taskId,
402
+ stream: 'stdout',
403
+ });
404
+ const stderrPath = ctx.runtime.logStore.taskOutputPath({
405
+ workDir,
406
+ runId: ctx.runId,
407
+ taskId,
408
+ stream: 'stderr',
409
+ });
402
410
  const runOpts = {
403
411
  timeoutMs,
404
412
  signal: ctx.abortController.signal,
@@ -422,7 +430,7 @@ export async function executeTask(options: ExecuteTaskOptions): Promise<void> {
422
430
  );
423
431
  }
424
432
  log.debug(`[task:${taskId}]`, `command: ${expandedCommand}`);
425
- result = await runCommand(expandedCommand, task.cwd ?? workDir, runOpts);
433
+ result = await ctx.runtime.runCommand(expandedCommand, task.cwd ?? workDir, runOpts);
426
434
  } else {
427
435
  // AI task: apply middleware chain against a structured PromptDocument.
428
436
  const driverName = task.driver ?? track.driver ?? config.driver ?? 'opencode';
@@ -466,48 +474,23 @@ export async function executeTask(options: ExecuteTaskOptions): Promise<void> {
466
474
  const beforeBlocks = doc.contexts.length;
467
475
  const beforeLen = serializePromptDocument(doc).length;
468
476
 
469
- // Prefer the structured API. Fall back to the legacy
470
- // `enhance(string) → string` path so v0.x plugins keep
471
- // working that fallback loses context structure (the
472
- // middleware's output becomes the new task body) but never
473
- // silently drops content.
474
- if (typeof mwPlugin.enhanceDoc === 'function') {
475
- const next = await mwPlugin.enhanceDoc(doc, mwConfig as Record<string, unknown>, mwCtx);
476
- if (
477
- !next ||
478
- typeof next !== 'object' ||
479
- !Array.isArray((next as PromptDocument).contexts) ||
480
- typeof (next as PromptDocument).task !== 'string'
481
- ) {
482
- throw new Error(
483
- `middleware "${mwConfig.type}".enhanceDoc() returned a malformed PromptDocument`,
484
- );
485
- }
486
- doc = next as PromptDocument;
487
- } else if (typeof mwPlugin.enhance === 'function') {
488
- const asString = serializePromptDocument(doc);
489
- const next = await mwPlugin.enhance(
490
- asString,
491
- mwConfig as Record<string, unknown>,
492
- mwCtx,
477
+ if (typeof mwPlugin.enhanceDoc !== 'function') {
478
+ throw new Error(
479
+ `middleware "${mwConfig.type}" must provide enhanceDoc`,
493
480
  );
494
- // R3: a middleware that returns undefined / null / a non-string
495
- // would silently corrupt the prompt. Fail loud.
496
- if (typeof next !== 'string') {
497
- throw new Error(
498
- `middleware "${mwConfig.type}".enhance() returned ${next === null ? 'null' : typeof next}, expected string`,
499
- );
500
- }
501
- // Legacy fallback: collapse the returned string into a
502
- // fresh doc. Earlier structure is folded into the string
503
- // (serializePromptDocument just ran), so bytes the driver
504
- // sees match the old string pipeline.
505
- doc = { contexts: [], task: next };
506
- } else {
481
+ }
482
+ const next = await mwPlugin.enhanceDoc(doc, mwConfig as Record<string, unknown>, mwCtx);
483
+ if (
484
+ !next ||
485
+ typeof next !== 'object' ||
486
+ !Array.isArray((next as PromptDocument).contexts) ||
487
+ typeof (next as PromptDocument).task !== 'string'
488
+ ) {
507
489
  throw new Error(
508
- `middleware "${mwConfig.type}" provides neither enhanceDoc nor enhance`,
490
+ `middleware "${mwConfig.type}".enhanceDoc() returned a malformed PromptDocument`,
509
491
  );
510
492
  }
493
+ doc = next as PromptDocument;
511
494
  const afterLen = serializePromptDocument(doc).length;
512
495
  const addedBlocks = doc.contexts.length - beforeBlocks;
513
496
  log.debug(
@@ -567,7 +550,7 @@ export async function executeTask(options: ExecuteTaskOptions): Promise<void> {
567
550
  if (spec.env)
568
551
  log.debug(`[task:${taskId}]`, `spawn env overrides: ${Object.keys(spec.env).join(', ')}`);
569
552
  if (spec.stdin) log.debug(`[task:${taskId}]`, `spawn stdin: ${spec.stdin.length} chars`);
570
- result = await runSpawn(spec, driver, runOpts);
553
+ result = await ctx.runtime.runSpawn(spec, driver, runOpts);
571
554
  }
572
555
 
573
556
  // 6. Determine terminal status (without emitting yet — result must be complete first)
@@ -5,7 +5,7 @@ import { join } from 'node:path';
5
5
  import { bootstrapBuiltins } from './bootstrap';
6
6
  import { runPipeline, type RunEventPayload } from './engine';
7
7
  import { PluginRegistry } from './registry';
8
- import type { DriverPlugin, PipelineConfig, TaskConfig } from './types';
8
+ import type { DriverPlugin, PipelineConfig, TagmaRuntime, TaskConfig, TaskResult } from './types';
9
9
 
10
10
  const PERMS = { read: true, write: false, execute: false };
11
11
 
@@ -13,40 +13,7 @@ function makeDir(): string {
13
13
  return mkdtempSync(join(tmpdir(), 'tagma-bindings-mixed-'));
14
14
  }
15
15
 
16
- function writeEmitScript(dir: string, name: string, payload: Record<string, unknown>): string {
17
- const path = join(dir, `${name}.js`);
18
- writeFileSync(
19
- path,
20
- `process.stdout.write(${JSON.stringify(JSON.stringify(payload))});\nprocess.stdout.write('\\n');\n`,
21
- );
22
- return path;
23
- }
24
-
25
- function writeEchoArgsScript(dir: string): string {
26
- const path = join(dir, 'echo.js');
27
- writeFileSync(path, `process.stdout.write(process.argv.slice(2).join('|'));\n`);
28
- return path;
29
- }
30
-
31
- function writeMockDriverScript(dir: string): string {
32
- const path = join(dir, 'mock-driver.js');
33
- writeFileSync(
34
- path,
35
- [
36
- `const fs = require('fs');`,
37
- `let buf = '';`,
38
- `process.stdin.setEncoding('utf8');`,
39
- `process.stdin.on('data', (c) => { buf += c; });`,
40
- `process.stdin.on('end', () => {`,
41
- ` fs.writeFileSync(process.env.MOCK_RECORD_PATH, buf);`,
42
- ` process.stdout.write(process.env.MOCK_RESPONSE + '\\n');`,
43
- `});`,
44
- ].join('\n'),
45
- );
46
- return path;
47
- }
48
-
49
- function registry(script: string, responses: Record<string, Record<string, unknown>>, records: Record<string, string>) {
16
+ function registry(responses: Record<string, Record<string, unknown>>, records: Record<string, string>) {
50
17
  const reg = new PluginRegistry();
51
18
  bootstrapBuiltins(reg);
52
19
  const driver: DriverPlugin = {
@@ -54,7 +21,7 @@ function registry(script: string, responses: Record<string, Record<string, unkno
54
21
  capabilities: { sessionResume: false, systemPrompt: true, outputFormat: true },
55
22
  async buildCommand(task) {
56
23
  return {
57
- args: ['node', script],
24
+ args: ['mock-driver', task.id],
58
25
  stdin: task.prompt ?? '',
59
26
  env: {
60
27
  MOCK_RESPONSE: JSON.stringify(responses[task.id] ?? {}),
@@ -85,12 +52,75 @@ async function run(config: PipelineConfig, workDir: string, reg: PluginRegistry)
85
52
  const events: RunEventPayload[] = [];
86
53
  const result = await runPipeline(config, workDir, {
87
54
  registry: reg,
55
+ runtime: fakeRuntime(),
88
56
  skipPluginLoading: true,
89
57
  onEvent: (e) => events.push(e),
90
58
  });
91
59
  return { events, success: result.success };
92
60
  }
93
61
 
62
+ function taskResult(stdout: string, normalizedOutput: string | null = null): TaskResult {
63
+ return {
64
+ exitCode: 0,
65
+ stdout,
66
+ stderr: '',
67
+ stdoutPath: null,
68
+ stderrPath: null,
69
+ stdoutBytes: stdout.length,
70
+ stderrBytes: 0,
71
+ durationMs: 1,
72
+ sessionId: null,
73
+ normalizedOutput,
74
+ failureKind: null,
75
+ };
76
+ }
77
+
78
+ function fakeRuntime(): TagmaRuntime {
79
+ return {
80
+ async runCommand(command) {
81
+ if (command.startsWith('emit-city')) return taskResult('{"city":"Berlin"}\n');
82
+ return taskResult('ok\n');
83
+ },
84
+ async runSpawn(spec) {
85
+ const response = spec.env?.['MOCK_RESPONSE'] ?? '{}';
86
+ const recordPath = spec.env?.['MOCK_RECORD_PATH'];
87
+ if (recordPath) writeFileSync(recordPath, spec.stdin ?? '');
88
+ return taskResult(response + '\n', response);
89
+ },
90
+ async ensureDir() {
91
+ /* no-op */
92
+ },
93
+ async fileExists() {
94
+ return false;
95
+ },
96
+ async *watch() {
97
+ /* no-op */
98
+ },
99
+ logStore: {
100
+ openRunLog({ runId }) {
101
+ return {
102
+ path: `mem://${runId}/pipeline.log`,
103
+ dir: `mem://${runId}`,
104
+ append() {
105
+ /* memory sink */
106
+ },
107
+ close() {
108
+ /* memory sink */
109
+ },
110
+ };
111
+ },
112
+ taskOutputPath({ runId, taskId, stream }) {
113
+ return `mem://${runId}/${taskId}.${stream}`;
114
+ },
115
+ logsDir() {
116
+ return 'mem://logs';
117
+ },
118
+ },
119
+ now: () => new Date('2026-04-26T00:00:00.000Z'),
120
+ sleep: () => Promise.resolve(),
121
+ };
122
+ }
123
+
94
124
  function finalUpdateFor(events: RunEventPayload[], qid: string): RunEventPayload | undefined {
95
125
  let last: RunEventPayload | undefined;
96
126
  for (const ev of events) {
@@ -103,17 +133,15 @@ describe('engine — mixed prompt/command unified bindings', () => {
103
133
  test('prompt outputs are inferred from downstream command inputs', async () => {
104
134
  const dir = makeDir();
105
135
  try {
106
- const driverScript = writeMockDriverScript(dir);
107
- const echo = writeEchoArgsScript(dir);
108
136
  const record = join(dir, 'prompt.txt');
109
- const reg = registry(driverScript, { plan: { city: 'Paris' } }, { plan: record });
137
+ const reg = registry({ plan: { city: 'Paris' } }, { plan: record });
110
138
  const config = pipeline([
111
139
  task({ id: 'plan', prompt: 'Pick a city' }),
112
140
  task({
113
141
  id: 'fetch',
114
142
  driver: 'opencode',
115
143
  depends_on: ['plan'],
116
- command: `node "${echo}" "{{inputs.city}}"`,
144
+ command: 'echo-city "{{inputs.city}}"',
117
145
  inputs: { city: { from: 't.plan.outputs.city', type: 'string', required: true } },
118
146
  }),
119
147
  ]);
@@ -131,15 +159,13 @@ describe('engine — mixed prompt/command unified bindings', () => {
131
159
  test('prompt inputs are inferred from upstream command outputs', async () => {
132
160
  const dir = makeDir();
133
161
  try {
134
- const emit = writeEmitScript(dir, 'emit', { city: 'Berlin' });
135
- const driverScript = writeMockDriverScript(dir);
136
162
  const record = join(dir, 'prompt.txt');
137
- const reg = registry(driverScript, { summarize: {} }, { summarize: record });
163
+ const reg = registry({ summarize: {} }, { summarize: record });
138
164
  const config = pipeline([
139
165
  task({
140
166
  id: 'up',
141
167
  driver: 'opencode',
142
- command: `node "${emit}"`,
168
+ command: 'emit-city',
143
169
  outputs: { city: { type: 'string' } },
144
170
  }),
145
171
  task({ id: 'summarize', depends_on: ['up'], prompt: 'City is {{inputs.city}}' }),
@@ -1,11 +1,11 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { bootstrapBuiltins } from './bootstrap';
6
6
  import { runPipeline, type RunEventPayload } from './engine';
7
7
  import { PluginRegistry } from './registry';
8
- import type { PipelineConfig, TaskConfig, TaskStatus } from './types';
8
+ import type { PipelineConfig, TaskConfig, TagmaRuntime, TaskResult, TaskStatus } from './types';
9
9
 
10
10
  const PERMS = { read: true, write: false, execute: false };
11
11
 
@@ -19,21 +19,6 @@ function makeDir(): string {
19
19
  return mkdtempSync(join(tmpdir(), 'tagma-bindings-'));
20
20
  }
21
21
 
22
- function writeEmitScript(dir: string, name: string, payload: Record<string, unknown>): string {
23
- const path = join(dir, `${name}.js`);
24
- writeFileSync(
25
- path,
26
- `process.stdout.write(${JSON.stringify(JSON.stringify(payload))});\nprocess.stdout.write('\\n');\n`,
27
- );
28
- return path;
29
- }
30
-
31
- function writeEchoArgsScript(dir: string, name: string): string {
32
- const path = join(dir, `${name}.js`);
33
- writeFileSync(path, `process.stdout.write(process.argv.slice(2).join('|'));\n`);
34
- return path;
35
- }
36
-
37
22
  function task(overrides: Partial<TaskConfig> & { id: string }): TaskConfig {
38
23
  return {
39
24
  name: overrides.id,
@@ -59,10 +44,72 @@ function pipeline(tasks: TaskConfig[]): PipelineConfig {
59
44
  };
60
45
  }
61
46
 
62
- async function run(config: PipelineConfig, workDir: string) {
47
+ function taskResult(stdout: string): TaskResult {
48
+ return {
49
+ exitCode: 0,
50
+ stdout,
51
+ stderr: '',
52
+ stdoutPath: null,
53
+ stderrPath: null,
54
+ stdoutBytes: stdout.length,
55
+ stderrBytes: 0,
56
+ durationMs: 1,
57
+ sessionId: null,
58
+ normalizedOutput: null,
59
+ failureKind: null,
60
+ };
61
+ }
62
+
63
+ function fakeRuntime(commandStdout: Record<string, string>): TagmaRuntime {
64
+ return {
65
+ async runCommand(command) {
66
+ for (const [prefix, stdout] of Object.entries(commandStdout)) {
67
+ if (command.startsWith(prefix)) return taskResult(stdout);
68
+ }
69
+ return taskResult('Shanghai|42\n');
70
+ },
71
+ async runSpawn() {
72
+ throw new Error('runSpawn should not be called');
73
+ },
74
+ async ensureDir() {
75
+ /* no-op */
76
+ },
77
+ async fileExists() {
78
+ return false;
79
+ },
80
+ async *watch() {
81
+ /* no-op */
82
+ },
83
+ logStore: {
84
+ openRunLog({ runId }) {
85
+ return {
86
+ path: `mem://${runId}/pipeline.log`,
87
+ dir: `mem://${runId}`,
88
+ append() {
89
+ /* memory sink */
90
+ },
91
+ close() {
92
+ /* memory sink */
93
+ },
94
+ };
95
+ },
96
+ taskOutputPath({ runId, taskId, stream }) {
97
+ return `mem://${runId}/${taskId}.${stream}`;
98
+ },
99
+ logsDir() {
100
+ return 'mem://logs';
101
+ },
102
+ },
103
+ now: () => new Date('2026-04-26T00:00:00.000Z'),
104
+ sleep: () => Promise.resolve(),
105
+ };
106
+ }
107
+
108
+ async function run(config: PipelineConfig, workDir: string, runtime: TagmaRuntime) {
63
109
  const events: RunEventPayload[] = [];
64
110
  const result = await runPipeline(config, workDir, {
65
111
  registry: freshRegistry(),
112
+ runtime,
66
113
  skipPluginLoading: true,
67
114
  onEvent: (e) => events.push(e),
68
115
  });
@@ -86,18 +133,17 @@ describe('engine — unified inputs and outputs', () => {
86
133
  test('typed outputs feed typed inputs and command placeholders', async () => {
87
134
  const dir = makeDir();
88
135
  try {
89
- const emit = writeEmitScript(dir, 'emit', { id: '42', city: 'Shanghai' });
90
- const echo = writeEchoArgsScript(dir, 'echo');
136
+ const runtime = fakeRuntime({ 'emit-valid': '{"id":"42","city":"Shanghai"}\n' });
91
137
  const config = pipeline([
92
138
  task({
93
139
  id: 'up',
94
- command: `node "${emit}"`,
140
+ command: 'emit-valid',
95
141
  outputs: { id: { type: 'number' }, city: { type: 'string' } },
96
142
  }),
97
143
  task({
98
144
  id: 'down',
99
145
  depends_on: ['up'],
100
- command: `node "${echo}" "{{inputs.city}}" "{{inputs.id}}"`,
146
+ command: 'echo-down "{{inputs.city}}" "{{inputs.id}}"',
101
147
  inputs: {
102
148
  city: { from: 't.up.outputs.city', type: 'string', required: true },
103
149
  id: { from: 't.up.outputs.id', type: 'number', required: true },
@@ -105,7 +151,7 @@ describe('engine — unified inputs and outputs', () => {
105
151
  }),
106
152
  ]);
107
153
 
108
- const { events, success } = await run(config, dir);
154
+ const { events, success } = await run(config, dir, runtime);
109
155
  expect(success).toBe(true);
110
156
  expect(finalUpdateFor(events, 't.up')?.outputs).toEqual({ id: 42, city: 'Shanghai' });
111
157
  expect(finalUpdateFor(events, 't.down')?.inputs).toEqual({ city: 'Shanghai', id: 42 });
@@ -118,19 +164,18 @@ describe('engine — unified inputs and outputs', () => {
118
164
  test('missing required unified input blocks without spawning downstream', async () => {
119
165
  const dir = makeDir();
120
166
  try {
121
- const emit = writeEmitScript(dir, 'emit', { other: 'x' });
122
- const echo = writeEchoArgsScript(dir, 'echo');
167
+ const runtime = fakeRuntime({ 'emit-missing': '{"other":"x"}\n' });
123
168
  const config = pipeline([
124
- task({ id: 'up', command: `node "${emit}"`, outputs: { city: { type: 'string' } } }),
169
+ task({ id: 'up', command: 'emit-missing', outputs: { city: { type: 'string' } } }),
125
170
  task({
126
171
  id: 'down',
127
172
  depends_on: ['up'],
128
- command: `node "${echo}" "{{inputs.city}}"`,
173
+ command: 'echo-down "{{inputs.city}}"',
129
174
  inputs: { city: { from: 't.up.outputs.city', type: 'string', required: true } },
130
175
  }),
131
176
  ]);
132
177
 
133
- const { events, success } = await run(config, dir);
178
+ const { events, success } = await run(config, dir, runtime);
134
179
  expect(success).toBe(false);
135
180
  expect(finalStatusFrom(events, 't.up')).toBe('success');
136
181
  expect(finalStatusFrom(events, 't.down')).toBe('blocked');
@@ -142,19 +187,18 @@ describe('engine — unified inputs and outputs', () => {
142
187
  test('typed output coercion diagnostics leave missing downstream input', async () => {
143
188
  const dir = makeDir();
144
189
  try {
145
- const emit = writeEmitScript(dir, 'emit', { id: 'not-a-number' });
146
- const echo = writeEchoArgsScript(dir, 'echo');
190
+ const runtime = fakeRuntime({ 'emit-bad': '{"id":"not-a-number"}\n' });
147
191
  const config = pipeline([
148
- task({ id: 'up', command: `node "${emit}"`, outputs: { id: { type: 'number' } } }),
192
+ task({ id: 'up', command: 'emit-bad', outputs: { id: { type: 'number' } } }),
149
193
  task({
150
194
  id: 'down',
151
195
  depends_on: ['up'],
152
- command: `node "${echo}" "{{inputs.id}}"`,
196
+ command: 'echo-down "{{inputs.id}}"',
153
197
  inputs: { id: { from: 't.up.outputs.id', type: 'number', required: true } },
154
198
  }),
155
199
  ]);
156
200
 
157
- const { events, success } = await run(config, dir);
201
+ const { events, success } = await run(config, dir, runtime);
158
202
  expect(success).toBe(false);
159
203
  expect(finalStatusFrom(events, 't.up')).toBe('success');
160
204
  expect(finalUpdateFor(events, 't.up')?.stderr).toContain('expected number');