@ryanfw/prompt-orchestration-pipeline 1.3.2 → 1.3.4
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 +80 -4
- package/package.json +1 -4
- package/src/api/__tests__/index.test.ts +443 -32
- package/src/api/index.ts +108 -127
- package/src/cli/__tests__/analyze-task.test.ts +40 -7
- package/src/cli/__tests__/index.test.ts +337 -17
- package/src/cli/__tests__/types.test.ts +0 -18
- package/src/cli/__tests__/update-pipeline-json.test.ts +19 -9
- package/src/cli/analyze-task.ts +3 -14
- package/src/cli/index.ts +73 -61
- package/src/cli/types.ts +0 -13
- package/src/cli/update-pipeline-json.ts +3 -14
- package/src/config/__tests__/paths.test.ts +46 -1
- package/src/config/__tests__/pipeline-registry.test.ts +767 -0
- package/src/config/__tests__/sse-events.test.ts +470 -0
- package/src/config/__tests__/statuses.test.ts +41 -0
- package/src/config/paths.ts +11 -2
- package/src/config/pipeline-registry.ts +258 -0
- package/src/config/sse-events.ts +122 -0
- package/src/config/statuses.ts +20 -1
- package/src/core/__tests__/agent-step.test.ts +115 -0
- package/src/core/__tests__/config.test.ts +545 -105
- package/src/core/__tests__/core-boot-resolution.test.ts +181 -0
- package/src/core/__tests__/job-concurrency.test.ts +260 -0
- package/src/core/__tests__/job-submission.test.ts +396 -0
- package/src/core/__tests__/job-view.test.ts +1341 -0
- package/src/core/__tests__/json-file.test.ts +111 -0
- package/src/core/__tests__/lifecycle-policy.test.ts +81 -7
- package/src/core/__tests__/logger.test.ts +22 -0
- package/src/core/__tests__/orchestrator-no-deprecated-exports.test.ts +41 -0
- package/src/core/__tests__/orchestrator.test.ts +615 -2
- package/src/core/__tests__/pipeline-runner.test.ts +946 -9
- package/src/core/__tests__/redact.test.ts +153 -0
- package/src/core/__tests__/runner-liveness.test.ts +910 -0
- package/src/core/__tests__/seed-naming.test.ts +57 -0
- package/src/core/__tests__/single-derivation.test.ts +159 -0
- package/src/core/__tests__/task-runner.test.ts +594 -3
- package/src/core/__tests__/task-telemetry.test.ts +241 -0
- package/src/core/__tests__/workspace-root-resolution-guard.test.ts +62 -0
- package/src/core/agent-step.ts +4 -1
- package/src/core/agent-types.ts +5 -4
- package/src/core/config.ts +134 -222
- package/src/core/control.ts +3 -0
- package/src/core/job-concurrency.ts +56 -20
- package/src/core/job-submission.ts +133 -0
- package/src/core/job-view.ts +473 -0
- package/src/core/json-file.ts +45 -0
- package/src/core/lifecycle-policy.ts +23 -8
- package/src/core/logger.ts +0 -29
- package/src/core/orchestrator.ts +200 -74
- package/src/core/pipeline-runner.ts +85 -53
- package/src/core/redact.ts +40 -0
- package/src/core/runner-liveness.ts +280 -0
- package/src/core/seed-naming.ts +9 -0
- package/src/core/status-writer.ts +27 -33
- package/src/core/task-runner.ts +356 -319
- package/src/core/task-telemetry.ts +107 -0
- package/src/harness/__tests__/subprocess.test.ts +112 -1
- package/src/harness/subprocess.ts +55 -16
- package/src/llm/__tests__/index.test.ts +684 -33
- package/src/llm/index.ts +310 -67
- package/src/providers/__tests__/alibaba.test.ts +67 -6
- package/src/providers/__tests__/anthropic.test.ts +35 -14
- package/src/providers/__tests__/base.test.ts +62 -0
- package/src/providers/__tests__/claude-code.test.ts +99 -14
- package/src/providers/__tests__/deepseek.test.ts +16 -6
- package/src/providers/__tests__/gemini.test.ts +47 -25
- package/src/providers/__tests__/moonshot.test.ts +27 -0
- package/src/providers/__tests__/openai.test.ts +262 -74
- package/src/providers/__tests__/opencode.test.ts +77 -0
- package/src/providers/__tests__/request-timeout-contract.test.ts +226 -0
- package/src/providers/__tests__/stream-accumulator.test.ts +52 -0
- package/src/providers/__tests__/types.test.ts +85 -0
- package/src/providers/__tests__/zero-fill-guard.test.ts +62 -0
- package/src/providers/__tests__/zhipu.test.ts +27 -14
- package/src/providers/alibaba.ts +20 -39
- package/src/providers/anthropic.ts +23 -11
- package/src/providers/base.ts +19 -0
- package/src/providers/claude-code.ts +27 -18
- package/src/providers/deepseek.ts +9 -28
- package/src/providers/gemini.ts +20 -58
- package/src/providers/moonshot.ts +15 -11
- package/src/providers/openai.ts +79 -61
- package/src/providers/opencode.ts +16 -33
- package/src/providers/stream-accumulator.ts +27 -21
- package/src/providers/types.ts +29 -4
- package/src/providers/zhipu.ts +15 -44
- package/src/task-analysis/__tests__/analyzer.test.ts +0 -0
- package/src/task-analysis/__tests__/enrichers-schema-deducer.test.ts +34 -2
- package/src/task-analysis/__tests__/no-ast-imports.test.ts +52 -0
- package/src/task-analysis/__tests__/repository.test.ts +65 -0
- package/src/task-analysis/__tests__/types.test.ts +63 -130
- package/src/task-analysis/analyzer.ts +91 -0
- package/src/task-analysis/enrichers/schema-deducer.ts +13 -2
- package/src/task-analysis/index.ts +2 -36
- package/src/task-analysis/repository.ts +45 -0
- package/src/task-analysis/types.ts +42 -58
- package/src/ui/client/__tests__/api.test.ts +143 -1
- package/src/ui/client/__tests__/bootstrap.test.ts +178 -62
- package/src/ui/client/__tests__/job-adapter.test.ts +203 -2
- package/src/ui/client/__tests__/load-state.test.ts +70 -0
- package/src/ui/client/__tests__/types.test.ts +66 -3
- package/src/ui/client/__tests__/useJobDetailWithUpdates.test.ts +390 -77
- package/src/ui/client/__tests__/useJobList.test.ts +198 -23
- package/src/ui/client/__tests__/useJobListWithUpdates.test.ts +218 -7
- package/src/ui/client/adapters/__tests__/job-adapter.test.ts +186 -0
- package/src/ui/client/adapters/job-adapter.ts +41 -16
- package/src/ui/client/api.ts +38 -15
- package/src/ui/client/bootstrap.ts +19 -14
- package/src/ui/client/hooks/useJobDetailWithUpdates.ts +73 -97
- package/src/ui/client/hooks/useJobList.ts +26 -31
- package/src/ui/client/hooks/useJobListWithUpdates.ts +42 -76
- package/src/ui/client/load-state.ts +20 -0
- package/src/ui/client/reducers/__tests__/job-events.test.ts +568 -0
- package/src/ui/client/reducers/job-events.ts +137 -0
- package/src/ui/client/types.ts +16 -20
- package/src/ui/components/DAGGrid.tsx +6 -1
- package/src/ui/components/JobDetail.tsx +12 -4
- package/src/ui/components/JobTable.tsx +41 -13
- package/src/ui/components/StageTimeline.tsx +8 -20
- package/src/ui/components/TaskAnalysisDisplay.tsx +48 -6
- package/src/ui/components/__tests__/DAGGrid.test.tsx +36 -0
- package/src/ui/components/__tests__/JobDetail.test.tsx +112 -0
- package/src/ui/components/__tests__/JobTable.test.tsx +137 -1
- package/src/ui/components/__tests__/StageTimeline.test.tsx +5 -6
- package/src/ui/components/__tests__/TaskAnalysisDisplay.test.tsx +64 -0
- package/src/ui/components/types.ts +35 -15
- package/src/ui/dist/assets/{index--RH3sAt3.js → index-DN3-zvtP.js} +4324 -263
- package/src/ui/dist/assets/index-DN3-zvtP.js.map +1 -0
- package/src/ui/dist/assets/style-CtZBnjlR.css +2 -0
- package/src/ui/dist/index.html +2 -2
- package/src/ui/pages/PipelineDetail.tsx +60 -4
- package/src/ui/pages/PromptPipelineDashboard.tsx +59 -7
- package/src/ui/pages/__tests__/PromptPipelineDashboard.test.tsx +114 -32
- package/src/ui/pages/__tests__/pages.test.tsx +236 -42
- package/src/ui/server/__tests__/concurrency-endpoint.test.ts +54 -0
- package/src/ui/server/__tests__/config-bridge-node.test.ts +34 -1
- package/src/ui/server/__tests__/config-bridge.test.ts +9 -1
- package/src/ui/server/__tests__/embedded-assets.test.ts +66 -0
- package/src/ui/server/__tests__/file-endpoints.test.ts +183 -5
- package/src/ui/server/__tests__/gate-endpoints.test.ts +55 -0
- package/src/ui/server/__tests__/index.test.ts +63 -0
- package/src/ui/server/__tests__/job-control-endpoints.test.ts +512 -2
- package/src/ui/server/__tests__/job-endpoints.test.ts +158 -2
- package/src/ui/server/__tests__/read-static-path-guard.test.ts +94 -0
- package/src/ui/server/__tests__/router-root-isolation.test.ts +204 -0
- package/src/ui/server/__tests__/router-threading.test.ts +148 -0
- package/src/ui/server/__tests__/router.test.ts +104 -0
- package/src/ui/server/__tests__/sse-broadcast.test.ts +44 -0
- package/src/ui/server/__tests__/sse-enhancer.test.ts +70 -0
- package/src/ui/server/config-bridge-node.ts +8 -26
- package/src/ui/server/config-bridge.ts +3 -4
- package/src/ui/server/embedded-assets-imports.d.ts +44 -0
- package/src/ui/server/embedded-assets.ts +13 -2
- package/src/ui/server/endpoints/__tests__/meta-endpoint.test.ts +1 -0
- package/src/ui/server/endpoints/__tests__/pipeline-analysis-endpoint.test.ts +167 -0
- package/src/ui/server/endpoints/__tests__/schema-file-endpoint.test.ts +104 -0
- package/src/ui/server/endpoints/__tests__/task-analysis-endpoint.test.ts +103 -0
- package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +162 -96
- package/src/ui/server/endpoints/concurrency-endpoint.ts +2 -1
- package/src/ui/server/endpoints/create-pipeline-endpoint.ts +14 -29
- package/src/ui/server/endpoints/file-endpoints.ts +15 -10
- package/src/ui/server/endpoints/gate-endpoints.ts +2 -6
- package/src/ui/server/endpoints/job-control-endpoints.ts +129 -141
- package/src/ui/server/endpoints/job-endpoints.ts +7 -0
- package/src/ui/server/endpoints/meta-endpoint.ts +1 -1
- package/src/ui/server/endpoints/pipeline-analysis-endpoint.ts +99 -11
- package/src/ui/server/endpoints/schema-file-endpoint.ts +11 -3
- package/src/ui/server/endpoints/task-analysis-endpoint.ts +21 -5
- package/src/ui/server/endpoints/task-save-endpoint.ts +1 -2
- package/src/ui/server/endpoints/upload-endpoints.ts +5 -40
- package/src/ui/server/index.ts +19 -14
- package/src/ui/server/job-reader.ts +14 -2
- package/src/ui/server/router.ts +33 -10
- package/src/ui/server/sse-broadcast.ts +14 -3
- package/src/ui/server/sse-enhancer.ts +12 -2
- package/src/ui/state/__tests__/schema-loader.test.ts +11 -1
- package/src/ui/state/__tests__/snapshot.test.ts +120 -14
- package/src/ui/state/__tests__/types.test.ts +104 -5
- package/src/ui/state/snapshot.ts +2 -2
- package/src/ui/state/transformers/__tests__/list-transformer.test.ts +85 -2
- package/src/ui/state/transformers/__tests__/status-transformer.test.ts +250 -22
- package/src/ui/state/transformers/list-transformer.ts +13 -3
- package/src/ui/state/transformers/status-transformer.ts +36 -170
- package/src/ui/state/types.ts +15 -48
- package/src/utils/__tests__/path-containment.test.ts +160 -0
- package/src/{ui/server/utils → utils}/path-containment.ts +21 -0
- package/src/task-analysis/__tests__/enrichers-analysis-writer.test.ts +0 -86
- package/src/task-analysis/__tests__/enrichers-artifact-resolver.test.ts +0 -124
- package/src/task-analysis/__tests__/extractors-artifacts.test.ts +0 -133
- package/src/task-analysis/__tests__/extractors-llm-calls.test.ts +0 -46
- package/src/task-analysis/__tests__/extractors-stages.test.ts +0 -52
- package/src/task-analysis/__tests__/index.test.ts +0 -143
- package/src/task-analysis/__tests__/parser.test.ts +0 -41
- package/src/task-analysis/__tests__/utils-ast.test.ts +0 -82
- package/src/task-analysis/enrichers/analysis-writer.ts +0 -75
- package/src/task-analysis/enrichers/artifact-resolver.ts +0 -89
- package/src/task-analysis/extractors/artifacts.ts +0 -143
- package/src/task-analysis/extractors/llm-calls.ts +0 -117
- package/src/task-analysis/extractors/stages.ts +0 -45
- package/src/task-analysis/parser.ts +0 -20
- package/src/task-analysis/utils/ast.ts +0 -45
- package/src/ui/dist/assets/index--RH3sAt3.js.map +0 -1
- package/src/ui/dist/assets/style-CSSKuMOe.css +0 -2
- package/src/ui/embedded-assets.js +0 -12
- package/src/ui/server/__tests__/path-containment.test.ts +0 -54
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import type { JobWire, SseEvent } from "../../../../config/sse-events.ts";
|
|
4
|
+
import { adaptJobDetail } from "../../adapters/job-adapter.ts";
|
|
5
|
+
import { applyDetailEvent, applyJobEvent } from "../job-events.ts";
|
|
6
|
+
import type { NormalizedJobDetail, NormalizedJobSummary } from "../../types.ts";
|
|
7
|
+
|
|
8
|
+
function makeJob(jobId: string, fields: Partial<NormalizedJobSummary> = {}): NormalizedJobSummary {
|
|
9
|
+
return {
|
|
10
|
+
id: jobId,
|
|
11
|
+
jobId,
|
|
12
|
+
name: jobId,
|
|
13
|
+
status: "pending",
|
|
14
|
+
progress: 0,
|
|
15
|
+
taskCount: 0,
|
|
16
|
+
doneCount: 0,
|
|
17
|
+
location: "current",
|
|
18
|
+
tasks: {},
|
|
19
|
+
current: null,
|
|
20
|
+
costsSummary: {
|
|
21
|
+
totalTokens: 0,
|
|
22
|
+
totalInputTokens: 0,
|
|
23
|
+
totalOutputTokens: 0,
|
|
24
|
+
totalCost: 0,
|
|
25
|
+
totalInputCost: 0,
|
|
26
|
+
totalOutputCost: 0,
|
|
27
|
+
estimatedCost: 0,
|
|
28
|
+
hasEstimated: false,
|
|
29
|
+
unavailableCost: 0,
|
|
30
|
+
},
|
|
31
|
+
totalCost: 0,
|
|
32
|
+
totalTokens: 0,
|
|
33
|
+
__warnings: [],
|
|
34
|
+
displayCategory: "current",
|
|
35
|
+
...fields,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function makeJobWire(fields: Partial<JobWire> & { jobId: string }): JobWire {
|
|
40
|
+
return {
|
|
41
|
+
jobId: fields.jobId,
|
|
42
|
+
title: fields.title ?? `Title for ${fields.jobId}`,
|
|
43
|
+
status: fields.status ?? "pending",
|
|
44
|
+
progress: fields.progress ?? 0,
|
|
45
|
+
createdAt: fields.createdAt ?? null,
|
|
46
|
+
updatedAt: fields.updatedAt ?? null,
|
|
47
|
+
location: fields.location ?? null,
|
|
48
|
+
tasks: fields.tasks ?? {},
|
|
49
|
+
costsSummary: fields.costsSummary ?? {
|
|
50
|
+
totalTokens: 0,
|
|
51
|
+
totalInputTokens: 0,
|
|
52
|
+
totalOutputTokens: 0,
|
|
53
|
+
totalCost: 0,
|
|
54
|
+
totalInputCost: 0,
|
|
55
|
+
totalOutputCost: 0,
|
|
56
|
+
estimatedCost: 0,
|
|
57
|
+
hasEstimated: false,
|
|
58
|
+
unavailableCost: 0,
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe("applyJobEvent", () => {
|
|
64
|
+
describe("job:created", () => {
|
|
65
|
+
it("appends to an empty list", () => {
|
|
66
|
+
// Arrange
|
|
67
|
+
const event: SseEvent = {
|
|
68
|
+
type: "job:created",
|
|
69
|
+
data: makeJobWire({ jobId: "job-1" }),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Act
|
|
73
|
+
const next = applyJobEvent([], event);
|
|
74
|
+
|
|
75
|
+
// Assert
|
|
76
|
+
expect(next).toHaveLength(1);
|
|
77
|
+
expect(next[0]?.jobId).toBe("job-1");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("appends to a non-empty list when the jobId is new", () => {
|
|
81
|
+
// Arrange
|
|
82
|
+
const jobs = [makeJob("job-1")];
|
|
83
|
+
const event: SseEvent = {
|
|
84
|
+
type: "job:created",
|
|
85
|
+
data: makeJobWire({ jobId: "job-2" }),
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Act
|
|
89
|
+
const next = applyJobEvent(jobs, event);
|
|
90
|
+
|
|
91
|
+
// Assert
|
|
92
|
+
expect(next).toHaveLength(2);
|
|
93
|
+
expect(next.map((job) => job.jobId).sort()).toEqual(["job-1", "job-2"]);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("job:updated", () => {
|
|
98
|
+
it("merges the incoming data into the existing entry", () => {
|
|
99
|
+
// Arrange
|
|
100
|
+
const jobs = [makeJob("job-1", { name: "old" })];
|
|
101
|
+
const event: SseEvent = {
|
|
102
|
+
type: "job:updated",
|
|
103
|
+
data: makeJobWire({ jobId: "job-1", title: "new" }),
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Act
|
|
107
|
+
const next = applyJobEvent(jobs, event);
|
|
108
|
+
|
|
109
|
+
// Assert
|
|
110
|
+
expect(next).toHaveLength(1);
|
|
111
|
+
expect(next[0]?.name).toBe("new");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("returns the same array reference when the update is a no-op", () => {
|
|
115
|
+
// Arrange
|
|
116
|
+
const jobs = [makeJob("job-1", { name: "same" })];
|
|
117
|
+
const event: SseEvent = {
|
|
118
|
+
type: "job:updated",
|
|
119
|
+
data: makeJobWire({ jobId: "job-1", title: "same" }),
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// Act
|
|
123
|
+
const next = applyJobEvent(jobs, event);
|
|
124
|
+
|
|
125
|
+
// Assert
|
|
126
|
+
expect(next).toBe(jobs);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("job:removed", () => {
|
|
131
|
+
it("drops the matching job", () => {
|
|
132
|
+
// Arrange
|
|
133
|
+
const jobs = [makeJob("job-1"), makeJob("job-2")];
|
|
134
|
+
const event: SseEvent = {
|
|
135
|
+
type: "job:removed",
|
|
136
|
+
data: { jobId: "job-1" },
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// Act
|
|
140
|
+
const next = applyJobEvent(jobs, event);
|
|
141
|
+
|
|
142
|
+
// Assert
|
|
143
|
+
expect(next).toHaveLength(1);
|
|
144
|
+
expect(next[0]?.jobId).toBe("job-2");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("returns the same array reference when the jobId is absent", () => {
|
|
148
|
+
// Arrange
|
|
149
|
+
const jobs = [makeJob("job-1")];
|
|
150
|
+
const event: SseEvent = {
|
|
151
|
+
type: "job:removed",
|
|
152
|
+
data: { jobId: "job-missing" },
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// Act
|
|
156
|
+
const next = applyJobEvent(jobs, event);
|
|
157
|
+
|
|
158
|
+
// Assert
|
|
159
|
+
expect(next).toBe(jobs);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("sort order", () => {
|
|
164
|
+
it("applies a stable 3-key sort (status, createdAt, jobId)", () => {
|
|
165
|
+
// Arrange — three pending jobs with the same createdAt; tie-break must be jobId asc.
|
|
166
|
+
const jobs = [
|
|
167
|
+
makeJob("job-c", { status: "pending", createdAt: "2026-01-01T00:00:00.000Z" }),
|
|
168
|
+
makeJob("job-a", { status: "pending", createdAt: "2026-01-01T00:00:00.000Z" }),
|
|
169
|
+
makeJob("job-b", { status: "pending", createdAt: "2026-01-01T00:00:00.000Z" }),
|
|
170
|
+
];
|
|
171
|
+
const event: SseEvent = {
|
|
172
|
+
type: "job:updated",
|
|
173
|
+
data: makeJobWire({
|
|
174
|
+
jobId: "job-x",
|
|
175
|
+
title: "x",
|
|
176
|
+
status: "pending",
|
|
177
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
178
|
+
}),
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// Act
|
|
182
|
+
const next = applyJobEvent(jobs, event);
|
|
183
|
+
|
|
184
|
+
// Assert
|
|
185
|
+
expect(next.map((job) => job.jobId)).toEqual(["job-a", "job-b", "job-c", "job-x"]);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("idempotency", () => {
|
|
190
|
+
it("does not duplicate when the same job:created snapshot is applied twice", () => {
|
|
191
|
+
// Arrange
|
|
192
|
+
const event: SseEvent = {
|
|
193
|
+
type: "job:created",
|
|
194
|
+
data: makeJobWire({ jobId: "job-1" }),
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// Act
|
|
198
|
+
const once = applyJobEvent([], event);
|
|
199
|
+
const twice = applyJobEvent(once, event);
|
|
200
|
+
|
|
201
|
+
// Assert
|
|
202
|
+
expect(twice).toHaveLength(1);
|
|
203
|
+
expect(twice[0]?.jobId).toBe("job-1");
|
|
204
|
+
expect(twice).toBe(once);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("does not duplicate or mutate when the same job:updated snapshot is applied twice", () => {
|
|
208
|
+
// Arrange
|
|
209
|
+
const jobs = [makeJob("job-1", { name: "old" })];
|
|
210
|
+
const event: SseEvent = {
|
|
211
|
+
type: "job:updated",
|
|
212
|
+
data: makeJobWire({ jobId: "job-1", title: "new" }),
|
|
213
|
+
};
|
|
214
|
+
const jobsBefore = jobs.slice();
|
|
215
|
+
|
|
216
|
+
// Act
|
|
217
|
+
const once = applyJobEvent(jobs, event);
|
|
218
|
+
const twice = applyJobEvent(once, event);
|
|
219
|
+
|
|
220
|
+
// Assert
|
|
221
|
+
expect(twice).toHaveLength(1);
|
|
222
|
+
expect(twice[0]?.name).toBe("new");
|
|
223
|
+
expect(jobs).toEqual(jobsBefore);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("commutes for events targeting different jobIds", () => {
|
|
227
|
+
// Arrange
|
|
228
|
+
const eventA: SseEvent = {
|
|
229
|
+
type: "job:created",
|
|
230
|
+
data: makeJobWire({ jobId: "job-a" }),
|
|
231
|
+
};
|
|
232
|
+
const eventB: SseEvent = {
|
|
233
|
+
type: "job:created",
|
|
234
|
+
data: makeJobWire({ jobId: "job-b" }),
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// Act
|
|
238
|
+
const orderAB = applyJobEvent(applyJobEvent([], eventA), eventB);
|
|
239
|
+
const orderBA = applyJobEvent(applyJobEvent([], eventB), eventA);
|
|
240
|
+
|
|
241
|
+
// Assert — same content, same deterministic order (jobId ascending tie-break)
|
|
242
|
+
expect(orderAB.map((job) => job.jobId)).toEqual(["job-a", "job-b"]);
|
|
243
|
+
expect(orderBA.map((job) => job.jobId)).toEqual(["job-a", "job-b"]);
|
|
244
|
+
expect(orderAB).toEqual(orderBA);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("non-list event types", () => {
|
|
249
|
+
it("returns the same array reference for heartbeat, seed:uploaded, state:change, and state:summary", () => {
|
|
250
|
+
// Arrange
|
|
251
|
+
const jobs = [makeJob("job-1")];
|
|
252
|
+
const heartbeat: SseEvent = {
|
|
253
|
+
type: "heartbeat",
|
|
254
|
+
data: { ok: true, timestamp: "2026-06-21T00:00:00.000Z" },
|
|
255
|
+
};
|
|
256
|
+
const seedUploaded: SseEvent = { type: "seed:uploaded", data: {} };
|
|
257
|
+
const stateChange: SseEvent = {
|
|
258
|
+
type: "state:change",
|
|
259
|
+
data: { jobId: "job-1", path: "pipeline-data/current/job-1/tasks-status.json" },
|
|
260
|
+
};
|
|
261
|
+
const stateSummary: SseEvent = { type: "state:summary", data: { changeCount: 1 } };
|
|
262
|
+
|
|
263
|
+
// Act + Assert
|
|
264
|
+
expect(applyJobEvent(jobs, heartbeat)).toBe(jobs);
|
|
265
|
+
expect(applyJobEvent(jobs, seedUploaded)).toBe(jobs);
|
|
266
|
+
expect(applyJobEvent(jobs, stateChange)).toBe(jobs);
|
|
267
|
+
expect(applyJobEvent(jobs, stateSummary)).toBe(jobs);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
function makeDetail(jobId: string): NormalizedJobDetail {
|
|
273
|
+
return {
|
|
274
|
+
id: jobId,
|
|
275
|
+
jobId,
|
|
276
|
+
name: jobId,
|
|
277
|
+
status: "pending",
|
|
278
|
+
progress: 0,
|
|
279
|
+
taskCount: 2,
|
|
280
|
+
doneCount: 0,
|
|
281
|
+
location: "current",
|
|
282
|
+
tasks: {
|
|
283
|
+
build: {
|
|
284
|
+
name: "build",
|
|
285
|
+
state: "pending",
|
|
286
|
+
startedAt: null,
|
|
287
|
+
endedAt: null,
|
|
288
|
+
files: { artifacts: [], logs: [], tmp: [] },
|
|
289
|
+
},
|
|
290
|
+
test: {
|
|
291
|
+
name: "test",
|
|
292
|
+
state: "pending",
|
|
293
|
+
startedAt: null,
|
|
294
|
+
endedAt: null,
|
|
295
|
+
files: { artifacts: [], logs: [], tmp: [] },
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
displayCategory: "current",
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
describe("applyDetailEvent", () => {
|
|
303
|
+
describe("job:updated", () => {
|
|
304
|
+
it("merges a matching event and updates the changed task state (AC-11)", () => {
|
|
305
|
+
// Arrange
|
|
306
|
+
const detail = makeDetail("job-1");
|
|
307
|
+
const event: SseEvent = {
|
|
308
|
+
type: "job:updated",
|
|
309
|
+
data: makeJobWire({
|
|
310
|
+
jobId: "job-1",
|
|
311
|
+
tasks: {
|
|
312
|
+
build: { state: "done" },
|
|
313
|
+
test: { state: "running" },
|
|
314
|
+
},
|
|
315
|
+
}),
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
// Act
|
|
319
|
+
const next = applyDetailEvent(detail, event);
|
|
320
|
+
|
|
321
|
+
// Assert
|
|
322
|
+
expect(next.tasks["build"]?.state).toBe("done");
|
|
323
|
+
expect(next.tasks["test"]?.state).toBe("running");
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("ignores a job:updated whose jobId does not match the detail", () => {
|
|
327
|
+
// Arrange
|
|
328
|
+
const detail = makeDetail("job-1");
|
|
329
|
+
const event: SseEvent = {
|
|
330
|
+
type: "job:updated",
|
|
331
|
+
data: makeJobWire({
|
|
332
|
+
jobId: "job-2",
|
|
333
|
+
tasks: { build: { state: "done" } },
|
|
334
|
+
}),
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
// Act
|
|
338
|
+
const next = applyDetailEvent(detail, event);
|
|
339
|
+
|
|
340
|
+
// Assert
|
|
341
|
+
expect(next).toBe(detail);
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
describe("task reference preservation (AC-9)", () => {
|
|
346
|
+
it("preserves the object reference for tasks whose normalized payload did not change and replaces the reference for tasks whose payload changed", () => {
|
|
347
|
+
// Arrange
|
|
348
|
+
const detail = makeDetail("job-1");
|
|
349
|
+
const event: SseEvent = {
|
|
350
|
+
type: "job:updated",
|
|
351
|
+
data: makeJobWire({
|
|
352
|
+
jobId: "job-1",
|
|
353
|
+
tasks: {
|
|
354
|
+
build: { state: "done" },
|
|
355
|
+
test: { state: "pending" },
|
|
356
|
+
},
|
|
357
|
+
}),
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
// Act
|
|
361
|
+
const next = applyDetailEvent(detail, event);
|
|
362
|
+
|
|
363
|
+
// Assert — changed task gets a new reference, unchanged task keeps its reference
|
|
364
|
+
expect(next.tasks["build"]).not.toBe(detail.tasks["build"]);
|
|
365
|
+
expect(next.tasks["build"]?.state).toBe("done");
|
|
366
|
+
expect(next.tasks["test"]).toBe(detail.tasks["test"]);
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
describe("idempotency (AC-8)", () => {
|
|
371
|
+
it("returns the same reference when the same snapshot is applied twice", () => {
|
|
372
|
+
// Arrange
|
|
373
|
+
const detail = makeDetail("job-1");
|
|
374
|
+
const event: SseEvent = {
|
|
375
|
+
type: "job:updated",
|
|
376
|
+
data: makeJobWire({
|
|
377
|
+
jobId: "job-1",
|
|
378
|
+
tasks: {
|
|
379
|
+
build: { state: "done" },
|
|
380
|
+
test: { state: "running" },
|
|
381
|
+
},
|
|
382
|
+
}),
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
// Act
|
|
386
|
+
const once = applyDetailEvent(detail, event);
|
|
387
|
+
const twice = applyDetailEvent(once, event);
|
|
388
|
+
|
|
389
|
+
// Assert
|
|
390
|
+
expect(twice).toBe(once);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("preserves task references and leaves derived fields unchanged when the event payload already matches the current detail", () => {
|
|
394
|
+
// Arrange
|
|
395
|
+
const detail = makeDetail("job-1");
|
|
396
|
+
const event: SseEvent = {
|
|
397
|
+
type: "job:updated",
|
|
398
|
+
data: makeJobWire({
|
|
399
|
+
jobId: "job-1",
|
|
400
|
+
tasks: {
|
|
401
|
+
build: { state: "pending" },
|
|
402
|
+
test: { state: "pending" },
|
|
403
|
+
},
|
|
404
|
+
}),
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
// Act
|
|
408
|
+
const next = applyDetailEvent(detail, event);
|
|
409
|
+
|
|
410
|
+
// Assert — payload matches ⇒ unchanged task references and unchanged derived fields
|
|
411
|
+
expect(next.tasks["build"]).toBe(detail.tasks["build"]);
|
|
412
|
+
expect(next.tasks["test"]).toBe(detail.tasks["test"]);
|
|
413
|
+
expect(next.progress).toBe(detail.progress);
|
|
414
|
+
expect(next.taskCount).toBe(detail.taskCount);
|
|
415
|
+
expect(next.doneCount).toBe(detail.doneCount);
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
describe("convergence (AC-10)", () => {
|
|
420
|
+
it("reducing a job:updated built from a fixture matches normalizing that fixture via adaptJobDetail", () => {
|
|
421
|
+
// Arrange — fixture designed so adaptBaseJob.progress and deriveProgress.progress agree
|
|
422
|
+
const fixture: JobWire = {
|
|
423
|
+
jobId: "job-1",
|
|
424
|
+
title: "My Job",
|
|
425
|
+
status: "running",
|
|
426
|
+
progress: 50,
|
|
427
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
428
|
+
updatedAt: "2026-01-02T00:00:00.000Z",
|
|
429
|
+
location: "current",
|
|
430
|
+
tasks: {
|
|
431
|
+
build: { state: "done" },
|
|
432
|
+
test: { state: "running" },
|
|
433
|
+
},
|
|
434
|
+
costsSummary: {
|
|
435
|
+
totalTokens: 100,
|
|
436
|
+
totalInputTokens: 60,
|
|
437
|
+
totalOutputTokens: 40,
|
|
438
|
+
totalCost: 0,
|
|
439
|
+
totalInputCost: 0,
|
|
440
|
+
totalOutputCost: 0,
|
|
441
|
+
estimatedCost: 0,
|
|
442
|
+
hasEstimated: false,
|
|
443
|
+
unavailableCost: 0,
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
const event: SseEvent = { type: "job:updated", data: fixture };
|
|
447
|
+
|
|
448
|
+
// Act
|
|
449
|
+
const refetched = adaptJobDetail({ ...fixture });
|
|
450
|
+
const reduced = applyDetailEvent(makeDetail("job-1"), event);
|
|
451
|
+
|
|
452
|
+
// Assert — convergence on the fields the refetch path also produces
|
|
453
|
+
expect(reduced.jobId).toBe(refetched.jobId);
|
|
454
|
+
expect(reduced.status).toBe(refetched.status);
|
|
455
|
+
expect(reduced.progress).toBe(refetched.progress);
|
|
456
|
+
expect(reduced.taskCount).toBe(refetched.taskCount);
|
|
457
|
+
expect(reduced.doneCount).toBe(refetched.doneCount);
|
|
458
|
+
expect(reduced.completedCount).toBe(refetched.completedCount);
|
|
459
|
+
expect(Object.keys(reduced.tasks).sort()).toEqual(Object.keys(refetched.tasks).sort());
|
|
460
|
+
for (const name of Object.keys(refetched.tasks)) {
|
|
461
|
+
expect(reduced.tasks[name]).toEqual(refetched.tasks[name]);
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("derives progress from the event's configured count, not the stale captured one (AC-10)", () => {
|
|
466
|
+
// Arrange — the detail was captured with a 2-task configured count, but the incoming
|
|
467
|
+
// job:updated carries a 4-task pipelineConfig (the detail endpoint enriches config via
|
|
468
|
+
// loadPipelineJson, so the wire config can legitimately differ from the value captured
|
|
469
|
+
// at load). Progress must converge on the event's config, exactly as a refetch would.
|
|
470
|
+
const prev: NormalizedJobDetail = {
|
|
471
|
+
...makeDetail("job-1"),
|
|
472
|
+
pipelineConfig: { tasks: ["build", "test"] },
|
|
473
|
+
taskCount: 2,
|
|
474
|
+
};
|
|
475
|
+
const wire: JobWire = {
|
|
476
|
+
...makeJobWire({
|
|
477
|
+
jobId: "job-1",
|
|
478
|
+
tasks: {
|
|
479
|
+
build: { state: "done" },
|
|
480
|
+
test: { state: "running" },
|
|
481
|
+
},
|
|
482
|
+
}),
|
|
483
|
+
pipelineConfig: { tasks: ["build", "test", "lint", "deploy"] },
|
|
484
|
+
};
|
|
485
|
+
const event: SseEvent = { type: "job:updated", data: wire };
|
|
486
|
+
|
|
487
|
+
// Act
|
|
488
|
+
const reduced = applyDetailEvent(prev, event);
|
|
489
|
+
const refetched = adaptJobDetail({ ...wire });
|
|
490
|
+
|
|
491
|
+
// Assert — converges on the event's 4-task denominator: floor(1/4*100) = 25
|
|
492
|
+
expect(reduced.taskCount).toBe(4);
|
|
493
|
+
expect(reduced.progress).toBe(25);
|
|
494
|
+
expect(reduced.taskCount).toBe(refetched.taskCount);
|
|
495
|
+
expect(reduced.progress).toBe(refetched.progress);
|
|
496
|
+
expect(reduced.doneCount).toBe(refetched.doneCount);
|
|
497
|
+
expect(reduced.completedCount).toBe(refetched.completedCount);
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
describe("non-job:updated events are no-ops (AC-11)", () => {
|
|
502
|
+
it("returns the same detail reference for heartbeat, state:change, state:summary, seed:uploaded, job:removed, and job:created", () => {
|
|
503
|
+
// Arrange
|
|
504
|
+
const detail = makeDetail("job-1");
|
|
505
|
+
const heartbeat: SseEvent = {
|
|
506
|
+
type: "heartbeat",
|
|
507
|
+
data: { ok: true, timestamp: "2026-06-21T00:00:00.000Z" },
|
|
508
|
+
};
|
|
509
|
+
const stateChange: SseEvent = {
|
|
510
|
+
type: "state:change",
|
|
511
|
+
data: { jobId: "job-1", path: "pipeline-data/current/job-1/tasks-status.json" },
|
|
512
|
+
};
|
|
513
|
+
const stateSummary: SseEvent = { type: "state:summary", data: { changeCount: 1 } };
|
|
514
|
+
const seedUploaded: SseEvent = { type: "seed:uploaded", data: {} };
|
|
515
|
+
const jobRemoved: SseEvent = { type: "job:removed", data: { jobId: "job-1" } };
|
|
516
|
+
const jobCreated: SseEvent = {
|
|
517
|
+
type: "job:created",
|
|
518
|
+
data: makeJobWire({ jobId: "job-1" }),
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
// Act + Assert
|
|
522
|
+
expect(applyDetailEvent(detail, heartbeat)).toBe(detail);
|
|
523
|
+
expect(applyDetailEvent(detail, stateChange)).toBe(detail);
|
|
524
|
+
expect(applyDetailEvent(detail, stateSummary)).toBe(detail);
|
|
525
|
+
expect(applyDetailEvent(detail, seedUploaded)).toBe(detail);
|
|
526
|
+
expect(applyDetailEvent(detail, jobRemoved)).toBe(detail);
|
|
527
|
+
expect(applyDetailEvent(detail, jobCreated)).toBe(detail);
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
describe("deriveProgress is the only progress formula (AC-7)", () => {
|
|
532
|
+
it("computes progress against the configured task count, not the current task list length", () => {
|
|
533
|
+
// Arrange — configured pipeline has 6 tasks, only 2 are recorded; one is "done".
|
|
534
|
+
// adaptBaseJob.progress would be floor(1/6*100) = 16; deriveProgress returns 16 too here,
|
|
535
|
+
// so to differentiate we instead assert the larger configured denominator wins even when
|
|
536
|
+
// tasks.length is greater than configuredCount.
|
|
537
|
+
const detail: NormalizedJobDetail = {
|
|
538
|
+
...makeDetail("job-1"),
|
|
539
|
+
pipelineConfig: { tasks: ["build", "test", "extra1", "extra2", "extra3", "extra4"] },
|
|
540
|
+
taskCount: 6,
|
|
541
|
+
doneCount: 0,
|
|
542
|
+
progress: 0,
|
|
543
|
+
};
|
|
544
|
+
const event: SseEvent = {
|
|
545
|
+
type: "job:updated",
|
|
546
|
+
data: makeJobWire({
|
|
547
|
+
jobId: "job-1",
|
|
548
|
+
tasks: {
|
|
549
|
+
build: { state: "done" },
|
|
550
|
+
test: { state: "pending" },
|
|
551
|
+
extra1: { state: "pending" },
|
|
552
|
+
extra2: { state: "pending" },
|
|
553
|
+
extra3: { state: "pending" },
|
|
554
|
+
extra4: { state: "pending" },
|
|
555
|
+
},
|
|
556
|
+
}),
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
// Act
|
|
560
|
+
const next = applyDetailEvent(detail, event);
|
|
561
|
+
|
|
562
|
+
// Assert — deriveProgress: max(6, 6) = 6 taskCount; completed = 1; progress = floor(1/6*100) = 16
|
|
563
|
+
expect(next.taskCount).toBe(6);
|
|
564
|
+
expect(next.doneCount).toBe(1);
|
|
565
|
+
expect(next.progress).toBe(16);
|
|
566
|
+
});
|
|
567
|
+
});
|
|
568
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { deriveProgress } from "../../../core/job-view.ts";
|
|
2
|
+
import type { SseEvent } from "../../../config/sse-events.ts";
|
|
3
|
+
import { adaptJobDetail, adaptJobSummary } from "../adapters/job-adapter.ts";
|
|
4
|
+
import type { NormalizedJobDetail, NormalizedJobSummary, NormalizedTask } from "../types.ts";
|
|
5
|
+
|
|
6
|
+
function sortNormalizedJobs(jobs: NormalizedJobSummary[]): NormalizedJobSummary[] {
|
|
7
|
+
const priority: Record<string, number> = {
|
|
8
|
+
running: 4,
|
|
9
|
+
waiting: 4,
|
|
10
|
+
failed: 3,
|
|
11
|
+
pending: 2,
|
|
12
|
+
complete: 1,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
return jobs.slice().sort((left, right) => {
|
|
16
|
+
const statusOrder = (priority[right.status] ?? 0) - (priority[left.status] ?? 0);
|
|
17
|
+
if (statusOrder !== 0) return statusOrder;
|
|
18
|
+
const leftCreated = left.createdAt ?? "";
|
|
19
|
+
const rightCreated = right.createdAt ?? "";
|
|
20
|
+
const createdOrder = leftCreated.localeCompare(rightCreated);
|
|
21
|
+
if (createdOrder !== 0) return createdOrder;
|
|
22
|
+
return left.jobId.localeCompare(right.jobId);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function completedCountFromTasks(job: NormalizedJobSummary): number {
|
|
27
|
+
return deriveProgress(Object.values(job.tasks), null).completedCount;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function comparableJob(job: NormalizedJobSummary): Record<string, unknown> {
|
|
31
|
+
const comparable: Record<string, unknown> = { ...job };
|
|
32
|
+
for (const [key, value] of Object.entries(comparable)) {
|
|
33
|
+
if (value === undefined) delete comparable[key];
|
|
34
|
+
}
|
|
35
|
+
if (comparable["completedCount"] === completedCountFromTasks(job)) {
|
|
36
|
+
delete comparable["completedCount"];
|
|
37
|
+
}
|
|
38
|
+
return comparable;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function jobsEqual(left: NormalizedJobSummary[], right: NormalizedJobSummary[]): boolean {
|
|
42
|
+
return JSON.stringify(left.map(comparableJob)) === JSON.stringify(right.map(comparableJob));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function mergeJob(current: NormalizedJobSummary, incoming: NormalizedJobSummary): NormalizedJobSummary {
|
|
46
|
+
return {
|
|
47
|
+
...current,
|
|
48
|
+
...incoming,
|
|
49
|
+
id: incoming.id || current.id,
|
|
50
|
+
jobId: incoming.jobId || current.jobId,
|
|
51
|
+
name: incoming.name || current.name,
|
|
52
|
+
createdAt: incoming.createdAt ?? current.createdAt,
|
|
53
|
+
updatedAt: incoming.updatedAt ?? current.updatedAt,
|
|
54
|
+
pipeline: incoming.pipeline ?? current.pipeline,
|
|
55
|
+
pipelineLabel: incoming.pipelineLabel ?? current.pipelineLabel,
|
|
56
|
+
current: incoming.current ?? current.current,
|
|
57
|
+
currentStage: incoming.currentStage ?? current.currentStage,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function applyJobEvent(
|
|
62
|
+
jobs: NormalizedJobSummary[],
|
|
63
|
+
event: SseEvent,
|
|
64
|
+
): NormalizedJobSummary[] {
|
|
65
|
+
if (event.type === "job:removed") {
|
|
66
|
+
const key = event.data.jobId;
|
|
67
|
+
const next = jobs.filter((job) => job.jobId !== key);
|
|
68
|
+
return jobsEqual(next, jobs) ? jobs : sortNormalizedJobs(next);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (event.type === "job:created" || event.type === "job:updated") {
|
|
72
|
+
const incoming = adaptJobSummary(event.data);
|
|
73
|
+
const key = incoming.jobId;
|
|
74
|
+
const existingIndex = jobs.findIndex((job) => job.jobId === key);
|
|
75
|
+
const next = existingIndex === -1
|
|
76
|
+
? [...jobs, incoming]
|
|
77
|
+
: jobs.map((job, index) => index === existingIndex ? mergeJob(job, incoming) : job);
|
|
78
|
+
const sorted = sortNormalizedJobs(next);
|
|
79
|
+
return jobsEqual(sorted, jobs) ? jobs : sorted;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return jobs;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function getConfiguredTaskCount(detail: NormalizedJobDetail): number | null {
|
|
86
|
+
const config = detail.pipelineConfig;
|
|
87
|
+
if (config && Array.isArray(config["tasks"])) return config["tasks"].length;
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function tasksEqual(left: NormalizedTask, right: NormalizedTask): boolean {
|
|
92
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function detailsDeepEqual(left: NormalizedJobDetail, right: NormalizedJobDetail): boolean {
|
|
96
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function applyDetailEvent(
|
|
100
|
+
detail: NormalizedJobDetail,
|
|
101
|
+
event: SseEvent,
|
|
102
|
+
): NormalizedJobDetail {
|
|
103
|
+
if (event.type !== "job:updated") return detail;
|
|
104
|
+
if (event.data.jobId !== detail.jobId) return detail;
|
|
105
|
+
|
|
106
|
+
const merged = { ...detail, ...event.data } as Record<string, unknown>;
|
|
107
|
+
const adapted = adaptJobDetail(merged);
|
|
108
|
+
|
|
109
|
+
const preservedTasks: Record<string, NormalizedTask> = {};
|
|
110
|
+
let tasksChanged = false;
|
|
111
|
+
for (const [name, nextTask] of Object.entries(adapted.tasks)) {
|
|
112
|
+
const prevTask = detail.tasks[name];
|
|
113
|
+
if (prevTask !== undefined && tasksEqual(prevTask, nextTask)) {
|
|
114
|
+
preservedTasks[name] = prevTask;
|
|
115
|
+
} else {
|
|
116
|
+
preservedTasks[name] = nextTask;
|
|
117
|
+
tasksChanged = true;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const derived = deriveProgress(Object.values(preservedTasks), getConfiguredTaskCount(adapted));
|
|
122
|
+
|
|
123
|
+
const candidate: NormalizedJobDetail = {
|
|
124
|
+
...adapted,
|
|
125
|
+
tasks: preservedTasks,
|
|
126
|
+
taskCount: derived.taskCount,
|
|
127
|
+
doneCount: derived.doneCount,
|
|
128
|
+
completedCount: derived.completedCount,
|
|
129
|
+
progress: derived.progress,
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
if (!tasksChanged && detailsDeepEqual(candidate, detail)) {
|
|
133
|
+
return detail;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return candidate;
|
|
137
|
+
}
|