@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,258 @@
|
|
|
1
|
+
import { join, isAbsolute, dirname } from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
openSync,
|
|
6
|
+
closeSync,
|
|
7
|
+
fsyncSync,
|
|
8
|
+
unlinkSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { mkdir, writeFile, rename } from "node:fs/promises";
|
|
11
|
+
|
|
12
|
+
export const REGISTRY_VERSION = 1;
|
|
13
|
+
|
|
14
|
+
export interface PipelineRegistryEntry {
|
|
15
|
+
name: string;
|
|
16
|
+
description: string;
|
|
17
|
+
/** Relative to root or absolute. Default: pipeline-config/<slug> */
|
|
18
|
+
configDir?: string;
|
|
19
|
+
/** Relative to root or absolute. Default: <configDir>/tasks */
|
|
20
|
+
tasksDir?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PipelineRegistry {
|
|
24
|
+
version: number;
|
|
25
|
+
pipelines: Record<string, PipelineRegistryEntry>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ResolvedPipeline {
|
|
29
|
+
pipelineJsonPath: string;
|
|
30
|
+
tasksDir: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class RegistryError extends Error {}
|
|
34
|
+
|
|
35
|
+
export function registryFilePath(root: string): string {
|
|
36
|
+
return join(root, "pipeline-config", "registry.json");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const REGISTRY_REPAIR_HINT =
|
|
40
|
+
"regenerate it with 'pipeline-orchestrator init' or fix the file";
|
|
41
|
+
|
|
42
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
43
|
+
return (
|
|
44
|
+
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function fail(filePath: string, reason: string): never {
|
|
49
|
+
throw new RegistryError(`${filePath}: ${reason}; ${REGISTRY_REPAIR_HINT}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function readRegistrySync(root: string): PipelineRegistry {
|
|
53
|
+
const filePath = registryFilePath(root);
|
|
54
|
+
|
|
55
|
+
if (!existsSync(filePath)) {
|
|
56
|
+
return { version: REGISTRY_VERSION, pipelines: {} };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const text = readFileSync(filePath, "utf8");
|
|
60
|
+
if (text.trim() === "") {
|
|
61
|
+
fail(filePath, "registry file is empty");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let parsed: unknown;
|
|
65
|
+
try {
|
|
66
|
+
parsed = JSON.parse(text);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
const cause = err instanceof Error ? err.message : String(err);
|
|
69
|
+
fail(filePath, `not valid JSON (${cause})`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!isPlainObject(parsed)) {
|
|
73
|
+
fail(filePath, "registry must be a JSON object");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const pipelines = (parsed as Record<string, unknown>)["pipelines"];
|
|
77
|
+
if (!isPlainObject(pipelines)) {
|
|
78
|
+
if ("slugs" in parsed) {
|
|
79
|
+
return { version: REGISTRY_VERSION, pipelines: {} };
|
|
80
|
+
}
|
|
81
|
+
fail(filePath, "missing 'pipelines' object");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const [slug, raw] of Object.entries(pipelines)) {
|
|
85
|
+
const entry = raw as Record<string, unknown>;
|
|
86
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
87
|
+
fail(filePath, `entry '${slug}': must be an object`);
|
|
88
|
+
}
|
|
89
|
+
if (typeof entry["name"] !== "string") {
|
|
90
|
+
fail(filePath, `entry '${slug}': 'name' must be a string`);
|
|
91
|
+
}
|
|
92
|
+
if (typeof entry["description"] !== "string") {
|
|
93
|
+
fail(filePath, `entry '${slug}': 'description' must be a string`);
|
|
94
|
+
}
|
|
95
|
+
if (
|
|
96
|
+
entry["configDir"] !== undefined &&
|
|
97
|
+
typeof entry["configDir"] !== "string"
|
|
98
|
+
) {
|
|
99
|
+
fail(
|
|
100
|
+
filePath,
|
|
101
|
+
`entry '${slug}': 'configDir' must be a string when present`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
if (
|
|
105
|
+
entry["tasksDir"] !== undefined &&
|
|
106
|
+
typeof entry["tasksDir"] !== "string"
|
|
107
|
+
) {
|
|
108
|
+
fail(
|
|
109
|
+
filePath,
|
|
110
|
+
`entry '${slug}': 'tasksDir' must be a string when present`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const version = (parsed as Record<string, unknown>)["version"];
|
|
116
|
+
if (
|
|
117
|
+
version !== undefined &&
|
|
118
|
+
(typeof version !== "number" || version !== REGISTRY_VERSION)
|
|
119
|
+
) {
|
|
120
|
+
fail(
|
|
121
|
+
filePath,
|
|
122
|
+
`unsupported registry version ${JSON.stringify(version)} (expected ${REGISTRY_VERSION})`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
version: REGISTRY_VERSION,
|
|
128
|
+
pipelines: pipelines as Record<string, PipelineRegistryEntry>,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function resolvePipelineEntry(
|
|
133
|
+
root: string,
|
|
134
|
+
slug: string,
|
|
135
|
+
entry: PipelineRegistryEntry,
|
|
136
|
+
): { configDir: string; tasksDir: string } {
|
|
137
|
+
const configDir = !entry.configDir
|
|
138
|
+
? join(root, "pipeline-config", slug)
|
|
139
|
+
: isAbsolute(entry.configDir)
|
|
140
|
+
? entry.configDir
|
|
141
|
+
: join(root, entry.configDir);
|
|
142
|
+
|
|
143
|
+
const tasksDir = !entry.tasksDir
|
|
144
|
+
? join(configDir, "tasks")
|
|
145
|
+
: isAbsolute(entry.tasksDir)
|
|
146
|
+
? entry.tasksDir
|
|
147
|
+
: join(root, entry.tasksDir);
|
|
148
|
+
|
|
149
|
+
return { configDir, tasksDir };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function resolvePipelineSync(
|
|
153
|
+
root: string,
|
|
154
|
+
slug: string,
|
|
155
|
+
): ResolvedPipeline {
|
|
156
|
+
const reg = readRegistrySync(root);
|
|
157
|
+
const entry = reg.pipelines[slug];
|
|
158
|
+
if (entry === undefined) {
|
|
159
|
+
throw new Error(`Pipeline '${slug}' not found in registry`);
|
|
160
|
+
}
|
|
161
|
+
const resolved = resolvePipelineEntry(root, slug, entry);
|
|
162
|
+
return {
|
|
163
|
+
pipelineJsonPath: join(resolved.configDir, "pipeline.json"),
|
|
164
|
+
tasksDir: resolved.tasksDir,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function resolveAllPipelinesSync(
|
|
169
|
+
root: string,
|
|
170
|
+
): Record<string, { configDir: string; tasksDir: string }> {
|
|
171
|
+
const reg = readRegistrySync(root);
|
|
172
|
+
const result: Record<string, { configDir: string; tasksDir: string }> = {};
|
|
173
|
+
for (const [slug, entry] of Object.entries(reg.pipelines)) {
|
|
174
|
+
result[slug] = resolvePipelineEntry(root, slug, entry);
|
|
175
|
+
}
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Resolve the task-module path for a tasks dir, preferring compiled index.js
|
|
181
|
+
* and falling back to index.ts (Bun runs .ts natively). Returns index.js path
|
|
182
|
+
* when neither exists so the loader's error names the canonical file.
|
|
183
|
+
*/
|
|
184
|
+
export function resolveTaskRegistryPath(tasksDir: string): string {
|
|
185
|
+
const jsPath = join(tasksDir, "index.js");
|
|
186
|
+
const tsPath = join(tasksDir, "index.ts");
|
|
187
|
+
if (existsSync(jsPath)) return jsPath;
|
|
188
|
+
if (existsSync(tsPath)) return tsPath;
|
|
189
|
+
return jsPath;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let tempCounter = 0;
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Atomic temp-file + rename write. Serializes the full JSON before opening any
|
|
196
|
+
* file, writes to a same-directory temp, fsyncs the temp, renames over the
|
|
197
|
+
* target, fsyncs the directory. On any error before the rename completes, the
|
|
198
|
+
* temp file is removed (if present) and the original error is rethrown.
|
|
199
|
+
*/
|
|
200
|
+
export async function writeRegistry(
|
|
201
|
+
root: string,
|
|
202
|
+
reg: PipelineRegistry,
|
|
203
|
+
): Promise<void> {
|
|
204
|
+
const serialized = JSON.stringify(reg);
|
|
205
|
+
const target = registryFilePath(root);
|
|
206
|
+
const dir = dirname(target);
|
|
207
|
+
await mkdir(dir, { recursive: true });
|
|
208
|
+
const temp = join(
|
|
209
|
+
dir,
|
|
210
|
+
`.registry.json.tmp.${process.pid}.${tempCounter++}`,
|
|
211
|
+
);
|
|
212
|
+
try {
|
|
213
|
+
await writeFile(temp, serialized);
|
|
214
|
+
const fd = openSync(temp, "r");
|
|
215
|
+
try {
|
|
216
|
+
fsyncSync(fd);
|
|
217
|
+
} finally {
|
|
218
|
+
closeSync(fd);
|
|
219
|
+
}
|
|
220
|
+
await rename(temp, target);
|
|
221
|
+
} catch (err) {
|
|
222
|
+
if (existsSync(temp)) {
|
|
223
|
+
try {
|
|
224
|
+
unlinkSync(temp);
|
|
225
|
+
} catch {
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
throw err;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Content is durable once rename succeeds. Fsync the directory so the new
|
|
232
|
+
// entry survives a crash, but treat failure as best-effort: the write has
|
|
233
|
+
// already committed, so a dir-fsync error must not propagate as a failure.
|
|
234
|
+
try {
|
|
235
|
+
const dirFd = openSync(dir, "r");
|
|
236
|
+
try {
|
|
237
|
+
fsyncSync(dirFd);
|
|
238
|
+
} finally {
|
|
239
|
+
closeSync(dirFd);
|
|
240
|
+
}
|
|
241
|
+
} catch {
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Read-modify-write a single entry. Reads the current registry (or a fresh v1
|
|
247
|
+
* document if absent), sets `pipelines[slug] = entry`, and writes back
|
|
248
|
+
* atomically through {@link writeRegistry}.
|
|
249
|
+
*/
|
|
250
|
+
export async function registerPipeline(
|
|
251
|
+
root: string,
|
|
252
|
+
slug: string,
|
|
253
|
+
entry: PipelineRegistryEntry,
|
|
254
|
+
): Promise<void> {
|
|
255
|
+
const reg = readRegistrySync(root);
|
|
256
|
+
reg.pipelines[slug] = entry;
|
|
257
|
+
await writeRegistry(root, reg);
|
|
258
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { JobStatus } from "./statuses.ts";
|
|
3
|
+
|
|
4
|
+
const GateInfoSchema = z
|
|
5
|
+
.object({
|
|
6
|
+
afterTask: z.string(),
|
|
7
|
+
message: z.string(),
|
|
8
|
+
artifacts: z.array(z.string()).optional(),
|
|
9
|
+
requestedAt: z.string(),
|
|
10
|
+
kind: z.enum(["approval", "control_invalid"]).optional(),
|
|
11
|
+
})
|
|
12
|
+
.passthrough();
|
|
13
|
+
|
|
14
|
+
const CostsSummarySchema = z
|
|
15
|
+
.object({
|
|
16
|
+
totalTokens: z.number(),
|
|
17
|
+
totalInputTokens: z.number(),
|
|
18
|
+
totalOutputTokens: z.number(),
|
|
19
|
+
totalCost: z.number(),
|
|
20
|
+
totalInputCost: z.number(),
|
|
21
|
+
totalOutputCost: z.number(),
|
|
22
|
+
estimatedCost: z.number().default(0),
|
|
23
|
+
hasEstimated: z.boolean().default(false),
|
|
24
|
+
unavailableCost: z.number().default(0),
|
|
25
|
+
})
|
|
26
|
+
.passthrough();
|
|
27
|
+
|
|
28
|
+
const JobErrorSchema = z
|
|
29
|
+
.object({
|
|
30
|
+
code: z.string(),
|
|
31
|
+
message: z.string(),
|
|
32
|
+
path: z.string().optional(),
|
|
33
|
+
})
|
|
34
|
+
.passthrough();
|
|
35
|
+
|
|
36
|
+
export const JobWireSchema = z
|
|
37
|
+
.object({
|
|
38
|
+
jobId: z.string(),
|
|
39
|
+
title: z.string(),
|
|
40
|
+
status: z.enum([
|
|
41
|
+
JobStatus.PENDING,
|
|
42
|
+
JobStatus.RUNNING,
|
|
43
|
+
JobStatus.FAILED,
|
|
44
|
+
JobStatus.COMPLETE,
|
|
45
|
+
JobStatus.WAITING,
|
|
46
|
+
]),
|
|
47
|
+
progress: z.number(),
|
|
48
|
+
createdAt: z.string().nullable(),
|
|
49
|
+
updatedAt: z.string().nullable(),
|
|
50
|
+
location: z.string().nullable(),
|
|
51
|
+
tasks: z.record(z.string(), z.unknown()),
|
|
52
|
+
costsSummary: CostsSummarySchema,
|
|
53
|
+
files: z.record(z.string(), z.unknown()).optional(),
|
|
54
|
+
current: z.unknown().optional(),
|
|
55
|
+
currentStage: z.unknown().optional(),
|
|
56
|
+
gate: GateInfoSchema.nullable().optional(),
|
|
57
|
+
pipelineSlug: z.string().optional(),
|
|
58
|
+
pipeline: z.string().optional(),
|
|
59
|
+
pipelineLabel: z.string().optional(),
|
|
60
|
+
pipelineConfig: z.record(z.string(), z.unknown()).optional(),
|
|
61
|
+
readable: z.boolean().optional(),
|
|
62
|
+
error: JobErrorSchema.optional(),
|
|
63
|
+
})
|
|
64
|
+
.passthrough();
|
|
65
|
+
|
|
66
|
+
export type JobWire = z.infer<typeof JobWireSchema>;
|
|
67
|
+
|
|
68
|
+
export const SseEvent = z.discriminatedUnion("type", [
|
|
69
|
+
z.object({
|
|
70
|
+
type: z.literal("state:change"),
|
|
71
|
+
data: z.object({ jobId: z.string(), path: z.string() }).passthrough(),
|
|
72
|
+
}),
|
|
73
|
+
z.object({
|
|
74
|
+
type: z.literal("state:summary"),
|
|
75
|
+
data: z.object({ changeCount: z.number() }).passthrough(),
|
|
76
|
+
}),
|
|
77
|
+
z.object({ type: z.literal("job:created"), data: JobWireSchema }),
|
|
78
|
+
z.object({ type: z.literal("job:updated"), data: JobWireSchema }),
|
|
79
|
+
z.object({
|
|
80
|
+
type: z.literal("job:removed"),
|
|
81
|
+
data: z.object({ jobId: z.string() }).passthrough(),
|
|
82
|
+
}),
|
|
83
|
+
z.object({
|
|
84
|
+
type: z.literal("heartbeat"),
|
|
85
|
+
data: z.object({ ok: z.boolean(), timestamp: z.string() }).passthrough(),
|
|
86
|
+
}),
|
|
87
|
+
z.object({
|
|
88
|
+
type: z.literal("seed:uploaded"),
|
|
89
|
+
data: z.object({}).passthrough(),
|
|
90
|
+
}),
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
export type SseEvent = z.infer<typeof SseEvent>;
|
|
94
|
+
export type SseEventType = SseEvent["type"];
|
|
95
|
+
|
|
96
|
+
export const DOCUMENTED_SSE_EVENTS = [
|
|
97
|
+
"state:change",
|
|
98
|
+
"state:summary",
|
|
99
|
+
"job:created",
|
|
100
|
+
"job:updated",
|
|
101
|
+
"job:removed",
|
|
102
|
+
"heartbeat",
|
|
103
|
+
"seed:uploaded",
|
|
104
|
+
] as const satisfies ReadonlyArray<SseEventType>;
|
|
105
|
+
|
|
106
|
+
export function parseSseEvent(type: string, data: unknown): SseEvent | null {
|
|
107
|
+
const result = SseEvent.safeParse({ type, data });
|
|
108
|
+
return result.success ? result.data : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function emitSseEvent(
|
|
112
|
+
registry: { broadcast: (type: string, data: unknown) => void },
|
|
113
|
+
event: SseEvent,
|
|
114
|
+
): void {
|
|
115
|
+
const result = SseEvent.safeParse(event);
|
|
116
|
+
if (!result.success) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`emitSseEvent: invalid event of type "${(event as { type?: string }).type ?? "unknown"}" failed schema validation: ${result.error.message}`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
registry.broadcast(event.type, event.data);
|
|
122
|
+
}
|
|
@@ -763,3 +763,118 @@ describe("runAgentStep", () => {
|
|
|
763
763
|
});
|
|
764
764
|
});
|
|
765
765
|
});
|
|
766
|
+
|
|
767
|
+
function createContentCapturingIO(): TaskFileIO & {
|
|
768
|
+
logWrites: Array<{ name: string; content: string }>;
|
|
769
|
+
} {
|
|
770
|
+
const logWrites: Array<{ name: string; content: string }> = [];
|
|
771
|
+
return {
|
|
772
|
+
logWrites,
|
|
773
|
+
async writeArtifact(_name: string, _content: string, _options?: WriteOptions) {},
|
|
774
|
+
async writeLog(name: string, content: string, _options?: WriteOptions) {
|
|
775
|
+
logWrites.push({ name, content });
|
|
776
|
+
},
|
|
777
|
+
async writeTmp(_name: string, _content: string, _options?: WriteOptions) {},
|
|
778
|
+
async readArtifact(_name: string) {
|
|
779
|
+
return "";
|
|
780
|
+
},
|
|
781
|
+
async readLog(_name: string) {
|
|
782
|
+
return "";
|
|
783
|
+
},
|
|
784
|
+
async readTmp(_name: string) {
|
|
785
|
+
return "";
|
|
786
|
+
},
|
|
787
|
+
getTaskDir() {
|
|
788
|
+
return join(tmpdir(), "pop-fake-task-dir");
|
|
789
|
+
},
|
|
790
|
+
writeLogSync() {},
|
|
791
|
+
getCurrentStage() {
|
|
792
|
+
return "test";
|
|
793
|
+
},
|
|
794
|
+
getDB() {
|
|
795
|
+
throw new Error("not implemented");
|
|
796
|
+
},
|
|
797
|
+
async runBatch() {
|
|
798
|
+
throw new Error("not implemented");
|
|
799
|
+
},
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
describe("agent event log redaction", () => {
|
|
804
|
+
it("with an MCP handle, onEvent redacts the token from Authorization fields and embedded strings while preserving non-secret fields", async () => {
|
|
805
|
+
const token = "s3cret-tok-abc123xyz";
|
|
806
|
+
const io = createContentCapturingIO();
|
|
807
|
+
const startMcpIoServer = mock(async () =>
|
|
808
|
+
createFakeMcpHandle([], { url: "http://127.0.0.1:9990/mcp", token }),
|
|
809
|
+
);
|
|
810
|
+
const run = mock((opts: { onEvent?: (event: HarnessEvent) => void }) => {
|
|
811
|
+
opts.onEvent?.({
|
|
812
|
+
harness: "claude",
|
|
813
|
+
seq: 0,
|
|
814
|
+
at: Date.now(),
|
|
815
|
+
type: "raw",
|
|
816
|
+
raw: {
|
|
817
|
+
model: "gpt",
|
|
818
|
+
sessionId: "abc",
|
|
819
|
+
Authorization: `Bearer ${token}`,
|
|
820
|
+
command: `run --token ${token} --verbose`,
|
|
821
|
+
nested: {
|
|
822
|
+
headers: { authorization: `Bearer ${token}` },
|
|
823
|
+
keep: "preserved",
|
|
824
|
+
},
|
|
825
|
+
},
|
|
826
|
+
});
|
|
827
|
+
return makeFakeHarnessRun(makeSuccessResult());
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
await executeAgent(
|
|
831
|
+
{ io, entry: { name: "a", harness: "claude", prompt: "p" } },
|
|
832
|
+
{ run, startMcpIoServer },
|
|
833
|
+
);
|
|
834
|
+
|
|
835
|
+
expect(io.logWrites).toHaveLength(1);
|
|
836
|
+
const content = io.logWrites[0]!.content;
|
|
837
|
+
expect(content).not.toContain(token);
|
|
838
|
+
expect(content).not.toContain(`Bearer ${token}`);
|
|
839
|
+
expect(content).toContain("[REDACTED]");
|
|
840
|
+
expect(content).toContain('"model":"gpt"');
|
|
841
|
+
expect(content).toContain('"sessionId":"abc"');
|
|
842
|
+
expect(content).toContain('"keep":"preserved"');
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
it("with no MCP handle, onEvent still runs key-based redaction and preserves non-secret content", async () => {
|
|
846
|
+
const io = createContentCapturingIO();
|
|
847
|
+
const startMcpIoServer = mock(async () => {
|
|
848
|
+
throw new Error("startMcpIoServer must not be called when entry.io === false");
|
|
849
|
+
});
|
|
850
|
+
const run = mock((opts: { onEvent?: (event: HarnessEvent) => void }) => {
|
|
851
|
+
opts.onEvent?.({
|
|
852
|
+
harness: "claude",
|
|
853
|
+
seq: 0,
|
|
854
|
+
at: Date.now(),
|
|
855
|
+
type: "raw",
|
|
856
|
+
raw: {
|
|
857
|
+
model: "gpt",
|
|
858
|
+
Authorization: "Bearer xyz",
|
|
859
|
+
sessionId: "abc",
|
|
860
|
+
message: "got nothing sensitive from server",
|
|
861
|
+
},
|
|
862
|
+
});
|
|
863
|
+
return makeFakeHarnessRun(makeSuccessResult());
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
await executeAgent(
|
|
867
|
+
{ io, entry: { name: "a", harness: "claude", prompt: "p", io: false } },
|
|
868
|
+
{ run, startMcpIoServer },
|
|
869
|
+
);
|
|
870
|
+
|
|
871
|
+
expect(startMcpIoServer).not.toHaveBeenCalled();
|
|
872
|
+
expect(io.logWrites).toHaveLength(1);
|
|
873
|
+
const content = io.logWrites[0]!.content;
|
|
874
|
+
expect(content).not.toContain("[Circular]");
|
|
875
|
+
expect(content).toContain('"Authorization":"[REDACTED]"');
|
|
876
|
+
expect(content).toContain('"model":"gpt"');
|
|
877
|
+
expect(content).toContain('"sessionId":"abc"');
|
|
878
|
+
expect(content).toContain('"message":"got nothing sensitive from server"');
|
|
879
|
+
});
|
|
880
|
+
});
|