@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 +1 -1
- package/src/engine.ts +47 -7
- package/src/hooks.ts +3 -0
- package/src/schema.ts +29 -1
- package/src/sdk.ts +1 -1
- package/src/triggers/file.ts +2 -1
- package/src/triggers/manual.ts +5 -3
- package/src/validate-raw.ts +34 -0
package/package.json
CHANGED
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 (
|
|
448
|
+
} else if (err instanceof TriggerBlockedError) {
|
|
430
449
|
setTaskStatus(taskId, 'blocked'); // user/policy rejection
|
|
431
|
-
} else if (
|
|
450
|
+
} else if (err instanceof TriggerTimeoutError) {
|
|
432
451
|
setTaskStatus(taskId, 'timeout'); // genuine trigger wait timeout
|
|
433
452
|
} else {
|
|
434
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) ──
|
package/src/triggers/file.ts
CHANGED
|
@@ -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
|
|
122
|
+
reject(new TriggerTimeoutError(`file trigger timeout: ${filePath} did not appear within ${config.timeout}`));
|
|
122
123
|
}, timeoutMs);
|
|
123
124
|
}
|
|
124
125
|
|
package/src/triggers/manual.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
76
|
+
throw new TriggerTimeoutError(`Manual trigger timeout: ${decision.reason ?? 'no decision made'}`);
|
|
75
77
|
case 'aborted':
|
|
76
|
-
throw new
|
|
78
|
+
throw new TriggerBlockedError(`Manual trigger aborted: ${decision.reason ?? 'pipeline aborted'}`);
|
|
77
79
|
}
|
|
78
80
|
},
|
|
79
81
|
};
|
package/src/validate-raw.ts
CHANGED
|
@@ -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
|
}
|