@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,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');
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
- executeHook,
14
- buildPipelineStartContext,
15
- buildPipelineCompleteContext,
16
- buildPipelineErrorContext,
17
- type PipelineInfo,
18
- } from './hooks';
19
- import { Logger } from './logger';
20
- import { InMemoryApprovalGateway, type ApprovalGateway } from './approval';
21
- import {
22
- freezeStates,
23
- summarizeStates,
24
- toRunTaskState,
25
- } from './core/run-state';
26
- import { preflight } from './core/preflight';
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
- // Poll interval when no tasks are in-flight but non-terminal tasks remain
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
- const approvalGateway = options.approvalGateway ?? new InMemoryApprovalGateway();
130
- const maxLogRuns = options.maxLogRuns ?? 20;
131
- const registry = options.registry;
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 './runtime';
4
- export type { TagmaRuntime, RunOptions as RuntimeRunOptions } from './runtime';
5
- export { definePipeline } from './pipeline-definition';
6
- export { PluginRegistry } from './registry';
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,