@ryanfw/prompt-orchestration-pipeline 1.3.2 → 1.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/docs/http-api.md +80 -4
  2. package/package.json +1 -4
  3. package/src/api/__tests__/index.test.ts +443 -32
  4. package/src/api/index.ts +108 -127
  5. package/src/cli/__tests__/analyze-task.test.ts +40 -7
  6. package/src/cli/__tests__/index.test.ts +337 -17
  7. package/src/cli/__tests__/types.test.ts +0 -18
  8. package/src/cli/__tests__/update-pipeline-json.test.ts +19 -9
  9. package/src/cli/analyze-task.ts +3 -14
  10. package/src/cli/index.ts +73 -61
  11. package/src/cli/types.ts +0 -13
  12. package/src/cli/update-pipeline-json.ts +3 -14
  13. package/src/config/__tests__/paths.test.ts +46 -1
  14. package/src/config/__tests__/pipeline-registry.test.ts +767 -0
  15. package/src/config/__tests__/sse-events.test.ts +470 -0
  16. package/src/config/__tests__/statuses.test.ts +41 -0
  17. package/src/config/paths.ts +11 -2
  18. package/src/config/pipeline-registry.ts +258 -0
  19. package/src/config/sse-events.ts +122 -0
  20. package/src/config/statuses.ts +20 -1
  21. package/src/core/__tests__/agent-step.test.ts +115 -0
  22. package/src/core/__tests__/config.test.ts +545 -105
  23. package/src/core/__tests__/core-boot-resolution.test.ts +181 -0
  24. package/src/core/__tests__/job-concurrency.test.ts +260 -0
  25. package/src/core/__tests__/job-submission.test.ts +396 -0
  26. package/src/core/__tests__/job-view.test.ts +1341 -0
  27. package/src/core/__tests__/json-file.test.ts +111 -0
  28. package/src/core/__tests__/lifecycle-policy.test.ts +81 -7
  29. package/src/core/__tests__/logger.test.ts +22 -0
  30. package/src/core/__tests__/orchestrator-no-deprecated-exports.test.ts +41 -0
  31. package/src/core/__tests__/orchestrator.test.ts +615 -2
  32. package/src/core/__tests__/pipeline-runner.test.ts +946 -9
  33. package/src/core/__tests__/redact.test.ts +153 -0
  34. package/src/core/__tests__/runner-liveness.test.ts +910 -0
  35. package/src/core/__tests__/seed-naming.test.ts +57 -0
  36. package/src/core/__tests__/single-derivation.test.ts +159 -0
  37. package/src/core/__tests__/task-runner.test.ts +594 -3
  38. package/src/core/__tests__/task-telemetry.test.ts +241 -0
  39. package/src/core/__tests__/workspace-root-resolution-guard.test.ts +62 -0
  40. package/src/core/agent-step.ts +4 -1
  41. package/src/core/agent-types.ts +5 -4
  42. package/src/core/config.ts +134 -222
  43. package/src/core/control.ts +3 -0
  44. package/src/core/job-concurrency.ts +56 -20
  45. package/src/core/job-submission.ts +133 -0
  46. package/src/core/job-view.ts +473 -0
  47. package/src/core/json-file.ts +45 -0
  48. package/src/core/lifecycle-policy.ts +23 -8
  49. package/src/core/logger.ts +0 -29
  50. package/src/core/orchestrator.ts +200 -74
  51. package/src/core/pipeline-runner.ts +85 -53
  52. package/src/core/redact.ts +40 -0
  53. package/src/core/runner-liveness.ts +280 -0
  54. package/src/core/seed-naming.ts +9 -0
  55. package/src/core/status-writer.ts +27 -33
  56. package/src/core/task-runner.ts +356 -319
  57. package/src/core/task-telemetry.ts +107 -0
  58. package/src/harness/__tests__/subprocess.test.ts +112 -1
  59. package/src/harness/subprocess.ts +55 -16
  60. package/src/llm/__tests__/index.test.ts +684 -33
  61. package/src/llm/index.ts +310 -67
  62. package/src/providers/__tests__/alibaba.test.ts +67 -6
  63. package/src/providers/__tests__/anthropic.test.ts +35 -14
  64. package/src/providers/__tests__/base.test.ts +62 -0
  65. package/src/providers/__tests__/claude-code.test.ts +99 -14
  66. package/src/providers/__tests__/deepseek.test.ts +16 -6
  67. package/src/providers/__tests__/gemini.test.ts +47 -25
  68. package/src/providers/__tests__/moonshot.test.ts +27 -0
  69. package/src/providers/__tests__/openai.test.ts +262 -74
  70. package/src/providers/__tests__/opencode.test.ts +77 -0
  71. package/src/providers/__tests__/request-timeout-contract.test.ts +226 -0
  72. package/src/providers/__tests__/stream-accumulator.test.ts +52 -0
  73. package/src/providers/__tests__/types.test.ts +85 -0
  74. package/src/providers/__tests__/zero-fill-guard.test.ts +62 -0
  75. package/src/providers/__tests__/zhipu.test.ts +27 -14
  76. package/src/providers/alibaba.ts +20 -39
  77. package/src/providers/anthropic.ts +23 -11
  78. package/src/providers/base.ts +19 -0
  79. package/src/providers/claude-code.ts +27 -18
  80. package/src/providers/deepseek.ts +9 -28
  81. package/src/providers/gemini.ts +20 -58
  82. package/src/providers/moonshot.ts +15 -11
  83. package/src/providers/openai.ts +79 -61
  84. package/src/providers/opencode.ts +16 -33
  85. package/src/providers/stream-accumulator.ts +27 -21
  86. package/src/providers/types.ts +29 -4
  87. package/src/providers/zhipu.ts +15 -44
  88. package/src/task-analysis/__tests__/analyzer.test.ts +0 -0
  89. package/src/task-analysis/__tests__/enrichers-schema-deducer.test.ts +34 -2
  90. package/src/task-analysis/__tests__/no-ast-imports.test.ts +52 -0
  91. package/src/task-analysis/__tests__/repository.test.ts +65 -0
  92. package/src/task-analysis/__tests__/types.test.ts +63 -130
  93. package/src/task-analysis/analyzer.ts +91 -0
  94. package/src/task-analysis/enrichers/schema-deducer.ts +13 -2
  95. package/src/task-analysis/index.ts +2 -36
  96. package/src/task-analysis/repository.ts +45 -0
  97. package/src/task-analysis/types.ts +42 -58
  98. package/src/ui/client/__tests__/api.test.ts +143 -1
  99. package/src/ui/client/__tests__/bootstrap.test.ts +178 -62
  100. package/src/ui/client/__tests__/job-adapter.test.ts +203 -2
  101. package/src/ui/client/__tests__/load-state.test.ts +70 -0
  102. package/src/ui/client/__tests__/types.test.ts +66 -3
  103. package/src/ui/client/__tests__/useJobDetailWithUpdates.test.ts +390 -77
  104. package/src/ui/client/__tests__/useJobList.test.ts +198 -23
  105. package/src/ui/client/__tests__/useJobListWithUpdates.test.ts +218 -7
  106. package/src/ui/client/adapters/__tests__/job-adapter.test.ts +186 -0
  107. package/src/ui/client/adapters/job-adapter.ts +41 -16
  108. package/src/ui/client/api.ts +38 -15
  109. package/src/ui/client/bootstrap.ts +19 -14
  110. package/src/ui/client/hooks/useJobDetailWithUpdates.ts +73 -97
  111. package/src/ui/client/hooks/useJobList.ts +26 -31
  112. package/src/ui/client/hooks/useJobListWithUpdates.ts +42 -76
  113. package/src/ui/client/load-state.ts +20 -0
  114. package/src/ui/client/reducers/__tests__/job-events.test.ts +568 -0
  115. package/src/ui/client/reducers/job-events.ts +137 -0
  116. package/src/ui/client/types.ts +16 -20
  117. package/src/ui/components/DAGGrid.tsx +6 -1
  118. package/src/ui/components/JobDetail.tsx +12 -4
  119. package/src/ui/components/JobTable.tsx +41 -13
  120. package/src/ui/components/StageTimeline.tsx +8 -20
  121. package/src/ui/components/TaskAnalysisDisplay.tsx +48 -6
  122. package/src/ui/components/__tests__/DAGGrid.test.tsx +36 -0
  123. package/src/ui/components/__tests__/JobDetail.test.tsx +112 -0
  124. package/src/ui/components/__tests__/JobTable.test.tsx +137 -1
  125. package/src/ui/components/__tests__/StageTimeline.test.tsx +5 -6
  126. package/src/ui/components/__tests__/TaskAnalysisDisplay.test.tsx +64 -0
  127. package/src/ui/components/types.ts +35 -15
  128. package/src/ui/dist/assets/{index--RH3sAt3.js → index-DN3-zvtP.js} +4324 -263
  129. package/src/ui/dist/assets/index-DN3-zvtP.js.map +1 -0
  130. package/src/ui/dist/assets/style-CtZBnjlR.css +2 -0
  131. package/src/ui/dist/index.html +2 -2
  132. package/src/ui/pages/PipelineDetail.tsx +60 -4
  133. package/src/ui/pages/PromptPipelineDashboard.tsx +59 -7
  134. package/src/ui/pages/__tests__/PromptPipelineDashboard.test.tsx +114 -32
  135. package/src/ui/pages/__tests__/pages.test.tsx +236 -42
  136. package/src/ui/server/__tests__/concurrency-endpoint.test.ts +54 -0
  137. package/src/ui/server/__tests__/config-bridge-node.test.ts +34 -1
  138. package/src/ui/server/__tests__/config-bridge.test.ts +9 -1
  139. package/src/ui/server/__tests__/embedded-assets.test.ts +66 -0
  140. package/src/ui/server/__tests__/file-endpoints.test.ts +183 -5
  141. package/src/ui/server/__tests__/gate-endpoints.test.ts +55 -0
  142. package/src/ui/server/__tests__/index.test.ts +63 -0
  143. package/src/ui/server/__tests__/job-control-endpoints.test.ts +512 -2
  144. package/src/ui/server/__tests__/job-endpoints.test.ts +158 -2
  145. package/src/ui/server/__tests__/read-static-path-guard.test.ts +94 -0
  146. package/src/ui/server/__tests__/router-root-isolation.test.ts +204 -0
  147. package/src/ui/server/__tests__/router-threading.test.ts +148 -0
  148. package/src/ui/server/__tests__/router.test.ts +104 -0
  149. package/src/ui/server/__tests__/sse-broadcast.test.ts +44 -0
  150. package/src/ui/server/__tests__/sse-enhancer.test.ts +70 -0
  151. package/src/ui/server/config-bridge-node.ts +8 -26
  152. package/src/ui/server/config-bridge.ts +3 -4
  153. package/src/ui/server/embedded-assets-imports.d.ts +44 -0
  154. package/src/ui/server/embedded-assets.ts +13 -2
  155. package/src/ui/server/endpoints/__tests__/meta-endpoint.test.ts +1 -0
  156. package/src/ui/server/endpoints/__tests__/pipeline-analysis-endpoint.test.ts +167 -0
  157. package/src/ui/server/endpoints/__tests__/schema-file-endpoint.test.ts +104 -0
  158. package/src/ui/server/endpoints/__tests__/task-analysis-endpoint.test.ts +103 -0
  159. package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +162 -96
  160. package/src/ui/server/endpoints/concurrency-endpoint.ts +2 -1
  161. package/src/ui/server/endpoints/create-pipeline-endpoint.ts +14 -29
  162. package/src/ui/server/endpoints/file-endpoints.ts +15 -10
  163. package/src/ui/server/endpoints/gate-endpoints.ts +2 -6
  164. package/src/ui/server/endpoints/job-control-endpoints.ts +129 -141
  165. package/src/ui/server/endpoints/job-endpoints.ts +7 -0
  166. package/src/ui/server/endpoints/meta-endpoint.ts +1 -1
  167. package/src/ui/server/endpoints/pipeline-analysis-endpoint.ts +99 -11
  168. package/src/ui/server/endpoints/schema-file-endpoint.ts +11 -3
  169. package/src/ui/server/endpoints/task-analysis-endpoint.ts +21 -5
  170. package/src/ui/server/endpoints/task-save-endpoint.ts +1 -2
  171. package/src/ui/server/endpoints/upload-endpoints.ts +5 -40
  172. package/src/ui/server/index.ts +19 -14
  173. package/src/ui/server/job-reader.ts +14 -2
  174. package/src/ui/server/router.ts +33 -10
  175. package/src/ui/server/sse-broadcast.ts +14 -3
  176. package/src/ui/server/sse-enhancer.ts +12 -2
  177. package/src/ui/state/__tests__/schema-loader.test.ts +11 -1
  178. package/src/ui/state/__tests__/snapshot.test.ts +120 -14
  179. package/src/ui/state/__tests__/types.test.ts +104 -5
  180. package/src/ui/state/snapshot.ts +2 -2
  181. package/src/ui/state/transformers/__tests__/list-transformer.test.ts +85 -2
  182. package/src/ui/state/transformers/__tests__/status-transformer.test.ts +250 -22
  183. package/src/ui/state/transformers/list-transformer.ts +13 -3
  184. package/src/ui/state/transformers/status-transformer.ts +36 -170
  185. package/src/ui/state/types.ts +15 -48
  186. package/src/utils/__tests__/path-containment.test.ts +160 -0
  187. package/src/{ui/server/utils → utils}/path-containment.ts +21 -0
  188. package/src/task-analysis/__tests__/enrichers-analysis-writer.test.ts +0 -86
  189. package/src/task-analysis/__tests__/enrichers-artifact-resolver.test.ts +0 -124
  190. package/src/task-analysis/__tests__/extractors-artifacts.test.ts +0 -133
  191. package/src/task-analysis/__tests__/extractors-llm-calls.test.ts +0 -46
  192. package/src/task-analysis/__tests__/extractors-stages.test.ts +0 -52
  193. package/src/task-analysis/__tests__/index.test.ts +0 -143
  194. package/src/task-analysis/__tests__/parser.test.ts +0 -41
  195. package/src/task-analysis/__tests__/utils-ast.test.ts +0 -82
  196. package/src/task-analysis/enrichers/analysis-writer.ts +0 -75
  197. package/src/task-analysis/enrichers/artifact-resolver.ts +0 -89
  198. package/src/task-analysis/extractors/artifacts.ts +0 -143
  199. package/src/task-analysis/extractors/llm-calls.ts +0 -117
  200. package/src/task-analysis/extractors/stages.ts +0 -45
  201. package/src/task-analysis/parser.ts +0 -20
  202. package/src/task-analysis/utils/ast.ts +0 -45
  203. package/src/ui/dist/assets/index--RH3sAt3.js.map +0 -1
  204. package/src/ui/dist/assets/style-CSSKuMOe.css +0 -2
  205. package/src/ui/embedded-assets.js +0 -12
  206. package/src/ui/server/__tests__/path-containment.test.ts +0 -54
@@ -1,14 +1,8 @@
1
- import {
2
- deriveJobStatusFromTasks,
3
- normalizeJobStatus,
4
- normalizeTaskState,
5
- } from "../../../config/statuses";
1
+ import { normalizeJobView } from "../../../core/job-view";
6
2
  import type {
7
3
  CanonicalJob,
8
- CanonicalJobStatus,
9
4
  CanonicalTask,
10
5
  ComputedStatus,
11
- GateInfo,
12
6
  JobReadResult,
13
7
  TransformationStats,
14
8
  } from "../types";
@@ -18,141 +12,13 @@ function asRecord(value: unknown): Record<string, unknown> | null {
18
12
  return value as Record<string, unknown>;
19
13
  }
20
14
 
21
- function asTaskFiles(value: unknown): CanonicalTask["files"] {
22
- const record = asRecord(value);
23
- return {
24
- artifacts: Array.isArray(record?.["artifacts"]) ? (record["artifacts"] as string[]) : [],
25
- logs: Array.isArray(record?.["logs"]) ? (record["logs"] as string[]) : [],
26
- tmp: Array.isArray(record?.["tmp"]) ? (record["tmp"] as string[]) : [],
27
- };
28
- }
29
-
30
- function toGateInfo(value: unknown): GateInfo | null | undefined {
31
- if (value === null) return null;
32
- const record = asRecord(value);
33
- if (!record) return undefined;
34
-
35
- const artifacts = Array.isArray(record["artifacts"])
36
- ? record["artifacts"].filter((artifact): artifact is string => typeof artifact === "string")
37
- : undefined;
38
-
39
- return {
40
- afterTask: typeof record["afterTask"] === "string" ? record["afterTask"] : "",
41
- message: typeof record["message"] === "string" ? record["message"] : "",
42
- requestedAt: typeof record["requestedAt"] === "string" ? record["requestedAt"] : "",
43
- ...(artifacts && artifacts.length > 0 ? { artifacts } : {}),
44
- };
45
- }
46
-
47
- function toTask(name: string, value: unknown): CanonicalTask {
48
- const task = asRecord(value) ?? {};
49
- return {
50
- name,
51
- state: normalizeTaskState(task["state"]),
52
- files: asTaskFiles(task["files"]),
53
- startedAt: typeof task["startedAt"] === "string" ? task["startedAt"] : null,
54
- endedAt: typeof task["endedAt"] === "string" ? task["endedAt"] : null,
55
- attempts: typeof task["attempts"] === "number" ? task["attempts"] : undefined,
56
- restartCount: typeof task["restartCount"] === "number" ? task["restartCount"] : undefined,
57
- retrying: typeof task["retrying"] === "boolean" ? task["retrying"] : undefined,
58
- nextRetryAt: typeof task["nextRetryAt"] === "string" ? task["nextRetryAt"] : undefined,
59
- lastRetryError: Object.hasOwn(task, "lastRetryError") ? task["lastRetryError"] as import("../types").RetryError | null : undefined,
60
- executionTimeMs:
61
- typeof task["executionTimeMs"] === "number" ? task["executionTimeMs"] : undefined,
62
- refinementAttempts:
63
- typeof task["refinementAttempts"] === "number"
64
- ? task["refinementAttempts"]
65
- : undefined,
66
- stageLogPath: typeof task["stageLogPath"] === "string" ? task["stageLogPath"] : undefined,
67
- errorContext: task["errorContext"],
68
- currentStage: typeof task["currentStage"] === "string" ? task["currentStage"] : undefined,
69
- failedStage: typeof task["failedStage"] === "string" ? task["failedStage"] : undefined,
70
- artifacts: task["artifacts"],
71
- error: asRecord(task["error"]) as CanonicalTask["error"],
72
- skipReason: typeof task["skipReason"] === "string" ? task["skipReason"] : undefined,
73
- skippedBy: typeof task["skippedBy"] === "string" ? task["skippedBy"] : undefined,
74
- controlApplied: typeof task["controlApplied"] === "boolean" ? task["controlApplied"] : undefined,
75
- };
76
- }
77
-
78
- function getStatusValue(status: ReturnType<typeof deriveJobStatusFromTasks>): CanonicalJobStatus {
79
- return status === "failed" ? "error" : status;
80
- }
81
-
82
- function getProgress(tasks: CanonicalTask[]): number {
83
- if (tasks.length === 0) return 0;
84
-
85
- const completed = tasks.filter((task) => task.state === "done" || task.state === "skipped").length;
86
- return Math.floor((completed / tasks.length) * 100);
87
- }
88
-
89
- function getTitle(raw: Record<string, unknown>, jobId: string): string {
90
- const title = raw["title"] ?? raw["name"];
91
- return typeof title === "string" && title.trim() !== "" ? title : jobId;
92
- }
93
-
94
- function getCosts(raw: Record<string, unknown>): Record<string, unknown> {
95
- const explicit = asRecord(raw["costs"]);
96
- if (explicit) return explicit;
97
-
98
- // Aggregate cost and token totals from individual task entries
99
- const tasks = asRecord(raw["tasks"]);
100
- if (!tasks) return {};
101
-
102
- let totalCost = 0;
103
- let totalInputTokens = 0;
104
- let totalOutputTokens = 0;
105
-
106
- for (const value of Object.values(tasks)) {
107
- const task = asRecord(value);
108
- if (!task) continue;
109
-
110
- // Sum from [model, input, output, cost?] tuples
111
- const usage = task["tokenUsage"];
112
- if (Array.isArray(usage)) {
113
- for (const entry of usage) {
114
- if (Array.isArray(entry) && entry.length >= 3) {
115
- if (typeof entry[1] === "number") totalInputTokens += entry[1];
116
- if (typeof entry[2] === "number") totalOutputTokens += entry[2];
117
- if (typeof entry[3] === "number") totalCost += entry[3];
118
- }
119
- }
120
- }
121
- }
122
-
123
- const totalTokens = totalInputTokens + totalOutputTokens;
124
- if (totalCost === 0 && totalTokens === 0) return {};
125
-
126
- return { totalCost, totalTokens, totalInputTokens, totalOutputTokens };
127
- }
128
-
129
15
  export function computeJobStatus(tasksInput: unknown): ComputedStatus {
130
- const tasks = Object.values(transformTasks(tasksInput));
131
- if (tasks.length === 0) {
132
- return { status: "pending", progress: 0 };
133
- }
134
-
135
- return {
136
- status: getStatusValue(deriveJobStatusFromTasks(tasks)),
137
- progress: getProgress(tasks),
138
- };
16
+ const view = normalizeJobView({ tasks: tasksInput }, "", "");
17
+ return { status: view.status, progress: view.progress };
139
18
  }
140
19
 
141
20
  export function transformTasks(rawTasks: unknown): Record<string, CanonicalTask> {
142
- if (Array.isArray(rawTasks)) {
143
- return Object.fromEntries(
144
- rawTasks.map((task, index) => {
145
- const record = asRecord(task);
146
- const name = typeof record?.["name"] === "string" ? (record["name"] as string) : `task-${index + 1}`;
147
- return [name, toTask(name, task)];
148
- }),
149
- );
150
- }
151
-
152
- const record = asRecord(rawTasks);
153
- if (!record) return {};
154
-
155
- return Object.fromEntries(Object.entries(record).map(([name, task]) => [name, toTask(name, task)]));
21
+ return normalizeJobView({ tasks: rawTasks }, "", "").tasks;
156
22
  }
157
23
 
158
24
  export function transformJobStatus(raw: unknown, jobId: string, location: string): CanonicalJob | null {
@@ -164,42 +30,41 @@ export function transformJobStatus(raw: unknown, jobId: string, location: string
164
30
  console.warn(`job id mismatch: expected "${jobId}" but received "${rawJobId}"`);
165
31
  }
166
32
 
167
- const tasks = transformTasks(record["tasks"]);
168
- const computed = computeJobStatus(tasks);
169
- const snapshotStatus = normalizeJobStatus(record["status"] ?? record["state"]);
170
- const gate = toGateInfo(record["gate"]);
171
- const title = getTitle(record, jobId);
33
+ return normalizeJobView(raw, jobId, location);
34
+ }
172
35
 
36
+ export function buildUnreadableJob(result: JobReadResult): CanonicalJob {
37
+ const code = result.code ?? "UNKNOWN_READ_ERROR";
38
+ const detail = result.message ? ` Reader error: ${result.message}` : "";
39
+ const message =
40
+ `Status file is unreadable (${code}). ` +
41
+ `Repair or delete ${result.path ?? "tasks-status.json"} for job "${result.jobId}" to recover.` +
42
+ detail;
173
43
  return {
174
- id: jobId,
175
- jobId,
176
- name: title,
177
- title,
178
- status: snapshotStatus === "waiting" || snapshotStatus === "failed"
179
- ? getStatusValue(snapshotStatus)
180
- : computed.status,
181
- progress: computed.progress,
182
- createdAt: typeof record["createdAt"] === "string" ? record["createdAt"] : null,
183
- updatedAt: typeof record["updatedAt"] === "string" ? record["updatedAt"] : null,
184
- location,
185
- tasks,
186
- files: asRecord(record["files"]) ?? {},
187
- costs: getCosts(record),
188
- pipeline: typeof record["pipeline"] === "string" ? record["pipeline"] : undefined,
189
- pipelineLabel:
190
- typeof record["pipelineLabel"] === "string" ? record["pipelineLabel"] : undefined,
191
- pipelineConfig: asRecord(record["pipelineConfig"]) ?? undefined,
192
- current: record["current"],
193
- currentStage: record["currentStage"],
194
- gate,
195
- warnings: Array.isArray(record["warnings"]) ? (record["warnings"] as string[]) : undefined,
44
+ id: result.jobId,
45
+ jobId: result.jobId,
46
+ name: result.jobId,
47
+ title: result.jobId,
48
+ status: "failed",
49
+ progress: 0,
50
+ createdAt: null,
51
+ updatedAt: null,
52
+ location: result.location ?? null,
53
+ tasks: {},
54
+ files: {},
55
+ costs: {},
56
+ readable: false,
57
+ error: { code, message, path: result.path },
196
58
  };
197
59
  }
198
60
 
199
61
  export function transformMultipleJobs(jobReadResults: JobReadResult[]): CanonicalJob[] {
200
62
  return jobReadResults
201
- .filter((result) => result.ok)
202
- .map((result) => transformJobStatus(result.data, result.jobId, result.location))
63
+ .map((result) =>
64
+ result.ok
65
+ ? transformJobStatus(result.data, result.jobId, result.location)
66
+ : result.code === "INVALID_JSON" ? buildUnreadableJob(result) : null,
67
+ )
203
68
  .filter((job): job is CanonicalJob => job !== null);
204
69
  }
205
70
 
@@ -208,6 +73,7 @@ export function getTransformationStats(
208
73
  transformedJobs: CanonicalJob[],
209
74
  ): TransformationStats {
210
75
  const successfulReads = readResults.filter((result) => result.ok).length;
76
+ const successfulTransforms = transformedJobs.filter((job) => job.readable !== false).length;
211
77
  const statusDistribution = transformedJobs.reduce<Record<string, number>>((acc, job) => {
212
78
  acc[job.status] = (acc[job.status] ?? 0) + 1;
213
79
  return acc;
@@ -216,9 +82,9 @@ export function getTransformationStats(
216
82
  return {
217
83
  totalRead: readResults.length,
218
84
  successfulReads,
219
- successfulTransforms: transformedJobs.length,
220
- failedTransforms: successfulReads - transformedJobs.length,
221
- transformationRate: successfulReads === 0 ? 0 : transformedJobs.length / successfulReads,
85
+ successfulTransforms,
86
+ failedTransforms: successfulReads - successfulTransforms,
87
+ transformationRate: successfulReads === 0 ? 0 : successfulTransforms / successfulReads,
222
88
  statusDistribution,
223
89
  };
224
90
  }
@@ -1,7 +1,8 @@
1
1
  import type { JobStatusValue, TaskStateValue } from "../../config/statuses";
2
+ import type { JobView, JobViewTask } from "../../core/job-view";
2
3
 
3
4
  export type ChangeType = "created" | "modified" | "deleted";
4
- export type CanonicalJobStatus = JobStatusValue | "error";
5
+ export type CanonicalJobStatus = JobStatusValue;
5
6
 
6
7
  export interface ChangeEntry {
7
8
  path: string;
@@ -56,7 +57,7 @@ export interface ComposeSnapshotOptions {
56
57
 
57
58
  export interface SnapshotDeps {
58
59
  listAllJobs?: () => { current: string[]; complete: string[] } | Promise<{ current: string[]; complete: string[] }>;
59
- readJob?: (id: string, location: string) => Promise<JobReadResult>;
60
+ readJob?: (id: string) => Promise<JobReadResult>;
60
61
  transformMultipleJobs?: (results: JobReadResult[]) => CanonicalJob[];
61
62
  now?: () => Date;
62
63
  paths?: Record<string, string>;
@@ -126,53 +127,12 @@ export interface GateInfo {
126
127
  message: string;
127
128
  artifacts?: string[];
128
129
  requestedAt: string;
130
+ kind?: "approval" | "control_invalid";
129
131
  }
130
132
 
131
- export interface CanonicalTask {
132
- state: TaskStateValue;
133
- name: string;
134
- files: { artifacts: string[]; logs: string[]; tmp: string[] };
135
- startedAt?: string | null;
136
- endedAt?: string | null;
137
- attempts?: number;
138
- restartCount?: number;
139
- retrying?: boolean;
140
- nextRetryAt?: string;
141
- lastRetryError?: RetryError | null;
142
- executionTimeMs?: number;
143
- refinementAttempts?: number;
144
- stageLogPath?: string;
145
- errorContext?: unknown;
146
- currentStage?: string;
147
- failedStage?: string;
148
- artifacts?: unknown;
149
- error?: { message: string; [key: string]: unknown } | null;
150
- skipReason?: string;
151
- skippedBy?: string;
152
- controlApplied?: boolean;
153
- }
154
-
155
- export interface CanonicalJob {
156
- id: string;
157
- jobId: string;
158
- name: string;
159
- title: string;
160
- status: CanonicalJobStatus;
161
- progress: number;
162
- createdAt: string | null;
163
- updatedAt: string | null;
164
- location: string | null;
165
- tasks: Record<string, CanonicalTask>;
166
- files: Record<string, unknown>;
167
- costs: Record<string, unknown>;
168
- pipeline?: string;
169
- pipelineLabel?: string;
170
- pipelineConfig?: Record<string, unknown>;
171
- current?: unknown;
172
- currentStage?: unknown;
173
- gate?: GateInfo | null;
174
- warnings?: string[];
175
- }
133
+ export type CanonicalTask = JobViewTask;
134
+
135
+ export type CanonicalJob = JobView;
176
136
 
177
137
  export interface JobReadResult {
178
138
  ok: boolean;
@@ -181,6 +141,7 @@ export interface JobReadResult {
181
141
  location: string;
182
142
  code?: string;
183
143
  message?: string;
144
+ path?: string;
184
145
  }
185
146
 
186
147
  export interface TransformationStats {
@@ -202,7 +163,7 @@ export interface JobListStats {
202
163
  export interface GroupedJobs {
203
164
  running: CanonicalJob[];
204
165
  waiting: CanonicalJob[];
205
- error: CanonicalJob[];
166
+ failed: CanonicalJob[];
206
167
  pending: CanonicalJob[];
207
168
  complete: CanonicalJob[];
208
169
  }
@@ -214,6 +175,9 @@ export interface CostsSummary {
214
175
  totalCost: number;
215
176
  totalInputCost: number;
216
177
  totalOutputCost: number;
178
+ estimatedCost: number;
179
+ hasEstimated: boolean;
180
+ unavailableCost: number;
217
181
  }
218
182
 
219
183
  export interface APIJob {
@@ -230,10 +194,13 @@ export interface APIJob {
230
194
  currentStage?: unknown;
231
195
  gate?: GateInfo | null;
232
196
  costsSummary: CostsSummary;
197
+ costs?: Record<string, unknown>;
233
198
  pipelineSlug?: string;
234
199
  pipeline?: string;
235
200
  pipelineLabel?: string;
236
201
  pipelineConfig?: Record<string, unknown>;
202
+ readable?: boolean;
203
+ error?: { code: string; message: string; path?: string };
237
204
  }
238
205
 
239
206
  export interface AggregationStats {
@@ -0,0 +1,160 @@
1
+ import path from "node:path";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { resolveUnderRoot, resolveWithin } from "../path-containment";
5
+
6
+ describe("resolveWithin", () => {
7
+ const rootDir = "/app/staging";
8
+
9
+ it("rejects relative path with parent dir escape", () => {
10
+ expect(resolveWithin(rootDir, "../x")).toBeNull();
11
+ });
12
+
13
+ it("rejects deeply nested parent dir escape", () => {
14
+ expect(resolveWithin(rootDir, "../../../../tmp/PWNED.txt")).toBeNull();
15
+ });
16
+
17
+ it("rejects normalized parent dir escape", () => {
18
+ expect(resolveWithin(rootDir, "a/../../b")).toBeNull();
19
+ });
20
+
21
+ it("rejects absolute path", () => {
22
+ expect(resolveWithin(rootDir, "/etc/passwd")).toBeNull();
23
+ });
24
+
25
+ it("rejects empty string", () => {
26
+ expect(resolveWithin(rootDir, "")).toBeNull();
27
+ });
28
+
29
+ it("rejects non-string input", () => {
30
+ expect(resolveWithin(rootDir, 42)).toBeNull();
31
+ });
32
+
33
+ it("rejects string containing NUL byte", () => {
34
+ expect(resolveWithin(rootDir, "safe\0/../escape")).toBeNull();
35
+ });
36
+
37
+ it("rejects current-directory root target", () => {
38
+ expect(resolveWithin(rootDir, ".")).toBeNull();
39
+ });
40
+
41
+ it("rejects nested path that normalizes to root", () => {
42
+ expect(resolveWithin(rootDir, "dir/..")).toBeNull();
43
+ });
44
+
45
+ it("resolves a plain filename within root", () => {
46
+ const result = resolveWithin(rootDir, "out.json");
47
+ expect(result).toBe(path.resolve(rootDir, "out.json"));
48
+ });
49
+
50
+ it("resolves a nested relative path within root", () => {
51
+ const result = resolveWithin(rootDir, "data/foo.json");
52
+ expect(result).toBe(path.resolve(rootDir, "data/foo.json"));
53
+ });
54
+ });
55
+
56
+ describe("resolveUnderRoot", () => {
57
+ const rootDir = "/app/staging";
58
+
59
+ it("rejects parent dir segment", () => {
60
+ expect(resolveUnderRoot(rootDir, "..")).toBeNull();
61
+ });
62
+
63
+ it("rejects encoded slash escape that decodes to traversal", () => {
64
+ expect(resolveUnderRoot(rootDir, "..%2f..%2fetc")).toBeNull();
65
+ });
66
+
67
+ it("rejects decoded null byte", () => {
68
+ expect(resolveUnderRoot(rootDir, "foo%00bar")).toBeNull();
69
+ });
70
+
71
+ it("rejects malformed percent-encoding", () => {
72
+ expect(resolveUnderRoot(rootDir, "%zz")).toBeNull();
73
+ });
74
+
75
+ it("rejects truncated percent-encoding", () => {
76
+ expect(resolveUnderRoot(rootDir, "%2")).toBeNull();
77
+ });
78
+
79
+ it("rejects non-string segment", () => {
80
+ expect(resolveUnderRoot(rootDir, "a", 42 as unknown as string)).toBeNull();
81
+ });
82
+
83
+ it("resolves a plain encoded filename", () => {
84
+ expect(resolveUnderRoot(rootDir, "out.json")).toBe(path.resolve(rootDir, "out.json"));
85
+ });
86
+
87
+ it("resolves multiple segments joined under root", () => {
88
+ expect(resolveUnderRoot(rootDir, "data", "foo.json")).toBe(
89
+ path.resolve(rootDir, "data/foo.json"),
90
+ );
91
+ });
92
+
93
+ it("decodes a legitimately encoded space once (AC-9)", () => {
94
+ expect(resolveUnderRoot(rootDir, "my%20file.json")).toBe(
95
+ path.resolve(rootDir, "my file.json"),
96
+ );
97
+ });
98
+
99
+ it("rejects encoded dot-dot (AC-3)", () => {
100
+ expect(resolveUnderRoot(rootDir, "%2e%2e")).toBeNull();
101
+ });
102
+
103
+ it("rejects mixed-case encoded dot-dot (AC-3)", () => {
104
+ expect(resolveUnderRoot(rootDir, "%2E%2E")).toBeNull();
105
+ });
106
+
107
+ it("rejects encoded traversal nested under a name (AC-3)", () => {
108
+ expect(resolveUnderRoot(rootDir, "a%2f..%2f..%2fetc")).toBeNull();
109
+ });
110
+
111
+ it("contains a doubly-encoded dot-dot that decodes to a literal (AC-3)", () => {
112
+ expect(resolveUnderRoot(rootDir, "%252e%252e")).toBe(path.resolve(rootDir, "%2e%2e"));
113
+ });
114
+ });
115
+
116
+ describe("URL parsing characterization (AC-2)", () => {
117
+ it("normalizes an encoded dot-dot path segment before capture", () => {
118
+ expect(new URL("https://h/a/%2e%2e/b").pathname).toBe("/b");
119
+ });
120
+
121
+ it("drops a standalone encoded dot-dot segment", () => {
122
+ expect(new URL("https://h/%2e%2e").pathname).toBe("/");
123
+ });
124
+
125
+ it("normalizes a mixed-case encoded dot-dot segment", () => {
126
+ expect(new URL("https://h/%2E%2E").pathname).toBe("/");
127
+ });
128
+
129
+ it("keeps an encoded slash encoded within a segment", () => {
130
+ expect(new URL("https://h/x%2f").pathname).toBe("/x%2f");
131
+ });
132
+
133
+ it("keeps a doubly-encoded dot encoded", () => {
134
+ expect(new URL("https://h/x%252e").pathname).toBe("/x%252e");
135
+ });
136
+
137
+ it("keeps an encoded space encoded within a segment", () => {
138
+ expect(new URL("https://h/x%20").pathname).toBe("/x%20");
139
+ });
140
+
141
+ it("URLPattern captures keep an encoded slash encoded", () => {
142
+ const result = new URLPattern({ pathname: "/:seg" }).exec(new URL("https://h/x%2f"));
143
+ expect(result?.pathname?.groups?.seg).toBe("x%2f");
144
+ });
145
+
146
+ it("URLPattern captures keep a doubly-encoded dot encoded", () => {
147
+ const result = new URLPattern({ pathname: "/:seg" }).exec(new URL("https://h/x%252e"));
148
+ expect(result?.pathname?.groups?.seg).toBe("x%252e");
149
+ });
150
+
151
+ it("URLPattern captures keep an encoded space encoded", () => {
152
+ const result = new URLPattern({ pathname: "/:seg" }).exec(new URL("https://h/x%20"));
153
+ expect(result?.pathname?.groups?.seg).toBe("x%20");
154
+ });
155
+
156
+ it("URLSearchParams.get() decodes query values exactly once", () => {
157
+ const a = new URL("https://h/x?a=%20b%2520c").searchParams.get("a");
158
+ expect(a).toBe(" b%20c");
159
+ });
160
+ });
@@ -16,3 +16,24 @@ export function resolveWithin(rootDir: string, relativePath: unknown): string |
16
16
  if (!resolved.startsWith(root + path.sep)) return null;
17
17
  return resolved;
18
18
  }
19
+
20
+ /**
21
+ * Decode each encoded URL path segment/pathname exactly once, then delegate to
22
+ * `resolveWithin` for lexical containment. For URLPattern path captures and
23
+ * static pathnames only — do NOT pass `URLSearchParams.get()` values here
24
+ * (they are already decoded once and would double-decode `%25`).
25
+ */
26
+ export function resolveUnderRoot(rootDir: string, ...segments: string[]): string | null {
27
+ const decoded: string[] = [];
28
+ for (const seg of segments) {
29
+ if (typeof seg !== "string") return null;
30
+ let d: string;
31
+ try {
32
+ d = decodeURIComponent(seg);
33
+ } catch {
34
+ return null;
35
+ }
36
+ decoded.push(d);
37
+ }
38
+ return resolveWithin(rootDir, decoded.join("/"));
39
+ }
@@ -1,86 +0,0 @@
1
- import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
- import { mkdtemp, rm } from "node:fs/promises";
3
- import { existsSync } from "node:fs";
4
- import path from "node:path";
5
- import os from "node:os";
6
- import { writeAnalysisFile } from "../enrichers/analysis-writer.ts";
7
- import type { TaskAnalysis } from "../types.ts";
8
-
9
- const VALID_ANALYSIS: TaskAnalysis = {
10
- taskFilePath: "/pipeline/tasks/research.js",
11
- stages: [{ name: "fetchData", order: 0, isAsync: true }],
12
- artifacts: {
13
- reads: [{ fileName: "input.json", stage: "fetchData", required: true }],
14
- writes: [{ fileName: "output.json", stage: "fetchData" }],
15
- unresolvedReads: [],
16
- unresolvedWrites: [],
17
- },
18
- models: [{ provider: "openai", method: "chat", stage: "fetchData" }],
19
- };
20
-
21
- let tmpDir: string;
22
-
23
- beforeAll(async () => {
24
- tmpDir = await mkdtemp(path.join(os.tmpdir(), "analysis-writer-test-"));
25
- });
26
-
27
- afterAll(async () => {
28
- await rm(tmpDir, { recursive: true, force: true });
29
- });
30
-
31
- describe("writeAnalysisFile", () => {
32
- it("writes analysis file with analyzedAt and all fields for valid input", async () => {
33
- const pipelinePath = path.join(tmpDir, "pipeline-1");
34
- const before = new Date();
35
-
36
- await writeAnalysisFile(pipelinePath, "research", VALID_ANALYSIS);
37
-
38
- const after = new Date();
39
- const filePath = path.join(pipelinePath, "analysis", "research.analysis.json");
40
- expect(existsSync(filePath)).toBe(true);
41
-
42
- const content = JSON.parse(await Bun.file(filePath).text()) as Record<string, unknown>;
43
-
44
- expect(typeof content.analyzedAt).toBe("string");
45
- const analyzedAt = new Date(content.analyzedAt as string);
46
- expect(analyzedAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
47
- expect(analyzedAt.getTime()).toBeLessThanOrEqual(after.getTime());
48
-
49
- expect(content.taskFilePath).toBe(VALID_ANALYSIS.taskFilePath);
50
- expect(content.stages).toEqual(VALID_ANALYSIS.stages);
51
- expect(content.artifacts).toEqual(VALID_ANALYSIS.artifacts);
52
- expect(content.models).toEqual(VALID_ANALYSIS.models);
53
- });
54
-
55
- it("throws before creating any file when taskFilePath is null", async () => {
56
- const pipelinePath = path.join(tmpDir, "pipeline-2");
57
- const bad = { ...VALID_ANALYSIS, taskFilePath: null } as unknown as TaskAnalysis;
58
-
59
- await expect(
60
- writeAnalysisFile(pipelinePath, "research", bad),
61
- ).rejects.toThrow(/taskFilePath/);
62
-
63
- expect(existsSync(path.join(pipelinePath, "analysis"))).toBe(false);
64
- });
65
-
66
- it("throws when stages is not an array", async () => {
67
- const pipelinePath = path.join(tmpDir, "pipeline-3");
68
- const bad = { ...VALID_ANALYSIS, stages: "not-an-array" } as unknown as TaskAnalysis;
69
-
70
- await expect(
71
- writeAnalysisFile(pipelinePath, "research", bad),
72
- ).rejects.toThrow(/stages/);
73
- });
74
-
75
- it("throws when artifacts is missing reads", async () => {
76
- const pipelinePath = path.join(tmpDir, "pipeline-4");
77
- const bad = {
78
- ...VALID_ANALYSIS,
79
- artifacts: { writes: [], unresolvedReads: [], unresolvedWrites: [] },
80
- } as unknown as TaskAnalysis;
81
-
82
- await expect(
83
- writeAnalysisFile(pipelinePath, "research", bad),
84
- ).rejects.toThrow(/artifacts/);
85
- });
86
- });