@ryanfw/prompt-orchestration-pipeline 1.3.1 → 1.3.2

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 (31) hide show
  1. package/docs/pop-task-guide.md +1 -0
  2. package/package.json +1 -2
  3. package/src/core/__tests__/agent-step.test.ts +322 -3
  4. package/src/core/__tests__/task-runner.test.ts +57 -1
  5. package/src/core/agent-step.ts +231 -14
  6. package/src/core/agent-types.ts +3 -0
  7. package/src/core/file-io.ts +8 -74
  8. package/src/core/pipeline-runner.ts +54 -3
  9. package/src/core/status-writer.ts +44 -26
  10. package/src/core/task-runner.ts +27 -3
  11. package/src/harness/__tests__/discovery.test.ts +60 -8
  12. package/src/harness/discovery.ts +16 -6
  13. package/src/ui/client/hooks/useJobListWithUpdates.ts +16 -1
  14. package/src/ui/dist/assets/{index-CbS3OsW7.js → index--RH3sAt3.js} +14 -1
  15. package/src/ui/dist/assets/{index-CbS3OsW7.js.map → index--RH3sAt3.js.map} +1 -1
  16. package/src/ui/dist/assets/style-CSSKuMOe.css +2 -0
  17. package/src/ui/dist/index.html +2 -2
  18. package/src/ui/embedded-assets.js +6 -6
  19. package/src/ui/server/__tests__/gate-endpoints.test.ts +90 -0
  20. package/src/ui/server/__tests__/job-control-endpoints.test.ts +188 -0
  21. package/src/ui/server/__tests__/path-containment.test.ts +54 -0
  22. package/src/ui/server/__tests__/status-corruption.test.ts +55 -0
  23. package/src/ui/server/config-bridge.ts +1 -0
  24. package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +77 -0
  25. package/src/ui/server/endpoints/gate-endpoints.ts +17 -1
  26. package/src/ui/server/endpoints/job-control-endpoints.ts +36 -2
  27. package/src/ui/server/endpoints/upload-endpoints.ts +13 -3
  28. package/src/ui/server/utils/http-utils.ts +6 -1
  29. package/src/ui/server/utils/path-containment.ts +18 -0
  30. package/src/ui/server/utils/status-corruption.ts +27 -0
  31. package/src/ui/dist/assets/style-BUFg3Sth.css +0 -2
@@ -11,8 +11,8 @@
11
11
  />
12
12
  <title>Prompt Pipeline Dashboard</title>
13
13
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
14
- <script type="module" crossorigin src="/assets/index-CbS3OsW7.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/style-BUFg3Sth.css">
14
+ <script type="module" crossorigin src="/assets/index--RH3sAt3.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/style-CSSKuMOe.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
@@ -1,12 +1,12 @@
1
1
  // Auto-generated by scripts/generate-embedded-assets.js — do not edit
2
2
  import _asset0 from "./dist/index.html" with { type: "file" };
3
- import _asset1 from "./dist/assets/index-CbS3OsW7.js.map" with { type: "file" };
4
- import _asset2 from "./dist/assets/style-BUFg3Sth.css" with { type: "file" };
5
- import _asset3 from "./dist/assets/index-CbS3OsW7.js" with { type: "file" };
3
+ import _asset1 from "./dist/assets/style-CSSKuMOe.css" with { type: "file" };
4
+ import _asset2 from "./dist/assets/index--RH3sAt3.js" with { type: "file" };
5
+ import _asset3 from "./dist/assets/index--RH3sAt3.js.map" with { type: "file" };
6
6
 
7
7
  export const embeddedAssets = {
8
8
  "/index.html": { path: _asset0, mime: "text/html" },
9
- "/assets/index-CbS3OsW7.js.map": { path: _asset1, mime: "application/json" },
10
- "/assets/style-BUFg3Sth.css": { path: _asset2, mime: "text/css" },
11
- "/assets/index-CbS3OsW7.js": { path: _asset3, mime: "application/javascript" }
9
+ "/assets/style-CSSKuMOe.css": { path: _asset1, mime: "text/css" },
10
+ "/assets/index--RH3sAt3.js": { path: _asset2, mime: "application/javascript" },
11
+ "/assets/index--RH3sAt3.js.map": { path: _asset3, mime: "application/json" }
12
12
  };
@@ -74,6 +74,32 @@ function gateRequest(jobId: string, body: unknown): Request {
74
74
  });
75
75
  }
76
76
 
77
+ function corruptStatusOnTextRead(
78
+ statusPath: string,
79
+ readNumber: number,
80
+ corruptContent = "{ invalid json }",
81
+ ): void {
82
+ const realFile = Bun.file.bind(Bun);
83
+ let textReads = 0;
84
+
85
+ vi.spyOn(Bun, "file").mockImplementation(((filePath: Parameters<typeof Bun.file>[0], options?: Parameters<typeof Bun.file>[1]) => {
86
+ const file = realFile(filePath, options);
87
+ if (String(filePath) !== statusPath) return file;
88
+
89
+ return {
90
+ exists: () => file.exists(),
91
+ text: async () => {
92
+ textReads += 1;
93
+ if (textReads === readNumber) {
94
+ await Bun.write(statusPath, corruptContent);
95
+ return corruptContent;
96
+ }
97
+ return file.text();
98
+ },
99
+ } as ReturnType<typeof Bun.file>;
100
+ }) as typeof Bun.file);
101
+ }
102
+
77
103
  afterEach(async () => {
78
104
  vi.restoreAllMocks();
79
105
  resetPATHS();
@@ -196,6 +222,70 @@ describe("handleGateDecision", () => {
196
222
  expect(body["code"]).toBe("JOB_NOT_FOUND");
197
223
  });
198
224
 
225
+ it("approve returns 409 STATUS_CORRUPT when current tasks-status.json is corrupt", async () => {
226
+ const root = await makeTempRoot();
227
+ initPATHS(root);
228
+ const jobDir = path.join(root, "pipeline-data", "current", "corrupt-approve");
229
+ await mkdir(jobDir, { recursive: true });
230
+ const corruptContent = "{ invalid json }";
231
+ await writeFile(path.join(jobDir, "tasks-status.json"), corruptContent);
232
+
233
+ const res = await handleGateDecision("corrupt-approve", gateRequest("corrupt-approve", { action: "approve" }), root);
234
+ const body = await res.json() as Record<string, unknown>;
235
+
236
+ expect(res.status).toBe(409);
237
+ expect(body["code"]).toBe("STATUS_CORRUPT");
238
+
239
+ const fileContent = await readFile(path.join(jobDir, "tasks-status.json"), "utf8");
240
+ expect(fileContent).toBe(corruptContent);
241
+ });
242
+
243
+ it("reject returns 409 STATUS_CORRUPT when current tasks-status.json is corrupt", async () => {
244
+ const root = await makeTempRoot();
245
+ initPATHS(root);
246
+ const jobDir = path.join(root, "pipeline-data", "current", "corrupt-reject");
247
+ await mkdir(jobDir, { recursive: true });
248
+ const corruptContent = "{ invalid json }";
249
+ await writeFile(path.join(jobDir, "tasks-status.json"), corruptContent);
250
+
251
+ const res = await handleGateDecision("corrupt-reject", gateRequest("corrupt-reject", { action: "reject" }), root);
252
+ const body = await res.json() as Record<string, unknown>;
253
+
254
+ expect(res.status).toBe(409);
255
+ expect(body["code"]).toBe("STATUS_CORRUPT");
256
+
257
+ const fileContent = await readFile(path.join(jobDir, "tasks-status.json"), "utf8");
258
+ expect(fileContent).toBe(corruptContent);
259
+ });
260
+
261
+ it("releases the gate slot when the status write detects corruption", async () => {
262
+ const root = await makeTempRoot();
263
+ initPATHS(root);
264
+ const jobDir = await setupJob(root, "write-corrupt-gate", makeSnapshot());
265
+ const statusPath = path.join(jobDir, "tasks-status.json");
266
+ corruptStatusOnTextRead(statusPath, 3);
267
+
268
+ const res = await handleGateDecision(
269
+ "write-corrupt-gate",
270
+ gateRequest("write-corrupt-gate", { action: "reject" }),
271
+ root,
272
+ );
273
+ const body = await res.json() as Record<string, unknown>;
274
+
275
+ expect(res.status).toBe(409);
276
+ expect(body["code"]).toBe("STATUS_CORRUPT");
277
+ expect(body["path"]).toBe(statusPath);
278
+ expect(await readFile(statusPath, "utf8")).toBe("{ invalid json }");
279
+
280
+ const reacquired = await tryAcquireJobSlot({
281
+ dataDir: path.join(root, "pipeline-data"),
282
+ jobId: "write-corrupt-gate",
283
+ maxConcurrentJobs: 1,
284
+ source: "gate",
285
+ });
286
+ expect(reacquired.ok).toBe(true);
287
+ });
288
+
199
289
  it("returns 400 for a bad action", async () => {
200
290
  const root = await makeTempRoot();
201
291
  initPATHS(root);
@@ -92,6 +92,32 @@ function spawnSleeper(): Subprocess {
92
92
  return proc;
93
93
  }
94
94
 
95
+ function corruptStatusOnTextRead(
96
+ statusPath: string,
97
+ readNumber: number,
98
+ corruptContent = "{ invalid json }",
99
+ ): void {
100
+ const realFile = Bun.file.bind(Bun);
101
+ let textReads = 0;
102
+
103
+ vi.spyOn(Bun, "file").mockImplementation(((filePath: Parameters<typeof Bun.file>[0], options?: Parameters<typeof Bun.file>[1]) => {
104
+ const file = realFile(filePath, options);
105
+ if (String(filePath) !== statusPath) return file;
106
+
107
+ return {
108
+ exists: () => file.exists(),
109
+ text: async () => {
110
+ textReads += 1;
111
+ if (textReads === readNumber) {
112
+ await Bun.write(statusPath, corruptContent);
113
+ return corruptContent;
114
+ }
115
+ return file.text();
116
+ },
117
+ } as ReturnType<typeof Bun.file>;
118
+ }) as typeof Bun.file);
119
+ }
120
+
95
121
  afterEach(async () => {
96
122
  vi.restoreAllMocks();
97
123
  resetPATHS();
@@ -1672,3 +1698,165 @@ describe("concurrency enforcement", () => {
1672
1698
  expect(lease.jobId).toBe("lease-pid");
1673
1699
  });
1674
1700
  });
1701
+
1702
+ describe("corrupt status handling", () => {
1703
+ async function setupCorruptJob(
1704
+ root: string,
1705
+ jobId: string,
1706
+ location: "current" | "complete" = "current",
1707
+ pid?: number,
1708
+ ): Promise<string> {
1709
+ const jobDir = path.join(root, "pipeline-data", location, jobId);
1710
+ await mkdir(jobDir, { recursive: true });
1711
+ await writeFile(path.join(jobDir, "tasks-status.json"), "not-valid-json{{{");
1712
+ if (pid !== undefined) {
1713
+ await writeFile(path.join(jobDir, "runner.pid"), String(pid));
1714
+ }
1715
+ return jobDir;
1716
+ }
1717
+
1718
+ it("corrupt current status makes handleJobStop return 409 STATUS_CORRUPT", async () => {
1719
+ const root = await makeTempRoot();
1720
+ const jobDir = await setupCorruptJob(root, "corrupt-stop", "current");
1721
+
1722
+ const req = new Request("http://localhost/api/jobs/corrupt-stop/stop", { method: "POST" });
1723
+ const res = await handleJobStop(req, "corrupt-stop", root);
1724
+
1725
+ expect(res.status).toBe(409);
1726
+ const body = await res.json() as Record<string, unknown>;
1727
+ expect(body["code"]).toBe("STATUS_CORRUPT");
1728
+ expect(body["path"]).toContain("corrupt-stop");
1729
+
1730
+ const raw = await readFile(path.join(jobDir, "tasks-status.json"), "utf-8");
1731
+ expect(raw).toBe("not-valid-json{{{");
1732
+ });
1733
+
1734
+ it("kills a live runner before returning STATUS_CORRUPT for corrupt current status", async () => {
1735
+ const root = await makeTempRoot();
1736
+ const proc = spawnSleeper();
1737
+ const jobDir = await setupCorruptJob(root, "corrupt-stop-live", "current", proc.pid);
1738
+
1739
+ const req = new Request("http://localhost/api/jobs/corrupt-stop-live/stop", { method: "POST" });
1740
+ const res = await handleJobStop(req, "corrupt-stop-live", root);
1741
+
1742
+ expect(res.status).toBe(409);
1743
+ const body = await res.json() as Record<string, unknown>;
1744
+ expect(body["code"]).toBe("STATUS_CORRUPT");
1745
+ expect(body["path"]).toContain("corrupt-stop-live");
1746
+
1747
+ const raw = await readFile(path.join(jobDir, "tasks-status.json"), "utf-8");
1748
+ expect(raw).toBe("not-valid-json{{{");
1749
+ expect(await Bun.file(path.join(jobDir, "runner.pid")).exists()).toBe(false);
1750
+
1751
+ const exited = await Promise.race([
1752
+ proc.exited.then(() => true),
1753
+ new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 1000)),
1754
+ ]);
1755
+ expect(exited).toBe(true);
1756
+ });
1757
+
1758
+ it("corrupt current status makes handleJobRestart return 409 STATUS_CORRUPT", async () => {
1759
+ const root = await makeTempRoot();
1760
+ const jobDir = await setupCorruptJob(root, "corrupt-restart", "current");
1761
+
1762
+ const req = new Request("http://localhost/api/jobs/corrupt-restart/restart", { method: "POST" });
1763
+ const res = await handleJobRestart(req, "corrupt-restart", root);
1764
+
1765
+ expect(res.status).toBe(409);
1766
+ const body = await res.json() as Record<string, unknown>;
1767
+ expect(body["code"]).toBe("STATUS_CORRUPT");
1768
+
1769
+ const raw = await readFile(path.join(jobDir, "tasks-status.json"), "utf-8");
1770
+ expect(raw).toBe("not-valid-json{{{");
1771
+ });
1772
+
1773
+ it("corrupt current status makes handleTaskStart return 409 STATUS_CORRUPT", async () => {
1774
+ const root = await makeTempRoot();
1775
+ const jobDir = await setupCorruptJob(root, "corrupt-task-start", "current");
1776
+
1777
+ const req = new Request("http://localhost/api/jobs/corrupt-task-start/tasks/research/start", { method: "POST" });
1778
+ const res = await handleTaskStart(req, "corrupt-task-start", "research", root);
1779
+
1780
+ expect(res.status).toBe(409);
1781
+ const body = await res.json() as Record<string, unknown>;
1782
+ expect(body["code"]).toBe("STATUS_CORRUPT");
1783
+
1784
+ const raw = await readFile(path.join(jobDir, "tasks-status.json"), "utf-8");
1785
+ expect(raw).toBe("not-valid-json{{{");
1786
+ });
1787
+
1788
+ it("corrupt complete status makes handleJobRestart return 409 STATUS_CORRUPT", async () => {
1789
+ const root = await makeTempRoot();
1790
+ const jobDir = await setupCorruptJob(root, "corrupt-complete-restart", "complete");
1791
+
1792
+ const req = new Request("http://localhost/api/jobs/corrupt-complete-restart/restart", { method: "POST" });
1793
+ const res = await handleJobRestart(req, "corrupt-complete-restart", root);
1794
+
1795
+ expect(res.status).toBe(409);
1796
+ const body = await res.json() as Record<string, unknown>;
1797
+ expect(body["code"]).toBe("STATUS_CORRUPT");
1798
+
1799
+ const raw = await readFile(path.join(jobDir, "tasks-status.json"), "utf-8");
1800
+ expect(raw).toBe("not-valid-json{{{");
1801
+ });
1802
+
1803
+ it("releases the restart slot when the status write detects corruption", async () => {
1804
+ const root = await makeTempRoot();
1805
+ const jobDir = await setupJob(root, "write-corrupt-restart", {
1806
+ id: "write-corrupt-restart",
1807
+ state: "pending",
1808
+ current: null,
1809
+ currentStage: null,
1810
+ lastUpdated: "2026-04-01T10:00:00.000Z",
1811
+ tasks: { research: { state: "pending" } },
1812
+ files: { artifacts: [], logs: [], tmp: [] },
1813
+ });
1814
+ const statusPath = path.join(jobDir, "tasks-status.json");
1815
+ corruptStatusOnTextRead(statusPath, 3);
1816
+
1817
+ const req = new Request("http://localhost/api/jobs/write-corrupt-restart/restart", {
1818
+ method: "POST",
1819
+ headers: { "Content-Type": "application/json" },
1820
+ body: JSON.stringify({ fromTask: "research" }),
1821
+ });
1822
+ const res = await handleJobRestart(req, "write-corrupt-restart", root);
1823
+ const body = await res.json() as Record<string, unknown>;
1824
+
1825
+ expect(res.status).toBe(409);
1826
+ expect(body["code"]).toBe("STATUS_CORRUPT");
1827
+ expect(body["path"]).toBe(statusPath);
1828
+ expect(await readFile(statusPath, "utf8")).toBe("{ invalid json }");
1829
+
1830
+ const reacquired = await tryAcquireJobSlot({
1831
+ dataDir: path.join(root, "pipeline-data"),
1832
+ jobId: "write-corrupt-restart",
1833
+ maxConcurrentJobs: 1,
1834
+ source: "restart",
1835
+ });
1836
+ expect(reacquired.ok).toBe(true);
1837
+ });
1838
+
1839
+ it("maps handleJobStop write-time StatusCorruptError to 409 STATUS_CORRUPT", async () => {
1840
+ const root = await makeTempRoot();
1841
+ const jobDir = await setupJob(root, "write-corrupt-stop", {
1842
+ id: "write-corrupt-stop",
1843
+ state: "running",
1844
+ current: "research",
1845
+ currentStage: null,
1846
+ lastUpdated: "2026-04-01T10:00:00.000Z",
1847
+ tasks: { research: { state: "running" } },
1848
+ files: { artifacts: [], logs: [], tmp: [] },
1849
+ });
1850
+ const statusPath = path.join(jobDir, "tasks-status.json");
1851
+ corruptStatusOnTextRead(statusPath, 2);
1852
+
1853
+ const req = new Request("http://localhost/api/jobs/write-corrupt-stop/stop", { method: "POST" });
1854
+ const res = await handleJobStop(req, "write-corrupt-stop", root);
1855
+ const body = await res.json() as Record<string, unknown>;
1856
+
1857
+ expect(res.status).toBe(409);
1858
+ expect(body["code"]).toBe("STATUS_CORRUPT");
1859
+ expect(body["path"]).toBe(statusPath);
1860
+ expect(await readFile(statusPath, "utf8")).toBe("{ invalid json }");
1861
+ });
1862
+ });
@@ -0,0 +1,54 @@
1
+ import path from "node:path";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { resolveWithin } from "../utils/path-containment";
5
+
6
+ describe("resolveWithin", () => {
7
+ const rootDir = "/app/staging";
8
+
9
+ it("rejects relative path with parent dir escape", () => {
10
+ expect(resolveWithin(rootDir, "../x")).toBeNull();
11
+ });
12
+
13
+ it("rejects deeply nested parent dir escape", () => {
14
+ expect(resolveWithin(rootDir, "../../../../tmp/PWNED.txt")).toBeNull();
15
+ });
16
+
17
+ it("rejects normalized parent dir escape", () => {
18
+ expect(resolveWithin(rootDir, "a/../../b")).toBeNull();
19
+ });
20
+
21
+ it("rejects absolute path", () => {
22
+ expect(resolveWithin(rootDir, "/etc/passwd")).toBeNull();
23
+ });
24
+
25
+ it("rejects empty string", () => {
26
+ expect(resolveWithin(rootDir, "")).toBeNull();
27
+ });
28
+
29
+ it("rejects non-string input", () => {
30
+ expect(resolveWithin(rootDir, 42)).toBeNull();
31
+ });
32
+
33
+ it("rejects string containing NUL byte", () => {
34
+ expect(resolveWithin(rootDir, "safe\0/../escape")).toBeNull();
35
+ });
36
+
37
+ it("rejects current-directory root target", () => {
38
+ expect(resolveWithin(rootDir, ".")).toBeNull();
39
+ });
40
+
41
+ it("rejects nested path that normalizes to root", () => {
42
+ expect(resolveWithin(rootDir, "dir/..")).toBeNull();
43
+ });
44
+
45
+ it("resolves a plain filename within root", () => {
46
+ const result = resolveWithin(rootDir, "out.json");
47
+ expect(result).toBe(path.resolve(rootDir, "out.json"));
48
+ });
49
+
50
+ it("resolves a nested relative path within root", () => {
51
+ const result = resolveWithin(rootDir, "data/foo.json");
52
+ expect(result).toBe(path.resolve(rootDir, "data/foo.json"));
53
+ });
54
+ });
@@ -0,0 +1,55 @@
1
+ import { mkdtempSync } from "node:fs";
2
+ import { mkdir, rm } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+
7
+ import { StatusCorruptError } from "../../../core/status-writer";
8
+ import { assertKnownJobStatusesParseable, assertStatusParseable, statusCorruptResponse } from "../utils/status-corruption";
9
+
10
+ const tempRoots: string[] = [];
11
+
12
+ function makeTempRoot(): string {
13
+ const dir = mkdtempSync(join(tmpdir(), "status-corruption-"));
14
+ tempRoots.push(dir);
15
+ return dir;
16
+ }
17
+
18
+ afterEach(async () => {
19
+ await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
20
+ });
21
+
22
+ describe("assertStatusParseable", () => {
23
+ it("throws StatusCorruptError for corrupt JSON", async () => {
24
+ const dir = makeTempRoot();
25
+ const statusPath = join(dir, "tasks-status.json");
26
+ await Bun.write(statusPath, "{ not valid json }");
27
+ await expect(assertStatusParseable(statusPath)).rejects.toThrow(StatusCorruptError);
28
+ });
29
+
30
+ it("resolves for missing file", async () => {
31
+ await expect(assertStatusParseable("/tmp/nonexistent-path/tasks-status.json")).resolves.toBeUndefined();
32
+ });
33
+ });
34
+
35
+ describe("assertKnownJobStatusesParseable", () => {
36
+ it("throws StatusCorruptError for a known corrupt complete status", async () => {
37
+ const root = makeTempRoot();
38
+ const jobDir = join(root, "pipeline-data", "complete", "job-corrupt");
39
+ await mkdir(jobDir, { recursive: true });
40
+ await Bun.write(join(jobDir, "tasks-status.json"), "{ not valid json }");
41
+
42
+ await expect(assertKnownJobStatusesParseable(root, "job-corrupt")).rejects.toThrow(StatusCorruptError);
43
+ });
44
+ });
45
+
46
+ describe("statusCorruptResponse", () => {
47
+ it("returns 409 with STATUS_CORRUPT code and path", async () => {
48
+ const err = new StatusCorruptError("/some/path");
49
+ const response = statusCorruptResponse(err);
50
+ const body = await response.json() as Record<string, unknown>;
51
+ expect(response.status).toBe(409);
52
+ expect(body.code).toBe("STATUS_CORRUPT");
53
+ expect(body.path).toBe("/some/path");
54
+ });
55
+ });
@@ -26,6 +26,7 @@ export const Constants = {
26
26
  FS_ERROR: "FS_ERROR",
27
27
  JOB_NOT_FOUND: "JOB_NOT_FOUND",
28
28
  BAD_REQUEST: "BAD_REQUEST",
29
+ STATUS_CORRUPT: "STATUS_CORRUPT",
29
30
  }),
30
31
  } as const;
31
32
 
@@ -99,4 +99,81 @@ describe("handleSeedUploadDirect", () => {
99
99
  await rm(dataDir, { recursive: true, force: true });
100
100
  }
101
101
  });
102
+
103
+ 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
+ }
117
+ });
118
+
119
+ 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
+ }
136
+ });
137
+
138
+ 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
+ }
155
+ });
156
+
157
+ 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
+ }
178
+ });
102
179
  });
@@ -7,7 +7,8 @@ import { getJobDirectoryPath, getPipelineDataDir } from "../../../config/paths";
7
7
  import { getOrchestratorConfig } from "../../../core/config";
8
8
  import { releaseJobSlot } from "../../../core/job-concurrency";
9
9
  import { appendRunEvent } from "../../../core/run-events";
10
- import { readJobStatus, writeJobStatus } from "../../../core/status-writer";
10
+ import { readJobStatus, writeJobStatus, StatusCorruptError } from "../../../core/status-writer";
11
+ import { assertStatusParseable, statusCorruptResponse } from "../utils/status-corruption";
11
12
  import {
12
13
  acquireConcurrencySlot,
13
14
  isProcessAlive,
@@ -58,6 +59,13 @@ export async function handleGateDecision(
58
59
  return sendJson(404, createErrorResponse(Constants.ERROR_CODES.JOB_NOT_FOUND, `job "${jobId}" was not found`));
59
60
  }
60
61
 
62
+ try {
63
+ await assertStatusParseable(statusPath);
64
+ } catch (err) {
65
+ if (err instanceof StatusCorruptError) return statusCorruptResponse(err);
66
+ throw err;
67
+ }
68
+
61
69
  const snapshot = await readJobStatus(jobDir);
62
70
  if (!snapshot) {
63
71
  return sendJson(500, createErrorResponse("status_unavailable", `job "${jobId}" status could not be read`));
@@ -108,6 +116,14 @@ export async function handleGateDecision(
108
116
  slotAcquired = false;
109
117
  }
110
118
  } catch (error) {
119
+ if (error instanceof StatusCorruptError) {
120
+ if (slotAcquired) {
121
+ const orchestrator = getOrchestratorConfig();
122
+ await releaseJobSlot(getPipelineDataDir(dataDir), jobId, orchestrator.lockFileTimeout);
123
+ slotAcquired = false;
124
+ }
125
+ return statusCorruptResponse(error);
126
+ }
111
127
  if (slotAcquired) {
112
128
  const orchestrator = getOrchestratorConfig();
113
129
  await releaseJobSlot(getPipelineDataDir(dataDir), jobId, orchestrator.lockFileTimeout);
@@ -19,8 +19,10 @@ import {
19
19
  resetJobToCleanSlate,
20
20
  resetSingleTask,
21
21
  writeJobStatus,
22
+ StatusCorruptError,
22
23
  type StatusSnapshot,
23
24
  } from "../../../core/status-writer";
25
+ import { assertKnownJobStatusesParseable, statusCorruptResponse } from "../utils/status-corruption";
24
26
  import {
25
27
  materializeNormalizedPipelineDefinition,
26
28
  } from "../../../core/pipeline-definition";
@@ -454,6 +456,18 @@ export async function resolveJobLifecycle(
454
456
  return null;
455
457
  }
456
458
 
459
+ async function resolveJobLifecycleByStatusPresence(
460
+ dataDir: string,
461
+ jobId: string,
462
+ ): Promise<"current" | "complete" | null> {
463
+ for (const location of READ_LOCATIONS) {
464
+ const statusPath = path.join(dataDir, "pipeline-data", location, jobId, "tasks-status.json");
465
+ if (await Bun.file(statusPath).exists()) return location;
466
+ }
467
+
468
+ return null;
469
+ }
470
+
457
471
  export function isRestartInProgress(jobId: string): boolean {
458
472
  return restartingJobs.has(jobId);
459
473
  }
@@ -500,6 +514,9 @@ export async function handleJobRestart(
500
514
  }
501
515
 
502
516
  try {
517
+ try { await assertKnownJobStatusesParseable(dataDir, jobId); }
518
+ catch (err) { if (err instanceof StatusCorruptError) return statusCorruptResponse(err); throw err; }
519
+
503
520
  // Parse optional request body
504
521
  let fromTask: string | undefined;
505
522
  let singleTask: boolean | undefined;
@@ -579,6 +596,12 @@ export async function handleJobRestart(
579
596
  });
580
597
  spawned = true;
581
598
  } catch (err) {
599
+ if (err instanceof StatusCorruptError) {
600
+ if (!spawned) {
601
+ await releaseJobSlot(getPipelineDataDir(dataDir), jobId, getOrchestratorRuntimeConfig().lockFileTimeout);
602
+ }
603
+ return statusCorruptResponse(err);
604
+ }
582
605
  if (!spawned) {
583
606
  await releaseJobSlot(getPipelineDataDir(dataDir), jobId, getOrchestratorRuntimeConfig().lockFileTimeout);
584
607
  }
@@ -601,7 +624,7 @@ export async function handleJobStop(
601
624
  }
602
625
 
603
626
  try {
604
- const lifecycle = await resolveJobLifecycle(dataDir, jobId);
627
+ const lifecycle = await resolveJobLifecycleByStatusPresence(dataDir, jobId);
605
628
  if (!lifecycle) {
606
629
  return sendJson(404, createErrorResponse(Constants.ERROR_CODES.JOB_NOT_FOUND, `job "${jobId}" was not found`));
607
630
  }
@@ -624,10 +647,14 @@ export async function handleJobStop(
624
647
  await cleanupRunnerPid(jobDir);
625
648
  }
626
649
 
650
+ try { await assertKnownJobStatusesParseable(dataDir, jobId); }
651
+ catch (err) { if (err instanceof StatusCorruptError) return statusCorruptResponse(err); throw err; }
652
+
627
653
  let resetTask: string | null = null;
628
654
 
629
655
  // Reset running task and clear root-level fields in a single atomic write.
630
- await writeJobStatus(jobDir, (snapshot) => {
656
+ try {
657
+ await writeJobStatus(jobDir, (snapshot) => {
631
658
  if (snapshot.current && snapshot.tasks[snapshot.current]?.state === "running") {
632
659
  resetTask = snapshot.current;
633
660
  } else {
@@ -667,6 +694,10 @@ export async function handleJobStop(
667
694
  snapshot.currentStage = null;
668
695
  snapshot.progress = getProgressPercent(snapshot);
669
696
  });
697
+ } catch (err) {
698
+ if (err instanceof StatusCorruptError) return statusCorruptResponse(err);
699
+ throw err;
700
+ }
670
701
 
671
702
  return sendJson(202, {
672
703
  ok: true,
@@ -703,6 +734,9 @@ export async function handleTaskStart(
703
734
  }
704
735
 
705
736
  try {
737
+ try { await assertKnownJobStatusesParseable(dataDir, jobId); }
738
+ catch (err) { if (err instanceof StatusCorruptError) return statusCorruptResponse(err); throw err; }
739
+
706
740
  const lifecycle = await resolveJobLifecycle(dataDir, jobId);
707
741
  if (!lifecycle) {
708
742
  return sendJson(404, createErrorResponse(Constants.ERROR_CODES.JOB_NOT_FOUND, `job "${jobId}" was not found`));