@tagma/sdk 0.2.6 → 0.2.7
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 +4 -2
- package/package.json +1 -1
- package/src/config-ops.ts +74 -1
- package/src/engine.ts +9 -1
- package/src/logger.ts +19 -6
- package/src/runner.ts +19 -6
- package/src/schema.ts +3 -1
- package/src/validate-raw.ts +6 -0
package/README.md
CHANGED
|
@@ -296,6 +296,7 @@ Options:
|
|
|
296
296
|
- `task_status_change` — a task changed status; includes `state: TaskState` (complete snapshot at the time of change: `startedAt` is populated before the `running` event; `result` and `finishedAt` are populated before any terminal-status event)
|
|
297
297
|
- `task_log` — a structured log line was written to `pipeline.log`. Mirrors every `Logger` call (info/warn/error/debug/section/quiet) and carries `{ taskId: string | null, level, timestamp, text }`. `taskId` is non-null for lines tagged with a `[task:<id>]` prefix (or passed explicitly to `section`/`quiet`) and `null` for pipeline-wide messages such as the configuration dump and DAG topology. Use this to stream the full run process into UIs without tailing the log file.
|
|
298
298
|
- `pipeline_end` — pipeline finished; includes `success: boolean`
|
|
299
|
+
- `runId` -- caller-supplied run ID. When provided the engine uses this instead of generating its own, keeping the caller and the SDK log directories aligned on the same ID
|
|
299
300
|
- `maxLogRuns` -- number of per-run log directories to keep under `<workDir>/.tagma/logs/` (default: 20)
|
|
300
301
|
|
|
301
302
|
### `PipelineRunner`
|
|
@@ -435,7 +436,7 @@ const yaml = serializePipeline(config);
|
|
|
435
436
|
| `upsertTask(config, trackId, task)` | Insert or replace a task |
|
|
436
437
|
| `removeTask(config, trackId, taskId, cleanRefs?)` | Remove a task; pass `cleanRefs: true` to also strip dangling `depends_on` / `continue_from` references. Only refs that resolve to the deleted task are removed — same-named tasks in other tracks are unaffected |
|
|
437
438
|
| `moveTask(config, trackId, taskId, toIndex)` | Reorder a task within its track |
|
|
438
|
-
| `transferTask(config, fromTrackId, taskId, toTrackId)` | Move a task across tracks |
|
|
439
|
+
| `transferTask(config, fromTrackId, taskId, toTrackId, qualifyRefs?)` | Move a task across tracks. When `qualifyRefs` is `true` (default), bare `depends_on` / `continue_from` references to the moved task are converted to fully-qualified form (`toTrackId.taskId`) so same-track resolution stays correct |
|
|
439
440
|
|
|
440
441
|
### `parseYaml(content: string): RawPipelineConfig`
|
|
441
442
|
|
|
@@ -469,7 +470,7 @@ Use `validateRaw` for editing raw configs in a UI; use `validateConfig` after `r
|
|
|
469
470
|
|
|
470
471
|
Validates a raw pipeline config without resolving inheritance or executing anything. Returns a flat list of `{ path, message }` objects — empty array means valid.
|
|
471
472
|
|
|
472
|
-
Checks: required fields, `prompt`/`command` exclusivity, `depends_on`/`continue_from` reference integrity (including ambiguous bare refs that exist in multiple tracks — use `trackId.taskId` to disambiguate), circular dependency detection.
|
|
473
|
+
Checks: required fields, `prompt`/`command` exclusivity, duplicate task IDs within a track, `depends_on`/`continue_from` reference integrity (including ambiguous bare refs that exist in multiple tracks — use `trackId.taskId` to disambiguate), circular dependency detection.
|
|
473
474
|
|
|
474
475
|
Does **not** check plugin registration (plugins may not be loaded at edit time).
|
|
475
476
|
|
|
@@ -508,6 +509,7 @@ logger.section('Title'); // file only — visual separator
|
|
|
508
509
|
logger.quiet(bulkText); // file only — bulk payload
|
|
509
510
|
logger.path; // log file path
|
|
510
511
|
logger.dir; // run artifact directory
|
|
512
|
+
logger.close(); // close the persistent file handle (called automatically by runPipeline at run completion)
|
|
511
513
|
```
|
|
512
514
|
|
|
513
515
|
Pass an optional third argument to stream every appended line out as a
|
package/package.json
CHANGED
package/src/config-ops.ts
CHANGED
|
@@ -226,13 +226,21 @@ export function moveTask(
|
|
|
226
226
|
/**
|
|
227
227
|
* Move a task from one track to another (appends to the target track).
|
|
228
228
|
* No-op if either trackId or taskId is not found.
|
|
229
|
+
*
|
|
230
|
+
* When `qualifyRefs` is true (the default), bare references (`depends_on`,
|
|
231
|
+
* `continue_from`) pointing to the moved task are converted to fully-qualified
|
|
232
|
+
* refs (`toTrackId.taskId`) so that same-track resolution doesn't silently
|
|
233
|
+
* break after the task changes tracks.
|
|
229
234
|
*/
|
|
230
235
|
export function transferTask(
|
|
231
236
|
config: RawPipelineConfig,
|
|
232
237
|
fromTrackId: string,
|
|
233
238
|
taskId: string,
|
|
234
239
|
toTrackId: string,
|
|
240
|
+
qualifyRefs = true,
|
|
235
241
|
): RawPipelineConfig {
|
|
242
|
+
if (fromTrackId === toTrackId) return config;
|
|
243
|
+
|
|
236
244
|
let task: RawTaskConfig | undefined;
|
|
237
245
|
const afterRemove = {
|
|
238
246
|
...config,
|
|
@@ -245,5 +253,70 @@ export function transferTask(
|
|
|
245
253
|
}),
|
|
246
254
|
};
|
|
247
255
|
if (!task) return config;
|
|
248
|
-
|
|
256
|
+
const afterInsert = upsertTask(afterRemove, toTrackId, task);
|
|
257
|
+
|
|
258
|
+
if (!qualifyRefs) return afterInsert;
|
|
259
|
+
|
|
260
|
+
// Qualify bare references to the moved task. After the move, bare ref
|
|
261
|
+
// "taskId" from the old track no longer resolves via same-track priority.
|
|
262
|
+
// Convert it to the qualified form "toTrackId.taskId" so the dependency
|
|
263
|
+
// graph stays correct.
|
|
264
|
+
const qualId = `${toTrackId}.${taskId}`;
|
|
265
|
+
const oldQualId = `${fromTrackId}.${taskId}`;
|
|
266
|
+
|
|
267
|
+
// Does any track (other than the destination) still have a task with this bare id?
|
|
268
|
+
const bareIdSurvivesElsewhere = afterInsert.tracks.some(t =>
|
|
269
|
+
t.id !== toTrackId && t.tasks.some(tk => tk.id === taskId),
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
...afterInsert,
|
|
274
|
+
tracks: afterInsert.tracks.map(t => {
|
|
275
|
+
const localHasId = t.tasks.some(tk => tk.id === taskId);
|
|
276
|
+
|
|
277
|
+
const qualifyRef = (ref: string): string => {
|
|
278
|
+
// Already-qualified ref to old location → rewrite to new location
|
|
279
|
+
if (ref === oldQualId) return qualId;
|
|
280
|
+
// Bare ref: only needs qualifying if it would have resolved to the
|
|
281
|
+
// moved task before the transfer
|
|
282
|
+
if (ref === taskId) {
|
|
283
|
+
if (t.id === fromTrackId) {
|
|
284
|
+
// Was same-track in the old track — now the task is gone.
|
|
285
|
+
// If no other local task shadows it, qualify to new location.
|
|
286
|
+
if (!localHasId) return qualId;
|
|
287
|
+
}
|
|
288
|
+
// From a different track: bare ref resolved globally before.
|
|
289
|
+
// If the bare id is now ambiguous or gone from this track's
|
|
290
|
+
// perspective, qualify it.
|
|
291
|
+
if (!localHasId && !bareIdSurvivesElsewhere) return qualId;
|
|
292
|
+
}
|
|
293
|
+
return ref;
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
...t,
|
|
298
|
+
tasks: t.tasks.map(tk => qualifyTaskRefs(tk, qualifyRef)),
|
|
299
|
+
};
|
|
300
|
+
}),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Rewrite `depends_on` and `continue_from` refs using a mapping function. */
|
|
305
|
+
function qualifyTaskRefs(
|
|
306
|
+
task: RawTaskConfig,
|
|
307
|
+
rewrite: (ref: string) => string,
|
|
308
|
+
): RawTaskConfig {
|
|
309
|
+
const newDeps = task.depends_on?.map(rewrite);
|
|
310
|
+
const newContinue = task.continue_from !== undefined ? rewrite(task.continue_from) : undefined;
|
|
311
|
+
|
|
312
|
+
const depsChanged = newDeps !== undefined && newDeps.some((d, i) => d !== task.depends_on![i]);
|
|
313
|
+
const continueChanged = newContinue !== undefined && newContinue !== task.continue_from;
|
|
314
|
+
|
|
315
|
+
if (!depsChanged && !continueChanged) return task;
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
...task,
|
|
319
|
+
...(newDeps !== undefined ? { depends_on: newDeps } : {}),
|
|
320
|
+
...(newContinue !== undefined ? { continue_from: newContinue } : {}),
|
|
321
|
+
};
|
|
249
322
|
}
|
package/src/engine.ts
CHANGED
|
@@ -132,6 +132,12 @@ export interface RunPipelineOptions {
|
|
|
132
132
|
* Oldest directories are deleted after each run. Defaults to 20. Set to 0 to disable cleanup.
|
|
133
133
|
*/
|
|
134
134
|
readonly maxLogRuns?: number;
|
|
135
|
+
/**
|
|
136
|
+
* Caller-supplied run ID. When provided the engine uses this instead of
|
|
137
|
+
* generating its own via `generateRunId()`, keeping the editor and SDK
|
|
138
|
+
* log directories aligned on the same ID.
|
|
139
|
+
*/
|
|
140
|
+
readonly runId?: string;
|
|
135
141
|
/**
|
|
136
142
|
* External AbortSignal — aborting it cancels the pipeline immediately.
|
|
137
143
|
* Equivalent to the pipeline timeout firing, but caller-controlled.
|
|
@@ -163,7 +169,7 @@ export async function runPipeline(
|
|
|
163
169
|
}
|
|
164
170
|
|
|
165
171
|
const dag = buildDag(config);
|
|
166
|
-
const runId = generateRunId();
|
|
172
|
+
const runId = options.runId ?? generateRunId();
|
|
167
173
|
preflight(config, dag);
|
|
168
174
|
|
|
169
175
|
const startedAt = nowISO();
|
|
@@ -723,6 +729,8 @@ export async function runPipeline(
|
|
|
723
729
|
return { success: allSuccess, runId, logPath: log.path, summary, states: freezeStates(states) };
|
|
724
730
|
|
|
725
731
|
} finally {
|
|
732
|
+
// Close the persistent log file handle before pruning.
|
|
733
|
+
log.close();
|
|
726
734
|
// Prune old per-run log directories on every exit path (normal, blocked, or thrown).
|
|
727
735
|
// Exclude the current runId so a concurrent run cannot delete its own live directory.
|
|
728
736
|
if (maxLogRuns > 0) {
|
package/src/logger.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { resolve, dirname } from 'node:path';
|
|
2
|
-
import { mkdirSync,
|
|
2
|
+
import { mkdirSync, writeFileSync, openSync, writeSync, closeSync } from 'node:fs';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Structured record emitted for every log line. Consumers (e.g. the editor
|
|
@@ -43,18 +43,21 @@ export class Logger {
|
|
|
43
43
|
private readonly filePath: string;
|
|
44
44
|
private readonly runDir: string;
|
|
45
45
|
private readonly onLine: LogListener | null;
|
|
46
|
+
/** Persistent file descriptor for append writes (avoids open/close per line). */
|
|
47
|
+
private fd: number | null;
|
|
46
48
|
|
|
47
49
|
constructor(workDir: string, runId: string, onLine?: LogListener) {
|
|
48
50
|
this.runDir = resolve(workDir, '.tagma', 'logs', runId);
|
|
49
51
|
this.filePath = resolve(this.runDir, 'pipeline.log');
|
|
50
52
|
this.onLine = onLine ?? null;
|
|
51
53
|
mkdirSync(dirname(this.filePath), { recursive: true });
|
|
52
|
-
|
|
53
|
-
this.filePath,
|
|
54
|
+
const header =
|
|
54
55
|
`# Pipeline run ${runId} @ ${new Date().toISOString()}\n` +
|
|
55
56
|
`# Host: ${process.platform} ${process.arch} Bun: ${process.versions.bun ?? 'n/a'}\n` +
|
|
56
|
-
`# Work dir: ${workDir}\n\n
|
|
57
|
-
);
|
|
57
|
+
`# Work dir: ${workDir}\n\n`;
|
|
58
|
+
writeFileSync(this.filePath, header);
|
|
59
|
+
// Open once for all subsequent appends (O_APPEND is implied by 'a' flag)
|
|
60
|
+
this.fd = openSync(this.filePath, 'a');
|
|
58
61
|
}
|
|
59
62
|
|
|
60
63
|
info(prefix: string, message: string): void {
|
|
@@ -105,13 +108,23 @@ export class Logger {
|
|
|
105
108
|
}
|
|
106
109
|
|
|
107
110
|
private append(line: string): void {
|
|
111
|
+
if (this.fd === null) return;
|
|
108
112
|
try {
|
|
109
|
-
|
|
113
|
+
const data = line.endsWith('\n') ? line : line + '\n';
|
|
114
|
+
writeSync(this.fd, data);
|
|
110
115
|
} catch {
|
|
111
116
|
// Swallow log write failures; engine correctness shouldn't depend on logging.
|
|
112
117
|
}
|
|
113
118
|
}
|
|
114
119
|
|
|
120
|
+
/** Close the persistent file handle. Called by the engine at run completion. */
|
|
121
|
+
close(): void {
|
|
122
|
+
if (this.fd !== null) {
|
|
123
|
+
try { closeSync(this.fd); } catch { /* already closed */ }
|
|
124
|
+
this.fd = null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
115
128
|
private emit(level: LogLevel, ts: string, text: string, taskId: string | null): void {
|
|
116
129
|
if (!this.onLine) return;
|
|
117
130
|
try {
|
package/src/runner.ts
CHANGED
|
@@ -15,10 +15,17 @@ const SIGKILL_DELAY_MS = 3_000;
|
|
|
15
15
|
function killProcessTree(pid: number): void {
|
|
16
16
|
if (process.platform !== 'win32') return;
|
|
17
17
|
try {
|
|
18
|
-
Bun.spawnSync(['taskkill', '/F', '/T', '/PID', String(pid)], {
|
|
19
|
-
stdout: '
|
|
20
|
-
stderr: '
|
|
18
|
+
const result = Bun.spawnSync(['taskkill', '/F', '/T', '/PID', String(pid)], {
|
|
19
|
+
stdout: 'pipe',
|
|
20
|
+
stderr: 'pipe',
|
|
21
21
|
});
|
|
22
|
+
if (result.exitCode !== 0) {
|
|
23
|
+
const stderr = new TextDecoder().decode(result.stderr);
|
|
24
|
+
// Exit code 128 = process not found (already exited) — not worth warning about
|
|
25
|
+
if (result.exitCode !== 128) {
|
|
26
|
+
console.error(`[killProcessTree] taskkill exited ${result.exitCode} for PID ${pid}: ${stderr.trim()}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
22
29
|
} catch {
|
|
23
30
|
/* best-effort — process may have already exited */
|
|
24
31
|
}
|
|
@@ -140,6 +147,7 @@ export async function runSpawn(
|
|
|
140
147
|
|
|
141
148
|
// ── 3. Timeout & abort handling ────────────────────────────────────────
|
|
142
149
|
let killedByUs = false;
|
|
150
|
+
let timedOut = false;
|
|
143
151
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
144
152
|
let forceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
145
153
|
|
|
@@ -165,7 +173,10 @@ export async function runSpawn(
|
|
|
165
173
|
};
|
|
166
174
|
|
|
167
175
|
if (timeoutMs && timeoutMs > 0) {
|
|
168
|
-
timer = setTimeout(
|
|
176
|
+
timer = setTimeout(() => {
|
|
177
|
+
timedOut = true;
|
|
178
|
+
killGracefully();
|
|
179
|
+
}, timeoutMs);
|
|
169
180
|
}
|
|
170
181
|
|
|
171
182
|
const onAbort = () => killGracefully();
|
|
@@ -197,8 +208,10 @@ export async function runSpawn(
|
|
|
197
208
|
// We initiated the kill (timeout or abort) — always treat as non-success
|
|
198
209
|
// regardless of exit code. A process that catches SIGTERM and exits 0 still
|
|
199
210
|
// hit the timeout; letting it pass as success would unblock downstream tasks
|
|
200
|
-
// incorrectly.
|
|
201
|
-
|
|
211
|
+
// incorrectly. The `timedOut` flag guards against the narrow race where the
|
|
212
|
+
// process exits naturally at the exact moment the timeout fires — even if
|
|
213
|
+
// killedByUs wasn't set in time, the timeout intention still applies.
|
|
214
|
+
if (killedByUs || timedOut) {
|
|
202
215
|
return {
|
|
203
216
|
exitCode: -1,
|
|
204
217
|
stdout,
|
package/src/schema.ts
CHANGED
|
@@ -101,7 +101,9 @@ async function loadTemplate(ref: string): Promise<TemplateConfig> {
|
|
|
101
101
|
// Expect the module to export a template.yaml content or parsed object
|
|
102
102
|
if (mod.template) return mod.template as TemplateConfig;
|
|
103
103
|
|
|
104
|
-
// Try loading template.yaml from the package
|
|
104
|
+
// Try loading template.yaml from the package.
|
|
105
|
+
// NOTE: require.resolve is a CommonJS API. Bun supports it natively, but
|
|
106
|
+
// this would need import.meta.resolve() for pure ESM runtimes (e.g. Deno).
|
|
105
107
|
const pkgPath = require.resolve(`${moduleName}/template.yaml`);
|
|
106
108
|
const content = await Bun.file(pkgPath).text();
|
|
107
109
|
const doc = yaml.load(content) as { template: TemplateConfig };
|
package/src/validate-raw.ts
CHANGED
|
@@ -74,6 +74,7 @@ export function validateRaw(config: RawPipelineConfig): ValidationError[] {
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
// ── Per-task validation ──
|
|
77
|
+
const seenTaskIds = new Set<string>();
|
|
77
78
|
for (let ki = 0; ki < track.tasks.length; ki++) {
|
|
78
79
|
const task = track.tasks[ki];
|
|
79
80
|
const taskPath = `${trackPath}.tasks[${ki}]`;
|
|
@@ -83,6 +84,11 @@ export function validateRaw(config: RawPipelineConfig): ValidationError[] {
|
|
|
83
84
|
continue; // Can't check further without an id
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
if (seenTaskIds.has(task.id)) {
|
|
88
|
+
errors.push({ path: taskPath, message: `Duplicate task id "${task.id}" in track "${track.id}"` });
|
|
89
|
+
}
|
|
90
|
+
seenTaskIds.add(task.id);
|
|
91
|
+
|
|
86
92
|
// Template-based tasks: skip prompt/command checks (params validated at runtime)
|
|
87
93
|
if (task.use) continue;
|
|
88
94
|
|