2105 lines
111 KiB
TypeScript
2105 lines
111 KiB
TypeScript
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { randomUUID } from "node:crypto";
|
|
import { execFileSync, spawn } from "node:child_process";
|
|
import { closeSync, existsSync, openSync } from "node:fs";
|
|
import path from "node:path";
|
|
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 type { RunnerSessionPvcOptions } from "../../src/runner/k8s-job.js";
|
|
import { renderCodexProviderSecretPlan } from "./secret-render.js";
|
|
import type { BackendProfile, JsonRecord, JsonValue, RenderAipodInput, RenderedAipodQueueTask, RunRecord } from "../../src/common/types.js";
|
|
import { AgentRunError, errorToJson } from "../../src/common/errors.js";
|
|
import type { RunnerOnceOptions } from "../../src/runner/run-once.js";
|
|
import { backendProfileSpec, isBackendProfile } from "../../src/common/backend-profiles.js";
|
|
import { outputBytesFromPayload, outputTruncatedFromPayload } from "../../src/common/output.js";
|
|
import { redactJson, redactText } from "../../src/common/redaction.js";
|
|
|
|
interface ParsedArgs {
|
|
positional: string[];
|
|
flags: Map<string, string | boolean>;
|
|
}
|
|
|
|
const plainTextOutputMarker = Symbol("agentrun.plainTextOutput");
|
|
|
|
interface PlainTextOutput {
|
|
[plainTextOutputMarker]: true;
|
|
text: string;
|
|
}
|
|
|
|
type CliResult = JsonValue | PlainTextOutput;
|
|
|
|
export async function runCli(argv: string[]): Promise<void> {
|
|
try {
|
|
const result = await dispatch(parseArgs(argv));
|
|
if (isPlainTextOutput(result)) {
|
|
process.stdout.write(result.text.endsWith("\n") ? result.text : `${result.text}\n`);
|
|
return;
|
|
}
|
|
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<CliResult> {
|
|
const [group, command, id] = args.positional;
|
|
if (!group || group === "help" || group === "--help") return help(args);
|
|
if (args.flags.get("help") === true) return help(args, group);
|
|
if (command === "help" || command === "--help") return help(args, group);
|
|
if (group === "manager" && (command === "url" || command === "resolve-url" || command === "status")) return managerEndpoint(args);
|
|
if (group === "server" && command === "start") return startServer(args);
|
|
if (group === "server" && command === "status") return serverStatus(args);
|
|
if (group === "server" && command === "logs") return serverLogs(args);
|
|
if (group === "server" && command === "stop") return stopServer(args);
|
|
if (group === "backends" && command === "list") return client(args).get("/api/v1/backends");
|
|
if ((group === "aipod-specs" || group === "aipods") && command === "list") return client(args).get("/api/v1/aipod-specs");
|
|
if ((group === "aipod-specs" || group === "aipods") && command === "show" && id) return client(args).get(`/api/v1/aipod-specs/${encodeURIComponent(id)}`);
|
|
if ((group === "aipod-specs" || group === "aipods") && command === "render" && id) return renderAipodSpecCli(args, id);
|
|
if ((group === "aipod-specs" || group === "aipods") && (command === "apply" || command === "set")) return applyAipodSpecCli(args, id ?? null);
|
|
if ((group === "aipod-specs" || group === "aipods") && (command === "delete" || command === "rm") && id) return client(args).delete(`/api/v1/aipod-specs/${encodeURIComponent(id)}`);
|
|
if (group === "provider-profiles" && command === "list") return client(args).get("/api/v1/provider-profiles");
|
|
if (group === "provider-profiles" && command === "show" && id) return client(args).get(`/api/v1/provider-profiles/${encodeURIComponent(normalizeProfile(id))}`);
|
|
if (group === "provider-profiles" && command === "config" && id) return client(args).get(`/api/v1/provider-profiles/${encodeURIComponent(normalizeProfile(id))}/config`);
|
|
if (group === "provider-profiles" && (command === "remove" || command === "delete" || command === "rm") && id) return removeProviderProfileCli(id, args);
|
|
if (group === "provider-profiles" && command === "set-key" && id) return setProviderProfileKey(args, id);
|
|
if (group === "provider-profiles" && command === "set-config" && id) return setProviderProfileConfig(args, id);
|
|
if (group === "provider-profiles" && command === "validate" && id) return validateProviderProfileCli(args, id);
|
|
if (group === "tool-credentials" && command === "list") return client(args).get("/api/v1/tool-credentials");
|
|
if (group === "tool-credentials" && command === "show" && id) return client(args).get(`/api/v1/tool-credentials/${encodeURIComponent(id)}`);
|
|
if (group === "tool-credentials" && command === "set-github-ssh") return setGithubSshToolCredentialCli(args);
|
|
if (group === "secrets" && command === "codex" && id === "render") return renderCodexSecret(args);
|
|
if (group === "sessions" && command === "ps") return listSessions(args);
|
|
if (group === "sessions" && command === "create") return sessionCreate(args, id ?? null);
|
|
if (group === "sessions" && command === "storage" && id) return sessionStorageGet(args, id);
|
|
if (group === "sessions" && command === "storage" && !id) throw new AgentRunError("schema-invalid", "sessions storage requires a sessionId", { httpStatus: 2 });
|
|
if (group === "sessions" && command === "show" && id) return client(args).get(`/api/v1/sessions/${encodeURIComponent(id)}${readerQuery(args)}`);
|
|
if (group === "sessions" && command === "read" && id) return sessionRead(args, id);
|
|
if (group === "sessions" && command === "trace" && id) return sessionEvents(args, id, "trace");
|
|
if (group === "sessions" && command === "output" && id) return sessionEvents(args, id, "output");
|
|
if (group === "sessions" && command === "send") return sessionSend(args, id ?? null);
|
|
if (group === "sessions" && command === "cancel" && id) return sessionCancel(args, id);
|
|
const sessionStorageCmd = group === "sessions" && (command === "storage-delete" || (command === "storage" && id && optionalFlag(args, "delete") === "true"));
|
|
if (sessionStorageCmd && id) return sessionStorageDelete(args, id);
|
|
if (group === "queue" && command === "submit") return submitQueueTask(args);
|
|
if (group === "queue" && command === "list") return listQueueTasks(args);
|
|
if (group === "queue" && command === "show" && id) return showQueueTask(args, id);
|
|
if (group === "queue" && command === "stats") return client(args).get(`/api/v1/queue/stats${queueQuery(args)}`);
|
|
if (group === "queue" && command === "commander") return queueCommander(args);
|
|
if (group === "queue" && command === "read" && id) return readQueueTask(args, id);
|
|
if (group === "queue" && command === "cancel" && id) return cancelQueueTask(args, id);
|
|
if (group === "queue" && command === "dispatch" && id) return dispatchQueueTask(args, id);
|
|
if (group === "queue" && command === "refresh" && id) return refreshQueueTask(args, id);
|
|
if (group === "runs" && command === "create") return client(args).post("/api/v1/runs", await jsonFile(args));
|
|
if (group === "runs" && command === "show" && id) return showRun(args, id);
|
|
if (group === "runs" && command === "events" && id) return runEvents(args, id);
|
|
if (group === "runs" && command === "result" && id) {
|
|
const commandId = optionalFlag(args, "command-id");
|
|
return client(args).get(`/api/v1/runs/${encodeURIComponent(id)}/result${commandId ? `?commandId=${encodeURIComponent(commandId)}` : ""}`);
|
|
}
|
|
if (group === "runs" && command === "cancel" && id) return client(args).post(`/api/v1/runs/${encodeURIComponent(id)}/cancel`, cancelBody(args));
|
|
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 showCommand(args, runId, id);
|
|
}
|
|
if (group === "commands" && command === "result" && id) {
|
|
const runId = flag(args, "run-id", "");
|
|
if (!runId) throw new AgentRunError("schema-invalid", "commands result requires --run-id", { httpStatus: 2 });
|
|
return client(args).get(`/api/v1/runs/${encodeURIComponent(runId)}/commands/${encodeURIComponent(id)}/result`);
|
|
}
|
|
if (group === "commands" && command === "cancel" && id) return client(args).post(`/api/v1/commands/${encodeURIComponent(id)}/cancel`, cancelBody(args));
|
|
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 backend = optionalFlag(args, "backend");
|
|
const codexCommand = optionalFlag(args, "codex-command");
|
|
const codexHome = optionalFlag(args, "codex-home") ?? process.env.CODEX_HOME;
|
|
if (runnerId) options.runnerId = runnerId;
|
|
if (backend) {
|
|
if (!isBackendProfile(backend)) throw new AgentRunError("schema-invalid", `runner start --backend ${backend} is not supported in v0.1`, { httpStatus: 2 });
|
|
options.backendProfile = backend;
|
|
}
|
|
if (codexCommand) options.codexCommand = codexCommand;
|
|
if (codexHome) options.codexHome = codexHome;
|
|
const idleTimeoutMs = optionalFlag(args, "idle-timeout-ms");
|
|
const pollIntervalMs = optionalFlag(args, "poll-interval-ms");
|
|
if (idleTimeoutMs) options.idleTimeoutMs = Number(idleTimeoutMs);
|
|
if (pollIntervalMs) options.pollIntervalMs = Number(pollIntervalMs);
|
|
if (args.flags.get("one-shot") === true) options.oneShot = true;
|
|
return runOnce(options) as unknown as JsonValue;
|
|
}
|
|
if (group === "runner" && command === "job") return renderRunnerJob(args);
|
|
if (group === "runner" && command === "jobs") return listRunnerJobs(args);
|
|
if (group === "runner" && command === "job-status") return showRunnerJobStatus(args);
|
|
throw new AgentRunError("schema-invalid", `unsupported command: ${args.positional.join(" ")}`, { httpStatus: 2 });
|
|
}
|
|
|
|
async function listRunnerJobs(args: ParsedArgs): Promise<JsonValue> {
|
|
const runId = flag(args, "run-id", "");
|
|
if (!runId) throw new AgentRunError("schema-invalid", "runner jobs requires --run-id", { httpStatus: 2 });
|
|
const commandId = optionalFlag(args, "command-id");
|
|
return client(args).get(`/api/v1/runs/${encodeURIComponent(runId)}/runner-jobs${commandId ? `?commandId=${encodeURIComponent(commandId)}` : ""}`);
|
|
}
|
|
|
|
async function showRun(args: ParsedArgs, runId: string): Promise<JsonValue> {
|
|
const run = await client(args).get(`/api/v1/runs/${encodeURIComponent(runId)}`);
|
|
if (wantsExpandedOutput(args)) return run;
|
|
const commandId = optionalFlag(args, "command-id");
|
|
const result = await client(args).get(`/api/v1/runs/${encodeURIComponent(runId)}/result${commandId ? `?commandId=${encodeURIComponent(commandId)}` : ""}`);
|
|
return summarizeRunShowResult(run, result, runId, commandId);
|
|
}
|
|
|
|
async function showCommand(args: ParsedArgs, runId: string, commandId: string): Promise<JsonValue> {
|
|
const command = await client(args).get(`/api/v1/runs/${encodeURIComponent(runId)}/commands/${encodeURIComponent(commandId)}`);
|
|
if (wantsExpandedOutput(args)) return command;
|
|
const result = await client(args).get(`/api/v1/runs/${encodeURIComponent(runId)}/commands/${encodeURIComponent(commandId)}/result`);
|
|
return summarizeCommandShowResult(command, result, runId, commandId);
|
|
}
|
|
|
|
function summarizeRunShowResult(run: JsonValue, result: JsonValue, runId: string, commandId: string | null): JsonRecord {
|
|
const resultRecord = jsonRecordValue(result);
|
|
const resolvedCommandId = commandId ?? stringValue(resultRecord?.commandId);
|
|
const sessionId = stringValue(jsonRecordValue(resultRecord?.sessionRef)?.sessionId);
|
|
const lastSeq = numberValue(resultRecord?.lastSeq) ?? 0;
|
|
return {
|
|
action: "runs-show-summary",
|
|
run: summarizeRunRecord(jsonRecordValue(run)),
|
|
result: summarizeResultEnvelope(resultRecord),
|
|
diagnosis: summarizeDiagnosisRecord(jsonRecordValue(resultRecord?.diagnosis)),
|
|
terminalClassification: summarizeTerminalClassificationIfPresent(resultRecord),
|
|
liveness: summarizeLivenessRecord(jsonRecordValue(resultRecord?.liveness)),
|
|
finalResponse: summarizeFinalResponseRecord(jsonRecordValue(resultRecord?.finalResponse)),
|
|
fullResponseBytes: jsonByteLength({ run, result }),
|
|
valuesPrinted: false,
|
|
drillDownCommands: runCommandDrillDown(runId, resolvedCommandId, sessionId, lastSeq),
|
|
};
|
|
}
|
|
|
|
function summarizeCommandShowResult(command: JsonValue, result: JsonValue, runId: string, commandId: string): JsonRecord {
|
|
const resultRecord = jsonRecordValue(result);
|
|
const sessionId = stringValue(jsonRecordValue(resultRecord?.sessionRef)?.sessionId);
|
|
const lastSeq = numberValue(resultRecord?.lastSeq) ?? 0;
|
|
return {
|
|
action: "commands-show-summary",
|
|
command: summarizeCommandRecord(jsonRecordValue(command)),
|
|
result: summarizeResultEnvelope(resultRecord),
|
|
diagnosis: summarizeDiagnosisRecord(jsonRecordValue(resultRecord?.diagnosis)),
|
|
terminalClassification: summarizeTerminalClassificationIfPresent(resultRecord),
|
|
liveness: summarizeLivenessRecord(jsonRecordValue(resultRecord?.liveness)),
|
|
finalResponse: summarizeFinalResponseRecord(jsonRecordValue(resultRecord?.finalResponse)),
|
|
fullResponseBytes: jsonByteLength({ command, result }),
|
|
valuesPrinted: false,
|
|
drillDownCommands: runCommandDrillDown(runId, commandId, sessionId, lastSeq),
|
|
};
|
|
}
|
|
|
|
function summarizeResultEnvelope(record: JsonRecord | null): JsonRecord | null {
|
|
if (!record) return null;
|
|
return withoutFullRecordBytes(compactRecord(record, {
|
|
keys: ["ok", "executionOk", "runId", "commandId", "attemptId", "runnerId", "jobName", "namespace", "status", "runStatus", "commandState", "terminalStatus", "terminalSource", "providerTerminalFailure", "recoverableViaSession", "completed", "finalResponseAuthority", "finalResponseFallback", "needsContinuation", "failureKind", "failureMessage", "lastSeq", "eventCount", "scopedEventCount", "scopedLastSeq", "runnerJobCount"],
|
|
}));
|
|
}
|
|
|
|
function summarizeTerminalClassificationIfPresent(record: JsonRecord | null): JsonRecord | null {
|
|
const liveness = jsonRecordValue(record?.liveness);
|
|
const classification = jsonRecordValue(record?.terminalClassification) ?? jsonRecordValue(liveness?.terminalClassification);
|
|
return classification ? summarizeTerminalClassification(classification) : null;
|
|
}
|
|
|
|
function summarizeLivenessRecord(record: JsonRecord | null): JsonRecord | null {
|
|
if (!record) return null;
|
|
const lastActivity = jsonRecordValue(record.lastActivity ?? record.lastCommandActivity);
|
|
const timeoutBudget = jsonRecordValue(record.timeoutBudget);
|
|
const transportDisconnect = jsonRecordValue(record.transportDisconnect);
|
|
const retryInterruption = jsonRecordValue(record.retryInterruption);
|
|
const lease = jsonRecordValue(record.lease);
|
|
return {
|
|
...withoutFullRecordBytes(compactRecord(record, { keys: ["phase", "active", "runStatus", "commandId", "commandType", "commandState", "lastSeq", "lastEventAt", "lastEventAgeMs"] })),
|
|
terminalClassification: summarizeTerminalClassificationIfPresent({ terminalClassification: record.terminalClassification ?? null }),
|
|
lastActivity: lastActivity ? withoutFullRecordBytes(compactRecord(lastActivity, { keys: ["sourceSeq", "eventId", "activityKind", "type", "status", "toolName", "itemId", "ageMs", "summary"] })) : null,
|
|
timeoutBudget: timeoutBudget ? withoutFullRecordBytes(compactRecord(timeoutBudget, { keys: ["state", "timeoutKind", "timeoutMs", "elapsedMs", "idleElapsedMs", "remainingMs", "startedAt", "idleStartedAt", "lastActivityAt", "lastActivitySeq", "source"] })) : null,
|
|
lease: lease ? withoutFullRecordBytes(compactRecord(lease, { keys: ["claimedBy", "leaseExpiresAt", "leaseExpired", "leaseRemainingMs"] })) : null,
|
|
transportDisconnect: transportDisconnect ? withoutFullRecordBytes(compactRecord(transportDisconnect, { keys: ["sourceSeq", "eventId", "activityKind", "type", "status", "ageMs", "summary"] })) : null,
|
|
retryInterruption: retryInterruption ? withoutFullRecordBytes(compactRecord(retryInterruption, { keys: ["sourceSeq", "eventId", "activityKind", "type", "status", "ageMs", "summary"] })) : null,
|
|
recoveryActions: summarizeRecoveryActions(record.recoveryActions),
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function summarizeDiagnosisRecord(record: JsonRecord | null): JsonRecord | null {
|
|
if (!record) return null;
|
|
const run = jsonRecordValue(record.run);
|
|
const command = jsonRecordValue(record.command);
|
|
const runnerJob = jsonRecordValue(record.runnerJob);
|
|
const session = jsonRecordValue(record.session);
|
|
return {
|
|
...withoutFullRecordBytes(compactRecord(record, { keys: ["category", "staleClaimed", "runnerLost", "terminalCommandOpenRun", "evidenceLevel", "providerEvidence", "providerInterruption", "providerInterruptionKnown", "terminalCategory", "terminalStatus", "failureKind", "failureMessage"] })),
|
|
run: run ? withoutFullRecordBytes(compactRecord(run, { keys: ["runId", "status", "claimedBy", "leaseExpiresAt", "leaseExpired", "leaseRemainingMs"] })) : null,
|
|
command: command ? withoutFullRecordBytes(compactRecord(command, { keys: ["commandId", "state", "terminalStatus", "acknowledgedAt", "updatedAt"] })) : null,
|
|
runnerJob: runnerJob ? withoutFullRecordBytes(compactRecord(runnerJob, { keys: ["runnerJobId", "attemptId", "runnerId", "namespace", "jobName", "phase", "terminalStatus", "startedAt", "finishedAt", "logPath"] })) : null,
|
|
session: session ? withoutFullRecordBytes(compactRecord(session, { keys: ["sessionId", "sessionRefNull", "sessionPath"] })) : null,
|
|
recoveryActions: summarizeRecoveryActions(record.recoveryActions),
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function summarizeFinalResponseRecord(record: JsonRecord | null): JsonRecord | null {
|
|
if (!record) return null;
|
|
return withoutFullRecordBytes(compactRecord(record, { keys: ["seq", "source", "final", "replyAuthority", "authority", "fallback", "needsContinuation", "textTruncated", "outputTruncated", "text"] }));
|
|
}
|
|
|
|
function runCommandDrillDown(runId: string, commandId: string | null, sessionId: string | null, lastSeq: number): JsonRecord {
|
|
return {
|
|
run: `describe run/${runId}`,
|
|
result: `result run/${runId}${commandId ? ` --command ${commandId}` : ""}`,
|
|
events: `events run/${runId} --after-seq ${lastSeq} --limit 100`,
|
|
...(commandId ? { command: `describe command/${commandId} --run ${runId}`, commandResult: `result command/${commandId} --run ${runId}` } : {}),
|
|
...(sessionId ? { logs: `logs session/${sessionId} --tail 100`, send: `send session/${sessionId} --prompt-stdin` } : {}),
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
async function runEvents(args: ParsedArgs, runId: string): Promise<CliResult> {
|
|
const afterSeq = integerFlag(args, "after-seq", 0, { min: 0 });
|
|
const limit = integerFlag(args, "limit", 100, { min: 1, max: 500 });
|
|
const page = await client(args).get(`/api/v1/runs/${encodeURIComponent(runId)}/events?afterSeq=${afterSeq}&limit=${limit}`);
|
|
const format = optionalFlag(args, "format") ?? "json";
|
|
if (format !== "json" && format !== "tsv") throw new AgentRunError("schema-invalid", "runs events --format must be json or tsv", { httpStatus: 2 });
|
|
|
|
const tail = tailFlag(args, limit);
|
|
const wantsSummary = args.flags.get("summary") === true || args.flags.get("tail-summary") === true || tail !== null || format === "tsv";
|
|
if (!wantsSummary) return page;
|
|
|
|
const summary = summarizeRunEventPage(page, {
|
|
runId,
|
|
afterSeq,
|
|
limit,
|
|
tail: tail ?? (args.flags.get("tail-summary") === true ? Math.min(limit, 20) : null),
|
|
summaryChars: integerFlag(args, "summary-chars", 900, { min: 1, max: 4_000 }),
|
|
});
|
|
if (format === "tsv") return plainTextOutput(renderRunEventSummaryTsv(summary));
|
|
return summary;
|
|
}
|
|
|
|
interface RunEventSummaryOptions {
|
|
runId: string;
|
|
afterSeq: number;
|
|
limit: number;
|
|
tail: number | null;
|
|
summaryChars: number;
|
|
}
|
|
|
|
interface SessionEventSummaryOptions {
|
|
kind: "trace" | "output";
|
|
sessionId: string;
|
|
afterSeq: number;
|
|
limit: number;
|
|
runId: string | null;
|
|
tail: number | null;
|
|
summaryChars: number;
|
|
includeOutput: boolean;
|
|
}
|
|
|
|
interface SessionEventDetailFilter {
|
|
seq: number | null;
|
|
eventId: string | null;
|
|
itemId: string | null;
|
|
}
|
|
|
|
export function summarizeRunEventPage(page: JsonValue, options: RunEventSummaryOptions): JsonRecord {
|
|
const events = eventPageItems(page);
|
|
const selected = options.tail === null ? events : events.slice(-options.tail);
|
|
const items = selected.map((event) => summarizeRunEvent(event, options.summaryChars));
|
|
const lastSeq = items.length > 0 ? items[items.length - 1]?.seq ?? null : null;
|
|
return {
|
|
action: "runs-events-summary",
|
|
runId: options.runId,
|
|
afterSeq: options.afterSeq,
|
|
limit: options.limit,
|
|
tail: options.tail,
|
|
sourceCount: events.length,
|
|
count: items.length,
|
|
lastSeq,
|
|
nextAfterSeq: lastSeq,
|
|
valuesPrinted: false,
|
|
items,
|
|
};
|
|
}
|
|
|
|
export function summarizeSessionEventPage(page: JsonValue, options: SessionEventSummaryOptions): JsonRecord {
|
|
const record = jsonRecordValue(page);
|
|
if (!record) throw new AgentRunError("schema-invalid", "sessions event response must be an object", { httpStatus: 2 });
|
|
const events = eventPageItems(page);
|
|
const selected = options.tail === null ? events : events.slice(-options.tail);
|
|
const summarized = selected.map((event) => summarizeRunEvent(event, options.summaryChars));
|
|
const visible = options.includeOutput ? summarized : summarized.filter(isDefaultVisibleSessionEvent);
|
|
const suppressed = summarizeSuppressedSessionEvents(summarized, visible);
|
|
const runId = stringValue(record.runId) ?? options.runId;
|
|
const items = withSessionDetailCommands(visible, options.kind, options.sessionId, runId);
|
|
const lastSeq = summarized.length > 0 ? summarized[summarized.length - 1]?.seq ?? null : null;
|
|
return {
|
|
action: `session-${options.kind}-summary`,
|
|
sessionId: stringValue(record.sessionId) ?? options.sessionId,
|
|
runId,
|
|
afterSeq: options.afterSeq,
|
|
limit: options.limit,
|
|
tail: options.tail,
|
|
sourceCount: events.length,
|
|
displayedCount: items.length,
|
|
count: items.length,
|
|
cursor: stringValue(record.cursor),
|
|
lastSeq,
|
|
nextAfterSeq: lastSeq,
|
|
suppressedEvents: suppressed,
|
|
valuesPrinted: false,
|
|
items,
|
|
drillDownCommands: sessionEventDrillDownCommands(options.kind, options.sessionId, { afterSeq: options.afterSeq, limit: options.limit, runId }),
|
|
progressiveDisclosure: {
|
|
default: "assistant messages plus tool summaries; command_output/backend_status chunks are counted, not displayed",
|
|
includeOutputFlag: "--include-output",
|
|
detailFlags: "--seq <seq> or --item-id <itemId> --full",
|
|
fullFlag: "--full",
|
|
rawFlag: "--raw",
|
|
},
|
|
};
|
|
}
|
|
|
|
export function summarizeQueueDispatchResult(result: JsonValue, taskId: string): JsonRecord {
|
|
const record = jsonRecordValue(result);
|
|
if (!record) throw new AgentRunError("schema-invalid", "queue dispatch response must be an object", { httpStatus: 2 });
|
|
const task = jsonRecordValue(record.task);
|
|
const run = jsonRecordValue(record.run);
|
|
const command = jsonRecordValue(record.command);
|
|
const runnerJob = jsonRecordValue(record.runnerJob);
|
|
const latestAttempt = jsonRecordValue(record.latestAttempt) ?? jsonRecordValue(task?.latestAttempt);
|
|
const runId = stringValue(run?.id) ?? stringValue(latestAttempt?.runId);
|
|
const commandId = stringValue(command?.id) ?? stringValue(latestAttempt?.commandId);
|
|
const sessionId = stringValue(latestAttempt?.sessionId) ?? stringValue(jsonRecordValue(run?.sessionRef)?.sessionId) ?? stringValue(jsonRecordValue(task?.sessionRef)?.sessionId);
|
|
return {
|
|
action: "queue-dispatch-summary",
|
|
taskId: stringValue(task?.id) ?? taskId,
|
|
mutation: record.mutation === true,
|
|
task: summarizeQueueTaskRecord(task, taskId),
|
|
latestAttempt: summarizeAttemptRecord(latestAttempt),
|
|
run: summarizeRunRecord(run),
|
|
command: summarizeCommandRecord(command),
|
|
runnerJob: summarizeRunnerJobRecord(runnerJob),
|
|
fullResponseBytes: jsonByteLength(result),
|
|
valuesPrinted: false,
|
|
pollCommands: {
|
|
queue: `describe task/${stringValue(task?.id) ?? taskId}`,
|
|
...(runId ? { run: `describe run/${runId}`, result: `result run/${runId}${commandId ? ` --command ${commandId}` : ""}`, events: `events run/${runId} --after-seq 0 --limit 100` } : {}),
|
|
...(runId && commandId ? { command: `describe command/${commandId} --run ${runId}` } : {}),
|
|
...(sessionId ? { logs: `logs session/${sessionId} --tail 100`, send: `send session/${sessionId} --prompt-stdin` } : {}),
|
|
},
|
|
expandedOutput: {
|
|
fullFlag: "--full",
|
|
rawFlag: "--raw",
|
|
note: "For mutating commands, request expanded output on the original invocation.",
|
|
},
|
|
};
|
|
}
|
|
|
|
function summarizeRunEvent(event: JsonRecord, summaryChars: number): JsonRecord {
|
|
const payload = jsonRecordValue(event.payload) ?? {};
|
|
const type = stringValue(event.type) ?? "unknown";
|
|
const command = type === "assistant_message" ? null : boundedSummaryString(stringValue(payload.command), Math.min(summaryChars, 240));
|
|
const text = type === "assistant_message" ? boundedSummaryString(stringValue(payload.text), summaryChars) : null;
|
|
const outputSummary = boundedSummaryString(stringValue(payload.outputSummary) ?? nestedSummaryText(payload), summaryChars);
|
|
const message = boundedSummaryString(stringValue(payload.failureMessage) ?? stringValue(payload.message), summaryChars);
|
|
const summary = text ?? outputSummary ?? command ?? message ?? "";
|
|
const summaryTruncated = [command, text, outputSummary, message].some((value) => value?.endsWith("...") === true);
|
|
const runnerTrace = findNestedValue(payload, "runnerTrace");
|
|
return {
|
|
eventId: stringValue(event.id),
|
|
seq: numberValue(event.seq) ?? 0,
|
|
type,
|
|
itemId: stringValue(payload.itemId) ?? stringValue(payload.id),
|
|
method: stringValue(payload.method),
|
|
status: stringValue(payload.status) ?? stringValue(payload.terminalStatus) ?? stringValue(payload.phase),
|
|
phase: stringValue(payload.phase),
|
|
commandId: stringValue(payload.commandId),
|
|
command,
|
|
text,
|
|
exitCode: numberValue(payload.exitCode),
|
|
durationMs: numberValue(payload.durationMs),
|
|
outputTruncated: outputTruncatedFromPayload(payload) || summaryTruncated,
|
|
outputBytes: outputBytesFromPayload(payload),
|
|
outputSummary,
|
|
summary,
|
|
payloadBytes: jsonByteLength(payload),
|
|
payloadKeys: Object.keys(payload).sort().slice(0, 24),
|
|
hasRunnerTrace: runnerTrace !== null,
|
|
runnerTraceBytes: runnerTrace === null ? 0 : jsonByteLength(runnerTrace),
|
|
hasRawEvent: hasNestedKey(payload, ["raw", "rawEvent", "raw_event", "event"]),
|
|
};
|
|
}
|
|
|
|
function isDefaultVisibleSessionEvent(item: JsonRecord): boolean {
|
|
const type = stringValue(item.type);
|
|
return type === "assistant_message" || type === "tool_call" || type === "error";
|
|
}
|
|
|
|
function summarizeSuppressedSessionEvents(all: JsonRecord[], visible: JsonRecord[]): JsonRecord {
|
|
const visibleSeqs = new Set(visible.map((item) => item.seq));
|
|
const suppressed = all.filter((item) => !visibleSeqs.has(item.seq));
|
|
const byType: JsonRecord = {};
|
|
for (const item of suppressed) {
|
|
const type = stringValue(item.type) ?? "unknown";
|
|
byType[type] = Number(byType[type] ?? 0) + 1;
|
|
}
|
|
return {
|
|
count: suppressed.length,
|
|
byType,
|
|
outputBytes: suppressed.reduce((total, item) => total + Number(item.outputBytes ?? 0), 0),
|
|
outputTruncated: suppressed.some((item) => item.outputTruncated === true),
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function withSessionDetailCommands(items: JsonRecord[], kind: "trace" | "output", sessionId: string, runId: string | null): JsonRecord[] {
|
|
return items.map((item) => {
|
|
const seq = numberValue(item.seq);
|
|
const itemId = stringValue(item.itemId);
|
|
const eventId = stringValue(item.eventId);
|
|
const pageHint = seq === null ? "" : ` --after-seq ${Math.max(0, seq - 1)} --limit 1`;
|
|
const runFlag = runId ? ` --run-id ${runId}` : "";
|
|
const detail = seq === null ? null : `./scripts/agentrun sessions ${kind} ${sessionId}${pageHint} --seq ${seq}${runFlag} --full`;
|
|
return {
|
|
...item,
|
|
...(detail || itemId || eventId ? {
|
|
detailCommands: {
|
|
...(detail ? { seq: detail } : {}),
|
|
...(itemId ? { item: `./scripts/agentrun sessions ${kind} ${sessionId}${pageHint} --item-id ${itemId}${runFlag} --full` } : {}),
|
|
...(eventId ? { event: `./scripts/agentrun sessions ${kind} ${sessionId}${pageHint} --event-id ${eventId}${runFlag} --full` } : {}),
|
|
},
|
|
} : {}),
|
|
};
|
|
});
|
|
}
|
|
|
|
function sessionEventDetailFilter(args: ParsedArgs, seq: number | null): SessionEventDetailFilter | null {
|
|
const eventId = optionalFlag(args, "event-id");
|
|
const itemId = optionalFlag(args, "item-id");
|
|
if (seq === null && !eventId && !itemId) return null;
|
|
return { seq, eventId, itemId };
|
|
}
|
|
|
|
function sessionEventDetailResult(page: JsonValue, options: { kind: "trace" | "output"; sessionId: string; runId: string | null; summaryChars: number; filter: SessionEventDetailFilter }): JsonRecord {
|
|
const record = jsonRecordValue(page);
|
|
if (!record) throw new AgentRunError("schema-invalid", "sessions event response must be an object", { httpStatus: 2 });
|
|
const events = eventPageItems(page);
|
|
const matches = events.filter((event) => matchesSessionEventFilter(event, options.filter));
|
|
if (matches.length === 0) {
|
|
throw new AgentRunError("schema-invalid", "no session event matched --seq/--event-id/--item-id in the fetched page", {
|
|
httpStatus: 2,
|
|
details: {
|
|
filter: options.filter as unknown as JsonRecord,
|
|
hint: "increase --limit or adjust --after-seq when looking up --event-id/--item-id outside the current page",
|
|
},
|
|
});
|
|
}
|
|
return sessionEventDetailResultFromMatches(page, matches, options);
|
|
}
|
|
|
|
function sessionEventDetailResultFromMatches(page: JsonValue, matches: JsonRecord[], options: { kind: "trace" | "output"; sessionId: string; runId: string | null; summaryChars: number; filter: SessionEventDetailFilter; pagesScanned?: number; eventsScanned?: number }): JsonRecord {
|
|
const record = jsonRecordValue(page);
|
|
if (!record) throw new AgentRunError("schema-invalid", "sessions event response must be an object", { httpStatus: 2 });
|
|
const events = eventPageItems(page);
|
|
const runId = stringValue(record.runId) ?? options.runId;
|
|
return {
|
|
action: `session-${options.kind}-event-detail`,
|
|
sessionId: stringValue(record.sessionId) ?? options.sessionId,
|
|
runId,
|
|
filter: options.filter as unknown as JsonRecord,
|
|
sourceCount: events.length,
|
|
count: matches.length,
|
|
...(options.pagesScanned === undefined ? {} : { pagesScanned: options.pagesScanned }),
|
|
...(options.eventsScanned === undefined ? {} : { eventsScanned: options.eventsScanned }),
|
|
valuesPrinted: false,
|
|
items: matches.map((event) => ({
|
|
summary: summarizeRunEvent(event, options.summaryChars),
|
|
event: redactJson(event) as JsonRecord,
|
|
eventBytes: jsonByteLength(event),
|
|
valuesPrinted: false,
|
|
})),
|
|
};
|
|
}
|
|
|
|
function matchesSessionEventFilter(event: JsonRecord, filter: SessionEventDetailFilter): boolean {
|
|
const payload = jsonRecordValue(event.payload) ?? {};
|
|
const nestedItem = jsonRecordValue(payload.item);
|
|
if (filter.seq !== null && numberValue(event.seq) === filter.seq) return true;
|
|
if (filter.eventId && stringValue(event.id) === filter.eventId) return true;
|
|
if (filter.itemId && (stringValue(payload.itemId) === filter.itemId || stringValue(payload.id) === filter.itemId || stringValue(nestedItem?.id) === filter.itemId)) return true;
|
|
return false;
|
|
}
|
|
|
|
export function renderRunEventSummaryTsv(summary: JsonRecord): string {
|
|
const items = Array.isArray(summary.items) ? summary.items.filter((item): item is JsonRecord => jsonRecordValue(item) !== null) : [];
|
|
const rows = items.map((item) => [
|
|
item.seq,
|
|
item.type,
|
|
item.method,
|
|
item.status,
|
|
item.exitCode,
|
|
item.durationMs,
|
|
item.outputTruncated,
|
|
item.outputBytes,
|
|
item.summary,
|
|
].map(tsvCell).join("\t"));
|
|
return ["seq\ttype\tmethod\tstatus\texitCode\tdurationMs\toutputTruncated\toutputBytes\tsummary", ...rows].join("\n");
|
|
}
|
|
|
|
function eventPageItems(page: JsonValue): JsonRecord[] {
|
|
const record = jsonRecordValue(page);
|
|
if (!record) throw new AgentRunError("schema-invalid", "runs events response must be an object", { httpStatus: 2 });
|
|
if (!Array.isArray(record.items)) throw new AgentRunError("schema-invalid", "runs events response.items must be an array", { httpStatus: 2 });
|
|
return record.items.map((item) => {
|
|
const event = jsonRecordValue(item);
|
|
if (!event) throw new AgentRunError("schema-invalid", "runs events item must be an object", { httpStatus: 2 });
|
|
return event;
|
|
});
|
|
}
|
|
|
|
function nestedSummaryText(payload: JsonRecord): string | null {
|
|
const summary = payload.summary;
|
|
if (typeof summary === "string") return summary;
|
|
const summaryRecord = jsonRecordValue(summary);
|
|
return summaryRecord ? stringValue(summaryRecord.text) : null;
|
|
}
|
|
|
|
function boundedSummaryString(value: string | null, limit: number): string | null {
|
|
if (!value) return null;
|
|
const normalized = redactText(value).replace(/\s+/gu, " ").trim();
|
|
if (normalized.length === 0) return null;
|
|
return normalized.length > limit ? `${normalized.slice(0, Math.max(0, limit - 3))}...` : normalized;
|
|
}
|
|
|
|
function integerFlag(args: ParsedArgs, name: string, fallback: number, options: { min: number; max?: number }): number {
|
|
const raw = optionalFlag(args, name);
|
|
const value = raw === null ? fallback : Number(raw);
|
|
if (!Number.isInteger(value) || value < options.min || (options.max !== undefined && value > options.max)) {
|
|
const maxText = options.max === undefined ? "" : ` and <= ${options.max}`;
|
|
throw new AgentRunError("schema-invalid", `--${name} must be an integer >= ${options.min}${maxText}`, { httpStatus: 2 });
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function optionalIntegerFlag(args: ParsedArgs, name: string, options: { min: number; max?: number }): number | null {
|
|
const raw = optionalFlag(args, name);
|
|
if (raw === null) return null;
|
|
const value = Number(raw);
|
|
if (!Number.isInteger(value) || value < options.min || (options.max !== undefined && value > options.max)) {
|
|
const maxText = options.max === undefined ? "" : ` and <= ${options.max}`;
|
|
throw new AgentRunError("schema-invalid", `--${name} must be an integer >= ${options.min}${maxText}`, { httpStatus: 2 });
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function tailFlag(args: ParsedArgs, limit: number): number | null {
|
|
const value = args.flags.get("tail");
|
|
if (value === undefined) return null;
|
|
if (value === true) return Math.min(limit, 20);
|
|
const parsed = Number(value);
|
|
if (!Number.isInteger(parsed) || parsed < 1 || parsed > limit) throw new AgentRunError("schema-invalid", `--tail must be an integer between 1 and --limit (${limit})`, { httpStatus: 2 });
|
|
return parsed;
|
|
}
|
|
|
|
function stringValue(value: JsonValue | undefined): string | null {
|
|
return typeof value === "string" && value.length > 0 ? value : null;
|
|
}
|
|
|
|
function numberValue(value: JsonValue | undefined): number | null {
|
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
}
|
|
|
|
function jsonRecordValue(value: unknown): JsonRecord | null {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as JsonRecord : null;
|
|
}
|
|
|
|
function tsvCell(value: JsonValue | undefined): string {
|
|
if (value === null || value === undefined) return "";
|
|
return String(value).replace(/[\t\r\n]+/gu, " ");
|
|
}
|
|
|
|
function plainTextOutput(text: string): PlainTextOutput {
|
|
return { [plainTextOutputMarker]: true, text };
|
|
}
|
|
|
|
function isPlainTextOutput(value: CliResult): value is PlainTextOutput {
|
|
return typeof value === "object" && value !== null && plainTextOutputMarker in value;
|
|
}
|
|
|
|
function wantsExpandedOutput(args: ParsedArgs): boolean {
|
|
return args.flags.get("full") === true || args.flags.get("raw") === true;
|
|
}
|
|
|
|
function summarizeSessionMutationResult(action: "session-cancel" | "session-read", sessionId: string, result: JsonValue, flags: JsonRecord): JsonRecord {
|
|
const record = jsonRecordValue(result);
|
|
return {
|
|
action,
|
|
sessionId,
|
|
mutation: true,
|
|
...flags,
|
|
result: summarizeGenericRecord(record),
|
|
fullResponseBytes: jsonByteLength(result),
|
|
valuesPrinted: false,
|
|
drillDownCommands: {
|
|
show: `describe session/${sessionId}`,
|
|
logs: `logs session/${sessionId} --tail 100`,
|
|
send: `send session/${sessionId} --prompt-stdin`,
|
|
read: `ack session/${sessionId}`,
|
|
cancel: `cancel session/${sessionId}`,
|
|
},
|
|
expandedOutput: {
|
|
fullFlag: "--full",
|
|
rawFlag: "--raw",
|
|
note: "For mutating commands, request expanded output on the original invocation.",
|
|
},
|
|
};
|
|
}
|
|
|
|
function summarizeSessionSendResult(result: JsonValue, sessionId: string, profile: string, aipod?: string): JsonRecord {
|
|
const record = jsonRecordValue(result);
|
|
const run = jsonRecordValue(record?.run);
|
|
const command = jsonRecordValue(record?.command);
|
|
const activeBefore = jsonRecordValue(record?.activeBefore);
|
|
const runnerJob = jsonRecordValue(record?.runnerJob);
|
|
const dryRun = record?.dryRun === true;
|
|
const afterSeq = numberValue(jsonRecordValue(record?.supervisor)?.lastSeq) ?? 0;
|
|
return {
|
|
action: dryRun ? "session-send-plan" : "session-send",
|
|
sessionId,
|
|
profile,
|
|
...(aipod ? { aipod } : {}),
|
|
dryRun,
|
|
mutation: record?.mutation === true,
|
|
decision: stringValue(record?.decision),
|
|
internalCommandType: stringValue(record?.internalCommandType),
|
|
activeBefore: activeBefore ? compactRecord(activeBefore, { keys: ["runId", "commandId", "commandState", "runStatus", "leaseExpiresAt", "leaseExpired", "reason"] }) : null,
|
|
run: summarizeRunRecord(run),
|
|
command: summarizeCommandRecord(command),
|
|
runnerJob: runnerJob ? compactRecord(runnerJob, { keys: ["action", "runId", "commandId", "attemptId", "runnerId", "namespace", "jobName", "image", "mutation"] }) : null,
|
|
fullResponseBytes: jsonByteLength(result),
|
|
valuesPrinted: false,
|
|
drillDownCommands: {
|
|
show: `describe session/${sessionId}`,
|
|
logs: `logs session/${sessionId} --tail 100`,
|
|
...(stringValue(run?.id) ? { events: `events run/${String(run?.id)} --after-seq ${afterSeq} --limit 100` } : {}),
|
|
...(stringValue(run?.id) && stringValue(command?.id) ? { result: `result run/${String(run?.id)} --command ${String(command?.id)}` } : {}),
|
|
send: `send session/${sessionId} --prompt-stdin`,
|
|
read: `ack session/${sessionId}`,
|
|
cancel: `cancel session/${sessionId}`,
|
|
},
|
|
expandedOutput: {
|
|
fullFlag: "--full",
|
|
rawFlag: "--raw",
|
|
note: dryRun ? "Dry-run is non-mutating; remove --dry-run to send." : "Use --full on the original invocation for the full manager response.",
|
|
},
|
|
};
|
|
}
|
|
|
|
async function sessionRunnerJobBody(args: ParsedArgs, defaults: JsonRecord = {}): Promise<JsonRecord> {
|
|
const runnerOverrides = await optionalRunnerJsonFile(args);
|
|
const body = { ...defaults, ...runnerOverrides } as JsonRecord;
|
|
copyOptionalFlag(args, body, "image");
|
|
copyOptionalFlag(args, body, "namespace");
|
|
copyOptionalFlag(args, body, "attempt-id", "attemptId");
|
|
copyOptionalFlag(args, body, "runner-id", "runnerId");
|
|
copyOptionalFlag(args, body, "source-commit", "sourceCommit");
|
|
copyRunnerManagerUrlFlag(args, body);
|
|
copyOptionalFlag(args, body, "service-account-name", "serviceAccountName");
|
|
const runnerIdempotencyKey = optionalFlag(args, "runner-idempotency-key");
|
|
if (runnerIdempotencyKey) body.idempotencyKey = runnerIdempotencyKey;
|
|
return body;
|
|
}
|
|
|
|
async function ensureSessionForSend(args: ParsedArgs, sessionId: string, tenantId: string, projectId: string, profile: string): Promise<void> {
|
|
try {
|
|
await client(args).get(`/api/v1/sessions/${encodeURIComponent(sessionId)}/storage`);
|
|
return;
|
|
} catch (error) {
|
|
if (!(error instanceof AgentRunError) || error.httpStatus !== 404) throw error;
|
|
}
|
|
const expiresInDays = Number(optionalFlag(args, "expires-in-days") ?? 30);
|
|
await client(args).post("/api/v1/sessions", {
|
|
sessionId,
|
|
tenantId,
|
|
projectId,
|
|
backendProfile: profile,
|
|
expiresAt: new Date(Date.now() + Math.max(1, expiresInDays) * 24 * 60 * 60 * 1000).toISOString(),
|
|
});
|
|
}
|
|
|
|
interface QueueSummaryOptions {
|
|
limit: number;
|
|
}
|
|
|
|
export function summarizeQueueTaskListResult(result: JsonValue, options: QueueSummaryOptions): JsonRecord {
|
|
const record = jsonRecordValue(result);
|
|
if (!record) throw new AgentRunError("schema-invalid", "queue list response must be an object", { httpStatus: 2 });
|
|
const items = queueItems(record.items);
|
|
const selected = items.slice(0, options.limit);
|
|
return {
|
|
action: "queue-list-summary",
|
|
queue: stringValue(record.queue),
|
|
state: stringValue(record.state),
|
|
cursor: stringValue(record.cursor),
|
|
nextCursor: stringValue(record.nextCursor),
|
|
sourceCount: items.length,
|
|
displayedCount: selected.length,
|
|
limit: options.limit,
|
|
items: selected.map((item) => summarizeQueueTaskWithAttempt(item, stringValue(item.id) ?? "unknown")),
|
|
fullResponseBytes: jsonByteLength(result),
|
|
valuesPrinted: false,
|
|
drillDownCommands: {
|
|
full: "./scripts/agentrun queue list --full",
|
|
raw: "./scripts/agentrun queue list --raw",
|
|
next: stringValue(record.nextCursor) ? `./scripts/agentrun queue list --cursor ${String(record.nextCursor)} --limit ${options.limit}` : null,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function summarizeQueueTaskShowResult(result: JsonValue, taskId: string): JsonRecord {
|
|
const record = jsonRecordValue(result);
|
|
if (!record) throw new AgentRunError("schema-invalid", "queue show response must be an object", { httpStatus: 2 });
|
|
const latestAttempt = jsonRecordValue(record.latestAttempt);
|
|
const runId = stringValue(latestAttempt?.runId);
|
|
const commandId = stringValue(latestAttempt?.commandId);
|
|
const sessionId = stringValue(jsonRecordValue(record.sessionRef)?.sessionId) ?? stringValue(latestAttempt?.sessionId);
|
|
const afterSeq = numberValue(jsonRecordValue(record.supervisor)?.lastSeq) ?? 0;
|
|
return {
|
|
action: "queue-show-summary",
|
|
task: summarizeQueueTaskWithAttempt(record, taskId),
|
|
payloadBytes: jsonByteLength(record.payload),
|
|
resourceBundleBytes: jsonByteLength(record.resourceBundleRef),
|
|
referencesCount: Array.isArray(record.references) ? record.references.length : null,
|
|
metadataKeys: Object.keys(jsonRecordValue(record.metadata) ?? {}).sort().slice(0, 24),
|
|
fullResponseBytes: jsonByteLength(result),
|
|
valuesPrinted: false,
|
|
pollCommands: {
|
|
full: `describe task/${taskId} --full`,
|
|
...(runId ? { run: `describe run/${runId}`, result: `result run/${runId}${commandId ? ` --command ${commandId}` : ""}`, events: `events run/${runId} --after-seq ${afterSeq} --limit 100` } : {}),
|
|
...(runId && commandId ? { command: `describe command/${commandId} --run ${runId}` } : {}),
|
|
...(sessionId ? { logs: `logs session/${sessionId} --tail 100`, send: `send session/${sessionId} --prompt-stdin` } : {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
export function summarizeQueueCommanderSnapshot(result: JsonValue, options: QueueSummaryOptions): JsonRecord {
|
|
const record = jsonRecordValue(result);
|
|
if (!record) throw new AgentRunError("schema-invalid", "queue commander response must be an object", { httpStatus: 2 });
|
|
const items = queueItems(record.items);
|
|
const selected = items.slice(0, options.limit);
|
|
return {
|
|
action: "queue-commander-summary",
|
|
queue: stringValue(record.queue),
|
|
readerId: stringValue(record.readerId),
|
|
stats: summarizeQueueStats(jsonRecordValue(record.stats)),
|
|
activeSessionCount: numberValue(record.activeSessionCount),
|
|
sourceCount: items.length,
|
|
displayedCount: selected.length,
|
|
limit: options.limit,
|
|
unreadCount: items.filter((item) => item.unread === true).length,
|
|
activeCount: items.filter((item) => item.active === true || stringValue(item.attentionState) === "active").length,
|
|
items: selected.map((item) => summarizeQueueTaskWithAttempt(item, stringValue(item.id) ?? "unknown")),
|
|
generatedAt: stringValue(record.generatedAt),
|
|
fullResponseBytes: jsonByteLength(result),
|
|
valuesPrinted: false,
|
|
drillDownCommands: {
|
|
full: "get tasks --queue commander -o wide",
|
|
item: "describe task/<taskId>",
|
|
run: "describe run/<runId>",
|
|
events: "events run/<runId> --after-seq <lastSeq> --limit 100",
|
|
result: "result run/<runId> --command <commandId>",
|
|
command: "describe command/<commandId> --run <runId>",
|
|
logs: "logs session/<sessionId> --tail 100",
|
|
send: "send session/<sessionId> --prompt-stdin",
|
|
sessionHint: "细节按实际 run/session 走 result/events/logs;续跑只用 send session/<sessionId>",
|
|
},
|
|
};
|
|
}
|
|
|
|
function summarizeQueueTaskMutationResult(action: "queue-read" | "queue-cancel" | "queue-refresh", taskId: string, result: JsonValue, flags: JsonRecord): JsonRecord {
|
|
const record = jsonRecordValue(result);
|
|
return {
|
|
action,
|
|
taskId,
|
|
mutation: true,
|
|
...flags,
|
|
task: summarizeQueueTaskWithAttempt(record, taskId),
|
|
fullResponseBytes: jsonByteLength(result),
|
|
valuesPrinted: false,
|
|
drillDownCommands: {
|
|
show: `./scripts/agentrun queue show ${taskId}`,
|
|
full: `./scripts/agentrun queue show ${taskId} --full`,
|
|
},
|
|
};
|
|
}
|
|
|
|
function queueMutationDryRunPlan(action: string, taskId: string | null, pathValue: string, body: JsonRecord, method: "POST", confirmCommand: string, task?: JsonValue, jsonInput?: JsonRecord): JsonRecord {
|
|
return {
|
|
action: `${action}-plan`,
|
|
dryRun: true,
|
|
mutation: false,
|
|
taskId,
|
|
request: {
|
|
method,
|
|
path: pathValue,
|
|
body: summarizeMutationBody(body),
|
|
bodyBytes: jsonByteLength(body),
|
|
valuesPrinted: false,
|
|
},
|
|
...(jsonInput ? { jsonInput } : {}),
|
|
...(task === undefined ? {} : { task: summarizeQueueTaskWithAttempt(jsonRecordValue(task), taskId ?? stringValue(jsonRecordValue(task)?.id) ?? "unknown") }),
|
|
next: {
|
|
confirm: confirmCommand,
|
|
note: "Remove --dry-run to perform the mutation. Prefer --json-stdin with a quoted heredoc for one-shot JSON; use --json-file only for reusable files.",
|
|
},
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function jsonInputDisclosure(args: ParsedArgs, filePlaceholder: string, options: { required?: boolean } = {}): JsonRecord {
|
|
const source = args.flags.get("json-stdin") === true ? "stdin" : optionalFlag(args, "json-file") ? "file" : "none";
|
|
return {
|
|
source,
|
|
required: options.required ?? true,
|
|
preferred: "--json-stdin",
|
|
fileFallback: `--json-file ${filePlaceholder}`,
|
|
note: "Use a quoted heredoc, for example: ./scripts/agentrun ... --json-stdin <<'JSON'. Do not create temporary dump files for one-shot Queue bodies.",
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function queueSubmitConfirmCommand(args: ParsedArgs): string {
|
|
const parts = ["./scripts/agentrun queue submit --json-stdin"];
|
|
if (optionalFlag(args, "idempotency-key")) parts.push("--idempotency-key <idempotency-key>");
|
|
return parts.join(" ");
|
|
}
|
|
|
|
function queueSubmitAipodConfirmCommand(args: ParsedArgs, aipod: string): string {
|
|
const parts = [`./scripts/agentrun queue submit --aipod ${aipod} --prompt-stdin`];
|
|
if (optionalFlag(args, "idempotency-key")) parts.push("--idempotency-key <idempotency-key>");
|
|
return parts.join(" ");
|
|
}
|
|
|
|
function queueDispatchConfirmCommand(args: ParsedArgs, taskId: string): string {
|
|
const parts = [`./scripts/agentrun queue dispatch ${taskId}`];
|
|
if (args.flags.get("json-stdin") === true || optionalFlag(args, "json-file")) parts.push("--json-stdin");
|
|
const flagPlaceholders: Record<string, string> = {
|
|
"idempotency-key": "<idempotency-key>",
|
|
image: "<image>",
|
|
namespace: "<namespace>",
|
|
"attempt-id": "<attempt-id>",
|
|
"runner-id": "<runner-id>",
|
|
"source-commit": "<source-commit>",
|
|
"runner-manager-url": "<url|auto>",
|
|
"service-account-name": "<service-account-name>",
|
|
};
|
|
for (const [flagName, placeholder] of Object.entries(flagPlaceholders)) {
|
|
if (optionalFlag(args, flagName)) parts.push(`--${flagName} ${placeholder}`);
|
|
}
|
|
return parts.join(" ");
|
|
}
|
|
|
|
function summarizeMutationBody(body: JsonRecord): JsonRecord {
|
|
return {
|
|
...compactRecord(body, { keys: ["idempotencyKey", "image", "namespace", "attemptId", "runnerId", "sourceCommit", "managerUrl", "serviceAccountName", "readerId", "reason"] }),
|
|
keys: Object.keys(body).sort().slice(0, 32),
|
|
payloadBytes: jsonByteLength(body.payload),
|
|
executionPolicyBytes: jsonByteLength(body.executionPolicy),
|
|
resourceBundleBytes: jsonByteLength(body.resourceBundleRef),
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function summarizeQueueStats(record: JsonRecord | null): JsonRecord | null {
|
|
if (!record) return null;
|
|
return withoutFullRecordBytes({
|
|
queue: stringValue(record.queue),
|
|
total: numberValue(record.total),
|
|
maxVersion: numberValue(record.maxVersion),
|
|
byState: jsonRecordValue(record.byState) ?? {},
|
|
byLane: jsonRecordValue(record.byLane) ?? {},
|
|
byBackendProfile: jsonRecordValue(record.byBackendProfile) ?? {},
|
|
generatedAt: stringValue(record.generatedAt),
|
|
fullRecordBytes: jsonByteLength(record),
|
|
valuesPrinted: false,
|
|
});
|
|
}
|
|
|
|
function summarizeQueueTaskWithAttempt(record: JsonRecord | null, fallbackTaskId: string): JsonRecord {
|
|
const summary = summarizeQueueTaskRecord(record, fallbackTaskId);
|
|
const latestAttempt = summarizeAttemptRecord(jsonRecordValue(record?.latestAttempt));
|
|
const supervisor = summarizeSupervisorRecord(jsonRecordValue(record?.supervisor));
|
|
const activeSession = summarizeActiveSessionRecord(jsonRecordValue(record?.activeSession));
|
|
const sessionRef = jsonRecordValue(record?.sessionRef);
|
|
if (latestAttempt) summary.latestAttempt = latestAttempt;
|
|
if (supervisor) summary.supervisor = supervisor;
|
|
if (activeSession) summary.activeSession = activeSession;
|
|
const sessionId = stringValue(sessionRef?.sessionId) ?? stringValue(latestAttempt?.sessionId);
|
|
if (sessionId) summary.sessionId = sessionId;
|
|
if (record?.readCursor !== undefined) summary.read = record.readCursor !== null;
|
|
return summary;
|
|
}
|
|
|
|
function queueItems(value: JsonValue | undefined): JsonRecord[] {
|
|
if (!Array.isArray(value)) throw new AgentRunError("schema-invalid", "queue response.items must be an array", { httpStatus: 2 });
|
|
return value.map((item) => {
|
|
const record = jsonRecordValue(item);
|
|
if (!record) throw new AgentRunError("schema-invalid", "queue response item must be an object", { httpStatus: 2 });
|
|
return record;
|
|
});
|
|
}
|
|
|
|
function sessionEventDrillDownCommands(kind: "trace" | "output", sessionId: string, options: { afterSeq: number; limit: number; runId: string | null }): JsonRecord {
|
|
const base = `./scripts/agentrun sessions ${kind} ${sessionId} --after-seq ${options.afterSeq} --limit ${options.limit}${options.runId ? ` --run-id ${options.runId}` : ""}`;
|
|
return {
|
|
full: `${base} --full`,
|
|
raw: `${base} --raw`,
|
|
next: `./scripts/agentrun sessions ${kind} ${sessionId} --after-seq <nextAfterSeq> --limit ${options.limit}${options.runId ? ` --run-id ${options.runId}` : ""}`,
|
|
};
|
|
}
|
|
|
|
function summarizeQueueTaskRecord(record: JsonRecord | null, fallbackTaskId: string): JsonRecord {
|
|
return withoutFullRecordBytes(compactRecord(record, {
|
|
fallback: { id: fallbackTaskId },
|
|
keys: ["id", "state", "queue", "lane", "title", "priority", "backendProfile", "providerId", "sessionPath", "version", "updatedAt", "cancelledAt", "cancelReason", "readerId", "attentionState", "unread", "active"],
|
|
}));
|
|
}
|
|
|
|
function summarizeAttemptRecord(record: JsonRecord | null): JsonRecord | null {
|
|
if (!record) return null;
|
|
return withoutFullRecordBytes(compactRecord(record, { keys: ["attemptId", "state", "runId", "commandId", "runnerJobId", "sessionId", "sessionPath"] }));
|
|
}
|
|
|
|
function summarizeSupervisorRecord(record: JsonRecord | null): JsonRecord | null {
|
|
if (!record) return null;
|
|
const lastActivity = jsonRecordValue(record.lastActivity);
|
|
const timeoutBudget = jsonRecordValue(record.timeoutBudget);
|
|
const terminalClassification = jsonRecordValue(record.terminalClassification);
|
|
const diagnosis = jsonRecordValue(record.diagnosis);
|
|
return {
|
|
...withoutFullRecordBytes(compactRecord(record, { keys: ["source", "sessionId", "executionState", "attentionState", "active", "activeRunId", "activeCommandId", "attemptRunId", "attemptCommandId", "phase", "status", "terminalStatus", "failureKind", "runId", "commandId", "lastSeq", "lastEventAt", "lastEventAgeMs", "leaseRemainingMs", "leaseExpired"] })),
|
|
diagnosis: diagnosis ? summarizeDiagnosisRecord(diagnosis) : null,
|
|
terminalClassification: terminalClassification ? summarizeTerminalClassification(terminalClassification) : null,
|
|
lastActivity: lastActivity ? withoutFullRecordBytes(compactRecord(lastActivity, { keys: ["sourceSeq", "eventId", "activityKind", "type", "status", "toolName", "itemId", "ageMs", "summary"] })) : null,
|
|
timeoutBudget: timeoutBudget ? withoutFullRecordBytes(compactRecord(timeoutBudget, { keys: ["state", "timeoutKind", "timeoutMs", "elapsedMs", "idleElapsedMs", "remainingMs", "startedAt", "idleStartedAt", "lastActivityAt", "lastActivitySeq", "source"] })) : null,
|
|
recoveryActions: summarizeRecoveryActions(record.recoveryActions),
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function summarizeActiveSessionRecord(record: JsonRecord | null): JsonRecord | null {
|
|
if (!record) return null;
|
|
return withoutFullRecordBytes(compactRecord(record, { keys: ["sessionId", "sessionPath", "executionState", "attentionState", "active", "activeRunId", "activeCommandId", "lastRunId", "lastCommandId", "terminalStatus", "failureKind", "lastActivityAt", "updatedAt"] }));
|
|
}
|
|
|
|
function summarizeTerminalClassification(record: JsonRecord): JsonRecord {
|
|
return withoutFullRecordBytes(compactRecord(record, { keys: ["category", "confidence", "terminalStatus", "failureKind", "providerEvidence", "providerInterruption", "providerInterruptionKnown", "providerInterruptionReason", "retryInterruptionObserved", "retryInterruptionSeq", "retryInterruptionKind", "hardTimeout", "idleTimeout", "timeoutKind", "timeoutState", "transportDisconnectObserved", "transportDisconnectSeq", "reason"] }));
|
|
}
|
|
|
|
function summarizeRecoveryActions(value: JsonValue | undefined): JsonValue[] {
|
|
if (!Array.isArray(value)) return [];
|
|
return value.slice(0, 5).map((item) => withoutFullRecordBytes(compactRecord(jsonRecordValue(item), { keys: ["action", "operation", "resourceKind", "resourceName", "reason", "reasonHint", "reasonRequired", "inputKind", "runId", "commandId", "runnerJobId", "sessionId", "afterSeq", "limit", "failureMessage"] })));
|
|
}
|
|
|
|
function withoutFullRecordBytes(record: JsonRecord): JsonRecord {
|
|
const { fullRecordBytes: _omitted, ...rest } = record;
|
|
return rest;
|
|
}
|
|
|
|
function summarizeRunRecord(record: JsonRecord | null): JsonRecord | null {
|
|
if (!record) return null;
|
|
return compactRecord(record, { keys: ["id", "status", "terminalStatus", "failureKind", "failureMessage", "backendProfile", "providerId", "claimedBy", "leaseExpiresAt", "createdAt", "updatedAt"] });
|
|
}
|
|
|
|
function summarizeCommandRecord(record: JsonRecord | null): JsonRecord | null {
|
|
if (!record) return null;
|
|
return compactRecord(record, { keys: ["id", "runId", "seq", "type", "state", "terminalStatus", "failureKind", "failureMessage", "createdAt", "updatedAt", "acknowledgedAt"] });
|
|
}
|
|
|
|
function summarizeRunnerJobRecord(record: JsonRecord | null): JsonRecord | null {
|
|
if (!record) return null;
|
|
const runner = jsonRecordValue(record.runner);
|
|
const jobIdentity = jsonRecordValue(record.jobIdentity);
|
|
const kubernetes = jsonRecordValue(record.kubernetes);
|
|
return {
|
|
...compactRecord(record, { keys: ["action", "mutation", "runId", "commandId", "attemptId", "runnerId", "namespace", "jobName"] }),
|
|
logPath: stringValue(runner?.logPath),
|
|
backendProfile: stringValue(runner?.backendProfile),
|
|
diagnosis: summarizeRunnerJobDiagnosis(jsonRecordValue(record.diagnosis)),
|
|
jobUid: stringValue(jobIdentity?.uid),
|
|
created: kubernetes?.created === true,
|
|
warnings: Array.isArray(record.warnings) ? record.warnings.map((item) => boundedSummaryString(typeof item === "string" ? item : JSON.stringify(item), 240)).filter((item): item is string => Boolean(item)) : [],
|
|
fullResponseBytes: jsonByteLength(record),
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function summarizeRunnerJobDiagnosis(record: JsonRecord | null): JsonRecord | null {
|
|
if (!record) return null;
|
|
return withoutFullRecordBytes({
|
|
...compactRecord(record, { keys: ["category", "runnerLostSuspected", "phase", "evidenceLevel", "runId", "commandId", "runnerJobId", "attemptId", "runnerId", "jobName", "namespace", "logPath"] }),
|
|
nextActions: summarizeRecoveryActions(record.nextActions),
|
|
valuesPrinted: false,
|
|
});
|
|
}
|
|
|
|
function summarizeGenericRecord(record: JsonRecord | null): JsonRecord | null {
|
|
if (!record) return null;
|
|
return compactRecord(record, {
|
|
keys: ["id", "runId", "commandId", "sessionId", "readerId", "sessionVersion", "taskId", "taskVersion", "status", "state", "terminalStatus", "failureKind", "failureMessage", "updatedAt", "readAt", "cancelledAt", "cancelReason"],
|
|
});
|
|
}
|
|
|
|
function compactRecord(record: JsonRecord | null, options: { keys: string[]; fallback?: JsonRecord }): JsonRecord {
|
|
const result: JsonRecord = { ...(options.fallback ?? {}) };
|
|
if (!record) return result;
|
|
for (const key of options.keys) {
|
|
const value = record[key];
|
|
if (value === undefined) continue;
|
|
if (typeof value === "string") result[key] = boundedSummaryString(value, key.toLowerCase().includes("message") ? 300 : 900) ?? "";
|
|
else if (typeof value === "number" || typeof value === "boolean" || value === null) result[key] = value;
|
|
}
|
|
result.fullRecordBytes = jsonByteLength(record);
|
|
result.valuesPrinted = false;
|
|
return result;
|
|
}
|
|
|
|
function jsonByteLength(value: unknown): number {
|
|
try {
|
|
return Buffer.byteLength(JSON.stringify(redactJson(value)) ?? "null", "utf8");
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
function findNestedValue(value: unknown, key: string, depth = 0): unknown | null {
|
|
if (depth > 8 || value === null || value === undefined) return null;
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) {
|
|
const found = findNestedValue(item, key, depth + 1);
|
|
if (found !== null) return found;
|
|
}
|
|
return null;
|
|
}
|
|
if (typeof value !== "object") return null;
|
|
const record = value as Record<string, unknown>;
|
|
if (Object.prototype.hasOwnProperty.call(record, key)) return record[key] ?? null;
|
|
for (const entry of Object.values(record)) {
|
|
const found = findNestedValue(entry, key, depth + 1);
|
|
if (found !== null) return found;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function hasNestedKey(value: unknown, keys: readonly string[], depth = 0): boolean {
|
|
if (depth > 8 || value === null || value === undefined) return false;
|
|
if (Array.isArray(value)) return value.some((item) => hasNestedKey(item, keys, depth + 1));
|
|
if (typeof value !== "object") return false;
|
|
const record = value as Record<string, unknown>;
|
|
if (keys.some((key) => Object.prototype.hasOwnProperty.call(record, key))) return true;
|
|
return Object.values(record).some((entry) => hasNestedKey(entry, keys, depth + 1));
|
|
}
|
|
|
|
async function listSessions(args: ParsedArgs): Promise<JsonValue> {
|
|
const params = new URLSearchParams();
|
|
const state = optionalFlag(args, "state") ?? (args.flags.get("running") === true ? "running" : args.flags.get("unread") === true ? "unread" : args.flags.get("all") === true ? "all" : null);
|
|
const profile = optionalFlag(args, "profile") ?? optionalFlag(args, "backend-profile");
|
|
const readerId = optionalFlag(args, "reader-id");
|
|
const cursor = optionalFlag(args, "cursor");
|
|
const limit = optionalFlag(args, "limit");
|
|
if (state) params.set("state", state);
|
|
if (profile) params.set("profile", normalizeProfile(profile));
|
|
if (readerId) params.set("readerId", readerId);
|
|
if (cursor) params.set("cursor", cursor);
|
|
if (limit) params.set("limit", limit);
|
|
const query = params.toString();
|
|
return client(args).get(`/api/v1/sessions${query ? `?${query}` : ""}`);
|
|
}
|
|
|
|
async function sessionEvents(args: ParsedArgs, sessionId: string, kind: "trace" | "output"): Promise<JsonValue> {
|
|
const params = new URLSearchParams();
|
|
const requestedSeq = optionalIntegerFlag(args, "seq", { min: 0 });
|
|
const hasExplicitAfterSeq = optionalFlag(args, "after-seq") !== null;
|
|
const afterSeq = requestedSeq !== null && !hasExplicitAfterSeq ? Math.max(0, requestedSeq - 1) : integerFlag(args, "after-seq", 0, { min: 0 });
|
|
const limit = requestedSeq !== null && optionalFlag(args, "limit") === null ? 1 : integerFlag(args, "limit", 100, { min: 1, max: 500 });
|
|
const runId = optionalFlag(args, "run-id");
|
|
const detailFilter = sessionEventDetailFilter(args, requestedSeq);
|
|
if (detailFilter && requestedSeq === null && !hasExplicitAfterSeq && (detailFilter.eventId || detailFilter.itemId)) {
|
|
return scanSessionEventDetail(args, { kind, sessionId, runId, afterSeq, limit, summaryChars: integerFlag(args, "summary-chars", 1_200, { min: 1, max: 8_000 }), filter: detailFilter });
|
|
}
|
|
params.set("afterSeq", String(afterSeq));
|
|
params.set("limit", String(limit));
|
|
if (runId) params.set("runId", runId);
|
|
const query = params.toString();
|
|
const page = await client(args).get(`/api/v1/sessions/${encodeURIComponent(sessionId)}/${kind}${query ? `?${query}` : ""}`);
|
|
if (detailFilter) return sessionEventDetailResult(page, { kind, sessionId, runId, summaryChars: integerFlag(args, "summary-chars", 1_200, { min: 1, max: 8_000 }), filter: detailFilter });
|
|
if (wantsExpandedOutput(args)) return page;
|
|
return summarizeSessionEventPage(page, {
|
|
kind,
|
|
sessionId,
|
|
afterSeq,
|
|
limit,
|
|
runId,
|
|
tail: tailFlag(args, limit),
|
|
summaryChars: integerFlag(args, "summary-chars", 900, { min: 1, max: 4_000 }),
|
|
includeOutput: args.flags.get("include-output") === true,
|
|
});
|
|
}
|
|
|
|
async function scanSessionEventDetail(args: ParsedArgs, options: { kind: "trace" | "output"; sessionId: string; runId: string | null; afterSeq: number; limit: number; summaryChars: number; filter: SessionEventDetailFilter }): Promise<JsonRecord> {
|
|
const maxPages = integerFlag(args, "detail-scan-pages", 20, { min: 1, max: 100 });
|
|
let afterSeq = options.afterSeq;
|
|
let pagesScanned = 0;
|
|
let eventsScanned = 0;
|
|
while (pagesScanned < maxPages) {
|
|
const params = new URLSearchParams();
|
|
params.set("afterSeq", String(afterSeq));
|
|
params.set("limit", String(options.limit));
|
|
if (options.runId) params.set("runId", options.runId);
|
|
const query = params.toString();
|
|
const page = await client(args).get(`/api/v1/sessions/${encodeURIComponent(options.sessionId)}/${options.kind}${query ? `?${query}` : ""}`);
|
|
const events = eventPageItems(page);
|
|
pagesScanned += 1;
|
|
eventsScanned += events.length;
|
|
const matches = events.filter((event) => matchesSessionEventFilter(event, options.filter));
|
|
if (matches.length > 0) return sessionEventDetailResultFromMatches(page, matches, { kind: options.kind, sessionId: options.sessionId, runId: options.runId, summaryChars: options.summaryChars, filter: options.filter, pagesScanned, eventsScanned });
|
|
const lastSeq = events.length > 0 ? numberValue(events[events.length - 1]?.seq) : null;
|
|
const cursorSeq = numberValue(jsonRecordValue(page)?.cursor);
|
|
const nextSeq = cursorSeq ?? lastSeq;
|
|
if (nextSeq === null || nextSeq <= afterSeq) break;
|
|
afterSeq = nextSeq;
|
|
}
|
|
throw new AgentRunError("schema-invalid", "no session event matched --event-id/--item-id in scanned pages", {
|
|
httpStatus: 2,
|
|
details: {
|
|
filter: options.filter as unknown as JsonRecord,
|
|
pagesScanned,
|
|
eventsScanned,
|
|
nextAfterSeq: afterSeq,
|
|
hint: "use the detailCommands from the summary, pass --seq, or add --after-seq/--limit near the event if the trace is longer than the scan window",
|
|
},
|
|
});
|
|
}
|
|
|
|
async function sessionCreate(args: ParsedArgs, positionalSessionId: string | null): Promise<JsonRecord> {
|
|
const sessionId = positionalSessionId ?? optionalFlag(args, "session-id") ?? newSessionId();
|
|
const profile = normalizeProfile(optionalFlag(args, "profile") ?? optionalFlag(args, "backend-profile") ?? "codex");
|
|
const tenantId = optionalFlag(args, "tenant-id") ?? "unidesk";
|
|
const projectId = optionalFlag(args, "project-id") ?? "default";
|
|
const providerId = optionalFlag(args, "provider-id") ?? "G14";
|
|
const expiresInDays = Number(optionalFlag(args, "expires-in-days") ?? 30);
|
|
const expiresAt = new Date(Date.now() + Math.max(1, expiresInDays) * 24 * 60 * 60 * 1000).toISOString();
|
|
const created = await client(args).post("/api/v1/sessions", {
|
|
sessionId,
|
|
tenantId,
|
|
projectId,
|
|
backendProfile: profile,
|
|
expiresAt,
|
|
}) as { action: string; pvc: { pvcName: string; pvcPhase: string }; session: { sessionId: string; storageKind: string; codexRolloutSubdir: string } };
|
|
const storage = await client(args).get(`/api/v1/sessions/${encodeURIComponent(sessionId)}/storage`) as { pvcName: string; pvcPhase: string; storageSizeBytes: number | null };
|
|
return {
|
|
action: created.action,
|
|
session: created.session,
|
|
pvc: created.pvc,
|
|
storage,
|
|
pollCommands: {
|
|
show: `describe session/${sessionId}`,
|
|
logs: `logs session/${sessionId} --tail 100`,
|
|
send: `send session/${sessionId} --prompt-stdin`,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function sessionStorageGet(args: ParsedArgs, sessionId: string): Promise<JsonRecord> {
|
|
return (await client(args).get(`/api/v1/sessions/${encodeURIComponent(sessionId)}/storage`)) as JsonRecord;
|
|
}
|
|
|
|
async function sessionStorageDelete(args: ParsedArgs, sessionId: string): Promise<JsonRecord> {
|
|
return (await client(args).delete(`/api/v1/sessions/${encodeURIComponent(sessionId)}/storage`)) as JsonRecord;
|
|
}
|
|
|
|
async function sessionSend(args: ParsedArgs, positionalSessionId: string | null): Promise<JsonRecord> {
|
|
const aipod = optionalFlag(args, "aipod") ?? optionalFlag(args, "aipod-spec");
|
|
if (aipod) return sessionSendWithAipod(args, positionalSessionId, aipod);
|
|
const body = await optionalJsonFile(args);
|
|
const sessionId = positionalSessionId ?? optionalFlag(args, "session-id") ?? newSessionId();
|
|
const requestedProfile = optionalFlag(args, "profile") ?? optionalFlag(args, "backend-profile") ?? (typeof body.backendProfile === "string" ? String(body.backendProfile) : "codex");
|
|
const profile = normalizeProfile(requestedProfile);
|
|
const prompt = await readPrompt(args);
|
|
body.tenantId = optionalFlag(args, "tenant-id") ?? stringField(body, "tenantId", "unidesk");
|
|
body.projectId = optionalFlag(args, "project-id") ?? stringField(body, "projectId", "default");
|
|
body.providerId = optionalFlag(args, "provider-id") ?? stringField(body, "providerId", "G14");
|
|
body.backendProfile = profile;
|
|
body.workspaceRef = jsonObjectFlag(args, "workspace-json") ?? objectField(body, "workspaceRef", { kind: "opaque", path: "." });
|
|
body.executionPolicy = jsonObjectFlag(args, "execution-policy-json") ?? objectField(body, "executionPolicy", defaultExecutionPolicy(profile));
|
|
body.traceSink = body.traceSink ?? null;
|
|
const sessionRef = objectField(body, "sessionRef", {});
|
|
const sessionMetadata = objectField(sessionRef, "metadata", {});
|
|
const title = optionalFlag(args, "title");
|
|
if (title) sessionMetadata.title = title;
|
|
body.sessionRef = { ...sessionRef, sessionId, metadata: sessionMetadata };
|
|
const runnerBody = await sessionRunnerJobBody(args);
|
|
const sendBody: JsonRecord = {
|
|
run: body,
|
|
payload: { prompt },
|
|
createRunnerJob: args.flags.get("no-runner-job") !== true,
|
|
runnerJob: runnerBody,
|
|
dryRun: args.flags.get("dry-run") === true,
|
|
};
|
|
const commandIdempotencyKey = optionalFlag(args, "command-idempotency-key") ?? optionalFlag(args, "idempotency-key");
|
|
if (commandIdempotencyKey) sendBody.commandIdempotencyKey = commandIdempotencyKey;
|
|
if (args.flags.get("dry-run") !== true) await ensureSessionForSend(args, sessionId, body.tenantId as string, body.projectId as string, profile);
|
|
const result = await client(args).post(`/api/v1/sessions/${encodeURIComponent(sessionId)}/send`, sendBody);
|
|
if (wantsExpandedOutput(args)) return { action: "session-send", result: result as JsonValue, valuesPrinted: false };
|
|
return summarizeSessionSendResult(result, sessionId, profile);
|
|
}
|
|
|
|
async function sessionCancel(args: ParsedArgs, sessionId: string): Promise<JsonRecord> {
|
|
const result = await client(args).post(`/api/v1/sessions/${encodeURIComponent(sessionId)}/control`, { action: "cancel", ...cancelBody(args) });
|
|
if (wantsExpandedOutput(args)) return { action: "session-cancel", sessionId, result: result as JsonValue };
|
|
return summarizeSessionMutationResult("session-cancel", sessionId, result, { cancelled: true });
|
|
}
|
|
|
|
async function sessionRead(args: ParsedArgs, sessionId: string): Promise<JsonValue> {
|
|
const result = await client(args).post(`/api/v1/sessions/${encodeURIComponent(sessionId)}/read`, { readerId: optionalFlag(args, "reader-id") ?? "cli" });
|
|
if (wantsExpandedOutput(args)) return result;
|
|
return summarizeSessionMutationResult("session-read", sessionId, result, { read: true });
|
|
}
|
|
|
|
async function renderAipodSpecCli(args: ParsedArgs, name: string): Promise<JsonValue> {
|
|
const input = await aipodRenderInput(args, 3);
|
|
return client(args).post(`/api/v1/aipod-specs/${encodeURIComponent(name)}/render`, input);
|
|
}
|
|
|
|
async function renderAipodForCommand(args: ParsedArgs, name: string, trailingPromptStart: number, overrides: RenderAipodInput = {}): Promise<RenderedAipodQueueTask> {
|
|
const input = await aipodRenderInput(args, trailingPromptStart, overrides);
|
|
return await client(args).post(`/api/v1/aipod-specs/${encodeURIComponent(name)}/render`, input) as RenderedAipodQueueTask;
|
|
}
|
|
|
|
async function aipodRenderInput(args: ParsedArgs, trailingPromptStart: number, overrides: RenderAipodInput = {}): Promise<RenderAipodInput> {
|
|
const input = await optionalJsonFile(args) as RenderAipodInput;
|
|
const prompt = await optionalPrompt(args, trailingPromptStart);
|
|
if (prompt) input.prompt = prompt;
|
|
copyOptionalFlag(args, input as JsonRecord, "tenant-id", "tenantId");
|
|
copyOptionalFlag(args, input as JsonRecord, "project-id", "projectId");
|
|
copyOptionalFlag(args, input as JsonRecord, "queue");
|
|
copyOptionalFlag(args, input as JsonRecord, "lane");
|
|
copyOptionalFlag(args, input as JsonRecord, "title");
|
|
copyOptionalFlag(args, input as JsonRecord, "provider-id", "providerId");
|
|
const profile = optionalFlag(args, "profile") ?? optionalFlag(args, "backend-profile");
|
|
if (profile) input.backendProfile = normalizeProfile(profile);
|
|
const priority = optionalFlag(args, "priority");
|
|
if (priority) input.priority = Number(priority);
|
|
copyOptionalFlag(args, input as JsonRecord, "idempotency-key", "idempotencyKey");
|
|
copyOptionalFlag(args, input as JsonRecord, "session-id", "sessionId");
|
|
const workspaceRef = jsonObjectFlag(args, "workspace-json");
|
|
if (workspaceRef) input.workspaceRef = workspaceRef as never;
|
|
return { ...input, ...overrides };
|
|
}
|
|
|
|
async function applyAipodSpecCli(args: ParsedArgs, name: string | null): Promise<JsonValue> {
|
|
const yaml = await aipodYamlInput(args);
|
|
const pathValue = name ? `/api/v1/aipod-specs/${encodeURIComponent(name)}` : "/api/v1/aipod-specs";
|
|
const method = name ? "PUT" : "POST";
|
|
if (args.flags.get("dry-run") === true) {
|
|
return { action: "aipod-spec-apply-plan", dryRun: true, mutation: false, request: { method, path: pathValue, yamlBytes: Buffer.byteLength(yaml, "utf8"), valuesPrinted: false }, next: { confirm: `./scripts/agentrun aipod-specs apply${name ? ` ${name}` : ""} --yaml-stdin` }, valuesPrinted: false };
|
|
}
|
|
return name ? client(args).put(pathValue, { yaml }) : client(args).post(pathValue, { yaml });
|
|
}
|
|
|
|
async function aipodYamlInput(args: ParsedArgs): Promise<string> {
|
|
if (args.flags.get("yaml-stdin") === true) return readStdinText();
|
|
const file = optionalFlag(args, "yaml-file");
|
|
if (!file) throw new AgentRunError("schema-invalid", "aipod-spec YAML input is required; use --yaml-stdin or --yaml-file <file>", { httpStatus: 2 });
|
|
return readFile(file, "utf8");
|
|
}
|
|
|
|
async function submitQueueTaskWithAipod(args: ParsedArgs, aipod: string): Promise<JsonValue> {
|
|
const rendered = await renderAipodForCommand(args, aipod, 2);
|
|
const body = rendered.queueTask as unknown as JsonRecord;
|
|
if (args.flags.get("dry-run") === true) {
|
|
return queueMutationDryRunPlan("queue-submit", null, "/api/v1/queue/tasks", body, "POST", queueSubmitAipodConfirmCommand(args, aipod), undefined, { source: "aipod-spec", aipod, preferred: "--aipod", valuesPrinted: false });
|
|
}
|
|
return client(args).post("/api/v1/queue/tasks", body);
|
|
}
|
|
|
|
async function sessionSendWithAipod(args: ParsedArgs, positionalSessionId: string | null, aipod: string): Promise<JsonRecord> {
|
|
const sessionId = positionalSessionId ?? optionalFlag(args, "session-id") ?? newSessionId();
|
|
const rendered = await renderAipodForCommand(args, aipod, positionalSessionId ? 3 : 2, { sessionId });
|
|
const task = rendered.queueTask;
|
|
const profile = String(task.backendProfile);
|
|
const sessionRef = objectField(task as unknown as JsonRecord, "sessionRef", {});
|
|
const metadata = objectField(sessionRef, "metadata", {});
|
|
const title = optionalFlag(args, "title") ?? task.title;
|
|
if (title) metadata.title = title;
|
|
const runBody: JsonRecord = {
|
|
tenantId: task.tenantId,
|
|
projectId: task.projectId,
|
|
providerId: task.providerId ?? "G14",
|
|
backendProfile: task.backendProfile,
|
|
workspaceRef: task.workspaceRef ?? { kind: "opaque", path: "." },
|
|
sessionRef: { ...sessionRef, sessionId, metadata },
|
|
executionPolicy: task.executionPolicy,
|
|
resourceBundleRef: task.resourceBundleRef,
|
|
traceSink: { kind: "aipod-session", aipod, sessionId, valuesPrinted: false },
|
|
};
|
|
const runnerDefaults = jsonRecordValue(rendered.dispatchDefaults.runnerJob) ?? {};
|
|
const runnerBody = await sessionRunnerJobBody(args, runnerDefaults);
|
|
const sendBody: JsonRecord = {
|
|
run: runBody,
|
|
payload: task.payload,
|
|
createRunnerJob: args.flags.get("no-runner-job") !== true,
|
|
runnerJob: runnerBody,
|
|
dryRun: args.flags.get("dry-run") === true,
|
|
};
|
|
const commandIdempotencyKey = optionalFlag(args, "command-idempotency-key") ?? optionalFlag(args, "idempotency-key");
|
|
if (commandIdempotencyKey) sendBody.commandIdempotencyKey = commandIdempotencyKey;
|
|
if (args.flags.get("dry-run") !== true) await ensureSessionForSend(args, sessionId, String(task.tenantId), String(task.projectId), profile);
|
|
const result = await client(args).post(`/api/v1/sessions/${encodeURIComponent(sessionId)}/send`, sendBody);
|
|
if (wantsExpandedOutput(args)) return { action: "session-send", aipod, result: result as JsonValue, valuesPrinted: false };
|
|
return summarizeSessionSendResult(result, sessionId, profile, aipod);
|
|
}
|
|
|
|
async function submitQueueTask(args: ParsedArgs): Promise<JsonValue> {
|
|
const aipod = optionalFlag(args, "aipod") ?? optionalFlag(args, "aipod-spec");
|
|
if (aipod) return submitQueueTaskWithAipod(args, aipod);
|
|
const body = await jsonFile(args);
|
|
const idempotencyKey = optionalFlag(args, "idempotency-key");
|
|
if (idempotencyKey) body.idempotencyKey = idempotencyKey;
|
|
if (args.flags.get("dry-run") === true) {
|
|
return queueMutationDryRunPlan("queue-submit", null, "/api/v1/queue/tasks", body, "POST", queueSubmitConfirmCommand(args), undefined, jsonInputDisclosure(args, "<task.json>"));
|
|
}
|
|
return client(args).post("/api/v1/queue/tasks", body);
|
|
}
|
|
|
|
async function listQueueTasks(args: ParsedArgs): Promise<JsonValue> {
|
|
const params = new URLSearchParams();
|
|
const queue = optionalFlag(args, "queue");
|
|
const state = optionalFlag(args, "state");
|
|
const cursor = optionalFlag(args, "cursor");
|
|
const limit = optionalFlag(args, "limit");
|
|
const updatedAfter = optionalFlag(args, "updated-after");
|
|
if (queue) params.set("queue", queue);
|
|
if (state) params.set("state", state);
|
|
if (cursor) params.set("cursor", cursor);
|
|
if (limit) params.set("limit", limit);
|
|
if (updatedAfter) params.set("updatedAfter", updatedAfter);
|
|
const query = params.toString();
|
|
const result = await client(args).get(`/api/v1/queue/tasks${query ? `?${query}` : ""}`);
|
|
if (wantsExpandedOutput(args)) return result;
|
|
return summarizeQueueTaskListResult(result, { limit: integerFlag(args, "limit", 20, { min: 1, max: 100 }) });
|
|
}
|
|
|
|
async function showQueueTask(args: ParsedArgs, taskId: string): Promise<JsonValue> {
|
|
const result = await client(args).get(`/api/v1/queue/tasks/${encodeURIComponent(taskId)}`);
|
|
if (wantsExpandedOutput(args)) return result;
|
|
return summarizeQueueTaskShowResult(result, taskId);
|
|
}
|
|
|
|
async function queueCommander(args: ParsedArgs): Promise<JsonValue> {
|
|
const result = await client(args).get(`/api/v1/queue/commander${queueQuery(args, { readerId: true })}`);
|
|
if (wantsExpandedOutput(args)) return result;
|
|
return summarizeQueueCommanderSnapshot(result, { limit: integerFlag(args, "limit", 20, { min: 1, max: 100 }) });
|
|
}
|
|
|
|
function queueQuery(args: ParsedArgs, options: { readerId?: boolean } = {}): string {
|
|
const params = new URLSearchParams();
|
|
const queue = optionalFlag(args, "queue");
|
|
if (queue) params.set("queue", queue);
|
|
if (options.readerId) {
|
|
const readerId = optionalFlag(args, "reader-id");
|
|
if (readerId) params.set("readerId", readerId);
|
|
}
|
|
const query = params.toString();
|
|
return query ? `?${query}` : "";
|
|
}
|
|
|
|
async function dispatchQueueTask(args: ParsedArgs, taskId: string): Promise<JsonValue> {
|
|
const body = await queueDispatchBody(args);
|
|
if (args.flags.get("dry-run") === true) {
|
|
const task = await client(args).get(`/api/v1/queue/tasks/${encodeURIComponent(taskId)}`);
|
|
return queueMutationDryRunPlan("queue-dispatch", taskId, `/api/v1/queue/tasks/${encodeURIComponent(taskId)}/dispatch`, body, "POST", queueDispatchConfirmCommand(args, taskId), task, jsonInputDisclosure(args, "<dispatch.json>", { required: false }));
|
|
}
|
|
const result = await client(args).post(`/api/v1/queue/tasks/${encodeURIComponent(taskId)}/dispatch`, body);
|
|
if (wantsExpandedOutput(args)) return result;
|
|
return summarizeQueueDispatchResult(result, taskId);
|
|
}
|
|
|
|
async function queueDispatchBody(args: ParsedArgs): Promise<JsonRecord> {
|
|
const body = await optionalJsonFile(args);
|
|
const copy = (flagName: string, key = flagName.replace(/-([a-z])/gu, (_, letter: string) => letter.toUpperCase())): void => {
|
|
const value = optionalFlag(args, flagName);
|
|
if (value) body[key] = value;
|
|
};
|
|
copy("idempotency-key", "idempotencyKey");
|
|
copy("image");
|
|
copy("namespace");
|
|
copy("attempt-id", "attemptId");
|
|
copy("runner-id", "runnerId");
|
|
copy("source-commit", "sourceCommit");
|
|
copyRunnerManagerUrlFlag(args, body);
|
|
copy("service-account-name", "serviceAccountName");
|
|
return body;
|
|
}
|
|
|
|
async function readQueueTask(args: ParsedArgs, taskId: string): Promise<JsonValue> {
|
|
const body = { readerId: optionalFlag(args, "reader-id") ?? "cli" };
|
|
if (args.flags.get("dry-run") === true) return queueMutationDryRunPlan("queue-read", taskId, `/api/v1/queue/tasks/${encodeURIComponent(taskId)}/read`, body, "POST", `./scripts/agentrun queue read ${taskId}`);
|
|
const result = await client(args).post(`/api/v1/queue/tasks/${encodeURIComponent(taskId)}/read`, body);
|
|
if (wantsExpandedOutput(args)) return result;
|
|
return summarizeQueueTaskMutationResult("queue-read", taskId, result, { read: true });
|
|
}
|
|
|
|
async function cancelQueueTask(args: ParsedArgs, taskId: string): Promise<JsonValue> {
|
|
const body = cancelBody(args);
|
|
if (args.flags.get("dry-run") === true) return queueMutationDryRunPlan("queue-cancel", taskId, `/api/v1/queue/tasks/${encodeURIComponent(taskId)}/cancel`, body, "POST", `./scripts/agentrun queue cancel ${taskId}${body.reason ? " --reason <text>" : ""}`);
|
|
const result = await client(args).post(`/api/v1/queue/tasks/${encodeURIComponent(taskId)}/cancel`, body);
|
|
if (wantsExpandedOutput(args)) return result;
|
|
return summarizeQueueTaskMutationResult("queue-cancel", taskId, result, { cancelled: true });
|
|
}
|
|
|
|
async function refreshQueueTask(args: ParsedArgs, taskId: string): Promise<JsonValue> {
|
|
const body: JsonRecord = {};
|
|
if (args.flags.get("dry-run") === true) return queueMutationDryRunPlan("queue-refresh", taskId, `/api/v1/queue/tasks/${encodeURIComponent(taskId)}/refresh`, body, "POST", `./scripts/agentrun queue refresh ${taskId}`);
|
|
const result = await client(args).post(`/api/v1/queue/tasks/${encodeURIComponent(taskId)}/refresh`, body);
|
|
if (wantsExpandedOutput(args)) return result;
|
|
return summarizeQueueTaskMutationResult("queue-refresh", taskId, result, { refreshed: true });
|
|
}
|
|
|
|
async function showRunnerJobStatus(args: ParsedArgs): Promise<JsonValue> {
|
|
const runId = flag(args, "run-id", "");
|
|
if (!runId) throw new AgentRunError("schema-invalid", "runner job-status requires --run-id", { httpStatus: 2 });
|
|
const runnerJobId = args.positional[2] ?? optionalFlag(args, "runner-job-id");
|
|
if (!runnerJobId) return listRunnerJobs(args);
|
|
return client(args).get(`/api/v1/runs/${encodeURIComponent(runId)}/runner-jobs/${encodeURIComponent(runnerJobId)}`);
|
|
}
|
|
|
|
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");
|
|
const idempotencyKey = optionalFlag(args, "idempotency-key");
|
|
if (namespace) body.namespace = namespace;
|
|
if (attemptId) body.attemptId = attemptId;
|
|
if (runnerId) body.runnerId = runnerId;
|
|
if (sourceCommit) body.sourceCommit = sourceCommit;
|
|
if (runnerManagerUrl) body.managerUrl = resolveRunnerManagerUrlFlag(args, runnerManagerUrl);
|
|
if (idempotencyKey) body.idempotencyKey = idempotencyKey;
|
|
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;
|
|
let sessionPvc: RunnerSessionPvcOptions | undefined;
|
|
if (run.sessionRef?.sessionId) {
|
|
try {
|
|
const session = await client(args).get(`/api/v1/sessions/${encodeURIComponent(run.sessionRef.sessionId)}`) as { storageKind?: string; storagePvcName?: string; storageNamespace?: string; codexRolloutSubdir?: string };
|
|
if (session?.storageKind === "pvc" && session.storagePvcName) {
|
|
const subdir = session.codexRolloutSubdir ?? "sessions";
|
|
sessionPvc = { pvcName: session.storagePvcName, namespace: session.storageNamespace ?? "agentrun-v01", mountPath: `/home/agentrun/.codex-${run.backendProfile}/${subdir}`, codexRolloutSubdir: subdir };
|
|
}
|
|
} catch { /* session not found, skip */ }
|
|
}
|
|
const options = {
|
|
run,
|
|
commandId,
|
|
image,
|
|
managerUrl: managerUrl(args),
|
|
namespace: optionalFlag(args, "namespace") ?? "agentrun-v01",
|
|
...(sessionPvc ? { sessionPvc } : {}),
|
|
};
|
|
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 profile = optionalFlag(args, "profile");
|
|
const codexHome = optionalFlag(args, "codex-home");
|
|
const authFile = optionalFlag(args, "auth-file");
|
|
const configFile = optionalFlag(args, "config-file");
|
|
const modelCatalogFile = optionalFlag(args, "model-catalog-file");
|
|
const namespace = optionalFlag(args, "namespace");
|
|
const secretName = optionalFlag(args, "secret-name");
|
|
if (profile) options.profile = profile;
|
|
if (codexHome) options.codexHome = codexHome;
|
|
if (authFile) options.authFile = authFile;
|
|
if (configFile) options.configFile = configFile;
|
|
if (modelCatalogFile) options.modelCatalogFile = modelCatalogFile;
|
|
if (namespace) options.namespace = namespace;
|
|
if (secretName) options.secretName = secretName;
|
|
return renderCodexProviderSecretPlan(options);
|
|
}
|
|
|
|
async function setGithubSshToolCredentialCli(args: ParsedArgs): Promise<JsonRecord> {
|
|
const privateKey = await textFromFileFlag(args, "private-key-file", "private key");
|
|
const knownHosts = await textFromFileFlag(args, "known-hosts-file", "known_hosts");
|
|
const configFile = optionalFlag(args, "config-file");
|
|
const body: JsonRecord = { privateKey, knownHosts };
|
|
if (configFile) body.config = await readFile(configFile, "utf8");
|
|
if (args.flags.get("dry-run") === true) {
|
|
return {
|
|
action: "tool-credential-github-ssh-plan",
|
|
mutation: false,
|
|
dryRun: true,
|
|
secretRef: { namespace: "agentrun-v01", name: "agentrun-v01-tool-github-ssh", keys: ["id_ed25519", "known_hosts", "config"], valuesPrinted: false },
|
|
inputs: {
|
|
privateKey: fileSummary("private-key-file", privateKey),
|
|
knownHosts: fileSummary("known-hosts-file", knownHosts),
|
|
config: configFile ? fileSummary("config-file", String(body.config)) : { provided: false, defaulted: true, valuesPrinted: false },
|
|
},
|
|
confirm: "./scripts/agentrun tool-credentials set-github-ssh --private-key-file <key> --known-hosts-file <known_hosts> [--config-file <ssh_config>]",
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
return await client(args).put("/api/v1/tool-credentials/github-ssh/credential", body) as JsonRecord;
|
|
}
|
|
|
|
async function textFromFileFlag(args: ParsedArgs, flagName: string, label: string): Promise<string> {
|
|
const file = optionalFlag(args, flagName);
|
|
if (!file) throw new AgentRunError("schema-invalid", `tool-credentials set-github-ssh requires --${flagName} <file> for ${label}`, { httpStatus: 2 });
|
|
return await readFile(file, "utf8");
|
|
}
|
|
|
|
function fileSummary(flagName: string, text: string): JsonRecord {
|
|
return { flag: flagName, bytes: Buffer.byteLength(text, "utf8"), valuesPrinted: false };
|
|
}
|
|
|
|
async function setProviderProfileKey(args: ParsedArgs, profileValue: string): Promise<JsonRecord> {
|
|
const profile = normalizeProfile(profileValue);
|
|
if (args.flags.get("key-stdin") !== true) throw new AgentRunError("schema-invalid", "provider-profiles set-key requires --key-stdin", { httpStatus: 2 });
|
|
const apiKey = (await readStdinText()).trim();
|
|
if (apiKey.length === 0) throw new AgentRunError("schema-invalid", "stdin api key is empty", { httpStatus: 2 });
|
|
const body: JsonRecord = {
|
|
apiKey,
|
|
reason: optionalFlag(args, "reason") ?? "operator-cli",
|
|
delegatedBy: {
|
|
system: optionalFlag(args, "delegated-system") ?? "operator-cli",
|
|
userId: optionalFlag(args, "delegated-user-id") ?? null,
|
|
username: optionalFlag(args, "delegated-username") ?? null,
|
|
requestId: optionalFlag(args, "delegated-request-id") ?? null,
|
|
},
|
|
};
|
|
const config: JsonRecord = {};
|
|
copyOptionalFlag(args, config, "model");
|
|
copyOptionalFlag(args, config, "base-url", "baseUrl");
|
|
copyOptionalFlag(args, config, "provider-name", "providerName");
|
|
copyOptionalFlag(args, config, "env-key", "envKey");
|
|
if (Object.keys(config).length > 0) body.config = config;
|
|
return await client(args).put(`/api/v1/provider-profiles/${encodeURIComponent(profile)}/credential`, body) as JsonRecord;
|
|
}
|
|
|
|
async function setProviderProfileConfig(args: ParsedArgs, profileValue: string): Promise<JsonRecord> {
|
|
const profile = normalizeProfile(profileValue);
|
|
if (args.flags.get("config-stdin") !== true) throw new AgentRunError("schema-invalid", "provider-profiles set-config requires --config-stdin", { httpStatus: 2 });
|
|
const configToml = await readStdinText();
|
|
if (configToml.trim().length === 0) throw new AgentRunError("schema-invalid", "stdin config.toml is empty", { httpStatus: 2 });
|
|
return await client(args).put(`/api/v1/provider-profiles/${encodeURIComponent(profile)}/config`, {
|
|
configToml,
|
|
reason: optionalFlag(args, "reason") ?? "operator-cli",
|
|
delegatedBy: {
|
|
system: optionalFlag(args, "delegated-system") ?? "operator-cli",
|
|
userId: optionalFlag(args, "delegated-user-id") ?? null,
|
|
username: optionalFlag(args, "delegated-username") ?? null,
|
|
requestId: optionalFlag(args, "delegated-request-id") ?? null,
|
|
},
|
|
}) as JsonRecord;
|
|
}
|
|
|
|
async function removeProviderProfileCli(profileValue: string, args: ParsedArgs): Promise<JsonRecord> {
|
|
const profile = normalizeProfile(profileValue);
|
|
return await client(args).delete(`/api/v1/provider-profiles/${encodeURIComponent(profile)}`) as JsonRecord;
|
|
}
|
|
|
|
async function validateProviderProfileCli(args: ParsedArgs, profileValue: string): Promise<JsonRecord> {
|
|
const profile = normalizeProfile(profileValue);
|
|
const started = await client(args).post(`/api/v1/provider-profiles/${encodeURIComponent(profile)}/validate`, {}) as JsonRecord;
|
|
if (args.flags.get("wait") !== true) return started;
|
|
const validationId = typeof started.validationId === "string" ? started.validationId : "";
|
|
if (!validationId) throw new AgentRunError("infra-failed", "provider profile validate response omitted validationId", { httpStatus: 1, details: started });
|
|
const timeoutMs = Math.max(1, Number(optionalFlag(args, "timeout-ms") ?? 60_000));
|
|
const deadline = Date.now() + timeoutMs;
|
|
let latest: JsonRecord = started;
|
|
while (Date.now() < deadline) {
|
|
await sleep(2_000);
|
|
latest = await client(args).get(`/api/v1/provider-profiles/${encodeURIComponent(profile)}/validations/${encodeURIComponent(validationId)}`) as JsonRecord;
|
|
if (latest.status === "completed" || latest.status === "failed" || latest.status === "cancelled") return { action: "provider-profile-validation", initial: started, latest, valuesPrinted: false };
|
|
}
|
|
return { action: "provider-profile-validation", initial: started, latest, timedOut: true, timeoutMs, valuesPrinted: false };
|
|
}
|
|
|
|
async function startServer(args: ParsedArgs): Promise<JsonRecord> {
|
|
if (args.flags.get("foreground") === true) return startServerForeground(args);
|
|
const port = Number(flag(args, "port", "8080"));
|
|
const host = flag(args, "host", "0.0.0.0");
|
|
const state = await readServerState(port);
|
|
if (state.pidAlive || state.portListening) {
|
|
throw new AgentRunError("infra-failed", `agentrun-mgr already appears to be running on port ${port}; use server status or server stop first`, { httpStatus: 409, details: state as JsonRecord });
|
|
}
|
|
await ensureDir(stateDir());
|
|
await ensureDir(logDir());
|
|
const logPath = serverLogPath(port);
|
|
const argsForChild = [process.argv[1] ?? "scripts/agentrun-cli.ts", "server", "start", "--foreground", "--host", host, "--port", String(port)];
|
|
const store = optionalFlag(args, "store");
|
|
if (store) argsForChild.push("--store", store);
|
|
const stdoutFd = openSync(logPath, "a");
|
|
const stderrFd = openSync(logPath, "a");
|
|
const child = spawn(process.execPath, argsForChild, {
|
|
cwd: process.cwd(),
|
|
env: process.env,
|
|
detached: true,
|
|
stdio: ["ignore", stdoutFd, stderrFd],
|
|
});
|
|
closeSync(stdoutFd);
|
|
closeSync(stderrFd);
|
|
child.unref();
|
|
const pidFile = pidFilePath(port);
|
|
await writeFile(pidFile, JSON.stringify({ pid: child.pid, port, host, logPath, startedAt: new Date().toISOString() }) + "\n", "utf8");
|
|
const localBaseUrl = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${port}`;
|
|
return { action: "server-start", mode: "background", serviceId: "agentrun-mgr", pid: child.pid ?? null, port, host, baseUrl: localBaseUrl, pidFile, logPath, pollCommands: { status: `./scripts/agentrun server status --port ${port}`, logs: `./scripts/agentrun server logs --port ${port}`, stop: `./scripts/agentrun server stop --port ${port}` } };
|
|
}
|
|
|
|
async function startServerForeground(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, mode: "foreground", note: "foreground process; use server start without --foreground for local background mode" };
|
|
}
|
|
|
|
async function serverStatus(args: ParsedArgs): Promise<JsonRecord> {
|
|
const explicitPort = optionalFlag(args, "port");
|
|
const baseUrl = explicitPort ? `http://127.0.0.1:${Number(explicitPort)}` : managerUrl(args);
|
|
const port = Number(explicitPort ?? portFromUrl(baseUrl));
|
|
const state = await readServerState(port);
|
|
let readiness: JsonValue = null;
|
|
let readinessFailure: JsonRecord | null = null;
|
|
if (explicitPort ? state.portListening : true) {
|
|
try {
|
|
readiness = await new ManagerClient(baseUrl).get("/health/readiness");
|
|
} catch (error) {
|
|
readinessFailure = errorToJson(error);
|
|
}
|
|
}
|
|
return { action: "server-status", serviceId: "agentrun-mgr", port, baseUrl, local: state as JsonRecord, readiness, readinessFailure, pollCommands: { status: `./scripts/agentrun server status --port ${port}`, logs: `./scripts/agentrun server logs --port ${port}`, stop: `./scripts/agentrun server stop --port ${port}` } };
|
|
}
|
|
|
|
async function serverLogs(args: ParsedArgs): Promise<JsonRecord> {
|
|
const port = Number(flag(args, "port", portFromManagerUrl(args)));
|
|
const state = await readServerState(port);
|
|
const logPath = optionalFlag(args, "log-file") ?? (typeof state.logPath === "string" ? state.logPath : null);
|
|
const tailBytes = Number(flag(args, "tail-bytes", "12000"));
|
|
if (!logPath) return { action: "server-logs", serviceId: "agentrun-mgr", port, logPath: null, exists: false, tail: "", bytes: 0, truncated: false, message: "no log file recorded for this port" };
|
|
if (!existsSync(logPath)) return { action: "server-logs", serviceId: "agentrun-mgr", port, logPath, exists: false, tail: "", bytes: 0, truncated: false, message: "log file does not exist" };
|
|
const bytes = await readFile(logPath);
|
|
const start = Math.max(0, bytes.byteLength - tailBytes);
|
|
return { action: "server-logs", serviceId: "agentrun-mgr", port, logPath, exists: true, bytes: bytes.byteLength, tailBytes, truncated: start > 0, tail: bytes.subarray(start).toString("utf8") };
|
|
}
|
|
|
|
async function stopServer(args: ParsedArgs): Promise<JsonRecord> {
|
|
const port = Number(flag(args, "port", portFromManagerUrl(args)));
|
|
const before = await readServerState(port);
|
|
let signalSent = false;
|
|
const beforePid = typeof before.pid === "number" ? before.pid : null;
|
|
const beforePortPid = typeof before.portPid === "number" ? before.portPid : null;
|
|
if (before.pidAlive === true && beforePid !== null) {
|
|
process.kill(beforePid, "SIGTERM");
|
|
signalSent = true;
|
|
} else if (beforePortPid !== null) {
|
|
process.kill(beforePortPid, "SIGTERM");
|
|
signalSent = true;
|
|
}
|
|
await sleep(500);
|
|
const after = await readServerState(port);
|
|
if (!after.pidAlive && !after.portListening && existsSync(pidFilePath(port))) await rm(pidFilePath(port), { force: true });
|
|
return { action: "server-stop", serviceId: "agentrun-mgr", port, signalSent, before: before as JsonRecord, after: after as JsonRecord, stopped: !after.pidAlive && !after.portListening };
|
|
}
|
|
|
|
function client(args: ParsedArgs): ManagerClient {
|
|
return new ManagerClient(managerUrl(args));
|
|
}
|
|
|
|
function managerUrl(args: ParsedArgs): string {
|
|
const explicit = optionalFlag(args, "manager-url") ?? process.env.AGENTRUN_MGR_URL;
|
|
if (explicit && explicit !== "auto") return explicit;
|
|
if (explicit === "auto") return resolveRuntimeManagerUrl();
|
|
return "http://127.0.0.1:8080";
|
|
}
|
|
|
|
function managerEndpoint(args: ParsedArgs): JsonRecord {
|
|
const url = managerUrl(args);
|
|
const explicit = optionalFlag(args, "manager-url") ?? process.env.AGENTRUN_MGR_URL ?? null;
|
|
return {
|
|
action: "manager-endpoint",
|
|
managerUrl: url,
|
|
source: explicit === "auto" ? "auto" : explicit ? (optionalFlag(args, "manager-url") ? "--manager-url" : "AGENTRUN_MGR_URL") : "default-localhost",
|
|
runtimeNamespace: "agentrun-v01",
|
|
serviceName: "agentrun-mgr",
|
|
valuesPrinted: false,
|
|
examples: {
|
|
commander: "./scripts/agentrun --manager-url auto queue commander",
|
|
sessions: "./scripts/agentrun --manager-url auto sessions ps --state default --reader-id cli",
|
|
explicit: "./scripts/agentrun --manager-url http://<reachable-host>:8080 queue commander",
|
|
},
|
|
};
|
|
}
|
|
|
|
function resolveRuntimeManagerUrl(): string {
|
|
const fromEnv = process.env.AGENTRUN_RUNTIME_MANAGER_URL;
|
|
if (fromEnv) return fromEnv;
|
|
try {
|
|
const serviceIp = execFileSync("kubectl", ["-n", "agentrun-v01", "get", "svc", "agentrun-mgr", "-o", "jsonpath={.spec.clusterIP}"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
if (serviceIp.length > 0 && serviceIp !== "None") return `http://${serviceIp}:8080`;
|
|
} catch {
|
|
// Fall through to localhost so the following request produces a structured connection failure.
|
|
}
|
|
return "http://127.0.0.1:8080";
|
|
}
|
|
|
|
function portFromManagerUrl(args: ParsedArgs): string {
|
|
return portFromUrl(managerUrl(args));
|
|
}
|
|
|
|
function portFromUrl(value: string): string {
|
|
try {
|
|
const url = new URL(value);
|
|
return url.port || (url.protocol === "https:" ? "443" : "80");
|
|
} catch {
|
|
return "8080";
|
|
}
|
|
}
|
|
|
|
function stateDir(): string {
|
|
return path.join(process.cwd(), ".state");
|
|
}
|
|
|
|
function logDir(): string {
|
|
return path.join(process.cwd(), "logs", new Date().toISOString().slice(0, 10).replace(/-/gu, ""));
|
|
}
|
|
|
|
function pidFilePath(port: number): string {
|
|
return path.join(stateDir(), `agentrun-mgr-${port}.pid.json`);
|
|
}
|
|
|
|
function serverLogPath(port: number): string {
|
|
return path.join(logDir(), `agentrun-mgr-${port}-${new Date().toISOString().replace(/[:.]/gu, "-")}.jsonl`);
|
|
}
|
|
|
|
async function ensureDir(dir: string): Promise<void> {
|
|
await mkdir(dir, { recursive: true });
|
|
}
|
|
|
|
async function readServerState(port: number): Promise<JsonRecord> {
|
|
const pidFile = pidFilePath(port);
|
|
const pidRecord = await readPidFile(pidFile);
|
|
const pid = typeof pidRecord?.pid === "number" ? pidRecord.pid : null;
|
|
const pidAlive = pid !== null && isPidAlive(pid);
|
|
const portPid = await pidForPort(port);
|
|
return { pidFile, pid, pidAlive, port, portListening: portPid !== null, portPid, logPath: typeof pidRecord?.logPath === "string" ? pidRecord.logPath : null, startedAt: typeof pidRecord?.startedAt === "string" ? pidRecord.startedAt : null };
|
|
}
|
|
|
|
async function readPidFile(pidFile: string): Promise<JsonRecord | null> {
|
|
try {
|
|
return JSON.parse(await readFile(pidFile, "utf8")) as JsonRecord;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isPidAlive(pid: number): boolean {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function pidForPort(port: number): Promise<number | null> {
|
|
const proc = spawn("sh", ["-c", `command -v ss >/dev/null 2>&1 && ss -ltnp 'sport = :${port}' || true`], { stdio: ["ignore", "pipe", "ignore"] });
|
|
const chunks: Buffer[] = [];
|
|
proc.stdout.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
await new Promise<void>((resolve) => proc.on("close", () => resolve()));
|
|
const text = Buffer.concat(chunks).toString("utf8");
|
|
const match = text.match(/pid=(\d+)/u);
|
|
return match ? Number(match[1]) : null;
|
|
}
|
|
|
|
async function sleep(ms: number): Promise<void> {
|
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
async function jsonFile(args: ParsedArgs): Promise<JsonRecord> {
|
|
if (args.flags.get("json-stdin") === true) return parseJsonObject(await readStdinText(), "stdin json");
|
|
const file = optionalFlag(args, "json-file");
|
|
if (!file) throw new AgentRunError("schema-invalid", "JSON input is required; prefer --json-stdin with a quoted heredoc, or use --json-file <file> for reusable files", { httpStatus: 2 });
|
|
return parseJsonObject(await readFile(file, "utf8"), "json file");
|
|
}
|
|
|
|
async function optionalJsonFile(args: ParsedArgs): Promise<JsonRecord> {
|
|
if (args.flags.get("json-stdin") === true) return jsonFile(args);
|
|
const file = optionalFlag(args, "json-file");
|
|
if (!file) return {};
|
|
return jsonFile(args);
|
|
}
|
|
|
|
async function optionalRunnerJsonFile(args: ParsedArgs): Promise<JsonRecord> {
|
|
if (args.flags.get("runner-json-stdin") === true) return parseJsonObject(await readStdinText(), "runner stdin json");
|
|
const file = optionalFlag(args, "runner-json-file");
|
|
if (!file) return {};
|
|
return parseJsonObject(await readFile(file, "utf8"), "runner json file");
|
|
}
|
|
|
|
function parseJsonObject(text: string, source: string): JsonRecord {
|
|
const value = JSON.parse(text) as unknown;
|
|
if (typeof value === "object" && value !== null && !Array.isArray(value)) return value as JsonRecord;
|
|
throw new AgentRunError("schema-invalid", `${source} must contain an object`, { httpStatus: 2 });
|
|
}
|
|
|
|
async function readPrompt(args: ParsedArgs): Promise<string> {
|
|
const promptFlag = optionalFlag(args, "prompt");
|
|
if (promptFlag) return promptFlag;
|
|
const promptFile = optionalFlag(args, "prompt-file");
|
|
if (promptFile) {
|
|
const text = await readFile(promptFile, "utf8");
|
|
if (text.trim().length === 0) throw new AgentRunError("schema-invalid", "prompt file is empty", { httpStatus: 2 });
|
|
return text;
|
|
}
|
|
if (args.flags.get("prompt-stdin") === true) {
|
|
const text = await readStdinText();
|
|
if (text.trim().length === 0) throw new AgentRunError("schema-invalid", "stdin prompt is empty", { httpStatus: 2 });
|
|
return text;
|
|
}
|
|
const inline = args.positional.slice(3).join(" ").trim();
|
|
if (inline.length > 0) return inline;
|
|
throw new AgentRunError("schema-invalid", "prompt is required; use --prompt, --prompt-file, --prompt-stdin, or trailing text", { httpStatus: 2 });
|
|
}
|
|
|
|
async function optionalPrompt(args: ParsedArgs, trailingStart: number): Promise<string | undefined> {
|
|
const promptFlag = optionalFlag(args, "prompt");
|
|
if (promptFlag) return promptFlag;
|
|
const promptFile = optionalFlag(args, "prompt-file");
|
|
if (promptFile) {
|
|
const text = await readFile(promptFile, "utf8");
|
|
if (text.trim().length === 0) throw new AgentRunError("schema-invalid", "prompt file is empty", { httpStatus: 2 });
|
|
return text;
|
|
}
|
|
if (args.flags.get("prompt-stdin") === true) {
|
|
const text = await readStdinText();
|
|
if (text.trim().length === 0) throw new AgentRunError("schema-invalid", "stdin prompt is empty", { httpStatus: 2 });
|
|
return text;
|
|
}
|
|
const inline = args.positional.slice(trailingStart).join(" ").trim();
|
|
return inline.length > 0 ? inline : undefined;
|
|
}
|
|
|
|
async function readStdinText(): Promise<string> {
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of process.stdin) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
return Buffer.concat(chunks).toString("utf8");
|
|
}
|
|
|
|
function normalizeProfile(value: string): BackendProfile {
|
|
const normalized = value.trim().toLowerCase();
|
|
const profile = normalized === "m3" || normalized === "minimax" || normalized === "minimax_m3" ? "minimax-m3" : normalized;
|
|
if (!isBackendProfile(profile)) throw new AgentRunError("schema-invalid", `backend profile ${value} is not supported in v0.1`, { httpStatus: 2 });
|
|
return profile;
|
|
}
|
|
|
|
function newSessionId(): string {
|
|
return `ses_${randomUUID().replace(/-/gu, "")}`;
|
|
}
|
|
|
|
function stringField(record: JsonRecord, key: string, fallback: string): string {
|
|
const value = record[key];
|
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback;
|
|
}
|
|
|
|
function objectField(record: JsonRecord, key: string, fallback: JsonRecord): JsonRecord {
|
|
const value = record[key];
|
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as JsonRecord : fallback;
|
|
}
|
|
|
|
function jsonObjectFlag(args: ParsedArgs, name: string): JsonRecord | null {
|
|
const value = optionalFlag(args, name);
|
|
if (!value) return null;
|
|
const parsed = JSON.parse(value) as unknown;
|
|
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) return parsed as JsonRecord;
|
|
throw new AgentRunError("schema-invalid", `--${name} must be a JSON object`, { httpStatus: 2 });
|
|
}
|
|
|
|
function defaultExecutionPolicy(profile: BackendProfile): JsonRecord {
|
|
const keys = [...(backendProfileSpec(profile)?.requiredSecretKeys ?? ["auth.json", "config.toml"])] as string[];
|
|
return { sandbox: "workspace-write", approval: "never", timeoutMs: 900000, network: "enabled", secretScope: { allowCredentialEcho: false, providerCredentials: [{ profile, secretRef: { name: `agentrun-v01-provider-${profile}`, keys } }] } };
|
|
}
|
|
|
|
function copyOptionalFlag(args: ParsedArgs, target: JsonRecord, flagName: string, key = flagName.replace(/-([a-z])/gu, (_, letter: string) => letter.toUpperCase())): void {
|
|
const value = optionalFlag(args, flagName);
|
|
if (value) target[key] = value;
|
|
}
|
|
|
|
function copyRunnerManagerUrlFlag(args: ParsedArgs, target: JsonRecord): void {
|
|
const value = optionalFlag(args, "runner-manager-url");
|
|
if (value) target.managerUrl = resolveRunnerManagerUrlFlag(args, value);
|
|
}
|
|
|
|
function resolveRunnerManagerUrlFlag(args: ParsedArgs, value: string): string {
|
|
return value === "auto" ? managerUrl(args) : value;
|
|
}
|
|
|
|
function readerQuery(args: ParsedArgs): string {
|
|
const readerId = optionalFlag(args, "reader-id");
|
|
return readerId ? `?readerId=${encodeURIComponent(readerId)}` : "";
|
|
}
|
|
|
|
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 cancelBody(args: ParsedArgs): JsonRecord {
|
|
const reason = optionalFlag(args, "reason");
|
|
return reason ? { reason } : {};
|
|
}
|
|
|
|
function help(args: ParsedArgs, group?: string): JsonRecord {
|
|
const commands = [
|
|
"runs create --json-stdin|--json-file <run.json>",
|
|
"runs show <runId>",
|
|
"runs events <runId> --after-seq <n> --limit <n> [--summary|--tail-summary] [--tail <n>] [--summary-chars <n>] [--format json|tsv]",
|
|
"runs result <runId> [--command-id <commandId>]",
|
|
"runs cancel <runId> [--reason <text>]",
|
|
"sessions ps [--state default|running|unread|terminal|idle|all] [--profile codex|deepseek|minimax-m3|dsflash-go|<dynamic-profile>|M3] [--reader-id <reader>]",
|
|
"sessions create [sessionId] [--profile codex|deepseek|minimax-m3|dsflash-go|<dynamic-profile>|M3] [--expires-in-days <n>]",
|
|
"sessions storage <sessionId>",
|
|
"sessions storage <sessionId> --delete",
|
|
"sessions show <sessionId> [--reader-id <reader>]",
|
|
"sessions send [sessionId] [--aipod <name>|--json-stdin|--json-file <run-base.json>] [--prompt-stdin|--prompt-file <file>|--prompt <text>] [--profile codex|deepseek|minimax-m3|dsflash-go|<dynamic-profile>|M3] [--runner-json-stdin|--runner-json-file <job.json>] [--no-runner-job] [--dry-run]",
|
|
"sessions cancel <sessionId> [--reason <text>] [--full|--raw]",
|
|
"sessions trace <sessionId> [--after-seq <n>] [--limit <n>] [--run-id <runId>] [--summary-chars <n>] [--include-output] [--seq <n>|--event-id <id>|--item-id <id>] [--detail-scan-pages <n>] [--full|--raw]",
|
|
"sessions output <sessionId> [--after-seq <n>] [--limit <n>] [--run-id <runId>] [--summary-chars <n>] [--include-output] [--seq <n>|--event-id <id>|--item-id <id>] [--detail-scan-pages <n>] [--full|--raw]",
|
|
"sessions read <sessionId> [--reader-id <reader>] [--full|--raw]",
|
|
"commands create <runId> --type turn|steer|interrupt --json-stdin|--json-file <payload.json>",
|
|
"commands show <commandId> --run-id <runId>",
|
|
"commands result <commandId> --run-id <runId>",
|
|
"commands cancel <commandId> [--reason <text>]",
|
|
"runner start --run-id <runId> [--backend codex|deepseek|minimax-m3|dsflash-go|<dynamic-profile>]",
|
|
"runner job --run-id <runId> --command-id <commandId> [--image <image>] [--runner-manager-url <url>] [--idempotency-key <key>]",
|
|
"runner job --dry-run --run-id <runId> --command-id <commandId> --image <image>",
|
|
"runner jobs --run-id <runId> [--command-id <commandId>]",
|
|
"runner job-status [runnerJobId] --run-id <runId>",
|
|
"queue submit --json-stdin|--json-file <task.json> [--idempotency-key <key>] [--dry-run]",
|
|
"queue submit --aipod <name> [--prompt-stdin|--prompt-file <file>|--prompt <text>] [--idempotency-key <key>] [--dry-run]",
|
|
"queue list [--queue <queue>] [--state <state>] [--cursor <cursor>] [--limit <limit>] [--updated-after <version>] [--full|--raw]",
|
|
"queue show <taskId> [--full|--raw]",
|
|
"queue stats [--queue <queue>]",
|
|
"queue commander [--queue <queue>] [--reader-id <reader>] [--limit <display-limit>] [--full|--raw]",
|
|
"queue read <taskId> [--reader-id <reader>] [--dry-run] [--full|--raw]",
|
|
"queue cancel <taskId> [--reason <text>] [--dry-run] [--full|--raw]",
|
|
"queue dispatch <taskId> [--json-stdin|--json-file <dispatch.json>] [--idempotency-key <key>] [--image <image>] [--namespace <namespace>] [--dry-run] [--full|--raw]",
|
|
"queue refresh <taskId> [--dry-run] [--full|--raw]",
|
|
"aipod-specs list",
|
|
"aipod-specs show <name>",
|
|
"aipod-specs render <name> [--json-stdin|--json-file <input.json>] [--prompt-stdin|--prompt-file <file>|--prompt <text>]",
|
|
"aipod-specs apply [name] --yaml-stdin|--yaml-file <spec.yaml> [--dry-run]",
|
|
"aipod-specs delete <name>",
|
|
"secrets codex render --dry-run [--profile codex|deepseek|minimax-m3|dsflash-go|<dynamic-profile>] [--codex-home <dir>] [--model-catalog-file <file>] [--namespace agentrun-v01] [--secret-name <name>]",
|
|
"tool-credentials list",
|
|
"tool-credentials show github-ssh|unidesk-ssh",
|
|
"tool-credentials set-github-ssh --private-key-file <id_ed25519> --known-hosts-file <known_hosts> [--config-file <ssh_config>] [--dry-run]",
|
|
"provider-profiles list",
|
|
"provider-profiles show <profile>",
|
|
"provider-profiles config <profile>",
|
|
"provider-profiles remove <profile>",
|
|
"provider-profiles set-key <profile> --key-stdin [--model <model>] [--base-url <url>]",
|
|
"provider-profiles set-config <profile> --config-stdin",
|
|
"provider-profiles validate <profile> [--wait] [--timeout-ms <ms>]",
|
|
"backends list",
|
|
"server start [--port <port>] [--host <host>] [--foreground]",
|
|
"server status [--port <port>]",
|
|
"server logs [--port <port>] [--tail-bytes <bytes>] [--log-file <path>]",
|
|
"server stop [--port <port>]",
|
|
];
|
|
if (group) {
|
|
const prefix = `${group} `;
|
|
const groupCommands = commands.filter((item) => item === group || item.startsWith(prefix));
|
|
return { group, commands: groupCommands, commandCount: groupCommands.length, supported: groupCommands.length > 0, manager: managerEndpoint(args) };
|
|
}
|
|
return {
|
|
groups: [...new Set(commands.map((item) => item.split(" ")[0]).filter(Boolean))].sort(),
|
|
commands,
|
|
manager: managerEndpoint(args),
|
|
};
|
|
}
|
|
|
|
function print(value: JsonRecord): void {
|
|
process.stdout.write(`${JSON.stringify(value)}\n`);
|
|
}
|