@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
@@ -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
- }
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { readJob } from "../job-reader";
4
4
  import { sendJson } from "../utils/http-utils";
5
5
  import { getMimeType, isTextMime } from "../utils/mime-types";
6
+ import { resolveWithin } from "../../../utils/path-containment";
6
7
 
7
8
  export function validateFileName(filename: string): boolean {
8
9
  return Boolean(
@@ -20,8 +21,7 @@ type TaskFiles = {
20
21
  tmp: string[];
21
22
  };
22
23
 
23
- function getJobFilesBase(location: string, jobId: string): string {
24
- const root = process.env["PO_ROOT"] ?? process.cwd();
24
+ function getJobFilesBase(location: string, jobId: string, root: string): string {
25
25
  return path.join(root, "pipeline-data", location, jobId, "files");
26
26
  }
27
27
 
@@ -49,25 +49,27 @@ function getTaskFiles(data: Record<string, unknown>, taskId: string): TaskFiles
49
49
  };
50
50
  }
51
51
 
52
- export async function handleTaskFileList(req: Request, jobId: string, taskId: string): Promise<Response> {
52
+ export async function handleTaskFileList(req: Request, jobId: string, taskId: string, root: string): Promise<Response> {
53
53
  const type = new URL(req.url).searchParams.get("type") ?? "artifacts";
54
54
  if (!validateFileName(type)) return sendJson(400, { ok: false, code: "BAD_REQUEST", message: "invalid file type" });
55
- const job = await readJob(jobId);
55
+ const job = await readJob(jobId, root);
56
56
  if (!job.ok) return sendJson(404, job);
57
57
 
58
58
  const files = getTaskFiles(job.data, taskId);
59
59
  return sendJson(200, { ok: true, data: files[type as keyof TaskFiles] ?? [] });
60
60
  }
61
61
 
62
- export async function handleTaskFile(req: Request, jobId: string, taskId: string): Promise<Response> {
62
+ export async function handleTaskFile(req: Request, jobId: string, taskId: string, root: string): Promise<Response> {
63
63
  const url = new URL(req.url);
64
64
  const type = url.searchParams.get("type") ?? "artifacts";
65
65
  const filename = url.searchParams.get("filename") ?? "";
66
- if (!validateFileName(type) || !validateFileName(filename)) {
66
+ const job = await readJob(jobId, root);
67
+ if (!job.ok) return sendJson(404, job);
68
+
69
+ const filePath = resolveWithin(getJobFilesBase(job.location, jobId, root), path.join(type, filename));
70
+ if (filePath === null) {
67
71
  return sendJson(400, { ok: false, code: "BAD_REQUEST", message: "invalid filename" });
68
72
  }
69
- const job = await readJob(jobId);
70
- if (!job.ok) return sendJson(404, job);
71
73
 
72
74
  const taskFiles = getTaskFiles(job.data, taskId);
73
75
  const allowedFiles = taskFiles[type as keyof TaskFiles] ?? [];
@@ -75,9 +77,12 @@ export async function handleTaskFile(req: Request, jobId: string, taskId: string
75
77
  return sendJson(404, { ok: false, code: "NOT_FOUND", message: `file not found for task: ${type}/${filename}` });
76
78
  }
77
79
 
78
- const filePath = path.join(getJobFilesBase(job.location, jobId), type, filename);
80
+ const file = Bun.file(filePath);
81
+ if (!(await file.exists())) {
82
+ return sendJson(404, { ok: false, code: "NOT_FOUND", message: `file not found for task: ${type}/${filename}` });
83
+ }
79
84
  const mime = getMimeType(filename);
80
- const buffer = new Uint8Array(await Bun.file(filePath).arrayBuffer());
85
+ const buffer = new Uint8Array(await file.arrayBuffer());
81
86
  return sendJson(200, {
82
87
  ok: true,
83
88
  data: isTextMime(mime) ? new TextDecoder().decode(buffer) : Buffer.from(buffer).toString("base64"),