@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 +1 -1
- package/src/engine.ts +15 -3
- package/src/hooks.ts +3 -0
- package/src/schema.ts +29 -1
- package/src/validate-raw.ts +34 -0
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
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
|
}
|