@tagma/sdk 0.2.8 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tagma/sdk",
3
- "version": "0.2.8",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
package/src/engine.ts CHANGED
@@ -19,6 +19,26 @@ import {
19
19
  import { Logger, tailLines, clip, type LogLevel } from './logger';
20
20
  import { InMemoryApprovalGateway, type ApprovalGateway } from './approval';
21
21
 
22
+ // ═══ A7: Typed trigger errors ═══
23
+ // Replace string-matching on error messages with structured error types so
24
+ // coincidental substrings don't cause misclassification.
25
+
26
+ export class TriggerBlockedError extends Error {
27
+ readonly code = 'TRIGGER_BLOCKED' as const;
28
+ constructor(message: string) {
29
+ super(message);
30
+ this.name = 'TriggerBlockedError';
31
+ }
32
+ }
33
+
34
+ export class TriggerTimeoutError extends Error {
35
+ readonly code = 'TRIGGER_TIMEOUT' as const;
36
+ constructor(message: string) {
37
+ super(message);
38
+ this.name = 'TriggerTimeoutError';
39
+ }
40
+ }
41
+
22
42
  // ═══ Preflight Validation ═══
23
43
 
24
44
  function preflight(config: PipelineConfig, dag: Dag): void {
@@ -420,20 +440,32 @@ export async function runPipeline(
420
440
  });
421
441
  log.debug(`[task:${taskId}]`, `trigger fired`);
422
442
  } catch (err: unknown) {
423
- const msg = err instanceof Error ? err.message : String(err);
424
443
  // If pipeline was aborted while we were still waiting for the trigger,
425
444
  // this task never entered running state → skipped, not timeout.
426
445
  state.finishedAt = nowISO();
427
446
  if (pipelineAborted) {
428
447
  setTaskStatus(taskId, 'skipped');
429
- } else if (msg.includes('rejected') || msg.includes('denied')) {
448
+ } else if (err instanceof TriggerBlockedError) {
430
449
  setTaskStatus(taskId, 'blocked'); // user/policy rejection
431
- } else if (msg.includes('timeout')) {
450
+ } else if (err instanceof TriggerTimeoutError) {
432
451
  setTaskStatus(taskId, 'timeout'); // genuine trigger wait timeout
433
452
  } else {
434
- setTaskStatus(taskId, 'failed'); // plugin error, watcher crash, etc.
453
+ // A7 fallback: also check message strings for backward-compat with
454
+ // third-party trigger plugins that don't throw typed errors yet.
455
+ const msg = err instanceof Error ? err.message : String(err);
456
+ if (msg.includes('rejected') || msg.includes('denied')) {
457
+ setTaskStatus(taskId, 'blocked');
458
+ } else if (msg.includes('timeout')) {
459
+ setTaskStatus(taskId, 'timeout');
460
+ } else {
461
+ setTaskStatus(taskId, 'failed'); // plugin error, watcher crash, etc.
462
+ }
463
+ }
464
+ try {
465
+ await fireHook(taskId, 'task_failure');
466
+ } catch (hookErr) {
467
+ log.error(`[task:${taskId}]`, `hook execution failed: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`);
435
468
  }
436
- await fireHook(taskId, 'task_failure');
437
469
  return;
438
470
  }
439
471
  }
@@ -448,7 +480,11 @@ export async function runPipeline(
448
480
  if (!hookResult.allowed) {
449
481
  state.finishedAt = nowISO();
450
482
  setTaskStatus(taskId, 'blocked');
451
- await fireHook(taskId, 'task_failure');
483
+ try {
484
+ await fireHook(taskId, 'task_failure');
485
+ } catch (hookErr) {
486
+ log.error(`[task:${taskId}]`, `hook execution failed: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`);
487
+ }
452
488
  return;
453
489
  }
454
490
 
@@ -628,7 +664,11 @@ export async function runPipeline(
628
664
 
629
665
  // 7. Fire hooks
630
666
  const finalStatus: TaskStatus = state.status;
631
- await fireHook(taskId, finalStatus === 'success' ? 'task_success' : 'task_failure');
667
+ try {
668
+ await fireHook(taskId, finalStatus === 'success' ? 'task_success' : 'task_failure');
669
+ } catch (hookErr) {
670
+ log.error(`[task:${taskId}]`, `hook execution failed: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`);
671
+ }
632
672
 
633
673
  // 8. Handle stop_all for failure states
634
674
  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',
package/src/sdk.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // The CLI (src/index.ts in the CLI project) also imports from here.
5
5
 
6
6
  // ── Core engine ──
7
- export { runPipeline } from './engine';
7
+ export { runPipeline, TriggerBlockedError, TriggerTimeoutError } from './engine';
8
8
  export type { EngineResult, RunPipelineOptions, PipelineEvent } from './engine';
9
9
 
10
10
  // ── Pipeline runner (multi-pipeline lifecycle management) ──
@@ -3,6 +3,7 @@ import { resolve, dirname } from 'path';
3
3
  import { mkdir } from 'fs/promises';
4
4
  import type { TriggerPlugin, TriggerContext } from '../types';
5
5
  import { parseDuration, validatePath } from '../utils';
6
+ import { TriggerTimeoutError } from '../engine';
6
7
 
7
8
  const IS_WINDOWS = process.platform === 'win32';
8
9
 
@@ -118,7 +119,7 @@ export const FileTrigger: TriggerPlugin = {
118
119
  timer = setTimeout(() => {
119
120
  if (settled) return;
120
121
  cleanup();
121
- reject(new Error(`file trigger timeout: ${filePath} did not appear within ${config.timeout}`));
122
+ reject(new TriggerTimeoutError(`file trigger timeout: ${filePath} did not appear within ${config.timeout}`));
122
123
  }, timeoutMs);
123
124
  }
124
125
 
@@ -1,5 +1,6 @@
1
1
  import type { TriggerPlugin, TriggerContext } from '../types';
2
2
  import { parseDuration } from '../utils';
3
+ import { TriggerBlockedError, TriggerTimeoutError } from '../engine';
3
4
 
4
5
  export const ManualTrigger: TriggerPlugin = {
5
6
  name: 'manual',
@@ -66,14 +67,15 @@ export const ManualTrigger: TriggerPlugin = {
66
67
  case 'approved':
67
68
  return { confirmed: true, approvalId: decision.approvalId, actor: decision.actor };
68
69
  case 'rejected':
69
- throw new Error(
70
+ // A7: Use typed error for proper classification in the engine.
71
+ throw new TriggerBlockedError(
70
72
  `Manual trigger rejected by ${decision.actor ?? 'user'}` +
71
73
  (decision.reason ? `: ${decision.reason}` : ''),
72
74
  );
73
75
  case 'timeout':
74
- throw new Error(`Manual trigger timeout: ${decision.reason ?? 'no decision made'}`);
76
+ throw new TriggerTimeoutError(`Manual trigger timeout: ${decision.reason ?? 'no decision made'}`);
75
77
  case 'aborted':
76
- throw new Error(`Manual trigger aborted: ${decision.reason ?? 'pipeline aborted'}`);
78
+ throw new TriggerBlockedError(`Manual trigger aborted: ${decision.reason ?? 'pipeline aborted'}`);
77
79
  }
78
80
  },
79
81
  };
@@ -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
  }