@tagma/sdk 0.2.8 → 0.2.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tagma/sdk",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
package/src/engine.ts CHANGED
@@ -433,7 +433,11 @@ export async function runPipeline(
433
433
  } else {
434
434
  setTaskStatus(taskId, 'failed'); // plugin error, watcher crash, etc.
435
435
  }
436
- await fireHook(taskId, 'task_failure');
436
+ try {
437
+ await fireHook(taskId, 'task_failure');
438
+ } catch (hookErr) {
439
+ log.error(`[task:${taskId}]`, `hook execution failed: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`);
440
+ }
437
441
  return;
438
442
  }
439
443
  }
@@ -448,7 +452,11 @@ export async function runPipeline(
448
452
  if (!hookResult.allowed) {
449
453
  state.finishedAt = nowISO();
450
454
  setTaskStatus(taskId, 'blocked');
451
- await fireHook(taskId, 'task_failure');
455
+ try {
456
+ await fireHook(taskId, 'task_failure');
457
+ } catch (hookErr) {
458
+ log.error(`[task:${taskId}]`, `hook execution failed: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`);
459
+ }
452
460
  return;
453
461
  }
454
462
 
@@ -628,7 +636,11 @@ export async function runPipeline(
628
636
 
629
637
  // 7. Fire hooks
630
638
  const finalStatus: TaskStatus = state.status;
631
- await fireHook(taskId, finalStatus === 'success' ? 'task_success' : 'task_failure');
639
+ try {
640
+ await fireHook(taskId, finalStatus === 'success' ? 'task_success' : 'task_failure');
641
+ } catch (hookErr) {
642
+ log.error(`[task:${taskId}]`, `hook execution failed: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`);
643
+ }
632
644
 
633
645
  // 8. Handle stop_all for failure states
634
646
  if (finalStatus !== 'success' && getOnFailure(taskId) === 'stop_all') {
package/src/hooks.ts CHANGED
@@ -79,6 +79,9 @@ async function runSingleHook(
79
79
  }
80
80
 
81
81
  return exitCode;
82
+ } catch (err) {
83
+ console.error(`[hook: ${command}] spawn error: ${err instanceof Error ? err.message : String(err)}`);
84
+ return -1;
82
85
  } finally {
83
86
  if (timer) clearTimeout(timer);
84
87
  if (signal) signal.removeEventListener('abort', onAbort);
package/src/schema.ts CHANGED
@@ -216,6 +216,32 @@ function expandTemplateTask(
216
216
  // ═══ Config Inheritance Resolution ═══
217
217
 
218
218
  export function resolveConfig(raw: RawPipelineConfig, workDir: string): PipelineConfig {
219
+ // Build qualified ID set for resolving bare continue_from references
220
+ const allQualifiedIds = new Set<string>();
221
+ for (const t of raw.tracks) {
222
+ if (!t.id) continue;
223
+ for (const tk of t.tasks ?? []) {
224
+ if (tk.id) allQualifiedIds.add(`${t.id}.${tk.id}`);
225
+ }
226
+ }
227
+
228
+ function qualifyContinueFrom(ref: string, trackId: string): string {
229
+ // Already qualified
230
+ if (allQualifiedIds.has(ref)) return ref;
231
+ // Same-track shorthand
232
+ const sameTrack = `${trackId}.${ref}`;
233
+ if (allQualifiedIds.has(sameTrack)) return sameTrack;
234
+ // Cross-track bare lookup — must be unambiguous
235
+ let match: string | null = null;
236
+ for (const qid of allQualifiedIds) {
237
+ if (qid.endsWith(`.${ref}`)) {
238
+ if (match !== null) return ref; // ambiguous — leave as-is
239
+ match = qid;
240
+ }
241
+ }
242
+ return match ?? ref; // not found — leave as-is (validated elsewhere)
243
+ }
244
+
219
245
  const tracks: TrackConfig[] = raw.tracks.map(rawTrack => {
220
246
  const trackDriver = rawTrack.driver ?? raw.driver;
221
247
  // validatePath enforces no .. traversal and no absolute paths escaping workDir.
@@ -232,7 +258,9 @@ export function resolveConfig(raw: RawPipelineConfig, workDir: string): Pipeline
232
258
  command: rawTask.command,
233
259
  depends_on: rawTask.depends_on,
234
260
  trigger: rawTask.trigger,
235
- continue_from: rawTask.continue_from,
261
+ continue_from: rawTask.continue_from
262
+ ? qualifyContinueFrom(rawTask.continue_from, rawTrack.id)
263
+ : undefined,
236
264
  output: rawTask.output,
237
265
  // Inheritance: Task > Track
238
266
  model_tier: rawTask.model_tier ?? rawTrack.model_tier ?? 'medium',
@@ -8,6 +8,14 @@
8
8
 
9
9
  import type { RawPipelineConfig } from './types';
10
10
 
11
+ const DURATION_RE = /^(\d*\.?\d+)\s*(s|m|h|d)$/;
12
+ function isValidDuration(input: string): boolean {
13
+ return DURATION_RE.test(input.trim());
14
+ }
15
+
16
+ const VALID_ON_FAILURE = new Set(['skip_downstream', 'stop_all', 'ignore']);
17
+ const VALID_MODEL_TIERS = new Set(['low', 'medium', 'high']);
18
+
11
19
  export interface ValidationError {
12
20
  /** JSONPath-style location, e.g. "tracks[0].tasks[1].prompt" */
13
21
  path: string;
@@ -57,16 +65,27 @@ export function validateRaw(config: RawPipelineConfig): ValidationError[] {
57
65
  }
58
66
 
59
67
  // ── Per-track validation ──
68
+ const seenTrackIds = new Set<string>();
60
69
  for (let ti = 0; ti < config.tracks.length; ti++) {
61
70
  const track = config.tracks[ti];
62
71
  const trackPath = `tracks[${ti}]`;
63
72
 
64
73
  if (!track.id?.trim()) {
65
74
  errors.push({ path: `${trackPath}.id`, message: 'Track id is required' });
75
+ } else if (seenTrackIds.has(track.id)) {
76
+ errors.push({ path: `${trackPath}.id`, message: `Duplicate track id "${track.id}"` });
77
+ } else {
78
+ seenTrackIds.add(track.id);
66
79
  }
67
80
  if (!track.name?.trim()) {
68
81
  errors.push({ path: `${trackPath}.name`, message: 'Track name is required' });
69
82
  }
83
+ if (track.on_failure && !VALID_ON_FAILURE.has(track.on_failure)) {
84
+ errors.push({ path: `${trackPath}.on_failure`, message: `Invalid on_failure value "${track.on_failure}". Expected "skip_downstream", "stop_all", or "ignore".` });
85
+ }
86
+ if (track.model_tier && !VALID_MODEL_TIERS.has(track.model_tier)) {
87
+ errors.push({ path: `${trackPath}.model_tier`, message: `Invalid model_tier "${track.model_tier}". Expected "low", "medium", or "high".` });
88
+ }
70
89
 
71
90
  if (!track.tasks || track.tasks.length === 0) {
72
91
  errors.push({ path: `${trackPath}.tasks`, message: `Track "${track.id || ti}": must have at least one task` });
@@ -108,6 +127,14 @@ export function validateRaw(config: RawPipelineConfig): ValidationError[] {
108
127
  });
109
128
  }
110
129
 
130
+ // ── Field-level validations ──
131
+ if (task.timeout && !isValidDuration(task.timeout)) {
132
+ errors.push({ path: `${taskPath}.timeout`, message: `Invalid duration format "${task.timeout}". Expected e.g. "30s", "5m", "1h".` });
133
+ }
134
+ if (task.model_tier && !VALID_MODEL_TIERS.has(task.model_tier)) {
135
+ errors.push({ path: `${taskPath}.model_tier`, message: `Invalid model_tier "${task.model_tier}". Expected "low", "medium", or "high".` });
136
+ }
137
+
111
138
  // ── depends_on reference checks ──
112
139
  if (task.depends_on && task.depends_on.length > 0) {
113
140
  for (const dep of task.depends_on) {
@@ -139,6 +166,13 @@ export function validateRaw(config: RawPipelineConfig): ValidationError[] {
139
166
  path: `${taskPath}.continue_from`,
140
167
  message: `Task "${task.id}": continue_from "${task.continue_from}" is ambiguous — multiple tracks have a task with this id. Use the fully-qualified form "trackId.${task.continue_from}".`,
141
168
  });
169
+ } else if (!task.depends_on || !task.depends_on.some(dep =>
170
+ resolveDepRef(dep, track.id, allQualified, bareToQualified) === resolved
171
+ )) {
172
+ errors.push({
173
+ path: `${taskPath}.continue_from`,
174
+ message: `Task "${task.id}": continue_from "${task.continue_from}" should also be listed in depends_on to ensure ordering`,
175
+ });
142
176
  }
143
177
  }
144
178
  }