@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
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
|
|
3
|
+
import type { JobView } from "../../../core/job-view";
|
|
3
4
|
import type {
|
|
4
5
|
APIJob,
|
|
5
6
|
AcquireResult,
|
|
6
7
|
CanonicalJob,
|
|
8
|
+
CanonicalJobStatus,
|
|
7
9
|
ChangeTrackerState,
|
|
8
10
|
ComposeSnapshotOptions,
|
|
9
11
|
FilterOptions,
|
|
@@ -44,7 +46,7 @@ describe("ui/state types", () => {
|
|
|
44
46
|
jobId: "job-1",
|
|
45
47
|
name: "Job 1",
|
|
46
48
|
title: "Job 1",
|
|
47
|
-
status: "
|
|
49
|
+
status: "failed",
|
|
48
50
|
progress: 0,
|
|
49
51
|
createdAt: null,
|
|
50
52
|
updatedAt: null,
|
|
@@ -56,15 +58,15 @@ describe("ui/state types", () => {
|
|
|
56
58
|
const grouped: GroupedJobs = {
|
|
57
59
|
running: [],
|
|
58
60
|
waiting: [],
|
|
59
|
-
|
|
60
|
-
pending: [
|
|
61
|
+
failed: [],
|
|
62
|
+
pending: [],
|
|
61
63
|
complete: [],
|
|
62
64
|
};
|
|
63
65
|
const filter: FilterOptions = { status: "pending", location: "current" };
|
|
64
66
|
const apiJob: APIJob = {
|
|
65
67
|
jobId: "job-1",
|
|
66
68
|
title: "Job 1",
|
|
67
|
-
status: "
|
|
69
|
+
status: "failed",
|
|
68
70
|
progress: 0,
|
|
69
71
|
createdAt: null,
|
|
70
72
|
updatedAt: null,
|
|
@@ -96,9 +98,100 @@ describe("ui/state types", () => {
|
|
|
96
98
|
expect(snapshotDeps.readJob).toBeUndefined();
|
|
97
99
|
expect(schema.fileName).toBe("seed.json");
|
|
98
100
|
expect(result.response).toBeInstanceOf(Response);
|
|
99
|
-
expect(
|
|
101
|
+
expect(job.status).toBe("failed");
|
|
102
|
+
expect(grouped.failed).toEqual([]);
|
|
100
103
|
expect(filter.location).toBe("current");
|
|
101
104
|
expect(apiJob.costsSummary.totalCost).toBe(0);
|
|
105
|
+
expect(apiJob.status).toBe("failed");
|
|
102
106
|
expect(stats.transformationRate).toBe(0);
|
|
103
107
|
});
|
|
108
|
+
|
|
109
|
+
it("rejects status:\"error\" as a CanonicalJobStatus at the type level", () => {
|
|
110
|
+
// @ts-expect-error — "error" is not assignable to CanonicalJobStatus
|
|
111
|
+
const rejected: CanonicalJobStatus = "error";
|
|
112
|
+
expect(rejected).toBe("error");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("rejects status:\"error\" on a CanonicalJob literal", () => {
|
|
116
|
+
const job: CanonicalJob = {
|
|
117
|
+
id: "job-1",
|
|
118
|
+
jobId: "job-1",
|
|
119
|
+
name: "Job 1",
|
|
120
|
+
title: "Job 1",
|
|
121
|
+
// @ts-expect-error — "error" is not assignable to CanonicalJobStatus
|
|
122
|
+
status: "error",
|
|
123
|
+
progress: 0,
|
|
124
|
+
createdAt: null,
|
|
125
|
+
updatedAt: null,
|
|
126
|
+
location: "current",
|
|
127
|
+
tasks: {},
|
|
128
|
+
files: {},
|
|
129
|
+
costs: {},
|
|
130
|
+
};
|
|
131
|
+
expect(job.jobId).toBe("job-1");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("rejects status:\"error\" on an APIJob literal", () => {
|
|
135
|
+
const apiJob: APIJob = {
|
|
136
|
+
jobId: "job-1",
|
|
137
|
+
title: "Job 1",
|
|
138
|
+
// @ts-expect-error — "error" is not assignable to CanonicalJobStatus
|
|
139
|
+
status: "error",
|
|
140
|
+
progress: 0,
|
|
141
|
+
createdAt: null,
|
|
142
|
+
updatedAt: null,
|
|
143
|
+
location: "current",
|
|
144
|
+
tasks: {},
|
|
145
|
+
costsSummary: {
|
|
146
|
+
totalTokens: 0,
|
|
147
|
+
totalInputTokens: 0,
|
|
148
|
+
totalOutputTokens: 0,
|
|
149
|
+
totalCost: 0,
|
|
150
|
+
totalInputCost: 0,
|
|
151
|
+
totalOutputCost: 0,
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
expect(apiJob.jobId).toBe("job-1");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("preserves readable:false and error: { code, message } on a CanonicalJob", () => {
|
|
158
|
+
const job: CanonicalJob = {
|
|
159
|
+
id: "job-1",
|
|
160
|
+
jobId: "job-1",
|
|
161
|
+
name: "Job 1",
|
|
162
|
+
title: "Job 1",
|
|
163
|
+
status: "failed",
|
|
164
|
+
progress: 0,
|
|
165
|
+
createdAt: null,
|
|
166
|
+
updatedAt: null,
|
|
167
|
+
location: "current",
|
|
168
|
+
tasks: {},
|
|
169
|
+
files: {},
|
|
170
|
+
costs: {},
|
|
171
|
+
readable: false,
|
|
172
|
+
error: { code: "E_FOO", message: "broken", path: "/tmp/job-1" },
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
expect(job.readable).toBe(false);
|
|
176
|
+
expect(job.error).toEqual({ code: "E_FOO", message: "broken", path: "/tmp/job-1" });
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("CanonicalJob is structurally identical to JobView", () => {
|
|
180
|
+
const job: CanonicalJob = {
|
|
181
|
+
id: "job-1",
|
|
182
|
+
jobId: "job-1",
|
|
183
|
+
name: "Job 1",
|
|
184
|
+
title: "Job 1",
|
|
185
|
+
status: "failed",
|
|
186
|
+
progress: 0,
|
|
187
|
+
createdAt: null,
|
|
188
|
+
updatedAt: null,
|
|
189
|
+
location: "current",
|
|
190
|
+
tasks: {},
|
|
191
|
+
files: {},
|
|
192
|
+
costs: {},
|
|
193
|
+
};
|
|
194
|
+
const view: JobView = job;
|
|
195
|
+
expect(view.id).toBe("job-1");
|
|
196
|
+
});
|
|
104
197
|
});
|
package/src/ui/state/snapshot.ts
CHANGED
|
@@ -10,7 +10,7 @@ import type {
|
|
|
10
10
|
StateSnapshot,
|
|
11
11
|
} from "./types";
|
|
12
12
|
|
|
13
|
-
const SNAPSHOT_STATUS_ORDER = ["
|
|
13
|
+
const SNAPSHOT_STATUS_ORDER = ["failed", "running", "complete", "pending"] as const;
|
|
14
14
|
|
|
15
15
|
function nowIso(now: (() => Date) | undefined): string {
|
|
16
16
|
return (now ?? (() => new Date()))().toISOString();
|
|
@@ -34,6 +34,7 @@ describe("list-transformer", () => {
|
|
|
34
34
|
it("returns status priorities and sorts valid jobs only", () => {
|
|
35
35
|
expect(getStatusPriority("running")).toBe(4);
|
|
36
36
|
expect(getStatusPriority("waiting")).toBe(4);
|
|
37
|
+
expect(getStatusPriority("failed")).toBe(3);
|
|
37
38
|
expect(getStatusPriority("unknown")).toBe(0);
|
|
38
39
|
|
|
39
40
|
const jobs = sortJobs([
|
|
@@ -57,17 +58,22 @@ describe("list-transformer", () => {
|
|
|
57
58
|
const jobs = [
|
|
58
59
|
makeJob({ id: "job-1", title: "Alpha", status: "running", progress: 50 }),
|
|
59
60
|
makeJob({ id: "job-2", jobId: "job-2", title: "Beta", status: "complete", progress: 100, location: "complete" }),
|
|
61
|
+
makeJob({ id: "job-3", jobId: "job-3", title: "Gamma", status: "failed", progress: 25 }),
|
|
60
62
|
];
|
|
61
63
|
|
|
62
|
-
|
|
64
|
+
const grouped = groupJobsByStatus(jobs);
|
|
65
|
+
expect(grouped).toMatchObject({
|
|
63
66
|
running: [jobs[0]],
|
|
64
67
|
complete: [jobs[1]],
|
|
68
|
+
failed: [jobs[2]],
|
|
65
69
|
waiting: [],
|
|
66
70
|
});
|
|
67
|
-
expect(
|
|
71
|
+
expect(grouped).not.toHaveProperty("error");
|
|
72
|
+
expect(getJobListStats(jobs).averageProgress).toBe(58);
|
|
68
73
|
expect(filterJobs(jobs, "alp")).toEqual([jobs[0]]);
|
|
69
74
|
expect(filterJobs(jobs, "JOB-2")).toEqual([jobs[1]]);
|
|
70
75
|
expect(filterJobs(jobs, "", { status: "complete", location: "complete" })).toEqual([jobs[1]]);
|
|
76
|
+
expect(filterJobs(jobs, "", { status: "failed" })).toEqual([jobs[2]]);
|
|
71
77
|
});
|
|
72
78
|
|
|
73
79
|
it("always includes zeroed costs in API output and computes aggregation stats", () => {
|
|
@@ -103,4 +109,39 @@ describe("list-transformer", () => {
|
|
|
103
109
|
gate,
|
|
104
110
|
});
|
|
105
111
|
});
|
|
112
|
+
|
|
113
|
+
it("keeps an unreadable placeholder with null createdAt through aggregate and transform", () => {
|
|
114
|
+
const error = {
|
|
115
|
+
code: "INVALID_JSON",
|
|
116
|
+
message: "Repair or delete /path/to/tasks-status.json for job \"job-x\" to recover.",
|
|
117
|
+
path: "/path/to/tasks-status.json",
|
|
118
|
+
};
|
|
119
|
+
const placeholder = makeJob({
|
|
120
|
+
id: "job-x",
|
|
121
|
+
jobId: "job-x",
|
|
122
|
+
status: "failed",
|
|
123
|
+
createdAt: null,
|
|
124
|
+
readable: false,
|
|
125
|
+
error,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const aggregated = aggregateAndSortJobs([placeholder], []);
|
|
129
|
+
expect(aggregated).toHaveLength(1);
|
|
130
|
+
expect(aggregated[0]?.id).toBe("job-x");
|
|
131
|
+
|
|
132
|
+
const apiJob = transformJobListForAPI(aggregated)[0];
|
|
133
|
+
expect(apiJob?.jobId).toBe("job-x");
|
|
134
|
+
expect(apiJob?.readable).toBe(false);
|
|
135
|
+
expect(apiJob?.error).toEqual(error);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("does not add readable or error to a normal job in API output", () => {
|
|
139
|
+
const normal = makeJob();
|
|
140
|
+
|
|
141
|
+
expect(aggregateAndSortJobs([normal], [])).toHaveLength(1);
|
|
142
|
+
|
|
143
|
+
const apiJob = transformJobListForAPI([normal])[0];
|
|
144
|
+
expect(apiJob).not.toHaveProperty("readable");
|
|
145
|
+
expect(apiJob).not.toHaveProperty("error");
|
|
146
|
+
});
|
|
106
147
|
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
|
+
import { normalizeJobView } from "../../../../core/job-view";
|
|
3
4
|
import {
|
|
5
|
+
buildUnreadableJob,
|
|
4
6
|
computeJobStatus,
|
|
5
7
|
getTransformationStats,
|
|
6
8
|
transformJobStatus,
|
|
@@ -48,11 +50,11 @@ describe("status-transformer", () => {
|
|
|
48
50
|
expect(job?.id).toBe("job-1");
|
|
49
51
|
expect(job?.jobId).toBe("job-1");
|
|
50
52
|
expect(job?.name).toBe("Title");
|
|
51
|
-
expect(warn).
|
|
53
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining("job id mismatch"));
|
|
52
54
|
warn.mockRestore();
|
|
53
55
|
});
|
|
54
56
|
|
|
55
|
-
it("
|
|
57
|
+
it("computeJobStatus returns running when half the tasks are done", () => {
|
|
56
58
|
const result = computeJobStatus({
|
|
57
59
|
a: { state: "done" },
|
|
58
60
|
b: { state: "done" },
|
|
@@ -63,7 +65,7 @@ describe("status-transformer", () => {
|
|
|
63
65
|
expect(result).toEqual({ status: "running", progress: 50 });
|
|
64
66
|
});
|
|
65
67
|
|
|
66
|
-
it("
|
|
68
|
+
it("computeJobStatus returns pending when some tasks are pending and none running", () => {
|
|
67
69
|
const result = computeJobStatus({
|
|
68
70
|
a: { state: "done" },
|
|
69
71
|
b: { state: "skipped" },
|
|
@@ -77,8 +79,8 @@ describe("status-transformer", () => {
|
|
|
77
79
|
expect(computeJobStatus([])).toEqual({ status: "pending", progress: 0 });
|
|
78
80
|
});
|
|
79
81
|
|
|
80
|
-
it("transformJobStatus
|
|
81
|
-
const
|
|
82
|
+
it("transformJobStatus reads finite record.progress before deriving from tasks", () => {
|
|
83
|
+
const withProgress = transformJobStatus(
|
|
82
84
|
{
|
|
83
85
|
title: "Test",
|
|
84
86
|
progress: 100,
|
|
@@ -91,8 +93,44 @@ describe("status-transformer", () => {
|
|
|
91
93
|
"current",
|
|
92
94
|
);
|
|
93
95
|
|
|
94
|
-
expect(
|
|
95
|
-
expect(
|
|
96
|
+
expect(withProgress?.progress).toBe(100);
|
|
97
|
+
expect(withProgress?.status).toBe("running");
|
|
98
|
+
|
|
99
|
+
const withoutProgress = transformJobStatus(
|
|
100
|
+
{
|
|
101
|
+
title: "Test",
|
|
102
|
+
tasks: {
|
|
103
|
+
a: { state: "done" },
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
"job-2",
|
|
107
|
+
"current",
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
expect(withoutProgress?.progress).toBe(100);
|
|
111
|
+
expect(withoutProgress?.status).toBe("complete");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("transformJobStatus aggregates task tokenUsage costs through the canonical view", () => {
|
|
115
|
+
const job = transformJobStatus(
|
|
116
|
+
{
|
|
117
|
+
title: "Costed",
|
|
118
|
+
state: "done",
|
|
119
|
+
tasks: {
|
|
120
|
+
a: { state: "done", tokenUsage: [["openai:gpt-4o", 12, 6, 0.25]] },
|
|
121
|
+
b: { state: "done", tokenUsage: [["anthropic:claude", 3, 9, 0.5]] },
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
"job-cost",
|
|
125
|
+
"complete",
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
expect(job?.costs).toEqual({
|
|
129
|
+
totalCost: 0.75,
|
|
130
|
+
totalTokens: 30,
|
|
131
|
+
totalInputTokens: 15,
|
|
132
|
+
totalOutputTokens: 15,
|
|
133
|
+
});
|
|
96
134
|
});
|
|
97
135
|
|
|
98
136
|
it("surfaces waiting snapshots and gate metadata", () => {
|
|
@@ -138,7 +176,7 @@ describe("status-transformer", () => {
|
|
|
138
176
|
"current",
|
|
139
177
|
);
|
|
140
178
|
|
|
141
|
-
expect(job?.status).toBe("
|
|
179
|
+
expect(job?.status).toBe("failed");
|
|
142
180
|
});
|
|
143
181
|
|
|
144
182
|
it("maps numeric restartCount on tasks", () => {
|
|
@@ -183,23 +221,15 @@ describe("status-transformer", () => {
|
|
|
183
221
|
expect(tasks.alpha?.lastRetryError).toBeNull();
|
|
184
222
|
});
|
|
185
223
|
|
|
186
|
-
it("
|
|
187
|
-
const
|
|
224
|
+
it("computes transformation stats for valid reads", () => {
|
|
225
|
+
const readResults = [
|
|
188
226
|
{ ok: true, data: { tasks: { a: { state: "done" } } }, jobId: "job-1", location: "current" },
|
|
189
|
-
|
|
190
|
-
|
|
227
|
+
];
|
|
228
|
+
const transformed = transformMultipleJobs(readResults);
|
|
191
229
|
|
|
192
230
|
expect(transformed).toHaveLength(1);
|
|
193
|
-
expect(
|
|
194
|
-
|
|
195
|
-
[
|
|
196
|
-
{ ok: true, data: { tasks: { a: { state: "done" } } }, jobId: "job-1", location: "current" },
|
|
197
|
-
{ ok: false, jobId: "job-2", location: "current" },
|
|
198
|
-
],
|
|
199
|
-
transformed,
|
|
200
|
-
),
|
|
201
|
-
).toEqual({
|
|
202
|
-
totalRead: 2,
|
|
231
|
+
expect(getTransformationStats(readResults, transformed)).toEqual({
|
|
232
|
+
totalRead: 1,
|
|
203
233
|
successfulReads: 1,
|
|
204
234
|
successfulTransforms: 1,
|
|
205
235
|
failedTransforms: 0,
|
|
@@ -207,4 +237,160 @@ describe("status-transformer", () => {
|
|
|
207
237
|
statusDistribution: { complete: 1 },
|
|
208
238
|
});
|
|
209
239
|
});
|
|
240
|
+
|
|
241
|
+
it("computes transformation stats without counting unreadable placeholders as successful transforms", () => {
|
|
242
|
+
const readResults = [
|
|
243
|
+
{ ok: true, data: { tasks: { a: { state: "done" } } }, jobId: "job-1", location: "current" },
|
|
244
|
+
{ ok: false, code: "INVALID_JSON", jobId: "job-2", location: "current" },
|
|
245
|
+
{ ok: false, code: "JOB_NOT_FOUND", jobId: "job-3", location: "current" },
|
|
246
|
+
];
|
|
247
|
+
const transformed = transformMultipleJobs(readResults);
|
|
248
|
+
|
|
249
|
+
expect(transformed).toHaveLength(2);
|
|
250
|
+
expect(getTransformationStats(readResults, transformed)).toEqual({
|
|
251
|
+
totalRead: 3,
|
|
252
|
+
successfulReads: 1,
|
|
253
|
+
successfulTransforms: 1,
|
|
254
|
+
failedTransforms: 0,
|
|
255
|
+
transformationRate: 1,
|
|
256
|
+
statusDistribution: { complete: 1, failed: 1 },
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("buildUnreadableJob maps corrupt reads to an error placeholder with recovery guidance", () => {
|
|
261
|
+
const placeholder = buildUnreadableJob({
|
|
262
|
+
ok: false,
|
|
263
|
+
code: "INVALID_JSON",
|
|
264
|
+
path: "/jobs/job-2/tasks-status.json",
|
|
265
|
+
jobId: "job-2",
|
|
266
|
+
location: "current",
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
expect(placeholder.readable).toBe(false);
|
|
270
|
+
expect(placeholder.status).toBe("failed");
|
|
271
|
+
expect(placeholder.error?.code).toBe("INVALID_JSON");
|
|
272
|
+
expect(placeholder.error?.path).toBe("/jobs/job-2/tasks-status.json");
|
|
273
|
+
const message = placeholder.error?.message.toLowerCase() ?? "";
|
|
274
|
+
expect(message).toContain("repair");
|
|
275
|
+
expect(message).toContain("delete");
|
|
276
|
+
expect(message).toContain("job-2");
|
|
277
|
+
expect(message).toContain("/jobs/job-2/tasks-status.json");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("buildUnreadableJob uses the same fallback code in the message and structured error", () => {
|
|
281
|
+
const placeholder = buildUnreadableJob({
|
|
282
|
+
ok: false,
|
|
283
|
+
message: "EACCES",
|
|
284
|
+
jobId: "job-2",
|
|
285
|
+
location: "current",
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
expect(placeholder.error?.code).toBe("UNKNOWN_READ_ERROR");
|
|
289
|
+
expect(placeholder.error?.message).toContain("(UNKNOWN_READ_ERROR)");
|
|
290
|
+
expect(placeholder.error?.message).toContain("EACCES");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("transformMultipleJobs maps corrupt reads to placeholders, skips missing reads, and leaves valid results readable", () => {
|
|
294
|
+
const transformed = transformMultipleJobs([
|
|
295
|
+
{
|
|
296
|
+
ok: true,
|
|
297
|
+
data: { tasks: { a: { state: "done" } } },
|
|
298
|
+
jobId: "job-1",
|
|
299
|
+
location: "current",
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
ok: false,
|
|
303
|
+
code: "INVALID_JSON",
|
|
304
|
+
path: "/jobs/job-2/tasks-status.json",
|
|
305
|
+
jobId: "job-2",
|
|
306
|
+
location: "current",
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
ok: false,
|
|
310
|
+
code: "JOB_NOT_FOUND",
|
|
311
|
+
jobId: "job-3",
|
|
312
|
+
location: "current",
|
|
313
|
+
},
|
|
314
|
+
]);
|
|
315
|
+
|
|
316
|
+
expect(transformed).toHaveLength(2);
|
|
317
|
+
const [valid, unreadable] = transformed;
|
|
318
|
+
|
|
319
|
+
expect(valid?.status).toBe("complete");
|
|
320
|
+
expect(valid?.readable).toBeUndefined();
|
|
321
|
+
expect(valid?.error).toBeUndefined();
|
|
322
|
+
|
|
323
|
+
expect(unreadable?.readable).toBe(false);
|
|
324
|
+
expect(unreadable?.status).toBe("failed");
|
|
325
|
+
expect(unreadable?.error?.code).toBe("INVALID_JSON");
|
|
326
|
+
expect(unreadable?.error?.path).toBe("/jobs/job-2/tasks-status.json");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("coerces parseable-but-tasks-missing snapshots to readable jobs", () => {
|
|
330
|
+
const transformed = transformMultipleJobs([
|
|
331
|
+
{ ok: true, data: { jobId: "job-3" }, jobId: "job-3", location: "current" },
|
|
332
|
+
]);
|
|
333
|
+
|
|
334
|
+
expect(transformed).toHaveLength(1);
|
|
335
|
+
const job = transformed[0];
|
|
336
|
+
expect(job?.readable).not.toBe(false);
|
|
337
|
+
expect(job?.error).toBeUndefined();
|
|
338
|
+
expect(job?.status).toBe("pending");
|
|
339
|
+
expect(job?.tasks).toEqual({});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("transformJobStatus output equals normalizeJobView for object inputs", () => {
|
|
343
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
344
|
+
const raw = {
|
|
345
|
+
jobId: "job-1",
|
|
346
|
+
title: "Title",
|
|
347
|
+
state: "failed",
|
|
348
|
+
tasks: { alpha: { state: "done" } },
|
|
349
|
+
gate: null,
|
|
350
|
+
error: { name: "GateRejected", message: "gate rejected" },
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const wrapped = transformJobStatus(raw, "job-1", "current");
|
|
354
|
+
const direct = normalizeJobView(raw, "job-1", "current");
|
|
355
|
+
|
|
356
|
+
expect(wrapped).not.toBeNull();
|
|
357
|
+
expect(wrapped).toEqual(direct);
|
|
358
|
+
warn.mockRestore();
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("transformJobStatus returns null for non-object raw input", () => {
|
|
362
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
363
|
+
expect(transformJobStatus(null, "job-1", "current")).toBeNull();
|
|
364
|
+
expect(transformJobStatus("not an object", "job-1", "current")).toBeNull();
|
|
365
|
+
expect(transformJobStatus([1, 2, 3], "job-1", "current")).toBeNull();
|
|
366
|
+
expect(warn).not.toHaveBeenCalled();
|
|
367
|
+
warn.mockRestore();
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("buildUnreadableJob returns status:'failed' and survives isValidJob", () => {
|
|
371
|
+
const placeholder = buildUnreadableJob({
|
|
372
|
+
ok: false,
|
|
373
|
+
code: "INVALID_JSON",
|
|
374
|
+
path: "/jobs/job-2/tasks-status.json",
|
|
375
|
+
jobId: "job-2",
|
|
376
|
+
location: "current",
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
expect(placeholder.status).toBe("failed");
|
|
380
|
+
expect(placeholder.readable).toBe(false);
|
|
381
|
+
expect(placeholder.id).toBe("job-2");
|
|
382
|
+
expect(placeholder.jobId).toBe("job-2");
|
|
383
|
+
expect(placeholder.error?.code).toBe("INVALID_JSON");
|
|
384
|
+
expect(placeholder.error?.path).toBe("/jobs/job-2/tasks-status.json");
|
|
385
|
+
|
|
386
|
+
expect(Boolean(placeholder.id && placeholder.status)).toBe(true);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("transformTasks uses zero-based fallback ids for unnamed array entries", () => {
|
|
390
|
+
const unnamed = transformTasks([{}, {}]);
|
|
391
|
+
expect(Object.keys(unnamed)).toEqual(["task-0", "task-1"]);
|
|
392
|
+
|
|
393
|
+
const mixed = transformTasks([{ name: "alpha" }, {}]);
|
|
394
|
+
expect(Object.keys(mixed)).toEqual(["alpha", "task-1"]);
|
|
395
|
+
});
|
|
210
396
|
});
|
|
@@ -12,7 +12,7 @@ import type {
|
|
|
12
12
|
const STATUS_PRIORITY: Record<string, number> = {
|
|
13
13
|
running: 4,
|
|
14
14
|
waiting: 4,
|
|
15
|
-
|
|
15
|
+
failed: 3,
|
|
16
16
|
pending: 2,
|
|
17
17
|
complete: 1,
|
|
18
18
|
};
|
|
@@ -27,6 +27,7 @@ const ZERO_COSTS: CostsSummary = {
|
|
|
27
27
|
};
|
|
28
28
|
|
|
29
29
|
function isValidJob(job: CanonicalJob): boolean {
|
|
30
|
+
if (job.readable === false) return Boolean(job.id && job.status);
|
|
30
31
|
return Boolean(job.id && job.status && job.createdAt);
|
|
31
32
|
}
|
|
32
33
|
|
|
@@ -56,7 +57,7 @@ export function sortJobs(jobs: CanonicalJob[]): CanonicalJob[] {
|
|
|
56
57
|
.sort((left, right) => {
|
|
57
58
|
const priority = getStatusPriority(right.status) - getStatusPriority(left.status);
|
|
58
59
|
if (priority !== 0) return priority;
|
|
59
|
-
const created = left.createdAt
|
|
60
|
+
const created = (left.createdAt ?? "").localeCompare(right.createdAt ?? "");
|
|
60
61
|
if (created !== 0) return created;
|
|
61
62
|
return left.id.localeCompare(right.id);
|
|
62
63
|
});
|
|
@@ -82,7 +83,7 @@ export function groupJobsByStatus(jobs: CanonicalJob[]): GroupedJobs {
|
|
|
82
83
|
const grouped: GroupedJobs = {
|
|
83
84
|
running: [],
|
|
84
85
|
waiting: [],
|
|
85
|
-
|
|
86
|
+
failed: [],
|
|
86
87
|
pending: [],
|
|
87
88
|
complete: [],
|
|
88
89
|
};
|
|
@@ -158,6 +159,9 @@ export function transformJobListForAPI(
|
|
|
158
159
|
apiJob.pipelineConfig = job.pipelineConfig;
|
|
159
160
|
}
|
|
160
161
|
|
|
162
|
+
if (job.readable !== undefined) apiJob.readable = job.readable;
|
|
163
|
+
if (job.error !== undefined) apiJob.error = job.error;
|
|
164
|
+
|
|
161
165
|
return apiJob;
|
|
162
166
|
});
|
|
163
167
|
}
|