@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,1341 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { __resetWarnedFallbackKeys, deriveProgress, normalizeJobView } from "../job-view";
|
|
3
|
+
import type { JobView } from "../job-view";
|
|
4
|
+
|
|
5
|
+
const JOB_ID = "job-abc-123";
|
|
6
|
+
const LOCATION = "current";
|
|
7
|
+
|
|
8
|
+
let warnSpy: ReturnType<typeof spyOn<Console, "warn">> | null = null;
|
|
9
|
+
|
|
10
|
+
function captureWarn(): ReturnType<typeof spyOn<Console, "warn">> {
|
|
11
|
+
warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
12
|
+
return warnSpy;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function restoreWarn(): void {
|
|
16
|
+
warnSpy?.mockRestore();
|
|
17
|
+
warnSpy = null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function callsContaining(spy: ReturnType<typeof spyOn<Console, "warn">>, fragment: string): number {
|
|
21
|
+
return spy.mock.calls.filter((call) => typeof call[0] === "string" && call[0].includes(fragment)).length;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function warnCallsContaining(fragment: string): number {
|
|
25
|
+
return warnSpy ? callsContaining(warnSpy, fragment) : 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
__resetWarnedFallbackKeys();
|
|
30
|
+
captureWarn();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
restoreWarn();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("normalizeJobView", () => {
|
|
38
|
+
describe("canonical output (AC-1)", () => {
|
|
39
|
+
test("never emits 'error' as a job status (persisted 'failed' maps to 'failed')", () => {
|
|
40
|
+
// Arrange / Act
|
|
41
|
+
const view: JobView = normalizeJobView(
|
|
42
|
+
{ state: "failed", tasks: { a: { state: "failed" } } },
|
|
43
|
+
JOB_ID,
|
|
44
|
+
LOCATION,
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// Assert
|
|
48
|
+
expect(view.status).toBe("failed");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("never emits 'done' as a job status (persisted 'done' maps to 'complete')", () => {
|
|
52
|
+
// Arrange / Act
|
|
53
|
+
const view: JobView = normalizeJobView(
|
|
54
|
+
{ state: "done", tasks: { a: { state: "done" } } },
|
|
55
|
+
JOB_ID,
|
|
56
|
+
LOCATION,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
// Assert
|
|
60
|
+
expect(view.status).toBe("complete");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("always sets id, jobId, location, and a title", () => {
|
|
64
|
+
// Arrange / Act
|
|
65
|
+
const view: JobView = normalizeJobView(
|
|
66
|
+
{ name: "My Job", state: "pending" },
|
|
67
|
+
JOB_ID,
|
|
68
|
+
LOCATION,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
// Assert
|
|
72
|
+
expect(view.id).toBe(JOB_ID);
|
|
73
|
+
expect(view.jobId).toBe(JOB_ID);
|
|
74
|
+
expect(view.location).toBe(LOCATION);
|
|
75
|
+
expect(view.name).toBe("My Job");
|
|
76
|
+
expect(view.title).toBe("My Job");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("falls back to jobId for the title when name/title are missing", () => {
|
|
80
|
+
// Arrange / Act
|
|
81
|
+
const view: JobView = normalizeJobView({ state: "pending" }, JOB_ID, LOCATION);
|
|
82
|
+
|
|
83
|
+
// Assert
|
|
84
|
+
expect(view.name).toBe(JOB_ID);
|
|
85
|
+
expect(view.title).toBe(JOB_ID);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("persisted state authoritative (AC-2 precedence 1)", () => {
|
|
90
|
+
test("state:'pending' returns 'pending' even when raw.status says 'failed'", () => {
|
|
91
|
+
// Arrange / Act
|
|
92
|
+
const view: JobView = normalizeJobView(
|
|
93
|
+
{ state: "pending", status: "failed" },
|
|
94
|
+
JOB_ID,
|
|
95
|
+
LOCATION,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// Assert
|
|
99
|
+
expect(view.status).toBe("pending");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("state:'running' wins over a 'failed' raw.status", () => {
|
|
103
|
+
// Arrange / Act
|
|
104
|
+
const view: JobView = normalizeJobView(
|
|
105
|
+
{ state: "running", status: "failed" },
|
|
106
|
+
JOB_ID,
|
|
107
|
+
LOCATION,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// Assert
|
|
111
|
+
expect(view.status).toBe("running");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("state:'done' (persisted JobState) maps to 'complete'", () => {
|
|
115
|
+
// Arrange / Act
|
|
116
|
+
const view: JobView = normalizeJobView({ state: "done" }, JOB_ID, LOCATION);
|
|
117
|
+
|
|
118
|
+
// Assert
|
|
119
|
+
expect(view.status).toBe("complete");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("state:'done' in current with pending tasks derives from tasks instead of hiding pending work", () => {
|
|
123
|
+
// Arrange / Act
|
|
124
|
+
const view: JobView = normalizeJobView(
|
|
125
|
+
{ state: "done", tasks: { a: { state: "done" }, b: { state: "pending" } } },
|
|
126
|
+
JOB_ID,
|
|
127
|
+
"current",
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
// Assert
|
|
131
|
+
expect(view.status).toBe("pending");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("state:'done' in current with running tasks derives running status", () => {
|
|
135
|
+
// Arrange / Act
|
|
136
|
+
const view: JobView = normalizeJobView(
|
|
137
|
+
{ state: "done", tasks: { a: { state: "done" }, b: { state: "running" } } },
|
|
138
|
+
JOB_ID,
|
|
139
|
+
"current",
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
// Assert
|
|
143
|
+
expect(view.status).toBe("running");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("state:'done' in complete location remains complete even when archived tasks disagree", () => {
|
|
147
|
+
// Arrange / Act
|
|
148
|
+
const view: JobView = normalizeJobView(
|
|
149
|
+
{ state: "done", tasks: { a: { state: "done" }, b: { state: "pending" } } },
|
|
150
|
+
JOB_ID,
|
|
151
|
+
"complete",
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
// Assert
|
|
155
|
+
expect(view.status).toBe("complete");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("state is case-insensitive: 'FAILED' → 'failed'", () => {
|
|
159
|
+
// Arrange / Act
|
|
160
|
+
const view: JobView = normalizeJobView({ state: "FAILED" }, JOB_ID, LOCATION);
|
|
161
|
+
|
|
162
|
+
// Assert
|
|
163
|
+
expect(view.status).toBe("failed");
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("reconciled interrupted job regression (#315, AC-2)", () => {
|
|
168
|
+
test("state:'failed' + task failed with errorContext.kind='runner-interrupted' → status:'failed' and errorContext preserved", () => {
|
|
169
|
+
// Arrange / Act
|
|
170
|
+
const view: JobView = normalizeJobView(
|
|
171
|
+
{
|
|
172
|
+
state: "failed",
|
|
173
|
+
tasks: {
|
|
174
|
+
research: {
|
|
175
|
+
state: "failed",
|
|
176
|
+
error: "runner exited without completing this task",
|
|
177
|
+
errorContext: {
|
|
178
|
+
kind: "runner-interrupted",
|
|
179
|
+
code: 1,
|
|
180
|
+
signal: null,
|
|
181
|
+
source: "child-exit",
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
JOB_ID,
|
|
187
|
+
LOCATION,
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
// Assert
|
|
191
|
+
expect(view.status).toBe("failed");
|
|
192
|
+
expect(view.tasks.research?.errorContext).toEqual({
|
|
193
|
+
kind: "runner-interrupted",
|
|
194
|
+
code: 1,
|
|
195
|
+
signal: null,
|
|
196
|
+
source: "child-exit",
|
|
197
|
+
});
|
|
198
|
+
expect(view.tasks.research?.error).toEqual({ message: "runner exited without completing this task" });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("state:'failed' + job-level runner interruption preserves root errorContext", () => {
|
|
202
|
+
// Arrange / Act
|
|
203
|
+
const view: JobView = normalizeJobView(
|
|
204
|
+
{
|
|
205
|
+
state: "failed",
|
|
206
|
+
error: {
|
|
207
|
+
name: "RunnerInterrupted",
|
|
208
|
+
message: "runner exited while the job was still marked running",
|
|
209
|
+
},
|
|
210
|
+
errorContext: {
|
|
211
|
+
kind: "runner-interrupted",
|
|
212
|
+
code: 1,
|
|
213
|
+
signal: null,
|
|
214
|
+
source: "boot-sweep",
|
|
215
|
+
},
|
|
216
|
+
tasks: {
|
|
217
|
+
research: { state: "done", currentStage: null },
|
|
218
|
+
analysis: { state: "pending", currentStage: null },
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
JOB_ID,
|
|
222
|
+
LOCATION,
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
// Assert
|
|
226
|
+
expect(view.status).toBe("failed");
|
|
227
|
+
expect(view.error).toEqual({
|
|
228
|
+
code: "RunnerInterrupted",
|
|
229
|
+
name: "RunnerInterrupted",
|
|
230
|
+
message: "runner exited while the job was still marked running",
|
|
231
|
+
});
|
|
232
|
+
expect(view.errorContext).toEqual({
|
|
233
|
+
kind: "runner-interrupted",
|
|
234
|
+
code: 1,
|
|
235
|
+
signal: null,
|
|
236
|
+
source: "boot-sweep",
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("waiting is never lost (AC-3)", () => {
|
|
242
|
+
test("state:'waiting' + all tasks done → 'waiting' (persisted state authoritative)", () => {
|
|
243
|
+
// Arrange / Act
|
|
244
|
+
const view: JobView = normalizeJobView(
|
|
245
|
+
{ state: "waiting", tasks: { a: { state: "done" }, b: { state: "done" } } },
|
|
246
|
+
JOB_ID,
|
|
247
|
+
LOCATION,
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
// Assert
|
|
251
|
+
expect(view.status).toBe("waiting");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("state:'waiting' wins over a 'failed' raw.status", () => {
|
|
255
|
+
// Arrange / Act
|
|
256
|
+
const view: JobView = normalizeJobView(
|
|
257
|
+
{ state: "waiting", status: "failed" },
|
|
258
|
+
JOB_ID,
|
|
259
|
+
LOCATION,
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
// Assert
|
|
263
|
+
expect(view.status).toBe("waiting");
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
describe("raw.status fallback (AC-2 precedence 2)", () => {
|
|
268
|
+
test("state absent + status:'running' (no tasks) → 'running'", () => {
|
|
269
|
+
// Arrange / Act
|
|
270
|
+
const view: JobView = normalizeJobView({ status: "running" }, JOB_ID, LOCATION);
|
|
271
|
+
|
|
272
|
+
// Assert
|
|
273
|
+
expect(view.status).toBe("running");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("state absent + status:'complete' (no tasks) → 'complete'", () => {
|
|
277
|
+
// Arrange / Act
|
|
278
|
+
const view: JobView = normalizeJobView({ status: "complete" }, JOB_ID, LOCATION);
|
|
279
|
+
|
|
280
|
+
// Assert
|
|
281
|
+
expect(view.status).toBe("complete");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("state absent + status:'failed' (no tasks) → 'failed'", () => {
|
|
285
|
+
// Arrange / Act
|
|
286
|
+
const view: JobView = normalizeJobView({ status: "failed" }, JOB_ID, LOCATION);
|
|
287
|
+
|
|
288
|
+
// Assert
|
|
289
|
+
expect(view.status).toBe("failed");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("state absent + status:'error' (legacy synonym) → 'failed'", () => {
|
|
293
|
+
// Arrange / Act
|
|
294
|
+
const view: JobView = normalizeJobView({ status: "error" }, JOB_ID, LOCATION);
|
|
295
|
+
|
|
296
|
+
// Assert
|
|
297
|
+
expect(view.status).toBe("failed");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("state absent + status:'done' (legacy synonym) → 'complete'", () => {
|
|
301
|
+
// Arrange / Act
|
|
302
|
+
const view: JobView = normalizeJobView({ status: "done" }, JOB_ID, LOCATION);
|
|
303
|
+
|
|
304
|
+
// Assert
|
|
305
|
+
expect(view.status).toBe("complete");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("status case-insensitive: 'ERROR' → 'failed'", () => {
|
|
309
|
+
// Arrange / Act
|
|
310
|
+
const view: JobView = normalizeJobView({ status: "ERROR" }, JOB_ID, LOCATION);
|
|
311
|
+
|
|
312
|
+
// Assert
|
|
313
|
+
expect(view.status).toBe("failed");
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("state unrecognized + status:'error' → 'failed'", () => {
|
|
317
|
+
// Arrange / Act
|
|
318
|
+
const view: JobView = normalizeJobView(
|
|
319
|
+
{ state: "bogus-state", status: "error" },
|
|
320
|
+
JOB_ID,
|
|
321
|
+
LOCATION,
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
// Assert
|
|
325
|
+
expect(view.status).toBe("failed");
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
describe("task-derived fallback (AC-2 precedence 3)", () => {
|
|
330
|
+
test("state absent + no status + all tasks done → 'complete' and warns once", () => {
|
|
331
|
+
// Arrange
|
|
332
|
+
const raw = { tasks: { a: { state: "done" }, b: { state: "done" } } };
|
|
333
|
+
|
|
334
|
+
// Act
|
|
335
|
+
const view: JobView = normalizeJobView(raw, JOB_ID, LOCATION);
|
|
336
|
+
|
|
337
|
+
// Assert
|
|
338
|
+
expect(view.status).toBe("complete");
|
|
339
|
+
expect(warnCallsContaining(`${LOCATION}:${JOB_ID}`)).toBe(1);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("state absent + no status + tasks failed + done → 'failed' (failed priority) and warns", () => {
|
|
343
|
+
// Arrange
|
|
344
|
+
const raw = { tasks: { a: { state: "failed" }, b: { state: "done" } } };
|
|
345
|
+
|
|
346
|
+
// Act
|
|
347
|
+
const view: JobView = normalizeJobView(raw, JOB_ID, LOCATION);
|
|
348
|
+
|
|
349
|
+
// Assert
|
|
350
|
+
expect(view.status).toBe("failed");
|
|
351
|
+
expect(warnCallsContaining(`${LOCATION}:${JOB_ID}`)).toBe(1);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("state absent + no status + tasks running + done → 'running' and warns", () => {
|
|
355
|
+
// Arrange
|
|
356
|
+
const raw = { tasks: { a: { state: "running" }, b: { state: "done" } } };
|
|
357
|
+
|
|
358
|
+
// Act
|
|
359
|
+
const view: JobView = normalizeJobView(raw, JOB_ID, LOCATION);
|
|
360
|
+
|
|
361
|
+
// Assert
|
|
362
|
+
expect(view.status).toBe("running");
|
|
363
|
+
expect(warnCallsContaining(`${LOCATION}:${JOB_ID}`)).toBe(1);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("state absent + no status + unrecognized status falls through to derivation", () => {
|
|
367
|
+
// Arrange
|
|
368
|
+
const raw = { status: "mystery-status", tasks: { a: { state: "done" } } };
|
|
369
|
+
|
|
370
|
+
// Act
|
|
371
|
+
const view: JobView = normalizeJobView(raw, JOB_ID, LOCATION);
|
|
372
|
+
|
|
373
|
+
// Assert
|
|
374
|
+
expect(view.status).toBe("complete");
|
|
375
|
+
expect(warnCallsContaining(`${LOCATION}:${JOB_ID}`)).toBe(1);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("task-derived fallback warns once across multiple calls for the same location:jobId", () => {
|
|
379
|
+
// Arrange
|
|
380
|
+
const raw = { tasks: { a: { state: "done" } } };
|
|
381
|
+
|
|
382
|
+
// Act
|
|
383
|
+
normalizeJobView(raw, JOB_ID, LOCATION);
|
|
384
|
+
normalizeJobView(raw, JOB_ID, LOCATION);
|
|
385
|
+
normalizeJobView(raw, JOB_ID, LOCATION);
|
|
386
|
+
|
|
387
|
+
// Assert
|
|
388
|
+
expect(warnCallsContaining(`${LOCATION}:${JOB_ID}`)).toBe(1);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("task-derived fallback warns separately for different location:jobId keys", () => {
|
|
392
|
+
// Arrange
|
|
393
|
+
const raw = { tasks: { a: { state: "done" } } };
|
|
394
|
+
|
|
395
|
+
// Act
|
|
396
|
+
normalizeJobView(raw, "job-A", "current");
|
|
397
|
+
normalizeJobView(raw, "job-B", "current");
|
|
398
|
+
normalizeJobView(raw, "job-A", "complete");
|
|
399
|
+
|
|
400
|
+
// Assert
|
|
401
|
+
expect(warnCallsContaining("current:job-A")).toBe(1);
|
|
402
|
+
expect(warnCallsContaining("current:job-B")).toBe(1);
|
|
403
|
+
expect(warnCallsContaining("complete:job-A")).toBe(1);
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
describe("disagreement cases (AC-4)", () => {
|
|
408
|
+
test("state:'failed' + tasks all done → 'failed' (persisted state authoritative)", () => {
|
|
409
|
+
// Arrange / Act
|
|
410
|
+
const view: JobView = normalizeJobView(
|
|
411
|
+
{ state: "failed", tasks: { a: { state: "done" }, b: { state: "done" } } },
|
|
412
|
+
JOB_ID,
|
|
413
|
+
LOCATION,
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
// Assert
|
|
417
|
+
expect(view.status).toBe("failed");
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("state:'running' + tasks all done → 'running' (persisted state authoritative)", () => {
|
|
421
|
+
// Arrange / Act
|
|
422
|
+
const view: JobView = normalizeJobView(
|
|
423
|
+
{ state: "running", tasks: { a: { state: "done" }, b: { state: "done" } } },
|
|
424
|
+
JOB_ID,
|
|
425
|
+
LOCATION,
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
// Assert
|
|
429
|
+
expect(view.status).toBe("running");
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("state absent + tasks all done → 'complete'", () => {
|
|
433
|
+
// Arrange
|
|
434
|
+
const raw = { tasks: { a: { state: "done" }, b: { state: "done" } } };
|
|
435
|
+
|
|
436
|
+
// Act
|
|
437
|
+
const view: JobView = normalizeJobView(raw, JOB_ID, LOCATION);
|
|
438
|
+
|
|
439
|
+
// Assert
|
|
440
|
+
expect(view.status).toBe("complete");
|
|
441
|
+
expect(warnCallsContaining(`${LOCATION}:${JOB_ID}`)).toBe(1);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("state:'done' + tasks all done → 'complete' (agreement)", () => {
|
|
445
|
+
// Arrange / Act
|
|
446
|
+
const view: JobView = normalizeJobView(
|
|
447
|
+
{ state: "done", tasks: { a: { state: "done" } } },
|
|
448
|
+
JOB_ID,
|
|
449
|
+
LOCATION,
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
// Assert
|
|
453
|
+
expect(view.status).toBe("complete");
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
describe("liberal input, canonical output (AC-5)", () => {
|
|
458
|
+
test("status:'error' (legacy) with no valid state → 'failed'", () => {
|
|
459
|
+
// Arrange / Act
|
|
460
|
+
const view: JobView = normalizeJobView({ status: "error" }, JOB_ID, LOCATION);
|
|
461
|
+
|
|
462
|
+
// Assert
|
|
463
|
+
expect(view.status).toBe("failed");
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("state:'done' (persisted) → 'complete' and never 'done'", () => {
|
|
467
|
+
// Arrange / Act
|
|
468
|
+
const view: JobView = normalizeJobView({ state: "done" }, JOB_ID, LOCATION);
|
|
469
|
+
|
|
470
|
+
// Assert
|
|
471
|
+
expect(view.status).toBe("complete");
|
|
472
|
+
expect(view.status).not.toBe("done");
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test("status:'done' (legacy) → 'complete' and never 'done'", () => {
|
|
476
|
+
// Arrange / Act
|
|
477
|
+
const view: JobView = normalizeJobView({ status: "done" }, JOB_ID, LOCATION);
|
|
478
|
+
|
|
479
|
+
// Assert
|
|
480
|
+
expect(view.status).toBe("complete");
|
|
481
|
+
expect(view.status).not.toBe("done");
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test("case-insensitive: state:'DONE' → 'complete'", () => {
|
|
485
|
+
// Arrange / Act
|
|
486
|
+
const view: JobView = normalizeJobView({ state: "DONE" }, JOB_ID, LOCATION);
|
|
487
|
+
|
|
488
|
+
// Assert
|
|
489
|
+
expect(view.status).toBe("complete");
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test("case-insensitive: state:' failed ' → 'failed'", () => {
|
|
493
|
+
// Arrange / Act
|
|
494
|
+
const view: JobView = normalizeJobView({ state: " failed " }, JOB_ID, LOCATION);
|
|
495
|
+
|
|
496
|
+
// Assert
|
|
497
|
+
expect(view.status).toBe("failed");
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
describe("field preservation", () => {
|
|
502
|
+
test("ignores persisted progress and derives via deriveProgress (AC-7)", () => {
|
|
503
|
+
// Arrange / Act
|
|
504
|
+
const view: JobView = normalizeJobView(
|
|
505
|
+
{ state: "running", progress: 42, tasks: { a: { state: "done" }, b: { state: "running" } } },
|
|
506
|
+
JOB_ID,
|
|
507
|
+
LOCATION,
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
// Assert
|
|
511
|
+
expect(view.progress).toBe(50);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test("derives progress with pipelineConfig task count when present", () => {
|
|
515
|
+
// Arrange / Act
|
|
516
|
+
const view: JobView = normalizeJobView(
|
|
517
|
+
{
|
|
518
|
+
state: "running",
|
|
519
|
+
progress: 99,
|
|
520
|
+
pipelineConfig: { tasks: ["a", "b", "c", "d"] },
|
|
521
|
+
tasks: {
|
|
522
|
+
a: { state: "done" },
|
|
523
|
+
b: { state: "running" },
|
|
524
|
+
},
|
|
525
|
+
},
|
|
526
|
+
JOB_ID,
|
|
527
|
+
LOCATION,
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
// Assert
|
|
531
|
+
expect(view.progress).toBe(25);
|
|
532
|
+
expect(view.pipelineConfig).toEqual({ tasks: ["a", "b", "c", "d"] });
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test("derives progress from completed tasks when persisted progress is missing", () => {
|
|
536
|
+
// Arrange / Act
|
|
537
|
+
const view: JobView = normalizeJobView(
|
|
538
|
+
{ state: "running", tasks: { a: { state: "done" }, b: { state: "skipped" }, c: { state: "pending" } } },
|
|
539
|
+
JOB_ID,
|
|
540
|
+
LOCATION,
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
// Assert
|
|
544
|
+
expect(view.progress).toBe(66);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
test("derives progress when persisted progress is not finite", () => {
|
|
548
|
+
// Arrange / Act
|
|
549
|
+
const view: JobView = normalizeJobView(
|
|
550
|
+
{ state: "running", progress: Number.NaN, tasks: { a: { state: "done" }, b: { state: "pending" } } },
|
|
551
|
+
JOB_ID,
|
|
552
|
+
LOCATION,
|
|
553
|
+
);
|
|
554
|
+
|
|
555
|
+
// Assert
|
|
556
|
+
expect(view.progress).toBe(50);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
test("maps live snapshot lastUpdated to updatedAt", () => {
|
|
560
|
+
// Arrange / Act
|
|
561
|
+
const view: JobView = normalizeJobView(
|
|
562
|
+
{ state: "running", lastUpdated: "2026-06-19T12:00:00.000Z" },
|
|
563
|
+
JOB_ID,
|
|
564
|
+
LOCATION,
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
// Assert
|
|
568
|
+
expect(view.updatedAt).toBe("2026-06-19T12:00:00.000Z");
|
|
569
|
+
expect(view.lastUpdated).toBe("2026-06-19T12:00:00.000Z");
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test("prefers explicit updatedAt over lastUpdated", () => {
|
|
573
|
+
// Arrange / Act
|
|
574
|
+
const view: JobView = normalizeJobView(
|
|
575
|
+
{
|
|
576
|
+
state: "running",
|
|
577
|
+
updatedAt: "2026-06-19T12:05:00.000Z",
|
|
578
|
+
lastUpdated: "2026-06-19T12:00:00.000Z",
|
|
579
|
+
},
|
|
580
|
+
JOB_ID,
|
|
581
|
+
LOCATION,
|
|
582
|
+
);
|
|
583
|
+
|
|
584
|
+
// Assert
|
|
585
|
+
expect(view.updatedAt).toBe("2026-06-19T12:05:00.000Z");
|
|
586
|
+
expect(view.lastUpdated).toBe("2026-06-19T12:00:00.000Z");
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
test("preserves explicit top-level costs when present", () => {
|
|
590
|
+
// Arrange / Act
|
|
591
|
+
const view: JobView = normalizeJobView(
|
|
592
|
+
{ state: "done", costs: { totalCost: 7, totalTokens: 12 } },
|
|
593
|
+
JOB_ID,
|
|
594
|
+
LOCATION,
|
|
595
|
+
);
|
|
596
|
+
|
|
597
|
+
// Assert
|
|
598
|
+
expect(view.costs).toEqual({ totalCost: 7, totalTokens: 12 });
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test("aggregates costs from task tokenUsage when top-level costs are absent", () => {
|
|
602
|
+
// Arrange / Act
|
|
603
|
+
const view: JobView = normalizeJobView(
|
|
604
|
+
{
|
|
605
|
+
state: "done",
|
|
606
|
+
tasks: {
|
|
607
|
+
alpha: { state: "done", tokenUsage: [["openai:gpt-4o", 10, 5, 0.25]] },
|
|
608
|
+
beta: { state: "done", tokenUsage: [["anthropic:claude", 7, 3, 0.5], ["bad"]] },
|
|
609
|
+
},
|
|
610
|
+
},
|
|
611
|
+
JOB_ID,
|
|
612
|
+
LOCATION,
|
|
613
|
+
);
|
|
614
|
+
|
|
615
|
+
// Assert
|
|
616
|
+
expect(view.costs).toEqual({
|
|
617
|
+
totalCost: 0.75,
|
|
618
|
+
totalTokens: 25,
|
|
619
|
+
totalInputTokens: 17,
|
|
620
|
+
totalOutputTokens: 8,
|
|
621
|
+
estimatedCost: 0,
|
|
622
|
+
unavailableCost: 0,
|
|
623
|
+
hasEstimated: false,
|
|
624
|
+
alpha: {
|
|
625
|
+
inputTokens: 10,
|
|
626
|
+
outputTokens: 5,
|
|
627
|
+
inputCost: 0,
|
|
628
|
+
outputCost: 0,
|
|
629
|
+
totalCost: 0.25,
|
|
630
|
+
source: "reported",
|
|
631
|
+
},
|
|
632
|
+
beta: {
|
|
633
|
+
inputTokens: 7,
|
|
634
|
+
outputTokens: 3,
|
|
635
|
+
inputCost: 0,
|
|
636
|
+
outputCost: 0,
|
|
637
|
+
totalCost: 0.5,
|
|
638
|
+
source: "reported",
|
|
639
|
+
},
|
|
640
|
+
});
|
|
641
|
+
expect(view.tasks.alpha?.tokenUsage).toEqual([["openai:gpt-4o", 10, 5, 0.25]]);
|
|
642
|
+
expect(view.tasks.beta?.tokenUsage).toEqual([
|
|
643
|
+
["anthropic:claude", 7, 3, 0.5],
|
|
644
|
+
["bad"],
|
|
645
|
+
]);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
test("normalizes persisted string task errors to message objects", () => {
|
|
649
|
+
// Arrange / Act
|
|
650
|
+
const view: JobView = normalizeJobView(
|
|
651
|
+
{
|
|
652
|
+
state: "failed",
|
|
653
|
+
tasks: {
|
|
654
|
+
agent: { state: "failed", error: "Agent step failed" },
|
|
655
|
+
},
|
|
656
|
+
},
|
|
657
|
+
JOB_ID,
|
|
658
|
+
LOCATION,
|
|
659
|
+
);
|
|
660
|
+
|
|
661
|
+
// Assert
|
|
662
|
+
expect(view.tasks.agent?.error).toEqual({ message: "Agent step failed" });
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
test("preserves readable:false when present", () => {
|
|
666
|
+
// Arrange / Act
|
|
667
|
+
const view: JobView = normalizeJobView(
|
|
668
|
+
{ state: "failed", readable: false },
|
|
669
|
+
JOB_ID,
|
|
670
|
+
LOCATION,
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
// Assert
|
|
674
|
+
expect(view.readable).toBe(false);
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
test("preserves readable:true when present", () => {
|
|
678
|
+
// Arrange / Act
|
|
679
|
+
const view: JobView = normalizeJobView(
|
|
680
|
+
{ state: "done", readable: true },
|
|
681
|
+
JOB_ID,
|
|
682
|
+
LOCATION,
|
|
683
|
+
);
|
|
684
|
+
|
|
685
|
+
// Assert
|
|
686
|
+
expect(view.readable).toBe(true);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
test("readable is undefined when absent (defaults to readable per #313)", () => {
|
|
690
|
+
// Arrange / Act
|
|
691
|
+
const view: JobView = normalizeJobView({ state: "done" }, JOB_ID, LOCATION);
|
|
692
|
+
|
|
693
|
+
// Assert
|
|
694
|
+
expect(view.readable).toBeUndefined();
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
test("preserves error: { code, message, path } when well-formed", () => {
|
|
698
|
+
// Arrange / Act
|
|
699
|
+
const view: JobView = normalizeJobView(
|
|
700
|
+
{ readable: false, error: { code: "STATUS_CORRUPT", message: "bad json", path: "/tmp/x" } },
|
|
701
|
+
JOB_ID,
|
|
702
|
+
LOCATION,
|
|
703
|
+
);
|
|
704
|
+
|
|
705
|
+
// Assert
|
|
706
|
+
expect(view.error).toEqual({ code: "STATUS_CORRUPT", message: "bad json", path: "/tmp/x" });
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
test("preserves error without path", () => {
|
|
710
|
+
// Arrange / Act
|
|
711
|
+
const view: JobView = normalizeJobView(
|
|
712
|
+
{ readable: false, error: { code: "STATUS_CORRUPT", message: "bad json" } },
|
|
713
|
+
JOB_ID,
|
|
714
|
+
LOCATION,
|
|
715
|
+
);
|
|
716
|
+
|
|
717
|
+
// Assert
|
|
718
|
+
expect(view.error).toEqual({ code: "STATUS_CORRUPT", message: "bad json" });
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
test("normalizes gate rejection errors from name/message", () => {
|
|
722
|
+
// Arrange / Act
|
|
723
|
+
const view: JobView = normalizeJobView(
|
|
724
|
+
{ error: { name: "GateRejected", message: "needs another pass" } },
|
|
725
|
+
JOB_ID,
|
|
726
|
+
LOCATION,
|
|
727
|
+
);
|
|
728
|
+
|
|
729
|
+
// Assert
|
|
730
|
+
expect(view.error).toEqual({
|
|
731
|
+
code: "GateRejected",
|
|
732
|
+
message: "needs another pass",
|
|
733
|
+
name: "GateRejected",
|
|
734
|
+
});
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
test("normalizes runner failures from name/message/stack", () => {
|
|
738
|
+
// Arrange / Act
|
|
739
|
+
const view: JobView = normalizeJobView(
|
|
740
|
+
{ error: { name: "Error", message: "boom", stack: "trace" } },
|
|
741
|
+
JOB_ID,
|
|
742
|
+
LOCATION,
|
|
743
|
+
);
|
|
744
|
+
|
|
745
|
+
// Assert
|
|
746
|
+
expect(view.error).toEqual({
|
|
747
|
+
code: "Error",
|
|
748
|
+
message: "boom",
|
|
749
|
+
name: "Error",
|
|
750
|
+
stack: "trace",
|
|
751
|
+
});
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
test("uses a fallback code for message-only errors", () => {
|
|
755
|
+
// Arrange / Act
|
|
756
|
+
const view: JobView = normalizeJobView(
|
|
757
|
+
{ error: { code: 42, message: "bad" } },
|
|
758
|
+
JOB_ID,
|
|
759
|
+
LOCATION,
|
|
760
|
+
);
|
|
761
|
+
|
|
762
|
+
// Assert
|
|
763
|
+
expect(view.error).toEqual({ code: "JOB_ERROR", message: "bad" });
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
test("drops error when message is not a string", () => {
|
|
767
|
+
// Arrange / Act
|
|
768
|
+
const view: JobView = normalizeJobView(
|
|
769
|
+
{ error: { code: "STATUS_CORRUPT", message: 42 } },
|
|
770
|
+
JOB_ID,
|
|
771
|
+
LOCATION,
|
|
772
|
+
);
|
|
773
|
+
|
|
774
|
+
// Assert
|
|
775
|
+
expect(view.error).toBeUndefined();
|
|
776
|
+
});
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
describe("task id synthesis (AC-8 server side)", () => {
|
|
780
|
+
test("array tasks with explicit name fields are keyed by name", () => {
|
|
781
|
+
// Arrange / Act
|
|
782
|
+
const view: JobView = normalizeJobView(
|
|
783
|
+
{ state: "done", tasks: [{ name: "alpha" }, { name: "beta" }] },
|
|
784
|
+
JOB_ID,
|
|
785
|
+
LOCATION,
|
|
786
|
+
);
|
|
787
|
+
|
|
788
|
+
// Assert
|
|
789
|
+
expect(Object.keys(view.tasks)).toEqual(["alpha", "beta"]);
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
test("array tasks with no name fall back to zero-based 'task-0', 'task-1'", () => {
|
|
793
|
+
// Arrange / Act
|
|
794
|
+
const view: JobView = normalizeJobView(
|
|
795
|
+
{ state: "done", tasks: [{}, {}] },
|
|
796
|
+
JOB_ID,
|
|
797
|
+
LOCATION,
|
|
798
|
+
);
|
|
799
|
+
|
|
800
|
+
// Assert
|
|
801
|
+
expect(Object.keys(view.tasks)).toEqual(["task-0", "task-1"]);
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
test("array tasks with no name preserve order in the fallback", () => {
|
|
805
|
+
// Arrange / Act
|
|
806
|
+
const view: JobView = normalizeJobView(
|
|
807
|
+
{ state: "done", tasks: [{ state: "done" }, { state: "done" }, { state: "done" }] },
|
|
808
|
+
JOB_ID,
|
|
809
|
+
LOCATION,
|
|
810
|
+
);
|
|
811
|
+
|
|
812
|
+
// Assert
|
|
813
|
+
expect(Object.keys(view.tasks)).toEqual(["task-0", "task-1", "task-2"]);
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
test("mixed array (named + unnamed) uses name where present, 'task-N' otherwise", () => {
|
|
817
|
+
// Arrange / Act
|
|
818
|
+
const view: JobView = normalizeJobView(
|
|
819
|
+
{ state: "done", tasks: [{ name: "alpha" }, {}] },
|
|
820
|
+
JOB_ID,
|
|
821
|
+
LOCATION,
|
|
822
|
+
);
|
|
823
|
+
|
|
824
|
+
// Assert
|
|
825
|
+
expect(Object.keys(view.tasks)).toEqual(["alpha", "task-1"]);
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
test("empty-string name falls back to 'task-${index}'", () => {
|
|
829
|
+
// Arrange / Act
|
|
830
|
+
const view: JobView = normalizeJobView(
|
|
831
|
+
{ state: "done", tasks: [{ name: "" }] },
|
|
832
|
+
JOB_ID,
|
|
833
|
+
LOCATION,
|
|
834
|
+
);
|
|
835
|
+
|
|
836
|
+
// Assert
|
|
837
|
+
expect(Object.keys(view.tasks)).toEqual(["task-0"]);
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
test("object tasks are keyed by their existing keys (pipeline order preserved)", () => {
|
|
841
|
+
// Arrange / Act
|
|
842
|
+
const view: JobView = normalizeJobView(
|
|
843
|
+
{ state: "done", tasks: { plan: { state: "done" }, review: { state: "done" } } },
|
|
844
|
+
JOB_ID,
|
|
845
|
+
LOCATION,
|
|
846
|
+
);
|
|
847
|
+
|
|
848
|
+
// Assert
|
|
849
|
+
expect(Object.keys(view.tasks)).toEqual(["plan", "review"]);
|
|
850
|
+
});
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
describe("robustness", () => {
|
|
854
|
+
test("non-object raw returns a JobView with empty tasks and pending status (no throw)", () => {
|
|
855
|
+
// Arrange / Act
|
|
856
|
+
const view: JobView = normalizeJobView("not an object", JOB_ID, LOCATION);
|
|
857
|
+
|
|
858
|
+
// Assert
|
|
859
|
+
expect(view.tasks).toEqual({});
|
|
860
|
+
expect(view.status).toBe("pending");
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
test("null raw returns a JobView with empty tasks and pending status (no throw)", () => {
|
|
864
|
+
// Arrange / Act
|
|
865
|
+
const view: JobView = normalizeJobView(null, JOB_ID, LOCATION);
|
|
866
|
+
|
|
867
|
+
// Assert
|
|
868
|
+
expect(view.tasks).toEqual({});
|
|
869
|
+
expect(view.status).toBe("pending");
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
test("undefined raw returns a JobView with empty tasks and pending status (no throw)", () => {
|
|
873
|
+
// Arrange / Act
|
|
874
|
+
const view: JobView = normalizeJobView(undefined, JOB_ID, LOCATION);
|
|
875
|
+
|
|
876
|
+
// Assert
|
|
877
|
+
expect(view.tasks).toEqual({});
|
|
878
|
+
expect(view.status).toBe("pending");
|
|
879
|
+
});
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
describe("gate kind preservation (AC-11)", () => {
|
|
883
|
+
test("preserves gate.kind === 'control_invalid' through normalizeJobView", () => {
|
|
884
|
+
// Arrange / Act
|
|
885
|
+
const view: JobView = normalizeJobView(
|
|
886
|
+
{
|
|
887
|
+
state: "waiting",
|
|
888
|
+
gate: {
|
|
889
|
+
afterTask: "review",
|
|
890
|
+
message: "Control file invalid for review: bad directive.",
|
|
891
|
+
requestedAt: "2026-06-19T12:00:00.000Z",
|
|
892
|
+
kind: "control_invalid",
|
|
893
|
+
},
|
|
894
|
+
tasks: { review: { state: "done" } },
|
|
895
|
+
},
|
|
896
|
+
JOB_ID,
|
|
897
|
+
LOCATION,
|
|
898
|
+
);
|
|
899
|
+
|
|
900
|
+
// Assert
|
|
901
|
+
expect(view.gate?.kind).toBe("control_invalid");
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
test("omits gate.kind when value is not one of the allowed strings", () => {
|
|
905
|
+
// Arrange / Act
|
|
906
|
+
const view: JobView = normalizeJobView(
|
|
907
|
+
{
|
|
908
|
+
state: "waiting",
|
|
909
|
+
gate: {
|
|
910
|
+
afterTask: "review",
|
|
911
|
+
message: "Control file invalid.",
|
|
912
|
+
requestedAt: "2026-06-19T12:00:00.000Z",
|
|
913
|
+
kind: 42,
|
|
914
|
+
},
|
|
915
|
+
tasks: { review: { state: "done" } },
|
|
916
|
+
},
|
|
917
|
+
JOB_ID,
|
|
918
|
+
LOCATION,
|
|
919
|
+
);
|
|
920
|
+
|
|
921
|
+
// Assert
|
|
922
|
+
expect(view.gate).toBeDefined();
|
|
923
|
+
expect(Object.hasOwn(view.gate!, "kind")).toBe(false);
|
|
924
|
+
});
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
describe("getCosts source propagation (AC-10)", () => {
|
|
928
|
+
test("mixed reported+estimated tuples separate measured from estimated totals", () => {
|
|
929
|
+
// Arrange / Act
|
|
930
|
+
const view: JobView = normalizeJobView(
|
|
931
|
+
{
|
|
932
|
+
state: "done",
|
|
933
|
+
tasks: {
|
|
934
|
+
alpha: { state: "done", tokenUsage: [["openai:gpt-4o", 10, 5, 0.25, "reported"]] },
|
|
935
|
+
beta: { state: "done", tokenUsage: [["anthropic:claude", 7, 3, 0.5, "estimated"]] },
|
|
936
|
+
},
|
|
937
|
+
},
|
|
938
|
+
JOB_ID,
|
|
939
|
+
LOCATION,
|
|
940
|
+
);
|
|
941
|
+
|
|
942
|
+
// Assert
|
|
943
|
+
expect(view.costs).toMatchObject({
|
|
944
|
+
totalCost: 0.25,
|
|
945
|
+
estimatedCost: 0.5,
|
|
946
|
+
unavailableCost: 0,
|
|
947
|
+
hasEstimated: true,
|
|
948
|
+
totalTokens: 25,
|
|
949
|
+
totalInputTokens: 17,
|
|
950
|
+
totalOutputTokens: 8,
|
|
951
|
+
});
|
|
952
|
+
expect(view.costs).toMatchObject({
|
|
953
|
+
alpha: { source: "reported", totalCost: 0.25, inputTokens: 10, outputTokens: 5 },
|
|
954
|
+
beta: { source: "estimated", totalCost: 0.5, inputTokens: 7, outputTokens: 3 },
|
|
955
|
+
});
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
test("all-reported tuples do not set an estimate marker", () => {
|
|
959
|
+
// Arrange / Act
|
|
960
|
+
const view: JobView = normalizeJobView(
|
|
961
|
+
{
|
|
962
|
+
state: "done",
|
|
963
|
+
tasks: {
|
|
964
|
+
alpha: { state: "done", tokenUsage: [["openai:gpt-4o", 10, 5, 0.25, "reported"]] },
|
|
965
|
+
},
|
|
966
|
+
},
|
|
967
|
+
JOB_ID,
|
|
968
|
+
LOCATION,
|
|
969
|
+
);
|
|
970
|
+
|
|
971
|
+
// Assert
|
|
972
|
+
expect(view.costs).toMatchObject({
|
|
973
|
+
totalCost: 0.25,
|
|
974
|
+
estimatedCost: 0,
|
|
975
|
+
unavailableCost: 0,
|
|
976
|
+
hasEstimated: false,
|
|
977
|
+
});
|
|
978
|
+
expect(view.costs).toMatchObject({ alpha: { source: "reported", totalCost: 0.25 } });
|
|
979
|
+
expect(Object.hasOwn(view.costs.alpha as object, "failed")).toBe(false);
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
test("reported usage with estimated cost stays out of measured totals", () => {
|
|
983
|
+
// Arrange / Act
|
|
984
|
+
const view: JobView = normalizeJobView(
|
|
985
|
+
{
|
|
986
|
+
state: "done",
|
|
987
|
+
tasks: {
|
|
988
|
+
alpha: {
|
|
989
|
+
state: "done",
|
|
990
|
+
tokenUsage: [["openai:unknown-model", 10, 5, 0, "reported", false, true]],
|
|
991
|
+
},
|
|
992
|
+
},
|
|
993
|
+
},
|
|
994
|
+
JOB_ID,
|
|
995
|
+
LOCATION,
|
|
996
|
+
);
|
|
997
|
+
|
|
998
|
+
// Assert
|
|
999
|
+
expect(view.costs).toMatchObject({
|
|
1000
|
+
totalCost: 0,
|
|
1001
|
+
estimatedCost: 0,
|
|
1002
|
+
unavailableCost: 0,
|
|
1003
|
+
hasEstimated: true,
|
|
1004
|
+
totalTokens: 15,
|
|
1005
|
+
alpha: {
|
|
1006
|
+
source: "reported",
|
|
1007
|
+
totalCost: 0,
|
|
1008
|
+
costEstimated: true,
|
|
1009
|
+
},
|
|
1010
|
+
});
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
test("legacy 4-element tuples are treated as reported", () => {
|
|
1014
|
+
// Arrange / Act
|
|
1015
|
+
const view: JobView = normalizeJobView(
|
|
1016
|
+
{
|
|
1017
|
+
state: "done",
|
|
1018
|
+
tasks: {
|
|
1019
|
+
alpha: { state: "done", tokenUsage: [["openai:gpt-4o", 10, 5, 0.25]] },
|
|
1020
|
+
},
|
|
1021
|
+
},
|
|
1022
|
+
JOB_ID,
|
|
1023
|
+
LOCATION,
|
|
1024
|
+
);
|
|
1025
|
+
|
|
1026
|
+
// Assert
|
|
1027
|
+
expect(view.costs).toMatchObject({
|
|
1028
|
+
totalCost: 0.25,
|
|
1029
|
+
estimatedCost: 0,
|
|
1030
|
+
unavailableCost: 0,
|
|
1031
|
+
hasEstimated: false,
|
|
1032
|
+
});
|
|
1033
|
+
expect(view.costs).toMatchObject({ alpha: { source: "reported", totalCost: 0.25 } });
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
test("unavailable source contributes to unavailableCost and sets hasEstimated", () => {
|
|
1037
|
+
// Arrange / Act
|
|
1038
|
+
const view: JobView = normalizeJobView(
|
|
1039
|
+
{
|
|
1040
|
+
state: "done",
|
|
1041
|
+
tasks: {
|
|
1042
|
+
alpha: {
|
|
1043
|
+
state: "done",
|
|
1044
|
+
tokenUsage: [["claudecode:default", 0, 0, 0.1, "unavailable"]],
|
|
1045
|
+
},
|
|
1046
|
+
},
|
|
1047
|
+
},
|
|
1048
|
+
JOB_ID,
|
|
1049
|
+
LOCATION,
|
|
1050
|
+
);
|
|
1051
|
+
|
|
1052
|
+
// Assert
|
|
1053
|
+
expect(view.costs).toMatchObject({
|
|
1054
|
+
totalCost: 0,
|
|
1055
|
+
estimatedCost: 0,
|
|
1056
|
+
unavailableCost: 0.1,
|
|
1057
|
+
hasEstimated: true,
|
|
1058
|
+
});
|
|
1059
|
+
expect(view.costs).toMatchObject({ alpha: { source: "unavailable", totalCost: 0.1 } });
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
test("cost-only agent tuple surfaces unavailable cost, not a measured total (AC-6)", () => {
|
|
1063
|
+
// Arrange — the exact 7-element tuple the stage runAgent / pipeline agent
|
|
1064
|
+
// path emits for a token-less (costUsd-only) harness via recordAgentUsage.
|
|
1065
|
+
const view: JobView = normalizeJobView(
|
|
1066
|
+
{
|
|
1067
|
+
state: "done",
|
|
1068
|
+
tasks: {
|
|
1069
|
+
"agent-cost-task": {
|
|
1070
|
+
state: "done",
|
|
1071
|
+
tokenUsage: [["opencode:gpt-4o", 0, 0, 0.12, "unavailable", false, true]],
|
|
1072
|
+
},
|
|
1073
|
+
},
|
|
1074
|
+
},
|
|
1075
|
+
JOB_ID,
|
|
1076
|
+
LOCATION,
|
|
1077
|
+
);
|
|
1078
|
+
|
|
1079
|
+
// Assert — cost lands in unavailableCost (estimated), never the measured totalCost.
|
|
1080
|
+
expect(view.costs).toMatchObject({
|
|
1081
|
+
totalCost: 0,
|
|
1082
|
+
totalTokens: 0,
|
|
1083
|
+
estimatedCost: 0,
|
|
1084
|
+
unavailableCost: 0.12,
|
|
1085
|
+
hasEstimated: true,
|
|
1086
|
+
});
|
|
1087
|
+
expect(view.costs).toMatchObject({
|
|
1088
|
+
"agent-cost-task": { source: "unavailable", totalCost: 0.12, costEstimated: true },
|
|
1089
|
+
});
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
test("zero source contributes no cost and does not set hasEstimated", () => {
|
|
1093
|
+
// Arrange / Act — mix zero with reported so the job has a non-empty costs
|
|
1094
|
+
// object and we can verify zero does not add to estimated/unavailable.
|
|
1095
|
+
const view: JobView = normalizeJobView(
|
|
1096
|
+
{
|
|
1097
|
+
state: "done",
|
|
1098
|
+
tasks: {
|
|
1099
|
+
alpha: {
|
|
1100
|
+
state: "done",
|
|
1101
|
+
tokenUsage: [
|
|
1102
|
+
["mock:mock", 0, 0, 0, "zero"],
|
|
1103
|
+
["openai:gpt-4o", 10, 5, 0.25, "reported"],
|
|
1104
|
+
],
|
|
1105
|
+
},
|
|
1106
|
+
},
|
|
1107
|
+
},
|
|
1108
|
+
JOB_ID,
|
|
1109
|
+
LOCATION,
|
|
1110
|
+
);
|
|
1111
|
+
|
|
1112
|
+
// Assert — zero source is legitimate reported-zero; no estimate marker.
|
|
1113
|
+
expect(view.costs).toMatchObject({
|
|
1114
|
+
totalCost: 0.25,
|
|
1115
|
+
estimatedCost: 0,
|
|
1116
|
+
unavailableCost: 0,
|
|
1117
|
+
hasEstimated: false,
|
|
1118
|
+
});
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
test("6-element failed tuple marks the task breakdown as failed", () => {
|
|
1122
|
+
// Arrange / Act
|
|
1123
|
+
const view: JobView = normalizeJobView(
|
|
1124
|
+
{
|
|
1125
|
+
state: "failed",
|
|
1126
|
+
tasks: {
|
|
1127
|
+
alpha: {
|
|
1128
|
+
state: "failed",
|
|
1129
|
+
tokenUsage: [["openai:gpt-4o", 10, 5, 0.25, "estimated", true]],
|
|
1130
|
+
},
|
|
1131
|
+
},
|
|
1132
|
+
},
|
|
1133
|
+
JOB_ID,
|
|
1134
|
+
LOCATION,
|
|
1135
|
+
);
|
|
1136
|
+
|
|
1137
|
+
// Assert
|
|
1138
|
+
expect(view.costs).toMatchObject({
|
|
1139
|
+
estimatedCost: 0.25,
|
|
1140
|
+
hasEstimated: true,
|
|
1141
|
+
alpha: { source: "estimated", failed: true, totalCost: 0.25 },
|
|
1142
|
+
});
|
|
1143
|
+
});
|
|
1144
|
+
|
|
1145
|
+
test("legacy 6-element failed tuple is not mistaken for a costEstimated slot", () => {
|
|
1146
|
+
// Arrange / Act
|
|
1147
|
+
const view: JobView = normalizeJobView(
|
|
1148
|
+
{
|
|
1149
|
+
state: "failed",
|
|
1150
|
+
tasks: {
|
|
1151
|
+
alpha: {
|
|
1152
|
+
state: "failed",
|
|
1153
|
+
tokenUsage: [["openai:gpt-4o", 10, 5, 0.25, "reported", true]],
|
|
1154
|
+
},
|
|
1155
|
+
},
|
|
1156
|
+
},
|
|
1157
|
+
JOB_ID,
|
|
1158
|
+
LOCATION,
|
|
1159
|
+
);
|
|
1160
|
+
|
|
1161
|
+
// Assert
|
|
1162
|
+
expect(view.costs).toMatchObject({
|
|
1163
|
+
totalCost: 0.25,
|
|
1164
|
+
estimatedCost: 0,
|
|
1165
|
+
hasEstimated: false,
|
|
1166
|
+
alpha: { source: "reported", failed: true, totalCost: 0.25 },
|
|
1167
|
+
});
|
|
1168
|
+
expect(Object.hasOwn(view.costs.alpha as object, "costEstimated")).toBe(false);
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
test("a task with mixed sources takes the worst (most uncertain) source", () => {
|
|
1172
|
+
// Arrange / Act
|
|
1173
|
+
const view: JobView = normalizeJobView(
|
|
1174
|
+
{
|
|
1175
|
+
state: "done",
|
|
1176
|
+
tasks: {
|
|
1177
|
+
alpha: {
|
|
1178
|
+
state: "done",
|
|
1179
|
+
tokenUsage: [
|
|
1180
|
+
["openai:gpt-4o", 10, 5, 0.25, "reported"],
|
|
1181
|
+
["anthropic:claude", 7, 3, 0.5, "estimated"],
|
|
1182
|
+
],
|
|
1183
|
+
},
|
|
1184
|
+
},
|
|
1185
|
+
},
|
|
1186
|
+
JOB_ID,
|
|
1187
|
+
LOCATION,
|
|
1188
|
+
);
|
|
1189
|
+
|
|
1190
|
+
// Assert
|
|
1191
|
+
expect(view.costs).toMatchObject({
|
|
1192
|
+
totalCost: 0.25,
|
|
1193
|
+
estimatedCost: 0.5,
|
|
1194
|
+
hasEstimated: true,
|
|
1195
|
+
alpha: { source: "estimated", totalCost: 0.75 },
|
|
1196
|
+
});
|
|
1197
|
+
});
|
|
1198
|
+
});
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1201
|
+
describe("deriveProgress (AC-6)", () => {
|
|
1202
|
+
test("taskCount = configured when configured > actual", () => {
|
|
1203
|
+
// Arrange
|
|
1204
|
+
const tasks = [{ state: "done" }, { state: "pending" }];
|
|
1205
|
+
|
|
1206
|
+
// Act
|
|
1207
|
+
const result = deriveProgress(tasks, 5);
|
|
1208
|
+
|
|
1209
|
+
// Assert
|
|
1210
|
+
expect(result.taskCount).toBe(5);
|
|
1211
|
+
expect(result.doneCount).toBe(1);
|
|
1212
|
+
expect(result.completedCount).toBe(1);
|
|
1213
|
+
expect(result.progress).toBe(Math.floor((1 / 5) * 100));
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
test("taskCount = actual when actual > configured", () => {
|
|
1217
|
+
// Arrange
|
|
1218
|
+
const tasks = [{ state: "done" }, { state: "done" }, { state: "skipped" }, { state: "pending" }];
|
|
1219
|
+
|
|
1220
|
+
// Act
|
|
1221
|
+
const result = deriveProgress(tasks, 1);
|
|
1222
|
+
|
|
1223
|
+
// Assert
|
|
1224
|
+
expect(result.taskCount).toBe(4);
|
|
1225
|
+
expect(result.doneCount).toBe(2);
|
|
1226
|
+
expect(result.completedCount).toBe(3);
|
|
1227
|
+
expect(result.progress).toBe(Math.floor((3 / 4) * 100));
|
|
1228
|
+
});
|
|
1229
|
+
|
|
1230
|
+
test("taskCount = actual when configuredCount is null", () => {
|
|
1231
|
+
// Arrange
|
|
1232
|
+
const tasks = [{ state: "done" }, { state: "skipped" }, { state: "pending" }];
|
|
1233
|
+
|
|
1234
|
+
// Act
|
|
1235
|
+
const result = deriveProgress(tasks, null);
|
|
1236
|
+
|
|
1237
|
+
// Assert
|
|
1238
|
+
expect(result.taskCount).toBe(3);
|
|
1239
|
+
expect(result.progress).toBe(Math.floor((2 / 3) * 100));
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
test("zero tasks returns taskCount 0 and progress 0", () => {
|
|
1243
|
+
// Arrange / Act
|
|
1244
|
+
const result = deriveProgress([], null);
|
|
1245
|
+
|
|
1246
|
+
// Assert
|
|
1247
|
+
expect(result.taskCount).toBe(0);
|
|
1248
|
+
expect(result.doneCount).toBe(0);
|
|
1249
|
+
expect(result.completedCount).toBe(0);
|
|
1250
|
+
expect(result.progress).toBe(0);
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1253
|
+
test("zero tasks + configuredCount 0 returns taskCount 0 and progress 0", () => {
|
|
1254
|
+
// Arrange / Act
|
|
1255
|
+
const result = deriveProgress([], 0);
|
|
1256
|
+
|
|
1257
|
+
// Assert
|
|
1258
|
+
expect(result.taskCount).toBe(0);
|
|
1259
|
+
expect(result.progress).toBe(0);
|
|
1260
|
+
});
|
|
1261
|
+
|
|
1262
|
+
test("progress clamps at 100 when configured < actual and all tasks are completed", () => {
|
|
1263
|
+
// Arrange
|
|
1264
|
+
const tasks = [{ state: "done" }, { state: "done" }];
|
|
1265
|
+
|
|
1266
|
+
// Act
|
|
1267
|
+
const result = deriveProgress(tasks, 1);
|
|
1268
|
+
|
|
1269
|
+
// Assert
|
|
1270
|
+
expect(result.taskCount).toBe(2);
|
|
1271
|
+
expect(result.completedCount).toBe(2);
|
|
1272
|
+
expect(result.progress).toBe(100);
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
test("doneCount counts only 'done', not 'skipped'", () => {
|
|
1276
|
+
// Arrange
|
|
1277
|
+
const tasks = [{ state: "done" }, { state: "skipped" }, { state: "skipped" }];
|
|
1278
|
+
|
|
1279
|
+
// Act
|
|
1280
|
+
const result = deriveProgress(tasks, null);
|
|
1281
|
+
|
|
1282
|
+
// Assert
|
|
1283
|
+
expect(result.doneCount).toBe(1);
|
|
1284
|
+
expect(result.completedCount).toBe(3);
|
|
1285
|
+
});
|
|
1286
|
+
|
|
1287
|
+
test("completedCount counts both 'done' and 'skipped'", () => {
|
|
1288
|
+
// Arrange
|
|
1289
|
+
const tasks = [{ state: "done" }, { state: "skipped" }, { state: "skipped" }];
|
|
1290
|
+
|
|
1291
|
+
// Act
|
|
1292
|
+
const result = deriveProgress(tasks, null);
|
|
1293
|
+
|
|
1294
|
+
// Assert
|
|
1295
|
+
expect(result.completedCount).toBe(3);
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
test("tasks with undefined state are not counted as completed", () => {
|
|
1299
|
+
// Arrange
|
|
1300
|
+
const tasks = [{}, { state: "done" }];
|
|
1301
|
+
|
|
1302
|
+
// Act
|
|
1303
|
+
const result = deriveProgress(tasks, null);
|
|
1304
|
+
|
|
1305
|
+
// Assert
|
|
1306
|
+
expect(result.doneCount).toBe(1);
|
|
1307
|
+
expect(result.completedCount).toBe(1);
|
|
1308
|
+
expect(result.progress).toBe(50);
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
test("tasks with non-completion state ('running') are not counted", () => {
|
|
1312
|
+
// Arrange
|
|
1313
|
+
const tasks = [{ state: "running" }, { state: "done" }];
|
|
1314
|
+
|
|
1315
|
+
// Act
|
|
1316
|
+
const result = deriveProgress(tasks, null);
|
|
1317
|
+
|
|
1318
|
+
// Assert
|
|
1319
|
+
expect(result.doneCount).toBe(1);
|
|
1320
|
+
expect(result.completedCount).toBe(1);
|
|
1321
|
+
expect(result.progress).toBe(50);
|
|
1322
|
+
});
|
|
1323
|
+
});
|
|
1324
|
+
|
|
1325
|
+
describe("__resetWarnedFallbackKeys", () => {
|
|
1326
|
+
test("clears the warn cache so a subsequent call warns again", () => {
|
|
1327
|
+
// Arrange
|
|
1328
|
+
const raw = { tasks: { a: { state: "done" } } };
|
|
1329
|
+
|
|
1330
|
+
// Act
|
|
1331
|
+
normalizeJobView(raw, JOB_ID, LOCATION);
|
|
1332
|
+
const firstCalls = warnCallsContaining(`${LOCATION}:${JOB_ID}`);
|
|
1333
|
+
__resetWarnedFallbackKeys();
|
|
1334
|
+
normalizeJobView(raw, JOB_ID, LOCATION);
|
|
1335
|
+
const secondCalls = warnCallsContaining(`${LOCATION}:${JOB_ID}`);
|
|
1336
|
+
|
|
1337
|
+
// Assert
|
|
1338
|
+
expect(firstCalls).toBe(1);
|
|
1339
|
+
expect(secondCalls).toBe(2);
|
|
1340
|
+
});
|
|
1341
|
+
});
|