diff --git a/AGENTS.md b/AGENTS.md index 5376d29e..0ec03799 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 ## CLI - `bun scripts/cli.ts help`:输出所有可用命令的 JSON 索引,详细规范见 `docs/reference/cli.md`。 -- `bun scripts/cli.ts --main-server-ip `:默认通过公网 frontend 登录态远程执行调试、用户服务(底层命令名 `microservice`)、`codex task ` 与节点自测命令,不要求主 server SSH key,详细规范见 `docs/reference/cli.md`。 +- `bun scripts/cli.ts --main-server-ip `:默认通过公网 frontend 登录态远程执行调试、用户服务(底层命令名 `microservice`)、Code Queue 查询与节点自测命令,不要求主 server SSH key,详细规范见 `docs/reference/cli.md`。 - `bun scripts/cli.ts config show`:校验并展示根目录 `config.json`,配置来源规则见 `docs/reference/config.md`。 - `bun scripts/cli.ts check`:运行配置、TypeScript、文件存在性和 Docker Compose 配置检查,测试入口见 `TEST.md`。 - `bun scripts/cli.ts server start`:以异步 job 启动 database、backend-core、frontend、provider-gateway 和主 server 用户服务,部署规则见 `docs/reference/deployment.md`。 @@ -32,11 +32,13 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `bun scripts/cli.ts server rebuild `:以 build-first、Compose lock、no-deps force-recreate 和 post-up validation 的异步 job 重建主 server Compose 内单个服务;Code Queue 部署在 D601,规则见 `docs/reference/deployment.md`。 - `bun scripts/cli.ts provider attach [--master-server URL] [--up] [--force]`:在新增计算节点上生成两项配置的 provider-gateway 挂载包;默认只需要主 server URL(默认 `http://74.48.78.17/`)和唯一 Provider ID,生成的 Compose 固定 Docker socket、`pid: "host"`、`restart: always`、只读 `/workspace`、SSH 维护私钥挂载和 loopback egress proxy 端口,规则见 `docs/reference/provider-gateway.md`。 - `bun scripts/cli.ts ssh [ssh-like args...]`:通过 provider-gateway 的 Host SSH / WSL SSH 维护桥打开近似原生 ssh 的交互会话或远端命令,并在远端 PATH 注入 `apply_patch`、`glob` 与 `skill-discover`;`apply-patch`、`py`、`skills`、结构化 `find`、`glob` 和 `argv` 子命令用于避免远端补丁、Python stdin、skill 发现与常用只读命令的嵌套转义问题,使用规则见 `docs/reference/cli.md` 和 `docs/reference/provider-gateway.md`。 -- `bun scripts/cli.ts microservice list/status/health/proxy`:管理和验证挂载在主 server、计算节点 Docker 或 k3s 控制面上的用户服务,OA Event Flow/Todo Note/Baidu Netdisk on main-server、k3s Control/Code Queue/MDTODO/FindJob/Pipeline/MET Nonlinear on D601 的规则见 `docs/reference/microservices.md`。 +- `bun scripts/cli.ts microservice list/status/health/proxy`:管理和验证挂载在主 server、计算节点 Docker 或 k3s 控制面上的用户服务,`proxy` 支持受控 JSON body,OA Event Flow/Todo Note/Baidu Netdisk on main-server、k3s Control/Code Queue/MDTODO/FindJob/Pipeline/MET Nonlinear on D601 的规则见 `docs/reference/microservices.md`。 - `bun scripts/cli.ts deploy check/plan/apply [--file deploy.json] [--service ]`:按根目录 `deploy.json` 的服务 repo 和 commit 期望状态校验或更新用户服务,目标侧自行 fetch、构建、部署和 live commit 验证;规则见 `docs/reference/deploy.md`。 - `bun scripts/cli.ts codex deploy `:Code Queue 兼容部署入口,会生成临时 desired manifest 并调用 `deploy apply --service code-queue` 的同一条 target-side build 与 live commit 验证路径;规则见 `docs/reference/codex-deploy.md`。 +- `bun scripts/cli.ts codex submit [prompt] [--prompt-file path|--prompt-stdin] [--queue ]`:通过 backend-core 私有代理提交 Code Queue 任务,`--dry-run` 可只检查请求体不入队,规则见 `docs/reference/cli.md`。 - `bun scripts/cli.ts codex task `:按 Code Queue 任务 ID 查询初始 prompt、最后 assistant message、工具调用摘要、attempt/judge/error 和耗时,便于新任务引用历史 session。 - `bun scripts/cli.ts codex judge --attempt [--dry-run]`:按指定 task/attempt 用与队列 worker 相同的上下文构建和 MiniMax judge 调用路径单步复现完成判定;`--dry-run` 只输出 prompt/payload 诊断。 +- `bun scripts/cli.ts codex interrupt|cancel `:通过 Code Queue 私有代理中断运行任务或取消 queued/retry_wait 任务,规则见 `docs/reference/cli.md`。 - `bun scripts/cli.ts server stop`:以异步 job 停止固定 Compose 项目中的全部 UniDesk 服务,停止后用 `server status` 复核。 - `bun scripts/cli.ts job list` / `bun scripts/cli.ts job status latest`:查询 `.state/jobs/` 中的异步任务状态,job 机制见 `docs/reference/cli.md`。 - `bun scripts/cli.ts debug health` / `bun scripts/cli.ts debug dispatch` / `bun scripts/cli.ts debug task`:通过 Docker 内网 core、真实 HTTP、WebSocket、系统指标、Docker 状态和 Host SSH 维护桥流程调试健康检查、任务下发与任务结果,调试规则见 `docs/reference/cli.md`。 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 94d1d5fd..2aa0ace8 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -18,13 +18,15 @@ UniDesk 的统一 CLI 入口是根目录 `scripts/cli.ts`,运行方式固定 - `ssh apply-patch [tool args...] < patch.diff` 直接调用远端注入的 `apply_patch` 工具,并把本地 stdin 中的标准 `*** Begin Patch` / `*** End Patch` patch 流透传给目标节点。 - `ssh py [script-args...] < script.py` 把本地 stdin 落到远端临时 `.py` 文件后再以 `python3 -u` 执行并自动清理,避免再手写 `'python3 -'`、heredoc 或多层引号;`script-args` 会按 argv 安全透传给远端脚本。 - `ssh skills [--scope all|wsl|windows] [--limit N]` 发现目标节点上的 WSL/Linux skill 根目录;当 provider 是 WSL 时同一次调用还会扫描 Windows 用户目录下的 `.agents/skills` 与 `.codex/skills`。 -- `microservice list/status/health/proxy` 通过 backend-core 内网 API 管理挂载在计算节点 Docker 中的用户服务(底层命令名仍为 microservice);`health` 和 `proxy` 会走真实 backend-core -> provider-gateway -> 节点本机后端链路,`proxy` 对超大 body 默认输出有界预览,规则见 `docs/reference/microservices.md`。 +- `microservice list/status/health/proxy` 通过 backend-core 内网 API 管理挂载在计算节点 Docker 或 k3s 控制面中的用户服务(底层命令名仍为 microservice);`health` 和 `proxy` 会走真实 backend-core -> provider-gateway 或 k3sctl-adapter -> 节点服务链路,`proxy` 支持受控 JSON 请求体并对超大响应 body 默认输出有界预览,规则见 `docs/reference/microservices.md`。 - `deploy check/plan/apply` 从根目录 `deploy.json` 读取服务 repo 与 commit 期望状态,join `config.json` 和现有 manifest 后使用 target-side build 单一路径校验或更新直管服务与 k3s 代管服务;规则见 `docs/reference/deploy.md`。 - `codex deploy ` 是 Code Queue 兼容部署入口,会生成临时 desired manifest 并调用 `deploy apply --service code-queue` 的同一条 target-side build、k3s import、rollout 和 live commit 验证路径;详细规则见 `docs/reference/codex-deploy.md`。 +- `codex submit [prompt] [--prompt-file path|--prompt-stdin] [--queue queueId] [--provider-id id] [--cwd path] [--model model] [--reasoning-effort effort] [--execution-mode mode] [--max-attempts N] [--reference-task-id id] [--dry-run]` 通过 backend-core 私有代理向 Code Queue 提交任务;prompt 必须且只能来自位置参数、文件或 stdin 之一,`--dry-run` 只返回结构化请求与 prompt 预览,不实际入队。 - `codex task ` 通过 Code Queue 私有代理按任务 ID 查询结构化执行摘要;默认只返回有界 prompt/response 预览、执行 Provider、工作目录、最后 assistant message、最近工具调用摘要、attempt、judge、错误、耗时和 trace 翻页提示,适合在新队列任务中引用历史 session 且避免噪声爆炸。 - `codex task --trace --tail|--from-start|--after-seq N|--before-seq N --limit N` 按页拉取 Code Queue 的逻辑 trace;响应会返回 `nextAfterSeq`、`previousBeforeSeq`、`hasMore`、`hasBefore` 和下一页/上一页命令,默认 `--trace` 取最新一页,需要完整 prompt/最后 response 时加 `--full`。 - `codex output --tail|--from-start|--after-seq N|--before-seq N --limit N [--full-text]` 按原始 output seq 分页读取底层记录;当 trace 行提示 `commandOmittedLines`、`bodyOmittedLines` 或 `rawSeqs` 时,用该命令按 seq 补取完整信息,默认仍有单条文本预览上限,显式 `--full-text` 才返回该页全文。 - `codex judge --attempt N [--dry-run] [--include-prompt]` 通过 Code Queue 私有代理按指定 attempt 单步复现 judge;后端会从 PostgreSQL task JSON 与 output 归档重建该 attempt 在真实队列 worker 中的 `QueueTask`/`CodexRunResult`,再调用同一套 judge prompt builder 和 MiniMax 请求路径。默认会真实调用 MiniMax,`--dry-run` 只返回 prompt/payload 大小、attempt 窗口和重建来源诊断,`--include-prompt` 仅用于本地深度排查。 +- `codex interrupt|cancel ` 通过 Code Queue 私有代理请求中断;running/judging 任务会请求当前 agent run 停止,queued/retry_wait 任务会直接转为 canceled,返回有界 task 摘要和后续查询命令。 - Code Queue 多队列 lane 由 `codex` 命令命名空间管理:`queues` 列表、`queue create ` 创建、`queue merge --into ` 合并、`move --queue ` 迁移;同一个 queue 内部串行执行,不同 queue 之间并行执行。合并会移动任务归属并自动删除源 queue 记录,只保留合并后的目标 queue;合并后的目标 queue 按任务原 `queueEnteredAt`/`createdAt` 时间顺序串行。迁移 queued/retry_wait 任务后会立即调度目标 queue。 - `job list` 与 `job status` 查询 `.state/jobs/` 文件系统状态,是异步命令的可观测入口。 - `debug health`、`debug dispatch` 与 `debug task` 走真实内部 core、WebSocket、数据库、provider、系统指标、Docker 状态和 Host SSH 维护桥流程,只用于开发调试,不写入 `TEST.md` 的正式验收步骤。 @@ -42,7 +44,7 @@ UniDesk 的统一 CLI 入口是根目录 `scripts/cli.ts`,运行方式固定 每条命令的最外层 JSON 包含 `ok`、`command` 和 `data` 或 `error`。失败时 CLI 设置非零退出码,但仍然输出 JSON 错误对象;错误对象应包含 `name`、`message` 和可用的 `stack`。 -`microservice proxy` 是面向人工验证的私有后端读取入口。正式写入型用户服务操作由 frontend 同源代理或 E2E 直接调用 backend-core 完成,并由 config 中的 `allowedMethods` 限制;CLI `proxy` 默认仍作为 GET/HEAD 读取验证入口,必要时可显式加 `--method POST|PUT|PATCH|DELETE` 调用无需自定义请求体的受控调试/自测端点,例如 `bun scripts/cli.ts microservice proxy baidu-netdisk /api/self-test --method POST --raw`。为了避免 Pipeline snapshot 这类超大业务 JSON 造成 CLI 输出爆炸,响应 body 超过默认阈值时会返回 `bodyOmitted=true`、`bodyPreview`、`bodyBytes` 和 `rawHint`;需要完整 body 时显式添加 `--raw`,或用 `--max-body-bytes ` 调整预览阈值。正式 frontend 展示仍应优先使用业务控件和 `__unideskArrayLimit` 这类展示级裁剪参数,而不是默认倾倒完整 JSON。 +`microservice proxy` 是面向人工验证和受控调试的私有后端入口。默认 method 为 GET;使用 `--body-json JSON`、`--body-file path` 或 `--body-stdin` 时默认 method 切换为 POST,也可显式加 `--method POST|PUT|PATCH|DELETE`,但 GET/HEAD 不允许携带请求体。所有请求仍受 config 中的 `allowedMethods` 和 `allowedPathPrefixes` 限制。为了避免 Pipeline snapshot 这类超大业务 JSON 造成 CLI 输出爆炸,响应 body 超过默认阈值时会返回 `bodyOmitted=true`、`bodyPreview`、`bodyBytes` 和 `rawHint`;需要完整 body 时显式添加 `--raw`,或用 `--max-body-bytes ` 调整预览阈值。正式 frontend 展示仍应优先使用业务控件和 `__unideskArrayLimit` 这类展示级裁剪参数,而不是默认倾倒完整 JSON。 `network perf` 用于生成组网性能前后对比数据。标准 Code Queue overview 读路径基准命令是 `bun scripts/cli.ts network perf --service code-queue --path /api/tasks/overview?limit=30 --count 30 --concurrency 1 --label before`,远程主 server 可用 `bun scripts/cli.ts --main-server-ip 74.48.78.17 network perf ...`。输出包含成功/失败数、状态码分布、`x-unidesk-cache`、`x-unidesk-proxy-mode`、`x-unidesk-upstream-proxy-mode` 分布和 min/p50/p90/p95/max;provider-gateway 长连接数据面验收应看到 `proxyModeCounts.provider-ws-http-tunnel`,adapter native Service 数据面验收应看到 upstream proxy mode 为 `kubernetes-native-service`,若出现 `kubernetes-api-service-proxy` 必须结合 `/api/control-plane.nativeServiceProxy.failedServices` 解释 fallback 原因。 diff --git a/scripts/cli.ts b/scripts/cli.ts index 664ba8af..cee254ee 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -43,14 +43,16 @@ function help(): unknown { { command: "microservice list", description: "List UniDesk-managed user services and their provider/runtime mapping." }, { command: "microservice status ", description: "Show one user service config, repository reference, backend mapping, and runtime status." }, { command: "microservice health ", description: "Probe one user service through backend-core -> provider-gateway HTTP proxy." }, - { command: "microservice proxy [--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 [--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 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 [--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 [--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 [--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 --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 ", 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 | queue merge --into | move --queue )", 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 [--tail-bytes N]", description: "Show job state with bounded stdout/stderr tails." }, diff --git a/scripts/src/code-queue.ts b/scripts/src/code-queue.ts index 6a856ebd..b9588eba 100644 --- a/scripts/src/code-queue.ts +++ b/scripts/src/code-queue.ts @@ -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; @@ -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 { + 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[] { + 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 { + 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 { + 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 `, + }, + }; +} + +function compactQueueMutationSummary(value: unknown): Record | 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 { 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"); } diff --git a/scripts/src/microservices.ts b/scripts/src/microservices.ts index d9422d4e..6de3e56a 100644 --- a/scripts/src/microservices.ts +++ b/scripts/src/microservices.ts @@ -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"); }