@ryanfw/prompt-orchestration-pipeline 1.3.0 → 1.3.2
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/README.md +1 -0
- package/docs/pop-task-guide.md +45 -0
- package/package.json +3 -3
- package/src/core/__tests__/agent-step.test.ts +402 -35
- package/src/core/__tests__/task-runner.test.ts +104 -0
- package/src/core/agent-step.ts +295 -41
- package/src/core/agent-types.ts +61 -0
- package/src/core/file-io.ts +8 -74
- package/src/core/orchestrator.ts +2 -1
- package/src/core/pipeline-definition.ts +1 -1
- package/src/core/pipeline-runner.ts +54 -3
- package/src/core/status-writer.ts +44 -26
- package/src/core/task-runner.ts +44 -1
- package/src/core/validation.ts +1 -1
- package/src/harness/__tests__/discovery.test.ts +235 -0
- package/src/harness/discovery.ts +109 -0
- package/src/harness/index.ts +22 -0
- package/src/harness/mcp-io-server.ts +1 -1
- package/src/ui/client/hooks/useJobListWithUpdates.ts +16 -1
- package/src/ui/dist/assets/{index-D7hzshSS.js → index--RH3sAt3.js} +129 -1
- package/src/ui/dist/assets/index--RH3sAt3.js.map +1 -0
- package/src/ui/dist/assets/style-CSSKuMOe.css +2 -0
- package/src/ui/dist/index.html +2 -2
- package/src/ui/embedded-assets.js +6 -6
- package/src/ui/pages/Code.tsx +135 -0
- package/src/ui/server/__tests__/gate-endpoints.test.ts +90 -0
- package/src/ui/server/__tests__/job-control-endpoints.test.ts +188 -0
- package/src/ui/server/__tests__/path-containment.test.ts +54 -0
- package/src/ui/server/__tests__/status-corruption.test.ts +55 -0
- package/src/ui/server/config-bridge.ts +1 -0
- package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +77 -0
- package/src/ui/server/endpoints/gate-endpoints.ts +17 -1
- package/src/ui/server/endpoints/job-control-endpoints.ts +36 -2
- package/src/ui/server/endpoints/upload-endpoints.ts +13 -3
- package/src/ui/server/utils/http-utils.ts +6 -1
- package/src/ui/server/utils/path-containment.ts +18 -0
- package/src/ui/server/utils/status-corruption.ts +27 -0
- package/src/harness/__tests__/descriptors.test.ts +0 -378
- package/src/harness/__tests__/executor.test.ts +0 -193
- package/src/harness/__tests__/resolve.test.ts +0 -200
- package/src/harness/__tests__/types.test.ts +0 -297
- package/src/harness/descriptors/claude.ts +0 -132
- package/src/harness/descriptors/codex.ts +0 -126
- package/src/harness/descriptors/index.ts +0 -10
- package/src/harness/descriptors/opencode.ts +0 -147
- package/src/harness/executor.ts +0 -128
- package/src/harness/resolve.ts +0 -176
- package/src/harness/types.ts +0 -100
- package/src/ui/dist/assets/index-D7hzshSS.js.map +0 -1
- package/src/ui/dist/assets/style-BUFg3Sth.css +0 -2
|
@@ -211,3 +211,107 @@ describe("task-runner writes correct task-level state transitions", () => {
|
|
|
211
211
|
expect(task!.error).toBe("stage exploded");
|
|
212
212
|
});
|
|
213
213
|
});
|
|
214
|
+
|
|
215
|
+
describe("runPipeline runAgent injection", () => {
|
|
216
|
+
it("passes runAgent to stages and delegates to the supplied override", async () => {
|
|
217
|
+
const root = await makeTempRoot();
|
|
218
|
+
const workDir = path.join(root, "job-agent");
|
|
219
|
+
await mkdir(workDir, { recursive: true });
|
|
220
|
+
await writeFile(
|
|
221
|
+
path.join(workDir, "tasks-status.json"),
|
|
222
|
+
JSON.stringify({ id: "job-agent", tasks: {} }),
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
const modulePath = path.join(root, "agent-task.mjs");
|
|
226
|
+
await writeFile(
|
|
227
|
+
modulePath,
|
|
228
|
+
[
|
|
229
|
+
"export const ingestion = async ({ runAgent, flags }) => {",
|
|
230
|
+
" const r = await runAgent({ harness: 'claude', prompt: 'hello agent' });",
|
|
231
|
+
" return { output: r, flags };",
|
|
232
|
+
"};",
|
|
233
|
+
].join("\n"),
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const calls: unknown[] = [];
|
|
237
|
+
const result = await runPipeline(modulePath, {
|
|
238
|
+
workDir,
|
|
239
|
+
taskName: "agent-task",
|
|
240
|
+
statusPath: path.join(workDir, "tasks-status.json"),
|
|
241
|
+
jobId: "job-agent",
|
|
242
|
+
envLoaded: true,
|
|
243
|
+
seed: { data: {} },
|
|
244
|
+
pipelineTasks: ["agent-task"],
|
|
245
|
+
llm: {} as never,
|
|
246
|
+
runAgent: async (options) => {
|
|
247
|
+
calls.push(options);
|
|
248
|
+
return {
|
|
249
|
+
ok: true,
|
|
250
|
+
finalMessage: "did it",
|
|
251
|
+
artifactsWritten: ["x.md"],
|
|
252
|
+
usage: { inputTokens: 12, outputTokens: 7, totalTokens: 19 },
|
|
253
|
+
costUsd: 0.03,
|
|
254
|
+
};
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
expect(result.ok).toBe(true);
|
|
259
|
+
expect(calls).toEqual([{ harness: "claude", prompt: "hello agent" }]);
|
|
260
|
+
if (result.ok) {
|
|
261
|
+
expect(result.context.data["ingestion"]).toMatchObject({
|
|
262
|
+
ok: true,
|
|
263
|
+
finalMessage: "did it",
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const status = JSON.parse(await readFile(path.join(workDir, "tasks-status.json"), "utf8")) as StatusSnapshot;
|
|
268
|
+
expect(status.tasks["agent-task"]?.tokenUsage).toEqual([
|
|
269
|
+
["claude:default", 12, 7, 0.03],
|
|
270
|
+
]);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("records runAgent cost when normalized usage is absent", async () => {
|
|
274
|
+
const root = await makeTempRoot();
|
|
275
|
+
const workDir = path.join(root, "job-agent-cost");
|
|
276
|
+
await mkdir(workDir, { recursive: true });
|
|
277
|
+
await writeFile(
|
|
278
|
+
path.join(workDir, "tasks-status.json"),
|
|
279
|
+
JSON.stringify({ id: "job-agent-cost", tasks: {} }),
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
const modulePath = path.join(root, "agent-cost-task.mjs");
|
|
283
|
+
await writeFile(
|
|
284
|
+
modulePath,
|
|
285
|
+
[
|
|
286
|
+
"export const ingestion = async ({ runAgent, flags }) => {",
|
|
287
|
+
" const r = await runAgent({ harness: 'opencode', model: 'gpt-4o', prompt: 'hello agent' });",
|
|
288
|
+
" return { output: r, flags };",
|
|
289
|
+
"};",
|
|
290
|
+
].join("\n"),
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
const result = await runPipeline(modulePath, {
|
|
294
|
+
workDir,
|
|
295
|
+
taskName: "agent-cost-task",
|
|
296
|
+
statusPath: path.join(workDir, "tasks-status.json"),
|
|
297
|
+
jobId: "job-agent-cost",
|
|
298
|
+
envLoaded: true,
|
|
299
|
+
seed: { data: {} },
|
|
300
|
+
pipelineTasks: ["agent-cost-task"],
|
|
301
|
+
llm: {} as never,
|
|
302
|
+
runAgent: async () => ({
|
|
303
|
+
ok: true,
|
|
304
|
+
finalMessage: "did it",
|
|
305
|
+
artifactsWritten: [],
|
|
306
|
+
costUsd: 0.12,
|
|
307
|
+
}),
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
expect(result.ok).toBe(true);
|
|
311
|
+
|
|
312
|
+
const status = JSON.parse(await readFile(path.join(workDir, "tasks-status.json"), "utf8")) as StatusSnapshot;
|
|
313
|
+
expect(status.tasks["agent-cost-task"]?.tokenUsage).toEqual([
|
|
314
|
+
["opencode:gpt-4o", 0, 0, 0.12],
|
|
315
|
+
]);
|
|
316
|
+
});
|
|
317
|
+
});
|
package/src/core/agent-step.ts
CHANGED
|
@@ -1,18 +1,33 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
3
|
-
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import { chmodSync, existsSync } from "node:fs";
|
|
3
|
+
import { cp, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { homedir, tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
4
6
|
import { createTaskFileIO, generateLogName } from "./file-io.ts";
|
|
5
7
|
import { LogEvent, LogFileExtension } from "../config/log-events.ts";
|
|
6
|
-
import {
|
|
8
|
+
import { run } from "../harness/index.ts";
|
|
7
9
|
import { startMcpIoServer } from "../harness/mcp-io-server.ts";
|
|
8
10
|
import type { McpIoServerHandle } from "../harness/mcp-io-server.ts";
|
|
11
|
+
import type { AgentEntryConfig, AgentStepResult } from "./agent-types.ts";
|
|
9
12
|
import type {
|
|
10
|
-
|
|
11
|
-
|
|
13
|
+
RunOptions,
|
|
14
|
+
HarnessRun,
|
|
12
15
|
HarnessEvent,
|
|
13
|
-
|
|
16
|
+
HarnessName,
|
|
17
|
+
McpServerConfig,
|
|
18
|
+
RunResult,
|
|
19
|
+
} from "../harness/index.ts";
|
|
14
20
|
import type { TaskFileIO } from "./file-io.ts";
|
|
15
21
|
|
|
22
|
+
type EnvSnapshot = Record<string, string | undefined>;
|
|
23
|
+
let compatibilityEnvQueue: Promise<void> = Promise.resolve();
|
|
24
|
+
|
|
25
|
+
interface HarnessCompatibility {
|
|
26
|
+
mcpServers?: McpServerConfig[];
|
|
27
|
+
env?: Record<string, string>;
|
|
28
|
+
cleanup?: () => Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
16
31
|
function gitSync(args: string[], cwd: string): { exitCode: number; stdout: string; stderr: string } {
|
|
17
32
|
const result = Bun.spawnSync(["git", ...args], {
|
|
18
33
|
cwd,
|
|
@@ -65,78 +80,284 @@ async function captureDiff(io: TaskFileIO, cwd: string): Promise<boolean> {
|
|
|
65
80
|
}
|
|
66
81
|
}
|
|
67
82
|
|
|
68
|
-
|
|
83
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
84
|
+
return typeof value === "object" && value !== null ? value as Record<string, unknown> : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function expandHome(path: string): string {
|
|
88
|
+
return path.startsWith("~/") ? join(process.env.HOME ?? "", path.slice(2)) : path;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function findExecutable(name: string, candidateDirs: string[]): string | null {
|
|
92
|
+
const pathEntries = (process.env.PATH ?? "").split(":").filter(Boolean);
|
|
93
|
+
for (const dir of [...pathEntries, ...candidateDirs.map(expandHome)]) {
|
|
94
|
+
const candidate = join(dir, name);
|
|
95
|
+
if (existsSync(candidate)) return candidate;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function existingBinOverride(harness: HarnessName): string | undefined {
|
|
101
|
+
const upper = harness.toUpperCase();
|
|
102
|
+
return process.env[`LLM_ADAPTER_${upper}_BIN`] ?? process.env[`POP_${upper}_BIN`];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function snapshotEnv(keys: string[]): EnvSnapshot {
|
|
106
|
+
return Object.fromEntries(keys.map((key) => [key, process.env[key]]));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function applyEnv(values: Record<string, string>): EnvSnapshot {
|
|
110
|
+
const previous = snapshotEnv(Object.keys(values));
|
|
111
|
+
for (const [key, value] of Object.entries(values)) {
|
|
112
|
+
process.env[key] = value;
|
|
113
|
+
}
|
|
114
|
+
return previous;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function restoreEnv(previous: EnvSnapshot): void {
|
|
118
|
+
for (const [key, value] of Object.entries(previous)) {
|
|
119
|
+
if (value === undefined) {
|
|
120
|
+
delete process.env[key];
|
|
121
|
+
} else {
|
|
122
|
+
process.env[key] = value;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function withCompatibilityEnv<T>(
|
|
128
|
+
compatibility: HarnessCompatibility,
|
|
129
|
+
runWithEnv: () => Promise<T>,
|
|
130
|
+
): Promise<T> {
|
|
131
|
+
if (!compatibility.env) {
|
|
132
|
+
try {
|
|
133
|
+
return await runWithEnv();
|
|
134
|
+
} finally {
|
|
135
|
+
await compatibility.cleanup?.();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const previousTurn = compatibilityEnvQueue;
|
|
140
|
+
let releaseTurn!: () => void;
|
|
141
|
+
compatibilityEnvQueue = new Promise<void>((resolve) => {
|
|
142
|
+
releaseTurn = resolve;
|
|
143
|
+
});
|
|
144
|
+
await previousTurn;
|
|
145
|
+
|
|
146
|
+
const previousEnv = applyEnv(compatibility.env);
|
|
147
|
+
try {
|
|
148
|
+
return await runWithEnv();
|
|
149
|
+
} finally {
|
|
150
|
+
restoreEnv(previousEnv);
|
|
151
|
+
try {
|
|
152
|
+
await compatibility.cleanup?.();
|
|
153
|
+
} finally {
|
|
154
|
+
releaseTurn();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function defaultOpencodeConfigDir(): string {
|
|
160
|
+
return join(process.env.HOME ?? homedir(), ".config", "opencode");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function opencodeConfigDir(): string {
|
|
164
|
+
return process.env.OPENCODE_CONFIG_DIR ?? defaultOpencodeConfigDir();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function readJsonObject(path: string): Promise<Record<string, unknown>> {
|
|
168
|
+
if (!existsSync(path)) return {};
|
|
169
|
+
const parsed = JSON.parse(await readFile(path, "utf8")) as unknown;
|
|
170
|
+
return asRecord(parsed) ?? {};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function codexConfigValue(key: string, value: string): string {
|
|
174
|
+
return `${key}=${JSON.stringify(value)}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function prepareHarnessCompatibility(
|
|
178
|
+
harness: HarnessName,
|
|
179
|
+
mcpHandle: McpIoServerHandle | undefined,
|
|
180
|
+
): Promise<HarnessCompatibility> {
|
|
181
|
+
if (!mcpHandle) return {};
|
|
182
|
+
|
|
183
|
+
const mcpServer: McpServerConfig = {
|
|
184
|
+
name: "popio",
|
|
185
|
+
url: mcpHandle.connection.url,
|
|
186
|
+
headers: { Authorization: `Bearer ${mcpHandle.connection.token}` },
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
if (harness === "claude") {
|
|
190
|
+
return { mcpServers: [mcpServer] };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (harness === "opencode") {
|
|
194
|
+
const configDir = await mkdtemp(join(tmpdir(), "opencode-mcp-"));
|
|
195
|
+
const sourceConfigDir = opencodeConfigDir();
|
|
196
|
+
if (existsSync(sourceConfigDir)) {
|
|
197
|
+
await cp(sourceConfigDir, configDir, { recursive: true, force: true });
|
|
198
|
+
}
|
|
199
|
+
const configPath = join(configDir, "opencode.json");
|
|
200
|
+
const existingConfig = await readJsonObject(configPath);
|
|
201
|
+
const existingMcp = asRecord(existingConfig["mcp"]) ?? {};
|
|
202
|
+
await writeFile(
|
|
203
|
+
configPath,
|
|
204
|
+
JSON.stringify({
|
|
205
|
+
...existingConfig,
|
|
206
|
+
mcp: {
|
|
207
|
+
...existingMcp,
|
|
208
|
+
popio: {
|
|
209
|
+
type: "remote",
|
|
210
|
+
url: mcpHandle.connection.url,
|
|
211
|
+
headers: { Authorization: `Bearer ${mcpHandle.connection.token}` },
|
|
212
|
+
enabled: true,
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
}, null, 2),
|
|
216
|
+
);
|
|
217
|
+
return {
|
|
218
|
+
env: { OPENCODE_CONFIG_DIR: configDir },
|
|
219
|
+
cleanup: async () => {
|
|
220
|
+
await rm(configDir, { recursive: true, force: true });
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (harness === "codex") {
|
|
226
|
+
const wrapperDir = await mkdtemp(join(tmpdir(), "codex-mcp-"));
|
|
227
|
+
const wrapperPath = join(wrapperDir, "codex");
|
|
228
|
+
const realBin =
|
|
229
|
+
existingBinOverride("codex") ??
|
|
230
|
+
findExecutable("codex", ["/opt/homebrew/bin", "~/.local/bin"]) ??
|
|
231
|
+
"codex";
|
|
232
|
+
await writeFile(
|
|
233
|
+
wrapperPath,
|
|
234
|
+
[
|
|
235
|
+
"#!/bin/sh",
|
|
236
|
+
'exec "$POP_REAL_CODEX_BIN" "$@" \\',
|
|
237
|
+
' -c "$POP_MCP_URL_CONFIG" \\',
|
|
238
|
+
' -c \'mcp_servers.popio.bearer_token_env_var="POP_MCP_TOKEN"\'',
|
|
239
|
+
"",
|
|
240
|
+
].join("\n"),
|
|
241
|
+
);
|
|
242
|
+
chmodSync(wrapperPath, 0o700);
|
|
243
|
+
return {
|
|
244
|
+
env: {
|
|
245
|
+
LLM_ADAPTER_CODEX_BIN: wrapperPath,
|
|
246
|
+
POP_REAL_CODEX_BIN: realBin,
|
|
247
|
+
POP_MCP_URL_CONFIG: codexConfigValue("mcp_servers.popio.url", mcpHandle.connection.url),
|
|
248
|
+
POP_MCP_TOKEN: mcpHandle.connection.token,
|
|
249
|
+
},
|
|
250
|
+
cleanup: async () => {
|
|
251
|
+
await rm(wrapperDir, { recursive: true, force: true });
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return {};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function costFromRaw(raw: unknown): number | undefined {
|
|
260
|
+
const record = asRecord(raw);
|
|
261
|
+
if (!record) return undefined;
|
|
262
|
+
if (typeof record["total_cost_usd"] === "number") return record["total_cost_usd"];
|
|
263
|
+
if (typeof record["costUsd"] === "number") return record["costUsd"];
|
|
264
|
+
const info = asRecord(record["info"]);
|
|
265
|
+
if (typeof info?.["costUsd"] === "number") return info["costUsd"];
|
|
266
|
+
return undefined;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function extractCostUsd(events: HarnessEvent[]): number | undefined {
|
|
270
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
271
|
+
const cost = costFromRaw(events[i]!.raw);
|
|
272
|
+
if (cost !== undefined) return cost;
|
|
273
|
+
}
|
|
274
|
+
return undefined;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Dependency seam shared by the agent runners (injected in tests). */
|
|
278
|
+
interface AgentRunDeps {
|
|
279
|
+
run?: typeof run;
|
|
280
|
+
startMcpIoServer?: typeof startMcpIoServer;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Runs a single CLI-agent harness step against an existing task `io`.
|
|
285
|
+
*
|
|
286
|
+
* This is the shared core behind both pipeline `agent:` entries (via
|
|
287
|
+
* {@link runAgentStep}) and the `runAgent()` helper injected into standard
|
|
288
|
+
* JavaScript task stages (via the task runner). The caller owns the `io`, so
|
|
289
|
+
* the agent reads and writes the same task artifacts the surrounding task sees.
|
|
290
|
+
*/
|
|
291
|
+
export async function executeAgent(
|
|
69
292
|
args: {
|
|
293
|
+
io: TaskFileIO;
|
|
70
294
|
entry: AgentEntryConfig & { name: string };
|
|
71
|
-
workDir: string;
|
|
72
|
-
statusPath: string;
|
|
73
|
-
jobId: string | undefined;
|
|
74
|
-
getStage: () => string;
|
|
75
|
-
},
|
|
76
|
-
deps?: {
|
|
77
|
-
runHarnessTask?: typeof runHarnessTask;
|
|
78
|
-
startMcpIoServer?: typeof startMcpIoServer;
|
|
79
|
-
createTaskFileIO?: typeof createTaskFileIO;
|
|
80
295
|
},
|
|
296
|
+
deps?: AgentRunDeps,
|
|
81
297
|
): Promise<AgentStepResult> {
|
|
82
|
-
const
|
|
298
|
+
const _run = deps?.run ?? run;
|
|
83
299
|
const _startMcpIoServer = deps?.startMcpIoServer ?? startMcpIoServer;
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
workDir: args.workDir,
|
|
88
|
-
taskName: args.entry.name,
|
|
89
|
-
getStage: args.getStage,
|
|
90
|
-
statusPath: args.statusPath,
|
|
91
|
-
});
|
|
300
|
+
const { io, entry } = args;
|
|
301
|
+
const observedEvents: HarnessEvent[] = [];
|
|
302
|
+
let completedResult: RunResult | undefined;
|
|
92
303
|
|
|
93
|
-
const cwd =
|
|
304
|
+
const cwd = entry.cwd ?? io.getTaskDir();
|
|
94
305
|
// The harness spawns with this cwd before any artifact is written, so the task
|
|
95
306
|
// dir may not exist yet — posix_spawn ENOENTs on a missing working directory.
|
|
96
307
|
await mkdir(cwd, { recursive: true });
|
|
97
308
|
|
|
98
309
|
let prompt: string;
|
|
99
|
-
if (
|
|
100
|
-
prompt =
|
|
101
|
-
} else if (
|
|
102
|
-
prompt = await io.readArtifact(
|
|
310
|
+
if (entry.prompt !== undefined) {
|
|
311
|
+
prompt = entry.prompt;
|
|
312
|
+
} else if (entry.promptFrom !== undefined) {
|
|
313
|
+
prompt = await io.readArtifact(entry.promptFrom);
|
|
103
314
|
} else {
|
|
104
315
|
throw new Error(
|
|
105
|
-
`Agent entry "${
|
|
316
|
+
`Agent entry "${entry.name}" must specify either "prompt" or "promptFrom"`,
|
|
106
317
|
);
|
|
107
318
|
}
|
|
108
319
|
|
|
109
320
|
let mcpHandle: McpIoServerHandle | undefined;
|
|
110
|
-
if (
|
|
321
|
+
if (entry.io !== false) {
|
|
111
322
|
mcpHandle = await _startMcpIoServer(io);
|
|
112
323
|
}
|
|
113
324
|
|
|
114
325
|
try {
|
|
115
326
|
const logName = generateLogName(
|
|
116
|
-
|
|
327
|
+
entry.name,
|
|
117
328
|
"agent",
|
|
118
329
|
LogEvent.DEBUG,
|
|
119
330
|
LogFileExtension.TEXT,
|
|
120
331
|
);
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
harness:
|
|
332
|
+
const compatibility = await prepareHarnessCompatibility(entry.harness, mcpHandle);
|
|
333
|
+
const options: RunOptions = {
|
|
334
|
+
harness: entry.harness,
|
|
124
335
|
prompt,
|
|
125
336
|
cwd,
|
|
126
|
-
model:
|
|
127
|
-
|
|
128
|
-
timeoutMs:
|
|
337
|
+
model: entry.model,
|
|
338
|
+
...(compatibility.mcpServers ? { mcpServers: compatibility.mcpServers } : {}),
|
|
339
|
+
timeoutMs: entry.timeoutMs,
|
|
340
|
+
...(entry.idleTimeoutMs !== undefined ? { idleTimeoutMs: entry.idleTimeoutMs } : {}),
|
|
341
|
+
permissionMode: "bypass",
|
|
129
342
|
onEvent: (event: HarnessEvent) => {
|
|
343
|
+
observedEvents.push(event);
|
|
130
344
|
void io.writeLog(logName, JSON.stringify(event.raw) + "\n", {
|
|
131
345
|
mode: "append",
|
|
132
346
|
});
|
|
133
347
|
},
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const result = await withCompatibilityEnv(compatibility, async () => {
|
|
351
|
+
const harnessRun: HarnessRun = _run(options);
|
|
352
|
+
const harnessResult = await harnessRun.result;
|
|
353
|
+
completedResult = harnessResult;
|
|
354
|
+
return harnessResult;
|
|
134
355
|
});
|
|
135
356
|
|
|
136
357
|
await io.writeArtifact("agent-result.md", result.finalMessage);
|
|
137
358
|
|
|
138
359
|
let patchWritten = false;
|
|
139
|
-
if (
|
|
360
|
+
if (entry.captureDiff) {
|
|
140
361
|
patchWritten = await captureDiff(io, cwd);
|
|
141
362
|
}
|
|
142
363
|
|
|
@@ -150,7 +371,7 @@ export async function runAgentStep(
|
|
|
150
371
|
finalMessage: result.finalMessage,
|
|
151
372
|
artifactsWritten: allArtifacts,
|
|
152
373
|
usage: result.usage,
|
|
153
|
-
costUsd: result.costUsd,
|
|
374
|
+
costUsd: result.costUsd ?? extractCostUsd(observedEvents),
|
|
154
375
|
sessionId: result.sessionId,
|
|
155
376
|
};
|
|
156
377
|
} catch (err) {
|
|
@@ -163,6 +384,9 @@ export async function runAgentStep(
|
|
|
163
384
|
ok: false,
|
|
164
385
|
finalMessage: "",
|
|
165
386
|
artifactsWritten: allArtifacts,
|
|
387
|
+
usage: completedResult?.usage,
|
|
388
|
+
costUsd: completedResult?.costUsd ?? extractCostUsd(observedEvents),
|
|
389
|
+
sessionId: completedResult?.sessionId,
|
|
166
390
|
error: err instanceof Error ? err.message : String(err),
|
|
167
391
|
};
|
|
168
392
|
} finally {
|
|
@@ -171,3 +395,33 @@ export async function runAgentStep(
|
|
|
171
395
|
}
|
|
172
396
|
}
|
|
173
397
|
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Runs a CLI-agent harness step for a pipeline `agent:` entry.
|
|
401
|
+
*
|
|
402
|
+
* Creates a task-scoped `io` from the run paths, then delegates to
|
|
403
|
+
* {@link executeAgent}. Used by the pipeline runner for `agent` entries.
|
|
404
|
+
*/
|
|
405
|
+
export async function runAgentStep(
|
|
406
|
+
args: {
|
|
407
|
+
entry: AgentEntryConfig & { name: string };
|
|
408
|
+
workDir: string;
|
|
409
|
+
statusPath: string;
|
|
410
|
+
jobId: string | undefined;
|
|
411
|
+
getStage: () => string;
|
|
412
|
+
},
|
|
413
|
+
deps?: AgentRunDeps & {
|
|
414
|
+
createTaskFileIO?: typeof createTaskFileIO;
|
|
415
|
+
},
|
|
416
|
+
): Promise<AgentStepResult> {
|
|
417
|
+
const _createTaskFileIO = deps?.createTaskFileIO ?? createTaskFileIO;
|
|
418
|
+
|
|
419
|
+
const io = _createTaskFileIO({
|
|
420
|
+
workDir: args.workDir,
|
|
421
|
+
taskName: args.entry.name,
|
|
422
|
+
getStage: args.getStage,
|
|
423
|
+
statusPath: args.statusPath,
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
return executeAgent({ io, entry: args.entry }, deps);
|
|
427
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { HarnessName, Usage } from "../harness/index.ts";
|
|
2
|
+
|
|
3
|
+
export interface McpServerConnection {
|
|
4
|
+
url: string;
|
|
5
|
+
token: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface AgentEntryConfig {
|
|
9
|
+
harness: HarnessName;
|
|
10
|
+
model?: string;
|
|
11
|
+
prompt?: string;
|
|
12
|
+
promptFrom?: string;
|
|
13
|
+
cwd?: string;
|
|
14
|
+
io?: boolean;
|
|
15
|
+
timeoutMs?: number;
|
|
16
|
+
idleTimeoutMs?: number;
|
|
17
|
+
captureDiff?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AgentStepResult {
|
|
21
|
+
ok: boolean;
|
|
22
|
+
finalMessage: string;
|
|
23
|
+
artifactsWritten: string[];
|
|
24
|
+
usage?: Usage;
|
|
25
|
+
costUsd?: number;
|
|
26
|
+
sessionId?: string;
|
|
27
|
+
error?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Options for the `runAgent()` helper injected into standard JavaScript task
|
|
32
|
+
* stages. Mirrors {@link AgentEntryConfig} but requires an inline `prompt`
|
|
33
|
+
* (a task builds the prompt programmatically rather than reading it from an
|
|
34
|
+
* artifact via `promptFrom`).
|
|
35
|
+
*/
|
|
36
|
+
export interface TaskAgentOptions {
|
|
37
|
+
/** Which CLI agent to run: `"claude" | "codex" | "opencode"`. */
|
|
38
|
+
harness: HarnessName;
|
|
39
|
+
/** The instruction handed to the agent. */
|
|
40
|
+
prompt: string;
|
|
41
|
+
/** Optional model id passed through to the harness verbatim. */
|
|
42
|
+
model?: string;
|
|
43
|
+
/** Working directory for the agent. Defaults to the task directory. */
|
|
44
|
+
cwd?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Bridge POP file I/O into the agent (read_artifact/write_artifact tools).
|
|
47
|
+
* Defaults to `true` so the agent shares the task's artifacts.
|
|
48
|
+
*/
|
|
49
|
+
io?: boolean;
|
|
50
|
+
/** Overall wall-clock cap in milliseconds. */
|
|
51
|
+
timeoutMs?: number;
|
|
52
|
+
/** No-output cap in milliseconds. Defaults to the adapter's idle timeout. */
|
|
53
|
+
idleTimeoutMs?: number;
|
|
54
|
+
/** Capture a git diff of the working tree as an `agent.patch` artifact. */
|
|
55
|
+
captureDiff?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The `runAgent()` function injected into JavaScript task stages. */
|
|
59
|
+
export type TaskAgentRunner = (
|
|
60
|
+
options: TaskAgentOptions,
|
|
61
|
+
) => Promise<AgentStepResult>;
|
package/src/core/file-io.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { mkdir, rename, appendFile } from "node:fs/promises";
|
|
2
|
-
import { existsSync, mkdirSync,
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync, appendFileSync, renameSync } from "node:fs";
|
|
3
3
|
import { dirname, join, basename } from "node:path";
|
|
4
4
|
import { Database } from "bun:sqlite";
|
|
5
5
|
import { LogFileExtension, isValidLogEvent, isValidLogFileExtension } from "../config/log-events";
|
|
6
|
-
import { writeJobStatus } from "./status-writer";
|
|
6
|
+
import { writeJobStatus, flushJobStatus } from "./status-writer";
|
|
7
|
+
import { createJobLogger } from "./logger";
|
|
7
8
|
import { executeBatch, validateBatchOptions } from "./batch-runner";
|
|
8
9
|
import type { BatchOptions, BatchResult } from "./batch-runner";
|
|
9
10
|
|
|
@@ -169,34 +170,6 @@ export async function trackFile(
|
|
|
169
170
|
});
|
|
170
171
|
}
|
|
171
172
|
|
|
172
|
-
/**
|
|
173
|
-
* @internal Synchronous status writer for writeLogSync and getDB.
|
|
174
|
-
* Reads tasks-status.json, applies updater, writes back as pretty-printed JSON.
|
|
175
|
-
* Falls back to a minimal default snapshot on missing or invalid JSON.
|
|
176
|
-
* No temp-file-rename, no async queue participation.
|
|
177
|
-
*/
|
|
178
|
-
export function writeJobStatusSync(
|
|
179
|
-
jobDir: string,
|
|
180
|
-
updater: (snapshot: Record<string, unknown>) => void,
|
|
181
|
-
): void {
|
|
182
|
-
const statusPath = join(jobDir, "tasks-status.json");
|
|
183
|
-
let snapshot: Record<string, unknown>;
|
|
184
|
-
try {
|
|
185
|
-
const raw = readFileSync(statusPath, "utf-8");
|
|
186
|
-
snapshot = JSON.parse(raw) as Record<string, unknown>;
|
|
187
|
-
} catch {
|
|
188
|
-
snapshot = {
|
|
189
|
-
id: basename(jobDir),
|
|
190
|
-
state: "pending",
|
|
191
|
-
tasks: {},
|
|
192
|
-
files: { artifacts: [], logs: [], tmp: [] },
|
|
193
|
-
};
|
|
194
|
-
}
|
|
195
|
-
updater(snapshot);
|
|
196
|
-
mkdirSync(jobDir, { recursive: true });
|
|
197
|
-
writeFileSync(statusPath, JSON.stringify(snapshot, null, 2));
|
|
198
|
-
}
|
|
199
|
-
|
|
200
173
|
export function createTaskFileIO(config: TaskFileIOConfig): TaskFileIO {
|
|
201
174
|
const { workDir, taskName, statusPath, getStage } = config;
|
|
202
175
|
const trackTaskFilesEnabled = config.trackTaskFiles ?? true;
|
|
@@ -234,28 +207,8 @@ export function createTaskFileIO(config: TaskFileIOConfig): TaskFileIO {
|
|
|
234
207
|
const db = new Database(dbPath);
|
|
235
208
|
db.exec("PRAGMA journal_mode = WAL;");
|
|
236
209
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
snapshot["files"] = files;
|
|
240
|
-
const artifactsList = (files.artifacts ?? []) as string[];
|
|
241
|
-
files.artifacts = artifactsList;
|
|
242
|
-
if (!artifactsList.includes("run.db")) {
|
|
243
|
-
artifactsList.push("run.db");
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
if (trackTaskFilesEnabled) {
|
|
247
|
-
const tasks = (snapshot["tasks"] ?? {}) as Record<string, TaskRecord>;
|
|
248
|
-
snapshot["tasks"] = tasks;
|
|
249
|
-
const task = (tasks[taskName] ?? {}) as TaskRecord;
|
|
250
|
-
tasks[taskName] = task;
|
|
251
|
-
const taskFiles = (task.files ?? {}) as FilesRecord;
|
|
252
|
-
task.files = taskFiles;
|
|
253
|
-
const taskArtifactsList = (taskFiles.artifacts ?? []) as string[];
|
|
254
|
-
taskFiles.artifacts = taskArtifactsList;
|
|
255
|
-
if (!taskArtifactsList.includes("run.db")) {
|
|
256
|
-
taskArtifactsList.push("run.db");
|
|
257
|
-
}
|
|
258
|
-
}
|
|
210
|
+
void trackFile(jobDir, "artifacts", "run.db", taskName, trackTaskFilesEnabled).catch((err) => {
|
|
211
|
+
createJobLogger("file-io", basename(jobDir)).warn("getDB: manifest tracking failed", err);
|
|
259
212
|
});
|
|
260
213
|
|
|
261
214
|
return db;
|
|
@@ -314,28 +267,8 @@ export function createTaskFileIO(config: TaskFileIOConfig): TaskFileIO {
|
|
|
314
267
|
renameSync(tmpPath, filePath);
|
|
315
268
|
}
|
|
316
269
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
snapshot["files"] = files;
|
|
320
|
-
const logsList = (files.logs ?? []) as string[];
|
|
321
|
-
files.logs = logsList;
|
|
322
|
-
if (!logsList.includes(name)) {
|
|
323
|
-
logsList.push(name);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
if (trackTaskFilesEnabled) {
|
|
327
|
-
const tasks = (snapshot["tasks"] ?? {}) as Record<string, TaskRecord>;
|
|
328
|
-
snapshot["tasks"] = tasks;
|
|
329
|
-
const task = (tasks[taskName] ?? {}) as TaskRecord;
|
|
330
|
-
tasks[taskName] = task;
|
|
331
|
-
const taskFiles = (task.files ?? {}) as FilesRecord;
|
|
332
|
-
task.files = taskFiles;
|
|
333
|
-
const taskLogsList = (taskFiles.logs ?? []) as string[];
|
|
334
|
-
taskFiles.logs = taskLogsList;
|
|
335
|
-
if (!taskLogsList.includes(name)) {
|
|
336
|
-
taskLogsList.push(name);
|
|
337
|
-
}
|
|
338
|
-
}
|
|
270
|
+
void trackFile(jobDir, "logs", name, taskName, trackTaskFilesEnabled).catch((err) => {
|
|
271
|
+
createJobLogger("file-io", basename(jobDir)).warn("writeLogSync: manifest tracking failed", err);
|
|
339
272
|
});
|
|
340
273
|
},
|
|
341
274
|
|
|
@@ -348,6 +281,7 @@ export function createTaskFileIO(config: TaskFileIOConfig): TaskFileIO {
|
|
|
348
281
|
return await executeBatch(db, options);
|
|
349
282
|
} finally {
|
|
350
283
|
db.close();
|
|
284
|
+
await flushJobStatus(jobDir);
|
|
351
285
|
}
|
|
352
286
|
},
|
|
353
287
|
};
|