@tagma/sdk 0.1.6 → 0.1.8
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 +22 -4
- package/package.json +1 -1
- package/src/config-ops.ts +38 -1
- package/src/dag.ts +86 -1
- package/src/engine.ts +34 -18
- package/src/pipeline-runner.ts +15 -3
- package/src/schema.ts +1 -1
- package/src/sdk.ts +2 -2
- package/src/validate-raw.ts +4 -0
package/README.md
CHANGED
|
@@ -113,7 +113,10 @@ Executes the pipeline. Returns `{ success, runId, logPath, summary, states }`.
|
|
|
113
113
|
Options:
|
|
114
114
|
- `approvalGateway` -- custom `ApprovalGateway` instance (defaults to `InMemoryApprovalGateway`)
|
|
115
115
|
- `signal` -- `AbortSignal` to cancel the run externally
|
|
116
|
-
- `onEvent` -- callback for real-time `PipelineEvent` updates
|
|
116
|
+
- `onEvent` -- callback for real-time `PipelineEvent` updates:
|
|
117
|
+
- `pipeline_start` — pipeline began; includes `states: ReadonlyMap<taskId, TaskState>` (initial snapshot of all tasks at `waiting`)
|
|
118
|
+
- `task_status_change` — a task changed status; includes `state: TaskState` (complete snapshot at the time of change, with `result` and `finishedAt` already populated for terminal statuses)
|
|
119
|
+
- `pipeline_end` — pipeline finished; includes `success: boolean`
|
|
117
120
|
- `maxLogRuns` -- number of per-run log directories to keep under `<workDir>/logs/` (default: 20)
|
|
118
121
|
|
|
119
122
|
### `PipelineRunner`
|
|
@@ -133,8 +136,9 @@ runner.start(); // returns Promise<EngineResult>, idempotent
|
|
|
133
136
|
// Cancel from IPC
|
|
134
137
|
runner.abort();
|
|
135
138
|
|
|
136
|
-
//
|
|
137
|
-
|
|
139
|
+
// Available from the first pipeline_start event onward (not just after completion)
|
|
140
|
+
// Returns null only if the pipeline has never started
|
|
141
|
+
const states = runner.getStates(); // ReadonlyMap<taskId, TaskState> | null
|
|
138
142
|
```
|
|
139
143
|
|
|
140
144
|
Properties:
|
|
@@ -184,7 +188,7 @@ const yaml = serializePipeline(config);
|
|
|
184
188
|
| `moveTrack(config, trackId, toIndex)` | Reorder a track |
|
|
185
189
|
| `updateTrack(config, trackId, fields)` | Patch track fields (not tasks) |
|
|
186
190
|
| `upsertTask(config, trackId, task)` | Insert or replace a task |
|
|
187
|
-
| `removeTask(config, trackId, taskId)` | Remove a task |
|
|
191
|
+
| `removeTask(config, trackId, taskId, cleanRefs?)` | Remove a task; pass `cleanRefs: true` to also strip dangling `depends_on` / `continue_from` references from other tasks |
|
|
188
192
|
| `moveTask(config, trackId, taskId, toIndex)` | Reorder a task within its track |
|
|
189
193
|
| `transferTask(config, fromTrackId, taskId, toTrackId)` | Move a task across tracks |
|
|
190
194
|
|
|
@@ -231,6 +235,20 @@ if (errors.length > 0) {
|
|
|
231
235
|
}
|
|
232
236
|
```
|
|
233
237
|
|
|
238
|
+
### `buildRawDag(config: RawPipelineConfig): RawDag`
|
|
239
|
+
|
|
240
|
+
Extracts the topology of a raw (unresolved) pipeline config as a graph — no `workDir` or plugin registration required. Intended for the visual editor to render the flow graph during editing.
|
|
241
|
+
|
|
242
|
+
Returns `{ nodes: ReadonlyMap<taskId, RawDagNode>, edges: { from, to }[] }` where each edge represents a dependency (from must complete before to). Template-expansion tasks (`use:` field) and unresolvable refs are silently skipped.
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
const { nodes, edges } = buildRawDag(draftConfig);
|
|
246
|
+
// nodes — keyed by "trackId.taskId"
|
|
247
|
+
// edges — [{ from: "track.taskA", to: "track.taskB" }, ...]
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Use `buildDag` instead when you have a fully resolved `PipelineConfig` and need topological sort order.
|
|
251
|
+
|
|
234
252
|
## Related Packages
|
|
235
253
|
|
|
236
254
|
| Package | Description |
|
package/package.json
CHANGED
package/src/config-ops.ts
CHANGED
|
@@ -117,19 +117,56 @@ export function upsertTask(
|
|
|
117
117
|
|
|
118
118
|
/**
|
|
119
119
|
* Remove a task from a track. No-op if either id is not found.
|
|
120
|
+
*
|
|
121
|
+
* When `cleanRefs` is true, all `depends_on` and `continue_from` references to the
|
|
122
|
+
* removed task are also removed from every other task in the pipeline. This prevents
|
|
123
|
+
* validateRaw from reporting dangling-ref errors after the deletion.
|
|
120
124
|
*/
|
|
121
125
|
export function removeTask(
|
|
122
126
|
config: RawPipelineConfig,
|
|
123
127
|
trackId: string,
|
|
124
128
|
taskId: string,
|
|
129
|
+
cleanRefs = false,
|
|
125
130
|
): RawPipelineConfig {
|
|
126
|
-
|
|
131
|
+
const withoutTask = {
|
|
127
132
|
...config,
|
|
128
133
|
tracks: config.tracks.map(t => {
|
|
129
134
|
if (t.id !== trackId) return t;
|
|
130
135
|
return { ...t, tasks: t.tasks.filter(tk => tk.id !== taskId) };
|
|
131
136
|
}),
|
|
132
137
|
};
|
|
138
|
+
|
|
139
|
+
if (!cleanRefs) return withoutTask;
|
|
140
|
+
|
|
141
|
+
// Both bare ("taskId") and fully-qualified ("trackId.taskId") forms are valid refs
|
|
142
|
+
const qualId = `${trackId}.${taskId}`;
|
|
143
|
+
const isRemoved = (ref: string) => ref === taskId || ref === qualId;
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
...withoutTask,
|
|
147
|
+
tracks: withoutTask.tracks.map(t => ({
|
|
148
|
+
...t,
|
|
149
|
+
tasks: t.tasks.map(tk => cleanTaskRefs(tk, isRemoved)),
|
|
150
|
+
})),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function cleanTaskRefs(
|
|
155
|
+
task: RawTaskConfig,
|
|
156
|
+
isRemoved: (ref: string) => boolean,
|
|
157
|
+
): RawTaskConfig {
|
|
158
|
+
const filteredDeps = task.depends_on?.filter(d => !isRemoved(d));
|
|
159
|
+
const dropContinueFrom = task.continue_from !== undefined && isRemoved(task.continue_from);
|
|
160
|
+
|
|
161
|
+
const depsUnchanged = filteredDeps === undefined || filteredDeps.length === task.depends_on!.length;
|
|
162
|
+
if (depsUnchanged && !dropContinueFrom) return task;
|
|
163
|
+
|
|
164
|
+
const { depends_on, continue_from, ...rest } = task;
|
|
165
|
+
return {
|
|
166
|
+
...rest,
|
|
167
|
+
...(filteredDeps !== undefined && filteredDeps.length > 0 ? { depends_on: filteredDeps } : {}),
|
|
168
|
+
...(!dropContinueFrom && continue_from !== undefined ? { continue_from } : {}),
|
|
169
|
+
} as RawTaskConfig;
|
|
133
170
|
}
|
|
134
171
|
|
|
135
172
|
/**
|
package/src/dag.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PipelineConfig, TaskConfig, TrackConfig } from './types';
|
|
1
|
+
import type { PipelineConfig, RawPipelineConfig, RawTaskConfig, TaskConfig, TrackConfig } from './types';
|
|
2
2
|
|
|
3
3
|
export interface DagNode {
|
|
4
4
|
readonly taskId: string; // fully qualified: track_id.task_id or just task_id
|
|
@@ -135,3 +135,88 @@ export function buildDag(config: PipelineConfig): Dag {
|
|
|
135
135
|
|
|
136
136
|
return { nodes, sorted };
|
|
137
137
|
}
|
|
138
|
+
|
|
139
|
+
// ═══ Raw DAG (for visual editor — no workDir required) ═══
|
|
140
|
+
|
|
141
|
+
export interface RawDagNode {
|
|
142
|
+
readonly taskId: string; // fully qualified: track_id.task_id
|
|
143
|
+
readonly trackId: string;
|
|
144
|
+
readonly rawTask: RawTaskConfig;
|
|
145
|
+
readonly dependsOn: readonly string[]; // fully qualified IDs, best-effort resolved
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface RawDag {
|
|
149
|
+
readonly nodes: ReadonlyMap<string, RawDagNode>;
|
|
150
|
+
/** Directed edges: from → to means "from must complete before to starts" */
|
|
151
|
+
readonly edges: readonly { readonly from: string; readonly to: string }[];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Build a lightweight DAG from a raw (unresolved) pipeline config.
|
|
156
|
+
* Unlike buildDag, this function:
|
|
157
|
+
* - Does not require a workDir or resolved PipelineConfig
|
|
158
|
+
* - Is lenient: missing or ambiguous refs are silently skipped
|
|
159
|
+
* - Skips template-expansion tasks (those with a `use` field)
|
|
160
|
+
*
|
|
161
|
+
* Intended for the visual editor to render the flow graph before a pipeline is run.
|
|
162
|
+
*/
|
|
163
|
+
export function buildRawDag(config: RawPipelineConfig): RawDag {
|
|
164
|
+
const nodes = new Map<string, RawDagNode>();
|
|
165
|
+
const bareToQualified = new Map<string, string>();
|
|
166
|
+
|
|
167
|
+
// 1. Register all concrete tasks
|
|
168
|
+
for (const track of config.tracks) {
|
|
169
|
+
for (const task of track.tasks) {
|
|
170
|
+
if (task.use) continue; // template-expansion tasks are not yet materialized
|
|
171
|
+
const qid = `${track.id}.${task.id}`;
|
|
172
|
+
if (nodes.has(qid)) continue; // skip duplicates silently
|
|
173
|
+
|
|
174
|
+
if (bareToQualified.has(task.id)) {
|
|
175
|
+
bareToQualified.set(task.id, '__ambiguous__');
|
|
176
|
+
} else {
|
|
177
|
+
bareToQualified.set(task.id, qid);
|
|
178
|
+
}
|
|
179
|
+
nodes.set(qid, { taskId: qid, trackId: track.id, rawTask: task, dependsOn: [] });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 2. Resolve dependency refs leniently (missing / ambiguous refs are skipped)
|
|
184
|
+
function tryResolve(ref: string, fromTrackId: string): string | null {
|
|
185
|
+
if (ref.includes('.')) return nodes.has(ref) ? ref : null;
|
|
186
|
+
const sameTrack = `${fromTrackId}.${ref}`;
|
|
187
|
+
if (nodes.has(sameTrack)) return sameTrack;
|
|
188
|
+
const global = bareToQualified.get(ref);
|
|
189
|
+
if (global && global !== '__ambiguous__') return global;
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const edges: { from: string; to: string }[] = [];
|
|
194
|
+
|
|
195
|
+
for (const track of config.tracks) {
|
|
196
|
+
for (const task of track.tasks) {
|
|
197
|
+
if (task.use) continue;
|
|
198
|
+
const qid = `${track.id}.${task.id}`;
|
|
199
|
+
const deps: string[] = [];
|
|
200
|
+
|
|
201
|
+
for (const ref of task.depends_on ?? []) {
|
|
202
|
+
const resolved = tryResolve(ref, track.id);
|
|
203
|
+
if (resolved && !deps.includes(resolved)) {
|
|
204
|
+
deps.push(resolved);
|
|
205
|
+
edges.push({ from: resolved, to: qid });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (task.continue_from) {
|
|
209
|
+
const resolved = tryResolve(task.continue_from, track.id);
|
|
210
|
+
if (resolved && !deps.includes(resolved)) {
|
|
211
|
+
deps.push(resolved);
|
|
212
|
+
edges.push({ from: resolved, to: qid });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const node = nodes.get(qid)!;
|
|
217
|
+
nodes.set(qid, { ...node, dependsOn: deps });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return { nodes, edges };
|
|
222
|
+
}
|
package/src/engine.ts
CHANGED
|
@@ -97,8 +97,8 @@ export interface EngineResult {
|
|
|
97
97
|
// ═══ Pipeline Events ═══
|
|
98
98
|
|
|
99
99
|
export type PipelineEvent =
|
|
100
|
-
| { readonly type: 'task_status_change'; readonly taskId: string; readonly status: TaskStatus; readonly prevStatus: TaskStatus; readonly runId: string }
|
|
101
|
-
| { readonly type: 'pipeline_start'; readonly runId: string }
|
|
100
|
+
| { readonly type: 'task_status_change'; readonly taskId: string; readonly status: TaskStatus; readonly prevStatus: TaskStatus; readonly runId: string; readonly state: TaskState }
|
|
101
|
+
| { readonly type: 'pipeline_start'; readonly runId: string; readonly states: ReadonlyMap<string, TaskState> }
|
|
102
102
|
| { readonly type: 'pipeline_end'; readonly runId: string; readonly success: boolean };
|
|
103
103
|
|
|
104
104
|
export interface RunPipelineOptions {
|
|
@@ -198,7 +198,11 @@ export async function runPipeline(
|
|
|
198
198
|
for (const [, state] of states) {
|
|
199
199
|
state.status = 'waiting';
|
|
200
200
|
}
|
|
201
|
-
|
|
201
|
+
// Include a full states snapshot so listeners can initialize their mirrors without missing events
|
|
202
|
+
const statesSnapshot: ReadonlyMap<string, TaskState> = new Map(
|
|
203
|
+
[...states.entries()].map(([id, s]) => [id, { ...s }])
|
|
204
|
+
);
|
|
205
|
+
options.onEvent?.({ type: 'pipeline_start', runId, states: statesSnapshot });
|
|
202
206
|
|
|
203
207
|
const sessionMap = new Map<string, string>();
|
|
204
208
|
const outputMap = new Map<string, string>();
|
|
@@ -246,7 +250,16 @@ export async function runPipeline(
|
|
|
246
250
|
const state = states.get(taskId)!;
|
|
247
251
|
const prevStatus = state.status;
|
|
248
252
|
state.status = newStatus;
|
|
249
|
-
emit
|
|
253
|
+
// Snapshot state at emit time — result and finishedAt must be set before calling this for terminal statuses
|
|
254
|
+
const snapshot: TaskState = {
|
|
255
|
+
config: state.config,
|
|
256
|
+
trackConfig: state.trackConfig,
|
|
257
|
+
status: state.status,
|
|
258
|
+
result: state.result,
|
|
259
|
+
startedAt: state.startedAt,
|
|
260
|
+
finishedAt: state.finishedAt,
|
|
261
|
+
};
|
|
262
|
+
emit({ type: 'task_status_change', taskId, status: newStatus, prevStatus, runId, state: snapshot });
|
|
250
263
|
}
|
|
251
264
|
|
|
252
265
|
function getOnFailure(taskId: string): OnFailure {
|
|
@@ -319,8 +332,8 @@ export async function runPipeline(
|
|
|
319
332
|
if (result === 'skip') {
|
|
320
333
|
const depStatus = states.get(depId)?.status ?? 'unknown';
|
|
321
334
|
log.debug(`[task:${taskId}]`, `skipped (upstream "${depId}" status=${depStatus})`);
|
|
322
|
-
setTaskStatus(taskId, 'skipped');
|
|
323
335
|
state.finishedAt = nowISO();
|
|
336
|
+
setTaskStatus(taskId, 'skipped');
|
|
324
337
|
return;
|
|
325
338
|
}
|
|
326
339
|
if (result === 'unsatisfied') return; // still waiting
|
|
@@ -343,6 +356,7 @@ export async function runPipeline(
|
|
|
343
356
|
const msg = err instanceof Error ? err.message : String(err);
|
|
344
357
|
// If pipeline was aborted while we were still waiting for the trigger,
|
|
345
358
|
// this task never entered running state → skipped, not timeout.
|
|
359
|
+
state.finishedAt = nowISO();
|
|
346
360
|
if (pipelineAborted) {
|
|
347
361
|
setTaskStatus(taskId, 'skipped');
|
|
348
362
|
} else if (msg.includes('rejected') || msg.includes('denied')) {
|
|
@@ -352,7 +366,6 @@ export async function runPipeline(
|
|
|
352
366
|
} else {
|
|
353
367
|
setTaskStatus(taskId, 'failed'); // plugin error, watcher crash, etc.
|
|
354
368
|
}
|
|
355
|
-
state.finishedAt = nowISO();
|
|
356
369
|
await fireHook(taskId, 'task_failure');
|
|
357
370
|
return;
|
|
358
371
|
}
|
|
@@ -366,8 +379,8 @@ export async function runPipeline(
|
|
|
366
379
|
`task_start hook exit=${hookResult.exitCode} allowed=${hookResult.allowed}`);
|
|
367
380
|
}
|
|
368
381
|
if (!hookResult.allowed) {
|
|
369
|
-
setTaskStatus(taskId, 'blocked');
|
|
370
382
|
state.finishedAt = nowISO();
|
|
383
|
+
setTaskStatus(taskId, 'blocked');
|
|
371
384
|
await fireHook(taskId, 'task_failure');
|
|
372
385
|
return;
|
|
373
386
|
}
|
|
@@ -443,18 +456,19 @@ export async function runPipeline(
|
|
|
443
456
|
result = await runSpawn(spec, driver, runOpts);
|
|
444
457
|
}
|
|
445
458
|
|
|
446
|
-
// 5. Determine status
|
|
459
|
+
// 5. Determine terminal status (without emitting yet — result must be complete first)
|
|
460
|
+
let terminalStatus: TaskStatus;
|
|
447
461
|
if (result.exitCode === -1) {
|
|
448
|
-
|
|
462
|
+
terminalStatus = 'timeout';
|
|
449
463
|
} else if (result.exitCode !== 0) {
|
|
450
|
-
|
|
464
|
+
terminalStatus = 'failed';
|
|
451
465
|
} else if (task.completion) {
|
|
452
466
|
const plugin = getHandler<CompletionPlugin>('completions', task.completion.type);
|
|
453
467
|
const completionCtx = { workDir: task.cwd ?? workDir };
|
|
454
468
|
const passed = await plugin.check(task.completion as Record<string, unknown>, result, completionCtx);
|
|
455
|
-
|
|
469
|
+
terminalStatus = passed ? 'success' : 'failed';
|
|
456
470
|
} else {
|
|
457
|
-
|
|
471
|
+
terminalStatus = 'success';
|
|
458
472
|
}
|
|
459
473
|
|
|
460
474
|
// 6. Write output file with RAW stdout (preserves driver output format).
|
|
@@ -488,16 +502,18 @@ export async function runPipeline(
|
|
|
488
502
|
if (!sessionMap.has(bareId)) sessionMap.set(bareId, result.sessionId);
|
|
489
503
|
}
|
|
490
504
|
|
|
505
|
+
// Set result and finishedAt before emitting terminal status so listeners see complete state
|
|
491
506
|
state.result = result;
|
|
492
507
|
state.finishedAt = nowISO();
|
|
508
|
+
setTaskStatus(taskId, terminalStatus);
|
|
493
509
|
|
|
494
510
|
// Log task outcome with relevant details
|
|
495
511
|
const durSec = (result.durationMs / 1000).toFixed(1);
|
|
496
|
-
if (
|
|
512
|
+
if (terminalStatus === 'success') {
|
|
497
513
|
log.info(`[task:${taskId}]`, `success (${durSec}s)`);
|
|
498
514
|
} else {
|
|
499
515
|
log.error(`[task:${taskId}]`,
|
|
500
|
-
`${
|
|
516
|
+
`${terminalStatus} exit=${result.exitCode} duration=${durSec}s`);
|
|
501
517
|
if (result.stderr) {
|
|
502
518
|
const tail = tailLines(result.stderr, 10);
|
|
503
519
|
log.error(`[task:${taskId}]`, `stderr tail:\n${tail}`);
|
|
@@ -524,12 +540,10 @@ export async function runPipeline(
|
|
|
524
540
|
}
|
|
525
541
|
if (task.completion) {
|
|
526
542
|
log.debug(`[task:${taskId}]`,
|
|
527
|
-
`completion check: type=${task.completion.type} result=${
|
|
543
|
+
`completion check: type=${task.completion.type} result=${terminalStatus}`);
|
|
528
544
|
}
|
|
529
545
|
|
|
530
546
|
} catch (err: unknown) {
|
|
531
|
-
setTaskStatus(taskId, 'failed');
|
|
532
|
-
state.finishedAt = nowISO();
|
|
533
547
|
const errMsg = err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
534
548
|
log.error(`[task:${taskId}]`, `failed before execution: ${errMsg}`);
|
|
535
549
|
state.result = {
|
|
@@ -539,6 +553,8 @@ export async function runPipeline(
|
|
|
539
553
|
outputPath: null, stderrPath: null, durationMs: 0,
|
|
540
554
|
sessionId: null, normalizedOutput: null,
|
|
541
555
|
};
|
|
556
|
+
state.finishedAt = nowISO();
|
|
557
|
+
setTaskStatus(taskId, 'failed');
|
|
542
558
|
}
|
|
543
559
|
|
|
544
560
|
// 7. Fire hooks
|
|
@@ -589,8 +605,8 @@ export async function runPipeline(
|
|
|
589
605
|
for (const [id, state] of states) {
|
|
590
606
|
if (!isTerminal(state.status)) {
|
|
591
607
|
// Running tasks get timeout (they were killed); waiting tasks get skipped
|
|
592
|
-
setTaskStatus(id, state.status === 'running' ? 'timeout' : 'skipped');
|
|
593
608
|
state.finishedAt = nowISO();
|
|
609
|
+
setTaskStatus(id, state.status === 'running' ? 'timeout' : 'skipped');
|
|
594
610
|
}
|
|
595
611
|
}
|
|
596
612
|
}
|
package/src/pipeline-runner.ts
CHANGED
|
@@ -42,6 +42,7 @@ export class PipelineRunner {
|
|
|
42
42
|
private _abortController = new AbortController();
|
|
43
43
|
private _handlers = new Set<(event: PipelineEvent) => void>();
|
|
44
44
|
private _states: ReadonlyMap<string, TaskState> | null = null;
|
|
45
|
+
private _statesMirror = new Map<string, TaskState>();
|
|
45
46
|
|
|
46
47
|
constructor(
|
|
47
48
|
private readonly config: PipelineConfig,
|
|
@@ -67,6 +68,14 @@ export class PipelineRunner {
|
|
|
67
68
|
onEvent: (event) => {
|
|
68
69
|
if (event.type === 'pipeline_start') {
|
|
69
70
|
this._runId = event.runId;
|
|
71
|
+
// Initialize the live mirror with the full initial state snapshot
|
|
72
|
+
for (const [id, state] of event.states) {
|
|
73
|
+
this._statesMirror.set(id, { ...state });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (event.type === 'task_status_change') {
|
|
77
|
+
// Keep the mirror up to date so getStates() works during the run
|
|
78
|
+
this._statesMirror.set(event.taskId, event.state);
|
|
70
79
|
}
|
|
71
80
|
if (event.type === 'pipeline_end') {
|
|
72
81
|
this._status = this._abortController.signal.aborted ? 'aborted' : 'done';
|
|
@@ -94,11 +103,14 @@ export class PipelineRunner {
|
|
|
94
103
|
}
|
|
95
104
|
|
|
96
105
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
106
|
+
* Live snapshot of task states. Available from the first pipeline_start event onward
|
|
107
|
+
* (i.e. as soon as start() is called) and remains accessible after the run completes.
|
|
108
|
+
* Returns null only if the pipeline has never started.
|
|
99
109
|
*/
|
|
100
110
|
getStates(): ReadonlyMap<string, TaskState> | null {
|
|
101
|
-
return this._states;
|
|
111
|
+
if (this._states) return this._states;
|
|
112
|
+
if (this._statesMirror.size > 0) return this._statesMirror;
|
|
113
|
+
return null;
|
|
102
114
|
}
|
|
103
115
|
|
|
104
116
|
/**
|
package/src/schema.ts
CHANGED
|
@@ -291,7 +291,7 @@ export function deresolvePipeline(config: PipelineConfig, workDir: string): RawP
|
|
|
291
291
|
...(task.middlewares !== undefined ? { middlewares: task.middlewares } : {}),
|
|
292
292
|
...(task.completion ? { completion: task.completion } : {}),
|
|
293
293
|
...(task.agent_profile ? { agent_profile: task.agent_profile } : {}),
|
|
294
|
-
...(task.permissions && JSON.stringify(task.permissions) !== JSON.stringify(
|
|
294
|
+
...(task.permissions && JSON.stringify(task.permissions) !== JSON.stringify(track.permissions)
|
|
295
295
|
? { permissions: task.permissions }
|
|
296
296
|
: {}),
|
|
297
297
|
};
|
package/src/sdk.ts
CHANGED
|
@@ -33,8 +33,8 @@ export type { ValidationError } from './validate-raw';
|
|
|
33
33
|
export { parseYaml, resolveConfig, expandTemplates, loadPipeline, serializePipeline, deresolvePipeline, validateConfig } from './schema';
|
|
34
34
|
|
|
35
35
|
// ── DAG ──
|
|
36
|
-
export { buildDag } from './dag';
|
|
37
|
-
export type { DagNode, Dag } from './dag';
|
|
36
|
+
export { buildDag, buildRawDag } from './dag';
|
|
37
|
+
export type { DagNode, Dag, RawDagNode, RawDag } from './dag';
|
|
38
38
|
|
|
39
39
|
// ── Plugin registry ──
|
|
40
40
|
export { bootstrapBuiltins } from './bootstrap';
|
package/src/validate-raw.ts
CHANGED
|
@@ -166,6 +166,10 @@ function detectCycles(
|
|
|
166
166
|
const resolved = resolveDepRef(dep, track.id, allQualified, bareToQualified);
|
|
167
167
|
if (resolved) deps.push(resolved);
|
|
168
168
|
}
|
|
169
|
+
if (task.continue_from) {
|
|
170
|
+
const resolved = resolveDepRef(task.continue_from, track.id, allQualified, bareToQualified);
|
|
171
|
+
if (resolved && !deps.includes(resolved)) deps.push(resolved);
|
|
172
|
+
}
|
|
169
173
|
adj.set(qid, deps);
|
|
170
174
|
}
|
|
171
175
|
}
|