@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
@@ -56,8 +56,9 @@ async function setupPipelineConfig(
56
56
  await mkdir(configDir, { recursive: true });
57
57
  await writeFile(path.join(configDir, "pipeline.json"), JSON.stringify({ name: slug, tasks }));
58
58
  await writeFile(path.join(root, "pipeline-config", "registry.json"), JSON.stringify({
59
+ version: 1,
59
60
  pipelines: {
60
- [slug]: {},
61
+ [slug]: { name: slug, description: "test pipeline" },
61
62
  },
62
63
  }));
63
64
  }
@@ -123,6 +124,7 @@ afterEach(async () => {
123
124
  resetPATHS();
124
125
  delete process.env["PO_MAX_RUNNING_JOBS"];
125
126
  delete process.env["PO_STALE_RUNNING_GRACE_MS"];
127
+ delete process.env["PO_STALE_LEASE_TIMEOUT_MS"];
126
128
  resetConfig();
127
129
  for (const proc of childProcs.splice(0)) {
128
130
  try { proc.kill(); } catch {}
@@ -515,6 +517,120 @@ describe("handleJobStop", () => {
515
517
  });
516
518
  });
517
519
 
520
+ describe("handleJobStop slot release (AC-1, AC-9)", () => {
521
+ it("releases the active slot lease after stopping (AC-1)", async () => {
522
+ const root = await makeTempRoot();
523
+ initPATHS(root);
524
+
525
+ await setupJob(root, "stop-release-active", {
526
+ id: "stop-release-active",
527
+ state: "running",
528
+ current: "research",
529
+ currentStage: null,
530
+ tasks: { research: { state: "running", currentStage: null } },
531
+ files: { artifacts: [], logs: [], tmp: [] },
532
+ }, 999999);
533
+
534
+ const acquired = await tryAcquireJobSlot({
535
+ dataDir: path.join(root, "pipeline-data"),
536
+ jobId: "stop-release-active",
537
+ maxConcurrentJobs: 2,
538
+ source: "task-start",
539
+ pid: 999999,
540
+ });
541
+ expect(acquired.ok).toBe(true);
542
+
543
+ const { runningJobsDir } = getConcurrencyRuntimePaths(path.join(root, "pipeline-data"));
544
+ const leasePath = path.join(runningJobsDir, "stop-release-active.json");
545
+ expect(await Bun.file(leasePath).exists()).toBe(true);
546
+
547
+ const req = new Request("http://localhost/api/jobs/stop-release-active/stop", { method: "POST" });
548
+ const res = await handleJobStop(req, "stop-release-active", root);
549
+ const body = await res.json() as Record<string, unknown>;
550
+
551
+ expect(res.status).toBe(202);
552
+ expect(body["ok"]).toBe(true);
553
+ expect(body["stopped"]).toBe(true);
554
+
555
+ expect(await Bun.file(leasePath).exists()).toBe(false);
556
+ });
557
+
558
+ it("does not throw and returns 202 when no lease exists (AC-9)", async () => {
559
+ const root = await makeTempRoot();
560
+ initPATHS(root);
561
+
562
+ await setupJob(root, "stop-release-no-lease", {
563
+ id: "stop-release-no-lease",
564
+ state: "running",
565
+ current: "research",
566
+ currentStage: null,
567
+ tasks: { research: { state: "running", currentStage: null } },
568
+ files: { artifacts: [], logs: [], tmp: [] },
569
+ });
570
+
571
+ const req = new Request("http://localhost/api/jobs/stop-release-no-lease/stop", { method: "POST" });
572
+ const res = await handleJobStop(req, "stop-release-no-lease", root);
573
+ const body = await res.json() as Record<string, unknown>;
574
+
575
+ expect(res.status).toBe(202);
576
+ expect(body["ok"]).toBe(true);
577
+ expect(body["stopped"]).toBe(false);
578
+ });
579
+
580
+ it("does not throw and returns 202 when stopping a complete/ job (AC-9)", async () => {
581
+ const root = await makeTempRoot();
582
+ initPATHS(root);
583
+
584
+ const completeDir = path.join(root, "pipeline-data", "complete", "stop-release-complete");
585
+ await mkdir(completeDir, { recursive: true });
586
+ await writeFile(path.join(completeDir, "tasks-status.json"), JSON.stringify({
587
+ id: "stop-release-complete",
588
+ state: "complete",
589
+ current: null,
590
+ currentStage: null,
591
+ tasks: { research: { state: "done", currentStage: null } },
592
+ files: { artifacts: [], logs: [], tmp: [] },
593
+ }));
594
+
595
+ const req = new Request("http://localhost/api/jobs/stop-release-complete/stop", { method: "POST" });
596
+ const res = await handleJobStop(req, "stop-release-complete", root);
597
+ const body = await res.json() as Record<string, unknown>;
598
+
599
+ expect(res.status).toBe(202);
600
+ expect(body["ok"]).toBe(true);
601
+ });
602
+
603
+ it("still releases the slot when the status is corrupt (AC-1)", async () => {
604
+ const root = await makeTempRoot();
605
+ initPATHS(root);
606
+
607
+ const jobDir = path.join(root, "pipeline-data", "current", "stop-release-corrupt");
608
+ await mkdir(jobDir, { recursive: true });
609
+ await writeFile(path.join(jobDir, "tasks-status.json"), "not-valid-json{{{");
610
+
611
+ const acquired = await tryAcquireJobSlot({
612
+ dataDir: path.join(root, "pipeline-data"),
613
+ jobId: "stop-release-corrupt",
614
+ maxConcurrentJobs: 2,
615
+ source: "task-start",
616
+ });
617
+ expect(acquired.ok).toBe(true);
618
+
619
+ const { runningJobsDir } = getConcurrencyRuntimePaths(path.join(root, "pipeline-data"));
620
+ const leasePath = path.join(runningJobsDir, "stop-release-corrupt.json");
621
+ expect(await Bun.file(leasePath).exists()).toBe(true);
622
+
623
+ const req = new Request("http://localhost/api/jobs/stop-release-corrupt/stop", { method: "POST" });
624
+ const res = await handleJobStop(req, "stop-release-corrupt", root);
625
+ const body = await res.json() as Record<string, unknown>;
626
+
627
+ expect(res.status).toBe(409);
628
+ expect(body["code"]).toBe("STATUS_CORRUPT");
629
+
630
+ expect(await Bun.file(leasePath).exists()).toBe(false);
631
+ });
632
+ });
633
+
518
634
  describe("handleJobRestart", () => {
519
635
  it("returns 409 when runner PID is alive", async () => {
520
636
  const root = await makeTempRoot();
@@ -841,6 +957,265 @@ describe("handleJobRestart", () => {
841
957
  });
842
958
  });
843
959
 
960
+ describe("handleJobRestart dependency gate (AC-5 restart, AC-8, AC-9)", () => {
961
+ it("returns 412 and leaves status/lease untouched when a from-task predecessor is pending", async () => {
962
+ const root = await makeTempRoot();
963
+ initPATHS(root);
964
+ await setupPipelineConfig(root, "restart-deps-pending-pipeline", ["research", "analysis"]);
965
+
966
+ const statusObj = {
967
+ id: "restart-deps-pending",
968
+ pipeline: "restart-deps-pending-pipeline",
969
+ state: "failed",
970
+ current: "analysis",
971
+ currentStage: null,
972
+ lastUpdated: "2026-04-01T10:00:00.000Z",
973
+ tasks: {
974
+ research: { state: "pending", currentStage: null },
975
+ analysis: { state: "pending", currentStage: null },
976
+ },
977
+ files: { artifacts: [], logs: [], tmp: [] },
978
+ };
979
+ const jobDir = await setupJob(root, "restart-deps-pending", statusObj);
980
+
981
+ const statusPath = path.join(jobDir, "tasks-status.json");
982
+ const snapshotBefore = await Bun.file(statusPath).text();
983
+ const spawnSpy = vi.spyOn(Bun, "spawn");
984
+
985
+ const req = new Request("http://localhost/api/jobs/restart-deps-pending/restart", {
986
+ method: "POST",
987
+ body: JSON.stringify({ fromTask: "analysis" }),
988
+ });
989
+ const res = await handleJobRestart(req, "restart-deps-pending", root);
990
+ const body = await res.json() as Record<string, unknown>;
991
+
992
+ expect(res.status).toBe(412);
993
+ expect(body["code"]).toBe("dependencies_not_satisfied");
994
+ expect(body["message"]).toContain("research");
995
+
996
+ expect(await Bun.file(statusPath).text()).toBe(snapshotBefore);
997
+ expect(spawnSpy).not.toHaveBeenCalled();
998
+
999
+ const { runningJobsDir } = getConcurrencyRuntimePaths(path.join(root, "pipeline-data"));
1000
+ expect(await Bun.file(path.join(runningJobsDir, "restart-deps-pending.json")).exists()).toBe(false);
1001
+ });
1002
+
1003
+ it("proceeds when from-task predecessor is done", async () => {
1004
+ const root = await makeTempRoot();
1005
+ initPATHS(root);
1006
+ await setupPipelineConfig(root, "restart-deps-done-pipeline", ["research", "analysis"]);
1007
+ mockRunnerSpawn();
1008
+
1009
+ await setupJob(root, "restart-deps-done", {
1010
+ id: "restart-deps-done",
1011
+ pipeline: "restart-deps-done-pipeline",
1012
+ state: "failed",
1013
+ current: "analysis",
1014
+ currentStage: null,
1015
+ lastUpdated: "2026-04-01T10:00:00.000Z",
1016
+ tasks: {
1017
+ research: { state: "done", currentStage: null },
1018
+ analysis: { state: "pending", currentStage: null },
1019
+ },
1020
+ files: { artifacts: [], logs: [], tmp: [] },
1021
+ });
1022
+
1023
+ const req = new Request("http://localhost/api/jobs/restart-deps-done/restart", {
1024
+ method: "POST",
1025
+ body: JSON.stringify({ fromTask: "analysis" }),
1026
+ });
1027
+ const res = await handleJobRestart(req, "restart-deps-done", root);
1028
+ const body = await res.json() as Record<string, unknown>;
1029
+
1030
+ expect(res.status).toBe(202);
1031
+ expect(body["ok"]).toBe(true);
1032
+ expect(body["mode"]).toBe("from-task");
1033
+ });
1034
+
1035
+ it("proceeds when from-task predecessor is skipped", async () => {
1036
+ const root = await makeTempRoot();
1037
+ initPATHS(root);
1038
+ await setupPipelineConfig(root, "restart-deps-skipped-pipeline", ["research", "analysis"]);
1039
+ mockRunnerSpawn();
1040
+
1041
+ await setupJob(root, "restart-deps-skipped", {
1042
+ id: "restart-deps-skipped",
1043
+ pipeline: "restart-deps-skipped-pipeline",
1044
+ state: "failed",
1045
+ current: "analysis",
1046
+ currentStage: null,
1047
+ lastUpdated: "2026-04-01T10:00:00.000Z",
1048
+ tasks: {
1049
+ research: { state: "skipped", currentStage: null },
1050
+ analysis: { state: "pending", currentStage: null },
1051
+ },
1052
+ files: { artifacts: [], logs: [], tmp: [] },
1053
+ });
1054
+
1055
+ const req = new Request("http://localhost/api/jobs/restart-deps-skipped/restart", {
1056
+ method: "POST",
1057
+ body: JSON.stringify({ fromTask: "analysis" }),
1058
+ });
1059
+ const res = await handleJobRestart(req, "restart-deps-skipped", root);
1060
+ const body = await res.json() as Record<string, unknown>;
1061
+
1062
+ expect(res.status).toBe(202);
1063
+ expect(body["ok"]).toBe(true);
1064
+ expect(body["mode"]).toBe("from-task");
1065
+ });
1066
+
1067
+ it("gates single-task restart identically when a predecessor is pending", async () => {
1068
+ const root = await makeTempRoot();
1069
+ initPATHS(root);
1070
+ await setupPipelineConfig(root, "restart-single-deps-pending-pipeline", ["review", "deploy"]);
1071
+
1072
+ const statusObj = {
1073
+ id: "restart-single-deps-pending",
1074
+ pipeline: "restart-single-deps-pending-pipeline",
1075
+ state: "failed",
1076
+ current: "deploy",
1077
+ currentStage: null,
1078
+ lastUpdated: "2026-04-01T10:00:00.000Z",
1079
+ tasks: {
1080
+ review: { state: "pending", currentStage: null },
1081
+ deploy: { state: "pending", currentStage: null },
1082
+ },
1083
+ files: { artifacts: [], logs: [], tmp: [] },
1084
+ };
1085
+ const jobDir = await setupJob(root, "restart-single-deps-pending", statusObj);
1086
+
1087
+ const statusPath = path.join(jobDir, "tasks-status.json");
1088
+ const snapshotBefore = await Bun.file(statusPath).text();
1089
+ const spawnSpy = vi.spyOn(Bun, "spawn");
1090
+
1091
+ const req = new Request("http://localhost/api/jobs/restart-single-deps-pending/restart", {
1092
+ method: "POST",
1093
+ body: JSON.stringify({ fromTask: "deploy", singleTask: true }),
1094
+ });
1095
+ const res = await handleJobRestart(req, "restart-single-deps-pending", root);
1096
+ const body = await res.json() as Record<string, unknown>;
1097
+
1098
+ expect(res.status).toBe(412);
1099
+ expect(body["code"]).toBe("dependencies_not_satisfied");
1100
+ expect(body["message"]).toContain("review");
1101
+
1102
+ expect(await Bun.file(statusPath).text()).toBe(snapshotBefore);
1103
+ expect(spawnSpy).not.toHaveBeenCalled();
1104
+
1105
+ const { runningJobsDir } = getConcurrencyRuntimePaths(path.join(root, "pipeline-data"));
1106
+ expect(await Bun.file(path.join(runningJobsDir, "restart-single-deps-pending.json")).exists()).toBe(false);
1107
+ });
1108
+
1109
+ it("does not gate clean-slate restart (no fromTask) even when a predecessor is pending", async () => {
1110
+ const root = await makeTempRoot();
1111
+ initPATHS(root);
1112
+ await setupPipelineConfig(root, "restart-clean-ungated-pipeline", ["research", "analysis"]);
1113
+ mockRunnerSpawn();
1114
+
1115
+ await setupJob(root, "restart-clean-ungated", {
1116
+ id: "restart-clean-ungated",
1117
+ pipeline: "restart-clean-ungated-pipeline",
1118
+ state: "failed",
1119
+ current: "analysis",
1120
+ currentStage: null,
1121
+ lastUpdated: "2026-04-01T10:00:00.000Z",
1122
+ tasks: {
1123
+ research: { state: "pending", currentStage: null },
1124
+ analysis: { state: "pending", currentStage: null },
1125
+ },
1126
+ files: { artifacts: [], logs: [], tmp: [] },
1127
+ });
1128
+
1129
+ const req = new Request("http://localhost/api/jobs/restart-clean-ungated/restart", { method: "POST" });
1130
+ const res = await handleJobRestart(req, "restart-clean-ungated", root);
1131
+ const body = await res.json() as Record<string, unknown>;
1132
+
1133
+ expect(res.status).toBe(202);
1134
+ expect(body["ok"]).toBe(true);
1135
+ expect(body["mode"]).toBe("clean-slate");
1136
+ });
1137
+
1138
+ it("returns 500 status_unavailable when the pipeline task order cannot be loaded", async () => {
1139
+ const root = await makeTempRoot();
1140
+ initPATHS(root);
1141
+
1142
+ const statusObj = {
1143
+ id: "restart-no-order",
1144
+ state: "failed",
1145
+ current: "analysis",
1146
+ currentStage: null,
1147
+ lastUpdated: "2026-04-01T10:00:00.000Z",
1148
+ tasks: {
1149
+ research: { state: "done", currentStage: null },
1150
+ analysis: { state: "pending", currentStage: null },
1151
+ },
1152
+ files: { artifacts: [], logs: [], tmp: [] },
1153
+ };
1154
+ const jobDir = await setupJob(root, "restart-no-order", statusObj);
1155
+
1156
+ const statusPath = path.join(jobDir, "tasks-status.json");
1157
+ const snapshotBefore = await Bun.file(statusPath).text();
1158
+ const spawnSpy = vi.spyOn(Bun, "spawn");
1159
+
1160
+ const req = new Request("http://localhost/api/jobs/restart-no-order/restart", {
1161
+ method: "POST",
1162
+ body: JSON.stringify({ fromTask: "analysis" }),
1163
+ });
1164
+ const res = await handleJobRestart(req, "restart-no-order", root);
1165
+ const body = await res.json() as Record<string, unknown>;
1166
+
1167
+ expect(res.status).toBe(500);
1168
+ expect(body["code"]).toBe("status_unavailable");
1169
+
1170
+ expect(await Bun.file(statusPath).text()).toBe(snapshotBefore);
1171
+ expect(spawnSpy).not.toHaveBeenCalled();
1172
+
1173
+ const { runningJobsDir } = getConcurrencyRuntimePaths(path.join(root, "pipeline-data"));
1174
+ expect(await Bun.file(path.join(runningJobsDir, "restart-no-order.json")).exists()).toBe(false);
1175
+ });
1176
+
1177
+ it("returns 404 task_not_found when fromTask is absent from the pipeline order", async () => {
1178
+ const root = await makeTempRoot();
1179
+ initPATHS(root);
1180
+ await setupPipelineConfig(root, "restart-unknown-task-pipeline", ["research", "analysis"]);
1181
+
1182
+ const statusObj = {
1183
+ id: "restart-unknown-task",
1184
+ pipeline: "restart-unknown-task-pipeline",
1185
+ state: "failed",
1186
+ current: "analysis",
1187
+ currentStage: null,
1188
+ lastUpdated: "2026-04-01T10:00:00.000Z",
1189
+ tasks: {
1190
+ research: { state: "done", currentStage: null },
1191
+ analysis: { state: "pending", currentStage: null },
1192
+ },
1193
+ files: { artifacts: [], logs: [], tmp: [] },
1194
+ };
1195
+ const jobDir = await setupJob(root, "restart-unknown-task", statusObj);
1196
+
1197
+ const statusPath = path.join(jobDir, "tasks-status.json");
1198
+ const snapshotBefore = await Bun.file(statusPath).text();
1199
+ const spawnSpy = vi.spyOn(Bun, "spawn");
1200
+
1201
+ const req = new Request("http://localhost/api/jobs/restart-unknown-task/restart", {
1202
+ method: "POST",
1203
+ body: JSON.stringify({ fromTask: "synthesis" }),
1204
+ });
1205
+ const res = await handleJobRestart(req, "restart-unknown-task", root);
1206
+ const body = await res.json() as Record<string, unknown>;
1207
+
1208
+ expect(res.status).toBe(404);
1209
+ expect(body["code"]).toBe("task_not_found");
1210
+
1211
+ expect(await Bun.file(statusPath).text()).toBe(snapshotBefore);
1212
+ expect(spawnSpy).not.toHaveBeenCalled();
1213
+
1214
+ const { runningJobsDir } = getConcurrencyRuntimePaths(path.join(root, "pipeline-data"));
1215
+ expect(await Bun.file(path.join(runningJobsDir, "restart-unknown-task.json")).exists()).toBe(false);
1216
+ });
1217
+ });
1218
+
844
1219
  describe("handleTaskStart", () => {
845
1220
  it("allows restart/start when snapshot has no valid lastUpdated and no live PID (crashed runner scenario)", async () => {
846
1221
  // A runner that crashed before writing a valid lastUpdated should not permanently block
@@ -1295,6 +1670,7 @@ describe("handleTaskStart", () => {
1295
1670
 
1296
1671
  expect(res.status).toBe(412);
1297
1672
  expect(body["code"]).toBe("dependencies_not_satisfied");
1673
+ expect(body["message"]).toContain("research");
1298
1674
  });
1299
1675
 
1300
1676
  it("returns 412 dependencies_not_satisfied when an earlier task is \"failed\"", async () => {
@@ -1321,6 +1697,80 @@ describe("handleTaskStart", () => {
1321
1697
 
1322
1698
  expect(res.status).toBe(412);
1323
1699
  expect(body["code"]).toBe("dependencies_not_satisfied");
1700
+ expect(body["message"]).toContain("research");
1701
+ });
1702
+
1703
+ it("returns 202 when an earlier task is \"skipped\" (done-or-skipped dependency rule)", async () => {
1704
+ const root = await makeTempRoot();
1705
+ initPATHS(root);
1706
+ await setupPipelineConfig(root, "task-start-skipped-deps-pipeline", ["research", "analysis"]);
1707
+ const spawnSpy = mockRunnerSpawn(88888);
1708
+
1709
+ await setupJob(root, "task-start-skipped-deps", {
1710
+ id: "task-start-skipped-deps",
1711
+ pipeline: "task-start-skipped-deps-pipeline",
1712
+ state: "pending",
1713
+ current: null,
1714
+ currentStage: null,
1715
+ tasks: {
1716
+ research: { state: "skipped", currentStage: null },
1717
+ analysis: { state: "pending", currentStage: null },
1718
+ },
1719
+ files: { artifacts: [], logs: [], tmp: [] },
1720
+ });
1721
+
1722
+ const req = new Request("http://localhost/api/jobs/task-start-skipped-deps/tasks/analysis/start", { method: "POST" });
1723
+ const res = await handleTaskStart(req, "task-start-skipped-deps", "analysis", root);
1724
+ const body = await res.json() as Record<string, unknown>;
1725
+
1726
+ expect(res.status).toBe(202);
1727
+ expect(body["ok"]).toBe(true);
1728
+ expect(body["taskId"]).toBe("analysis");
1729
+ expect(spawnSpy).toHaveBeenCalledTimes(1);
1730
+ });
1731
+ });
1732
+
1733
+ describe("acquireConcurrencySlot stale-lease age (AC-5)", () => {
1734
+ it("does not prune a fresh null-pid lease when lockFileTimeout is short relative to PO_STALE_LEASE_TIMEOUT_MS", async () => {
1735
+ const root = await makeTempRoot();
1736
+ initPATHS(root);
1737
+ process.env["PO_STALE_LEASE_TIMEOUT_MS"] = "60000";
1738
+ resetConfig();
1739
+
1740
+ const now = Date.parse("2026-04-01T10:01:00.000Z");
1741
+ vi.spyOn(Date, "now").mockReturnValue(now);
1742
+
1743
+ const jobDir = await setupJob(root, "ac-5-held", {
1744
+ id: "ac-5-held",
1745
+ state: "running",
1746
+ current: "research",
1747
+ currentStage: "prompt",
1748
+ lastUpdated: "2026-04-01T10:00:15.000Z",
1749
+ tasks: {
1750
+ research: { state: "running", currentStage: "prompt" },
1751
+ },
1752
+ files: { artifacts: [], logs: [], tmp: [] },
1753
+ }, 999999);
1754
+
1755
+ const runningJobsDir = path.join(root, "pipeline-data", "runtime", "running-jobs");
1756
+ await mkdir(runningJobsDir, { recursive: true });
1757
+ const slotPath = path.join(runningJobsDir, "ac-5-held.json");
1758
+ await writeFile(slotPath, JSON.stringify({
1759
+ jobId: "ac-5-held",
1760
+ pid: null,
1761
+ acquiredAt: new Date(now - 40_000).toISOString(),
1762
+ source: "restart",
1763
+ slotPath,
1764
+ }));
1765
+
1766
+ const req = new Request("http://localhost/api/jobs/ac-5-held/restart", { method: "POST" });
1767
+ const res = await handleJobRestart(req, "ac-5-held", root);
1768
+ const body = await res.json() as Record<string, unknown>;
1769
+
1770
+ expect(res.status).toBe(409);
1771
+ expect(body["code"]).toBe("job_running");
1772
+
1773
+ expect(await Bun.file(slotPath).text()).toBeDefined();
1324
1774
  });
1325
1775
  });
1326
1776
 
@@ -1867,6 +2317,10 @@ describe("corrupt status handling", () => {
1867
2317
  tasks: { research: { state: "pending" } },
1868
2318
  files: { artifacts: [], logs: [], tmp: [] },
1869
2319
  });
2320
+ await writeFile(path.join(jobDir, "pipeline.json"), JSON.stringify({
2321
+ name: "write-corrupt-restart-pipeline",
2322
+ tasks: [{ name: "research" }],
2323
+ }));
1870
2324
  const statusPath = path.join(jobDir, "tasks-status.json");
1871
2325
  corruptStatusOnTextRead(statusPath, 3);
1872
2326
 
@@ -25,7 +25,12 @@ async function writePipelineRegistry(root: string): Promise<void> {
25
25
  await mkdir(tasksDir, { recursive: true });
26
26
  await writeFile(
27
27
  path.join(root, "pipeline-config", "registry.json"),
28
- JSON.stringify({ pipelines: { demo: { configDir, tasksDir } } }),
28
+ JSON.stringify({
29
+ version: 1,
30
+ pipelines: {
31
+ demo: { name: "Demo", description: "demo pipeline", configDir, tasksDir },
32
+ },
33
+ }),
29
34
  );
30
35
  await writeFile(
31
36
  path.join(configDir, "pipeline.json"),
@@ -121,6 +126,43 @@ describe("job endpoints pipeline config loading", () => {
121
126
  expect(body.data.pipelineConfig?.["marker"]).toBe("shared");
122
127
  expect(body.data.pipelineConfig?.["tasks"]).toEqual([{ name: "shared-task" }]);
123
128
  });
129
+
130
+ it("preserves per-task costs in job detail responses", async () => {
131
+ const root = await makeTempRoot();
132
+ process.env["PO_ROOT"] = root;
133
+ resetConfig();
134
+ initPATHS(root);
135
+ await writePipelineRegistry(root);
136
+ const jobDir = await writeJob(root, "current", "job-costs");
137
+ await writeFile(
138
+ path.join(jobDir, "tasks-status.json"),
139
+ JSON.stringify({
140
+ id: "job-costs",
141
+ name: "job-costs",
142
+ pipeline: "demo",
143
+ createdAt: "2026-06-12T12:00:00.000Z",
144
+ state: "done",
145
+ tasks: {
146
+ build: {
147
+ state: "done",
148
+ tokenUsage: [["openai:gpt-4o", 10, 5, 0.25, "estimated", false, true]],
149
+ },
150
+ },
151
+ }),
152
+ );
153
+
154
+ const res = await handleJobDetail("job-costs");
155
+ const body = await res.json() as {
156
+ data: { costs?: Record<string, Record<string, unknown>> };
157
+ };
158
+
159
+ expect(res.status).toBe(200);
160
+ expect(body.data.costs?.["build"]).toMatchObject({
161
+ source: "estimated",
162
+ totalCost: 0.25,
163
+ costEstimated: true,
164
+ });
165
+ });
124
166
  });
125
167
 
126
168
  describe("handleJobDetail corrupt status", () => {
@@ -0,0 +1,94 @@
1
+ // Source-scan guard (AC-8 / spec §7 Step 6).
2
+ //
3
+ // Asserts that none of the four read/static HTTP boundary files builds its final
4
+ // filesystem read path via a bare `path.join`/`join` from URL-derived params
5
+ // outside the containment helpers `resolveUnderRoot` / `resolveWithin`. The
6
+ // current code is clean; this test exists so a future edit like
7
+ // `const p = path.join(root, slug); await Bun.file(p).text()` fails CI.
8
+ //
9
+ // The scan is span-based, not line-based: each join call's full balanced-paren
10
+ // argument span is evaluated as one unit, so a join split across multiple lines
11
+ // (`path.join(\n root,\n slug,\n)`) is still checked. A join whose call site is
12
+ // lexically enclosed by a `resolveUnderRoot(...)`/`resolveWithin(...)` argument
13
+ // span is allowed — the param is being contained.
14
+ //
15
+ // Known lexical-guard limitation: a URL param first copied to a local variable
16
+ // and then joined (`const s = slug; path.join(root, s)`) is not detected — that
17
+ // needs dataflow, which is out of scope for a source scan.
18
+
19
+ import { describe, expect, it } from "vitest";
20
+
21
+ import path from "node:path";
22
+
23
+ const BOUNDARY_FILES = [
24
+ "../endpoints/schema-file-endpoint.ts",
25
+ "../endpoints/file-endpoints.ts",
26
+ "../endpoints/task-analysis-endpoint.ts",
27
+ "../router.ts",
28
+ ];
29
+
30
+ // Identifiers treated as URL-derived when they appear in a join argument span.
31
+ // Documented exclusions: `type`/`params`/`jobId`/`location` are intentionally
32
+ // omitted (see subspec 6-spec.md) — those inputs are out of this guard's scope
33
+ // because they are not part of the encoded-path containment boundary.
34
+ const URL_DERIVED = /\b(slug|filename|taskId|taskName|pathname|searchParams)\b/;
35
+ // Matches `path.join(`, `path.posix.join(`, `path.win32.join(`, or a bare `join(`
36
+ // not preceded by a word char, `$`, or `.` (the `.` exclusion skips array `.join(`).
37
+ const JOIN_CALL = /(?:path(?:\.posix|\.win32)?\.join|(?<![\w$.])join)\s*\(/g;
38
+ // Containment helpers whose argument span makes a join nested inside them safe.
39
+ const CONTAINMENT_CALL = /(?:resolveUnderRoot|resolveWithin)\s*\(/g;
40
+
41
+ type Span = { open: number; close: number };
42
+ type Violation = { file: string; line: number; snippet: string };
43
+
44
+ // Given the index of an opening `(`, return the index of its matching `)`.
45
+ function matchingParen(src: string, openIndex: number): number {
46
+ let depth = 0;
47
+ for (let i = openIndex; i < src.length; i++) {
48
+ if (src[i] === "(") depth++;
49
+ else if (src[i] === ")") {
50
+ depth--;
51
+ if (depth === 0) return i;
52
+ }
53
+ }
54
+ return src.length - 1;
55
+ }
56
+
57
+ // All call spans for a regex that matches up to and including the `(`.
58
+ function callSpans(src: string, callRe: RegExp): Span[] {
59
+ const spans: Span[] = [];
60
+ callRe.lastIndex = 0;
61
+ for (let m = callRe.exec(src); m !== null; m = callRe.exec(src)) {
62
+ const open = m.index + m[0].length - 1; // index of the `(`
63
+ spans.push({ open, close: matchingParen(src, open) });
64
+ }
65
+ return spans;
66
+ }
67
+
68
+ async function scan(filePath: string): Promise<Violation[]> {
69
+ const src = await Bun.file(filePath).text();
70
+ const containment = callSpans(src, CONTAINMENT_CALL);
71
+ const violations: Violation[] = [];
72
+ for (const join of callSpans(src, JOIN_CALL)) {
73
+ const args = src.slice(join.open + 1, join.close);
74
+ if (!URL_DERIVED.test(args) && !args.includes("${")) continue;
75
+ // Allowed when the join call sits inside a containment call's argument span.
76
+ const contained = containment.some((c) => c.open < join.open && join.close <= c.close);
77
+ if (contained) continue;
78
+ const line = src.slice(0, join.open).split("\n").length;
79
+ violations.push({ file: path.basename(filePath), line, snippet: args.replace(/\s+/g, " ").trim() });
80
+ }
81
+ return violations;
82
+ }
83
+
84
+ describe("read/static path-building source guard (AC-8)", () => {
85
+ it("no boundary file builds a final read path via bare path.join(<root>, <url-param>)", async () => {
86
+ const base = import.meta.dirname;
87
+ const all: Violation[] = [];
88
+ for (const rel of BOUNDARY_FILES) {
89
+ const full = path.resolve(base, rel);
90
+ all.push(...(await scan(full)));
91
+ }
92
+ expect(all, all.map((v) => `${v.file}:${v.line} → ${v.snippet}`).join("\n")).toEqual([]);
93
+ });
94
+ });