@tagma/sdk 0.7.3 → 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 (92) hide show
  1. package/README.md +26 -5
  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 +1 -1
  15. package/dist/bootstrap.d.ts.map +1 -1
  16. package/dist/core/task-executor.d.ts.map +1 -1
  17. package/dist/core/task-executor.js +13 -4
  18. package/dist/core/task-executor.js.map +1 -1
  19. package/dist/engine.d.ts +5 -56
  20. package/dist/engine.d.ts.map +1 -1
  21. package/dist/engine.js +7 -297
  22. package/dist/engine.js.map +1 -1
  23. package/dist/index.d.ts +4 -6
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +2 -4
  26. package/dist/index.js.map +1 -1
  27. package/dist/logger.d.ts +2 -60
  28. package/dist/logger.d.ts.map +1 -1
  29. package/dist/logger.js +1 -153
  30. package/dist/logger.js.map +1 -1
  31. package/dist/plugins.d.ts +2 -2
  32. package/dist/plugins.d.ts.map +1 -1
  33. package/dist/plugins.js +1 -1
  34. package/dist/plugins.js.map +1 -1
  35. package/dist/registry.d.ts +2 -66
  36. package/dist/registry.d.ts.map +1 -1
  37. package/dist/registry.js +1 -292
  38. package/dist/registry.js.map +1 -1
  39. package/dist/runner.d.ts +1 -35
  40. package/dist/runner.d.ts.map +1 -1
  41. package/dist/runner.js +1 -610
  42. package/dist/runner.js.map +1 -1
  43. package/dist/runtime/adapters/stdin-approval.d.ts +2 -0
  44. package/dist/runtime/adapters/stdin-approval.d.ts.map +1 -0
  45. package/dist/runtime/adapters/stdin-approval.js +2 -0
  46. package/dist/runtime/adapters/stdin-approval.js.map +1 -0
  47. package/dist/runtime/adapters/websocket-approval.d.ts +2 -0
  48. package/dist/runtime/adapters/websocket-approval.d.ts.map +1 -0
  49. package/dist/runtime/adapters/websocket-approval.js +2 -0
  50. package/dist/runtime/adapters/websocket-approval.js.map +1 -0
  51. package/dist/runtime/bun-process-runner.d.ts +2 -0
  52. package/dist/runtime/bun-process-runner.d.ts.map +1 -0
  53. package/dist/runtime/bun-process-runner.js +2 -0
  54. package/dist/runtime/bun-process-runner.js.map +1 -0
  55. package/dist/runtime.d.ts +2 -8
  56. package/dist/runtime.d.ts.map +1 -1
  57. package/dist/runtime.js +1 -7
  58. package/dist/runtime.js.map +1 -1
  59. package/dist/tagma.d.ts +3 -4
  60. package/dist/tagma.d.ts.map +1 -1
  61. package/dist/tagma.js +2 -3
  62. package/dist/tagma.js.map +1 -1
  63. package/dist/triggers/file.d.ts.map +1 -1
  64. package/dist/triggers/file.js +74 -107
  65. package/dist/triggers/file.js.map +1 -1
  66. package/package.json +15 -4
  67. package/src/adapters/stdin-approval.ts +1 -106
  68. package/src/adapters/websocket-approval.ts +1 -224
  69. package/src/approval.ts +5 -127
  70. package/src/bootstrap.ts +1 -1
  71. package/src/core/run-context.test.ts +35 -0
  72. package/src/core/task-executor.ts +13 -4
  73. package/src/engine-ports-mixed.test.ts +70 -44
  74. package/src/engine-ports.test.ts +77 -33
  75. package/src/engine.ts +18 -444
  76. package/src/index.ts +4 -6
  77. package/src/logger.ts +2 -182
  78. package/src/package-split.test.ts +15 -0
  79. package/src/pipeline-runner.test.ts +65 -12
  80. package/src/plugin-registry.test.ts +69 -3
  81. package/src/plugins.ts +2 -2
  82. package/src/registry.ts +7 -353
  83. package/src/runner.ts +1 -666
  84. package/src/runtime/adapters/stdin-approval.ts +1 -0
  85. package/src/runtime/adapters/websocket-approval.ts +1 -0
  86. package/src/runtime/bun-process-runner.ts +1 -0
  87. package/src/runtime-adapters.test.ts +10 -0
  88. package/src/runtime.ts +12 -20
  89. package/src/tagma.test.ts +162 -0
  90. package/src/tagma.ts +9 -4
  91. package/src/triggers/file.test.ts +79 -0
  92. package/src/triggers/file.ts +85 -118
@@ -1,224 +1 @@
1
- import type { ApprovalGateway, ApprovalEvent } from '../approval';
2
-
3
- // ═══ WebSocket Approval Adapter ═══
4
- //
5
- // Bridges the ApprovalGateway to WebSocket clients (e.g. a frontend UI).
6
- // Mirrors the stdin-approval adapter pattern: subscribe to gateway events,
7
- // forward them as JSON to all connected clients, and call gateway.resolve()
8
- // when a client sends a resolution message.
9
- //
10
- // Protocol — server → client:
11
- // { type: 'pending', requests: ApprovalRequest[] } ← sent on connect
12
- // { type: 'approval_requested', request: ApprovalRequest }
13
- // { type: 'approval_resolved', request: ApprovalRequest, decision: ApprovalDecision }
14
- // { type: 'approval_expired', request: ApprovalRequest }
15
- // { type: 'approval_aborted', request: ApprovalRequest, reason: string }
16
- //
17
- // Protocol — client → server:
18
- // { type: 'resolve', approvalId: string, outcome: 'approved'|'rejected',
19
- // actor?: string, reason?: string }
20
-
21
- export interface WebSocketApprovalAdapterOptions {
22
- port?: number; // default: 3000
23
- hostname?: string; // default: 'localhost'
24
- /**
25
- * M11: shared secret required from the client during the WebSocket
26
- * upgrade. The token can be supplied either as the `?token=` query
27
- * parameter or in the `x-tagma-token` request header. When set, any
28
- * upgrade request that fails the check is rejected with HTTP 401 and
29
- * never reaches the WebSocket layer (so a misconfigured client cannot
30
- * exhaust rate-limit slots either). Leave undefined for backward
31
- * compatibility with localhost-only deployments.
32
- */
33
- token?: string;
34
- /**
35
- * M11: opt-out of origin checking. Defaults to false, meaning Origin
36
- * headers are restricted to loopback hosts (localhost / 127.0.0.1 / ::1).
37
- * Requests without an Origin header are still allowed so non-browser local
38
- * clients can connect. Set true only for trusted reverse-proxy setups.
39
- */
40
- allowAnyOrigin?: boolean;
41
- }
42
-
43
- export interface WebSocketApprovalAdapter {
44
- readonly port: number;
45
- readonly detach: () => void;
46
- }
47
-
48
- // Maximum allowed message payload (bytes) to prevent DoS via oversized messages.
49
- const MAX_PAYLOAD_BYTES = 4_096;
50
- // Per-client rate limit: at most this many messages per window.
51
- const RATE_LIMIT_MAX = 10;
52
- const RATE_LIMIT_WINDOW_MS = 1_000;
53
-
54
- export function attachWebSocketApprovalAdapter(
55
- gateway: ApprovalGateway,
56
- options: WebSocketApprovalAdapterOptions = {},
57
- ): WebSocketApprovalAdapter {
58
- const port = options.port ?? 3000;
59
- const hostname = options.hostname ?? 'localhost';
60
- const requiredToken = options.token ?? null;
61
- const enforceOriginCheck = options.allowAnyOrigin !== true;
62
-
63
- function isLoopbackOrigin(origin: string): boolean {
64
- try {
65
- const host = new URL(origin).hostname.toLowerCase();
66
- return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
67
- } catch {
68
- return false;
69
- }
70
- }
71
-
72
- type WS = import('bun').ServerWebSocket<unknown>;
73
- const clients = new Set<WS>();
74
- const clientRates = new Map<WS, { count: number; resetAt: number }>();
75
-
76
- function broadcast(msg: unknown): void {
77
- const text = JSON.stringify(msg);
78
- for (const ws of clients) {
79
- ws.send(text);
80
- }
81
- }
82
-
83
- const unsubscribe = gateway.subscribe((event: ApprovalEvent) => {
84
- switch (event.type) {
85
- case 'requested':
86
- broadcast({ type: 'approval_requested', request: event.request });
87
- break;
88
- case 'resolved':
89
- broadcast({ type: 'approval_resolved', request: event.request, decision: event.decision });
90
- break;
91
- case 'expired':
92
- broadcast({ type: 'approval_expired', request: event.request });
93
- break;
94
- case 'aborted':
95
- broadcast({ type: 'approval_aborted', request: event.request, reason: event.reason });
96
- break;
97
- }
98
- });
99
-
100
- const server = Bun.serve({
101
- port,
102
- hostname,
103
-
104
- fetch(req, server) {
105
- if (enforceOriginCheck) {
106
- const origin = req.headers.get('origin');
107
- if (origin && !isLoopbackOrigin(origin)) {
108
- return new Response('forbidden origin', { status: 403 });
109
- }
110
- }
111
- // M11: enforce token before any upgrade so an unauthenticated client
112
- // can't even open a socket. Tokens may arrive via header or query.
113
- if (requiredToken !== null) {
114
- const headerToken = req.headers.get('x-tagma-token') ?? '';
115
- let queryToken = '';
116
- try {
117
- queryToken = new URL(req.url).searchParams.get('token') ?? '';
118
- } catch {
119
- /* malformed URL — leave queryToken empty */
120
- }
121
- const presented = headerToken || queryToken;
122
- if (presented !== requiredToken) {
123
- return new Response('unauthorized', { status: 401 });
124
- }
125
- }
126
- if (server.upgrade(req)) return undefined;
127
- return new Response('tagma-sdk WebSocket approval endpoint', { status: 426 });
128
- },
129
-
130
- websocket: {
131
- open(ws) {
132
- clients.add(ws);
133
- // Sync current pending approvals to newly connected client.
134
- ws.send(JSON.stringify({ type: 'pending', requests: gateway.pending() }));
135
- },
136
-
137
- message(ws, raw) {
138
- const rawStr = typeof raw === 'string' ? raw : raw.toString();
139
-
140
- // Payload size guard — reject oversized messages before parsing.
141
- if (rawStr.length > MAX_PAYLOAD_BYTES) {
142
- ws.send(JSON.stringify({ type: 'error', message: 'message too large' }));
143
- return;
144
- }
145
-
146
- // Per-client rate limit.
147
- const now = Date.now();
148
- const rate = clientRates.get(ws) ?? { count: 0, resetAt: now + RATE_LIMIT_WINDOW_MS };
149
- if (now >= rate.resetAt) {
150
- rate.count = 0;
151
- rate.resetAt = now + RATE_LIMIT_WINDOW_MS;
152
- }
153
- rate.count++;
154
- clientRates.set(ws, rate);
155
- if (rate.count > RATE_LIMIT_MAX) {
156
- ws.send(JSON.stringify({ type: 'error', message: 'rate limit exceeded' }));
157
- return;
158
- }
159
-
160
- let msg: unknown;
161
- try {
162
- msg = JSON.parse(rawStr);
163
- } catch {
164
- ws.send(JSON.stringify({ type: 'error', message: 'invalid JSON' }));
165
- return;
166
- }
167
-
168
- if (!isResolveMessage(msg)) {
169
- ws.send(JSON.stringify({ type: 'error', message: 'unknown message type' }));
170
- return;
171
- }
172
-
173
- const ok = gateway.resolve(msg.approvalId, {
174
- outcome: msg.outcome,
175
- actor: msg.actor ?? 'websocket',
176
- reason: msg.reason,
177
- });
178
-
179
- if (!ok) {
180
- ws.send(
181
- JSON.stringify({
182
- type: 'error',
183
- message: `approval ${msg.approvalId} not found or already resolved`,
184
- }),
185
- );
186
- }
187
- },
188
-
189
- close(ws) {
190
- clients.delete(ws);
191
- clientRates.delete(ws);
192
- },
193
- },
194
- });
195
-
196
- return {
197
- port: server.port!,
198
- detach() {
199
- unsubscribe();
200
- clients.clear();
201
- server.stop(true);
202
- },
203
- };
204
- }
205
-
206
- // ── Type guard ──
207
-
208
- interface ResolveMessage {
209
- type: 'resolve';
210
- approvalId: string;
211
- outcome: 'approved' | 'rejected';
212
- actor?: string;
213
- reason?: string;
214
- }
215
-
216
- function isResolveMessage(v: unknown): v is ResolveMessage {
217
- if (typeof v !== 'object' || v === null) return false;
218
- const m = v as Record<string, unknown>;
219
- return (
220
- m['type'] === 'resolve' &&
221
- typeof m['approvalId'] === 'string' &&
222
- (m['outcome'] === 'approved' || m['outcome'] === 'rejected')
223
- );
224
- }
1
+ export * from '../runtime/adapters/websocket-approval';
package/src/approval.ts CHANGED
@@ -1,131 +1,9 @@
1
- import { randomUUID } from 'crypto';
2
- import { nowISO } from './utils';
3
-
4
- // Approval types (ApprovalRequest, ApprovalDecision, ApprovalOutcome,
5
- // ApprovalEvent, ApprovalListener, ApprovalGateway) live in the shared
6
- // @tagma/types package so trigger plugins can import them without
7
- // depending on the engine's runtime implementation. This module keeps
8
- // only the in-memory implementation. Internal SDK imports go through
9
- // ./types (the engine-side re-export) for consistency with the rest of
10
- // the SDK source.
11
- import type {
12
- ApprovalRequest,
13
- ApprovalDecision,
14
- ApprovalEvent,
15
- ApprovalListener,
16
- ApprovalGateway,
17
- } from './types';
18
-
19
- // Re-export for existing engine-side consumers that import from this file.
1
+ export { InMemoryApprovalGateway } from '@tagma/core';
20
2
  export type {
21
- ApprovalRequest,
22
3
  ApprovalDecision,
23
- ApprovalOutcome,
24
4
  ApprovalEvent,
25
- ApprovalListener,
26
5
  ApprovalGateway,
27
- } from './types';
28
-
29
- // ═══ Default In-Memory Implementation ═══
30
-
31
- interface PendingEntry {
32
- readonly request: ApprovalRequest;
33
- readonly settle: (decision: ApprovalDecision) => void;
34
- readonly timer: ReturnType<typeof setTimeout> | null;
35
- }
36
-
37
- export class InMemoryApprovalGateway implements ApprovalGateway {
38
- private readonly pendingMap = new Map<string, PendingEntry>();
39
- private readonly listeners = new Set<ApprovalListener>();
40
-
41
- request(req: Omit<ApprovalRequest, 'id' | 'createdAt'>): Promise<ApprovalDecision> {
42
- const full: ApprovalRequest = {
43
- id: randomUUID(),
44
- createdAt: nowISO(),
45
- taskId: req.taskId,
46
- trackId: req.trackId,
47
- message: req.message,
48
- timeoutMs: req.timeoutMs,
49
- metadata: req.metadata,
50
- };
51
-
52
- return new Promise<ApprovalDecision>((resolvePromise) => {
53
- let timer: ReturnType<typeof setTimeout> | null = null;
54
- if (full.timeoutMs > 0) {
55
- timer = setTimeout(() => {
56
- const entry = this.pendingMap.get(full.id);
57
- if (!entry) return;
58
- this.pendingMap.delete(full.id);
59
- const decision: ApprovalDecision = {
60
- approvalId: full.id,
61
- outcome: 'timeout',
62
- reason: `Approval timed out after ${full.timeoutMs}ms`,
63
- decidedAt: nowISO(),
64
- };
65
- this.emit({ type: 'expired', request: full });
66
- resolvePromise(decision);
67
- }, full.timeoutMs);
68
- }
69
-
70
- this.pendingMap.set(full.id, { request: full, settle: resolvePromise, timer });
71
- this.emit({ type: 'requested', request: full });
72
- });
73
- }
74
-
75
- resolve(
76
- approvalId: string,
77
- decision: Omit<ApprovalDecision, 'approvalId' | 'decidedAt'>,
78
- ): boolean {
79
- const entry = this.pendingMap.get(approvalId);
80
- if (!entry) return false;
81
- this.pendingMap.delete(approvalId);
82
- if (entry.timer) clearTimeout(entry.timer);
83
-
84
- const full: ApprovalDecision = {
85
- approvalId,
86
- outcome: decision.outcome,
87
- actor: decision.actor,
88
- reason: decision.reason,
89
- decidedAt: nowISO(),
90
- };
91
- this.emit({ type: 'resolved', request: entry.request, decision: full });
92
- entry.settle(full);
93
- return true;
94
- }
95
-
96
- pending(): readonly ApprovalRequest[] {
97
- return Array.from(this.pendingMap.values()).map((e) => e.request);
98
- }
99
-
100
- subscribe(listener: ApprovalListener): () => void {
101
- this.listeners.add(listener);
102
- return () => {
103
- this.listeners.delete(listener);
104
- };
105
- }
106
-
107
- abortAll(reason: string): void {
108
- const entries = Array.from(this.pendingMap.entries());
109
- this.pendingMap.clear();
110
- for (const [id, entry] of entries) {
111
- if (entry.timer) clearTimeout(entry.timer);
112
- this.emit({ type: 'aborted', request: entry.request, reason });
113
- entry.settle({
114
- approvalId: id,
115
- outcome: 'aborted',
116
- reason,
117
- decidedAt: nowISO(),
118
- });
119
- }
120
- }
121
-
122
- private emit(event: ApprovalEvent): void {
123
- for (const listener of this.listeners) {
124
- try {
125
- listener(event);
126
- } catch (err) {
127
- console.error('[approval gateway] listener error:', err);
128
- }
129
- }
130
- }
131
- }
6
+ ApprovalListener,
7
+ ApprovalOutcome,
8
+ ApprovalRequest,
9
+ } from '@tagma/core';
package/src/bootstrap.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { PluginRegistry } from './registry';
1
+ import type { PluginRegistry } from '@tagma/core';
2
2
  import type { TagmaPlugin } from './types';
3
3
 
4
4
  // Built-in Drivers
@@ -12,6 +12,41 @@ const fakeRuntime: TagmaRuntime = {
12
12
  async runSpawn() {
13
13
  throw new Error('fakeRuntime.runSpawn should not be called by RunContext tests');
14
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
+ },
15
50
  };
16
51
 
17
52
  function makeContext(overrides: Partial<{
@@ -1,4 +1,3 @@
1
- import { resolve } from 'path';
2
1
  import type {
3
2
  CompletionPlugin,
4
3
  DriverContext,
@@ -136,6 +135,7 @@ export async function executeTask(options: ExecuteTaskOptions): Promise<void> {
136
135
  workDir: task.cwd ?? workDir,
137
136
  signal: ctx.abortController.signal,
138
137
  approvalGateway,
138
+ runtime: ctx.runtime,
139
139
  })
140
140
  .then(
141
141
  (v) => {
@@ -395,9 +395,18 @@ export async function executeTask(options: ExecuteTaskOptions): Promise<void> {
395
395
  // and keep only a bounded tail in the returned TaskResult. Filenames
396
396
  // mirror the existing `.stderr` naming — dots in task ids are replaced
397
397
  // so hierarchical ids (e.g. `track1.task2`) map cleanly to a flat dir.
398
- const fsSafeTaskId = taskId.replace(/\./g, '_');
399
- const stdoutPath = resolve(log.dir, `${fsSafeTaskId}.stdout`);
400
- 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
+ });
401
410
  const runOpts = {
402
411
  timeoutMs,
403
412
  signal: ctx.abortController.signal,
@@ -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}}' }),