@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
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { mkdtemp, mkdir, readdir, readFile, rm, stat } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
submitSeed,
|
|
8
|
+
submitUploadedSeed,
|
|
9
|
+
type SubmitSuccessResult,
|
|
10
|
+
type SubmitFailureResult,
|
|
11
|
+
} from "../job-submission.ts";
|
|
12
|
+
import { SEED_FILENAME_PATTERN, parseSeedFilename } from "../seed-naming.ts";
|
|
13
|
+
|
|
14
|
+
async function readJson(filePath: string): Promise<unknown> {
|
|
15
|
+
const text = await Bun.file(filePath).text();
|
|
16
|
+
return JSON.parse(text) as unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function scaffoldWorkspace(tmpDir: string): Promise<void> {
|
|
20
|
+
const pipelineConfigDir = join(tmpDir, "pipeline-config", "test-pipeline");
|
|
21
|
+
|
|
22
|
+
await mkdir(pipelineConfigDir, { recursive: true });
|
|
23
|
+
await mkdir(join(pipelineConfigDir, "tasks"), { recursive: true });
|
|
24
|
+
await mkdir(join(tmpDir, "pipeline-data", "pending"), { recursive: true });
|
|
25
|
+
await mkdir(join(tmpDir, "pipeline-data", "current"), { recursive: true });
|
|
26
|
+
await mkdir(join(tmpDir, "pipeline-data", "complete"), { recursive: true });
|
|
27
|
+
|
|
28
|
+
await Bun.write(
|
|
29
|
+
join(tmpDir, "pipeline-config", "registry.json"),
|
|
30
|
+
JSON.stringify({
|
|
31
|
+
version: 1,
|
|
32
|
+
pipelines: {
|
|
33
|
+
"test-pipeline": {
|
|
34
|
+
name: "Test Pipeline",
|
|
35
|
+
description: "test pipeline",
|
|
36
|
+
configDir: join(tmpDir, "pipeline-config", "test-pipeline"),
|
|
37
|
+
tasksDir: join(tmpDir, "pipeline-config", "test-pipeline", "tasks"),
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
await Bun.write(
|
|
44
|
+
join(pipelineConfigDir, "pipeline.json"),
|
|
45
|
+
JSON.stringify({ name: "test-pipeline", tasks: [] }),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const UUID_PATTERN =
|
|
50
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
51
|
+
|
|
52
|
+
describe("submitSeed", () => {
|
|
53
|
+
let tmpDir: string;
|
|
54
|
+
|
|
55
|
+
beforeEach(async () => {
|
|
56
|
+
tmpDir = await mkdtemp(join(tmpdir(), "pop-job-submission-test-"));
|
|
57
|
+
await scaffoldWorkspace(tmpDir);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterEach(async () => {
|
|
61
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("writes pending seed file with UUID jobId and returns success", async () => {
|
|
65
|
+
const result = await submitSeed({
|
|
66
|
+
root: tmpDir,
|
|
67
|
+
seedObject: { pipeline: "test-pipeline" },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(result.success).toBe(true);
|
|
71
|
+
const success = result as SubmitSuccessResult;
|
|
72
|
+
expect(success.jobId).toMatch(UUID_PATTERN);
|
|
73
|
+
expect(success.jobName).toBe(success.jobId);
|
|
74
|
+
|
|
75
|
+
const pendingFiles = await readdir(join(tmpDir, "pipeline-data", "pending"));
|
|
76
|
+
const seedFile = pendingFiles.find((f) => f.endsWith("-seed.json"));
|
|
77
|
+
expect(seedFile).toBeDefined();
|
|
78
|
+
expect(seedFile).toBe(`${success.jobId}-seed.json`);
|
|
79
|
+
|
|
80
|
+
const written = await readJson(join(tmpDir, "pipeline-data", "pending", seedFile!));
|
|
81
|
+
expect(written).toEqual({ pipeline: "test-pipeline" });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("uses the seed's name as jobName when provided", async () => {
|
|
85
|
+
const result = await submitSeed({
|
|
86
|
+
root: tmpDir,
|
|
87
|
+
seedObject: { pipeline: "test-pipeline", name: "my-job" },
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(result.success).toBe(true);
|
|
91
|
+
expect((result as SubmitSuccessResult).jobName).toBe("my-job");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("writes the seed file with a name matching SEED_FILENAME_PATTERN", async () => {
|
|
95
|
+
const result = await submitSeed({
|
|
96
|
+
root: tmpDir,
|
|
97
|
+
seedObject: { pipeline: "test-pipeline" },
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const success = result as SubmitSuccessResult;
|
|
101
|
+
const pendingFiles = await readdir(join(tmpDir, "pipeline-data", "pending"));
|
|
102
|
+
const seedFile = pendingFiles.find((f) => f.endsWith("-seed.json"))!;
|
|
103
|
+
expect(SEED_FILENAME_PATTERN.exec(seedFile)).not.toBeNull();
|
|
104
|
+
expect(parseSeedFilename(seedFile)).toBe(success.jobId);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("does not leave a .tmp file in pending after a successful write", async () => {
|
|
108
|
+
await submitSeed({
|
|
109
|
+
root: tmpDir,
|
|
110
|
+
seedObject: { pipeline: "test-pipeline" },
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const pendingFiles = await readdir(join(tmpDir, "pipeline-data", "pending"));
|
|
114
|
+
const tmpFiles = pendingFiles.filter((f) => f.endsWith(".tmp"));
|
|
115
|
+
expect(tmpFiles).toEqual([]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("produces distinct jobIds and files for two submits with identical name", async () => {
|
|
119
|
+
const first = await submitSeed({
|
|
120
|
+
root: tmpDir,
|
|
121
|
+
seedObject: { pipeline: "test-pipeline", name: "same-name" },
|
|
122
|
+
});
|
|
123
|
+
const second = await submitSeed({
|
|
124
|
+
root: tmpDir,
|
|
125
|
+
seedObject: { pipeline: "test-pipeline", name: "same-name" },
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(first.success).toBe(true);
|
|
129
|
+
expect(second.success).toBe(true);
|
|
130
|
+
|
|
131
|
+
const firstSuccess = first as SubmitSuccessResult;
|
|
132
|
+
const secondSuccess = second as SubmitSuccessResult;
|
|
133
|
+
expect(firstSuccess.jobId).not.toBe(secondSuccess.jobId);
|
|
134
|
+
expect(firstSuccess.jobName).toBe("same-name");
|
|
135
|
+
expect(secondSuccess.jobName).toBe("same-name");
|
|
136
|
+
|
|
137
|
+
const pendingFiles = await readdir(join(tmpDir, "pipeline-data", "pending"));
|
|
138
|
+
const seedFiles = pendingFiles.filter((f) => f.endsWith("-seed.json"));
|
|
139
|
+
expect(seedFiles).toContain(`${firstSuccess.jobId}-seed.json`);
|
|
140
|
+
expect(seedFiles).toContain(`${secondSuccess.jobId}-seed.json`);
|
|
141
|
+
expect(seedFiles.length).toBe(2);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("rejects a non-object seed with the exact pinned message", async () => {
|
|
145
|
+
const result = await submitSeed({
|
|
146
|
+
root: tmpDir,
|
|
147
|
+
seedObject: "not an object",
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
expect(result.success).toBe(false);
|
|
151
|
+
expect((result as SubmitFailureResult).message).toBe("seed must be a JSON object");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("rejects an array seed with the exact pinned message", async () => {
|
|
155
|
+
const result = await submitSeed({
|
|
156
|
+
root: tmpDir,
|
|
157
|
+
seedObject: [1, 2, 3],
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(result.success).toBe(false);
|
|
161
|
+
expect((result as SubmitFailureResult).message).toBe("seed must be a JSON object");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("rejects a null seed with the exact pinned message", async () => {
|
|
165
|
+
const result = await submitSeed({
|
|
166
|
+
root: tmpDir,
|
|
167
|
+
seedObject: null,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
expect(result.success).toBe(false);
|
|
171
|
+
expect((result as SubmitFailureResult).message).toBe("seed must be a JSON object");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("rejects an empty pipeline string with the exact pinned message", async () => {
|
|
175
|
+
const result = await submitSeed({
|
|
176
|
+
root: tmpDir,
|
|
177
|
+
seedObject: { pipeline: "" },
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
expect(result.success).toBe(false);
|
|
181
|
+
expect((result as SubmitFailureResult).message).toBe(
|
|
182
|
+
"seed.pipeline must be a non-empty string",
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("passes through the underlying error for an unknown pipeline slug", async () => {
|
|
187
|
+
const result = await submitSeed({
|
|
188
|
+
root: tmpDir,
|
|
189
|
+
seedObject: { pipeline: "does-not-exist" },
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
expect(result.success).toBe(false);
|
|
193
|
+
expect((result as SubmitFailureResult).message).toBe(
|
|
194
|
+
"Pipeline 'does-not-exist' not found in registry",
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("rejects an empty name string with the exact pinned message", async () => {
|
|
199
|
+
const result = await submitSeed({
|
|
200
|
+
root: tmpDir,
|
|
201
|
+
seedObject: { pipeline: "test-pipeline", name: "" },
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
expect(result.success).toBe(false);
|
|
205
|
+
expect((result as SubmitFailureResult).message).toBe(
|
|
206
|
+
"seed.name must be a non-empty string if provided",
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
async function pathExists(target: string): Promise<boolean> {
|
|
212
|
+
try {
|
|
213
|
+
await stat(target);
|
|
214
|
+
return true;
|
|
215
|
+
} catch {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
describe("submitUploadedSeed", () => {
|
|
221
|
+
let tmpDir: string;
|
|
222
|
+
|
|
223
|
+
beforeEach(async () => {
|
|
224
|
+
tmpDir = await mkdtemp(join(tmpdir(), "pop-job-submission-upload-test-"));
|
|
225
|
+
await scaffoldWorkspace(tmpDir);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
afterEach(async () => {
|
|
229
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("stages artifacts under pipeline-data/staging/<jobId>/ before writing the pending seed", async () => {
|
|
233
|
+
const content = new TextEncoder().encode("artifact bytes");
|
|
234
|
+
const result = await submitUploadedSeed({
|
|
235
|
+
root: tmpDir,
|
|
236
|
+
seedObject: { pipeline: "test-pipeline", name: "upload-job" },
|
|
237
|
+
artifacts: [{ filename: "notes.md", content }],
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
expect(result.success).toBe(true);
|
|
241
|
+
const success = result as SubmitSuccessResult;
|
|
242
|
+
|
|
243
|
+
const stagedPath = join(tmpDir, "pipeline-data", "staging", success.jobId, "notes.md");
|
|
244
|
+
const stagedBytes = await readFile(stagedPath);
|
|
245
|
+
expect(Array.from(stagedBytes)).toEqual(Array.from(content));
|
|
246
|
+
|
|
247
|
+
const pendingPath = join(
|
|
248
|
+
tmpDir,
|
|
249
|
+
"pipeline-data",
|
|
250
|
+
"pending",
|
|
251
|
+
`${success.jobId}-seed.json`,
|
|
252
|
+
);
|
|
253
|
+
const pendingJson = (await readJson(pendingPath)) as { pipeline: string; name: string };
|
|
254
|
+
expect(pendingJson).toEqual({ pipeline: "test-pipeline", name: "upload-job" });
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("stages nested artifacts preserving their subpath under staging/<jobId>/", async () => {
|
|
258
|
+
const result = await submitUploadedSeed({
|
|
259
|
+
root: tmpDir,
|
|
260
|
+
seedObject: { pipeline: "test-pipeline" },
|
|
261
|
+
artifacts: [
|
|
262
|
+
{ filename: "out.json", content: new TextEncoder().encode("{}") },
|
|
263
|
+
{
|
|
264
|
+
filename: "data/foo.json",
|
|
265
|
+
content: new TextEncoder().encode('{"a":1}'),
|
|
266
|
+
},
|
|
267
|
+
],
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
expect(result.success).toBe(true);
|
|
271
|
+
const success = result as SubmitSuccessResult;
|
|
272
|
+
const stagingJobDir = join(tmpDir, "pipeline-data", "staging", success.jobId);
|
|
273
|
+
|
|
274
|
+
expect(await pathExists(join(stagingJobDir, "out.json"))).toBe(true);
|
|
275
|
+
expect(await pathExists(join(stagingJobDir, "data", "foo.json"))).toBe(true);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("rejects a parent-traversal artifact path with the pinned message and writes nothing", async () => {
|
|
279
|
+
const filename = "../escape.txt";
|
|
280
|
+
const result = await submitUploadedSeed({
|
|
281
|
+
root: tmpDir,
|
|
282
|
+
seedObject: { pipeline: "test-pipeline" },
|
|
283
|
+
artifacts: [{ filename, content: new TextEncoder().encode("escape") }],
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
expect(result.success).toBe(false);
|
|
287
|
+
const failure = result as SubmitFailureResult;
|
|
288
|
+
expect(failure.message.startsWith("unsafe artifact path: ")).toBe(true);
|
|
289
|
+
expect(failure.message).toContain(filename);
|
|
290
|
+
|
|
291
|
+
expect(await pathExists(join(tmpDir, "pipeline-data", "staging"))).toBe(false);
|
|
292
|
+
expect(await pathExists(join(tmpDir, "escape.txt"))).toBe(false);
|
|
293
|
+
|
|
294
|
+
const pendingFiles = await readdir(join(tmpDir, "pipeline-data", "pending"));
|
|
295
|
+
expect(pendingFiles.filter((f) => f.endsWith("-seed.json"))).toEqual([]);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("rejects an absolute artifact path with the pinned message and writes nothing", async () => {
|
|
299
|
+
const filename = "/etc/passwd";
|
|
300
|
+
const result = await submitUploadedSeed({
|
|
301
|
+
root: tmpDir,
|
|
302
|
+
seedObject: { pipeline: "test-pipeline" },
|
|
303
|
+
artifacts: [{ filename, content: new TextEncoder().encode("data") }],
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
expect(result.success).toBe(false);
|
|
307
|
+
const failure = result as SubmitFailureResult;
|
|
308
|
+
expect(failure.message.startsWith("unsafe artifact path: ")).toBe(true);
|
|
309
|
+
expect(failure.message).toContain(filename);
|
|
310
|
+
|
|
311
|
+
expect(await pathExists(join(tmpDir, "pipeline-data", "staging"))).toBe(false);
|
|
312
|
+
|
|
313
|
+
const pendingFiles = await readdir(join(tmpDir, "pipeline-data", "pending"));
|
|
314
|
+
expect(pendingFiles.filter((f) => f.endsWith("-seed.json"))).toEqual([]);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("rejects a dir/.. artifact path with the pinned message and writes nothing", async () => {
|
|
318
|
+
const filename = "dir/..";
|
|
319
|
+
const result = await submitUploadedSeed({
|
|
320
|
+
root: tmpDir,
|
|
321
|
+
seedObject: { pipeline: "test-pipeline" },
|
|
322
|
+
artifacts: [{ filename, content: new TextEncoder().encode("root") }],
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
expect(result.success).toBe(false);
|
|
326
|
+
const failure = result as SubmitFailureResult;
|
|
327
|
+
expect(failure.message.startsWith("unsafe artifact path: ")).toBe(true);
|
|
328
|
+
expect(failure.message).toContain(filename);
|
|
329
|
+
|
|
330
|
+
expect(await pathExists(join(tmpDir, "pipeline-data", "staging"))).toBe(false);
|
|
331
|
+
|
|
332
|
+
const pendingFiles = await readdir(join(tmpDir, "pipeline-data", "pending"));
|
|
333
|
+
expect(pendingFiles.filter((f) => f.endsWith("-seed.json"))).toEqual([]);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("rejects the root '.' artifact path with the pinned message and writes nothing", async () => {
|
|
337
|
+
const filename = ".";
|
|
338
|
+
const result = await submitUploadedSeed({
|
|
339
|
+
root: tmpDir,
|
|
340
|
+
seedObject: { pipeline: "test-pipeline" },
|
|
341
|
+
artifacts: [{ filename, content: new TextEncoder().encode("root") }],
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
expect(result.success).toBe(false);
|
|
345
|
+
const failure = result as SubmitFailureResult;
|
|
346
|
+
expect(failure.message.startsWith("unsafe artifact path: ")).toBe(true);
|
|
347
|
+
expect(failure.message).toContain(filename);
|
|
348
|
+
|
|
349
|
+
expect(await pathExists(join(tmpDir, "pipeline-data", "staging"))).toBe(false);
|
|
350
|
+
|
|
351
|
+
const pendingFiles = await readdir(join(tmpDir, "pipeline-data", "pending"));
|
|
352
|
+
expect(pendingFiles.filter((f) => f.endsWith("-seed.json"))).toEqual([]);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("rejects a mixed safe+unsafe artifact array with no partial writes", async () => {
|
|
356
|
+
const unsafeFilename = "../escape.txt";
|
|
357
|
+
const result = await submitUploadedSeed({
|
|
358
|
+
root: tmpDir,
|
|
359
|
+
seedObject: { pipeline: "test-pipeline" },
|
|
360
|
+
artifacts: [
|
|
361
|
+
{ filename: "safe.md", content: new TextEncoder().encode("safe") },
|
|
362
|
+
{ filename: unsafeFilename, content: new TextEncoder().encode("escape") },
|
|
363
|
+
],
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
expect(result.success).toBe(false);
|
|
367
|
+
const failure = result as SubmitFailureResult;
|
|
368
|
+
expect(failure.message.startsWith("unsafe artifact path: ")).toBe(true);
|
|
369
|
+
expect(failure.message).toContain(unsafeFilename);
|
|
370
|
+
|
|
371
|
+
expect(await pathExists(join(tmpDir, "pipeline-data", "staging"))).toBe(false);
|
|
372
|
+
|
|
373
|
+
const pendingFiles = await readdir(join(tmpDir, "pipeline-data", "pending"));
|
|
374
|
+
expect(pendingFiles.filter((f) => f.endsWith("-seed.json"))).toEqual([]);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("rejects an unknown pipeline slug with artifacts and writes no staging dir or seed", async () => {
|
|
378
|
+
const result = await submitUploadedSeed({
|
|
379
|
+
root: tmpDir,
|
|
380
|
+
seedObject: { pipeline: "does-not-exist", name: "upload-job" },
|
|
381
|
+
artifacts: [
|
|
382
|
+
{ filename: "safe.md", content: new TextEncoder().encode("safe") },
|
|
383
|
+
],
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
expect(result.success).toBe(false);
|
|
387
|
+
expect((result as SubmitFailureResult).message).toBe(
|
|
388
|
+
"Pipeline 'does-not-exist' not found in registry",
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
expect(await pathExists(join(tmpDir, "pipeline-data", "staging"))).toBe(false);
|
|
392
|
+
|
|
393
|
+
const pendingFiles = await readdir(join(tmpDir, "pipeline-data", "pending"));
|
|
394
|
+
expect(pendingFiles.filter((f) => f.endsWith("-seed.json"))).toEqual([]);
|
|
395
|
+
});
|
|
396
|
+
});
|