@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,4 +1,8 @@
1
1
  import type { JobStatusValue, TaskStateValue } from "../../config/statuses";
2
+ import type { SseEventType } from "../../config/sse-events.ts";
3
+ import type { UsageSource } from "../../providers/types";
4
+
5
+ export type { SseEventType };
2
6
 
3
7
  export type ApiErrorCode =
4
8
  | "job_running"
@@ -43,19 +47,6 @@ export interface RestartJobOptions {
43
47
  };
44
48
  }
45
49
 
46
- export type SseEventType =
47
- | "state"
48
- | "job:updated"
49
- | "job:created"
50
- | "job:removed"
51
- | "heartbeat"
52
- | "message"
53
- | "status:changed"
54
- | "seed:uploaded"
55
- | "state:change"
56
- | "state:summary"
57
- | "task:updated";
58
-
59
50
  export interface BootstrapOptions {
60
51
  stateUrl?: string;
61
52
  sseUrl?: string;
@@ -87,6 +78,9 @@ export interface CostsSummary {
87
78
  totalCost: number;
88
79
  totalInputCost: number;
89
80
  totalOutputCost: number;
81
+ estimatedCost: number;
82
+ hasEstimated: boolean;
83
+ unavailableCost: number;
90
84
  }
91
85
 
92
86
  export type RetryError = { message: string; name?: string; stack?: string } | string;
@@ -96,6 +90,7 @@ export interface GateInfo {
96
90
  message: string;
97
91
  artifacts?: string[];
98
92
  requestedAt: string;
93
+ kind?: "approval" | "control_invalid";
99
94
  }
100
95
 
101
96
  export interface TaskFiles {
@@ -155,6 +150,8 @@ export interface NormalizedJobSummary {
155
150
  totalTokens?: number;
156
151
  displayCategory: string;
157
152
  __warnings?: string[];
153
+ readable?: boolean;
154
+ error?: { code: string; message: string; path?: string };
158
155
  }
159
156
 
160
157
  export interface TaskCostBreakdown {
@@ -163,6 +160,9 @@ export interface TaskCostBreakdown {
163
160
  inputCost: number;
164
161
  outputCost: number;
165
162
  totalCost: number;
163
+ source?: UsageSource;
164
+ failed?: boolean;
165
+ costEstimated?: boolean;
166
166
  }
167
167
 
168
168
  export interface NormalizedJobDetail extends NormalizedJobSummary {
@@ -183,11 +183,12 @@ export interface UseJobListWithUpdatesResult extends UseJobListResult {
183
183
  export interface UseJobDetailWithUpdatesResult {
184
184
  data: NormalizedJobDetail | null;
185
185
  loading: boolean;
186
- error: string | null;
186
+ error: ApiError | null;
187
187
  connectionStatus: ConnectionStatus;
188
188
  isRefreshing: boolean;
189
189
  isTransitioning: boolean;
190
190
  isHydrated: boolean;
191
+ refetch: () => void;
191
192
  }
192
193
 
193
194
  export type AnalysisStatus = "idle" | "connecting" | "running" | "complete" | "error";
@@ -233,7 +234,7 @@ export interface JobConcurrencyApiStatus {
233
234
  }>;
234
235
  staleSlots: Array<{
235
236
  jobId: string;
236
- reason: "missing_current_job" | "missing_pid" | "dead_pid" | "invalid_json";
237
+ reason: "missing_current_job" | "missing_pid" | "dead_pid" | "invalid_json" | "stale_runner";
237
238
  }>;
238
239
  }
239
240
 
@@ -244,11 +245,6 @@ export interface UseConcurrencyStatusResult {
244
245
  refetch: () => void;
245
246
  }
246
247
 
247
- export interface SseJobEvent {
248
- type: SseEventType;
249
- data: Record<string, unknown>;
250
- }
251
-
252
248
  export type AnalysisSseEventType =
253
249
  | "started"
254
250
  | "task:start"
@@ -346,7 +346,12 @@ export default function DAGGrid({
346
346
  </div>
347
347
  </div>
348
348
  <div className="p-4">
349
- {items[index]?.subtitle ? <div className="text-sm text-gray-600">{items[index]?.subtitle}</div> : null}
349
+ {items[index]?.subtitle ? (
350
+ <div className="text-sm text-gray-600">
351
+ {items[index]?.costEstimated ? <abbr title="Estimated cost" className="mr-0.5">≈</abbr> : null}
352
+ {items[index]?.subtitle}
353
+ </div>
354
+ ) : null}
350
355
  {items[index]?.body ? <div className="mt-2 text-sm text-gray-700">{items[index]?.body}</div> : null}
351
356
  <div className="mt-3 flex gap-2 border-t border-gray-100 pt-3">
352
357
  {itemStatus === "pending" ? (
@@ -33,13 +33,20 @@ export default function JobDetail({
33
33
  typeof task?.config?.["model"] === "string" ? String(task.config.model) : null,
34
34
  typeof task?.refinementAttempts === "number" ? `${task.refinementAttempts} refinements` : null,
35
35
  ];
36
- const breakdown = (job.costs?.taskBreakdown?.[item.id] as Record<string, unknown> | undefined)?.["summary"] as Record<string, unknown> | undefined;
37
- if (typeof breakdown?.["totalTokens"] === "number" && breakdown.totalTokens > 0) parts.push(formatTokensCompact(breakdown.totalTokens));
38
- if (typeof breakdown?.["totalCost"] === "number" && breakdown.totalCost > 0) parts.push(formatCurrency4(breakdown.totalCost));
36
+ const breakdown = job.costs?.[item.id];
37
+ const breakdownTokens = breakdown ? breakdown.inputTokens + breakdown.outputTokens : 0;
38
+ if (breakdownTokens > 0) parts.push(formatTokensCompact(breakdownTokens));
39
+ if (typeof breakdown?.totalCost === "number" && breakdown.totalCost > 0) parts.push(formatCurrency4(breakdown.totalCost));
40
+ const source = breakdown?.source;
41
+ const costEstimated =
42
+ breakdown?.costEstimated === true ||
43
+ source === "estimated" ||
44
+ source === "unavailable";
39
45
  return {
40
46
  ...item,
41
47
  subtitle: parts.filter(Boolean).join(" · ") || null,
42
48
  body: item.body ?? task?.error?.message ?? null,
49
+ ...(costEstimated ? { costEstimated: true } : {}),
43
50
  };
44
51
  });
45
52
 
@@ -54,7 +61,8 @@ export default function JobDetail({
54
61
  prior.title === item.title &&
55
62
  prior.subtitle === item.subtitle &&
56
63
  prior.body === item.body &&
57
- prior.restartCount === item.restartCount
64
+ prior.restartCount === item.restartCount &&
65
+ prior.costEstimated === item.costEstimated
58
66
  ) {
59
67
  return prior;
60
68
  }
@@ -65,47 +65,75 @@ export default function JobTable({
65
65
  <tbody className="divide-y divide-gray-200">
66
66
  {jobs.map((job) => {
67
67
  const validId = Boolean(job.id);
68
+ const readable = job.readable !== false;
69
+ const clickable = validId && readable;
68
70
  const taskMap = Array.isArray(job.tasks) ? Object.fromEntries(job.tasks.map((task) => [task.name, task] as const)) : job.tasks;
69
71
  const currentTaskId =
70
72
  typeof job.current === "string" ? job.current : job.current?.taskName;
71
73
  const currentTask = currentTaskId ? taskMap[currentTaskId] : undefined;
72
- const totalCost = job.totalCost ?? job.costsSummary?.totalCost ?? 0;
74
+ const totalCost =
75
+ (job.totalCost ?? job.costsSummary?.totalCost ?? 0) +
76
+ (job.costsSummary?.estimatedCost ?? 0) +
77
+ (job.costsSummary?.unavailableCost ?? 0);
73
78
  const totalTokens = job.totalTokens ?? job.costsSummary?.totalTokens ?? 0;
79
+ const hasEstimated = job.costsSummary?.hasEstimated === true;
74
80
  const startMs = Object.values(taskMap).map((task) => toMs(task.startedAt)).filter((value): value is number => value !== null).sort((a, b) => a - b)[0] ?? null;
75
81
  const endMs = Object.values(taskMap).map((task) => toMs(task.endedAt)).filter((value): value is number => value !== null).sort((a, b) => b - a)[0] ?? null;
76
82
 
77
83
  return (
78
84
  <tr
79
85
  key={job.jobId}
80
- className={validId ? "cursor-pointer transition hover:bg-gray-50" : "cursor-not-allowed opacity-60"}
81
- onClick={() => validId && onOpenJob(job.id)}
82
- tabIndex={validId ? 0 : -1}
86
+ className={clickable ? "cursor-pointer transition hover:bg-gray-50" : "cursor-not-allowed opacity-60"}
87
+ onClick={() => clickable && onOpenJob(job.id)}
88
+ tabIndex={clickable ? 0 : -1}
83
89
  onKeyDown={(event) => {
84
- if (validId && (event.key === "Enter" || event.key === " ")) onOpenJob(job.id);
90
+ if (clickable && (event.key === "Enter" || event.key === " ")) onOpenJob(job.id);
85
91
  }}
86
92
  >
87
93
  <td className="px-4 py-4">
88
94
  <div className="font-medium text-[#6d28d9]">{job.name}</div>
89
95
  <div className="text-xs text-gray-500">{job.id}</div>
96
+ {!readable && job.error ? <div className="text-xs text-red-600">{job.error.message}</div> : null}
90
97
  </td>
91
98
  <td className="px-4 py-4 text-sm text-gray-700">{job.pipelineLabel ?? job.pipeline ?? pipeline?.name ?? "—"}</td>
92
99
  <td className="px-4 py-4">
93
- <Badge intent={getIntent(job.status)}>{job.status}</Badge>
100
+ {readable ? (
101
+ <Badge intent={getIntent(job.status)}>{job.status}</Badge>
102
+ ) : (
103
+ <Badge intent="red">Unreadable</Badge>
104
+ )}
94
105
  </td>
95
106
  <td className="px-4 py-4 text-sm text-gray-700">{currentTask?.name ?? currentTaskId ?? "—"}</td>
96
107
  <td className="px-4 py-4">
97
- <div className="flex min-w-[160px] items-center gap-3">
98
- <Progress value={job.progress} variant={job.status === "failed" ? "error" : job.status === "complete" ? "completed" : job.status === "running" ? "running" : "pending"} className="flex-1" />
99
- <span className="text-sm text-gray-600">{job.progress}%</span>
100
- </div>
108
+ {readable ? (
109
+ <div className="flex min-w-[160px] items-center gap-3">
110
+ <Progress value={job.progress} variant={job.status === "failed" ? "error" : job.status === "complete" ? "completed" : job.status === "running" ? "running" : "pending"} className="flex-1" />
111
+ <span className="text-sm text-gray-600">{job.progress}%</span>
112
+ </div>
113
+ ) : (
114
+ "—"
115
+ )}
101
116
  </td>
102
117
  <td className="px-4 py-4 text-sm text-gray-700">{countCompleted(job)} of {job.taskCount}</td>
103
118
  <td className="px-4 py-4">
104
- <div className="text-sm text-gray-700">{totalCost > 0 ? formatCurrency4(totalCost) : "—"}</div>
105
- {totalTokens > 0 ? <div className="text-xs text-gray-500">{formatTokensCompact(totalTokens)}</div> : null}
119
+ {readable ? (
120
+ <>
121
+ <div className="text-sm text-gray-700">
122
+ {totalCost > 0 ? (
123
+ <>
124
+ {hasEstimated ? <abbr title="Estimated cost" className="mr-0.5">≈</abbr> : null}
125
+ {formatCurrency4(totalCost)}
126
+ </>
127
+ ) : "—"}
128
+ </div>
129
+ {totalTokens > 0 ? <div className="text-xs text-gray-500">{formatTokensCompact(totalTokens)}</div> : null}
130
+ </>
131
+ ) : (
132
+ "—"
133
+ )}
106
134
  </td>
107
135
  <td className="px-4 py-4 text-sm text-gray-700">
108
- {startMs ? <TimerText startMs={startMs} endMs={endMs} granularity="second" /> : "—"}
136
+ {readable ? (startMs ? <TimerText startMs={startMs} endMs={endMs} granularity="second" /> : "—") : "—"}
109
137
  </td>
110
138
  </tr>
111
139
  );
@@ -1,29 +1,17 @@
1
1
  import { memo } from "react";
2
2
 
3
- import type { Stage } from "./types";
4
- import { Badge } from "./ui/Badge";
3
+ import type { AnalysisStage } from "./types";
5
4
 
6
- export const StageTimeline = memo(function StageTimeline({ stages }: { stages: Stage[] }) {
7
- const sortedStages = stages
8
- .filter((stage) => stage.name)
9
- .slice()
10
- .sort((a, b) => {
11
- const orderA = typeof a.order === "number" ? a.order : Number.MAX_SAFE_INTEGER;
12
- const orderB = typeof b.order === "number" ? b.order : Number.MAX_SAFE_INTEGER;
13
- return orderA - orderB;
14
- });
5
+ export const StageTimeline = memo(function StageTimeline({ stages }: { stages: AnalysisStage[] }) {
6
+ const namedStages = stages.filter((stage) => stage.name);
15
7
 
16
8
  return (
17
9
  <ol role="list" aria-label="Task execution stages" className="relative ml-1 space-y-4 border-l border-gray-300">
18
- {sortedStages.map((stage) => (
19
- <li key={stage.name} className="relative flex items-center gap-3 pl-4">
20
- <div className="absolute left-[-5px] h-2 w-2 rounded-full bg-[#6d28d9]" />
21
- <span className="text-sm">{stage.name}</span>
22
- {stage.isAsync ? (
23
- <Badge intent="amber" className="ml-auto">
24
- async
25
- </Badge>
26
- ) : null}
10
+ {namedStages.map((stage) => (
11
+ <li key={stage.name} className="relative flex flex-col gap-1 pl-4">
12
+ <div className="absolute left-[-5px] top-1.5 h-2 w-2 rounded-full bg-[#6d28d9]" />
13
+ <span className="text-sm font-medium">{stage.name}</span>
14
+ {stage.purpose ? <span className="text-sm text-gray-600">{stage.purpose}</span> : null}
27
15
  </li>
28
16
  ))}
29
17
  </ol>
@@ -45,11 +45,47 @@ export const TaskAnalysisDisplay = memo(function TaskAnalysisDisplay({
45
45
  };
46
46
 
47
47
  if (loading) return <div className="p-4 text-sm text-gray-500">Loading analysis…</div>;
48
- if (error) return <div className="p-4 text-sm text-red-700">{error}</div>;
48
+ if (error) return <div className="p-4 text-sm text-red-700" role="alert">{error}</div>;
49
49
  if (analysis === null) return <div className="p-4 text-sm text-gray-500">No analysis available.</div>;
50
50
 
51
+ if (analysis.analysisStatus === "unconfigured") {
52
+ return (
53
+ <div className="space-y-3 p-6 text-sm" role="status">
54
+ <h3 className="text-base font-semibold">Analysis not configured</h3>
55
+ <p className="text-gray-600">
56
+ No analysis model is configured. Set an internal analysis model to generate a reference for this
57
+ task.
58
+ </p>
59
+ {analysis.analysisError ? (
60
+ <p className="text-xs text-gray-500">{analysis.analysisError}</p>
61
+ ) : null}
62
+ <Button size="sm" variant="outline">
63
+ Configure model
64
+ </Button>
65
+ </div>
66
+ );
67
+ }
68
+
69
+ if (analysis.analysisStatus === "error") {
70
+ return (
71
+ <div className="space-y-2 p-6 text-sm" role="alert">
72
+ <h3 className="text-base font-semibold text-red-700">Analysis failed</h3>
73
+ <p className="text-gray-600">The analysis could not be generated. Re-run analysis to try again.</p>
74
+ {analysis.analysisError ? (
75
+ <p className="text-xs text-gray-500">{analysis.analysisError}</p>
76
+ ) : null}
77
+ </div>
78
+ );
79
+ }
80
+
51
81
  return (
52
82
  <div className="space-y-6 p-6">
83
+ {analysis.summary ? (
84
+ <section className="space-y-2">
85
+ <h3 className="text-base font-semibold">Summary</h3>
86
+ <p className="text-sm text-gray-700">{analysis.summary}</p>
87
+ </section>
88
+ ) : null}
53
89
  <section className="space-y-3">
54
90
  <h3 className="text-base font-semibold">Artifacts</h3>
55
91
  <div className="grid gap-4 md:grid-cols-2">
@@ -57,8 +93,11 @@ export const TaskAnalysisDisplay = memo(function TaskAnalysisDisplay({
57
93
  <p className="mb-2 text-sm font-medium">Reads</p>
58
94
  <ul className="space-y-2 text-sm">
59
95
  {analysis.artifacts.reads.map((artifact) => (
60
- <li key={`read-${artifact.fileName}`} className="flex items-center justify-between gap-3 rounded border p-2">
61
- <span>{artifact.fileName}</span>
96
+ <li key={`read-${artifact.fileName}`} className="flex items-start justify-between gap-3 rounded border p-2">
97
+ <span className="flex flex-col">
98
+ <span className="font-medium">{artifact.fileName}</span>
99
+ {artifact.role ? <span className="text-xs text-gray-600">{artifact.role}</span> : null}
100
+ </span>
62
101
  <Button size="sm" variant="outline" onClick={() => void openPreview(artifact.fileName, "read")}>
63
102
  Preview
64
103
  </Button>
@@ -70,8 +109,11 @@ export const TaskAnalysisDisplay = memo(function TaskAnalysisDisplay({
70
109
  <p className="mb-2 text-sm font-medium">Writes</p>
71
110
  <ul className="space-y-2 text-sm">
72
111
  {analysis.artifacts.writes.map((artifact) => (
73
- <li key={`write-${artifact.fileName}`} className="flex items-center justify-between gap-3 rounded border p-2">
74
- <span>{artifact.fileName}</span>
112
+ <li key={`write-${artifact.fileName}`} className="flex items-start justify-between gap-3 rounded border p-2">
113
+ <span className="flex flex-col">
114
+ <span className="font-medium">{artifact.fileName}</span>
115
+ {artifact.role ? <span className="text-xs text-gray-600">{artifact.role}</span> : null}
116
+ </span>
75
117
  <Button size="sm" variant="outline" onClick={() => void openPreview(artifact.fileName, "write")}>
76
118
  Preview
77
119
  </Button>
@@ -95,7 +137,7 @@ export const TaskAnalysisDisplay = memo(function TaskAnalysisDisplay({
95
137
  ))}
96
138
  </ul>
97
139
  </section>
98
- <p className="text-xs text-gray-500">Analyzed at {analysis.analyzedAt}</p>
140
+ <p className="text-xs text-gray-500">AI-generated, approximate · Analyzed at {analysis.analyzedAt}</p>
99
141
  {preview !== null ? (
100
142
  <SchemaPreviewPanel
101
143
  fileName={preview.fileName}
@@ -246,3 +246,39 @@ test("DAGGrid renders gate-adjacent waiting nodes with a visible waiting label",
246
246
  expect(view.getByText("Waiting")).toBeTruthy();
247
247
  expect(view.container.querySelector('[data-status="waiting"]')).not.toBeNull();
248
248
  });
249
+
250
+ test("DAGGrid renders estimated marker when costEstimated is true", () => {
251
+ installMatchMedia();
252
+
253
+ const view = render(
254
+ <DAGGrid
255
+ items={[makeItem({ id: "build", title: "Build", status: "done", subtitle: "gpt-4 · 150 tok · $0.0012", costEstimated: true })]}
256
+ jobId="job-1"
257
+ taskById={{ build: makeTaskState({ name: "build", state: "done" }) }}
258
+ pipelineTasks={["build"]}
259
+ filesByTypeForItem={() => emptyFiles}
260
+ geometryAdapter={geometryAdapter}
261
+ />,
262
+ );
263
+
264
+ const marker = view.container.querySelector('abbr[title="Estimated cost"]');
265
+ expect(marker).not.toBeNull();
266
+ expect(marker?.textContent).toBe("≈");
267
+ });
268
+
269
+ test("DAGGrid omits estimated marker when costEstimated is absent", () => {
270
+ installMatchMedia();
271
+
272
+ const view = render(
273
+ <DAGGrid
274
+ items={[makeItem({ id: "build", title: "Build", status: "done", subtitle: "gpt-4 · 150 tok · $0.0012" })]}
275
+ jobId="job-1"
276
+ taskById={{ build: makeTaskState({ name: "build", state: "done" }) }}
277
+ pipelineTasks={["build"]}
278
+ filesByTypeForItem={() => emptyFiles}
279
+ geometryAdapter={geometryAdapter}
280
+ />,
281
+ );
282
+
283
+ expect(view.container.querySelector('abbr[title="Estimated cost"]')).toBeNull();
284
+ });
@@ -137,3 +137,115 @@ test("JobDetail cancels gate rejection when the prompt is cancelled", () => {
137
137
  window.prompt = originalPrompt;
138
138
  }
139
139
  });
140
+
141
+ test("JobDetail flags estimated task cost on the DAG item", () => {
142
+ const job: JobDetailType = {
143
+ ...makeJob(0),
144
+ costs: {
145
+ build: {
146
+ inputTokens: 100,
147
+ outputTokens: 50,
148
+ inputCost: 0.0006,
149
+ outputCost: 0.0006,
150
+ totalCost: 0.0012,
151
+ source: "estimated",
152
+ },
153
+ },
154
+ };
155
+
156
+ render(<JobDetail job={job} pipeline={pipeline} />);
157
+
158
+ const lastItems = capturedItems[capturedItems.length - 1] as DagItem[];
159
+ const buildItem = lastItems.find((item) => item.id === "build");
160
+ expect(buildItem?.costEstimated).toBe(true);
161
+ });
162
+
163
+ test("JobDetail flags unavailable task cost on the DAG item", () => {
164
+ const job: JobDetailType = {
165
+ ...makeJob(0),
166
+ costs: {
167
+ build: {
168
+ inputTokens: 100,
169
+ outputTokens: 50,
170
+ inputCost: 0.0006,
171
+ outputCost: 0.0006,
172
+ totalCost: 0.0012,
173
+ source: "unavailable",
174
+ },
175
+ },
176
+ };
177
+
178
+ render(<JobDetail job={job} pipeline={pipeline} />);
179
+
180
+ const lastItems = capturedItems[capturedItems.length - 1] as DagItem[];
181
+ const buildItem = lastItems.find((item) => item.id === "build");
182
+ expect(buildItem?.costEstimated).toBe(true);
183
+ });
184
+
185
+ test("JobDetail does not flag reported failed task cost as estimated", () => {
186
+ const job: JobDetailType = {
187
+ ...makeJob(0),
188
+ costs: {
189
+ build: {
190
+ inputTokens: 100,
191
+ outputTokens: 0,
192
+ inputCost: 0.0006,
193
+ outputCost: 0,
194
+ totalCost: 0.0006,
195
+ source: "reported",
196
+ failed: true,
197
+ },
198
+ },
199
+ };
200
+
201
+ render(<JobDetail job={job} pipeline={pipeline} />);
202
+
203
+ const lastItems = capturedItems[capturedItems.length - 1] as DagItem[];
204
+ const buildItem = lastItems.find((item) => item.id === "build");
205
+ expect(buildItem?.costEstimated).toBeFalsy();
206
+ });
207
+
208
+ test("JobDetail flags reported task cost when costEstimated is explicit", () => {
209
+ const job: JobDetailType = {
210
+ ...makeJob(0),
211
+ costs: {
212
+ build: {
213
+ inputTokens: 100,
214
+ outputTokens: 50,
215
+ inputCost: 0,
216
+ outputCost: 0,
217
+ totalCost: 0,
218
+ source: "reported",
219
+ costEstimated: true,
220
+ },
221
+ },
222
+ };
223
+
224
+ render(<JobDetail job={job} pipeline={pipeline} />);
225
+
226
+ const lastItems = capturedItems[capturedItems.length - 1] as DagItem[];
227
+ const buildItem = lastItems.find((item) => item.id === "build");
228
+ expect(buildItem?.costEstimated).toBe(true);
229
+ });
230
+
231
+ test("JobDetail leaves costEstimated unset for reported task cost", () => {
232
+ const job: JobDetailType = {
233
+ ...makeJob(0),
234
+ costs: {
235
+ build: {
236
+ inputTokens: 100,
237
+ outputTokens: 50,
238
+ inputCost: 0.0006,
239
+ outputCost: 0.0006,
240
+ totalCost: 0.0012,
241
+ source: "reported",
242
+ },
243
+ },
244
+ };
245
+
246
+ render(<JobDetail job={job} pipeline={pipeline} />);
247
+
248
+ const lastItems = capturedItems[capturedItems.length - 1] as DagItem[];
249
+ const buildItem = lastItems.find((item) => item.id === "build");
250
+ expect(buildItem?.costEstimated).toBeFalsy();
251
+ });
@@ -1,6 +1,6 @@
1
1
  import "./test-dom";
2
2
 
3
- import { render } from "@testing-library/react";
3
+ import { fireEvent, render } from "@testing-library/react";
4
4
  import { MemoryRouter } from "react-router-dom";
5
5
  import { afterEach, expect, mock, test } from "bun:test";
6
6
 
@@ -53,3 +53,139 @@ test("JobTable renders waiting status badge", () => {
53
53
 
54
54
  expect(view.getByText("waiting")).toBeTruthy();
55
55
  });
56
+
57
+ test("JobTable renders an unreadable job as a non-clickable row with badge and recovery text", () => {
58
+ const recoveryMessage =
59
+ 'Status file is unreadable (INVALID_JSON). Repair or delete tasks-status.json for job "job-bad" to recover.';
60
+ const onOpenJob = mock((_jobId: string) => {});
61
+ const view = render(
62
+ <MemoryRouter>
63
+ <JobTable
64
+ jobs={[
65
+ makeJob({
66
+ id: "job-bad",
67
+ jobId: "job-bad",
68
+ name: "Bad Job",
69
+ status: "error",
70
+ readable: false,
71
+ error: { code: "INVALID_JSON", message: recoveryMessage },
72
+ }),
73
+ ]}
74
+ onOpenJob={onOpenJob}
75
+ />
76
+ </MemoryRouter>,
77
+ );
78
+
79
+ const row = view.container.querySelector("tbody tr");
80
+ expect(row).toBeTruthy();
81
+ expect(row?.getAttribute("tabindex")).toBe("-1");
82
+ expect(row?.className).toContain("cursor-not-allowed");
83
+
84
+ expect(view.getByText("Unreadable")).toBeTruthy();
85
+ expect(view.getByText(recoveryMessage)).toBeTruthy();
86
+
87
+ fireEvent.click(row!);
88
+ fireEvent.keyDown(row!, { key: "Enter" });
89
+ expect(onOpenJob).not.toHaveBeenCalled();
90
+ });
91
+
92
+ test("JobTable renders a normal job without readable or error as a clickable row", () => {
93
+ const onOpenJob = mock((_jobId: string) => {});
94
+ const view = render(
95
+ <MemoryRouter>
96
+ <JobTable jobs={[makeJob()]} onOpenJob={onOpenJob} />
97
+ </MemoryRouter>,
98
+ );
99
+
100
+ const row = view.container.querySelector("tbody tr");
101
+ expect(row).toBeTruthy();
102
+ expect(row?.getAttribute("tabindex")).toBe("0");
103
+ expect(row?.className).toContain("cursor-pointer");
104
+
105
+ fireEvent.click(row!);
106
+ expect(onOpenJob).toHaveBeenCalledTimes(1);
107
+ expect(onOpenJob).toHaveBeenCalledWith("job-1");
108
+ });
109
+
110
+ test("JobTable marks estimated cost when costsSummary.hasEstimated is true", () => {
111
+ const view = render(
112
+ <MemoryRouter>
113
+ <JobTable
114
+ jobs={[
115
+ makeJob({
116
+ costsSummary: {
117
+ totalTokens: 10,
118
+ totalInputTokens: 5,
119
+ totalOutputTokens: 5,
120
+ totalCost: 0.0012,
121
+ totalInputCost: 0.0006,
122
+ totalOutputCost: 0.0006,
123
+ estimatedCost: 0.0012,
124
+ hasEstimated: true,
125
+ unavailableCost: 0,
126
+ },
127
+ }),
128
+ ]}
129
+ onOpenJob={() => undefined}
130
+ />
131
+ </MemoryRouter>,
132
+ );
133
+
134
+ const marker = view.container.querySelector('tbody abbr[title="Estimated cost"]');
135
+ expect(marker).not.toBeNull();
136
+ expect(marker?.textContent).toBe("≈");
137
+ });
138
+
139
+ test("JobTable displays reported, estimated, and unavailable cost buckets together", () => {
140
+ const view = render(
141
+ <MemoryRouter>
142
+ <JobTable
143
+ jobs={[
144
+ makeJob({
145
+ costsSummary: {
146
+ totalTokens: 10,
147
+ totalInputTokens: 5,
148
+ totalOutputTokens: 5,
149
+ totalCost: 0.001,
150
+ totalInputCost: 0.001,
151
+ totalOutputCost: 0,
152
+ estimatedCost: 0.002,
153
+ hasEstimated: true,
154
+ unavailableCost: 0.003,
155
+ },
156
+ }),
157
+ ]}
158
+ onOpenJob={() => undefined}
159
+ />
160
+ </MemoryRouter>,
161
+ );
162
+
163
+ expect(view.getByText("$0.006")).toBeTruthy();
164
+ });
165
+
166
+ test("JobTable omits estimated marker for a fully reported job", () => {
167
+ const view = render(
168
+ <MemoryRouter>
169
+ <JobTable
170
+ jobs={[
171
+ makeJob({
172
+ costsSummary: {
173
+ totalTokens: 10,
174
+ totalInputTokens: 5,
175
+ totalOutputTokens: 5,
176
+ totalCost: 0.0012,
177
+ totalInputCost: 0.0006,
178
+ totalOutputCost: 0.0006,
179
+ estimatedCost: 0,
180
+ hasEstimated: false,
181
+ unavailableCost: 0,
182
+ },
183
+ }),
184
+ ]}
185
+ onOpenJob={() => undefined}
186
+ />
187
+ </MemoryRouter>,
188
+ );
189
+
190
+ expect(view.container.querySelector('tbody abbr[title="Estimated cost"]')).toBeNull();
191
+ });