feat: add code queue submit cli

This commit is contained in:
Codex
2026-05-17 05:49:27 +00:00
parent 68a14cdd4e
commit 2b2a327301
5 changed files with 250 additions and 9 deletions
+3 -1
View File
@@ -43,14 +43,16 @@ function help(): unknown {
{ command: "microservice list", description: "List UniDesk-managed user services and their provider/runtime mapping." },
{ command: "microservice status <id>", description: "Show one user service config, repository reference, backend mapping, and runtime status." },
{ command: "microservice health <id>", description: "Probe one user service through backend-core -> provider-gateway HTTP proxy." },
{ command: "microservice proxy <id> <path> [--method GET|POST|PUT|PATCH|DELETE] [--raw] [--max-body-bytes N]", description: "Access a private user-service backend path through the same frontend-only proxy used by WebUI; large bodies are summarized unless --raw is set." },
{ command: "microservice proxy <id> <path> [--method GET|POST|PUT|PATCH|DELETE] [--body-json JSON|--body-file path|--body-stdin] [--raw] [--max-body-bytes N]", description: "Access a private user-service backend path through the same frontend-only proxy used by WebUI; JSON request bodies are supported for controlled write/debug endpoints." },
{ command: "deploy check|plan|apply [--file deploy.json] [--service id] [--dry-run] [--force]", description: "Reconcile services from a repo+commit manifest using target-side build and live commit verification." },
{ command: "schedule list|get|runs|run|delete", description: "Manage backend-core scheduled tasks and run history; schedule run <id> supports --wait-ms N." },
{ command: "schedule upsert-pgdata-backup [--time HH:MM] [--remote-base /SERVER_DATA/UNIDESK_PG_DATA]", description: "Create or update the daily PGDATA physical backup task that uploads monthly rotated archives to Baidu Netdisk." },
{ command: "codex deploy <commitId> [--provider-id D601] [--timeout-ms N]", description: "Compatibility wrapper for deploy apply --service code-queue with a temporary repo+commit manifest." },
{ command: "codex submit [prompt] [--prompt-file path|--prompt-stdin] [--queue queueId] [--provider-id id] [--cwd path] [--model model] [--execution-mode mode] [--max-attempts N] [--reference-task-id id] [--dry-run]", description: "Submit a Code Queue task through backend-core -> code-queue proxy; --dry-run shows the structured request without enqueueing." },
{ command: "codex task <taskId> [--trace --tail|--from-start|--after-seq N|--before-seq N --limit N] [--full]", description: "Fetch a compact Code Queue task summary; trace rows are opt-in and paged with next/previous commands to avoid output explosion." },
{ command: "codex output <taskId> [--tail|--from-start|--after-seq N|--before-seq N --limit N] [--full-text]", description: "Fetch paged raw Code Queue output records by seq when a trace row has omitted command/output text." },
{ command: "codex judge <taskId> --attempt N [--dry-run] [--include-prompt]", description: "Replay one stored Code Queue attempt through the same judge context builder and MiniMax judge call path used by the live queue worker." },
{ command: "codex interrupt|cancel <taskId>", description: "Request interrupt for a running Code Queue task, or cancel a queued/retry_wait task, through the same private proxy." },
{ command: "codex (queues | queue create <queueId> | queue merge <sourceQueueId> --into <targetQueueId> | move <taskId> --queue <queueId>)", description: "List/create/merge Code Queue lanes and move a queued task; merge preserves task queue time order and deletes the source queue record." },
{ command: "job list", description: "List async jobs from .state/jobs." },
{ command: "job status <jobId|latest> [--tail-bytes N]", description: "Show job state with bounded stdout/stderr tails." },
+205 -1
View File
@@ -1,3 +1,4 @@
import { readFileSync } from "node:fs";
import { type UniDeskConfig } from "./config";
import { coreInternalFetch } from "./microservices";
@@ -33,6 +34,19 @@ interface CodexJudgeOptions {
includePrompt: boolean;
}
interface CodexSubmitOptions {
prompt: string;
queueId: string | undefined;
providerId: string | undefined;
cwd: string | undefined;
model: string | undefined;
reasoningEffort: string | undefined;
executionMode: string | undefined;
maxAttempts: number | undefined;
referenceTaskIds: string[];
dryRun: boolean;
}
type CodexRequestInit = { method?: string; body?: unknown };
type CodexResponseFetcher = (path: string, init?: CodexRequestInit) => unknown;
type AsyncCodexResponseFetcher = (path: string, init?: CodexRequestInit) => Promise<unknown>;
@@ -66,6 +80,16 @@ function stringList(value: unknown): string[] {
return asArray(value).map((item) => String(item ?? "")).filter((item) => item.length > 0);
}
function textPreview(value: string, maxChars: number): Record<string, unknown> {
const truncated = value.length > maxChars;
return {
text: truncated ? value.slice(0, maxChars) : value,
chars: value.length,
truncated,
omittedChars: truncated ? value.length - maxChars : 0,
};
}
function fmtDuration(ms: unknown): string {
const value = Number(ms);
if (!Number.isFinite(value) || value < 0) return "--";
@@ -624,6 +648,19 @@ function optionValue(args: string[], names: string[]): string | undefined {
return undefined;
}
function optionValues(args: string[], names: string[]): string[] {
const values: string[] = [];
for (let index = 0; index < args.length; index += 1) {
const name = args[index] ?? "";
if (!names.includes(name)) continue;
const raw = args[index + 1];
if (raw === undefined || raw.trim().length === 0) throw new Error(`${name} requires a non-empty value`);
values.push(raw.trim());
index += 1;
}
return values;
}
function positionalArgs(args: string[]): string[] {
const positions: string[] = [];
for (let index = 0; index < args.length; index += 1) {
@@ -637,6 +674,19 @@ function positionalArgs(args: string[]): string[] {
return positions;
}
function positionalArgsWithValueOptions(args: string[], valueOptions: Set<string>): string[] {
const positions: string[] = [];
for (let index = 0; index < args.length; index += 1) {
const value = args[index] ?? "";
if (value.startsWith("--")) {
if (valueOptions.has(value)) index += 1;
continue;
}
positions.push(value);
}
return positions;
}
function requireMergeSourceQueueId(args: string[], command: string): string {
const raw = optionValue(args, ["--source", "--from", "--queue"]) ?? positionalArgs(args)[0];
if (raw === undefined || raw.trim().length === 0) throw new Error(`${command} requires source queue id, for example: codex queue merge old --into default`);
@@ -665,8 +715,158 @@ function codexMoveTask(taskId: string, queueId: string): unknown {
return unwrapCodexResponse(coreInternalFetch(`/api/microservices/code-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/move`, { method: "POST", body: { queueId } }));
}
function promptFromSubmitArgs(args: string[]): string {
const promptFile = optionValue(args, ["--prompt-file", "--file"]);
const promptStdin = hasFlag(args, "--prompt-stdin") || hasFlag(args, "--stdin");
const promptArgs = positionalArgsWithValueOptions(args, new Set([
"--prompt-file",
"--file",
"--queue",
"--queue-id",
"--provider",
"--provider-id",
"--cwd",
"--workdir",
"--model",
"--reasoning-effort",
"--execution-mode",
"--mode",
"--max-attempts",
"--reference-task-id",
"--reference",
"--ref",
]));
const sources = [promptFile !== undefined, promptStdin, promptArgs.length > 0].filter(Boolean).length;
if (sources !== 1) throw new Error("codex submit requires exactly one prompt source: positional prompt, --prompt-file, or --prompt-stdin");
const text = promptFile !== undefined
? (promptFile === "-" ? readFileSync(0, "utf8") : readFileSync(promptFile, "utf8"))
: promptStdin
? readFileSync(0, "utf8")
: promptArgs.join(" ");
if (text.trim().length === 0) throw new Error("codex submit prompt must not be empty");
return text;
}
function referenceTaskIdsFromOptions(args: string[]): string[] {
const values = optionValues(args, ["--reference-task-id", "--reference", "--ref"]);
const ids: string[] = [];
for (const value of values.flatMap((item) => item.split(/[,\s]+/u))) {
const id = value.trim();
if (id.length > 0 && !ids.includes(id)) ids.push(id);
}
return ids;
}
function parseSubmitOptions(args: string[]): CodexSubmitOptions {
const maxAttempts = args.some((arg) => arg === "--max-attempts")
? positiveIntegerOption(args, ["--max-attempts"], 99, 99)
: undefined;
return {
prompt: promptFromSubmitArgs(args),
queueId: optionValue(args, ["--queue", "--queue-id"]),
providerId: optionValue(args, ["--provider-id", "--provider"]),
cwd: optionValue(args, ["--cwd", "--workdir"]),
model: optionValue(args, ["--model"]),
reasoningEffort: optionValue(args, ["--reasoning-effort"]),
executionMode: optionValue(args, ["--execution-mode", "--mode"]),
maxAttempts,
referenceTaskIds: referenceTaskIdsFromOptions(args),
dryRun: hasFlag(args, "--dry-run"),
};
}
function submitPayload(options: CodexSubmitOptions): Record<string, unknown> {
return {
prompt: options.prompt,
...(options.queueId === undefined ? {} : { queueId: options.queueId }),
...(options.providerId === undefined ? {} : { providerId: options.providerId }),
...(options.cwd === undefined ? {} : { cwd: options.cwd }),
...(options.model === undefined ? {} : { model: options.model }),
...(options.reasoningEffort === undefined ? {} : { reasoningEffort: options.reasoningEffort }),
...(options.executionMode === undefined ? {} : { executionMode: options.executionMode }),
...(options.maxAttempts === undefined ? {} : { maxAttempts: options.maxAttempts }),
...(options.referenceTaskIds.length === 0 ? {} : { referenceTaskIds: options.referenceTaskIds }),
};
}
function compactTaskMutationResponse(task: unknown): Record<string, unknown> {
const record = asRecord(task) ?? {};
const taskId = asString(record.id);
return {
id: taskId || null,
queueId: record.queueId ?? null,
status: record.status ?? null,
queuedReason: record.queuedReason ?? null,
providerId: record.providerId ?? null,
model: record.model ?? null,
reasoningEffort: record.reasoningEffort ?? null,
cwd: record.cwd ?? null,
executionMode: record.executionMode ?? null,
maxAttempts: record.maxAttempts ?? null,
currentAttempt: record.currentAttempt ?? null,
cancelRequested: record.cancelRequested ?? null,
createdAt: record.createdAt ?? null,
startedAt: record.startedAt ?? null,
updatedAt: record.updatedAt ?? null,
finishedAt: record.finishedAt ?? null,
prompt: textPreview(asString(record.displayPrompt ?? record.basePrompt ?? record.prompt), 1200),
commands: taskId.length === 0 ? null : {
show: `bun scripts/cli.ts codex task ${taskId}`,
trace: `bun scripts/cli.ts codex task ${taskId} --trace --tail --limit ${defaultTraceLimit}`,
output: `bun scripts/cli.ts codex output ${taskId} --tail --limit ${defaultOutputLimit}`,
interrupt: `bun scripts/cli.ts codex interrupt ${taskId}`,
move: `bun scripts/cli.ts codex move ${taskId} --queue <queueId>`,
},
};
}
function compactQueueMutationSummary(value: unknown): Record<string, unknown> | null {
const record = asRecord(value);
if (record === null) return null;
return {
activeQueueIds: record.activeQueueIds ?? null,
activeTaskIds: record.activeTaskIds ?? null,
queuedTaskIds: record.queuedTaskIds ?? null,
counts: record.counts ?? null,
byQueue: Array.isArray(record.byQueue) ? record.byQueue : undefined,
};
}
function codexSubmitTask(args: string[]): unknown {
const options = parseSubmitOptions(args);
const payload = submitPayload(options);
if (options.dryRun) {
return {
ok: true,
dryRun: true,
request: {
...payload,
prompt: textPreview(options.prompt, 3000),
},
};
}
const response = unwrapCodexResponse(coreInternalFetch("/api/microservices/code-queue/proxy/api/tasks", { method: "POST", body: payload }));
return {
upstream: response.upstream,
tasks: asArray(response.body.tasks).map(compactTaskMutationResponse),
queue: compactQueueMutationSummary(response.body.queue),
};
}
function codexInterruptTask(taskId: string): unknown {
const response = unwrapCodexResponse(coreInternalFetch(`/api/microservices/k3sctl-adapter/proxy/api/services/code-queue-scheduler/proxy/api/tasks/${encodeURIComponent(taskId)}/interrupt`, { method: "POST" }));
return {
upstream: response.upstream,
task: compactTaskMutationResponse(response.body.task),
queue: compactQueueMutationSummary(response.body.queue),
};
}
export async function runCodeQueueCommand(_config: UniDeskConfig, args: string[]): Promise<unknown> {
const [action = "task", taskIdArg] = args;
if (action === "submit" || action === "enqueue") {
return codexSubmitTask(args.slice(1));
}
if (action === "task" || action === "summary" || action === "show") {
const taskId = requireTaskId(taskIdArg, `codex ${action}`);
return codexTaskQuery(taskId, args.slice(2));
@@ -693,5 +893,9 @@ export async function runCodeQueueCommand(_config: UniDeskConfig, args: string[]
const taskId = requireTaskId(taskIdArg, "codex move");
return codexMoveTask(taskId, requireQueueId(args.slice(2), "codex move"));
}
throw new Error("codex command must be one of: task, summary, show, output, judge, queues, queue list, queue create, queue merge, move");
if (action === "interrupt" || action === "cancel") {
const taskId = requireTaskId(taskIdArg, `codex ${action}`);
return codexInterruptTask(taskId);
}
throw new Error("codex command must be one of: submit, enqueue, task, summary, show, output, judge, queues, queue list, queue create, queue merge, move, interrupt, cancel");
}
+34 -3
View File
@@ -1,3 +1,4 @@
import { readFileSync } from "node:fs";
import { runCommand } from "./command";
import { type UniDeskConfig, repoRoot } from "./config";
import { jsonByteLength, previewJson } from "./preview";
@@ -58,9 +59,38 @@ function stringOption(args: string[], name: string): string | undefined {
return raw;
}
function methodOption(args: string[]): string {
const method = (stringOption(args, "--method") ?? "GET").toUpperCase();
function hasFlag(args: string[], name: string): boolean {
return args.includes(name);
}
function parseJsonOption(raw: string, name: string): unknown {
try {
return JSON.parse(raw) as unknown;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`${name} must be valid JSON: ${message}`);
}
}
function requestBodyOption(args: string[]): unknown | undefined {
const bodyJson = stringOption(args, "--body-json");
const bodyFile = stringOption(args, "--body-file");
const bodyStdin = hasFlag(args, "--body-stdin");
const sources = [bodyJson !== undefined, bodyFile !== undefined, bodyStdin].filter(Boolean).length;
if (sources > 1) throw new Error("microservice proxy accepts only one request body source: --body-json, --body-file, or --body-stdin");
if (bodyJson !== undefined) return parseJsonOption(bodyJson, "--body-json");
if (bodyFile !== undefined) {
const text = bodyFile === "-" ? readFileSync(0, "utf8") : readFileSync(bodyFile, "utf8");
return parseJsonOption(text, "--body-file");
}
if (bodyStdin) return parseJsonOption(readFileSync(0, "utf8"), "--body-stdin");
return undefined;
}
function methodOption(args: string[], hasBody = false): string {
const method = (stringOption(args, "--method") ?? (hasBody ? "POST" : "GET")).toUpperCase();
if (!["GET", "HEAD", "POST", "DELETE", "PUT", "PATCH"].includes(method)) throw new Error(`unsupported --method ${method}`);
if (hasBody && (method === "GET" || method === "HEAD")) throw new Error(`microservice proxy cannot send a request body with ${method}`);
return method;
}
@@ -98,7 +128,8 @@ export async function runMicroserviceCommand(_config: UniDeskConfig, args: strin
if (action === "proxy") {
const id = requireId(idArg, "microservice proxy");
const path = requireProxyPath(pathArg);
return summarizeMicroserviceProxyResponse(coreInternalFetch(`/api/microservices/${encodeId(id)}/proxy${path}`, { method: methodOption(args) }), args);
const body = requestBodyOption(args);
return summarizeMicroserviceProxyResponse(coreInternalFetch(`/api/microservices/${encodeId(id)}/proxy${path}`, { method: methodOption(args, body !== undefined), body }), args);
}
throw new Error("microservice command must be one of: list, status, health, proxy");
}