@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
package/src/core/orchestrator.ts
CHANGED
|
@@ -112,7 +112,8 @@ import { buildReexecArgs } from "../cli/self-reexec";
|
|
|
112
112
|
import { writeJobStatus } from "./status-writer";
|
|
113
113
|
import { initializeStatusFromArtifacts } from "./status-initializer";
|
|
114
114
|
import { materializeNormalizedPipelineDefinition } from "./pipeline-definition";
|
|
115
|
-
import {
|
|
115
|
+
import { discoverHarnesses } from "../harness/index.ts";
|
|
116
|
+
import { applyHarnessDiscovery } from "../harness/discovery.ts";
|
|
116
117
|
import {
|
|
117
118
|
listQueuedSeeds,
|
|
118
119
|
releaseJobSlot,
|
|
@@ -5,7 +5,7 @@ import { unlinkSync } from "node:fs";
|
|
|
5
5
|
import { getConfig, getPipelineConfig } from "./config";
|
|
6
6
|
import { validatePipelineOrThrow } from "./validation";
|
|
7
7
|
import { loadFreshModule } from "./module-loader";
|
|
8
|
-
import { atomicWrite, writeJobStatus, type GateInfo } from "./status-writer";
|
|
8
|
+
import { atomicWrite, writeJobStatus, flushJobStatus, StatusCorruptError, type GateInfo } from "./status-writer";
|
|
9
9
|
import { decideTransition } from "./lifecycle-policy";
|
|
10
10
|
import { runPipeline } from "./task-runner";
|
|
11
11
|
import type { AuditLogEntry, PipelineResult } from "./task-runner";
|
|
@@ -672,8 +672,14 @@ export async function runPipelineJob(jobId: string): Promise<void> {
|
|
|
672
672
|
while (true) {
|
|
673
673
|
const pipeline = await loadPipeline(config.pipelineJsonPath);
|
|
674
674
|
const pipelineTasks = normalizePipelineTasks(pipeline);
|
|
675
|
-
|
|
676
|
-
|
|
675
|
+
let status: { tasks: Record<string, { state?: string }> };
|
|
676
|
+
try {
|
|
677
|
+
const statusText = await Bun.file(config.statusPath).text();
|
|
678
|
+
status = JSON.parse(statusText) as { tasks: Record<string, { state?: string }> };
|
|
679
|
+
} catch (parseErr: unknown) {
|
|
680
|
+
if (parseErr instanceof SyntaxError) throw new StatusCorruptError(config.statusPath, { cause: parseErr });
|
|
681
|
+
throw parseErr;
|
|
682
|
+
}
|
|
677
683
|
|
|
678
684
|
let selectedEntry: PipelineTaskEntry | null = null;
|
|
679
685
|
let selectedTaskState: TaskStateValue = TaskState.PENDING;
|
|
@@ -1092,6 +1098,49 @@ export async function runPipelineJob(jobId: string): Promise<void> {
|
|
|
1092
1098
|
}
|
|
1093
1099
|
await releaseJobSlotBestEffort(dataDir, jobId);
|
|
1094
1100
|
} catch (err) {
|
|
1101
|
+
if (err instanceof StatusCorruptError) {
|
|
1102
|
+
console.error(`Status corruption detected for job "${jobId}": ${err.statusPath} — refusing to overwrite`);
|
|
1103
|
+
if (workDir !== undefined) {
|
|
1104
|
+
try {
|
|
1105
|
+
const failureIO = createTaskFileIO({
|
|
1106
|
+
workDir,
|
|
1107
|
+
taskName: "orchestrator",
|
|
1108
|
+
trackTaskFiles: false,
|
|
1109
|
+
getStage: () => "runPipelineJob",
|
|
1110
|
+
statusPath: join(workDir, "tasks-status.json"),
|
|
1111
|
+
});
|
|
1112
|
+
const failureLogName = generateLogName(
|
|
1113
|
+
"orchestrator",
|
|
1114
|
+
"runPipelineJob",
|
|
1115
|
+
LogEvent.FAILURE_DETAILS,
|
|
1116
|
+
LogFileExtension.JSON,
|
|
1117
|
+
);
|
|
1118
|
+
await failureIO.writeLog(
|
|
1119
|
+
failureLogName,
|
|
1120
|
+
JSON.stringify({
|
|
1121
|
+
type: "status_corrupt",
|
|
1122
|
+
jobId,
|
|
1123
|
+
statusPath: err.statusPath,
|
|
1124
|
+
message: err.message,
|
|
1125
|
+
}, null, 2),
|
|
1126
|
+
);
|
|
1127
|
+
} catch {
|
|
1128
|
+
// Do not mask the original error if log-write fails
|
|
1129
|
+
}
|
|
1130
|
+
try {
|
|
1131
|
+
await cleanupPidFile(workDir);
|
|
1132
|
+
} catch {
|
|
1133
|
+
// Do not mask the original failure if PID cleanup fails
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
if (dataDir !== undefined) {
|
|
1137
|
+
await releaseJobSlotBestEffort(dataDir, jobId);
|
|
1138
|
+
}
|
|
1139
|
+
process.exitCode = 1;
|
|
1140
|
+
setTimeout(() => process.exit(1), 5000).unref();
|
|
1141
|
+
process.exit(1);
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1095
1144
|
const normalized = normalizeError(err);
|
|
1096
1145
|
console.error(normalized.message);
|
|
1097
1146
|
if (workDir !== undefined) {
|
|
@@ -1171,6 +1220,8 @@ export async function completeJob(
|
|
|
1171
1220
|
// Create complete/ directory if needed
|
|
1172
1221
|
await mkdir(config.completeDir, { recursive: true });
|
|
1173
1222
|
|
|
1223
|
+
await flushJobStatus(config.workDir);
|
|
1224
|
+
|
|
1174
1225
|
// Move job directory from current/ to complete/
|
|
1175
1226
|
await rename(config.workDir, destDir);
|
|
1176
1227
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { rename, unlink, mkdir } from "node:fs/promises";
|
|
2
|
-
import { basename, join } from "node:path";
|
|
1
|
+
import { rename, unlink, mkdir, open } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
3
|
import { createJobLogger } from "./logger";
|
|
4
4
|
import type { NormalizedError } from "./pipeline-runner";
|
|
5
5
|
|
|
@@ -71,6 +71,19 @@ export interface UploadArtifact {
|
|
|
71
71
|
content: string;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
export class StatusCorruptError extends Error {
|
|
75
|
+
readonly statusPath: string;
|
|
76
|
+
constructor(statusPath: string, options?: { cause?: unknown }) {
|
|
77
|
+
super(
|
|
78
|
+
`tasks-status.json is corrupt (unparseable) at ${statusPath}; refusing to overwrite — ` +
|
|
79
|
+
`repair the file or delete it to let a fresh status be created`,
|
|
80
|
+
options,
|
|
81
|
+
);
|
|
82
|
+
this.name = "StatusCorruptError";
|
|
83
|
+
this.statusPath = statusPath;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
74
87
|
export const STATUS_FILENAME = "tasks-status.json";
|
|
75
88
|
|
|
76
89
|
function clearResetTaskMetadata(task: TaskEntry): void {
|
|
@@ -150,13 +163,40 @@ export async function atomicWrite(filePath: string, content: string): Promise<vo
|
|
|
150
163
|
const tmp = `${filePath}.tmp.${Date.now()}.${Math.random().toString(36).slice(2)}`;
|
|
151
164
|
try {
|
|
152
165
|
await Bun.write(tmp, content);
|
|
166
|
+
const fh = await open(tmp, "r+");
|
|
167
|
+
try { await fh.sync(); } finally { await fh.close(); }
|
|
153
168
|
await rename(tmp, filePath);
|
|
169
|
+
const dirFh = await open(dirname(filePath), "r");
|
|
170
|
+
try { await dirFh.sync(); } finally { await dirFh.close(); }
|
|
154
171
|
} catch (err) {
|
|
155
172
|
await unlink(tmp).catch(() => {});
|
|
156
173
|
throw err;
|
|
157
174
|
}
|
|
158
175
|
}
|
|
159
176
|
|
|
177
|
+
async function readSnapshotForUpdate(statusPath: string, jobDir: string): Promise<unknown> {
|
|
178
|
+
try {
|
|
179
|
+
const text = await Bun.file(statusPath).text();
|
|
180
|
+
return JSON.parse(text);
|
|
181
|
+
} catch (err: unknown) {
|
|
182
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
183
|
+
if (code === "ENOENT") return createDefaultStatus(jobDir);
|
|
184
|
+
if (err instanceof SyntaxError) throw new StatusCorruptError(statusPath, { cause: err });
|
|
185
|
+
throw err;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function flushJobStatus(jobDir: string): Promise<void> {
|
|
190
|
+
const pending = writeQueues.get(jobDir);
|
|
191
|
+
if (pending) {
|
|
192
|
+
try {
|
|
193
|
+
await pending;
|
|
194
|
+
} catch {
|
|
195
|
+
// drain only
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
160
200
|
const writeQueues = new Map<string, Promise<StatusSnapshot>>();
|
|
161
201
|
|
|
162
202
|
export async function readJobStatus(jobDir: string): Promise<StatusSnapshot | null> {
|
|
@@ -195,18 +235,7 @@ export function updateTaskStatus(jobDir: string, taskId: string, taskUpdateFn: T
|
|
|
195
235
|
return next;
|
|
196
236
|
|
|
197
237
|
async function runTaskWrite(): Promise<StatusSnapshot> {
|
|
198
|
-
|
|
199
|
-
try {
|
|
200
|
-
const text = await Bun.file(statusPath).text();
|
|
201
|
-
raw = JSON.parse(text);
|
|
202
|
-
} catch (err: unknown) {
|
|
203
|
-
const code = (err as NodeJS.ErrnoException).code;
|
|
204
|
-
if (code === "ENOENT" || err instanceof SyntaxError) {
|
|
205
|
-
raw = createDefaultStatus(jobDir);
|
|
206
|
-
} else {
|
|
207
|
-
throw err;
|
|
208
|
-
}
|
|
209
|
-
}
|
|
238
|
+
const raw = await readSnapshotForUpdate(statusPath, jobDir);
|
|
210
239
|
|
|
211
240
|
const snapshot = validateStatusSnapshot(raw, jobDir);
|
|
212
241
|
|
|
@@ -361,18 +390,7 @@ export function writeJobStatus(jobDir: string, updateFn: StatusUpdateFn): Promis
|
|
|
361
390
|
return next;
|
|
362
391
|
|
|
363
392
|
async function runWrite(): Promise<StatusSnapshot> {
|
|
364
|
-
|
|
365
|
-
try {
|
|
366
|
-
const text = await Bun.file(statusPath).text();
|
|
367
|
-
raw = JSON.parse(text);
|
|
368
|
-
} catch (err: unknown) {
|
|
369
|
-
const code = (err as NodeJS.ErrnoException).code;
|
|
370
|
-
if (code === "ENOENT" || err instanceof SyntaxError) {
|
|
371
|
-
raw = createDefaultStatus(jobDir);
|
|
372
|
-
} else {
|
|
373
|
-
throw err;
|
|
374
|
-
}
|
|
375
|
-
}
|
|
393
|
+
const raw = await readSnapshotForUpdate(statusPath, jobDir);
|
|
376
394
|
|
|
377
395
|
let snapshot = validateStatusSnapshot(raw, jobDir);
|
|
378
396
|
|
package/src/core/task-runner.ts
CHANGED
|
@@ -14,14 +14,19 @@ import { loadFreshModule } from "./module-loader";
|
|
|
14
14
|
import { loadEnvironment } from "./environment";
|
|
15
15
|
import { createJobLogger } from "./logger";
|
|
16
16
|
import { createHighLevelLLM, createLLMWithOverride, getLLMEvents } from "../llm/index";
|
|
17
|
+
import { executeAgent } from "./agent-step";
|
|
17
18
|
import { TaskState } from "../config/statuses";
|
|
18
19
|
import { LogEvent, LogFileExtension } from "../config/log-events";
|
|
19
20
|
// LLMClient: src/llm/index.ts exports HighLevelLLM (no LLMClient type exists there).
|
|
20
21
|
import type { HighLevelLLM } from "../providers/types";
|
|
22
|
+
import type { AgentStepResult, TaskAgentOptions, TaskAgentRunner } from "./agent-types";
|
|
21
23
|
|
|
22
24
|
/** Opaque alias — the LLM client passed into execution contexts. */
|
|
23
25
|
export type LLMClient = HighLevelLLM;
|
|
24
26
|
|
|
27
|
+
/** The CLI-agent harness runner injected into JavaScript task stages. */
|
|
28
|
+
export type { TaskAgentOptions, TaskAgentRunner } from "./agent-types";
|
|
29
|
+
|
|
25
30
|
/** Validation result returned by schema validators. */
|
|
26
31
|
export type { SchemaValidationResult } from "../api/validators/json";
|
|
27
32
|
|
|
@@ -55,6 +60,7 @@ export interface StageResult {
|
|
|
55
60
|
export interface ExecutionContext {
|
|
56
61
|
io: TaskFileIO;
|
|
57
62
|
llm: LLMClient;
|
|
63
|
+
runAgent: TaskAgentRunner;
|
|
58
64
|
meta: ExecutionMeta;
|
|
59
65
|
data: Record<string, unknown>;
|
|
60
66
|
flags: Record<string, unknown>;
|
|
@@ -85,6 +91,7 @@ export interface ModelConfig {
|
|
|
85
91
|
export interface StageContext {
|
|
86
92
|
io: TaskFileIO;
|
|
87
93
|
llm: LLMClient;
|
|
94
|
+
runAgent: TaskAgentRunner;
|
|
88
95
|
meta: ExecutionMeta;
|
|
89
96
|
data: Record<string, unknown>;
|
|
90
97
|
flags: Record<string, unknown>;
|
|
@@ -171,6 +178,8 @@ export interface InitialContext {
|
|
|
171
178
|
envLoaded?: boolean;
|
|
172
179
|
llm?: LLMClient;
|
|
173
180
|
llmOverride?: unknown;
|
|
181
|
+
/** Override the injected `runAgent` helper (used by tests/custom hosts). */
|
|
182
|
+
runAgent?: TaskAgentRunner;
|
|
174
183
|
seed?: unknown;
|
|
175
184
|
modelConfig?: ModelConfig;
|
|
176
185
|
pipelineTasks?: string[];
|
|
@@ -245,6 +254,21 @@ export function deriveModelKeyAndTokens(metric: Record<string, unknown>): TokenU
|
|
|
245
254
|
return [modelKey, inputTokens, outputTokens, cost];
|
|
246
255
|
}
|
|
247
256
|
|
|
257
|
+
function deriveAgentUsageTuple(options: TaskAgentOptions, result: AgentStepResult): TokenUsageTuple | null {
|
|
258
|
+
const costUsd =
|
|
259
|
+
typeof result.costUsd === "number" && Number.isFinite(result.costUsd)
|
|
260
|
+
? result.costUsd
|
|
261
|
+
: undefined;
|
|
262
|
+
if (!result.usage && costUsd === undefined) return null;
|
|
263
|
+
|
|
264
|
+
return [
|
|
265
|
+
`${options.harness}:${options.model ?? "default"}`,
|
|
266
|
+
result.usage?.inputTokens ?? 0,
|
|
267
|
+
result.usage?.outputTokens ?? 0,
|
|
268
|
+
costUsd ?? 0,
|
|
269
|
+
];
|
|
270
|
+
}
|
|
271
|
+
|
|
248
272
|
// ─── Safe clone ─────────────────────────────────────────────────────────────
|
|
249
273
|
|
|
250
274
|
function safeClone<T>(value: T): T {
|
|
@@ -516,8 +540,11 @@ export async function runPipeline(
|
|
|
516
540
|
};
|
|
517
541
|
llmMetrics.push(record);
|
|
518
542
|
|
|
519
|
-
// Append token usage to status file, serialized via promise queue
|
|
520
543
|
const tuple = deriveModelKeyAndTokens(metric);
|
|
544
|
+
enqueueTokenUsage(tuple);
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
const enqueueTokenUsage = (tuple: TokenUsageTuple) => {
|
|
521
548
|
tokenWriteQueue = tokenWriteQueue.then(async () => {
|
|
522
549
|
await writeJobStatus(jobDir, (snapshot: StatusSnapshot) => {
|
|
523
550
|
const tasks = snapshot.tasks;
|
|
@@ -557,6 +584,20 @@ export async function runPipeline(
|
|
|
557
584
|
statusPath,
|
|
558
585
|
});
|
|
559
586
|
|
|
587
|
+
// 9b. Build the CLI-agent harness runner injected into stages. It reuses the
|
|
588
|
+
// task's own `io`, so an agent reads and writes the same artifacts the task
|
|
589
|
+
// sees. A caller may override it (tests/custom hosts).
|
|
590
|
+
const runAgentCore: TaskAgentRunner =
|
|
591
|
+
initialContext.runAgent ??
|
|
592
|
+
((options: TaskAgentOptions) =>
|
|
593
|
+
executeAgent({ io, entry: { ...options, name: taskName } }));
|
|
594
|
+
const runAgent: TaskAgentRunner = async (options: TaskAgentOptions) => {
|
|
595
|
+
const result = await runAgentCore(options);
|
|
596
|
+
const tuple = deriveAgentUsageTuple(options, result);
|
|
597
|
+
if (tuple) enqueueTokenUsage(tuple);
|
|
598
|
+
return result;
|
|
599
|
+
};
|
|
600
|
+
|
|
560
601
|
// 10. Build execution context
|
|
561
602
|
const pipelineTasks = initialContext.pipelineTasks ?? initialContext.meta?.pipelineTasks;
|
|
562
603
|
|
|
@@ -568,6 +609,7 @@ export async function runPipeline(
|
|
|
568
609
|
const context: ExecutionContext = {
|
|
569
610
|
io,
|
|
570
611
|
llm,
|
|
612
|
+
runAgent,
|
|
571
613
|
meta: {
|
|
572
614
|
taskName,
|
|
573
615
|
workDir,
|
|
@@ -644,6 +686,7 @@ export async function runPipeline(
|
|
|
644
686
|
const stageContext: StageContext = {
|
|
645
687
|
io,
|
|
646
688
|
llm,
|
|
689
|
+
runAgent,
|
|
647
690
|
meta: context.meta,
|
|
648
691
|
data: safeClone(context.data),
|
|
649
692
|
flags: safeClone(context.flags),
|
package/src/core/validation.ts
CHANGED
|
@@ -2,7 +2,7 @@ import Ajv from "ajv";
|
|
|
2
2
|
import addFormats from "ajv-formats";
|
|
3
3
|
import { getConfig } from "./config";
|
|
4
4
|
import { isNonEmptyString, isPlainObject } from "./object-utils";
|
|
5
|
-
import type { HarnessName } from "../harness/
|
|
5
|
+
import type { HarnessName } from "../harness/index.ts";
|
|
6
6
|
|
|
7
7
|
export interface ValidationError {
|
|
8
8
|
message: string;
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { healedPath, popBinEnvVar, adapterBinEnvVar, applyHarnessDiscovery } from "../discovery.ts";
|
|
6
|
+
import type { HarnessName, HarnessStatus } from "../index.ts";
|
|
7
|
+
|
|
8
|
+
function makeStatus(overrides: Partial<HarnessStatus> & { name: HarnessName }): HarnessStatus {
|
|
9
|
+
return { installed: false, binPath: null, version: null, authenticated: null, ...overrides };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("healedPath", () => {
|
|
13
|
+
let existingDir: string;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
existingDir = mkdtempSync(join(tmpdir(), "discovery-test-"));
|
|
17
|
+
});
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
rmSync(existingDir, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("prepends existing standard dirs and preserves the base PATH suffix", () => {
|
|
23
|
+
const result = healedPath("/usr/bin:/bin");
|
|
24
|
+
expect(result.endsWith("/usr/bin:/bin")).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("does not duplicate dirs already on the base PATH", () => {
|
|
28
|
+
const result = healedPath(`${existingDir}:/usr/bin`, [existingDir]);
|
|
29
|
+
expect(result.split(":").filter((d) => d === existingDir)).toHaveLength(1);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("defaults to process.env.PATH when basePath is undefined", () => {
|
|
33
|
+
const original = process.env.PATH;
|
|
34
|
+
process.env.PATH = "/usr/bin";
|
|
35
|
+
try {
|
|
36
|
+
const result = healedPath();
|
|
37
|
+
expect(result).toContain("/usr/bin");
|
|
38
|
+
} finally {
|
|
39
|
+
process.env.PATH = original;
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("handles empty basePath", () => {
|
|
44
|
+
const result = healedPath("");
|
|
45
|
+
// Should still include any existing standard dirs
|
|
46
|
+
expect(typeof result).toBe("string");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("popBinEnvVar", () => {
|
|
51
|
+
it("maps harness names to POP_<HARNESS>_BIN", () => {
|
|
52
|
+
expect(popBinEnvVar("opencode")).toBe("POP_OPENCODE_BIN");
|
|
53
|
+
expect(popBinEnvVar("claude")).toBe("POP_CLAUDE_BIN");
|
|
54
|
+
expect(popBinEnvVar("codex")).toBe("POP_CODEX_BIN");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("adapterBinEnvVar", () => {
|
|
59
|
+
it("maps harness names to LLM_ADAPTER_<HARNESS>_BIN", () => {
|
|
60
|
+
expect(adapterBinEnvVar("opencode")).toBe("LLM_ADAPTER_OPENCODE_BIN");
|
|
61
|
+
expect(adapterBinEnvVar("claude")).toBe("LLM_ADAPTER_CLAUDE_BIN");
|
|
62
|
+
expect(adapterBinEnvVar("codex")).toBe("LLM_ADAPTER_CODEX_BIN");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("applyHarnessDiscovery", () => {
|
|
67
|
+
it("populates both POP_*_BIN and LLM_ADAPTER_*_BIN for discovered binaries", () => {
|
|
68
|
+
const env: Record<string, string | undefined> = { PATH: "/usr/bin" };
|
|
69
|
+
const statuses: Record<HarnessName, HarnessStatus> = {
|
|
70
|
+
opencode: makeStatus({ name: "opencode", installed: true, binPath: "/x/opencode", authenticated: true }),
|
|
71
|
+
claude: makeStatus({ name: "claude", installed: true, binPath: "/y/claude", authenticated: true }),
|
|
72
|
+
codex: makeStatus({ name: "codex", installed: false }),
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
applyHarnessDiscovery(statuses, env);
|
|
76
|
+
|
|
77
|
+
expect(env.POP_OPENCODE_BIN).toBe("/x/opencode");
|
|
78
|
+
expect(env.LLM_ADAPTER_OPENCODE_BIN).toBe("/x/opencode");
|
|
79
|
+
expect(env.POP_CLAUDE_BIN).toBe("/y/claude");
|
|
80
|
+
expect(env.LLM_ADAPTER_CLAUDE_BIN).toBe("/y/claude");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("keeps the original path suffix", () => {
|
|
84
|
+
const env: Record<string, string | undefined> = { PATH: "/usr/bin:/bin" };
|
|
85
|
+
const statuses: Record<HarnessName, HarnessStatus> = {
|
|
86
|
+
opencode: makeStatus({ name: "opencode", installed: false }),
|
|
87
|
+
claude: makeStatus({ name: "claude", installed: false }),
|
|
88
|
+
codex: makeStatus({ name: "codex", installed: false }),
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
applyHarnessDiscovery(statuses, env);
|
|
92
|
+
|
|
93
|
+
expect(env.PATH).toContain("/usr/bin:/bin");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("produces a warning for missing harnesses", () => {
|
|
97
|
+
const env: Record<string, string | undefined> = { PATH: "/usr/bin" };
|
|
98
|
+
const statuses: Record<HarnessName, HarnessStatus> = {
|
|
99
|
+
opencode: makeStatus({ name: "opencode", installed: false }),
|
|
100
|
+
claude: makeStatus({ name: "claude", installed: true, binPath: "/y/claude", authenticated: true }),
|
|
101
|
+
codex: makeStatus({ name: "codex", installed: true, binPath: "/z/codex", authenticated: true }),
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const messages = applyHarnessDiscovery(statuses, env);
|
|
105
|
+
const opencodeMsg = messages.find((m) => m.message.includes('"opencode"'));
|
|
106
|
+
|
|
107
|
+
expect(opencodeMsg).toBeDefined();
|
|
108
|
+
expect(opencodeMsg!.level).toBe("warn");
|
|
109
|
+
expect(opencodeMsg!.message).toContain("not found");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("produces a warning for unauthenticated harnesses", () => {
|
|
113
|
+
const env: Record<string, string | undefined> = { PATH: "/usr/bin" };
|
|
114
|
+
const statuses: Record<HarnessName, HarnessStatus> = {
|
|
115
|
+
opencode: makeStatus({ name: "opencode", installed: true, binPath: "/x/opencode", authenticated: false }),
|
|
116
|
+
claude: makeStatus({ name: "claude", installed: true, binPath: "/y/claude", authenticated: true }),
|
|
117
|
+
codex: makeStatus({ name: "codex", installed: true, binPath: "/z/codex", authenticated: true }),
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const messages = applyHarnessDiscovery(statuses, env);
|
|
121
|
+
const opencodeMsg = messages.find((m) => m.message.includes('"opencode"'));
|
|
122
|
+
|
|
123
|
+
expect(opencodeMsg).toBeDefined();
|
|
124
|
+
expect(opencodeMsg!.level).toBe("warn");
|
|
125
|
+
expect(opencodeMsg!.message).toContain("not authenticated");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("produces an info message for authenticated harnesses", () => {
|
|
129
|
+
const env: Record<string, string | undefined> = { PATH: "/usr/bin" };
|
|
130
|
+
const statuses: Record<HarnessName, HarnessStatus> = {
|
|
131
|
+
opencode: makeStatus({ name: "opencode", installed: true, binPath: "/x/opencode", authenticated: true }),
|
|
132
|
+
claude: makeStatus({ name: "claude", installed: true, binPath: "/y/claude", authenticated: true }),
|
|
133
|
+
codex: makeStatus({ name: "codex", installed: true, binPath: "/z/codex", authenticated: true }),
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const messages = applyHarnessDiscovery(statuses, env);
|
|
137
|
+
const opencodeMsg = messages.find((m) => m.message.includes('"opencode"'));
|
|
138
|
+
|
|
139
|
+
expect(opencodeMsg).toBeDefined();
|
|
140
|
+
expect(opencodeMsg!.level).toBe("info");
|
|
141
|
+
expect(opencodeMsg!.message).toContain("authenticated");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("produces an info message for installed harnesses with unknown auth", () => {
|
|
145
|
+
const env: Record<string, string | undefined> = { PATH: "/usr/bin" };
|
|
146
|
+
const statuses: Record<HarnessName, HarnessStatus> = {
|
|
147
|
+
opencode: makeStatus({ name: "opencode", installed: true, binPath: "/x/opencode", authenticated: null }),
|
|
148
|
+
claude: makeStatus({ name: "claude", installed: true, binPath: "/y/claude", authenticated: true }),
|
|
149
|
+
codex: makeStatus({ name: "codex", installed: true, binPath: "/z/codex", authenticated: true }),
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const messages = applyHarnessDiscovery(statuses, env);
|
|
153
|
+
const opencodeMsg = messages.find((m) => m.message.includes('"opencode"'));
|
|
154
|
+
|
|
155
|
+
expect(opencodeMsg).toBeDefined();
|
|
156
|
+
expect(opencodeMsg!.level).toBe("info");
|
|
157
|
+
expect(opencodeMsg!.message).toContain("auth status unknown");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("does not set bin env vars for missing harnesses", () => {
|
|
161
|
+
const env: Record<string, string | undefined> = { PATH: "/usr/bin" };
|
|
162
|
+
const statuses: Record<HarnessName, HarnessStatus> = {
|
|
163
|
+
opencode: makeStatus({ name: "opencode", installed: false }),
|
|
164
|
+
claude: makeStatus({ name: "claude", installed: false }),
|
|
165
|
+
codex: makeStatus({ name: "codex", installed: false }),
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
applyHarnessDiscovery(statuses, env);
|
|
169
|
+
|
|
170
|
+
expect(env.POP_OPENCODE_BIN).toBeUndefined();
|
|
171
|
+
expect(env.LLM_ADAPTER_OPENCODE_BIN).toBeUndefined();
|
|
172
|
+
expect(env.POP_CLAUDE_BIN).toBeUndefined();
|
|
173
|
+
expect(env.LLM_ADAPTER_CLAUDE_BIN).toBeUndefined();
|
|
174
|
+
expect(env.POP_CODEX_BIN).toBeUndefined();
|
|
175
|
+
expect(env.LLM_ADAPTER_CODEX_BIN).toBeUndefined();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("bridges existing POP_* binary overrides to adapter env vars when discovery misses", () => {
|
|
179
|
+
const env: Record<string, string | undefined> = {
|
|
180
|
+
PATH: "/usr/bin",
|
|
181
|
+
POP_OPENCODE_BIN: "/custom/opencode",
|
|
182
|
+
};
|
|
183
|
+
const statuses: Record<HarnessName, HarnessStatus> = {
|
|
184
|
+
opencode: makeStatus({ name: "opencode", installed: false }),
|
|
185
|
+
claude: makeStatus({ name: "claude", installed: false }),
|
|
186
|
+
codex: makeStatus({ name: "codex", installed: false }),
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const messages = applyHarnessDiscovery(statuses, env);
|
|
190
|
+
|
|
191
|
+
expect(env.POP_OPENCODE_BIN).toBe("/custom/opencode");
|
|
192
|
+
expect(env.LLM_ADAPTER_OPENCODE_BIN).toBe("/custom/opencode");
|
|
193
|
+
expect(messages.find((m) => m.message.includes('"opencode"'))).toMatchObject({
|
|
194
|
+
level: "info",
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("bridges existing adapter binary overrides back to POP env vars when discovery misses", () => {
|
|
199
|
+
const env: Record<string, string | undefined> = {
|
|
200
|
+
PATH: "/usr/bin",
|
|
201
|
+
LLM_ADAPTER_CODEX_BIN: "/custom/codex",
|
|
202
|
+
};
|
|
203
|
+
const statuses: Record<HarnessName, HarnessStatus> = {
|
|
204
|
+
opencode: makeStatus({ name: "opencode", installed: false }),
|
|
205
|
+
claude: makeStatus({ name: "claude", installed: false }),
|
|
206
|
+
codex: makeStatus({ name: "codex", installed: false }),
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
applyHarnessDiscovery(statuses, env);
|
|
210
|
+
|
|
211
|
+
expect(env.LLM_ADAPTER_CODEX_BIN).toBe("/custom/codex");
|
|
212
|
+
expect(env.POP_CODEX_BIN).toBe("/custom/codex");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("keeps POP_* binary overrides ahead of discovered PATH binaries", () => {
|
|
216
|
+
const env: Record<string, string | undefined> = {
|
|
217
|
+
PATH: "/usr/bin",
|
|
218
|
+
POP_CODEX_BIN: "/custom/codex",
|
|
219
|
+
};
|
|
220
|
+
const statuses: Record<HarnessName, HarnessStatus> = {
|
|
221
|
+
opencode: makeStatus({ name: "opencode", installed: false }),
|
|
222
|
+
claude: makeStatus({ name: "claude", installed: false }),
|
|
223
|
+
codex: makeStatus({ name: "codex", installed: true, binPath: "/path/codex", authenticated: true }),
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const messages = applyHarnessDiscovery(statuses, env);
|
|
227
|
+
|
|
228
|
+
expect(env.POP_CODEX_BIN).toBe("/custom/codex");
|
|
229
|
+
expect(env.LLM_ADAPTER_CODEX_BIN).toBe("/custom/codex");
|
|
230
|
+
expect(messages.find((m) => m.message.includes('"codex"'))).toMatchObject({
|
|
231
|
+
level: "info",
|
|
232
|
+
message: 'harness "codex": using configured binary override /custom/codex',
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { HarnessName, HarnessStatus } from "./index.ts";
|
|
5
|
+
|
|
6
|
+
type EnvLike = Record<string, string | undefined>;
|
|
7
|
+
|
|
8
|
+
export interface PreflightMessage {
|
|
9
|
+
level: "info" | "warn";
|
|
10
|
+
message: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Standard user/local bin dirs that interactive shells add (via .zshrc etc.) but
|
|
15
|
+
* GUI/launchd/cron launches usually miss.
|
|
16
|
+
*/
|
|
17
|
+
function standardBinDirs(): string[] {
|
|
18
|
+
const home = homedir();
|
|
19
|
+
return [
|
|
20
|
+
join(home, ".opencode/bin"),
|
|
21
|
+
join(home, ".local/bin"),
|
|
22
|
+
join(home, ".bun/bin"),
|
|
23
|
+
join(home, ".cargo/bin"),
|
|
24
|
+
join(home, ".npm-global/bin"),
|
|
25
|
+
join(home, ".deno/bin"),
|
|
26
|
+
"/opt/homebrew/bin",
|
|
27
|
+
"/usr/local/bin",
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build a PATH with known bin dirs prepended — keeping only dirs that exist
|
|
33
|
+
* and are not already present.
|
|
34
|
+
*/
|
|
35
|
+
export function healedPath(basePath?: string, candidateDirs = standardBinDirs()): string {
|
|
36
|
+
const current = (basePath ?? process.env.PATH ?? "").split(":").filter(Boolean);
|
|
37
|
+
const seen = new Set(current);
|
|
38
|
+
const prepend: string[] = [];
|
|
39
|
+
for (const dir of candidateDirs) {
|
|
40
|
+
if (dir && !seen.has(dir) && existsSync(dir)) {
|
|
41
|
+
prepend.push(dir);
|
|
42
|
+
seen.add(dir);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return [...prepend, ...current].join(":");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Env var that pins a harness CLI to an absolute path, e.g. "opencode" → POP_OPENCODE_BIN. */
|
|
49
|
+
export function popBinEnvVar(name: HarnessName): string {
|
|
50
|
+
return `POP_${name.toUpperCase()}_BIN`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Env var the adapter reads for binary override, e.g. "opencode" → LLM_ADAPTER_OPENCODE_BIN. */
|
|
54
|
+
export function adapterBinEnvVar(name: HarnessName): string {
|
|
55
|
+
return `LLM_ADAPTER_${name.toUpperCase()}_BIN`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Apply discovery results to an env object — heal PATH, cache resolved absolute
|
|
60
|
+
* paths as both POP_*_BIN and LLM_ADAPTER_*_BIN, and return human-readable
|
|
61
|
+
* status/warning messages.
|
|
62
|
+
*/
|
|
63
|
+
export function applyHarnessDiscovery(
|
|
64
|
+
statuses: Record<HarnessName, HarnessStatus>,
|
|
65
|
+
env: EnvLike,
|
|
66
|
+
): PreflightMessage[] {
|
|
67
|
+
env.PATH = healedPath(env.PATH ?? "");
|
|
68
|
+
|
|
69
|
+
const messages: PreflightMessage[] = [];
|
|
70
|
+
for (const status of Object.values(statuses) as HarnessStatus[]) {
|
|
71
|
+
const adapterEnv = adapterBinEnvVar(status.name);
|
|
72
|
+
const popEnv = popBinEnvVar(status.name);
|
|
73
|
+
const explicitBin = env[popEnv] ?? env[adapterEnv];
|
|
74
|
+
const configuredBin = explicitBin ?? status.binPath;
|
|
75
|
+
|
|
76
|
+
if (configuredBin) {
|
|
77
|
+
env[adapterEnv] = configuredBin;
|
|
78
|
+
env[popEnv] = configuredBin;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (explicitBin) {
|
|
82
|
+
messages.push({
|
|
83
|
+
level: "info",
|
|
84
|
+
message: `harness "${status.name}": using configured binary override ${configuredBin}`,
|
|
85
|
+
});
|
|
86
|
+
} else if (!status.installed && !configuredBin) {
|
|
87
|
+
messages.push({
|
|
88
|
+
level: "warn",
|
|
89
|
+
message: `harness "${status.name}": CLI not found on PATH or known install dirs; agent steps using it will fail until it is installed`,
|
|
90
|
+
});
|
|
91
|
+
} else if (status.authenticated === false) {
|
|
92
|
+
messages.push({
|
|
93
|
+
level: "warn",
|
|
94
|
+
message: `harness "${status.name}": found at ${status.binPath} but not authenticated; runs may fail until you log in`,
|
|
95
|
+
});
|
|
96
|
+
} else if (status.authenticated === null) {
|
|
97
|
+
messages.push({
|
|
98
|
+
level: "info",
|
|
99
|
+
message: `harness "${status.name}": ${status.binPath} (auth status unknown)`,
|
|
100
|
+
});
|
|
101
|
+
} else {
|
|
102
|
+
messages.push({
|
|
103
|
+
level: "info",
|
|
104
|
+
message: `harness "${status.name}": ${status.binPath} (authenticated)`,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return messages;
|
|
109
|
+
}
|