@tagma/sdk 0.7.3 → 0.7.5

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 (230) hide show
  1. package/README.md +85 -57
  2. package/dist/approval.d.ts +2 -12
  3. package/dist/approval.d.ts.map +1 -1
  4. package/dist/approval.js +1 -90
  5. package/dist/approval.js.map +1 -1
  6. package/dist/bootstrap.d.ts +1 -1
  7. package/dist/bootstrap.d.ts.map +1 -1
  8. package/dist/completions/file-exists.js +1 -1
  9. package/dist/completions/file-exists.js.map +1 -1
  10. package/dist/completions/output-check.d.ts.map +1 -1
  11. package/dist/completions/output-check.js +17 -4
  12. package/dist/completions/output-check.js.map +1 -1
  13. package/dist/config.d.ts +4 -4
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/config.js +2 -2
  16. package/dist/config.js.map +1 -1
  17. package/dist/dataflow.d.ts +3 -0
  18. package/dist/dataflow.d.ts.map +1 -0
  19. package/dist/dataflow.js +2 -0
  20. package/dist/dataflow.js.map +1 -0
  21. package/dist/drivers/opencode.d.ts.map +1 -1
  22. package/dist/drivers/opencode.js +23 -71
  23. package/dist/drivers/opencode.js.map +1 -1
  24. package/dist/engine.d.ts +5 -56
  25. package/dist/engine.d.ts.map +1 -1
  26. package/dist/engine.js +7 -297
  27. package/dist/engine.js.map +1 -1
  28. package/dist/index.d.ts +4 -6
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +2 -4
  31. package/dist/index.js.map +1 -1
  32. package/dist/logger.d.ts +2 -60
  33. package/dist/logger.d.ts.map +1 -1
  34. package/dist/logger.js +1 -153
  35. package/dist/logger.js.map +1 -1
  36. package/dist/middlewares/static-context.d.ts.map +1 -1
  37. package/dist/middlewares/static-context.js +1 -2
  38. package/dist/middlewares/static-context.js.map +1 -1
  39. package/dist/pipeline-runner.d.ts.map +1 -1
  40. package/dist/pipeline-runner.js +2 -2
  41. package/dist/pipeline-runner.js.map +1 -1
  42. package/dist/plugins.d.ts +2 -2
  43. package/dist/plugins.d.ts.map +1 -1
  44. package/dist/plugins.js +1 -1
  45. package/dist/plugins.js.map +1 -1
  46. package/dist/runner.d.ts +1 -35
  47. package/dist/runner.d.ts.map +1 -1
  48. package/dist/runner.js +1 -610
  49. package/dist/runner.js.map +1 -1
  50. package/dist/runtime/adapters/stdin-approval.d.ts +2 -0
  51. package/dist/runtime/adapters/stdin-approval.d.ts.map +1 -0
  52. package/dist/runtime/adapters/stdin-approval.js +2 -0
  53. package/dist/runtime/adapters/stdin-approval.js.map +1 -0
  54. package/dist/runtime/adapters/websocket-approval.d.ts +2 -0
  55. package/dist/runtime/adapters/websocket-approval.d.ts.map +1 -0
  56. package/dist/runtime/adapters/websocket-approval.js +2 -0
  57. package/dist/runtime/adapters/websocket-approval.js.map +1 -0
  58. package/dist/runtime/bun-process-runner.d.ts +2 -0
  59. package/dist/runtime/bun-process-runner.d.ts.map +1 -0
  60. package/dist/runtime/bun-process-runner.js +2 -0
  61. package/dist/runtime/bun-process-runner.js.map +1 -0
  62. package/dist/runtime.d.ts +2 -8
  63. package/dist/runtime.d.ts.map +1 -1
  64. package/dist/runtime.js +1 -7
  65. package/dist/runtime.js.map +1 -1
  66. package/dist/schema.d.ts.map +1 -1
  67. package/dist/schema.js +3 -4
  68. package/dist/schema.js.map +1 -1
  69. package/dist/tagma.d.ts +3 -4
  70. package/dist/tagma.d.ts.map +1 -1
  71. package/dist/tagma.js +2 -3
  72. package/dist/tagma.js.map +1 -1
  73. package/dist/triggers/file.d.ts.map +1 -1
  74. package/dist/triggers/file.js +74 -108
  75. package/dist/triggers/file.js.map +1 -1
  76. package/dist/triggers/manual.d.ts.map +1 -1
  77. package/dist/triggers/manual.js +1 -2
  78. package/dist/triggers/manual.js.map +1 -1
  79. package/dist/types.d.ts +1 -2
  80. package/dist/types.d.ts.map +1 -1
  81. package/dist/types.js +1 -12
  82. package/dist/types.js.map +1 -1
  83. package/dist/utils-api.d.ts +1 -1
  84. package/dist/utils-api.d.ts.map +1 -1
  85. package/dist/utils-api.js +1 -1
  86. package/dist/utils-api.js.map +1 -1
  87. package/dist/validate-raw.d.ts.map +1 -1
  88. package/dist/validate-raw.js +5 -12
  89. package/dist/validate-raw.js.map +1 -1
  90. package/package.json +20 -22
  91. package/dist/adapters/stdin-approval.d.ts +0 -6
  92. package/dist/adapters/stdin-approval.d.ts.map +0 -1
  93. package/dist/adapters/stdin-approval.js +0 -90
  94. package/dist/adapters/stdin-approval.js.map +0 -1
  95. package/dist/adapters/websocket-approval.d.ts +0 -28
  96. package/dist/adapters/websocket-approval.d.ts.map +0 -1
  97. package/dist/adapters/websocket-approval.js +0 -147
  98. package/dist/adapters/websocket-approval.js.map +0 -1
  99. package/dist/core/dataflow.d.ts +0 -23
  100. package/dist/core/dataflow.d.ts.map +0 -1
  101. package/dist/core/dataflow.js +0 -99
  102. package/dist/core/dataflow.js.map +0 -1
  103. package/dist/core/log-prune.d.ts +0 -16
  104. package/dist/core/log-prune.d.ts.map +0 -1
  105. package/dist/core/log-prune.js +0 -34
  106. package/dist/core/log-prune.js.map +0 -1
  107. package/dist/core/preflight.d.ts +0 -13
  108. package/dist/core/preflight.d.ts.map +0 -1
  109. package/dist/core/preflight.js +0 -61
  110. package/dist/core/preflight.js.map +0 -1
  111. package/dist/core/run-context.d.ts +0 -55
  112. package/dist/core/run-context.d.ts.map +0 -1
  113. package/dist/core/run-context.js +0 -158
  114. package/dist/core/run-context.js.map +0 -1
  115. package/dist/core/run-state.d.ts +0 -25
  116. package/dist/core/run-state.d.ts.map +0 -1
  117. package/dist/core/run-state.js +0 -93
  118. package/dist/core/run-state.js.map +0 -1
  119. package/dist/core/scheduler.d.ts +0 -13
  120. package/dist/core/scheduler.d.ts.map +0 -1
  121. package/dist/core/scheduler.js +0 -35
  122. package/dist/core/scheduler.js.map +0 -1
  123. package/dist/core/task-executor.d.ts +0 -13
  124. package/dist/core/task-executor.d.ts.map +0 -1
  125. package/dist/core/task-executor.js +0 -601
  126. package/dist/core/task-executor.js.map +0 -1
  127. package/dist/core/trigger-errors.d.ts +0 -9
  128. package/dist/core/trigger-errors.d.ts.map +0 -1
  129. package/dist/core/trigger-errors.js +0 -15
  130. package/dist/core/trigger-errors.js.map +0 -1
  131. package/dist/dag.d.ts +0 -45
  132. package/dist/dag.d.ts.map +0 -1
  133. package/dist/dag.js +0 -177
  134. package/dist/dag.js.map +0 -1
  135. package/dist/hooks.d.ts +0 -73
  136. package/dist/hooks.d.ts.map +0 -1
  137. package/dist/hooks.js +0 -106
  138. package/dist/hooks.js.map +0 -1
  139. package/dist/pipeline-definition.d.ts +0 -3
  140. package/dist/pipeline-definition.d.ts.map +0 -1
  141. package/dist/pipeline-definition.js +0 -4
  142. package/dist/pipeline-definition.js.map +0 -1
  143. package/dist/ports.d.ts +0 -196
  144. package/dist/ports.d.ts.map +0 -1
  145. package/dist/ports.js +0 -688
  146. package/dist/ports.js.map +0 -1
  147. package/dist/prompt-doc.d.ts +0 -70
  148. package/dist/prompt-doc.d.ts.map +0 -1
  149. package/dist/prompt-doc.js +0 -154
  150. package/dist/prompt-doc.js.map +0 -1
  151. package/dist/registry.d.ts +0 -67
  152. package/dist/registry.d.ts.map +0 -1
  153. package/dist/registry.js +0 -293
  154. package/dist/registry.js.map +0 -1
  155. package/dist/task-ref.d.ts +0 -55
  156. package/dist/task-ref.d.ts.map +0 -1
  157. package/dist/task-ref.js +0 -103
  158. package/dist/task-ref.js.map +0 -1
  159. package/dist/utils.d.ts +0 -13
  160. package/dist/utils.d.ts.map +0 -1
  161. package/dist/utils.js +0 -177
  162. package/dist/utils.js.map +0 -1
  163. package/src/adapters/stdin-approval.ts +0 -106
  164. package/src/adapters/websocket-approval.ts +0 -224
  165. package/src/approval.ts +0 -131
  166. package/src/bootstrap.ts +0 -55
  167. package/src/completions/exit-code.ts +0 -34
  168. package/src/completions/file-exists.ts +0 -66
  169. package/src/completions/output-check.test.ts +0 -50
  170. package/src/completions/output-check.ts +0 -92
  171. package/src/config-ops.test.ts +0 -70
  172. package/src/config-ops.ts +0 -328
  173. package/src/config.ts +0 -26
  174. package/src/core/dataflow.test.ts +0 -166
  175. package/src/core/dataflow.ts +0 -161
  176. package/src/core/log-prune.test.ts +0 -58
  177. package/src/core/log-prune.ts +0 -43
  178. package/src/core/preflight.test.ts +0 -49
  179. package/src/core/preflight.ts +0 -89
  180. package/src/core/run-context.test.ts +0 -256
  181. package/src/core/run-context.ts +0 -211
  182. package/src/core/run-state.test.ts +0 -98
  183. package/src/core/run-state.ts +0 -122
  184. package/src/core/scheduler.test.ts +0 -83
  185. package/src/core/scheduler.ts +0 -42
  186. package/src/core/task-executor.ts +0 -743
  187. package/src/core/trigger-errors.ts +0 -15
  188. package/src/dag.test.ts +0 -56
  189. package/src/dag.ts +0 -245
  190. package/src/drivers/opencode.ts +0 -410
  191. package/src/engine-ports-mixed.test.ts +0 -156
  192. package/src/engine-ports.test.ts +0 -166
  193. package/src/engine-task-type.test.ts +0 -56
  194. package/src/engine.ts +0 -458
  195. package/src/hooks.ts +0 -193
  196. package/src/index.ts +0 -33
  197. package/src/logger.ts +0 -182
  198. package/src/middlewares/static-context.ts +0 -49
  199. package/src/pipeline-definition.ts +0 -5
  200. package/src/pipeline-runner.test.ts +0 -91
  201. package/src/pipeline-runner.ts +0 -194
  202. package/src/plugin-registry.test.ts +0 -382
  203. package/src/plugins.ts +0 -21
  204. package/src/ports.test.ts +0 -678
  205. package/src/ports.ts +0 -925
  206. package/src/prompt-doc.test.ts +0 -174
  207. package/src/prompt-doc.ts +0 -169
  208. package/src/registry.ts +0 -353
  209. package/src/runner.test.ts +0 -142
  210. package/src/runner.ts +0 -666
  211. package/src/runtime.ts +0 -20
  212. package/src/schema-ports.test.ts +0 -172
  213. package/src/schema.test.ts +0 -213
  214. package/src/schema.ts +0 -379
  215. package/src/tagma.test.ts +0 -155
  216. package/src/tagma.ts +0 -62
  217. package/src/task-ref.test.ts +0 -401
  218. package/src/task-ref.ts +0 -121
  219. package/src/triggers/file.ts +0 -164
  220. package/src/triggers/manual.ts +0 -86
  221. package/src/types.ts +0 -18
  222. package/src/utils-api.ts +0 -8
  223. package/src/utils.test.ts +0 -28
  224. package/src/utils.ts +0 -203
  225. package/src/validate-raw-plugin-types.test.ts +0 -60
  226. package/src/validate-raw-ports.test.ts +0 -136
  227. package/src/validate-raw.ts +0 -852
  228. package/src/yaml-compiler.test.ts +0 -108
  229. package/src/yaml-compiler.ts +0 -110
  230. package/src/yaml.ts +0 -11
@@ -1,166 +0,0 @@
1
- import { describe, expect, test } from 'bun:test';
2
- import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
- import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
5
- import { bootstrapBuiltins } from './bootstrap';
6
- import { runPipeline, type RunEventPayload } from './engine';
7
- import { PluginRegistry } from './registry';
8
- import type { PipelineConfig, TaskConfig, TaskStatus } from './types';
9
-
10
- const PERMS = { read: true, write: false, execute: false };
11
-
12
- function freshRegistry(): PluginRegistry {
13
- const reg = new PluginRegistry();
14
- bootstrapBuiltins(reg);
15
- return reg;
16
- }
17
-
18
- function makeDir(): string {
19
- return mkdtempSync(join(tmpdir(), 'tagma-bindings-'));
20
- }
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
- function task(overrides: Partial<TaskConfig> & { id: string }): TaskConfig {
38
- return {
39
- name: overrides.id,
40
- permissions: PERMS,
41
- driver: 'opencode',
42
- ...overrides,
43
- };
44
- }
45
-
46
- function pipeline(tasks: TaskConfig[]): PipelineConfig {
47
- return {
48
- name: 'bindings-test',
49
- tracks: [
50
- {
51
- id: 't',
52
- name: 'T',
53
- driver: 'opencode',
54
- permissions: PERMS,
55
- on_failure: 'skip_downstream',
56
- tasks,
57
- },
58
- ],
59
- };
60
- }
61
-
62
- async function run(config: PipelineConfig, workDir: string) {
63
- const events: RunEventPayload[] = [];
64
- const result = await runPipeline(config, workDir, {
65
- registry: freshRegistry(),
66
- skipPluginLoading: true,
67
- onEvent: (e) => events.push(e),
68
- });
69
- return { events, success: result.success };
70
- }
71
-
72
- function finalUpdateFor(events: RunEventPayload[], qid: string): RunEventPayload | undefined {
73
- let last: RunEventPayload | undefined;
74
- for (const ev of events) {
75
- if (ev.type === 'task_update' && ev.taskId === qid) last = ev;
76
- }
77
- return last;
78
- }
79
-
80
- function finalStatusFrom(events: RunEventPayload[], qid: string): TaskStatus | undefined {
81
- const last = finalUpdateFor(events, qid);
82
- return last && last.type === 'task_update' ? last.status : undefined;
83
- }
84
-
85
- describe('engine — unified inputs and outputs', () => {
86
- test('typed outputs feed typed inputs and command placeholders', async () => {
87
- const dir = makeDir();
88
- try {
89
- const emit = writeEmitScript(dir, 'emit', { id: '42', city: 'Shanghai' });
90
- const echo = writeEchoArgsScript(dir, 'echo');
91
- const config = pipeline([
92
- task({
93
- id: 'up',
94
- command: `node "${emit}"`,
95
- outputs: { id: { type: 'number' }, city: { type: 'string' } },
96
- }),
97
- task({
98
- id: 'down',
99
- depends_on: ['up'],
100
- command: `node "${echo}" "{{inputs.city}}" "{{inputs.id}}"`,
101
- inputs: {
102
- city: { from: 't.up.outputs.city', type: 'string', required: true },
103
- id: { from: 't.up.outputs.id', type: 'number', required: true },
104
- },
105
- }),
106
- ]);
107
-
108
- const { events, success } = await run(config, dir);
109
- expect(success).toBe(true);
110
- expect(finalUpdateFor(events, 't.up')?.outputs).toEqual({ id: 42, city: 'Shanghai' });
111
- expect(finalUpdateFor(events, 't.down')?.inputs).toEqual({ city: 'Shanghai', id: 42 });
112
- expect(finalUpdateFor(events, 't.down')?.stdout).toContain('Shanghai|42');
113
- } finally {
114
- rmSync(dir, { recursive: true, force: true });
115
- }
116
- });
117
-
118
- test('missing required unified input blocks without spawning downstream', async () => {
119
- const dir = makeDir();
120
- try {
121
- const emit = writeEmitScript(dir, 'emit', { other: 'x' });
122
- const echo = writeEchoArgsScript(dir, 'echo');
123
- const config = pipeline([
124
- task({ id: 'up', command: `node "${emit}"`, outputs: { city: { type: 'string' } } }),
125
- task({
126
- id: 'down',
127
- depends_on: ['up'],
128
- command: `node "${echo}" "{{inputs.city}}"`,
129
- inputs: { city: { from: 't.up.outputs.city', type: 'string', required: true } },
130
- }),
131
- ]);
132
-
133
- const { events, success } = await run(config, dir);
134
- expect(success).toBe(false);
135
- expect(finalStatusFrom(events, 't.up')).toBe('success');
136
- expect(finalStatusFrom(events, 't.down')).toBe('blocked');
137
- } finally {
138
- rmSync(dir, { recursive: true, force: true });
139
- }
140
- });
141
-
142
- test('typed output coercion diagnostics leave missing downstream input', async () => {
143
- const dir = makeDir();
144
- try {
145
- const emit = writeEmitScript(dir, 'emit', { id: 'not-a-number' });
146
- const echo = writeEchoArgsScript(dir, 'echo');
147
- const config = pipeline([
148
- task({ id: 'up', command: `node "${emit}"`, outputs: { id: { type: 'number' } } }),
149
- task({
150
- id: 'down',
151
- depends_on: ['up'],
152
- command: `node "${echo}" "{{inputs.id}}"`,
153
- inputs: { id: { from: 't.up.outputs.id', type: 'number', required: true } },
154
- }),
155
- ]);
156
-
157
- const { events, success } = await run(config, dir);
158
- expect(success).toBe(false);
159
- expect(finalStatusFrom(events, 't.up')).toBe('success');
160
- expect(finalUpdateFor(events, 't.up')?.stderr).toContain('expected number');
161
- expect(finalStatusFrom(events, 't.down')).toBe('blocked');
162
- } finally {
163
- rmSync(dir, { recursive: true, force: true });
164
- }
165
- });
166
- });
@@ -1,56 +0,0 @@
1
- import { describe, expect, test } from 'bun:test';
2
- import { mkdtempSync, rmSync } from 'node:fs';
3
- import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
5
- import { runPipeline, type RunEventPayload } from './engine';
6
- import { PluginRegistry } from './registry';
7
- import type { PipelineConfig } from './types';
8
-
9
- function makeDir(): string {
10
- return mkdtempSync(join(tmpdir(), 'tagma-task-type-'));
11
- }
12
-
13
- describe('engine task type detection', () => {
14
- test('empty command is still a command task and does not require a driver', async () => {
15
- const dir = makeDir();
16
- const previousShell = process.env.PIPELINE_SHELL;
17
- process.env.PIPELINE_SHELL = 'cmd';
18
- try {
19
- const events: RunEventPayload[] = [];
20
- const config: PipelineConfig = {
21
- name: 'empty-command',
22
- tracks: [
23
- {
24
- id: 't',
25
- name: 'T',
26
- tasks: [{ id: 'cmd', name: 'cmd', command: '' }],
27
- },
28
- ],
29
- };
30
-
31
- const result = await runPipeline(config, dir, {
32
- registry: new PluginRegistry(),
33
- skipPluginLoading: true,
34
- onEvent: (event) => events.push(event),
35
- });
36
-
37
- expect(result.success).toBe(true);
38
- expect(events.some((event) => event.type === 'run_start')).toBe(true);
39
- const final = events.findLast(
40
- (event) => event.type === 'task_update' && event.taskId === 't.cmd',
41
- );
42
- expect(final?.type).toBe('task_update');
43
- if (final?.type === 'task_update') {
44
- expect(final.status).toBe('success');
45
- expect(final.resolvedDriver).toBeNull();
46
- }
47
- } finally {
48
- if (previousShell === undefined) {
49
- delete process.env.PIPELINE_SHELL;
50
- } else {
51
- process.env.PIPELINE_SHELL = previousShell;
52
- }
53
- rmSync(dir, { recursive: true, force: true });
54
- }
55
- });
56
- });
package/src/engine.ts DELETED
@@ -1,458 +0,0 @@
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
- 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;
108
- /**
109
- * Runtime implementation for command and driver process execution.
110
- * Defaults to the SDK's Bun runtime.
111
- */
112
- readonly runtime?: TagmaRuntime;
113
- }
114
-
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(
125
- config: PipelineConfig,
126
- workDir: string,
127
- options: RunPipelineOptions,
128
- ): 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
- });
166
- });
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
- }
457
-
458
-