@tagma/sdk 0.2.4 → 0.2.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 +68 -6
- package/package.json +1 -1
- package/src/adapters/stdin-approval.ts +106 -117
- package/src/adapters/websocket-approval.ts +1 -3
- package/src/approval.ts +1 -6
- package/src/completions/exit-code.ts +30 -19
- package/src/completions/file-exists.ts +60 -39
- package/src/completions/output-check.ts +17 -0
- package/src/dag.ts +222 -222
- package/src/engine.ts +27 -7
- package/src/logger.ts +164 -112
- package/src/middlewares/static-context.ts +16 -0
- package/src/sdk.ts +5 -0
- package/src/templates.ts +97 -0
- package/src/triggers/file.ts +16 -0
- package/src/triggers/manual.ts +72 -61
package/src/dag.ts
CHANGED
|
@@ -1,222 +1,222 @@
|
|
|
1
|
-
import type { PipelineConfig, RawPipelineConfig, RawTaskConfig, TaskConfig, TrackConfig } from './types';
|
|
2
|
-
|
|
3
|
-
export interface DagNode {
|
|
4
|
-
readonly taskId: string; // fully qualified: track_id.task_id or just task_id
|
|
5
|
-
readonly task: TaskConfig;
|
|
6
|
-
readonly track: TrackConfig;
|
|
7
|
-
readonly dependsOn: readonly string[];
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export interface Dag {
|
|
11
|
-
readonly nodes: ReadonlyMap<string, DagNode>;
|
|
12
|
-
readonly sorted: readonly string[]; // topological order
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
// Build a global task ID: for cross-track refs we use "track_id.task_id"
|
|
16
|
-
// Within a track, bare "task_id" is also valid
|
|
17
|
-
function qualifyId(trackId: string, taskId: string): string {
|
|
18
|
-
return `${trackId}.${taskId}`;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function buildDag(config: PipelineConfig): Dag {
|
|
22
|
-
const nodes = new Map<string, DagNode>();
|
|
23
|
-
// Map bare task IDs to qualified IDs (for resolving unqualified refs)
|
|
24
|
-
const bareToQualified = new Map<string, string>();
|
|
25
|
-
|
|
26
|
-
// 1. Register all nodes
|
|
27
|
-
for (const track of config.tracks) {
|
|
28
|
-
for (const task of track.tasks) {
|
|
29
|
-
const qid = qualifyId(track.id, task.id);
|
|
30
|
-
|
|
31
|
-
if (nodes.has(qid)) {
|
|
32
|
-
throw new Error(`Duplicate task ID: "${qid}"`);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// Track bare ID → qualified. If same bare ID in multiple tracks, mark ambiguous
|
|
36
|
-
if (bareToQualified.has(task.id)) {
|
|
37
|
-
bareToQualified.set(task.id, '__ambiguous__');
|
|
38
|
-
} else {
|
|
39
|
-
bareToQualified.set(task.id, qid);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
nodes.set(qid, {
|
|
43
|
-
taskId: qid,
|
|
44
|
-
task,
|
|
45
|
-
track,
|
|
46
|
-
dependsOn: [], // filled below
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// Helper to resolve a dependency ref to a qualified ID
|
|
52
|
-
function resolveRef(ref: string, fromTrackId: string): string {
|
|
53
|
-
// Already qualified (contains dot)
|
|
54
|
-
if (ref.includes('.')) {
|
|
55
|
-
if (!nodes.has(ref)) {
|
|
56
|
-
throw new Error(`Task reference "${ref}" not found`);
|
|
57
|
-
}
|
|
58
|
-
return ref;
|
|
59
|
-
}
|
|
60
|
-
// Try within same track first
|
|
61
|
-
const sameTrack = qualifyId(fromTrackId, ref);
|
|
62
|
-
if (nodes.has(sameTrack)) return sameTrack;
|
|
63
|
-
// Try global bare lookup
|
|
64
|
-
const global = bareToQualified.get(ref);
|
|
65
|
-
if (global && global !== '__ambiguous__') return global;
|
|
66
|
-
if (global === '__ambiguous__') {
|
|
67
|
-
throw new Error(
|
|
68
|
-
`Ambiguous task reference "${ref}" exists in multiple tracks. ` +
|
|
69
|
-
`Use "track_id.task_id" format.`
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
|
-
throw new Error(`Task reference "${ref}" not found`);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// 2. Resolve depends_on and continue_from to qualified IDs
|
|
76
|
-
for (const track of config.tracks) {
|
|
77
|
-
for (const task of track.tasks) {
|
|
78
|
-
const qid = qualifyId(track.id, task.id);
|
|
79
|
-
const deps: string[] = [];
|
|
80
|
-
|
|
81
|
-
if (task.depends_on) {
|
|
82
|
-
for (const dep of task.depends_on) {
|
|
83
|
-
deps.push(resolveRef(dep, track.id));
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
if (task.continue_from) {
|
|
87
|
-
const resolved = resolveRef(task.continue_from, track.id);
|
|
88
|
-
if (!deps.includes(resolved)) {
|
|
89
|
-
deps.push(resolved); // continue_from implies dependency
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// Replace node with resolved deps
|
|
94
|
-
const node = nodes.get(qid)!;
|
|
95
|
-
nodes.set(qid, { ...node, dependsOn: deps });
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// 3. Topological sort + cycle detection (Kahn's algorithm)
|
|
100
|
-
const inDegree = new Map<string, number>();
|
|
101
|
-
const adjacency = new Map<string, string[]>(); // parent → children
|
|
102
|
-
|
|
103
|
-
for (const [id] of nodes) {
|
|
104
|
-
inDegree.set(id, 0);
|
|
105
|
-
adjacency.set(id, []);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
for (const [id, node] of nodes) {
|
|
109
|
-
for (const dep of node.dependsOn) {
|
|
110
|
-
adjacency.get(dep)!.push(id);
|
|
111
|
-
inDegree.set(id, (inDegree.get(id) ?? 0) + 1);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const queue: string[] = [];
|
|
116
|
-
for (const [id, degree] of inDegree) {
|
|
117
|
-
if (degree === 0) queue.push(id);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const sorted: string[] = [];
|
|
121
|
-
while (queue.length > 0) {
|
|
122
|
-
const current = queue.shift()!;
|
|
123
|
-
sorted.push(current);
|
|
124
|
-
for (const child of adjacency.get(current)!) {
|
|
125
|
-
const newDegree = inDegree.get(child)! - 1;
|
|
126
|
-
inDegree.set(child, newDegree);
|
|
127
|
-
if (newDegree === 0) queue.push(child);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
if (sorted.length !== nodes.size) {
|
|
132
|
-
const remaining = [...nodes.keys()].filter(id => !sorted.includes(id));
|
|
133
|
-
throw new Error(`Circular dependency detected involving tasks: ${remaining.join(', ')}`);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
return { nodes, sorted };
|
|
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
|
-
}
|
|
1
|
+
import type { PipelineConfig, RawPipelineConfig, RawTaskConfig, TaskConfig, TrackConfig } from './types';
|
|
2
|
+
|
|
3
|
+
export interface DagNode {
|
|
4
|
+
readonly taskId: string; // fully qualified: track_id.task_id or just task_id
|
|
5
|
+
readonly task: TaskConfig;
|
|
6
|
+
readonly track: TrackConfig;
|
|
7
|
+
readonly dependsOn: readonly string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface Dag {
|
|
11
|
+
readonly nodes: ReadonlyMap<string, DagNode>;
|
|
12
|
+
readonly sorted: readonly string[]; // topological order
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Build a global task ID: for cross-track refs we use "track_id.task_id"
|
|
16
|
+
// Within a track, bare "task_id" is also valid
|
|
17
|
+
function qualifyId(trackId: string, taskId: string): string {
|
|
18
|
+
return `${trackId}.${taskId}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function buildDag(config: PipelineConfig): Dag {
|
|
22
|
+
const nodes = new Map<string, DagNode>();
|
|
23
|
+
// Map bare task IDs to qualified IDs (for resolving unqualified refs)
|
|
24
|
+
const bareToQualified = new Map<string, string>();
|
|
25
|
+
|
|
26
|
+
// 1. Register all nodes
|
|
27
|
+
for (const track of config.tracks) {
|
|
28
|
+
for (const task of track.tasks) {
|
|
29
|
+
const qid = qualifyId(track.id, task.id);
|
|
30
|
+
|
|
31
|
+
if (nodes.has(qid)) {
|
|
32
|
+
throw new Error(`Duplicate task ID: "${qid}"`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Track bare ID → qualified. If same bare ID in multiple tracks, mark ambiguous
|
|
36
|
+
if (bareToQualified.has(task.id)) {
|
|
37
|
+
bareToQualified.set(task.id, '__ambiguous__');
|
|
38
|
+
} else {
|
|
39
|
+
bareToQualified.set(task.id, qid);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
nodes.set(qid, {
|
|
43
|
+
taskId: qid,
|
|
44
|
+
task,
|
|
45
|
+
track,
|
|
46
|
+
dependsOn: [], // filled below
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Helper to resolve a dependency ref to a qualified ID
|
|
52
|
+
function resolveRef(ref: string, fromTrackId: string): string {
|
|
53
|
+
// Already qualified (contains dot)
|
|
54
|
+
if (ref.includes('.')) {
|
|
55
|
+
if (!nodes.has(ref)) {
|
|
56
|
+
throw new Error(`Task reference "${ref}" not found`);
|
|
57
|
+
}
|
|
58
|
+
return ref;
|
|
59
|
+
}
|
|
60
|
+
// Try within same track first
|
|
61
|
+
const sameTrack = qualifyId(fromTrackId, ref);
|
|
62
|
+
if (nodes.has(sameTrack)) return sameTrack;
|
|
63
|
+
// Try global bare lookup
|
|
64
|
+
const global = bareToQualified.get(ref);
|
|
65
|
+
if (global && global !== '__ambiguous__') return global;
|
|
66
|
+
if (global === '__ambiguous__') {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`Ambiguous task reference "${ref}" exists in multiple tracks. ` +
|
|
69
|
+
`Use "track_id.task_id" format.`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
throw new Error(`Task reference "${ref}" not found`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 2. Resolve depends_on and continue_from to qualified IDs
|
|
76
|
+
for (const track of config.tracks) {
|
|
77
|
+
for (const task of track.tasks) {
|
|
78
|
+
const qid = qualifyId(track.id, task.id);
|
|
79
|
+
const deps: string[] = [];
|
|
80
|
+
|
|
81
|
+
if (task.depends_on) {
|
|
82
|
+
for (const dep of task.depends_on) {
|
|
83
|
+
deps.push(resolveRef(dep, track.id));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (task.continue_from) {
|
|
87
|
+
const resolved = resolveRef(task.continue_from, track.id);
|
|
88
|
+
if (!deps.includes(resolved)) {
|
|
89
|
+
deps.push(resolved); // continue_from implies dependency
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Replace node with resolved deps
|
|
94
|
+
const node = nodes.get(qid)!;
|
|
95
|
+
nodes.set(qid, { ...node, dependsOn: deps });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 3. Topological sort + cycle detection (Kahn's algorithm)
|
|
100
|
+
const inDegree = new Map<string, number>();
|
|
101
|
+
const adjacency = new Map<string, string[]>(); // parent → children
|
|
102
|
+
|
|
103
|
+
for (const [id] of nodes) {
|
|
104
|
+
inDegree.set(id, 0);
|
|
105
|
+
adjacency.set(id, []);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const [id, node] of nodes) {
|
|
109
|
+
for (const dep of node.dependsOn) {
|
|
110
|
+
adjacency.get(dep)!.push(id);
|
|
111
|
+
inDegree.set(id, (inDegree.get(id) ?? 0) + 1);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const queue: string[] = [];
|
|
116
|
+
for (const [id, degree] of inDegree) {
|
|
117
|
+
if (degree === 0) queue.push(id);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const sorted: string[] = [];
|
|
121
|
+
while (queue.length > 0) {
|
|
122
|
+
const current = queue.shift()!;
|
|
123
|
+
sorted.push(current);
|
|
124
|
+
for (const child of adjacency.get(current)!) {
|
|
125
|
+
const newDegree = inDegree.get(child)! - 1;
|
|
126
|
+
inDegree.set(child, newDegree);
|
|
127
|
+
if (newDegree === 0) queue.push(child);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (sorted.length !== nodes.size) {
|
|
132
|
+
const remaining = [...nodes.keys()].filter(id => !sorted.includes(id));
|
|
133
|
+
throw new Error(`Circular dependency detected involving tasks: ${remaining.join(', ')}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { nodes, sorted };
|
|
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
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
buildPipelineCompleteContext, buildPipelineErrorContext,
|
|
17
17
|
type PipelineInfo, type TrackInfo, type TaskInfo,
|
|
18
18
|
} from './hooks';
|
|
19
|
-
import { Logger, tailLines, clip } from './logger';
|
|
19
|
+
import { Logger, tailLines, clip, type LogLevel } from './logger';
|
|
20
20
|
import { InMemoryApprovalGateway, type ApprovalGateway } from './approval';
|
|
21
21
|
|
|
22
22
|
// ═══ Preflight Validation ═══
|
|
@@ -115,7 +115,15 @@ export interface EngineResult {
|
|
|
115
115
|
export type PipelineEvent =
|
|
116
116
|
| { readonly type: 'task_status_change'; readonly taskId: string; readonly status: TaskStatus; readonly prevStatus: TaskStatus; readonly runId: string; readonly state: TaskState }
|
|
117
117
|
| { readonly type: 'pipeline_start'; readonly runId: string; readonly states: ReadonlyMap<string, TaskState> }
|
|
118
|
-
| { readonly type: 'pipeline_end'; readonly runId: string; readonly success: boolean }
|
|
118
|
+
| { readonly type: 'pipeline_end'; readonly runId: string; readonly success: boolean }
|
|
119
|
+
/**
|
|
120
|
+
* Fine-grained log line emitted alongside every write to pipeline.log.
|
|
121
|
+
* Consumers use this to stream the full run process into UIs without
|
|
122
|
+
* tailing the log file. `taskId` is non-null for task-scoped lines and
|
|
123
|
+
* null for pipeline-wide messages (e.g. configuration dumps, DAG
|
|
124
|
+
* topology, pipeline start/end).
|
|
125
|
+
*/
|
|
126
|
+
| { readonly type: 'task_log'; readonly runId: string; readonly taskId: string | null; readonly level: LogLevel; readonly timestamp: string; readonly text: string };
|
|
119
127
|
|
|
120
128
|
export interface RunPipelineOptions {
|
|
121
129
|
readonly approvalGateway?: ApprovalGateway;
|
|
@@ -160,7 +168,19 @@ export async function runPipeline(
|
|
|
160
168
|
|
|
161
169
|
const startedAt = nowISO();
|
|
162
170
|
const pipelineInfo: PipelineInfo = { name: config.name, run_id: runId, started_at: startedAt };
|
|
163
|
-
|
|
171
|
+
// Forward every structured log line to subscribers as task_log events.
|
|
172
|
+
// Reading options.onEvent inside the callback (vs. capturing it once) keeps
|
|
173
|
+
// the SDK behavior correct if callers pass a fresh onEvent on each run.
|
|
174
|
+
const log = new Logger(workDir, runId, (record) => {
|
|
175
|
+
options.onEvent?.({
|
|
176
|
+
type: 'task_log',
|
|
177
|
+
runId,
|
|
178
|
+
taskId: record.taskId,
|
|
179
|
+
level: record.level,
|
|
180
|
+
timestamp: record.timestamp,
|
|
181
|
+
text: record.text,
|
|
182
|
+
});
|
|
183
|
+
});
|
|
164
184
|
log.info('[pipeline]', `start "${config.name}" run_id=${runId}`);
|
|
165
185
|
|
|
166
186
|
// File-only: dump the resolved pipeline shape + DAG topology for post-mortem.
|
|
@@ -352,7 +372,7 @@ export async function runPipeline(
|
|
|
352
372
|
const task = node.task;
|
|
353
373
|
const track = node.track;
|
|
354
374
|
|
|
355
|
-
log.section(`Task ${taskId}
|
|
375
|
+
log.section(`Task ${taskId}`, taskId);
|
|
356
376
|
log.debug(`[task:${taskId}]`,
|
|
357
377
|
`type=${task.prompt ? 'ai' : 'cmd'} track=${track.id} deps=[${node.dependsOn.join(', ') || '(root)'}]`);
|
|
358
378
|
|
|
@@ -469,7 +489,7 @@ export async function runPipeline(
|
|
|
469
489
|
}
|
|
470
490
|
log.debug(`[task:${taskId}]`,
|
|
471
491
|
`prompt: ${originalLen} chars (final: ${prompt.length} chars)`);
|
|
472
|
-
log.quiet(`--- prompt (final) ---\n${clip(prompt)}\n--- end prompt
|
|
492
|
+
log.quiet(`--- prompt (final) ---\n${clip(prompt)}\n--- end prompt ---`, taskId);
|
|
473
493
|
|
|
474
494
|
const enrichedTask: TaskConfig = { ...task, prompt };
|
|
475
495
|
const driverCtx: DriverContext = {
|
|
@@ -565,10 +585,10 @@ export async function runPipeline(
|
|
|
565
585
|
log.debug(`[task:${taskId}]`, `wrote stderr: ${result.stderrPath}`);
|
|
566
586
|
}
|
|
567
587
|
if (result.stdout) {
|
|
568
|
-
log.quiet(`--- stdout (${taskId}) ---\n${clip(result.stdout)}\n--- end stdout
|
|
588
|
+
log.quiet(`--- stdout (${taskId}) ---\n${clip(result.stdout)}\n--- end stdout ---`, taskId);
|
|
569
589
|
}
|
|
570
590
|
if (result.stderr) {
|
|
571
|
-
log.quiet(`--- stderr (${taskId}) ---\n${clip(result.stderr)}\n--- end stderr
|
|
591
|
+
log.quiet(`--- stderr (${taskId}) ---\n${clip(result.stderr)}\n--- end stderr ---`, taskId);
|
|
572
592
|
}
|
|
573
593
|
if (task.completion) {
|
|
574
594
|
log.debug(`[task:${taskId}]`,
|