@ryanfw/prompt-orchestration-pipeline 0.0.1 → 0.3.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.
Files changed (76) hide show
  1. package/README.md +415 -24
  2. package/package.json +45 -8
  3. package/src/api/files.js +48 -0
  4. package/src/api/index.js +149 -53
  5. package/src/api/validators/seed.js +141 -0
  6. package/src/cli/index.js +456 -29
  7. package/src/cli/run-orchestrator.js +39 -0
  8. package/src/cli/update-pipeline-json.js +47 -0
  9. package/src/components/DAGGrid.jsx +649 -0
  10. package/src/components/JobCard.jsx +96 -0
  11. package/src/components/JobDetail.jsx +159 -0
  12. package/src/components/JobTable.jsx +202 -0
  13. package/src/components/Layout.jsx +134 -0
  14. package/src/components/TaskFilePane.jsx +570 -0
  15. package/src/components/UploadSeed.jsx +239 -0
  16. package/src/components/ui/badge.jsx +20 -0
  17. package/src/components/ui/button.jsx +43 -0
  18. package/src/components/ui/card.jsx +20 -0
  19. package/src/components/ui/focus-styles.css +60 -0
  20. package/src/components/ui/progress.jsx +26 -0
  21. package/src/components/ui/select.jsx +27 -0
  22. package/src/components/ui/separator.jsx +6 -0
  23. package/src/config/paths.js +99 -0
  24. package/src/core/config.js +270 -9
  25. package/src/core/file-io.js +202 -0
  26. package/src/core/module-loader.js +157 -0
  27. package/src/core/orchestrator.js +275 -294
  28. package/src/core/pipeline-runner.js +95 -41
  29. package/src/core/progress.js +66 -0
  30. package/src/core/status-writer.js +331 -0
  31. package/src/core/task-runner.js +719 -73
  32. package/src/core/validation.js +120 -1
  33. package/src/lib/utils.js +6 -0
  34. package/src/llm/README.md +139 -30
  35. package/src/llm/index.js +222 -72
  36. package/src/pages/PipelineDetail.jsx +111 -0
  37. package/src/pages/PromptPipelineDashboard.jsx +223 -0
  38. package/src/providers/deepseek.js +3 -15
  39. package/src/ui/client/adapters/job-adapter.js +258 -0
  40. package/src/ui/client/bootstrap.js +120 -0
  41. package/src/ui/client/hooks/useJobDetailWithUpdates.js +619 -0
  42. package/src/ui/client/hooks/useJobList.js +50 -0
  43. package/src/ui/client/hooks/useJobListWithUpdates.js +335 -0
  44. package/src/ui/client/hooks/useTicker.js +26 -0
  45. package/src/ui/client/index.css +31 -0
  46. package/src/ui/client/index.html +18 -0
  47. package/src/ui/client/main.jsx +38 -0
  48. package/src/ui/config-bridge.browser.js +149 -0
  49. package/src/ui/config-bridge.js +149 -0
  50. package/src/ui/config-bridge.node.js +310 -0
  51. package/src/ui/dist/assets/index-BDABnI-4.js +33399 -0
  52. package/src/ui/dist/assets/style-Ks8LY8gB.css +28496 -0
  53. package/src/ui/dist/index.html +19 -0
  54. package/src/ui/endpoints/job-endpoints.js +300 -0
  55. package/src/ui/file-reader.js +216 -0
  56. package/src/ui/job-change-detector.js +83 -0
  57. package/src/ui/job-index.js +231 -0
  58. package/src/ui/job-reader.js +274 -0
  59. package/src/ui/job-scanner.js +188 -0
  60. package/src/ui/public/app.js +3 -1
  61. package/src/ui/server.js +1636 -59
  62. package/src/ui/sse-enhancer.js +149 -0
  63. package/src/ui/sse.js +204 -0
  64. package/src/ui/state-snapshot.js +252 -0
  65. package/src/ui/transformers/list-transformer.js +347 -0
  66. package/src/ui/transformers/status-transformer.js +307 -0
  67. package/src/ui/watcher.js +61 -7
  68. package/src/utils/dag.js +101 -0
  69. package/src/utils/duration.js +126 -0
  70. package/src/utils/id-generator.js +30 -0
  71. package/src/utils/jobs.js +7 -0
  72. package/src/utils/pipelines.js +44 -0
  73. package/src/utils/task-files.js +271 -0
  74. package/src/utils/ui.jsx +76 -0
  75. package/src/ui/public/index.html +0 -53
  76. package/src/ui/public/style.css +0 -341
@@ -1,7 +1,9 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { pathToFileURL } from "node:url";
4
3
  import { runPipeline } from "./task-runner.js";
4
+ import { loadFreshModule } from "./module-loader.js";
5
+ import { validatePipelineOrThrow } from "./validation.js";
6
+ import { getPipelineConfig } from "./config.js";
5
7
 
6
8
  const ROOT = process.env.PO_ROOT || process.cwd();
7
9
  const DATA_DIR = path.join(ROOT, process.env.PO_DATA_DIR || "pipeline-data");
@@ -10,23 +12,45 @@ const CURRENT_DIR =
10
12
  const COMPLETE_DIR =
11
13
  process.env.PO_COMPLETE_DIR || path.join(DATA_DIR, "complete");
12
14
 
13
- const CONFIG_DIR =
14
- process.env.PO_CONFIG_DIR || path.join(ROOT, "pipeline-config");
15
+ const jobId = process.argv[2];
16
+ if (!jobId) throw new Error("runner requires jobId as argument");
17
+
18
+ const workDir = path.join(CURRENT_DIR, jobId);
19
+
20
+ // Get pipeline slug from environment or fallback to seed.json
21
+ let pipelineSlug = process.env.PO_PIPELINE_SLUG;
22
+ if (!pipelineSlug) {
23
+ try {
24
+ const seedPath = path.join(workDir, "seed.json");
25
+ const seedData = JSON.parse(await fs.readFile(seedPath, "utf8"));
26
+ pipelineSlug = seedData?.pipeline;
27
+ if (!pipelineSlug) {
28
+ throw new Error("No pipeline slug found in seed.json");
29
+ }
30
+ } catch (error) {
31
+ throw new Error(
32
+ `Pipeline slug is required. Set PO_PIPELINE_SLUG environment variable or ensure seed.json contains a 'pipeline' field. Error: ${error.message}`
33
+ );
34
+ }
35
+ }
36
+
37
+ // Use explicit pipeline configuration
38
+ const pipelineConfig = getPipelineConfig(pipelineSlug);
39
+
15
40
  const TASK_REGISTRY =
16
- process.env.PO_TASK_REGISTRY || path.join(CONFIG_DIR, "tasks/index.js");
41
+ process.env.PO_TASK_REGISTRY ||
42
+ path.join(pipelineConfig.tasksDir, "index.js");
17
43
  const PIPELINE_DEF_PATH =
18
- process.env.PO_PIPELINE_PATH || path.join(CONFIG_DIR, "pipeline.json");
19
-
20
- const name = process.argv[2];
21
- if (!name) throw new Error("runner requires pipeline name");
44
+ process.env.PO_PIPELINE_PATH || pipelineConfig.pipelineJsonPath;
22
45
 
23
- const workDir = path.join(CURRENT_DIR, name);
24
46
  const tasksStatusPath = path.join(workDir, "tasks-status.json");
25
47
 
26
48
  const pipeline = JSON.parse(await fs.readFile(PIPELINE_DEF_PATH, "utf8"));
27
- // Add cache busting to force task registry reload
28
- const taskRegistryUrl = `${pathToFileURL(TASK_REGISTRY).href}?t=${Date.now()}`;
29
- const tasks = (await import(taskRegistryUrl)).default;
49
+
50
+ // Validate pipeline format early with a friendly error message
51
+ validatePipelineOrThrow(pipeline, PIPELINE_DEF_PATH);
52
+
53
+ const tasks = (await loadFreshModule(TASK_REGISTRY)).default;
30
54
 
31
55
  const status = JSON.parse(await fs.readFile(tasksStatusPath, "utf8"));
32
56
  const seed = JSON.parse(
@@ -63,9 +87,13 @@ for (const taskName of pipeline.tasks) {
63
87
  workDir,
64
88
  taskDir,
65
89
  seed,
66
- artifacts: pipelineArtifacts,
67
90
  taskName,
68
91
  taskConfig: pipeline.taskConfig?.[taskName] || {},
92
+ statusPath: tasksStatusPath,
93
+ jobId,
94
+ meta: {
95
+ pipelineTasks: [...pipeline.tasks],
96
+ },
69
97
  };
70
98
  const modulePath = tasks[taskName];
71
99
  if (!modulePath) throw new Error(`Task not registered: ${taskName}`);
@@ -78,19 +106,54 @@ for (const taskName of pipeline.tasks) {
78
106
  const result = await runPipeline(absoluteModulePath, ctx);
79
107
 
80
108
  if (!result.ok) {
81
- throw new Error(
82
- `${taskName} failed after ${result.refinementAttempts || 0} attempts: ${result.error?.message || "unknown"}`
83
- );
84
- }
85
-
86
- if (result.context?.output) {
109
+ // Persist execution-logs.json and failure-details.json on task failure
110
+ if (result.logs) {
111
+ await atomicWrite(
112
+ path.join(taskDir, "execution-logs.json"),
113
+ JSON.stringify(result.logs, null, 2)
114
+ );
115
+ }
116
+ const failureDetails = {
117
+ failedStage: result.failedStage,
118
+ error: result.error,
119
+ logs: result.logs,
120
+ context: result.context,
121
+ refinementAttempts: result.refinementAttempts || 0,
122
+ };
87
123
  await atomicWrite(
88
- path.join(taskDir, "output.json"),
89
- JSON.stringify(result.context.output, null, 2)
124
+ path.join(taskDir, "failure-details.json"),
125
+ JSON.stringify(failureDetails, null, 2)
90
126
  );
91
- pipelineArtifacts[taskName] = result.context.output;
127
+
128
+ // Update tasks-status.json with enriched failure context
129
+ await updateStatus(taskName, {
130
+ state: "failed",
131
+ endedAt: now(),
132
+ error: result.error, // Don't double-normalize - use result.error as-is
133
+ failedStage: result.failedStage,
134
+ refinementAttempts: result.refinementAttempts || 0,
135
+ stageLogPath: path.join(
136
+ workDir,
137
+ "files",
138
+ "logs",
139
+ `stage-${result.failedStage}.log`
140
+ ),
141
+ errorContext: {
142
+ previousStage: result.context?.previousStage || "seed",
143
+ dataHasSeed: !!result.context?.data?.seed,
144
+ seedHasData: result.context?.data?.seed?.data !== undefined,
145
+ flagsKeys: Object.keys(result.context?.flags || {}),
146
+ },
147
+ });
148
+
149
+ // Exit with non-zero status but do not throw to keep consistent flow
150
+ process.exitCode = 1;
151
+ process.exit(1);
92
152
  }
93
153
 
154
+ // The file I/O system automatically handles writing outputs and updating tasks-status.json
155
+ // No need to manually write output.json or enumerate artifacts
156
+
94
157
  if (result.logs) {
95
158
  await atomicWrite(
96
159
  path.join(taskDir, "execution-logs.json"),
@@ -98,12 +161,10 @@ for (const taskName of pipeline.tasks) {
98
161
  );
99
162
  }
100
163
 
101
- const artifacts = await getArtifacts(taskDir);
102
164
  await updateStatus(taskName, {
103
165
  state: "done",
104
166
  endedAt: now(),
105
- artifacts,
106
- executionTime:
167
+ executionTimeMs:
107
168
  result.logs?.reduce((total, log) => total + (log.ms || 0), 0) || 0,
108
169
  refinementAttempts: result.refinementAttempts || 0,
109
170
  });
@@ -119,17 +180,16 @@ for (const taskName of pipeline.tasks) {
119
180
  }
120
181
 
121
182
  await fs.mkdir(COMPLETE_DIR, { recursive: true });
122
- const dest = path.join(COMPLETE_DIR, name);
183
+ const dest = path.join(COMPLETE_DIR, jobId);
123
184
  await fs.rename(workDir, dest);
124
185
  await appendLine(
125
186
  path.join(COMPLETE_DIR, "runs.jsonl"),
126
187
  JSON.stringify({
127
- name,
128
- pipelineId: status.pipelineId,
188
+ id: status.id,
129
189
  finishedAt: now(),
130
190
  tasks: Object.keys(status.tasks),
131
191
  totalExecutionTime: Object.values(status.tasks).reduce(
132
- (total, t) => total + (t.executionTime || 0),
192
+ (total, t) => total + (t.executionTimeMs || 0),
133
193
  0
134
194
  ),
135
195
  totalRefinementAttempts: Object.values(status.tasks).reduce(
@@ -147,6 +207,7 @@ function now() {
147
207
  async function updateStatus(taskName, patch) {
148
208
  const current = JSON.parse(await fs.readFile(tasksStatusPath, "utf8"));
149
209
  current.current = taskName;
210
+ current.tasks = current.tasks || {};
150
211
  current.tasks[taskName] = { ...(current.tasks[taskName] || {}), ...patch };
151
212
  await atomicWrite(tasksStatusPath, JSON.stringify(current, null, 2));
152
213
  Object.assign(status, current);
@@ -164,19 +225,12 @@ async function atomicWrite(file, data) {
164
225
  }
165
226
 
166
227
  function normalizeError(e) {
228
+ // If it's already a structured error object with a message string, pass it through
229
+ if (e && typeof e === "object" && typeof e.message === "string") {
230
+ return e;
231
+ }
232
+
167
233
  if (e instanceof Error)
168
234
  return { name: e.name, message: e.message, stack: e.stack };
169
235
  return { message: String(e) };
170
236
  }
171
-
172
- async function getArtifacts(dir) {
173
- const potentialFiles = ["output.json", "letter.json", "execution-logs.json"];
174
- const artifacts = [];
175
- for (const file of potentialFiles) {
176
- try {
177
- await fs.stat(path.join(dir, file));
178
- artifacts.push(file);
179
- } catch {}
180
- }
181
- return artifacts;
182
- }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Deterministic progress computation for pipeline tasks.
3
+ * Provides a single authoritative mapping from (pipelineTaskIds, currentTaskId, currentStageName) → progress percentage.
4
+ */
5
+
6
+ /**
7
+ * Fixed ordered list of all possible stages in a pipeline.
8
+ * The order is canonical and used for deterministic progress calculation.
9
+ */
10
+ export const KNOWN_STAGES = [
11
+ "ingestion",
12
+ "preProcessing",
13
+ "promptTemplating",
14
+ "inference",
15
+ "parsing",
16
+ "validateStructure",
17
+ "validateQuality",
18
+ "critique",
19
+ "refine",
20
+ "finalValidation",
21
+ "integration",
22
+ ];
23
+
24
+ /**
25
+ * Computes deterministic progress percentage for a pipeline execution.
26
+ *
27
+ * Progress is calculated based on the position of the current task in the ordered pipeline
28
+ * and the position of the current stage in the fixed stage list. This ensures that
29
+ * identical inputs always produce the same progress value.
30
+ *
31
+ * @param {string[]} pipelineTaskIds - Ordered list of task IDs in the pipeline
32
+ * @param {string} currentTaskId - ID of the currently executing task
33
+ * @param {string} currentStageName - Name of the current stage being executed
34
+ * @param {string[]} [stages=KNOWN_STAGES] - Stage list to use for calculation (defaults to KNOWN_STAGES)
35
+ * @returns {number} Progress percentage as integer in [0, 100]
36
+ *
37
+ * @example
38
+ * computeDeterministicProgress(
39
+ * ["task-1", "task-2"],
40
+ * "task-1",
41
+ * "ingestion"
42
+ * ); // → 5
43
+ */
44
+ export function computeDeterministicProgress(
45
+ pipelineTaskIds,
46
+ currentTaskId,
47
+ currentStageName,
48
+ stages = KNOWN_STAGES
49
+ ) {
50
+ // Guard against empty pipeline to avoid division by zero
51
+ const totalSteps = Math.max(1, pipelineTaskIds.length * stages.length);
52
+
53
+ // Find task position, fallback to 0 if not found
54
+ const taskIdx = Math.max(0, pipelineTaskIds.indexOf(currentTaskId));
55
+
56
+ // Find stage position, fallback to 0 if not found
57
+ const stageIdx = Math.max(0, stages.indexOf(currentStageName));
58
+
59
+ // Completed steps = (completed tasks * stages per task) + (completed stages in current task)
60
+ // We count the current stage as completed since this is called after stage completion
61
+ const completed = taskIdx * stages.length + (stageIdx + 1);
62
+
63
+ // Calculate percentage and clamp to [0, 100]
64
+ const percent = Math.round((100 * completed) / totalSteps);
65
+ return Math.max(0, Math.min(100, percent));
66
+ }
@@ -0,0 +1,331 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ // Lazy import SSE registry to avoid circular dependencies
5
+ let sseRegistry = null;
6
+ async function getSSERegistry() {
7
+ if (!sseRegistry) {
8
+ try {
9
+ const module = await import("../ui/sse.js");
10
+ sseRegistry = module.sseRegistry;
11
+ } catch (error) {
12
+ // SSE not available in all environments
13
+ return null;
14
+ }
15
+ }
16
+ return sseRegistry;
17
+ }
18
+
19
+ // Instrumentation helper for status writer
20
+ const createStatusWriterLogger = (jobId) => {
21
+ const prefix = `[StatusWriter:${jobId || "unknown"}]`;
22
+ return {
23
+ log: (message, data = null) => {
24
+ console.log(`${prefix} ${message}`, data ? data : "");
25
+ },
26
+ warn: (message, data = null) => {
27
+ console.warn(`${prefix} ${message}`, data ? data : "");
28
+ },
29
+ error: (message, data = null) => {
30
+ console.error(`${prefix} ${message}`, data ? data : "");
31
+ },
32
+ group: (label) => console.group(`${prefix} ${label}`),
33
+ groupEnd: () => console.groupEnd(),
34
+ sse: (eventType, eventData) => {
35
+ console.log(
36
+ `%c${prefix} SSE Broadcast: ${eventType}`,
37
+ "color: #cc6600; font-weight: bold;",
38
+ eventData
39
+ );
40
+ },
41
+ };
42
+ };
43
+
44
+ /**
45
+ * Atomic status writer utility for tasks-status.json
46
+ *
47
+ * Provides atomic updates to job status files with proper error handling
48
+ * and shape validation for the new status schema.
49
+ */
50
+
51
+ /**
52
+ * Default status shape for new files
53
+ */
54
+ function createDefaultStatus(jobId) {
55
+ return {
56
+ id: jobId,
57
+ state: "pending",
58
+ current: null,
59
+ currentStage: null,
60
+ lastUpdated: new Date().toISOString(),
61
+ tasks: {},
62
+ files: {
63
+ artifacts: [],
64
+ logs: [],
65
+ tmp: [],
66
+ },
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Reads and parses tasks-status.json, creates default if missing
72
+ */
73
+ async function readStatusFile(statusPath, jobId) {
74
+ try {
75
+ const content = await fs.readFile(statusPath, "utf8");
76
+ const parsed = JSON.parse(content);
77
+ return parsed;
78
+ } catch (error) {
79
+ if (error.code === "ENOENT") {
80
+ // File doesn't exist, return default structure
81
+ return createDefaultStatus(jobId);
82
+ }
83
+ if (error instanceof SyntaxError) {
84
+ // Invalid JSON, log warning and return default
85
+ console.warn(
86
+ `Invalid JSON in ${statusPath}, creating new status:`,
87
+ error.message
88
+ );
89
+ return createDefaultStatus(jobId);
90
+ }
91
+ throw error;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Atomic write using temp file + rename pattern
97
+ */
98
+ async function atomicWrite(filePath, data) {
99
+ const tmpPath = `${filePath}.tmp.${Date.now()}.${Math.random().toString(36).slice(2)}`;
100
+ try {
101
+ await fs.writeFile(tmpPath, JSON.stringify(data, null, 2), "utf8");
102
+ await fs.rename(tmpPath, filePath);
103
+ } catch (error) {
104
+ // Clean up temp file if write failed
105
+ try {
106
+ await fs.unlink(tmpPath);
107
+ } catch {
108
+ // Ignore cleanup errors
109
+ }
110
+ throw error;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Validates that the status snapshot has required structure.
116
+ *
117
+ * This function preserves all unknown fields, including optional numeric fields
118
+ * like `snapshot.progress`. Only the required root fields are validated and
119
+ * fixed if missing or malformed. Extra fields are passed through unchanged.
120
+ *
121
+ * @param {Object} snapshot - The status snapshot to validate
122
+ * @returns {Object} The validated and normalized snapshot
123
+ */
124
+ function validateStatusSnapshot(snapshot) {
125
+ if (!snapshot || typeof snapshot !== "object") {
126
+ throw new Error("Status snapshot must be an object");
127
+ }
128
+
129
+ // Ensure required root fields exist
130
+ if (typeof snapshot.state !== "string") {
131
+ snapshot.state = "pending";
132
+ }
133
+ if (snapshot.current !== null && typeof snapshot.current !== "string") {
134
+ snapshot.current = null;
135
+ }
136
+ if (
137
+ snapshot.currentStage !== null &&
138
+ typeof snapshot.currentStage !== "string"
139
+ ) {
140
+ snapshot.currentStage = null;
141
+ }
142
+
143
+ // Ensure timestamp exists
144
+ if (!snapshot.lastUpdated || typeof snapshot.lastUpdated !== "string") {
145
+ snapshot.lastUpdated = new Date().toISOString();
146
+ }
147
+
148
+ // Ensure tasks object exists
149
+ if (!snapshot.tasks || typeof snapshot.tasks !== "object") {
150
+ snapshot.tasks = {};
151
+ }
152
+
153
+ // Ensure files object exists with proper structure
154
+ if (!snapshot.files || typeof snapshot.files !== "object") {
155
+ snapshot.files = { artifacts: [], logs: [], tmp: [] };
156
+ } else {
157
+ // Ensure each files array exists
158
+ for (const type of ["artifacts", "logs", "tmp"]) {
159
+ if (!Array.isArray(snapshot.files[type])) {
160
+ snapshot.files[type] = [];
161
+ }
162
+ }
163
+ }
164
+
165
+ return snapshot;
166
+ }
167
+
168
+ /**
169
+ * Atomically updates tasks-status.json with the provided update function
170
+ *
171
+ * @param {string} jobDir - Job directory path containing tasks-status.json
172
+ * @param {Function} updateFn - Function that receives and mutates the status snapshot
173
+ * @returns {Promise<Object>} The updated status snapshot
174
+ *
175
+ * Example:
176
+ * await writeJobStatus(jobDir, (snapshot) => {
177
+ * snapshot.current = "task-1";
178
+ * snapshot.currentStage = "processing";
179
+ * snapshot.tasks["task-1"].currentStage = "processing";
180
+ * snapshot.tasks["task-1"].state = "running";
181
+ * });
182
+ */
183
+ export async function writeJobStatus(jobDir, updateFn) {
184
+ if (!jobDir || typeof jobDir !== "string") {
185
+ throw new Error("jobDir must be a non-empty string");
186
+ }
187
+
188
+ if (typeof updateFn !== "function") {
189
+ throw new Error("updateFn must be a function");
190
+ }
191
+
192
+ const statusPath = path.join(jobDir, "tasks-status.json");
193
+ const jobId = path.basename(jobDir);
194
+ const logger = createStatusWriterLogger(jobId);
195
+
196
+ logger.group("Status Write Operation");
197
+ logger.log(`Updating status for job: ${jobId}`);
198
+ logger.log(`Status file path: ${statusPath}`);
199
+
200
+ // Read existing status or create default
201
+ let snapshot = await readStatusFile(statusPath, jobId);
202
+ logger.log("Current status snapshot:", snapshot);
203
+
204
+ // Validate basic structure
205
+ snapshot = validateStatusSnapshot(snapshot);
206
+
207
+ // Apply user updates
208
+ try {
209
+ const result = updateFn(snapshot);
210
+ // If updateFn returns a value, use it as new snapshot
211
+ if (result !== undefined) {
212
+ snapshot = result;
213
+ }
214
+ logger.log("Status after update function:", snapshot);
215
+ } catch (error) {
216
+ logger.error("Update function failed:", error);
217
+ throw new Error(`Update function failed: ${error.message}`);
218
+ }
219
+
220
+ // Validate final structure
221
+ snapshot = validateStatusSnapshot(snapshot);
222
+
223
+ // Update timestamp
224
+ snapshot.lastUpdated = new Date().toISOString();
225
+
226
+ // Atomic write
227
+ await atomicWrite(statusPath, snapshot);
228
+ logger.log("Status file written successfully");
229
+
230
+ // Emit SSE event for tasks-status.json change
231
+ const registry = await getSSERegistry();
232
+ if (registry) {
233
+ try {
234
+ const eventData = {
235
+ type: "state:change",
236
+ data: {
237
+ path: path.join(jobDir, "tasks-status.json"),
238
+ id: jobId,
239
+ jobId,
240
+ },
241
+ };
242
+ registry.broadcast(eventData);
243
+ logger.sse("state:change", eventData.data);
244
+ logger.log("SSE event broadcasted successfully");
245
+ } catch (error) {
246
+ // Don't fail the write if SSE emission fails
247
+ logger.error("Failed to emit SSE event:", error);
248
+ console.warn(`Failed to emit SSE event: ${error.message}`);
249
+ }
250
+ } else {
251
+ logger.warn("SSE registry not available - no event broadcasted");
252
+ }
253
+
254
+ logger.groupEnd();
255
+ return snapshot;
256
+ }
257
+
258
+ /**
259
+ * Reads tasks-status.json with proper error handling
260
+ *
261
+ * @param {string} jobDir - Job directory path
262
+ * @returns {Promise<Object|null>} Status snapshot or null if file cannot be read
263
+ */
264
+ export async function readJobStatus(jobDir) {
265
+ if (!jobDir || typeof jobDir !== "string") {
266
+ throw new Error("jobDir must be a non-empty string");
267
+ }
268
+
269
+ const statusPath = path.join(jobDir, "tasks-status.json");
270
+
271
+ try {
272
+ // Check if file exists first
273
+ await fs.access(statusPath);
274
+
275
+ const content = await fs.readFile(statusPath, "utf8");
276
+ const parsed = JSON.parse(content);
277
+ return validateStatusSnapshot(parsed);
278
+ } catch (error) {
279
+ if (error.code === "ENOENT") {
280
+ return null;
281
+ }
282
+ if (error instanceof SyntaxError) {
283
+ console.warn(
284
+ `Invalid JSON in ${statusPath}, cannot read status:`,
285
+ error.message
286
+ );
287
+ return null;
288
+ }
289
+ console.warn(`Failed to read status from ${jobDir}:`, error.message);
290
+ return null;
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Utility to update task-specific fields atomically
296
+ *
297
+ * @param {string} jobDir - Job directory path
298
+ * @param {string} taskId - Task identifier
299
+ * @param {Function} taskUpdateFn - Function that receives and mutates the task object
300
+ * @returns {Promise<Object>} The updated status snapshot
301
+ */
302
+ export async function updateTaskStatus(jobDir, taskId, taskUpdateFn) {
303
+ if (!jobDir || typeof jobDir !== "string") {
304
+ throw new Error("jobDir must be a non-empty string");
305
+ }
306
+
307
+ if (!taskId || typeof taskId !== "string") {
308
+ throw new Error("taskId must be a non-empty string");
309
+ }
310
+
311
+ if (typeof taskUpdateFn !== "function") {
312
+ throw new Error("taskUpdateFn must be a function");
313
+ }
314
+
315
+ return writeJobStatus(jobDir, (snapshot) => {
316
+ // Ensure task exists
317
+ if (!snapshot.tasks[taskId]) {
318
+ snapshot.tasks[taskId] = {};
319
+ }
320
+
321
+ const task = snapshot.tasks[taskId];
322
+
323
+ // Apply task updates
324
+ const result = taskUpdateFn(task);
325
+ if (result !== undefined) {
326
+ snapshot.tasks[taskId] = result;
327
+ }
328
+
329
+ return snapshot;
330
+ });
331
+ }