@ryanfw/prompt-orchestration-pipeline 1.3.2 → 1.3.3
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/docs/http-api.md +66 -0
- package/package.json +1 -1
- package/src/api/__tests__/index.test.ts +311 -3
- package/src/api/index.ts +94 -45
- package/src/config/__tests__/statuses.test.ts +41 -0
- package/src/config/statuses.ts +20 -1
- package/src/core/__tests__/config.test.ts +31 -1
- package/src/core/__tests__/job-concurrency.test.ts +60 -0
- package/src/core/__tests__/job-view.test.ts +859 -0
- package/src/core/__tests__/orchestrator.test.ts +242 -0
- package/src/core/__tests__/runner-liveness.test.ts +865 -0
- package/src/core/__tests__/single-derivation.test.ts +159 -0
- package/src/core/config.ts +19 -0
- package/src/core/job-concurrency.ts +6 -1
- package/src/core/job-view.ts +329 -0
- package/src/core/orchestrator.ts +78 -6
- package/src/core/runner-liveness.ts +276 -0
- package/src/core/status-writer.ts +24 -5
- package/src/ui/client/__tests__/job-adapter.test.ts +78 -0
- package/src/ui/client/adapters/job-adapter.ts +10 -0
- package/src/ui/client/types.ts +2 -0
- package/src/ui/components/JobTable.tsx +29 -12
- package/src/ui/components/__tests__/JobTable.test.tsx +54 -1
- package/src/ui/components/types.ts +2 -0
- package/src/ui/dist/assets/{index--RH3sAt3.js → index-L6cvsCAx.js} +25 -12
- package/src/ui/dist/assets/{index--RH3sAt3.js.map → index-L6cvsCAx.js.map} +1 -1
- package/src/ui/dist/index.html +1 -1
- package/src/ui/embedded-assets.js +6 -6
- package/src/ui/server/__tests__/config-bridge.test.ts +9 -1
- package/src/ui/server/__tests__/job-control-endpoints.test.ts +57 -1
- package/src/ui/server/__tests__/job-endpoints.test.ts +115 -1
- package/src/ui/server/__tests__/sse-enhancer.test.ts +31 -0
- package/src/ui/server/config-bridge.ts +3 -4
- package/src/ui/server/endpoints/__tests__/meta-endpoint.test.ts +1 -0
- package/src/ui/server/endpoints/gate-endpoints.ts +2 -6
- package/src/ui/server/endpoints/job-control-endpoints.ts +7 -68
- package/src/ui/server/endpoints/job-endpoints.ts +6 -0
- package/src/ui/server/endpoints/meta-endpoint.ts +1 -1
- package/src/ui/state/__tests__/snapshot.test.ts +51 -0
- package/src/ui/state/__tests__/types.test.ts +98 -5
- package/src/ui/state/snapshot.ts +1 -1
- package/src/ui/state/transformers/__tests__/list-transformer.test.ts +43 -2
- package/src/ui/state/transformers/__tests__/status-transformer.test.ts +208 -22
- package/src/ui/state/transformers/list-transformer.ts +7 -3
- package/src/ui/state/transformers/status-transformer.ts +36 -170
- package/src/ui/state/types.ts +9 -47
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { deriveJobStatusFromTasks } from "../config/statuses";
|
|
5
|
+
import { createLogger } from "./logger";
|
|
6
|
+
import { readJobStatusResult, writeJobStatus } from "./status-writer";
|
|
7
|
+
import type { StatusSnapshot } from "./status-writer";
|
|
8
|
+
import { readJobSlotLease, withRuntimeLock } from "./job-concurrency";
|
|
9
|
+
import type { JobSlotLease } from "./job-concurrency";
|
|
10
|
+
|
|
11
|
+
export const STALE_RUNNING_GRACE_MS = 30_000;
|
|
12
|
+
|
|
13
|
+
export interface StaleRunningInput {
|
|
14
|
+
status: StatusSnapshot;
|
|
15
|
+
pid: number | null;
|
|
16
|
+
pidAlive: boolean;
|
|
17
|
+
nowMs: number;
|
|
18
|
+
staleAfterMs: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type StaleRunningDecision =
|
|
22
|
+
| { stale: false; reason: "not_running" | "live_pid" | "fresh_status" }
|
|
23
|
+
| { stale: true; runningTaskId: string | null; ageMs: number };
|
|
24
|
+
|
|
25
|
+
export type ReconcileSource = "child-exit" | "boot-sweep";
|
|
26
|
+
|
|
27
|
+
export type ReconcileReason =
|
|
28
|
+
| "marked-failed"
|
|
29
|
+
| "not-running"
|
|
30
|
+
| "no-running-task"
|
|
31
|
+
| "live-pid"
|
|
32
|
+
| "live-lease"
|
|
33
|
+
| "fresh-status"
|
|
34
|
+
| "unreadable";
|
|
35
|
+
|
|
36
|
+
export interface ReconcileResult {
|
|
37
|
+
reconciled: boolean;
|
|
38
|
+
taskId?: string;
|
|
39
|
+
reason: ReconcileReason;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ReconcileOptions {
|
|
43
|
+
dataDir: string;
|
|
44
|
+
jobId: string;
|
|
45
|
+
jobDir: string;
|
|
46
|
+
source: ReconcileSource;
|
|
47
|
+
lockTimeoutMs: number;
|
|
48
|
+
code?: number | null;
|
|
49
|
+
signal?: string | null;
|
|
50
|
+
requireStaleStatus?: boolean;
|
|
51
|
+
staleAfterMs?: number;
|
|
52
|
+
now?: () => number;
|
|
53
|
+
isAlive?: (pid: number) => boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface SweepOptions {
|
|
57
|
+
dataDir: string;
|
|
58
|
+
currentDir: string;
|
|
59
|
+
lockTimeoutMs: number;
|
|
60
|
+
staleAfterMs?: number;
|
|
61
|
+
now?: () => number;
|
|
62
|
+
isAlive?: (pid: number) => boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface SweepResult {
|
|
66
|
+
scanned: number;
|
|
67
|
+
reconciled: string[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function readRunnerPid(jobDir: string): Promise<number | null> {
|
|
71
|
+
try {
|
|
72
|
+
const content = await Bun.file(path.join(jobDir, "runner.pid")).text();
|
|
73
|
+
const pid = parseInt(content.trim(), 10);
|
|
74
|
+
return Number.isNaN(pid) ? null : pid;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function isProcessAlive(pid: number): boolean {
|
|
81
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
82
|
+
try {
|
|
83
|
+
process.kill(pid, 0);
|
|
84
|
+
return true;
|
|
85
|
+
} catch (err) {
|
|
86
|
+
return (err as NodeJS.ErrnoException).code === "EPERM";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function getTaskDerivedStatus(snapshot: StatusSnapshot): string {
|
|
91
|
+
return deriveJobStatusFromTasks(
|
|
92
|
+
Object.values(snapshot.tasks).map((task) => ({ state: task.state })),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function findRunningTaskId(snapshot: StatusSnapshot): string | null {
|
|
97
|
+
if (snapshot.current && snapshot.tasks[snapshot.current]?.state === "running") {
|
|
98
|
+
return snapshot.current;
|
|
99
|
+
}
|
|
100
|
+
return Object.keys(snapshot.tasks).find((taskId) => snapshot.tasks[taskId]?.state === "running") ?? null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function classifyStaleRunningStatus(input: StaleRunningInput): StaleRunningDecision {
|
|
104
|
+
if (getTaskDerivedStatus(input.status) !== "running") {
|
|
105
|
+
return { stale: false, reason: "not_running" };
|
|
106
|
+
}
|
|
107
|
+
if (input.pid !== null && input.pidAlive) {
|
|
108
|
+
return { stale: false, reason: "live_pid" };
|
|
109
|
+
}
|
|
110
|
+
const updatedMs = Date.parse(input.status.lastUpdated);
|
|
111
|
+
if (!Number.isFinite(updatedMs)) {
|
|
112
|
+
return { stale: true, runningTaskId: findRunningTaskId(input.status), ageMs: 0 };
|
|
113
|
+
}
|
|
114
|
+
const ageMs = input.nowMs - updatedMs;
|
|
115
|
+
if (ageMs < input.staleAfterMs) {
|
|
116
|
+
return { stale: false, reason: "fresh_status" };
|
|
117
|
+
}
|
|
118
|
+
return { stale: true, runningTaskId: findRunningTaskId(input.status), ageMs };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const reconcileLogger = createLogger("runner-liveness");
|
|
122
|
+
|
|
123
|
+
function isLeaseLiveOrInFlight(
|
|
124
|
+
lease: JobSlotLease,
|
|
125
|
+
nowMs: number,
|
|
126
|
+
lockTimeoutMs: number,
|
|
127
|
+
isAlive: (pid: number) => boolean,
|
|
128
|
+
): boolean {
|
|
129
|
+
if (typeof lease.pid === "number" && lease.pid > 0) {
|
|
130
|
+
return isAlive(lease.pid);
|
|
131
|
+
}
|
|
132
|
+
const acquiredMs = Date.parse(lease.acquiredAt);
|
|
133
|
+
if (!Number.isFinite(acquiredMs)) return false;
|
|
134
|
+
return nowMs - acquiredMs < lockTimeoutMs;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function isFreshStatus(snapshot: StatusSnapshot, nowMs: number, staleAfterMs: number): boolean {
|
|
138
|
+
const updatedMs = Date.parse(snapshot.lastUpdated);
|
|
139
|
+
return Number.isFinite(updatedMs) && nowMs - updatedMs < staleAfterMs;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function reconcileInterruptedJob(opts: ReconcileOptions): Promise<ReconcileResult> {
|
|
143
|
+
const {
|
|
144
|
+
dataDir,
|
|
145
|
+
jobId,
|
|
146
|
+
jobDir,
|
|
147
|
+
source,
|
|
148
|
+
lockTimeoutMs,
|
|
149
|
+
code,
|
|
150
|
+
signal,
|
|
151
|
+
requireStaleStatus = false,
|
|
152
|
+
staleAfterMs = STALE_RUNNING_GRACE_MS,
|
|
153
|
+
now = Date.now,
|
|
154
|
+
isAlive = isProcessAlive,
|
|
155
|
+
} = opts;
|
|
156
|
+
|
|
157
|
+
return withRuntimeLock(dataDir, lockTimeoutMs, async () => {
|
|
158
|
+
const statusResult = await readJobStatusResult(jobDir);
|
|
159
|
+
if (!statusResult.ok) {
|
|
160
|
+
return { reconciled: false, reason: "unreadable" } satisfies ReconcileResult;
|
|
161
|
+
}
|
|
162
|
+
const snapshot = statusResult.value;
|
|
163
|
+
|
|
164
|
+
const derived = getTaskDerivedStatus(snapshot);
|
|
165
|
+
if (derived !== "running" && snapshot.state !== "running") {
|
|
166
|
+
return { reconciled: false, reason: "not-running" } satisfies ReconcileResult;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const pid = await readRunnerPid(jobDir);
|
|
170
|
+
if (pid !== null && isAlive(pid)) {
|
|
171
|
+
return { reconciled: false, reason: "live-pid" } satisfies ReconcileResult;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const lease = await readJobSlotLease(dataDir, jobId);
|
|
175
|
+
if (lease && isLeaseLiveOrInFlight(lease, now(), lockTimeoutMs, isAlive)) {
|
|
176
|
+
return { reconciled: false, reason: "live-lease" } satisfies ReconcileResult;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (requireStaleStatus && derived === "running") {
|
|
180
|
+
const decision = classifyStaleRunningStatus({
|
|
181
|
+
status: snapshot,
|
|
182
|
+
pid,
|
|
183
|
+
pidAlive: false,
|
|
184
|
+
nowMs: now(),
|
|
185
|
+
staleAfterMs,
|
|
186
|
+
});
|
|
187
|
+
if (!decision.stale) {
|
|
188
|
+
return {
|
|
189
|
+
reconciled: false,
|
|
190
|
+
reason: decision.reason === "not_running" ? "not-running" : "fresh-status",
|
|
191
|
+
} satisfies ReconcileResult;
|
|
192
|
+
}
|
|
193
|
+
} else if (requireStaleStatus && snapshot.state === "running") {
|
|
194
|
+
if (isFreshStatus(snapshot, now(), staleAfterMs)) {
|
|
195
|
+
return { reconciled: false, reason: "fresh-status" } satisfies ReconcileResult;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const taskId = findRunningTaskId(snapshot);
|
|
200
|
+
const nowMs = now();
|
|
201
|
+
await writeJobStatus(jobDir, (s) => {
|
|
202
|
+
if (taskId !== null) {
|
|
203
|
+
const task = s.tasks[taskId];
|
|
204
|
+
if (task) {
|
|
205
|
+
task.state = "failed";
|
|
206
|
+
task.endedAt = new Date(nowMs).toISOString();
|
|
207
|
+
task.error = "runner exited without completing this task";
|
|
208
|
+
task.errorContext = { kind: "runner-interrupted", code, signal, source };
|
|
209
|
+
}
|
|
210
|
+
} else {
|
|
211
|
+
s.current = null;
|
|
212
|
+
s.currentStage = null;
|
|
213
|
+
s.error = {
|
|
214
|
+
name: "RunnerInterrupted",
|
|
215
|
+
message: "runner exited while the job was still marked running",
|
|
216
|
+
};
|
|
217
|
+
s.errorContext = { kind: "runner-interrupted", code, signal, source };
|
|
218
|
+
}
|
|
219
|
+
s.state = "failed";
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
reconcileLogger.log("reconciled interrupted job", { jobId, taskId, pid, code, signal, path: source });
|
|
223
|
+
|
|
224
|
+
return taskId === null
|
|
225
|
+
? { reconciled: true, reason: "marked-failed" } satisfies ReconcileResult
|
|
226
|
+
: { reconciled: true, taskId, reason: "marked-failed" } satisfies ReconcileResult;
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function isErrnoException(value: unknown): value is NodeJS.ErrnoException {
|
|
231
|
+
return value instanceof Error && typeof (value as NodeJS.ErrnoException).code === "string";
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function reconcileOrphanedCurrentJobs(opts: SweepOptions): Promise<SweepResult> {
|
|
235
|
+
const { dataDir, currentDir, lockTimeoutMs, staleAfterMs, now, isAlive } = opts;
|
|
236
|
+
|
|
237
|
+
let entries: string[];
|
|
238
|
+
try {
|
|
239
|
+
const dirents = await readdir(currentDir, { withFileTypes: true });
|
|
240
|
+
entries = dirents.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
241
|
+
} catch (err) {
|
|
242
|
+
if (isErrnoException(err) && err.code === "ENOENT") {
|
|
243
|
+
return { scanned: 0, reconciled: [] };
|
|
244
|
+
}
|
|
245
|
+
throw err;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const reconciled: string[] = [];
|
|
249
|
+
for (const entry of entries) {
|
|
250
|
+
const jobId = entry;
|
|
251
|
+
const jobDir = path.join(currentDir, entry);
|
|
252
|
+
const result = await reconcileInterruptedJob({
|
|
253
|
+
dataDir,
|
|
254
|
+
jobId,
|
|
255
|
+
jobDir,
|
|
256
|
+
source: "boot-sweep",
|
|
257
|
+
requireStaleStatus: true,
|
|
258
|
+
staleAfterMs,
|
|
259
|
+
lockTimeoutMs,
|
|
260
|
+
now,
|
|
261
|
+
isAlive,
|
|
262
|
+
});
|
|
263
|
+
if (result.reconciled) {
|
|
264
|
+
reconciled.push(jobId);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (entries.length > 0) {
|
|
269
|
+
reconcileLogger.log("reconciled orphaned current jobs", {
|
|
270
|
+
scanned: entries.length,
|
|
271
|
+
reconciled: reconciled.length,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return { scanned: entries.length, reconciled };
|
|
276
|
+
}
|
|
@@ -199,18 +199,37 @@ export async function flushJobStatus(jobDir: string): Promise<void> {
|
|
|
199
199
|
|
|
200
200
|
const writeQueues = new Map<string, Promise<StatusSnapshot>>();
|
|
201
201
|
|
|
202
|
-
export
|
|
202
|
+
export type StatusReadResult =
|
|
203
|
+
| { ok: true; value: StatusSnapshot }
|
|
204
|
+
| { ok: false; reason: "missing" | "corrupt" };
|
|
205
|
+
|
|
206
|
+
export async function readJobStatusResult(jobDir: string): Promise<StatusReadResult> {
|
|
203
207
|
if (typeof jobDir !== "string" || jobDir.length === 0) {
|
|
204
208
|
throw new Error("jobDir must be a non-empty string");
|
|
205
209
|
}
|
|
206
210
|
|
|
207
211
|
const statusPath = join(jobDir, STATUS_FILENAME);
|
|
208
212
|
try {
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
213
|
+
const raw = JSON.parse(await Bun.file(statusPath).text());
|
|
214
|
+
return { ok: true, value: validateStatusSnapshot(raw, jobDir) };
|
|
215
|
+
} catch (err: unknown) {
|
|
216
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return { ok: false, reason: "missing" };
|
|
217
|
+
if (err instanceof SyntaxError) return { ok: false, reason: "corrupt" };
|
|
218
|
+
throw err;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** @deprecated conflates missing and corrupt as null; use readJobStatusResult. */
|
|
223
|
+
export async function readJobStatus(jobDir: string): Promise<StatusSnapshot | null> {
|
|
224
|
+
if (typeof jobDir !== "string" || jobDir.length === 0) {
|
|
225
|
+
throw new Error("jobDir must be a non-empty string");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
const result = await readJobStatusResult(jobDir);
|
|
230
|
+
return result.ok ? result.value : null;
|
|
212
231
|
} catch (err) {
|
|
213
|
-
console.warn(`readJobStatus: could not read ${
|
|
232
|
+
console.warn(`readJobStatus: could not read status for ${jobDir}`, err);
|
|
214
233
|
return null;
|
|
215
234
|
}
|
|
216
235
|
}
|
|
@@ -227,6 +227,38 @@ describe("job adapter", () => {
|
|
|
227
227
|
expect(job.__warnings).toEqual(["Unsupported task collection shape"]);
|
|
228
228
|
});
|
|
229
229
|
|
|
230
|
+
it("passes readable:false and the full error payload through to the normalized summary", () => {
|
|
231
|
+
const error = {
|
|
232
|
+
code: "INVALID_JSON",
|
|
233
|
+
message: "Status file is unreadable. Repair or delete tasks-status.json for job \"job-1\" to recover.",
|
|
234
|
+
path: "pipeline-data/current/job-1/tasks-status.json",
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const job = adaptJobSummary({
|
|
238
|
+
jobId: "job-1",
|
|
239
|
+
status: "error",
|
|
240
|
+
tasks: {},
|
|
241
|
+
readable: false,
|
|
242
|
+
error,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
expect(job.readable).toBe(false);
|
|
246
|
+
expect(job.error).toEqual(error);
|
|
247
|
+
expect(job.error?.path).toBe(error.path);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("omits readable and error when the input does not provide them", () => {
|
|
251
|
+
const job = adaptJobSummary({
|
|
252
|
+
jobId: "job-1",
|
|
253
|
+
tasks: { build: { state: "done" } },
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
expect(job.readable).toBeUndefined();
|
|
257
|
+
expect(Object.hasOwn(job, "readable")).toBe(false);
|
|
258
|
+
expect(job.error).toBeUndefined();
|
|
259
|
+
expect(Object.hasOwn(job, "error")).toBe(false);
|
|
260
|
+
});
|
|
261
|
+
|
|
230
262
|
it("disables actions for running jobs", () => {
|
|
231
263
|
const job = adaptJobSummary({
|
|
232
264
|
jobId: "job-1",
|
|
@@ -270,4 +302,50 @@ describe("job adapter", () => {
|
|
|
270
302
|
|
|
271
303
|
expect(deriveAllowedActions(job, ["build", "test"])).toEqual({ start: true, restart: true });
|
|
272
304
|
});
|
|
305
|
+
|
|
306
|
+
it("adapts a canonical failed job to status 'failed' and displayCategory 'errors'", () => {
|
|
307
|
+
const job = adaptJobSummary({
|
|
308
|
+
jobId: "job-1",
|
|
309
|
+
status: "failed",
|
|
310
|
+
tasks: { build: { state: "done" } },
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
expect(job.status).toBe("failed");
|
|
314
|
+
expect(job.displayCategory).toBe("errors");
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("tolerates legacy status 'error' and emits canonical 'failed'", () => {
|
|
318
|
+
const job = adaptJobSummary({
|
|
319
|
+
jobId: "job-1",
|
|
320
|
+
status: "error",
|
|
321
|
+
tasks: { build: { state: "done" } },
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
expect(job.status).toBe("failed");
|
|
325
|
+
expect(job.displayCategory).toBe("errors");
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("uses 'task-0' and 'task-1' fallback ids for unnamed array entries to match the server fixture", () => {
|
|
329
|
+
const tasks = normalizeTasks([
|
|
330
|
+
{ state: "done" },
|
|
331
|
+
{ state: "pending" },
|
|
332
|
+
]);
|
|
333
|
+
|
|
334
|
+
expect(Object.keys(tasks).sort()).toEqual(["task-0", "task-1"]);
|
|
335
|
+
expect(tasks["task-0"]?.name).toBe("task-0");
|
|
336
|
+
expect(tasks["task-0"]?.state).toBe("done");
|
|
337
|
+
expect(tasks["task-1"]?.name).toBe("task-1");
|
|
338
|
+
expect(tasks["task-1"]?.state).toBe("pending");
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("uses explicit task names as object keys when array entries are named", () => {
|
|
342
|
+
const tasks = normalizeTasks([
|
|
343
|
+
{ name: "build", state: "done" },
|
|
344
|
+
{ name: "test", state: "pending" },
|
|
345
|
+
]);
|
|
346
|
+
|
|
347
|
+
expect(Object.keys(tasks).sort()).toEqual(["build", "test"]);
|
|
348
|
+
expect(tasks["build"]?.name).toBe("build");
|
|
349
|
+
expect(tasks["test"]?.name).toBe("test");
|
|
350
|
+
});
|
|
273
351
|
});
|
|
@@ -108,6 +108,14 @@ function getWarnings(rawTasks: unknown): string[] {
|
|
|
108
108
|
return ["Unsupported task collection shape"];
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
function isErrorShape(value: unknown): value is { code: string; message: string; path?: string } {
|
|
112
|
+
if (!isRecord(value)) return false;
|
|
113
|
+
if (typeof value["code"] !== "string") return false;
|
|
114
|
+
if (typeof value["message"] !== "string") return false;
|
|
115
|
+
if ("path" in value && typeof value["path"] !== "string") return false;
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
111
119
|
function normalizeCostsSummary(costs: unknown): CostsSummary {
|
|
112
120
|
if (!isRecord(costs)) return EMPTY_COSTS;
|
|
113
121
|
return {
|
|
@@ -202,6 +210,8 @@ function adaptBaseJob(apiJob: Record<string, unknown>): NormalizedJobSummary {
|
|
|
202
210
|
totalTokens: toNumber(apiJob["totalTokens"], normalizeCostsSummary(apiJob["costsSummary"]).totalTokens),
|
|
203
211
|
displayCategory: getDisplayCategory(status),
|
|
204
212
|
__warnings: getWarnings(rawTasks),
|
|
213
|
+
...(typeof apiJob["readable"] === "boolean" ? { readable: apiJob["readable"] } : {}),
|
|
214
|
+
...(isErrorShape(apiJob["error"]) ? { error: apiJob["error"] } : {}),
|
|
205
215
|
};
|
|
206
216
|
}
|
|
207
217
|
|
package/src/ui/client/types.ts
CHANGED
|
@@ -155,6 +155,8 @@ export interface NormalizedJobSummary {
|
|
|
155
155
|
totalTokens?: number;
|
|
156
156
|
displayCategory: string;
|
|
157
157
|
__warnings?: string[];
|
|
158
|
+
readable?: boolean;
|
|
159
|
+
error?: { code: string; message: string; path?: string };
|
|
158
160
|
}
|
|
159
161
|
|
|
160
162
|
export interface TaskCostBreakdown {
|
|
@@ -65,6 +65,8 @@ export default function JobTable({
|
|
|
65
65
|
<tbody className="divide-y divide-gray-200">
|
|
66
66
|
{jobs.map((job) => {
|
|
67
67
|
const validId = Boolean(job.id);
|
|
68
|
+
const readable = job.readable !== false;
|
|
69
|
+
const clickable = validId && readable;
|
|
68
70
|
const taskMap = Array.isArray(job.tasks) ? Object.fromEntries(job.tasks.map((task) => [task.name, task] as const)) : job.tasks;
|
|
69
71
|
const currentTaskId =
|
|
70
72
|
typeof job.current === "string" ? job.current : job.current?.taskName;
|
|
@@ -77,35 +79,50 @@ export default function JobTable({
|
|
|
77
79
|
return (
|
|
78
80
|
<tr
|
|
79
81
|
key={job.jobId}
|
|
80
|
-
className={
|
|
81
|
-
onClick={() =>
|
|
82
|
-
tabIndex={
|
|
82
|
+
className={clickable ? "cursor-pointer transition hover:bg-gray-50" : "cursor-not-allowed opacity-60"}
|
|
83
|
+
onClick={() => clickable && onOpenJob(job.id)}
|
|
84
|
+
tabIndex={clickable ? 0 : -1}
|
|
83
85
|
onKeyDown={(event) => {
|
|
84
|
-
if (
|
|
86
|
+
if (clickable && (event.key === "Enter" || event.key === " ")) onOpenJob(job.id);
|
|
85
87
|
}}
|
|
86
88
|
>
|
|
87
89
|
<td className="px-4 py-4">
|
|
88
90
|
<div className="font-medium text-[#6d28d9]">{job.name}</div>
|
|
89
91
|
<div className="text-xs text-gray-500">{job.id}</div>
|
|
92
|
+
{!readable && job.error ? <div className="text-xs text-red-600">{job.error.message}</div> : null}
|
|
90
93
|
</td>
|
|
91
94
|
<td className="px-4 py-4 text-sm text-gray-700">{job.pipelineLabel ?? job.pipeline ?? pipeline?.name ?? "—"}</td>
|
|
92
95
|
<td className="px-4 py-4">
|
|
93
|
-
|
|
96
|
+
{readable ? (
|
|
97
|
+
<Badge intent={getIntent(job.status)}>{job.status}</Badge>
|
|
98
|
+
) : (
|
|
99
|
+
<Badge intent="red">Unreadable</Badge>
|
|
100
|
+
)}
|
|
94
101
|
</td>
|
|
95
102
|
<td className="px-4 py-4 text-sm text-gray-700">{currentTask?.name ?? currentTaskId ?? "—"}</td>
|
|
96
103
|
<td className="px-4 py-4">
|
|
97
|
-
|
|
98
|
-
<
|
|
99
|
-
|
|
100
|
-
|
|
104
|
+
{readable ? (
|
|
105
|
+
<div className="flex min-w-[160px] items-center gap-3">
|
|
106
|
+
<Progress value={job.progress} variant={job.status === "failed" ? "error" : job.status === "complete" ? "completed" : job.status === "running" ? "running" : "pending"} className="flex-1" />
|
|
107
|
+
<span className="text-sm text-gray-600">{job.progress}%</span>
|
|
108
|
+
</div>
|
|
109
|
+
) : (
|
|
110
|
+
"—"
|
|
111
|
+
)}
|
|
101
112
|
</td>
|
|
102
113
|
<td className="px-4 py-4 text-sm text-gray-700">{countCompleted(job)} of {job.taskCount}</td>
|
|
103
114
|
<td className="px-4 py-4">
|
|
104
|
-
|
|
105
|
-
|
|
115
|
+
{readable ? (
|
|
116
|
+
<>
|
|
117
|
+
<div className="text-sm text-gray-700">{totalCost > 0 ? formatCurrency4(totalCost) : "—"}</div>
|
|
118
|
+
{totalTokens > 0 ? <div className="text-xs text-gray-500">{formatTokensCompact(totalTokens)}</div> : null}
|
|
119
|
+
</>
|
|
120
|
+
) : (
|
|
121
|
+
"—"
|
|
122
|
+
)}
|
|
106
123
|
</td>
|
|
107
124
|
<td className="px-4 py-4 text-sm text-gray-700">
|
|
108
|
-
{startMs ? <TimerText startMs={startMs} endMs={endMs} granularity="second" /> : "—"}
|
|
125
|
+
{readable ? (startMs ? <TimerText startMs={startMs} endMs={endMs} granularity="second" /> : "—") : "—"}
|
|
109
126
|
</td>
|
|
110
127
|
</tr>
|
|
111
128
|
);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import "./test-dom";
|
|
2
2
|
|
|
3
|
-
import { render } from "@testing-library/react";
|
|
3
|
+
import { fireEvent, render } from "@testing-library/react";
|
|
4
4
|
import { MemoryRouter } from "react-router-dom";
|
|
5
5
|
import { afterEach, expect, mock, test } from "bun:test";
|
|
6
6
|
|
|
@@ -53,3 +53,56 @@ test("JobTable renders waiting status badge", () => {
|
|
|
53
53
|
|
|
54
54
|
expect(view.getByText("waiting")).toBeTruthy();
|
|
55
55
|
});
|
|
56
|
+
|
|
57
|
+
test("JobTable renders an unreadable job as a non-clickable row with badge and recovery text", () => {
|
|
58
|
+
const recoveryMessage =
|
|
59
|
+
'Status file is unreadable (INVALID_JSON). Repair or delete tasks-status.json for job "job-bad" to recover.';
|
|
60
|
+
const onOpenJob = mock((_jobId: string) => {});
|
|
61
|
+
const view = render(
|
|
62
|
+
<MemoryRouter>
|
|
63
|
+
<JobTable
|
|
64
|
+
jobs={[
|
|
65
|
+
makeJob({
|
|
66
|
+
id: "job-bad",
|
|
67
|
+
jobId: "job-bad",
|
|
68
|
+
name: "Bad Job",
|
|
69
|
+
status: "error",
|
|
70
|
+
readable: false,
|
|
71
|
+
error: { code: "INVALID_JSON", message: recoveryMessage },
|
|
72
|
+
}),
|
|
73
|
+
]}
|
|
74
|
+
onOpenJob={onOpenJob}
|
|
75
|
+
/>
|
|
76
|
+
</MemoryRouter>,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const row = view.container.querySelector("tbody tr");
|
|
80
|
+
expect(row).toBeTruthy();
|
|
81
|
+
expect(row?.getAttribute("tabindex")).toBe("-1");
|
|
82
|
+
expect(row?.className).toContain("cursor-not-allowed");
|
|
83
|
+
|
|
84
|
+
expect(view.getByText("Unreadable")).toBeTruthy();
|
|
85
|
+
expect(view.getByText(recoveryMessage)).toBeTruthy();
|
|
86
|
+
|
|
87
|
+
fireEvent.click(row!);
|
|
88
|
+
fireEvent.keyDown(row!, { key: "Enter" });
|
|
89
|
+
expect(onOpenJob).not.toHaveBeenCalled();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("JobTable renders a normal job without readable or error as a clickable row", () => {
|
|
93
|
+
const onOpenJob = mock((_jobId: string) => {});
|
|
94
|
+
const view = render(
|
|
95
|
+
<MemoryRouter>
|
|
96
|
+
<JobTable jobs={[makeJob()]} onOpenJob={onOpenJob} />
|
|
97
|
+
</MemoryRouter>,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const row = view.container.querySelector("tbody tr");
|
|
101
|
+
expect(row).toBeTruthy();
|
|
102
|
+
expect(row?.getAttribute("tabindex")).toBe("0");
|
|
103
|
+
expect(row?.className).toContain("cursor-pointer");
|
|
104
|
+
|
|
105
|
+
fireEvent.click(row!);
|
|
106
|
+
expect(onOpenJob).toHaveBeenCalledTimes(1);
|
|
107
|
+
expect(onOpenJob).toHaveBeenCalledWith("job-1");
|
|
108
|
+
});
|