@tagma/sdk 0.1.4 → 0.1.6
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 +107 -1
- package/package.json +4 -4
- package/src/config-ops.ts +183 -0
- package/src/engine.ts +106 -45
- package/src/pipeline-runner.ts +113 -0
- package/src/registry.ts +1 -3
- package/src/runner.ts +5 -3
- package/src/schema.ts +99 -1
- package/src/sdk.ts +25 -3
- package/src/validate-raw.ts +199 -0
package/README.md
CHANGED
|
@@ -108,10 +108,39 @@ Parses YAML, resolves inheritance, expands templates, and validates the configur
|
|
|
108
108
|
|
|
109
109
|
### `runPipeline(config, workDir, options?): Promise<EngineResult>`
|
|
110
110
|
|
|
111
|
-
Executes the pipeline. Returns `{ success, summary, states }`.
|
|
111
|
+
Executes the pipeline. Returns `{ success, runId, logPath, summary, states }`.
|
|
112
112
|
|
|
113
113
|
Options:
|
|
114
114
|
- `approvalGateway` -- custom `ApprovalGateway` instance (defaults to `InMemoryApprovalGateway`)
|
|
115
|
+
- `signal` -- `AbortSignal` to cancel the run externally
|
|
116
|
+
- `onEvent` -- callback for real-time `PipelineEvent` updates (task status changes, pipeline start/end)
|
|
117
|
+
- `maxLogRuns` -- number of per-run log directories to keep under `<workDir>/logs/` (default: 20)
|
|
118
|
+
|
|
119
|
+
### `PipelineRunner`
|
|
120
|
+
|
|
121
|
+
Higher-level wrapper for managing multiple concurrent pipeline runs — designed for sidecar / Tauri IPC scenarios where the frontend controls pipeline lifecycle by ID.
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
const runner = new PipelineRunner(config, workDir);
|
|
125
|
+
|
|
126
|
+
// Subscribe before start — handler is called for every PipelineEvent
|
|
127
|
+
const unsubscribe = runner.subscribe(event => {
|
|
128
|
+
tauriEmit('pipeline_event', { id: runner.instanceId, event });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
runner.start(); // returns Promise<EngineResult>, idempotent
|
|
132
|
+
|
|
133
|
+
// Cancel from IPC
|
|
134
|
+
runner.abort();
|
|
135
|
+
|
|
136
|
+
// After completion
|
|
137
|
+
const states = runner.getStates(); // ReadonlyMap<taskId, TaskState>
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Properties:
|
|
141
|
+
- `instanceId` — stable ID assigned at construction, safe to use as a Map key before `start()`
|
|
142
|
+
- `runId` — engine-assigned run ID, available after the first `pipeline_start` event (`null` until then)
|
|
143
|
+
- `status` — `'idle' | 'running' | 'done' | 'aborted'`
|
|
115
144
|
|
|
116
145
|
### `loadPlugins(names: string[]): Promise<void>`
|
|
117
146
|
|
|
@@ -125,6 +154,83 @@ Attaches an interactive stdin-based approval handler.
|
|
|
125
154
|
|
|
126
155
|
Starts a WebSocket server for remote approval decisions.
|
|
127
156
|
|
|
157
|
+
### Config CRUD (`config-ops`)
|
|
158
|
+
|
|
159
|
+
Pure, immutable helper functions for building and editing `RawPipelineConfig` in a visual editor. No runtime dependencies — safe to use in renderer processes.
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
import {
|
|
163
|
+
createEmptyPipeline, setPipelineField,
|
|
164
|
+
upsertTrack, removeTrack, moveTrack, updateTrack,
|
|
165
|
+
upsertTask, removeTask, moveTask, transferTask,
|
|
166
|
+
serializePipeline,
|
|
167
|
+
} from '@tagma/sdk';
|
|
168
|
+
|
|
169
|
+
// Build a config programmatically
|
|
170
|
+
let config = createEmptyPipeline('my-pipeline');
|
|
171
|
+
config = upsertTrack(config, { id: 'backend', name: 'Backend', tasks: [] });
|
|
172
|
+
config = upsertTask(config, 'backend', { id: 'implement', prompt: 'Add /health endpoint' });
|
|
173
|
+
|
|
174
|
+
// Sync back to YAML
|
|
175
|
+
const yaml = serializePipeline(config);
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
| Function | Description |
|
|
179
|
+
|---|---|
|
|
180
|
+
| `createEmptyPipeline(name)` | Create a minimal pipeline config |
|
|
181
|
+
| `setPipelineField(config, fields)` | Update top-level pipeline fields |
|
|
182
|
+
| `upsertTrack(config, track)` | Insert or replace a track by id |
|
|
183
|
+
| `removeTrack(config, trackId)` | Remove a track |
|
|
184
|
+
| `moveTrack(config, trackId, toIndex)` | Reorder a track |
|
|
185
|
+
| `updateTrack(config, trackId, fields)` | Patch track fields (not tasks) |
|
|
186
|
+
| `upsertTask(config, trackId, task)` | Insert or replace a task |
|
|
187
|
+
| `removeTask(config, trackId, taskId)` | Remove a task |
|
|
188
|
+
| `moveTask(config, trackId, taskId, toIndex)` | Reorder a task within its track |
|
|
189
|
+
| `transferTask(config, fromTrackId, taskId, toTrackId)` | Move a task across tracks |
|
|
190
|
+
|
|
191
|
+
### `parseYaml(content: string): RawPipelineConfig`
|
|
192
|
+
|
|
193
|
+
Parses a YAML string and returns the raw (unresolved) pipeline config. Use this when you need to edit and re-save YAML without losing relative paths or user-authored formatting — pass the result to `serializePipeline()` rather than going through `loadPipeline()`.
|
|
194
|
+
|
|
195
|
+
### `deresolvePipeline(config: PipelineConfig, workDir: string): RawPipelineConfig`
|
|
196
|
+
|
|
197
|
+
Converts a resolved `PipelineConfig` back to a `RawPipelineConfig` suitable for serialization. Strips injected defaults and converts absolute `cwd` paths back to relative so the output YAML is portable across machines.
|
|
198
|
+
|
|
199
|
+
Use this when you have a programmatically modified resolved config and need to save it back to YAML:
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
// Correct: load → modify resolved config → deresolve → save
|
|
203
|
+
const config = await loadPipeline(yaml, workDir);
|
|
204
|
+
const modified = { ...config, name: 'renamed' };
|
|
205
|
+
const savedYaml = serializePipeline(deresolvePipeline(modified, workDir));
|
|
206
|
+
|
|
207
|
+
// Also correct: work entirely in raw space (preferred for visual editors)
|
|
208
|
+
const raw = parseYaml(yaml);
|
|
209
|
+
const updatedRaw = setPipelineField(raw, { name: 'renamed' });
|
|
210
|
+
const savedYaml = serializePipeline(updatedRaw);
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### `validateConfig(config: PipelineConfig): string[]`
|
|
214
|
+
|
|
215
|
+
Validates a resolved pipeline config without executing it. Checks DAG structure (cycles, missing dependencies). Returns an array of error message strings — empty means valid.
|
|
216
|
+
|
|
217
|
+
Use `validateRaw` for editing raw configs in a UI; use `validateConfig` after `resolveConfig` for a final pre-run check.
|
|
218
|
+
|
|
219
|
+
### `validateRaw(config: RawPipelineConfig): ValidationError[]`
|
|
220
|
+
|
|
221
|
+
Validates a raw pipeline config without resolving inheritance or executing anything. Returns a flat list of `{ path, message }` objects — empty array means valid.
|
|
222
|
+
|
|
223
|
+
Checks: required fields, `prompt`/`command` exclusivity, `depends_on`/`continue_from` reference integrity, circular dependency detection.
|
|
224
|
+
|
|
225
|
+
Does **not** check plugin registration (plugins may not be loaded at edit time).
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
const errors = validateRaw(draftConfig);
|
|
229
|
+
if (errors.length > 0) {
|
|
230
|
+
errors.forEach(e => highlightNode(e.path, e.message));
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
128
234
|
## Related Packages
|
|
129
235
|
|
|
130
236
|
| Package | Description |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tagma/sdk",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"workspaces": [
|
|
6
6
|
"plugins/*"
|
|
@@ -20,13 +20,13 @@
|
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"js-yaml": "^4.1.0",
|
|
22
22
|
"chokidar": "^4.0.0",
|
|
23
|
-
"@tagma/types": "
|
|
23
|
+
"@tagma/types": "0.1.3"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/js-yaml": "^4.0.9",
|
|
27
27
|
"bun-types": "latest",
|
|
28
28
|
"typescript": "^6.0.2",
|
|
29
|
-
"@tagma/driver-codex": "
|
|
30
|
-
"@tagma/driver-opencode": "
|
|
29
|
+
"@tagma/driver-codex": "0.1.3",
|
|
30
|
+
"@tagma/driver-opencode": "0.1.3"
|
|
31
31
|
}
|
|
32
32
|
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// ═══ RawPipelineConfig CRUD Operations ═══
|
|
2
|
+
//
|
|
3
|
+
// Pure, immutable helper functions for building and editing pipeline configs
|
|
4
|
+
// in a visual editor. None of these functions have runtime dependencies —
|
|
5
|
+
// safe to import in any context (sidecar, renderer, tests).
|
|
6
|
+
//
|
|
7
|
+
// All operations return a new config object; inputs are never mutated.
|
|
8
|
+
|
|
9
|
+
import type { RawPipelineConfig, RawTrackConfig, RawTaskConfig } from './types';
|
|
10
|
+
|
|
11
|
+
// ── Pipeline ──
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Create a minimal empty pipeline config.
|
|
15
|
+
*/
|
|
16
|
+
export function createEmptyPipeline(name: string): RawPipelineConfig {
|
|
17
|
+
return { name, tracks: [] };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Update a top-level pipeline field (name, driver, timeout, etc.).
|
|
22
|
+
*/
|
|
23
|
+
export function setPipelineField(
|
|
24
|
+
config: RawPipelineConfig,
|
|
25
|
+
fields: Partial<Omit<RawPipelineConfig, 'tracks'>>,
|
|
26
|
+
): RawPipelineConfig {
|
|
27
|
+
return { ...config, ...fields };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Tracks ──
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Insert or replace a track by id. Appends if the id is new.
|
|
34
|
+
*/
|
|
35
|
+
export function upsertTrack(
|
|
36
|
+
config: RawPipelineConfig,
|
|
37
|
+
track: RawTrackConfig,
|
|
38
|
+
): RawPipelineConfig {
|
|
39
|
+
const exists = config.tracks.some(t => t.id === track.id);
|
|
40
|
+
return {
|
|
41
|
+
...config,
|
|
42
|
+
tracks: exists
|
|
43
|
+
? config.tracks.map(t => (t.id === track.id ? track : t))
|
|
44
|
+
: [...config.tracks, track],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Remove a track by id. No-op if the id is not found.
|
|
50
|
+
*/
|
|
51
|
+
export function removeTrack(
|
|
52
|
+
config: RawPipelineConfig,
|
|
53
|
+
trackId: string,
|
|
54
|
+
): RawPipelineConfig {
|
|
55
|
+
return { ...config, tracks: config.tracks.filter(t => t.id !== trackId) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Move a track to a new index position (0-based).
|
|
60
|
+
* Clamps toIndex to valid bounds.
|
|
61
|
+
*/
|
|
62
|
+
export function moveTrack(
|
|
63
|
+
config: RawPipelineConfig,
|
|
64
|
+
trackId: string,
|
|
65
|
+
toIndex: number,
|
|
66
|
+
): RawPipelineConfig {
|
|
67
|
+
const idx = config.tracks.findIndex(t => t.id === trackId);
|
|
68
|
+
if (idx === -1) return config;
|
|
69
|
+
const tracks = [...config.tracks];
|
|
70
|
+
const [track] = tracks.splice(idx, 1);
|
|
71
|
+
const clamped = Math.max(0, Math.min(toIndex, tracks.length));
|
|
72
|
+
tracks.splice(clamped, 0, track);
|
|
73
|
+
return { ...config, tracks };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Update fields on a single track (excluding tasks list, use upsertTask / removeTask for that).
|
|
78
|
+
*/
|
|
79
|
+
export function updateTrack(
|
|
80
|
+
config: RawPipelineConfig,
|
|
81
|
+
trackId: string,
|
|
82
|
+
fields: Partial<Omit<RawTrackConfig, 'id' | 'tasks'>>,
|
|
83
|
+
): RawPipelineConfig {
|
|
84
|
+
return {
|
|
85
|
+
...config,
|
|
86
|
+
tracks: config.tracks.map(t =>
|
|
87
|
+
t.id === trackId ? { ...t, ...fields } : t,
|
|
88
|
+
),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Tasks ──
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Insert or replace a task within a track, matched by task.id. Appends if new.
|
|
96
|
+
* No-op if the trackId is not found.
|
|
97
|
+
*/
|
|
98
|
+
export function upsertTask(
|
|
99
|
+
config: RawPipelineConfig,
|
|
100
|
+
trackId: string,
|
|
101
|
+
task: RawTaskConfig,
|
|
102
|
+
): RawPipelineConfig {
|
|
103
|
+
return {
|
|
104
|
+
...config,
|
|
105
|
+
tracks: config.tracks.map(t => {
|
|
106
|
+
if (t.id !== trackId) return t;
|
|
107
|
+
const exists = t.tasks.some(tk => tk.id === task.id);
|
|
108
|
+
return {
|
|
109
|
+
...t,
|
|
110
|
+
tasks: exists
|
|
111
|
+
? t.tasks.map(tk => (tk.id === task.id ? task : tk))
|
|
112
|
+
: [...t.tasks, task],
|
|
113
|
+
};
|
|
114
|
+
}),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Remove a task from a track. No-op if either id is not found.
|
|
120
|
+
*/
|
|
121
|
+
export function removeTask(
|
|
122
|
+
config: RawPipelineConfig,
|
|
123
|
+
trackId: string,
|
|
124
|
+
taskId: string,
|
|
125
|
+
): RawPipelineConfig {
|
|
126
|
+
return {
|
|
127
|
+
...config,
|
|
128
|
+
tracks: config.tracks.map(t => {
|
|
129
|
+
if (t.id !== trackId) return t;
|
|
130
|
+
return { ...t, tasks: t.tasks.filter(tk => tk.id !== taskId) };
|
|
131
|
+
}),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Reorder a task within its track.
|
|
137
|
+
* Clamps toIndex to valid bounds.
|
|
138
|
+
*/
|
|
139
|
+
export function moveTask(
|
|
140
|
+
config: RawPipelineConfig,
|
|
141
|
+
trackId: string,
|
|
142
|
+
taskId: string,
|
|
143
|
+
toIndex: number,
|
|
144
|
+
): RawPipelineConfig {
|
|
145
|
+
return {
|
|
146
|
+
...config,
|
|
147
|
+
tracks: config.tracks.map(t => {
|
|
148
|
+
if (t.id !== trackId) return t;
|
|
149
|
+
const idx = t.tasks.findIndex(tk => tk.id === taskId);
|
|
150
|
+
if (idx === -1) return t;
|
|
151
|
+
const tasks = [...t.tasks];
|
|
152
|
+
const [task] = tasks.splice(idx, 1);
|
|
153
|
+
const clamped = Math.max(0, Math.min(toIndex, tasks.length));
|
|
154
|
+
tasks.splice(clamped, 0, task);
|
|
155
|
+
return { ...t, tasks };
|
|
156
|
+
}),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Move a task from one track to another (appends to the target track).
|
|
162
|
+
* No-op if either trackId or taskId is not found.
|
|
163
|
+
*/
|
|
164
|
+
export function transferTask(
|
|
165
|
+
config: RawPipelineConfig,
|
|
166
|
+
fromTrackId: string,
|
|
167
|
+
taskId: string,
|
|
168
|
+
toTrackId: string,
|
|
169
|
+
): RawPipelineConfig {
|
|
170
|
+
let task: RawTaskConfig | undefined;
|
|
171
|
+
const afterRemove = {
|
|
172
|
+
...config,
|
|
173
|
+
tracks: config.tracks.map(t => {
|
|
174
|
+
if (t.id !== fromTrackId) return t;
|
|
175
|
+
const found = t.tasks.find(tk => tk.id === taskId);
|
|
176
|
+
if (!found) return t;
|
|
177
|
+
task = found;
|
|
178
|
+
return { ...t, tasks: t.tasks.filter(tk => tk.id !== taskId) };
|
|
179
|
+
}),
|
|
180
|
+
};
|
|
181
|
+
if (!task) return config;
|
|
182
|
+
return upsertTask(afterRemove, toTrackId, task);
|
|
183
|
+
}
|
package/src/engine.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type {
|
|
|
7
7
|
OnFailure,
|
|
8
8
|
} from './types';
|
|
9
9
|
import { buildDag, type Dag, type DagNode } from './dag';
|
|
10
|
-
import { getHandler, hasHandler } from './registry';
|
|
10
|
+
import { getHandler, hasHandler, loadPlugins } from './registry';
|
|
11
11
|
import { runSpawn, runCommand } from './runner';
|
|
12
12
|
import { parseDuration, nowISO, generateRunId } from './utils';
|
|
13
13
|
import {
|
|
@@ -94,6 +94,13 @@ export interface EngineResult {
|
|
|
94
94
|
readonly states: ReadonlyMap<string, TaskState>;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
// ═══ Pipeline Events ═══
|
|
98
|
+
|
|
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 }
|
|
102
|
+
| { readonly type: 'pipeline_end'; readonly runId: string; readonly success: boolean };
|
|
103
|
+
|
|
97
104
|
export interface RunPipelineOptions {
|
|
98
105
|
readonly approvalGateway?: ApprovalGateway;
|
|
99
106
|
/**
|
|
@@ -101,6 +108,16 @@ export interface RunPipelineOptions {
|
|
|
101
108
|
* Oldest directories are deleted after each run. Defaults to 20. Set to 0 to disable cleanup.
|
|
102
109
|
*/
|
|
103
110
|
readonly maxLogRuns?: number;
|
|
111
|
+
/**
|
|
112
|
+
* External AbortSignal — aborting it cancels the pipeline immediately.
|
|
113
|
+
* Equivalent to the pipeline timeout firing, but caller-controlled.
|
|
114
|
+
*/
|
|
115
|
+
readonly signal?: AbortSignal;
|
|
116
|
+
/**
|
|
117
|
+
* Called on every pipeline/task status transition.
|
|
118
|
+
* Use for real-time UI updates (e.g. updating a visual workflow graph).
|
|
119
|
+
*/
|
|
120
|
+
readonly onEvent?: (event: PipelineEvent) => void;
|
|
104
121
|
}
|
|
105
122
|
|
|
106
123
|
export async function runPipeline(
|
|
@@ -110,10 +127,17 @@ export async function runPipeline(
|
|
|
110
127
|
): Promise<EngineResult> {
|
|
111
128
|
const approvalGateway = options.approvalGateway ?? new InMemoryApprovalGateway();
|
|
112
129
|
const maxLogRuns = options.maxLogRuns ?? 20;
|
|
130
|
+
|
|
131
|
+
// Load any plugins declared in the pipeline config before preflight so that
|
|
132
|
+
// drivers, completions, and middlewares referenced in YAML are registered.
|
|
133
|
+
if (config.plugins?.length) {
|
|
134
|
+
await loadPlugins(config.plugins);
|
|
135
|
+
}
|
|
136
|
+
|
|
113
137
|
const dag = buildDag(config);
|
|
138
|
+
const runId = generateRunId();
|
|
114
139
|
preflight(config, dag);
|
|
115
140
|
|
|
116
|
-
const runId = generateRunId();
|
|
117
141
|
const startedAt = nowISO();
|
|
118
142
|
const pipelineInfo: PipelineInfo = { name: config.name, run_id: runId, started_at: startedAt };
|
|
119
143
|
const log = new Logger(workDir, runId);
|
|
@@ -150,6 +174,8 @@ export async function runPipeline(
|
|
|
150
174
|
});
|
|
151
175
|
}
|
|
152
176
|
|
|
177
|
+
try {
|
|
178
|
+
|
|
153
179
|
// Pipeline start hook (gate)
|
|
154
180
|
const startHook = await executeHook(
|
|
155
181
|
config.hooks, 'pipeline_start', buildPipelineStartContext(pipelineInfo), workDir,
|
|
@@ -172,6 +198,7 @@ export async function runPipeline(
|
|
|
172
198
|
for (const [, state] of states) {
|
|
173
199
|
state.status = 'waiting';
|
|
174
200
|
}
|
|
201
|
+
options.onEvent?.({ type: 'pipeline_start', runId });
|
|
175
202
|
|
|
176
203
|
const sessionMap = new Map<string, string>();
|
|
177
204
|
const outputMap = new Map<string, string>();
|
|
@@ -196,8 +223,32 @@ export async function runPipeline(
|
|
|
196
223
|
approvalGateway.abortAll('pipeline aborted');
|
|
197
224
|
});
|
|
198
225
|
|
|
226
|
+
// Wire external cancel signal into the internal abort controller.
|
|
227
|
+
if (options.signal) {
|
|
228
|
+
if (options.signal.aborted) {
|
|
229
|
+
pipelineAborted = true;
|
|
230
|
+
abortController.abort();
|
|
231
|
+
} else {
|
|
232
|
+
options.signal.addEventListener('abort', () => {
|
|
233
|
+
pipelineAborted = true;
|
|
234
|
+
abortController.abort();
|
|
235
|
+
}, { once: true });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
199
239
|
// ── Helpers ──
|
|
200
240
|
|
|
241
|
+
function emit(event: PipelineEvent): void {
|
|
242
|
+
options.onEvent?.(event);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function setTaskStatus(taskId: string, newStatus: TaskStatus): void {
|
|
246
|
+
const state = states.get(taskId)!;
|
|
247
|
+
const prevStatus = state.status;
|
|
248
|
+
state.status = newStatus;
|
|
249
|
+
emit({ type: 'task_status_change', taskId, status: newStatus, prevStatus, runId });
|
|
250
|
+
}
|
|
251
|
+
|
|
201
252
|
function getOnFailure(taskId: string): OnFailure {
|
|
202
253
|
return dag.nodes.get(taskId)?.track.on_failure ?? 'skip_downstream';
|
|
203
254
|
}
|
|
@@ -215,10 +266,9 @@ export async function runPipeline(
|
|
|
215
266
|
}
|
|
216
267
|
|
|
217
268
|
function applyStopAll(trackId: string): void {
|
|
218
|
-
for (const [, state] of states) {
|
|
219
|
-
const node = dag.nodes.get(state.config.id);
|
|
269
|
+
for (const [id, state] of states) {
|
|
220
270
|
if (state.trackConfig.id === trackId && !isTerminal(state.status)) {
|
|
221
|
-
|
|
271
|
+
setTaskStatus(id, 'skipped');
|
|
222
272
|
state.finishedAt = nowISO();
|
|
223
273
|
}
|
|
224
274
|
}
|
|
@@ -269,7 +319,7 @@ export async function runPipeline(
|
|
|
269
319
|
if (result === 'skip') {
|
|
270
320
|
const depStatus = states.get(depId)?.status ?? 'unknown';
|
|
271
321
|
log.debug(`[task:${taskId}]`, `skipped (upstream "${depId}" status=${depStatus})`);
|
|
272
|
-
|
|
322
|
+
setTaskStatus(taskId, 'skipped');
|
|
273
323
|
state.finishedAt = nowISO();
|
|
274
324
|
return;
|
|
275
325
|
}
|
|
@@ -294,13 +344,13 @@ export async function runPipeline(
|
|
|
294
344
|
// If pipeline was aborted while we were still waiting for the trigger,
|
|
295
345
|
// this task never entered running state → skipped, not timeout.
|
|
296
346
|
if (pipelineAborted) {
|
|
297
|
-
|
|
347
|
+
setTaskStatus(taskId, 'skipped');
|
|
298
348
|
} else if (msg.includes('rejected') || msg.includes('denied')) {
|
|
299
|
-
|
|
349
|
+
setTaskStatus(taskId, 'blocked'); // user/policy rejection
|
|
300
350
|
} else if (msg.includes('timeout')) {
|
|
301
|
-
|
|
351
|
+
setTaskStatus(taskId, 'timeout'); // genuine trigger wait timeout
|
|
302
352
|
} else {
|
|
303
|
-
|
|
353
|
+
setTaskStatus(taskId, 'failed'); // plugin error, watcher crash, etc.
|
|
304
354
|
}
|
|
305
355
|
state.finishedAt = nowISO();
|
|
306
356
|
await fireHook(taskId, 'task_failure');
|
|
@@ -316,14 +366,14 @@ export async function runPipeline(
|
|
|
316
366
|
`task_start hook exit=${hookResult.exitCode} allowed=${hookResult.allowed}`);
|
|
317
367
|
}
|
|
318
368
|
if (!hookResult.allowed) {
|
|
319
|
-
|
|
369
|
+
setTaskStatus(taskId, 'blocked');
|
|
320
370
|
state.finishedAt = nowISO();
|
|
321
371
|
await fireHook(taskId, 'task_failure');
|
|
322
372
|
return;
|
|
323
373
|
}
|
|
324
374
|
|
|
325
375
|
// 4. Mark running
|
|
326
|
-
|
|
376
|
+
setTaskStatus(taskId, 'running');
|
|
327
377
|
state.startedAt = nowISO();
|
|
328
378
|
log.info(`[task:${taskId}]`, task.command ? `running: ${task.command}` : `running (driver task)`);
|
|
329
379
|
|
|
@@ -395,16 +445,16 @@ export async function runPipeline(
|
|
|
395
445
|
|
|
396
446
|
// 5. Determine status
|
|
397
447
|
if (result.exitCode === -1) {
|
|
398
|
-
|
|
448
|
+
setTaskStatus(taskId, 'timeout');
|
|
399
449
|
} else if (result.exitCode !== 0) {
|
|
400
|
-
|
|
450
|
+
setTaskStatus(taskId, 'failed');
|
|
401
451
|
} else if (task.completion) {
|
|
402
452
|
const plugin = getHandler<CompletionPlugin>('completions', task.completion.type);
|
|
403
453
|
const completionCtx = { workDir: task.cwd ?? workDir };
|
|
404
454
|
const passed = await plugin.check(task.completion as Record<string, unknown>, result, completionCtx);
|
|
405
|
-
|
|
455
|
+
setTaskStatus(taskId, passed ? 'success' : 'failed');
|
|
406
456
|
} else {
|
|
407
|
-
|
|
457
|
+
setTaskStatus(taskId, 'success');
|
|
408
458
|
}
|
|
409
459
|
|
|
410
460
|
// 6. Write output file with RAW stdout (preserves driver output format).
|
|
@@ -478,7 +528,7 @@ export async function runPipeline(
|
|
|
478
528
|
}
|
|
479
529
|
|
|
480
530
|
} catch (err: unknown) {
|
|
481
|
-
|
|
531
|
+
setTaskStatus(taskId, 'failed');
|
|
482
532
|
state.finishedAt = nowISO();
|
|
483
533
|
const errMsg = err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
484
534
|
log.error(`[task:${taskId}]`, `failed before execution: ${errMsg}`);
|
|
@@ -502,40 +552,44 @@ export async function runPipeline(
|
|
|
502
552
|
}
|
|
503
553
|
|
|
504
554
|
// ── Event loop ──
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
555
|
+
// Each task is launched as soon as ALL its deps reach a terminal state.
|
|
556
|
+
// We track in-flight tasks in `running` so a task completing mid-batch
|
|
557
|
+
// immediately unblocks its dependents without waiting for sibling tasks.
|
|
558
|
+
const running = new Map<string, Promise<void>>();
|
|
509
559
|
|
|
510
|
-
|
|
511
|
-
|
|
560
|
+
try {
|
|
561
|
+
while (!pipelineAborted) {
|
|
562
|
+
// Launch every task whose deps are all terminal and that isn't already in-flight
|
|
512
563
|
for (const [id, state] of states) {
|
|
513
|
-
if (state.status !== 'waiting') continue;
|
|
564
|
+
if (state.status !== 'waiting' || running.has(id)) continue;
|
|
514
565
|
const node = dag.nodes.get(id)!;
|
|
515
566
|
const allDepsTerminal = node.dependsOn.length === 0 ||
|
|
516
567
|
node.dependsOn.every(d => isTerminal(states.get(d)!.status));
|
|
517
|
-
if (allDepsTerminal)
|
|
568
|
+
if (!allDepsTerminal) continue;
|
|
569
|
+
const p = processTask(id).finally(() => running.delete(id));
|
|
570
|
+
running.set(id, p);
|
|
518
571
|
}
|
|
519
572
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
573
|
+
// All tasks terminal — done
|
|
574
|
+
if ([...states.values()].every(s => isTerminal(s.status))) break;
|
|
575
|
+
|
|
576
|
+
if (running.size === 0) {
|
|
577
|
+
// Nothing in-flight but non-terminal tasks exist (e.g. trigger-wait states
|
|
578
|
+
// that processTask hasn't been called for yet). Poll briefly.
|
|
524
579
|
await new Promise(r => setTimeout(r, 50));
|
|
525
|
-
|
|
526
|
-
|
|
580
|
+
} else {
|
|
581
|
+
// Wait for any one task to finish, then re-scan for new launchables.
|
|
582
|
+
await Promise.race(running.values());
|
|
527
583
|
}
|
|
528
|
-
|
|
529
|
-
// Launch all launchable tasks concurrently
|
|
530
|
-
await Promise.all(launchable.map(id => processTask(id)));
|
|
531
|
-
progress = true;
|
|
532
584
|
}
|
|
533
585
|
|
|
534
586
|
if (pipelineAborted) {
|
|
535
|
-
for
|
|
587
|
+
// Wait for in-flight tasks to honour the abort signal before marking states.
|
|
588
|
+
if (running.size > 0) await Promise.allSettled(running.values());
|
|
589
|
+
for (const [id, state] of states) {
|
|
536
590
|
if (!isTerminal(state.status)) {
|
|
537
591
|
// Running tasks get timeout (they were killed); waiting tasks get skipped
|
|
538
|
-
|
|
592
|
+
setTaskStatus(id, state.status === 'running' ? 'timeout' : 'skipped');
|
|
539
593
|
state.finishedAt = nowISO();
|
|
540
594
|
}
|
|
541
595
|
}
|
|
@@ -597,20 +651,27 @@ export async function runPipeline(
|
|
|
597
651
|
console.log(` Duration: ${(durationMs / 1000).toFixed(1)}s`);
|
|
598
652
|
console.log(` Log: ${log.path}`);
|
|
599
653
|
|
|
600
|
-
|
|
601
|
-
if (maxLogRuns > 0) {
|
|
602
|
-
await pruneLogDirs(resolve(workDir, 'logs'), maxLogRuns);
|
|
603
|
-
}
|
|
604
|
-
|
|
654
|
+
emit({ type: 'pipeline_end', runId, success: allSuccess });
|
|
605
655
|
return { success: allSuccess, runId, logPath: log.path, summary, states };
|
|
656
|
+
|
|
657
|
+
} finally {
|
|
658
|
+
// Prune old per-run log directories on every exit path (normal, blocked, or thrown).
|
|
659
|
+
// Exclude the current runId so a concurrent run cannot delete its own live directory.
|
|
660
|
+
if (maxLogRuns > 0) {
|
|
661
|
+
await pruneLogDirs(resolve(workDir, 'logs'), maxLogRuns, runId);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
606
664
|
}
|
|
607
665
|
|
|
608
666
|
/**
|
|
609
667
|
* Delete the oldest subdirectories under `logsDir`, keeping only the most recent `keep`.
|
|
610
668
|
* Directories are sorted lexicographically; because runIds are prefixed with a base-36
|
|
611
669
|
* timestamp, lexicographic order equals chronological order.
|
|
670
|
+
*
|
|
671
|
+
* `excludeRunId` is always skipped from deletion even if it would otherwise be pruned —
|
|
672
|
+
* this prevents a concurrent run from removing a live log directory that is still in use.
|
|
612
673
|
*/
|
|
613
|
-
async function pruneLogDirs(logsDir: string, keep: number): Promise<void> {
|
|
674
|
+
async function pruneLogDirs(logsDir: string, keep: number, excludeRunId: string): Promise<void> {
|
|
614
675
|
let entries: string[];
|
|
615
676
|
try {
|
|
616
677
|
entries = await readdir(logsDir);
|
|
@@ -618,8 +679,8 @@ async function pruneLogDirs(logsDir: string, keep: number): Promise<void> {
|
|
|
618
679
|
return; // logsDir doesn't exist yet — nothing to prune
|
|
619
680
|
}
|
|
620
681
|
|
|
621
|
-
// Only consider directories that look like run IDs (run_<...>)
|
|
622
|
-
const runDirs = entries.filter(e => e.startsWith('run_')).sort();
|
|
682
|
+
// Only consider directories that look like run IDs (run_<...>), excluding the live run.
|
|
683
|
+
const runDirs = entries.filter(e => e.startsWith('run_') && e !== excludeRunId).sort();
|
|
623
684
|
const toDelete = runDirs.slice(0, Math.max(0, runDirs.length - keep));
|
|
624
685
|
|
|
625
686
|
await Promise.all(
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// ═══ PipelineRunner ═══
|
|
2
|
+
//
|
|
3
|
+
// Wraps runPipeline in a lifecycle object suited for multi-pipeline management
|
|
4
|
+
// in sidecar / Tauri IPC scenarios. Each instance controls one pipeline run.
|
|
5
|
+
//
|
|
6
|
+
// Typical sidecar usage:
|
|
7
|
+
//
|
|
8
|
+
// const runners = new Map<string, PipelineRunner>();
|
|
9
|
+
//
|
|
10
|
+
// const runner = new PipelineRunner(config, workDir);
|
|
11
|
+
// runner.subscribe(event => ipcEmit('pipeline_event', event));
|
|
12
|
+
// runner.start();
|
|
13
|
+
// runners.set(runner.instanceId, runner);
|
|
14
|
+
//
|
|
15
|
+
// // Later, from IPC:
|
|
16
|
+
// runners.get(id)?.abort();
|
|
17
|
+
|
|
18
|
+
import { runPipeline } from './engine';
|
|
19
|
+
import type { EngineResult, PipelineEvent, RunPipelineOptions } from './engine';
|
|
20
|
+
import type { PipelineConfig, TaskState } from './types';
|
|
21
|
+
import { generateRunId } from './utils';
|
|
22
|
+
|
|
23
|
+
export type { PipelineEvent, EngineResult };
|
|
24
|
+
|
|
25
|
+
export type PipelineRunnerStatus = 'idle' | 'running' | 'done' | 'aborted';
|
|
26
|
+
|
|
27
|
+
export class PipelineRunner {
|
|
28
|
+
/**
|
|
29
|
+
* Stable ID assigned before start() — safe to use as a Map key in the sidecar
|
|
30
|
+
* before the engine-assigned runId becomes available.
|
|
31
|
+
*/
|
|
32
|
+
readonly instanceId: string;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The runId generated by the engine. Available after the first 'pipeline_start'
|
|
36
|
+
* event fires (i.e. effectively immediately after start() is called).
|
|
37
|
+
* null until then.
|
|
38
|
+
*/
|
|
39
|
+
private _runId: string | null = null;
|
|
40
|
+
private _status: PipelineRunnerStatus = 'idle';
|
|
41
|
+
private _result: Promise<EngineResult> | null = null;
|
|
42
|
+
private _abortController = new AbortController();
|
|
43
|
+
private _handlers = new Set<(event: PipelineEvent) => void>();
|
|
44
|
+
private _states: ReadonlyMap<string, TaskState> | null = null;
|
|
45
|
+
|
|
46
|
+
constructor(
|
|
47
|
+
private readonly config: PipelineConfig,
|
|
48
|
+
private readonly workDir: string,
|
|
49
|
+
private readonly opts: Omit<RunPipelineOptions, 'signal' | 'onEvent'> = {},
|
|
50
|
+
) {
|
|
51
|
+
this.instanceId = generateRunId();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
get runId(): string | null { return this._runId; }
|
|
55
|
+
get status(): PipelineRunnerStatus { return this._status; }
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Start the pipeline. Calling start() more than once returns the same Promise.
|
|
59
|
+
*/
|
|
60
|
+
start(): Promise<EngineResult> {
|
|
61
|
+
if (this._result) return this._result;
|
|
62
|
+
|
|
63
|
+
this._status = 'running';
|
|
64
|
+
this._result = runPipeline(this.config, this.workDir, {
|
|
65
|
+
...this.opts,
|
|
66
|
+
signal: this._abortController.signal,
|
|
67
|
+
onEvent: (event) => {
|
|
68
|
+
if (event.type === 'pipeline_start') {
|
|
69
|
+
this._runId = event.runId;
|
|
70
|
+
}
|
|
71
|
+
if (event.type === 'pipeline_end') {
|
|
72
|
+
this._status = this._abortController.signal.aborted ? 'aborted' : 'done';
|
|
73
|
+
}
|
|
74
|
+
for (const h of this._handlers) h(event);
|
|
75
|
+
},
|
|
76
|
+
}).then(result => {
|
|
77
|
+
this._states = result.states;
|
|
78
|
+
if (this._status === 'running') this._status = 'done';
|
|
79
|
+
return result;
|
|
80
|
+
}).catch(err => {
|
|
81
|
+
this._status = 'aborted';
|
|
82
|
+
throw err;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return this._result;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Cancel the running pipeline. Safe to call multiple times or before start().
|
|
90
|
+
*/
|
|
91
|
+
abort(reason?: string): void {
|
|
92
|
+
this._status = 'aborted';
|
|
93
|
+
this._abortController.abort(reason);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Snapshot of task states. Populated after the run completes.
|
|
98
|
+
* During a run, listen to subscribe() events for incremental updates.
|
|
99
|
+
*/
|
|
100
|
+
getStates(): ReadonlyMap<string, TaskState> | null {
|
|
101
|
+
return this._states;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Subscribe to pipeline/task events. Returns an unsubscribe function.
|
|
106
|
+
* Events are emitted synchronously in the engine's event loop, so keep
|
|
107
|
+
* handlers non-blocking (e.g. queue to IPC, do not await inside).
|
|
108
|
+
*/
|
|
109
|
+
subscribe(handler: (event: PipelineEvent) => void): () => void {
|
|
110
|
+
this._handlers.add(handler);
|
|
111
|
+
return () => this._handlers.delete(handler);
|
|
112
|
+
}
|
|
113
|
+
}
|
package/src/registry.ts
CHANGED
|
@@ -16,9 +16,7 @@ export function registerPlugin<T extends PluginType>(
|
|
|
16
16
|
category: PluginCategory, type: string, handler: T,
|
|
17
17
|
): void {
|
|
18
18
|
const registry = registries[category] as Map<string, T>;
|
|
19
|
-
if (registry.has(type))
|
|
20
|
-
throw new Error(`${category} type "${type}" is already registered`);
|
|
21
|
-
}
|
|
19
|
+
if (registry.has(type)) return; // idempotent — skip duplicate registration
|
|
22
20
|
registry.set(type, handler);
|
|
23
21
|
}
|
|
24
22
|
|
package/src/runner.ts
CHANGED
|
@@ -151,9 +151,11 @@ export async function runSpawn(
|
|
|
151
151
|
|
|
152
152
|
const durationMs = elapsed();
|
|
153
153
|
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
154
|
+
// We initiated the kill (timeout or abort) — always treat as non-success
|
|
155
|
+
// regardless of exit code. A process that catches SIGTERM and exits 0 still
|
|
156
|
+
// hit the timeout; letting it pass as success would unblock downstream tasks
|
|
157
|
+
// incorrectly.
|
|
158
|
+
if (killedByUs) {
|
|
157
159
|
return {
|
|
158
160
|
exitCode: -1,
|
|
159
161
|
stdout,
|
package/src/schema.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import yaml from 'js-yaml';
|
|
2
|
-
import { resolve } from 'path';
|
|
2
|
+
import { resolve, relative } from 'path';
|
|
3
3
|
import type {
|
|
4
4
|
PipelineConfig, RawPipelineConfig, RawTrackConfig, RawTaskConfig,
|
|
5
5
|
TrackConfig, TaskConfig, Permissions, MiddlewareConfig,
|
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
} from './types';
|
|
8
8
|
import { truncateForName, validatePathParam } from './utils';
|
|
9
9
|
import { DEFAULT_PERMISSIONS } from './types';
|
|
10
|
+
import { buildDag } from './dag';
|
|
10
11
|
|
|
11
12
|
// ═══ YAML Parsing ═══
|
|
12
13
|
|
|
@@ -243,6 +244,103 @@ export function resolveConfig(raw: RawPipelineConfig, workDir: string): Pipeline
|
|
|
243
244
|
};
|
|
244
245
|
}
|
|
245
246
|
|
|
247
|
+
// ═══ YAML Serialization ═══
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Serialize a pipeline config back to YAML string.
|
|
251
|
+
* Wraps the config under the top-level `pipeline` key as expected by parseYaml.
|
|
252
|
+
*/
|
|
253
|
+
export function serializePipeline(config: PipelineConfig | RawPipelineConfig): string {
|
|
254
|
+
return yaml.dump({ pipeline: config }, { lineWidth: 120, indent: 2 });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Convert a resolved PipelineConfig back to a RawPipelineConfig for serialization.
|
|
259
|
+
* Strips injected defaults and converts absolute cwd paths back to relative so the
|
|
260
|
+
* resulting YAML is portable across machines.
|
|
261
|
+
*
|
|
262
|
+
* Use this when you need to save a config that was previously loaded via
|
|
263
|
+
* loadPipeline(). For a pure load→edit→save cycle on raw YAML, prefer
|
|
264
|
+
* parseYaml() → edit RawPipelineConfig → serializePipeline().
|
|
265
|
+
*/
|
|
266
|
+
export function deresolvePipeline(config: PipelineConfig, workDir: string): RawPipelineConfig {
|
|
267
|
+
const tracks: RawTrackConfig[] = config.tracks.map(track => {
|
|
268
|
+
const trackCwdRel = track.cwd && track.cwd !== workDir
|
|
269
|
+
? relative(workDir, track.cwd)
|
|
270
|
+
: undefined;
|
|
271
|
+
const effectiveTrackDriver = track.driver ?? config.driver ?? 'claude-code';
|
|
272
|
+
|
|
273
|
+
const tasks: RawTaskConfig[] = track.tasks.map(task => {
|
|
274
|
+
const taskCwdRel = task.cwd && task.cwd !== track.cwd
|
|
275
|
+
? relative(workDir, task.cwd)
|
|
276
|
+
: undefined;
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
id: task.id,
|
|
280
|
+
...(task.name ? { name: task.name } : {}),
|
|
281
|
+
...(task.prompt !== undefined ? { prompt: task.prompt } : {}),
|
|
282
|
+
...(task.command !== undefined ? { command: task.command } : {}),
|
|
283
|
+
...(task.depends_on?.length ? { depends_on: task.depends_on } : {}),
|
|
284
|
+
...(task.trigger ? { trigger: task.trigger } : {}),
|
|
285
|
+
...(task.continue_from ? { continue_from: task.continue_from } : {}),
|
|
286
|
+
...(task.output ? { output: task.output } : {}),
|
|
287
|
+
...(taskCwdRel ? { cwd: taskCwdRel } : {}),
|
|
288
|
+
...(task.model_tier && task.model_tier !== 'medium' ? { model_tier: task.model_tier } : {}),
|
|
289
|
+
...(task.driver && task.driver !== effectiveTrackDriver ? { driver: task.driver } : {}),
|
|
290
|
+
...(task.timeout ? { timeout: task.timeout } : {}),
|
|
291
|
+
...(task.middlewares !== undefined ? { middlewares: task.middlewares } : {}),
|
|
292
|
+
...(task.completion ? { completion: task.completion } : {}),
|
|
293
|
+
...(task.agent_profile ? { agent_profile: task.agent_profile } : {}),
|
|
294
|
+
...(task.permissions && JSON.stringify(task.permissions) !== JSON.stringify(DEFAULT_PERMISSIONS)
|
|
295
|
+
? { permissions: task.permissions }
|
|
296
|
+
: {}),
|
|
297
|
+
};
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
id: track.id,
|
|
302
|
+
name: track.name,
|
|
303
|
+
...(track.color ? { color: track.color } : {}),
|
|
304
|
+
...(track.agent_profile ? { agent_profile: track.agent_profile } : {}),
|
|
305
|
+
...(track.model_tier && track.model_tier !== 'medium' ? { model_tier: track.model_tier } : {}),
|
|
306
|
+
...(track.driver && track.driver !== (config.driver ?? 'claude-code') ? { driver: track.driver } : {}),
|
|
307
|
+
...(trackCwdRel ? { cwd: trackCwdRel } : {}),
|
|
308
|
+
...(track.middlewares?.length ? { middlewares: track.middlewares } : {}),
|
|
309
|
+
...(track.on_failure && track.on_failure !== 'skip_downstream' ? { on_failure: track.on_failure } : {}),
|
|
310
|
+
...(track.permissions && JSON.stringify(track.permissions) !== JSON.stringify(DEFAULT_PERMISSIONS)
|
|
311
|
+
? { permissions: track.permissions }
|
|
312
|
+
: {}),
|
|
313
|
+
tasks,
|
|
314
|
+
};
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
name: config.name,
|
|
319
|
+
...(config.driver ? { driver: config.driver } : {}),
|
|
320
|
+
...(config.timeout ? { timeout: config.timeout } : {}),
|
|
321
|
+
...(config.plugins?.length ? { plugins: config.plugins } : {}),
|
|
322
|
+
...(config.hooks ? { hooks: config.hooks } : {}),
|
|
323
|
+
tracks,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ═══ Offline Validation ═══
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Validate a pipeline config without executing it.
|
|
331
|
+
* Only checks structural/DAG correctness — does not check plugin registration.
|
|
332
|
+
* Returns an array of error messages (empty = valid).
|
|
333
|
+
*/
|
|
334
|
+
export function validateConfig(config: PipelineConfig): string[] {
|
|
335
|
+
const errors: string[] = [];
|
|
336
|
+
try {
|
|
337
|
+
buildDag(config);
|
|
338
|
+
} catch (err) {
|
|
339
|
+
errors.push(err instanceof Error ? err.message : String(err));
|
|
340
|
+
}
|
|
341
|
+
return errors;
|
|
342
|
+
}
|
|
343
|
+
|
|
246
344
|
// ═══ Full Parse Pipeline ═══
|
|
247
345
|
|
|
248
346
|
export async function loadPipeline(yamlContent: string, workDir: string): Promise<PipelineConfig> {
|
package/src/sdk.ts
CHANGED
|
@@ -5,10 +5,32 @@
|
|
|
5
5
|
|
|
6
6
|
// ── Core engine ──
|
|
7
7
|
export { runPipeline } from './engine';
|
|
8
|
-
export type { EngineResult, RunPipelineOptions } from './engine';
|
|
8
|
+
export type { EngineResult, RunPipelineOptions, PipelineEvent } from './engine';
|
|
9
9
|
|
|
10
|
-
// ──
|
|
11
|
-
export {
|
|
10
|
+
// ── Pipeline runner (multi-pipeline lifecycle management) ──
|
|
11
|
+
export { PipelineRunner } from './pipeline-runner';
|
|
12
|
+
export type { PipelineRunnerStatus } from './pipeline-runner';
|
|
13
|
+
|
|
14
|
+
// ── Raw config CRUD (visual editor / YAML sync) ──
|
|
15
|
+
export {
|
|
16
|
+
createEmptyPipeline,
|
|
17
|
+
setPipelineField,
|
|
18
|
+
upsertTrack,
|
|
19
|
+
removeTrack,
|
|
20
|
+
moveTrack,
|
|
21
|
+
updateTrack,
|
|
22
|
+
upsertTask,
|
|
23
|
+
removeTask,
|
|
24
|
+
moveTask,
|
|
25
|
+
transferTask,
|
|
26
|
+
} from './config-ops';
|
|
27
|
+
|
|
28
|
+
// ── Raw config validation (real-time feedback) ──
|
|
29
|
+
export { validateRaw } from './validate-raw';
|
|
30
|
+
export type { ValidationError } from './validate-raw';
|
|
31
|
+
|
|
32
|
+
// ── Schema: parse / resolve / load / serialize / validate ──
|
|
33
|
+
export { parseYaml, resolveConfig, expandTemplates, loadPipeline, serializePipeline, deresolvePipeline, validateConfig } from './schema';
|
|
12
34
|
|
|
13
35
|
// ── DAG ──
|
|
14
36
|
export { buildDag } from './dag';
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// ═══ Raw Pipeline Config Validation ═══
|
|
2
|
+
//
|
|
3
|
+
// Validates a RawPipelineConfig without resolving inheritance or executing
|
|
4
|
+
// anything — intended for real-time feedback in a visual editor (e.g. drag
|
|
5
|
+
// to add a task, live error highlighting).
|
|
6
|
+
//
|
|
7
|
+
// Returns a flat list of ValidationError objects. An empty array means valid.
|
|
8
|
+
|
|
9
|
+
import type { RawPipelineConfig } from './types';
|
|
10
|
+
|
|
11
|
+
export interface ValidationError {
|
|
12
|
+
/** JSONPath-style location, e.g. "tracks[0].tasks[1].prompt" */
|
|
13
|
+
path: string;
|
|
14
|
+
message: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Validate a raw pipeline config.
|
|
19
|
+
* Checks structure, required fields, prompt/command exclusivity,
|
|
20
|
+
* depends_on reference integrity, and circular dependencies.
|
|
21
|
+
*
|
|
22
|
+
* Does NOT check plugin registration — plugins may not be loaded yet
|
|
23
|
+
* when the frontend is editing a config offline.
|
|
24
|
+
*/
|
|
25
|
+
export function validateRaw(config: RawPipelineConfig): ValidationError[] {
|
|
26
|
+
const errors: ValidationError[] = [];
|
|
27
|
+
|
|
28
|
+
// ── Top level ──
|
|
29
|
+
if (!config.name?.trim()) {
|
|
30
|
+
errors.push({ path: 'name', message: 'Pipeline name is required' });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!config.tracks || config.tracks.length === 0) {
|
|
34
|
+
errors.push({ path: 'tracks', message: 'At least one track is required' });
|
|
35
|
+
return errors; // No point going further without tracks
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Build qualified ID sets for cross-reference checks ──
|
|
39
|
+
// Qualified ID format: "trackId.taskId" (mirrors the engine's convention)
|
|
40
|
+
const allQualified = new Set<string>();
|
|
41
|
+
// For bare depends_on references: bare taskId → first qualified ID found
|
|
42
|
+
const bareToQualified = new Map<string, string>();
|
|
43
|
+
|
|
44
|
+
for (const track of config.tracks) {
|
|
45
|
+
if (!track.id) continue;
|
|
46
|
+
for (const task of track.tasks ?? []) {
|
|
47
|
+
if (!task.id) continue;
|
|
48
|
+
const qid = `${track.id}.${task.id}`;
|
|
49
|
+
allQualified.add(qid);
|
|
50
|
+
if (!bareToQualified.has(task.id)) {
|
|
51
|
+
bareToQualified.set(task.id, qid);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Per-track validation ──
|
|
57
|
+
for (let ti = 0; ti < config.tracks.length; ti++) {
|
|
58
|
+
const track = config.tracks[ti];
|
|
59
|
+
const trackPath = `tracks[${ti}]`;
|
|
60
|
+
|
|
61
|
+
if (!track.id?.trim()) {
|
|
62
|
+
errors.push({ path: `${trackPath}.id`, message: 'Track id is required' });
|
|
63
|
+
}
|
|
64
|
+
if (!track.name?.trim()) {
|
|
65
|
+
errors.push({ path: `${trackPath}.name`, message: 'Track name is required' });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!track.tasks || track.tasks.length === 0) {
|
|
69
|
+
errors.push({ path: `${trackPath}.tasks`, message: `Track "${track.id || ti}": must have at least one task` });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Per-task validation ──
|
|
74
|
+
for (let ki = 0; ki < track.tasks.length; ki++) {
|
|
75
|
+
const task = track.tasks[ki];
|
|
76
|
+
const taskPath = `${trackPath}.tasks[${ki}]`;
|
|
77
|
+
|
|
78
|
+
if (!task.id?.trim()) {
|
|
79
|
+
errors.push({ path: `${taskPath}.id`, message: 'Task id is required' });
|
|
80
|
+
continue; // Can't check further without an id
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Template-based tasks: skip prompt/command checks (params validated at runtime)
|
|
84
|
+
if (task.use) continue;
|
|
85
|
+
|
|
86
|
+
const hasPrompt = typeof task.prompt === 'string' && task.prompt.trim().length > 0;
|
|
87
|
+
const hasCommand = typeof task.command === 'string' && task.command.trim().length > 0;
|
|
88
|
+
|
|
89
|
+
if (!hasPrompt && !hasCommand) {
|
|
90
|
+
errors.push({
|
|
91
|
+
path: taskPath,
|
|
92
|
+
message: `Task "${task.id}": must have "prompt" or "command"`,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
if (hasPrompt && hasCommand) {
|
|
96
|
+
errors.push({
|
|
97
|
+
path: taskPath,
|
|
98
|
+
message: `Task "${task.id}": cannot have both "prompt" and "command"`,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── depends_on reference checks ──
|
|
103
|
+
if (task.depends_on && task.depends_on.length > 0) {
|
|
104
|
+
for (const dep of task.depends_on) {
|
|
105
|
+
const resolved = resolveDepRef(dep, track.id, allQualified, bareToQualified);
|
|
106
|
+
if (!resolved) {
|
|
107
|
+
errors.push({
|
|
108
|
+
path: `${taskPath}.depends_on`,
|
|
109
|
+
message: `Task "${task.id}": depends_on "${dep}" — no such task found`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── continue_from reference check ──
|
|
116
|
+
if (task.continue_from) {
|
|
117
|
+
const resolved = resolveDepRef(task.continue_from, track.id, allQualified, bareToQualified);
|
|
118
|
+
if (!resolved) {
|
|
119
|
+
errors.push({
|
|
120
|
+
path: `${taskPath}.continue_from`,
|
|
121
|
+
message: `Task "${task.id}": continue_from "${task.continue_from}" — no such task found`,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Cycle detection ──
|
|
129
|
+
errors.push(...detectCycles(config, allQualified, bareToQualified));
|
|
130
|
+
|
|
131
|
+
return errors;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Helpers ──
|
|
135
|
+
|
|
136
|
+
function resolveDepRef(
|
|
137
|
+
ref: string,
|
|
138
|
+
fromTrackId: string,
|
|
139
|
+
allQualified: Set<string>,
|
|
140
|
+
bareToQualified: Map<string, string>,
|
|
141
|
+
): string | null {
|
|
142
|
+
// Fully qualified reference (trackId.taskId)
|
|
143
|
+
if (allQualified.has(ref)) return ref;
|
|
144
|
+
// Same-track shorthand (just taskId)
|
|
145
|
+
const sameTrack = `${fromTrackId}.${ref}`;
|
|
146
|
+
if (allQualified.has(sameTrack)) return sameTrack;
|
|
147
|
+
// Global bare lookup (first match across all tracks)
|
|
148
|
+
return bareToQualified.get(ref) ?? null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function detectCycles(
|
|
152
|
+
config: RawPipelineConfig,
|
|
153
|
+
allQualified: Set<string>,
|
|
154
|
+
bareToQualified: Map<string, string>,
|
|
155
|
+
): ValidationError[] {
|
|
156
|
+
// Build adjacency: qualifiedId → [resolved dep qualifiedIds]
|
|
157
|
+
const adj = new Map<string, string[]>();
|
|
158
|
+
|
|
159
|
+
for (const track of config.tracks) {
|
|
160
|
+
if (!track.id) continue;
|
|
161
|
+
for (const task of track.tasks ?? []) {
|
|
162
|
+
if (!task.id || task.use) continue;
|
|
163
|
+
const qid = `${track.id}.${task.id}`;
|
|
164
|
+
const deps: string[] = [];
|
|
165
|
+
for (const dep of task.depends_on ?? []) {
|
|
166
|
+
const resolved = resolveDepRef(dep, track.id, allQualified, bareToQualified);
|
|
167
|
+
if (resolved) deps.push(resolved);
|
|
168
|
+
}
|
|
169
|
+
adj.set(qid, deps);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const errors: ValidationError[] = [];
|
|
174
|
+
const visited = new Set<string>();
|
|
175
|
+
const inStack = new Set<string>();
|
|
176
|
+
|
|
177
|
+
function dfs(id: string, path: string[]): void {
|
|
178
|
+
if (inStack.has(id)) {
|
|
179
|
+
// Trim path to just the cycle portion
|
|
180
|
+
const cycleStart = path.indexOf(id);
|
|
181
|
+
const cycle = [...path.slice(cycleStart), id].join(' → ');
|
|
182
|
+
errors.push({ path: 'tracks', message: `Circular dependency detected: ${cycle}` });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (visited.has(id)) return;
|
|
186
|
+
visited.add(id);
|
|
187
|
+
inStack.add(id);
|
|
188
|
+
for (const dep of adj.get(id) ?? []) {
|
|
189
|
+
dfs(dep, [...path, id]);
|
|
190
|
+
}
|
|
191
|
+
inStack.delete(id);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
for (const id of adj.keys()) {
|
|
195
|
+
if (!visited.has(id)) dfs(id, []);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return errors;
|
|
199
|
+
}
|