@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
@@ -70,6 +70,50 @@ describe("router", () => {
70
70
  expect(response.status).toBe(202);
71
71
  expect(body).toMatchObject({ ok: true, jobId: "gate-job", action: "reject", spawned: false });
72
72
  });
73
+
74
+ it("serves the embedded index.html for / when distDir is missing (AC-10)", async () => {
75
+ const root = await makeTempRoot();
76
+ process.env["PO_ROOT"] = root;
77
+ initPATHS(root);
78
+ const router = createRouter({ dataDir: root, distDir: path.join(root, "no-such-dist") });
79
+
80
+ const response = await router.handle(new Request("http://localhost/", { headers: { Host: "localhost" } }));
81
+ expect(response.status).toBe(200);
82
+ expect(response.headers.get("Content-Type")).toBe("text/html");
83
+ });
84
+
85
+ it("serves the embedded index.html for an unknown SPA route when distDir is missing (AC-11)", async () => {
86
+ const root = await makeTempRoot();
87
+ process.env["PO_ROOT"] = root;
88
+ initPATHS(root);
89
+ const router = createRouter({ dataDir: root, distDir: path.join(root, "no-such-dist") });
90
+
91
+ const response = await router.handle(new Request("http://localhost/jobs/client-route", { headers: { Host: "localhost" } }));
92
+ expect(response.status).toBe(200);
93
+ expect(response.headers.get("Content-Type")).toBe("text/html");
94
+ });
95
+
96
+ it("serves an exact embedded /assets/... entry with its generated MIME when distDir is missing (AC-12)", async () => {
97
+ const { embeddedAssets } = await import("../embedded-assets");
98
+ const assetsKey = Object.keys(embeddedAssets).find((k) => k.startsWith("/assets/"));
99
+ if (!assetsKey) {
100
+ throw new Error("expected at least one /assets/... key in embeddedAssets");
101
+ }
102
+ const entry = embeddedAssets[assetsKey];
103
+ if (!entry) {
104
+ throw new Error(`expected embeddedAssets to have entry for ${assetsKey}`);
105
+ }
106
+ const expectedMime = entry.mime;
107
+
108
+ const root = await makeTempRoot();
109
+ process.env["PO_ROOT"] = root;
110
+ initPATHS(root);
111
+ const router = createRouter({ dataDir: root, distDir: path.join(root, "no-such-dist") });
112
+
113
+ const response = await router.handle(new Request(`http://localhost${assetsKey}`, { headers: { Host: "localhost" } }));
114
+ expect(response.status).toBe(200);
115
+ expect(response.headers.get("Content-Type")).toBe(expectedMime);
116
+ });
73
117
  });
74
118
 
75
119
  describe("router CORS and hardening", () => {
@@ -261,3 +305,63 @@ describe("router CORS and hardening", () => {
261
305
  expect(response.status).not.toBe(403);
262
306
  });
263
307
  });
308
+
309
+ describe("router static-asset containment (AC-7)", () => {
310
+ it("does not serve a file outside distDir via an encoded-dotdot escape", async () => {
311
+ const root = await makeTempRoot();
312
+ process.env["PO_ROOT"] = root;
313
+ initPATHS(root);
314
+ await mkdir(path.join(root, "dist"), { recursive: true });
315
+ await writeFile(path.join(root, "dist", "index.html"), "<html>spa</html>");
316
+ await writeFile(path.join(root, "secret.txt"), "AC7-SENTINEL");
317
+
318
+ const router = createRouter({ dataDir: root, distDir: path.join(root, "dist") });
319
+
320
+ const response = await router.handle(new Request("http://localhost/%2e%2fsecret.txt", { headers: { Host: "localhost" } }));
321
+ const body = await response.text();
322
+ expect(body).not.toContain("AC7-SENTINEL");
323
+ });
324
+
325
+ it("does not serve an absolute-path escape after percent-decode", async () => {
326
+ const root = await makeTempRoot();
327
+ process.env["PO_ROOT"] = root;
328
+ initPATHS(root);
329
+ await mkdir(path.join(root, "dist"), { recursive: true });
330
+ await writeFile(path.join(root, "dist", "index.html"), "<html>spa</html>");
331
+
332
+ const router = createRouter({ dataDir: root, distDir: path.join(root, "dist") });
333
+
334
+ const response = await router.handle(new Request("http://localhost/%2fetc%2fpasswd", { headers: { Host: "localhost" } }));
335
+ const body = await response.text();
336
+ expect(body).not.toContain("root:");
337
+ });
338
+
339
+ it("serves a real in-distDir disk asset", async () => {
340
+ const root = await makeTempRoot();
341
+ process.env["PO_ROOT"] = root;
342
+ initPATHS(root);
343
+ await mkdir(path.join(root, "dist", "assets"), { recursive: true });
344
+ await writeFile(path.join(root, "dist", "assets", "app.js"), "console.log('app');");
345
+
346
+ const router = createRouter({ dataDir: root, distDir: path.join(root, "dist") });
347
+
348
+ const response = await router.handle(new Request("http://localhost/assets/app.js", { headers: { Host: "localhost" } }));
349
+ expect(response.status).toBe(200);
350
+ expect(response.headers.get("Content-Type")).toBe("application/javascript");
351
+ await expect(response.text()).resolves.toBe("console.log('app');");
352
+ });
353
+
354
+ it("falls back to index.html for a contained but nonexistent client route", async () => {
355
+ const root = await makeTempRoot();
356
+ process.env["PO_ROOT"] = root;
357
+ initPATHS(root);
358
+ await mkdir(path.join(root, "dist"), { recursive: true });
359
+ await writeFile(path.join(root, "dist", "index.html"), "<html>spa-shell</html>");
360
+
361
+ const router = createRouter({ dataDir: root, distDir: path.join(root, "dist") });
362
+
363
+ const response = await router.handle(new Request("http://localhost/jobs/abc", { headers: { Host: "localhost" } }));
364
+ expect(response.status).toBe(200);
365
+ await expect(response.text()).resolves.toBe("<html>spa-shell</html>");
366
+ });
367
+ });
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
2
 
3
+ import { SseEvent } from "../../../config/sse-events";
3
4
  import { broadcastStateUpdate } from "../sse-broadcast";
4
5
  import { sseRegistry } from "../sse-registry";
5
6
 
@@ -20,6 +21,7 @@ describe("sse-broadcast", () => {
20
21
  "state:change",
21
22
  expect.objectContaining({ jobId: "job-1", lifecycle: "current" }),
22
23
  );
24
+ spy.mockRestore();
23
25
  });
24
26
 
25
27
  it("falls back to summary and never throws", () => {
@@ -29,4 +31,46 @@ describe("sse-broadcast", () => {
29
31
  expect(() => broadcastStateUpdate({ recentChanges: [], changeCount: 2 })).not.toThrow();
30
32
  spy.mockRestore();
31
33
  });
34
+
35
+ it("emits state:summary with changeCount when no parseable job path exists", () => {
36
+ const spy = vi.spyOn(sseRegistry, "broadcast").mockImplementation(() => undefined);
37
+ broadcastStateUpdate({
38
+ recentChanges: [
39
+ {
40
+ path: "some/other/path/tasks-status.json",
41
+ type: "modified",
42
+ timestamp: "2024-01-01T00:00:00.000Z",
43
+ },
44
+ ],
45
+ changeCount: 5,
46
+ });
47
+ expect(spy).toHaveBeenCalledWith("state:summary", { changeCount: 5 });
48
+ const call = spy.mock.calls[0] as unknown as [string, unknown];
49
+ const [type, data] = call;
50
+ expect(SseEvent.safeParse({ type, data }).success).toBe(true);
51
+ spy.mockRestore();
52
+ });
53
+
54
+ it("emits state:change whose payload SseEvent.parse accepts", () => {
55
+ const spy = vi.spyOn(sseRegistry, "broadcast").mockImplementation(() => undefined);
56
+ broadcastStateUpdate({
57
+ recentChanges: [
58
+ {
59
+ path: "pipeline-data/complete/job-7/tasks-status.json",
60
+ type: "modified",
61
+ timestamp: "2024-01-01T00:00:00.000Z",
62
+ },
63
+ ],
64
+ changeCount: 1,
65
+ });
66
+ const call = spy.mock.calls[0] as unknown as [string, unknown];
67
+ const [type, data] = call;
68
+ const parsed = SseEvent.safeParse({ type, data });
69
+ expect(parsed.success).toBe(true);
70
+ if (parsed.success) {
71
+ expect(parsed.data.type).toBe("state:change");
72
+ expect(parsed.data.data.jobId).toBe("job-7");
73
+ }
74
+ spy.mockRestore();
75
+ });
32
76
  });
@@ -1,5 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
 
3
+ import { SseEvent } from "../../../config/sse-events";
3
4
  import { createSSEEnhancer } from "../sse-enhancer";
4
5
 
5
6
  describe("sse-enhancer", () => {
@@ -53,4 +54,73 @@ describe("sse-enhancer", () => {
53
54
  enhancer.cleanup();
54
55
  expect(enhancer.getPendingCount()).toBe(0);
55
56
  });
57
+
58
+ it("broadcasts canonical failed status when the persisted state is 'failed'", async () => {
59
+ const readJobFn = vi.fn(async () => ({
60
+ ok: true as const,
61
+ jobId: "j1",
62
+ location: "current",
63
+ path: "/tmp/j1/tasks-status.json",
64
+ data: {
65
+ title: "Job 1",
66
+ state: "failed",
67
+ createdAt: "2024-01-01T00:00:00.000Z",
68
+ updatedAt: "2024-01-01T00:00:00.000Z",
69
+ tasks: { a: { state: "failed", files: {} } },
70
+ },
71
+ }));
72
+ const broadcast = vi.fn();
73
+ const enhancer = createSSEEnhancer({
74
+ readJobFn,
75
+ sseRegistry: { addClient() {}, removeClient() {}, broadcast, getClientCount() { return 0; }, closeAll() {} },
76
+ debounceMs: 100,
77
+ });
78
+
79
+ enhancer.handleJobChange({ jobId: "j1" });
80
+ vi.advanceTimersByTime(100);
81
+ await Promise.resolve();
82
+
83
+ expect(broadcast).toHaveBeenCalledTimes(1);
84
+ const [, payload] = broadcast.mock.calls[0] as [string, { status: string }];
85
+ expect(broadcast.mock.calls[0]?.[0]).toBe("job:created");
86
+ expect(payload.status).toBe("failed");
87
+ });
88
+
89
+ it("emits a job:updated event whose payload SseEvent.parse accepts and carries jobId", async () => {
90
+ const readJobFn = vi.fn(async () => ({
91
+ ok: true as const,
92
+ jobId: "j1",
93
+ location: "current",
94
+ path: "/tmp/j1/tasks-status.json",
95
+ data: {
96
+ title: "Job 1",
97
+ createdAt: "2024-01-01T00:00:00.000Z",
98
+ updatedAt: "2024-01-01T00:00:00.000Z",
99
+ tasks: { a: { state: "done", files: {} } },
100
+ },
101
+ }));
102
+ const broadcast = vi.fn();
103
+ const enhancer = createSSEEnhancer({
104
+ readJobFn,
105
+ sseRegistry: { addClient() {}, removeClient() {}, broadcast, getClientCount() { return 0; }, closeAll() {} },
106
+ debounceMs: 100,
107
+ });
108
+
109
+ enhancer.handleJobChange({ jobId: "j1" });
110
+ vi.advanceTimersByTime(100);
111
+ await Promise.resolve();
112
+ enhancer.handleJobChange({ jobId: "j1" });
113
+ vi.advanceTimersByTime(100);
114
+ await Promise.resolve();
115
+
116
+ const updatedCall = broadcast.mock.calls.find((call) => call[0] === "job:updated");
117
+ expect(updatedCall).toBeDefined();
118
+ const [type, data] = updatedCall as [string, unknown];
119
+ const parsed = SseEvent.safeParse({ type, data });
120
+ expect(parsed.success).toBe(true);
121
+ if (parsed.success) {
122
+ expect(parsed.data.type).toBe("job:updated");
123
+ expect(parsed.data.data.jobId).toBe("j1");
124
+ }
125
+ });
56
126
  });
@@ -11,6 +11,8 @@ import {
11
11
  validateTaskState,
12
12
  } from "./config-bridge";
13
13
 
14
+ import { resolvePipelinePaths as resolvePipelinePathsCore } from "../../config/paths";
15
+
14
16
  export type { ErrorEnvelope } from "./config-bridge";
15
17
 
16
18
  export interface ResolvedPaths {
@@ -20,30 +22,14 @@ export interface ResolvedPaths {
20
22
  rejected: string;
21
23
  }
22
24
 
23
- const DEFAULT_ROOT = process.env["PO_ROOT"] ?? process.cwd();
24
-
25
25
  let cachedPaths: ResolvedPaths | null = null;
26
26
 
27
27
  export const Constants = {
28
28
  ...UniversalConstants,
29
- RETRY_CONFIG: {
30
- ...UniversalConstants.RETRY_CONFIG,
31
- DELAY_MS: process.env["NODE_ENV"] === "test" ? 10 : UniversalConstants.RETRY_CONFIG.DELAY_MS,
32
- },
33
29
  } as const;
34
30
 
35
- function toRoot(root?: string): string {
36
- return path.resolve(root ?? DEFAULT_ROOT);
37
- }
38
-
39
- export function resolvePipelinePaths(root?: string): ResolvedPaths {
40
- const base = toRoot(root);
41
- return {
42
- current: path.join(base, "pipeline-data", "current"),
43
- complete: path.join(base, "pipeline-data", "complete"),
44
- pending: path.join(base, "pipeline-data", "pending"),
45
- rejected: path.join(base, "pipeline-data", "rejected"),
46
- };
31
+ export function resolvePipelinePaths(root: string): ResolvedPaths {
32
+ return resolvePipelinePathsCore(root);
47
33
  }
48
34
 
49
35
  export function initPATHS(root: string): void {
@@ -54,13 +40,11 @@ export function resetPATHS(): void {
54
40
  cachedPaths = null;
55
41
  }
56
42
 
57
- export function getPATHS(root?: string): ResolvedPaths {
58
- if (root) {
59
- initPATHS(root);
60
- } else if (!cachedPaths) {
61
- cachedPaths = resolvePipelinePaths();
43
+ export function getPATHS(): ResolvedPaths {
44
+ if (!cachedPaths) {
45
+ throw new Error("initPATHS(root) must be called before getPATHS()");
62
46
  }
63
- return cachedPaths!;
47
+ return cachedPaths;
64
48
  }
65
49
 
66
50
  export function getJobPath(jobId: string, location: keyof ResolvedPaths = "current"): string {
@@ -109,8 +93,6 @@ export {
109
93
  validateTaskState,
110
94
  };
111
95
 
112
- export const PATHS = getPATHS();
113
-
114
96
  export function asErrorEnvelope(error: unknown, fallbackCode = Constants.ERROR_CODES.FS_ERROR): ErrorEnvelope {
115
97
  return createErrorResponse(
116
98
  fallbackCode,
@@ -16,7 +16,7 @@ export const Constants = {
16
16
  JOB_ID_REGEX: /^[A-Za-z0-9-_]+$/,
17
17
  TASK_STATES: Object.freeze(Object.values(TaskState)),
18
18
  JOB_LOCATIONS: Object.freeze(Object.values(JobLocation)),
19
- STATUS_ORDER: Object.freeze(["running", "error", "pending", "complete"]),
19
+ STATUS_ORDER: Object.freeze(["running", "failed", "pending", "complete"]),
20
20
  FILE_LIMITS: Object.freeze({ MAX_FILE_SIZE: 5 * 1024 * 1024 }),
21
21
  RETRY_CONFIG: Object.freeze({ MAX_ATTEMPTS: 3, DELAY_MS: 10 }),
22
22
  SSE_CONFIG: Object.freeze({ DEBOUNCE_MS: 200 }),
@@ -32,7 +32,7 @@ export const Constants = {
32
32
 
33
33
  const STATUS_PRIORITY = new Map<string, number>([
34
34
  ["running", 4],
35
- ["error", 3],
35
+ ["failed", 3],
36
36
  ["pending", 2],
37
37
  ["complete", 1],
38
38
  ]);
@@ -53,8 +53,7 @@ export function determineJobStatus(tasks: Record<string, { state: string }>): st
53
53
  const normalizedTasks = Object.values(tasks).map((task) => ({
54
54
  state: task.state,
55
55
  }));
56
- const status = deriveJobStatusFromTasks(normalizedTasks);
57
- return status === "failed" ? "error" : status;
56
+ return deriveJobStatusFromTasks(normalizedTasks);
58
57
  }
59
58
 
60
59
  export function createErrorResponse(
@@ -0,0 +1,44 @@
1
+ // Ambient module declarations for non-HTML Vite asset file imports used by the
2
+ // generated src/ui/server/embedded-assets.ts module. `*.html` is intentionally
3
+ // left to bun-types (declared as `import("bun").HTMLBundle`); the generator
4
+ // casts every emitted `path` value `as unknown as string` to bridge the type.
5
+ declare module "*.js" {
6
+ const path: string;
7
+ export default path;
8
+ }
9
+ declare module "*.css" {
10
+ const path: string;
11
+ export default path;
12
+ }
13
+ declare module "*.svg" {
14
+ const path: string;
15
+ export default path;
16
+ }
17
+ declare module "*.map" {
18
+ const path: string;
19
+ export default path;
20
+ }
21
+ declare module "*.png" {
22
+ const path: string;
23
+ export default path;
24
+ }
25
+ declare module "*.jpg" {
26
+ const path: string;
27
+ export default path;
28
+ }
29
+ declare module "*.ico" {
30
+ const path: string;
31
+ export default path;
32
+ }
33
+ declare module "*.woff" {
34
+ const path: string;
35
+ export default path;
36
+ }
37
+ declare module "*.woff2" {
38
+ const path: string;
39
+ export default path;
40
+ }
41
+ declare module "*.ttf" {
42
+ const path: string;
43
+ export default path;
44
+ }
@@ -1,7 +1,18 @@
1
+ // Auto-generated by scripts/generate-embedded-assets.js — do not edit
2
+ import _asset0 from "../dist/assets/index-DN3-zvtP.js" with { type: "file" };
3
+ import _asset1 from "../dist/assets/index-DN3-zvtP.js.map" with { type: "file" };
4
+ import _asset2 from "../dist/assets/style-CtZBnjlR.css" with { type: "file" };
5
+ import _asset3 from "../dist/index.html" with { type: "file" };
6
+
1
7
  export interface EmbeddedAssetEntry {
2
8
  path: string;
3
9
  mime: string;
4
10
  }
5
11
 
6
- // Generated during UI build for embedded/binary distributions.
7
- export const embeddedAssets: Record<string, EmbeddedAssetEntry> = {};
12
+ export const embeddedAssets: Record<string, EmbeddedAssetEntry> = {
13
+ "/assets/index-DN3-zvtP.js": { path: _asset0 as unknown as string, mime: "application/javascript" },
14
+ "/assets/index-DN3-zvtP.js.map": { path: _asset1 as unknown as string, mime: "application/json" },
15
+ "/assets/style-CtZBnjlR.css": { path: _asset2 as unknown as string, mime: "text/css" },
16
+ "/": { path: _asset3 as unknown as string, mime: "text/html" },
17
+ "/index.html": { path: _asset3 as unknown as string, mime: "text/html" }
18
+ };
@@ -11,6 +11,7 @@ describe("handleMeta", () => {
11
11
  const body = await res.json() as { ok: boolean; data: Record<string, unknown> };
12
12
  expect(body.ok).toBe(true);
13
13
  expect(body.data["protocolVersion"]).toBe(PROTOCOL_VERSION);
14
+ expect(body.data["protocolVersion"]).toBe(2);
14
15
  });
15
16
 
16
17
  it("serves the exact package.json version (drift guard)", async () => {
@@ -0,0 +1,167 @@
1
+ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
2
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import type { AnalysisResult } from "../../../../task-analysis/analyzer";
7
+
8
+ // ─── Mock the analyzer so no LLM/gateway is exercised ─────────────────────────
9
+ // `analyzeResults` is keyed by task name; default is an `ok` result.
10
+ const analyzeResults: Record<string, AnalysisResult> = {};
11
+ let lastAnalyzedSource: string | null = null;
12
+
13
+ function okResult(): AnalysisResult {
14
+ return {
15
+ summary: "does a thing",
16
+ stages: [{ name: "main", purpose: "runs" }],
17
+ artifacts: { reads: [], writes: [] },
18
+ models: [],
19
+ analysisStatus: "ok",
20
+ };
21
+ }
22
+
23
+ mock.module("../../../../task-analysis/analyzer", () => ({
24
+ analyzeTask: mock((code: string) => {
25
+ lastAnalyzedSource = code;
26
+ return Promise.resolve(analyzeResults[code] ?? okResult());
27
+ }),
28
+ }));
29
+
30
+ import { handlePipelineAnalysis } from "../pipeline-analysis-endpoint";
31
+ import { TaskAnalysisRepository } from "../../../../task-analysis/repository";
32
+ import { getLockStatus, releaseLock } from "../../../state/analysis-lock";
33
+
34
+ // ─── SSE helpers ──────────────────────────────────────────────────────────────
35
+
36
+ interface SseEvent {
37
+ event: string;
38
+ data: Record<string, unknown>;
39
+ }
40
+
41
+ async function readEvents(res: Response): Promise<SseEvent[]> {
42
+ const text = await res.text();
43
+ const events: SseEvent[] = [];
44
+ for (const block of text.split("\n\n")) {
45
+ const trimmed = block.trim();
46
+ if (!trimmed) continue;
47
+ const eventLine = trimmed.split("\n").find((l) => l.startsWith("event: "));
48
+ const dataLine = trimmed.split("\n").find((l) => l.startsWith("data: "));
49
+ if (!eventLine || !dataLine) continue;
50
+ events.push({
51
+ event: eventLine.slice("event: ".length),
52
+ data: JSON.parse(dataLine.slice("data: ".length)),
53
+ });
54
+ }
55
+ return events;
56
+ }
57
+
58
+ // ─── Fixture: a workspace root with a registry + one task source ──────────────
59
+
60
+ let root: string;
61
+
62
+ function writeWorkspace(tasks: Record<string, string>): void {
63
+ const tasksDir = join(root, "pipeline-config", "demo", "tasks");
64
+ mkdirSync(tasksDir, { recursive: true });
65
+
66
+ // Registry entry so resolvePipelineSync finds the slug.
67
+ const registry = { version: 1, pipelines: { demo: { name: "demo", description: "d" } } };
68
+ writeFileSync(join(root, "pipeline-config", "registry.json"), JSON.stringify(registry));
69
+
70
+ // Task registry index (default export: task name -> relative module path).
71
+ const entries = Object.keys(tasks)
72
+ .map((name) => ` ${JSON.stringify(name)}: ${JSON.stringify(`./${name}.ts`)},`)
73
+ .join("\n");
74
+ writeFileSync(join(tasksDir, "index.ts"), `export default {\n${entries}\n};\n`);
75
+
76
+ for (const [name, source] of Object.entries(tasks)) {
77
+ writeFileSync(join(tasksDir, `${name}.ts`), source);
78
+ }
79
+ }
80
+
81
+ beforeEach(() => {
82
+ root = mkdtempSync(join(tmpdir(), "po-analysis-"));
83
+ for (const key of Object.keys(analyzeResults)) delete analyzeResults[key];
84
+ lastAnalyzedSource = null;
85
+ });
86
+
87
+ afterEach(() => {
88
+ if (getLockStatus()) releaseLock(getLockStatus()!.pipelineSlug);
89
+ rmSync(root, { recursive: true, force: true });
90
+ });
91
+
92
+ describe("handlePipelineAnalysis", () => {
93
+ it("emits started, per-task task:start/task:complete, then complete", async () => {
94
+ writeWorkspace({ alpha: "// alpha source", beta: "// beta source" });
95
+
96
+ const res = await handlePipelineAnalysis(new Request("http://localhost/"), "demo", root);
97
+ const events = await readEvents(res);
98
+
99
+ expect(events[0]).toEqual({ event: "started", data: { slug: "demo", totalTasks: 2 } });
100
+
101
+ const names = events.map((e) => e.event);
102
+ expect(names).toEqual([
103
+ "started",
104
+ "task:start",
105
+ "task:complete",
106
+ "task:start",
107
+ "task:complete",
108
+ "complete",
109
+ ]);
110
+ expect(events.at(-1)).toEqual({ event: "complete", data: { slug: "demo" } });
111
+ });
112
+
113
+ it("persists each analyzed task via the repository", async () => {
114
+ writeWorkspace({ alpha: "// alpha source" });
115
+
116
+ const res = await handlePipelineAnalysis(new Request("http://localhost/"), "demo", root);
117
+ await readEvents(res);
118
+
119
+ const persisted = await TaskAnalysisRepository.read(root, "demo", "alpha");
120
+ expect(persisted?.analysisStatus).toBe("ok");
121
+ expect(persisted?.summary).toBe("does a thing");
122
+ expect(typeof persisted?.analyzedAt).toBe("string");
123
+ });
124
+
125
+ it("persists an error placeholder for a failing task yet still reaches complete", async () => {
126
+ writeWorkspace({ good: "// good source", bad: "// bad source" });
127
+ // Force the analyzer to throw for the bad task's source.
128
+ analyzeResults["// bad source"] = undefined as unknown as AnalysisResult;
129
+ const analyzerMod = await import("../../../../task-analysis/analyzer");
130
+ (analyzerMod.analyzeTask as ReturnType<typeof mock>).mockImplementation((code: string) => {
131
+ if (code === "// bad source") throw new Error("analyzer blew up");
132
+ return Promise.resolve(okResult());
133
+ });
134
+
135
+ const res = await handlePipelineAnalysis(new Request("http://localhost/"), "demo", root);
136
+ const events = await readEvents(res);
137
+
138
+ expect(events.at(-1)?.event).toBe("complete");
139
+ const errorEvent = events.find((e) => e.event === "error");
140
+ expect(errorEvent?.data["task"]).toBe("bad");
141
+
142
+ const badAnalysis = await TaskAnalysisRepository.read(root, "demo", "bad");
143
+ expect(badAnalysis?.analysisStatus).toBe("error");
144
+ expect(badAnalysis?.analysisError).toContain("analyzer blew up");
145
+
146
+ // Restore the default mock implementation for later tests.
147
+ (analyzerMod.analyzeTask as ReturnType<typeof mock>).mockImplementation((code: string) => {
148
+ lastAnalyzedSource = code;
149
+ return Promise.resolve(analyzeResults[code] ?? okResult());
150
+ });
151
+ });
152
+
153
+ it("blocks a concurrent run with a 409 while the lock is held", async () => {
154
+ writeWorkspace({ alpha: "// alpha source" });
155
+
156
+ // Acquire the lock by starting one run but not draining its stream.
157
+ const first = await handlePipelineAnalysis(new Request("http://localhost/"), "demo", root);
158
+
159
+ const second = await handlePipelineAnalysis(new Request("http://localhost/"), "demo", root);
160
+ expect(second.status).toBe(409);
161
+ const body = await second.json();
162
+ expect(body.code).toBe("BAD_REQUEST");
163
+
164
+ // Drain the first to release the lock.
165
+ await readEvents(first);
166
+ });
167
+ });