@ryanfw/prompt-orchestration-pipeline 1.3.2 → 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 +80 -4
- package/package.json +1 -4
- package/src/api/__tests__/index.test.ts +443 -32
- package/src/api/index.ts +108 -127
- 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/__tests__/statuses.test.ts +41 -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/config/statuses.ts +20 -1
- package/src/core/__tests__/agent-step.test.ts +115 -0
- package/src/core/__tests__/config.test.ts +545 -105
- package/src/core/__tests__/core-boot-resolution.test.ts +181 -0
- package/src/core/__tests__/job-concurrency.test.ts +260 -0
- package/src/core/__tests__/job-submission.test.ts +396 -0
- package/src/core/__tests__/job-view.test.ts +1341 -0
- 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 +615 -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 +910 -0
- package/src/core/__tests__/seed-naming.test.ts +57 -0
- package/src/core/__tests__/single-derivation.test.ts +159 -0
- 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 +134 -222
- package/src/core/control.ts +3 -0
- package/src/core/job-concurrency.ts +56 -20
- package/src/core/job-submission.ts +133 -0
- package/src/core/job-view.ts +473 -0
- 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 +200 -74
- package/src/core/pipeline-runner.ts +85 -53
- package/src/core/redact.ts +40 -0
- package/src/core/runner-liveness.ts +280 -0
- package/src/core/seed-naming.ts +9 -0
- package/src/core/status-writer.ts +27 -33
- 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 +203 -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 +41 -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 +16 -20
- package/src/ui/components/DAGGrid.tsx +6 -1
- package/src/ui/components/JobDetail.tsx +12 -4
- package/src/ui/components/JobTable.tsx +41 -13
- 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 +137 -1
- 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 +35 -15
- package/src/ui/dist/assets/{index--RH3sAt3.js → index-DN3-zvtP.js} +4324 -263
- 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__/config-bridge.test.ts +9 -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 +512 -2
- package/src/ui/server/__tests__/job-endpoints.test.ts +158 -2
- 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 +70 -0
- package/src/ui/server/config-bridge-node.ts +8 -26
- package/src/ui/server/config-bridge.ts +3 -4
- 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__/meta-endpoint.test.ts +1 -0
- 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/gate-endpoints.ts +2 -6
- package/src/ui/server/endpoints/job-control-endpoints.ts +129 -141
- package/src/ui/server/endpoints/job-endpoints.ts +7 -0
- package/src/ui/server/endpoints/meta-endpoint.ts +1 -1
- 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 +120 -14
- package/src/ui/state/__tests__/types.test.ts +104 -5
- package/src/ui/state/snapshot.ts +2 -2
- package/src/ui/state/transformers/__tests__/list-transformer.test.ts +85 -2
- package/src/ui/state/transformers/__tests__/status-transformer.test.ts +250 -22
- package/src/ui/state/transformers/list-transformer.ts +13 -3
- package/src/ui/state/transformers/status-transformer.ts +36 -170
- package/src/ui/state/types.ts +15 -48
- 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--RH3sAt3.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
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
6
|
+
|
|
7
|
+
import { handleSchemaFile } from "../schema-file-endpoint";
|
|
8
|
+
|
|
9
|
+
const SLUG = "demo";
|
|
10
|
+
|
|
11
|
+
async function ensureDir(dir: string): Promise<void> {
|
|
12
|
+
await mkdir(dir, { recursive: true });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("handleSchemaFile", () => {
|
|
16
|
+
let root: string;
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
root = await mkdtemp(path.join(tmpdir(), "schema-file-endpoint-"));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
await rm(root, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("rejects a plain `..` slug with 400 and does not serve a file outside pipeline-config", async () => {
|
|
27
|
+
await ensureDir(path.join(root, "schemas"));
|
|
28
|
+
await writeFile(path.join(root, "schemas", "schema.json"), "LEAKED");
|
|
29
|
+
|
|
30
|
+
const res = await handleSchemaFile(new Request("http://x"), "..", "schema.json", root);
|
|
31
|
+
const text = await res.text();
|
|
32
|
+
|
|
33
|
+
expect(res.status).toBe(400);
|
|
34
|
+
expect(text).not.toContain("LEAKED");
|
|
35
|
+
const body = JSON.parse(text) as { ok: boolean; code: string };
|
|
36
|
+
expect(body.ok).toBe(false);
|
|
37
|
+
expect(body.code).toBe("BAD_REQUEST");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("rejects an encoded traversal slug with 400", async () => {
|
|
41
|
+
const res = await handleSchemaFile(new Request("http://x"), "..%2f..", "schema.json", root);
|
|
42
|
+
|
|
43
|
+
expect(res.status).toBe(400);
|
|
44
|
+
const body = (await res.json()) as { ok: boolean; code: string };
|
|
45
|
+
expect(body.ok).toBe(false);
|
|
46
|
+
expect(body.code).toBe("BAD_REQUEST");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("rejects a traversal filename with 400 and does not read outside pipeline-config", async () => {
|
|
50
|
+
await ensureDir(path.join(root, "pipeline-config", SLUG, "schemas"));
|
|
51
|
+
await writeFile(path.join(root, "secret.json"), "LEAKED");
|
|
52
|
+
|
|
53
|
+
const res = await handleSchemaFile(
|
|
54
|
+
new Request("http://x"),
|
|
55
|
+
SLUG,
|
|
56
|
+
"..%2f..%2f..%2fsecret.json",
|
|
57
|
+
root,
|
|
58
|
+
);
|
|
59
|
+
const text = await res.text();
|
|
60
|
+
|
|
61
|
+
expect(res.status).toBe(400);
|
|
62
|
+
expect(text).not.toContain("LEAKED");
|
|
63
|
+
const body = JSON.parse(text) as { ok: boolean; code: string };
|
|
64
|
+
expect(body.ok).toBe(false);
|
|
65
|
+
expect(body.code).toBe("BAD_REQUEST");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("returns 200 with file contents for a valid request", async () => {
|
|
69
|
+
await ensureDir(path.join(root, "pipeline-config", SLUG, "schemas"));
|
|
70
|
+
await writeFile(
|
|
71
|
+
path.join(root, "pipeline-config", SLUG, "schemas", "schema.json"),
|
|
72
|
+
'{"type":"object"}',
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const res = await handleSchemaFile(new Request("http://x"), SLUG, "schema.json", root);
|
|
76
|
+
|
|
77
|
+
expect(res.status).toBe(200);
|
|
78
|
+
const body = (await res.json()) as { ok: boolean; data: string };
|
|
79
|
+
expect(body.ok).toBe(true);
|
|
80
|
+
expect(body.data).toBe('{"type":"object"}');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("returns 404 for a contained-but-missing schema file", async () => {
|
|
84
|
+
await ensureDir(path.join(root, "pipeline-config", SLUG, "schemas"));
|
|
85
|
+
|
|
86
|
+
const res = await handleSchemaFile(new Request("http://x"), SLUG, "missing.json", root);
|
|
87
|
+
|
|
88
|
+
expect(res.status).toBe(404);
|
|
89
|
+
const body = (await res.json()) as { ok: boolean; code: string; message: string };
|
|
90
|
+
expect(body.ok).toBe(false);
|
|
91
|
+
expect(body.code).toBe("NOT_FOUND");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("serves a legitimately percent-encoded filename (decode-once correctness)", async () => {
|
|
95
|
+
await ensureDir(path.join(root, "pipeline-config", SLUG, "schemas"));
|
|
96
|
+
await writeFile(path.join(root, "pipeline-config", SLUG, "schemas", "my file.json"), "ok");
|
|
97
|
+
|
|
98
|
+
const res = await handleSchemaFile(new Request("http://x"), SLUG, "my%20file.json", root);
|
|
99
|
+
|
|
100
|
+
expect(res.status).toBe(200);
|
|
101
|
+
const body = (await res.json()) as { ok: boolean; data: string };
|
|
102
|
+
expect(body.data).toBe("ok");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
6
|
+
|
|
7
|
+
import { handleTaskAnalysis } from "../task-analysis-endpoint";
|
|
8
|
+
import { TaskAnalysisRepository } from "../../../../task-analysis/repository";
|
|
9
|
+
import type { PersistedTaskAnalysis } from "../../../../task-analysis/types";
|
|
10
|
+
|
|
11
|
+
const SLUG = "demo-pipeline";
|
|
12
|
+
const TASK = "extract";
|
|
13
|
+
|
|
14
|
+
function makeAnalysis(): PersistedTaskAnalysis {
|
|
15
|
+
return {
|
|
16
|
+
summary: "Extracts structured fields from raw input.",
|
|
17
|
+
stages: [{ name: "extract", purpose: "Pull fields out." }],
|
|
18
|
+
artifacts: {
|
|
19
|
+
reads: [{ fileName: "input.json", role: "source" }],
|
|
20
|
+
writes: [{ fileName: "output.json", role: "result" }],
|
|
21
|
+
},
|
|
22
|
+
models: [{ provider: "deepseek", method: "chat", stage: "extract" }],
|
|
23
|
+
analyzedAt: "2026-06-22T00:00:00.000Z",
|
|
24
|
+
analysisStatus: "ok",
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("handleTaskAnalysis", () => {
|
|
29
|
+
let root: string;
|
|
30
|
+
|
|
31
|
+
beforeEach(async () => {
|
|
32
|
+
root = await mkdtemp(path.join(tmpdir(), "task-analysis-endpoint-"));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(async () => {
|
|
36
|
+
await rm(root, { recursive: true, force: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("returns a generated analysis written through the repository", async () => {
|
|
40
|
+
const analysis = makeAnalysis();
|
|
41
|
+
await TaskAnalysisRepository.write(root, SLUG, TASK, analysis);
|
|
42
|
+
|
|
43
|
+
const res = await handleTaskAnalysis(new Request("http://x"), SLUG, TASK, root);
|
|
44
|
+
|
|
45
|
+
expect(res.status).toBe(200);
|
|
46
|
+
const body = (await res.json()) as { ok: boolean; data: PersistedTaskAnalysis };
|
|
47
|
+
expect(body.ok).toBe(true);
|
|
48
|
+
expect(body.data).toEqual(analysis);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("returns 404 only when the analysis is truly absent", async () => {
|
|
52
|
+
const res = await handleTaskAnalysis(new Request("http://x"), SLUG, "missing-task", root);
|
|
53
|
+
|
|
54
|
+
expect(res.status).toBe(404);
|
|
55
|
+
const body = (await res.json()) as { ok: boolean; code: string; message: string };
|
|
56
|
+
expect(body.ok).toBe(false);
|
|
57
|
+
expect(body.code).toBe("NOT_FOUND");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("returns 400 and does not read outside pipeline-config for a traversal slug", async () => {
|
|
61
|
+
const sentinelDir = path.join(root, "tasks");
|
|
62
|
+
await mkdir(sentinelDir, { recursive: true });
|
|
63
|
+
const sentinelPayload = "SENTINEL_OUTSIDE_PIPELINE_CONFIG";
|
|
64
|
+
await writeFile(path.join(sentinelDir, `${TASK}.analysis.json`), sentinelPayload);
|
|
65
|
+
|
|
66
|
+
const res = await handleTaskAnalysis(new Request("http://x"), "..", TASK, root);
|
|
67
|
+
|
|
68
|
+
expect(res.status).toBe(400);
|
|
69
|
+
const text = await res.text();
|
|
70
|
+
expect(text).not.toContain(sentinelPayload);
|
|
71
|
+
const body = JSON.parse(text) as { ok: boolean; code: string };
|
|
72
|
+
expect(body.ok).toBe(false);
|
|
73
|
+
expect(body.code).toBe("BAD_PATH");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("returns 400 and does not read outside pipeline-config for a traversal taskId", async () => {
|
|
77
|
+
const sentinelPayload = "SENTINEL_OUTSIDE_ROOT_VIA_TASKID";
|
|
78
|
+
await writeFile(path.join(root, "secret.analysis.json"), sentinelPayload);
|
|
79
|
+
|
|
80
|
+
const res = await handleTaskAnalysis(
|
|
81
|
+
new Request("http://x"),
|
|
82
|
+
SLUG,
|
|
83
|
+
"..%2f..%2f..%2fsecret",
|
|
84
|
+
root,
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
expect(res.status).toBe(400);
|
|
88
|
+
const text = await res.text();
|
|
89
|
+
expect(text).not.toContain(sentinelPayload);
|
|
90
|
+
const body = JSON.parse(text) as { ok: boolean; code: string };
|
|
91
|
+
expect(body.ok).toBe(false);
|
|
92
|
+
expect(body.code).toBe("BAD_PATH");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("returns 400 for a malformed percent-encoded slug", async () => {
|
|
96
|
+
const res = await handleTaskAnalysis(new Request("http://x"), "%zz", TASK, root);
|
|
97
|
+
|
|
98
|
+
expect(res.status).toBe(400);
|
|
99
|
+
const body = (await res.json()) as { ok: boolean; code: string };
|
|
100
|
+
expect(body.ok).toBe(false);
|
|
101
|
+
expect(body.code).toBe("BAD_PATH");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
|
|
1
|
+
import { mkdtemp, mkdir, readFile, rm, stat } from "node:fs/promises";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
5
|
import { zipSync } from "fflate";
|
|
6
|
-
import { describe, expect, it } from "vitest";
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
7
7
|
|
|
8
8
|
import { handleSeedUploadDirect, normalizeSeedUpload } from "../upload-endpoints";
|
|
9
9
|
|
|
10
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
11
|
+
|
|
10
12
|
async function pathExists(target: string): Promise<boolean> {
|
|
11
13
|
try {
|
|
12
14
|
await stat(target);
|
|
@@ -27,6 +29,32 @@ function concatBytes(parts: Uint8Array[]): Uint8Array<ArrayBuffer> {
|
|
|
27
29
|
return out;
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
async function scaffoldWorkspace(tmpDir: string): Promise<void> {
|
|
33
|
+
const pipelineConfigDir = path.join(tmpDir, "pipeline-config", "test-pipeline");
|
|
34
|
+
await mkdir(pipelineConfigDir, { recursive: true });
|
|
35
|
+
await mkdir(path.join(pipelineConfigDir, "tasks"), { recursive: true });
|
|
36
|
+
|
|
37
|
+
await Bun.write(
|
|
38
|
+
path.join(tmpDir, "pipeline-config", "registry.json"),
|
|
39
|
+
JSON.stringify({
|
|
40
|
+
version: 1,
|
|
41
|
+
pipelines: {
|
|
42
|
+
"test-pipeline": {
|
|
43
|
+
name: "Test Pipeline",
|
|
44
|
+
description: "test pipeline",
|
|
45
|
+
configDir: pipelineConfigDir,
|
|
46
|
+
tasksDir: path.join(pipelineConfigDir, "tasks"),
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
await Bun.write(
|
|
53
|
+
path.join(pipelineConfigDir, "pipeline.json"),
|
|
54
|
+
JSON.stringify({ name: "test-pipeline", tasks: [] }),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
30
58
|
describe("normalizeSeedUpload", () => {
|
|
31
59
|
it("extracts seed and binary artifact from a multipart zip upload", async () => {
|
|
32
60
|
const seed = { name: "demo", pipeline: "x" };
|
|
@@ -57,10 +85,8 @@ describe("normalizeSeedUpload", () => {
|
|
|
57
85
|
|
|
58
86
|
const result = await normalizeSeedUpload(request);
|
|
59
87
|
|
|
60
|
-
// AC3: seedObject deep-equals the original seed.
|
|
61
88
|
expect(result.seedObject).toEqual(seed);
|
|
62
89
|
|
|
63
|
-
// AC3: exactly one artifact, byte-identical to the source blob.
|
|
64
90
|
expect(result.artifacts).toHaveLength(1);
|
|
65
91
|
const blob = result.artifacts![0]!;
|
|
66
92
|
expect(blob.filename).toBe("artifacts/blob.bin");
|
|
@@ -70,110 +96,150 @@ describe("normalizeSeedUpload", () => {
|
|
|
70
96
|
});
|
|
71
97
|
|
|
72
98
|
describe("handleSeedUploadDirect", () => {
|
|
99
|
+
let dataDir: string;
|
|
100
|
+
|
|
101
|
+
beforeEach(async () => {
|
|
102
|
+
dataDir = await mkdtemp(path.join(tmpdir(), "upload-endpoint-"));
|
|
103
|
+
await scaffoldWorkspace(dataDir);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
afterEach(async () => {
|
|
107
|
+
await rm(dataDir, { recursive: true, force: true });
|
|
108
|
+
});
|
|
109
|
+
|
|
73
110
|
it("stages artifacts under staging/{jobId}/ without creating current/{jobId}/", async () => {
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
111
|
+
const content = new TextEncoder().encode("# notes\nhello\n");
|
|
112
|
+
const seedObject = { name: "demo", pipeline: "test-pipeline" };
|
|
113
|
+
|
|
114
|
+
const response = await handleSeedUploadDirect(seedObject, dataDir, [
|
|
115
|
+
{ filename: "notes.md", content },
|
|
116
|
+
]);
|
|
117
|
+
expect(response.status).toBe(201);
|
|
118
|
+
const body = (await response.json()) as { data: { jobId: string } };
|
|
119
|
+
const jobId = body.data.jobId;
|
|
120
|
+
expect(jobId).toMatch(UUID_PATTERN);
|
|
121
|
+
|
|
122
|
+
const pipelineData = path.join(dataDir, "pipeline-data");
|
|
123
|
+
|
|
124
|
+
const staged = path.join(pipelineData, "staging", jobId, "notes.md");
|
|
125
|
+
const stagedBytes = await readFile(staged);
|
|
126
|
+
expect(Array.from(stagedBytes)).toEqual(Array.from(content));
|
|
127
|
+
|
|
128
|
+
expect(await pathExists(path.join(pipelineData, "pending", `${jobId}-seed.json`))).toBe(true);
|
|
129
|
+
|
|
130
|
+
expect(await pathExists(path.join(pipelineData, "current", jobId))).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("rejects seed with unknown pipeline as 400 with error envelope and writes no files", async () => {
|
|
134
|
+
const response = await handleSeedUploadDirect(
|
|
135
|
+
{ name: "demo", pipeline: "missing-pipeline" },
|
|
136
|
+
dataDir,
|
|
137
|
+
[{ filename: "notes.md", content: new TextEncoder().encode("hi") }],
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
expect(response.status).toBe(400);
|
|
141
|
+
const body = (await response.json()) as { ok: boolean; code: string; message: string };
|
|
142
|
+
expect(body.ok).toBe(false);
|
|
143
|
+
expect(body.code).toBe("BAD_REQUEST");
|
|
144
|
+
expect(body.message).toContain("not found");
|
|
145
|
+
|
|
146
|
+
expect(await pathExists(path.join(dataDir, "pipeline-data", "staging"))).toBe(false);
|
|
147
|
+
expect(await pathExists(path.join(dataDir, "pipeline-data", "pending"))).toBe(false);
|
|
101
148
|
});
|
|
102
149
|
|
|
103
150
|
it("AC-12: unsafe artifact path returns 400 and does not create the escape target", async () => {
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
151
|
+
const seedObject = { name: "demo", pipeline: "test-pipeline" };
|
|
152
|
+
const filename = "../../../../tmp/PWNED.txt";
|
|
153
|
+
const response = await handleSeedUploadDirect(seedObject, dataDir, [
|
|
154
|
+
{ filename, content: new TextEncoder().encode("pwned") },
|
|
155
|
+
]);
|
|
156
|
+
expect(response.status).toBe(400);
|
|
157
|
+
const body = (await response.json()) as { ok: boolean; code: string; message: string };
|
|
158
|
+
expect(body.ok).toBe(false);
|
|
159
|
+
expect(body.code).toBe("BAD_REQUEST");
|
|
160
|
+
expect(body.message.startsWith("unsafe artifact path: ")).toBe(true);
|
|
161
|
+
expect(body.message).toContain(filename);
|
|
162
|
+
|
|
163
|
+
const escapeTarget = "/tmp/PWNED.txt";
|
|
164
|
+
expect(await pathExists(escapeTarget)).toBe(false);
|
|
165
|
+
expect(await pathExists(path.join(dataDir, "pipeline-data", "staging"))).toBe(false);
|
|
166
|
+
expect(await pathExists(path.join(dataDir, "pipeline-data", "pending"))).toBe(false);
|
|
117
167
|
});
|
|
118
168
|
|
|
119
169
|
it("AC-13: mixed safe and unsafe paths return 400 with no partial writes", async () => {
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
170
|
+
const seedObject = { name: "demo", pipeline: "test-pipeline" };
|
|
171
|
+
const unsafeFilename = "../../tmp/escape.txt";
|
|
172
|
+
const artifacts = [
|
|
173
|
+
{ filename: "safe.md", content: new TextEncoder().encode("safe") },
|
|
174
|
+
{ filename: unsafeFilename, content: new TextEncoder().encode("escape") },
|
|
175
|
+
];
|
|
176
|
+
|
|
177
|
+
const response = await handleSeedUploadDirect(seedObject, dataDir, artifacts);
|
|
178
|
+
expect(response.status).toBe(400);
|
|
179
|
+
const body = (await response.json()) as { ok: boolean; code: string; message: string };
|
|
180
|
+
expect(body.ok).toBe(false);
|
|
181
|
+
expect(body.code).toBe("BAD_REQUEST");
|
|
182
|
+
expect(body.message.startsWith("unsafe artifact path: ")).toBe(true);
|
|
183
|
+
expect(body.message).toContain(unsafeFilename);
|
|
184
|
+
|
|
185
|
+
const stagingRoot = path.join(dataDir, "pipeline-data", "staging");
|
|
186
|
+
expect(await pathExists(stagingRoot)).toBe(false);
|
|
187
|
+
expect(await pathExists(path.join(dataDir, "pipeline-data", "pending"))).toBe(false);
|
|
136
188
|
});
|
|
137
189
|
|
|
138
190
|
it("rejects root-normalizing artifact paths without partial writes", async () => {
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
191
|
+
const seedObject = { name: "demo", pipeline: "test-pipeline" };
|
|
192
|
+
const unsafeFilename = "dir/..";
|
|
193
|
+
const artifacts = [
|
|
194
|
+
{ filename: "safe.md", content: new TextEncoder().encode("safe") },
|
|
195
|
+
{ filename: unsafeFilename, content: new TextEncoder().encode("root") },
|
|
196
|
+
];
|
|
197
|
+
|
|
198
|
+
const response = await handleSeedUploadDirect(seedObject, dataDir, artifacts);
|
|
199
|
+
expect(response.status).toBe(400);
|
|
200
|
+
const body = (await response.json()) as { ok: boolean; code: string; message: string };
|
|
201
|
+
expect(body.ok).toBe(false);
|
|
202
|
+
expect(body.code).toBe("BAD_REQUEST");
|
|
203
|
+
expect(body.message.startsWith("unsafe artifact path: ")).toBe(true);
|
|
204
|
+
expect(body.message).toContain(unsafeFilename);
|
|
205
|
+
|
|
206
|
+
const stagingRoot = path.join(dataDir, "pipeline-data", "staging");
|
|
207
|
+
expect(await pathExists(stagingRoot)).toBe(false);
|
|
208
|
+
expect(await pathExists(path.join(dataDir, "pipeline-data", "pending"))).toBe(false);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("rejects absolute artifact paths without partial writes", async () => {
|
|
212
|
+
const seedObject = { name: "demo", pipeline: "test-pipeline" };
|
|
213
|
+
const filename = "/etc/passwd";
|
|
214
|
+
const response = await handleSeedUploadDirect(seedObject, dataDir, [
|
|
215
|
+
{ filename, content: new TextEncoder().encode("data") },
|
|
216
|
+
]);
|
|
217
|
+
expect(response.status).toBe(400);
|
|
218
|
+
const body = (await response.json()) as { ok: boolean; code: string; message: string };
|
|
219
|
+
expect(body.ok).toBe(false);
|
|
220
|
+
expect(body.code).toBe("BAD_REQUEST");
|
|
221
|
+
expect(body.message.startsWith("unsafe artifact path: ")).toBe(true);
|
|
222
|
+
expect(body.message).toContain(filename);
|
|
223
|
+
|
|
224
|
+
expect(await pathExists(path.join(dataDir, "pipeline-data", "staging"))).toBe(false);
|
|
225
|
+
expect(await pathExists(path.join(dataDir, "pipeline-data", "pending"))).toBe(false);
|
|
155
226
|
});
|
|
156
227
|
|
|
157
228
|
it("AC-14: all-safe artifact paths with nesting return 201 and files present", async () => {
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
expect(await pathExists(path.join(stagingJobDir, "out.json"))).toBe(true);
|
|
174
|
-
expect(await pathExists(path.join(stagingJobDir, "data/foo.json"))).toBe(true);
|
|
175
|
-
} finally {
|
|
176
|
-
await rm(dataDir, { recursive: true, force: true });
|
|
177
|
-
}
|
|
229
|
+
const seedObject = { name: "demo", pipeline: "test-pipeline" };
|
|
230
|
+
const artifacts = [
|
|
231
|
+
{ filename: "out.json", content: new TextEncoder().encode("{}") },
|
|
232
|
+
{ filename: "data/foo.json", content: new TextEncoder().encode('{"a":1}') },
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
const response = await handleSeedUploadDirect(seedObject, dataDir, artifacts);
|
|
236
|
+
expect(response.status).toBe(201);
|
|
237
|
+
|
|
238
|
+
const body = (await response.json()) as { data: { jobId: string } };
|
|
239
|
+
const jobId = body.data.jobId;
|
|
240
|
+
const stagingJobDir = path.join(dataDir, "pipeline-data", "staging", jobId);
|
|
241
|
+
|
|
242
|
+
expect(await pathExists(path.join(stagingJobDir, "out.json"))).toBe(true);
|
|
243
|
+
expect(await pathExists(path.join(stagingJobDir, "data/foo.json"))).toBe(true);
|
|
178
244
|
});
|
|
179
245
|
});
|
|
@@ -26,7 +26,7 @@ interface PublicConcurrencyStatus {
|
|
|
26
26
|
}>;
|
|
27
27
|
staleSlots: Array<{
|
|
28
28
|
jobId: string;
|
|
29
|
-
reason: "missing_current_job" | "missing_pid" | "dead_pid" | "invalid_json";
|
|
29
|
+
reason: "missing_current_job" | "missing_pid" | "dead_pid" | "invalid_json" | "stale_runner";
|
|
30
30
|
}>;
|
|
31
31
|
}
|
|
32
32
|
|
|
@@ -62,6 +62,7 @@ export async function handleConcurrencyStatus(dataDir: string): Promise<Response
|
|
|
62
62
|
getPipelineDataDir(dataDir),
|
|
63
63
|
orchestrator.maxConcurrentJobs,
|
|
64
64
|
orchestrator.lockFileTimeout,
|
|
65
|
+
orchestrator.staleLeaseTimeoutMs,
|
|
65
66
|
);
|
|
66
67
|
const response = sendJson(200, { ok: true, data: toPublicStatus(status) });
|
|
67
68
|
response.headers.set("Cache-Control", "no-store");
|
|
@@ -1,18 +1,16 @@
|
|
|
1
|
-
import { mkdir
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { resetConfig } from "../../../core/config";
|
|
5
|
+
import {
|
|
6
|
+
readRegistrySync,
|
|
7
|
+
registerPipeline,
|
|
8
|
+
} from "../../../config/pipeline-registry";
|
|
5
9
|
import { createErrorResponse } from "../config-bridge";
|
|
6
10
|
import { sendJson } from "../utils/http-utils";
|
|
7
11
|
import { ensureUniqueSlug, generateSlug } from "../utils/slug";
|
|
8
12
|
|
|
9
|
-
async function
|
|
10
|
-
const tmpPath = `${filePath}.${process.pid}.tmp`;
|
|
11
|
-
await Bun.write(tmpPath, JSON.stringify(value, null, 2));
|
|
12
|
-
await rename(tmpPath, filePath);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export async function handleCreatePipeline(req: Request): Promise<Response> {
|
|
13
|
+
export async function handleCreatePipeline(req: Request, root: string): Promise<Response> {
|
|
16
14
|
const body = (await req.json()) as Record<string, unknown>;
|
|
17
15
|
const name = typeof body["name"] === "string" ? body["name"].trim() : "";
|
|
18
16
|
const description = typeof body["description"] === "string" ? body["description"].trim() : "";
|
|
@@ -20,33 +18,20 @@ export async function handleCreatePipeline(req: Request): Promise<Response> {
|
|
|
20
18
|
return sendJson(400, createErrorResponse("BAD_REQUEST", "pipeline name is required"));
|
|
21
19
|
}
|
|
22
20
|
|
|
23
|
-
const
|
|
24
|
-
const registryPath = path.join(root, "pipeline-config", "registry.json");
|
|
25
|
-
const registry = (await Bun.file(registryPath).exists())
|
|
26
|
-
? (JSON.parse(await Bun.file(registryPath).text()) as { pipelines: Record<string, { configDir: string; tasksDir: string }> })
|
|
27
|
-
: { pipelines: {} };
|
|
21
|
+
const registry = readRegistrySync(root);
|
|
28
22
|
const slug = ensureUniqueSlug(generateSlug(name), new Set(Object.keys(registry.pipelines)));
|
|
29
23
|
|
|
30
24
|
const configDir = path.join(root, "pipeline-config", slug);
|
|
31
25
|
const tasksDir = path.join(configDir, "tasks");
|
|
32
26
|
await mkdir(tasksDir, { recursive: true });
|
|
33
27
|
await Bun.write(path.join(configDir, "pipeline.json"), JSON.stringify({ name, description, slug }, null, 2));
|
|
34
|
-
await
|
|
28
|
+
await registerPipeline(root, slug, {
|
|
29
|
+
name,
|
|
30
|
+
description,
|
|
31
|
+
configDir: path.relative(root, configDir),
|
|
32
|
+
tasksDir: path.relative(root, tasksDir),
|
|
33
|
+
});
|
|
35
34
|
resetConfig();
|
|
36
35
|
|
|
37
36
|
return sendJson(201, { ok: true, data: { slug, name, description } });
|
|
38
37
|
}
|
|
39
|
-
|
|
40
|
-
async function registryFileUpdate(
|
|
41
|
-
registryPath: string,
|
|
42
|
-
registry: { pipelines: Record<string, { configDir: string; tasksDir: string }> },
|
|
43
|
-
slug: string,
|
|
44
|
-
configDir: string,
|
|
45
|
-
tasksDir: string,
|
|
46
|
-
): Promise<void> {
|
|
47
|
-
registry.pipelines[slug] = {
|
|
48
|
-
configDir: path.relative(process.env["PO_ROOT"] ?? process.cwd(), configDir),
|
|
49
|
-
tasksDir: path.relative(process.env["PO_ROOT"] ?? process.cwd(), tasksDir),
|
|
50
|
-
};
|
|
51
|
-
await writeAtomicJson(registryPath, registry);
|
|
52
|
-
}
|