@tagma/sdk 0.1.4 → 0.1.6

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 CHANGED
@@ -108,10 +108,39 @@ Parses YAML, resolves inheritance, expands templates, and validates the configur
108
108
 
109
109
  ### `runPipeline(config, workDir, options?): Promise<EngineResult>`
110
110
 
111
- Executes the pipeline. Returns `{ success, summary, states }`.
111
+ Executes the pipeline. Returns `{ success, runId, logPath, summary, states }`.
112
112
 
113
113
  Options:
114
114
  - `approvalGateway` -- custom `ApprovalGateway` instance (defaults to `InMemoryApprovalGateway`)
115
+ - `signal` -- `AbortSignal` to cancel the run externally
116
+ - `onEvent` -- callback for real-time `PipelineEvent` updates (task status changes, pipeline start/end)
117
+ - `maxLogRuns` -- number of per-run log directories to keep under `<workDir>/logs/` (default: 20)
118
+
119
+ ### `PipelineRunner`
120
+
121
+ Higher-level wrapper for managing multiple concurrent pipeline runs — designed for sidecar / Tauri IPC scenarios where the frontend controls pipeline lifecycle by ID.
122
+
123
+ ```ts
124
+ const runner = new PipelineRunner(config, workDir);
125
+
126
+ // Subscribe before start — handler is called for every PipelineEvent
127
+ const unsubscribe = runner.subscribe(event => {
128
+ tauriEmit('pipeline_event', { id: runner.instanceId, event });
129
+ });
130
+
131
+ runner.start(); // returns Promise<EngineResult>, idempotent
132
+
133
+ // Cancel from IPC
134
+ runner.abort();
135
+
136
+ // After completion
137
+ const states = runner.getStates(); // ReadonlyMap<taskId, TaskState>
138
+ ```
139
+
140
+ Properties:
141
+ - `instanceId` — stable ID assigned at construction, safe to use as a Map key before `start()`
142
+ - `runId` — engine-assigned run ID, available after the first `pipeline_start` event (`null` until then)
143
+ - `status` — `'idle' | 'running' | 'done' | 'aborted'`
115
144
 
116
145
  ### `loadPlugins(names: string[]): Promise<void>`
117
146
 
@@ -125,6 +154,83 @@ Attaches an interactive stdin-based approval handler.
125
154
 
126
155
  Starts a WebSocket server for remote approval decisions.
127
156
 
157
+ ### Config CRUD (`config-ops`)
158
+
159
+ Pure, immutable helper functions for building and editing `RawPipelineConfig` in a visual editor. No runtime dependencies — safe to use in renderer processes.
160
+
161
+ ```ts
162
+ import {
163
+ createEmptyPipeline, setPipelineField,
164
+ upsertTrack, removeTrack, moveTrack, updateTrack,
165
+ upsertTask, removeTask, moveTask, transferTask,
166
+ serializePipeline,
167
+ } from '@tagma/sdk';
168
+
169
+ // Build a config programmatically
170
+ let config = createEmptyPipeline('my-pipeline');
171
+ config = upsertTrack(config, { id: 'backend', name: 'Backend', tasks: [] });
172
+ config = upsertTask(config, 'backend', { id: 'implement', prompt: 'Add /health endpoint' });
173
+
174
+ // Sync back to YAML
175
+ const yaml = serializePipeline(config);
176
+ ```
177
+
178
+ | Function | Description |
179
+ |---|---|
180
+ | `createEmptyPipeline(name)` | Create a minimal pipeline config |
181
+ | `setPipelineField(config, fields)` | Update top-level pipeline fields |
182
+ | `upsertTrack(config, track)` | Insert or replace a track by id |
183
+ | `removeTrack(config, trackId)` | Remove a track |
184
+ | `moveTrack(config, trackId, toIndex)` | Reorder a track |
185
+ | `updateTrack(config, trackId, fields)` | Patch track fields (not tasks) |
186
+ | `upsertTask(config, trackId, task)` | Insert or replace a task |
187
+ | `removeTask(config, trackId, taskId)` | Remove a task |
188
+ | `moveTask(config, trackId, taskId, toIndex)` | Reorder a task within its track |
189
+ | `transferTask(config, fromTrackId, taskId, toTrackId)` | Move a task across tracks |
190
+
191
+ ### `parseYaml(content: string): RawPipelineConfig`
192
+
193
+ Parses a YAML string and returns the raw (unresolved) pipeline config. Use this when you need to edit and re-save YAML without losing relative paths or user-authored formatting — pass the result to `serializePipeline()` rather than going through `loadPipeline()`.
194
+
195
+ ### `deresolvePipeline(config: PipelineConfig, workDir: string): RawPipelineConfig`
196
+
197
+ Converts a resolved `PipelineConfig` back to a `RawPipelineConfig` suitable for serialization. Strips injected defaults and converts absolute `cwd` paths back to relative so the output YAML is portable across machines.
198
+
199
+ Use this when you have a programmatically modified resolved config and need to save it back to YAML:
200
+
201
+ ```ts
202
+ // Correct: load → modify resolved config → deresolve → save
203
+ const config = await loadPipeline(yaml, workDir);
204
+ const modified = { ...config, name: 'renamed' };
205
+ const savedYaml = serializePipeline(deresolvePipeline(modified, workDir));
206
+
207
+ // Also correct: work entirely in raw space (preferred for visual editors)
208
+ const raw = parseYaml(yaml);
209
+ const updatedRaw = setPipelineField(raw, { name: 'renamed' });
210
+ const savedYaml = serializePipeline(updatedRaw);
211
+ ```
212
+
213
+ ### `validateConfig(config: PipelineConfig): string[]`
214
+
215
+ Validates a resolved pipeline config without executing it. Checks DAG structure (cycles, missing dependencies). Returns an array of error message strings — empty means valid.
216
+
217
+ Use `validateRaw` for editing raw configs in a UI; use `validateConfig` after `resolveConfig` for a final pre-run check.
218
+
219
+ ### `validateRaw(config: RawPipelineConfig): ValidationError[]`
220
+
221
+ Validates a raw pipeline config without resolving inheritance or executing anything. Returns a flat list of `{ path, message }` objects — empty array means valid.
222
+
223
+ Checks: required fields, `prompt`/`command` exclusivity, `depends_on`/`continue_from` reference integrity, circular dependency detection.
224
+
225
+ Does **not** check plugin registration (plugins may not be loaded at edit time).
226
+
227
+ ```ts
228
+ const errors = validateRaw(draftConfig);
229
+ if (errors.length > 0) {
230
+ errors.forEach(e => highlightNode(e.path, e.message));
231
+ }
232
+ ```
233
+
128
234
  ## Related Packages
129
235
 
130
236
  | Package | Description |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tagma/sdk",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "workspaces": [
6
6
  "plugins/*"
@@ -20,13 +20,13 @@
20
20
  "dependencies": {
21
21
  "js-yaml": "^4.1.0",
22
22
  "chokidar": "^4.0.0",
23
- "@tagma/types": "workspace:*"
23
+ "@tagma/types": "0.1.3"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/js-yaml": "^4.0.9",
27
27
  "bun-types": "latest",
28
28
  "typescript": "^6.0.2",
29
- "@tagma/driver-codex": "workspace:*",
30
- "@tagma/driver-opencode": "workspace:*"
29
+ "@tagma/driver-codex": "0.1.3",
30
+ "@tagma/driver-opencode": "0.1.3"
31
31
  }
32
32
  }
@@ -0,0 +1,183 @@
1
+ // ═══ RawPipelineConfig CRUD Operations ═══
2
+ //
3
+ // Pure, immutable helper functions for building and editing pipeline configs
4
+ // in a visual editor. None of these functions have runtime dependencies —
5
+ // safe to import in any context (sidecar, renderer, tests).
6
+ //
7
+ // All operations return a new config object; inputs are never mutated.
8
+
9
+ import type { RawPipelineConfig, RawTrackConfig, RawTaskConfig } from './types';
10
+
11
+ // ── Pipeline ──
12
+
13
+ /**
14
+ * Create a minimal empty pipeline config.
15
+ */
16
+ export function createEmptyPipeline(name: string): RawPipelineConfig {
17
+ return { name, tracks: [] };
18
+ }
19
+
20
+ /**
21
+ * Update a top-level pipeline field (name, driver, timeout, etc.).
22
+ */
23
+ export function setPipelineField(
24
+ config: RawPipelineConfig,
25
+ fields: Partial<Omit<RawPipelineConfig, 'tracks'>>,
26
+ ): RawPipelineConfig {
27
+ return { ...config, ...fields };
28
+ }
29
+
30
+ // ── Tracks ──
31
+
32
+ /**
33
+ * Insert or replace a track by id. Appends if the id is new.
34
+ */
35
+ export function upsertTrack(
36
+ config: RawPipelineConfig,
37
+ track: RawTrackConfig,
38
+ ): RawPipelineConfig {
39
+ const exists = config.tracks.some(t => t.id === track.id);
40
+ return {
41
+ ...config,
42
+ tracks: exists
43
+ ? config.tracks.map(t => (t.id === track.id ? track : t))
44
+ : [...config.tracks, track],
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Remove a track by id. No-op if the id is not found.
50
+ */
51
+ export function removeTrack(
52
+ config: RawPipelineConfig,
53
+ trackId: string,
54
+ ): RawPipelineConfig {
55
+ return { ...config, tracks: config.tracks.filter(t => t.id !== trackId) };
56
+ }
57
+
58
+ /**
59
+ * Move a track to a new index position (0-based).
60
+ * Clamps toIndex to valid bounds.
61
+ */
62
+ export function moveTrack(
63
+ config: RawPipelineConfig,
64
+ trackId: string,
65
+ toIndex: number,
66
+ ): RawPipelineConfig {
67
+ const idx = config.tracks.findIndex(t => t.id === trackId);
68
+ if (idx === -1) return config;
69
+ const tracks = [...config.tracks];
70
+ const [track] = tracks.splice(idx, 1);
71
+ const clamped = Math.max(0, Math.min(toIndex, tracks.length));
72
+ tracks.splice(clamped, 0, track);
73
+ return { ...config, tracks };
74
+ }
75
+
76
+ /**
77
+ * Update fields on a single track (excluding tasks list, use upsertTask / removeTask for that).
78
+ */
79
+ export function updateTrack(
80
+ config: RawPipelineConfig,
81
+ trackId: string,
82
+ fields: Partial<Omit<RawTrackConfig, 'id' | 'tasks'>>,
83
+ ): RawPipelineConfig {
84
+ return {
85
+ ...config,
86
+ tracks: config.tracks.map(t =>
87
+ t.id === trackId ? { ...t, ...fields } : t,
88
+ ),
89
+ };
90
+ }
91
+
92
+ // ── Tasks ──
93
+
94
+ /**
95
+ * Insert or replace a task within a track, matched by task.id. Appends if new.
96
+ * No-op if the trackId is not found.
97
+ */
98
+ export function upsertTask(
99
+ config: RawPipelineConfig,
100
+ trackId: string,
101
+ task: RawTaskConfig,
102
+ ): RawPipelineConfig {
103
+ return {
104
+ ...config,
105
+ tracks: config.tracks.map(t => {
106
+ if (t.id !== trackId) return t;
107
+ const exists = t.tasks.some(tk => tk.id === task.id);
108
+ return {
109
+ ...t,
110
+ tasks: exists
111
+ ? t.tasks.map(tk => (tk.id === task.id ? task : tk))
112
+ : [...t.tasks, task],
113
+ };
114
+ }),
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Remove a task from a track. No-op if either id is not found.
120
+ */
121
+ export function removeTask(
122
+ config: RawPipelineConfig,
123
+ trackId: string,
124
+ taskId: string,
125
+ ): RawPipelineConfig {
126
+ return {
127
+ ...config,
128
+ tracks: config.tracks.map(t => {
129
+ if (t.id !== trackId) return t;
130
+ return { ...t, tasks: t.tasks.filter(tk => tk.id !== taskId) };
131
+ }),
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Reorder a task within its track.
137
+ * Clamps toIndex to valid bounds.
138
+ */
139
+ export function moveTask(
140
+ config: RawPipelineConfig,
141
+ trackId: string,
142
+ taskId: string,
143
+ toIndex: number,
144
+ ): RawPipelineConfig {
145
+ return {
146
+ ...config,
147
+ tracks: config.tracks.map(t => {
148
+ if (t.id !== trackId) return t;
149
+ const idx = t.tasks.findIndex(tk => tk.id === taskId);
150
+ if (idx === -1) return t;
151
+ const tasks = [...t.tasks];
152
+ const [task] = tasks.splice(idx, 1);
153
+ const clamped = Math.max(0, Math.min(toIndex, tasks.length));
154
+ tasks.splice(clamped, 0, task);
155
+ return { ...t, tasks };
156
+ }),
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Move a task from one track to another (appends to the target track).
162
+ * No-op if either trackId or taskId is not found.
163
+ */
164
+ export function transferTask(
165
+ config: RawPipelineConfig,
166
+ fromTrackId: string,
167
+ taskId: string,
168
+ toTrackId: string,
169
+ ): RawPipelineConfig {
170
+ let task: RawTaskConfig | undefined;
171
+ const afterRemove = {
172
+ ...config,
173
+ tracks: config.tracks.map(t => {
174
+ if (t.id !== fromTrackId) return t;
175
+ const found = t.tasks.find(tk => tk.id === taskId);
176
+ if (!found) return t;
177
+ task = found;
178
+ return { ...t, tasks: t.tasks.filter(tk => tk.id !== taskId) };
179
+ }),
180
+ };
181
+ if (!task) return config;
182
+ return upsertTask(afterRemove, toTrackId, task);
183
+ }
package/src/engine.ts CHANGED
@@ -7,7 +7,7 @@ import type {
7
7
  OnFailure,
8
8
  } from './types';
9
9
  import { buildDag, type Dag, type DagNode } from './dag';
10
- import { getHandler, hasHandler } from './registry';
10
+ import { getHandler, hasHandler, loadPlugins } from './registry';
11
11
  import { runSpawn, runCommand } from './runner';
12
12
  import { parseDuration, nowISO, generateRunId } from './utils';
13
13
  import {
@@ -94,6 +94,13 @@ export interface EngineResult {
94
94
  readonly states: ReadonlyMap<string, TaskState>;
95
95
  }
96
96
 
97
+ // ═══ Pipeline Events ═══
98
+
99
+ export type PipelineEvent =
100
+ | { readonly type: 'task_status_change'; readonly taskId: string; readonly status: TaskStatus; readonly prevStatus: TaskStatus; readonly runId: string }
101
+ | { readonly type: 'pipeline_start'; readonly runId: string }
102
+ | { readonly type: 'pipeline_end'; readonly runId: string; readonly success: boolean };
103
+
97
104
  export interface RunPipelineOptions {
98
105
  readonly approvalGateway?: ApprovalGateway;
99
106
  /**
@@ -101,6 +108,16 @@ export interface RunPipelineOptions {
101
108
  * Oldest directories are deleted after each run. Defaults to 20. Set to 0 to disable cleanup.
102
109
  */
103
110
  readonly maxLogRuns?: number;
111
+ /**
112
+ * External AbortSignal — aborting it cancels the pipeline immediately.
113
+ * Equivalent to the pipeline timeout firing, but caller-controlled.
114
+ */
115
+ readonly signal?: AbortSignal;
116
+ /**
117
+ * Called on every pipeline/task status transition.
118
+ * Use for real-time UI updates (e.g. updating a visual workflow graph).
119
+ */
120
+ readonly onEvent?: (event: PipelineEvent) => void;
104
121
  }
105
122
 
106
123
  export async function runPipeline(
@@ -110,10 +127,17 @@ export async function runPipeline(
110
127
  ): Promise<EngineResult> {
111
128
  const approvalGateway = options.approvalGateway ?? new InMemoryApprovalGateway();
112
129
  const maxLogRuns = options.maxLogRuns ?? 20;
130
+
131
+ // Load any plugins declared in the pipeline config before preflight so that
132
+ // drivers, completions, and middlewares referenced in YAML are registered.
133
+ if (config.plugins?.length) {
134
+ await loadPlugins(config.plugins);
135
+ }
136
+
113
137
  const dag = buildDag(config);
138
+ const runId = generateRunId();
114
139
  preflight(config, dag);
115
140
 
116
- const runId = generateRunId();
117
141
  const startedAt = nowISO();
118
142
  const pipelineInfo: PipelineInfo = { name: config.name, run_id: runId, started_at: startedAt };
119
143
  const log = new Logger(workDir, runId);
@@ -150,6 +174,8 @@ export async function runPipeline(
150
174
  });
151
175
  }
152
176
 
177
+ try {
178
+
153
179
  // Pipeline start hook (gate)
154
180
  const startHook = await executeHook(
155
181
  config.hooks, 'pipeline_start', buildPipelineStartContext(pipelineInfo), workDir,
@@ -172,6 +198,7 @@ export async function runPipeline(
172
198
  for (const [, state] of states) {
173
199
  state.status = 'waiting';
174
200
  }
201
+ options.onEvent?.({ type: 'pipeline_start', runId });
175
202
 
176
203
  const sessionMap = new Map<string, string>();
177
204
  const outputMap = new Map<string, string>();
@@ -196,8 +223,32 @@ export async function runPipeline(
196
223
  approvalGateway.abortAll('pipeline aborted');
197
224
  });
198
225
 
226
+ // Wire external cancel signal into the internal abort controller.
227
+ if (options.signal) {
228
+ if (options.signal.aborted) {
229
+ pipelineAborted = true;
230
+ abortController.abort();
231
+ } else {
232
+ options.signal.addEventListener('abort', () => {
233
+ pipelineAborted = true;
234
+ abortController.abort();
235
+ }, { once: true });
236
+ }
237
+ }
238
+
199
239
  // ── Helpers ──
200
240
 
241
+ function emit(event: PipelineEvent): void {
242
+ options.onEvent?.(event);
243
+ }
244
+
245
+ function setTaskStatus(taskId: string, newStatus: TaskStatus): void {
246
+ const state = states.get(taskId)!;
247
+ const prevStatus = state.status;
248
+ state.status = newStatus;
249
+ emit({ type: 'task_status_change', taskId, status: newStatus, prevStatus, runId });
250
+ }
251
+
201
252
  function getOnFailure(taskId: string): OnFailure {
202
253
  return dag.nodes.get(taskId)?.track.on_failure ?? 'skip_downstream';
203
254
  }
@@ -215,10 +266,9 @@ export async function runPipeline(
215
266
  }
216
267
 
217
268
  function applyStopAll(trackId: string): void {
218
- for (const [, state] of states) {
219
- const node = dag.nodes.get(state.config.id);
269
+ for (const [id, state] of states) {
220
270
  if (state.trackConfig.id === trackId && !isTerminal(state.status)) {
221
- state.status = 'skipped';
271
+ setTaskStatus(id, 'skipped');
222
272
  state.finishedAt = nowISO();
223
273
  }
224
274
  }
@@ -269,7 +319,7 @@ export async function runPipeline(
269
319
  if (result === 'skip') {
270
320
  const depStatus = states.get(depId)?.status ?? 'unknown';
271
321
  log.debug(`[task:${taskId}]`, `skipped (upstream "${depId}" status=${depStatus})`);
272
- state.status = 'skipped';
322
+ setTaskStatus(taskId, 'skipped');
273
323
  state.finishedAt = nowISO();
274
324
  return;
275
325
  }
@@ -294,13 +344,13 @@ export async function runPipeline(
294
344
  // If pipeline was aborted while we were still waiting for the trigger,
295
345
  // this task never entered running state → skipped, not timeout.
296
346
  if (pipelineAborted) {
297
- state.status = 'skipped';
347
+ setTaskStatus(taskId, 'skipped');
298
348
  } else if (msg.includes('rejected') || msg.includes('denied')) {
299
- state.status = 'blocked'; // user/policy rejection
349
+ setTaskStatus(taskId, 'blocked'); // user/policy rejection
300
350
  } else if (msg.includes('timeout')) {
301
- state.status = 'timeout'; // genuine trigger wait timeout
351
+ setTaskStatus(taskId, 'timeout'); // genuine trigger wait timeout
302
352
  } else {
303
- state.status = 'failed'; // plugin error, watcher crash, etc.
353
+ setTaskStatus(taskId, 'failed'); // plugin error, watcher crash, etc.
304
354
  }
305
355
  state.finishedAt = nowISO();
306
356
  await fireHook(taskId, 'task_failure');
@@ -316,14 +366,14 @@ export async function runPipeline(
316
366
  `task_start hook exit=${hookResult.exitCode} allowed=${hookResult.allowed}`);
317
367
  }
318
368
  if (!hookResult.allowed) {
319
- state.status = 'blocked';
369
+ setTaskStatus(taskId, 'blocked');
320
370
  state.finishedAt = nowISO();
321
371
  await fireHook(taskId, 'task_failure');
322
372
  return;
323
373
  }
324
374
 
325
375
  // 4. Mark running
326
- state.status = 'running';
376
+ setTaskStatus(taskId, 'running');
327
377
  state.startedAt = nowISO();
328
378
  log.info(`[task:${taskId}]`, task.command ? `running: ${task.command}` : `running (driver task)`);
329
379
 
@@ -395,16 +445,16 @@ export async function runPipeline(
395
445
 
396
446
  // 5. Determine status
397
447
  if (result.exitCode === -1) {
398
- state.status = 'timeout';
448
+ setTaskStatus(taskId, 'timeout');
399
449
  } else if (result.exitCode !== 0) {
400
- state.status = 'failed';
450
+ setTaskStatus(taskId, 'failed');
401
451
  } else if (task.completion) {
402
452
  const plugin = getHandler<CompletionPlugin>('completions', task.completion.type);
403
453
  const completionCtx = { workDir: task.cwd ?? workDir };
404
454
  const passed = await plugin.check(task.completion as Record<string, unknown>, result, completionCtx);
405
- state.status = passed ? 'success' : 'failed';
455
+ setTaskStatus(taskId, passed ? 'success' : 'failed');
406
456
  } else {
407
- state.status = 'success';
457
+ setTaskStatus(taskId, 'success');
408
458
  }
409
459
 
410
460
  // 6. Write output file with RAW stdout (preserves driver output format).
@@ -478,7 +528,7 @@ export async function runPipeline(
478
528
  }
479
529
 
480
530
  } catch (err: unknown) {
481
- state.status = 'failed';
531
+ setTaskStatus(taskId, 'failed');
482
532
  state.finishedAt = nowISO();
483
533
  const errMsg = err instanceof Error ? (err.stack ?? err.message) : String(err);
484
534
  log.error(`[task:${taskId}]`, `failed before execution: ${errMsg}`);
@@ -502,40 +552,44 @@ export async function runPipeline(
502
552
  }
503
553
 
504
554
  // ── Event loop ──
505
- try {
506
- let progress = true;
507
- while (progress && !pipelineAborted) {
508
- progress = false;
555
+ // Each task is launched as soon as ALL its deps reach a terminal state.
556
+ // We track in-flight tasks in `running` so a task completing mid-batch
557
+ // immediately unblocks its dependents without waiting for sibling tasks.
558
+ const running = new Map<string, Promise<void>>();
509
559
 
510
- // Collect tasks whose deps are all terminal and that are still waiting
511
- const launchable: string[] = [];
560
+ try {
561
+ while (!pipelineAborted) {
562
+ // Launch every task whose deps are all terminal and that isn't already in-flight
512
563
  for (const [id, state] of states) {
513
- if (state.status !== 'waiting') continue;
564
+ if (state.status !== 'waiting' || running.has(id)) continue;
514
565
  const node = dag.nodes.get(id)!;
515
566
  const allDepsTerminal = node.dependsOn.length === 0 ||
516
567
  node.dependsOn.every(d => isTerminal(states.get(d)!.status));
517
- if (allDepsTerminal) launchable.push(id);
568
+ if (!allDepsTerminal) continue;
569
+ const p = processTask(id).finally(() => running.delete(id));
570
+ running.set(id, p);
518
571
  }
519
572
 
520
- if (launchable.length === 0) {
521
- // Check if anything is still running (trigger waits etc.)
522
- const anyNonTerminal = [...states.values()].some(s => !isTerminal(s.status));
523
- if (!anyNonTerminal) break;
573
+ // All tasks terminal — done
574
+ if ([...states.values()].every(s => isTerminal(s.status))) break;
575
+
576
+ if (running.size === 0) {
577
+ // Nothing in-flight but non-terminal tasks exist (e.g. trigger-wait states
578
+ // that processTask hasn't been called for yet). Poll briefly.
524
579
  await new Promise(r => setTimeout(r, 50));
525
- progress = true;
526
- continue;
580
+ } else {
581
+ // Wait for any one task to finish, then re-scan for new launchables.
582
+ await Promise.race(running.values());
527
583
  }
528
-
529
- // Launch all launchable tasks concurrently
530
- await Promise.all(launchable.map(id => processTask(id)));
531
- progress = true;
532
584
  }
533
585
 
534
586
  if (pipelineAborted) {
535
- for (const [, state] of states) {
587
+ // Wait for in-flight tasks to honour the abort signal before marking states.
588
+ if (running.size > 0) await Promise.allSettled(running.values());
589
+ for (const [id, state] of states) {
536
590
  if (!isTerminal(state.status)) {
537
591
  // Running tasks get timeout (they were killed); waiting tasks get skipped
538
- state.status = state.status === 'running' ? 'timeout' : 'skipped';
592
+ setTaskStatus(id, state.status === 'running' ? 'timeout' : 'skipped');
539
593
  state.finishedAt = nowISO();
540
594
  }
541
595
  }
@@ -597,20 +651,27 @@ export async function runPipeline(
597
651
  console.log(` Duration: ${(durationMs / 1000).toFixed(1)}s`);
598
652
  console.log(` Log: ${log.path}`);
599
653
 
600
- // Prune old per-run log directories, keeping only the most recent maxLogRuns.
601
- if (maxLogRuns > 0) {
602
- await pruneLogDirs(resolve(workDir, 'logs'), maxLogRuns);
603
- }
604
-
654
+ emit({ type: 'pipeline_end', runId, success: allSuccess });
605
655
  return { success: allSuccess, runId, logPath: log.path, summary, states };
656
+
657
+ } finally {
658
+ // Prune old per-run log directories on every exit path (normal, blocked, or thrown).
659
+ // Exclude the current runId so a concurrent run cannot delete its own live directory.
660
+ if (maxLogRuns > 0) {
661
+ await pruneLogDirs(resolve(workDir, 'logs'), maxLogRuns, runId);
662
+ }
663
+ }
606
664
  }
607
665
 
608
666
  /**
609
667
  * Delete the oldest subdirectories under `logsDir`, keeping only the most recent `keep`.
610
668
  * Directories are sorted lexicographically; because runIds are prefixed with a base-36
611
669
  * timestamp, lexicographic order equals chronological order.
670
+ *
671
+ * `excludeRunId` is always skipped from deletion even if it would otherwise be pruned —
672
+ * this prevents a concurrent run from removing a live log directory that is still in use.
612
673
  */
613
- async function pruneLogDirs(logsDir: string, keep: number): Promise<void> {
674
+ async function pruneLogDirs(logsDir: string, keep: number, excludeRunId: string): Promise<void> {
614
675
  let entries: string[];
615
676
  try {
616
677
  entries = await readdir(logsDir);
@@ -618,8 +679,8 @@ async function pruneLogDirs(logsDir: string, keep: number): Promise<void> {
618
679
  return; // logsDir doesn't exist yet — nothing to prune
619
680
  }
620
681
 
621
- // Only consider directories that look like run IDs (run_<...>)
622
- const runDirs = entries.filter(e => e.startsWith('run_')).sort();
682
+ // Only consider directories that look like run IDs (run_<...>), excluding the live run.
683
+ const runDirs = entries.filter(e => e.startsWith('run_') && e !== excludeRunId).sort();
623
684
  const toDelete = runDirs.slice(0, Math.max(0, runDirs.length - keep));
624
685
 
625
686
  await Promise.all(
@@ -0,0 +1,113 @@
1
+ // ═══ PipelineRunner ═══
2
+ //
3
+ // Wraps runPipeline in a lifecycle object suited for multi-pipeline management
4
+ // in sidecar / Tauri IPC scenarios. Each instance controls one pipeline run.
5
+ //
6
+ // Typical sidecar usage:
7
+ //
8
+ // const runners = new Map<string, PipelineRunner>();
9
+ //
10
+ // const runner = new PipelineRunner(config, workDir);
11
+ // runner.subscribe(event => ipcEmit('pipeline_event', event));
12
+ // runner.start();
13
+ // runners.set(runner.instanceId, runner);
14
+ //
15
+ // // Later, from IPC:
16
+ // runners.get(id)?.abort();
17
+
18
+ import { runPipeline } from './engine';
19
+ import type { EngineResult, PipelineEvent, RunPipelineOptions } from './engine';
20
+ import type { PipelineConfig, TaskState } from './types';
21
+ import { generateRunId } from './utils';
22
+
23
+ export type { PipelineEvent, EngineResult };
24
+
25
+ export type PipelineRunnerStatus = 'idle' | 'running' | 'done' | 'aborted';
26
+
27
+ export class PipelineRunner {
28
+ /**
29
+ * Stable ID assigned before start() — safe to use as a Map key in the sidecar
30
+ * before the engine-assigned runId becomes available.
31
+ */
32
+ readonly instanceId: string;
33
+
34
+ /**
35
+ * The runId generated by the engine. Available after the first 'pipeline_start'
36
+ * event fires (i.e. effectively immediately after start() is called).
37
+ * null until then.
38
+ */
39
+ private _runId: string | null = null;
40
+ private _status: PipelineRunnerStatus = 'idle';
41
+ private _result: Promise<EngineResult> | null = null;
42
+ private _abortController = new AbortController();
43
+ private _handlers = new Set<(event: PipelineEvent) => void>();
44
+ private _states: ReadonlyMap<string, TaskState> | null = null;
45
+
46
+ constructor(
47
+ private readonly config: PipelineConfig,
48
+ private readonly workDir: string,
49
+ private readonly opts: Omit<RunPipelineOptions, 'signal' | 'onEvent'> = {},
50
+ ) {
51
+ this.instanceId = generateRunId();
52
+ }
53
+
54
+ get runId(): string | null { return this._runId; }
55
+ get status(): PipelineRunnerStatus { return this._status; }
56
+
57
+ /**
58
+ * Start the pipeline. Calling start() more than once returns the same Promise.
59
+ */
60
+ start(): Promise<EngineResult> {
61
+ if (this._result) return this._result;
62
+
63
+ this._status = 'running';
64
+ this._result = runPipeline(this.config, this.workDir, {
65
+ ...this.opts,
66
+ signal: this._abortController.signal,
67
+ onEvent: (event) => {
68
+ if (event.type === 'pipeline_start') {
69
+ this._runId = event.runId;
70
+ }
71
+ if (event.type === 'pipeline_end') {
72
+ this._status = this._abortController.signal.aborted ? 'aborted' : 'done';
73
+ }
74
+ for (const h of this._handlers) h(event);
75
+ },
76
+ }).then(result => {
77
+ this._states = result.states;
78
+ if (this._status === 'running') this._status = 'done';
79
+ return result;
80
+ }).catch(err => {
81
+ this._status = 'aborted';
82
+ throw err;
83
+ });
84
+
85
+ return this._result;
86
+ }
87
+
88
+ /**
89
+ * Cancel the running pipeline. Safe to call multiple times or before start().
90
+ */
91
+ abort(reason?: string): void {
92
+ this._status = 'aborted';
93
+ this._abortController.abort(reason);
94
+ }
95
+
96
+ /**
97
+ * Snapshot of task states. Populated after the run completes.
98
+ * During a run, listen to subscribe() events for incremental updates.
99
+ */
100
+ getStates(): ReadonlyMap<string, TaskState> | null {
101
+ return this._states;
102
+ }
103
+
104
+ /**
105
+ * Subscribe to pipeline/task events. Returns an unsubscribe function.
106
+ * Events are emitted synchronously in the engine's event loop, so keep
107
+ * handlers non-blocking (e.g. queue to IPC, do not await inside).
108
+ */
109
+ subscribe(handler: (event: PipelineEvent) => void): () => void {
110
+ this._handlers.add(handler);
111
+ return () => this._handlers.delete(handler);
112
+ }
113
+ }
package/src/registry.ts CHANGED
@@ -16,9 +16,7 @@ export function registerPlugin<T extends PluginType>(
16
16
  category: PluginCategory, type: string, handler: T,
17
17
  ): void {
18
18
  const registry = registries[category] as Map<string, T>;
19
- if (registry.has(type)) {
20
- throw new Error(`${category} type "${type}" is already registered`);
21
- }
19
+ if (registry.has(type)) return; // idempotent — skip duplicate registration
22
20
  registry.set(type, handler);
23
21
  }
24
22
 
package/src/runner.ts CHANGED
@@ -151,9 +151,11 @@ export async function runSpawn(
151
151
 
152
152
  const durationMs = elapsed();
153
153
 
154
- // If we killed the process but it had already exited with a real code
155
- // before our signal landed, don't treat it as a timeout.
156
- if (killedByUs && exitCode !== 0) {
154
+ // We initiated the kill (timeout or abort) always treat as non-success
155
+ // regardless of exit code. A process that catches SIGTERM and exits 0 still
156
+ // hit the timeout; letting it pass as success would unblock downstream tasks
157
+ // incorrectly.
158
+ if (killedByUs) {
157
159
  return {
158
160
  exitCode: -1,
159
161
  stdout,
package/src/schema.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import yaml from 'js-yaml';
2
- import { resolve } from 'path';
2
+ import { resolve, relative } from 'path';
3
3
  import type {
4
4
  PipelineConfig, RawPipelineConfig, RawTrackConfig, RawTaskConfig,
5
5
  TrackConfig, TaskConfig, Permissions, MiddlewareConfig,
@@ -7,6 +7,7 @@ import type {
7
7
  } from './types';
8
8
  import { truncateForName, validatePathParam } from './utils';
9
9
  import { DEFAULT_PERMISSIONS } from './types';
10
+ import { buildDag } from './dag';
10
11
 
11
12
  // ═══ YAML Parsing ═══
12
13
 
@@ -243,6 +244,103 @@ export function resolveConfig(raw: RawPipelineConfig, workDir: string): Pipeline
243
244
  };
244
245
  }
245
246
 
247
+ // ═══ YAML Serialization ═══
248
+
249
+ /**
250
+ * Serialize a pipeline config back to YAML string.
251
+ * Wraps the config under the top-level `pipeline` key as expected by parseYaml.
252
+ */
253
+ export function serializePipeline(config: PipelineConfig | RawPipelineConfig): string {
254
+ return yaml.dump({ pipeline: config }, { lineWidth: 120, indent: 2 });
255
+ }
256
+
257
+ /**
258
+ * Convert a resolved PipelineConfig back to a RawPipelineConfig for serialization.
259
+ * Strips injected defaults and converts absolute cwd paths back to relative so the
260
+ * resulting YAML is portable across machines.
261
+ *
262
+ * Use this when you need to save a config that was previously loaded via
263
+ * loadPipeline(). For a pure load→edit→save cycle on raw YAML, prefer
264
+ * parseYaml() → edit RawPipelineConfig → serializePipeline().
265
+ */
266
+ export function deresolvePipeline(config: PipelineConfig, workDir: string): RawPipelineConfig {
267
+ const tracks: RawTrackConfig[] = config.tracks.map(track => {
268
+ const trackCwdRel = track.cwd && track.cwd !== workDir
269
+ ? relative(workDir, track.cwd)
270
+ : undefined;
271
+ const effectiveTrackDriver = track.driver ?? config.driver ?? 'claude-code';
272
+
273
+ const tasks: RawTaskConfig[] = track.tasks.map(task => {
274
+ const taskCwdRel = task.cwd && task.cwd !== track.cwd
275
+ ? relative(workDir, task.cwd)
276
+ : undefined;
277
+
278
+ return {
279
+ id: task.id,
280
+ ...(task.name ? { name: task.name } : {}),
281
+ ...(task.prompt !== undefined ? { prompt: task.prompt } : {}),
282
+ ...(task.command !== undefined ? { command: task.command } : {}),
283
+ ...(task.depends_on?.length ? { depends_on: task.depends_on } : {}),
284
+ ...(task.trigger ? { trigger: task.trigger } : {}),
285
+ ...(task.continue_from ? { continue_from: task.continue_from } : {}),
286
+ ...(task.output ? { output: task.output } : {}),
287
+ ...(taskCwdRel ? { cwd: taskCwdRel } : {}),
288
+ ...(task.model_tier && task.model_tier !== 'medium' ? { model_tier: task.model_tier } : {}),
289
+ ...(task.driver && task.driver !== effectiveTrackDriver ? { driver: task.driver } : {}),
290
+ ...(task.timeout ? { timeout: task.timeout } : {}),
291
+ ...(task.middlewares !== undefined ? { middlewares: task.middlewares } : {}),
292
+ ...(task.completion ? { completion: task.completion } : {}),
293
+ ...(task.agent_profile ? { agent_profile: task.agent_profile } : {}),
294
+ ...(task.permissions && JSON.stringify(task.permissions) !== JSON.stringify(DEFAULT_PERMISSIONS)
295
+ ? { permissions: task.permissions }
296
+ : {}),
297
+ };
298
+ });
299
+
300
+ return {
301
+ id: track.id,
302
+ name: track.name,
303
+ ...(track.color ? { color: track.color } : {}),
304
+ ...(track.agent_profile ? { agent_profile: track.agent_profile } : {}),
305
+ ...(track.model_tier && track.model_tier !== 'medium' ? { model_tier: track.model_tier } : {}),
306
+ ...(track.driver && track.driver !== (config.driver ?? 'claude-code') ? { driver: track.driver } : {}),
307
+ ...(trackCwdRel ? { cwd: trackCwdRel } : {}),
308
+ ...(track.middlewares?.length ? { middlewares: track.middlewares } : {}),
309
+ ...(track.on_failure && track.on_failure !== 'skip_downstream' ? { on_failure: track.on_failure } : {}),
310
+ ...(track.permissions && JSON.stringify(track.permissions) !== JSON.stringify(DEFAULT_PERMISSIONS)
311
+ ? { permissions: track.permissions }
312
+ : {}),
313
+ tasks,
314
+ };
315
+ });
316
+
317
+ return {
318
+ name: config.name,
319
+ ...(config.driver ? { driver: config.driver } : {}),
320
+ ...(config.timeout ? { timeout: config.timeout } : {}),
321
+ ...(config.plugins?.length ? { plugins: config.plugins } : {}),
322
+ ...(config.hooks ? { hooks: config.hooks } : {}),
323
+ tracks,
324
+ };
325
+ }
326
+
327
+ // ═══ Offline Validation ═══
328
+
329
+ /**
330
+ * Validate a pipeline config without executing it.
331
+ * Only checks structural/DAG correctness — does not check plugin registration.
332
+ * Returns an array of error messages (empty = valid).
333
+ */
334
+ export function validateConfig(config: PipelineConfig): string[] {
335
+ const errors: string[] = [];
336
+ try {
337
+ buildDag(config);
338
+ } catch (err) {
339
+ errors.push(err instanceof Error ? err.message : String(err));
340
+ }
341
+ return errors;
342
+ }
343
+
246
344
  // ═══ Full Parse Pipeline ═══
247
345
 
248
346
  export async function loadPipeline(yamlContent: string, workDir: string): Promise<PipelineConfig> {
package/src/sdk.ts CHANGED
@@ -5,10 +5,32 @@
5
5
 
6
6
  // ── Core engine ──
7
7
  export { runPipeline } from './engine';
8
- export type { EngineResult, RunPipelineOptions } from './engine';
8
+ export type { EngineResult, RunPipelineOptions, PipelineEvent } from './engine';
9
9
 
10
- // ── Schema: parse / resolve / load ──
11
- export { parseYaml, resolveConfig, expandTemplates, loadPipeline } from './schema';
10
+ // ── Pipeline runner (multi-pipeline lifecycle management) ──
11
+ export { PipelineRunner } from './pipeline-runner';
12
+ export type { PipelineRunnerStatus } from './pipeline-runner';
13
+
14
+ // ── Raw config CRUD (visual editor / YAML sync) ──
15
+ export {
16
+ createEmptyPipeline,
17
+ setPipelineField,
18
+ upsertTrack,
19
+ removeTrack,
20
+ moveTrack,
21
+ updateTrack,
22
+ upsertTask,
23
+ removeTask,
24
+ moveTask,
25
+ transferTask,
26
+ } from './config-ops';
27
+
28
+ // ── Raw config validation (real-time feedback) ──
29
+ export { validateRaw } from './validate-raw';
30
+ export type { ValidationError } from './validate-raw';
31
+
32
+ // ── Schema: parse / resolve / load / serialize / validate ──
33
+ export { parseYaml, resolveConfig, expandTemplates, loadPipeline, serializePipeline, deresolvePipeline, validateConfig } from './schema';
12
34
 
13
35
  // ── DAG ──
14
36
  export { buildDag } from './dag';
@@ -0,0 +1,199 @@
1
+ // ═══ Raw Pipeline Config Validation ═══
2
+ //
3
+ // Validates a RawPipelineConfig without resolving inheritance or executing
4
+ // anything — intended for real-time feedback in a visual editor (e.g. drag
5
+ // to add a task, live error highlighting).
6
+ //
7
+ // Returns a flat list of ValidationError objects. An empty array means valid.
8
+
9
+ import type { RawPipelineConfig } from './types';
10
+
11
+ export interface ValidationError {
12
+ /** JSONPath-style location, e.g. "tracks[0].tasks[1].prompt" */
13
+ path: string;
14
+ message: string;
15
+ }
16
+
17
+ /**
18
+ * Validate a raw pipeline config.
19
+ * Checks structure, required fields, prompt/command exclusivity,
20
+ * depends_on reference integrity, and circular dependencies.
21
+ *
22
+ * Does NOT check plugin registration — plugins may not be loaded yet
23
+ * when the frontend is editing a config offline.
24
+ */
25
+ export function validateRaw(config: RawPipelineConfig): ValidationError[] {
26
+ const errors: ValidationError[] = [];
27
+
28
+ // ── Top level ──
29
+ if (!config.name?.trim()) {
30
+ errors.push({ path: 'name', message: 'Pipeline name is required' });
31
+ }
32
+
33
+ if (!config.tracks || config.tracks.length === 0) {
34
+ errors.push({ path: 'tracks', message: 'At least one track is required' });
35
+ return errors; // No point going further without tracks
36
+ }
37
+
38
+ // ── Build qualified ID sets for cross-reference checks ──
39
+ // Qualified ID format: "trackId.taskId" (mirrors the engine's convention)
40
+ const allQualified = new Set<string>();
41
+ // For bare depends_on references: bare taskId → first qualified ID found
42
+ const bareToQualified = new Map<string, string>();
43
+
44
+ for (const track of config.tracks) {
45
+ if (!track.id) continue;
46
+ for (const task of track.tasks ?? []) {
47
+ if (!task.id) continue;
48
+ const qid = `${track.id}.${task.id}`;
49
+ allQualified.add(qid);
50
+ if (!bareToQualified.has(task.id)) {
51
+ bareToQualified.set(task.id, qid);
52
+ }
53
+ }
54
+ }
55
+
56
+ // ── Per-track validation ──
57
+ for (let ti = 0; ti < config.tracks.length; ti++) {
58
+ const track = config.tracks[ti];
59
+ const trackPath = `tracks[${ti}]`;
60
+
61
+ if (!track.id?.trim()) {
62
+ errors.push({ path: `${trackPath}.id`, message: 'Track id is required' });
63
+ }
64
+ if (!track.name?.trim()) {
65
+ errors.push({ path: `${trackPath}.name`, message: 'Track name is required' });
66
+ }
67
+
68
+ if (!track.tasks || track.tasks.length === 0) {
69
+ errors.push({ path: `${trackPath}.tasks`, message: `Track "${track.id || ti}": must have at least one task` });
70
+ continue;
71
+ }
72
+
73
+ // ── Per-task validation ──
74
+ for (let ki = 0; ki < track.tasks.length; ki++) {
75
+ const task = track.tasks[ki];
76
+ const taskPath = `${trackPath}.tasks[${ki}]`;
77
+
78
+ if (!task.id?.trim()) {
79
+ errors.push({ path: `${taskPath}.id`, message: 'Task id is required' });
80
+ continue; // Can't check further without an id
81
+ }
82
+
83
+ // Template-based tasks: skip prompt/command checks (params validated at runtime)
84
+ if (task.use) continue;
85
+
86
+ const hasPrompt = typeof task.prompt === 'string' && task.prompt.trim().length > 0;
87
+ const hasCommand = typeof task.command === 'string' && task.command.trim().length > 0;
88
+
89
+ if (!hasPrompt && !hasCommand) {
90
+ errors.push({
91
+ path: taskPath,
92
+ message: `Task "${task.id}": must have "prompt" or "command"`,
93
+ });
94
+ }
95
+ if (hasPrompt && hasCommand) {
96
+ errors.push({
97
+ path: taskPath,
98
+ message: `Task "${task.id}": cannot have both "prompt" and "command"`,
99
+ });
100
+ }
101
+
102
+ // ── depends_on reference checks ──
103
+ if (task.depends_on && task.depends_on.length > 0) {
104
+ for (const dep of task.depends_on) {
105
+ const resolved = resolveDepRef(dep, track.id, allQualified, bareToQualified);
106
+ if (!resolved) {
107
+ errors.push({
108
+ path: `${taskPath}.depends_on`,
109
+ message: `Task "${task.id}": depends_on "${dep}" — no such task found`,
110
+ });
111
+ }
112
+ }
113
+ }
114
+
115
+ // ── continue_from reference check ──
116
+ if (task.continue_from) {
117
+ const resolved = resolveDepRef(task.continue_from, track.id, allQualified, bareToQualified);
118
+ if (!resolved) {
119
+ errors.push({
120
+ path: `${taskPath}.continue_from`,
121
+ message: `Task "${task.id}": continue_from "${task.continue_from}" — no such task found`,
122
+ });
123
+ }
124
+ }
125
+ }
126
+ }
127
+
128
+ // ── Cycle detection ──
129
+ errors.push(...detectCycles(config, allQualified, bareToQualified));
130
+
131
+ return errors;
132
+ }
133
+
134
+ // ── Helpers ──
135
+
136
+ function resolveDepRef(
137
+ ref: string,
138
+ fromTrackId: string,
139
+ allQualified: Set<string>,
140
+ bareToQualified: Map<string, string>,
141
+ ): string | null {
142
+ // Fully qualified reference (trackId.taskId)
143
+ if (allQualified.has(ref)) return ref;
144
+ // Same-track shorthand (just taskId)
145
+ const sameTrack = `${fromTrackId}.${ref}`;
146
+ if (allQualified.has(sameTrack)) return sameTrack;
147
+ // Global bare lookup (first match across all tracks)
148
+ return bareToQualified.get(ref) ?? null;
149
+ }
150
+
151
+ function detectCycles(
152
+ config: RawPipelineConfig,
153
+ allQualified: Set<string>,
154
+ bareToQualified: Map<string, string>,
155
+ ): ValidationError[] {
156
+ // Build adjacency: qualifiedId → [resolved dep qualifiedIds]
157
+ const adj = new Map<string, string[]>();
158
+
159
+ for (const track of config.tracks) {
160
+ if (!track.id) continue;
161
+ for (const task of track.tasks ?? []) {
162
+ if (!task.id || task.use) continue;
163
+ const qid = `${track.id}.${task.id}`;
164
+ const deps: string[] = [];
165
+ for (const dep of task.depends_on ?? []) {
166
+ const resolved = resolveDepRef(dep, track.id, allQualified, bareToQualified);
167
+ if (resolved) deps.push(resolved);
168
+ }
169
+ adj.set(qid, deps);
170
+ }
171
+ }
172
+
173
+ const errors: ValidationError[] = [];
174
+ const visited = new Set<string>();
175
+ const inStack = new Set<string>();
176
+
177
+ function dfs(id: string, path: string[]): void {
178
+ if (inStack.has(id)) {
179
+ // Trim path to just the cycle portion
180
+ const cycleStart = path.indexOf(id);
181
+ const cycle = [...path.slice(cycleStart), id].join(' → ');
182
+ errors.push({ path: 'tracks', message: `Circular dependency detected: ${cycle}` });
183
+ return;
184
+ }
185
+ if (visited.has(id)) return;
186
+ visited.add(id);
187
+ inStack.add(id);
188
+ for (const dep of adj.get(id) ?? []) {
189
+ dfs(dep, [...path, id]);
190
+ }
191
+ inStack.delete(id);
192
+ }
193
+
194
+ for (const id of adj.keys()) {
195
+ if (!visited.has(id)) dfs(id, []);
196
+ }
197
+
198
+ return errors;
199
+ }