@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.
- package/README.md +109 -48
- package/dist/adapters/stdin-approval.d.ts +1 -5
- package/dist/adapters/stdin-approval.d.ts.map +1 -1
- package/dist/adapters/stdin-approval.js +1 -89
- package/dist/adapters/stdin-approval.js.map +1 -1
- package/dist/adapters/websocket-approval.d.ts +1 -27
- package/dist/adapters/websocket-approval.d.ts.map +1 -1
- package/dist/adapters/websocket-approval.js +1 -146
- package/dist/adapters/websocket-approval.js.map +1 -1
- package/dist/approval.d.ts +2 -12
- package/dist/approval.d.ts.map +1 -1
- package/dist/approval.js +1 -90
- package/dist/approval.js.map +1 -1
- package/dist/bootstrap.d.ts +21 -1
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/bootstrap.js +21 -11
- package/dist/bootstrap.js.map +1 -1
- package/dist/core/run-context.d.ts +3 -0
- package/dist/core/run-context.d.ts.map +1 -1
- package/dist/core/run-context.js +2 -0
- package/dist/core/run-context.js.map +1 -1
- package/dist/core/task-executor.d.ts.map +1 -1
- package/dist/core/task-executor.js +24 -37
- package/dist/core/task-executor.js.map +1 -1
- package/dist/engine.d.ts +8 -53
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +7 -294
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -3
- package/dist/index.js.map +1 -1
- package/dist/logger.d.ts +2 -60
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +1 -153
- package/dist/logger.js.map +1 -1
- package/dist/plugins.d.ts +3 -3
- package/dist/plugins.d.ts.map +1 -1
- package/dist/plugins.js +1 -1
- package/dist/plugins.js.map +1 -1
- package/dist/registry.d.ts +2 -60
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +1 -253
- package/dist/registry.js.map +1 -1
- package/dist/runner.d.ts +1 -35
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +1 -610
- package/dist/runner.js.map +1 -1
- package/dist/runtime/adapters/stdin-approval.d.ts +2 -0
- package/dist/runtime/adapters/stdin-approval.d.ts.map +1 -0
- package/dist/runtime/adapters/stdin-approval.js +2 -0
- package/dist/runtime/adapters/stdin-approval.js.map +1 -0
- package/dist/runtime/adapters/websocket-approval.d.ts +2 -0
- package/dist/runtime/adapters/websocket-approval.d.ts.map +1 -0
- package/dist/runtime/adapters/websocket-approval.js +2 -0
- package/dist/runtime/adapters/websocket-approval.js.map +1 -0
- package/dist/runtime/bun-process-runner.d.ts +2 -0
- package/dist/runtime/bun-process-runner.d.ts.map +1 -0
- package/dist/runtime/bun-process-runner.js +2 -0
- package/dist/runtime/bun-process-runner.js.map +1 -0
- package/dist/runtime.d.ts +3 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +2 -0
- package/dist/runtime.js.map +1 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +1 -7
- package/dist/schema.js.map +1 -1
- package/dist/tagma.d.ts +13 -4
- package/dist/tagma.d.ts.map +1 -1
- package/dist/tagma.js +7 -2
- package/dist/tagma.js.map +1 -1
- package/dist/triggers/file.d.ts.map +1 -1
- package/dist/triggers/file.js +74 -107
- package/dist/triggers/file.js.map +1 -1
- package/dist/validate-raw.d.ts.map +1 -1
- package/dist/validate-raw.js +1 -101
- package/dist/validate-raw.js.map +1 -1
- package/package.json +15 -4
- package/src/adapters/stdin-approval.ts +1 -106
- package/src/adapters/websocket-approval.ts +1 -224
- package/src/approval.ts +5 -127
- package/src/bootstrap.ts +24 -15
- package/src/core/run-context.test.ts +47 -0
- package/src/core/run-context.ts +4 -0
- package/src/core/task-executor.ts +28 -45
- package/src/engine-ports-mixed.test.ts +70 -44
- package/src/engine-ports.test.ts +77 -33
- package/src/engine.ts +21 -439
- package/src/index.ts +7 -4
- package/src/logger.ts +2 -182
- package/src/package-split.test.ts +15 -0
- package/src/pipeline-runner.test.ts +65 -12
- package/src/plugin-registry.test.ts +207 -4
- package/src/plugins.ts +6 -3
- package/src/registry.ts +7 -298
- package/src/runner.ts +1 -666
- package/src/runtime/adapters/stdin-approval.ts +1 -0
- package/src/runtime/adapters/websocket-approval.ts +1 -0
- package/src/runtime/bun-process-runner.ts +1 -0
- package/src/runtime-adapters.test.ts +10 -0
- package/src/runtime.ts +12 -0
- package/src/schema-ports.test.ts +23 -0
- package/src/schema.ts +1 -7
- package/src/tagma.test.ts +234 -1
- package/src/tagma.ts +24 -4
- package/src/triggers/file.test.ts +79 -0
- package/src/triggers/file.ts +85 -118
- package/src/validate-raw.ts +1 -117
package/src/bootstrap.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { PluginRegistry } from '
|
|
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
|
-
|
|
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
|
});
|
package/src/core/run-context.ts
CHANGED
|
@@ -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
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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}"
|
|
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
|
|
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: ['
|
|
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(
|
|
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:
|
|
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(
|
|
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:
|
|
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}}' }),
|
package/src/engine-ports.test.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import { mkdtempSync, rmSync
|
|
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
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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');
|