@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,12 +1,28 @@
1
1
  import path from "node:path";
2
2
 
3
+ import { TaskAnalysisRepository } from "../../../task-analysis/repository";
4
+ import { resolveUnderRoot } from "../../../utils/path-containment";
3
5
  import { sendJson } from "../utils/http-utils";
4
6
 
5
- export async function handleTaskAnalysis(_req: Request, slug: string, taskId: string): Promise<Response> {
6
- const root = process.env["PO_ROOT"] ?? process.cwd();
7
- const filePath = path.join(root, "pipeline-config", slug, "tasks", `${taskId}.analysis.json`);
8
- if (!(await Bun.file(filePath).exists())) {
7
+ export async function handleTaskAnalysis(
8
+ _req: Request,
9
+ slug: string,
10
+ taskId: string,
11
+ root: string,
12
+ ): Promise<Response> {
13
+ const validatedPath = resolveUnderRoot(
14
+ path.join(root, "pipeline-config"),
15
+ slug,
16
+ "tasks",
17
+ `${taskId}.analysis.json`,
18
+ );
19
+ if (validatedPath === null) {
20
+ return sendJson(400, { ok: false, code: "BAD_PATH", message: "invalid task-analysis path" });
21
+ }
22
+
23
+ const analysis = await TaskAnalysisRepository.readFromPath(validatedPath);
24
+ if (analysis === null) {
9
25
  return sendJson(404, { ok: false, code: "NOT_FOUND", message: "analysis file not found" });
10
26
  }
11
- return sendJson(200, { ok: true, data: JSON.parse(await Bun.file(filePath).text()) });
27
+ return sendJson(200, { ok: true, data: analysis });
12
28
  }
@@ -3,12 +3,11 @@ import path from "node:path";
3
3
 
4
4
  import { sendJson } from "../utils/http-utils";
5
5
 
6
- export async function handleTaskSave(req: Request): Promise<Response> {
6
+ export async function handleTaskSave(req: Request, root: string): Promise<Response> {
7
7
  const body = (await req.json()) as Record<string, unknown>;
8
8
  const slug = typeof body["slug"] === "string" ? body["slug"] : "";
9
9
  const taskId = typeof body["taskId"] === "string" ? body["taskId"] : "";
10
10
  const content = typeof body["content"] === "string" ? body["content"] : "";
11
- const root = process.env["PO_ROOT"] ?? process.cwd();
12
11
  const taskPath = path.join(root, "pipeline-config", slug, "tasks", `${taskId}.md`);
13
12
  await mkdir(path.dirname(taskPath), { recursive: true });
14
13
  await Bun.write(taskPath, content);
@@ -1,21 +1,14 @@
1
- import { mkdir } from "node:fs/promises";
2
- import path from "node:path";
3
-
4
1
  import { parseMultipartFormData, sendJson } from "../utils/http-utils";
5
2
  import { extractSeedZip } from "../zip-utils";
6
3
  import { createErrorResponse } from "../config-bridge";
7
4
  import { Constants } from "../config-bridge-node";
8
- import { resolveWithin } from "../utils/path-containment";
5
+ import { submitUploadedSeed } from "../../../core/job-submission.ts";
9
6
 
10
7
  export interface SeedUploadResult {
11
8
  seedObject: Record<string, unknown>;
12
9
  artifacts?: Array<{ filename: string; content: Uint8Array }>;
13
10
  }
14
11
 
15
- function nextJobId(): string {
16
- return `job-${Date.now()}`;
17
- }
18
-
19
12
  export async function normalizeSeedUpload(req: Request): Promise<SeedUploadResult> {
20
13
  const contentType = req.headers.get("content-type") ?? "";
21
14
  if (contentType.includes("application/json")) {
@@ -34,39 +27,11 @@ export async function handleSeedUploadDirect(
34
27
  dataDir: string,
35
28
  artifacts: Array<{ filename: string; content: Uint8Array }> = [],
36
29
  ): Promise<Response> {
37
- const jobId = nextJobId();
38
- const pipelineData = path.join(dataDir, "pipeline-data");
39
- const pendingDir = path.join(pipelineData, "pending");
40
-
41
- // Stage artifacts under staging/{jobId}/ (before the trigger file).
42
- // The orchestrator owns current/{jobId}/ and moves staged artifacts into
43
- // current/{jobId}/files/artifacts/ during promotion.
44
- if (artifacts.length > 0) {
45
- const stagingJobDir = path.join(pipelineData, "staging", jobId);
46
-
47
- for (const artifact of artifacts) {
48
- if (resolveWithin(stagingJobDir, artifact.filename) === null) {
49
- return sendJson(400, createErrorResponse(Constants.ERROR_CODES.BAD_REQUEST, `unsafe artifact path: ${artifact.filename}`));
50
- }
51
- }
52
-
53
- for (const artifact of artifacts) {
54
- const resolved = resolveWithin(stagingJobDir, artifact.filename)!;
55
- await mkdir(path.dirname(resolved), { recursive: true });
56
- await Bun.write(resolved, artifact.content);
57
- }
30
+ const result = await submitUploadedSeed({ root: dataDir, seedObject, artifacts });
31
+ if (!result.success) {
32
+ return sendJson(400, createErrorResponse(Constants.ERROR_CODES.BAD_REQUEST, result.message));
58
33
  }
59
-
60
- // Write the seed to pending/ as the trigger file for the orchestrator.
61
- // The orchestrator watches pending/*.json and handles the full lifecycle:
62
- // move to current/, write tasks-status.json, and spawn the pipeline runner.
63
- await mkdir(pendingDir, { recursive: true });
64
- await Bun.write(
65
- path.join(pendingDir, `${jobId}-seed.json`),
66
- JSON.stringify(seedObject, null, 2),
67
- );
68
-
69
- return sendJson(201, { ok: true, data: { jobId } });
34
+ return sendJson(201, { ok: true, data: { jobId: result.jobId } });
70
35
  }
71
36
 
72
37
  export async function handleSeedUpload(req: Request, dataDir: string): Promise<Response> {
@@ -1,7 +1,8 @@
1
1
  import path from "node:path";
2
2
 
3
- import { getPipelineDataDir } from "../../config/paths";
4
- import { getOrchestratorConfig } from "../../core/config";
3
+ import { emitSseEvent, type SseEvent } from "../../config/sse-events.ts";
4
+ import { getPipelineDataDir, resolveWorkspaceRoot } from "../../config/paths";
5
+ import { getConfig, getOrchestratorConfig } from "../../core/config";
5
6
  import { loadEnvironment } from "../../core/environment";
6
7
  import { getConcurrencyRuntimePaths } from "../../core/job-concurrency";
7
8
  import { createLogger } from "../../core/logger";
@@ -19,7 +20,8 @@ import { sseRegistry } from "./sse-registry";
19
20
  const logger = createLogger("ui-server");
20
21
 
21
22
  export interface ServerOptions {
22
- dataDir: string;
23
+ /** Workspace root (not the `pipeline-data` subdir); omitted values fall back to `PO_ROOT` at boot. */
24
+ dataDir?: string;
23
25
  port?: number;
24
26
  }
25
27
 
@@ -62,8 +64,8 @@ export async function initializeWatcher(dataDir: string): Promise<void> {
62
64
  );
63
65
  }
64
66
 
65
- export function createServer(dataDir?: string): { fetch: (req: Request) => Promise<Response> } {
66
- const resolvedDataDir = dataDir ?? process.env["PO_ROOT"] ?? process.cwd();
67
+ export function createServer(dataDir: string): { fetch: (req: Request) => Promise<Response> } {
68
+ const resolvedDataDir = dataDir;
67
69
  const rawOrigins = process.env["PO_CORS_ORIGINS"];
68
70
  const allowNull = process.env["PO_CORS_ALLOW_NULL_ORIGIN"];
69
71
  const cors = parseCorsConfig(rawOrigins, allowNull === "1" || allowNull === "true");
@@ -72,18 +74,21 @@ export function createServer(dataDir?: string): { fetch: (req: Request) => Promi
72
74
  }
73
75
 
74
76
  export async function startServer(options: ServerOptions): Promise<ServerHandle> {
75
- if (process.env["NODE_ENV"] !== "test" && !process.env["PO_ROOT"]) {
76
- throw new Error("PO_ROOT is required in non-test environments");
77
- }
77
+ const root = resolveWorkspaceRoot(options.dataDir);
78
78
 
79
- await loadEnvironment({ rootDir: options.dataDir });
80
- initPATHS(options.dataDir);
79
+ await loadEnvironment({ rootDir: root });
80
+ getConfig();
81
+ initPATHS(root);
81
82
 
82
83
  const port = options.port ?? 4000;
83
- const app = createServer(options.dataDir);
84
+ const app = createServer(root);
84
85
 
85
86
  let heartbeat: ReturnType<typeof setInterval> | null = setInterval(() => {
86
- sseRegistry.broadcast("heartbeat", { ok: true, timestamp: new Date().toISOString() });
87
+ const event: SseEvent = {
88
+ type: "heartbeat",
89
+ data: { ok: true, timestamp: new Date().toISOString() },
90
+ };
91
+ emitSseEvent(sseRegistry, event);
87
92
  }, 30_000);
88
93
 
89
94
  let server: Bun.Server<undefined> | null = null;
@@ -112,7 +117,7 @@ export async function startServer(options: ServerOptions): Promise<ServerHandle>
112
117
  ]);
113
118
  await startup;
114
119
  try {
115
- await initializeWatcher(options.dataDir);
120
+ await initializeWatcher(root);
116
121
  } catch (error) {
117
122
  if (heartbeat) clearInterval(heartbeat);
118
123
  heartbeat = null;
@@ -164,7 +169,7 @@ if (
164
169
  process.argv[1] !== process.execPath &&
165
170
  import.meta.url === Bun.pathToFileURL(process.argv[1]).href
166
171
  ) {
167
- const dataDir = process.env["PO_ROOT"] ?? process.cwd();
172
+ const dataDir = process.env["PO_ROOT"];
168
173
  startServer({ dataDir }).catch((err) => {
169
174
  console.error("Failed to start server:", err);
170
175
  process.exit(1);
@@ -1,9 +1,12 @@
1
+ import { join } from "node:path";
2
+
1
3
  import type { ErrorEnvelope } from "./config-bridge";
2
4
  import {
3
5
  Constants,
4
6
  createErrorResponse,
5
7
  getTasksStatusPath,
6
8
  isLocked,
9
+ resolvePipelinePaths,
7
10
  validateJobId,
8
11
  } from "./config-bridge-node";
9
12
  import { getFileReadingStats, readFileWithRetry } from "./file-reader";
@@ -30,13 +33,22 @@ export interface JobReadingStats {
30
33
 
31
34
  const READ_LOCATIONS = ["current", "complete"] as const;
32
35
 
33
- export async function readJob(jobId: string): Promise<JobReadResult> {
36
+ function getTasksStatusPathForRoot(
37
+ jobId: string,
38
+ location: (typeof READ_LOCATIONS)[number],
39
+ root?: string,
40
+ ): string {
41
+ if (root === undefined) return getTasksStatusPath(jobId, location);
42
+ return join(resolvePipelinePaths(root)[location], jobId, "tasks-status.json");
43
+ }
44
+
45
+ export async function readJob(jobId: string, root?: string): Promise<JobReadResult> {
34
46
  if (!validateJobId(jobId)) {
35
47
  return { ...createErrorResponse(Constants.ERROR_CODES.BAD_REQUEST, "invalid job id"), jobId, location: "" };
36
48
  }
37
49
 
38
50
  for (const location of READ_LOCATIONS) {
39
- const path = getTasksStatusPath(jobId, location);
51
+ const path = getTasksStatusPathForRoot(jobId, location, root);
40
52
  const result = await readFileWithRetry(path);
41
53
  if (!result.ok) {
42
54
  if (result.code === Constants.ERROR_CODES.NOT_FOUND) continue;
@@ -1,6 +1,8 @@
1
1
  import path from "node:path";
2
2
 
3
+ import { resolveUnderRoot } from "../../utils/path-containment";
3
4
  import { embeddedAssets } from "./embedded-assets";
5
+ import type { EmbeddedAssetEntry } from "./embedded-assets";
4
6
  import { handleConcurrencyStatus } from "./endpoints/concurrency-endpoint";
5
7
  import { handleCreatePipeline } from "./endpoints/create-pipeline-endpoint";
6
8
  import { handleTaskFile, handleTaskFileList } from "./endpoints/file-endpoints";
@@ -38,6 +40,7 @@ interface Route {
38
40
  }
39
41
 
40
42
  interface RouterOptions {
43
+ /** Workspace root (not the `pipeline-data` subdir). The CLI transport ensures this is set; the boot function asserts. */
41
44
  dataDir: string;
42
45
  distDir?: string;
43
46
  cors?: CorsConfig;
@@ -49,6 +52,12 @@ function normalizeParams(groups: Record<string, string | undefined>): Record<str
49
52
  );
50
53
  }
51
54
 
55
+ function responseFromEmbedded(entry: EmbeddedAssetEntry): Response {
56
+ return new Response(Bun.file(entry.path), {
57
+ headers: { "Content-Type": entry.mime },
58
+ });
59
+ }
60
+
52
61
  export function createRouter(options: RouterOptions): {
53
62
  addRoute(method: string, path: string, handler: RouteHandler): void;
54
63
  handle(req: Request): Promise<Response>;
@@ -64,11 +73,14 @@ export function createRouter(options: RouterOptions): {
64
73
  const serveAsset = async (pathname: string): Promise<Response | null> => {
65
74
  const embedded = embeddedAssets[pathname];
66
75
  if (embedded) {
67
- return new Response(Bun.file(embedded.path), { headers: { "Content-Type": embedded.mime } });
76
+ return responseFromEmbedded(embedded);
68
77
  }
69
78
 
70
- const diskPath = pathname === "/" ? path.join(distDir, "index.html") : path.join(distDir, pathname);
71
- if (await Bun.file(diskPath).exists()) {
79
+ const diskPath = resolveUnderRoot(
80
+ distDir,
81
+ pathname === "/" ? "index.html" : pathname.replace(/^\/+/, ""),
82
+ );
83
+ if (diskPath !== null && (await Bun.file(diskPath).exists())) {
72
84
  return new Response(Bun.file(diskPath), { headers: { "Content-Type": getMimeType(diskPath) } });
73
85
  }
74
86
 
@@ -77,6 +89,11 @@ export function createRouter(options: RouterOptions): {
77
89
  return new Response(Bun.file(indexPath), { headers: { "Content-Type": "text/html" } });
78
90
  }
79
91
 
92
+ const shell = embeddedAssets["/"];
93
+ if (shell) {
94
+ return responseFromEmbedded(shell);
95
+ }
96
+
80
97
  return null;
81
98
  };
82
99
 
@@ -87,17 +104,17 @@ export function createRouter(options: RouterOptions): {
87
104
  addRoute("POST", "/api/jobs/:jobId/stop", (req, params) => handleJobStop(req, params["jobId"]!, options.dataDir));
88
105
  addRoute("POST", "/api/jobs/:jobId/rescan", (req, params) => handleJobRescan(req, params["jobId"]!, options.dataDir));
89
106
  addRoute("POST", "/api/jobs/:jobId/tasks/:taskId/start", (req, params) => handleTaskStart(req, params["jobId"]!, params["taskId"]!, options.dataDir));
90
- addRoute("GET", "/api/jobs/:jobId/tasks/:taskId/files", (req, params) => handleTaskFileList(req, params["jobId"]!, params["taskId"]!));
91
- addRoute("GET", "/api/jobs/:jobId/tasks/:taskId/file", (req, params) => handleTaskFile(req, params["jobId"]!, params["taskId"]!));
107
+ addRoute("GET", "/api/jobs/:jobId/tasks/:taskId/files", (req, params) => handleTaskFileList(req, params["jobId"]!, params["taskId"]!, options.dataDir));
108
+ addRoute("GET", "/api/jobs/:jobId/tasks/:taskId/file", (req, params) => handleTaskFile(req, params["jobId"]!, params["taskId"]!, options.dataDir));
92
109
  addRoute("GET", "/api/pipelines", () => handlePipelinesList());
93
110
  addRoute("GET", "/api/pipelines/:slug", (_req, params) => handlePipelineTypeDetail(params["slug"]!));
94
- addRoute("POST", "/api/pipelines", (req) => handleCreatePipeline(req));
95
- addRoute("POST", "/api/pipelines/:slug/analyze", (req, params) => handlePipelineAnalysis(req, params["slug"]!));
111
+ addRoute("POST", "/api/pipelines", (req) => handleCreatePipeline(req, options.dataDir));
112
+ addRoute("POST", "/api/pipelines/:slug/analyze", (req, params) => handlePipelineAnalysis(req, params["slug"]!, options.dataDir));
96
113
  addRoute("GET", "/api/pipelines/:slug/artifacts", (req, params) => handlePipelineArtifacts(req, params["slug"]!));
97
- addRoute("GET", "/api/pipelines/:slug/tasks/:taskId/analysis", (req, params) => handleTaskAnalysis(req, params["slug"]!, params["taskId"]!));
98
- addRoute("GET", "/api/pipelines/:slug/schemas/:filename", (req, params) => handleSchemaFile(req, params["slug"]!, params["filename"]!));
114
+ addRoute("GET", "/api/pipelines/:slug/tasks/:taskId/analysis", (req, params) => handleTaskAnalysis(req, params["slug"]!, params["taskId"]!, options.dataDir));
115
+ addRoute("GET", "/api/pipelines/:slug/schemas/:filename", (req, params) => handleSchemaFile(req, params["slug"]!, params["filename"]!, options.dataDir));
99
116
  addRoute("POST", "/api/ai/task-plan", (req) => handleTaskPlan(req));
100
- addRoute("POST", "/api/tasks/create", (req) => handleTaskSave(req));
117
+ addRoute("POST", "/api/tasks/create", (req) => handleTaskSave(req, options.dataDir));
101
118
  addRoute("GET", "/api/state", () => handleApiState());
102
119
  addRoute("GET", "/api/meta", () => handleMeta());
103
120
  addRoute("GET", "/api/concurrency", () => handleConcurrencyStatus(options.dataDir));
@@ -150,6 +167,12 @@ export function createRouter(options: RouterOptions): {
150
167
  return extraHeaders ? decorateHeaders(response, extraHeaders) : response;
151
168
  }
152
169
 
170
+ if (url.pathname.startsWith("/api/")) {
171
+ const notFound = sendJson(404, { ok: false, code: "NOT_FOUND", message: "route not found" });
172
+ const extraHeaders = corsHeadersFor(origin, host, cors);
173
+ return extraHeaders ? decorateHeaders(notFound, extraHeaders) : notFound;
174
+ }
175
+
153
176
  const asset = await serveAsset(url.pathname);
154
177
  if (asset) {
155
178
  const extraHeaders = corsHeadersFor(origin, host, cors);
@@ -1,3 +1,4 @@
1
+ import { emitSseEvent, type SseEvent } from "../../config/sse-events.ts";
1
2
  import { createLogger } from "../../core/logger";
2
3
  import { sseRegistry } from "./sse-registry";
3
4
 
@@ -20,6 +21,8 @@ function parseChange(change: ChangeLike): ChangeLike & { jobId?: string; lifecyc
20
21
  return { ...change, lifecycle: match[1], jobId: match[2] };
21
22
  }
22
23
 
24
+ type StateChangeData = Extract<SseEvent, { type: "state:change" }>["data"];
25
+
23
26
  export function broadcastStateUpdate(currentState: StateLike): void {
24
27
  try {
25
28
  const prioritized = currentState.recentChanges.find((change) =>
@@ -27,11 +30,19 @@ export function broadcastStateUpdate(currentState: StateLike): void {
27
30
  );
28
31
 
29
32
  if (prioritized) {
30
- sseRegistry.broadcast("state:change", parseChange(prioritized));
31
- return;
33
+ const parsed = parseChange(prioritized);
34
+ if (typeof parsed.jobId === "string") {
35
+ const data: StateChangeData = { ...parsed, jobId: parsed.jobId };
36
+ emitSseEvent(sseRegistry, { type: "state:change", data });
37
+ return;
38
+ }
32
39
  }
33
40
 
34
- sseRegistry.broadcast("state:summary", { changeCount: currentState.changeCount });
41
+ const event: SseEvent = {
42
+ type: "state:summary",
43
+ data: { changeCount: currentState.changeCount },
44
+ };
45
+ emitSseEvent(sseRegistry, event);
35
46
  } catch (error) {
36
47
  try {
37
48
  logger.error("failed to broadcast state update", error);
@@ -1,8 +1,12 @@
1
+ import { emitSseEvent, type JobWire, type SseEvent } from "../../config/sse-events.ts";
1
2
  import { transformJobListForAPI } from "../state/transformers/list-transformer";
2
3
  import { transformJobStatus } from "../state/transformers/status-transformer";
3
4
  import { Constants } from "./config-bridge-node";
4
5
  import { readJob, type JobReadResult } from "./job-reader";
5
6
  import { sseRegistry, type SSERegistry } from "./sse-registry";
7
+ import { createLogger } from "../../core/logger";
8
+
9
+ const logger = createLogger("ui-server-sse-enhancer");
6
10
 
7
11
  export interface SSEEnhancerOptions {
8
12
  readJobFn: (jobId: string) => Promise<JobReadResult>;
@@ -29,8 +33,14 @@ export function createSSEEnhancer(options: SSEEnhancerOptions): SSEEnhancer {
29
33
  const transformed = transformJobStatus(result.data, jobId, result.location);
30
34
  if (!transformed) return;
31
35
  const [apiJob] = transformJobListForAPI([transformed], { includePipelineMetadata: true });
32
- options.sseRegistry.broadcast(seen.has(jobId) ? "job:updated" : "job:created", apiJob);
33
- seen.add(jobId);
36
+ if (!apiJob) return;
37
+ const type: SseEvent["type"] = seen.has(jobId) ? "job:updated" : "job:created";
38
+ try {
39
+ emitSseEvent(options.sseRegistry, { type, data: apiJob as JobWire });
40
+ seen.add(jobId);
41
+ } catch (error) {
42
+ logger.error(`failed to emit ${type} for job ${jobId}`, error);
43
+ }
34
44
  };
35
45
 
36
46
  return {
@@ -19,7 +19,17 @@ describe("schema-loader", () => {
19
19
  resetConfig();
20
20
  await Bun.write(
21
21
  registryPath,
22
- JSON.stringify({ pipelines: { demo: { configDir: "pipeline-config/demo", tasksDir: "tasks/demo" } } }),
22
+ JSON.stringify({
23
+ version: 1,
24
+ pipelines: {
25
+ demo: {
26
+ name: "Demo",
27
+ description: "demo pipeline",
28
+ configDir: "pipeline-config/demo",
29
+ tasksDir: "tasks/demo",
30
+ },
31
+ },
32
+ }),
23
33
  );
24
34
  await Bun.write(path.join(pipelineDir, "pipeline.json"), JSON.stringify({ slug: "demo" }));
25
35
  });
@@ -1,6 +1,26 @@
1
- import { describe, expect, it, vi } from "vitest";
1
+ import { mkdir, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
2
4
 
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+
7
+ import { initPATHS, resetPATHS } from "../../server/config-bridge-node";
3
8
  import { buildSnapshotFromFilesystem, composeStateSnapshot } from "../snapshot";
9
+ import type { CanonicalJob, JobReadResult } from "../types";
10
+
11
+ const tempRoots: string[] = [];
12
+
13
+ async function makeTempRoot(): Promise<string> {
14
+ const root = await Bun.$`mktemp -d ${join(tmpdir(), "snapshot-XXXXXX")}`.text();
15
+ const trimmed = root.trim();
16
+ tempRoots.push(trimmed);
17
+ return trimmed;
18
+ }
19
+
20
+ afterEach(async () => {
21
+ resetPATHS();
22
+ await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
23
+ });
4
24
 
5
25
  describe("snapshot", () => {
6
26
  it("composes default snapshots and extracts ids from variant fields", () => {
@@ -17,17 +37,22 @@ describe("snapshot", () => {
17
37
  });
18
38
 
19
39
  it("builds a sorted deduplicated filesystem snapshot from injected deps", async () => {
20
- const readJob = vi.fn(async (jobId: string, location: string) => ({
21
- ok: true,
22
- jobId,
23
- location,
24
- data: {
25
- title: `${jobId}-${location}`,
26
- createdAt: "2024-01-01T00:00:00.000Z",
27
- updatedAt: location === "current" ? "2024-02-01T00:00:00.000Z" : "2024-01-15T00:00:00.000Z",
28
- tasks: { a: { state: location === "current" ? "running" : "done" } },
29
- },
30
- }));
40
+ const readLocations = ["current", "current", "complete"] as const;
41
+ let readIndex = 0;
42
+ const readJob = vi.fn(async (jobId: string) => {
43
+ const location = readLocations[readIndex++] ?? "current";
44
+ return {
45
+ ok: true,
46
+ jobId,
47
+ location,
48
+ data: {
49
+ title: `${jobId}-${location}`,
50
+ createdAt: "2024-01-01T00:00:00.000Z",
51
+ updatedAt: location === "current" ? "2024-02-01T00:00:00.000Z" : "2024-01-15T00:00:00.000Z",
52
+ tasks: { a: { state: location === "current" ? "running" : "done" } },
53
+ },
54
+ };
55
+ });
31
56
 
32
57
  const snapshot = await buildSnapshotFromFilesystem({
33
58
  listAllJobs: () => ({ current: ["job-1", "job-2"], complete: ["job-1"] }),
@@ -45,14 +70,45 @@ describe("snapshot", () => {
45
70
  });
46
71
  });
47
72
 
73
+ it("builds the default filesystem snapshot with the real job reader", async () => {
74
+ const root = await makeTempRoot();
75
+ initPATHS(root);
76
+
77
+ const jobDir = join(root, "pipeline-data", "current", "job-1");
78
+ await mkdir(jobDir, { recursive: true });
79
+ await writeFile(
80
+ join(jobDir, "tasks-status.json"),
81
+ JSON.stringify({
82
+ jobId: "job-1",
83
+ name: "Job 1",
84
+ tasks: { research: { state: "done" } },
85
+ createdAt: "2024-01-01T00:00:00.000Z",
86
+ updatedAt: "2024-02-01T00:00:00.000Z",
87
+ }),
88
+ );
89
+
90
+ const snapshot = await buildSnapshotFromFilesystem({
91
+ now: () => new Date("2024-03-01T00:00:00.000Z"),
92
+ });
93
+
94
+ expect(snapshot.jobs).toEqual([
95
+ expect.objectContaining({
96
+ jobId: "job-1",
97
+ title: "Job 1",
98
+ location: "current",
99
+ }),
100
+ ]);
101
+ expect(snapshot.meta.lastUpdated).toBe("2024-03-01T00:00:00.000Z");
102
+ });
103
+
48
104
  it("catches individual job read failures and continues with successful reads", async () => {
49
105
  const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
50
- const readJob = vi.fn(async (jobId: string, location: string) => {
106
+ const readJob = vi.fn(async (jobId: string) => {
51
107
  if (jobId === "bad-job") throw new Error("disk read failed");
52
108
  return {
53
109
  ok: true,
54
110
  jobId,
55
- location,
111
+ location: "current",
56
112
  data: { title: jobId, tasks: { a: { state: "done" } }, createdAt: "2024-01-01T00:00:00.000Z" },
57
113
  };
58
114
  });
@@ -81,4 +137,54 @@ describe("snapshot", () => {
81
137
  }),
82
138
  ).rejects.toThrow(/unavailable/);
83
139
  });
140
+
141
+ it("sorts snapshot jobs with failed (former error) priority first", async () => {
142
+ const statusByJobId: Record<string, CanonicalJob["status"]> = {
143
+ "job-failed": "failed",
144
+ "job-running": "running",
145
+ "job-complete": "complete",
146
+ "job-pending": "pending",
147
+ };
148
+ const transformMultipleJobs = vi.fn((results: JobReadResult[]): CanonicalJob[] =>
149
+ results.map((result) => ({
150
+ id: result.jobId,
151
+ jobId: result.jobId,
152
+ name: result.jobId,
153
+ title: result.jobId,
154
+ status: statusByJobId[result.jobId] ?? "pending",
155
+ progress: 0,
156
+ createdAt: "2024-01-01T00:00:00.000Z",
157
+ updatedAt: "2024-01-01T00:00:00.000Z",
158
+ location: result.location,
159
+ tasks: {},
160
+ files: {},
161
+ costs: {},
162
+ })),
163
+ );
164
+
165
+ const snapshot = await buildSnapshotFromFilesystem({
166
+ listAllJobs: () => ({
167
+ current: ["job-pending", "job-running", "job-failed", "job-complete"],
168
+ complete: [],
169
+ }),
170
+ readJob: async (jobId) => ({ ok: true, jobId, location: "current", data: {} }),
171
+ transformMultipleJobs,
172
+ now: () => new Date("2024-03-01T00:00:00.000Z"),
173
+ });
174
+
175
+ expect(transformMultipleJobs).toHaveBeenCalledTimes(1);
176
+ expect(snapshot.jobs.map((job) => job.jobId)).toEqual([
177
+ "job-failed",
178
+ "job-running",
179
+ "job-complete",
180
+ "job-pending",
181
+ ]);
182
+ expect(snapshot.jobs.map((job) => job.status)).toEqual([
183
+ "failed",
184
+ "running",
185
+ "complete",
186
+ "pending",
187
+ ]);
188
+ expect(snapshot.jobs[0]?.jobId).toBe("job-failed");
189
+ });
84
190
  });