@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
@@ -11,8 +11,8 @@
11
11
  />
12
12
  <title>Prompt Pipeline Dashboard</title>
13
13
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
14
- <script type="module" crossorigin src="/assets/index--RH3sAt3.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/style-CSSKuMOe.css">
14
+ <script type="module" crossorigin src="/assets/index-DN3-zvtP.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/style-CtZBnjlR.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
@@ -13,6 +13,20 @@ import { statusBadge } from "../../utils/ui";
13
13
  import { formatCurrency4, formatTokensCompact } from "../../utils/formatters";
14
14
  import type { JobDetail as JobDetailType } from "../components/types";
15
15
  import { HintBanner } from "../components/onboarding";
16
+ import type { ApiError } from "../client/types";
17
+
18
+ function humanErrorMessage(error: ApiError): { title: string; detail: string } {
19
+ if (error.code === "malformed_response") {
20
+ return {
21
+ title: "Couldn't load job",
22
+ detail: "Server returned an unexpected response. Try again in a moment.",
23
+ };
24
+ }
25
+ return {
26
+ title: "Couldn't load job",
27
+ detail: error.message,
28
+ };
29
+ }
16
30
 
17
31
  export default function PipelineDetail() {
18
32
  const { jobId } = useParams<{ jobId: string }>();
@@ -24,15 +38,19 @@ export default function PipelineDetail() {
24
38
  return <Layout pageTitle="Pipeline Details"><div className="rounded-sm border-l-[3px] border-l-red-600 bg-red-100 p-4 text-red-700">No job ID provided</div></Layout>;
25
39
  }
26
40
 
27
- const { data: job, loading, error } = useJobDetailWithUpdates(jobId);
41
+ const { data: job, loading, error, refetch } = useJobDetailWithUpdates(jobId);
28
42
  const breadcrumbs = [
29
43
  { label: "Home", href: "/" },
30
44
  { label: job?.pipelineLabel ?? "Pipeline Details" },
31
45
  ...(job?.name ? [{ label: job.name }] : []),
32
46
  ];
33
47
 
34
- const totalCost = job?.totalCost ?? job?.costsSummary?.totalCost ?? 0;
48
+ const totalCost =
49
+ (job?.totalCost ?? job?.costsSummary?.totalCost ?? 0) +
50
+ (job?.costsSummary?.estimatedCost ?? 0) +
51
+ (job?.costsSummary?.unavailableCost ?? 0);
35
52
  const totalTokens = job?.totalTokens ?? job?.costsSummary?.totalTokens ?? 0;
53
+ const hasEstimated = job?.costsSummary?.hasEstimated === true;
36
54
 
37
55
  return (
38
56
  <Layout
@@ -46,7 +64,8 @@ export default function PipelineDetail() {
46
64
  <Tooltip.Root>
47
65
  <Tooltip.Trigger asChild>
48
66
  <button type="button" className="cursor-help border-b border-dotted border-gray-400 text-sm text-gray-500">
49
- Cost: {formatCurrency4(totalCost)}
67
+ Cost: {hasEstimated ? <abbr title="Estimated cost" className="mr-0.5">≈</abbr> : null}
68
+ {formatCurrency4(totalCost)}
50
69
  </button>
51
70
  </Tooltip.Trigger>
52
71
  <Tooltip.Portal>
@@ -73,7 +92,44 @@ export default function PipelineDetail() {
73
92
  >
74
93
  <HintBanner storageKey="pipeline-detail-hint" title="Steps run left to right." variant="info">Each step waits for its upstream dependencies to finish. Click any step card to see its output.</HintBanner>
75
94
  {loading && !job ? <div className="rounded-md border border-gray-200 bg-white p-10 text-center text-gray-500">Loading job details...</div> : null}
76
- {error ? <div className="rounded-sm border-l-[3px] border-l-red-600 bg-red-100 p-4 text-red-700">{error}</div> : null}
95
+ {error && !job ? (
96
+ <section
97
+ role="alert"
98
+ aria-labelledby="pipeline-detail-error-title"
99
+ data-testid="pipeline-detail-error"
100
+ className="mb-4 rounded-sm border-l-[3px] border-l-red-600 bg-red-50 p-4 text-sm text-red-800"
101
+ >
102
+ <h2 id="pipeline-detail-error-title" className="mb-1 font-semibold text-red-900">
103
+ {humanErrorMessage(error).title}
104
+ </h2>
105
+ <p className="mb-3 text-red-800">{humanErrorMessage(error).detail}</p>
106
+ <button
107
+ type="button"
108
+ onClick={() => refetch()}
109
+ aria-label="Retry loading job"
110
+ className="inline-flex items-center rounded-md border border-red-300 bg-white px-3 py-1.5 text-sm font-medium text-red-700 hover:bg-red-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-600 focus-visible:ring-offset-2"
111
+ >
112
+ Retry loading job
113
+ </button>
114
+ </section>
115
+ ) : null}
116
+ {error && job ? (
117
+ <div
118
+ role="status"
119
+ data-testid="pipeline-detail-stale"
120
+ className="mb-4 flex flex-wrap items-center gap-3 rounded-sm border-l-[3px] border-l-yellow-600 bg-yellow-50 p-3 text-sm text-yellow-800"
121
+ >
122
+ <span>Showing last known job state. {humanErrorMessage(error).detail}</span>
123
+ <button
124
+ type="button"
125
+ onClick={() => refetch()}
126
+ aria-label="Retry loading job"
127
+ className="inline-flex items-center rounded-md border border-yellow-300 bg-white px-3 py-1.5 text-sm font-medium text-yellow-700 hover:bg-yellow-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-yellow-600 focus-visible:ring-offset-2"
128
+ >
129
+ Retry loading job
130
+ </button>
131
+ </div>
132
+ ) : null}
77
133
  {!loading && !error && !job ? <div className="rounded-xl border border-gray-200 bg-white p-4 text-gray-700">Job not found</div> : null}
78
134
  {job ? <JobDetail job={job as unknown as JobDetailType} pipeline={{ name: job.pipelineLabel ?? job.pipeline ?? "", slug: job.pipeline ?? "", description: "", tasks: (
79
135
  Array.isArray(job.pipelineConfig?.tasks)
@@ -8,7 +8,7 @@ import Layout from "../components/Layout";
8
8
  import type { JobSummary } from "../components/types";
9
9
  import { Progress } from "../components/ui/Progress";
10
10
  import { HintBanner } from "../components/onboarding";
11
- import type { JobConcurrencyApiStatus } from "../client/types";
11
+ import type { ApiError, JobConcurrencyApiStatus } from "../client/types";
12
12
 
13
13
  type TabKey = "current" | "errors" | "complete" | "concurrency";
14
14
 
@@ -17,6 +17,7 @@ const STALE_REASON_LABELS: Record<JobConcurrencyApiStatus["staleSlots"][number][
17
17
  missing_pid: "Lease missing PID past timeout",
18
18
  dead_pid: "Process no longer running",
19
19
  invalid_json: "Lease file is not valid JSON",
20
+ stale_runner: "Live PID no longer matches runner file",
20
21
  };
21
22
 
22
23
  function formatTimestamp(value: string | null | undefined): string {
@@ -26,6 +27,19 @@ function formatTimestamp(value: string | null | undefined): string {
26
27
  return new Date(ms).toLocaleString();
27
28
  }
28
29
 
30
+ function humanErrorMessage(error: ApiError): { title: string; detail: string } {
31
+ if (error.code === "malformed_response") {
32
+ return {
33
+ title: "Couldn't load jobs",
34
+ detail: "Server returned an unexpected response. Try again in a moment.",
35
+ };
36
+ }
37
+ return {
38
+ title: "Couldn't load jobs",
39
+ detail: error.message,
40
+ };
41
+ }
42
+
29
43
  function CapacityMetrics({ status }: { status: JobConcurrencyApiStatus }) {
30
44
  const cells: Array<{ label: string; value: number }> = [
31
45
  { label: "Limit", value: status.limit },
@@ -178,11 +192,10 @@ export default function PromptPipelineDashboard() {
178
192
  const { data: apiJobs, error } = hookResult;
179
193
  const [activeTab, setActiveTab] = useState<TabKey>("current");
180
194
 
181
- const jobs = useMemo<JobSummary[]>(() => {
182
- const source = Array.isArray(apiJobs) ? apiJobs : [];
183
- if (error) return [];
184
- return source as unknown as JobSummary[];
185
- }, [apiJobs, error]);
195
+ const jobs = useMemo<JobSummary[]>(
196
+ () => (Array.isArray(apiJobs) ? (apiJobs as unknown as JobSummary[]) : []),
197
+ [apiJobs],
198
+ );
186
199
 
187
200
  const grouped = useMemo(
188
201
  () => ({
@@ -197,6 +210,8 @@ export default function PromptPipelineDashboard() {
197
210
  const aggregateProgress =
198
211
  runningJobs.length === 0 ? 0 : Math.round(runningJobs.reduce((sum, job) => sum + job.progress, 0) / runningJobs.length);
199
212
 
213
+ const errorMessage = error ? humanErrorMessage(error) : null;
214
+
200
215
  const tabLabels: Record<TabKey, string> = {
201
216
  current: "Current",
202
217
  errors: "Errors",
@@ -228,7 +243,44 @@ export default function PromptPipelineDashboard() {
228
243
  ) : null
229
244
  }
230
245
  >
231
- {error ? <div className="mb-4 rounded-sm border-l-[3px] border-l-yellow-600 bg-yellow-100 p-3 text-sm text-yellow-700">Unable to load jobs from the server</div> : null}
246
+ {errorMessage && apiJobs === null ? (
247
+ <section
248
+ role="alert"
249
+ aria-labelledby="dashboard-error-title"
250
+ data-testid="dashboard-error"
251
+ className="mb-4 rounded-sm border-l-[3px] border-l-red-600 bg-red-50 p-4 text-sm text-red-800"
252
+ >
253
+ <h2 id="dashboard-error-title" className="mb-1 font-semibold text-red-900">
254
+ {errorMessage.title}
255
+ </h2>
256
+ <p className="mb-3 text-red-800">{errorMessage.detail}</p>
257
+ <button
258
+ type="button"
259
+ onClick={() => hookResult.refetch()}
260
+ aria-label="Retry loading jobs"
261
+ className="inline-flex items-center rounded-md border border-red-300 bg-white px-3 py-1.5 text-sm font-medium text-red-700 hover:bg-red-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-600 focus-visible:ring-offset-2"
262
+ >
263
+ Retry loading jobs
264
+ </button>
265
+ </section>
266
+ ) : null}
267
+ {errorMessage && apiJobs !== null ? (
268
+ <div
269
+ role="status"
270
+ data-testid="dashboard-stale"
271
+ className="mb-4 flex flex-wrap items-center gap-3 rounded-sm border-l-[3px] border-l-yellow-600 bg-yellow-50 p-3 text-sm text-yellow-800"
272
+ >
273
+ <span>Showing last known jobs. {errorMessage.detail}</span>
274
+ <button
275
+ type="button"
276
+ onClick={() => hookResult.refetch()}
277
+ aria-label="Retry loading jobs"
278
+ className="inline-flex items-center rounded-md border border-yellow-300 bg-white px-3 py-1.5 text-sm font-medium text-yellow-700 hover:bg-yellow-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-yellow-600 focus-visible:ring-offset-2"
279
+ >
280
+ Retry loading jobs
281
+ </button>
282
+ </div>
283
+ ) : null}
232
284
  <HintBanner storageKey="dashboard-hint" title="Upload a seed file to get started." variant="action">Your first pipeline takes about 2 minutes to complete.</HintBanner>
233
285
  <div className="mb-4 flex flex-wrap gap-2">
234
286
  {(["current", "errors", "complete", "concurrency"] as const).map((tab) => (
@@ -4,43 +4,64 @@ import { act, fireEvent, render, waitFor } from "@testing-library/react";
4
4
  import { MemoryRouter } from "react-router-dom";
5
5
  import { afterEach, beforeEach, expect, mock, test } from "bun:test";
6
6
 
7
- import type { JobConcurrencyApiStatus } from "../../client/types";
7
+ import type { ApiError, JobConcurrencyApiStatus } from "../../client/types";
8
8
 
9
9
  const originalLocalStorage = globalThis.localStorage;
10
10
  const originalFetch = globalThis.fetch;
11
11
  const originalEventSource = globalThis.EventSource;
12
12
 
13
+ type HookError = ApiError;
14
+
15
+ type HookState = {
16
+ data: unknown[] | null;
17
+ error: HookError | null;
18
+ };
19
+
20
+ const defaultJobs: unknown[] = [
21
+ {
22
+ id: "job-1",
23
+ jobId: "job-1",
24
+ name: "Job One",
25
+ status: "running",
26
+ progress: 50,
27
+ taskCount: 2,
28
+ doneCount: 1,
29
+ location: "current",
30
+ tasks: { task1: { name: "task1", state: "running", files: { artifacts: [], logs: [], tmp: [] } } },
31
+ current: "task1",
32
+ displayCategory: "current",
33
+ },
34
+ {
35
+ id: "job-2",
36
+ jobId: "job-2",
37
+ name: "Job Two",
38
+ status: "failed",
39
+ progress: 100,
40
+ taskCount: 1,
41
+ doneCount: 1,
42
+ location: "current",
43
+ tasks: {},
44
+ current: null,
45
+ displayCategory: "errors",
46
+ },
47
+ ];
48
+
49
+ let hookState: HookState = { data: defaultJobs, error: null };
50
+ let refetchCalls = 0;
51
+
52
+ function setHookState(next: HookState): void {
53
+ hookState = next;
54
+ }
55
+
13
56
  mock.module("../../client/hooks/useJobListWithUpdates", () => ({
14
57
  useJobListWithUpdates: () => ({
15
- data: [
16
- {
17
- id: "job-1",
18
- jobId: "job-1",
19
- name: "Job One",
20
- status: "running",
21
- progress: 50,
22
- taskCount: 2,
23
- doneCount: 1,
24
- location: "current",
25
- tasks: { task1: { name: "task1", state: "running", files: { artifacts: [], logs: [], tmp: [] } } },
26
- current: "task1",
27
- displayCategory: "current",
28
- },
29
- {
30
- id: "job-2",
31
- jobId: "job-2",
32
- name: "Job Two",
33
- status: "failed",
34
- progress: 100,
35
- taskCount: 1,
36
- doneCount: 1,
37
- location: "current",
38
- tasks: {},
39
- current: null,
40
- displayCategory: "errors",
41
- },
42
- ],
43
- error: null,
58
+ data: hookState.data,
59
+ error: hookState.error,
60
+ loading: false,
61
+ refetch: () => {
62
+ refetchCalls += 1;
63
+ },
64
+ connectionStatus: "connected",
44
65
  }),
45
66
  }));
46
67
 
@@ -98,7 +119,7 @@ function installConcurrencyFetch(): void {
98
119
  }
99
120
 
100
121
  beforeEach(() => {
101
- const store: Record<string, string> = {};
122
+ const store: Record<string, string> = { hasSeenWelcome: "1" };
102
123
  globalThis.localStorage = {
103
124
  getItem: (key: string) => store[key] ?? null,
104
125
  setItem: (key: string, value: string) => { store[key] = value; },
@@ -107,6 +128,8 @@ beforeEach(() => {
107
128
  get length() { return Object.keys(store).length; },
108
129
  key: (i: number) => Object.keys(store)[i] ?? null,
109
130
  } as Storage;
131
+ setHookState({ data: defaultJobs, error: null });
132
+ refetchCalls = 0;
110
133
  setConcurrency(emptyStatus());
111
134
  MockEventSource.reset();
112
135
  installConcurrencyFetch();
@@ -249,6 +272,7 @@ test("Concurrency tab renders stale slot warnings when present", async () => {
249
272
  staleSlots: [
250
273
  { jobId: "stale-1", reason: "dead_pid" },
251
274
  { jobId: "stale-2", reason: "invalid_json" },
275
+ { jobId: "stale-3", reason: "stale_runner" },
252
276
  ],
253
277
  });
254
278
  const { default: PromptPipelineDashboard } = await import("../PromptPipelineDashboard");
@@ -260,11 +284,13 @@ test("Concurrency tab renders stale slot warnings when present", async () => {
260
284
 
261
285
  fireEvent.click(view.getByText("Concurrency"));
262
286
 
263
- await waitFor(() => expect(view.getByText(/Stale slots \(2\)/)).toBeTruthy());
287
+ await waitFor(() => expect(view.getByText(/Stale slots \(3\)/)).toBeTruthy());
264
288
  expect(view.getByText("stale-1")).toBeTruthy();
265
289
  expect(view.getByText("Process no longer running")).toBeTruthy();
266
290
  expect(view.getByText("stale-2")).toBeTruthy();
267
291
  expect(view.getByText("Lease file is not valid JSON")).toBeTruthy();
292
+ expect(view.getByText("stale-3")).toBeTruthy();
293
+ expect(view.getByText("Live PID no longer matches runner file")).toBeTruthy();
268
294
  });
269
295
 
270
296
  test("Concurrency tab updates after an SSE refresh without changing tabs", async () => {
@@ -340,3 +366,59 @@ test("Concurrency tab shows stale data when live updates disconnect", async () =
340
366
  await waitFor(() => expect(view.getByText(/Showing last known concurrency status/)).toBeTruthy());
341
367
  expect(view.getByText("active-before-error")).toBeTruthy();
342
368
  });
369
+
370
+ test("PromptPipelineDashboard renders an announced error state with a working retry when the list read fails", async () => {
371
+ setHookState({
372
+ data: null,
373
+ error: { code: "unknown_error", message: "The server failed to process the request", status: 500 },
374
+ });
375
+ const { default: PromptPipelineDashboard } = await import("../PromptPipelineDashboard");
376
+ const view = render(
377
+ <MemoryRouter>
378
+ <PromptPipelineDashboard />
379
+ </MemoryRouter>,
380
+ );
381
+
382
+ const alert = view.getByRole("alert");
383
+ expect(alert).toBeTruthy();
384
+ expect(view.getByText(/The server failed to process the request/)).toBeTruthy();
385
+
386
+ const retry = view.getByRole("button", { name: "Retry loading jobs" });
387
+ expect(refetchCalls).toBe(0);
388
+ fireEvent.click(retry);
389
+ expect(refetchCalls).toBe(1);
390
+ });
391
+
392
+ test("PromptPipelineDashboard renders a distinct first-use empty state when the read returns no jobs", async () => {
393
+ setHookState({ data: [], error: null });
394
+ const { default: PromptPipelineDashboard } = await import("../PromptPipelineDashboard");
395
+ const view = render(
396
+ <MemoryRouter>
397
+ <PromptPipelineDashboard />
398
+ </MemoryRouter>,
399
+ );
400
+
401
+ expect(view.queryByRole("alert")).toBeNull();
402
+ expect(view.getByText(/No pipelines yet/)).toBeTruthy();
403
+ });
404
+
405
+ test("PromptPipelineDashboard shows a human message for malformed_response and never the raw code", async () => {
406
+ setHookState({
407
+ data: null,
408
+ error: { code: "malformed_response", message: "Malformed response", status: 200 },
409
+ });
410
+ const { default: PromptPipelineDashboard } = await import("../PromptPipelineDashboard");
411
+ const view = render(
412
+ <MemoryRouter>
413
+ <PromptPipelineDashboard />
414
+ </MemoryRouter>,
415
+ );
416
+
417
+ const alert = view.getByRole("alert");
418
+ expect(alert).toBeTruthy();
419
+ expect(view.getByText(/Server returned an unexpected response/)).toBeTruthy();
420
+
421
+ const alertText = alert.textContent ?? "";
422
+ expect(alertText.includes("malformed_response")).toBe(false);
423
+ expect(view.queryByText(/malformed_response/)).toBeNull();
424
+ });