@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
|
@@ -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
|
-
}
|
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { readJob } from "../job-reader";
|
|
4
4
|
import { sendJson } from "../utils/http-utils";
|
|
5
5
|
import { getMimeType, isTextMime } from "../utils/mime-types";
|
|
6
|
+
import { resolveWithin } from "../../../utils/path-containment";
|
|
6
7
|
|
|
7
8
|
export function validateFileName(filename: string): boolean {
|
|
8
9
|
return Boolean(
|
|
@@ -20,8 +21,7 @@ type TaskFiles = {
|
|
|
20
21
|
tmp: string[];
|
|
21
22
|
};
|
|
22
23
|
|
|
23
|
-
function getJobFilesBase(location: string, jobId: string): string {
|
|
24
|
-
const root = process.env["PO_ROOT"] ?? process.cwd();
|
|
24
|
+
function getJobFilesBase(location: string, jobId: string, root: string): string {
|
|
25
25
|
return path.join(root, "pipeline-data", location, jobId, "files");
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -49,25 +49,27 @@ function getTaskFiles(data: Record<string, unknown>, taskId: string): TaskFiles
|
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
export async function handleTaskFileList(req: Request, jobId: string, taskId: string): Promise<Response> {
|
|
52
|
+
export async function handleTaskFileList(req: Request, jobId: string, taskId: string, root: string): Promise<Response> {
|
|
53
53
|
const type = new URL(req.url).searchParams.get("type") ?? "artifacts";
|
|
54
54
|
if (!validateFileName(type)) return sendJson(400, { ok: false, code: "BAD_REQUEST", message: "invalid file type" });
|
|
55
|
-
const job = await readJob(jobId);
|
|
55
|
+
const job = await readJob(jobId, root);
|
|
56
56
|
if (!job.ok) return sendJson(404, job);
|
|
57
57
|
|
|
58
58
|
const files = getTaskFiles(job.data, taskId);
|
|
59
59
|
return sendJson(200, { ok: true, data: files[type as keyof TaskFiles] ?? [] });
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
export async function handleTaskFile(req: Request, jobId: string, taskId: string): Promise<Response> {
|
|
62
|
+
export async function handleTaskFile(req: Request, jobId: string, taskId: string, root: string): Promise<Response> {
|
|
63
63
|
const url = new URL(req.url);
|
|
64
64
|
const type = url.searchParams.get("type") ?? "artifacts";
|
|
65
65
|
const filename = url.searchParams.get("filename") ?? "";
|
|
66
|
-
|
|
66
|
+
const job = await readJob(jobId, root);
|
|
67
|
+
if (!job.ok) return sendJson(404, job);
|
|
68
|
+
|
|
69
|
+
const filePath = resolveWithin(getJobFilesBase(job.location, jobId, root), path.join(type, filename));
|
|
70
|
+
if (filePath === null) {
|
|
67
71
|
return sendJson(400, { ok: false, code: "BAD_REQUEST", message: "invalid filename" });
|
|
68
72
|
}
|
|
69
|
-
const job = await readJob(jobId);
|
|
70
|
-
if (!job.ok) return sendJson(404, job);
|
|
71
73
|
|
|
72
74
|
const taskFiles = getTaskFiles(job.data, taskId);
|
|
73
75
|
const allowedFiles = taskFiles[type as keyof TaskFiles] ?? [];
|
|
@@ -75,9 +77,12 @@ export async function handleTaskFile(req: Request, jobId: string, taskId: string
|
|
|
75
77
|
return sendJson(404, { ok: false, code: "NOT_FOUND", message: `file not found for task: ${type}/${filename}` });
|
|
76
78
|
}
|
|
77
79
|
|
|
78
|
-
const
|
|
80
|
+
const file = Bun.file(filePath);
|
|
81
|
+
if (!(await file.exists())) {
|
|
82
|
+
return sendJson(404, { ok: false, code: "NOT_FOUND", message: `file not found for task: ${type}/${filename}` });
|
|
83
|
+
}
|
|
79
84
|
const mime = getMimeType(filename);
|
|
80
|
-
const buffer = new Uint8Array(await
|
|
85
|
+
const buffer = new Uint8Array(await file.arrayBuffer());
|
|
81
86
|
return sendJson(200, {
|
|
82
87
|
ok: true,
|
|
83
88
|
data: isTextMime(mime) ? new TextDecoder().decode(buffer) : Buffer.from(buffer).toString("base64"),
|