@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,3 +1,4 @@
1
+ import { deriveProgress } from "../../../core/job-view.ts";
1
2
  import { deriveJobStatusFromTasks, normalizeJobStatus, normalizeTaskState } from "../../../config/statuses";
2
3
  import type {
3
4
  AllowedActions,
@@ -19,6 +20,9 @@ const EMPTY_COSTS: CostsSummary = {
19
20
  totalCost: 0,
20
21
  totalInputCost: 0,
21
22
  totalOutputCost: 0,
23
+ estimatedCost: 0,
24
+ hasEstimated: false,
25
+ unavailableCost: 0,
22
26
  };
23
27
 
24
28
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -50,10 +54,14 @@ function normalizeGate(value: unknown): GateInfo | null | undefined {
50
54
  ? value["artifacts"].filter((artifact): artifact is string => typeof artifact === "string")
51
55
  : undefined;
52
56
 
57
+ const rawKind = value["kind"];
58
+ const kind = rawKind === "approval" || rawKind === "control_invalid" ? rawKind : undefined;
59
+
53
60
  return {
54
61
  afterTask: typeof value["afterTask"] === "string" ? value["afterTask"] : "",
55
62
  message: typeof value["message"] === "string" ? value["message"] : "",
56
63
  requestedAt: typeof value["requestedAt"] === "string" ? value["requestedAt"] : "",
64
+ ...(kind ? { kind } : {}),
57
65
  ...(artifacts && artifacts.length > 0 ? { artifacts } : {}),
58
66
  };
59
67
  }
@@ -108,6 +116,14 @@ function getWarnings(rawTasks: unknown): string[] {
108
116
  return ["Unsupported task collection shape"];
109
117
  }
110
118
 
119
+ function isErrorShape(value: unknown): value is { code: string; message: string; path?: string } {
120
+ if (!isRecord(value)) return false;
121
+ if (typeof value["code"] !== "string") return false;
122
+ if (typeof value["message"] !== "string") return false;
123
+ if ("path" in value && typeof value["path"] !== "string") return false;
124
+ return true;
125
+ }
126
+
111
127
  function normalizeCostsSummary(costs: unknown): CostsSummary {
112
128
  if (!isRecord(costs)) return EMPTY_COSTS;
113
129
  return {
@@ -117,6 +133,9 @@ function normalizeCostsSummary(costs: unknown): CostsSummary {
117
133
  totalCost: toNumber(costs["totalCost"]),
118
134
  totalInputCost: toNumber(costs["totalInputCost"]),
119
135
  totalOutputCost: toNumber(costs["totalOutputCost"]),
136
+ estimatedCost: toNumber(costs["estimatedCost"]),
137
+ hasEstimated: typeof costs["hasEstimated"] === "boolean" ? costs["hasEstimated"] : false,
138
+ unavailableCost: toNumber(costs["unavailableCost"]),
120
139
  };
121
140
  }
122
141
 
@@ -124,13 +143,24 @@ function normalizeCostBreakdown(costs: unknown): Record<string, TaskCostBreakdow
124
143
  if (!isRecord(costs)) return undefined;
125
144
  const entries = Object.entries(costs).flatMap(([name, value]) => {
126
145
  if (!isRecord(value)) return [];
127
- return [[name, {
146
+ const entry: TaskCostBreakdown = {
128
147
  inputTokens: toNumber(value["inputTokens"]),
129
148
  outputTokens: toNumber(value["outputTokens"]),
130
149
  inputCost: toNumber(value["inputCost"]),
131
150
  outputCost: toNumber(value["outputCost"]),
132
151
  totalCost: toNumber(value["totalCost"]),
133
- } satisfies TaskCostBreakdown] as const];
152
+ };
153
+ const rawSource = value["source"];
154
+ if (rawSource === "reported" || rawSource === "estimated" || rawSource === "unavailable" || rawSource === "zero") {
155
+ entry.source = rawSource;
156
+ }
157
+ if (typeof value["failed"] === "boolean") {
158
+ entry.failed = value["failed"];
159
+ }
160
+ if (typeof value["costEstimated"] === "boolean") {
161
+ entry.costEstimated = value["costEstimated"];
162
+ }
163
+ return [[name, entry] as const];
134
164
  });
135
165
  return Object.fromEntries(entries);
136
166
  }
@@ -159,20 +189,13 @@ function adaptBaseJob(apiJob: Record<string, unknown>): NormalizedJobSummary {
159
189
  const rawTasks = apiJob["tasks"] ?? apiJob["tasksStatus"];
160
190
  const tasks = normalizeTasks(rawTasks);
161
191
  const taskList = Object.values(tasks);
162
- const doneCount = taskList.filter((task) => task.state === "done").length;
163
- const completedCount = taskList.filter((task) => isCompletedTaskState(task.state)).length;
164
192
  const rawPipelineConfig = isRecord(apiJob["pipelineConfig"]) ? apiJob["pipelineConfig"] : null;
165
- const pipelineTaskArray = rawPipelineConfig && Array.isArray(rawPipelineConfig["tasks"])
166
- ? rawPipelineConfig["tasks"] as unknown[]
193
+ const configuredCount = rawPipelineConfig && Array.isArray(rawPipelineConfig["tasks"])
194
+ ? (rawPipelineConfig["tasks"] as unknown[]).length
167
195
  : null;
168
- const taskCount = pipelineTaskArray ? pipelineTaskArray.length : taskList.length;
196
+ const derived = deriveProgress(taskList, configuredCount);
169
197
  const inferredStatus = deriveJobStatusFromTasks(taskList);
170
198
  const status = normalizeJobStatus(apiJob["status"] ?? inferredStatus);
171
- const progress = pipelineTaskArray
172
- ? (taskCount === 0 ? 0 : Math.min(100, Math.floor((completedCount / taskCount) * 100)))
173
- : typeof apiJob["progress"] === "number"
174
- ? apiJob["progress"]
175
- : taskCount === 0 ? 0 : Math.min(100, Math.floor((completedCount / taskCount) * 100));
176
199
 
177
200
  return {
178
201
  id: typeof apiJob["id"] === "string" ? apiJob["id"] : String(apiJob["jobId"] ?? ""),
@@ -181,10 +204,10 @@ function adaptBaseJob(apiJob: Record<string, unknown>): NormalizedJobSummary {
181
204
  ? apiJob["name"]
182
205
  : typeof apiJob["title"] === "string" ? apiJob["title"] : "",
183
206
  status,
184
- progress,
185
- taskCount,
186
- doneCount,
187
- completedCount,
207
+ progress: derived.progress,
208
+ taskCount: derived.taskCount,
209
+ doneCount: derived.doneCount,
210
+ completedCount: derived.completedCount,
188
211
  location: typeof apiJob["location"] === "string" ? apiJob["location"] : "current",
189
212
  tasks,
190
213
  current: normalizeCurrent(apiJob["current"]),
@@ -202,6 +225,8 @@ function adaptBaseJob(apiJob: Record<string, unknown>): NormalizedJobSummary {
202
225
  totalTokens: toNumber(apiJob["totalTokens"], normalizeCostsSummary(apiJob["costsSummary"]).totalTokens),
203
226
  displayCategory: getDisplayCategory(status),
204
227
  __warnings: getWarnings(rawTasks),
228
+ ...(typeof apiJob["readable"] === "boolean" ? { readable: apiJob["readable"] } : {}),
229
+ ...(isErrorShape(apiJob["error"]) ? { error: apiJob["error"] } : {}),
205
230
  };
206
231
  }
207
232
 
@@ -201,14 +201,22 @@ export async function decideGate(
201
201
  ) as Promise<GateDecisionResponse>;
202
202
  }
203
203
 
204
- export async function fetchConcurrencyStatus(
205
- signal?: AbortSignal,
206
- ): Promise<JobConcurrencyApiStatus> {
204
+ export async function getJson<T>(
205
+ url: string,
206
+ opts?: {
207
+ signal?: AbortSignal;
208
+ validate?: (data: unknown) => data is T;
209
+ malformedMessage?: string;
210
+ },
211
+ ): Promise<T> {
212
+ const signal = opts?.signal;
213
+ if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
214
+
207
215
  let response: Response;
208
216
  let payload: unknown;
209
217
 
210
218
  try {
211
- response = await fetch("/api/concurrency", { signal });
219
+ response = await fetch(url, { signal });
212
220
  if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
213
221
  payload = await parseJson(response);
214
222
  } catch (error) {
@@ -227,19 +235,34 @@ export async function fetchConcurrencyStatus(
227
235
  );
228
236
  }
229
237
 
230
- if (
231
- isRecord(payload) &&
232
- payload["ok"] === true &&
233
- isJobConcurrencyApiStatus(payload["data"])
234
- ) {
235
- return payload["data"];
238
+ if (!isRecord(payload) || payload["ok"] !== true) {
239
+ throw {
240
+ code: "malformed_response",
241
+ message: opts?.malformedMessage ?? "Malformed response",
242
+ status: response.status,
243
+ } satisfies ApiError;
236
244
  }
237
245
 
238
- throw {
239
- code: "malformed_response",
240
- message: "Malformed concurrency status response",
241
- status: response.status,
242
- } satisfies ApiError;
246
+ const data = payload["data"];
247
+ if (data === undefined || (opts?.validate && !opts.validate(data))) {
248
+ throw {
249
+ code: "malformed_response",
250
+ message: opts?.malformedMessage ?? "Malformed response",
251
+ status: response.status,
252
+ } satisfies ApiError;
253
+ }
254
+
255
+ return data as T;
256
+ }
257
+
258
+ export async function fetchConcurrencyStatus(
259
+ signal?: AbortSignal,
260
+ ): Promise<JobConcurrencyApiStatus> {
261
+ return getJson<JobConcurrencyApiStatus>("/api/concurrency", {
262
+ signal,
263
+ validate: isJobConcurrencyApiStatus,
264
+ malformedMessage: "Malformed concurrency status response",
265
+ });
243
266
  }
244
267
 
245
268
  function isJobConcurrencyApiStatus(value: unknown): value is JobConcurrencyApiStatus {
@@ -1,29 +1,34 @@
1
- import type { BootstrapOptions, SseEventType } from "./types";
1
+ import { getJson } from "./api";
2
+ import type { ApiError, BootstrapOptions, SseEventType } from "./types";
2
3
 
3
4
  const DEFAULT_STATE_URL = "/api/state";
4
5
  const DEFAULT_SSE_URL = "/api/events";
5
- const BOOTSTRAP_EVENTS: SseEventType[] = [
6
- "state",
7
- "job:updated",
6
+ const BOOTSTRAP_EVENTS: readonly SseEventType[] = [
7
+ "state:change",
8
+ "state:summary",
8
9
  "job:created",
10
+ "job:updated",
9
11
  "job:removed",
10
12
  "heartbeat",
11
- "message",
13
+ "seed:uploaded",
12
14
  ];
13
15
 
14
- async function parseJson(response: Response): Promise<unknown> {
15
- try {
16
- return await response.json();
17
- } catch {
18
- return null;
19
- }
16
+ function isApiError(value: unknown): value is ApiError {
17
+ return (
18
+ typeof value === "object" &&
19
+ value !== null &&
20
+ typeof (value as { code?: unknown }).code === "string" &&
21
+ typeof (value as { message?: unknown }).message === "string"
22
+ );
20
23
  }
21
24
 
22
25
  async function loadSnapshot(stateUrl: string): Promise<unknown> {
23
26
  try {
24
- const response = await fetch(stateUrl);
25
- return await parseJson(response);
26
- } catch {
27
+ return await getJson<unknown>(stateUrl);
28
+ } catch (error) {
29
+ if (isApiError(error)) {
30
+ console.warn("bootstrap: failed to load initial state", error);
31
+ }
27
32
  return null;
28
33
  }
29
34
  }
@@ -1,14 +1,19 @@
1
1
  import { useCallback, useEffect, useRef, useState, useTransition } from "react";
2
2
 
3
+ import { parseSseEvent, type SseEvent } from "../../../config/sse-events.ts";
3
4
  import { adaptJobDetail } from "../adapters/job-adapter";
5
+ import { getJson } from "../api";
6
+ import { nextLoadState, type LoadState } from "../load-state";
7
+ import { applyDetailEvent } from "../reducers/job-events.ts";
4
8
  import type {
9
+ ApiError,
5
10
  ConnectionStatus,
6
11
  NormalizedJobDetail,
7
- NormalizedTask,
8
- SseJobEvent,
9
12
  UseJobDetailWithUpdatesResult,
10
13
  } from "../types";
11
14
 
15
+ export { applyDetailEvent };
16
+
12
17
  export const REFRESH_DEBOUNCE_MS = 200;
13
18
  export const POLL_INTERVAL_MS = 3_000;
14
19
 
@@ -16,18 +21,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
16
21
  return typeof value === "object" && value !== null;
17
22
  }
18
23
 
19
- export function extractJobDetail(payload: unknown): Record<string, unknown> | null {
20
- if (isRecord(payload) && isRecord(payload["data"])) return payload["data"];
21
- if (isRecord(payload)) return payload;
22
- return null;
23
- }
24
-
25
- function getPipelineTaskCount(detail: NormalizedJobDetail): number | null {
26
- const config = detail.pipelineConfig;
27
- if (config && Array.isArray(config["tasks"])) return config["tasks"].length;
28
- return null;
29
- }
30
-
31
24
  function getPipelineTaskNames(detail: NormalizedJobDetail): string[] {
32
25
  const config = detail.pipelineConfig;
33
26
  if (!config || !Array.isArray(config["tasks"])) return Object.keys(detail.tasks);
@@ -46,12 +39,7 @@ function sameTaskSet(left: string[], right: string[]): boolean {
46
39
  return left.every((taskName) => rightSet.has(taskName));
47
40
  }
48
41
 
49
- export function shouldRefetchDetailForTaskSet(detail: NormalizedJobDetail, event: SseJobEvent): boolean {
50
- if (event.type === "task:updated") {
51
- const taskName = typeof event.data["taskName"] === "string" ? event.data["taskName"] : null;
52
- return taskName !== null && detail.tasks[taskName] === undefined;
53
- }
54
-
42
+ export function shouldRefetchDetailForTaskSet(detail: NormalizedJobDetail, event: SseEvent): boolean {
55
43
  if (event.type !== "job:updated") return false;
56
44
  const rawTasks = event.data["tasks"];
57
45
  if (!isRecord(rawTasks)) return false;
@@ -61,21 +49,6 @@ export function shouldRefetchDetailForTaskSet(detail: NormalizedJobDetail, event
61
49
  return !sameTaskSet(getPipelineTaskNames(detail), incomingTaskNames);
62
50
  }
63
51
 
64
- function recomputeProgress(detail: NormalizedJobDetail): NormalizedJobDetail {
65
- const tasks = Object.values(detail.tasks);
66
- const doneCount = tasks.filter((task) => task.state === "done").length;
67
- const completedCount = tasks.filter((task) => task.state === "done" || task.state === "skipped").length;
68
- const taskCount = getPipelineTaskCount(detail) ?? tasks.length;
69
- return {
70
- ...detail,
71
- doneCount,
72
- completedCount,
73
- taskCount,
74
- progress: taskCount === 0 ? 0 : Math.min(100, Math.floor((completedCount / taskCount) * 100)),
75
- updatedAt: new Date().toISOString(),
76
- };
77
- }
78
-
79
52
  function escapeRegExp(value: string): string {
80
53
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
81
54
  }
@@ -85,67 +58,72 @@ export function matchesJobTasksStatusPath(path: string, jobId: string): boolean
85
58
  return pattern.test(path);
86
59
  }
87
60
 
88
- export function applyDetailEvent(
89
- detail: NormalizedJobDetail,
90
- event: SseJobEvent,
91
- ): NormalizedJobDetail {
92
- const eventJobId = typeof event.data["jobId"] === "string" ? event.data["jobId"] : null;
93
- if (eventJobId !== detail.jobId) return detail;
94
-
95
- if (event.type === "job:updated") {
96
- return adaptJobDetail({ ...detail, ...event.data });
61
+ export type DetailMessageAction =
62
+ | { kind: "refetch" }
63
+ | { kind: "apply"; event: SseEvent }
64
+ | { kind: "skip" };
65
+
66
+ export function handleDetailSseMessage(
67
+ type: string,
68
+ rawData: string,
69
+ jobId: string,
70
+ ): DetailMessageAction {
71
+ let parsedData: unknown;
72
+ try {
73
+ parsedData = JSON.parse(rawData);
74
+ } catch (error) {
75
+ console.warn("useJobDetailWithUpdates: failed to parse SSE payload", error);
76
+ return { kind: "refetch" };
97
77
  }
98
78
 
99
- if (event.type === "task:updated") {
100
- const taskName = typeof event.data["taskName"] === "string" ? event.data["taskName"] : null;
101
- if (taskName === null) return detail;
102
- const currentTask = detail.tasks[taskName];
103
- if (!currentTask) return detail;
104
-
105
- const nextTask: NormalizedTask = {
106
- ...currentTask,
107
- ...(isRecord(event.data["task"]) ? adaptJobDetail({
108
- ...detail,
109
- tasks: {
110
- [taskName]: event.data["task"],
111
- },
112
- }).tasks[taskName] ?? currentTask : currentTask),
113
- };
79
+ const event = parseSseEvent(type, parsedData);
80
+ if (event === null) {
81
+ console.warn(`useJobDetailWithUpdates: dropping invalid SSE event of type "${type}"`);
82
+ return { kind: "refetch" };
83
+ }
114
84
 
115
- return recomputeProgress({
116
- ...detail,
117
- tasks: {
118
- ...detail.tasks,
119
- [taskName]: nextTask,
120
- },
121
- });
85
+ if (event.type === "state:change") {
86
+ return matchesJobTasksStatusPath(event.data.path, jobId)
87
+ ? { kind: "refetch" }
88
+ : { kind: "skip" };
122
89
  }
123
90
 
124
- return detail;
91
+ return { kind: "apply", event };
125
92
  }
126
93
 
127
- async function fetchJobDetail(jobId: string, signal: AbortSignal): Promise<NormalizedJobDetail> {
128
- const response = await fetch(`/api/jobs/${jobId}`, { signal });
129
- const payload = extractJobDetail(await response.json());
130
- return adaptJobDetail(payload ?? {});
94
+ export async function fetchJobDetail(jobId: string, signal: AbortSignal): Promise<NormalizedJobDetail> {
95
+ const raw = await getJson<Record<string, unknown>>(`/api/jobs/${jobId}`, {
96
+ signal,
97
+ validate: isRecord,
98
+ malformedMessage: "Malformed job detail response",
99
+ });
100
+ return adaptJobDetail(raw);
131
101
  }
132
102
 
133
103
  export function useJobDetailWithUpdates(jobId: string): UseJobDetailWithUpdatesResult {
134
104
  const [data, setData] = useState<NormalizedJobDetail | null>(null);
135
105
  const [loading, setLoading] = useState(true);
136
- const [error, setError] = useState<string | null>(null);
106
+ const [error, setError] = useState<ApiError | null>(null);
137
107
  const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>("disconnected");
138
108
  const [isRefreshing, setIsRefreshing] = useState(false);
139
109
  const [isHydrated, setIsHydrated] = useState(false);
140
110
  const [isTransitioning, startTransition] = useTransition();
141
111
  const mountedRef = useRef(true);
142
112
  const dataRef = useRef<NormalizedJobDetail | null>(null);
113
+ const errorRef = useRef<ApiError | null>(null);
143
114
  const hydratedRef = useRef(false);
144
115
  const abortRef = useRef<AbortController | null>(null);
145
- const queueRef = useRef<SseJobEvent[]>([]);
116
+ const queueRef = useRef<SseEvent[]>([]);
146
117
  const refetchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
147
118
  const refetchingRef = useRef(false);
148
119
 
120
+ const apply = (next: LoadState<NormalizedJobDetail>) => {
121
+ dataRef.current = next.data;
122
+ errorRef.current = next.error;
123
+ setData(next.data);
124
+ setError(next.error);
125
+ };
126
+
149
127
  const load = useCallback(() => {
150
128
  abortRef.current?.abort();
151
129
  const controller = new AbortController();
@@ -160,16 +138,20 @@ export function useJobDetailWithUpdates(jobId: string): UseJobDetailWithUpdatesR
160
138
  if (!mountedRef.current) return;
161
139
  const replayed = queueRef.current.reduce(applyDetailEvent, detail);
162
140
  queueRef.current = [];
163
- dataRef.current = replayed;
141
+ apply(nextLoadState(
142
+ { data: dataRef.current, error: errorRef.current },
143
+ { ok: true, data: replayed },
144
+ ));
164
145
  hydratedRef.current = true;
165
- setData(replayed);
166
- setError(null);
167
146
  setIsHydrated(true);
168
147
  })
169
- .catch((fetchError) => {
148
+ .catch((fetchError: unknown) => {
170
149
  if (fetchError instanceof DOMException && fetchError.name === "AbortError") return;
171
150
  if (!mountedRef.current) return;
172
- setError(fetchError instanceof Error ? fetchError.message : "Failed to load job");
151
+ apply(nextLoadState(
152
+ { data: dataRef.current, error: errorRef.current },
153
+ { ok: false, error: fetchError as ApiError },
154
+ ));
173
155
  })
174
156
  .finally(() => {
175
157
  if (!mountedRef.current) return;
@@ -202,31 +184,21 @@ export function useJobDetailWithUpdates(jobId: string): UseJobDetailWithUpdatesR
202
184
  };
203
185
 
204
186
  const onMessage = (event: MessageEvent<string>) => {
205
- let payload: SseJobEvent;
206
- try {
207
- payload = {
208
- type: event.type as SseJobEvent["type"],
209
- data: JSON.parse(event.data) as Record<string, unknown>,
210
- };
211
- } catch (parseError) {
212
- console.warn("Failed to parse job detail SSE payload", parseError);
213
- return;
214
- }
187
+ const result = handleDetailSseMessage(event.type, event.data, jobId);
215
188
 
216
- if (payload.type === "state:change") {
217
- const path = typeof payload.data["path"] === "string" ? payload.data["path"] : "";
218
- if (matchesJobTasksStatusPath(path, jobId)) {
219
- scheduleRefetch();
220
- }
189
+ if (result.kind === "refetch") {
190
+ scheduleRefetch();
221
191
  return;
222
192
  }
223
193
 
194
+ if (result.kind === "skip") return;
195
+
224
196
  if (!hydratedRef.current || refetchingRef.current || dataRef.current === null) {
225
- queueRef.current.push(payload);
197
+ queueRef.current.push(result.event);
226
198
  return;
227
199
  }
228
200
 
229
- if (shouldRefetchDetailForTaskSet(dataRef.current, payload)) {
201
+ if (shouldRefetchDetailForTaskSet(dataRef.current, result.event)) {
230
202
  scheduleRefetch();
231
203
  return;
232
204
  }
@@ -234,7 +206,7 @@ export function useJobDetailWithUpdates(jobId: string): UseJobDetailWithUpdatesR
234
206
  startTransition(() => {
235
207
  setData((current) => {
236
208
  if (current === null) return current;
237
- const next = applyDetailEvent(current, payload);
209
+ const next = applyDetailEvent(current, result.event);
238
210
  dataRef.current = next;
239
211
  return next;
240
212
  });
@@ -242,7 +214,6 @@ export function useJobDetailWithUpdates(jobId: string): UseJobDetailWithUpdatesR
242
214
  };
243
215
 
244
216
  source.addEventListener("job:updated", onMessage as EventListener);
245
- source.addEventListener("task:updated", onMessage as EventListener);
246
217
  source.addEventListener("state:change", onMessage as EventListener);
247
218
 
248
219
  return () => {
@@ -265,6 +236,10 @@ export function useJobDetailWithUpdates(jobId: string): UseJobDetailWithUpdatesR
265
236
  return () => clearInterval(interval);
266
237
  }, [data?.status, load]);
267
238
 
239
+ const refetch = useCallback(() => {
240
+ load();
241
+ }, [load]);
242
+
268
243
  return {
269
244
  data,
270
245
  loading,
@@ -273,5 +248,6 @@ export function useJobDetailWithUpdates(jobId: string): UseJobDetailWithUpdatesR
273
248
  isRefreshing,
274
249
  isTransitioning,
275
250
  isHydrated,
251
+ refetch,
276
252
  };
277
253
  }
@@ -1,39 +1,17 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
2
 
3
3
  import { adaptJobSummary } from "../adapters/job-adapter";
4
+ import { getJson } from "../api";
5
+ import { nextLoadState, type LoadState } from "../load-state";
4
6
  import type { ApiError, NormalizedJobSummary, UseJobListResult } from "../types";
5
7
 
6
8
  function isRecord(value: unknown): value is Record<string, unknown> {
7
9
  return typeof value === "object" && value !== null;
8
10
  }
9
11
 
10
- function toApiError(error: unknown): ApiError {
11
- if (isRecord(error) && typeof error["code"] === "string" && typeof error["message"] === "string") {
12
- return {
13
- code: error["code"] as ApiError["code"],
14
- message: error["message"],
15
- status: typeof error["status"] === "number" ? error["status"] : undefined,
16
- };
17
- }
18
-
19
- return {
20
- code: "unknown_error",
21
- message: error instanceof Error ? error.message : "Failed to load jobs",
22
- };
23
- }
24
-
25
- export function extractJobList(payload: unknown): Record<string, unknown>[] {
26
- if (Array.isArray(payload)) return payload.filter(isRecord);
27
- if (isRecord(payload) && payload["ok"] === true && Array.isArray(payload["data"])) {
28
- return payload["data"].filter(isRecord);
29
- }
30
- return [];
31
- }
32
-
33
12
  export async function fetchJobList(signal?: AbortSignal): Promise<NormalizedJobSummary[]> {
34
- const response = await fetch("/api/jobs", { signal });
35
- const payload = await response.json();
36
- return extractJobList(payload).map(adaptJobSummary);
13
+ const raw = await getJson<unknown[]>("/api/jobs", { signal, validate: Array.isArray });
14
+ return raw.filter(isRecord).map(adaptJobSummary);
37
15
  }
38
16
 
39
17
  export function useJobList(): UseJobListResult {
@@ -41,6 +19,15 @@ export function useJobList(): UseJobListResult {
41
19
  const [data, setData] = useState<NormalizedJobSummary[] | null>(null);
42
20
  const [error, setError] = useState<ApiError | null>(null);
43
21
  const abortRef = useRef<AbortController | null>(null);
22
+ const dataRef = useRef<NormalizedJobSummary[] | null>(null);
23
+ const errorRef = useRef<ApiError | null>(null);
24
+
25
+ const apply = (next: LoadState<NormalizedJobSummary[]>) => {
26
+ dataRef.current = next.data;
27
+ errorRef.current = next.error;
28
+ setData(next.data);
29
+ setError(next.error);
30
+ };
44
31
 
45
32
  const load = useCallback((cancelPrevious: boolean) => {
46
33
  if (cancelPrevious) abortRef.current?.abort();
@@ -50,14 +37,22 @@ export function useJobList(): UseJobListResult {
50
37
 
51
38
  void fetchJobList(controller.signal)
52
39
  .then((jobs) => {
53
- setData(jobs);
54
- setError(null);
40
+ if (abortRef.current !== controller) return;
41
+ apply(nextLoadState(
42
+ { data: dataRef.current, error: errorRef.current },
43
+ { ok: true, data: jobs },
44
+ ));
55
45
  })
56
- .catch((fetchError) => {
46
+ .catch((fetchError: unknown) => {
57
47
  if (fetchError instanceof DOMException && fetchError.name === "AbortError") return;
58
- setError(toApiError(fetchError));
48
+ if (abortRef.current !== controller) return;
49
+ apply(nextLoadState(
50
+ { data: dataRef.current, error: errorRef.current },
51
+ { ok: false, error: fetchError as ApiError },
52
+ ));
59
53
  })
60
54
  .finally(() => {
55
+ if (abortRef.current !== controller) return;
61
56
  setLoading(false);
62
57
  });
63
58
  }, []);
@@ -72,4 +67,4 @@ export function useJobList(): UseJobListResult {
72
67
  }, [load]);
73
68
 
74
69
  return { loading, data, error, refetch };
75
- }
70
+ }