@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,44 @@
|
|
|
1
|
+
// Ambient module declarations for non-HTML Vite asset file imports used by the
|
|
2
|
+
// generated src/ui/server/embedded-assets.ts module. `*.html` is intentionally
|
|
3
|
+
// left to bun-types (declared as `import("bun").HTMLBundle`); the generator
|
|
4
|
+
// casts every emitted `path` value `as unknown as string` to bridge the type.
|
|
5
|
+
declare module "*.js" {
|
|
6
|
+
const path: string;
|
|
7
|
+
export default path;
|
|
8
|
+
}
|
|
9
|
+
declare module "*.css" {
|
|
10
|
+
const path: string;
|
|
11
|
+
export default path;
|
|
12
|
+
}
|
|
13
|
+
declare module "*.svg" {
|
|
14
|
+
const path: string;
|
|
15
|
+
export default path;
|
|
16
|
+
}
|
|
17
|
+
declare module "*.map" {
|
|
18
|
+
const path: string;
|
|
19
|
+
export default path;
|
|
20
|
+
}
|
|
21
|
+
declare module "*.png" {
|
|
22
|
+
const path: string;
|
|
23
|
+
export default path;
|
|
24
|
+
}
|
|
25
|
+
declare module "*.jpg" {
|
|
26
|
+
const path: string;
|
|
27
|
+
export default path;
|
|
28
|
+
}
|
|
29
|
+
declare module "*.ico" {
|
|
30
|
+
const path: string;
|
|
31
|
+
export default path;
|
|
32
|
+
}
|
|
33
|
+
declare module "*.woff" {
|
|
34
|
+
const path: string;
|
|
35
|
+
export default path;
|
|
36
|
+
}
|
|
37
|
+
declare module "*.woff2" {
|
|
38
|
+
const path: string;
|
|
39
|
+
export default path;
|
|
40
|
+
}
|
|
41
|
+
declare module "*.ttf" {
|
|
42
|
+
const path: string;
|
|
43
|
+
export default path;
|
|
44
|
+
}
|
|
@@ -1,7 +1,18 @@
|
|
|
1
|
+
// Auto-generated by scripts/generate-embedded-assets.js — do not edit
|
|
2
|
+
import _asset0 from "../dist/assets/index-DN3-zvtP.js" with { type: "file" };
|
|
3
|
+
import _asset1 from "../dist/assets/index-DN3-zvtP.js.map" with { type: "file" };
|
|
4
|
+
import _asset2 from "../dist/assets/style-CtZBnjlR.css" with { type: "file" };
|
|
5
|
+
import _asset3 from "../dist/index.html" with { type: "file" };
|
|
6
|
+
|
|
1
7
|
export interface EmbeddedAssetEntry {
|
|
2
8
|
path: string;
|
|
3
9
|
mime: string;
|
|
4
10
|
}
|
|
5
11
|
|
|
6
|
-
|
|
7
|
-
|
|
12
|
+
export const embeddedAssets: Record<string, EmbeddedAssetEntry> = {
|
|
13
|
+
"/assets/index-DN3-zvtP.js": { path: _asset0 as unknown as string, mime: "application/javascript" },
|
|
14
|
+
"/assets/index-DN3-zvtP.js.map": { path: _asset1 as unknown as string, mime: "application/json" },
|
|
15
|
+
"/assets/style-CtZBnjlR.css": { path: _asset2 as unknown as string, mime: "text/css" },
|
|
16
|
+
"/": { path: _asset3 as unknown as string, mime: "text/html" },
|
|
17
|
+
"/index.html": { path: _asset3 as unknown as string, mime: "text/html" }
|
|
18
|
+
};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import type { AnalysisResult } from "../../../../task-analysis/analyzer";
|
|
7
|
+
|
|
8
|
+
// ─── Mock the analyzer so no LLM/gateway is exercised ─────────────────────────
|
|
9
|
+
// `analyzeResults` is keyed by task name; default is an `ok` result.
|
|
10
|
+
const analyzeResults: Record<string, AnalysisResult> = {};
|
|
11
|
+
let lastAnalyzedSource: string | null = null;
|
|
12
|
+
|
|
13
|
+
function okResult(): AnalysisResult {
|
|
14
|
+
return {
|
|
15
|
+
summary: "does a thing",
|
|
16
|
+
stages: [{ name: "main", purpose: "runs" }],
|
|
17
|
+
artifacts: { reads: [], writes: [] },
|
|
18
|
+
models: [],
|
|
19
|
+
analysisStatus: "ok",
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
mock.module("../../../../task-analysis/analyzer", () => ({
|
|
24
|
+
analyzeTask: mock((code: string) => {
|
|
25
|
+
lastAnalyzedSource = code;
|
|
26
|
+
return Promise.resolve(analyzeResults[code] ?? okResult());
|
|
27
|
+
}),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
import { handlePipelineAnalysis } from "../pipeline-analysis-endpoint";
|
|
31
|
+
import { TaskAnalysisRepository } from "../../../../task-analysis/repository";
|
|
32
|
+
import { getLockStatus, releaseLock } from "../../../state/analysis-lock";
|
|
33
|
+
|
|
34
|
+
// ─── SSE helpers ──────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
interface SseEvent {
|
|
37
|
+
event: string;
|
|
38
|
+
data: Record<string, unknown>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function readEvents(res: Response): Promise<SseEvent[]> {
|
|
42
|
+
const text = await res.text();
|
|
43
|
+
const events: SseEvent[] = [];
|
|
44
|
+
for (const block of text.split("\n\n")) {
|
|
45
|
+
const trimmed = block.trim();
|
|
46
|
+
if (!trimmed) continue;
|
|
47
|
+
const eventLine = trimmed.split("\n").find((l) => l.startsWith("event: "));
|
|
48
|
+
const dataLine = trimmed.split("\n").find((l) => l.startsWith("data: "));
|
|
49
|
+
if (!eventLine || !dataLine) continue;
|
|
50
|
+
events.push({
|
|
51
|
+
event: eventLine.slice("event: ".length),
|
|
52
|
+
data: JSON.parse(dataLine.slice("data: ".length)),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return events;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ─── Fixture: a workspace root with a registry + one task source ──────────────
|
|
59
|
+
|
|
60
|
+
let root: string;
|
|
61
|
+
|
|
62
|
+
function writeWorkspace(tasks: Record<string, string>): void {
|
|
63
|
+
const tasksDir = join(root, "pipeline-config", "demo", "tasks");
|
|
64
|
+
mkdirSync(tasksDir, { recursive: true });
|
|
65
|
+
|
|
66
|
+
// Registry entry so resolvePipelineSync finds the slug.
|
|
67
|
+
const registry = { version: 1, pipelines: { demo: { name: "demo", description: "d" } } };
|
|
68
|
+
writeFileSync(join(root, "pipeline-config", "registry.json"), JSON.stringify(registry));
|
|
69
|
+
|
|
70
|
+
// Task registry index (default export: task name -> relative module path).
|
|
71
|
+
const entries = Object.keys(tasks)
|
|
72
|
+
.map((name) => ` ${JSON.stringify(name)}: ${JSON.stringify(`./${name}.ts`)},`)
|
|
73
|
+
.join("\n");
|
|
74
|
+
writeFileSync(join(tasksDir, "index.ts"), `export default {\n${entries}\n};\n`);
|
|
75
|
+
|
|
76
|
+
for (const [name, source] of Object.entries(tasks)) {
|
|
77
|
+
writeFileSync(join(tasksDir, `${name}.ts`), source);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
beforeEach(() => {
|
|
82
|
+
root = mkdtempSync(join(tmpdir(), "po-analysis-"));
|
|
83
|
+
for (const key of Object.keys(analyzeResults)) delete analyzeResults[key];
|
|
84
|
+
lastAnalyzedSource = null;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
afterEach(() => {
|
|
88
|
+
if (getLockStatus()) releaseLock(getLockStatus()!.pipelineSlug);
|
|
89
|
+
rmSync(root, { recursive: true, force: true });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("handlePipelineAnalysis", () => {
|
|
93
|
+
it("emits started, per-task task:start/task:complete, then complete", async () => {
|
|
94
|
+
writeWorkspace({ alpha: "// alpha source", beta: "// beta source" });
|
|
95
|
+
|
|
96
|
+
const res = await handlePipelineAnalysis(new Request("http://localhost/"), "demo", root);
|
|
97
|
+
const events = await readEvents(res);
|
|
98
|
+
|
|
99
|
+
expect(events[0]).toEqual({ event: "started", data: { slug: "demo", totalTasks: 2 } });
|
|
100
|
+
|
|
101
|
+
const names = events.map((e) => e.event);
|
|
102
|
+
expect(names).toEqual([
|
|
103
|
+
"started",
|
|
104
|
+
"task:start",
|
|
105
|
+
"task:complete",
|
|
106
|
+
"task:start",
|
|
107
|
+
"task:complete",
|
|
108
|
+
"complete",
|
|
109
|
+
]);
|
|
110
|
+
expect(events.at(-1)).toEqual({ event: "complete", data: { slug: "demo" } });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("persists each analyzed task via the repository", async () => {
|
|
114
|
+
writeWorkspace({ alpha: "// alpha source" });
|
|
115
|
+
|
|
116
|
+
const res = await handlePipelineAnalysis(new Request("http://localhost/"), "demo", root);
|
|
117
|
+
await readEvents(res);
|
|
118
|
+
|
|
119
|
+
const persisted = await TaskAnalysisRepository.read(root, "demo", "alpha");
|
|
120
|
+
expect(persisted?.analysisStatus).toBe("ok");
|
|
121
|
+
expect(persisted?.summary).toBe("does a thing");
|
|
122
|
+
expect(typeof persisted?.analyzedAt).toBe("string");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("persists an error placeholder for a failing task yet still reaches complete", async () => {
|
|
126
|
+
writeWorkspace({ good: "// good source", bad: "// bad source" });
|
|
127
|
+
// Force the analyzer to throw for the bad task's source.
|
|
128
|
+
analyzeResults["// bad source"] = undefined as unknown as AnalysisResult;
|
|
129
|
+
const analyzerMod = await import("../../../../task-analysis/analyzer");
|
|
130
|
+
(analyzerMod.analyzeTask as ReturnType<typeof mock>).mockImplementation((code: string) => {
|
|
131
|
+
if (code === "// bad source") throw new Error("analyzer blew up");
|
|
132
|
+
return Promise.resolve(okResult());
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const res = await handlePipelineAnalysis(new Request("http://localhost/"), "demo", root);
|
|
136
|
+
const events = await readEvents(res);
|
|
137
|
+
|
|
138
|
+
expect(events.at(-1)?.event).toBe("complete");
|
|
139
|
+
const errorEvent = events.find((e) => e.event === "error");
|
|
140
|
+
expect(errorEvent?.data["task"]).toBe("bad");
|
|
141
|
+
|
|
142
|
+
const badAnalysis = await TaskAnalysisRepository.read(root, "demo", "bad");
|
|
143
|
+
expect(badAnalysis?.analysisStatus).toBe("error");
|
|
144
|
+
expect(badAnalysis?.analysisError).toContain("analyzer blew up");
|
|
145
|
+
|
|
146
|
+
// Restore the default mock implementation for later tests.
|
|
147
|
+
(analyzerMod.analyzeTask as ReturnType<typeof mock>).mockImplementation((code: string) => {
|
|
148
|
+
lastAnalyzedSource = code;
|
|
149
|
+
return Promise.resolve(analyzeResults[code] ?? okResult());
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("blocks a concurrent run with a 409 while the lock is held", async () => {
|
|
154
|
+
writeWorkspace({ alpha: "// alpha source" });
|
|
155
|
+
|
|
156
|
+
// Acquire the lock by starting one run but not draining its stream.
|
|
157
|
+
const first = await handlePipelineAnalysis(new Request("http://localhost/"), "demo", root);
|
|
158
|
+
|
|
159
|
+
const second = await handlePipelineAnalysis(new Request("http://localhost/"), "demo", root);
|
|
160
|
+
expect(second.status).toBe(409);
|
|
161
|
+
const body = await second.json();
|
|
162
|
+
expect(body.code).toBe("BAD_REQUEST");
|
|
163
|
+
|
|
164
|
+
// Drain the first to release the lock.
|
|
165
|
+
await readEvents(first);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -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
|
+
});
|