@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.
- package/docs/http-api.md +14 -4
- package/package.json +1 -4
- package/src/api/__tests__/index.test.ts +144 -41
- package/src/api/index.ts +15 -83
- package/src/cli/__tests__/analyze-task.test.ts +40 -7
- package/src/cli/__tests__/index.test.ts +337 -17
- package/src/cli/__tests__/types.test.ts +0 -18
- package/src/cli/__tests__/update-pipeline-json.test.ts +19 -9
- package/src/cli/analyze-task.ts +3 -14
- package/src/cli/index.ts +73 -61
- package/src/cli/types.ts +0 -13
- package/src/cli/update-pipeline-json.ts +3 -14
- package/src/config/__tests__/paths.test.ts +46 -1
- package/src/config/__tests__/pipeline-registry.test.ts +767 -0
- package/src/config/__tests__/sse-events.test.ts +470 -0
- package/src/config/paths.ts +11 -2
- package/src/config/pipeline-registry.ts +258 -0
- package/src/config/sse-events.ts +122 -0
- package/src/core/__tests__/agent-step.test.ts +115 -0
- package/src/core/__tests__/config.test.ts +517 -107
- package/src/core/__tests__/core-boot-resolution.test.ts +181 -0
- package/src/core/__tests__/job-concurrency.test.ts +200 -0
- package/src/core/__tests__/job-submission.test.ts +396 -0
- package/src/core/__tests__/job-view.test.ts +485 -3
- package/src/core/__tests__/json-file.test.ts +111 -0
- package/src/core/__tests__/lifecycle-policy.test.ts +81 -7
- package/src/core/__tests__/logger.test.ts +22 -0
- package/src/core/__tests__/orchestrator-no-deprecated-exports.test.ts +41 -0
- package/src/core/__tests__/orchestrator.test.ts +373 -2
- package/src/core/__tests__/pipeline-runner.test.ts +946 -9
- package/src/core/__tests__/redact.test.ts +153 -0
- package/src/core/__tests__/runner-liveness.test.ts +45 -0
- package/src/core/__tests__/seed-naming.test.ts +57 -0
- package/src/core/__tests__/single-derivation.test.ts +1 -1
- package/src/core/__tests__/task-runner.test.ts +594 -3
- package/src/core/__tests__/task-telemetry.test.ts +241 -0
- package/src/core/__tests__/workspace-root-resolution-guard.test.ts +62 -0
- package/src/core/agent-step.ts +4 -1
- package/src/core/agent-types.ts +5 -4
- package/src/core/config.ts +127 -234
- package/src/core/control.ts +3 -0
- package/src/core/job-concurrency.ts +50 -19
- package/src/core/job-submission.ts +133 -0
- package/src/core/job-view.ts +162 -18
- package/src/core/json-file.ts +45 -0
- package/src/core/lifecycle-policy.ts +23 -8
- package/src/core/logger.ts +0 -29
- package/src/core/orchestrator.ts +124 -70
- package/src/core/pipeline-runner.ts +85 -53
- package/src/core/redact.ts +40 -0
- package/src/core/runner-liveness.ts +8 -4
- package/src/core/seed-naming.ts +9 -0
- package/src/core/status-writer.ts +3 -28
- package/src/core/task-runner.ts +356 -319
- package/src/core/task-telemetry.ts +107 -0
- package/src/harness/__tests__/subprocess.test.ts +112 -1
- package/src/harness/subprocess.ts +55 -16
- package/src/llm/__tests__/index.test.ts +684 -33
- package/src/llm/index.ts +310 -67
- package/src/providers/__tests__/alibaba.test.ts +67 -6
- package/src/providers/__tests__/anthropic.test.ts +35 -14
- package/src/providers/__tests__/base.test.ts +62 -0
- package/src/providers/__tests__/claude-code.test.ts +99 -14
- package/src/providers/__tests__/deepseek.test.ts +16 -6
- package/src/providers/__tests__/gemini.test.ts +47 -25
- package/src/providers/__tests__/moonshot.test.ts +27 -0
- package/src/providers/__tests__/openai.test.ts +262 -74
- package/src/providers/__tests__/opencode.test.ts +77 -0
- package/src/providers/__tests__/request-timeout-contract.test.ts +226 -0
- package/src/providers/__tests__/stream-accumulator.test.ts +52 -0
- package/src/providers/__tests__/types.test.ts +85 -0
- package/src/providers/__tests__/zero-fill-guard.test.ts +62 -0
- package/src/providers/__tests__/zhipu.test.ts +27 -14
- package/src/providers/alibaba.ts +20 -39
- package/src/providers/anthropic.ts +23 -11
- package/src/providers/base.ts +19 -0
- package/src/providers/claude-code.ts +27 -18
- package/src/providers/deepseek.ts +9 -28
- package/src/providers/gemini.ts +20 -58
- package/src/providers/moonshot.ts +15 -11
- package/src/providers/openai.ts +79 -61
- package/src/providers/opencode.ts +16 -33
- package/src/providers/stream-accumulator.ts +27 -21
- package/src/providers/types.ts +29 -4
- package/src/providers/zhipu.ts +15 -44
- package/src/task-analysis/__tests__/analyzer.test.ts +0 -0
- package/src/task-analysis/__tests__/enrichers-schema-deducer.test.ts +34 -2
- package/src/task-analysis/__tests__/no-ast-imports.test.ts +52 -0
- package/src/task-analysis/__tests__/repository.test.ts +65 -0
- package/src/task-analysis/__tests__/types.test.ts +63 -130
- package/src/task-analysis/analyzer.ts +91 -0
- package/src/task-analysis/enrichers/schema-deducer.ts +13 -2
- package/src/task-analysis/index.ts +2 -36
- package/src/task-analysis/repository.ts +45 -0
- package/src/task-analysis/types.ts +42 -58
- package/src/ui/client/__tests__/api.test.ts +143 -1
- package/src/ui/client/__tests__/bootstrap.test.ts +178 -62
- package/src/ui/client/__tests__/job-adapter.test.ts +125 -2
- package/src/ui/client/__tests__/load-state.test.ts +70 -0
- package/src/ui/client/__tests__/types.test.ts +66 -3
- package/src/ui/client/__tests__/useJobDetailWithUpdates.test.ts +390 -77
- package/src/ui/client/__tests__/useJobList.test.ts +198 -23
- package/src/ui/client/__tests__/useJobListWithUpdates.test.ts +218 -7
- package/src/ui/client/adapters/__tests__/job-adapter.test.ts +186 -0
- package/src/ui/client/adapters/job-adapter.ts +31 -16
- package/src/ui/client/api.ts +38 -15
- package/src/ui/client/bootstrap.ts +19 -14
- package/src/ui/client/hooks/useJobDetailWithUpdates.ts +73 -97
- package/src/ui/client/hooks/useJobList.ts +26 -31
- package/src/ui/client/hooks/useJobListWithUpdates.ts +42 -76
- package/src/ui/client/load-state.ts +20 -0
- package/src/ui/client/reducers/__tests__/job-events.test.ts +568 -0
- package/src/ui/client/reducers/job-events.ts +137 -0
- package/src/ui/client/types.ts +14 -20
- package/src/ui/components/DAGGrid.tsx +6 -1
- package/src/ui/components/JobDetail.tsx +12 -4
- package/src/ui/components/JobTable.tsx +13 -2
- package/src/ui/components/StageTimeline.tsx +8 -20
- package/src/ui/components/TaskAnalysisDisplay.tsx +48 -6
- package/src/ui/components/__tests__/DAGGrid.test.tsx +36 -0
- package/src/ui/components/__tests__/JobDetail.test.tsx +112 -0
- package/src/ui/components/__tests__/JobTable.test.tsx +83 -0
- package/src/ui/components/__tests__/StageTimeline.test.tsx +5 -6
- package/src/ui/components/__tests__/TaskAnalysisDisplay.test.tsx +64 -0
- package/src/ui/components/types.ts +33 -15
- package/src/ui/dist/assets/{index-L6cvsCAx.js → index-DN3-zvtP.js} +4299 -251
- package/src/ui/dist/assets/index-DN3-zvtP.js.map +1 -0
- package/src/ui/dist/assets/style-CtZBnjlR.css +2 -0
- package/src/ui/dist/index.html +2 -2
- package/src/ui/pages/PipelineDetail.tsx +60 -4
- package/src/ui/pages/PromptPipelineDashboard.tsx +59 -7
- package/src/ui/pages/__tests__/PromptPipelineDashboard.test.tsx +114 -32
- package/src/ui/pages/__tests__/pages.test.tsx +236 -42
- package/src/ui/server/__tests__/concurrency-endpoint.test.ts +54 -0
- package/src/ui/server/__tests__/config-bridge-node.test.ts +34 -1
- package/src/ui/server/__tests__/embedded-assets.test.ts +66 -0
- package/src/ui/server/__tests__/file-endpoints.test.ts +183 -5
- package/src/ui/server/__tests__/gate-endpoints.test.ts +55 -0
- package/src/ui/server/__tests__/index.test.ts +63 -0
- package/src/ui/server/__tests__/job-control-endpoints.test.ts +455 -1
- package/src/ui/server/__tests__/job-endpoints.test.ts +43 -1
- package/src/ui/server/__tests__/read-static-path-guard.test.ts +94 -0
- package/src/ui/server/__tests__/router-root-isolation.test.ts +204 -0
- package/src/ui/server/__tests__/router-threading.test.ts +148 -0
- package/src/ui/server/__tests__/router.test.ts +104 -0
- package/src/ui/server/__tests__/sse-broadcast.test.ts +44 -0
- package/src/ui/server/__tests__/sse-enhancer.test.ts +39 -0
- package/src/ui/server/config-bridge-node.ts +8 -26
- package/src/ui/server/embedded-assets-imports.d.ts +44 -0
- package/src/ui/server/embedded-assets.ts +13 -2
- package/src/ui/server/endpoints/__tests__/pipeline-analysis-endpoint.test.ts +167 -0
- package/src/ui/server/endpoints/__tests__/schema-file-endpoint.test.ts +104 -0
- package/src/ui/server/endpoints/__tests__/task-analysis-endpoint.test.ts +103 -0
- package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +162 -96
- package/src/ui/server/endpoints/concurrency-endpoint.ts +2 -1
- package/src/ui/server/endpoints/create-pipeline-endpoint.ts +14 -29
- package/src/ui/server/endpoints/file-endpoints.ts +15 -10
- package/src/ui/server/endpoints/job-control-endpoints.ts +122 -73
- package/src/ui/server/endpoints/job-endpoints.ts +1 -0
- package/src/ui/server/endpoints/pipeline-analysis-endpoint.ts +99 -11
- package/src/ui/server/endpoints/schema-file-endpoint.ts +11 -3
- package/src/ui/server/endpoints/task-analysis-endpoint.ts +21 -5
- package/src/ui/server/endpoints/task-save-endpoint.ts +1 -2
- package/src/ui/server/endpoints/upload-endpoints.ts +5 -40
- package/src/ui/server/index.ts +19 -14
- package/src/ui/server/job-reader.ts +14 -2
- package/src/ui/server/router.ts +33 -10
- package/src/ui/server/sse-broadcast.ts +14 -3
- package/src/ui/server/sse-enhancer.ts +12 -2
- package/src/ui/state/__tests__/schema-loader.test.ts +11 -1
- package/src/ui/state/__tests__/snapshot.test.ts +70 -15
- package/src/ui/state/__tests__/types.test.ts +6 -0
- package/src/ui/state/snapshot.ts +1 -1
- package/src/ui/state/transformers/__tests__/list-transformer.test.ts +42 -0
- package/src/ui/state/transformers/__tests__/status-transformer.test.ts +45 -3
- package/src/ui/state/transformers/list-transformer.ts +6 -0
- package/src/ui/state/types.ts +6 -1
- package/src/utils/__tests__/path-containment.test.ts +160 -0
- package/src/{ui/server/utils → utils}/path-containment.ts +21 -0
- package/src/task-analysis/__tests__/enrichers-analysis-writer.test.ts +0 -86
- package/src/task-analysis/__tests__/enrichers-artifact-resolver.test.ts +0 -124
- package/src/task-analysis/__tests__/extractors-artifacts.test.ts +0 -133
- package/src/task-analysis/__tests__/extractors-llm-calls.test.ts +0 -46
- package/src/task-analysis/__tests__/extractors-stages.test.ts +0 -52
- package/src/task-analysis/__tests__/index.test.ts +0 -143
- package/src/task-analysis/__tests__/parser.test.ts +0 -41
- package/src/task-analysis/__tests__/utils-ast.test.ts +0 -82
- package/src/task-analysis/enrichers/analysis-writer.ts +0 -75
- package/src/task-analysis/enrichers/artifact-resolver.ts +0 -89
- package/src/task-analysis/extractors/artifacts.ts +0 -143
- package/src/task-analysis/extractors/llm-calls.ts +0 -117
- package/src/task-analysis/extractors/stages.ts +0 -45
- package/src/task-analysis/parser.ts +0 -20
- package/src/task-analysis/utils/ast.ts +0 -45
- package/src/ui/dist/assets/index-L6cvsCAx.js.map +0 -1
- package/src/ui/dist/assets/style-CSSKuMOe.css +0 -2
- package/src/ui/embedded-assets.js +0 -12
- 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({
|
|
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
|
+
});
|