@ryanfw/prompt-orchestration-pipeline 1.3.3 → 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 (198) hide show
  1. package/docs/http-api.md +14 -4
  2. package/package.json +1 -4
  3. package/src/api/__tests__/index.test.ts +144 -41
  4. package/src/api/index.ts +15 -83
  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/paths.ts +11 -2
  17. package/src/config/pipeline-registry.ts +258 -0
  18. package/src/config/sse-events.ts +122 -0
  19. package/src/core/__tests__/agent-step.test.ts +115 -0
  20. package/src/core/__tests__/config.test.ts +517 -107
  21. package/src/core/__tests__/core-boot-resolution.test.ts +181 -0
  22. package/src/core/__tests__/job-concurrency.test.ts +200 -0
  23. package/src/core/__tests__/job-submission.test.ts +396 -0
  24. package/src/core/__tests__/job-view.test.ts +485 -3
  25. package/src/core/__tests__/json-file.test.ts +111 -0
  26. package/src/core/__tests__/lifecycle-policy.test.ts +81 -7
  27. package/src/core/__tests__/logger.test.ts +22 -0
  28. package/src/core/__tests__/orchestrator-no-deprecated-exports.test.ts +41 -0
  29. package/src/core/__tests__/orchestrator.test.ts +373 -2
  30. package/src/core/__tests__/pipeline-runner.test.ts +946 -9
  31. package/src/core/__tests__/redact.test.ts +153 -0
  32. package/src/core/__tests__/runner-liveness.test.ts +45 -0
  33. package/src/core/__tests__/seed-naming.test.ts +57 -0
  34. package/src/core/__tests__/single-derivation.test.ts +1 -1
  35. package/src/core/__tests__/task-runner.test.ts +594 -3
  36. package/src/core/__tests__/task-telemetry.test.ts +241 -0
  37. package/src/core/__tests__/workspace-root-resolution-guard.test.ts +62 -0
  38. package/src/core/agent-step.ts +4 -1
  39. package/src/core/agent-types.ts +5 -4
  40. package/src/core/config.ts +127 -234
  41. package/src/core/control.ts +3 -0
  42. package/src/core/job-concurrency.ts +50 -19
  43. package/src/core/job-submission.ts +133 -0
  44. package/src/core/job-view.ts +162 -18
  45. package/src/core/json-file.ts +45 -0
  46. package/src/core/lifecycle-policy.ts +23 -8
  47. package/src/core/logger.ts +0 -29
  48. package/src/core/orchestrator.ts +124 -70
  49. package/src/core/pipeline-runner.ts +85 -53
  50. package/src/core/redact.ts +40 -0
  51. package/src/core/runner-liveness.ts +8 -4
  52. package/src/core/seed-naming.ts +9 -0
  53. package/src/core/status-writer.ts +3 -28
  54. package/src/core/task-runner.ts +356 -319
  55. package/src/core/task-telemetry.ts +107 -0
  56. package/src/harness/__tests__/subprocess.test.ts +112 -1
  57. package/src/harness/subprocess.ts +55 -16
  58. package/src/llm/__tests__/index.test.ts +684 -33
  59. package/src/llm/index.ts +310 -67
  60. package/src/providers/__tests__/alibaba.test.ts +67 -6
  61. package/src/providers/__tests__/anthropic.test.ts +35 -14
  62. package/src/providers/__tests__/base.test.ts +62 -0
  63. package/src/providers/__tests__/claude-code.test.ts +99 -14
  64. package/src/providers/__tests__/deepseek.test.ts +16 -6
  65. package/src/providers/__tests__/gemini.test.ts +47 -25
  66. package/src/providers/__tests__/moonshot.test.ts +27 -0
  67. package/src/providers/__tests__/openai.test.ts +262 -74
  68. package/src/providers/__tests__/opencode.test.ts +77 -0
  69. package/src/providers/__tests__/request-timeout-contract.test.ts +226 -0
  70. package/src/providers/__tests__/stream-accumulator.test.ts +52 -0
  71. package/src/providers/__tests__/types.test.ts +85 -0
  72. package/src/providers/__tests__/zero-fill-guard.test.ts +62 -0
  73. package/src/providers/__tests__/zhipu.test.ts +27 -14
  74. package/src/providers/alibaba.ts +20 -39
  75. package/src/providers/anthropic.ts +23 -11
  76. package/src/providers/base.ts +19 -0
  77. package/src/providers/claude-code.ts +27 -18
  78. package/src/providers/deepseek.ts +9 -28
  79. package/src/providers/gemini.ts +20 -58
  80. package/src/providers/moonshot.ts +15 -11
  81. package/src/providers/openai.ts +79 -61
  82. package/src/providers/opencode.ts +16 -33
  83. package/src/providers/stream-accumulator.ts +27 -21
  84. package/src/providers/types.ts +29 -4
  85. package/src/providers/zhipu.ts +15 -44
  86. package/src/task-analysis/__tests__/analyzer.test.ts +0 -0
  87. package/src/task-analysis/__tests__/enrichers-schema-deducer.test.ts +34 -2
  88. package/src/task-analysis/__tests__/no-ast-imports.test.ts +52 -0
  89. package/src/task-analysis/__tests__/repository.test.ts +65 -0
  90. package/src/task-analysis/__tests__/types.test.ts +63 -130
  91. package/src/task-analysis/analyzer.ts +91 -0
  92. package/src/task-analysis/enrichers/schema-deducer.ts +13 -2
  93. package/src/task-analysis/index.ts +2 -36
  94. package/src/task-analysis/repository.ts +45 -0
  95. package/src/task-analysis/types.ts +42 -58
  96. package/src/ui/client/__tests__/api.test.ts +143 -1
  97. package/src/ui/client/__tests__/bootstrap.test.ts +178 -62
  98. package/src/ui/client/__tests__/job-adapter.test.ts +125 -2
  99. package/src/ui/client/__tests__/load-state.test.ts +70 -0
  100. package/src/ui/client/__tests__/types.test.ts +66 -3
  101. package/src/ui/client/__tests__/useJobDetailWithUpdates.test.ts +390 -77
  102. package/src/ui/client/__tests__/useJobList.test.ts +198 -23
  103. package/src/ui/client/__tests__/useJobListWithUpdates.test.ts +218 -7
  104. package/src/ui/client/adapters/__tests__/job-adapter.test.ts +186 -0
  105. package/src/ui/client/adapters/job-adapter.ts +31 -16
  106. package/src/ui/client/api.ts +38 -15
  107. package/src/ui/client/bootstrap.ts +19 -14
  108. package/src/ui/client/hooks/useJobDetailWithUpdates.ts +73 -97
  109. package/src/ui/client/hooks/useJobList.ts +26 -31
  110. package/src/ui/client/hooks/useJobListWithUpdates.ts +42 -76
  111. package/src/ui/client/load-state.ts +20 -0
  112. package/src/ui/client/reducers/__tests__/job-events.test.ts +568 -0
  113. package/src/ui/client/reducers/job-events.ts +137 -0
  114. package/src/ui/client/types.ts +14 -20
  115. package/src/ui/components/DAGGrid.tsx +6 -1
  116. package/src/ui/components/JobDetail.tsx +12 -4
  117. package/src/ui/components/JobTable.tsx +13 -2
  118. package/src/ui/components/StageTimeline.tsx +8 -20
  119. package/src/ui/components/TaskAnalysisDisplay.tsx +48 -6
  120. package/src/ui/components/__tests__/DAGGrid.test.tsx +36 -0
  121. package/src/ui/components/__tests__/JobDetail.test.tsx +112 -0
  122. package/src/ui/components/__tests__/JobTable.test.tsx +83 -0
  123. package/src/ui/components/__tests__/StageTimeline.test.tsx +5 -6
  124. package/src/ui/components/__tests__/TaskAnalysisDisplay.test.tsx +64 -0
  125. package/src/ui/components/types.ts +33 -15
  126. package/src/ui/dist/assets/{index-L6cvsCAx.js → index-DN3-zvtP.js} +4299 -251
  127. package/src/ui/dist/assets/index-DN3-zvtP.js.map +1 -0
  128. package/src/ui/dist/assets/style-CtZBnjlR.css +2 -0
  129. package/src/ui/dist/index.html +2 -2
  130. package/src/ui/pages/PipelineDetail.tsx +60 -4
  131. package/src/ui/pages/PromptPipelineDashboard.tsx +59 -7
  132. package/src/ui/pages/__tests__/PromptPipelineDashboard.test.tsx +114 -32
  133. package/src/ui/pages/__tests__/pages.test.tsx +236 -42
  134. package/src/ui/server/__tests__/concurrency-endpoint.test.ts +54 -0
  135. package/src/ui/server/__tests__/config-bridge-node.test.ts +34 -1
  136. package/src/ui/server/__tests__/embedded-assets.test.ts +66 -0
  137. package/src/ui/server/__tests__/file-endpoints.test.ts +183 -5
  138. package/src/ui/server/__tests__/gate-endpoints.test.ts +55 -0
  139. package/src/ui/server/__tests__/index.test.ts +63 -0
  140. package/src/ui/server/__tests__/job-control-endpoints.test.ts +455 -1
  141. package/src/ui/server/__tests__/job-endpoints.test.ts +43 -1
  142. package/src/ui/server/__tests__/read-static-path-guard.test.ts +94 -0
  143. package/src/ui/server/__tests__/router-root-isolation.test.ts +204 -0
  144. package/src/ui/server/__tests__/router-threading.test.ts +148 -0
  145. package/src/ui/server/__tests__/router.test.ts +104 -0
  146. package/src/ui/server/__tests__/sse-broadcast.test.ts +44 -0
  147. package/src/ui/server/__tests__/sse-enhancer.test.ts +39 -0
  148. package/src/ui/server/config-bridge-node.ts +8 -26
  149. package/src/ui/server/embedded-assets-imports.d.ts +44 -0
  150. package/src/ui/server/embedded-assets.ts +13 -2
  151. package/src/ui/server/endpoints/__tests__/pipeline-analysis-endpoint.test.ts +167 -0
  152. package/src/ui/server/endpoints/__tests__/schema-file-endpoint.test.ts +104 -0
  153. package/src/ui/server/endpoints/__tests__/task-analysis-endpoint.test.ts +103 -0
  154. package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +162 -96
  155. package/src/ui/server/endpoints/concurrency-endpoint.ts +2 -1
  156. package/src/ui/server/endpoints/create-pipeline-endpoint.ts +14 -29
  157. package/src/ui/server/endpoints/file-endpoints.ts +15 -10
  158. package/src/ui/server/endpoints/job-control-endpoints.ts +122 -73
  159. package/src/ui/server/endpoints/job-endpoints.ts +1 -0
  160. package/src/ui/server/endpoints/pipeline-analysis-endpoint.ts +99 -11
  161. package/src/ui/server/endpoints/schema-file-endpoint.ts +11 -3
  162. package/src/ui/server/endpoints/task-analysis-endpoint.ts +21 -5
  163. package/src/ui/server/endpoints/task-save-endpoint.ts +1 -2
  164. package/src/ui/server/endpoints/upload-endpoints.ts +5 -40
  165. package/src/ui/server/index.ts +19 -14
  166. package/src/ui/server/job-reader.ts +14 -2
  167. package/src/ui/server/router.ts +33 -10
  168. package/src/ui/server/sse-broadcast.ts +14 -3
  169. package/src/ui/server/sse-enhancer.ts +12 -2
  170. package/src/ui/state/__tests__/schema-loader.test.ts +11 -1
  171. package/src/ui/state/__tests__/snapshot.test.ts +70 -15
  172. package/src/ui/state/__tests__/types.test.ts +6 -0
  173. package/src/ui/state/snapshot.ts +1 -1
  174. package/src/ui/state/transformers/__tests__/list-transformer.test.ts +42 -0
  175. package/src/ui/state/transformers/__tests__/status-transformer.test.ts +45 -3
  176. package/src/ui/state/transformers/list-transformer.ts +6 -0
  177. package/src/ui/state/types.ts +6 -1
  178. package/src/utils/__tests__/path-containment.test.ts +160 -0
  179. package/src/{ui/server/utils → utils}/path-containment.ts +21 -0
  180. package/src/task-analysis/__tests__/enrichers-analysis-writer.test.ts +0 -86
  181. package/src/task-analysis/__tests__/enrichers-artifact-resolver.test.ts +0 -124
  182. package/src/task-analysis/__tests__/extractors-artifacts.test.ts +0 -133
  183. package/src/task-analysis/__tests__/extractors-llm-calls.test.ts +0 -46
  184. package/src/task-analysis/__tests__/extractors-stages.test.ts +0 -52
  185. package/src/task-analysis/__tests__/index.test.ts +0 -143
  186. package/src/task-analysis/__tests__/parser.test.ts +0 -41
  187. package/src/task-analysis/__tests__/utils-ast.test.ts +0 -82
  188. package/src/task-analysis/enrichers/analysis-writer.ts +0 -75
  189. package/src/task-analysis/enrichers/artifact-resolver.ts +0 -89
  190. package/src/task-analysis/extractors/artifacts.ts +0 -143
  191. package/src/task-analysis/extractors/llm-calls.ts +0 -117
  192. package/src/task-analysis/extractors/stages.ts +0 -45
  193. package/src/task-analysis/parser.ts +0 -20
  194. package/src/task-analysis/utils/ast.ts +0 -45
  195. package/src/ui/dist/assets/index-L6cvsCAx.js.map +0 -1
  196. package/src/ui/dist/assets/style-CSSKuMOe.css +0 -2
  197. package/src/ui/embedded-assets.js +0 -12
  198. 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 {
@@ -165,6 +160,9 @@ export interface TaskCostBreakdown {
165
160
  inputCost: number;
166
161
  outputCost: number;
167
162
  totalCost: number;
163
+ source?: UsageSource;
164
+ failed?: boolean;
165
+ costEstimated?: boolean;
168
166
  }
169
167
 
170
168
  export interface NormalizedJobDetail extends NormalizedJobSummary {
@@ -185,11 +183,12 @@ export interface UseJobListWithUpdatesResult extends UseJobListResult {
185
183
  export interface UseJobDetailWithUpdatesResult {
186
184
  data: NormalizedJobDetail | null;
187
185
  loading: boolean;
188
- error: string | null;
186
+ error: ApiError | null;
189
187
  connectionStatus: ConnectionStatus;
190
188
  isRefreshing: boolean;
191
189
  isTransitioning: boolean;
192
190
  isHydrated: boolean;
191
+ refetch: () => void;
193
192
  }
194
193
 
195
194
  export type AnalysisStatus = "idle" | "connecting" | "running" | "complete" | "error";
@@ -235,7 +234,7 @@ export interface JobConcurrencyApiStatus {
235
234
  }>;
236
235
  staleSlots: Array<{
237
236
  jobId: string;
238
- reason: "missing_current_job" | "missing_pid" | "dead_pid" | "invalid_json";
237
+ reason: "missing_current_job" | "missing_pid" | "dead_pid" | "invalid_json" | "stale_runner";
239
238
  }>;
240
239
  }
241
240
 
@@ -246,11 +245,6 @@ export interface UseConcurrencyStatusResult {
246
245
  refetch: () => void;
247
246
  }
248
247
 
249
- export interface SseJobEvent {
250
- type: SseEventType;
251
- data: Record<string, unknown>;
252
- }
253
-
254
248
  export type AnalysisSseEventType =
255
249
  | "started"
256
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
  }
@@ -71,8 +71,12 @@ export default function JobTable({
71
71
  const currentTaskId =
72
72
  typeof job.current === "string" ? job.current : job.current?.taskName;
73
73
  const currentTask = currentTaskId ? taskMap[currentTaskId] : undefined;
74
- 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);
75
78
  const totalTokens = job.totalTokens ?? job.costsSummary?.totalTokens ?? 0;
79
+ const hasEstimated = job.costsSummary?.hasEstimated === true;
76
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;
77
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;
78
82
 
@@ -114,7 +118,14 @@ export default function JobTable({
114
118
  <td className="px-4 py-4">
115
119
  {readable ? (
116
120
  <>
117
- <div className="text-sm text-gray-700">{totalCost > 0 ? formatCurrency4(totalCost) : "—"}</div>
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>
118
129
  {totalTokens > 0 ? <div className="text-xs text-gray-500">{formatTokensCompact(totalTokens)}</div> : null}
119
130
  </>
120
131
  ) : (
@@ -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
+ });
@@ -106,3 +106,86 @@ test("JobTable renders a normal job without readable or error as a clickable row
106
106
  expect(onOpenJob).toHaveBeenCalledTimes(1);
107
107
  expect(onOpenJob).toHaveBeenCalledWith("job-1");
108
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
+ });
@@ -11,20 +11,19 @@ afterEach(() => {
11
11
  document.body.innerHTML = "";
12
12
  });
13
13
 
14
- test("StageTimeline sorts by order and puts unordered stages last", () => {
14
+ test("StageTimeline renders each stage name and purpose in order", () => {
15
15
  const view = render(
16
16
  <StageTimeline
17
17
  stages={[
18
- { name: "B", order: 2 },
19
- { name: "A", order: 1, isAsync: true },
20
- { name: "C" },
18
+ { name: "ingestion", purpose: "Load raw input" },
19
+ { name: "inference", purpose: "Run the model" },
21
20
  ]}
22
21
  />,
23
22
  );
24
23
 
25
24
  const items = Array.from(view.container.querySelectorAll("li")).map((node) => node.textContent?.trim());
26
- expect(items).toEqual(["Aasync", "B", "C"]);
27
- expect(view.getByText("async")).toBeTruthy();
25
+ expect(items).toEqual(["ingestionLoad raw input", "inferenceRun the model"]);
26
+ expect(view.getByText("Load raw input")).toBeTruthy();
28
27
  });
29
28
 
30
29
  test("PageSubheader renders breadcrumbs", () => {
@@ -0,0 +1,64 @@
1
+ import "./test-dom";
2
+
3
+ import { render } from "@testing-library/react";
4
+ import { afterEach, expect, test } from "bun:test";
5
+
6
+ import { TaskAnalysisDisplay } from "../TaskAnalysisDisplay";
7
+ import type { TaskAnalysis } from "../types";
8
+
9
+ afterEach(() => {
10
+ document.body.innerHTML = "";
11
+ });
12
+
13
+ function makeAnalysis(overrides: Partial<TaskAnalysis> = {}): TaskAnalysis {
14
+ return {
15
+ summary: "Summarizes the input document.",
16
+ stages: [{ name: "ingestion", purpose: "Load raw input" }],
17
+ artifacts: {
18
+ reads: [{ fileName: "input.json", role: "source data" }],
19
+ writes: [{ fileName: "output.json", role: "result data" }],
20
+ },
21
+ models: [{ provider: "deepseek", method: "chat", stage: "ingestion" }],
22
+ analyzedAt: "2026-06-22T00:00:00.000Z",
23
+ analysisStatus: "ok",
24
+ ...overrides,
25
+ };
26
+ }
27
+
28
+ test("ok renders summary, stage purpose, artifacts, models, and the AI-generated label", () => {
29
+ const view = render(
30
+ <TaskAnalysisDisplay analysis={makeAnalysis()} loading={false} error={null} pipelineSlug="demo" />,
31
+ );
32
+
33
+ expect(view.getByText("Summarizes the input document.")).toBeTruthy();
34
+ expect(view.getByText("Load raw input")).toBeTruthy();
35
+ expect(view.getByText("input.json")).toBeTruthy();
36
+ expect(view.getByText("source data")).toBeTruthy();
37
+ expect(view.getByText("output.json")).toBeTruthy();
38
+ expect(view.getByText("result data")).toBeTruthy();
39
+ expect(view.getByText("deepseek · chat · ingestion")).toBeTruthy();
40
+ expect(view.container.textContent).toContain("AI-generated, approximate");
41
+ });
42
+
43
+ test("unconfigured renders the configure message and CTA", () => {
44
+ const analysis = makeAnalysis({ analysisStatus: "unconfigured" });
45
+ const view = render(
46
+ <TaskAnalysisDisplay analysis={analysis} loading={false} error={null} pipelineSlug="demo" />,
47
+ );
48
+
49
+ expect(view.container.textContent).toContain("No analysis model is configured");
50
+ expect(view.getByRole("button", { name: "Configure model" })).toBeTruthy();
51
+ });
52
+
53
+ test("error renders an error note with the analysis error detail", () => {
54
+ const analysis = makeAnalysis({
55
+ analysisStatus: "error",
56
+ analysisError: "validation failed for field summary",
57
+ });
58
+ const view = render(
59
+ <TaskAnalysisDisplay analysis={analysis} loading={false} error={null} pipelineSlug="demo" />,
60
+ );
61
+
62
+ expect(view.getByText("Analysis failed")).toBeTruthy();
63
+ expect(view.getByText("validation failed for field summary")).toBeTruthy();
64
+ });