@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
package/src/api/index.ts CHANGED
@@ -1,33 +1,28 @@
1
1
  import path from "node:path";
2
- import { mkdir, readdir, rename, stat } from "node:fs/promises";
3
- import { resolvePipelinePaths, getPendingSeedPath } from "../config/paths";
2
+ import { readdir, stat } from "node:fs/promises";
3
+ import { resolvePipelinePaths, resolveWorkspaceRoot } from "../config/paths";
4
4
  import type { PipelinePaths } from "../config/paths";
5
- import { getPipelineConfig } from "../core/config";
6
- import { deriveJobStatusFromTasks } from "../config/statuses";
7
- import { SEED_PATTERN } from "../core/orchestrator";
5
+ import { submitSeed } from "../core/job-submission.ts";
6
+ import type { SubmitResult } from "../core/job-submission.ts";
7
+ import { normalizeJobView } from "../core/job-view";
8
+ import type { JobView } from "../core/job-view";
9
+ import { parseSeedFilename } from "../core/seed-naming.ts";
10
+ import { readJobStatusResult, STATUS_FILENAME, type StatusReadResult } from "../core/status-writer";
8
11
 
9
- /** Result of a successful job submission. */
10
- export interface SubmitSuccessResult {
11
- success: true;
12
- jobId: string;
13
- jobName: string;
14
- }
15
-
16
- /** Result of a failed job submission. */
17
- export interface SubmitFailureResult {
18
- success: false;
19
- message: string;
20
- }
21
-
22
- export type SubmitResult = SubmitSuccessResult | SubmitFailureResult;
12
+ export type { SubmitResult, SubmitSuccessResult, SubmitFailureResult } from "../core/job-submission.ts";
23
13
 
24
14
  /** Options for submitJobWithValidation. */
25
15
  export interface SubmitJobOptions {
26
- dataDir: string;
16
+ root?: string;
27
17
  seedObject: unknown;
28
18
  }
29
19
 
30
- /** Job status record returned by getStatus. */
20
+ /**
21
+ * Job status record returned by getStatus.
22
+ *
23
+ * Additive optional fields (present only when the on-disk status is unreadable):
24
+ * `readable: false` and `error: { code, message, path }`. Absent ⇒ normal/readable.
25
+ */
31
26
  export interface JobStatusRecord {
32
27
  jobId: string;
33
28
  jobName: string;
@@ -40,28 +35,11 @@ export interface JobStatusRecord {
40
35
  /** Orchestrator construction options. */
41
36
  export interface OrchestratorOptions {
42
37
  autoStart: boolean;
38
+ root?: string;
43
39
  }
44
40
 
45
41
  // ─── Private Helpers ──────────────────────────────────────────────────────────
46
42
 
47
- async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
48
- const tmp = `${filePath}.${Date.now()}.tmp`;
49
- await Bun.write(tmp, JSON.stringify(data, null, 2));
50
- await rename(tmp, filePath);
51
- }
52
-
53
- function assertSeedObject(value: unknown): asserts value is Record<string, unknown> {
54
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
55
- throw new Error("seed must be a JSON object");
56
- }
57
- }
58
-
59
- function assertPipelineSlug(value: unknown): asserts value is string {
60
- if (typeof value !== "string" || value.length === 0) {
61
- throw new Error("seed.pipeline must be a non-empty string");
62
- }
63
- }
64
-
65
43
  async function safeReaddir(dirPath: string): Promise<string[]> {
66
44
  try {
67
45
  return await readdir(dirPath);
@@ -71,66 +49,77 @@ async function safeReaddir(dirPath: string): Promise<string[]> {
71
49
  }
72
50
  }
73
51
 
52
+ function locationForPath(paths: PipelinePaths, jobDir: string): "current" | "complete" {
53
+ if (jobDir.startsWith(paths.current + path.sep) || jobDir === paths.current) return "current";
54
+ return "complete";
55
+ }
56
+
74
57
  async function readJsonFile<T = unknown>(filePath: string): Promise<T> {
75
58
  const text = await Bun.file(filePath).text();
76
59
  return JSON.parse(text) as T;
77
60
  }
78
61
 
79
- function mapStatusToRecord(data: Record<string, unknown>): JobStatusRecord {
80
- const { id, name, pipeline, state, createdAt, ...rest } = data;
81
- return {
82
- jobId: String(id ?? ""),
83
- jobName: String(name ?? id ?? ""),
84
- pipeline: String(pipeline ?? ""),
85
- state: String(state ?? ""),
86
- createdAt: String(createdAt ?? ""),
62
+ function toJobStatusRecord(view: JobView): JobStatusRecord {
63
+ const {
64
+ id: _id,
65
+ jobId: _jobId,
66
+ name,
67
+ title: _title,
68
+ status,
69
+ pipeline,
70
+ createdAt,
71
+ readable,
72
+ error,
73
+ ...rest
74
+ } = view;
75
+ const record: JobStatusRecord = {
76
+ jobId: view.jobId,
77
+ jobName: name,
78
+ pipeline: pipeline ?? "",
79
+ state: status,
80
+ createdAt: createdAt ?? "",
87
81
  ...rest,
88
82
  };
83
+ if (readable !== undefined) record["readable"] = readable;
84
+ if (error !== undefined) record["error"] = error;
85
+ return record;
89
86
  }
90
87
 
91
- // ─── Public API ───────────────────────────────────────────────────────────────
92
-
93
- export async function submitJobWithValidation(
94
- opts: SubmitJobOptions,
95
- ): Promise<SubmitResult> {
96
- const rootDir = path.resolve(opts.dataDir);
97
-
98
- try {
99
- assertSeedObject(opts.seedObject);
100
- assertPipelineSlug(opts.seedObject["pipeline"]);
101
- } catch (err) {
102
- return { success: false, message: (err as Error).message };
103
- }
104
-
105
- const seed = opts.seedObject as Record<string, unknown>;
106
-
107
- if (seed["name"] !== undefined) {
108
- if (typeof seed["name"] !== "string" || seed["name"].length === 0) {
109
- return { success: false, message: "seed.name must be a non-empty string if provided" };
110
- }
111
- }
88
+ function buildCorruptPlaceholder(jobDir: string, jobId: string): JobStatusRecord {
89
+ const statusPath = path.join(jobDir, STATUS_FILENAME);
90
+ return {
91
+ jobId,
92
+ jobName: jobId,
93
+ pipeline: "",
94
+ state: "failed",
95
+ createdAt: "",
96
+ readable: false,
97
+ error: {
98
+ code: "STATUS_CORRUPT",
99
+ message:
100
+ `Status file is unreadable (corrupt). ` +
101
+ `Repair or delete ${statusPath} for job "${jobId}" to recover.`,
102
+ path: statusPath,
103
+ },
104
+ };
105
+ }
112
106
 
107
+ async function readStatusForApi(jobDir: string, label: string): Promise<StatusReadResult | null> {
113
108
  try {
114
- getPipelineConfig(seed["pipeline"] as string, rootDir);
109
+ return await readJobStatusResult(jobDir);
115
110
  } catch (err) {
116
- return { success: false, message: (err as Error).message };
111
+ console.warn(`[api] skipping unreadable status ${label}: ${(err as Error).message}`);
112
+ return null;
117
113
  }
114
+ }
118
115
 
119
- const jobId = crypto.randomUUID();
120
- const jobName = (typeof seed["name"] === "string" && seed["name"].length > 0)
121
- ? seed["name"]
122
- : jobId;
123
- const pendingPath = getPendingSeedPath(rootDir, jobId);
124
-
125
- await mkdir(path.dirname(pendingPath), { recursive: true });
126
-
127
- try {
128
- await atomicWriteJson(pendingPath, opts.seedObject);
129
- } catch (err) {
130
- throw new Error(`failed to write seed file for job ${jobId}: ${(err as Error).message}`);
131
- }
116
+ // ─── Public API ───────────────────────────────────────────────────────────────
132
117
 
133
- return { success: true, jobId, jobName };
118
+ export async function submitJobWithValidation(
119
+ opts: SubmitJobOptions,
120
+ ): Promise<SubmitResult> {
121
+ const rootDir = resolveWorkspaceRoot(opts.root);
122
+ return submitSeed({ root: rootDir, seedObject: opts.seedObject });
134
123
  }
135
124
 
136
125
  export class PipelineOrchestrator {
@@ -140,20 +129,22 @@ export class PipelineOrchestrator {
140
129
 
141
130
  constructor(opts: OrchestratorOptions) {
142
131
  this.autoStart = opts.autoStart;
143
- this.root = path.resolve(process.env["PO_ROOT"] ?? process.cwd());
132
+ this.root = resolveWorkspaceRoot(opts.root);
144
133
  this.paths = resolvePipelinePaths(this.root);
145
134
  }
146
135
 
147
136
  async getStatus(jobName: string): Promise<JobStatusRecord> {
148
137
  // 1. Direct jobId lookup in current, then complete
149
138
  for (const dir of [this.paths.current, this.paths.complete]) {
150
- const statusPath = path.join(dir, jobName, "tasks-status.json");
151
- try {
152
- const data = await readJsonFile<Record<string, unknown>>(statusPath);
153
- return mapStatusToRecord(data);
154
- } catch {
155
- // not found here, continue
139
+ const jobDir = path.join(dir, jobName);
140
+ const result = await readStatusForApi(jobDir, jobName);
141
+ if (!result) continue;
142
+ if (result.ok) {
143
+ return toJobStatusRecord(
144
+ normalizeJobView(result.value, jobName, locationForPath(this.paths, jobDir)),
145
+ );
156
146
  }
147
+ if (result.reason === "corrupt") return buildCorruptPlaceholder(jobDir, jobName);
157
148
  }
158
149
 
159
150
  // 2. Name scan fallback
@@ -161,14 +152,14 @@ export class PipelineOrchestrator {
161
152
  const entries = await safeReaddir(dir);
162
153
  for (const entry of entries) {
163
154
  if (entry === ".gitkeep") continue;
164
- const statusPath = path.join(dir, entry, "tasks-status.json");
165
- try {
166
- const data = await readJsonFile<Record<string, unknown>>(statusPath);
167
- if (data["name"] === jobName) {
168
- return mapStatusToRecord(data);
169
- }
170
- } catch {
171
- // skip unreadable entries
155
+ const jobDir = path.join(dir, entry);
156
+ const result = await readStatusForApi(jobDir, entry);
157
+ if (!result) continue;
158
+ if (!result.ok) continue;
159
+ if (result.value["name"] === jobName) {
160
+ return toJobStatusRecord(
161
+ normalizeJobView(result.value, entry, locationForPath(this.paths, jobDir)),
162
+ );
172
163
  }
173
164
  }
174
165
  }
@@ -176,10 +167,8 @@ export class PipelineOrchestrator {
176
167
  // 3. Pending fallback
177
168
  const pendingFiles = await safeReaddir(this.paths.pending);
178
169
  for (const file of pendingFiles) {
179
- const match = file.match(SEED_PATTERN);
180
- if (!match) continue;
181
- const id = match[1]!;
182
- if (id !== jobName) continue;
170
+ const id = parseSeedFilename(file);
171
+ if (id === null || id !== jobName) continue;
183
172
 
184
173
  const filePath = path.join(this.paths.pending, file);
185
174
  const seed = await readJsonFile<Record<string, unknown>>(filePath);
@@ -202,9 +191,8 @@ export class PipelineOrchestrator {
202
191
  // Pending jobs
203
192
  const pendingFiles = await safeReaddir(this.paths.pending);
204
193
  for (const file of pendingFiles) {
205
- const match = file.match(SEED_PATTERN);
206
- if (!match) continue;
207
- const id = match[1]!;
194
+ const id = parseSeedFilename(file);
195
+ if (id === null) continue;
208
196
  const filePath = path.join(this.paths.pending, file);
209
197
  try {
210
198
  const seed = await readJsonFile<Record<string, unknown>>(filePath);
@@ -225,35 +213,28 @@ export class PipelineOrchestrator {
225
213
  const currentEntries = await safeReaddir(this.paths.current);
226
214
  for (const entry of currentEntries) {
227
215
  if (entry === ".gitkeep") continue;
228
- const statusPath = path.join(this.paths.current, entry, "tasks-status.json");
229
- try {
230
- const data = await readJsonFile<Record<string, unknown>>(statusPath);
231
- const tasks = data["tasks"];
232
- const taskArray = tasks && typeof tasks === "object" && !Array.isArray(tasks)
233
- ? Object.values(tasks as Record<string, { state: unknown }>)
234
- : [];
235
- const state = deriveJobStatusFromTasks(taskArray);
236
- const record = mapStatusToRecord(data);
237
- record.state = state;
238
- results.push(record);
239
- } catch (err) {
240
- console.warn(`[api] skipping unreadable current job ${entry}: ${(err as Error).message}`);
216
+ const jobDir = path.join(this.paths.current, entry);
217
+ const result = await readStatusForApi(jobDir, entry);
218
+ if (!result) continue;
219
+ if (!result.ok) {
220
+ if (result.reason === "corrupt") results.push(buildCorruptPlaceholder(jobDir, entry));
221
+ continue;
241
222
  }
223
+ results.push(toJobStatusRecord(normalizeJobView(result.value, entry, "current")));
242
224
  }
243
225
 
244
226
  // Complete jobs
245
227
  const completeEntries = await safeReaddir(this.paths.complete);
246
228
  for (const entry of completeEntries) {
247
229
  if (entry === ".gitkeep") continue;
248
- const statusPath = path.join(this.paths.complete, entry, "tasks-status.json");
249
- try {
250
- const data = await readJsonFile<Record<string, unknown>>(statusPath);
251
- const record = mapStatusToRecord(data);
252
- record.state = "complete";
253
- results.push(record);
254
- } catch (err) {
255
- console.warn(`[api] skipping unreadable complete job ${entry}: ${(err as Error).message}`);
230
+ const jobDir = path.join(this.paths.complete, entry);
231
+ const result = await readStatusForApi(jobDir, entry);
232
+ if (!result) continue;
233
+ if (!result.ok) {
234
+ if (result.reason === "corrupt") results.push(buildCorruptPlaceholder(jobDir, entry));
235
+ continue;
256
236
  }
237
+ results.push(toJobStatusRecord(normalizeJobView(result.value, entry, "complete")));
257
238
  }
258
239
 
259
240
  return results;
@@ -1,7 +1,43 @@
1
- import { describe, it, expect, beforeEach, afterEach, spyOn } from "bun:test";
1
+ import { describe, it, expect, beforeEach, afterEach, spyOn, mock } from "bun:test";
2
2
  import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import type { ChatResponse, ProviderAvailability } from "../../providers/types.ts";
6
+
7
+ // Mock the gateway boundary so the analyzer produces a deterministic, valid
8
+ // result without a real LLM call. (See task-analysis/__tests__/analyzer.test.ts.)
9
+ const availability: ProviderAvailability = {
10
+ openai: false,
11
+ deepseek: true,
12
+ anthropic: false,
13
+ gemini: false,
14
+ zai: false,
15
+ zhipu: false,
16
+ claudecode: false,
17
+ moonshot: false,
18
+ alibaba: false,
19
+ opencode: false,
20
+ mock: false,
21
+ };
22
+
23
+ const VALID_ANALYSIS = {
24
+ summary: "Ingests raw data and writes a processed artifact.",
25
+ stages: [{ name: "ingestion", purpose: "Reads raw input and normalizes it." }],
26
+ artifacts: {
27
+ reads: [{ fileName: "seed.json", role: "raw input" }],
28
+ writes: [{ fileName: "processed.json", role: "normalized output" }],
29
+ },
30
+ models: [{ provider: "deepseek", method: "chat", stage: "ingestion" }],
31
+ };
32
+
33
+ mock.module("../../llm/index.ts", () => ({
34
+ chat: (): Promise<ChatResponse> =>
35
+ Promise.resolve({
36
+ content: VALID_ANALYSIS,
37
+ usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2, source: "reported" },
38
+ }),
39
+ getAvailableProviders: () => availability,
40
+ }));
5
41
 
6
42
  describe("analyzeTaskFile", () => {
7
43
  let tmpDir: string;
@@ -22,11 +58,11 @@ describe("analyzeTaskFile", () => {
22
58
  stdoutSpy.mockRestore();
23
59
  });
24
60
 
25
- it("outputs JSON result for a valid task file", async () => {
61
+ it("prints a status-tagged analysis as JSON for a sample file", async () => {
26
62
  const taskFile = join(tmpDir, "task.ts");
27
63
  await writeFile(
28
64
  taskFile,
29
- 'export async function ingestion({ io, llm }) { await io.readArtifact("seed.json"); await llm.openai.complete({ prompt: "json" }); }',
65
+ 'export async function ingestion({ io, llm }) { await io.readArtifact("seed.json"); }',
30
66
  );
31
67
 
32
68
  const { analyzeTaskFile } = await import("../analyze-task.ts");
@@ -35,10 +71,7 @@ describe("analyzeTaskFile", () => {
35
71
  expect(stdoutSpy).toHaveBeenCalledTimes(1);
36
72
  const written = (stdoutSpy.mock.calls[0] as [string])[0];
37
73
  const parsed = JSON.parse(written);
38
- expect(parsed.taskFilePath).toBe(taskFile);
39
- expect(parsed.stages).toEqual([{ name: "ingestion", order: 1, isAsync: true }]);
40
- expect(parsed.artifacts.reads).toEqual([{ fileName: "seed.json", stage: "ingestion", required: true }]);
41
- expect(parsed.models).toEqual([{ provider: "openai", method: "complete", stage: "ingestion" }]);
74
+ expect(parsed.analysisStatus).toBe("ok");
42
75
  });
43
76
 
44
77
  it("calls process.exit(1) for a non-existent file", async () => {