From 4ee3a67089b5c5398a2432a7605a6c53dd4b2617 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 10 Jun 2026 14:36:00 +0000 Subject: [PATCH] fix: split github contracts from script check --- .agents/skills/unidesk-sub2api/SKILL.md | 2 +- AGENTS.md | 2 +- docs/reference/cli.md | 2 +- .../check-gh-contract-scope-contract-test.ts | 31 ++++++++++ scripts/code-queue-cli-steer-test.ts | 45 +++++++++----- ...e-queue-cli-submit-prompt-contract-test.ts | 48 +++++++++------ .../code-queue-prompt-lint-contract-test.ts | 26 +++++--- scripts/code-queue-resume-contract-test.ts | 27 ++++++-- ...eue-submit-execution-mode-contract-test.ts | 44 ++++++------- ...dex-commander-prompt-lint-contract-test.ts | 17 ++++-- ...fra-sub2api-codex-routing-contract-test.ts | 22 ++++++- scripts/src/check.ts | 61 ++++++++++++++++--- 12 files changed, 233 insertions(+), 94 deletions(-) create mode 100644 scripts/check-gh-contract-scope-contract-test.ts diff --git a/.agents/skills/unidesk-sub2api/SKILL.md b/.agents/skills/unidesk-sub2api/SKILL.md index 0cd688bf..38ff98e2 100644 --- a/.agents/skills/unidesk-sub2api/SKILL.md +++ b/.agents/skills/unidesk-sub2api/SKILL.md @@ -85,7 +85,7 @@ Codex 启动时反复出现 WebSocket reconnect、HTTPS fallback、`websocket cl 1. 在 master `~/.codex/` 准备带后缀的上游 profile 文件,例如 `config.toml.` 和 `auth.json.`;禁止覆盖默认 `config.toml` / `auth.json`。 2. 在 `config/platform-infra/sub2api-codex-pool.yaml` 添加 `profiles.entries` 项,指定 `profile`、`accountName`、`configFile`、`authFile`。 3. 如需要,给该项加 `priority`、`capacity`、`loadFactor`、`tempUnschedulable`、`openaiResponsesWebSocketsV2Mode` 或 `upstreamUserAgent`;capacity/loadFactor 的具体数值只写在 YAML。 -4. 如果新增账号会提高声明 capacity 总和,同步提高 `pool.minOwnerConcurrency`;`codex-pool plan` 会拒绝 owner concurrency 低于总 capacity 的配置。 +4. 如果新增账号会提高声明 capacity 总和,默认让省略的 `pool.minOwnerConcurrency` 继续按 capacity 总和自动解析;只有 YAML 已经显式写了该 override 时,才同步提高到不低于总 capacity,或删除 override 回到自动解析。 5. 跑 `codex-pool plan`,确认 profile 可读、`base_url` 和 API key 来源有效,且 stdout 未泄露完整 key。 6. 跑 `codex-pool sync --confirm`。 7. 跑 `codex-pool validate`。 diff --git a/AGENTS.md b/AGENTS.md index eb427e9e..80421d6d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -209,7 +209,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `bun scripts/cli.ts help`:输出所有可用命令的 JSON 索引,详细规范见 `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 [--full|--files|--scripts-typecheck|--components|--compose|--logs|--recovery-guardrails|--rust]` / `bun scripts/cli.ts check recovery-guardrails`:默认只运行轻量配置和 TypeScript 语法检查;`check recovery-guardrails` 只读低噪声报告 D601 reboot 后 k3s/Code Queue hostPath、`/proc/mounts`、CRI sandbox 和 ContainerCreating 风险;Rust backend-core 检查默认只能在 D601 CI/dev execution 中用 `UNIDESK_D601_RUST_CHECK=1` 开启,backend-core 主 server 上线受控编译例外不改变 `check --rust` guard,规则见 `docs/reference/dev-environment.md` 和 `docs/reference/devops-hygiene.md`。 +- `bun scripts/cli.ts check [--full|--files|--scripts-typecheck|--gh-contracts|--components|--compose|--logs|--recovery-guardrails|--rust]` / `bun scripts/cli.ts check recovery-guardrails`:默认只运行轻量配置和 TypeScript 语法检查;GitHub issue/PR live contract 必须显式用 `--gh-contracts` 或 `--full` 开启;`check recovery-guardrails` 只读低噪声报告 D601 reboot 后 k3s/Code Queue hostPath、`/proc/mounts`、CRI sandbox 和 ContainerCreating 风险;Rust backend-core 检查默认只能在 D601 CI/dev execution 中用 `UNIDESK_D601_RUST_CHECK=1` 开启,backend-core 主 server 上线受控编译例外不改变 `check --rust` guard,规则见 `docs/reference/dev-environment.md` 和 `docs/reference/devops-hygiene.md`。 - `bun scripts/cli.ts server start`:以异步 job 启动 database、backend-core、frontend、provider-gateway、code-queue-mgr 和主 server 用户服务,部署规则见 `docs/reference/deployment.md`。 - `bun scripts/cli.ts server status`:查询固定端口、swap 摘要、容器状态、健康检查和访问 URL,包含生产 frontend、dev frontend proxy 和 provider ingress,判定标准见 `docs/reference/deployment.md` 与 `docs/reference/dev-environment.md`。 - `bun scripts/cli.ts server swap status|ensure [--path /swapfile] [--size 2GiB] [--dry-run]`:以 JSON 查看或幂等创建主 server swapfile,`ensure` 输出 before/after、动作、持久化状态和 degraded/failed 详情,规则见 `docs/reference/deployment.md`。 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 8dafee61..d8492f18 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -26,7 +26,7 @@ CI/CD、GitOps、rollout、artifact 发布、PR 合并后的 runtime lane 滚动 - 每个 CLI 命名空间必须支持 `help`、`--help` 或 `-h` 并返回 JSON,不得为了打印帮助而访问 runtime 服务、拉起交互会话或执行长时任务。 - `--main-server-ip ` 默认通过公网 frontend 登录态调用主 server 的同源 API 代理,不要求计算节点持有主 server SSH key;显式提供 `--main-server-key` 或 `--main-server-transport ssh` 时才使用旧 SSH 传输。远程 frontend 传输下的 `ssh ...` 必须复用同一套结构化 route parser,支持 `D601`、`G14`、host workspace、`D601:win`、`D601:win/c/test`、`D601:k3s` 和 `D601:k3s::` 这类定位路径;它不向调用容器下发 provider token,也不要求调用容器能解析 backend-core 内网 DNS。 - `config show` 读取并校验根目录 `config.json`,不从环境变量、默认值或隐藏文件静默补配置。 -- `check` 默认只执行轻量配置校验、Bun 版本检查和 Bun Transpiler 语法解析(覆盖 CLI 入口、主要 `scripts/` 模块和核心组件入口,不做类型推导);关键文件存在性、`scripts/` TypeScript 类型检查、`src/components/` TypeScript 类型检查、Docker Compose config、日志轮转策略扫描和 D601 recovery guardrails 默认不启用,分别通过 `--files`、`--scripts-typecheck`、`--components`、`--compose`、`--logs`、`--recovery-guardrails` 开启,或用 `--full` 一次性开启。`check recovery-guardrails` 是同一诊断的低噪声直接入口,报告 malformed `/proc/mounts`、kubelet validation risk、stale CRI sandbox count、Code Queue worktree/symlink、Code Queue/MDTODO hostPath 和 `ContainerCreating` 分类;它不得重启 k3s、删除 CRI sandbox、修改 hostPath、deploy/rollout 或 prune/reset。`--rust` 只允许在 D601 CI/dev execution 中配合 `UNIDESK_D601_RUST_CHECK=1` 使用,长期规则见 `docs/reference/dev-environment.md` 和 `docs/reference/devops-hygiene.md`。 +- `check` 默认只执行轻量配置校验、Bun 版本检查和 Bun Transpiler 语法解析(覆盖 CLI 入口、主要 `scripts/` 模块和核心组件入口,不做类型推导);关键文件存在性、`scripts/` TypeScript 类型检查、GitHub CLI contract、`src/components/` TypeScript 类型检查、Docker Compose config、日志轮转策略扫描和 D601 recovery guardrails 默认不启用,分别通过 `--files`、`--scripts-typecheck`、`--gh-contracts`、`--components`、`--compose`、`--logs`、`--recovery-guardrails` 开启,或用 `--full` 一次性开启。`--scripts-typecheck` 只表示 scripts TypeScript 与本地脚本合同,不应触发 GitHub issue/PR live contract 这类可能受网络/API 时延影响的检查;需要验证 GitHub CLI 时显式加 `--gh-contracts`,并以该 profile 的结果判定 GitHub CLI 变更。长命令项必须在 stderr 输出 `unidesk.check.progress` JSON lines,stdout 保持最终 JSON 结果,避免 post-task 或人工运行时长时间无可见进度。`check recovery-guardrails` 是同一诊断的低噪声直接入口,报告 malformed `/proc/mounts`、kubelet validation risk、stale CRI sandbox count、Code Queue worktree/symlink、Code Queue/MDTODO hostPath 和 `ContainerCreating` 分类;它不得重启 k3s、删除 CRI sandbox、修改 hostPath、deploy/rollout 或 prune/reset。`--rust` 只允许在 D601 CI/dev execution 中配合 `UNIDESK_D601_RUST_CHECK=1` 使用,长期规则见 `docs/reference/dev-environment.md` 和 `docs/reference/devops-hygiene.md`。 - `server start` 创建异步 job,在后台执行 Docker 构建和启动;命令本身只负责返回 job id、日志路径和启动命令。 - `server stop` 创建异步 job,在后台停止固定 Compose project 中的全部 UniDesk 服务。 - `server status` 查询公开端口、受限宿主端口、内部端口、主机 swap 摘要、Compose 容器、core/frontend/dev-frontend/provider/database 健康检查和访问 URL;D601 Code Queue 使用的 PostgreSQL/OA Event Flow host mapping 必须出现在受限宿主端口而不是无条件公开入口中。低内存主 server 上 `swap.warning` 非空时,先执行 `server swap status` 或 `server swap ensure`。 diff --git a/scripts/check-gh-contract-scope-contract-test.ts b/scripts/check-gh-contract-scope-contract-test.ts new file mode 100644 index 00000000..2ec00b9a --- /dev/null +++ b/scripts/check-gh-contract-scope-contract-test.ts @@ -0,0 +1,31 @@ +import { strict as assert } from "node:assert"; +import { parseCheckOptions, runChecks } from "./src/check"; + +const scriptsOnly = parseCheckOptions(["--scripts-typecheck"]); +assert.equal(scriptsOnly.scriptsTypecheck, true); +assert.equal(scriptsOnly.ghContracts, false); + +const full = parseCheckOptions(["--full"]); +assert.equal(full.scriptsTypecheck, true); +assert.equal(full.ghContracts, true); + +const explicit = parseCheckOptions(["--gh-contracts"]); +assert.equal(explicit.scriptsTypecheck, false); +assert.equal(explicit.ghContracts, true); + +const minimalConfig = { + project: { name: "contract" }, + runtime: "contract", +} as never; +const result = runChecks(minimalConfig, { ...scriptsOnly, scriptsTypecheck: false }); +const issueGuard = result.items.find((item) => item.name === "gh:issue-guard-contract"); +const prFiles = result.items.find((item) => item.name === "gh:pr-files-contract"); +const pr = result.items.find((item) => item.name === "gh:pr-contract"); + +for (const item of [issueGuard, prFiles, pr]) { + assert.ok(item, "GitHub contract item should be visible in check output"); + assert.equal(item?.ok, true); + assert.equal((item?.detail as { skipped?: boolean }).skipped, true); +} + +console.log("check gh contract scope contract ok"); diff --git a/scripts/code-queue-cli-steer-test.ts b/scripts/code-queue-cli-steer-test.ts index 0ef0b4b3..e4f7598e 100644 --- a/scripts/code-queue-cli-steer-test.ts +++ b/scripts/code-queue-cli-steer-test.ts @@ -49,6 +49,20 @@ function deterministicSteerId(taskId: string, prompt: string): string { return `steer_${Bun.SHA256.hash(`unidesk-code-queue-steer:v1\0${taskId}\0${prompt}`, "hex").slice(0, 24)}`; } +function assertLegacyFrozenWrite(result: { status: number | null; stdout: string; stderr: string; json: JsonRecord | null }, command: string): void { + assertCondition(result.status !== 0 && result.json?.ok === false, `${command} should be frozen`, result.json ?? { stdout: result.stdout, stderr: result.stderr }); + const data = nestedRecord(result.json?.data, []); + assertCondition(data.ok === false, `${command} frozen payload should be ok=false`, data); + assertCondition(data.frozen === true, `${command} frozen payload should expose frozen=true`, data); + assertCondition(data.mutation === false, `${command} frozen payload should be non-mutating`, data); + assertCondition(data.degradedReason === "legacy-code-queue-frozen", `${command} should use the legacy frozen reason`, data); + assertCondition(data.command === command, `${command} frozen payload should identify the command`, data); + const replacement = nestedRecord(data, ["replacement"]); + assertCondition(String(replacement.sessionsSteer || "").includes("agentrun v01 sessions steer"), `${command} should point to AgentRun sessions steer`, replacement); + const legacy = nestedRecord(data, ["legacy"]); + assertCondition(legacy.noDoubleWrite === true, `${command} should document no double-write`, legacy); +} + function assertDryRunPrompt(response: JsonRecord, expectedText: string): void { assertCondition(response.ok === true, "CLI dry-run should succeed", response); const data = nestedRecord(response.data, []); @@ -82,31 +96,29 @@ function assertReason(result: unknown, reason: string, status: number | null): v export function runCodeQueueCliSteerContract(): JsonRecord { const positional = runCli(["codex", "steer", "codex_test_task", "correct the running task", "--dry-run"]); - assertDryRunPrompt(positional.json ?? {}, "correct the running task"); + assertLegacyFrozenWrite(positional, "codex steer"); assertCondition(String(positional.json?.command || "").includes(""), "outer command should redact positional steer prompt", positional.json ?? {}); assertCondition(!String(positional.json?.command || "").includes("correct the running task"), "outer command must not echo positional steer prompt", positional.json ?? {}); const stdin = runCli(["codex", "steer", "codex_test_task", "--prompt-stdin", "--dry-run"], "stdin steer prompt\n"); - assertDryRunPrompt(stdin.json ?? {}, "stdin steer prompt\n"); + assertLegacyFrozenWrite(stdin, "codex steer"); + assertCondition(!stdin.stdout.includes("stdin steer prompt"), "frozen steer must not echo stdin prompt", { stdout: stdin.stdout }); const promptFile = join(tmpdir(), `unidesk-code-queue-steer-${process.pid}.txt`); writeFileSync(promptFile, "file steer prompt", "utf8"); try { const fromFile = runCli(["codex", "steer", "codex_test_task", "--prompt-file", promptFile, "--dry-run"]); - assertDryRunPrompt(fromFile.json ?? {}, "file steer prompt"); + assertLegacyFrozenWrite(fromFile, "codex steer"); + assertCondition(!fromFile.stdout.includes("file steer prompt"), "frozen steer must not echo file prompt", { stdout: fromFile.stdout }); } finally { unlinkSync(promptFile); } const duplicateSource = runCli(["codex", "steer", "codex_test_task", "positional", "--prompt-stdin", "--dry-run"], "stdin\n"); - assertCondition(duplicateSource.status !== 0, "duplicate prompt source should fail", duplicateSource.json ?? { stdout: duplicateSource.stdout }); - const duplicateMessage = String(nestedRecord(duplicateSource.json, ["error"]).message || ""); - assertCondition(duplicateMessage.includes("exactly one prompt source"), "duplicate prompt source error should be explicit", { duplicateMessage }); + assertLegacyFrozenWrite(duplicateSource, "codex steer"); const unknownOption = runCli(["codex", "steer", "codex_test_task", "--queue", "default", "prompt", "--dry-run"]); - assertCondition(unknownOption.status !== 0, "unknown steer option should fail", unknownOption.json ?? { stdout: unknownOption.stdout }); - const unknownMessage = String(nestedRecord(unknownOption.json, ["error"]).message || ""); - assertCondition(unknownMessage.includes("unsupported codex steer option: --queue"), "unknown option error should name option", { unknownMessage }); + assertLegacyFrozenWrite(unknownOption, "codex steer"); const help = runCli(["codex", "help"]); assertCondition(help.status === 0 && help.json?.ok === true, "codex help should succeed", help.json ?? { stdout: help.stdout }); @@ -118,7 +130,10 @@ export function runCodeQueueCliSteerContract(): JsonRecord { assertCondition(!("error" in (advertisedConfirm.json ?? {})), "advertised steer-confirm command must not fall through to top-level error", advertisedConfirm.json ?? {}); const advertisedDeliveryStatus = String(nestedRecord(advertisedConfirm.json?.data, ["delivery"]).status || ""); assertCondition(["confirmed", "pending", "unknown", "not-supported"].includes(advertisedDeliveryStatus), "advertised steer-confirm command should return structured delivery status", { advertisedDeliveryStatus, json: advertisedConfirm.json }); - assertCondition(!String(JSON.stringify(advertisedConfirm.json)).includes("\"message\":\"not found\""), "advertised steer-confirm command must not return top-level not found", advertisedConfirm.json ?? {}); + if (advertisedDeliveryStatus === "not-supported") { + const diagnostics = nestedRecord(advertisedConfirm.json?.data, ["diagnostics"]); + assertCondition(diagnostics.reason === "steer-confirmation-endpoint-not-supported", "unsupported steer-confirm should use structured diagnostics", diagnostics); + } let dryRunFetchCount = 0; const dryRunDirect = codexSteerTaskForTest("direct_task", ["do not send", "--dry-run"], () => { @@ -501,11 +516,11 @@ export function runCodeQueueCliSteerContract(): JsonRecord { return { ok: true, checks: [ - "steer positional dry-run", - "steer stdin dry-run", - "steer prompt-file dry-run", - "duplicate prompt source failure", - "unsupported option failure", + "legacy steer positional dry-run is frozen", + "legacy steer stdin dry-run is frozen", + "legacy steer prompt-file dry-run is frozen", + "legacy steer duplicate prompt source is frozen", + "legacy steer unsupported option is frozen", "codex help lists steer", "advertised steer-confirm CLI command returns structured status", "outer command redacts positional steer prompt", diff --git a/scripts/code-queue-cli-submit-prompt-contract-test.ts b/scripts/code-queue-cli-submit-prompt-contract-test.ts index e9e52bd6..dedc1fb1 100644 --- a/scripts/code-queue-cli-submit-prompt-contract-test.ts +++ b/scripts/code-queue-cli-submit-prompt-contract-test.ts @@ -45,6 +45,21 @@ function stringArray(value: unknown): string[] { return Array.isArray(value) ? value.map((item) => String(item)) : []; } +function assertLegacyFrozenWrite(result: { status: number | null; stdout: string; stderr: string; json: JsonRecord | null }, command: string): void { + assertCondition(result.status !== 0 && result.json?.ok === false, `${command} should be frozen`, result.json ?? { stdout: result.stdout, stderr: result.stderr }); + const data = nestedRecord(result.json?.data, []); + assertCondition(data.ok === false, `${command} frozen payload should be ok=false`, data); + assertCondition(data.frozen === true, `${command} frozen payload should expose frozen=true`, data); + assertCondition(data.mutation === false, `${command} frozen payload should be non-mutating`, data); + assertCondition(data.degradedReason === "legacy-code-queue-frozen", `${command} should use the legacy frozen reason`, data); + assertCondition(data.command === command, `${command} frozen payload should identify the command`, data); + const replacement = nestedRecord(data, ["replacement"]); + assertCondition(String(replacement.queueSubmit || "").includes("agentrun v01 queue submit"), `${command} should point to AgentRun queue submit`, replacement); + assertCondition(String(replacement.sessionsSteer || "").includes("agentrun v01 sessions steer"), `${command} should point to AgentRun sessions steer`, replacement); + const legacy = nestedRecord(data, ["legacy"]); + assertCondition(legacy.noDoubleWrite === true, `${command} should document no double-write`, legacy); +} + function assertDryRunPrompt(response: JsonRecord, expectedText: string): void { assertCondition(response.ok === true, "submit dry-run should succeed", response); const data = nestedRecord(response.data, []); @@ -66,8 +81,8 @@ export function runCodeQueueCliSubmitPromptContract(): JsonRecord { ].join("\n"); const stdin = runCli(["codex", "submit", "--prompt-stdin", "--queue", "prompt-contract", "--dry-run"], multilinePrompt); - assertDryRunPrompt(stdin.json ?? {}, multilinePrompt); - assertCondition(String(stdin.json?.command || "") === "codex submit --prompt-stdin --queue prompt-contract --dry-run", "stdin command should list flags without echoing prompt", stdin.json ?? {}); + assertLegacyFrozenWrite(stdin, "codex submit"); + assertCondition(!stdin.stdout.includes(multilinePrompt), "frozen submit must not echo stdin prompt", { stdout: stdin.stdout }); const tmp = mkdtempSync(join(tmpdir(), "unidesk-code-queue-submit-")); const promptFile = join(tmp, "prompt.md"); @@ -75,22 +90,19 @@ export function runCodeQueueCliSubmitPromptContract(): JsonRecord { writeFileSync(promptFile, filePrompt, "utf8"); try { const fromFile = runCli(["codex", "submit", "--prompt-file", promptFile, "--queue", "prompt-contract", "--dry-run"]); - assertDryRunPrompt(fromFile.json ?? {}, filePrompt); - assertCondition(String(fromFile.json?.command || "").includes(`--prompt-file ${promptFile}`), "prompt-file command should retain file path for review", fromFile.json ?? {}); - assertCondition(!String(fromFile.json?.command || "").includes("file prompt tail"), "prompt-file command must not echo file prompt text", fromFile.json ?? {}); + assertLegacyFrozenWrite(fromFile, "codex submit"); + assertCondition(!fromFile.stdout.includes("file prompt tail"), "frozen submit must not echo file prompt text", { stdout: fromFile.stdout }); } finally { rmSync(tmp, { recursive: true, force: true }); } const positional = runCli(["codex", "submit", "short smoke prompt", "--dry-run"]); - assertDryRunPrompt(positional.json ?? {}, "short smoke prompt"); + assertLegacyFrozenWrite(positional, "codex submit"); assertCondition(String(positional.json?.command || "").includes(""), "outer command should redact positional submit prompt", positional.json ?? {}); assertCondition(!String(positional.json?.command || "").includes("short smoke prompt"), "outer command must not echo positional submit prompt", positional.json ?? {}); const duplicateSource = runCli(["codex", "submit", "positional", "--prompt-stdin", "--dry-run"], "stdin\n"); - assertCondition(duplicateSource.status !== 0, "duplicate prompt source should fail", duplicateSource.json ?? { stdout: duplicateSource.stdout }); - const duplicateMessage = String(nestedRecord(duplicateSource.json, ["error"]).message || ""); - assertCondition(duplicateMessage.includes("exactly one prompt source"), "duplicate prompt source error should be explicit", { duplicateMessage }); + assertLegacyFrozenWrite(duplicateSource, "codex submit"); const longSubmittedPrompt = `${multilinePrompt}${"submitted prompt body must not be echoed\n".repeat(80)}`; const submitSuccess = compactSubmitSuccessResponseForTest({ @@ -128,23 +140,23 @@ export function runCodeQueueCliSubmitPromptContract(): JsonRecord { const promptInput = nestedRecord(data, ["promptInput"]); const recommended = stringArray(promptInput.recommended); const examples = nestedRecord(data, ["examples"]); - assertCondition(usage.some((line) => line.includes("--prompt-stdin")), "help usage should include --prompt-stdin", { usage }); - assertCondition(usage.some((line) => line.includes("--prompt-file")), "help usage should include --prompt-file", { usage }); - assertCondition(usage.some((line) => line.includes("cat <<'PROMPT'")), "help usage should include a quoted heredoc example", { usage }); + const submitSummary = nestedRecord(data, ["submitSummary"]); + assertCondition(usage.some((line) => line.includes("codex submit # frozen legacy write entry")), "help usage should document frozen legacy submit", { usage }); assertCondition(recommended.includes("--prompt-stdin") && recommended.includes("--prompt-file"), "help should recommend stdin and file prompt sources", promptInput); assertCondition(String(promptInput.sourceRule || "").includes("Exactly one prompt source"), "help should document exact prompt source rule", promptInput); - assertCondition(stringArray(examples.stdin).some((line) => line.includes("--prompt-stdin")), "help examples should include stdin command", examples); - assertCondition(String(examples.file || "").includes("--prompt-file"), "help examples should include file command", examples); + assertCondition(String(submitSummary.default || "").includes("legacy-code-queue-frozen"), "help submit summary should document frozen reason", submitSummary); + assertCondition(String(submitSummary.replacement || "").includes("agentrun v01 queue submit"), "help should point new submissions at AgentRun", submitSummary); + assertCondition(String(examples.agentRunSubmit || "").includes("agentrun v01 queue submit"), "help examples should include AgentRun submit", examples); return { ok: true, checks: [ - "submit --prompt-stdin preserves multiline quotes and newlines", - "submit --prompt-file preserves reviewed file contents", + "legacy submit --prompt-stdin is frozen and does not echo prompt text", + "legacy submit --prompt-file is frozen and does not echo prompt text", "submit positional prompt is redacted from the outer command envelope", - "duplicate submit prompt source fails explicitly", + "legacy submit duplicate prompt sources still fail at the frozen boundary", "submit success confirms write without echoing prompt", - "codex submit help documents stdin/file recommendations and copyable examples", + "codex submit help documents frozen legacy submit and AgentRun replacement", ], }; } diff --git a/scripts/code-queue-prompt-lint-contract-test.ts b/scripts/code-queue-prompt-lint-contract-test.ts index 691a63f5..e2bfd016 100644 --- a/scripts/code-queue-prompt-lint-contract-test.ts +++ b/scripts/code-queue-prompt-lint-contract-test.ts @@ -27,6 +27,17 @@ function stringArray(value: unknown): string[] { return Array.isArray(value) ? value.map((item) => String(item)) : []; } +function assertLegacyFrozenWrite(result: { status: number | null; stdout: string; stderr: string; json: JsonRecord | null }, command: string): void { + assertCondition(result.status !== 0 && result.json?.ok === false, `${command} should be frozen`, result.json ?? { stdout: result.stdout, stderr: result.stderr }); + const data = nestedRecord(result.json?.data, []); + assertCondition(data.frozen === true, `${command} frozen payload should expose frozen=true`, data); + assertCondition(data.mutation === false, `${command} frozen payload should be non-mutating`, data); + assertCondition(data.degradedReason === "legacy-code-queue-frozen", `${command} should use the legacy frozen reason`, data); + const replacement = nestedRecord(data, ["replacement"]); + assertCondition(String(replacement.queueSubmit || "").includes("agentrun v01 queue submit"), `${command} should point to AgentRun queue submit`, replacement); + assertCondition(String(replacement.sessionsSteer || "").includes("agentrun v01 sessions steer"), `${command} should point to AgentRun sessions steer`, replacement); +} + function runCli(args: string[], stdin?: string): { status: number | null; stdout: string; stderr: string; json: JsonRecord | null } { const result = spawnSync("bun", ["scripts/cli.ts", ...args], { cwd: process.cwd(), @@ -148,15 +159,12 @@ export function runCodeQueuePromptLintContract(): JsonRecord { } const submitDryRun = runCli(["codex", "submit", "--prompt-stdin", "--dry-run"], unclassifiedM3SmokePrompt); - assertCondition(submitDryRun.status === 0 && submitDryRun.json?.ok === true, "submit dry-run should still succeed for commander review", submitDryRun.json ?? { stdout: submitDryRun.stdout }); - const submitPromptLint = nestedRecord(submitDryRun.json?.data, ["promptLint"]); - assertCondition(submitPromptLint.dispatchDisposition === "needs-authorization", "submit dry-run should embed prompt lint authorization blocker", submitPromptLint); - assertCondition(submitPromptLint.requiredClass === "live-mutating", "submit dry-run lint should require live-mutating", submitPromptLint); + assertLegacyFrozenWrite(submitDryRun, "codex submit"); + assertCondition(!submitDryRun.stdout.includes("res_boxsimu_1"), "frozen submit must not echo prompt text", { stdout: submitDryRun.stdout }); const steerDryRun = runCli(["codex", "steer", "codex_test_task", "--prompt-stdin", "--dry-run"], unclassifiedM3SmokePrompt); - assertCondition(steerDryRun.status === 0 && steerDryRun.json?.ok === true, "steer dry-run should succeed for commander review", steerDryRun.json ?? { stdout: steerDryRun.stdout }); - const steerPromptLint = nestedRecord(steerDryRun.json?.data, ["promptLint"]); - assertCondition(steerPromptLint.dispatchDisposition === "needs-authorization", "steer dry-run should embed prompt lint authorization blocker", steerPromptLint); + assertLegacyFrozenWrite(steerDryRun, "codex steer"); + assertCondition(!steerDryRun.stdout.includes("res_boxsimu_1"), "frozen steer must not echo prompt text", { stdout: steerDryRun.stdout }); const help = runCli(["codex", "help"]); assertCondition(help.status === 0 && help.json?.ok === true, "codex help should succeed", help.json ?? { stdout: help.stdout }); @@ -174,8 +182,8 @@ export function runCodeQueuePromptLintContract(): JsonRecord { "unclassified HWLAB M3 smoke defaults read-only but requires live-mutating authorization", "prompt-lint evidence redacts secret-looking values", "prompt-lint CLI is dry-run, non-mutating, and does not echo full prompt text", - "submit --dry-run embeds prompt live-authorization lint", - "steer --dry-run embeds prompt live-authorization lint", + "legacy submit --dry-run is frozen and points to AgentRun", + "legacy steer --dry-run is frozen and points to AgentRun", "codex help documents prompt-lint and authorization classes", ], }; diff --git a/scripts/code-queue-resume-contract-test.ts b/scripts/code-queue-resume-contract-test.ts index 3f11c852..4a94a371 100644 --- a/scripts/code-queue-resume-contract-test.ts +++ b/scripts/code-queue-resume-contract-test.ts @@ -82,6 +82,20 @@ function deterministicResumeId(taskId: string, prompt: string): string { return `resume_${Bun.SHA256.hash(`unidesk-code-queue-resume:v1\0${taskId}\0${prompt}`, "hex").slice(0, 24)}`; } +function assertLegacyFrozenWrite(result: { status: number | null; stdout: string; stderr: string; json: JsonRecord | null }, command: string): void { + assertCondition(result.status !== 0 && result.json?.ok === false, `${command} should be frozen`, result.json ?? { stdout: result.stdout, stderr: result.stderr }); + const data = nestedRecord(result.json?.data, []); + assertCondition(data.ok === false, `${command} frozen payload should be ok=false`, data); + assertCondition(data.frozen === true, `${command} frozen payload should expose frozen=true`, data); + assertCondition(data.mutation === false, `${command} frozen payload should be non-mutating`, data); + assertCondition(data.degradedReason === "legacy-code-queue-frozen", `${command} should use the legacy frozen reason`, data); + assertCondition(data.command === command, `${command} frozen payload should identify the command`, data); + const replacement = nestedRecord(data, ["replacement"]); + assertCondition(String(replacement.sessionsSteer || "").includes("agentrun v01 sessions steer"), `${command} should point to AgentRun sessions steer`, replacement); + const legacy = nestedRecord(data, ["legacy"]); + assertCondition(legacy.noDoubleWrite === true, `${command} should document no double-write`, legacy); +} + function assertDryRunPrompt(response: JsonRecord, expectedText: string): void { assertCondition(response.ok === true, "CLI dry-run should succeed", response); const data = nestedRecord(response.data, []); @@ -100,25 +114,26 @@ function assertDryRunPrompt(response: JsonRecord, expectedText: string): void { export function runCodeQueueResumeContract(): JsonRecord { const positional = runCli(["codex", "resume", "codex_test_task", "fix the PR conflict", "--dry-run"]); - assertDryRunPrompt(positional.json ?? {}, "fix the PR conflict"); + assertLegacyFrozenWrite(positional, "codex resume"); assertCondition(String(positional.json?.command || "").includes(""), "outer command should redact positional resume prompt", positional.json ?? {}); assertCondition(!String(positional.json?.command || "").includes("fix the PR conflict"), "outer command must not echo positional resume prompt", positional.json ?? {}); const stdin = runCli(["codex", "resume", "codex_test_task", "--prompt-stdin", "--dry-run"], "stdin resume prompt\n"); - assertDryRunPrompt(stdin.json ?? {}, "stdin resume prompt\n"); + assertLegacyFrozenWrite(stdin, "codex resume"); + assertCondition(!stdin.stdout.includes("stdin resume prompt"), "frozen resume must not echo stdin prompt", { stdout: stdin.stdout }); const promptFile = join(tmpdir(), `unidesk-code-queue-resume-${process.pid}.txt`); writeFileSync(promptFile, "file resume prompt", "utf8"); try { const fromFile = runCli(["codex", "resume", "codex_test_task", "--prompt-file", promptFile, "--dry-run"]); - assertDryRunPrompt(fromFile.json ?? {}, "file resume prompt"); + assertLegacyFrozenWrite(fromFile, "codex resume"); + assertCondition(!fromFile.stdout.includes("file resume prompt"), "frozen resume must not echo file prompt", { stdout: fromFile.stdout }); } finally { unlinkSync(promptFile); } const duplicateSource = runCli(["codex", "resume", "codex_test_task", "positional", "--prompt-stdin", "--dry-run"], "stdin\n"); - assertCondition(duplicateSource.status !== 0, "duplicate prompt source should fail", duplicateSource.json ?? { stdout: duplicateSource.stdout }); - assertCondition(String(nestedRecord(duplicateSource.json, ["error"]).message || "").includes("exactly one prompt source"), "duplicate prompt source error should be explicit", duplicateSource.json ?? {}); + assertLegacyFrozenWrite(duplicateSource, "codex resume"); const help = runCli(["codex", "help"]); const usage = Array.isArray(nestedRecord(help.json?.data, []).usage) ? nestedRecord(help.json?.data, []).usage as unknown[] : []; @@ -291,7 +306,7 @@ export function runCodeQueueResumeContract(): JsonRecord { return { ok: true, checks: [ - "resume positional/stdin/file dry-runs", + "legacy resume positional/stdin/file dry-runs are frozen", "bounded disclosure and outer command redaction", "non-dry-run sends resumeId and omits prompt from output", "terminal resume accepted with thread reuse metadata", diff --git a/scripts/code-queue-submit-execution-mode-contract-test.ts b/scripts/code-queue-submit-execution-mode-contract-test.ts index 4ffe01fa..996bf717 100644 --- a/scripts/code-queue-submit-execution-mode-contract-test.ts +++ b/scripts/code-queue-submit-execution-mode-contract-test.ts @@ -54,6 +54,19 @@ function assertSecretFree(output: string): void { } } +function assertLegacyFrozenWrite(result: { status: number | null; stdout: string; stderr: string; json: JsonRecord | null }, command: string): void { + assertCondition(result.status !== 0 && result.json?.ok === false, `${command} should be frozen`, result.json ?? { stdout: result.stdout, stderr: result.stderr }); + const data = nestedRecord(result.json?.data, []); + assertCondition(data.ok === false, `${command} frozen payload should be ok=false`, data); + assertCondition(data.frozen === true, `${command} frozen payload should expose frozen=true`, data); + assertCondition(data.mutation === false, `${command} frozen payload should be non-mutating`, data); + assertCondition(data.degradedReason === "legacy-code-queue-frozen", `${command} should use the legacy frozen reason`, data); + const replacement = nestedRecord(data, ["replacement"]); + assertCondition(String(replacement.queueSubmit || "").includes("agentrun v01 queue submit"), `${command} should point to AgentRun queue submit`, replacement); + const legacy = nestedRecord(data, ["legacy"]); + assertCondition(legacy.noDoubleWrite === true, `${command} should document no double-write`, legacy); +} + export function runCodeQueueSubmitExecutionModeContract(): JsonRecord { assertCondition(normalizeRequestedCodeExecutionMode("full-access") === "full-access", "shared parser should preserve short requested mode ids"); assertCondition(normalizeCodeExecutionMode("full-access") === "default", "shared execution-mode normalizer should keep full-access on effective default"); @@ -61,33 +74,12 @@ export function runCodeQueueSubmitExecutionModeContract(): JsonRecord { assertCondition(requestedCodeExecutionModeIsRecognized("default") === true, "shared recognition helper should accept default mode"); const defaultMode = runCli(["codex", "submit", "execution mode default smoke", "--dry-run"]); - assertCondition(defaultMode.status === 0 && defaultMode.json?.ok === true, "default submit dry-run should succeed", defaultMode.json ?? { stdout: defaultMode.stdout, stderr: defaultMode.stderr }); + assertLegacyFrozenWrite(defaultMode, "codex submit"); assertSecretFree(defaultMode.stdout); - const defaultData = nestedRecord(defaultMode.json?.data, []); - const defaultRequest = nestedRecord(defaultData, ["request"]); - const defaultExecutionMode = nestedRecord(defaultData, ["executionMode"]); - const defaultPermissions = nestedRecord(defaultData, ["runnerPermissions"]); - assertCondition(defaultRequest.executionMode === undefined, "default payload should omit executionMode so service default is authoritative", defaultRequest); - assertCondition(defaultExecutionMode.requested === null, "default mode should show no explicit requested mode", defaultExecutionMode); - assertCondition(defaultExecutionMode.effective === "default", "default mode should expose effective default", defaultExecutionMode); - assertCondition(defaultExecutionMode.normalized === false, "default mode should not be reported as normalized", defaultExecutionMode); - assertCondition(defaultExecutionMode.recognized === true, "default mode should be recognized", defaultExecutionMode); - assertCondition(defaultPermissions.observed === false && defaultPermissions.perTaskOverrideSupported === false, "dry-run should mark runner permissions unobserved and non per-task", defaultPermissions); const fullAccess = runCli(["codex", "submit", "execution mode full access smoke", "--execution-mode", "full-access", "--dry-run"]); - assertCondition(fullAccess.status === 0 && fullAccess.json?.ok === true, "full-access submit dry-run should succeed", fullAccess.json ?? { stdout: fullAccess.stdout, stderr: fullAccess.stderr }); + assertLegacyFrozenWrite(fullAccess, "codex submit"); assertSecretFree(fullAccess.stdout); - const fullData = nestedRecord(fullAccess.json?.data, []); - const fullRequest = nestedRecord(fullData, ["request"]); - const fullExecutionMode = nestedRecord(fullData, ["executionMode"]); - assertCondition(fullRequest.executionMode === "full-access", "payload should preserve the requested executionMode value for backend visibility", fullRequest); - assertCondition(fullExecutionMode.requested === "full-access", "full-access request should be visible", fullExecutionMode); - assertCondition(fullExecutionMode.effective === "default", "full-access should normalize to the effective default runtime mode", fullExecutionMode); - assertCondition(fullExecutionMode.recognized === false, "full-access should not be treated as a recognized Code Queue execution mode", fullExecutionMode); - assertCondition(fullExecutionMode.normalized === true, "full-access should explicitly show normalization", fullExecutionMode); - assertCondition(fullExecutionMode.requestedLooksLikeSandbox === true, "full-access should be classified as a sandbox-like request", fullExecutionMode); - assertCondition(String(fullExecutionMode.permissionBoundary || "").includes("runnerPermissions.sandbox"), "permission boundary should point at runnerPermissions.sandbox", fullExecutionMode); - assertCondition(String(fullExecutionMode.warning || "").includes("not applied"), "full-access warning should say it is not a per-task sandbox override", fullExecutionMode); const promptText = "submitted full-access prompt body must stay omitted"; const submitted = compactSubmitSuccessResponseForTest({ @@ -138,11 +130,11 @@ export function runCodeQueueSubmitExecutionModeContract(): JsonRecord { return { ok: true, checks: [ - "default codex submit dry-run omits executionMode, reports effective default, and marks runner permissions unobserved", - "--execution-mode full-access preserves requested mode, reports effective default, and warns that sandbox permissions are service-level", + "legacy codex submit dry-run is frozen and points to AgentRun", + "legacy --execution-mode full-access submit dry-run is frozen without printing credentials", "real submit summary fixture exposes requested/effective mode plus observed runnerPermissions without prompt echo", "shared execution-mode helpers preserve requested full-access while normalizing effective runtime to default", - "execution-mode dry-run output does not print credential assignments", + "execution-mode frozen output does not print credential assignments", ], }; } diff --git a/scripts/host-codex-commander-prompt-lint-contract-test.ts b/scripts/host-codex-commander-prompt-lint-contract-test.ts index 6cc16be5..88be1790 100644 --- a/scripts/host-codex-commander-prompt-lint-contract-test.ts +++ b/scripts/host-codex-commander-prompt-lint-contract-test.ts @@ -43,6 +43,16 @@ function dataOf(envelope: JsonRecord): JsonRecord { return asRecord(envelope.data, "data"); } +function assertLegacyFrozenWrite(result: { status: number | null; stdout: string; stderr: string; envelope: JsonRecord }, command: string): void { + assertCondition(result.status !== 0 && result.envelope.ok === false, `${command} should be frozen`, result.envelope); + const data = dataOf(result.envelope); + assertCondition(data.frozen === true, `${command} frozen payload should expose frozen=true`, data); + assertCondition(data.mutation === false, `${command} frozen payload should be non-mutating`, data); + assertCondition(data.degradedReason === "legacy-code-queue-frozen", `${command} should use the legacy frozen reason`, data); + const replacement = asRecord(data.replacement, "replacement"); + assertCondition(String(replacement.queueSubmit || "").includes("agentrun v01 queue submit"), `${command} should point to AgentRun queue submit`, replacement); +} + const completePrompt = ` UniDesk#20 #118 / commander prompt boundary lint @@ -116,9 +126,8 @@ try { } const submitDryRun = runCli(["codex", "submit", "--prompt-stdin", "--queue", "prompt-lint-contract", "--dry-run"], incompletePromptWithSecret); -assertCondition(submitDryRun.status === 0 && submitDryRun.envelope.ok === true, "codex submit --dry-run should not be gated by commander prompt-lint", submitDryRun.envelope); -const submitData = dataOf(submitDryRun.envelope); -assertCondition(asRecord(submitData.request, "submit request").prompt !== undefined, "submit dry-run should keep its existing prompt review behavior", submitData); +assertLegacyFrozenWrite(submitDryRun, "codex submit"); +assertCondition(!submitDryRun.stdout.includes("ghp_prompt_lint_contract_secret"), "frozen submit must not echo prompt secret", submitDryRun.stdout); const helpRun = runCli(["commander", "--help"]); assertCondition(helpRun.status === 0 && helpRun.envelope.ok === true, "commander help should succeed", helpRun.envelope); @@ -143,7 +152,7 @@ process.stdout.write(`${JSON.stringify({ "missing PR/artifact/DEV rollout/ROLLOUT_OK/PROD-secret-DB-rollback clauses are reported as high risk", "prompt-lint supports --prompt-file and --stdin", "prompt-lint output does not echo full prompt or secret-like prompt text", - "commander prompt-lint remains advisory and does not gate codex submit --dry-run", + "commander prompt-lint remains advisory while legacy codex submit stays frozen", "commander help and host commander reference document the advisory lint entry", ], }, null, 2)}\n`); diff --git a/scripts/platform-infra-sub2api-codex-routing-contract-test.ts b/scripts/platform-infra-sub2api-codex-routing-contract-test.ts index af2fafed..f06f4f37 100644 --- a/scripts/platform-infra-sub2api-codex-routing-contract-test.ts +++ b/scripts/platform-infra-sub2api-codex-routing-contract-test.ts @@ -14,7 +14,7 @@ const parsed = Bun.YAML.parse(readFileSync(configPath, "utf8")) as { minOwnerConcurrency?: number; defaultTempUnschedulable?: { enabled?: boolean; - rules?: Array<{ statusCode?: number; keywords?: string[]; durationMinutes?: number }>; + rules?: Array<{ statusCode?: number; keywords?: string[]; durationMinutes?: number; description?: string }>; }; }; profiles?: { entries?: Array<{ profile?: string; accountName?: string; capacity?: number; loadFactor?: number; openaiResponsesWebSocketsV2Mode?: string | null }> }; @@ -28,6 +28,8 @@ const defaultPriority = parsed.pool?.defaultAccountPriority ?? 0; const defaultCapacity = parsed.pool?.defaultAccountCapacity ?? 0; const defaultLoadFactor = parsed.pool?.defaultAccountLoadFactor ?? 0; const desiredCapacity = entries.reduce((total, entry) => total + (entry.capacity ?? defaultCapacity), 0); +const explicitMinOwnerConcurrency = parsed.pool?.minOwnerConcurrency; +const resolvedMinOwnerConcurrency = explicitMinOwnerConcurrency ?? desiredCapacity; const allowedWebSocketModes = new Set(["off", "ctx_pool", "passthrough"]); const wsEnabledEntries = entries.filter((entry) => entry.openaiResponsesWebSocketsV2Mode && entry.openaiResponsesWebSocketsV2Mode !== "off"); const localWsEnabled = parsed.localCodex?.supportsWebSockets === true || parsed.localCodex?.responsesWebSocketsV2 === true; @@ -56,7 +58,21 @@ if (localWsEnabled) { } else { assertCondition(wsEnabledEntries.length === 0, "local Codex WebSocket transport disabled means all account WSv2 capability declarations must be off or omitted", { localCodex: parsed.localCodex, wsEnabledEntries }); } -assertCondition((parsed.pool?.minOwnerConcurrency ?? 0) >= desiredCapacity, "pool owner concurrency must not bottleneck the declared account capacity set", { minOwnerConcurrency: parsed.pool?.minOwnerConcurrency, desiredCapacity }); +assertCondition( + explicitMinOwnerConcurrency === undefined || (Number.isInteger(explicitMinOwnerConcurrency) && explicitMinOwnerConcurrency > 0), + "explicit pool owner concurrency override must be a positive integer when declared", + { minOwnerConcurrency: explicitMinOwnerConcurrency }, +); +assertCondition( + resolvedMinOwnerConcurrency >= desiredCapacity, + "pool owner concurrency must auto-resolve or be configured to cover the declared account capacity set", + { + minOwnerConcurrency: explicitMinOwnerConcurrency, + minOwnerConcurrencySource: explicitMinOwnerConcurrency === undefined ? "auto-capacity-sum" : "yaml", + resolvedMinOwnerConcurrency, + desiredCapacity, + }, +); if (parsed.pool?.defaultTempUnschedulable?.enabled === true) { assertCondition(rules.length > 0, "enabled temporary unschedulable policy must declare rules", parsed.pool?.defaultTempUnschedulable); assertCondition(rules.every((rule) => Number.isInteger(rule.statusCode) && (rule.statusCode ?? 0) >= 100 && (rule.statusCode ?? 0) <= 599), "temporary unschedulable rules must declare valid HTTP status codes", rules); @@ -116,7 +132,7 @@ console.log(JSON.stringify({ ok: true, checks: [ "routing config is schema-valid without profile-specific test gates", - "pool owner concurrency covers the YAML account capacity set", + "pool owner concurrency auto-resolves or covers the YAML account capacity set", "profile load factor overrides are YAML-controlled positive integers", "public Caddy response-header timeout is long enough for Codex compact", "optional WebSocket mode overrides use supported values", diff --git a/scripts/src/check.ts b/scripts/src/check.ts index 6dce37b6..74f530e4 100644 --- a/scripts/src/check.ts +++ b/scripts/src/check.ts @@ -19,6 +19,7 @@ const syntaxFiles = [ "scripts/platform-infra-sub2api-codex-routing-contract-test.ts", "scripts/platform-infra-sub2api-codex-temp-unsched-contract-test.ts", "scripts/platform-infra-sub2api-http-upstream-contract-test.ts", + "scripts/check-gh-contract-scope-contract-test.ts", "scripts/src/playwright-cli.ts", "scripts/src/check.ts", "scripts/src/artifact-registry.ts", @@ -86,6 +87,7 @@ export interface CheckOptions { compose: boolean; logs: boolean; recoveryGuardrails: boolean; + ghContracts: boolean; rust: boolean; } @@ -97,6 +99,7 @@ const defaultCheckOptions: CheckOptions = { compose: false, logs: false, recoveryGuardrails: false, + ghContracts: false, rust: false, }; @@ -105,15 +108,16 @@ export function checkHelp(): Record { ok: true, command: "check", usage: [ - "bun scripts/cli.ts check [--syntax-only|--full|--files|--scripts-typecheck|--components|--compose|--logs|--recovery-guardrails|--rust]", + "bun scripts/cli.ts check [--syntax-only|--full|--files|--scripts-typecheck|--gh-contracts|--components|--compose|--logs|--recovery-guardrails|--rust]", "bun scripts/cli.ts check recovery-guardrails", ], defaultMode: "syntax/config only; Rust is never compiled on the master server by default", options: [ { name: "--syntax-only|--basic", description: "Run only config validation, Bun version and TypeScript syntax transpile." }, - { name: "--full", description: "Enable all local checks except Rust compilation." }, + { name: "--full", description: "Enable all non-Rust checks, including explicit GitHub CLI contracts." }, { name: "--files", description: "Verify required entrypoint files, including backend-core Cargo files." }, { name: "--scripts-typecheck", description: "Run scripts TypeScript typecheck." }, + { name: "--gh-contracts", description: "Run slower GitHub CLI contract tests; intentionally separate from generic scripts typecheck." }, { name: "--components", description: "Run component TypeScript typecheck." }, { name: "--compose", description: "Render Docker Compose config." }, { name: "--logs", description: "Check unified log rotation policy." }, @@ -143,10 +147,13 @@ export function parseCheckOptions(args: string[]): CheckOptions { options.compose = true; options.logs = true; options.recoveryGuardrails = true; + options.ghContracts = true; } else if (arg === "--files") { options.files = true; } else if (arg === "--scripts-typecheck") { options.scriptsTypecheck = true; + } else if (arg === "--gh-contracts") { + options.ghContracts = true; } else if (arg === "--components") { options.components = true; } else if (arg === "--compose") { @@ -171,13 +178,29 @@ function fileItem(path: string): CheckItem { return { name: `file:${path}`, ok: existsSync(absolute), detail: absolute }; } +function emitCheckProgress(detail: Record): void { + console.error(JSON.stringify({ event: "unidesk.check.progress", ...detail })); +} + function commandItem(name: string, command: string[], timeoutMs = 30_000, env: NodeJS.ProcessEnv = process.env): CheckItem { + const startedAt = Date.now(); + emitCheckProgress({ phase: "started", name, command, timeoutMs }); const result = runCommand(command, repoRoot, { timeoutMs, env }); + const durationMs = Date.now() - startedAt; + emitCheckProgress({ + phase: result.exitCode === 0 ? "succeeded" : "failed", + name, + durationMs, + exitCode: result.exitCode, + signal: result.signal, + timedOut: result.timedOut, + }); return { name, ok: result.exitCode === 0, detail: { command, + durationMs, exitCode: result.exitCode, signal: result.signal, timedOut: result.timedOut, @@ -312,7 +335,7 @@ export function runRecoveryGuardrailsCheck(config: UniDeskConfig): ReturnType item.ok), mode: options.full ? "full" : "basic", options, items }; + const failedItems = items.filter((item) => !item.ok).map((item) => item.name); + return { + ok: failedItems.length === 0, + mode: options.full ? "full" : "basic", + options, + summary: { + total: items.length, + passed: items.length - failedItems.length, + failed: failedItems.length, + failedItems, + }, + items, + }; }