From 9002724299952aebb226825d6380d46309b247e9 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 10 Jun 2026 13:00:03 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20Queue=20dry-run=20=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=20stdin=20=E8=BE=93=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/src/cli.ts | 64 +++++++++++++++++----- src/selftest/cases/75-queue-q2-dispatch.ts | 52 +++++++++++++++++- 2 files changed, 100 insertions(+), 16 deletions(-) diff --git a/scripts/src/cli.ts b/scripts/src/cli.ts index e3f6308..0b9acb4 100644 --- a/scripts/src/cli.ts +++ b/scripts/src/cli.ts @@ -648,7 +648,7 @@ function summarizeQueueTaskMutationResult(action: "queue-read" | "queue-cancel" }; } -function queueMutationDryRunPlan(action: string, taskId: string | null, pathValue: string, body: JsonRecord, method: "POST", confirmCommand: string, task?: JsonValue): JsonRecord { +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, @@ -661,15 +661,53 @@ function queueMutationDryRunPlan(action: string, taskId: string | null, pathValu 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.", + 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 "); + 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 = { + "idempotency-key": "", + image: "", + namespace: "", + "attempt-id": "", + "runner-id": "", + "source-commit": "", + "runner-manager-url": "", + "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"] }), @@ -1049,7 +1087,7 @@ async function submitQueueTask(args: ParsedArgs): Promise { 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", `./scripts/agentrun queue submit ${jsonInputHelp(args, "")}`); + return queueMutationDryRunPlan("queue-submit", null, "/api/v1/queue/tasks", body, "POST", queueSubmitConfirmCommand(args), undefined, jsonInputDisclosure(args, "")); } return client(args).post("/api/v1/queue/tasks", body); } @@ -1100,7 +1138,7 @@ async function dispatchQueueTask(args: ParsedArgs, taskId: string): Promise")}`, task); + return queueMutationDryRunPlan("queue-dispatch", taskId, `/api/v1/queue/tasks/${encodeURIComponent(taskId)}/dispatch`, body, "POST", queueDispatchConfirmCommand(args, taskId), task, jsonInputDisclosure(args, "", { required: false })); } const result = await client(args).post(`/api/v1/queue/tasks/${encodeURIComponent(taskId)}/dispatch`, body); if (wantsExpandedOutput(args)) return result; @@ -1500,7 +1538,7 @@ async function sleep(ms: number): Promise { async function jsonFile(args: ParsedArgs): Promise { 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; use --json-file or --json-stdin", { httpStatus: 2 }); + if (!file) throw new AgentRunError("schema-invalid", "JSON input is required; prefer --json-stdin with a quoted heredoc, or use --json-file for reusable files", { httpStatus: 2 }); return parseJsonObject(await readFile(file, "utf8"), "json file"); } @@ -1524,10 +1562,6 @@ function parseJsonObject(text: string, source: string): JsonRecord { throw new AgentRunError("schema-invalid", `${source} must contain an object`, { httpStatus: 2 }); } -function jsonInputHelp(args: ParsedArgs, filePlaceholder: string): string { - return args.flags.get("json-stdin") === true ? "--json-stdin" : `--json-file ${filePlaceholder}`; -} - async function readPrompt(args: ParsedArgs): Promise { const promptFlag = optionalFlag(args, "prompt"); if (promptFlag) return promptFlag; @@ -1643,7 +1677,7 @@ function cancelBody(args: ParsedArgs): JsonRecord { function help(args: ParsedArgs, group?: string): JsonRecord { const commands = [ - "runs create --json-file |--json-stdin", + "runs create --json-stdin|--json-file ", "runs show ", "runs events --after-seq --limit [--summary|--tail-summary] [--tail ] [--summary-chars ] [--format json|tsv]", "runs result [--command-id ]", @@ -1653,13 +1687,13 @@ function help(args: ParsedArgs, group?: string): JsonRecord { "sessions storage ", "sessions storage --delete", "sessions show [--reader-id ]", - "sessions turn [sessionId] [--json-file |--json-stdin] [--prompt-file |--prompt-stdin|--prompt ] [--profile codex|deepseek|minimax-m3|dsflash-go||M3] [--runner-json-file |--runner-json-stdin]", - "sessions steer [--prompt-file |--prompt-stdin|--prompt ]", + "sessions turn [sessionId] [--json-stdin|--json-file ] [--prompt-stdin|--prompt-file |--prompt ] [--profile codex|deepseek|minimax-m3|dsflash-go||M3] [--runner-json-stdin|--runner-json-file ]", + "sessions steer [--prompt-stdin|--prompt-file |--prompt ]", "sessions cancel [--reason ] [--full|--raw]", "sessions trace [--after-seq ] [--limit ] [--run-id ] [--summary-chars ] [--include-output] [--seq |--event-id |--item-id ] [--detail-scan-pages ] [--full|--raw]", "sessions output [--after-seq ] [--limit ] [--run-id ] [--summary-chars ] [--include-output] [--seq |--event-id |--item-id ] [--detail-scan-pages ] [--full|--raw]", "sessions read [--reader-id ] [--full|--raw]", - "commands create --type turn|steer|interrupt --json-file |--json-stdin", + "commands create --type turn|steer|interrupt --json-stdin|--json-file ", "commands show --run-id ", "commands result --run-id ", "commands cancel [--reason ]", @@ -1668,14 +1702,14 @@ function help(args: ParsedArgs, group?: string): JsonRecord { "runner job --dry-run --run-id --command-id --image ", "runner jobs --run-id [--command-id ]", "runner job-status [runnerJobId] --run-id ", - "queue submit --json-file |--json-stdin [--idempotency-key ] [--dry-run]", + "queue submit --json-stdin|--json-file [--idempotency-key ] [--dry-run]", "queue list [--queue ] [--state ] [--cursor ] [--limit ] [--updated-after ] [--full|--raw]", "queue show [--full|--raw]", "queue stats [--queue ]", "queue commander [--queue ] [--reader-id ] [--limit ] [--full|--raw]", "queue read [--reader-id ] [--dry-run] [--full|--raw]", "queue cancel [--reason ] [--dry-run] [--full|--raw]", - "queue dispatch [--json-file |--json-stdin] [--idempotency-key ] [--image ] [--namespace ] [--dry-run] [--full|--raw]", + "queue dispatch [--json-stdin|--json-file ] [--idempotency-key ] [--image ] [--namespace ] [--dry-run] [--full|--raw]", "queue refresh [--dry-run] [--full|--raw]", "secrets codex render --dry-run [--profile codex|deepseek|minimax-m3|dsflash-go|] [--codex-home ] [--model-catalog-file ] [--namespace agentrun-v01] [--secret-name ]", "provider-profiles list", diff --git a/src/selftest/cases/75-queue-q2-dispatch.ts b/src/selftest/cases/75-queue-q2-dispatch.ts index 3ce89ea..3101496 100644 --- a/src/selftest/cases/75-queue-q2-dispatch.ts +++ b/src/selftest/cases/75-queue-q2-dispatch.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; import { chmod, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { startManagerServer } from "../../mgr/server.js"; @@ -53,6 +54,25 @@ process.exit(1); }); try { const client = new ManagerClient(server.baseUrl); + const stdinSubmitPlan = await runCliJson(context, server.baseUrl, ["queue", "submit", "--json-stdin", "--dry-run", "--idempotency-key", "queue-q2-cli-stdin-dry-run"], { + tenantId: "unidesk", + projectId: "pikasTech/unidesk", + queue: "dev", + title: "stdin queue dry-run task", + payload: { prompt: "stdin queue dry-run" }, + }); + assert.equal(stdinSubmitPlan.ok, true); + assert.equal(((stdinSubmitPlan.data as JsonRecord).action), "queue-submit-plan"); + assert.equal((((stdinSubmitPlan.data as JsonRecord).jsonInput as JsonRecord).preferred), "--json-stdin"); + assert.equal(String(((stdinSubmitPlan.data as JsonRecord).next as JsonRecord).confirm).includes("--json-stdin"), true); + assert.equal(String(((stdinSubmitPlan.data as JsonRecord).next as JsonRecord).confirm).includes("--idempotency-key "), true); + assert.equal(String(((stdinSubmitPlan.data as JsonRecord).next as JsonRecord).confirm).includes("--json-file"), false); + + const help = await runCliJson(context, server.baseUrl, ["help"]); + const commands = ((help.data as JsonRecord).commands as string[]) ?? []; + assert.equal(commands.some((item) => item.includes("queue submit --json-stdin|--json-file")), true); + assert.equal(commands.some((item) => item.includes("queue submit --json-file |--json-stdin")), false); + const created = await client.post("/api/v1/queue/tasks", { tenantId: "unidesk", projectId: "pikasTech/unidesk", @@ -77,6 +97,17 @@ process.exit(1); metadata: { source: "queue-q2-self-test" }, idempotencyKey: "queue-q2-dispatch-self-test", }) as QueueTaskRecord; + const dispatchPlan = await runCliJson(context, server.baseUrl, ["queue", "dispatch", String(created.id), "--dry-run", "--attempt-id", "attempt_queue_q2_cli_dryrun"]); + assert.equal(dispatchPlan.ok, true); + assert.equal(((dispatchPlan.data as JsonRecord).action), "queue-dispatch-plan"); + assert.equal(String(((dispatchPlan.data as JsonRecord).next as JsonRecord).confirm).includes("--json-file"), false); + assert.equal(String(((dispatchPlan.data as JsonRecord).next as JsonRecord).confirm).includes("--attempt-id "), true); + + const dispatchStdinPlan = await runCliJson(context, server.baseUrl, ["queue", "dispatch", String(created.id), "--json-stdin", "--dry-run"], { attemptId: "attempt_queue_q2_cli_stdin_dryrun" }); + assert.equal(dispatchStdinPlan.ok, true); + assert.equal(String(((dispatchStdinPlan.data as JsonRecord).next as JsonRecord).confirm).includes("--json-stdin"), true); + assert.equal(String(((dispatchStdinPlan.data as JsonRecord).next as JsonRecord).confirm).includes("--json-file"), false); + const dispatched = await client.post(`/api/v1/queue/tasks/${created.id}/dispatch`, { attemptId: "attempt_queue_q2_selftest" }) as QueueDispatchResult; assert.equal(dispatched.action, "queue-dispatch"); assert.equal(dispatched.mutation, true); @@ -242,7 +273,7 @@ process.exit(1); assert.ok(JSON.stringify(cancelManifest).includes(cancelDispatched.run.id)); assertNoSecretLeak(dispatched); assertNoSecretLeak(cancelled); - return { name: "queue-q2-dispatch", tests: ["queue-dispatch-run-command-runner-job", "queue-read-views-refresh-terminal-state", "queue-refresh-from-core-status", "queue-dispatch-no-repeat", "queue-unidesk-ssh-endpoint-auto-env", "queue-blocked-run-state-wins-over-command-failed", "queue-cancel-propagates-to-run-command-session"] }; + return { name: "queue-q2-dispatch", tests: ["queue-cli-json-stdin-dry-run", "queue-dispatch-run-command-runner-job", "queue-read-views-refresh-terminal-state", "queue-refresh-from-core-status", "queue-dispatch-no-repeat", "queue-unidesk-ssh-endpoint-auto-env", "queue-blocked-run-state-wins-over-command-failed", "queue-cancel-propagates-to-run-command-session"] }; } finally { await new Promise((resolve) => server.server.close(() => resolve())); } @@ -250,6 +281,25 @@ process.exit(1); export default selfTest; +async function runCliJson(context: { root: string }, managerUrl: string, args: string[], stdinJson?: JsonRecord): Promise { + const proc = spawn(process.execPath, [`${context.root}/scripts/agentrun-cli.ts`, "--manager-url", managerUrl, ...args], { stdio: ["pipe", "pipe", "pipe"] }); + if (stdinJson !== undefined) proc.stdin.write(JSON.stringify(stdinJson)); + proc.stdin.end(); + const [stdout, stderr, code] = await Promise.all([readStream(proc.stdout), readStream(proc.stderr), new Promise((resolve) => proc.on("close", resolve))]); + assert.equal(code, 0, stderr || stdout); + return JSON.parse(stdout) as JsonRecord; +} + +async function readStream(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + stream.on("data", (chunk: Buffer | string) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + await new Promise((resolve, reject) => { + stream.on("end", resolve); + stream.on("error", reject); + }); + return Buffer.concat(chunks).toString("utf8"); +} + function runnerEnvValue(manifest: JsonRecord, name: string): unknown { const spec = manifest.spec as JsonRecord; const template = spec.template as JsonRecord;