@tagma/sdk 0.4.19 → 0.5.1
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 +28 -24
- package/dist/bootstrap.js +5 -5
- package/dist/bootstrap.js.map +1 -1
- package/dist/drivers/opencode.d.ts +3 -0
- package/dist/drivers/opencode.d.ts.map +1 -0
- package/dist/drivers/opencode.js +176 -0
- package/dist/drivers/opencode.js.map +1 -0
- package/dist/engine.d.ts +3 -34
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +135 -41
- package/dist/engine.js.map +1 -1
- package/dist/hooks.d.ts +3 -2
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +7 -2
- package/dist/hooks.js.map +1 -1
- package/dist/pipeline-runner.d.ts +22 -18
- package/dist/pipeline-runner.d.ts.map +1 -1
- package/dist/pipeline-runner.js +69 -52
- package/dist/pipeline-runner.js.map +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +0 -1
- package/dist/registry.js.map +1 -1
- package/dist/schema.js +4 -4
- package/dist/schema.js.map +1 -1
- package/dist/schema.test.js +3 -3
- package/dist/schema.test.js.map +1 -1
- package/dist/sdk.d.ts +1 -1
- package/dist/sdk.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/bootstrap.ts +5 -5
- package/src/drivers/opencode.ts +204 -0
- package/src/engine.ts +159 -76
- package/src/hooks.ts +8 -2
- package/src/pipeline-runner.ts +73 -56
- package/src/registry.ts +0 -1
- package/src/schema.test.ts +3 -3
- package/src/schema.ts +4 -4
- package/src/sdk.ts +1 -1
- package/src/drivers/claude-code.ts +0 -250
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DriverPlugin,
|
|
3
|
+
DriverCapabilities,
|
|
4
|
+
DriverResultMeta,
|
|
5
|
+
TaskConfig,
|
|
6
|
+
TrackConfig,
|
|
7
|
+
DriverContext,
|
|
8
|
+
SpawnSpec,
|
|
9
|
+
} from '../types';
|
|
10
|
+
|
|
11
|
+
const DEFAULT_MODEL = 'opencode/big-pickle';
|
|
12
|
+
|
|
13
|
+
// NOTE on Windows multi-line prompts: `opencode` resolves to `opencode.cmd`,
|
|
14
|
+
// an npm-generated batch wrapper. cmd.exe silently truncates argv elements
|
|
15
|
+
// at the first newline, so a multi-line prompt reaches the model as only
|
|
16
|
+
// its first line. The SDK's runner auto-unwraps npm .cmd shims into direct
|
|
17
|
+
// `node <js-entry>` invocations so newlines survive, and this driver can
|
|
18
|
+
// keep using the bare `opencode` name on every platform.
|
|
19
|
+
|
|
20
|
+
// tagma uses a provider-neutral reasoning_effort vocabulary (low|medium|high)
|
|
21
|
+
// but opencode's `--variant` is provider-specific (e.g. high|max|minimal).
|
|
22
|
+
// Map the tagma values to the closest opencode variant:
|
|
23
|
+
// low → minimal (least thinking)
|
|
24
|
+
// medium → <no flag, provider default>
|
|
25
|
+
// high → high (most thinking)
|
|
26
|
+
// Unknown values pass through unchanged so users who target a specific
|
|
27
|
+
// opencode variant (e.g. "max") still work.
|
|
28
|
+
const EFFORT_TO_VARIANT: Record<string, string | null> = {
|
|
29
|
+
low: 'minimal',
|
|
30
|
+
medium: null,
|
|
31
|
+
high: 'high',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const OpenCodeDriver: DriverPlugin = {
|
|
35
|
+
name: 'opencode',
|
|
36
|
+
|
|
37
|
+
capabilities: {
|
|
38
|
+
sessionResume: true, // supports --session
|
|
39
|
+
systemPrompt: false, // no --system-prompt flag; prepend to prompt instead
|
|
40
|
+
outputFormat: true, // supports --format json
|
|
41
|
+
} satisfies DriverCapabilities,
|
|
42
|
+
|
|
43
|
+
resolveModel(): string {
|
|
44
|
+
return DEFAULT_MODEL;
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
async buildCommand(task: TaskConfig, track: TrackConfig, ctx: DriverContext): Promise<SpawnSpec> {
|
|
48
|
+
const model = task.model ?? track.model ?? DEFAULT_MODEL;
|
|
49
|
+
// Resolve reasoning_effort → opencode --variant. SDK schema layer already
|
|
50
|
+
// resolved task → track → pipeline inheritance, so we only need to read
|
|
51
|
+
// task.reasoning_effort here.
|
|
52
|
+
const rawEffort = task.reasoning_effort ?? track.reasoning_effort;
|
|
53
|
+
const variant = rawEffort
|
|
54
|
+
? rawEffort in EFFORT_TO_VARIANT
|
|
55
|
+
? EFFORT_TO_VARIANT[rawEffort]
|
|
56
|
+
: rawEffort
|
|
57
|
+
: null;
|
|
58
|
+
|
|
59
|
+
let prompt = task.prompt!;
|
|
60
|
+
|
|
61
|
+
// agent_profile has no dedicated flag; prepend to prompt
|
|
62
|
+
const profile = task.agent_profile ?? track.agent_profile;
|
|
63
|
+
if (profile) {
|
|
64
|
+
prompt = `[Role]\n${profile}\n\n[Task]\n${prompt}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// continue_from: prefer session resume, fall back to text injection
|
|
68
|
+
let sessionId: string | null = null;
|
|
69
|
+
if (task.continue_from) {
|
|
70
|
+
sessionId = ctx.sessionMap.get(task.continue_from) ?? null;
|
|
71
|
+
if (!sessionId) {
|
|
72
|
+
// no session — degrade to text context passthrough
|
|
73
|
+
let prev: string | null = null;
|
|
74
|
+
if (ctx.normalizedMap.has(task.continue_from)) {
|
|
75
|
+
prev = ctx.normalizedMap.get(task.continue_from)!;
|
|
76
|
+
}
|
|
77
|
+
if (prev !== null) {
|
|
78
|
+
prompt = `[Previous Output]\n${prev}\n\n[Current Task]\n${prompt}`;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// opencode run does not support stdin (no `-` placeholder like codex exec).
|
|
84
|
+
// Prompt is always a positional argument. Flags must be declared before `--`;
|
|
85
|
+
// the prompt follows after so that leading `--flag` content cannot be
|
|
86
|
+
// misread by opencode's argument parser (flag-injection mitigation).
|
|
87
|
+
// Shell-level injection is already prevented by Bun.spawn's direct argv array.
|
|
88
|
+
// Windows cmd.exe argv truncation on the `.cmd` wrapper is handled by the
|
|
89
|
+
// SDK runner's shim unwrapping — see note at the top of this file.
|
|
90
|
+
const args: string[] = [
|
|
91
|
+
'opencode',
|
|
92
|
+
'run',
|
|
93
|
+
'--model',
|
|
94
|
+
model,
|
|
95
|
+
'--format',
|
|
96
|
+
'json', // JSON output for parseResult
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
// `--variant` must precede `--` like every other flag. opencode rejects
|
|
100
|
+
// unknown variant names with a clear error, so we don't pre-validate.
|
|
101
|
+
if (variant) {
|
|
102
|
+
args.push('--variant', variant);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// session resume (must appear before --)
|
|
106
|
+
if (sessionId) {
|
|
107
|
+
args.push('--session', sessionId);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// `--` (POSIX end-of-options) isolates prompt from flag parsing
|
|
111
|
+
args.push('--', prompt);
|
|
112
|
+
|
|
113
|
+
return { args, cwd: task.cwd ?? ctx.workDir };
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
parseResult(stdout: string): DriverResultMeta {
|
|
117
|
+
// opencode --format json emits NDJSON — one JSON object per line
|
|
118
|
+
// (step_start / text / step_finish / …). The previous single
|
|
119
|
+
// `JSON.parse(stdout)` always threw on this shape and fell through to
|
|
120
|
+
// the catch, returning sessionId:null and losing session resume.
|
|
121
|
+
// Walk line-by-line, pick up the first sessionID we see, concatenate
|
|
122
|
+
// any text-type parts into normalizedOutput, and bail early on error
|
|
123
|
+
// payloads.
|
|
124
|
+
const lines = stdout.split(/\r?\n/);
|
|
125
|
+
let sessionId: string | undefined;
|
|
126
|
+
const textParts: string[] = [];
|
|
127
|
+
let sawAnyJson = false;
|
|
128
|
+
let errorReason: string | null = null;
|
|
129
|
+
|
|
130
|
+
for (const raw of lines) {
|
|
131
|
+
const line = raw.trim();
|
|
132
|
+
if (!line) continue;
|
|
133
|
+
let json: Record<string, unknown>;
|
|
134
|
+
try {
|
|
135
|
+
json = JSON.parse(line) as Record<string, unknown>;
|
|
136
|
+
} catch {
|
|
137
|
+
continue; // tolerate interleaved non-JSON noise
|
|
138
|
+
}
|
|
139
|
+
sawAnyJson = true;
|
|
140
|
+
|
|
141
|
+
// M12: opencode sometimes emits {type:"error", error:{...}} with
|
|
142
|
+
// exit 0 for transient API failures. Force-fail so downstream
|
|
143
|
+
// skip_downstream / stop_all kicks in.
|
|
144
|
+
if (json.type === 'error') {
|
|
145
|
+
const err = json.error as { message?: unknown } | string | undefined;
|
|
146
|
+
const msg =
|
|
147
|
+
typeof err === 'object' && err !== null && typeof err.message === 'string'
|
|
148
|
+
? err.message
|
|
149
|
+
: typeof err === 'string'
|
|
150
|
+
? err
|
|
151
|
+
: null;
|
|
152
|
+
errorReason = msg
|
|
153
|
+
? `opencode reported error: ${msg}`
|
|
154
|
+
: 'opencode emitted an error JSON payload';
|
|
155
|
+
// D21: stop at the first error. Continuing meant subsequent text
|
|
156
|
+
// lines got accumulated into `textParts` only to be discarded by
|
|
157
|
+
// the error-return below, and a later `{type:"error"}` would
|
|
158
|
+
// silently overwrite the original cause — operators then debugged
|
|
159
|
+
// a downstream symptom while the root-cause line scrolled past.
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Session id — opencode uses `sessionID` (camelCase with capital D).
|
|
164
|
+
// Keep `session_id` / `sessionId` as fallbacks for forward/backward
|
|
165
|
+
// compatibility with other shapes.
|
|
166
|
+
if (!sessionId) {
|
|
167
|
+
const sid =
|
|
168
|
+
(json.sessionID as string | undefined) ??
|
|
169
|
+
(json.session_id as string | undefined) ??
|
|
170
|
+
(json.sessionId as string | undefined) ??
|
|
171
|
+
null;
|
|
172
|
+
if (typeof sid === 'string' && sid.length > 0) sessionId = sid;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Extract human-readable text from text-type parts.
|
|
176
|
+
if (json.type === 'text') {
|
|
177
|
+
const part = json.part as { text?: unknown } | undefined;
|
|
178
|
+
if (part && typeof part.text === 'string') {
|
|
179
|
+
textParts.push(part.text);
|
|
180
|
+
}
|
|
181
|
+
} else if (typeof json.result === 'string') {
|
|
182
|
+
textParts.push(json.result);
|
|
183
|
+
} else if (typeof json.content === 'string') {
|
|
184
|
+
textParts.push(json.content);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (errorReason) {
|
|
189
|
+
return { forceFailure: true, forceFailureReason: errorReason };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// If nothing parsed as JSON, treat stdout as plain text.
|
|
193
|
+
const normalizedOutput = !sawAnyJson
|
|
194
|
+
? stdout
|
|
195
|
+
: textParts.length > 0
|
|
196
|
+
? textParts.join('\n')
|
|
197
|
+
: stdout;
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
sessionId,
|
|
201
|
+
normalizedOutput,
|
|
202
|
+
};
|
|
203
|
+
},
|
|
204
|
+
};
|
package/src/engine.ts
CHANGED
|
@@ -14,6 +14,10 @@ import type {
|
|
|
14
14
|
DriverContext,
|
|
15
15
|
OnFailure,
|
|
16
16
|
PromptDocument,
|
|
17
|
+
Permissions,
|
|
18
|
+
AbortReason,
|
|
19
|
+
RunEventPayload,
|
|
20
|
+
RunTaskState,
|
|
17
21
|
} from './types';
|
|
18
22
|
import { buildDag, type Dag } from './dag';
|
|
19
23
|
import { getHandler, hasHandler, loadPlugins } from './registry';
|
|
@@ -30,7 +34,7 @@ import {
|
|
|
30
34
|
type TrackInfo,
|
|
31
35
|
type TaskInfo,
|
|
32
36
|
} from './hooks';
|
|
33
|
-
import { Logger, tailLines, clip
|
|
37
|
+
import { Logger, tailLines, clip } from './logger';
|
|
34
38
|
import { InMemoryApprovalGateway, type ApprovalGateway } from './approval';
|
|
35
39
|
|
|
36
40
|
// ═══ A7: Typed trigger errors ═══
|
|
@@ -61,7 +65,7 @@ function preflight(config: PipelineConfig, dag: Dag): void {
|
|
|
61
65
|
for (const [, node] of dag.nodes) {
|
|
62
66
|
const task = node.task;
|
|
63
67
|
const track = node.track;
|
|
64
|
-
const driverName = task.driver ?? track.driver ?? config.driver ?? '
|
|
68
|
+
const driverName = task.driver ?? track.driver ?? config.driver ?? 'opencode';
|
|
65
69
|
|
|
66
70
|
// Pure command tasks don't use a driver — skip driver registration check.
|
|
67
71
|
const isCommandOnly = task.command && !task.prompt;
|
|
@@ -101,7 +105,7 @@ function preflight(config: PipelineConfig, dag: Dag): void {
|
|
|
101
105
|
// OR in-memory text injection through normalizedMap
|
|
102
106
|
// (when the upstream driver implements parseResult and returns normalizedOutput).
|
|
103
107
|
const upstreamDriverName =
|
|
104
|
-
upstream.task.driver ?? upstream.track.driver ?? config.driver ?? '
|
|
108
|
+
upstream.task.driver ?? upstream.track.driver ?? config.driver ?? 'opencode';
|
|
105
109
|
const upstreamDriver = hasHandler('drivers', upstreamDriverName)
|
|
106
110
|
? getHandler<DriverPlugin>('drivers', upstreamDriverName)
|
|
107
111
|
: null;
|
|
@@ -144,37 +148,52 @@ export interface EngineResult {
|
|
|
144
148
|
}
|
|
145
149
|
|
|
146
150
|
// ═══ Pipeline Events ═══
|
|
151
|
+
//
|
|
152
|
+
// The engine emits RunEventPayload values (defined in @tagma/types) via
|
|
153
|
+
// `onEvent`. Every payload carries `runId`; the editor server stamps a
|
|
154
|
+
// per-run `seq` before broadcasting. There is one event vocabulary
|
|
155
|
+
// end-to-end — no server-side translation layer.
|
|
147
156
|
|
|
148
|
-
export type
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
157
|
+
// Re-export so SDK consumers can import the event type without reaching
|
|
158
|
+
// into @tagma/types directly.
|
|
159
|
+
export type { RunEventPayload } from './types';
|
|
160
|
+
|
|
161
|
+
// ═══ Helpers ═══
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Project the engine's internal TaskState onto the wire RunTaskState
|
|
165
|
+
* shape. `logs` / `totalLogCount` default to empty — they are populated
|
|
166
|
+
* on the server side from streamed `task_log` events, not from state.
|
|
167
|
+
*/
|
|
168
|
+
function toRunTaskState(
|
|
169
|
+
taskId: string,
|
|
170
|
+
trackId: string,
|
|
171
|
+
taskName: string,
|
|
172
|
+
state: TaskState,
|
|
173
|
+
): RunTaskState {
|
|
174
|
+
const result = state.result;
|
|
175
|
+
const cfg = state.config;
|
|
176
|
+
return {
|
|
177
|
+
taskId,
|
|
178
|
+
trackId,
|
|
179
|
+
taskName,
|
|
180
|
+
status: state.status,
|
|
181
|
+
startedAt: state.startedAt,
|
|
182
|
+
finishedAt: state.finishedAt,
|
|
183
|
+
durationMs: result?.durationMs ?? null,
|
|
184
|
+
exitCode: result?.exitCode ?? null,
|
|
185
|
+
stdout: result?.stdout ?? '',
|
|
186
|
+
stderr: result?.stderr ?? '',
|
|
187
|
+
stderrPath: result?.stderrPath ?? null,
|
|
188
|
+
sessionId: result?.sessionId ?? null,
|
|
189
|
+
normalizedOutput: result?.normalizedOutput ?? null,
|
|
190
|
+
resolvedDriver: cfg.driver ?? null,
|
|
191
|
+
resolvedModel: cfg.model ?? null,
|
|
192
|
+
resolvedPermissions: (cfg.permissions as Permissions | undefined) ?? null,
|
|
193
|
+
logs: [],
|
|
194
|
+
totalLogCount: 0,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
178
197
|
|
|
179
198
|
export interface RunPipelineOptions {
|
|
180
199
|
readonly approvalGateway?: ApprovalGateway;
|
|
@@ -198,7 +217,7 @@ export interface RunPipelineOptions {
|
|
|
198
217
|
* Called on every pipeline/task status transition.
|
|
199
218
|
* Use for real-time UI updates (e.g. updating a visual workflow graph).
|
|
200
219
|
*/
|
|
201
|
-
readonly onEvent?: (event:
|
|
220
|
+
readonly onEvent?: (event: RunEventPayload) => void;
|
|
202
221
|
/**
|
|
203
222
|
* Skip the engine's built-in `loadPlugins(config.plugins)` call.
|
|
204
223
|
* Use this when the host has already pre-loaded plugins from a custom
|
|
@@ -260,7 +279,7 @@ export async function runPipeline(
|
|
|
260
279
|
// File-only: dump the resolved pipeline shape + DAG topology for post-mortem.
|
|
261
280
|
log.section('Pipeline configuration');
|
|
262
281
|
log.quiet(`name: ${config.name}`);
|
|
263
|
-
log.quiet(`driver: ${config.driver ?? '(default:
|
|
282
|
+
log.quiet(`driver: ${config.driver ?? '(default: opencode)'}`);
|
|
264
283
|
log.quiet(`timeout: ${config.timeout ?? '(none)'}`);
|
|
265
284
|
log.quiet(`tracks: ${config.tracks.length}`);
|
|
266
285
|
log.quiet(`tasks (total): ${dag.nodes.size}`);
|
|
@@ -290,7 +309,10 @@ export async function runPipeline(
|
|
|
290
309
|
});
|
|
291
310
|
}
|
|
292
311
|
|
|
293
|
-
// Pipeline start hook (gate)
|
|
312
|
+
// Pipeline start hook (gate). Runs BEFORE the engine emits run_start so
|
|
313
|
+
// a blocked pipeline produces zero wire events (the server treats the
|
|
314
|
+
// thrown error as run_error). Hosts get a rich error message; nothing
|
|
315
|
+
// is ever half-broadcast.
|
|
294
316
|
const startHook = await executeHook(
|
|
295
317
|
config.hooks,
|
|
296
318
|
'pipeline_start',
|
|
@@ -305,7 +327,6 @@ export async function runPipeline(
|
|
|
305
327
|
buildPipelineErrorContext(pipelineInfo, 'pipeline_blocked', 'pipeline_blocked'),
|
|
306
328
|
workDir,
|
|
307
329
|
);
|
|
308
|
-
// All tasks stay idle — pipeline never started
|
|
309
330
|
return {
|
|
310
331
|
success: false,
|
|
311
332
|
runId,
|
|
@@ -322,41 +343,51 @@ export async function runPipeline(
|
|
|
322
343
|
};
|
|
323
344
|
}
|
|
324
345
|
|
|
325
|
-
// Pipeline approved — transition all tasks to waiting
|
|
346
|
+
// Pipeline approved — transition all tasks to waiting.
|
|
326
347
|
for (const [, state] of states) {
|
|
327
348
|
state.status = 'waiting';
|
|
328
349
|
}
|
|
329
|
-
//
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
350
|
+
// Emit run_start with a wire-shape snapshot so SSE subscribers can
|
|
351
|
+
// initialize their task maps on the same event stream that carries
|
|
352
|
+
// updates. No separate "server pre-broadcasts run_start" ceremony —
|
|
353
|
+
// the engine owns the lifecycle boundary.
|
|
354
|
+
const runStartTasks: RunTaskState[] = [];
|
|
355
|
+
for (const [id, node] of dag.nodes) {
|
|
356
|
+
const s = states.get(id)!;
|
|
357
|
+
runStartTasks.push(toRunTaskState(id, node.track.id, node.task.name ?? id, s));
|
|
358
|
+
}
|
|
359
|
+
emit({ type: 'run_start', runId, tasks: runStartTasks });
|
|
334
360
|
|
|
335
361
|
const sessionMap = new Map<string, string>();
|
|
336
362
|
const normalizedMap = new Map<string, string>();
|
|
337
363
|
|
|
338
|
-
// Pipeline timeout
|
|
364
|
+
// Pipeline timeout + abort reason tracking.
|
|
365
|
+
//
|
|
366
|
+
// `abortReason` replaces the previous `pipelineAborted: boolean`: it
|
|
367
|
+
// carries the concrete cause (timeout / stop_all / external) through
|
|
368
|
+
// to run_end and the pipeline_error hook so downstream consumers can
|
|
369
|
+
// distinguish them without scraping message strings.
|
|
339
370
|
const pipelineTimeoutMs = config.timeout ? parseDuration(config.timeout) : 0;
|
|
340
|
-
let
|
|
371
|
+
let abortReason: AbortReason | null = null;
|
|
341
372
|
const abortController = new AbortController();
|
|
342
373
|
let pipelineTimer: ReturnType<typeof setTimeout> | null = null;
|
|
343
374
|
|
|
344
375
|
if (pipelineTimeoutMs > 0) {
|
|
345
376
|
pipelineTimer = setTimeout(() => {
|
|
346
|
-
|
|
377
|
+
if (abortReason === null) abortReason = 'timeout';
|
|
347
378
|
abortController.abort();
|
|
348
379
|
}, pipelineTimeoutMs);
|
|
349
380
|
}
|
|
350
381
|
|
|
351
|
-
// When the pipeline is aborted (timeout, external
|
|
352
|
-
// pending approvals so waiting triggers unblock immediately.
|
|
382
|
+
// When the pipeline is aborted (timeout, stop_all, external), drain
|
|
383
|
+
// all pending approvals so waiting triggers unblock immediately.
|
|
353
384
|
abortController.signal.addEventListener('abort', () => {
|
|
354
385
|
approvalGateway.abortAll('pipeline aborted');
|
|
355
386
|
});
|
|
356
387
|
|
|
357
388
|
// Wire external cancel signal into the internal abort controller.
|
|
358
389
|
const externalAbortHandler = () => {
|
|
359
|
-
|
|
390
|
+
if (abortReason === null) abortReason = 'external';
|
|
360
391
|
abortController.abort();
|
|
361
392
|
};
|
|
362
393
|
if (options.signal) {
|
|
@@ -367,9 +398,45 @@ export async function runPipeline(
|
|
|
367
398
|
}
|
|
368
399
|
}
|
|
369
400
|
|
|
401
|
+
// Bridge approval gateway events onto the wire stream so hosts (editor
|
|
402
|
+
// server, CLI adapters) see approvals on the same channel as task
|
|
403
|
+
// updates. The server no longer needs its own gateway subscription.
|
|
404
|
+
const unsubscribeApprovals = approvalGateway.subscribe((ev) => {
|
|
405
|
+
if (ev.type === 'requested') {
|
|
406
|
+
emit({
|
|
407
|
+
type: 'approval_request',
|
|
408
|
+
runId,
|
|
409
|
+
request: {
|
|
410
|
+
id: ev.request.id,
|
|
411
|
+
taskId: ev.request.taskId,
|
|
412
|
+
trackId: ev.request.trackId,
|
|
413
|
+
message: ev.request.message,
|
|
414
|
+
createdAt: ev.request.createdAt,
|
|
415
|
+
timeoutMs: ev.request.timeoutMs,
|
|
416
|
+
metadata: ev.request.metadata,
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (ev.type === 'resolved' || ev.type === 'expired' || ev.type === 'aborted') {
|
|
422
|
+
const outcome =
|
|
423
|
+
ev.type === 'resolved'
|
|
424
|
+
? ev.decision.outcome
|
|
425
|
+
: ev.type === 'expired'
|
|
426
|
+
? 'timeout'
|
|
427
|
+
: 'aborted';
|
|
428
|
+
emit({
|
|
429
|
+
type: 'approval_resolved',
|
|
430
|
+
runId,
|
|
431
|
+
requestId: ev.request.id,
|
|
432
|
+
outcome,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
|
|
370
437
|
// ── Helpers ──
|
|
371
438
|
|
|
372
|
-
function emit(event:
|
|
439
|
+
function emit(event: RunEventPayload): void {
|
|
373
440
|
options.onEvent?.(event);
|
|
374
441
|
}
|
|
375
442
|
|
|
@@ -380,24 +447,26 @@ export async function runPipeline(
|
|
|
380
447
|
// skipped and then having their in-flight processTask promise overwrite
|
|
381
448
|
// that with success/failed, producing an invalid double transition.
|
|
382
449
|
if (isTerminal(state.status)) return;
|
|
383
|
-
const prevStatus = state.status;
|
|
384
450
|
state.status = newStatus;
|
|
385
|
-
|
|
386
|
-
const
|
|
387
|
-
config: state.config,
|
|
388
|
-
trackConfig: state.trackConfig,
|
|
389
|
-
status: state.status,
|
|
390
|
-
result: state.result,
|
|
391
|
-
startedAt: state.startedAt,
|
|
392
|
-
finishedAt: state.finishedAt,
|
|
393
|
-
};
|
|
451
|
+
const result = state.result;
|
|
452
|
+
const cfg = state.config;
|
|
394
453
|
emit({
|
|
395
|
-
type: '
|
|
454
|
+
type: 'task_update',
|
|
455
|
+
runId,
|
|
396
456
|
taskId,
|
|
397
457
|
status: newStatus,
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
458
|
+
startedAt: state.startedAt ?? undefined,
|
|
459
|
+
finishedAt: state.finishedAt ?? undefined,
|
|
460
|
+
durationMs: result?.durationMs,
|
|
461
|
+
exitCode: result?.exitCode,
|
|
462
|
+
stdout: result?.stdout,
|
|
463
|
+
stderr: result?.stderr,
|
|
464
|
+
stderrPath: result?.stderrPath ?? null,
|
|
465
|
+
sessionId: result?.sessionId ?? null,
|
|
466
|
+
normalizedOutput: result?.normalizedOutput ?? null,
|
|
467
|
+
resolvedDriver: cfg.driver ?? null,
|
|
468
|
+
resolvedModel: cfg.model ?? null,
|
|
469
|
+
resolvedPermissions: (cfg.permissions as Permissions | undefined) ?? null,
|
|
401
470
|
});
|
|
402
471
|
}
|
|
403
472
|
|
|
@@ -435,7 +504,7 @@ export async function runPipeline(
|
|
|
435
504
|
* should a completed running task try to overwrite the skipped state.
|
|
436
505
|
*/
|
|
437
506
|
function applyStopAll(_failedTrackId: string): void {
|
|
438
|
-
|
|
507
|
+
if (abortReason === null) abortReason = 'stop_all';
|
|
439
508
|
abortController.abort();
|
|
440
509
|
for (const [id, state] of states) {
|
|
441
510
|
if (state.status === 'waiting') {
|
|
@@ -559,7 +628,7 @@ export async function runPipeline(
|
|
|
559
628
|
// If pipeline was aborted while we were still waiting for the trigger,
|
|
560
629
|
// this task never entered running state → skipped, not timeout.
|
|
561
630
|
state.finishedAt = nowISO();
|
|
562
|
-
if (
|
|
631
|
+
if (abortReason !== null) {
|
|
563
632
|
setTaskStatus(taskId, 'skipped');
|
|
564
633
|
} else if (err instanceof TriggerBlockedError) {
|
|
565
634
|
setTaskStatus(taskId, 'blocked'); // user/policy rejection
|
|
@@ -618,7 +687,7 @@ export async function runPipeline(
|
|
|
618
687
|
}
|
|
619
688
|
|
|
620
689
|
// 4. Mark running — set startedAt before emitting so subscribers see a
|
|
621
|
-
// complete
|
|
690
|
+
// complete task_update (startedAt non-null) on the status transition.
|
|
622
691
|
state.startedAt = nowISO();
|
|
623
692
|
setTaskStatus(taskId, 'running');
|
|
624
693
|
log.info(
|
|
@@ -627,7 +696,7 @@ export async function runPipeline(
|
|
|
627
696
|
);
|
|
628
697
|
|
|
629
698
|
// File-only: resolved config for this task
|
|
630
|
-
const resolvedDriver = task.driver ?? track.driver ?? config.driver ?? '
|
|
699
|
+
const resolvedDriver = task.driver ?? track.driver ?? config.driver ?? 'opencode';
|
|
631
700
|
const resolvedModel = task.model ?? track.model ?? config.model ?? '(default)';
|
|
632
701
|
const resolvedPerms = task.permissions ?? track.permissions ?? '(default)';
|
|
633
702
|
const resolvedCwd = task.cwd ?? track.cwd ?? workDir;
|
|
@@ -654,7 +723,7 @@ export async function runPipeline(
|
|
|
654
723
|
result = await runCommand(task.command, task.cwd ?? workDir, runOpts);
|
|
655
724
|
} else {
|
|
656
725
|
// AI task: apply middleware chain against a structured PromptDocument.
|
|
657
|
-
const driverName = task.driver ?? track.driver ?? config.driver ?? '
|
|
726
|
+
const driverName = task.driver ?? track.driver ?? config.driver ?? 'opencode';
|
|
658
727
|
const driver = getHandler<DriverPlugin>('drivers', driverName);
|
|
659
728
|
|
|
660
729
|
const originalLen = task.prompt!.length;
|
|
@@ -936,7 +1005,7 @@ export async function runPipeline(
|
|
|
936
1005
|
const running = new Map<string, Promise<void>>();
|
|
937
1006
|
|
|
938
1007
|
try {
|
|
939
|
-
while (
|
|
1008
|
+
while (abortReason === null) {
|
|
940
1009
|
// Launch every task whose deps are all terminal and that isn't already in-flight
|
|
941
1010
|
for (const [id, state] of states) {
|
|
942
1011
|
if (state.status !== 'waiting' || running.has(id)) continue;
|
|
@@ -962,7 +1031,7 @@ export async function runPipeline(
|
|
|
962
1031
|
}
|
|
963
1032
|
}
|
|
964
1033
|
|
|
965
|
-
if (
|
|
1034
|
+
if (abortReason !== null) {
|
|
966
1035
|
// Wait for in-flight tasks to honour the abort signal before marking states.
|
|
967
1036
|
if (running.size > 0) await Promise.allSettled(running.values());
|
|
968
1037
|
for (const [id, state] of states) {
|
|
@@ -986,6 +1055,9 @@ export async function runPipeline(
|
|
|
986
1055
|
if (approvalGateway.pending().length > 0) {
|
|
987
1056
|
approvalGateway.abortAll('pipeline finished');
|
|
988
1057
|
}
|
|
1058
|
+
// Detach gateway → onEvent bridge so a long-lived gateway (host-supplied)
|
|
1059
|
+
// doesn't keep firing into a dead run.
|
|
1060
|
+
unsubscribeApprovals();
|
|
989
1061
|
}
|
|
990
1062
|
|
|
991
1063
|
// ── Summary ──
|
|
@@ -1014,11 +1086,17 @@ export async function runPipeline(
|
|
|
1014
1086
|
const finishedAt = nowISO();
|
|
1015
1087
|
const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
|
|
1016
1088
|
|
|
1017
|
-
if (
|
|
1089
|
+
if (abortReason !== null) {
|
|
1090
|
+
const reasonText =
|
|
1091
|
+
abortReason === 'timeout'
|
|
1092
|
+
? 'Pipeline timeout exceeded'
|
|
1093
|
+
: abortReason === 'stop_all'
|
|
1094
|
+
? 'Pipeline stopped (on_failure: stop_all)'
|
|
1095
|
+
: 'Pipeline aborted by host';
|
|
1018
1096
|
await executeHook(
|
|
1019
1097
|
config.hooks,
|
|
1020
1098
|
'pipeline_error',
|
|
1021
|
-
buildPipelineErrorContext(pipelineInfo,
|
|
1099
|
+
buildPipelineErrorContext(pipelineInfo, reasonText, undefined, abortReason),
|
|
1022
1100
|
workDir,
|
|
1023
1101
|
);
|
|
1024
1102
|
} else {
|
|
@@ -1034,10 +1112,15 @@ export async function runPipeline(
|
|
|
1034
1112
|
}
|
|
1035
1113
|
|
|
1036
1114
|
const allSuccess =
|
|
1037
|
-
|
|
1115
|
+
abortReason === null &&
|
|
1116
|
+
summary.failed === 0 &&
|
|
1117
|
+
summary.timeout === 0 &&
|
|
1118
|
+
summary.blocked === 0;
|
|
1038
1119
|
|
|
1039
1120
|
log.section('Pipeline summary');
|
|
1040
|
-
log.quiet(
|
|
1121
|
+
log.quiet(
|
|
1122
|
+
`status: ${abortReason !== null ? `aborted (${abortReason})` : 'completed'}`,
|
|
1123
|
+
);
|
|
1041
1124
|
log.quiet(`duration: ${(durationMs / 1000).toFixed(1)}s`);
|
|
1042
1125
|
log.quiet(
|
|
1043
1126
|
`counts: total=${summary.total} success=${summary.success} ` +
|
|
@@ -1061,7 +1144,7 @@ export async function runPipeline(
|
|
|
1061
1144
|
log.info('[pipeline]', `Duration: ${(durationMs / 1000).toFixed(1)}s`);
|
|
1062
1145
|
log.info('[pipeline]', `Log: ${log.path}`);
|
|
1063
1146
|
|
|
1064
|
-
emit({ type: '
|
|
1147
|
+
emit({ type: 'run_end', runId, success: allSuccess, abortReason });
|
|
1065
1148
|
return { success: allSuccess, runId, logPath: log.path, summary, states: freezeStates(states) };
|
|
1066
1149
|
} finally {
|
|
1067
1150
|
// Close the persistent log file handle before pruning.
|
package/src/hooks.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { HooksConfig, HookCommand } from './types';
|
|
1
|
+
import type { HooksConfig, HookCommand, AbortReason } from './types';
|
|
2
2
|
import { shellArgs } from './utils';
|
|
3
3
|
|
|
4
4
|
type HookEvent =
|
|
@@ -182,6 +182,12 @@ export function buildPipelineErrorContext(
|
|
|
182
182
|
pipeline: PipelineInfo,
|
|
183
183
|
error: string,
|
|
184
184
|
eventType?: string,
|
|
185
|
+
abortReason?: AbortReason,
|
|
185
186
|
) {
|
|
186
|
-
return {
|
|
187
|
+
return {
|
|
188
|
+
event: eventType ?? 'pipeline_error',
|
|
189
|
+
pipeline,
|
|
190
|
+
error,
|
|
191
|
+
...(abortReason !== undefined ? { abort_reason: abortReason } : {}),
|
|
192
|
+
};
|
|
187
193
|
}
|