@ryanfw/prompt-orchestration-pipeline 1.3.0 → 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.
- package/README.md +1 -0
- package/docs/pop-task-guide.md +45 -0
- package/package.json +3 -3
- package/src/core/__tests__/agent-step.test.ts +402 -35
- package/src/core/__tests__/task-runner.test.ts +104 -0
- package/src/core/agent-step.ts +295 -41
- package/src/core/agent-types.ts +61 -0
- package/src/core/file-io.ts +8 -74
- package/src/core/orchestrator.ts +2 -1
- package/src/core/pipeline-definition.ts +1 -1
- package/src/core/pipeline-runner.ts +54 -3
- package/src/core/status-writer.ts +44 -26
- package/src/core/task-runner.ts +44 -1
- package/src/core/validation.ts +1 -1
- package/src/harness/__tests__/discovery.test.ts +235 -0
- package/src/harness/discovery.ts +109 -0
- package/src/harness/index.ts +22 -0
- package/src/harness/mcp-io-server.ts +1 -1
- package/src/ui/client/hooks/useJobListWithUpdates.ts +16 -1
- package/src/ui/dist/assets/{index-D7hzshSS.js → index--RH3sAt3.js} +129 -1
- package/src/ui/dist/assets/index--RH3sAt3.js.map +1 -0
- package/src/ui/dist/assets/style-CSSKuMOe.css +2 -0
- package/src/ui/dist/index.html +2 -2
- package/src/ui/embedded-assets.js +6 -6
- package/src/ui/pages/Code.tsx +135 -0
- package/src/ui/server/__tests__/gate-endpoints.test.ts +90 -0
- package/src/ui/server/__tests__/job-control-endpoints.test.ts +188 -0
- package/src/ui/server/__tests__/path-containment.test.ts +54 -0
- package/src/ui/server/__tests__/status-corruption.test.ts +55 -0
- package/src/ui/server/config-bridge.ts +1 -0
- package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +77 -0
- package/src/ui/server/endpoints/gate-endpoints.ts +17 -1
- package/src/ui/server/endpoints/job-control-endpoints.ts +36 -2
- package/src/ui/server/endpoints/upload-endpoints.ts +13 -3
- package/src/ui/server/utils/http-utils.ts +6 -1
- package/src/ui/server/utils/path-containment.ts +18 -0
- package/src/ui/server/utils/status-corruption.ts +27 -0
- package/src/harness/__tests__/descriptors.test.ts +0 -378
- package/src/harness/__tests__/executor.test.ts +0 -193
- package/src/harness/__tests__/resolve.test.ts +0 -200
- package/src/harness/__tests__/types.test.ts +0 -297
- package/src/harness/descriptors/claude.ts +0 -132
- package/src/harness/descriptors/codex.ts +0 -126
- package/src/harness/descriptors/index.ts +0 -10
- package/src/harness/descriptors/opencode.ts +0 -147
- package/src/harness/executor.ts +0 -128
- package/src/harness/resolve.ts +0 -176
- package/src/harness/types.ts +0 -100
- package/src/ui/dist/assets/index-D7hzshSS.js.map +0 -1
- package/src/ui/dist/assets/style-BUFg3Sth.css +0 -2
package/src/ui/dist/index.html
CHANGED
|
@@ -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
|
|
15
|
-
<link rel="stylesheet" crossorigin href="/assets/style-
|
|
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/
|
|
4
|
-
import _asset2 from "./dist/assets/index
|
|
5
|
-
import _asset3 from "./dist/assets/
|
|
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/
|
|
10
|
-
"/assets/index
|
|
11
|
-
"/assets/
|
|
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
|
};
|
package/src/ui/pages/Code.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { Fragment, useEffect, useRef, useState } from "react";
|
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
|
+
Bot,
|
|
5
6
|
ChevronDown,
|
|
6
7
|
ChevronRight,
|
|
7
8
|
Cpu,
|
|
@@ -66,6 +67,7 @@ const SECTIONS: readonly Section[] = [
|
|
|
66
67
|
{ id: "run-control", label: "Run Control", icon: GitBranch },
|
|
67
68
|
{ id: "io-api", label: "IO API", icon: Database },
|
|
68
69
|
{ id: "llm-api", label: "LLM API", icon: Cpu },
|
|
70
|
+
{ id: "agent-api", label: "Agent API", icon: Bot },
|
|
69
71
|
{ id: "validation", label: "Validation", icon: Shield },
|
|
70
72
|
];
|
|
71
73
|
|
|
@@ -210,6 +212,28 @@ const VALIDATION_EXAMPLE_CODE = `export const validateStructure = async ({
|
|
|
210
212
|
return { output: {}, flags };
|
|
211
213
|
};`;
|
|
212
214
|
|
|
215
|
+
const AGENT_OPTIONS: readonly PipelineField[] = [
|
|
216
|
+
{ name: "harness", required: true, type: '"claude" | "codex" | "opencode"', description: "Which CLI agent to run." },
|
|
217
|
+
{ name: "prompt", required: true, type: "string", description: "The instruction handed to the agent. Build it from seed or upstream stage data." },
|
|
218
|
+
{ name: "model", required: false, type: "string", description: "Model id passed through to the CLI verbatim." },
|
|
219
|
+
{ name: "io", required: false, type: "boolean", description: "Bridge POP file I/O into the agent (read_artifact / write_artifact tools). Default true." },
|
|
220
|
+
{ name: "timeoutMs", required: false, type: "number", description: "Overall wall-clock cap in milliseconds." },
|
|
221
|
+
{ name: "captureDiff", required: false, type: "boolean", description: "Capture a git diff of the working tree as an agent.patch artifact." },
|
|
222
|
+
];
|
|
223
|
+
|
|
224
|
+
const AGENT_EXAMPLE_CODE = `export const inference = async ({ runAgent, io, data, flags }) => {
|
|
225
|
+
// Build the prompt programmatically from upstream data
|
|
226
|
+
const prompt = \`Summarize: \${data.ingestion.topic}.\` +
|
|
227
|
+
" Save the summary as a POP artifact named summary.md via write_artifact.";
|
|
228
|
+
|
|
229
|
+
const result = await runAgent({ harness: "claude", prompt });
|
|
230
|
+
if (!result.ok) throw new Error(result.error);
|
|
231
|
+
|
|
232
|
+
// The agent wrote summary.md through POP's bridged file I/O
|
|
233
|
+
const summary = await io.readArtifact("summary.md");
|
|
234
|
+
return { output: { summary }, flags };
|
|
235
|
+
};`;
|
|
236
|
+
|
|
213
237
|
// ---------------------------------------------------------------------------
|
|
214
238
|
// Local components
|
|
215
239
|
// ---------------------------------------------------------------------------
|
|
@@ -787,6 +811,117 @@ export default function Code() {
|
|
|
787
811
|
</CollapsibleSection>
|
|
788
812
|
</div>
|
|
789
813
|
|
|
814
|
+
{/* ── Agent API ───────────────────────────────────────── */}
|
|
815
|
+
<div id="agent-api" ref={sectionRef("agent-api")} className="scroll-mt-24">
|
|
816
|
+
<CollapsibleSection
|
|
817
|
+
id="agent-api"
|
|
818
|
+
title="Agent API"
|
|
819
|
+
icon={Bot}
|
|
820
|
+
isOpen={openSections["agent-api"]}
|
|
821
|
+
onToggle={() => toggle("agent-api")}
|
|
822
|
+
>
|
|
823
|
+
<p className="text-base text-gray-600 mb-6">
|
|
824
|
+
Run a CLI coding agent (claude, codex, or opencode) from inside a task stage with{" "}
|
|
825
|
+
<code className="text-sm font-mono">runAgent</code>. Unlike a single{" "}
|
|
826
|
+
<code className="text-sm font-mono">llm</code> call, the agent uses tools over
|
|
827
|
+
multiple turns and can read and write the task's artifacts.
|
|
828
|
+
</p>
|
|
829
|
+
|
|
830
|
+
<div className="space-y-6">
|
|
831
|
+
{/* Options Table */}
|
|
832
|
+
<div>
|
|
833
|
+
<span className="text-sm font-medium mb-3 block">Options</span>
|
|
834
|
+
<div className="border border-gray-200 rounded-md overflow-hidden">
|
|
835
|
+
<table className="w-full text-sm">
|
|
836
|
+
<thead>
|
|
837
|
+
<tr>
|
|
838
|
+
<th className="bg-gray-50 px-4 py-3 text-left font-medium">Option</th>
|
|
839
|
+
<th className="bg-gray-50 px-4 py-3 text-left font-medium">Type</th>
|
|
840
|
+
<th className="bg-gray-50 px-4 py-3 text-left font-medium">Required</th>
|
|
841
|
+
<th className="bg-gray-50 px-4 py-3 text-left font-medium">Description</th>
|
|
842
|
+
</tr>
|
|
843
|
+
</thead>
|
|
844
|
+
<tbody>
|
|
845
|
+
{AGENT_OPTIONS.map((field) => (
|
|
846
|
+
<tr key={field.name} className="border-t border-gray-100">
|
|
847
|
+
<td className="px-4 py-3">
|
|
848
|
+
<code className="text-sm font-mono">{field.name}</code>
|
|
849
|
+
</td>
|
|
850
|
+
<td className="px-4 py-3">
|
|
851
|
+
<code className="text-xs font-mono text-gray-600">{field.type}</code>
|
|
852
|
+
</td>
|
|
853
|
+
<td className="px-4 py-3">
|
|
854
|
+
{field.required ? (
|
|
855
|
+
<span className="text-red-600 font-medium">Yes</span>
|
|
856
|
+
) : (
|
|
857
|
+
<span className="text-gray-400">No</span>
|
|
858
|
+
)}
|
|
859
|
+
</td>
|
|
860
|
+
<td className="px-4 py-3 text-gray-600">{field.description}</td>
|
|
861
|
+
</tr>
|
|
862
|
+
))}
|
|
863
|
+
</tbody>
|
|
864
|
+
</table>
|
|
865
|
+
</div>
|
|
866
|
+
</div>
|
|
867
|
+
|
|
868
|
+
{/* Returns */}
|
|
869
|
+
<div>
|
|
870
|
+
<span className="text-sm font-medium mb-2 block">Returns</span>
|
|
871
|
+
<div className="bg-gray-50 rounded-md p-3 border border-gray-200">
|
|
872
|
+
<code className="text-sm font-mono">{`Promise<{ ok, finalMessage, artifactsWritten, usage?, costUsd?, sessionId?, error? }>`}</code>
|
|
873
|
+
</div>
|
|
874
|
+
</div>
|
|
875
|
+
|
|
876
|
+
{/* When-to-use Callout */}
|
|
877
|
+
<div className="bg-[#f5f3ff] border border-[#ede9fe] rounded-md p-4">
|
|
878
|
+
<div className="flex items-center gap-2 mb-2">
|
|
879
|
+
<Bot className="h-4 w-4 text-[#6d28d9]" />
|
|
880
|
+
<span className="text-sm font-medium text-gray-900">
|
|
881
|
+
runAgent vs llm vs agent entries
|
|
882
|
+
</span>
|
|
883
|
+
<Badge intent="green">NEW</Badge>
|
|
884
|
+
</div>
|
|
885
|
+
<ul className="space-y-1 text-sm text-gray-700">
|
|
886
|
+
<li className="flex items-start gap-2">
|
|
887
|
+
<span className="text-[#6d28d9] mt-0.5">•</span>
|
|
888
|
+
<span>
|
|
889
|
+
Use <code className="font-mono text-xs">llm.<provider>.chat()</code> for
|
|
890
|
+
a single request/response model call.
|
|
891
|
+
</span>
|
|
892
|
+
</li>
|
|
893
|
+
<li className="flex items-start gap-2">
|
|
894
|
+
<span className="text-[#6d28d9] mt-0.5">•</span>
|
|
895
|
+
<span>
|
|
896
|
+
Use <code className="font-mono text-xs">runAgent()</code> for a tool-using
|
|
897
|
+
CLI agent that reads and writes files over multiple turns.
|
|
898
|
+
</span>
|
|
899
|
+
</li>
|
|
900
|
+
<li className="flex items-start gap-2">
|
|
901
|
+
<span className="text-[#6d28d9] mt-0.5">•</span>
|
|
902
|
+
<span>
|
|
903
|
+
Prefer <code className="font-mono text-xs">runAgent()</code> over a static{" "}
|
|
904
|
+
<code className="font-mono text-xs">agent:</code> pipeline entry when the
|
|
905
|
+
prompt is built from seed or stage data.
|
|
906
|
+
</span>
|
|
907
|
+
</li>
|
|
908
|
+
</ul>
|
|
909
|
+
</div>
|
|
910
|
+
|
|
911
|
+
{/* Example */}
|
|
912
|
+
<div>
|
|
913
|
+
<span className="text-sm font-medium mb-2 block">Usage Example</span>
|
|
914
|
+
<CopyableCodeBlock maxHeight="260px">{AGENT_EXAMPLE_CODE}</CopyableCodeBlock>
|
|
915
|
+
</div>
|
|
916
|
+
|
|
917
|
+
{/* Requirements */}
|
|
918
|
+
<div className="text-sm text-gray-500">
|
|
919
|
+
Requires the selected harness CLI to be installed and authenticated on the host.
|
|
920
|
+
</div>
|
|
921
|
+
</div>
|
|
922
|
+
</CollapsibleSection>
|
|
923
|
+
</div>
|
|
924
|
+
|
|
790
925
|
{/* ── Validation ──────────────────────────────────────── */}
|
|
791
926
|
<div id="validation" ref={sectionRef("validation")} className="scroll-mt-24">
|
|
792
927
|
<CollapsibleSection
|
|
@@ -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
|
+
});
|