@tagma/sdk 0.4.18 → 0.5.0
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/dist/engine.d.ts +3 -34
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +130 -36
- 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/runner.d.ts.map +1 -1
- package/dist/runner.js +78 -10
- package/dist/runner.js.map +1 -1
- package/dist/sdk.d.ts +1 -1
- package/dist/sdk.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/engine.ts +153 -70
- package/src/hooks.ts +8 -2
- package/src/pipeline-runner.ts +73 -56
- package/src/registry.ts +0 -1
- package/src/runner.ts +76 -11
- package/src/sdk.ts +1 -1
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';
|
|
@@ -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
|
|
@@ -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(
|
|
@@ -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
|
}
|
package/src/pipeline-runner.ts
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
// ═══ PipelineRunner ═══
|
|
2
2
|
//
|
|
3
|
-
// Wraps runPipeline in a lifecycle object suited for multi-pipeline
|
|
4
|
-
// in sidecar / Tauri IPC scenarios. Each instance controls
|
|
3
|
+
// Wraps runPipeline in a lifecycle object suited for multi-pipeline
|
|
4
|
+
// management in sidecar / Tauri IPC scenarios. Each instance controls
|
|
5
|
+
// one pipeline run.
|
|
6
|
+
//
|
|
7
|
+
// The runner forwards wire-shape `RunEventPayload` values to its
|
|
8
|
+
// subscribers — identical to what the editor server broadcasts over SSE —
|
|
9
|
+
// so sidecar hosts don't need to know anything about the engine's
|
|
10
|
+
// internal TaskState.
|
|
5
11
|
//
|
|
6
12
|
// Typical sidecar usage:
|
|
7
13
|
//
|
|
8
14
|
// const runners = new Map<string, PipelineRunner>();
|
|
9
15
|
//
|
|
10
16
|
// const runner = new PipelineRunner(config, workDir);
|
|
11
|
-
// runner.subscribe(event => ipcEmit('
|
|
17
|
+
// runner.subscribe(event => ipcEmit('run_event', event));
|
|
12
18
|
// runner.start();
|
|
13
19
|
// runners.set(runner.instanceId, runner);
|
|
14
20
|
//
|
|
@@ -16,33 +22,37 @@
|
|
|
16
22
|
// runners.get(id)?.abort();
|
|
17
23
|
|
|
18
24
|
import { runPipeline } from './engine';
|
|
19
|
-
import type { EngineResult,
|
|
20
|
-
import type { PipelineConfig,
|
|
25
|
+
import type { EngineResult, RunPipelineOptions } from './engine';
|
|
26
|
+
import type { PipelineConfig, RunEventPayload, RunTaskState } from './types';
|
|
21
27
|
import { generateRunId } from './utils';
|
|
22
28
|
|
|
23
|
-
export type {
|
|
29
|
+
export type { EngineResult };
|
|
24
30
|
|
|
25
31
|
export type PipelineRunnerStatus = 'idle' | 'running' | 'done' | 'aborted';
|
|
26
32
|
|
|
27
33
|
export class PipelineRunner {
|
|
28
34
|
/**
|
|
29
|
-
* Stable ID assigned before start() — safe to use as a Map key
|
|
30
|
-
*
|
|
35
|
+
* Stable ID assigned before start() — safe to use as a Map key before
|
|
36
|
+
* the engine-assigned runId becomes available.
|
|
31
37
|
*/
|
|
32
38
|
readonly instanceId: string;
|
|
33
39
|
|
|
34
40
|
/**
|
|
35
|
-
* The runId generated by the engine.
|
|
36
|
-
* event
|
|
37
|
-
* null until then.
|
|
41
|
+
* The runId generated by the engine. Set when the first `run_start`
|
|
42
|
+
* event arrives on the forwarded event stream. null until then.
|
|
38
43
|
*/
|
|
39
44
|
private _runId: string | null = null;
|
|
40
45
|
private _status: PipelineRunnerStatus = 'idle';
|
|
41
46
|
private _result: Promise<EngineResult> | null = null;
|
|
42
47
|
private _abortController = new AbortController();
|
|
43
|
-
private _handlers = new Set<(event:
|
|
44
|
-
|
|
45
|
-
|
|
48
|
+
private _handlers = new Set<(event: RunEventPayload) => void>();
|
|
49
|
+
/**
|
|
50
|
+
* Wire-shape task mirror, kept in sync with `run_start` / `task_update`
|
|
51
|
+
* events. Exposed through `getTasks()`. Hosts see the same wire
|
|
52
|
+
* projection the editor client sees, so there is exactly one task-state
|
|
53
|
+
* vocabulary across IPC boundaries.
|
|
54
|
+
*/
|
|
55
|
+
private _tasks = new Map<string, RunTaskState>();
|
|
46
56
|
|
|
47
57
|
constructor(
|
|
48
58
|
private readonly config: PipelineConfig,
|
|
@@ -79,25 +89,11 @@ export class PipelineRunner {
|
|
|
79
89
|
...this.opts,
|
|
80
90
|
signal: this._abortController.signal,
|
|
81
91
|
onEvent: (event) => {
|
|
82
|
-
|
|
83
|
-
this._runId = event.runId;
|
|
84
|
-
// Initialize the live mirror with the full initial state snapshot
|
|
85
|
-
for (const [id, state] of event.states) {
|
|
86
|
-
this._statesMirror.set(id, { ...state });
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
if (event.type === 'task_status_change') {
|
|
90
|
-
// Keep the mirror up to date so getStates() works during the run
|
|
91
|
-
this._statesMirror.set(event.taskId, event.state);
|
|
92
|
-
}
|
|
93
|
-
if (event.type === 'pipeline_end') {
|
|
94
|
-
this._status = this._abortController.signal.aborted ? 'aborted' : 'done';
|
|
95
|
-
}
|
|
92
|
+
this._applyEvent(event);
|
|
96
93
|
for (const h of this._handlers) h(event);
|
|
97
94
|
},
|
|
98
95
|
})
|
|
99
96
|
.then((result) => {
|
|
100
|
-
this._states = result.states;
|
|
101
97
|
if (this._status === 'running') this._status = 'done';
|
|
102
98
|
return result;
|
|
103
99
|
})
|
|
@@ -109,6 +105,44 @@ export class PipelineRunner {
|
|
|
109
105
|
return this._result;
|
|
110
106
|
}
|
|
111
107
|
|
|
108
|
+
private _applyEvent(event: RunEventPayload): void {
|
|
109
|
+
switch (event.type) {
|
|
110
|
+
case 'run_start':
|
|
111
|
+
this._runId = event.runId;
|
|
112
|
+
this._tasks.clear();
|
|
113
|
+
for (const t of event.tasks) this._tasks.set(t.taskId, { ...t });
|
|
114
|
+
return;
|
|
115
|
+
case 'task_update': {
|
|
116
|
+
const prev = this._tasks.get(event.taskId);
|
|
117
|
+
if (!prev) return;
|
|
118
|
+
const pick = <T>(incoming: T | undefined, previous: T): T =>
|
|
119
|
+
incoming !== undefined ? incoming : previous;
|
|
120
|
+
this._tasks.set(event.taskId, {
|
|
121
|
+
...prev,
|
|
122
|
+
status: event.status,
|
|
123
|
+
startedAt: pick(event.startedAt, prev.startedAt),
|
|
124
|
+
finishedAt: pick(event.finishedAt, prev.finishedAt),
|
|
125
|
+
durationMs: pick(event.durationMs, prev.durationMs),
|
|
126
|
+
exitCode: pick(event.exitCode, prev.exitCode),
|
|
127
|
+
stdout: pick(event.stdout, prev.stdout),
|
|
128
|
+
stderr: pick(event.stderr, prev.stderr),
|
|
129
|
+
stderrPath: pick(event.stderrPath, prev.stderrPath),
|
|
130
|
+
sessionId: pick(event.sessionId, prev.sessionId),
|
|
131
|
+
normalizedOutput: pick(event.normalizedOutput, prev.normalizedOutput),
|
|
132
|
+
resolvedDriver: pick(event.resolvedDriver, prev.resolvedDriver),
|
|
133
|
+
resolvedModel: pick(event.resolvedModel, prev.resolvedModel),
|
|
134
|
+
resolvedPermissions: pick(event.resolvedPermissions, prev.resolvedPermissions),
|
|
135
|
+
});
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
case 'run_end':
|
|
139
|
+
this._status = this._abortController.signal.aborted ? 'aborted' : 'done';
|
|
140
|
+
return;
|
|
141
|
+
default:
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
112
146
|
/**
|
|
113
147
|
* Cancel the running pipeline. Safe to call multiple times or before start().
|
|
114
148
|
*/
|
|
@@ -118,39 +152,22 @@ export class PipelineRunner {
|
|
|
118
152
|
}
|
|
119
153
|
|
|
120
154
|
/**
|
|
121
|
-
* Live snapshot of task states.
|
|
122
|
-
*
|
|
123
|
-
* Returns null only if the pipeline has never started.
|
|
155
|
+
* Live snapshot of wire-shape task states. Populated from the first
|
|
156
|
+
* `run_start` event onward. Returns an empty map before the run starts.
|
|
124
157
|
*/
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
return
|
|
158
|
+
getTasks(): ReadonlyMap<string, RunTaskState> {
|
|
159
|
+
const copy = new Map<string, RunTaskState>();
|
|
160
|
+
for (const [id, t] of this._tasks) copy.set(id, { ...t });
|
|
161
|
+
return copy;
|
|
129
162
|
}
|
|
130
163
|
|
|
131
164
|
/**
|
|
132
|
-
* Subscribe to
|
|
133
|
-
*
|
|
134
|
-
*
|
|
165
|
+
* Subscribe to run events. Returns an unsubscribe function. Events are
|
|
166
|
+
* emitted synchronously in the engine's event loop, so keep handlers
|
|
167
|
+
* non-blocking (e.g. queue to IPC, do not await inside).
|
|
135
168
|
*/
|
|
136
|
-
subscribe(handler: (event:
|
|
169
|
+
subscribe(handler: (event: RunEventPayload) => void): () => void {
|
|
137
170
|
this._handlers.add(handler);
|
|
138
171
|
return () => this._handlers.delete(handler);
|
|
139
172
|
}
|
|
140
173
|
}
|
|
141
|
-
|
|
142
|
-
/** Deep-copy a states map so callers cannot mutate SDK internals. */
|
|
143
|
-
function snapshotStates(src: ReadonlyMap<string, TaskState>): ReadonlyMap<string, TaskState> {
|
|
144
|
-
const copy = new Map<string, TaskState>();
|
|
145
|
-
for (const [id, s] of src) {
|
|
146
|
-
copy.set(id, {
|
|
147
|
-
config: { ...s.config },
|
|
148
|
-
trackConfig: { ...s.trackConfig },
|
|
149
|
-
status: s.status,
|
|
150
|
-
result: s.result ? { ...s.result } : null,
|
|
151
|
-
startedAt: s.startedAt,
|
|
152
|
-
finishedAt: s.finishedAt,
|
|
153
|
-
});
|
|
154
|
-
}
|
|
155
|
-
return copy;
|
|
156
|
-
}
|
package/src/registry.ts
CHANGED
|
@@ -144,7 +144,6 @@ export function registerPlugin<T extends PluginType>(
|
|
|
144
144
|
// first's consumers with no audit trail. A console.warn is cheap,
|
|
145
145
|
// respects existing callers that rely on 'replaced', and gives ops a
|
|
146
146
|
// grep-able signal when registrations collide unexpectedly.
|
|
147
|
-
// eslint-disable-next-line no-console
|
|
148
147
|
console.warn(
|
|
149
148
|
`[tagma-sdk] registerPlugin: replaced existing ${category}/${type} — ` +
|
|
150
149
|
`check for duplicate plugin packages claiming the same type.`,
|