@ryanfw/prompt-orchestration-pipeline 0.5.0 → 0.7.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 +1 -2
- package/package.json +1 -2
- package/src/api/validators/json.js +39 -0
- package/src/components/DAGGrid.jsx +392 -303
- package/src/components/JobCard.jsx +14 -12
- package/src/components/JobDetail.jsx +54 -51
- package/src/components/JobTable.jsx +72 -23
- package/src/components/Layout.jsx +145 -42
- package/src/components/LiveText.jsx +47 -0
- package/src/components/PageSubheader.jsx +75 -0
- package/src/components/TaskDetailSidebar.jsx +216 -0
- package/src/components/TimerText.jsx +82 -0
- package/src/components/UploadSeed.jsx +0 -70
- package/src/components/ui/Logo.jsx +16 -0
- package/src/components/ui/RestartJobModal.jsx +140 -0
- package/src/components/ui/toast.jsx +138 -0
- package/src/config/models.js +322 -0
- package/src/config/statuses.js +119 -0
- package/src/core/config.js +4 -34
- package/src/core/file-io.js +13 -28
- package/src/core/module-loader.js +54 -40
- package/src/core/pipeline-runner.js +65 -26
- package/src/core/status-writer.js +213 -58
- package/src/core/symlink-bridge.js +57 -0
- package/src/core/symlink-utils.js +94 -0
- package/src/core/task-runner.js +321 -437
- package/src/llm/index.js +258 -86
- package/src/pages/Code.jsx +351 -0
- package/src/pages/PipelineDetail.jsx +124 -15
- package/src/pages/PromptPipelineDashboard.jsx +20 -88
- package/src/providers/anthropic.js +83 -69
- package/src/providers/base.js +52 -0
- package/src/providers/deepseek.js +20 -21
- package/src/providers/gemini.js +226 -0
- package/src/providers/openai.js +36 -106
- package/src/providers/zhipu.js +136 -0
- package/src/ui/client/adapters/job-adapter.js +42 -28
- package/src/ui/client/api.js +134 -0
- package/src/ui/client/hooks/useJobDetailWithUpdates.js +65 -179
- package/src/ui/client/index.css +15 -0
- package/src/ui/client/index.html +2 -1
- package/src/ui/client/main.jsx +19 -14
- package/src/ui/client/time-store.js +161 -0
- package/src/ui/config-bridge.js +15 -24
- package/src/ui/config-bridge.node.js +15 -24
- package/src/ui/dist/assets/{index-CxcrauYR.js → index-DqkbzXZ1.js} +2132 -1086
- package/src/ui/dist/assets/style-DBF9NQGk.css +62 -0
- package/src/ui/dist/index.html +4 -3
- package/src/ui/job-reader.js +0 -108
- package/src/ui/public/favicon.svg +12 -0
- package/src/ui/server.js +252 -0
- package/src/ui/sse-enhancer.js +0 -1
- package/src/ui/transformers/list-transformer.js +32 -12
- package/src/ui/transformers/status-transformer.js +29 -42
- package/src/utils/dag.js +8 -4
- package/src/utils/duration.js +13 -19
- package/src/utils/formatters.js +27 -0
- package/src/utils/geometry-equality.js +83 -0
- package/src/utils/pipelines.js +5 -1
- package/src/utils/time-utils.js +40 -0
- package/src/utils/token-cost-calculator.js +294 -0
- package/src/utils/ui.jsx +18 -20
- package/src/components/ui/select.jsx +0 -27
- package/src/lib/utils.js +0 -6
- package/src/ui/client/hooks/useTicker.js +0 -26
- package/src/ui/config-bridge.browser.js +0 -149
- package/src/ui/dist/assets/style-D6K_oQ12.css +0 -62
package/src/ui/dist/index.html
CHANGED
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
7
7
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
8
8
|
<link
|
|
9
|
-
href="https://fonts.googleapis.com/css2?family=
|
|
9
|
+
href="https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400..700;1,400..700&family=Source+Sans+3:ital,wght@0,200..900;1,200..900&display=swap"
|
|
10
10
|
rel="stylesheet"
|
|
11
11
|
/>
|
|
12
12
|
<title>Prompt Pipeline Dashboard</title>
|
|
13
|
-
<
|
|
14
|
-
<
|
|
13
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-DqkbzXZ1.js"></script>
|
|
15
|
+
<link rel="stylesheet" crossorigin href="/assets/style-DBF9NQGk.css">
|
|
15
16
|
</head>
|
|
16
17
|
<body>
|
|
17
18
|
<div id="root"></div>
|
package/src/ui/job-reader.js
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
* - readJob(jobId)
|
|
6
6
|
* - readMultipleJobs(jobIds)
|
|
7
7
|
* - getJobReadingStats(jobIds, results)
|
|
8
|
-
* - validateJobData(jobData, expectedJobId)
|
|
9
8
|
*
|
|
10
9
|
* Uses config-bridge for paths/constants and file-reader for safe file I/O.
|
|
11
10
|
*/
|
|
@@ -44,14 +43,6 @@ export async function readJob(jobId) {
|
|
|
44
43
|
`readJob: will check lock at ${jobDir} and attempt to read ${tasksPath}`
|
|
45
44
|
);
|
|
46
45
|
|
|
47
|
-
// Check locks with retry
|
|
48
|
-
const maxLockAttempts =
|
|
49
|
-
configBridge.Constants?.RETRY_CONFIG?.MAX_ATTEMPTS ?? 3;
|
|
50
|
-
const configuredDelay =
|
|
51
|
-
configBridge.Constants?.RETRY_CONFIG?.DELAY_MS ?? 50;
|
|
52
|
-
// Cap lock retry delay during tests to avoid long waits; use small bound for responsiveness
|
|
53
|
-
const lockDelay = Math.min(configuredDelay, 20);
|
|
54
|
-
|
|
55
46
|
// Check lock with a small, deterministic retry loop.
|
|
56
47
|
// Tests mock isLocked to return true once then false; this loop allows that behavior.
|
|
57
48
|
// Single-check lock flow with one re-check after a short wait.
|
|
@@ -173,102 +164,3 @@ export function getJobReadingStats(jobIds = [], results = []) {
|
|
|
173
164
|
locations,
|
|
174
165
|
};
|
|
175
166
|
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Validate job data conforms to minimal schema and expected job id.
|
|
179
|
-
* Supports both legacy (id, name, tasks) and canonical (jobId, title, tasksStatus) fields.
|
|
180
|
-
* Returns { valid: boolean, warnings: string[], error?: string }
|
|
181
|
-
*/
|
|
182
|
-
export function validateJobData(jobData, expectedJobId) {
|
|
183
|
-
const warnings = [];
|
|
184
|
-
|
|
185
|
-
if (
|
|
186
|
-
jobData === null ||
|
|
187
|
-
typeof jobData !== "object" ||
|
|
188
|
-
Array.isArray(jobData)
|
|
189
|
-
) {
|
|
190
|
-
return { valid: false, error: "Job data must be an object" };
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// Support both legacy and canonical field names
|
|
194
|
-
const hasLegacyId = "id" in jobData;
|
|
195
|
-
const hasCanonicalId = "jobId" in jobData;
|
|
196
|
-
const hasLegacyName = "name" in jobData;
|
|
197
|
-
const hasCanonicalName = "title" in jobData;
|
|
198
|
-
const hasLegacyTasks = "tasks" in jobData;
|
|
199
|
-
const hasCanonicalTasks = "tasksStatus" in jobData;
|
|
200
|
-
|
|
201
|
-
// Required: at least one ID field
|
|
202
|
-
if (!hasLegacyId && !hasCanonicalId) {
|
|
203
|
-
return { valid: false, error: "Missing required field: id or jobId" };
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Required: at least one name field
|
|
207
|
-
if (!hasLegacyName && !hasCanonicalName) {
|
|
208
|
-
return { valid: false, error: "Missing required field: name or title" };
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// Required: createdAt
|
|
212
|
-
if (!("createdAt" in jobData)) {
|
|
213
|
-
return { valid: false, error: "Missing required field: createdAt" };
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// Required: at least one tasks field
|
|
217
|
-
if (!hasLegacyTasks && !hasCanonicalTasks) {
|
|
218
|
-
return {
|
|
219
|
-
valid: false,
|
|
220
|
-
error: "Missing required field: tasks or tasksStatus",
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// Get actual ID for validation
|
|
225
|
-
const actualId = jobData.jobId ?? jobData.id;
|
|
226
|
-
if (actualId !== expectedJobId) {
|
|
227
|
-
warnings.push("Job ID mismatch");
|
|
228
|
-
console.warn(
|
|
229
|
-
`Job ID mismatch: expected ${expectedJobId}, found ${actualId}`
|
|
230
|
-
);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// Validate tasks (prefer canonical, fallback to legacy)
|
|
234
|
-
const tasks = jobData.tasksStatus ?? jobData.tasks;
|
|
235
|
-
if (typeof tasks !== "object" || tasks === null || Array.isArray(tasks)) {
|
|
236
|
-
return { valid: false, error: "Tasks must be an object" };
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
const validStates = configBridge.Constants?.TASK_STATES || [
|
|
240
|
-
"pending",
|
|
241
|
-
"running",
|
|
242
|
-
"done",
|
|
243
|
-
"error",
|
|
244
|
-
];
|
|
245
|
-
|
|
246
|
-
for (const [taskName, task] of Object.entries(tasks)) {
|
|
247
|
-
if (!task || typeof task !== "object") {
|
|
248
|
-
return { valid: false, error: `Task ${taskName} missing state field` };
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
if (!("state" in task)) {
|
|
252
|
-
return { valid: false, error: `Task ${taskName} missing state field` };
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
const state = task.state;
|
|
256
|
-
if (!validStates.includes(state)) {
|
|
257
|
-
warnings.push(`Unknown state: ${state}`);
|
|
258
|
-
console.warn(`Unknown task state for ${taskName}: ${state}`);
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// Add warnings for legacy field usage
|
|
263
|
-
if (hasLegacyId && hasCanonicalId) {
|
|
264
|
-
warnings.push("Both id and jobId present, using jobId");
|
|
265
|
-
}
|
|
266
|
-
if (hasLegacyName && hasCanonicalName) {
|
|
267
|
-
warnings.push("Both name and title present, using title");
|
|
268
|
-
}
|
|
269
|
-
if (hasLegacyTasks && hasCanonicalTasks) {
|
|
270
|
-
warnings.push("Both tasks and tasksStatus present, using tasksStatus");
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
return { valid: true, warnings };
|
|
274
|
-
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<svg
|
|
2
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
3
|
+
width="32"
|
|
4
|
+
height="32"
|
|
5
|
+
viewBox="0 0 1200 1200"
|
|
6
|
+
>
|
|
7
|
+
<path
|
|
8
|
+
fill="#009966"
|
|
9
|
+
d="M406.13 988.31c-17.297 75.047-84.562 131.11-164.86 131.11-93.375 0-169.18-75.797-169.18-169.18s75.797-169.18 169.18-169.18 169.18 75.797 169.18 169.18v1.266h447.74v-167.9H671.63c-14.859 0-29.062-5.906-39.562-16.406s-16.406-24.703-16.406-39.562v-37.312h-317.16c-10.312 0-18.656-8.344-18.656-18.656V355.78h-147.94c-14.859 0-29.062-5.906-39.562-16.406s-16.406-24.75-16.406-39.562v-111.94c0-14.859 5.906-29.109 16.406-39.562 10.5-10.5 24.75-16.406 39.562-16.406h391.78c14.859 0 29.062 5.906 39.562 16.406s16.406 24.75 16.406 39.562v37.312h202.4c9.281-84.609 81.094-150.52 168.14-150.52 93.375 0 169.18 75.797 169.18 169.18s-75.797 169.18-169.18 169.18c-87.047 0-158.86-65.906-168.14-150.52h-202.4v37.312c0 14.859-5.906 29.062-16.406 39.562s-24.75 16.406-39.562 16.406h-206.53v297.24h298.5v-37.312c0-14.859 5.906-29.062 16.406-39.562s24.703-16.406 39.562-16.406h392.63c14.859 0 29.062 5.906 39.562 16.406s16.406 24.703 16.406 39.562v111.94c0 14.859-5.906 29.062-16.406 39.562s-24.75 16.406-39.562 16.406h-168.74v186.56c0 10.312-8.344 18.656-18.656 18.656h-466.4c-1.5 0-2.906-.187-4.312-.516zM225.19 262.45h18.656c10.312 0 18.656-8.344 18.656-18.656s-8.344-18.656-18.656-18.656H225.19c-10.312 0-18.656 8.344-18.656 18.656s8.344 18.656 18.656 18.656zm186.56 0h18.656c10.312 0 18.656-8.344 18.656-18.656s-8.344-18.656-18.656-18.656H411.75c-10.312 0-18.656 8.344-18.656 18.656s8.344 18.656 18.656 18.656zm-93.281 0h18.656c10.312 0 18.656-8.344 18.656-18.656s-8.344-18.656-18.656-18.656h-18.656c-10.312 0-18.656 8.344-18.656 18.656s8.344 18.656 18.656 18.656zm616.18 0h85.5c10.312 0 18.656-8.344 18.656-18.656s-8.344-18.656-18.656-18.656h-85.5l29.062-22.594c8.109-6.328 9.609-18.047 3.281-26.156s-18.047-9.609-26.156-3.281l-71.953 55.969a18.61 18.61 0 0 0 0 29.438l71.953 55.969c8.109 6.328 19.875 4.875 26.156-3.281 6.328-8.109 4.875-19.875-3.281-26.203l-29.062-22.594zm-779.95 696.66l50.391 50.391c7.266 7.313 19.078 7.313 26.391 0l100.73-100.73c7.266-7.266 7.266-19.078 0-26.391-7.266-7.266-19.078-7.266-26.391 0l-87.562 87.562-37.172-37.172c-7.266-7.266-19.078-7.266-26.391 0-7.266 7.266-7.266 19.078 0 26.391zm797.21-268.78h18.656c10.312 0 18.656-8.344 18.656-18.656s-8.344-18.656-18.656-18.656h-18.656c-10.312 0-18.656 8.344-18.656 18.656s8.344 18.656 18.656 18.656zm-186.56 0h18.656c10.312 0 18.656-8.344 18.656-18.656s-8.344-18.656-18.656-18.656h-18.656c-10.312 0-18.656 8.344-18.656 18.656s8.344 18.656 18.656 18.656zm93.281 0h18.656c10.312 0 18.656-8.344 18.656-18.656s-8.344-18.656-18.656-18.656H858.63c-10.312 0-18.656 8.344-18.656 18.656s8.344 18.656 18.656 18.656z"
|
|
10
|
+
fill-rule="evenodd"
|
|
11
|
+
/>
|
|
12
|
+
</svg>
|
package/src/ui/server.js
CHANGED
|
@@ -12,6 +12,8 @@ import * as state from "./state.js";
|
|
|
12
12
|
// Import orchestrator-related functions only in non-test mode
|
|
13
13
|
let submitJobWithValidation;
|
|
14
14
|
import { sseRegistry } from "./sse.js";
|
|
15
|
+
import { resetJobToCleanSlate } from "../core/status-writer.js";
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
15
17
|
import {
|
|
16
18
|
getPendingSeedPath,
|
|
17
19
|
resolvePipelinePaths,
|
|
@@ -29,6 +31,22 @@ const __dirname = path.dirname(__filename);
|
|
|
29
31
|
// Vite dev server instance (populated in development mode)
|
|
30
32
|
let viteServer = null;
|
|
31
33
|
|
|
34
|
+
// In-memory restart guard to prevent duplicate concurrent restarts per job
|
|
35
|
+
const restartingJobs = new Set();
|
|
36
|
+
|
|
37
|
+
// Helper functions for restart guard
|
|
38
|
+
function isRestartInProgress(jobId) {
|
|
39
|
+
return restartingJobs.has(jobId);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function beginRestart(jobId) {
|
|
43
|
+
restartingJobs.add(jobId);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function endRestart(jobId) {
|
|
47
|
+
restartingJobs.delete(jobId);
|
|
48
|
+
}
|
|
49
|
+
|
|
32
50
|
// Configuration
|
|
33
51
|
const PORT = process.env.PORT || 4000;
|
|
34
52
|
const WATCHED_PATHS = (
|
|
@@ -1072,6 +1090,11 @@ function createServer() {
|
|
|
1072
1090
|
`http://${req.headers.host}`
|
|
1073
1091
|
);
|
|
1074
1092
|
|
|
1093
|
+
// DEBUG: Log all API requests
|
|
1094
|
+
if (pathname.startsWith("/api/")) {
|
|
1095
|
+
console.log(`DEBUG: API Request: ${req.method} ${pathname}`);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1075
1098
|
// CORS headers for API endpoints
|
|
1076
1099
|
if (pathname.startsWith("/api/")) {
|
|
1077
1100
|
// Important for tests: avoid idle keep-alive sockets on short API calls
|
|
@@ -1404,6 +1427,212 @@ function createServer() {
|
|
|
1404
1427
|
return;
|
|
1405
1428
|
}
|
|
1406
1429
|
|
|
1430
|
+
// Route: GET /api/llm/functions
|
|
1431
|
+
if (pathname === "/api/llm/functions" && req.method === "GET") {
|
|
1432
|
+
try {
|
|
1433
|
+
const { PROVIDER_FUNCTIONS } = await import("../config/models.js");
|
|
1434
|
+
|
|
1435
|
+
sendJson(res, 200, PROVIDER_FUNCTIONS);
|
|
1436
|
+
} catch (error) {
|
|
1437
|
+
console.error("Error handling /api/llm/functions:", error);
|
|
1438
|
+
sendJson(res, 500, {
|
|
1439
|
+
ok: false,
|
|
1440
|
+
error: "internal_error",
|
|
1441
|
+
message: "Failed to get LLM functions",
|
|
1442
|
+
});
|
|
1443
|
+
}
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
// Route: POST /api/jobs/:jobId/restart
|
|
1448
|
+
if (
|
|
1449
|
+
pathname.startsWith("/api/jobs/") &&
|
|
1450
|
+
pathname.endsWith("/restart") &&
|
|
1451
|
+
req.method === "POST"
|
|
1452
|
+
) {
|
|
1453
|
+
const pathMatch = pathname.match(/^\/api\/jobs\/([^\/]+)\/restart$/);
|
|
1454
|
+
if (!pathMatch) {
|
|
1455
|
+
sendJson(res, 400, {
|
|
1456
|
+
ok: false,
|
|
1457
|
+
error: "bad_request",
|
|
1458
|
+
message: "Invalid path format",
|
|
1459
|
+
});
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
const [, jobId] = pathMatch;
|
|
1464
|
+
const dataDir = process.env.PO_ROOT || DATA_DIR;
|
|
1465
|
+
|
|
1466
|
+
try {
|
|
1467
|
+
// Validate jobId
|
|
1468
|
+
if (!jobId || typeof jobId !== "string" || jobId.trim() === "") {
|
|
1469
|
+
sendJson(res, 400, {
|
|
1470
|
+
ok: false,
|
|
1471
|
+
error: "bad_request",
|
|
1472
|
+
message: "jobId is required",
|
|
1473
|
+
});
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
// Resolve job lifecycle
|
|
1478
|
+
const lifecycle = await resolveJobLifecycle(dataDir, jobId);
|
|
1479
|
+
if (!lifecycle) {
|
|
1480
|
+
sendJson(res, 404, {
|
|
1481
|
+
ok: false,
|
|
1482
|
+
code: "job_not_found",
|
|
1483
|
+
message: "Job not found",
|
|
1484
|
+
});
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
// Only support current lifecycle for MVP
|
|
1489
|
+
if (lifecycle !== "current") {
|
|
1490
|
+
sendJson(res, 409, {
|
|
1491
|
+
ok: false,
|
|
1492
|
+
code: "unsupported_lifecycle",
|
|
1493
|
+
message:
|
|
1494
|
+
"Job restart is only supported for jobs in 'current' lifecycle",
|
|
1495
|
+
});
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
// Check if job is already running
|
|
1500
|
+
const jobDir = getJobDirectoryPath(dataDir, jobId, "current");
|
|
1501
|
+
const statusPath = path.join(jobDir, "tasks-status.json");
|
|
1502
|
+
|
|
1503
|
+
let snapshot;
|
|
1504
|
+
try {
|
|
1505
|
+
const content = await fs.promises.readFile(statusPath, "utf8");
|
|
1506
|
+
snapshot = JSON.parse(content);
|
|
1507
|
+
} catch (error) {
|
|
1508
|
+
if (error.code === "ENOENT") {
|
|
1509
|
+
sendJson(res, 404, {
|
|
1510
|
+
ok: false,
|
|
1511
|
+
code: "job_not_found",
|
|
1512
|
+
message: "Job status file not found",
|
|
1513
|
+
});
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1516
|
+
throw error;
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
// Guard against running jobs
|
|
1520
|
+
if (snapshot.state === "running") {
|
|
1521
|
+
sendJson(res, 409, {
|
|
1522
|
+
ok: false,
|
|
1523
|
+
code: "job_running",
|
|
1524
|
+
message: "Job is currently running",
|
|
1525
|
+
});
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
// Guard against concurrent restarts
|
|
1530
|
+
if (isRestartInProgress(jobId)) {
|
|
1531
|
+
sendJson(res, 409, {
|
|
1532
|
+
ok: false,
|
|
1533
|
+
code: "job_running",
|
|
1534
|
+
message: "Job restart is already in progress",
|
|
1535
|
+
});
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
// Begin restart guard
|
|
1540
|
+
beginRestart(jobId);
|
|
1541
|
+
|
|
1542
|
+
try {
|
|
1543
|
+
// Parse optional fromTask from request body for targeted restart
|
|
1544
|
+
let body = {};
|
|
1545
|
+
try {
|
|
1546
|
+
const rawBody = await readRawBody(req);
|
|
1547
|
+
body = JSON.parse(rawBody.toString("utf8"));
|
|
1548
|
+
} catch (error) {
|
|
1549
|
+
sendJson(res, 400, {
|
|
1550
|
+
ok: false,
|
|
1551
|
+
error: "bad_request",
|
|
1552
|
+
message: "Invalid JSON in request body",
|
|
1553
|
+
});
|
|
1554
|
+
return;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
const { fromTask } = body;
|
|
1558
|
+
|
|
1559
|
+
// Reset job: clean-slate or partial from a specific task
|
|
1560
|
+
const { resetJobFromTask } = await import("../core/status-writer.js");
|
|
1561
|
+
if (fromTask) {
|
|
1562
|
+
await resetJobFromTask(jobDir, fromTask, { clearTokenUsage: true });
|
|
1563
|
+
} else {
|
|
1564
|
+
await resetJobToCleanSlate(jobDir, { clearTokenUsage: true });
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
// Spawn detached pipeline-runner process
|
|
1568
|
+
const runnerPath = path.join(__dirname, "../core/pipeline-runner.js");
|
|
1569
|
+
const base = process.env.PO_ROOT || DATA_DIR;
|
|
1570
|
+
const env = {
|
|
1571
|
+
...process.env,
|
|
1572
|
+
PO_ROOT: base,
|
|
1573
|
+
PO_DATA_DIR: path.join(base, "pipeline-data"),
|
|
1574
|
+
PO_PENDING_DIR: path.join(base, "pipeline-data", "pending"),
|
|
1575
|
+
PO_CURRENT_DIR: path.join(base, "pipeline-data", "current"),
|
|
1576
|
+
PO_COMPLETE_DIR: path.join(base, "pipeline-data", "complete"),
|
|
1577
|
+
...(fromTask && { PO_START_FROM_TASK: fromTask }),
|
|
1578
|
+
};
|
|
1579
|
+
|
|
1580
|
+
const child = spawn(process.execPath, [runnerPath, jobId], {
|
|
1581
|
+
env,
|
|
1582
|
+
stdio: "ignore",
|
|
1583
|
+
detached: true,
|
|
1584
|
+
});
|
|
1585
|
+
|
|
1586
|
+
// Unref the child process so it runs in the background
|
|
1587
|
+
child.unref();
|
|
1588
|
+
|
|
1589
|
+
// Send success response
|
|
1590
|
+
sendJson(res, 202, {
|
|
1591
|
+
ok: true,
|
|
1592
|
+
jobId,
|
|
1593
|
+
mode: "clean-slate",
|
|
1594
|
+
spawned: true,
|
|
1595
|
+
});
|
|
1596
|
+
|
|
1597
|
+
console.log(
|
|
1598
|
+
`Job ${jobId} restarted successfully, detached runner PID: ${child.pid}`
|
|
1599
|
+
);
|
|
1600
|
+
} finally {
|
|
1601
|
+
// Always end restart guard
|
|
1602
|
+
endRestart(jobId);
|
|
1603
|
+
}
|
|
1604
|
+
} catch (error) {
|
|
1605
|
+
console.error(`Error handling POST /api/jobs/${jobId}/restart:`, error);
|
|
1606
|
+
|
|
1607
|
+
// Clean up restart guard on error
|
|
1608
|
+
if (isRestartInProgress(jobId)) {
|
|
1609
|
+
endRestart(jobId);
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
if (error.code === "ENOENT") {
|
|
1613
|
+
sendJson(res, 404, {
|
|
1614
|
+
ok: false,
|
|
1615
|
+
code: "job_not_found",
|
|
1616
|
+
message: "Job directory not found",
|
|
1617
|
+
});
|
|
1618
|
+
} else if (error.code === "spawn failed") {
|
|
1619
|
+
sendJson(res, 500, {
|
|
1620
|
+
ok: false,
|
|
1621
|
+
code: "spawn_failed",
|
|
1622
|
+
message: error.message || "Failed to spawn pipeline runner",
|
|
1623
|
+
});
|
|
1624
|
+
} else {
|
|
1625
|
+
sendJson(res, 500, {
|
|
1626
|
+
ok: false,
|
|
1627
|
+
code: "internal_error",
|
|
1628
|
+
message: "Internal server error",
|
|
1629
|
+
});
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
return;
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1407
1636
|
// Route: GET /api/jobs/:jobId
|
|
1408
1637
|
if (pathname.startsWith("/api/jobs/") && req.method === "GET") {
|
|
1409
1638
|
const jobId = pathname.substring("/api/jobs/".length);
|
|
@@ -1436,6 +1665,25 @@ function createServer() {
|
|
|
1436
1665
|
return;
|
|
1437
1666
|
}
|
|
1438
1667
|
|
|
1668
|
+
// Route: GET /favicon.svg
|
|
1669
|
+
if (pathname === "/favicon.svg" && req.method === "GET") {
|
|
1670
|
+
const faviconPath = path.join(__dirname, "public", "favicon.svg");
|
|
1671
|
+
|
|
1672
|
+
try {
|
|
1673
|
+
const content = await fs.promises.readFile(faviconPath, "utf8");
|
|
1674
|
+
res.writeHead(200, {
|
|
1675
|
+
"Content-Type": "image/svg+xml",
|
|
1676
|
+
"Cache-Control": "public, max-age=3600", // Cache for 1 hour
|
|
1677
|
+
});
|
|
1678
|
+
res.end(content);
|
|
1679
|
+
} catch (error) {
|
|
1680
|
+
console.error("Error serving favicon:", error);
|
|
1681
|
+
res.writeHead(404);
|
|
1682
|
+
res.end("Favicon not found");
|
|
1683
|
+
}
|
|
1684
|
+
return;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1439
1687
|
// Unknown API endpoint fallback (keep API responses in JSON)
|
|
1440
1688
|
if (pathname.startsWith("/api/")) {
|
|
1441
1689
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
@@ -1799,6 +2047,10 @@ export {
|
|
|
1799
2047
|
initializeWatcher,
|
|
1800
2048
|
state,
|
|
1801
2049
|
resolveJobLifecycle,
|
|
2050
|
+
restartingJobs,
|
|
2051
|
+
isRestartInProgress,
|
|
2052
|
+
beginRestart,
|
|
2053
|
+
endRestart,
|
|
1802
2054
|
};
|
|
1803
2055
|
|
|
1804
2056
|
// Start server if run directly
|
package/src/ui/sse-enhancer.js
CHANGED
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
* - cleanup() clears timers
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { detectJobChange } from "./job-change-detector.js";
|
|
18
17
|
import { transformJobStatus } from "./transformers/status-transformer.js";
|
|
19
18
|
import { transformJobListForAPI } from "./transformers/list-transformer.js";
|
|
20
19
|
|
|
@@ -250,28 +250,48 @@ export function transformJobListForAPI(jobs = [], options = {}) {
|
|
|
250
250
|
base.currentStage = job.currentStage;
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
-
if (job.
|
|
254
|
-
// Include
|
|
255
|
-
const
|
|
256
|
-
for (const [taskId, task] of Object.entries(job.
|
|
253
|
+
if (job.tasks && typeof job.tasks === "object") {
|
|
254
|
+
// Include tasks with all required fields for UI computation
|
|
255
|
+
const tasks = {};
|
|
256
|
+
for (const [taskId, task] of Object.entries(job.tasks)) {
|
|
257
257
|
if (task && typeof task === "object") {
|
|
258
|
-
|
|
258
|
+
tasks[taskId] = {
|
|
259
259
|
state: task.state || "pending",
|
|
260
260
|
};
|
|
261
261
|
|
|
262
262
|
// Include optional fields if present
|
|
263
|
-
if (task.startedAt != null)
|
|
264
|
-
|
|
265
|
-
if (task.endedAt != null) tasksStatus[taskId].endedAt = task.endedAt;
|
|
263
|
+
if (task.startedAt != null) tasks[taskId].startedAt = task.startedAt;
|
|
264
|
+
if (task.endedAt != null) tasks[taskId].endedAt = task.endedAt;
|
|
266
265
|
if (task.executionTimeMs != null)
|
|
267
|
-
|
|
266
|
+
tasks[taskId].executionTimeMs = task.executionTimeMs;
|
|
268
267
|
if (task.currentStage != null)
|
|
269
|
-
|
|
268
|
+
tasks[taskId].currentStage = task.currentStage;
|
|
270
269
|
if (task.failedStage != null)
|
|
271
|
-
|
|
270
|
+
tasks[taskId].failedStage = task.failedStage;
|
|
272
271
|
}
|
|
273
272
|
}
|
|
274
|
-
base.
|
|
273
|
+
base.tasks = tasks;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Add costs summary with zeroed structure if job.costs is absent
|
|
277
|
+
if (job.costs && job.costs.summary) {
|
|
278
|
+
base.costsSummary = {
|
|
279
|
+
totalTokens: job.costs.summary.totalTokens || 0,
|
|
280
|
+
totalInputTokens: job.costs.summary.totalInputTokens || 0,
|
|
281
|
+
totalOutputTokens: job.costs.summary.totalOutputTokens || 0,
|
|
282
|
+
totalCost: job.costs.summary.totalCost || 0,
|
|
283
|
+
totalInputCost: job.costs.summary.totalInputCost || 0,
|
|
284
|
+
totalOutputCost: job.costs.summary.totalOutputCost || 0,
|
|
285
|
+
};
|
|
286
|
+
} else {
|
|
287
|
+
base.costsSummary = {
|
|
288
|
+
totalTokens: 0,
|
|
289
|
+
totalInputTokens: 0,
|
|
290
|
+
totalOutputTokens: 0,
|
|
291
|
+
totalCost: 0,
|
|
292
|
+
totalInputCost: 0,
|
|
293
|
+
totalOutputCost: 0,
|
|
294
|
+
};
|
|
275
295
|
}
|
|
276
296
|
|
|
277
297
|
// Only include pipeline metadata if option is enabled
|
|
@@ -1,23 +1,15 @@
|
|
|
1
1
|
import { normalizeTaskFiles } from "../../utils/task-files.js";
|
|
2
2
|
import { derivePipelineMetadata } from "../../utils/pipelines.js";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const states = names.map((n) => tasks[n]?.state);
|
|
15
|
-
|
|
16
|
-
if (states.includes("failed")) return "failed";
|
|
17
|
-
if (states.includes("running")) return "running";
|
|
18
|
-
if (states.every((s) => s === "done")) return "complete";
|
|
19
|
-
return "pending";
|
|
20
|
-
}
|
|
3
|
+
import {
|
|
4
|
+
calculateJobCosts,
|
|
5
|
+
formatCostDataForAPI,
|
|
6
|
+
} from "../../utils/token-cost-calculator.js";
|
|
7
|
+
import {
|
|
8
|
+
VALID_TASK_STATES,
|
|
9
|
+
normalizeTaskState,
|
|
10
|
+
deriveJobStatusFromTasks,
|
|
11
|
+
TaskState,
|
|
12
|
+
} from "../../config/statuses.js";
|
|
21
13
|
|
|
22
14
|
/**
|
|
23
15
|
* Compute job status object { status, progress } and emit warnings for unknown states.
|
|
@@ -46,14 +38,14 @@ export function computeJobStatus(tasksInput, existingProgress = null) {
|
|
|
46
38
|
const t = tasksInput[name];
|
|
47
39
|
const state = t && typeof t === "object" ? t.state : undefined;
|
|
48
40
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
} else {
|
|
55
|
-
normalized[name] = { state };
|
|
41
|
+
const normalizedState = normalizeTaskState(state);
|
|
42
|
+
|
|
43
|
+
// Track unknown states for warning
|
|
44
|
+
if (state != null && state !== normalizedState) {
|
|
45
|
+
unknownStatesFound.add(state);
|
|
56
46
|
}
|
|
47
|
+
|
|
48
|
+
normalized[name] = { state: normalizedState };
|
|
57
49
|
}
|
|
58
50
|
|
|
59
51
|
// Warn for unknown states
|
|
@@ -61,7 +53,7 @@ export function computeJobStatus(tasksInput, existingProgress = null) {
|
|
|
61
53
|
console.warn(`Unknown task state "${s}"`);
|
|
62
54
|
}
|
|
63
55
|
|
|
64
|
-
const status =
|
|
56
|
+
const status = deriveJobStatusFromTasks(Object.values(normalized));
|
|
65
57
|
// Use existing progress if provided, otherwise default to 0
|
|
66
58
|
// Progress is pre-calculated in task-statuses.json, not computed from task states
|
|
67
59
|
const progress = existingProgress !== null ? existingProgress : 0;
|
|
@@ -99,12 +91,11 @@ export function transformTasks(rawTasks) {
|
|
|
99
91
|
const rawState =
|
|
100
92
|
raw && typeof raw === "object" && "state" in raw ? raw.state : undefined;
|
|
101
93
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
94
|
+
const finalState = normalizeTaskState(rawState);
|
|
95
|
+
|
|
96
|
+
// Warn for invalid states (different from normalized)
|
|
97
|
+
if (rawState != null && rawState !== finalState) {
|
|
106
98
|
console.warn(`Invalid task state "${rawState}"`);
|
|
107
|
-
finalState = "pending";
|
|
108
99
|
}
|
|
109
100
|
|
|
110
101
|
const task = {
|
|
@@ -168,7 +159,7 @@ export function transformTasks(rawTasks) {
|
|
|
168
159
|
* - createdAt / updatedAt: ISO strings | null
|
|
169
160
|
* - location: lifecycle bucket
|
|
170
161
|
* - current / currentStage: stage metadata (optional)
|
|
171
|
-
* -
|
|
162
|
+
* - tasks: object keyed by task name
|
|
172
163
|
* - files: normalized job-level files
|
|
173
164
|
*/
|
|
174
165
|
export function transformJobStatus(raw, jobId, location) {
|
|
@@ -189,17 +180,13 @@ export function transformJobStatus(raw, jobId, location) {
|
|
|
189
180
|
const updatedAt = raw.updatedAt || raw.lastUpdated || createdAt || null;
|
|
190
181
|
const resolvedLocation = location || raw.location || null;
|
|
191
182
|
|
|
192
|
-
|
|
193
|
-
const
|
|
194
|
-
const jobStatusObj = computeJobStatus(tasksStatus, raw.progress);
|
|
195
|
-
|
|
183
|
+
const tasks = transformTasks(raw.tasks);
|
|
184
|
+
const jobStatusObj = computeJobStatus(tasks, raw.progress);
|
|
196
185
|
const jobFiles = normalizeTaskFiles(raw.files);
|
|
197
186
|
|
|
198
|
-
//
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
...task,
|
|
202
|
-
}));
|
|
187
|
+
// Calculate costs for this job
|
|
188
|
+
const costs = calculateJobCosts(raw);
|
|
189
|
+
const costData = formatCostDataForAPI(costs);
|
|
203
190
|
|
|
204
191
|
const job = {
|
|
205
192
|
id: jobId, // API expects 'id' not 'jobId'
|
|
@@ -211,9 +198,9 @@ export function transformJobStatus(raw, jobId, location) {
|
|
|
211
198
|
createdAt,
|
|
212
199
|
updatedAt,
|
|
213
200
|
location: resolvedLocation,
|
|
214
|
-
tasksStatus, // Keep tasksStatus for backward compatibility
|
|
215
201
|
tasks, // API expects 'tasks' array
|
|
216
202
|
files: jobFiles,
|
|
203
|
+
costs: costData, // Add cost data to job response
|
|
217
204
|
};
|
|
218
205
|
|
|
219
206
|
if (raw.current != null) job.current = raw.current;
|
package/src/utils/dag.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { TaskState } from "../config/statuses.js";
|
|
2
|
+
|
|
1
3
|
function normalizeJobTasks(tasks) {
|
|
2
4
|
if (!tasks) return {};
|
|
3
5
|
|
|
@@ -53,7 +55,7 @@ export function computeDagItems(job, pipeline) {
|
|
|
53
55
|
const jobTask = jobTasks[taskId];
|
|
54
56
|
return {
|
|
55
57
|
id: taskId,
|
|
56
|
-
status: jobTask ? jobTask.state :
|
|
58
|
+
status: jobTask ? jobTask.state : TaskState.PENDING,
|
|
57
59
|
source: "pipeline",
|
|
58
60
|
stage: computeTaskStage(job, taskId),
|
|
59
61
|
};
|
|
@@ -82,18 +84,20 @@ export function computeActiveIndex(items) {
|
|
|
82
84
|
|
|
83
85
|
// Find first running task
|
|
84
86
|
const firstRunningIndex = items.findIndex(
|
|
85
|
-
(item) => item.status ===
|
|
87
|
+
(item) => item.status === TaskState.RUNNING
|
|
86
88
|
);
|
|
87
89
|
if (firstRunningIndex !== -1) return firstRunningIndex;
|
|
88
90
|
|
|
89
91
|
// Find first failed task
|
|
90
|
-
const firstFailedIndex = items.findIndex(
|
|
92
|
+
const firstFailedIndex = items.findIndex(
|
|
93
|
+
(item) => item.status === TaskState.FAILED
|
|
94
|
+
);
|
|
91
95
|
if (firstFailedIndex !== -1) return firstFailedIndex;
|
|
92
96
|
|
|
93
97
|
// Find last completed task
|
|
94
98
|
let lastDoneIndex = -1;
|
|
95
99
|
items.forEach((item, index) => {
|
|
96
|
-
if (item.status ===
|
|
100
|
+
if (item.status === TaskState.DONE) lastDoneIndex = index;
|
|
97
101
|
});
|
|
98
102
|
|
|
99
103
|
if (lastDoneIndex !== -1) return lastDoneIndex;
|