204 lines
9.6 KiB
TypeScript
204 lines
9.6 KiB
TypeScript
import { readFile } from "node:fs/promises";
|
|
import { startManagerServer } from "../../src/mgr/server.js";
|
|
import { MemoryAgentRunStore } from "../../src/mgr/store.js";
|
|
import { ManagerClient } from "../../src/mgr/client.js";
|
|
import { runOnce } from "../../src/runner/run-once.js";
|
|
import { renderRunnerJobDryRun } from "../../src/runner/k8s-job.js";
|
|
import { renderCodexProviderSecretPlan } from "./secret-render.js";
|
|
import type { JsonRecord, JsonValue, RunRecord } from "../../src/common/types.js";
|
|
import { AgentRunError, errorToJson } from "../../src/common/errors.js";
|
|
import type { RunnerOnceOptions } from "../../src/runner/run-once.js";
|
|
|
|
interface ParsedArgs {
|
|
positional: string[];
|
|
flags: Map<string, string | boolean>;
|
|
}
|
|
|
|
export async function runCli(argv: string[]): Promise<void> {
|
|
try {
|
|
const result = await dispatch(parseArgs(argv));
|
|
print({ ok: true, data: result });
|
|
} catch (error) {
|
|
const status = error instanceof AgentRunError ? error.httpStatus : 1;
|
|
print({ ok: false, ...(error instanceof AgentRunError ? { failureKind: error.failureKind, message: error.message } : { failureKind: "infra-failed", message: error instanceof Error ? error.message : String(error) }), error: errorToJson(error) });
|
|
process.exitCode = status === 0 ? 1 : status > 255 ? 1 : status;
|
|
}
|
|
}
|
|
|
|
async function dispatch(args: ParsedArgs): Promise<JsonValue> {
|
|
const [group, command, id] = args.positional;
|
|
if (!group || group === "help") return help();
|
|
if (group === "server" && command === "start") return startServer(args);
|
|
if (group === "server" && command === "status") return client(args).get("/health/readiness");
|
|
if (group === "backends" && command === "list") return client(args).get("/api/v1/backends");
|
|
if (group === "secrets" && command === "codex" && id === "render") return renderCodexSecret(args);
|
|
if (group === "runs" && command === "create") return client(args).post("/api/v1/runs", await jsonFile(args));
|
|
if (group === "runs" && command === "show" && id) return client(args).get(`/api/v1/runs/${encodeURIComponent(id)}`);
|
|
if (group === "runs" && command === "events" && id) return client(args).get(`/api/v1/runs/${encodeURIComponent(id)}/events?afterSeq=${flag(args, "after-seq", "0")}&limit=${flag(args, "limit", "100")}`);
|
|
if (group === "commands" && command === "create" && id) {
|
|
const body = await jsonFile(args);
|
|
if (!body.type) body.type = flag(args, "type", "turn");
|
|
const idempotencyKey = optionalFlag(args, "idempotency-key");
|
|
if (idempotencyKey) body.idempotencyKey = idempotencyKey;
|
|
return client(args).post(`/api/v1/runs/${encodeURIComponent(id)}/commands`, body);
|
|
}
|
|
if (group === "commands" && command === "show" && id) {
|
|
const runId = flag(args, "run-id", "");
|
|
if (!runId) throw new AgentRunError("schema-invalid", "commands show requires --run-id", { httpStatus: 2 });
|
|
return client(args).get(`/api/v1/runs/${encodeURIComponent(runId)}/commands/${encodeURIComponent(id)}`);
|
|
}
|
|
if (group === "runner" && command === "start") {
|
|
const runId = flag(args, "run-id", "");
|
|
if (!runId) throw new AgentRunError("schema-invalid", "runner start requires --run-id", { httpStatus: 2 });
|
|
const options: RunnerOnceOptions = {
|
|
managerUrl: managerUrl(args),
|
|
runId,
|
|
};
|
|
const runnerId = optionalFlag(args, "runner-id");
|
|
const codexCommand = optionalFlag(args, "codex-command");
|
|
const codexHome = optionalFlag(args, "codex-home") ?? process.env.CODEX_HOME;
|
|
if (runnerId) options.runnerId = runnerId;
|
|
if (codexCommand) options.codexCommand = codexCommand;
|
|
if (codexHome) options.codexHome = codexHome;
|
|
return runOnce(options) as unknown as JsonValue;
|
|
}
|
|
if (group === "runner" && command === "job") return renderRunnerJob(args);
|
|
throw new AgentRunError("schema-invalid", `unsupported command: ${args.positional.join(" ")}`, { httpStatus: 2 });
|
|
}
|
|
|
|
async function renderRunnerJob(args: ParsedArgs): Promise<JsonRecord> {
|
|
const runId = flag(args, "run-id", "");
|
|
const commandId = flag(args, "command-id", "");
|
|
if (!runId) throw new AgentRunError("schema-invalid", "runner job requires --run-id", { httpStatus: 2 });
|
|
if (!commandId) throw new AgentRunError("schema-invalid", "runner job requires --command-id", { httpStatus: 2 });
|
|
const image = optionalFlag(args, "image");
|
|
if (args.flags.get("dry-run") !== true) {
|
|
const body: JsonRecord = { commandId };
|
|
if (image) body.image = image;
|
|
const namespace = optionalFlag(args, "namespace");
|
|
const attemptId = optionalFlag(args, "attempt-id");
|
|
const runnerId = optionalFlag(args, "runner-id");
|
|
const sourceCommit = optionalFlag(args, "source-commit");
|
|
const runnerManagerUrl = optionalFlag(args, "runner-manager-url");
|
|
if (namespace) body.namespace = namespace;
|
|
if (attemptId) body.attemptId = attemptId;
|
|
if (runnerId) body.runnerId = runnerId;
|
|
if (sourceCommit) body.sourceCommit = sourceCommit;
|
|
if (runnerManagerUrl) body.managerUrl = runnerManagerUrl;
|
|
return await client(args).post(`/api/v1/runs/${encodeURIComponent(runId)}/runner-jobs`, body) as JsonRecord;
|
|
}
|
|
if (!image) throw new AgentRunError("schema-invalid", "runner job --dry-run requires --image", { httpStatus: 2 });
|
|
const run = await client(args).get(`/api/v1/runs/${encodeURIComponent(runId)}`) as RunRecord;
|
|
const options = {
|
|
run,
|
|
commandId,
|
|
image,
|
|
managerUrl: managerUrl(args),
|
|
namespace: optionalFlag(args, "namespace") ?? "agentrun-v01",
|
|
};
|
|
const attemptId = optionalFlag(args, "attempt-id");
|
|
const runnerId = optionalFlag(args, "runner-id");
|
|
const sourceCommit = optionalFlag(args, "source-commit");
|
|
return renderRunnerJobDryRun({
|
|
...options,
|
|
...(attemptId ? { attemptId } : {}),
|
|
...(runnerId ? { runnerId } : {}),
|
|
...(sourceCommit ? { sourceCommit } : {}),
|
|
});
|
|
}
|
|
|
|
async function renderCodexSecret(args: ParsedArgs): Promise<JsonRecord> {
|
|
if (args.flags.get("dry-run") !== true) {
|
|
throw new AgentRunError("schema-invalid", "secrets codex render requires --dry-run", { httpStatus: 2 });
|
|
}
|
|
const options: Parameters<typeof renderCodexProviderSecretPlan>[0] = { dryRun: true };
|
|
const codexHome = optionalFlag(args, "codex-home");
|
|
const authFile = optionalFlag(args, "auth-file");
|
|
const configFile = optionalFlag(args, "config-file");
|
|
const namespace = optionalFlag(args, "namespace");
|
|
const secretName = optionalFlag(args, "secret-name");
|
|
if (codexHome) options.codexHome = codexHome;
|
|
if (authFile) options.authFile = authFile;
|
|
if (configFile) options.configFile = configFile;
|
|
if (namespace) options.namespace = namespace;
|
|
if (secretName) options.secretName = secretName;
|
|
return renderCodexProviderSecretPlan(options);
|
|
}
|
|
|
|
async function startServer(args: ParsedArgs): Promise<JsonRecord> {
|
|
const port = Number(flag(args, "port", "8080"));
|
|
const host = flag(args, "host", "0.0.0.0");
|
|
const storeMode = optionalFlag(args, "store") ?? process.env.AGENTRUN_STORE ?? process.env.AGENTRUN_MGR_STORE;
|
|
const started = await startManagerServer({ port, host, ...(storeMode === "memory" ? { store: new MemoryAgentRunStore() } : {}) });
|
|
const database = await started.store.health();
|
|
return { serviceId: "agentrun-mgr", baseUrl: started.baseUrl, pid: process.pid, database, note: "foreground process; use Kubernetes/Tekton for v0.1 runtime" };
|
|
}
|
|
|
|
function client(args: ParsedArgs): ManagerClient {
|
|
return new ManagerClient(managerUrl(args));
|
|
}
|
|
|
|
function managerUrl(args: ParsedArgs): string {
|
|
return optionalFlag(args, "manager-url") ?? process.env.AGENTRUN_MGR_URL ?? "http://127.0.0.1:8080";
|
|
}
|
|
|
|
async function jsonFile(args: ParsedArgs): Promise<JsonRecord> {
|
|
const file = optionalFlag(args, "json-file");
|
|
if (!file) throw new AgentRunError("schema-invalid", "--json-file is required", { httpStatus: 2 });
|
|
const value = JSON.parse(await readFile(file, "utf8")) as unknown;
|
|
if (typeof value === "object" && value !== null && !Array.isArray(value)) return value as JsonRecord;
|
|
throw new AgentRunError("schema-invalid", "json file must contain an object", { httpStatus: 2 });
|
|
}
|
|
|
|
function parseArgs(argv: string[]): ParsedArgs {
|
|
const positional: string[] = [];
|
|
const flags = new Map<string, string | boolean>();
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const item = argv[index] ?? "";
|
|
if (!item.startsWith("--")) {
|
|
positional.push(item);
|
|
continue;
|
|
}
|
|
const key = item.slice(2);
|
|
const next = argv[index + 1];
|
|
if (next === undefined || next.startsWith("--")) flags.set(key, true);
|
|
else {
|
|
flags.set(key, next);
|
|
index += 1;
|
|
}
|
|
}
|
|
return { positional, flags };
|
|
}
|
|
|
|
function flag(args: ParsedArgs, name: string, fallback: string): string {
|
|
const value = args.flags.get(name);
|
|
return typeof value === "string" ? value : fallback;
|
|
}
|
|
|
|
function optionalFlag(args: ParsedArgs, name: string): string | null {
|
|
const value = args.flags.get(name);
|
|
return typeof value === "string" && value.length > 0 ? value : null;
|
|
}
|
|
|
|
function help(): JsonRecord {
|
|
return {
|
|
commands: [
|
|
"runs create --json-file <run.json>",
|
|
"runs show <runId>",
|
|
"runs events <runId> --after-seq <n> --limit <n>",
|
|
"commands create <runId> --type turn --json-file <payload.json>",
|
|
"commands show <commandId> --run-id <runId>",
|
|
"runner start --run-id <runId>",
|
|
"runner job --run-id <runId> --command-id <commandId> [--image <image>] [--runner-manager-url <url>]",
|
|
"runner job --dry-run --run-id <runId> --command-id <commandId> --image <image>",
|
|
"secrets codex render --dry-run [--codex-home <dir>] [--namespace agentrun-v01] [--secret-name agentrun-v01-provider-codex]",
|
|
"backends list",
|
|
"server start|status",
|
|
],
|
|
};
|
|
}
|
|
|
|
function print(value: JsonRecord): void {
|
|
process.stdout.write(`${JSON.stringify(value)}\n`);
|
|
}
|