@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
@@ -0,0 +1,104 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
6
+
7
+ import { handleSchemaFile } from "../schema-file-endpoint";
8
+
9
+ const SLUG = "demo";
10
+
11
+ async function ensureDir(dir: string): Promise<void> {
12
+ await mkdir(dir, { recursive: true });
13
+ }
14
+
15
+ describe("handleSchemaFile", () => {
16
+ let root: string;
17
+
18
+ beforeEach(async () => {
19
+ root = await mkdtemp(path.join(tmpdir(), "schema-file-endpoint-"));
20
+ });
21
+
22
+ afterEach(async () => {
23
+ await rm(root, { recursive: true, force: true });
24
+ });
25
+
26
+ it("rejects a plain `..` slug with 400 and does not serve a file outside pipeline-config", async () => {
27
+ await ensureDir(path.join(root, "schemas"));
28
+ await writeFile(path.join(root, "schemas", "schema.json"), "LEAKED");
29
+
30
+ const res = await handleSchemaFile(new Request("http://x"), "..", "schema.json", root);
31
+ const text = await res.text();
32
+
33
+ expect(res.status).toBe(400);
34
+ expect(text).not.toContain("LEAKED");
35
+ const body = JSON.parse(text) as { ok: boolean; code: string };
36
+ expect(body.ok).toBe(false);
37
+ expect(body.code).toBe("BAD_REQUEST");
38
+ });
39
+
40
+ it("rejects an encoded traversal slug with 400", async () => {
41
+ const res = await handleSchemaFile(new Request("http://x"), "..%2f..", "schema.json", root);
42
+
43
+ expect(res.status).toBe(400);
44
+ const body = (await res.json()) as { ok: boolean; code: string };
45
+ expect(body.ok).toBe(false);
46
+ expect(body.code).toBe("BAD_REQUEST");
47
+ });
48
+
49
+ it("rejects a traversal filename with 400 and does not read outside pipeline-config", async () => {
50
+ await ensureDir(path.join(root, "pipeline-config", SLUG, "schemas"));
51
+ await writeFile(path.join(root, "secret.json"), "LEAKED");
52
+
53
+ const res = await handleSchemaFile(
54
+ new Request("http://x"),
55
+ SLUG,
56
+ "..%2f..%2f..%2fsecret.json",
57
+ root,
58
+ );
59
+ const text = await res.text();
60
+
61
+ expect(res.status).toBe(400);
62
+ expect(text).not.toContain("LEAKED");
63
+ const body = JSON.parse(text) as { ok: boolean; code: string };
64
+ expect(body.ok).toBe(false);
65
+ expect(body.code).toBe("BAD_REQUEST");
66
+ });
67
+
68
+ it("returns 200 with file contents for a valid request", async () => {
69
+ await ensureDir(path.join(root, "pipeline-config", SLUG, "schemas"));
70
+ await writeFile(
71
+ path.join(root, "pipeline-config", SLUG, "schemas", "schema.json"),
72
+ '{"type":"object"}',
73
+ );
74
+
75
+ const res = await handleSchemaFile(new Request("http://x"), SLUG, "schema.json", root);
76
+
77
+ expect(res.status).toBe(200);
78
+ const body = (await res.json()) as { ok: boolean; data: string };
79
+ expect(body.ok).toBe(true);
80
+ expect(body.data).toBe('{"type":"object"}');
81
+ });
82
+
83
+ it("returns 404 for a contained-but-missing schema file", async () => {
84
+ await ensureDir(path.join(root, "pipeline-config", SLUG, "schemas"));
85
+
86
+ const res = await handleSchemaFile(new Request("http://x"), SLUG, "missing.json", root);
87
+
88
+ expect(res.status).toBe(404);
89
+ const body = (await res.json()) as { ok: boolean; code: string; message: string };
90
+ expect(body.ok).toBe(false);
91
+ expect(body.code).toBe("NOT_FOUND");
92
+ });
93
+
94
+ it("serves a legitimately percent-encoded filename (decode-once correctness)", async () => {
95
+ await ensureDir(path.join(root, "pipeline-config", SLUG, "schemas"));
96
+ await writeFile(path.join(root, "pipeline-config", SLUG, "schemas", "my file.json"), "ok");
97
+
98
+ const res = await handleSchemaFile(new Request("http://x"), SLUG, "my%20file.json", root);
99
+
100
+ expect(res.status).toBe(200);
101
+ const body = (await res.json()) as { ok: boolean; data: string };
102
+ expect(body.data).toBe("ok");
103
+ });
104
+ });
@@ -0,0 +1,103 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
6
+
7
+ import { handleTaskAnalysis } from "../task-analysis-endpoint";
8
+ import { TaskAnalysisRepository } from "../../../../task-analysis/repository";
9
+ import type { PersistedTaskAnalysis } from "../../../../task-analysis/types";
10
+
11
+ const SLUG = "demo-pipeline";
12
+ const TASK = "extract";
13
+
14
+ function makeAnalysis(): PersistedTaskAnalysis {
15
+ return {
16
+ summary: "Extracts structured fields from raw input.",
17
+ stages: [{ name: "extract", purpose: "Pull fields out." }],
18
+ artifacts: {
19
+ reads: [{ fileName: "input.json", role: "source" }],
20
+ writes: [{ fileName: "output.json", role: "result" }],
21
+ },
22
+ models: [{ provider: "deepseek", method: "chat", stage: "extract" }],
23
+ analyzedAt: "2026-06-22T00:00:00.000Z",
24
+ analysisStatus: "ok",
25
+ };
26
+ }
27
+
28
+ describe("handleTaskAnalysis", () => {
29
+ let root: string;
30
+
31
+ beforeEach(async () => {
32
+ root = await mkdtemp(path.join(tmpdir(), "task-analysis-endpoint-"));
33
+ });
34
+
35
+ afterEach(async () => {
36
+ await rm(root, { recursive: true, force: true });
37
+ });
38
+
39
+ it("returns a generated analysis written through the repository", async () => {
40
+ const analysis = makeAnalysis();
41
+ await TaskAnalysisRepository.write(root, SLUG, TASK, analysis);
42
+
43
+ const res = await handleTaskAnalysis(new Request("http://x"), SLUG, TASK, root);
44
+
45
+ expect(res.status).toBe(200);
46
+ const body = (await res.json()) as { ok: boolean; data: PersistedTaskAnalysis };
47
+ expect(body.ok).toBe(true);
48
+ expect(body.data).toEqual(analysis);
49
+ });
50
+
51
+ it("returns 404 only when the analysis is truly absent", async () => {
52
+ const res = await handleTaskAnalysis(new Request("http://x"), SLUG, "missing-task", root);
53
+
54
+ expect(res.status).toBe(404);
55
+ const body = (await res.json()) as { ok: boolean; code: string; message: string };
56
+ expect(body.ok).toBe(false);
57
+ expect(body.code).toBe("NOT_FOUND");
58
+ });
59
+
60
+ it("returns 400 and does not read outside pipeline-config for a traversal slug", async () => {
61
+ const sentinelDir = path.join(root, "tasks");
62
+ await mkdir(sentinelDir, { recursive: true });
63
+ const sentinelPayload = "SENTINEL_OUTSIDE_PIPELINE_CONFIG";
64
+ await writeFile(path.join(sentinelDir, `${TASK}.analysis.json`), sentinelPayload);
65
+
66
+ const res = await handleTaskAnalysis(new Request("http://x"), "..", TASK, root);
67
+
68
+ expect(res.status).toBe(400);
69
+ const text = await res.text();
70
+ expect(text).not.toContain(sentinelPayload);
71
+ const body = JSON.parse(text) as { ok: boolean; code: string };
72
+ expect(body.ok).toBe(false);
73
+ expect(body.code).toBe("BAD_PATH");
74
+ });
75
+
76
+ it("returns 400 and does not read outside pipeline-config for a traversal taskId", async () => {
77
+ const sentinelPayload = "SENTINEL_OUTSIDE_ROOT_VIA_TASKID";
78
+ await writeFile(path.join(root, "secret.analysis.json"), sentinelPayload);
79
+
80
+ const res = await handleTaskAnalysis(
81
+ new Request("http://x"),
82
+ SLUG,
83
+ "..%2f..%2f..%2fsecret",
84
+ root,
85
+ );
86
+
87
+ expect(res.status).toBe(400);
88
+ const text = await res.text();
89
+ expect(text).not.toContain(sentinelPayload);
90
+ const body = JSON.parse(text) as { ok: boolean; code: string };
91
+ expect(body.ok).toBe(false);
92
+ expect(body.code).toBe("BAD_PATH");
93
+ });
94
+
95
+ it("returns 400 for a malformed percent-encoded slug", async () => {
96
+ const res = await handleTaskAnalysis(new Request("http://x"), "%zz", TASK, root);
97
+
98
+ expect(res.status).toBe(400);
99
+ const body = (await res.json()) as { ok: boolean; code: string };
100
+ expect(body.ok).toBe(false);
101
+ expect(body.code).toBe("BAD_PATH");
102
+ });
103
+ });
@@ -1,12 +1,14 @@
1
- import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
1
+ import { mkdtemp, mkdir, readFile, rm, stat } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import path from "node:path";
4
4
 
5
5
  import { zipSync } from "fflate";
6
- import { describe, expect, it } from "vitest";
6
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
7
7
 
8
8
  import { handleSeedUploadDirect, normalizeSeedUpload } from "../upload-endpoints";
9
9
 
10
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
11
+
10
12
  async function pathExists(target: string): Promise<boolean> {
11
13
  try {
12
14
  await stat(target);
@@ -27,6 +29,32 @@ function concatBytes(parts: Uint8Array[]): Uint8Array<ArrayBuffer> {
27
29
  return out;
28
30
  }
29
31
 
32
+ async function scaffoldWorkspace(tmpDir: string): Promise<void> {
33
+ const pipelineConfigDir = path.join(tmpDir, "pipeline-config", "test-pipeline");
34
+ await mkdir(pipelineConfigDir, { recursive: true });
35
+ await mkdir(path.join(pipelineConfigDir, "tasks"), { recursive: true });
36
+
37
+ await Bun.write(
38
+ path.join(tmpDir, "pipeline-config", "registry.json"),
39
+ JSON.stringify({
40
+ version: 1,
41
+ pipelines: {
42
+ "test-pipeline": {
43
+ name: "Test Pipeline",
44
+ description: "test pipeline",
45
+ configDir: pipelineConfigDir,
46
+ tasksDir: path.join(pipelineConfigDir, "tasks"),
47
+ },
48
+ },
49
+ }),
50
+ );
51
+
52
+ await Bun.write(
53
+ path.join(pipelineConfigDir, "pipeline.json"),
54
+ JSON.stringify({ name: "test-pipeline", tasks: [] }),
55
+ );
56
+ }
57
+
30
58
  describe("normalizeSeedUpload", () => {
31
59
  it("extracts seed and binary artifact from a multipart zip upload", async () => {
32
60
  const seed = { name: "demo", pipeline: "x" };
@@ -57,10 +85,8 @@ describe("normalizeSeedUpload", () => {
57
85
 
58
86
  const result = await normalizeSeedUpload(request);
59
87
 
60
- // AC3: seedObject deep-equals the original seed.
61
88
  expect(result.seedObject).toEqual(seed);
62
89
 
63
- // AC3: exactly one artifact, byte-identical to the source blob.
64
90
  expect(result.artifacts).toHaveLength(1);
65
91
  const blob = result.artifacts![0]!;
66
92
  expect(blob.filename).toBe("artifacts/blob.bin");
@@ -70,110 +96,150 @@ describe("normalizeSeedUpload", () => {
70
96
  });
71
97
 
72
98
  describe("handleSeedUploadDirect", () => {
99
+ let dataDir: string;
100
+
101
+ beforeEach(async () => {
102
+ dataDir = await mkdtemp(path.join(tmpdir(), "upload-endpoint-"));
103
+ await scaffoldWorkspace(dataDir);
104
+ });
105
+
106
+ afterEach(async () => {
107
+ await rm(dataDir, { recursive: true, force: true });
108
+ });
109
+
73
110
  it("stages artifacts under staging/{jobId}/ without creating current/{jobId}/", async () => {
74
- const dataDir = await mkdtemp(path.join(tmpdir(), "upload-direct-"));
75
- try {
76
- const content = new TextEncoder().encode("# notes\nhello\n");
77
- const seedObject = { name: "demo", pipeline: "x" };
78
-
79
- const response = await handleSeedUploadDirect(seedObject, dataDir, [
80
- { filename: "notes.md", content },
81
- ]);
82
- expect(response.status).toBe(201);
83
- const body = (await response.json()) as { data: { jobId: string } };
84
- const jobId = body.data.jobId;
85
-
86
- const pipelineData = path.join(dataDir, "pipeline-data");
87
-
88
- // Artifact staged byte-identical under staging/{jobId}/.
89
- const staged = path.join(pipelineData, "staging", jobId, "notes.md");
90
- const stagedBytes = await readFile(staged);
91
- expect(Array.from(stagedBytes)).toEqual(Array.from(content));
92
-
93
- // Pending seed trigger written.
94
- expect(await pathExists(path.join(pipelineData, "pending", `${jobId}-seed.json`))).toBe(true);
95
-
96
- // The endpoint must not create current/{jobId}/.
97
- expect(await pathExists(path.join(pipelineData, "current", jobId))).toBe(false);
98
- } finally {
99
- await rm(dataDir, { recursive: true, force: true });
100
- }
111
+ const content = new TextEncoder().encode("# notes\nhello\n");
112
+ const seedObject = { name: "demo", pipeline: "test-pipeline" };
113
+
114
+ const response = await handleSeedUploadDirect(seedObject, dataDir, [
115
+ { filename: "notes.md", content },
116
+ ]);
117
+ expect(response.status).toBe(201);
118
+ const body = (await response.json()) as { data: { jobId: string } };
119
+ const jobId = body.data.jobId;
120
+ expect(jobId).toMatch(UUID_PATTERN);
121
+
122
+ const pipelineData = path.join(dataDir, "pipeline-data");
123
+
124
+ const staged = path.join(pipelineData, "staging", jobId, "notes.md");
125
+ const stagedBytes = await readFile(staged);
126
+ expect(Array.from(stagedBytes)).toEqual(Array.from(content));
127
+
128
+ expect(await pathExists(path.join(pipelineData, "pending", `${jobId}-seed.json`))).toBe(true);
129
+
130
+ expect(await pathExists(path.join(pipelineData, "current", jobId))).toBe(false);
131
+ });
132
+
133
+ it("rejects seed with unknown pipeline as 400 with error envelope and writes no files", async () => {
134
+ const response = await handleSeedUploadDirect(
135
+ { name: "demo", pipeline: "missing-pipeline" },
136
+ dataDir,
137
+ [{ filename: "notes.md", content: new TextEncoder().encode("hi") }],
138
+ );
139
+
140
+ expect(response.status).toBe(400);
141
+ const body = (await response.json()) as { ok: boolean; code: string; message: string };
142
+ expect(body.ok).toBe(false);
143
+ expect(body.code).toBe("BAD_REQUEST");
144
+ expect(body.message).toContain("not found");
145
+
146
+ expect(await pathExists(path.join(dataDir, "pipeline-data", "staging"))).toBe(false);
147
+ expect(await pathExists(path.join(dataDir, "pipeline-data", "pending"))).toBe(false);
101
148
  });
102
149
 
103
150
  it("AC-12: unsafe artifact path returns 400 and does not create the escape target", async () => {
104
- const dataDir = await mkdtemp(path.join(tmpdir(), "upload-unsafe-"));
105
- try {
106
- const seedObject = { name: "demo", pipeline: "x" };
107
- const response = await handleSeedUploadDirect(seedObject, dataDir, [
108
- { filename: "../../../../tmp/PWNED.txt", content: new TextEncoder().encode("pwned") },
109
- ]);
110
- expect(response.status).toBe(400);
111
-
112
- const escapeTarget = "/tmp/PWNED.txt";
113
- expect(await pathExists(escapeTarget)).toBe(false);
114
- } finally {
115
- await rm(dataDir, { recursive: true, force: true });
116
- }
151
+ const seedObject = { name: "demo", pipeline: "test-pipeline" };
152
+ const filename = "../../../../tmp/PWNED.txt";
153
+ const response = await handleSeedUploadDirect(seedObject, dataDir, [
154
+ { filename, content: new TextEncoder().encode("pwned") },
155
+ ]);
156
+ expect(response.status).toBe(400);
157
+ const body = (await response.json()) as { ok: boolean; code: string; message: string };
158
+ expect(body.ok).toBe(false);
159
+ expect(body.code).toBe("BAD_REQUEST");
160
+ expect(body.message.startsWith("unsafe artifact path: ")).toBe(true);
161
+ expect(body.message).toContain(filename);
162
+
163
+ const escapeTarget = "/tmp/PWNED.txt";
164
+ expect(await pathExists(escapeTarget)).toBe(false);
165
+ expect(await pathExists(path.join(dataDir, "pipeline-data", "staging"))).toBe(false);
166
+ expect(await pathExists(path.join(dataDir, "pipeline-data", "pending"))).toBe(false);
117
167
  });
118
168
 
119
169
  it("AC-13: mixed safe and unsafe paths return 400 with no partial writes", async () => {
120
- const dataDir = await mkdtemp(path.join(tmpdir(), "upload-mixed-"));
121
- try {
122
- const seedObject = { name: "demo", pipeline: "x" };
123
- const artifacts = [
124
- { filename: "safe.md", content: new TextEncoder().encode("safe") },
125
- { filename: "../../tmp/escape.txt", content: new TextEncoder().encode("escape") },
126
- ];
127
-
128
- const response = await handleSeedUploadDirect(seedObject, dataDir, artifacts);
129
- expect(response.status).toBe(400);
130
-
131
- const stagingRoot = path.join(dataDir, "pipeline-data", "staging");
132
- expect(await pathExists(stagingRoot)).toBe(false);
133
- } finally {
134
- await rm(dataDir, { recursive: true, force: true });
135
- }
170
+ const seedObject = { name: "demo", pipeline: "test-pipeline" };
171
+ const unsafeFilename = "../../tmp/escape.txt";
172
+ const artifacts = [
173
+ { filename: "safe.md", content: new TextEncoder().encode("safe") },
174
+ { filename: unsafeFilename, content: new TextEncoder().encode("escape") },
175
+ ];
176
+
177
+ const response = await handleSeedUploadDirect(seedObject, dataDir, artifacts);
178
+ expect(response.status).toBe(400);
179
+ const body = (await response.json()) as { ok: boolean; code: string; message: string };
180
+ expect(body.ok).toBe(false);
181
+ expect(body.code).toBe("BAD_REQUEST");
182
+ expect(body.message.startsWith("unsafe artifact path: ")).toBe(true);
183
+ expect(body.message).toContain(unsafeFilename);
184
+
185
+ const stagingRoot = path.join(dataDir, "pipeline-data", "staging");
186
+ expect(await pathExists(stagingRoot)).toBe(false);
187
+ expect(await pathExists(path.join(dataDir, "pipeline-data", "pending"))).toBe(false);
136
188
  });
137
189
 
138
190
  it("rejects root-normalizing artifact paths without partial writes", async () => {
139
- const dataDir = await mkdtemp(path.join(tmpdir(), "upload-root-target-"));
140
- try {
141
- const seedObject = { name: "demo", pipeline: "x" };
142
- const artifacts = [
143
- { filename: "safe.md", content: new TextEncoder().encode("safe") },
144
- { filename: "dir/..", content: new TextEncoder().encode("root") },
145
- ];
146
-
147
- const response = await handleSeedUploadDirect(seedObject, dataDir, artifacts);
148
- expect(response.status).toBe(400);
149
-
150
- const stagingRoot = path.join(dataDir, "pipeline-data", "staging");
151
- expect(await pathExists(stagingRoot)).toBe(false);
152
- } finally {
153
- await rm(dataDir, { recursive: true, force: true });
154
- }
191
+ const seedObject = { name: "demo", pipeline: "test-pipeline" };
192
+ const unsafeFilename = "dir/..";
193
+ const artifacts = [
194
+ { filename: "safe.md", content: new TextEncoder().encode("safe") },
195
+ { filename: unsafeFilename, content: new TextEncoder().encode("root") },
196
+ ];
197
+
198
+ const response = await handleSeedUploadDirect(seedObject, dataDir, artifacts);
199
+ expect(response.status).toBe(400);
200
+ const body = (await response.json()) as { ok: boolean; code: string; message: string };
201
+ expect(body.ok).toBe(false);
202
+ expect(body.code).toBe("BAD_REQUEST");
203
+ expect(body.message.startsWith("unsafe artifact path: ")).toBe(true);
204
+ expect(body.message).toContain(unsafeFilename);
205
+
206
+ const stagingRoot = path.join(dataDir, "pipeline-data", "staging");
207
+ expect(await pathExists(stagingRoot)).toBe(false);
208
+ expect(await pathExists(path.join(dataDir, "pipeline-data", "pending"))).toBe(false);
209
+ });
210
+
211
+ it("rejects absolute artifact paths without partial writes", async () => {
212
+ const seedObject = { name: "demo", pipeline: "test-pipeline" };
213
+ const filename = "/etc/passwd";
214
+ const response = await handleSeedUploadDirect(seedObject, dataDir, [
215
+ { filename, content: new TextEncoder().encode("data") },
216
+ ]);
217
+ expect(response.status).toBe(400);
218
+ const body = (await response.json()) as { ok: boolean; code: string; message: string };
219
+ expect(body.ok).toBe(false);
220
+ expect(body.code).toBe("BAD_REQUEST");
221
+ expect(body.message.startsWith("unsafe artifact path: ")).toBe(true);
222
+ expect(body.message).toContain(filename);
223
+
224
+ expect(await pathExists(path.join(dataDir, "pipeline-data", "staging"))).toBe(false);
225
+ expect(await pathExists(path.join(dataDir, "pipeline-data", "pending"))).toBe(false);
155
226
  });
156
227
 
157
228
  it("AC-14: all-safe artifact paths with nesting return 201 and files present", async () => {
158
- const dataDir = await mkdtemp(path.join(tmpdir(), "upload-safe-"));
159
- try {
160
- const seedObject = { name: "demo", pipeline: "x" };
161
- const artifacts = [
162
- { filename: "out.json", content: new TextEncoder().encode("{}") },
163
- { filename: "data/foo.json", content: new TextEncoder().encode('{"a":1}') },
164
- ];
165
-
166
- const response = await handleSeedUploadDirect(seedObject, dataDir, artifacts);
167
- expect(response.status).toBe(201);
168
-
169
- const body = (await response.json()) as { data: { jobId: string } };
170
- const jobId = body.data.jobId;
171
- const stagingJobDir = path.join(dataDir, "pipeline-data", "staging", jobId);
172
-
173
- expect(await pathExists(path.join(stagingJobDir, "out.json"))).toBe(true);
174
- expect(await pathExists(path.join(stagingJobDir, "data/foo.json"))).toBe(true);
175
- } finally {
176
- await rm(dataDir, { recursive: true, force: true });
177
- }
229
+ const seedObject = { name: "demo", pipeline: "test-pipeline" };
230
+ const artifacts = [
231
+ { filename: "out.json", content: new TextEncoder().encode("{}") },
232
+ { filename: "data/foo.json", content: new TextEncoder().encode('{"a":1}') },
233
+ ];
234
+
235
+ const response = await handleSeedUploadDirect(seedObject, dataDir, artifacts);
236
+ expect(response.status).toBe(201);
237
+
238
+ const body = (await response.json()) as { data: { jobId: string } };
239
+ const jobId = body.data.jobId;
240
+ const stagingJobDir = path.join(dataDir, "pipeline-data", "staging", jobId);
241
+
242
+ expect(await pathExists(path.join(stagingJobDir, "out.json"))).toBe(true);
243
+ expect(await pathExists(path.join(stagingJobDir, "data/foo.json"))).toBe(true);
178
244
  });
179
245
  });
@@ -26,7 +26,7 @@ interface PublicConcurrencyStatus {
26
26
  }>;
27
27
  staleSlots: Array<{
28
28
  jobId: string;
29
- reason: "missing_current_job" | "missing_pid" | "dead_pid" | "invalid_json";
29
+ reason: "missing_current_job" | "missing_pid" | "dead_pid" | "invalid_json" | "stale_runner";
30
30
  }>;
31
31
  }
32
32
 
@@ -62,6 +62,7 @@ export async function handleConcurrencyStatus(dataDir: string): Promise<Response
62
62
  getPipelineDataDir(dataDir),
63
63
  orchestrator.maxConcurrentJobs,
64
64
  orchestrator.lockFileTimeout,
65
+ orchestrator.staleLeaseTimeoutMs,
65
66
  );
66
67
  const response = sendJson(200, { ok: true, data: toPublicStatus(status) });
67
68
  response.headers.set("Cache-Control", "no-store");
@@ -1,18 +1,16 @@
1
- import { mkdir, rename } from "node:fs/promises";
1
+ import { mkdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
- import { getConfig, resetConfig } from "../../../core/config";
4
+ import { resetConfig } from "../../../core/config";
5
+ import {
6
+ readRegistrySync,
7
+ registerPipeline,
8
+ } from "../../../config/pipeline-registry";
5
9
  import { createErrorResponse } from "../config-bridge";
6
10
  import { sendJson } from "../utils/http-utils";
7
11
  import { ensureUniqueSlug, generateSlug } from "../utils/slug";
8
12
 
9
- async function writeAtomicJson(filePath: string, value: unknown): Promise<void> {
10
- const tmpPath = `${filePath}.${process.pid}.tmp`;
11
- await Bun.write(tmpPath, JSON.stringify(value, null, 2));
12
- await rename(tmpPath, filePath);
13
- }
14
-
15
- export async function handleCreatePipeline(req: Request): Promise<Response> {
13
+ export async function handleCreatePipeline(req: Request, root: string): Promise<Response> {
16
14
  const body = (await req.json()) as Record<string, unknown>;
17
15
  const name = typeof body["name"] === "string" ? body["name"].trim() : "";
18
16
  const description = typeof body["description"] === "string" ? body["description"].trim() : "";
@@ -20,33 +18,20 @@ export async function handleCreatePipeline(req: Request): Promise<Response> {
20
18
  return sendJson(400, createErrorResponse("BAD_REQUEST", "pipeline name is required"));
21
19
  }
22
20
 
23
- const root = process.env["PO_ROOT"] ?? process.cwd();
24
- const registryPath = path.join(root, "pipeline-config", "registry.json");
25
- const registry = (await Bun.file(registryPath).exists())
26
- ? (JSON.parse(await Bun.file(registryPath).text()) as { pipelines: Record<string, { configDir: string; tasksDir: string }> })
27
- : { pipelines: {} };
21
+ const registry = readRegistrySync(root);
28
22
  const slug = ensureUniqueSlug(generateSlug(name), new Set(Object.keys(registry.pipelines)));
29
23
 
30
24
  const configDir = path.join(root, "pipeline-config", slug);
31
25
  const tasksDir = path.join(configDir, "tasks");
32
26
  await mkdir(tasksDir, { recursive: true });
33
27
  await Bun.write(path.join(configDir, "pipeline.json"), JSON.stringify({ name, description, slug }, null, 2));
34
- await registryFileUpdate(registryPath, registry, slug, configDir, tasksDir);
28
+ await registerPipeline(root, slug, {
29
+ name,
30
+ description,
31
+ configDir: path.relative(root, configDir),
32
+ tasksDir: path.relative(root, tasksDir),
33
+ });
35
34
  resetConfig();
36
35
 
37
36
  return sendJson(201, { ok: true, data: { slug, name, description } });
38
37
  }
39
-
40
- async function registryFileUpdate(
41
- registryPath: string,
42
- registry: { pipelines: Record<string, { configDir: string; tasksDir: string }> },
43
- slug: string,
44
- configDir: string,
45
- tasksDir: string,
46
- ): Promise<void> {
47
- registry.pipelines[slug] = {
48
- configDir: path.relative(process.env["PO_ROOT"] ?? process.cwd(), configDir),
49
- tasksDir: path.relative(process.env["PO_ROOT"] ?? process.cwd(), tasksDir),
50
- };
51
- await writeAtomicJson(registryPath, registry);
52
- }