@ryanfw/prompt-orchestration-pipeline 0.0.1 → 0.4.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/README.md +415 -24
- package/package.json +46 -8
- package/src/api/files.js +48 -0
- package/src/api/index.js +149 -53
- package/src/api/validators/seed.js +141 -0
- package/src/cli/index.js +444 -29
- package/src/cli/run-orchestrator.js +39 -0
- package/src/cli/update-pipeline-json.js +47 -0
- package/src/components/DAGGrid.jsx +649 -0
- package/src/components/JobCard.jsx +96 -0
- package/src/components/JobDetail.jsx +159 -0
- package/src/components/JobTable.jsx +202 -0
- package/src/components/Layout.jsx +134 -0
- package/src/components/TaskFilePane.jsx +570 -0
- package/src/components/UploadSeed.jsx +239 -0
- package/src/components/ui/badge.jsx +20 -0
- package/src/components/ui/button.jsx +43 -0
- package/src/components/ui/card.jsx +20 -0
- package/src/components/ui/focus-styles.css +60 -0
- package/src/components/ui/progress.jsx +26 -0
- package/src/components/ui/select.jsx +27 -0
- package/src/components/ui/separator.jsx +6 -0
- package/src/config/paths.js +99 -0
- package/src/core/config.js +270 -9
- package/src/core/file-io.js +202 -0
- package/src/core/module-loader.js +157 -0
- package/src/core/orchestrator.js +275 -294
- package/src/core/pipeline-runner.js +95 -41
- package/src/core/progress.js +66 -0
- package/src/core/status-writer.js +331 -0
- package/src/core/task-runner.js +719 -73
- package/src/core/validation.js +120 -1
- package/src/lib/utils.js +6 -0
- package/src/llm/README.md +139 -30
- package/src/llm/index.js +222 -72
- package/src/pages/PipelineDetail.jsx +111 -0
- package/src/pages/PromptPipelineDashboard.jsx +223 -0
- package/src/providers/deepseek.js +3 -15
- package/src/ui/client/adapters/job-adapter.js +258 -0
- package/src/ui/client/bootstrap.js +120 -0
- package/src/ui/client/hooks/useJobDetailWithUpdates.js +619 -0
- package/src/ui/client/hooks/useJobList.js +50 -0
- package/src/ui/client/hooks/useJobListWithUpdates.js +335 -0
- package/src/ui/client/hooks/useTicker.js +26 -0
- package/src/ui/client/index.css +31 -0
- package/src/ui/client/index.html +18 -0
- package/src/ui/client/main.jsx +38 -0
- package/src/ui/config-bridge.browser.js +149 -0
- package/src/ui/config-bridge.js +149 -0
- package/src/ui/config-bridge.node.js +310 -0
- package/src/ui/dist/assets/index-CxcrauYR.js +22702 -0
- package/src/ui/dist/assets/style-D6K_oQ12.css +62 -0
- package/src/ui/dist/index.html +19 -0
- package/src/ui/endpoints/job-endpoints.js +300 -0
- package/src/ui/file-reader.js +216 -0
- package/src/ui/job-change-detector.js +83 -0
- package/src/ui/job-index.js +231 -0
- package/src/ui/job-reader.js +274 -0
- package/src/ui/job-scanner.js +188 -0
- package/src/ui/public/app.js +3 -1
- package/src/ui/server.js +1636 -59
- package/src/ui/sse-enhancer.js +149 -0
- package/src/ui/sse.js +204 -0
- package/src/ui/state-snapshot.js +252 -0
- package/src/ui/transformers/list-transformer.js +347 -0
- package/src/ui/transformers/status-transformer.js +307 -0
- package/src/ui/watcher.js +61 -7
- package/src/utils/dag.js +101 -0
- package/src/utils/duration.js +126 -0
- package/src/utils/id-generator.js +30 -0
- package/src/utils/jobs.js +7 -0
- package/src/utils/pipelines.js +44 -0
- package/src/utils/task-files.js +271 -0
- package/src/utils/ui.jsx +76 -0
- package/src/ui/public/index.html +0 -53
- 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
|
|
14
|
-
|
|
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 ||
|
|
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 ||
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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, "
|
|
89
|
-
JSON.stringify(
|
|
124
|
+
path.join(taskDir, "failure-details.json"),
|
|
125
|
+
JSON.stringify(failureDetails, null, 2)
|
|
90
126
|
);
|
|
91
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|