@ryanfw/prompt-orchestration-pipeline 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/core/logger.js +219 -0
- package/src/core/orchestrator.js +37 -24
- package/src/core/pipeline-runner.js +50 -1
- package/src/core/status-writer.js +15 -64
- package/src/core/symlink-utils.js +7 -3
- package/src/core/task-runner.js +45 -2
- package/src/ui/job-reader.js +6 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ryanfw/prompt-orchestration-pipeline",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "A Prompt-orchestration pipeline (POP) is a framework for building, running, and experimenting with complex chains of LLM tasks.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/ui/server.js",
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized logging utility for the prompt orchestration pipeline
|
|
3
|
+
*
|
|
4
|
+
* Provides consistent, structured, and context-aware logging across all core files
|
|
5
|
+
* with SSE integration capabilities and multiple log levels.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Lazy import SSE registry to avoid circular dependencies
|
|
9
|
+
let sseRegistry = null;
|
|
10
|
+
async function getSSERegistry() {
|
|
11
|
+
if (!sseRegistry) {
|
|
12
|
+
try {
|
|
13
|
+
const module = await import("../ui/sse.js");
|
|
14
|
+
sseRegistry = module.sseRegistry;
|
|
15
|
+
} catch (error) {
|
|
16
|
+
// SSE not available in all environments
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return sseRegistry;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Creates a logger instance with component name and optional context
|
|
25
|
+
*
|
|
26
|
+
* @param {string} componentName - Name of the component (e.g., 'Orchestrator', 'TaskRunner')
|
|
27
|
+
* @param {Object} context - Optional context object (e.g., { jobId, taskName })
|
|
28
|
+
* @returns {Object} Logger instance with methods for different log levels
|
|
29
|
+
*/
|
|
30
|
+
export function createLogger(componentName, context = {}) {
|
|
31
|
+
// Build context string for log prefixes
|
|
32
|
+
const contextParts = [];
|
|
33
|
+
if (context.jobId) contextParts.push(context.jobId);
|
|
34
|
+
if (context.taskName) contextParts.push(context.taskName);
|
|
35
|
+
if (context.stage) contextParts.push(context.stage);
|
|
36
|
+
|
|
37
|
+
const contextString =
|
|
38
|
+
contextParts.length > 0 ? `|${contextParts.join("|")}` : "";
|
|
39
|
+
const prefix = `[${componentName}${contextString}]`;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Formats data for consistent JSON output
|
|
43
|
+
* @param {*} data - Data to format
|
|
44
|
+
* @returns {string|null} Formatted JSON string or null if data is null/undefined
|
|
45
|
+
*/
|
|
46
|
+
function formatData(data) {
|
|
47
|
+
if (data === null || data === undefined) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
if (typeof data === "object") {
|
|
51
|
+
try {
|
|
52
|
+
return JSON.stringify(data, null, 2);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
return `{ "serialization_error": "${error.message}" }`;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return data;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Broadcasts SSE event if registry is available
|
|
62
|
+
* @param {string} eventType - Type of SSE event
|
|
63
|
+
* @param {*} eventData - Data to broadcast
|
|
64
|
+
*/
|
|
65
|
+
async function broadcastSSE(eventType, eventData) {
|
|
66
|
+
const registry = await getSSERegistry().catch(() => null);
|
|
67
|
+
if (registry) {
|
|
68
|
+
try {
|
|
69
|
+
const payload = {
|
|
70
|
+
type: eventType,
|
|
71
|
+
data: eventData,
|
|
72
|
+
component: componentName,
|
|
73
|
+
timestamp: new Date().toISOString(),
|
|
74
|
+
...context,
|
|
75
|
+
};
|
|
76
|
+
registry.broadcast(payload);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
// Don't fail logging if SSE broadcast fails
|
|
79
|
+
console.warn(
|
|
80
|
+
`${prefix} Failed to broadcast SSE event: ${error.message}`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
/**
|
|
88
|
+
* Debug level logging
|
|
89
|
+
* @param {string} message - Log message
|
|
90
|
+
* @param {*} data - Optional data to log
|
|
91
|
+
*/
|
|
92
|
+
debug: (message, data = null) => {
|
|
93
|
+
if (process.env.NODE_ENV !== "production" || process.env.DEBUG) {
|
|
94
|
+
console.debug(`${prefix} ${message}`, formatData(data) || "");
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Info level logging
|
|
100
|
+
* @param {string} message - Log message
|
|
101
|
+
* @param {*} data - Optional data to log
|
|
102
|
+
*/
|
|
103
|
+
log: (message, data = null) => {
|
|
104
|
+
console.log(`${prefix} ${message}`, formatData(data) || "");
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Warning level logging
|
|
109
|
+
* @param {string} message - Log message
|
|
110
|
+
* @param {*} data - Optional data to log
|
|
111
|
+
*/
|
|
112
|
+
warn: (message, data = null) => {
|
|
113
|
+
console.warn(`${prefix} ${message}`, formatData(data) || "");
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Error level logging with enhanced error context
|
|
118
|
+
* @param {string} message - Log message
|
|
119
|
+
* @param {*} data - Optional data to log
|
|
120
|
+
*/
|
|
121
|
+
error: (message, data = null) => {
|
|
122
|
+
let enhancedData = data;
|
|
123
|
+
|
|
124
|
+
// Enhance error objects with additional context
|
|
125
|
+
if (data && data instanceof Error) {
|
|
126
|
+
enhancedData = {
|
|
127
|
+
name: data.name,
|
|
128
|
+
message: data.message,
|
|
129
|
+
stack: data.stack,
|
|
130
|
+
component: componentName,
|
|
131
|
+
timestamp: new Date().toISOString(),
|
|
132
|
+
...context,
|
|
133
|
+
};
|
|
134
|
+
} else if (
|
|
135
|
+
data &&
|
|
136
|
+
typeof data === "object" &&
|
|
137
|
+
data.error instanceof Error
|
|
138
|
+
) {
|
|
139
|
+
enhancedData = {
|
|
140
|
+
...data,
|
|
141
|
+
error: {
|
|
142
|
+
name: data.error.name,
|
|
143
|
+
message: data.error.message,
|
|
144
|
+
stack: data.error.stack,
|
|
145
|
+
},
|
|
146
|
+
component: componentName,
|
|
147
|
+
timestamp: new Date().toISOString(),
|
|
148
|
+
...context,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
console.error(`${prefix} ${message}`, formatData(enhancedData) || "");
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Console group management
|
|
157
|
+
* @param {string} label - Group label
|
|
158
|
+
* @param {*} data - Optional data to log
|
|
159
|
+
*/
|
|
160
|
+
group: (label, data = null) => {
|
|
161
|
+
console.group(`${prefix} ${label}`);
|
|
162
|
+
if (data !== null && data !== undefined) {
|
|
163
|
+
const formattedData = formatData(data);
|
|
164
|
+
if (formattedData !== null) {
|
|
165
|
+
console.log(formattedData);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* End console group
|
|
172
|
+
*/
|
|
173
|
+
groupEnd: () => console.groupEnd(),
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* SSE event broadcasting
|
|
177
|
+
* @param {string} eventType - Type of SSE event
|
|
178
|
+
* @param {*} eventData - Data to broadcast
|
|
179
|
+
*/
|
|
180
|
+
sse: async (eventType, eventData) => {
|
|
181
|
+
// Log SSE broadcast with styling for visibility
|
|
182
|
+
console.log(
|
|
183
|
+
`%c${prefix} SSE Broadcast: ${eventType}`,
|
|
184
|
+
"color: #cc6600; font-weight: bold;",
|
|
185
|
+
formatData(eventData) || ""
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
await broadcastSSE(eventType, eventData);
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Creates a logger with job context (convenience function)
|
|
195
|
+
* @param {string} componentName - Component name
|
|
196
|
+
* @param {string} jobId - Job ID
|
|
197
|
+
* @param {Object} additionalContext - Additional context
|
|
198
|
+
* @returns {Object} Logger instance with job context
|
|
199
|
+
*/
|
|
200
|
+
export function createJobLogger(componentName, jobId, additionalContext = {}) {
|
|
201
|
+
return createLogger(componentName, { jobId, ...additionalContext });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Creates a logger with task context (convenience function)
|
|
206
|
+
* @param {string} componentName - Component name
|
|
207
|
+
* @param {string} jobId - Job ID
|
|
208
|
+
* @param {string} taskName - Task name
|
|
209
|
+
* @param {Object} additionalContext - Additional context
|
|
210
|
+
* @returns {Object} Logger instance with task context
|
|
211
|
+
*/
|
|
212
|
+
export function createTaskLogger(
|
|
213
|
+
componentName,
|
|
214
|
+
jobId,
|
|
215
|
+
taskName,
|
|
216
|
+
additionalContext = {}
|
|
217
|
+
) {
|
|
218
|
+
return createLogger(componentName, { jobId, taskName, ...additionalContext });
|
|
219
|
+
}
|
package/src/core/orchestrator.js
CHANGED
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import chokidar from "chokidar";
|
|
5
5
|
import { spawn as defaultSpawn } from "node:child_process";
|
|
6
6
|
import { getConfig, getPipelineConfig } from "./config.js";
|
|
7
|
+
import { createLogger } from "./logger.js";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Resolve canonical pipeline directories for the given data root.
|
|
@@ -70,6 +71,8 @@ export async function startOrchestrator(opts) {
|
|
|
70
71
|
const watcherFactory = opts?.watcherFactory ?? chokidar.watch;
|
|
71
72
|
const testMode = !!opts?.testMode;
|
|
72
73
|
|
|
74
|
+
const logger = createLogger("Orchestrator");
|
|
75
|
+
|
|
73
76
|
const dirs = resolveDirs(dataDir);
|
|
74
77
|
await ensureDir(dirs.pending);
|
|
75
78
|
await ensureDir(dirs.current);
|
|
@@ -94,7 +97,7 @@ export async function startOrchestrator(opts) {
|
|
|
94
97
|
const base = path.basename(filePath);
|
|
95
98
|
const match = base.match(/^([A-Za-z0-9-_]+)-seed\.json$/);
|
|
96
99
|
if (!match) {
|
|
97
|
-
|
|
100
|
+
logger.warn("Rejecting non-id seed file:", { filename: base });
|
|
98
101
|
return;
|
|
99
102
|
}
|
|
100
103
|
const jobId = match[1];
|
|
@@ -118,12 +121,16 @@ export async function startOrchestrator(opts) {
|
|
|
118
121
|
} catch {}
|
|
119
122
|
|
|
120
123
|
// Move seed to current/{jobId}/seed.json
|
|
121
|
-
|
|
124
|
+
logger.log("Moving file", { from: filePath, to: dest });
|
|
122
125
|
try {
|
|
123
126
|
await moveFile(filePath, dest);
|
|
124
|
-
|
|
127
|
+
logger.log("Successfully moved file", { destination: dest });
|
|
125
128
|
} catch (error) {
|
|
126
|
-
|
|
129
|
+
logger.error("Failed to move file", {
|
|
130
|
+
from: filePath,
|
|
131
|
+
to: dest,
|
|
132
|
+
error: error.message,
|
|
133
|
+
});
|
|
127
134
|
throw error; // Re-throw to see the actual error
|
|
128
135
|
}
|
|
129
136
|
|
|
@@ -147,14 +154,22 @@ export async function startOrchestrator(opts) {
|
|
|
147
154
|
await fs.writeFile(statusPath, JSON.stringify(status, null, 2));
|
|
148
155
|
}
|
|
149
156
|
// Spawn runner for this job
|
|
150
|
-
const child = spawnRunner(
|
|
157
|
+
const child = spawnRunner(
|
|
158
|
+
logger,
|
|
159
|
+
jobId,
|
|
160
|
+
dirs,
|
|
161
|
+
running,
|
|
162
|
+
spawn,
|
|
163
|
+
testMode,
|
|
164
|
+
seed
|
|
165
|
+
);
|
|
151
166
|
// child registered inside spawnRunner
|
|
152
167
|
return child;
|
|
153
168
|
}
|
|
154
169
|
|
|
155
170
|
// Watch pending directory for seeds
|
|
156
171
|
const watchPattern = path.join(dirs.pending, "*.json");
|
|
157
|
-
|
|
172
|
+
logger.log("Watching pattern", { pattern: watchPattern });
|
|
158
173
|
const watcher = watcherFactory(watchPattern, {
|
|
159
174
|
ignoreInitial: false,
|
|
160
175
|
awaitWriteFinish: false, // Disable awaitWriteFinish for faster detection
|
|
@@ -164,19 +179,19 @@ export async function startOrchestrator(opts) {
|
|
|
164
179
|
// Wait for watcher to be ready before resolving
|
|
165
180
|
await new Promise((resolve, reject) => {
|
|
166
181
|
watcher.on("ready", () => {
|
|
167
|
-
|
|
182
|
+
logger.log("Watcher is ready");
|
|
168
183
|
resolve();
|
|
169
184
|
});
|
|
170
185
|
|
|
171
186
|
watcher.on("error", (error) => {
|
|
172
|
-
|
|
187
|
+
logger.error("Watcher error", error);
|
|
173
188
|
reject(error);
|
|
174
189
|
});
|
|
175
190
|
});
|
|
176
191
|
|
|
177
192
|
watcher.on("add", (file) => {
|
|
178
|
-
|
|
179
|
-
// Return
|
|
193
|
+
logger.log("Detected file add", { file });
|
|
194
|
+
// Return promise so tests awaiting the add handler block until processing completes
|
|
180
195
|
return handleSeedAdd(file);
|
|
181
196
|
});
|
|
182
197
|
|
|
@@ -212,6 +227,7 @@ export async function startOrchestrator(opts) {
|
|
|
212
227
|
* Spawn a pipeline runner. In testMode we still call spawn() so tests can assert,
|
|
213
228
|
* but we resolve immediately and let tests drive the lifecycle (emit 'exit', etc.).
|
|
214
229
|
*
|
|
230
|
+
* @param {Object} logger - Logger instance for orchestrator logging
|
|
215
231
|
* @param {string} jobId
|
|
216
232
|
* @param {{dataDir:string,pending:string,current:string,complete:string}} dirs
|
|
217
233
|
* @param {Map<string, import('node:child_process').ChildProcess>} running
|
|
@@ -219,7 +235,7 @@ export async function startOrchestrator(opts) {
|
|
|
219
235
|
* @param {boolean} testMode
|
|
220
236
|
* @param {Object} seed - Seed data containing pipeline information
|
|
221
237
|
*/
|
|
222
|
-
function spawnRunner(jobId, dirs, running, spawn, testMode, seed) {
|
|
238
|
+
function spawnRunner(logger, jobId, dirs, running, spawn, testMode, seed) {
|
|
223
239
|
// Use path relative to this file to avoid process.cwd() issues
|
|
224
240
|
const orchestratorDir = path.dirname(new URL(import.meta.url).pathname);
|
|
225
241
|
const runnerPath = path.join(orchestratorDir, "pipeline-runner.js");
|
|
@@ -234,7 +250,7 @@ function spawnRunner(jobId, dirs, running, spawn, testMode, seed) {
|
|
|
234
250
|
const availablePipelines = Object.keys(configSnapshot?.pipelines ?? {});
|
|
235
251
|
const pipelineSlug = seed?.pipeline;
|
|
236
252
|
|
|
237
|
-
|
|
253
|
+
logger.log("spawnRunner invoked", {
|
|
238
254
|
jobId,
|
|
239
255
|
pipelineSlug: pipelineSlug ?? null,
|
|
240
256
|
availablePipelines,
|
|
@@ -242,22 +258,19 @@ function spawnRunner(jobId, dirs, running, spawn, testMode, seed) {
|
|
|
242
258
|
});
|
|
243
259
|
|
|
244
260
|
if (!availablePipelines.length) {
|
|
245
|
-
|
|
246
|
-
"
|
|
261
|
+
logger.warn(
|
|
262
|
+
"No pipelines registered in config() when spawnRunner invoked"
|
|
247
263
|
);
|
|
248
264
|
} else if (!availablePipelines.includes(pipelineSlug)) {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
availablePipelines,
|
|
255
|
-
}
|
|
256
|
-
);
|
|
265
|
+
logger.warn("Requested pipeline slug missing from registry snapshot", {
|
|
266
|
+
jobId,
|
|
267
|
+
pipelineSlug,
|
|
268
|
+
availablePipelines,
|
|
269
|
+
});
|
|
257
270
|
}
|
|
258
271
|
|
|
259
272
|
if (!pipelineSlug) {
|
|
260
|
-
|
|
273
|
+
logger.error("Missing pipeline slug in seed", {
|
|
261
274
|
jobId,
|
|
262
275
|
seed,
|
|
263
276
|
availablePipelines,
|
|
@@ -271,7 +284,7 @@ function spawnRunner(jobId, dirs, running, spawn, testMode, seed) {
|
|
|
271
284
|
try {
|
|
272
285
|
pipelineConfig = getPipelineConfig(pipelineSlug);
|
|
273
286
|
} catch (error) {
|
|
274
|
-
|
|
287
|
+
logger.error("Pipeline lookup failed", {
|
|
275
288
|
jobId,
|
|
276
289
|
pipelineSlug,
|
|
277
290
|
availablePipelines,
|
|
@@ -9,6 +9,7 @@ import { TaskState } from "../config/statuses.js";
|
|
|
9
9
|
import { ensureTaskSymlinkBridge } from "./symlink-bridge.js";
|
|
10
10
|
import { cleanupTaskSymlinks } from "./symlink-utils.js";
|
|
11
11
|
import { createTaskFileIO } from "./file-io.js";
|
|
12
|
+
import { createJobLogger } from "./logger.js";
|
|
12
13
|
|
|
13
14
|
const ROOT = process.env.PO_ROOT || process.cwd();
|
|
14
15
|
const DATA_DIR = path.join(ROOT, process.env.PO_DATA_DIR || "pipeline-data");
|
|
@@ -20,6 +21,8 @@ const COMPLETE_DIR =
|
|
|
20
21
|
const jobId = process.argv[2];
|
|
21
22
|
if (!jobId) throw new Error("runner requires jobId as argument");
|
|
22
23
|
|
|
24
|
+
const logger = createJobLogger("PipelineRunner", jobId);
|
|
25
|
+
|
|
23
26
|
const workDir = path.join(CURRENT_DIR, jobId);
|
|
24
27
|
|
|
25
28
|
const startFromTask = process.env.PO_START_FROM_TASK;
|
|
@@ -66,12 +69,23 @@ const seed = JSON.parse(
|
|
|
66
69
|
|
|
67
70
|
let pipelineArtifacts = {};
|
|
68
71
|
|
|
72
|
+
logger.group("Pipeline execution", {
|
|
73
|
+
jobId,
|
|
74
|
+
pipelineSlug,
|
|
75
|
+
totalTasks: pipeline.tasks.length,
|
|
76
|
+
startFromTask: startFromTask || null,
|
|
77
|
+
});
|
|
78
|
+
|
|
69
79
|
for (const taskName of pipeline.tasks) {
|
|
70
80
|
// Skip tasks before startFromTask when targeting a specific restart point
|
|
71
81
|
if (
|
|
72
82
|
startFromTask &&
|
|
73
83
|
pipeline.tasks.indexOf(taskName) < pipeline.tasks.indexOf(startFromTask)
|
|
74
84
|
) {
|
|
85
|
+
logger.log("Skipping task before restart point", {
|
|
86
|
+
taskName,
|
|
87
|
+
startFromTask,
|
|
88
|
+
});
|
|
75
89
|
continue;
|
|
76
90
|
}
|
|
77
91
|
|
|
@@ -80,10 +94,14 @@ for (const taskName of pipeline.tasks) {
|
|
|
80
94
|
const outputPath = path.join(workDir, "tasks", taskName, "output.json");
|
|
81
95
|
const output = JSON.parse(await fs.readFile(outputPath, "utf8"));
|
|
82
96
|
pipelineArtifacts[taskName] = output;
|
|
83
|
-
|
|
97
|
+
logger.log("Task already completed", { taskName });
|
|
98
|
+
} catch {
|
|
99
|
+
logger.warn("Failed to read completed task output", { taskName });
|
|
100
|
+
}
|
|
84
101
|
continue;
|
|
85
102
|
}
|
|
86
103
|
|
|
104
|
+
logger.log("Starting task", { taskName });
|
|
87
105
|
await updateStatus(taskName, {
|
|
88
106
|
state: TaskState.RUNNING,
|
|
89
107
|
startedAt: now(),
|
|
@@ -130,9 +148,17 @@ for (const taskName of pipeline.tasks) {
|
|
|
130
148
|
statusPath: tasksStatusPath,
|
|
131
149
|
});
|
|
132
150
|
|
|
151
|
+
logger.log("Running task", { taskName, modulePath: absoluteModulePath });
|
|
133
152
|
const result = await runPipeline(relocatedEntry, ctx);
|
|
134
153
|
|
|
135
154
|
if (!result.ok) {
|
|
155
|
+
logger.error("Task failed", {
|
|
156
|
+
taskName,
|
|
157
|
+
failedStage: result.failedStage,
|
|
158
|
+
error: result.error,
|
|
159
|
+
refinementAttempts: result.refinementAttempts || 0,
|
|
160
|
+
});
|
|
161
|
+
|
|
136
162
|
// Persist execution-logs.json and failure-details.json on task failure via IO
|
|
137
163
|
if (result.logs) {
|
|
138
164
|
await fileIO.writeLog(
|
|
@@ -180,6 +206,13 @@ for (const taskName of pipeline.tasks) {
|
|
|
180
206
|
process.exit(1);
|
|
181
207
|
}
|
|
182
208
|
|
|
209
|
+
logger.log("Task completed successfully", {
|
|
210
|
+
taskName,
|
|
211
|
+
executionTimeMs:
|
|
212
|
+
result.logs?.reduce((total, log) => total + (log.ms || 0), 0) || 0,
|
|
213
|
+
refinementAttempts: result.refinementAttempts || 0,
|
|
214
|
+
});
|
|
215
|
+
|
|
183
216
|
// The file I/O system automatically handles writing outputs and updating tasks-status.json
|
|
184
217
|
// No need to manually write output.json or enumerate artifacts
|
|
185
218
|
|
|
@@ -211,6 +244,20 @@ for (const taskName of pipeline.tasks) {
|
|
|
211
244
|
|
|
212
245
|
await fs.mkdir(COMPLETE_DIR, { recursive: true });
|
|
213
246
|
const dest = path.join(COMPLETE_DIR, jobId);
|
|
247
|
+
|
|
248
|
+
logger.log("Pipeline completed", {
|
|
249
|
+
jobId,
|
|
250
|
+
totalExecutionTime: Object.values(status.tasks).reduce(
|
|
251
|
+
(total, t) => total + (t.executionTimeMs || 0),
|
|
252
|
+
0
|
|
253
|
+
),
|
|
254
|
+
totalRefinementAttempts: Object.values(status.tasks).reduce(
|
|
255
|
+
(total, t) => total + (t.refinementAttempts || 0),
|
|
256
|
+
0
|
|
257
|
+
),
|
|
258
|
+
finalArtifacts: Object.keys(pipelineArtifacts),
|
|
259
|
+
});
|
|
260
|
+
|
|
214
261
|
await fs.rename(workDir, dest);
|
|
215
262
|
await appendLine(
|
|
216
263
|
path.join(COMPLETE_DIR, "runs.jsonl"),
|
|
@@ -233,6 +280,8 @@ await appendLine(
|
|
|
233
280
|
// Clean up task symlinks to avoid dangling links in archives
|
|
234
281
|
await cleanupTaskSymlinks(dest);
|
|
235
282
|
|
|
283
|
+
logger.groupEnd();
|
|
284
|
+
|
|
236
285
|
function now() {
|
|
237
286
|
return new Date().toISOString();
|
|
238
287
|
}
|
|
@@ -1,50 +1,11 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { TaskState } from "../config/statuses.js";
|
|
4
|
-
|
|
5
|
-
// Lazy import SSE registry to avoid circular dependencies
|
|
6
|
-
let sseRegistry = null;
|
|
7
|
-
async function getSSERegistry() {
|
|
8
|
-
if (!sseRegistry) {
|
|
9
|
-
try {
|
|
10
|
-
const module = await import("../ui/sse.js");
|
|
11
|
-
sseRegistry = module.sseRegistry;
|
|
12
|
-
} catch (error) {
|
|
13
|
-
// SSE not available in all environments
|
|
14
|
-
return null;
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
return sseRegistry;
|
|
18
|
-
}
|
|
4
|
+
import { createJobLogger } from "./logger.js";
|
|
19
5
|
|
|
20
6
|
// Per-job write queues to serialize writes to tasks-status.json
|
|
21
7
|
const writeQueues = new Map(); // Map<string jobDir, Promise<any>>
|
|
22
8
|
|
|
23
|
-
// Instrumentation helper for status writer
|
|
24
|
-
const createStatusWriterLogger = (jobId) => {
|
|
25
|
-
const prefix = `[StatusWriter:${jobId || "unknown"}]`;
|
|
26
|
-
return {
|
|
27
|
-
log: (message, data = null) => {
|
|
28
|
-
console.log(`${prefix} ${message}`, data ? data : "");
|
|
29
|
-
},
|
|
30
|
-
warn: (message, data = null) => {
|
|
31
|
-
console.warn(`${prefix} ${message}`, data ? data : "");
|
|
32
|
-
},
|
|
33
|
-
error: (message, data = null) => {
|
|
34
|
-
console.error(`${prefix} ${message}`, data ? data : "");
|
|
35
|
-
},
|
|
36
|
-
group: (label) => console.group(`${prefix} ${label}`),
|
|
37
|
-
groupEnd: () => console.groupEnd(),
|
|
38
|
-
sse: (eventType, eventData) => {
|
|
39
|
-
console.log(
|
|
40
|
-
`%c${prefix} SSE Broadcast: ${eventType}`,
|
|
41
|
-
"color: #cc6600; font-weight: bold;",
|
|
42
|
-
eventData
|
|
43
|
-
);
|
|
44
|
-
},
|
|
45
|
-
};
|
|
46
|
-
};
|
|
47
|
-
|
|
48
9
|
/**
|
|
49
10
|
* Atomic status writer utility for tasks-status.json
|
|
50
11
|
*
|
|
@@ -195,7 +156,7 @@ export async function writeJobStatus(jobDir, updateFn) {
|
|
|
195
156
|
|
|
196
157
|
const statusPath = path.join(jobDir, "tasks-status.json");
|
|
197
158
|
const jobId = path.basename(jobDir);
|
|
198
|
-
const logger =
|
|
159
|
+
const logger = createJobLogger("StatusWriter", jobId);
|
|
199
160
|
|
|
200
161
|
// Get or create the write queue for this job directory
|
|
201
162
|
const prev = writeQueues.get(jobDir) || Promise.resolve();
|
|
@@ -219,7 +180,7 @@ export async function writeJobStatus(jobDir, updateFn) {
|
|
|
219
180
|
try {
|
|
220
181
|
maybeUpdated = updateFn(validated);
|
|
221
182
|
} catch (error) {
|
|
222
|
-
|
|
183
|
+
logger.error("Error executing update function:", error);
|
|
223
184
|
throw new Error(`Update function failed: ${error.message}`);
|
|
224
185
|
}
|
|
225
186
|
const snapshot = validateStatusSnapshot(
|
|
@@ -233,28 +194,18 @@ export async function writeJobStatus(jobDir, updateFn) {
|
|
|
233
194
|
await atomicWrite(statusPath, snapshot);
|
|
234
195
|
logger.log("Status file written successfully");
|
|
235
196
|
|
|
236
|
-
// Emit SSE event for tasks-status.json change
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
registry.broadcast(eventData);
|
|
249
|
-
logger.sse("state:change", eventData.data);
|
|
250
|
-
logger.log("SSE event broadcasted successfully");
|
|
251
|
-
} catch (error) {
|
|
252
|
-
// Don't fail the write if SSE emission fails
|
|
253
|
-
logger.error("Failed to emit SSE event:", error);
|
|
254
|
-
console.warn(`Failed to emit SSE event: ${error.message}`);
|
|
255
|
-
}
|
|
256
|
-
} else {
|
|
257
|
-
logger.warn("SSE registry not available - no event broadcasted");
|
|
197
|
+
// Emit SSE event for tasks-status.json change using logger
|
|
198
|
+
try {
|
|
199
|
+
const eventData = {
|
|
200
|
+
path: path.join(jobDir, "tasks-status.json"),
|
|
201
|
+
id: jobId,
|
|
202
|
+
jobId,
|
|
203
|
+
};
|
|
204
|
+
await logger.sse("state:change", eventData);
|
|
205
|
+
logger.log("SSE event broadcasted successfully");
|
|
206
|
+
} catch (error) {
|
|
207
|
+
// Don't fail the write if SSE emission fails
|
|
208
|
+
logger.error("Failed to emit SSE event:", error);
|
|
258
209
|
}
|
|
259
210
|
|
|
260
211
|
logger.groupEnd();
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { createLogger } from "./logger.js";
|
|
4
|
+
|
|
5
|
+
const logger = createLogger("SymlinkUtils");
|
|
3
6
|
|
|
4
7
|
/**
|
|
5
8
|
* Creates an idempotent symlink, safely handling existing files/symlinks.
|
|
@@ -87,8 +90,9 @@ export async function cleanupTaskSymlinks(completedJobDir) {
|
|
|
87
90
|
}
|
|
88
91
|
} catch (error) {
|
|
89
92
|
// Log but don't fail - cleanup is optional
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
+
logger.warn("Failed to cleanup task symlinks", {
|
|
94
|
+
directory: completedJobDir,
|
|
95
|
+
error: error.message,
|
|
96
|
+
});
|
|
93
97
|
}
|
|
94
98
|
}
|
package/src/core/task-runner.js
CHANGED
|
@@ -9,6 +9,7 @@ import { writeJobStatus } from "./status-writer.js";
|
|
|
9
9
|
import { computeDeterministicProgress } from "./progress.js";
|
|
10
10
|
import { TaskState } from "../config/statuses.js";
|
|
11
11
|
import { validateWithSchema } from "../api/validators/json.js";
|
|
12
|
+
import { createJobLogger } from "./logger.js";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Derives model key and token counts from LLM metric event.
|
|
@@ -341,6 +342,11 @@ const PIPELINE_STAGES = [
|
|
|
341
342
|
* Runs a pipeline by loading a module that exports functions keyed by stage name.
|
|
342
343
|
*/
|
|
343
344
|
export async function runPipeline(modulePath, initialContext = {}) {
|
|
345
|
+
const logger = createJobLogger(
|
|
346
|
+
"TaskRunner",
|
|
347
|
+
initialContext.jobId || "unknown"
|
|
348
|
+
);
|
|
349
|
+
|
|
344
350
|
if (!initialContext.envLoaded) {
|
|
345
351
|
await loadEnvironment();
|
|
346
352
|
initialContext.envLoaded = true;
|
|
@@ -477,6 +483,10 @@ export async function runPipeline(modulePath, initialContext = {}) {
|
|
|
477
483
|
|
|
478
484
|
// Skip stages when skipIf predicate returns true
|
|
479
485
|
if (stageConfig.skipIf && stageConfig.skipIf(context.flags)) {
|
|
486
|
+
logger.log("Skipping stage", {
|
|
487
|
+
stage: stageName,
|
|
488
|
+
reason: "skipIf predicate returned true",
|
|
489
|
+
});
|
|
480
490
|
context.logs.push({
|
|
481
491
|
stage: stageName,
|
|
482
492
|
action: "skipped",
|
|
@@ -488,6 +498,7 @@ export async function runPipeline(modulePath, initialContext = {}) {
|
|
|
488
498
|
|
|
489
499
|
// Skip if handler is not available (not implemented)
|
|
490
500
|
if (typeof stageHandler !== "function") {
|
|
501
|
+
logger.log("Stage not available, skipping", { stage: stageName });
|
|
491
502
|
logs.push({
|
|
492
503
|
stage: stageName,
|
|
493
504
|
skipped: true,
|
|
@@ -510,6 +521,11 @@ export async function runPipeline(modulePath, initialContext = {}) {
|
|
|
510
521
|
// Set current stage before execution
|
|
511
522
|
context.currentStage = stageName;
|
|
512
523
|
|
|
524
|
+
logger.log("Starting stage execution", {
|
|
525
|
+
stage: stageName,
|
|
526
|
+
taskName: context.meta.taskName,
|
|
527
|
+
});
|
|
528
|
+
|
|
513
529
|
// Write stage start status using writeJobStatus
|
|
514
530
|
if (context.meta.workDir && context.meta.taskName) {
|
|
515
531
|
try {
|
|
@@ -527,7 +543,10 @@ export async function runPipeline(modulePath, initialContext = {}) {
|
|
|
527
543
|
});
|
|
528
544
|
} catch (error) {
|
|
529
545
|
// Don't fail the pipeline if status write fails
|
|
530
|
-
|
|
546
|
+
logger.error("Failed to write stage start status", {
|
|
547
|
+
stage: stageName,
|
|
548
|
+
error: error.message,
|
|
549
|
+
});
|
|
531
550
|
}
|
|
532
551
|
}
|
|
533
552
|
|
|
@@ -678,6 +697,12 @@ export async function runPipeline(modulePath, initialContext = {}) {
|
|
|
678
697
|
}
|
|
679
698
|
|
|
680
699
|
const ms = +(performance.now() - start).toFixed(2);
|
|
700
|
+
logger.log("Stage completed successfully", {
|
|
701
|
+
stage: stageName,
|
|
702
|
+
executionTimeMs: ms,
|
|
703
|
+
outputType: typeof stageResult.output,
|
|
704
|
+
flagKeys: Object.keys(stageResult.flags),
|
|
705
|
+
});
|
|
681
706
|
logs.push({
|
|
682
707
|
stage: stageName,
|
|
683
708
|
ok: true,
|
|
@@ -688,6 +713,14 @@ export async function runPipeline(modulePath, initialContext = {}) {
|
|
|
688
713
|
const ms = +(performance.now() - start).toFixed(2);
|
|
689
714
|
const errInfo = normalizeError(error);
|
|
690
715
|
|
|
716
|
+
logger.error("Stage execution failed", {
|
|
717
|
+
stage: stageName,
|
|
718
|
+
taskName: context.meta.taskName,
|
|
719
|
+
executionTimeMs: ms,
|
|
720
|
+
error: errInfo,
|
|
721
|
+
previousStage: lastExecutedStageName,
|
|
722
|
+
});
|
|
723
|
+
|
|
691
724
|
// Attach debug metadata to the error envelope for richer diagnostics
|
|
692
725
|
errInfo.debug = {
|
|
693
726
|
stage: stageName,
|
|
@@ -756,6 +789,13 @@ export async function runPipeline(modulePath, initialContext = {}) {
|
|
|
756
789
|
|
|
757
790
|
llmEvents.off("llm:request:complete", onLLMComplete);
|
|
758
791
|
|
|
792
|
+
logger.log("Pipeline completed successfully", {
|
|
793
|
+
taskName: context.meta.taskName,
|
|
794
|
+
totalStages: PIPELINE_STAGES.length,
|
|
795
|
+
executedStages: logs.filter((l) => l.ok).length,
|
|
796
|
+
llmMetricsCount: llmMetrics.length,
|
|
797
|
+
});
|
|
798
|
+
|
|
759
799
|
// Write final status with currentStage: null to indicate completion
|
|
760
800
|
if (context.meta.workDir && context.meta.taskName) {
|
|
761
801
|
try {
|
|
@@ -775,7 +815,10 @@ export async function runPipeline(modulePath, initialContext = {}) {
|
|
|
775
815
|
});
|
|
776
816
|
} catch (error) {
|
|
777
817
|
// Don't fail the pipeline if final status write fails
|
|
778
|
-
|
|
818
|
+
logger.error("Failed to write final status", {
|
|
819
|
+
taskName: context.meta.taskName,
|
|
820
|
+
error: error.message,
|
|
821
|
+
});
|
|
779
822
|
}
|
|
780
823
|
}
|
|
781
824
|
|
package/src/ui/job-reader.js
CHANGED
|
@@ -30,6 +30,7 @@ export async function readJob(jobId) {
|
|
|
30
30
|
|
|
31
31
|
// Locations in precedence order
|
|
32
32
|
const locations = ["current", "complete"];
|
|
33
|
+
const attemptedLocations = [];
|
|
33
34
|
|
|
34
35
|
for (const location of locations) {
|
|
35
36
|
console.log(`readJob: checking location ${location} for ${jobId}`);
|
|
@@ -70,11 +71,8 @@ export async function readJob(jobId) {
|
|
|
70
71
|
const result = await readFileWithRetry(tasksPath);
|
|
71
72
|
|
|
72
73
|
if (!result.ok) {
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
`Failed to read tasks-status.json for job ${jobId} in ${location}`,
|
|
76
|
-
result
|
|
77
|
-
);
|
|
74
|
+
// Track attempted location for final warning if needed
|
|
75
|
+
attemptedLocations.push(location);
|
|
78
76
|
|
|
79
77
|
// If not found, continue to next location
|
|
80
78
|
if (result.code === configBridge.Constants.ERROR_CODES.NOT_FOUND) {
|
|
@@ -101,6 +99,9 @@ export async function readJob(jobId) {
|
|
|
101
99
|
}
|
|
102
100
|
|
|
103
101
|
// If we reach here, job not found in any location
|
|
102
|
+
console.warn(
|
|
103
|
+
`Job ${jobId} not found in any location. Searched: ${attemptedLocations.join(", ")}`
|
|
104
|
+
);
|
|
104
105
|
return configBridge.createErrorResponse(
|
|
105
106
|
configBridge.Constants.ERROR_CODES.JOB_NOT_FOUND,
|
|
106
107
|
"Job not found",
|