@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
@@ -0,0 +1,473 @@
1
+ import {
2
+ JOB_STATUS_SYNONYMS,
3
+ VALID_JOB_STATUSES,
4
+ deriveJobStatusFromTasks,
5
+ jobStateToStatus,
6
+ normalizeJobStatus,
7
+ normalizeTaskState,
8
+ } from "../config/statuses.ts";
9
+ import type { JobStatusValue, TaskStateValue } from "../config/statuses.ts";
10
+ import type { UsageSource } from "../providers/types.ts";
11
+ import type { GateInfo } from "./status-writer.ts";
12
+
13
+ export type RetryError = { message: string; name?: string; stack?: string } | string;
14
+
15
+ export interface JobViewError {
16
+ code: string;
17
+ message: string;
18
+ path?: string;
19
+ name?: string;
20
+ stack?: string;
21
+ }
22
+
23
+ export interface JobViewTask {
24
+ state: TaskStateValue;
25
+ name: string;
26
+ files: { artifacts: string[]; logs: string[]; tmp: string[] };
27
+ startedAt?: string | null;
28
+ endedAt?: string | null;
29
+ attempts?: number;
30
+ restartCount?: number;
31
+ retrying?: boolean;
32
+ nextRetryAt?: string;
33
+ lastRetryError?: RetryError | null;
34
+ executionTimeMs?: number;
35
+ refinementAttempts?: number;
36
+ stageLogPath?: string;
37
+ errorContext?: unknown;
38
+ currentStage?: string;
39
+ failedStage?: string;
40
+ artifacts?: unknown;
41
+ tokenUsage?: unknown[];
42
+ error?: { message: string; [key: string]: unknown } | null;
43
+ skipReason?: string;
44
+ skippedBy?: string;
45
+ controlApplied?: boolean;
46
+ }
47
+
48
+ export interface JobView {
49
+ id: string;
50
+ jobId: string;
51
+ name: string;
52
+ title: string;
53
+ status: JobStatusValue;
54
+ progress: number;
55
+ createdAt: string | null;
56
+ updatedAt: string | null;
57
+ lastUpdated?: string;
58
+ location: string | null;
59
+ tasks: Record<string, JobViewTask>;
60
+ files: Record<string, unknown>;
61
+ costs: Record<string, unknown>;
62
+ gate?: GateInfo | null;
63
+ pipeline?: string;
64
+ pipelineLabel?: string;
65
+ pipelineConfig?: Record<string, unknown>;
66
+ current?: unknown;
67
+ currentStage?: unknown;
68
+ warnings?: string[];
69
+ readable?: boolean;
70
+ error?: JobViewError;
71
+ errorContext?: unknown;
72
+ }
73
+
74
+ interface TaskFilesView {
75
+ artifacts: string[];
76
+ logs: string[];
77
+ tmp: string[];
78
+ }
79
+
80
+ const EMPTY_TASK_FILES: TaskFilesView = { artifacts: [], logs: [], tmp: [] };
81
+ const DEFAULT_JOB_ERROR_CODE = "JOB_ERROR";
82
+
83
+ export interface DerivedProgress {
84
+ taskCount: number;
85
+ doneCount: number;
86
+ completedCount: number;
87
+ progress: number;
88
+ }
89
+
90
+ export function deriveProgress(
91
+ tasks: ReadonlyArray<{ state?: string }>,
92
+ configuredCount: number | null,
93
+ ): DerivedProgress {
94
+ const actual = tasks.length;
95
+ const doneCount = tasks.filter((task) => task.state === "done").length;
96
+ const completedCount = tasks.filter(
97
+ (task) => task.state === "done" || task.state === "skipped",
98
+ ).length;
99
+ const taskCount = Math.max(configuredCount ?? 0, actual);
100
+ const progress = taskCount === 0
101
+ ? 0
102
+ : Math.min(100, Math.floor((completedCount / taskCount) * 100));
103
+ return { taskCount, doneCount, completedCount, progress };
104
+ }
105
+
106
+ const warnedFallbackKeys = new Set<string>();
107
+
108
+ function isRecord(value: unknown): value is Record<string, unknown> {
109
+ return typeof value === "object" && value !== null && !Array.isArray(value);
110
+ }
111
+
112
+ function isRecognizedStatusValue(value: unknown): value is string {
113
+ if (typeof value !== "string") return false;
114
+ const normalized = value.toLowerCase().trim();
115
+ if (Object.prototype.hasOwnProperty.call(JOB_STATUS_SYNONYMS, normalized)) return true;
116
+ return VALID_JOB_STATUSES.has(normalized);
117
+ }
118
+
119
+ function toStringOrNull(value: unknown): string | null {
120
+ return typeof value === "string" ? value : null;
121
+ }
122
+
123
+ function toStringArray(value: unknown): string[] {
124
+ if (!Array.isArray(value)) return [];
125
+ return value.filter((entry): entry is string => typeof entry === "string");
126
+ }
127
+
128
+ function toFiniteNumber(value: unknown): number | undefined {
129
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
130
+ }
131
+
132
+ function toTaskFiles(value: unknown): TaskFilesView {
133
+ if (!isRecord(value)) return { ...EMPTY_TASK_FILES };
134
+ return {
135
+ artifacts: toStringArray(value["artifacts"]),
136
+ logs: toStringArray(value["logs"]),
137
+ tmp: toStringArray(value["tmp"]),
138
+ };
139
+ }
140
+
141
+ function toTaskError(value: unknown): JobViewTask["error"] {
142
+ if (value === null) return null;
143
+ if (typeof value === "string") return { message: value };
144
+ if (isRecord(value)) return value as { message: string; [key: string]: unknown };
145
+ return undefined;
146
+ }
147
+
148
+ function toGateInfo(value: unknown): GateInfo | null | undefined {
149
+ if (value === null) return null;
150
+ if (!isRecord(value)) return undefined;
151
+ const rawArtifacts = value["artifacts"];
152
+ const artifacts = Array.isArray(rawArtifacts)
153
+ ? rawArtifacts.filter((entry): entry is string => typeof entry === "string")
154
+ : undefined;
155
+ const rawKind = value["kind"];
156
+ const kind = rawKind === "approval" || rawKind === "control_invalid" ? rawKind : undefined;
157
+ return {
158
+ afterTask: typeof value["afterTask"] === "string" ? value["afterTask"] : "",
159
+ message: typeof value["message"] === "string" ? value["message"] : "",
160
+ requestedAt: typeof value["requestedAt"] === "string" ? value["requestedAt"] : "",
161
+ ...(kind ? { kind } : {}),
162
+ ...(artifacts && artifacts.length > 0 ? { artifacts } : {}),
163
+ };
164
+ }
165
+
166
+ function toJobViewTask(name: string, value: unknown): JobViewTask {
167
+ const task = isRecord(value) ? value : {};
168
+ return {
169
+ name,
170
+ state: normalizeTaskState(task["state"]),
171
+ files: toTaskFiles(task["files"]),
172
+ startedAt: toStringOrNull(task["startedAt"]),
173
+ endedAt: toStringOrNull(task["endedAt"]),
174
+ attempts: typeof task["attempts"] === "number" ? task["attempts"] : undefined,
175
+ restartCount: typeof task["restartCount"] === "number" ? task["restartCount"] : undefined,
176
+ retrying: typeof task["retrying"] === "boolean" ? task["retrying"] : undefined,
177
+ nextRetryAt: typeof task["nextRetryAt"] === "string" ? task["nextRetryAt"] : undefined,
178
+ lastRetryError: Object.hasOwn(task, "lastRetryError")
179
+ ? (task["lastRetryError"] as RetryError | null)
180
+ : undefined,
181
+ executionTimeMs: typeof task["executionTimeMs"] === "number"
182
+ ? task["executionTimeMs"]
183
+ : undefined,
184
+ refinementAttempts: typeof task["refinementAttempts"] === "number"
185
+ ? task["refinementAttempts"]
186
+ : undefined,
187
+ stageLogPath: typeof task["stageLogPath"] === "string" ? task["stageLogPath"] : undefined,
188
+ errorContext: task["errorContext"],
189
+ currentStage: typeof task["currentStage"] === "string" ? task["currentStage"] : undefined,
190
+ failedStage: typeof task["failedStage"] === "string" ? task["failedStage"] : undefined,
191
+ artifacts: task["artifacts"],
192
+ tokenUsage: Array.isArray(task["tokenUsage"]) ? task["tokenUsage"] : undefined,
193
+ error: toTaskError(task["error"]),
194
+ skipReason: typeof task["skipReason"] === "string" ? task["skipReason"] : undefined,
195
+ skippedBy: typeof task["skippedBy"] === "string" ? task["skippedBy"] : undefined,
196
+ controlApplied: typeof task["controlApplied"] === "boolean" ? task["controlApplied"] : undefined,
197
+ };
198
+ }
199
+
200
+ function toJobViewTasks(value: unknown): Record<string, JobViewTask> {
201
+ if (Array.isArray(value)) {
202
+ return Object.fromEntries(
203
+ value.map((entry, index) => {
204
+ const record = isRecord(entry) ? entry : {};
205
+ const explicitName = record["name"];
206
+ const name = typeof explicitName === "string" && explicitName.length > 0
207
+ ? explicitName
208
+ : `task-${index}`;
209
+ return [name, toJobViewTask(name, record)] as const;
210
+ }),
211
+ );
212
+ }
213
+ if (isRecord(value)) {
214
+ return Object.fromEntries(
215
+ Object.entries(value).map(([name, entry]) => [name, toJobViewTask(name, entry)] as const),
216
+ );
217
+ }
218
+ return {};
219
+ }
220
+
221
+ function getRawTaskNameValuePairs(value: unknown): Array<[string, unknown]> {
222
+ if (Array.isArray(value)) {
223
+ return value.map((entry, index) => {
224
+ const record = isRecord(entry) ? entry : {};
225
+ const explicitName = record["name"];
226
+ const name = typeof explicitName === "string" && explicitName.length > 0
227
+ ? explicitName
228
+ : `task-${index}`;
229
+ return [name, entry] as [string, unknown];
230
+ });
231
+ }
232
+ if (isRecord(value)) {
233
+ return Object.entries(value);
234
+ }
235
+ return [];
236
+ }
237
+
238
+ function readUsageSource(value: unknown): UsageSource {
239
+ return value === "reported" ||
240
+ value === "estimated" ||
241
+ value === "unavailable" ||
242
+ value === "zero"
243
+ ? value
244
+ : "reported";
245
+ }
246
+
247
+ const SOURCE_RANK: Record<UsageSource, number> = {
248
+ reported: 0,
249
+ zero: 0,
250
+ estimated: 1,
251
+ unavailable: 2,
252
+ };
253
+
254
+ function combineSource(current: UsageSource | undefined, next: UsageSource): UsageSource {
255
+ if (current === undefined) return next;
256
+ return SOURCE_RANK[current] >= SOURCE_RANK[next] ? current : next;
257
+ }
258
+
259
+ function readCostEstimated(entry: unknown[], source: UsageSource): boolean {
260
+ if (typeof entry[6] === "boolean") return entry[6];
261
+ if (source === "estimated" || source === "unavailable") return true;
262
+ return false;
263
+ }
264
+
265
+ interface TaskCostBreakdownEntry {
266
+ inputTokens: number;
267
+ outputTokens: number;
268
+ inputCost: number;
269
+ outputCost: number;
270
+ totalCost: number;
271
+ source?: UsageSource;
272
+ failed?: true;
273
+ costEstimated?: true;
274
+ }
275
+
276
+ function getCosts(record: Record<string, unknown>): Record<string, unknown> {
277
+ const explicitCosts = record["costs"];
278
+ if (isRecord(explicitCosts)) return explicitCosts;
279
+
280
+ let totalCost = 0;
281
+ let estimatedCost = 0;
282
+ let unavailableCost = 0;
283
+ let totalInputTokens = 0;
284
+ let totalOutputTokens = 0;
285
+ let hasEstimated = false;
286
+ const breakdown: Record<string, TaskCostBreakdownEntry> = {};
287
+
288
+ for (const [name, value] of getRawTaskNameValuePairs(record["tasks"])) {
289
+ if (!isRecord(value)) continue;
290
+ const usage = value["tokenUsage"];
291
+ if (!Array.isArray(usage)) continue;
292
+
293
+ let taskInputTokens = 0;
294
+ let taskOutputTokens = 0;
295
+ let taskReportedCost = 0;
296
+ let taskEstimatedCost = 0;
297
+ let taskUnavailableCost = 0;
298
+ let taskSource: UsageSource | undefined;
299
+ let taskFailed: true | undefined;
300
+ let taskCostEstimated = false;
301
+ let contributed = false;
302
+
303
+ for (const entry of usage) {
304
+ if (!Array.isArray(entry)) continue;
305
+ contributed = true;
306
+ const inputTokens = toFiniteNumber(entry[1]) ?? 0;
307
+ const outputTokens = toFiniteNumber(entry[2]) ?? 0;
308
+ const cost = toFiniteNumber(entry[3]) ?? 0;
309
+ const source = readUsageSource(entry[4]);
310
+ const failed = entry[5] === true;
311
+ const costEstimated = readCostEstimated(entry, source);
312
+
313
+ taskInputTokens += inputTokens;
314
+ taskOutputTokens += outputTokens;
315
+ totalInputTokens += inputTokens;
316
+ totalOutputTokens += outputTokens;
317
+
318
+ if (source === "unavailable") {
319
+ unavailableCost += cost;
320
+ taskUnavailableCost += cost;
321
+ hasEstimated = true;
322
+ } else if (costEstimated) {
323
+ estimatedCost += cost;
324
+ taskEstimatedCost += cost;
325
+ hasEstimated = true;
326
+ } else {
327
+ totalCost += cost;
328
+ taskReportedCost += cost;
329
+ }
330
+
331
+ taskSource = combineSource(taskSource, source);
332
+ if (failed) taskFailed = true;
333
+ if (costEstimated) {
334
+ hasEstimated = true;
335
+ taskCostEstimated = true;
336
+ }
337
+ }
338
+
339
+ if (!contributed) continue;
340
+
341
+ const entry: TaskCostBreakdownEntry = {
342
+ inputTokens: taskInputTokens,
343
+ outputTokens: taskOutputTokens,
344
+ inputCost: 0,
345
+ outputCost: 0,
346
+ totalCost: taskReportedCost + taskEstimatedCost + taskUnavailableCost,
347
+ };
348
+ if (taskSource !== undefined) entry.source = taskSource;
349
+ if (taskFailed === true) entry.failed = true;
350
+ if (taskCostEstimated) entry.costEstimated = true;
351
+ breakdown[name] = entry;
352
+ }
353
+
354
+ const totalTokens = totalInputTokens + totalOutputTokens;
355
+ if (
356
+ totalTokens === 0 &&
357
+ totalCost === 0 &&
358
+ estimatedCost === 0 &&
359
+ unavailableCost === 0
360
+ ) {
361
+ return {};
362
+ }
363
+ return {
364
+ totalCost,
365
+ totalTokens,
366
+ totalInputTokens,
367
+ totalOutputTokens,
368
+ estimatedCost,
369
+ unavailableCost,
370
+ hasEstimated,
371
+ ...breakdown,
372
+ };
373
+ }
374
+
375
+ function resolveStateStatus(
376
+ fromState: JobStatusValue,
377
+ tasks: JobViewTask[],
378
+ location: string,
379
+ ): JobStatusValue {
380
+ if (fromState !== "complete" || location === "complete" || tasks.length === 0) {
381
+ return fromState;
382
+ }
383
+
384
+ const taskStatus = deriveJobStatusFromTasks(tasks);
385
+ return taskStatus === "complete" ? fromState : taskStatus;
386
+ }
387
+
388
+ function toTitle(record: Record<string, unknown>, fallback: string): string {
389
+ const candidate = record["title"] ?? record["name"];
390
+ return typeof candidate === "string" && candidate.length > 0 ? candidate : fallback;
391
+ }
392
+
393
+ function toErrorView(value: unknown): JobViewError | undefined {
394
+ if (!isRecord(value)) return undefined;
395
+ if (typeof value["message"] !== "string") return undefined;
396
+ const code = typeof value["code"] === "string"
397
+ ? value["code"]
398
+ : typeof value["name"] === "string"
399
+ ? value["name"]
400
+ : DEFAULT_JOB_ERROR_CODE;
401
+ const result: JobViewError = { code, message: value["message"] };
402
+ if (typeof value["path"] === "string") result.path = value["path"];
403
+ if (typeof value["name"] === "string") result.name = value["name"];
404
+ if (typeof value["stack"] === "string") result.stack = value["stack"];
405
+ return result;
406
+ }
407
+
408
+ export function normalizeJobView(raw: unknown, jobId: string, location: string): JobView {
409
+ const record = isRecord(raw) ? raw : {};
410
+
411
+ const tasks = toJobViewTasks(record["tasks"]);
412
+ const taskList = Object.values(tasks);
413
+ const pipelineConfig = isRecord(record["pipelineConfig"]) ? record["pipelineConfig"] : null;
414
+ const configuredTaskCount = pipelineConfig && Array.isArray(pipelineConfig["tasks"])
415
+ ? pipelineConfig["tasks"].length
416
+ : null;
417
+
418
+ const fromState = jobStateToStatus(record["state"]);
419
+ let status: JobStatusValue;
420
+ if (fromState !== null) {
421
+ status = resolveStateStatus(fromState, taskList, location);
422
+ } else if (isRecognizedStatusValue(record["status"])) {
423
+ status = normalizeJobStatus(record["status"]);
424
+ } else {
425
+ const warnKey = `${location}:${jobId}`;
426
+ if (!warnedFallbackKeys.has(warnKey)) {
427
+ warnedFallbackKeys.add(warnKey);
428
+ console.warn(
429
+ `normalizeJobView: no valid state or status for ${warnKey}; deriving job status from tasks.`,
430
+ );
431
+ }
432
+ status = deriveJobStatusFromTasks(taskList);
433
+ }
434
+
435
+ const title = toTitle(record, jobId);
436
+ const lastUpdated = toStringOrNull(record["lastUpdated"]);
437
+
438
+ const result: JobView = {
439
+ id: jobId,
440
+ jobId,
441
+ name: title,
442
+ title,
443
+ status,
444
+ progress: deriveProgress(taskList, configuredTaskCount).progress,
445
+ createdAt: toStringOrNull(record["createdAt"]),
446
+ updatedAt: toStringOrNull(record["updatedAt"]) ?? lastUpdated,
447
+ location,
448
+ tasks,
449
+ files: isRecord(record["files"]) ? record["files"] : {},
450
+ costs: getCosts(record),
451
+ };
452
+
453
+ if (lastUpdated !== null) result.lastUpdated = lastUpdated;
454
+ if ("gate" in record) result.gate = toGateInfo(record["gate"]);
455
+ if (typeof record["pipeline"] === "string") result.pipeline = record["pipeline"];
456
+ if (typeof record["pipelineLabel"] === "string") result.pipelineLabel = record["pipelineLabel"];
457
+ if (pipelineConfig) result.pipelineConfig = pipelineConfig;
458
+ if ("current" in record) result.current = record["current"];
459
+ if ("currentStage" in record) result.currentStage = record["currentStage"];
460
+ if (Array.isArray(record["warnings"])) {
461
+ result.warnings = record["warnings"].filter((entry): entry is string => typeof entry === "string");
462
+ }
463
+ if (typeof record["readable"] === "boolean") result.readable = record["readable"];
464
+ const errorView = toErrorView(record["error"]);
465
+ if (errorView) result.error = errorView;
466
+ if ("errorContext" in record) result.errorContext = record["errorContext"];
467
+
468
+ return result;
469
+ }
470
+
471
+ export function __resetWarnedFallbackKeys(): void {
472
+ warnedFallbackKeys.clear();
473
+ }
@@ -0,0 +1,45 @@
1
+ import { atomicWrite } from "./status-writer";
2
+
3
+ function causeDetail(cause: unknown): string {
4
+ if (cause instanceof Error) {
5
+ const code = (cause as NodeJS.ErrnoException).code;
6
+ return code ? `${code}: ${cause.message}` : cause.message;
7
+ }
8
+ return String(cause);
9
+ }
10
+
11
+ export class JsonFileError extends Error {
12
+ readonly path: string;
13
+ readonly reason: "missing" | "parse";
14
+ constructor(path: string, reason: "missing" | "parse", cause?: unknown) {
15
+ super(`JSON file ${reason} at ${path}: ${causeDetail(cause)}`, { cause });
16
+ this.name = "JsonFileError";
17
+ this.path = path;
18
+ this.reason = reason;
19
+ }
20
+ }
21
+
22
+ export async function readJsonFile<T>(filePath: string): Promise<T> {
23
+ let text: string;
24
+ try {
25
+ text = await Bun.file(filePath).text();
26
+ } catch (err: unknown) {
27
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
28
+ throw new JsonFileError(filePath, "missing", err);
29
+ }
30
+ throw err;
31
+ }
32
+
33
+ try {
34
+ return JSON.parse(text) as T;
35
+ } catch (err: unknown) {
36
+ if (err instanceof SyntaxError) {
37
+ throw new JsonFileError(filePath, "parse", err);
38
+ }
39
+ throw err;
40
+ }
41
+ }
42
+
43
+ export async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
44
+ await atomicWrite(filePath, JSON.stringify(value, null, 2) + "\n");
45
+ }
@@ -1,3 +1,5 @@
1
+ import { normalizeTaskState } from "../config/statuses";
2
+
1
3
  type LifecycleOp = "start" | "restart";
2
4
 
3
5
  interface TransitionInput {
@@ -13,7 +15,7 @@ interface TransitionAllowed {
13
15
  interface TransitionBlocked {
14
16
  ok: false;
15
17
  code: "unsupported_lifecycle";
16
- reason: "dependencies" | "policy";
18
+ reason: "dependencies";
17
19
  }
18
20
 
19
21
  type TransitionDecision = TransitionAllowed | TransitionBlocked;
@@ -24,11 +26,24 @@ const BLOCKED_DEPS: Readonly<TransitionBlocked> = Object.freeze({
24
26
  code: "unsupported_lifecycle" as const,
25
27
  reason: "dependencies" as const,
26
28
  });
27
- const BLOCKED_POLICY: Readonly<TransitionBlocked> = Object.freeze({
28
- ok: false as const,
29
- code: "unsupported_lifecycle" as const,
30
- reason: "policy" as const,
31
- });
29
+
30
+ export function areDependenciesSatisfied(predecessorStates: Iterable<string>): boolean {
31
+ for (const state of predecessorStates) {
32
+ const normalized = normalizeTaskState(state);
33
+ if (normalized !== "done" && normalized !== "skipped") return false;
34
+ }
35
+ return true;
36
+ }
37
+
38
+ export function firstUnsatisfiedDependency(
39
+ predecessors: ReadonlyArray<{ name: string; state: string }>,
40
+ ): { name: string; state: string } | null {
41
+ for (const predecessor of predecessors) {
42
+ const normalized = normalizeTaskState(predecessor.state);
43
+ if (normalized !== "done" && normalized !== "skipped") return predecessor;
44
+ }
45
+ return null;
46
+ }
32
47
 
33
48
  export function decideTransition(input: TransitionInput): Readonly<TransitionDecision> {
34
49
  const { op, taskState, dependenciesReady } = input;
@@ -47,6 +62,6 @@ export function decideTransition(input: TransitionInput): Readonly<TransitionDec
47
62
  return dependenciesReady ? ALLOWED : BLOCKED_DEPS;
48
63
  }
49
64
 
50
- // op === "restart": only allowed from "done" state, ignores dependenciesReady
51
- return taskState === "done" ? ALLOWED : BLOCKED_POLICY;
65
+ // op === "restart": gated on dependenciesReady (same rule as "start")
66
+ return dependenciesReady ? ALLOWED : BLOCKED_DEPS;
52
67
  }
@@ -17,25 +17,6 @@ export interface Logger {
17
17
  sse(eventType: string, eventData: unknown): void;
18
18
  }
19
19
 
20
- // Lazy SSE registry cache
21
- let sseRegistry: { broadcast: (eventType: string, data: unknown) => void } | null = null;
22
-
23
- async function getSSERegistry(): Promise<{ broadcast: (eventType: string, data: unknown) => void } | null> {
24
- if (sseRegistry !== null) return sseRegistry;
25
- try {
26
- // Dynamic import path kept as a variable to avoid static resolution errors
27
- // when the module does not yet exist. Fails gracefully at runtime.
28
- const ssePath = "../ui/sse.ts";
29
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
30
- const mod = await import(/* @vite-ignore */ ssePath);
31
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
32
- sseRegistry = mod as { broadcast: (eventType: string, data: unknown) => void };
33
- return sseRegistry;
34
- } catch {
35
- return null;
36
- }
37
- }
38
-
39
20
  function formatPrefix(componentName: string, context?: LogContext): string {
40
21
  const parts: string[] = [componentName];
41
22
  if (context?.jobId) parts.push(context.jobId);
@@ -124,16 +105,6 @@ export function createLogger(componentName: string, context?: LogContext): Logge
124
105
 
125
106
  sse(eventType, eventData) {
126
107
  console.log(prefix, `[SSE:${eventType}]`, stringify(eventData));
127
- void getSSERegistry().then((registry) => {
128
- if (!registry) return;
129
- try {
130
- registry.broadcast(eventType, formatData(eventData));
131
- } catch (err) {
132
- console.warn(prefix, "SSE broadcast failed", err);
133
- }
134
- }).catch((err) => {
135
- console.warn(prefix, "SSE registry unavailable", err);
136
- });
137
108
  },
138
109
  };
139
110
  }