@ryanfw/prompt-orchestration-pipeline 1.3.3 → 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 (198) hide show
  1. package/docs/http-api.md +14 -4
  2. package/package.json +1 -4
  3. package/src/api/__tests__/index.test.ts +144 -41
  4. package/src/api/index.ts +15 -83
  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/paths.ts +11 -2
  17. package/src/config/pipeline-registry.ts +258 -0
  18. package/src/config/sse-events.ts +122 -0
  19. package/src/core/__tests__/agent-step.test.ts +115 -0
  20. package/src/core/__tests__/config.test.ts +517 -107
  21. package/src/core/__tests__/core-boot-resolution.test.ts +181 -0
  22. package/src/core/__tests__/job-concurrency.test.ts +200 -0
  23. package/src/core/__tests__/job-submission.test.ts +396 -0
  24. package/src/core/__tests__/job-view.test.ts +485 -3
  25. package/src/core/__tests__/json-file.test.ts +111 -0
  26. package/src/core/__tests__/lifecycle-policy.test.ts +81 -7
  27. package/src/core/__tests__/logger.test.ts +22 -0
  28. package/src/core/__tests__/orchestrator-no-deprecated-exports.test.ts +41 -0
  29. package/src/core/__tests__/orchestrator.test.ts +373 -2
  30. package/src/core/__tests__/pipeline-runner.test.ts +946 -9
  31. package/src/core/__tests__/redact.test.ts +153 -0
  32. package/src/core/__tests__/runner-liveness.test.ts +45 -0
  33. package/src/core/__tests__/seed-naming.test.ts +57 -0
  34. package/src/core/__tests__/single-derivation.test.ts +1 -1
  35. package/src/core/__tests__/task-runner.test.ts +594 -3
  36. package/src/core/__tests__/task-telemetry.test.ts +241 -0
  37. package/src/core/__tests__/workspace-root-resolution-guard.test.ts +62 -0
  38. package/src/core/agent-step.ts +4 -1
  39. package/src/core/agent-types.ts +5 -4
  40. package/src/core/config.ts +127 -234
  41. package/src/core/control.ts +3 -0
  42. package/src/core/job-concurrency.ts +50 -19
  43. package/src/core/job-submission.ts +133 -0
  44. package/src/core/job-view.ts +162 -18
  45. package/src/core/json-file.ts +45 -0
  46. package/src/core/lifecycle-policy.ts +23 -8
  47. package/src/core/logger.ts +0 -29
  48. package/src/core/orchestrator.ts +124 -70
  49. package/src/core/pipeline-runner.ts +85 -53
  50. package/src/core/redact.ts +40 -0
  51. package/src/core/runner-liveness.ts +8 -4
  52. package/src/core/seed-naming.ts +9 -0
  53. package/src/core/status-writer.ts +3 -28
  54. package/src/core/task-runner.ts +356 -319
  55. package/src/core/task-telemetry.ts +107 -0
  56. package/src/harness/__tests__/subprocess.test.ts +112 -1
  57. package/src/harness/subprocess.ts +55 -16
  58. package/src/llm/__tests__/index.test.ts +684 -33
  59. package/src/llm/index.ts +310 -67
  60. package/src/providers/__tests__/alibaba.test.ts +67 -6
  61. package/src/providers/__tests__/anthropic.test.ts +35 -14
  62. package/src/providers/__tests__/base.test.ts +62 -0
  63. package/src/providers/__tests__/claude-code.test.ts +99 -14
  64. package/src/providers/__tests__/deepseek.test.ts +16 -6
  65. package/src/providers/__tests__/gemini.test.ts +47 -25
  66. package/src/providers/__tests__/moonshot.test.ts +27 -0
  67. package/src/providers/__tests__/openai.test.ts +262 -74
  68. package/src/providers/__tests__/opencode.test.ts +77 -0
  69. package/src/providers/__tests__/request-timeout-contract.test.ts +226 -0
  70. package/src/providers/__tests__/stream-accumulator.test.ts +52 -0
  71. package/src/providers/__tests__/types.test.ts +85 -0
  72. package/src/providers/__tests__/zero-fill-guard.test.ts +62 -0
  73. package/src/providers/__tests__/zhipu.test.ts +27 -14
  74. package/src/providers/alibaba.ts +20 -39
  75. package/src/providers/anthropic.ts +23 -11
  76. package/src/providers/base.ts +19 -0
  77. package/src/providers/claude-code.ts +27 -18
  78. package/src/providers/deepseek.ts +9 -28
  79. package/src/providers/gemini.ts +20 -58
  80. package/src/providers/moonshot.ts +15 -11
  81. package/src/providers/openai.ts +79 -61
  82. package/src/providers/opencode.ts +16 -33
  83. package/src/providers/stream-accumulator.ts +27 -21
  84. package/src/providers/types.ts +29 -4
  85. package/src/providers/zhipu.ts +15 -44
  86. package/src/task-analysis/__tests__/analyzer.test.ts +0 -0
  87. package/src/task-analysis/__tests__/enrichers-schema-deducer.test.ts +34 -2
  88. package/src/task-analysis/__tests__/no-ast-imports.test.ts +52 -0
  89. package/src/task-analysis/__tests__/repository.test.ts +65 -0
  90. package/src/task-analysis/__tests__/types.test.ts +63 -130
  91. package/src/task-analysis/analyzer.ts +91 -0
  92. package/src/task-analysis/enrichers/schema-deducer.ts +13 -2
  93. package/src/task-analysis/index.ts +2 -36
  94. package/src/task-analysis/repository.ts +45 -0
  95. package/src/task-analysis/types.ts +42 -58
  96. package/src/ui/client/__tests__/api.test.ts +143 -1
  97. package/src/ui/client/__tests__/bootstrap.test.ts +178 -62
  98. package/src/ui/client/__tests__/job-adapter.test.ts +125 -2
  99. package/src/ui/client/__tests__/load-state.test.ts +70 -0
  100. package/src/ui/client/__tests__/types.test.ts +66 -3
  101. package/src/ui/client/__tests__/useJobDetailWithUpdates.test.ts +390 -77
  102. package/src/ui/client/__tests__/useJobList.test.ts +198 -23
  103. package/src/ui/client/__tests__/useJobListWithUpdates.test.ts +218 -7
  104. package/src/ui/client/adapters/__tests__/job-adapter.test.ts +186 -0
  105. package/src/ui/client/adapters/job-adapter.ts +31 -16
  106. package/src/ui/client/api.ts +38 -15
  107. package/src/ui/client/bootstrap.ts +19 -14
  108. package/src/ui/client/hooks/useJobDetailWithUpdates.ts +73 -97
  109. package/src/ui/client/hooks/useJobList.ts +26 -31
  110. package/src/ui/client/hooks/useJobListWithUpdates.ts +42 -76
  111. package/src/ui/client/load-state.ts +20 -0
  112. package/src/ui/client/reducers/__tests__/job-events.test.ts +568 -0
  113. package/src/ui/client/reducers/job-events.ts +137 -0
  114. package/src/ui/client/types.ts +14 -20
  115. package/src/ui/components/DAGGrid.tsx +6 -1
  116. package/src/ui/components/JobDetail.tsx +12 -4
  117. package/src/ui/components/JobTable.tsx +13 -2
  118. package/src/ui/components/StageTimeline.tsx +8 -20
  119. package/src/ui/components/TaskAnalysisDisplay.tsx +48 -6
  120. package/src/ui/components/__tests__/DAGGrid.test.tsx +36 -0
  121. package/src/ui/components/__tests__/JobDetail.test.tsx +112 -0
  122. package/src/ui/components/__tests__/JobTable.test.tsx +83 -0
  123. package/src/ui/components/__tests__/StageTimeline.test.tsx +5 -6
  124. package/src/ui/components/__tests__/TaskAnalysisDisplay.test.tsx +64 -0
  125. package/src/ui/components/types.ts +33 -15
  126. package/src/ui/dist/assets/{index-L6cvsCAx.js → index-DN3-zvtP.js} +4299 -251
  127. package/src/ui/dist/assets/index-DN3-zvtP.js.map +1 -0
  128. package/src/ui/dist/assets/style-CtZBnjlR.css +2 -0
  129. package/src/ui/dist/index.html +2 -2
  130. package/src/ui/pages/PipelineDetail.tsx +60 -4
  131. package/src/ui/pages/PromptPipelineDashboard.tsx +59 -7
  132. package/src/ui/pages/__tests__/PromptPipelineDashboard.test.tsx +114 -32
  133. package/src/ui/pages/__tests__/pages.test.tsx +236 -42
  134. package/src/ui/server/__tests__/concurrency-endpoint.test.ts +54 -0
  135. package/src/ui/server/__tests__/config-bridge-node.test.ts +34 -1
  136. package/src/ui/server/__tests__/embedded-assets.test.ts +66 -0
  137. package/src/ui/server/__tests__/file-endpoints.test.ts +183 -5
  138. package/src/ui/server/__tests__/gate-endpoints.test.ts +55 -0
  139. package/src/ui/server/__tests__/index.test.ts +63 -0
  140. package/src/ui/server/__tests__/job-control-endpoints.test.ts +455 -1
  141. package/src/ui/server/__tests__/job-endpoints.test.ts +43 -1
  142. package/src/ui/server/__tests__/read-static-path-guard.test.ts +94 -0
  143. package/src/ui/server/__tests__/router-root-isolation.test.ts +204 -0
  144. package/src/ui/server/__tests__/router-threading.test.ts +148 -0
  145. package/src/ui/server/__tests__/router.test.ts +104 -0
  146. package/src/ui/server/__tests__/sse-broadcast.test.ts +44 -0
  147. package/src/ui/server/__tests__/sse-enhancer.test.ts +39 -0
  148. package/src/ui/server/config-bridge-node.ts +8 -26
  149. package/src/ui/server/embedded-assets-imports.d.ts +44 -0
  150. package/src/ui/server/embedded-assets.ts +13 -2
  151. package/src/ui/server/endpoints/__tests__/pipeline-analysis-endpoint.test.ts +167 -0
  152. package/src/ui/server/endpoints/__tests__/schema-file-endpoint.test.ts +104 -0
  153. package/src/ui/server/endpoints/__tests__/task-analysis-endpoint.test.ts +103 -0
  154. package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +162 -96
  155. package/src/ui/server/endpoints/concurrency-endpoint.ts +2 -1
  156. package/src/ui/server/endpoints/create-pipeline-endpoint.ts +14 -29
  157. package/src/ui/server/endpoints/file-endpoints.ts +15 -10
  158. package/src/ui/server/endpoints/job-control-endpoints.ts +122 -73
  159. package/src/ui/server/endpoints/job-endpoints.ts +1 -0
  160. package/src/ui/server/endpoints/pipeline-analysis-endpoint.ts +99 -11
  161. package/src/ui/server/endpoints/schema-file-endpoint.ts +11 -3
  162. package/src/ui/server/endpoints/task-analysis-endpoint.ts +21 -5
  163. package/src/ui/server/endpoints/task-save-endpoint.ts +1 -2
  164. package/src/ui/server/endpoints/upload-endpoints.ts +5 -40
  165. package/src/ui/server/index.ts +19 -14
  166. package/src/ui/server/job-reader.ts +14 -2
  167. package/src/ui/server/router.ts +33 -10
  168. package/src/ui/server/sse-broadcast.ts +14 -3
  169. package/src/ui/server/sse-enhancer.ts +12 -2
  170. package/src/ui/state/__tests__/schema-loader.test.ts +11 -1
  171. package/src/ui/state/__tests__/snapshot.test.ts +70 -15
  172. package/src/ui/state/__tests__/types.test.ts +6 -0
  173. package/src/ui/state/snapshot.ts +1 -1
  174. package/src/ui/state/transformers/__tests__/list-transformer.test.ts +42 -0
  175. package/src/ui/state/transformers/__tests__/status-transformer.test.ts +45 -3
  176. package/src/ui/state/transformers/list-transformer.ts +6 -0
  177. package/src/ui/state/types.ts +6 -1
  178. package/src/utils/__tests__/path-containment.test.ts +160 -0
  179. package/src/{ui/server/utils → utils}/path-containment.ts +21 -0
  180. package/src/task-analysis/__tests__/enrichers-analysis-writer.test.ts +0 -86
  181. package/src/task-analysis/__tests__/enrichers-artifact-resolver.test.ts +0 -124
  182. package/src/task-analysis/__tests__/extractors-artifacts.test.ts +0 -133
  183. package/src/task-analysis/__tests__/extractors-llm-calls.test.ts +0 -46
  184. package/src/task-analysis/__tests__/extractors-stages.test.ts +0 -52
  185. package/src/task-analysis/__tests__/index.test.ts +0 -143
  186. package/src/task-analysis/__tests__/parser.test.ts +0 -41
  187. package/src/task-analysis/__tests__/utils-ast.test.ts +0 -82
  188. package/src/task-analysis/enrichers/analysis-writer.ts +0 -75
  189. package/src/task-analysis/enrichers/artifact-resolver.ts +0 -89
  190. package/src/task-analysis/extractors/artifacts.ts +0 -143
  191. package/src/task-analysis/extractors/llm-calls.ts +0 -117
  192. package/src/task-analysis/extractors/stages.ts +0 -45
  193. package/src/task-analysis/parser.ts +0 -20
  194. package/src/task-analysis/utils/ast.ts +0 -45
  195. package/src/ui/dist/assets/index-L6cvsCAx.js.map +0 -1
  196. package/src/ui/dist/assets/style-CSSKuMOe.css +0 -2
  197. package/src/ui/embedded-assets.js +0 -12
  198. package/src/ui/server/__tests__/path-containment.test.ts +0 -54
@@ -0,0 +1,204 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
6
+
7
+ import { resetPATHS } from "../config-bridge-node";
8
+ import { createRouter } from "../router";
9
+
10
+ const ORIGINAL_PO_ROOT = process.env["PO_ROOT"];
11
+
12
+ const tempRoots: string[] = [];
13
+
14
+ async function makeTempRoot(prefix: string): Promise<string> {
15
+ const root = await Bun.$`mktemp -d ${path.join(os.tmpdir(), `${prefix}-XXXXXX`)}`.text();
16
+ const trimmed = root.trim();
17
+ tempRoots.push(trimmed);
18
+ return trimmed;
19
+ }
20
+
21
+ beforeEach(() => {
22
+ resetPATHS();
23
+ });
24
+
25
+ afterEach(async () => {
26
+ if (ORIGINAL_PO_ROOT === undefined) {
27
+ delete process.env["PO_ROOT"];
28
+ } else {
29
+ process.env["PO_ROOT"] = ORIGINAL_PO_ROOT;
30
+ }
31
+ resetPATHS();
32
+ await Promise.all(tempRoots.splice(0).map((root) => Bun.$`rm -rf ${root}`));
33
+ });
34
+
35
+ describe("router root isolation (AC-5)", () => {
36
+ it("handleTaskFile reads from the threaded root, ignoring PO_ROOT=/B", async () => {
37
+ const rootA = await makeTempRoot("router-iso-A");
38
+ const rootB = await makeTempRoot("router-iso-B");
39
+ process.env["PO_ROOT"] = rootB;
40
+
41
+ const jobDir = path.join(rootA, "pipeline-data", "current", "job-1");
42
+ await mkdir(path.join(jobDir, "files", "artifacts"), { recursive: true });
43
+ await writeFile(path.join(jobDir, "files", "artifacts", "out.json"), '{"source":"A"}\n');
44
+ await writeFile(
45
+ path.join(jobDir, "tasks-status.json"),
46
+ JSON.stringify({
47
+ id: "job-1",
48
+ tasks: {
49
+ research: {
50
+ files: { artifacts: ["out.json"], logs: [], tmp: [] },
51
+ },
52
+ },
53
+ }),
54
+ );
55
+
56
+ const router = createRouter({ dataDir: rootA });
57
+ const response = await router.handle(
58
+ new Request("http://localhost/api/jobs/job-1/tasks/research/file?type=artifacts&filename=out.json", {
59
+ headers: { Host: "localhost" },
60
+ }),
61
+ );
62
+
63
+ expect(response.status).toBe(200);
64
+ await expect(response.json()).resolves.toMatchObject({
65
+ ok: true,
66
+ data: '{"source":"A"}\n',
67
+ mime: "application/json",
68
+ });
69
+ });
70
+
71
+ it("handleTaskFileList reads from the threaded root, ignoring PO_ROOT=/B", async () => {
72
+ const rootA = await makeTempRoot("router-iso-A");
73
+ const rootB = await makeTempRoot("router-iso-B");
74
+ process.env["PO_ROOT"] = rootB;
75
+
76
+ const jobDir = path.join(rootA, "pipeline-data", "current", "job-1");
77
+ await mkdir(jobDir, { recursive: true });
78
+ await writeFile(
79
+ path.join(jobDir, "tasks-status.json"),
80
+ JSON.stringify({
81
+ id: "job-1",
82
+ tasks: {
83
+ research: {
84
+ files: { artifacts: ["listed.json"], logs: [], tmp: [] },
85
+ },
86
+ },
87
+ }),
88
+ );
89
+
90
+ const router = createRouter({ dataDir: rootA });
91
+ const response = await router.handle(
92
+ new Request("http://localhost/api/jobs/job-1/tasks/research/files?type=artifacts", {
93
+ headers: { Host: "localhost" },
94
+ }),
95
+ );
96
+
97
+ expect(response.status).toBe(200);
98
+ await expect(response.json()).resolves.toEqual({ ok: true, data: ["listed.json"] });
99
+ });
100
+
101
+ it("handleSchemaFile reads from the threaded root, ignoring PO_ROOT=/B", async () => {
102
+ const rootA = await makeTempRoot("router-iso-A");
103
+ const rootB = await makeTempRoot("router-iso-B");
104
+ process.env["PO_ROOT"] = rootB;
105
+
106
+ await mkdir(path.join(rootA, "pipeline-config", "slug-1", "schemas"), { recursive: true });
107
+ await writeFile(path.join(rootA, "pipeline-config", "slug-1", "schemas", "task.schema.json"), '{"$schema":"http://example"}\n');
108
+
109
+ const router = createRouter({ dataDir: rootA });
110
+ const response = await router.handle(
111
+ new Request("http://localhost/api/pipelines/slug-1/schemas/task.schema.json", {
112
+ headers: { Host: "localhost" },
113
+ }),
114
+ );
115
+
116
+ expect(response.status).toBe(200);
117
+ await expect(response.json()).resolves.toEqual({ ok: true, data: '{"$schema":"http://example"}\n' });
118
+ });
119
+
120
+ it("handleTaskAnalysis reads from the threaded root, ignoring PO_ROOT=/B", async () => {
121
+ const rootA = await makeTempRoot("router-iso-A");
122
+ const rootB = await makeTempRoot("router-iso-B");
123
+ process.env["PO_ROOT"] = rootB;
124
+
125
+ await mkdir(path.join(rootA, "pipeline-config", "slug-1", "tasks"), { recursive: true });
126
+ await writeFile(
127
+ path.join(rootA, "pipeline-config", "slug-1", "tasks", "research.analysis.json"),
128
+ JSON.stringify({ taskId: "research", steps: 3 }),
129
+ );
130
+
131
+ const router = createRouter({ dataDir: rootA });
132
+ const response = await router.handle(
133
+ new Request("http://localhost/api/pipelines/slug-1/tasks/research/analysis", {
134
+ headers: { Host: "localhost" },
135
+ }),
136
+ );
137
+
138
+ expect(response.status).toBe(200);
139
+ await expect(response.json()).resolves.toEqual({ ok: true, data: { taskId: "research", steps: 3 } });
140
+ });
141
+
142
+ it("handleCreatePipeline writes under the threaded root, ignoring PO_ROOT=/B", async () => {
143
+ const rootA = await makeTempRoot("router-iso-A");
144
+ const rootB = await makeTempRoot("router-iso-B");
145
+ process.env["PO_ROOT"] = rootB;
146
+
147
+ const router = createRouter({ dataDir: rootA });
148
+ const response = await router.handle(
149
+ new Request("http://localhost/api/pipelines", {
150
+ method: "POST",
151
+ headers: { "Content-Type": "application/json", Host: "localhost" },
152
+ body: JSON.stringify({ name: "Isolation Test Pipeline", description: "x" }),
153
+ }),
154
+ );
155
+
156
+ expect(response.status).toBe(201);
157
+ const body = (await response.json()) as { ok: boolean; data: { slug: string; name: string } };
158
+ expect(body.ok).toBe(true);
159
+ const slug = body.data.slug;
160
+ expect(slug.length).toBeGreaterThan(0);
161
+
162
+ const pipelinePath = path.join(rootA, "pipeline-config", slug, "pipeline.json");
163
+ const pipelineText = await Bun.file(pipelinePath).text();
164
+ const pipeline = JSON.parse(pipelineText) as { name: string; slug: string };
165
+ expect(pipeline).toMatchObject({ name: "Isolation Test Pipeline", slug });
166
+
167
+ const registryPath = path.join(rootA, "pipeline-config", "registry.json");
168
+ const registry = JSON.parse(await Bun.file(registryPath).text()) as {
169
+ pipelines: Record<string, { name: string; description: string; configDir: string; tasksDir: string }>;
170
+ };
171
+ expect(registry.pipelines[slug]?.configDir).toBe(path.join("pipeline-config", slug));
172
+ expect(registry.pipelines[slug]?.tasksDir).toBe(path.join("pipeline-config", slug, "tasks"));
173
+ expect(registry.pipelines[slug]?.name).toBe("Isolation Test Pipeline");
174
+ expect(registry.pipelines[slug]?.description).toBe("x");
175
+
176
+ const bRootExists = await Bun.file(path.join(rootB, "pipeline-config", "registry.json")).exists();
177
+ expect(bRootExists).toBe(false);
178
+ });
179
+
180
+ it("handleTaskSave writes under the threaded root, ignoring PO_ROOT=/B", async () => {
181
+ const rootA = await makeTempRoot("router-iso-A");
182
+ const rootB = await makeTempRoot("router-iso-B");
183
+ process.env["PO_ROOT"] = rootB;
184
+
185
+ const router = createRouter({ dataDir: rootA });
186
+ const response = await router.handle(
187
+ new Request("http://localhost/api/tasks/create", {
188
+ method: "POST",
189
+ headers: { "Content-Type": "application/json", Host: "localhost" },
190
+ body: JSON.stringify({ slug: "saved-slug", taskId: "research", content: "# research notes\n" }),
191
+ }),
192
+ );
193
+
194
+ expect(response.status).toBe(201);
195
+ await expect(response.json()).resolves.toEqual({ ok: true, data: { slug: "saved-slug", taskId: "research" } });
196
+
197
+ const savedPath = path.join(rootA, "pipeline-config", "saved-slug", "tasks", "research.md");
198
+ const savedText = await Bun.file(savedPath).text();
199
+ expect(savedText).toBe("# research notes\n");
200
+
201
+ const bRootExists = await Bun.file(path.join(rootB, "pipeline-config", "saved-slug", "tasks", "research.md")).exists();
202
+ expect(bRootExists).toBe(false);
203
+ });
204
+ });
@@ -0,0 +1,148 @@
1
+ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
2
+
3
+ // ─── Capture-args table: populated by mocked handlers so each test can assert
4
+ // the last argument the router passed.
5
+ const capturedArgs: Record<string, unknown[] | undefined> = {};
6
+
7
+ mock.module("../endpoints/file-endpoints", () => ({
8
+ handleTaskFile: mock((...args: unknown[]) => {
9
+ capturedArgs.handleTaskFile = args;
10
+ return Promise.resolve(new Response("{}", { headers: { "Content-Type": "application/json" } }));
11
+ }),
12
+ handleTaskFileList: mock((...args: unknown[]) => {
13
+ capturedArgs.handleTaskFileList = args;
14
+ return Promise.resolve(new Response("{}", { headers: { "Content-Type": "application/json" } }));
15
+ }),
16
+ }));
17
+
18
+ mock.module("../endpoints/schema-file-endpoint", () => ({
19
+ handleSchemaFile: mock((...args: unknown[]) => {
20
+ capturedArgs.handleSchemaFile = args;
21
+ return Promise.resolve(new Response("{}", { headers: { "Content-Type": "application/json" } }));
22
+ }),
23
+ }));
24
+
25
+ mock.module("../endpoints/task-analysis-endpoint", () => ({
26
+ handleTaskAnalysis: mock((...args: unknown[]) => {
27
+ capturedArgs.handleTaskAnalysis = args;
28
+ return Promise.resolve(new Response("{}", { headers: { "Content-Type": "application/json" } }));
29
+ }),
30
+ }));
31
+
32
+ mock.module("../endpoints/pipeline-analysis-endpoint", () => ({
33
+ handlePipelineAnalysis: mock((...args: unknown[]) => {
34
+ capturedArgs.handlePipelineAnalysis = args;
35
+ return Promise.resolve(new Response("{}", { headers: { "Content-Type": "application/json" } }));
36
+ }),
37
+ }));
38
+
39
+ mock.module("../endpoints/create-pipeline-endpoint", () => ({
40
+ handleCreatePipeline: mock((...args: unknown[]) => {
41
+ capturedArgs.handleCreatePipeline = args;
42
+ return Promise.resolve(new Response("{}", { headers: { "Content-Type": "application/json" } }));
43
+ }),
44
+ }));
45
+
46
+ mock.module("../endpoints/task-save-endpoint", () => ({
47
+ handleTaskSave: mock((...args: unknown[]) => {
48
+ capturedArgs.handleTaskSave = args;
49
+ return Promise.resolve(new Response("{}", { headers: { "Content-Type": "application/json" } }));
50
+ }),
51
+ }));
52
+
53
+ // ─── Import the module under test (after mock.module calls) ───────────────────
54
+ import { createRouter } from "../router";
55
+
56
+ // ─── Env capture/restore per ~/.agents/rules/unit-testing.md §6 ───────────────
57
+ const ORIGINAL_PO_ROOT = process.env["PO_ROOT"];
58
+
59
+ beforeEach(() => {
60
+ process.env["PO_ROOT"] = "/B";
61
+ for (const key of Object.keys(capturedArgs)) delete capturedArgs[key];
62
+ });
63
+
64
+ afterEach(() => {
65
+ if (ORIGINAL_PO_ROOT === undefined) {
66
+ delete process.env["PO_ROOT"];
67
+ } else {
68
+ process.env["PO_ROOT"] = ORIGINAL_PO_ROOT;
69
+ }
70
+ });
71
+
72
+ describe("router threads resolved root to six handlers (AC-5)", () => {
73
+ it("passes options.dataDir as the last argument to handleTaskFile", async () => {
74
+ const router = createRouter({ dataDir: "/A" });
75
+ await router.handle(
76
+ new Request("http://localhost/api/jobs/job-1/tasks/task-1/file?type=artifacts&filename=out.txt", {
77
+ headers: { Host: "localhost" },
78
+ }),
79
+ );
80
+ expect(capturedArgs.handleTaskFile?.at(-1)).toBe("/A");
81
+ });
82
+
83
+ it("passes options.dataDir as the last argument to handleTaskFileList", async () => {
84
+ const router = createRouter({ dataDir: "/A" });
85
+ await router.handle(
86
+ new Request("http://localhost/api/jobs/job-1/tasks/task-1/files?type=artifacts", {
87
+ headers: { Host: "localhost" },
88
+ }),
89
+ );
90
+ expect(capturedArgs.handleTaskFileList?.at(-1)).toBe("/A");
91
+ });
92
+
93
+ it("passes options.dataDir as the last argument to handleSchemaFile", async () => {
94
+ const router = createRouter({ dataDir: "/A" });
95
+ await router.handle(
96
+ new Request("http://localhost/api/pipelines/slug-1/schemas/task.schema.json", {
97
+ headers: { Host: "localhost" },
98
+ }),
99
+ );
100
+ expect(capturedArgs.handleSchemaFile?.at(-1)).toBe("/A");
101
+ });
102
+
103
+ it("passes options.dataDir as the last argument to handleTaskAnalysis", async () => {
104
+ const router = createRouter({ dataDir: "/A" });
105
+ await router.handle(
106
+ new Request("http://localhost/api/pipelines/slug-1/tasks/task-1/analysis", {
107
+ headers: { Host: "localhost" },
108
+ }),
109
+ );
110
+ expect(capturedArgs.handleTaskAnalysis?.at(-1)).toBe("/A");
111
+ });
112
+
113
+ it("passes options.dataDir as the last argument to handlePipelineAnalysis", async () => {
114
+ const router = createRouter({ dataDir: "/A" });
115
+ await router.handle(
116
+ new Request("http://localhost/api/pipelines/slug-1/analyze", {
117
+ method: "POST",
118
+ headers: { "Content-Type": "application/json", Host: "localhost" },
119
+ body: "{}",
120
+ }),
121
+ );
122
+ expect(capturedArgs.handlePipelineAnalysis?.at(-1)).toBe("/A");
123
+ });
124
+
125
+ it("passes options.dataDir as the last argument to handleCreatePipeline", async () => {
126
+ const router = createRouter({ dataDir: "/A" });
127
+ await router.handle(
128
+ new Request("http://localhost/api/pipelines", {
129
+ method: "POST",
130
+ headers: { "Content-Type": "application/json", Host: "localhost" },
131
+ body: "{}",
132
+ }),
133
+ );
134
+ expect(capturedArgs.handleCreatePipeline?.at(-1)).toBe("/A");
135
+ });
136
+
137
+ it("passes options.dataDir as the last argument to handleTaskSave", async () => {
138
+ const router = createRouter({ dataDir: "/A" });
139
+ await router.handle(
140
+ new Request("http://localhost/api/tasks/create", {
141
+ method: "POST",
142
+ headers: { "Content-Type": "application/json", Host: "localhost" },
143
+ body: "{}",
144
+ }),
145
+ );
146
+ expect(capturedArgs.handleTaskSave?.at(-1)).toBe("/A");
147
+ });
148
+ });
@@ -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", () => {
@@ -84,4 +85,42 @@ describe("sse-enhancer", () => {
84
85
  expect(broadcast.mock.calls[0]?.[0]).toBe("job:created");
85
86
  expect(payload.status).toBe("failed");
86
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
+ });
87
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,