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