diff --git a/scripts/src/agentrun.test.ts b/scripts/src/agentrun.test.ts index 7f700503..d3060e50 100644 --- a/scripts/src/agentrun.test.ts +++ b/scripts/src/agentrun.test.ts @@ -4,6 +4,20 @@ import { parseAgentRunClientConfigYaml, resolveAgentRunAuth, runAgentRunCommand, import { resolveAgentRunLaneTarget } from "./agentrun-lanes"; import { placeholderAgentRunImage, renderAgentRunGitopsFiles } from "./agentrun-manifests"; +function renderedTextOf(value: unknown): string { + if (typeof value !== "object" || value === null) throw new Error("expected rendered CLI result"); + const text = (value as { renderedText?: unknown }).renderedText; + if (typeof text !== "string") throw new Error("expected renderedText string"); + return text; +} + +function renderedContentTypeOf(value: unknown): string { + if (typeof value !== "object" || value === null) throw new Error("expected rendered CLI result"); + const contentType = (value as { contentType?: unknown }).contentType; + if (typeof contentType !== "string") throw new Error("expected contentType string"); + return contentType; +} + const agentRunClientYaml = [ "version: 1", "kind: AgentRunConfig", @@ -313,6 +327,137 @@ describe("AgentRun default transport contract", () => { const attemptHelp = await runAgentRunCommand(null, ["get", "attempts", "--help"]); expect("renderedText" in retryHelp ? retryHelp.renderedText : "").toContain("agentrun retry task/ --idempotency-key --reason "); expect("renderedText" in attemptHelp ? attemptHelp.renderedText : "").toContain("agentrun get attempts --task "); + + const rootHelp = Bun.spawnSync(["bun", "scripts/cli.ts", "agentrun", "--help"], { + cwd: new URL("../..", import.meta.url).pathname, + stdout: "pipe", + stderr: "pipe", + }); + const rootHelpText = rootHelp.stdout.toString(); + expect(rootHelp.exitCode).toBe(0); + expect(rootHelpText).toContain("Verbs: get, describe, events, logs, result, ack, cancel, retry,"); + expect(rootHelpText).toContain("Resources: task/qt, attempt, run,"); + expect(rootHelpText).toContain("agentrun retry task/ --idempotency-key --reason --dry-run"); + expect(rootHelpText).toContain("agentrun get attempts --task --limit 20"); + expect(rootHelpText.split("\n").length).toBeLessThan(40); + }); + + test("local retry and attempt validation preserves bounded human and machine errors", async () => { + const cases = [ + { + args: ["retry", "task/qt_failed_1", "--reason", "dependency repaired"], + message: "retry requires --idempotency-key ", + }, + { + args: ["retry", "task/qt_failed_1", "--idempotency-key", "retry-key-1"], + message: "retry requires --reason ", + }, + { + args: ["get", "attempts"], + message: "get attempts requires --task ", + }, + { + args: ["retry", "run/run_invalid", "--idempotency-key", "retry-key-1", "--reason", "dependency repaired"], + message: "retry supports task/", + }, + { + args: ["retry"], + message: "retry requires task/", + }, + ]; + for (const item of cases) { + const human = await runAgentRunCommand(null, item.args); + const humanText = renderedTextOf(human); + expect(human.ok).toBe(false); + expect(humanText).toContain("Error: validation-failed"); + expect(humanText).toContain(item.message); + expect(humanText.length).toBeLessThan(1600); + + for (const outputArgs of [["-o", "json"], ["--raw"]]) { + const machine = await runAgentRunCommand(null, [...item.args, ...outputArgs]); + const machineText = renderedTextOf(machine); + const payload = JSON.parse(machineText) as { ok?: boolean; failureKind?: string; message?: string }; + expect(machine.ok).toBe(false); + expect(renderedContentTypeOf(machine)).toBe("application/json"); + expect(payload.ok).toBe(false); + expect(payload.failureKind).toBe("validation-failed"); + expect(payload.message).toBe(item.message); + expect(machineText.length).toBeLessThan(2400); + } + } + + for (const cliArgs of [ + ["retry", "run/run_invalid", "--idempotency-key", "retry-key-1", "--reason", "dependency repaired", "-o", "json"], + ["get", "attempts", "--raw"], + ]) { + const cli = Bun.spawnSync(["bun", "scripts/cli.ts", "agentrun", ...cliArgs], { + cwd: new URL("../..", import.meta.url).pathname, + stdout: "pipe", + stderr: "pipe", + }); + const payload = JSON.parse(cli.stdout.toString()) as { ok?: boolean; failureKind?: string }; + expect(cli.exitCode).toBe(1); + expect(payload.ok).toBe(false); + expect(payload.failureKind).toBe("validation-failed"); + expect(cli.stdout.toString().length).toBeLessThan(2400); + } + }); + + test("server retry validation errors stay bounded in human and JSON output", async () => { + const taskId = "qt_completed_1"; + const longMessage = `queue task ${taskId} cannot be retried from state completed: ${"x".repeat(6000)}`; + const longDiagnostic = `server diagnostic: ${"y".repeat(9000)}`; + let requests = 0; + const server = Bun.serve({ + port: 0, + async fetch(request) { + const url = new URL(request.url); + expect(url.pathname).toBe(`/api/v1/queue/tasks/${taskId}/retry`); + expect(request.method).toBe("POST"); + expect(await request.json()).toEqual({ idempotencyKey: "retry-key-terminal", reason: "operator requested retry", dryRun: true }); + requests += 1; + return Response.json({ + ok: false, + failureKind: "schema-invalid", + message: longMessage, + details: { allowedStates: ["failed", "blocked"], diagnostic: longDiagnostic, valuesPrinted: false }, + valuesPrinted: false, + }, { status: 409 }); + }, + }); + const previousConfig = process.env.AGENTRUN_CLIENT_CONFIG; + const previousKey = process.env.HWLAB_API_KEY; + process.env.HWLAB_API_KEY = "secret-value"; + const tempConfigPath = `/tmp/unidesk-agentrun-retry-error-test-${process.pid}-${Date.now()}.yaml`; + try { + process.env.AGENTRUN_CLIENT_CONFIG = tempConfigPath; + await Bun.write(tempConfigPath, agentRunMinimalClientYaml.replace("http://agentrun.example.local:8080", server.url.href.replace(/\/$/u, ""))); + const baseArgs = ["retry", `task/${taskId}`, "--idempotency-key", "retry-key-terminal", "--reason", "operator requested retry", "--dry-run"]; + const human = await runAgentRunCommand(null, baseArgs); + const humanText = renderedTextOf(human); + expect(human.ok).toBe(false); + expect(humanText).toContain(`queue task ${taskId} cannot be retried from state completed`); + expect(humanText).not.toContain("x".repeat(500)); + expect(humanText.length).toBeLessThan(1800); + + const json = await runAgentRunCommand(null, [...baseArgs, "-o", "json"]); + const jsonText = renderedTextOf(json); + const payload = JSON.parse(jsonText) as { ok?: boolean; failureKind?: string; message?: string; agentrun?: { details?: { diagnostic?: string } } }; + expect(json.ok).toBe(false); + expect(renderedContentTypeOf(json)).toBe("application/json"); + expect(payload.failureKind).toBe("validation-failed"); + expect(payload.message?.length).toBeLessThanOrEqual(400); + expect(payload.agentrun?.details?.diagnostic?.length).toBeLessThanOrEqual(400); + expect(jsonText.length).toBeLessThan(5000); + expect(requests).toBe(2); + } finally { + server.stop(true); + if (previousConfig === undefined) delete process.env.AGENTRUN_CLIENT_CONFIG; + else process.env.AGENTRUN_CLIENT_CONFIG = previousConfig; + if (previousKey === undefined) delete process.env.HWLAB_API_KEY; + else process.env.HWLAB_API_KEY = previousKey; + rmSync(tempConfigPath, { force: true }); + } }); }); diff --git a/scripts/src/agentrun/entry.ts b/scripts/src/agentrun/entry.ts index b45b859a..21a2851a 100644 --- a/scripts/src/agentrun/entry.ts +++ b/scripts/src/agentrun/entry.ts @@ -385,8 +385,8 @@ export function agentRunHelpText(args: string[]): string { return [ "Usage: bun scripts/cli.ts agentrun [options]", "", - "Verbs: get, describe, events, logs, result, ack, cancel, dispatch, create, apply, send, explain", - "Resources: task/qt, run, command/cmd, runnerjob/rjob, session/ses, aipodspec/aps", + "Verbs: get, describe, events, logs, result, ack, cancel, retry, dispatch, create, apply, send, explain", + "Resources: task/qt, attempt, run, command/cmd, runnerjob/rjob, session/ses, aipodspec/aps", "", "Common:", " bun scripts/cli.ts agentrun get tasks --queue commander --limit 20", @@ -394,6 +394,8 @@ export function agentRunHelpText(args: string[]): string { " bun scripts/cli.ts agentrun describe run/ --node NC01 --lane nc01-v02", " bun scripts/cli.ts agentrun events run/ --after-seq 0 --limit 100", " bun scripts/cli.ts agentrun logs session/ --tail 100", + " bun scripts/cli.ts agentrun retry task/ --idempotency-key --reason --dry-run", + " bun scripts/cli.ts agentrun get attempts --task --limit 20", " bun scripts/cli.ts agentrun create task --aipod Artificer --prompt-stdin", "", "Machine/debug output:", diff --git a/scripts/src/agentrun/resource-actions.ts b/scripts/src/agentrun/resource-actions.ts index 1a2f9aee..facefa15 100644 --- a/scripts/src/agentrun/resource-actions.ts +++ b/scripts/src/agentrun/resource-actions.ts @@ -57,9 +57,17 @@ export function agentRunGetKindHelp(kindRaw: string): string { export async function runAgentRunResourceCommand(config: UniDeskConfig | null, verb: AgentRunResourceVerb, action: string | undefined, actionArgs: string[], canonicalArgs: string[]): Promise { if (isHelpArg(action) || actionArgs.some(isHelpArg)) return renderAgentRunHelp(canonicalArgs); const resourceArgs = action === undefined ? actionArgs : [action, ...actionArgs]; - const options = parseResourceOptions(resourceArgs); - const bridgeActionArgs = stripAgentRunResourceWrapperArgs(actionArgs); const command = `agentrun ${canonicalArgs.join(" ")}`.trim(); + let options: AgentRunResourceOptions; + try { + options = parseResourceOptions(resourceArgs); + } catch (error) { + const validationError = error instanceof AgentRunRestError + ? error + : new AgentRunRestError("validation-failed", error instanceof Error ? error.message : String(error)); + return renderAgentRunRestError(command, validationError, resourceErrorOutputOptions(resourceArgs)); + } + const bridgeActionArgs = stripAgentRunResourceWrapperArgs(actionArgs); try { return await withAgentRunRestTarget(resolveAgentRunRestTarget(config, options), async () => { if (verb === "explain") return renderedCliResult(true, command, agentRunExplain(action ?? "task", resourceArgs, options)); @@ -84,6 +92,21 @@ export async function runAgentRunResourceCommand(config: UniDeskConfig | null, v return renderedCliResult(false, command, `Unsupported AgentRun resource command. Try: bun scripts/cli.ts agentrun --help`); } +function resourceErrorOutputOptions(args: string[]): AgentRunResourceOptions { + const options = parseResourceOptions([]); + options.raw = args.includes("--raw"); + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] ?? ""; + const value = arg === "-o" || arg === "--output" + ? args[index + 1] + : arg.startsWith("--output=") || arg.startsWith("-o=") + ? arg.slice(arg.indexOf("=") + 1) + : null; + if (value === "json" || value === "yaml") options.output = value; + } + return options; +} + export function stripAgentRunResourceWrapperArgs(args: string[]): string[] { const result: string[] = []; for (let index = 0; index < args.length; index += 1) { @@ -224,7 +247,8 @@ export async function resourceGet(config: UniDeskConfig | null, command: string, result = await runAgentRunRestCommand(config, "sessions", ["ps", "--limit", String(options.limit)]); return renderResourceResult(command, result, options, "Session", normalizeSessionItems(innerData(result)).slice(0, options.limit)); } - if (kind === "attempt" && options.taskId !== null) { + if (kind === "attempt") { + if (options.taskId === null) throw new AgentRunRestError("validation-failed", "get attempts requires --task "); result = await runAgentRunRestCommand(config, "queue", [ "attempts", options.taskId, @@ -255,17 +279,23 @@ export async function resourceGet(config: UniDeskConfig | null, command: string, } export async function resourceRetry(config: UniDeskConfig | null, command: string, action: string | undefined, args: string[], options: AgentRunResourceOptions): Promise { - const ref = parseResourceRef(action, args, "task"); - if (ref.kind !== "task") throw new Error("retry supports task/"); - const idempotencyKey = options.idempotencyKey ?? requiredContext("retry", "--idempotency-key "); - const reason = options.reason ?? requiredContext("retry", "--reason "); + if (action === undefined || action.startsWith("-")) throw new AgentRunRestError("validation-failed", "retry requires task/"); + let ref: AgentRunResourceRef; + try { + ref = parseResourceRef(action, args, "task"); + } catch { + throw new AgentRunRestError("validation-failed", "retry requires task/"); + } + if (ref.kind !== "task") throw new AgentRunRestError("validation-failed", "retry supports task/"); + if (options.idempotencyKey === null) throw new AgentRunRestError("validation-failed", "retry requires --idempotency-key "); + if (options.reason === null) throw new AgentRunRestError("validation-failed", "retry requires --reason "); const result = await runAgentRunRestCommand(config, "queue", [ "retry", ref.name, "--idempotency-key", - idempotencyKey, + options.idempotencyKey, "--reason", - reason, + options.reason, ...(options.dryRun ? ["--dry-run"] : []), ]); return renderQueueRetrySummary(command, result, options, ref.name); diff --git a/scripts/src/agentrun/rest-bridge.ts b/scripts/src/agentrun/rest-bridge.ts index dea35e5e..8a7b2129 100644 --- a/scripts/src/agentrun/rest-bridge.ts +++ b/scripts/src/agentrun/rest-bridge.ts @@ -1059,8 +1059,10 @@ export async function withAgentRunRestTarget(target: AgentRunRestTarget | nul } export function renderAgentRunRestError(command: string, error: AgentRunRestError, options: AgentRunResourceOptions): RenderedCliResult { - const payload = error.toPayload(command); - if (options.raw || options.output === "json" || options.output === "yaml") return renderMachine(command, payload, options.output === "yaml" ? "yaml" : "json", false); + const rawPayload = error.toPayload(command); + if (options.raw) return renderMachine(command, rawPayload, "json", false); + const payload = boundedAgentRunRestErrorPayload(rawPayload); + if (options.output === "json" || options.output === "yaml") return renderMachine(command, payload, options.output === "yaml" ? "yaml" : "json", false); const laneHint = record(record(payload.agentrun).laneAwareLookup); const currentEndpoint = record(laneHint.currentEndpoint); const nextCommands = Array.isArray(laneHint.nextCommands) @@ -1075,12 +1077,18 @@ export function renderAgentRunRestError(command: string, error: AgentRunRestErro ` Config: ${String(currentEndpoint.configPath ?? "(unknown)")}`, ` ${String(laneHint.summary ?? "The resource was not found on the currently configured AgentRun manager endpoint.")}`, ]; + const validationHelp = `bun scripts/cli.ts agentrun ${command.split(/\s+/u)[1] ?? "--help"} --help`; const nextLines = nextCommands.length > 0 ? nextCommands - : [ - "Check config/agentrun.yaml manager.baseUrl and the AgentRun API route.", - "Verify HWLAB_API_KEY is present in env or in the configured auth.file without printing the key.", - ]; + : error.failureKind === "validation-failed" + ? [ + "Review the AgentRun validation message and correct the request; do not bypass the formal resource API.", + validationHelp, + ] + : [ + "Check config/agentrun.yaml manager.baseUrl and the AgentRun API route.", + "Verify HWLAB_API_KEY is present in env or in the configured auth.file without printing the key.", + ]; return renderedCliResult(false, command, [ `Error: ${payload.failureKind}`, String(payload.message ?? ""), @@ -1091,6 +1099,23 @@ export function renderAgentRunRestError(command: string, error: AgentRunRestErro ].join("\n")); } +export function boundedAgentRunRestErrorPayload(payload: Record): Record { + return boundedAgentRunRestErrorValue(payload, 0) as Record; +} + +function boundedAgentRunRestErrorValue(value: unknown, depth: number): unknown { + if (typeof value === "string") return truncateOneLine(value, 400); + if (value === null || typeof value === "number" || typeof value === "boolean") return value; + if (Array.isArray(value)) return value.slice(0, 12).map((item) => boundedAgentRunRestErrorValue(item, depth + 1)); + if (typeof value !== "object") return String(value); + if (depth >= 5) return "[nested details omitted]"; + const entries = Object.entries(value as Record); + const result: Record = {}; + for (const [key, item] of entries.slice(0, 20)) result[key] = boundedAgentRunRestErrorValue(item, depth + 1); + if (entries.length > 20) result.omittedFieldCount = entries.length - 20; + return result; +} + export class AgentRunRestError extends Error { readonly failureKind: AgentRunFailureKind; readonly bridge: Record | null;