@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.
- package/README.md +26 -5
- 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 +1 -1
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/core/task-executor.d.ts.map +1 -1
- package/dist/core/task-executor.js +13 -4
- package/dist/core/task-executor.js.map +1 -1
- package/dist/engine.d.ts +5 -56
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +7 -297
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +4 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -4
- 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 +2 -2
- 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 -66
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +1 -292
- 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 +2 -8
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +1 -7
- package/dist/runtime.js.map +1 -1
- package/dist/tagma.d.ts +3 -4
- package/dist/tagma.d.ts.map +1 -1
- package/dist/tagma.js +2 -3
- 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/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 +1 -1
- package/src/core/run-context.test.ts +35 -0
- package/src/core/task-executor.ts +13 -4
- package/src/engine-ports-mixed.test.ts +70 -44
- package/src/engine-ports.test.ts +77 -33
- package/src/engine.ts +18 -444
- package/src/index.ts +4 -6
- 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 +69 -3
- package/src/plugins.ts +2 -2
- package/src/registry.ts +7 -353
- 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 -20
- package/src/tagma.test.ts +162 -0
- package/src/tagma.ts +9 -4
- package/src/triggers/file.test.ts +79 -0
- package/src/triggers/file.ts +85 -118
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');
|
package/src/engine.ts
CHANGED
|
@@ -1,110 +1,18 @@
|
|
|
1
|
-
import { resolve } from 'path';
|
|
2
|
-
import type {
|
|
3
|
-
PipelineConfig,
|
|
4
|
-
TaskConfig,
|
|
5
|
-
TaskState,
|
|
6
|
-
RunEventPayload,
|
|
7
|
-
RunTaskState,
|
|
8
|
-
} from './types';
|
|
9
|
-
import { buildDag } from './dag';
|
|
10
|
-
import type { PluginRegistry } from './registry';
|
|
11
|
-
import { parseDuration, nowISO, generateRunId } from './utils';
|
|
12
1
|
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
type
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
import { pruneLogDirs } from './core/log-prune';
|
|
28
|
-
import { RunContext } from './core/run-context';
|
|
29
|
-
import {
|
|
30
|
-
allTasksTerminal,
|
|
31
|
-
findLaunchableTasks,
|
|
32
|
-
skipNonTerminalTasks,
|
|
33
|
-
} from './core/scheduler';
|
|
34
|
-
import { executeTask } from './core/task-executor';
|
|
35
|
-
import { bunRuntime, type TagmaRuntime } from './runtime';
|
|
36
|
-
export { TriggerBlockedError, TriggerTimeoutError } from './core/trigger-errors';
|
|
37
|
-
|
|
38
|
-
function isPromptTaskConfig(
|
|
39
|
-
task: TaskConfig,
|
|
40
|
-
): task is TaskConfig & { readonly prompt: string; readonly command?: undefined } {
|
|
41
|
-
return task.prompt !== undefined && task.command === undefined;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// ═══ Engine ═══
|
|
45
|
-
|
|
46
|
-
export interface EngineResult {
|
|
47
|
-
readonly success: boolean;
|
|
48
|
-
readonly runId: string;
|
|
49
|
-
readonly logPath: string;
|
|
50
|
-
readonly summary: {
|
|
51
|
-
total: number;
|
|
52
|
-
success: number;
|
|
53
|
-
failed: number;
|
|
54
|
-
skipped: number;
|
|
55
|
-
timeout: number;
|
|
56
|
-
blocked: number;
|
|
57
|
-
};
|
|
58
|
-
readonly states: ReadonlyMap<string, TaskState>;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// ═══ Pipeline Events ═══
|
|
62
|
-
//
|
|
63
|
-
// The engine emits RunEventPayload values (defined in @tagma/types) via
|
|
64
|
-
// `onEvent`. Every payload carries `runId`; the editor server stamps a
|
|
65
|
-
// per-run `seq` before broadcasting. There is one event vocabulary
|
|
66
|
-
// end-to-end — no server-side translation layer.
|
|
67
|
-
|
|
68
|
-
// Re-export so SDK consumers can import the event type without reaching
|
|
69
|
-
// into @tagma/types directly.
|
|
70
|
-
export type { RunEventPayload } from './types';
|
|
71
|
-
|
|
72
|
-
export interface RunPipelineOptions {
|
|
73
|
-
readonly approvalGateway?: ApprovalGateway;
|
|
74
|
-
/**
|
|
75
|
-
* Maximum number of per-run log directories to retain under `<workDir>/.tagma/logs/`.
|
|
76
|
-
* Oldest directories are deleted after each run. Defaults to 20. Set to 0 to disable cleanup.
|
|
77
|
-
*/
|
|
78
|
-
readonly maxLogRuns?: number;
|
|
79
|
-
/**
|
|
80
|
-
* Caller-supplied run ID. When provided the engine uses this instead of
|
|
81
|
-
* generating its own via `generateRunId()`, keeping the editor and SDK
|
|
82
|
-
* log directories aligned on the same ID.
|
|
83
|
-
*/
|
|
84
|
-
readonly runId?: string;
|
|
85
|
-
/**
|
|
86
|
-
* External AbortSignal — aborting it cancels the pipeline immediately.
|
|
87
|
-
* Equivalent to the pipeline timeout firing, but caller-controlled.
|
|
88
|
-
*/
|
|
89
|
-
readonly signal?: AbortSignal;
|
|
90
|
-
/**
|
|
91
|
-
* Called on every pipeline/task status transition.
|
|
92
|
-
* Use for real-time UI updates (e.g. updating a visual workflow graph).
|
|
93
|
-
*/
|
|
94
|
-
readonly onEvent?: (event: RunEventPayload) => void;
|
|
95
|
-
/**
|
|
96
|
-
* Skip the engine's built-in `loadPlugins(config.plugins)` call.
|
|
97
|
-
* Use this when the host has already pre-loaded plugins from a custom
|
|
98
|
-
* resolution path (e.g. a user workspace's node_modules) so the engine
|
|
99
|
-
* doesn't re-resolve them via Node's default cwd-based import.
|
|
100
|
-
*/
|
|
101
|
-
readonly skipPluginLoading?: boolean;
|
|
102
|
-
/**
|
|
103
|
-
* Plugin registry to resolve drivers/triggers/completions/middlewares from.
|
|
104
|
-
* Callers pass a per-instance or per-workspace registry so concurrent runs
|
|
105
|
-
* do not share handler state.
|
|
106
|
-
*/
|
|
107
|
-
readonly registry: PluginRegistry;
|
|
2
|
+
runPipeline as runCorePipeline,
|
|
3
|
+
TriggerBlockedError,
|
|
4
|
+
TriggerTimeoutError,
|
|
5
|
+
type EngineResult,
|
|
6
|
+
type RunEventPayload,
|
|
7
|
+
type RunPipelineOptions as CoreRunPipelineOptions,
|
|
8
|
+
} from '@tagma/core';
|
|
9
|
+
import { bunRuntime } from '@tagma/runtime-bun';
|
|
10
|
+
import type { PipelineConfig, TagmaRuntime } from './types';
|
|
11
|
+
|
|
12
|
+
export { TriggerBlockedError, TriggerTimeoutError };
|
|
13
|
+
export type { EngineResult, RunEventPayload };
|
|
14
|
+
|
|
15
|
+
export interface RunPipelineOptions extends Omit<CoreRunPipelineOptions, 'runtime'> {
|
|
108
16
|
/**
|
|
109
17
|
* Runtime implementation for command and driver process execution.
|
|
110
18
|
* Defaults to the SDK's Bun runtime.
|
|
@@ -112,347 +20,13 @@ export interface RunPipelineOptions {
|
|
|
112
20
|
readonly runtime?: TagmaRuntime;
|
|
113
21
|
}
|
|
114
22
|
|
|
115
|
-
|
|
116
|
-
// (e.g. tasks waiting on a file or manual trigger).
|
|
117
|
-
const POLL_INTERVAL_MS = 50;
|
|
118
|
-
|
|
119
|
-
// R15: cap on each normalized-output entry stored in normalizedMap so a
|
|
120
|
-
// runaway parseResult can't accumulate hundreds of MB across tasks. 1 MB
|
|
121
|
-
// is generous for any text-context handoff between AI tasks.
|
|
122
|
-
const MAX_NORMALIZED_BYTES = 1_000_000;
|
|
123
|
-
|
|
124
|
-
export async function runPipeline(
|
|
23
|
+
export function runPipeline(
|
|
125
24
|
config: PipelineConfig,
|
|
126
25
|
workDir: string,
|
|
127
26
|
options: RunPipelineOptions,
|
|
128
27
|
): Promise<EngineResult> {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const runtime = options.runtime ?? bunRuntime();
|
|
133
|
-
if (!registry) {
|
|
134
|
-
throw new Error(
|
|
135
|
-
'runPipeline requires options.registry. Use createTagma().run(...) for the public SDK API.',
|
|
136
|
-
);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// Load any plugins declared in the pipeline config before preflight so that
|
|
140
|
-
// drivers, completions, and middlewares referenced in YAML are registered.
|
|
141
|
-
// Hosts that pre-load plugins from a custom path (e.g. the editor loading
|
|
142
|
-
// from the user's workspace node_modules) pass skipPluginLoading: true so
|
|
143
|
-
// we don't re-resolve via Node's cwd-based default import.
|
|
144
|
-
if (!options.skipPluginLoading && config.plugins?.length) {
|
|
145
|
-
await registry.loadPlugins(config.plugins);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const dag = buildDag(config);
|
|
149
|
-
const runId = options.runId ?? generateRunId();
|
|
150
|
-
preflight(config, dag, registry);
|
|
151
|
-
|
|
152
|
-
const startedAt = nowISO();
|
|
153
|
-
const pipelineInfo: PipelineInfo = { name: config.name, run_id: runId, started_at: startedAt };
|
|
154
|
-
// Forward every structured log line to subscribers as task_log events.
|
|
155
|
-
// Reading options.onEvent inside the callback (vs. capturing it once) keeps
|
|
156
|
-
// the SDK behavior correct if callers pass a fresh onEvent on each run.
|
|
157
|
-
const log = new Logger(workDir, runId, (record) => {
|
|
158
|
-
options.onEvent?.({
|
|
159
|
-
type: 'task_log',
|
|
160
|
-
runId,
|
|
161
|
-
taskId: record.taskId,
|
|
162
|
-
level: record.level,
|
|
163
|
-
timestamp: record.timestamp,
|
|
164
|
-
text: record.text,
|
|
165
|
-
});
|
|
28
|
+
return runCorePipeline(config, workDir, {
|
|
29
|
+
...options,
|
|
30
|
+
runtime: options.runtime ?? bunRuntime(),
|
|
166
31
|
});
|
|
167
|
-
|
|
168
|
-
try {
|
|
169
|
-
log.info('[pipeline]', `start "${config.name}" run_id=${runId}`);
|
|
170
|
-
|
|
171
|
-
// File-only: dump the resolved pipeline shape + DAG topology for post-mortem.
|
|
172
|
-
log.section('Pipeline configuration');
|
|
173
|
-
log.quiet(`name: ${config.name}`);
|
|
174
|
-
log.quiet(`driver: ${config.driver ?? '(default: opencode)'}`);
|
|
175
|
-
log.quiet(`timeout: ${config.timeout ?? '(none)'}`);
|
|
176
|
-
log.quiet(`tracks: ${config.tracks.length}`);
|
|
177
|
-
log.quiet(`tasks (total): ${dag.nodes.size}`);
|
|
178
|
-
log.quiet(`plugins: ${(config.plugins ?? []).join(', ') || '(none)'}`);
|
|
179
|
-
log.quiet(
|
|
180
|
-
`hooks: ${config.hooks ? Object.keys(config.hooks).join(', ') || '(none)' : '(none)'}`,
|
|
181
|
-
);
|
|
182
|
-
|
|
183
|
-
log.section('DAG topology');
|
|
184
|
-
for (const [id, node] of dag.nodes) {
|
|
185
|
-
const deps = node.dependsOn.length ? node.dependsOn.join(', ') : '(root)';
|
|
186
|
-
const kind = isPromptTaskConfig(node.task) ? 'ai' : 'cmd';
|
|
187
|
-
log.quiet(` • ${id} [${kind}] track=${node.track.id} deps=[${deps}]`);
|
|
188
|
-
}
|
|
189
|
-
log.quiet('');
|
|
190
|
-
|
|
191
|
-
// Per-run state container. Constructed before the pipeline_start hook
|
|
192
|
-
// so the early-return path (blocked pipeline) can call freezeStates on
|
|
193
|
-
// the populated idle-state map. The constructor has no side effects —
|
|
194
|
-
// no listeners installed, no events emitted.
|
|
195
|
-
const ctx = new RunContext({
|
|
196
|
-
runId,
|
|
197
|
-
dag,
|
|
198
|
-
config,
|
|
199
|
-
workDir,
|
|
200
|
-
pipelineInfo,
|
|
201
|
-
onEvent: options.onEvent,
|
|
202
|
-
runtime,
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
// Pipeline start hook (gate). Runs BEFORE the engine emits run_start so
|
|
206
|
-
// a blocked pipeline produces zero wire events (the server treats the
|
|
207
|
-
// thrown error as run_error). Hosts get a rich error message; nothing
|
|
208
|
-
// is ever half-broadcast.
|
|
209
|
-
const startHook = await executeHook(
|
|
210
|
-
config.hooks,
|
|
211
|
-
'pipeline_start',
|
|
212
|
-
buildPipelineStartContext(pipelineInfo),
|
|
213
|
-
workDir,
|
|
214
|
-
);
|
|
215
|
-
if (!startHook.allowed) {
|
|
216
|
-
console.error(`Pipeline blocked by pipeline_start hook (exit code ${startHook.exitCode})`);
|
|
217
|
-
await executeHook(
|
|
218
|
-
config.hooks,
|
|
219
|
-
'pipeline_error',
|
|
220
|
-
buildPipelineErrorContext(pipelineInfo, 'pipeline_blocked', 'pipeline_blocked'),
|
|
221
|
-
workDir,
|
|
222
|
-
);
|
|
223
|
-
return {
|
|
224
|
-
success: false,
|
|
225
|
-
runId,
|
|
226
|
-
logPath: log.path,
|
|
227
|
-
summary: {
|
|
228
|
-
total: dag.nodes.size,
|
|
229
|
-
success: 0,
|
|
230
|
-
failed: 0,
|
|
231
|
-
skipped: 0,
|
|
232
|
-
timeout: 0,
|
|
233
|
-
blocked: 0,
|
|
234
|
-
},
|
|
235
|
-
states: freezeStates(ctx.states),
|
|
236
|
-
};
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Pipeline approved — transition all tasks to waiting.
|
|
240
|
-
for (const [, state] of ctx.states) {
|
|
241
|
-
state.status = 'waiting';
|
|
242
|
-
}
|
|
243
|
-
// Emit run_start with a wire-shape snapshot so SSE subscribers can
|
|
244
|
-
// initialize their task maps on the same event stream that carries
|
|
245
|
-
// updates. No separate "server pre-broadcasts run_start" ceremony —
|
|
246
|
-
// the engine owns the lifecycle boundary.
|
|
247
|
-
const runStartTasks: RunTaskState[] = [];
|
|
248
|
-
for (const [id, node] of dag.nodes) {
|
|
249
|
-
const s = ctx.states.get(id)!;
|
|
250
|
-
runStartTasks.push(toRunTaskState(id, node.track.id, node.task.name ?? id, s));
|
|
251
|
-
}
|
|
252
|
-
ctx.emit({ type: 'run_start', runId, tasks: runStartTasks });
|
|
253
|
-
|
|
254
|
-
// Pipeline timeout. `ctx.abortReason` carries the concrete cause
|
|
255
|
-
// (timeout / stop_all / external) through to run_end and the
|
|
256
|
-
// pipeline_error hook so downstream consumers can distinguish them
|
|
257
|
-
// without scraping message strings.
|
|
258
|
-
const pipelineTimeoutMs = config.timeout ? parseDuration(config.timeout) : 0;
|
|
259
|
-
let pipelineTimer: ReturnType<typeof setTimeout> | null = null;
|
|
260
|
-
|
|
261
|
-
if (pipelineTimeoutMs > 0) {
|
|
262
|
-
pipelineTimer = setTimeout(() => {
|
|
263
|
-
if (ctx.abortReason === null) ctx.abortReason = 'timeout';
|
|
264
|
-
ctx.abortController.abort();
|
|
265
|
-
}, pipelineTimeoutMs);
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// When the pipeline is aborted (timeout, stop_all, external), drain
|
|
269
|
-
// all pending approvals so waiting triggers unblock immediately.
|
|
270
|
-
ctx.abortController.signal.addEventListener('abort', () => {
|
|
271
|
-
approvalGateway.abortAll('pipeline aborted');
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
// Wire external cancel signal into the internal abort controller.
|
|
275
|
-
const externalAbortHandler = () => {
|
|
276
|
-
if (ctx.abortReason === null) ctx.abortReason = 'external';
|
|
277
|
-
ctx.abortController.abort();
|
|
278
|
-
};
|
|
279
|
-
if (options.signal) {
|
|
280
|
-
if (options.signal.aborted) {
|
|
281
|
-
externalAbortHandler();
|
|
282
|
-
} else {
|
|
283
|
-
options.signal.addEventListener('abort', externalAbortHandler, { once: true });
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// Bridge approval gateway events onto the wire stream so hosts (editor
|
|
288
|
-
// server, CLI adapters) see approvals on the same channel as task
|
|
289
|
-
// updates. The server no longer needs its own gateway subscription.
|
|
290
|
-
const unsubscribeApprovals = approvalGateway.subscribe((ev) => {
|
|
291
|
-
if (ev.type === 'requested') {
|
|
292
|
-
ctx.emit({
|
|
293
|
-
type: 'approval_request',
|
|
294
|
-
runId,
|
|
295
|
-
request: {
|
|
296
|
-
id: ev.request.id,
|
|
297
|
-
taskId: ev.request.taskId,
|
|
298
|
-
trackId: ev.request.trackId,
|
|
299
|
-
message: ev.request.message,
|
|
300
|
-
createdAt: ev.request.createdAt,
|
|
301
|
-
timeoutMs: ev.request.timeoutMs,
|
|
302
|
-
metadata: ev.request.metadata,
|
|
303
|
-
},
|
|
304
|
-
});
|
|
305
|
-
return;
|
|
306
|
-
}
|
|
307
|
-
if (ev.type === 'resolved' || ev.type === 'expired' || ev.type === 'aborted') {
|
|
308
|
-
const outcome =
|
|
309
|
-
ev.type === 'resolved'
|
|
310
|
-
? ev.decision.outcome
|
|
311
|
-
: ev.type === 'expired'
|
|
312
|
-
? 'timeout'
|
|
313
|
-
: 'aborted';
|
|
314
|
-
ctx.emit({
|
|
315
|
-
type: 'approval_resolved',
|
|
316
|
-
runId,
|
|
317
|
-
requestId: ev.request.id,
|
|
318
|
-
outcome,
|
|
319
|
-
});
|
|
320
|
-
}
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
// ── Process a single task ──
|
|
324
|
-
// ── Event loop ──
|
|
325
|
-
// Each task is launched as soon as ALL its deps reach a terminal state.
|
|
326
|
-
// We track in-flight tasks in `running` so a task completing mid-batch
|
|
327
|
-
// immediately unblocks its dependents without waiting for sibling tasks.
|
|
328
|
-
const running = new Map<string, Promise<void>>();
|
|
329
|
-
|
|
330
|
-
try {
|
|
331
|
-
while (ctx.abortReason === null) {
|
|
332
|
-
// Launch every task whose deps are all terminal and that isn't already in-flight
|
|
333
|
-
for (const id of findLaunchableTasks(ctx, new Set(running.keys()))) {
|
|
334
|
-
const p = executeTask({
|
|
335
|
-
taskId: id,
|
|
336
|
-
ctx,
|
|
337
|
-
registry,
|
|
338
|
-
log,
|
|
339
|
-
approvalGateway,
|
|
340
|
-
}).finally(() => running.delete(id));
|
|
341
|
-
running.set(id, p);
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
// All tasks terminal — done
|
|
345
|
-
if (allTasksTerminal(ctx)) break;
|
|
346
|
-
|
|
347
|
-
if (running.size === 0) {
|
|
348
|
-
// Nothing in-flight but non-terminal tasks exist (e.g. trigger-wait states
|
|
349
|
-
// that processTask hasn't been called for yet). Poll briefly.
|
|
350
|
-
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
351
|
-
} else {
|
|
352
|
-
// Wait for any one task to finish, then re-scan for new launchables.
|
|
353
|
-
await Promise.race(running.values());
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
if (ctx.abortReason !== null) {
|
|
358
|
-
// Wait for in-flight tasks to honour the abort signal before marking states.
|
|
359
|
-
if (running.size > 0) await Promise.allSettled(running.values());
|
|
360
|
-
// By the time allSettled resolves, processTask's try/finally has already
|
|
361
|
-
// set running tasks to success/failed/timeout. The only non-terminal
|
|
362
|
-
// statuses remaining here are waiting/idle tasks that were never started.
|
|
363
|
-
skipNonTerminalTasks(ctx);
|
|
364
|
-
}
|
|
365
|
-
} finally {
|
|
366
|
-
if (pipelineTimer) clearTimeout(pipelineTimer);
|
|
367
|
-
// Clean up the external abort signal listener to prevent dead references
|
|
368
|
-
// accumulating on long-lived shared AbortControllers.
|
|
369
|
-
if (options.signal) {
|
|
370
|
-
options.signal.removeEventListener('abort', externalAbortHandler);
|
|
371
|
-
}
|
|
372
|
-
// Safety net: drain any approvals still pending at shutdown (e.g. crash path).
|
|
373
|
-
if (approvalGateway.pending().length > 0) {
|
|
374
|
-
approvalGateway.abortAll('pipeline finished');
|
|
375
|
-
}
|
|
376
|
-
// Detach gateway → onEvent bridge so a long-lived gateway (host-supplied)
|
|
377
|
-
// doesn't keep firing into a dead run.
|
|
378
|
-
unsubscribeApprovals();
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
// ── Summary ──
|
|
382
|
-
const summary = summarizeStates(ctx.states);
|
|
383
|
-
|
|
384
|
-
const finishedAt = nowISO();
|
|
385
|
-
const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
|
|
386
|
-
|
|
387
|
-
if (ctx.abortReason !== null) {
|
|
388
|
-
const reasonText =
|
|
389
|
-
ctx.abortReason === 'timeout'
|
|
390
|
-
? 'Pipeline timeout exceeded'
|
|
391
|
-
: ctx.abortReason === 'stop_all'
|
|
392
|
-
? 'Pipeline stopped (on_failure: stop_all)'
|
|
393
|
-
: 'Pipeline aborted by host';
|
|
394
|
-
await executeHook(
|
|
395
|
-
config.hooks,
|
|
396
|
-
'pipeline_error',
|
|
397
|
-
buildPipelineErrorContext(pipelineInfo, reasonText, undefined, ctx.abortReason),
|
|
398
|
-
workDir,
|
|
399
|
-
);
|
|
400
|
-
} else {
|
|
401
|
-
await executeHook(
|
|
402
|
-
config.hooks,
|
|
403
|
-
'pipeline_complete',
|
|
404
|
-
buildPipelineCompleteContext(
|
|
405
|
-
{ ...pipelineInfo, finished_at: finishedAt, duration_ms: durationMs },
|
|
406
|
-
summary,
|
|
407
|
-
),
|
|
408
|
-
workDir,
|
|
409
|
-
);
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
const allSuccess =
|
|
413
|
-
ctx.abortReason === null &&
|
|
414
|
-
summary.failed === 0 &&
|
|
415
|
-
summary.timeout === 0 &&
|
|
416
|
-
summary.blocked === 0;
|
|
417
|
-
|
|
418
|
-
log.section('Pipeline summary');
|
|
419
|
-
log.quiet(
|
|
420
|
-
`status: ${ctx.abortReason !== null ? `aborted (${ctx.abortReason})` : 'completed'}`,
|
|
421
|
-
);
|
|
422
|
-
log.quiet(`duration: ${(durationMs / 1000).toFixed(1)}s`);
|
|
423
|
-
log.quiet(
|
|
424
|
-
`counts: total=${summary.total} success=${summary.success} ` +
|
|
425
|
-
`failed=${summary.failed} skipped=${summary.skipped} ` +
|
|
426
|
-
`timeout=${summary.timeout} blocked=${summary.blocked}`,
|
|
427
|
-
);
|
|
428
|
-
log.quiet('');
|
|
429
|
-
log.quiet('per-task:');
|
|
430
|
-
for (const [id, state] of ctx.states) {
|
|
431
|
-
const dur =
|
|
432
|
-
state.result?.durationMs != null ? `${(state.result.durationMs / 1000).toFixed(1)}s` : '-';
|
|
433
|
-
const exit = state.result?.exitCode ?? '-';
|
|
434
|
-
log.quiet(` ${state.status.padEnd(8)} ${id} (exit=${exit}, ${dur})`);
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
log.info('[pipeline]', `completed "${config.name}"`);
|
|
438
|
-
log.info(
|
|
439
|
-
'[pipeline]',
|
|
440
|
-
`Total: ${summary.total} | Success: ${summary.success} | Failed: ${summary.failed} | Skipped: ${summary.skipped} | Timeout: ${summary.timeout} | Blocked: ${summary.blocked}`,
|
|
441
|
-
);
|
|
442
|
-
log.info('[pipeline]', `Duration: ${(durationMs / 1000).toFixed(1)}s`);
|
|
443
|
-
log.info('[pipeline]', `Log: ${log.path}`);
|
|
444
|
-
|
|
445
|
-
ctx.emit({ type: 'run_end', runId, success: allSuccess, abortReason: ctx.abortReason });
|
|
446
|
-
return { success: allSuccess, runId, logPath: log.path, summary, states: freezeStates(ctx.states) };
|
|
447
|
-
} finally {
|
|
448
|
-
// Close the persistent log file handle before pruning.
|
|
449
|
-
log.close();
|
|
450
|
-
// Prune old per-run log directories on every exit path (normal, blocked, or thrown).
|
|
451
|
-
// Exclude the current runId so a concurrent run cannot delete its own live directory.
|
|
452
|
-
if (maxLogRuns > 0) {
|
|
453
|
-
await pruneLogDirs(resolve(workDir, '.tagma', 'logs'), maxLogRuns, runId);
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
32
|
}
|
|
457
|
-
|
|
458
|
-
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
export { createTagma } from './tagma';
|
|
2
2
|
export type { CreateTagmaOptions, Tagma, TagmaRunOptions } from './tagma';
|
|
3
|
-
export { bunRuntime } from '
|
|
4
|
-
export type { TagmaRuntime, RunOptions as RuntimeRunOptions } from '
|
|
5
|
-
export { definePipeline } from '
|
|
6
|
-
export {
|
|
7
|
-
export { TriggerBlockedError, TriggerTimeoutError } from './engine';
|
|
8
|
-
export type { EngineResult, RunEventPayload } from './engine';
|
|
3
|
+
export { bunRuntime } from '@tagma/runtime-bun';
|
|
4
|
+
export type { TagmaRuntime, RunOptions as RuntimeRunOptions } from '@tagma/core';
|
|
5
|
+
export { definePipeline, PluginRegistry, TriggerBlockedError, TriggerTimeoutError } from '@tagma/core';
|
|
6
|
+
export type { EngineResult, RunEventPayload } from '@tagma/core';
|
|
9
7
|
export { RUN_PROTOCOL_VERSION, TASK_LOG_CAP } from './types';
|
|
10
8
|
export type {
|
|
11
9
|
PipelineConfig,
|