@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.
Files changed (206) hide show
  1. package/docs/http-api.md +80 -4
  2. package/package.json +1 -4
  3. package/src/api/__tests__/index.test.ts +443 -32
  4. package/src/api/index.ts +108 -127
  5. package/src/cli/__tests__/analyze-task.test.ts +40 -7
  6. package/src/cli/__tests__/index.test.ts +337 -17
  7. package/src/cli/__tests__/types.test.ts +0 -18
  8. package/src/cli/__tests__/update-pipeline-json.test.ts +19 -9
  9. package/src/cli/analyze-task.ts +3 -14
  10. package/src/cli/index.ts +73 -61
  11. package/src/cli/types.ts +0 -13
  12. package/src/cli/update-pipeline-json.ts +3 -14
  13. package/src/config/__tests__/paths.test.ts +46 -1
  14. package/src/config/__tests__/pipeline-registry.test.ts +767 -0
  15. package/src/config/__tests__/sse-events.test.ts +470 -0
  16. package/src/config/__tests__/statuses.test.ts +41 -0
  17. package/src/config/paths.ts +11 -2
  18. package/src/config/pipeline-registry.ts +258 -0
  19. package/src/config/sse-events.ts +122 -0
  20. package/src/config/statuses.ts +20 -1
  21. package/src/core/__tests__/agent-step.test.ts +115 -0
  22. package/src/core/__tests__/config.test.ts +545 -105
  23. package/src/core/__tests__/core-boot-resolution.test.ts +181 -0
  24. package/src/core/__tests__/job-concurrency.test.ts +260 -0
  25. package/src/core/__tests__/job-submission.test.ts +396 -0
  26. package/src/core/__tests__/job-view.test.ts +1341 -0
  27. package/src/core/__tests__/json-file.test.ts +111 -0
  28. package/src/core/__tests__/lifecycle-policy.test.ts +81 -7
  29. package/src/core/__tests__/logger.test.ts +22 -0
  30. package/src/core/__tests__/orchestrator-no-deprecated-exports.test.ts +41 -0
  31. package/src/core/__tests__/orchestrator.test.ts +615 -2
  32. package/src/core/__tests__/pipeline-runner.test.ts +946 -9
  33. package/src/core/__tests__/redact.test.ts +153 -0
  34. package/src/core/__tests__/runner-liveness.test.ts +910 -0
  35. package/src/core/__tests__/seed-naming.test.ts +57 -0
  36. package/src/core/__tests__/single-derivation.test.ts +159 -0
  37. package/src/core/__tests__/task-runner.test.ts +594 -3
  38. package/src/core/__tests__/task-telemetry.test.ts +241 -0
  39. package/src/core/__tests__/workspace-root-resolution-guard.test.ts +62 -0
  40. package/src/core/agent-step.ts +4 -1
  41. package/src/core/agent-types.ts +5 -4
  42. package/src/core/config.ts +134 -222
  43. package/src/core/control.ts +3 -0
  44. package/src/core/job-concurrency.ts +56 -20
  45. package/src/core/job-submission.ts +133 -0
  46. package/src/core/job-view.ts +473 -0
  47. package/src/core/json-file.ts +45 -0
  48. package/src/core/lifecycle-policy.ts +23 -8
  49. package/src/core/logger.ts +0 -29
  50. package/src/core/orchestrator.ts +200 -74
  51. package/src/core/pipeline-runner.ts +85 -53
  52. package/src/core/redact.ts +40 -0
  53. package/src/core/runner-liveness.ts +280 -0
  54. package/src/core/seed-naming.ts +9 -0
  55. package/src/core/status-writer.ts +27 -33
  56. package/src/core/task-runner.ts +356 -319
  57. package/src/core/task-telemetry.ts +107 -0
  58. package/src/harness/__tests__/subprocess.test.ts +112 -1
  59. package/src/harness/subprocess.ts +55 -16
  60. package/src/llm/__tests__/index.test.ts +684 -33
  61. package/src/llm/index.ts +310 -67
  62. package/src/providers/__tests__/alibaba.test.ts +67 -6
  63. package/src/providers/__tests__/anthropic.test.ts +35 -14
  64. package/src/providers/__tests__/base.test.ts +62 -0
  65. package/src/providers/__tests__/claude-code.test.ts +99 -14
  66. package/src/providers/__tests__/deepseek.test.ts +16 -6
  67. package/src/providers/__tests__/gemini.test.ts +47 -25
  68. package/src/providers/__tests__/moonshot.test.ts +27 -0
  69. package/src/providers/__tests__/openai.test.ts +262 -74
  70. package/src/providers/__tests__/opencode.test.ts +77 -0
  71. package/src/providers/__tests__/request-timeout-contract.test.ts +226 -0
  72. package/src/providers/__tests__/stream-accumulator.test.ts +52 -0
  73. package/src/providers/__tests__/types.test.ts +85 -0
  74. package/src/providers/__tests__/zero-fill-guard.test.ts +62 -0
  75. package/src/providers/__tests__/zhipu.test.ts +27 -14
  76. package/src/providers/alibaba.ts +20 -39
  77. package/src/providers/anthropic.ts +23 -11
  78. package/src/providers/base.ts +19 -0
  79. package/src/providers/claude-code.ts +27 -18
  80. package/src/providers/deepseek.ts +9 -28
  81. package/src/providers/gemini.ts +20 -58
  82. package/src/providers/moonshot.ts +15 -11
  83. package/src/providers/openai.ts +79 -61
  84. package/src/providers/opencode.ts +16 -33
  85. package/src/providers/stream-accumulator.ts +27 -21
  86. package/src/providers/types.ts +29 -4
  87. package/src/providers/zhipu.ts +15 -44
  88. package/src/task-analysis/__tests__/analyzer.test.ts +0 -0
  89. package/src/task-analysis/__tests__/enrichers-schema-deducer.test.ts +34 -2
  90. package/src/task-analysis/__tests__/no-ast-imports.test.ts +52 -0
  91. package/src/task-analysis/__tests__/repository.test.ts +65 -0
  92. package/src/task-analysis/__tests__/types.test.ts +63 -130
  93. package/src/task-analysis/analyzer.ts +91 -0
  94. package/src/task-analysis/enrichers/schema-deducer.ts +13 -2
  95. package/src/task-analysis/index.ts +2 -36
  96. package/src/task-analysis/repository.ts +45 -0
  97. package/src/task-analysis/types.ts +42 -58
  98. package/src/ui/client/__tests__/api.test.ts +143 -1
  99. package/src/ui/client/__tests__/bootstrap.test.ts +178 -62
  100. package/src/ui/client/__tests__/job-adapter.test.ts +203 -2
  101. package/src/ui/client/__tests__/load-state.test.ts +70 -0
  102. package/src/ui/client/__tests__/types.test.ts +66 -3
  103. package/src/ui/client/__tests__/useJobDetailWithUpdates.test.ts +390 -77
  104. package/src/ui/client/__tests__/useJobList.test.ts +198 -23
  105. package/src/ui/client/__tests__/useJobListWithUpdates.test.ts +218 -7
  106. package/src/ui/client/adapters/__tests__/job-adapter.test.ts +186 -0
  107. package/src/ui/client/adapters/job-adapter.ts +41 -16
  108. package/src/ui/client/api.ts +38 -15
  109. package/src/ui/client/bootstrap.ts +19 -14
  110. package/src/ui/client/hooks/useJobDetailWithUpdates.ts +73 -97
  111. package/src/ui/client/hooks/useJobList.ts +26 -31
  112. package/src/ui/client/hooks/useJobListWithUpdates.ts +42 -76
  113. package/src/ui/client/load-state.ts +20 -0
  114. package/src/ui/client/reducers/__tests__/job-events.test.ts +568 -0
  115. package/src/ui/client/reducers/job-events.ts +137 -0
  116. package/src/ui/client/types.ts +16 -20
  117. package/src/ui/components/DAGGrid.tsx +6 -1
  118. package/src/ui/components/JobDetail.tsx +12 -4
  119. package/src/ui/components/JobTable.tsx +41 -13
  120. package/src/ui/components/StageTimeline.tsx +8 -20
  121. package/src/ui/components/TaskAnalysisDisplay.tsx +48 -6
  122. package/src/ui/components/__tests__/DAGGrid.test.tsx +36 -0
  123. package/src/ui/components/__tests__/JobDetail.test.tsx +112 -0
  124. package/src/ui/components/__tests__/JobTable.test.tsx +137 -1
  125. package/src/ui/components/__tests__/StageTimeline.test.tsx +5 -6
  126. package/src/ui/components/__tests__/TaskAnalysisDisplay.test.tsx +64 -0
  127. package/src/ui/components/types.ts +35 -15
  128. package/src/ui/dist/assets/{index--RH3sAt3.js → index-DN3-zvtP.js} +4324 -263
  129. package/src/ui/dist/assets/index-DN3-zvtP.js.map +1 -0
  130. package/src/ui/dist/assets/style-CtZBnjlR.css +2 -0
  131. package/src/ui/dist/index.html +2 -2
  132. package/src/ui/pages/PipelineDetail.tsx +60 -4
  133. package/src/ui/pages/PromptPipelineDashboard.tsx +59 -7
  134. package/src/ui/pages/__tests__/PromptPipelineDashboard.test.tsx +114 -32
  135. package/src/ui/pages/__tests__/pages.test.tsx +236 -42
  136. package/src/ui/server/__tests__/concurrency-endpoint.test.ts +54 -0
  137. package/src/ui/server/__tests__/config-bridge-node.test.ts +34 -1
  138. package/src/ui/server/__tests__/config-bridge.test.ts +9 -1
  139. package/src/ui/server/__tests__/embedded-assets.test.ts +66 -0
  140. package/src/ui/server/__tests__/file-endpoints.test.ts +183 -5
  141. package/src/ui/server/__tests__/gate-endpoints.test.ts +55 -0
  142. package/src/ui/server/__tests__/index.test.ts +63 -0
  143. package/src/ui/server/__tests__/job-control-endpoints.test.ts +512 -2
  144. package/src/ui/server/__tests__/job-endpoints.test.ts +158 -2
  145. package/src/ui/server/__tests__/read-static-path-guard.test.ts +94 -0
  146. package/src/ui/server/__tests__/router-root-isolation.test.ts +204 -0
  147. package/src/ui/server/__tests__/router-threading.test.ts +148 -0
  148. package/src/ui/server/__tests__/router.test.ts +104 -0
  149. package/src/ui/server/__tests__/sse-broadcast.test.ts +44 -0
  150. package/src/ui/server/__tests__/sse-enhancer.test.ts +70 -0
  151. package/src/ui/server/config-bridge-node.ts +8 -26
  152. package/src/ui/server/config-bridge.ts +3 -4
  153. package/src/ui/server/embedded-assets-imports.d.ts +44 -0
  154. package/src/ui/server/embedded-assets.ts +13 -2
  155. package/src/ui/server/endpoints/__tests__/meta-endpoint.test.ts +1 -0
  156. package/src/ui/server/endpoints/__tests__/pipeline-analysis-endpoint.test.ts +167 -0
  157. package/src/ui/server/endpoints/__tests__/schema-file-endpoint.test.ts +104 -0
  158. package/src/ui/server/endpoints/__tests__/task-analysis-endpoint.test.ts +103 -0
  159. package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +162 -96
  160. package/src/ui/server/endpoints/concurrency-endpoint.ts +2 -1
  161. package/src/ui/server/endpoints/create-pipeline-endpoint.ts +14 -29
  162. package/src/ui/server/endpoints/file-endpoints.ts +15 -10
  163. package/src/ui/server/endpoints/gate-endpoints.ts +2 -6
  164. package/src/ui/server/endpoints/job-control-endpoints.ts +129 -141
  165. package/src/ui/server/endpoints/job-endpoints.ts +7 -0
  166. package/src/ui/server/endpoints/meta-endpoint.ts +1 -1
  167. package/src/ui/server/endpoints/pipeline-analysis-endpoint.ts +99 -11
  168. package/src/ui/server/endpoints/schema-file-endpoint.ts +11 -3
  169. package/src/ui/server/endpoints/task-analysis-endpoint.ts +21 -5
  170. package/src/ui/server/endpoints/task-save-endpoint.ts +1 -2
  171. package/src/ui/server/endpoints/upload-endpoints.ts +5 -40
  172. package/src/ui/server/index.ts +19 -14
  173. package/src/ui/server/job-reader.ts +14 -2
  174. package/src/ui/server/router.ts +33 -10
  175. package/src/ui/server/sse-broadcast.ts +14 -3
  176. package/src/ui/server/sse-enhancer.ts +12 -2
  177. package/src/ui/state/__tests__/schema-loader.test.ts +11 -1
  178. package/src/ui/state/__tests__/snapshot.test.ts +120 -14
  179. package/src/ui/state/__tests__/types.test.ts +104 -5
  180. package/src/ui/state/snapshot.ts +2 -2
  181. package/src/ui/state/transformers/__tests__/list-transformer.test.ts +85 -2
  182. package/src/ui/state/transformers/__tests__/status-transformer.test.ts +250 -22
  183. package/src/ui/state/transformers/list-transformer.ts +13 -3
  184. package/src/ui/state/transformers/status-transformer.ts +36 -170
  185. package/src/ui/state/types.ts +15 -48
  186. package/src/utils/__tests__/path-containment.test.ts +160 -0
  187. package/src/{ui/server/utils → utils}/path-containment.ts +21 -0
  188. package/src/task-analysis/__tests__/enrichers-analysis-writer.test.ts +0 -86
  189. package/src/task-analysis/__tests__/enrichers-artifact-resolver.test.ts +0 -124
  190. package/src/task-analysis/__tests__/extractors-artifacts.test.ts +0 -133
  191. package/src/task-analysis/__tests__/extractors-llm-calls.test.ts +0 -46
  192. package/src/task-analysis/__tests__/extractors-stages.test.ts +0 -52
  193. package/src/task-analysis/__tests__/index.test.ts +0 -143
  194. package/src/task-analysis/__tests__/parser.test.ts +0 -41
  195. package/src/task-analysis/__tests__/utils-ast.test.ts +0 -82
  196. package/src/task-analysis/enrichers/analysis-writer.ts +0 -75
  197. package/src/task-analysis/enrichers/artifact-resolver.ts +0 -89
  198. package/src/task-analysis/extractors/artifacts.ts +0 -143
  199. package/src/task-analysis/extractors/llm-calls.ts +0 -117
  200. package/src/task-analysis/extractors/stages.ts +0 -45
  201. package/src/task-analysis/parser.ts +0 -20
  202. package/src/task-analysis/utils/ast.ts +0 -45
  203. package/src/ui/dist/assets/index--RH3sAt3.js.map +0 -1
  204. package/src/ui/dist/assets/style-CSSKuMOe.css +0 -2
  205. package/src/ui/embedded-assets.js +0 -12
  206. package/src/ui/server/__tests__/path-containment.test.ts +0 -54
@@ -1,9 +1,11 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
 
3
+ import type { JobView } from "../../../core/job-view";
3
4
  import type {
4
5
  APIJob,
5
6
  AcquireResult,
6
7
  CanonicalJob,
8
+ CanonicalJobStatus,
7
9
  ChangeTrackerState,
8
10
  ComposeSnapshotOptions,
9
11
  FilterOptions,
@@ -44,7 +46,7 @@ describe("ui/state types", () => {
44
46
  jobId: "job-1",
45
47
  name: "Job 1",
46
48
  title: "Job 1",
47
- status: "pending",
49
+ status: "failed",
48
50
  progress: 0,
49
51
  createdAt: null,
50
52
  updatedAt: null,
@@ -56,15 +58,15 @@ describe("ui/state types", () => {
56
58
  const grouped: GroupedJobs = {
57
59
  running: [],
58
60
  waiting: [],
59
- error: [],
60
- pending: [job],
61
+ failed: [],
62
+ pending: [],
61
63
  complete: [],
62
64
  };
63
65
  const filter: FilterOptions = { status: "pending", location: "current" };
64
66
  const apiJob: APIJob = {
65
67
  jobId: "job-1",
66
68
  title: "Job 1",
67
- status: "pending",
69
+ status: "failed",
68
70
  progress: 0,
69
71
  createdAt: null,
70
72
  updatedAt: null,
@@ -77,6 +79,9 @@ describe("ui/state types", () => {
77
79
  totalCost: 0,
78
80
  totalInputCost: 0,
79
81
  totalOutputCost: 0,
82
+ estimatedCost: 0,
83
+ hasEstimated: false,
84
+ unavailableCost: 0,
80
85
  },
81
86
  };
82
87
  const stats: TransformationStats = {
@@ -96,9 +101,103 @@ describe("ui/state types", () => {
96
101
  expect(snapshotDeps.readJob).toBeUndefined();
97
102
  expect(schema.fileName).toBe("seed.json");
98
103
  expect(result.response).toBeInstanceOf(Response);
99
- expect(grouped.pending[0]).toBe(job);
104
+ expect(job.status).toBe("failed");
105
+ expect(grouped.failed).toEqual([]);
100
106
  expect(filter.location).toBe("current");
101
107
  expect(apiJob.costsSummary.totalCost).toBe(0);
108
+ expect(apiJob.status).toBe("failed");
102
109
  expect(stats.transformationRate).toBe(0);
103
110
  });
111
+
112
+ it("rejects status:\"error\" as a CanonicalJobStatus at the type level", () => {
113
+ // @ts-expect-error — "error" is not assignable to CanonicalJobStatus
114
+ const rejected: CanonicalJobStatus = "error";
115
+ expect(rejected).toBe("error");
116
+ });
117
+
118
+ it("rejects status:\"error\" on a CanonicalJob literal", () => {
119
+ const job: CanonicalJob = {
120
+ id: "job-1",
121
+ jobId: "job-1",
122
+ name: "Job 1",
123
+ title: "Job 1",
124
+ // @ts-expect-error — "error" is not assignable to CanonicalJobStatus
125
+ status: "error",
126
+ progress: 0,
127
+ createdAt: null,
128
+ updatedAt: null,
129
+ location: "current",
130
+ tasks: {},
131
+ files: {},
132
+ costs: {},
133
+ };
134
+ expect(job.jobId).toBe("job-1");
135
+ });
136
+
137
+ it("rejects status:\"error\" on an APIJob literal", () => {
138
+ const apiJob: APIJob = {
139
+ jobId: "job-1",
140
+ title: "Job 1",
141
+ // @ts-expect-error — "error" is not assignable to CanonicalJobStatus
142
+ status: "error",
143
+ progress: 0,
144
+ createdAt: null,
145
+ updatedAt: null,
146
+ location: "current",
147
+ tasks: {},
148
+ costsSummary: {
149
+ totalTokens: 0,
150
+ totalInputTokens: 0,
151
+ totalOutputTokens: 0,
152
+ totalCost: 0,
153
+ totalInputCost: 0,
154
+ totalOutputCost: 0,
155
+ estimatedCost: 0,
156
+ hasEstimated: false,
157
+ unavailableCost: 0,
158
+ },
159
+ };
160
+ expect(apiJob.jobId).toBe("job-1");
161
+ });
162
+
163
+ it("preserves readable:false and error: { code, message } on a CanonicalJob", () => {
164
+ const job: CanonicalJob = {
165
+ id: "job-1",
166
+ jobId: "job-1",
167
+ name: "Job 1",
168
+ title: "Job 1",
169
+ status: "failed",
170
+ progress: 0,
171
+ createdAt: null,
172
+ updatedAt: null,
173
+ location: "current",
174
+ tasks: {},
175
+ files: {},
176
+ costs: {},
177
+ readable: false,
178
+ error: { code: "E_FOO", message: "broken", path: "/tmp/job-1" },
179
+ };
180
+
181
+ expect(job.readable).toBe(false);
182
+ expect(job.error).toEqual({ code: "E_FOO", message: "broken", path: "/tmp/job-1" });
183
+ });
184
+
185
+ it("CanonicalJob is structurally identical to JobView", () => {
186
+ const job: CanonicalJob = {
187
+ id: "job-1",
188
+ jobId: "job-1",
189
+ name: "Job 1",
190
+ title: "Job 1",
191
+ status: "failed",
192
+ progress: 0,
193
+ createdAt: null,
194
+ updatedAt: null,
195
+ location: "current",
196
+ tasks: {},
197
+ files: {},
198
+ costs: {},
199
+ };
200
+ const view: JobView = job;
201
+ expect(view.id).toBe("job-1");
202
+ });
104
203
  });
@@ -10,7 +10,7 @@ import type {
10
10
  StateSnapshot,
11
11
  } from "./types";
12
12
 
13
- const SNAPSHOT_STATUS_ORDER = ["error", "running", "complete", "pending"] as const;
13
+ const SNAPSHOT_STATUS_ORDER = ["failed", "running", "complete", "pending"] as const;
14
14
 
15
15
  function nowIso(now: (() => Date) | undefined): string {
16
16
  return (now ?? (() => new Date()))().toISOString();
@@ -137,7 +137,7 @@ export async function buildSnapshotFromFilesystem(deps: SnapshotDeps = {}): Prom
137
137
  const readResults = await Promise.all(
138
138
  readRequests.map(async ({ jobId, location }) => {
139
139
  try {
140
- return await resolved.readJob(jobId, location);
140
+ return await resolved.readJob(jobId);
141
141
  } catch (error) {
142
142
  console.warn(`failed to read job "${jobId}" from "${location}"`, error);
143
143
  return { ok: false, jobId, location, message: String(error) } satisfies JobReadResult;
@@ -34,6 +34,7 @@ describe("list-transformer", () => {
34
34
  it("returns status priorities and sorts valid jobs only", () => {
35
35
  expect(getStatusPriority("running")).toBe(4);
36
36
  expect(getStatusPriority("waiting")).toBe(4);
37
+ expect(getStatusPriority("failed")).toBe(3);
37
38
  expect(getStatusPriority("unknown")).toBe(0);
38
39
 
39
40
  const jobs = sortJobs([
@@ -57,17 +58,22 @@ describe("list-transformer", () => {
57
58
  const jobs = [
58
59
  makeJob({ id: "job-1", title: "Alpha", status: "running", progress: 50 }),
59
60
  makeJob({ id: "job-2", jobId: "job-2", title: "Beta", status: "complete", progress: 100, location: "complete" }),
61
+ makeJob({ id: "job-3", jobId: "job-3", title: "Gamma", status: "failed", progress: 25 }),
60
62
  ];
61
63
 
62
- expect(groupJobsByStatus(jobs)).toMatchObject({
64
+ const grouped = groupJobsByStatus(jobs);
65
+ expect(grouped).toMatchObject({
63
66
  running: [jobs[0]],
64
67
  complete: [jobs[1]],
68
+ failed: [jobs[2]],
65
69
  waiting: [],
66
70
  });
67
- expect(getJobListStats(jobs).averageProgress).toBe(75);
71
+ expect(grouped).not.toHaveProperty("error");
72
+ expect(getJobListStats(jobs).averageProgress).toBe(58);
68
73
  expect(filterJobs(jobs, "alp")).toEqual([jobs[0]]);
69
74
  expect(filterJobs(jobs, "JOB-2")).toEqual([jobs[1]]);
70
75
  expect(filterJobs(jobs, "", { status: "complete", location: "complete" })).toEqual([jobs[1]]);
76
+ expect(filterJobs(jobs, "", { status: "failed" })).toEqual([jobs[2]]);
71
77
  });
72
78
 
73
79
  it("always includes zeroed costs in API output and computes aggregation stats", () => {
@@ -80,6 +86,9 @@ describe("list-transformer", () => {
80
86
  totalCost: 0,
81
87
  totalInputCost: 0,
82
88
  totalOutputCost: 0,
89
+ estimatedCost: 0,
90
+ hasEstimated: false,
91
+ unavailableCost: 0,
83
92
  });
84
93
  expect(getAggregationStats(jobs, jobs, jobs)).toEqual({
85
94
  totalInput: 2,
@@ -91,6 +100,45 @@ describe("list-transformer", () => {
91
100
  });
92
101
  });
93
102
 
103
+ it("preserves estimated cost fields in API output", () => {
104
+ const job = makeJob({
105
+ costs: {
106
+ totalTokens: 10,
107
+ totalInputTokens: 6,
108
+ totalOutputTokens: 4,
109
+ totalCost: 1,
110
+ totalInputCost: 0.6,
111
+ totalOutputCost: 0.4,
112
+ estimatedCost: 2,
113
+ unavailableCost: 3,
114
+ hasEstimated: true,
115
+ },
116
+ });
117
+
118
+ expect(transformJobListForAPI([job])[0]?.costsSummary).toEqual({
119
+ totalTokens: 10,
120
+ totalInputTokens: 6,
121
+ totalOutputTokens: 4,
122
+ totalCost: 1,
123
+ totalInputCost: 0.6,
124
+ totalOutputCost: 0.4,
125
+ estimatedCost: 2,
126
+ unavailableCost: 3,
127
+ hasEstimated: true,
128
+ });
129
+ });
130
+
131
+ it("defaults missing estimated cost fields to 0/false", () => {
132
+ const job = makeJob({ costs: { totalCost: 5 } });
133
+
134
+ expect(transformJobListForAPI([job])[0]?.costsSummary).toMatchObject({
135
+ totalCost: 5,
136
+ estimatedCost: 0,
137
+ hasEstimated: false,
138
+ unavailableCost: 0,
139
+ });
140
+ });
141
+
94
142
  it("passes gate metadata through API list output", () => {
95
143
  const gate = {
96
144
  afterTask: "review",
@@ -103,4 +151,39 @@ describe("list-transformer", () => {
103
151
  gate,
104
152
  });
105
153
  });
154
+
155
+ it("keeps an unreadable placeholder with null createdAt through aggregate and transform", () => {
156
+ const error = {
157
+ code: "INVALID_JSON",
158
+ message: "Repair or delete /path/to/tasks-status.json for job \"job-x\" to recover.",
159
+ path: "/path/to/tasks-status.json",
160
+ };
161
+ const placeholder = makeJob({
162
+ id: "job-x",
163
+ jobId: "job-x",
164
+ status: "failed",
165
+ createdAt: null,
166
+ readable: false,
167
+ error,
168
+ });
169
+
170
+ const aggregated = aggregateAndSortJobs([placeholder], []);
171
+ expect(aggregated).toHaveLength(1);
172
+ expect(aggregated[0]?.id).toBe("job-x");
173
+
174
+ const apiJob = transformJobListForAPI(aggregated)[0];
175
+ expect(apiJob?.jobId).toBe("job-x");
176
+ expect(apiJob?.readable).toBe(false);
177
+ expect(apiJob?.error).toEqual(error);
178
+ });
179
+
180
+ it("does not add readable or error to a normal job in API output", () => {
181
+ const normal = makeJob();
182
+
183
+ expect(aggregateAndSortJobs([normal], [])).toHaveLength(1);
184
+
185
+ const apiJob = transformJobListForAPI([normal])[0];
186
+ expect(apiJob).not.toHaveProperty("readable");
187
+ expect(apiJob).not.toHaveProperty("error");
188
+ });
106
189
  });
@@ -1,6 +1,8 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
2
 
3
+ import { normalizeJobView } from "../../../../core/job-view";
3
4
  import {
5
+ buildUnreadableJob,
4
6
  computeJobStatus,
5
7
  getTransformationStats,
6
8
  transformJobStatus,
@@ -48,11 +50,11 @@ describe("status-transformer", () => {
48
50
  expect(job?.id).toBe("job-1");
49
51
  expect(job?.jobId).toBe("job-1");
50
52
  expect(job?.name).toBe("Title");
51
- expect(warn).toHaveBeenCalledOnce();
53
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("job id mismatch"));
52
54
  warn.mockRestore();
53
55
  });
54
56
 
55
- it("computes progress from tasks alone, ignoring any previously persisted value", () => {
57
+ it("computeJobStatus returns running when half the tasks are done", () => {
56
58
  const result = computeJobStatus({
57
59
  a: { state: "done" },
58
60
  b: { state: "done" },
@@ -63,7 +65,7 @@ describe("status-transformer", () => {
63
65
  expect(result).toEqual({ status: "running", progress: 50 });
64
66
  });
65
67
 
66
- it("counts skipped tasks as completed progress units", () => {
68
+ it("computeJobStatus returns pending when some tasks are pending and none running", () => {
67
69
  const result = computeJobStatus({
68
70
  a: { state: "done" },
69
71
  b: { state: "skipped" },
@@ -77,8 +79,8 @@ describe("status-transformer", () => {
77
79
  expect(computeJobStatus([])).toEqual({ status: "pending", progress: 0 });
78
80
  });
79
81
 
80
- it("transformJobStatus derives progress from tasks, not record.progress", () => {
81
- const job = transformJobStatus(
82
+ it("transformJobStatus derives progress from tasks via deriveProgress (ignores persisted progress)", () => {
83
+ const withProgress = transformJobStatus(
82
84
  {
83
85
  title: "Test",
84
86
  progress: 100,
@@ -91,8 +93,44 @@ describe("status-transformer", () => {
91
93
  "current",
92
94
  );
93
95
 
94
- expect(job?.progress).toBe(50);
95
- expect(job?.status).toBe("running");
96
+ expect(withProgress?.progress).toBe(50);
97
+ expect(withProgress?.status).toBe("running");
98
+
99
+ const withoutProgress = transformJobStatus(
100
+ {
101
+ title: "Test",
102
+ tasks: {
103
+ a: { state: "done" },
104
+ },
105
+ },
106
+ "job-2",
107
+ "current",
108
+ );
109
+
110
+ expect(withoutProgress?.progress).toBe(100);
111
+ expect(withoutProgress?.status).toBe("complete");
112
+ });
113
+
114
+ it("transformJobStatus aggregates task tokenUsage costs through the canonical view", () => {
115
+ const job = transformJobStatus(
116
+ {
117
+ title: "Costed",
118
+ state: "done",
119
+ tasks: {
120
+ a: { state: "done", tokenUsage: [["openai:gpt-4o", 12, 6, 0.25]] },
121
+ b: { state: "done", tokenUsage: [["anthropic:claude", 3, 9, 0.5]] },
122
+ },
123
+ },
124
+ "job-cost",
125
+ "complete",
126
+ );
127
+
128
+ expect(job?.costs).toMatchObject({
129
+ totalCost: 0.75,
130
+ totalTokens: 30,
131
+ totalInputTokens: 15,
132
+ totalOutputTokens: 15,
133
+ });
96
134
  });
97
135
 
98
136
  it("surfaces waiting snapshots and gate metadata", () => {
@@ -138,7 +176,7 @@ describe("status-transformer", () => {
138
176
  "current",
139
177
  );
140
178
 
141
- expect(job?.status).toBe("error");
179
+ expect(job?.status).toBe("failed");
142
180
  });
143
181
 
144
182
  it("maps numeric restartCount on tasks", () => {
@@ -183,23 +221,15 @@ describe("status-transformer", () => {
183
221
  expect(tasks.alpha?.lastRetryError).toBeNull();
184
222
  });
185
223
 
186
- it("filters failed reads and computes transformation stats", () => {
187
- const transformed = transformMultipleJobs([
224
+ it("computes transformation stats for valid reads", () => {
225
+ const readResults = [
188
226
  { ok: true, data: { tasks: { a: { state: "done" } } }, jobId: "job-1", location: "current" },
189
- { ok: false, jobId: "job-2", location: "current" },
190
- ]);
227
+ ];
228
+ const transformed = transformMultipleJobs(readResults);
191
229
 
192
230
  expect(transformed).toHaveLength(1);
193
- expect(
194
- getTransformationStats(
195
- [
196
- { ok: true, data: { tasks: { a: { state: "done" } } }, jobId: "job-1", location: "current" },
197
- { ok: false, jobId: "job-2", location: "current" },
198
- ],
199
- transformed,
200
- ),
201
- ).toEqual({
202
- totalRead: 2,
231
+ expect(getTransformationStats(readResults, transformed)).toEqual({
232
+ totalRead: 1,
203
233
  successfulReads: 1,
204
234
  successfulTransforms: 1,
205
235
  failedTransforms: 0,
@@ -207,4 +237,202 @@ describe("status-transformer", () => {
207
237
  statusDistribution: { complete: 1 },
208
238
  });
209
239
  });
240
+
241
+ it("computes transformation stats without counting unreadable placeholders as successful transforms", () => {
242
+ const readResults = [
243
+ { ok: true, data: { tasks: { a: { state: "done" } } }, jobId: "job-1", location: "current" },
244
+ { ok: false, code: "INVALID_JSON", jobId: "job-2", location: "current" },
245
+ { ok: false, code: "JOB_NOT_FOUND", jobId: "job-3", location: "current" },
246
+ ];
247
+ const transformed = transformMultipleJobs(readResults);
248
+
249
+ expect(transformed).toHaveLength(2);
250
+ expect(getTransformationStats(readResults, transformed)).toEqual({
251
+ totalRead: 3,
252
+ successfulReads: 1,
253
+ successfulTransforms: 1,
254
+ failedTransforms: 0,
255
+ transformationRate: 1,
256
+ statusDistribution: { complete: 1, failed: 1 },
257
+ });
258
+ });
259
+
260
+ it("buildUnreadableJob maps corrupt reads to an error placeholder with recovery guidance", () => {
261
+ const placeholder = buildUnreadableJob({
262
+ ok: false,
263
+ code: "INVALID_JSON",
264
+ path: "/jobs/job-2/tasks-status.json",
265
+ jobId: "job-2",
266
+ location: "current",
267
+ });
268
+
269
+ expect(placeholder.readable).toBe(false);
270
+ expect(placeholder.status).toBe("failed");
271
+ expect(placeholder.error?.code).toBe("INVALID_JSON");
272
+ expect(placeholder.error?.path).toBe("/jobs/job-2/tasks-status.json");
273
+ const message = placeholder.error?.message.toLowerCase() ?? "";
274
+ expect(message).toContain("repair");
275
+ expect(message).toContain("delete");
276
+ expect(message).toContain("job-2");
277
+ expect(message).toContain("/jobs/job-2/tasks-status.json");
278
+ });
279
+
280
+ it("buildUnreadableJob uses the same fallback code in the message and structured error", () => {
281
+ const placeholder = buildUnreadableJob({
282
+ ok: false,
283
+ message: "EACCES",
284
+ jobId: "job-2",
285
+ location: "current",
286
+ });
287
+
288
+ expect(placeholder.error?.code).toBe("UNKNOWN_READ_ERROR");
289
+ expect(placeholder.error?.message).toContain("(UNKNOWN_READ_ERROR)");
290
+ expect(placeholder.error?.message).toContain("EACCES");
291
+ });
292
+
293
+ it("transformMultipleJobs maps corrupt reads to placeholders, skips missing reads, and leaves valid results readable", () => {
294
+ const transformed = transformMultipleJobs([
295
+ {
296
+ ok: true,
297
+ data: { tasks: { a: { state: "done" } } },
298
+ jobId: "job-1",
299
+ location: "current",
300
+ },
301
+ {
302
+ ok: false,
303
+ code: "INVALID_JSON",
304
+ path: "/jobs/job-2/tasks-status.json",
305
+ jobId: "job-2",
306
+ location: "current",
307
+ },
308
+ {
309
+ ok: false,
310
+ code: "JOB_NOT_FOUND",
311
+ jobId: "job-3",
312
+ location: "current",
313
+ },
314
+ ]);
315
+
316
+ expect(transformed).toHaveLength(2);
317
+ const [valid, unreadable] = transformed;
318
+
319
+ expect(valid?.status).toBe("complete");
320
+ expect(valid?.readable).toBeUndefined();
321
+ expect(valid?.error).toBeUndefined();
322
+
323
+ expect(unreadable?.readable).toBe(false);
324
+ expect(unreadable?.status).toBe("failed");
325
+ expect(unreadable?.error?.code).toBe("INVALID_JSON");
326
+ expect(unreadable?.error?.path).toBe("/jobs/job-2/tasks-status.json");
327
+ });
328
+
329
+ it("coerces parseable-but-tasks-missing snapshots to readable jobs", () => {
330
+ const transformed = transformMultipleJobs([
331
+ { ok: true, data: { jobId: "job-3" }, jobId: "job-3", location: "current" },
332
+ ]);
333
+
334
+ expect(transformed).toHaveLength(1);
335
+ const job = transformed[0];
336
+ expect(job?.readable).not.toBe(false);
337
+ expect(job?.error).toBeUndefined();
338
+ expect(job?.status).toBe("pending");
339
+ expect(job?.tasks).toEqual({});
340
+ });
341
+
342
+ it("transformJobStatus output equals normalizeJobView for object inputs", () => {
343
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
344
+ const raw = {
345
+ jobId: "job-1",
346
+ title: "Title",
347
+ state: "failed",
348
+ tasks: { alpha: { state: "done" } },
349
+ gate: null,
350
+ error: { name: "GateRejected", message: "gate rejected" },
351
+ };
352
+
353
+ const wrapped = transformJobStatus(raw, "job-1", "current");
354
+ const direct = normalizeJobView(raw, "job-1", "current");
355
+
356
+ expect(wrapped).not.toBeNull();
357
+ expect(wrapped).toEqual(direct);
358
+ warn.mockRestore();
359
+ });
360
+
361
+ it("transformJobStatus returns null for non-object raw input", () => {
362
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
363
+ expect(transformJobStatus(null, "job-1", "current")).toBeNull();
364
+ expect(transformJobStatus("not an object", "job-1", "current")).toBeNull();
365
+ expect(transformJobStatus([1, 2, 3], "job-1", "current")).toBeNull();
366
+ expect(warn).not.toHaveBeenCalled();
367
+ warn.mockRestore();
368
+ });
369
+
370
+ it("buildUnreadableJob returns status:'failed' and survives isValidJob", () => {
371
+ const placeholder = buildUnreadableJob({
372
+ ok: false,
373
+ code: "INVALID_JSON",
374
+ path: "/jobs/job-2/tasks-status.json",
375
+ jobId: "job-2",
376
+ location: "current",
377
+ });
378
+
379
+ expect(placeholder.status).toBe("failed");
380
+ expect(placeholder.readable).toBe(false);
381
+ expect(placeholder.id).toBe("job-2");
382
+ expect(placeholder.jobId).toBe("job-2");
383
+ expect(placeholder.error?.code).toBe("INVALID_JSON");
384
+ expect(placeholder.error?.path).toBe("/jobs/job-2/tasks-status.json");
385
+
386
+ expect(Boolean(placeholder.id && placeholder.status)).toBe(true);
387
+ });
388
+
389
+ it("transformTasks uses zero-based fallback ids for unnamed array entries", () => {
390
+ const unnamed = transformTasks([{}, {}]);
391
+ expect(Object.keys(unnamed)).toEqual(["task-0", "task-1"]);
392
+
393
+ const mixed = transformTasks([{ name: "alpha" }, {}]);
394
+ expect(Object.keys(mixed)).toEqual(["alpha", "task-1"]);
395
+ });
396
+
397
+ it("transformJobStatus preserves control_invalid kind and omits invalid kind (AC-11)", () => {
398
+ const controlInvalid = transformJobStatus(
399
+ {
400
+ state: "waiting",
401
+ gate: {
402
+ afterTask: "review",
403
+ message: "Control file invalid for review: bad directive.",
404
+ requestedAt: "2026-06-19T12:00:00.000Z",
405
+ kind: "control_invalid",
406
+ },
407
+ tasks: {
408
+ review: { state: "done" },
409
+ deploy: { state: "pending" },
410
+ },
411
+ },
412
+ "job-1",
413
+ "current",
414
+ );
415
+
416
+ expect(controlInvalid?.gate?.kind).toBe("control_invalid");
417
+
418
+ const invalidKind = transformJobStatus(
419
+ {
420
+ state: "waiting",
421
+ gate: {
422
+ afterTask: "review",
423
+ message: "Control file invalid.",
424
+ requestedAt: "2026-06-19T12:00:00.000Z",
425
+ kind: "garbage",
426
+ },
427
+ tasks: {
428
+ review: { state: "done" },
429
+ },
430
+ },
431
+ "job-2",
432
+ "current",
433
+ );
434
+
435
+ expect(invalidKind?.gate).toBeDefined();
436
+ expect(Object.hasOwn(invalidKind!.gate!, "kind")).toBe(false);
437
+ });
210
438
  });
@@ -12,7 +12,7 @@ import type {
12
12
  const STATUS_PRIORITY: Record<string, number> = {
13
13
  running: 4,
14
14
  waiting: 4,
15
- error: 3,
15
+ failed: 3,
16
16
  pending: 2,
17
17
  complete: 1,
18
18
  };
@@ -24,9 +24,13 @@ const ZERO_COSTS: CostsSummary = {
24
24
  totalCost: 0,
25
25
  totalInputCost: 0,
26
26
  totalOutputCost: 0,
27
+ estimatedCost: 0,
28
+ hasEstimated: false,
29
+ unavailableCost: 0,
27
30
  };
28
31
 
29
32
  function isValidJob(job: CanonicalJob): boolean {
33
+ if (job.readable === false) return Boolean(job.id && job.status);
30
34
  return Boolean(job.id && job.status && job.createdAt);
31
35
  }
32
36
 
@@ -42,6 +46,9 @@ function getCostsSummary(costs: Record<string, unknown>): CostsSummary {
42
46
  totalCost: typeof costs["totalCost"] === "number" ? costs["totalCost"] : 0,
43
47
  totalInputCost: typeof costs["totalInputCost"] === "number" ? costs["totalInputCost"] : 0,
44
48
  totalOutputCost: typeof costs["totalOutputCost"] === "number" ? costs["totalOutputCost"] : 0,
49
+ estimatedCost: typeof costs["estimatedCost"] === "number" ? costs["estimatedCost"] : 0,
50
+ hasEstimated: typeof costs["hasEstimated"] === "boolean" ? costs["hasEstimated"] : false,
51
+ unavailableCost: typeof costs["unavailableCost"] === "number" ? costs["unavailableCost"] : 0,
45
52
  };
46
53
  }
47
54
 
@@ -56,7 +63,7 @@ export function sortJobs(jobs: CanonicalJob[]): CanonicalJob[] {
56
63
  .sort((left, right) => {
57
64
  const priority = getStatusPriority(right.status) - getStatusPriority(left.status);
58
65
  if (priority !== 0) return priority;
59
- const created = left.createdAt!.localeCompare(right.createdAt!);
66
+ const created = (left.createdAt ?? "").localeCompare(right.createdAt ?? "");
60
67
  if (created !== 0) return created;
61
68
  return left.id.localeCompare(right.id);
62
69
  });
@@ -82,7 +89,7 @@ export function groupJobsByStatus(jobs: CanonicalJob[]): GroupedJobs {
82
89
  const grouped: GroupedJobs = {
83
90
  running: [],
84
91
  waiting: [],
85
- error: [],
92
+ failed: [],
86
93
  pending: [],
87
94
  complete: [],
88
95
  };
@@ -158,6 +165,9 @@ export function transformJobListForAPI(
158
165
  apiJob.pipelineConfig = job.pipelineConfig;
159
166
  }
160
167
 
168
+ if (job.readable !== undefined) apiJob.readable = job.readable;
169
+ if (job.error !== undefined) apiJob.error = job.error;
170
+
161
171
  return apiJob;
162
172
  });
163
173
  }