@ryanfw/prompt-orchestration-pipeline 0.8.0 → 0.9.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ryanfw/prompt-orchestration-pipeline",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
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
+ }
@@ -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
- console.warn("Rejecting non-id seed file:", base);
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
- console.log(`[Orchestrator] Moving file from ${filePath} to ${dest}`);
124
+ logger.log("Moving file", { from: filePath, to: dest });
122
125
  try {
123
126
  await moveFile(filePath, dest);
124
- console.log(`[Orchestrator] ✓ Successfully moved file to ${dest}`);
127
+ logger.log("Successfully moved file", { destination: dest });
125
128
  } catch (error) {
126
- console.log(`[Orchestrator] ✗ Failed to move file: ${error.message}`);
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(jobId, dirs, running, spawn, testMode, seed);
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
- console.log("Orchestrator watching pattern:", watchPattern);
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
- console.log("Orchestrator watcher is ready");
182
+ logger.log("Watcher is ready");
168
183
  resolve();
169
184
  });
170
185
 
171
186
  watcher.on("error", (error) => {
172
- console.log("Orchestrator watcher error:", error);
187
+ logger.error("Watcher error", error);
173
188
  reject(error);
174
189
  });
175
190
  });
176
191
 
177
192
  watcher.on("add", (file) => {
178
- console.log("Orchestrator detected file add:", file);
179
- // Return the promise so tests awaiting the add handler block until processing completes
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
- console.log("[Orchestrator] spawnRunner invoked", {
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
- console.warn(
246
- "[Orchestrator] No pipelines registered in config() when spawnRunner invoked"
261
+ logger.warn(
262
+ "No pipelines registered in config() when spawnRunner invoked"
247
263
  );
248
264
  } else if (!availablePipelines.includes(pipelineSlug)) {
249
- console.warn(
250
- "[Orchestrator] Requested pipeline slug missing from registry snapshot",
251
- {
252
- jobId,
253
- pipelineSlug,
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
- console.error("[Orchestrator] Missing pipeline slug in seed", {
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
- console.error("[Orchestrator] Pipeline lookup failed", {
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
- } catch {}
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 = createStatusWriterLogger(jobId);
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
- console.error(`[${jobId}] Error executing update function:`, error);
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
- const registry = (await getSSERegistry().catch(() => null)) || null;
238
- if (registry) {
239
- try {
240
- const eventData = {
241
- type: "state:change",
242
- data: {
243
- path: path.join(jobDir, "tasks-status.json"),
244
- id: jobId,
245
- jobId,
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
- console.warn(
91
- `Warning: Failed to cleanup task symlinks in ${completedJobDir}: ${error.message}`
92
- );
93
+ logger.warn("Failed to cleanup task symlinks", {
94
+ directory: completedJobDir,
95
+ error: error.message,
96
+ });
93
97
  }
94
98
  }
@@ -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
- console.warn(`Failed to write stage start status: ${error.message}`);
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
- console.warn(`Failed to write final status: ${error.message}`);
818
+ logger.error("Failed to write final status", {
819
+ taskName: context.meta.taskName,
820
+ error: error.message,
821
+ });
779
822
  }
780
823
  }
781
824
 
@@ -812,8 +855,41 @@ function toAbsFileURL(p) {
812
855
  }
813
856
 
814
857
  function normalizeError(err) {
815
- if (err instanceof Error)
858
+ if (err instanceof Error) {
816
859
  return { name: err.name, message: err.message, stack: err.stack };
860
+ }
861
+
862
+ // Handle plain object errors (like those from HTTP responses)
863
+ if (typeof err === "object" && err !== null) {
864
+ let message = "Unknown error";
865
+ if (typeof err?.message === "string") {
866
+ message = err.message;
867
+ } else if (typeof err?.error?.message === "string") {
868
+ message = err.error.message;
869
+ } else if (typeof err?.error === "string") {
870
+ message = err.error;
871
+ }
872
+ const result = { message };
873
+
874
+ // Include additional context if available
875
+ if (err.status) result.status = err.status;
876
+ if (err.code) result.code = err.code;
877
+ if (err.error) {
878
+ if (typeof err.error === "string") {
879
+ result.error = err.error;
880
+ } else if (typeof err.error === "object" && err.error !== null) {
881
+ // Try to extract a message property, else serialize the object
882
+ result.error = err.error.message
883
+ ? err.error.message
884
+ : JSON.stringify(err.error);
885
+ } else {
886
+ result.error = String(err.error);
887
+ }
888
+ }
889
+
890
+ return result;
891
+ }
892
+
817
893
  return { message: String(err) };
818
894
  }
819
895
 
@@ -65,20 +65,39 @@ export async function zhipuChat({
65
65
  };
66
66
 
67
67
  console.log("[Zhipu] Calling Zhipu API...");
68
- const response = await fetch("https://api.z.ai/api/coding/paas/v4", {
69
- method: "POST",
70
- headers: {
71
- "Content-Type": "application/json",
72
- Authorization: `Bearer ${process.env.ZHIPU_API_KEY}`,
73
- },
74
- body: JSON.stringify(requestBody),
75
- });
68
+ const response = await fetch(
69
+ "https://api.z.ai/api/paas/v4/chat/completions",
70
+ {
71
+ method: "POST",
72
+ headers: {
73
+ "Content-Type": "application/json",
74
+ Authorization: `Bearer ${process.env.ZHIPU_API_KEY}`,
75
+ },
76
+ body: JSON.stringify(requestBody),
77
+ }
78
+ );
76
79
 
77
80
  if (!response.ok) {
78
- const error = await response
79
- .json()
80
- .catch(() => ({ error: response.statusText }));
81
- throw { status: response.status, ...error };
81
+ let errorMessage;
82
+ try {
83
+ const errorData = await response.json();
84
+ errorMessage =
85
+ errorData?.error?.message ||
86
+ errorData?.message ||
87
+ response.statusText ||
88
+ "Unknown error";
89
+ } catch {
90
+ // If JSON parsing fails, try to get text response
91
+ try {
92
+ errorMessage = await response.text();
93
+ } catch {
94
+ errorMessage = response.statusText || "Unknown error";
95
+ }
96
+ }
97
+
98
+ const error = new Error(errorMessage);
99
+ error.status = response.status;
100
+ throw error;
82
101
  }
83
102
 
84
103
  const data = await response.json();
@@ -117,7 +136,7 @@ export async function zhipuChat({
117
136
  };
118
137
  } catch (error) {
119
138
  lastError = error;
120
- const msg = error?.error?.message || error?.message || "";
139
+ const msg = error?.message || error?.toString() || "Unknown error";
121
140
  console.error("[Zhipu] Error occurred:", msg);
122
141
  console.error("[Zhipu] Error status:", error?.status);
123
142
 
@@ -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
- // Log a warning for failed reads of tasks-status.json in this location
74
- console.warn(
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",