@ryanfw/prompt-orchestration-pipeline 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/src/api/index.js +38 -1
- package/src/components/DAGGrid.jsx +180 -53
- package/src/components/JobDetail.jsx +11 -0
- package/src/components/TaskDetailSidebar.jsx +27 -3
- package/src/components/UploadSeed.jsx +2 -2
- package/src/components/ui/RestartJobModal.jsx +26 -6
- package/src/components/ui/StopJobModal.jsx +183 -0
- package/src/core/config.js +7 -3
- package/src/core/lifecycle-policy.js +62 -0
- package/src/core/orchestrator.js +32 -0
- package/src/core/pipeline-runner.js +312 -217
- package/src/core/status-initializer.js +155 -0
- package/src/core/status-writer.js +235 -13
- package/src/pages/Code.jsx +8 -1
- package/src/pages/PipelineDetail.jsx +85 -3
- package/src/pages/PromptPipelineDashboard.jsx +10 -11
- package/src/ui/client/adapters/job-adapter.js +81 -2
- package/src/ui/client/api.js +233 -8
- package/src/ui/client/hooks/useJobDetailWithUpdates.js +92 -0
- package/src/ui/client/hooks/useJobList.js +14 -1
- package/src/ui/dist/app.js +262 -0
- package/src/ui/dist/assets/{index-DqkbzXZ1.js → index-B320avRx.js} +5051 -2186
- package/src/ui/dist/assets/index-B320avRx.js.map +1 -0
- package/src/ui/dist/assets/style-BYCoLBnK.css +62 -0
- package/src/ui/dist/favicon.svg +12 -0
- package/src/ui/dist/index.html +2 -2
- package/src/ui/endpoints/file-endpoints.js +330 -0
- package/src/ui/endpoints/job-control-endpoints.js +1001 -0
- package/src/ui/endpoints/job-endpoints.js +62 -0
- package/src/ui/endpoints/sse-endpoints.js +223 -0
- package/src/ui/endpoints/state-endpoint.js +85 -0
- package/src/ui/endpoints/upload-endpoints.js +406 -0
- package/src/ui/express-app.js +182 -0
- package/src/ui/server.js +38 -1788
- package/src/ui/sse-broadcast.js +93 -0
- package/src/ui/utils/http-utils.js +139 -0
- package/src/ui/utils/mime-types.js +196 -0
- package/src/ui/vite.config.js +22 -0
- package/src/ui/zip-utils.js +103 -0
- package/src/utils/jobs.js +39 -0
- package/src/ui/dist/assets/style-DBF9NQGk.css +0 -62
|
@@ -3,6 +3,8 @@ import {
|
|
|
3
3
|
normalizeTaskState,
|
|
4
4
|
deriveJobStatusFromTasks,
|
|
5
5
|
} from "../../../config/statuses.js";
|
|
6
|
+
import { classifyJobForDisplay } from "../../../utils/jobs.js";
|
|
7
|
+
import { decideTransition } from "../../../core/lifecycle-policy.js";
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* Normalize a raw task state into canonical enum.
|
|
@@ -75,6 +77,8 @@ function normalizeTasks(rawTasks) {
|
|
|
75
77
|
: undefined,
|
|
76
78
|
// Preserve tokenUsage if present
|
|
77
79
|
...(t && t.tokenUsage ? { tokenUsage: t.tokenUsage } : {}),
|
|
80
|
+
// Preserve error object if present
|
|
81
|
+
...(t && t.error ? { error: t.error } : {}),
|
|
78
82
|
};
|
|
79
83
|
tasks[name] = taskObj;
|
|
80
84
|
});
|
|
@@ -106,11 +110,28 @@ function normalizeTasks(rawTasks) {
|
|
|
106
110
|
...(typeof t?.failedStage === "string" && t.failedStage.length > 0
|
|
107
111
|
? { failedStage: t.failedStage }
|
|
108
112
|
: {}),
|
|
113
|
+
// Prefer new files.* schema, fallback to legacy artifacts
|
|
114
|
+
files:
|
|
115
|
+
t && t.files
|
|
116
|
+
? {
|
|
117
|
+
artifacts: Array.isArray(t.files.artifacts)
|
|
118
|
+
? t.files.artifacts.slice()
|
|
119
|
+
: [],
|
|
120
|
+
logs: Array.isArray(t.files.logs) ? t.files.logs.slice() : [],
|
|
121
|
+
tmp: Array.isArray(t.files.tmp) ? t.files.tmp.slice() : [],
|
|
122
|
+
}
|
|
123
|
+
: {
|
|
124
|
+
artifacts: [],
|
|
125
|
+
logs: [],
|
|
126
|
+
tmp: [],
|
|
127
|
+
},
|
|
109
128
|
artifacts: Array.isArray(t && t.artifacts)
|
|
110
129
|
? t.artifacts.slice()
|
|
111
130
|
: undefined,
|
|
112
131
|
// Preserve tokenUsage if present
|
|
113
132
|
...(t && t.tokenUsage ? { tokenUsage: t.tokenUsage } : {}),
|
|
133
|
+
// Preserve error object if present
|
|
134
|
+
...(t && t.error ? { error: t.error } : {}),
|
|
114
135
|
};
|
|
115
136
|
});
|
|
116
137
|
return { tasks, warnings };
|
|
@@ -152,7 +173,7 @@ export function adaptJobSummary(apiJob) {
|
|
|
152
173
|
// Demo-only: read canonical fields strictly
|
|
153
174
|
const id = apiJob.jobId;
|
|
154
175
|
const name = apiJob.title || "";
|
|
155
|
-
const rawTasks = apiJob.tasks;
|
|
176
|
+
const rawTasks = apiJob.tasks || apiJob.tasksStatus; // Handle both formats for backward compatibility
|
|
156
177
|
const location = apiJob.location;
|
|
157
178
|
|
|
158
179
|
// Job-level stage metadata
|
|
@@ -206,6 +227,9 @@ export function adaptJobSummary(apiJob) {
|
|
|
206
227
|
job.totalTokens = job.costsSummary.totalTokens;
|
|
207
228
|
}
|
|
208
229
|
|
|
230
|
+
// Compute and attach display category for UI bucketing
|
|
231
|
+
job.displayCategory = classifyJobForDisplay(job);
|
|
232
|
+
|
|
209
233
|
// Include warnings for debugging
|
|
210
234
|
if (warnings.length > 0) job.__warnings = warnings;
|
|
211
235
|
|
|
@@ -221,7 +245,7 @@ export function adaptJobDetail(apiDetail) {
|
|
|
221
245
|
// Demo-only: read canonical fields strictly
|
|
222
246
|
const id = apiDetail.jobId;
|
|
223
247
|
const name = apiDetail.title || "";
|
|
224
|
-
const rawTasks = apiDetail.tasks;
|
|
248
|
+
const rawTasks = apiDetail.tasks || apiDetail.tasksStatus; // Handle both formats for backward compatibility
|
|
225
249
|
const location = apiDetail.location;
|
|
226
250
|
|
|
227
251
|
// Job-level stage metadata
|
|
@@ -265,8 +289,63 @@ export function adaptJobDetail(apiDetail) {
|
|
|
265
289
|
detail.costs = apiDetail.costs;
|
|
266
290
|
}
|
|
267
291
|
|
|
292
|
+
// Compute and attach display category for UI bucketing
|
|
293
|
+
detail.displayCategory = classifyJobForDisplay(detail);
|
|
294
|
+
|
|
268
295
|
// Include warnings for debugging
|
|
269
296
|
if (warnings.length > 0) detail.__warnings = warnings;
|
|
270
297
|
|
|
271
298
|
return detail;
|
|
272
299
|
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* deriveAllowedActions(adaptedJob, pipelineTasks)
|
|
303
|
+
* - adaptedJob: normalized job object from adaptJobSummary/adaptJobDetail
|
|
304
|
+
* - pipelineTasks: array of task names in execution order from pipeline.json
|
|
305
|
+
* Returns { start, restart } boolean flags for UI controls.
|
|
306
|
+
*/
|
|
307
|
+
export function deriveAllowedActions(adaptedJob, pipelineTasks) {
|
|
308
|
+
// Check if any task is running
|
|
309
|
+
const hasRunningTask = Object.values(adaptedJob.tasks || {}).some(
|
|
310
|
+
(task) => task.state === "running"
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
// Default to disabled if job state is running or any task is running
|
|
314
|
+
if (adaptedJob.status === "running" || hasRunningTask) {
|
|
315
|
+
return { start: false, restart: false };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Default to enabled for restart if not running
|
|
319
|
+
const restart = true;
|
|
320
|
+
|
|
321
|
+
// Start requires checking if ANY task can be started (not ALL tasks)
|
|
322
|
+
// Edge case: if no pipeline tasks, default to enabled
|
|
323
|
+
if (pipelineTasks.length === 0) {
|
|
324
|
+
return { start: true, restart };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const start = pipelineTasks.some((taskName) => {
|
|
328
|
+
const task = adaptedJob.tasks[taskName];
|
|
329
|
+
if (!task) return false; // Task not found, skip
|
|
330
|
+
|
|
331
|
+
const taskState = task.state || "pending";
|
|
332
|
+
|
|
333
|
+
// Check if all upstream tasks are done for dependency evaluation
|
|
334
|
+
const taskIndex = pipelineTasks.indexOf(taskName);
|
|
335
|
+
const upstreamTasks = pipelineTasks.slice(0, taskIndex);
|
|
336
|
+
const dependenciesReady = upstreamTasks.every((upstreamTaskName) => {
|
|
337
|
+
const upstreamTask = adaptedJob.tasks[upstreamTaskName];
|
|
338
|
+
return upstreamTask && upstreamTask.state === "done";
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const startDecision = decideTransition({
|
|
342
|
+
op: "start",
|
|
343
|
+
taskState,
|
|
344
|
+
dependenciesReady,
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
return startDecision.ok;
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
return { start, restart };
|
|
351
|
+
}
|
package/src/ui/client/api.js
CHANGED
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Restart a job with clean-slate mode
|
|
6
|
+
* Restart a job with clean-slate mode or from a specific task
|
|
7
7
|
*
|
|
8
8
|
* @param {string} jobId - The ID of the job to restart
|
|
9
9
|
* @param {Object} opts - Options object
|
|
10
|
-
* @param {
|
|
11
|
-
* @param {boolean} opts.
|
|
10
|
+
* @param {string} [opts.fromTask] - Task ID to restart from (inclusive)
|
|
11
|
+
* @param {boolean} [opts.singleTask] - Whether to run only the target task and then stop
|
|
12
|
+
* @param {Object} [opts.options] - Additional options for the restart
|
|
13
|
+
* @param {boolean} [opts.options.clearTokenUsage=true] - Whether to clear token usage
|
|
12
14
|
* @returns {Promise<Object>} Parsed JSON response from the server
|
|
13
15
|
* @throws {Object} Structured error object with { code, message } for non-2xx responses
|
|
14
16
|
*/
|
|
@@ -19,8 +21,16 @@ export async function restartJob(jobId, opts = {}) {
|
|
|
19
21
|
};
|
|
20
22
|
|
|
21
23
|
const requestBody = opts.fromTask
|
|
22
|
-
? {
|
|
23
|
-
|
|
24
|
+
? {
|
|
25
|
+
fromTask: opts.fromTask,
|
|
26
|
+
options,
|
|
27
|
+
...(opts.singleTask !== undefined && { singleTask: opts.singleTask }),
|
|
28
|
+
}
|
|
29
|
+
: {
|
|
30
|
+
mode: "clean-slate",
|
|
31
|
+
options,
|
|
32
|
+
...(opts.singleTask !== undefined && { singleTask: opts.singleTask }),
|
|
33
|
+
};
|
|
24
34
|
|
|
25
35
|
try {
|
|
26
36
|
const response = await fetch(
|
|
@@ -67,6 +77,164 @@ export async function restartJob(jobId, opts = {}) {
|
|
|
67
77
|
}
|
|
68
78
|
}
|
|
69
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Rescan a job to synchronize tasks with the pipeline definition, detecting both added and removed tasks.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} jobId - The ID of the job to rescan
|
|
84
|
+
* @returns {Promise<Object>} Parsed JSON response from the server
|
|
85
|
+
* @throws {Object} Structured error object with { code, message } for non-2xx responses
|
|
86
|
+
*/
|
|
87
|
+
export async function rescanJob(jobId) {
|
|
88
|
+
try {
|
|
89
|
+
const response = await fetch(
|
|
90
|
+
`/api/jobs/${encodeURIComponent(jobId)}/rescan`,
|
|
91
|
+
{
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: {
|
|
94
|
+
"Content-Type": "application/json",
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
if (!response.ok) {
|
|
100
|
+
// Try to parse error response, fall back to status text if parsing fails
|
|
101
|
+
let errorData;
|
|
102
|
+
try {
|
|
103
|
+
errorData = await response.json();
|
|
104
|
+
} catch {
|
|
105
|
+
errorData = { message: response.statusText };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Throw structured error with code and message
|
|
109
|
+
throw {
|
|
110
|
+
code: errorData.code || getErrorCodeFromStatus(response.status),
|
|
111
|
+
message:
|
|
112
|
+
errorData.message || getErrorMessageFromStatus(response.status),
|
|
113
|
+
status: response.status,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Return parsed JSON for successful responses
|
|
118
|
+
return await response.json();
|
|
119
|
+
} catch (error) {
|
|
120
|
+
// Re-throw structured errors as-is
|
|
121
|
+
if (error.code && error.message) {
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Handle network errors or other unexpected errors
|
|
126
|
+
throw {
|
|
127
|
+
code: "network_error",
|
|
128
|
+
message: error.message || "Failed to connect to server",
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Start a specific pending task for a job that is not actively running
|
|
135
|
+
*
|
|
136
|
+
* @param {string} jobId - The ID of the job
|
|
137
|
+
* @param {string} taskId - The ID of the task to start
|
|
138
|
+
* @returns {Promise<Object>} Parsed JSON response from the server
|
|
139
|
+
* @throws {Object} Structured error object with { code, message } for non-2xx responses
|
|
140
|
+
*/
|
|
141
|
+
export async function startTask(jobId, taskId) {
|
|
142
|
+
try {
|
|
143
|
+
const response = await fetch(
|
|
144
|
+
`/api/jobs/${encodeURIComponent(jobId)}/tasks/${encodeURIComponent(taskId)}/start`,
|
|
145
|
+
{
|
|
146
|
+
method: "POST",
|
|
147
|
+
headers: {
|
|
148
|
+
"Content-Type": "application/json",
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
if (!response.ok) {
|
|
154
|
+
// Try to parse error response, fall back to status text if parsing fails
|
|
155
|
+
let errorData;
|
|
156
|
+
try {
|
|
157
|
+
errorData = await response.json();
|
|
158
|
+
} catch {
|
|
159
|
+
errorData = { message: response.statusText };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Throw structured error with code and message
|
|
163
|
+
throw {
|
|
164
|
+
code: errorData.code || getErrorCodeFromStatus(response.status),
|
|
165
|
+
message: getStartTaskErrorMessage(errorData, response.status),
|
|
166
|
+
status: response.status,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Return parsed JSON for successful responses
|
|
171
|
+
return await response.json();
|
|
172
|
+
} catch (error) {
|
|
173
|
+
// Re-throw structured errors as-is
|
|
174
|
+
if (error.code && error.message) {
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Handle network errors or other unexpected errors
|
|
179
|
+
throw {
|
|
180
|
+
code: "network_error",
|
|
181
|
+
message: error.message || "Failed to connect to server",
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Stop a running job's pipeline
|
|
188
|
+
*
|
|
189
|
+
* @param {string} jobId - The ID of the job to stop
|
|
190
|
+
* @returns {Promise<Object>} Parsed JSON response from the server
|
|
191
|
+
* @throws {Object} Structured error object with { code, message } for non-2xx responses
|
|
192
|
+
*/
|
|
193
|
+
export async function stopJob(jobId) {
|
|
194
|
+
try {
|
|
195
|
+
const response = await fetch(
|
|
196
|
+
`/api/jobs/${encodeURIComponent(jobId)}/stop`,
|
|
197
|
+
{
|
|
198
|
+
method: "POST",
|
|
199
|
+
headers: {
|
|
200
|
+
"Content-Type": "application/json",
|
|
201
|
+
},
|
|
202
|
+
}
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
if (!response.ok) {
|
|
206
|
+
// Try to parse error response, fall back to status text if parsing fails
|
|
207
|
+
let errorData;
|
|
208
|
+
try {
|
|
209
|
+
errorData = await response.json();
|
|
210
|
+
} catch {
|
|
211
|
+
errorData = { message: response.statusText };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Throw structured error with code and message
|
|
215
|
+
throw {
|
|
216
|
+
code: errorData.code || getErrorCodeFromStatus(response.status),
|
|
217
|
+
message: getStopErrorMessage(errorData, response.status),
|
|
218
|
+
status: response.status,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Return parsed JSON for successful responses
|
|
223
|
+
return await response.json();
|
|
224
|
+
} catch (error) {
|
|
225
|
+
// Re-throw structured errors as-is
|
|
226
|
+
if (error.code && error.message) {
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Handle network errors or other unexpected errors
|
|
231
|
+
throw {
|
|
232
|
+
code: "network_error",
|
|
233
|
+
message: error.message || "Failed to connect to server",
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
70
238
|
/**
|
|
71
239
|
* Map HTTP status codes to error codes for structured error handling
|
|
72
240
|
*/
|
|
@@ -108,9 +276,6 @@ function getRestartErrorMessage(errorData, status) {
|
|
|
108
276
|
if (errorData.code === "job_running") {
|
|
109
277
|
return "Job is currently running; restart is unavailable.";
|
|
110
278
|
}
|
|
111
|
-
if (errorData.code === "unsupported_lifecycle") {
|
|
112
|
-
return "Job must be in current to restart.";
|
|
113
|
-
}
|
|
114
279
|
if (errorData.message?.includes("job_running")) {
|
|
115
280
|
return "Job is currently running; restart is unavailable.";
|
|
116
281
|
}
|
|
@@ -132,3 +297,63 @@ function getRestartErrorMessage(errorData, status) {
|
|
|
132
297
|
// Fall back to provided message or default
|
|
133
298
|
return errorData.message || "Failed to restart job.";
|
|
134
299
|
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Get specific error message from error response for start task functionality
|
|
303
|
+
*/
|
|
304
|
+
function getStartTaskErrorMessage(errorData, status) {
|
|
305
|
+
// Handle specific 409 conflict errors
|
|
306
|
+
if (status === 409) {
|
|
307
|
+
if (errorData.code === "job_running") {
|
|
308
|
+
return "Job is currently running; start is unavailable.";
|
|
309
|
+
}
|
|
310
|
+
if (errorData.code === "dependencies_not_satisfied") {
|
|
311
|
+
return "Dependencies not satisfied for task.";
|
|
312
|
+
}
|
|
313
|
+
if (errorData.code === "unsupported_lifecycle") {
|
|
314
|
+
return "Job must be in current to start a task.";
|
|
315
|
+
}
|
|
316
|
+
return "Request conflict.";
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Handle 404 errors
|
|
320
|
+
if (status === 404) {
|
|
321
|
+
return "Job not found.";
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Handle 400 errors
|
|
325
|
+
if (status === 400) {
|
|
326
|
+
return errorData.message || "Bad request";
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Handle 500 errors
|
|
330
|
+
if (status === 500) {
|
|
331
|
+
return "Internal server error";
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Fall back to provided message or default
|
|
335
|
+
return errorData.message || "Failed to start task.";
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Get specific error message from error response for stop functionality
|
|
340
|
+
*/
|
|
341
|
+
function getStopErrorMessage(errorData, status) {
|
|
342
|
+
// Handle 404 errors
|
|
343
|
+
if (status === 404) {
|
|
344
|
+
return "Job not found.";
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Handle 409 errors
|
|
348
|
+
if (status === 409) {
|
|
349
|
+
return errorData.message || "Job stop is already in progress.";
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Handle 500 errors
|
|
353
|
+
if (status === 500) {
|
|
354
|
+
return "Internal server error";
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Fall back to provided message or default
|
|
358
|
+
return errorData.message || `Request failed with status ${status}`;
|
|
359
|
+
}
|
|
@@ -347,6 +347,7 @@ export function useJobDetailWithUpdates(jobId) {
|
|
|
347
347
|
newEs.addEventListener("job:removed", onJobRemoved);
|
|
348
348
|
newEs.addEventListener("status:changed", onStatusChanged);
|
|
349
349
|
newEs.addEventListener("state:change", onStateChange);
|
|
350
|
+
newEs.addEventListener("task:updated", onTaskUpdated);
|
|
350
351
|
newEs.addEventListener("error", onError);
|
|
351
352
|
|
|
352
353
|
esRef.current = newEs;
|
|
@@ -373,6 +374,94 @@ export function useJobDetailWithUpdates(jobId) {
|
|
|
373
374
|
return;
|
|
374
375
|
}
|
|
375
376
|
|
|
377
|
+
// Handle task:updated events with task-level merge logic
|
|
378
|
+
if (type === "task:updated") {
|
|
379
|
+
const p = payload || {};
|
|
380
|
+
const { jobId: eventJobId, taskId, task } = p;
|
|
381
|
+
|
|
382
|
+
// Filter by jobId
|
|
383
|
+
if (eventJobId && eventJobId !== jobId) {
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Validate required fields
|
|
388
|
+
if (!taskId || !task) {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
startTransition(() => {
|
|
393
|
+
setData((prev) => {
|
|
394
|
+
// If no previous data or tasks, return unchanged
|
|
395
|
+
if (!prev || !prev.tasks) {
|
|
396
|
+
return prev;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const prevTask = prev.tasks[taskId];
|
|
400
|
+
|
|
401
|
+
// Compare observable fields to determine if update is needed
|
|
402
|
+
const fieldsToCompare = [
|
|
403
|
+
"state",
|
|
404
|
+
"currentStage",
|
|
405
|
+
"failedStage",
|
|
406
|
+
"startedAt",
|
|
407
|
+
"endedAt",
|
|
408
|
+
"attempts",
|
|
409
|
+
"executionTimeMs",
|
|
410
|
+
"error",
|
|
411
|
+
];
|
|
412
|
+
|
|
413
|
+
let hasChanged = false;
|
|
414
|
+
|
|
415
|
+
// If task doesn't exist yet, always treat as changed
|
|
416
|
+
if (!prevTask) {
|
|
417
|
+
hasChanged = true;
|
|
418
|
+
} else {
|
|
419
|
+
// Compare observable fields to determine if update is needed
|
|
420
|
+
for (const field of fieldsToCompare) {
|
|
421
|
+
if (prevTask[field] !== task[field]) {
|
|
422
|
+
hasChanged = true;
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Also compare tokenUsage and files arrays by reference
|
|
428
|
+
if (
|
|
429
|
+
prevTask.tokenUsage !== task.tokenUsage ||
|
|
430
|
+
prevTask.files !== task.files
|
|
431
|
+
) {
|
|
432
|
+
hasChanged = true;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (!hasChanged) {
|
|
437
|
+
return prev; // No change, preserve identity
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Create new tasks map with updated task
|
|
441
|
+
const nextTasks = { ...prev.tasks, [taskId]: task };
|
|
442
|
+
|
|
443
|
+
// Recompute job-level summary fields
|
|
444
|
+
const taskCount = Object.keys(nextTasks).length;
|
|
445
|
+
const doneCount = Object.values(nextTasks).filter(
|
|
446
|
+
(t) => t.state === "done"
|
|
447
|
+
).length;
|
|
448
|
+
const progress =
|
|
449
|
+
taskCount > 0 ? (doneCount / taskCount) * 100 : 0;
|
|
450
|
+
|
|
451
|
+
// Return new job object with updated tasks and summary
|
|
452
|
+
return {
|
|
453
|
+
...prev,
|
|
454
|
+
tasks: nextTasks,
|
|
455
|
+
doneCount,
|
|
456
|
+
taskCount,
|
|
457
|
+
progress,
|
|
458
|
+
lastUpdated: new Date().toISOString(),
|
|
459
|
+
};
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
return; // Skip generic handling for task:updated
|
|
463
|
+
}
|
|
464
|
+
|
|
376
465
|
// Path-matching state:change → schedule debounced refetch
|
|
377
466
|
if (type === "state:change") {
|
|
378
467
|
const d = (payload && (payload.data || payload)) || {};
|
|
@@ -415,6 +504,7 @@ export function useJobDetailWithUpdates(jobId) {
|
|
|
415
504
|
const onStatusChanged = (evt) =>
|
|
416
505
|
handleIncomingEvent("status:changed", evt);
|
|
417
506
|
const onStateChange = (evt) => handleIncomingEvent("state:change", evt);
|
|
507
|
+
const onTaskUpdated = (evt) => handleIncomingEvent("task:updated", evt);
|
|
418
508
|
|
|
419
509
|
es.addEventListener("open", onOpen);
|
|
420
510
|
es.addEventListener("job:updated", onJobUpdated);
|
|
@@ -422,6 +512,7 @@ export function useJobDetailWithUpdates(jobId) {
|
|
|
422
512
|
es.addEventListener("job:removed", onJobRemoved);
|
|
423
513
|
es.addEventListener("status:changed", onStatusChanged);
|
|
424
514
|
es.addEventListener("state:change", onStateChange);
|
|
515
|
+
es.addEventListener("task:updated", onTaskUpdated);
|
|
425
516
|
es.addEventListener("error", onError);
|
|
426
517
|
|
|
427
518
|
// Set connection status from readyState when possible
|
|
@@ -439,6 +530,7 @@ export function useJobDetailWithUpdates(jobId) {
|
|
|
439
530
|
es.removeEventListener("job:removed", onJobRemoved);
|
|
440
531
|
es.removeEventListener("status:changed", onStatusChanged);
|
|
441
532
|
es.removeEventListener("state:change", onStateChange);
|
|
533
|
+
es.removeEventListener("task:updated", onTaskUpdated);
|
|
442
534
|
es.removeEventListener("error", onError);
|
|
443
535
|
es.close();
|
|
444
536
|
} catch (err) {
|
|
@@ -20,7 +20,20 @@ export function useJobList() {
|
|
|
20
20
|
setData(null);
|
|
21
21
|
} else {
|
|
22
22
|
const json = await res.json();
|
|
23
|
-
|
|
23
|
+
if (json && typeof json === "object" && "ok" in json) {
|
|
24
|
+
if (json.ok && Array.isArray(json.data)) {
|
|
25
|
+
setData(json.data);
|
|
26
|
+
} else if (!json.ok) {
|
|
27
|
+
setError({ code: json.code, message: json.message });
|
|
28
|
+
setData(null);
|
|
29
|
+
} else {
|
|
30
|
+
// Fallback: data is not an array; treat as empty to avoid crashes
|
|
31
|
+
setData([]);
|
|
32
|
+
}
|
|
33
|
+
} else {
|
|
34
|
+
// Legacy path: response is already an array
|
|
35
|
+
setData(Array.isArray(json) ? json : []);
|
|
36
|
+
}
|
|
24
37
|
}
|
|
25
38
|
} catch (err) {
|
|
26
39
|
if (err.name === "AbortError") {
|