@tagma/sdk 0.1.7 → 0.1.8

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
@@ -113,7 +113,10 @@ Executes the pipeline. Returns `{ success, runId, logPath, summary, states }`.
113
113
  Options:
114
114
  - `approvalGateway` -- custom `ApprovalGateway` instance (defaults to `InMemoryApprovalGateway`)
115
115
  - `signal` -- `AbortSignal` to cancel the run externally
116
- - `onEvent` -- callback for real-time `PipelineEvent` updates (task status changes, pipeline start/end)
116
+ - `onEvent` -- callback for real-time `PipelineEvent` updates:
117
+ - `pipeline_start` — pipeline began; includes `states: ReadonlyMap<taskId, TaskState>` (initial snapshot of all tasks at `waiting`)
118
+ - `task_status_change` — a task changed status; includes `state: TaskState` (complete snapshot at the time of change, with `result` and `finishedAt` already populated for terminal statuses)
119
+ - `pipeline_end` — pipeline finished; includes `success: boolean`
117
120
  - `maxLogRuns` -- number of per-run log directories to keep under `<workDir>/logs/` (default: 20)
118
121
 
119
122
  ### `PipelineRunner`
@@ -133,8 +136,9 @@ runner.start(); // returns Promise<EngineResult>, idempotent
133
136
  // Cancel from IPC
134
137
  runner.abort();
135
138
 
136
- // After completion
137
- const states = runner.getStates(); // ReadonlyMap<taskId, TaskState>
139
+ // Available from the first pipeline_start event onward (not just after completion)
140
+ // Returns null only if the pipeline has never started
141
+ const states = runner.getStates(); // ReadonlyMap<taskId, TaskState> | null
138
142
  ```
139
143
 
140
144
  Properties:
@@ -184,7 +188,7 @@ const yaml = serializePipeline(config);
184
188
  | `moveTrack(config, trackId, toIndex)` | Reorder a track |
185
189
  | `updateTrack(config, trackId, fields)` | Patch track fields (not tasks) |
186
190
  | `upsertTask(config, trackId, task)` | Insert or replace a task |
187
- | `removeTask(config, trackId, taskId)` | Remove a task |
191
+ | `removeTask(config, trackId, taskId, cleanRefs?)` | Remove a task; pass `cleanRefs: true` to also strip dangling `depends_on` / `continue_from` references from other tasks |
188
192
  | `moveTask(config, trackId, taskId, toIndex)` | Reorder a task within its track |
189
193
  | `transferTask(config, fromTrackId, taskId, toTrackId)` | Move a task across tracks |
190
194
 
@@ -231,6 +235,20 @@ if (errors.length > 0) {
231
235
  }
232
236
  ```
233
237
 
238
+ ### `buildRawDag(config: RawPipelineConfig): RawDag`
239
+
240
+ Extracts the topology of a raw (unresolved) pipeline config as a graph — no `workDir` or plugin registration required. Intended for the visual editor to render the flow graph during editing.
241
+
242
+ Returns `{ nodes: ReadonlyMap<taskId, RawDagNode>, edges: { from, to }[] }` where each edge represents a dependency (from must complete before to). Template-expansion tasks (`use:` field) and unresolvable refs are silently skipped.
243
+
244
+ ```ts
245
+ const { nodes, edges } = buildRawDag(draftConfig);
246
+ // nodes — keyed by "trackId.taskId"
247
+ // edges — [{ from: "track.taskA", to: "track.taskB" }, ...]
248
+ ```
249
+
250
+ Use `buildDag` instead when you have a fully resolved `PipelineConfig` and need topological sort order.
251
+
234
252
  ## Related Packages
235
253
 
236
254
  | Package | Description |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tagma/sdk",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "type": "module",
5
5
  "workspaces": [
6
6
  "plugins/*"
package/src/config-ops.ts CHANGED
@@ -117,19 +117,56 @@ export function upsertTask(
117
117
 
118
118
  /**
119
119
  * Remove a task from a track. No-op if either id is not found.
120
+ *
121
+ * When `cleanRefs` is true, all `depends_on` and `continue_from` references to the
122
+ * removed task are also removed from every other task in the pipeline. This prevents
123
+ * validateRaw from reporting dangling-ref errors after the deletion.
120
124
  */
121
125
  export function removeTask(
122
126
  config: RawPipelineConfig,
123
127
  trackId: string,
124
128
  taskId: string,
129
+ cleanRefs = false,
125
130
  ): RawPipelineConfig {
126
- return {
131
+ const withoutTask = {
127
132
  ...config,
128
133
  tracks: config.tracks.map(t => {
129
134
  if (t.id !== trackId) return t;
130
135
  return { ...t, tasks: t.tasks.filter(tk => tk.id !== taskId) };
131
136
  }),
132
137
  };
138
+
139
+ if (!cleanRefs) return withoutTask;
140
+
141
+ // Both bare ("taskId") and fully-qualified ("trackId.taskId") forms are valid refs
142
+ const qualId = `${trackId}.${taskId}`;
143
+ const isRemoved = (ref: string) => ref === taskId || ref === qualId;
144
+
145
+ return {
146
+ ...withoutTask,
147
+ tracks: withoutTask.tracks.map(t => ({
148
+ ...t,
149
+ tasks: t.tasks.map(tk => cleanTaskRefs(tk, isRemoved)),
150
+ })),
151
+ };
152
+ }
153
+
154
+ function cleanTaskRefs(
155
+ task: RawTaskConfig,
156
+ isRemoved: (ref: string) => boolean,
157
+ ): RawTaskConfig {
158
+ const filteredDeps = task.depends_on?.filter(d => !isRemoved(d));
159
+ const dropContinueFrom = task.continue_from !== undefined && isRemoved(task.continue_from);
160
+
161
+ const depsUnchanged = filteredDeps === undefined || filteredDeps.length === task.depends_on!.length;
162
+ if (depsUnchanged && !dropContinueFrom) return task;
163
+
164
+ const { depends_on, continue_from, ...rest } = task;
165
+ return {
166
+ ...rest,
167
+ ...(filteredDeps !== undefined && filteredDeps.length > 0 ? { depends_on: filteredDeps } : {}),
168
+ ...(!dropContinueFrom && continue_from !== undefined ? { continue_from } : {}),
169
+ } as RawTaskConfig;
133
170
  }
134
171
 
135
172
  /**
package/src/dag.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { PipelineConfig, TaskConfig, TrackConfig } from './types';
1
+ import type { PipelineConfig, RawPipelineConfig, RawTaskConfig, TaskConfig, TrackConfig } from './types';
2
2
 
3
3
  export interface DagNode {
4
4
  readonly taskId: string; // fully qualified: track_id.task_id or just task_id
@@ -135,3 +135,88 @@ export function buildDag(config: PipelineConfig): Dag {
135
135
 
136
136
  return { nodes, sorted };
137
137
  }
138
+
139
+ // ═══ Raw DAG (for visual editor — no workDir required) ═══
140
+
141
+ export interface RawDagNode {
142
+ readonly taskId: string; // fully qualified: track_id.task_id
143
+ readonly trackId: string;
144
+ readonly rawTask: RawTaskConfig;
145
+ readonly dependsOn: readonly string[]; // fully qualified IDs, best-effort resolved
146
+ }
147
+
148
+ export interface RawDag {
149
+ readonly nodes: ReadonlyMap<string, RawDagNode>;
150
+ /** Directed edges: from → to means "from must complete before to starts" */
151
+ readonly edges: readonly { readonly from: string; readonly to: string }[];
152
+ }
153
+
154
+ /**
155
+ * Build a lightweight DAG from a raw (unresolved) pipeline config.
156
+ * Unlike buildDag, this function:
157
+ * - Does not require a workDir or resolved PipelineConfig
158
+ * - Is lenient: missing or ambiguous refs are silently skipped
159
+ * - Skips template-expansion tasks (those with a `use` field)
160
+ *
161
+ * Intended for the visual editor to render the flow graph before a pipeline is run.
162
+ */
163
+ export function buildRawDag(config: RawPipelineConfig): RawDag {
164
+ const nodes = new Map<string, RawDagNode>();
165
+ const bareToQualified = new Map<string, string>();
166
+
167
+ // 1. Register all concrete tasks
168
+ for (const track of config.tracks) {
169
+ for (const task of track.tasks) {
170
+ if (task.use) continue; // template-expansion tasks are not yet materialized
171
+ const qid = `${track.id}.${task.id}`;
172
+ if (nodes.has(qid)) continue; // skip duplicates silently
173
+
174
+ if (bareToQualified.has(task.id)) {
175
+ bareToQualified.set(task.id, '__ambiguous__');
176
+ } else {
177
+ bareToQualified.set(task.id, qid);
178
+ }
179
+ nodes.set(qid, { taskId: qid, trackId: track.id, rawTask: task, dependsOn: [] });
180
+ }
181
+ }
182
+
183
+ // 2. Resolve dependency refs leniently (missing / ambiguous refs are skipped)
184
+ function tryResolve(ref: string, fromTrackId: string): string | null {
185
+ if (ref.includes('.')) return nodes.has(ref) ? ref : null;
186
+ const sameTrack = `${fromTrackId}.${ref}`;
187
+ if (nodes.has(sameTrack)) return sameTrack;
188
+ const global = bareToQualified.get(ref);
189
+ if (global && global !== '__ambiguous__') return global;
190
+ return null;
191
+ }
192
+
193
+ const edges: { from: string; to: string }[] = [];
194
+
195
+ for (const track of config.tracks) {
196
+ for (const task of track.tasks) {
197
+ if (task.use) continue;
198
+ const qid = `${track.id}.${task.id}`;
199
+ const deps: string[] = [];
200
+
201
+ for (const ref of task.depends_on ?? []) {
202
+ const resolved = tryResolve(ref, track.id);
203
+ if (resolved && !deps.includes(resolved)) {
204
+ deps.push(resolved);
205
+ edges.push({ from: resolved, to: qid });
206
+ }
207
+ }
208
+ if (task.continue_from) {
209
+ const resolved = tryResolve(task.continue_from, track.id);
210
+ if (resolved && !deps.includes(resolved)) {
211
+ deps.push(resolved);
212
+ edges.push({ from: resolved, to: qid });
213
+ }
214
+ }
215
+
216
+ const node = nodes.get(qid)!;
217
+ nodes.set(qid, { ...node, dependsOn: deps });
218
+ }
219
+ }
220
+
221
+ return { nodes, edges };
222
+ }
package/src/engine.ts CHANGED
@@ -97,8 +97,8 @@ export interface EngineResult {
97
97
  // ═══ Pipeline Events ═══
98
98
 
99
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 }
100
+ | { readonly type: 'task_status_change'; readonly taskId: string; readonly status: TaskStatus; readonly prevStatus: TaskStatus; readonly runId: string; readonly state: TaskState }
101
+ | { readonly type: 'pipeline_start'; readonly runId: string; readonly states: ReadonlyMap<string, TaskState> }
102
102
  | { readonly type: 'pipeline_end'; readonly runId: string; readonly success: boolean };
103
103
 
104
104
  export interface RunPipelineOptions {
@@ -198,7 +198,11 @@ export async function runPipeline(
198
198
  for (const [, state] of states) {
199
199
  state.status = 'waiting';
200
200
  }
201
- options.onEvent?.({ type: 'pipeline_start', runId });
201
+ // Include a full states snapshot so listeners can initialize their mirrors without missing events
202
+ const statesSnapshot: ReadonlyMap<string, TaskState> = new Map(
203
+ [...states.entries()].map(([id, s]) => [id, { ...s }])
204
+ );
205
+ options.onEvent?.({ type: 'pipeline_start', runId, states: statesSnapshot });
202
206
 
203
207
  const sessionMap = new Map<string, string>();
204
208
  const outputMap = new Map<string, string>();
@@ -246,7 +250,16 @@ export async function runPipeline(
246
250
  const state = states.get(taskId)!;
247
251
  const prevStatus = state.status;
248
252
  state.status = newStatus;
249
- emit({ type: 'task_status_change', taskId, status: newStatus, prevStatus, runId });
253
+ // Snapshot state at emit time result and finishedAt must be set before calling this for terminal statuses
254
+ const snapshot: TaskState = {
255
+ config: state.config,
256
+ trackConfig: state.trackConfig,
257
+ status: state.status,
258
+ result: state.result,
259
+ startedAt: state.startedAt,
260
+ finishedAt: state.finishedAt,
261
+ };
262
+ emit({ type: 'task_status_change', taskId, status: newStatus, prevStatus, runId, state: snapshot });
250
263
  }
251
264
 
252
265
  function getOnFailure(taskId: string): OnFailure {
@@ -319,8 +332,8 @@ export async function runPipeline(
319
332
  if (result === 'skip') {
320
333
  const depStatus = states.get(depId)?.status ?? 'unknown';
321
334
  log.debug(`[task:${taskId}]`, `skipped (upstream "${depId}" status=${depStatus})`);
322
- setTaskStatus(taskId, 'skipped');
323
335
  state.finishedAt = nowISO();
336
+ setTaskStatus(taskId, 'skipped');
324
337
  return;
325
338
  }
326
339
  if (result === 'unsatisfied') return; // still waiting
@@ -343,6 +356,7 @@ export async function runPipeline(
343
356
  const msg = err instanceof Error ? err.message : String(err);
344
357
  // If pipeline was aborted while we were still waiting for the trigger,
345
358
  // this task never entered running state → skipped, not timeout.
359
+ state.finishedAt = nowISO();
346
360
  if (pipelineAborted) {
347
361
  setTaskStatus(taskId, 'skipped');
348
362
  } else if (msg.includes('rejected') || msg.includes('denied')) {
@@ -352,7 +366,6 @@ export async function runPipeline(
352
366
  } else {
353
367
  setTaskStatus(taskId, 'failed'); // plugin error, watcher crash, etc.
354
368
  }
355
- state.finishedAt = nowISO();
356
369
  await fireHook(taskId, 'task_failure');
357
370
  return;
358
371
  }
@@ -366,8 +379,8 @@ export async function runPipeline(
366
379
  `task_start hook exit=${hookResult.exitCode} allowed=${hookResult.allowed}`);
367
380
  }
368
381
  if (!hookResult.allowed) {
369
- setTaskStatus(taskId, 'blocked');
370
382
  state.finishedAt = nowISO();
383
+ setTaskStatus(taskId, 'blocked');
371
384
  await fireHook(taskId, 'task_failure');
372
385
  return;
373
386
  }
@@ -443,18 +456,19 @@ export async function runPipeline(
443
456
  result = await runSpawn(spec, driver, runOpts);
444
457
  }
445
458
 
446
- // 5. Determine status
459
+ // 5. Determine terminal status (without emitting yet — result must be complete first)
460
+ let terminalStatus: TaskStatus;
447
461
  if (result.exitCode === -1) {
448
- setTaskStatus(taskId, 'timeout');
462
+ terminalStatus = 'timeout';
449
463
  } else if (result.exitCode !== 0) {
450
- setTaskStatus(taskId, 'failed');
464
+ terminalStatus = 'failed';
451
465
  } else if (task.completion) {
452
466
  const plugin = getHandler<CompletionPlugin>('completions', task.completion.type);
453
467
  const completionCtx = { workDir: task.cwd ?? workDir };
454
468
  const passed = await plugin.check(task.completion as Record<string, unknown>, result, completionCtx);
455
- setTaskStatus(taskId, passed ? 'success' : 'failed');
469
+ terminalStatus = passed ? 'success' : 'failed';
456
470
  } else {
457
- setTaskStatus(taskId, 'success');
471
+ terminalStatus = 'success';
458
472
  }
459
473
 
460
474
  // 6. Write output file with RAW stdout (preserves driver output format).
@@ -488,16 +502,18 @@ export async function runPipeline(
488
502
  if (!sessionMap.has(bareId)) sessionMap.set(bareId, result.sessionId);
489
503
  }
490
504
 
505
+ // Set result and finishedAt before emitting terminal status so listeners see complete state
491
506
  state.result = result;
492
507
  state.finishedAt = nowISO();
508
+ setTaskStatus(taskId, terminalStatus);
493
509
 
494
510
  // Log task outcome with relevant details
495
511
  const durSec = (result.durationMs / 1000).toFixed(1);
496
- if (state.status === 'success') {
512
+ if (terminalStatus === 'success') {
497
513
  log.info(`[task:${taskId}]`, `success (${durSec}s)`);
498
514
  } else {
499
515
  log.error(`[task:${taskId}]`,
500
- `${state.status} exit=${result.exitCode} duration=${durSec}s`);
516
+ `${terminalStatus} exit=${result.exitCode} duration=${durSec}s`);
501
517
  if (result.stderr) {
502
518
  const tail = tailLines(result.stderr, 10);
503
519
  log.error(`[task:${taskId}]`, `stderr tail:\n${tail}`);
@@ -524,12 +540,10 @@ export async function runPipeline(
524
540
  }
525
541
  if (task.completion) {
526
542
  log.debug(`[task:${taskId}]`,
527
- `completion check: type=${task.completion.type} result=${state.status}`);
543
+ `completion check: type=${task.completion.type} result=${terminalStatus}`);
528
544
  }
529
545
 
530
546
  } catch (err: unknown) {
531
- setTaskStatus(taskId, 'failed');
532
- state.finishedAt = nowISO();
533
547
  const errMsg = err instanceof Error ? (err.stack ?? err.message) : String(err);
534
548
  log.error(`[task:${taskId}]`, `failed before execution: ${errMsg}`);
535
549
  state.result = {
@@ -539,6 +553,8 @@ export async function runPipeline(
539
553
  outputPath: null, stderrPath: null, durationMs: 0,
540
554
  sessionId: null, normalizedOutput: null,
541
555
  };
556
+ state.finishedAt = nowISO();
557
+ setTaskStatus(taskId, 'failed');
542
558
  }
543
559
 
544
560
  // 7. Fire hooks
@@ -589,8 +605,8 @@ export async function runPipeline(
589
605
  for (const [id, state] of states) {
590
606
  if (!isTerminal(state.status)) {
591
607
  // Running tasks get timeout (they were killed); waiting tasks get skipped
592
- setTaskStatus(id, state.status === 'running' ? 'timeout' : 'skipped');
593
608
  state.finishedAt = nowISO();
609
+ setTaskStatus(id, state.status === 'running' ? 'timeout' : 'skipped');
594
610
  }
595
611
  }
596
612
  }
@@ -42,6 +42,7 @@ export class PipelineRunner {
42
42
  private _abortController = new AbortController();
43
43
  private _handlers = new Set<(event: PipelineEvent) => void>();
44
44
  private _states: ReadonlyMap<string, TaskState> | null = null;
45
+ private _statesMirror = new Map<string, TaskState>();
45
46
 
46
47
  constructor(
47
48
  private readonly config: PipelineConfig,
@@ -67,6 +68,14 @@ export class PipelineRunner {
67
68
  onEvent: (event) => {
68
69
  if (event.type === 'pipeline_start') {
69
70
  this._runId = event.runId;
71
+ // Initialize the live mirror with the full initial state snapshot
72
+ for (const [id, state] of event.states) {
73
+ this._statesMirror.set(id, { ...state });
74
+ }
75
+ }
76
+ if (event.type === 'task_status_change') {
77
+ // Keep the mirror up to date so getStates() works during the run
78
+ this._statesMirror.set(event.taskId, event.state);
70
79
  }
71
80
  if (event.type === 'pipeline_end') {
72
81
  this._status = this._abortController.signal.aborted ? 'aborted' : 'done';
@@ -94,11 +103,14 @@ export class PipelineRunner {
94
103
  }
95
104
 
96
105
  /**
97
- * Snapshot of task states. Populated after the run completes.
98
- * During a run, listen to subscribe() events for incremental updates.
106
+ * Live snapshot of task states. Available from the first pipeline_start event onward
107
+ * (i.e. as soon as start() is called) and remains accessible after the run completes.
108
+ * Returns null only if the pipeline has never started.
99
109
  */
100
110
  getStates(): ReadonlyMap<string, TaskState> | null {
101
- return this._states;
111
+ if (this._states) return this._states;
112
+ if (this._statesMirror.size > 0) return this._statesMirror;
113
+ return null;
102
114
  }
103
115
 
104
116
  /**
package/src/sdk.ts CHANGED
@@ -33,8 +33,8 @@ export type { ValidationError } from './validate-raw';
33
33
  export { parseYaml, resolveConfig, expandTemplates, loadPipeline, serializePipeline, deresolvePipeline, validateConfig } from './schema';
34
34
 
35
35
  // ── DAG ──
36
- export { buildDag } from './dag';
37
- export type { DagNode, Dag } from './dag';
36
+ export { buildDag, buildRawDag } from './dag';
37
+ export type { DagNode, Dag, RawDagNode, RawDag } from './dag';
38
38
 
39
39
  // ── Plugin registry ──
40
40
  export { bootstrapBuiltins } from './bootstrap';