fix: tighten code queue pr preflight contract
This commit is contained in:
@@ -43,7 +43,7 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI
|
||||
- `schedule list|get|runs|run|retry-run|delete|upsert-pgdata-backup` 管理 backend-core 定时任务和运行历史。`schedule list`、`schedule get`、`schedule runs --limit N` 和 `schedule runs <scheduleId> --limit N` 是只读观察入口;`schedule run`、`schedule retry-run`、`schedule delete` 和 `schedule upsert-pgdata-backup` 会触发运行或写入配置,生产恢复时必须有明确授权。`schedule runs --limit N` 是全局历史视图,返回 `scope=global` 和 `scheduleId=null`;`schedule runs <scheduleId> --limit N` 是指定 schedule 历史视图,返回 `scope=schedule` 和对应 `scheduleId`。CLI 必须拒绝 `schedule runs 50` 这类纯数字位置参数,并提示使用 `schedule runs --limit 50`,避免把空数组误判成“没有历史 run”。`schedule run <id> --wait-ms N` 触发同一 schedule,并且即使 wait 超时也必须返回 `newRunId` 和 `observeCommand`;`schedule retry-run <failedRunId>` 只接受 failed run,从原 run 反查 `scheduleId` 后重触发同一 schedule,并输出 `originalRunId`、`scheduleId`、`newRunId` 和 `observeCommand`。当 backend-core 目标容器缺失或只观察到 verify-only 容器时,schedule/microservice 命令必须以非零退出并返回 `failureKind=target-stack-not-running`、`runnerDisposition=infra-blocked`、`readOnlyCommands` 和 `authorizationRequiredForRecovery`,不得把 Docker 的 `No such container` 当成成功的空历史。
|
||||
- `codex deploy <commitId>` 是旧 Code Queue 兼容部署入口,已禁用以防止维护通道直连 D601 部署 Code Queue;当前 dev 自动化只做 `ci run-dev-e2e` smoke,不提供 Code Queue CD,详细规则见 `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` 只返回结构化请求且不实际入队。dry-run 会额外输出 `routingRecommendation`,包含推荐 route、runner、model、风险信号、prompt 自包含/issue 非唯一来源/prod-secret-DB 禁止/运行态或 release 禁止/证据要求/中等复杂度候选等 guard 状态;同时输出 `policyContract`,固定暴露 GPT-5.5、DeepSeek、MiniMax 的风险分层、并发上限和外部 provider 429 退避处置。该建议只用于指挥官 preflight,不会改写 payload,不改变 runtime admission,也不假设生产 MiniMax 或 DeepSeek 可用。提交确认和 dry-run 必须返回完整 prompt、字符数和 `truncated=false`,不能套用任务详情的预览截断策略,否则长任务 prompt 无法被人工验收。真实提交会经过本机本地串行化保护和短节流,避免同一指挥端并发 submit 把低内存主机或 `code-queue-mgr` 控制面打抖;返回值会附带 `submitConcurrencyGuard` 说明本次提交的锁与等待信息。backend-core 默认把提交、队列 CRUD、已读状态、历史摘要和轻量 Trace 读取分流到主 server `code-queue-mgr`,由它写入主 PostgreSQL;D601 scheduler 只轮询并执行已入库任务。
|
||||
- `codex pr-preflight [--remote] [--push-dry-run --push-dry-run-ref refs/heads/probe/<name>] [--pr-create-dry-run --pr-create-dry-run-head <head>] [--issue N] [--full]` 通过稳定 `code-queue` proxy 请求 D601 scheduler `/api/runtime-preflight`,用于 PR 型派单 admission。输出会压缩展示 scheduler/runner 的 token 覆盖、工具、agent port、Git worktree、GitHub egress、repo/issue/PR 只读探测、可选 push dry-run,以及可选 PR body/create dry-run guard;只报告 `GH_TOKEN`/`GITHUB_TOKEN` 是否存在和来源 key,不打印值。缺少 env token 时顶层 `ok=false`、`runnerDisposition=infra-blocked`,`tokenCoverage.missing` 同时列出 `GH_TOKEN` 与 `GITHUB_TOKEN`。`preflight.prCapabilityContract` 是 runner-facing 合同摘要,必须包含目标分支、token 来源 key、`systemGhBinaryRequiredForWrites=false`、UniDesk REST `bun scripts/cli.ts gh` 可用性、push dry-run/PR create dry-run 的 `writesRemote=false`、expected PR handoff 和 `gh pr merge` 的 `unsupported-command` 边界。`--remote` 在 runner-like 环境里不再依赖本地 `unidesk-backend-core`、`unidesk-database`、`baidu-netdisk-backend` 容器存在;这些缺失只作为本地观测证据。若远程控制面可达,则继续走远程控制面结果;若远程控制面不可达,则结构化返回 `failureKind=proxy-gap` / `degradedReason=remote-control-plane-unreachable`,而不是把本地 `backend-core-container-missing` 当作最终阻塞。`--pr-create-dry-run` 不 POST GitHub,只证明 runner 内 PR body 生成、`scripts/cli.ts gh pr create --dry-run` 和 branch 参数形态可用;服务端创建权限仍以 token auth、repo/issue/PR read、push dry-run 和最终授权后的真实 PR 创建结果为准。
|
||||
- `codex pr-preflight [--remote] [--push-dry-run --push-dry-run-ref refs/heads/probe/<name>] [--pr-create-dry-run --pr-create-dry-run-head <head>] [--issue N] [--full]` 通过稳定 `code-queue` proxy 请求 D601 scheduler `/api/runtime-preflight`,用于 PR 型派单 admission。输出会压缩展示 scheduler/runner 的 token 覆盖、工具、agent port、Git worktree、GitHub egress、repo/issue/PR 只读探测、可选 push dry-run,以及可选 PR body/create dry-run guard;只报告 `GH_TOKEN`/`GITHUB_TOKEN` 是否存在和来源 key,不打印值。缺少 env token 时顶层 `ok=false`、`runnerDisposition=infra-blocked`,`tokenCoverage.missing` 同时列出 `GH_TOKEN` 与 `GITHUB_TOKEN`,并输出 `preflight.authBroker.source="broker/auth-broker-needed"`。`preflight.prCapabilityContract` 是 runner-facing 合同摘要,必须包含目标分支、token 来源 key、`systemGhBinaryRequiredForWrites=false`、UniDesk REST `bun scripts/cli.ts gh` 可用性、push dry-run/PR create dry-run 的 `writesRemote=false`、expected PR handoff 和 `gh pr merge` 的 `unsupported-command` 边界;系统 `gh` binary 缺失只进入 `tools.systemGhBinary`,不得误判为 UniDesk REST `gh` CLI 不可用。`--remote` 在 runner-like 环境里不再依赖本地 `unidesk-backend-core`、`unidesk-database`、`baidu-netdisk-backend` 容器存在;这些缺失只作为本地观测证据。若远程控制面可达,则继续走远程控制面结果;若远程控制面不可达,则结构化返回 `failureKind=control-plane-missing` / `degradedReason=remote-control-plane-unreachable`,而不是把本地 `backend-core-container-missing` 当作最终阻塞。`--pr-create-dry-run` 不 POST GitHub,只证明 runner 内 PR body 生成、`scripts/cli.ts gh pr create --dry-run` 和 branch 参数形态可用;服务端创建权限仍以 token auth、repo/issue/PR read、push dry-run 和最终授权后的真实 PR 创建结果为准。
|
||||
- `codex task <taskId>` 通过 Code Queue 私有代理按任务 ID 查询结构化审阅摘要;默认只返回任务身份、执行 Provider、工作目录、attempt 计数、原始 prompt、最终 response、最后错误和渐进披露命令,适合指挥官审阅完成未读任务且避免上下文爆炸。需要旧式详细摘要时显式加 `--detail`;需要完整 prompt/response 文本时加 `--full`;需要工具调用、judge、attempt 全量摘要时使用 `--detail --full --tool-limit N`。该摘要读取默认由主 server `code-queue-mgr` 从 PostgreSQL 返回,不依赖 D601 `code-queue-read` Service 可用。
|
||||
- `codex tasks [--view supervisor|full] [--queue id] [--status succeeded|running|queued|failed|canceled|judging|retry_wait[,..]] [--unread|--unread-only] [--limit N] [--before-id id]` 通过同一私有代理输出渐进式披露视图。默认 `supervisor` 只返回 `running`、`completedUnread`、`recentCompleted`、`queued` 和 `executionDiagnostics` 摘要,不嵌入完整 Trace、final response 或全量 overview;每个条目都带 `commands.show`、`commands.trace`、`commands.output`、`commands.read` 和 `commands.full`。`--unread` 是 `--unread-only` 的别名,必须只保留未读终态;`--status` 必须真实过滤支持的状态,未知参数或未知状态必须结构化失败,不能静默忽略。需要完整当前页任务简表时显式使用 `--view full` 或 `--full`,仍受 `--limit` 和 `--before-id` 分页约束。
|
||||
- `codex task <taskId> --trace --tail|--from-start|--after-seq N|--before-seq N --limit N` 按页拉取 Code Queue 的逻辑 trace;响应会返回 `nextAfterSeq`、`previousBeforeSeq`、`hasMore`、`hasBefore` 和下一页/上一页命令,默认 `--trace` 取最新一页,且仍以分页 trace 为主;需要完整 prompt/最终 response 时加 `--full`,需要详细 task 摘要时加 `--detail`。
|
||||
|
||||
@@ -151,7 +151,7 @@ Runner preflight 优先使用执行面诊断入口:
|
||||
bun scripts/cli.ts codex pr-preflight --remote --issue 20
|
||||
```
|
||||
|
||||
该命令经 backend-core 稳定 `code-queue` proxy 访问 D601 scheduler 的 `/api/runtime-preflight`,报告 scheduler/runner 环境里的 `GH_TOKEN`/`GITHUB_TOKEN` 覆盖、工具、Git worktree、GitHub egress、repo/issue/PR 只读探测和可选 push dry-run。需要复核 PR body/创建命令 guard 时追加 `--pr-create-dry-run --pr-create-dry-run-head <head>`;该 guard 只执行 dry-run,不创建 PR。缺少 env token 时必须返回 `ok=false`、`runnerDisposition=infra-blocked` 和 `tokenCoverage.missing=["GH_TOKEN","GITHUB_TOKEN"]`,因为 provider dev container 只能转发 scheduler 已经拥有的 token。`--remote` 在 runner-like 环境里不再要求本地 `unidesk-backend-core`、`unidesk-database`、`baidu-netdisk-backend` 容器存在;这些本地 target stack 缺失只作为证据,不是最终主阻塞。若远程控制面可达,输出继续保留 ready preflight;若远程控制面不可达,结构化失败归类为 `failureKind=proxy-gap` / `degradedReason=remote-control-plane-unreachable`。输出中的 `prCapabilityContract` 用于指挥官快速审查 runner handoff:目标分支固定显示、push/PR create dry-run 标记为不写远端、系统 `gh` binary 与 UniDesk REST `bun scripts/cli.ts gh` 可用性分开报告,且 merge 明确保持 `unsupported-command`。
|
||||
该命令经 backend-core 稳定 `code-queue` proxy 访问 D601 scheduler 的 `/api/runtime-preflight`,报告 scheduler/runner 环境里的 `GH_TOKEN`/`GITHUB_TOKEN` 覆盖、工具、Git worktree、GitHub egress、repo/issue/PR 只读探测和可选 push dry-run。需要复核 PR body/创建命令 guard 时追加 `--pr-create-dry-run --pr-create-dry-run-head <head>`;该 guard 只执行 dry-run,不创建 PR。缺少 env token 时必须返回 `ok=false`、`runnerDisposition=infra-blocked`、`tokenCoverage.missing=["GH_TOKEN","GITHUB_TOKEN"]` 和 `authBroker.source="broker/auth-broker-needed"`,因为 provider dev container 只能转发 scheduler 已经拥有的 token,除非后续接入 broker-held GitHub credential。系统 `gh` binary 缺失只能作为 `tools.systemGhBinary.ok=false` 观测,不得把它误判为 UniDesk REST `bun scripts/cli.ts gh` 不可用。`--remote` 在 runner-like 环境里不再要求本地 `unidesk-backend-core`、`unidesk-database`、`baidu-netdisk-backend` 容器存在;这些本地 target stack 缺失只作为证据,不是最终主阻塞。若远程控制面可达,输出继续保留 ready preflight;若远程控制面不可达,结构化失败归类为 `failureKind=control-plane-missing` / `degradedReason=remote-control-plane-unreachable`。输出中的 `prCapabilityContract` 用于指挥官快速审查 runner handoff:目标分支固定显示、push/PR create dry-run 标记为不写远端、系统 `gh` binary 与 UniDesk REST `bun scripts/cli.ts gh` 可用性分开报告,且 merge 明确保持 `unsupported-command`。
|
||||
|
||||
本地 runner preflight 示例:
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ D601 原生 k3s 的人工诊断必须显式使用 host kubeconfig:`KUBECONFIG=
|
||||
|
||||
Code Queue worker 可以在任务明确要求审查型交付时创建 Pull Request。PR 交付不是默认出口;默认集成仍遵循项目当前 master-only 规则,直到具体任务或指挥官要求改为 PR。PR 型任务必须从最新目标线创建短生命周期分支,报告源分支、目标分支、PR URL、关联 issue、验证证据和未完成风险;分支命名应使用 `probe/`、`code-queue/` 或其他明确任务前缀,禁止把隐藏分支当成长期交付状态。
|
||||
|
||||
Code Queue runtime 提供 `/api/runtime-preflight` 作为 PR 能力探测入口;CLI 稳定入口是 `bun scripts/cli.ts codex pr-preflight [--remote] [--push-dry-run --push-dry-run-ref refs/heads/probe/<name>] [--pr-create-dry-run --pr-create-dry-run-head <head>] [--issue N]`。默认请求只检查本地工具、凭证可见性、Git worktree、HOME、known_hosts、agent port 和 proxy DNS;`--remote` 会增加 GitHub 网络、issue API、SSH/HTTPS `git ls-remote`、GitHub SSH、`gh auth status`、`gh repo view`、`gh issue read/view` 和只读 `gh pr list` 探测;`--push-dry-run` 会额外执行 `git push --dry-run`,验证远端写权限但不创建分支;`--pr-create-dry-run` 会在 runner 内生成受控 PR body 并执行 `scripts/cli.ts gh pr create --dry-run`,只证明 PR body guard 和命令形态可用,不 POST GitHub。探测输出只报告 `GH_TOKEN`/`GITHUB_TOKEN` 是否存在,不得输出 token 内容,并通过 `prCapabilityContract` 明确 token source、target branch、expected PR handoff、dry-run 不写远端和 merge unsupported 边界。backend-core 的稳定 `code-queue` proxy 必须把 `/api/runtime-preflight` 路由到 D601 scheduler,而不是主 server `code-queue-mgr`,因为 token 和 PR runner 能力属于执行面环境。
|
||||
Code Queue runtime 提供 `/api/runtime-preflight` 作为 PR 能力探测入口;CLI 稳定入口是 `bun scripts/cli.ts codex pr-preflight [--remote] [--push-dry-run --push-dry-run-ref refs/heads/probe/<name>] [--pr-create-dry-run --pr-create-dry-run-head <head>] [--issue N]`。默认请求只检查本地工具、凭证可见性、Git worktree、HOME、known_hosts、agent port 和 proxy DNS;`--remote` 会增加 GitHub 网络、issue API、SSH/HTTPS `git ls-remote`、GitHub SSH、`gh auth status`、`gh repo view`、`gh issue read/view` 和只读 `gh pr list` 探测;`--push-dry-run` 会额外执行 `git push --dry-run`,验证远端写权限但不创建分支;`--pr-create-dry-run` 会在 runner 内生成受控 PR body 并执行 `scripts/cli.ts gh pr create --dry-run`,只证明 PR body guard 和命令形态可用,不 POST GitHub。探测输出只报告 `GH_TOKEN`/`GITHUB_TOKEN` 是否存在,不得输出 token 内容,并通过 `prCapabilityContract` 明确 token source、target branch、expected PR handoff、dry-run 不写远端和 merge unsupported 边界。缺少 runner env token 时,`authBroker.source="broker/auth-broker-needed"` 是标准结构化证据;系统 `gh` binary 缺失必须与 UniDesk REST `bun scripts/cli.ts gh` 可用性分开报告。backend-core 的稳定 `code-queue` proxy 必须把 `/api/runtime-preflight` 路由到 D601 scheduler,而不是主 server `code-queue-mgr`,因为 token 和 PR runner 能力属于执行面环境。
|
||||
|
||||
PR 创建依赖以下最小运行时能力:
|
||||
|
||||
@@ -81,7 +81,7 @@ PR 创建依赖以下最小运行时能力:
|
||||
|
||||
安全验证顺序固定为先只读、再 dry-run、最后才创建真实 PR。优先执行 `/api/runtime-preflight`、`/api/runtime-preflight?remote=1`、带 `pushDryRun=1` 的 probe ref,以及带 `prCreateDryRun=1` 的 PR body/create dry-run guard;只有工具、token、网络、push dry-run 和 PR body guard 都满足且任务明确允许时,才创建 draft PR 或普通 PR。若创建真实 probe PR,最终报告必须记录 URL 并说明保留或关闭状态。
|
||||
|
||||
preflight failure 必须定位到具体缺失项。已知必须区分的状态包括:`gh`/`hub` 缺失、`GH_TOKEN`/`GITHUB_TOKEN`/`GH_HOST`/`GITHUB_API_URL`/`GH_REPO` 未注入、`/root/.config/gh/hosts.yml` 与 `/root/.git-credentials` 缺失、SSH Git 可读但 GitHub issue/API 匿名访问返回 404、匿名 HTTPS Git 不可用、以及 `HTTP_PROXY`/`HTTPS_PROXY` 指向的 egress proxy 在 runner 内不可解析。发现这类缺口时,worker 应报告 preflight error 和阻塞项,不得强行创建 PR。
|
||||
preflight failure 必须定位到具体缺失项。已知必须区分的状态包括:`auth-missing`(`GH_TOKEN`/`GITHUB_TOKEN` 未注入,且需要 env token 或 auth-broker)、`control-plane-missing`(远程 frontend/backend-core/code-queue proxy 不可达)、`git-remote-gap`(`git ls-remote` 或 `git push --dry-run` 失败)、系统 `gh`/`hub` 缺失、`GH_HOST`/`GITHUB_API_URL`/`GH_REPO` 未注入、`/root/.config/gh/hosts.yml` 与 `/root/.git-credentials` 缺失、SSH Git 可读但 GitHub issue/API 匿名访问返回 404、匿名 HTTPS Git 不可用、以及 `HTTP_PROXY`/`HTTPS_PROXY` 指向的 egress proxy 在 runner 内不可解析。发现这类缺口时,worker 应报告 preflight error 和阻塞项,不得强行创建 PR。
|
||||
|
||||
## Boundaries
|
||||
|
||||
|
||||
@@ -69,6 +69,30 @@ function remoteControlPlaneResult(overrides: Partial<JsonRecord> = {}): JsonReco
|
||||
missing: [],
|
||||
scope: "scheduler-runner-env",
|
||||
},
|
||||
authBroker: {
|
||||
ok: true,
|
||||
source: "GH_TOKEN",
|
||||
needed: false,
|
||||
configured: false,
|
||||
runnerDisposition: "ready",
|
||||
failureKind: null,
|
||||
degradedReason: null,
|
||||
runnerEnvTokenRequiredWithoutBroker: true,
|
||||
brokerCredentialSource: null,
|
||||
valuesPrinted: false,
|
||||
evidence: {
|
||||
envTokenMissing: false,
|
||||
missing: [],
|
||||
systemGhBinaryOk: true,
|
||||
systemGhBinaryRequiredForWrites: false,
|
||||
unideskGhCliObserved: true,
|
||||
unideskGhCliOk: true,
|
||||
unideskGhCliRequiresSystemGhBinary: false,
|
||||
systemGhMissingMisclassifiedAsUniDeskCliMissing: false,
|
||||
},
|
||||
next: [],
|
||||
reference: "docs/reference/auth-broker.md#post-v1githubpr-preflight",
|
||||
},
|
||||
prCapabilityContract: {
|
||||
targetBranch: "master",
|
||||
tokenSource: "GH_TOKEN",
|
||||
@@ -231,7 +255,7 @@ async function main(): Promise<void> {
|
||||
});
|
||||
const remoteControlPlaneMissingRecord = asRecord(authMissing);
|
||||
assertCondition(remoteControlPlaneMissingRecord.ok === false, "missing control plane should fail", remoteControlPlaneMissingRecord);
|
||||
assertCondition(remoteControlPlaneMissingRecord.failureKind === "proxy-gap", "missing control plane should classify as proxy-gap", remoteControlPlaneMissingRecord);
|
||||
assertCondition(remoteControlPlaneMissingRecord.failureKind === "control-plane-missing", "missing control plane should classify as control-plane-missing", remoteControlPlaneMissingRecord);
|
||||
assertCondition(remoteControlPlaneMissingRecord.degradedReason === "remote-control-plane-unreachable", "missing control plane should classify as remote-control-plane-unreachable", remoteControlPlaneMissingRecord);
|
||||
assertCondition(asRecord(remoteControlPlaneMissingRecord.controlPlane).localBackendCoreMissing === true, "local backend-core absence should remain evidence only", remoteControlPlaneMissingRecord.controlPlane);
|
||||
|
||||
@@ -277,16 +301,211 @@ async function main(): Promise<void> {
|
||||
const gitRemoteGapRecord = asRecord(gitRemoteGap);
|
||||
assertCondition(gitRemoteGapRecord.failureKind === "git-remote-gap", "git probe failures should stay structured", gitRemoteGapRecord);
|
||||
|
||||
const proxyGap = await codexPrPreflightQueryForTest(["--remote"], {
|
||||
config: null,
|
||||
coreFetch: () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
runtimePreflight: {
|
||||
ok: false,
|
||||
checkedAt: "2026-05-20T00:00:00.000Z",
|
||||
cwd: "/workspace/unidesk",
|
||||
pid: 123,
|
||||
pullRequestDelivery: {
|
||||
ok: false,
|
||||
checkedAt: "2026-05-20T00:00:00.000Z",
|
||||
tools: {
|
||||
git: { ok: true, path: "/usr/bin/git", version: "git version 2.43.0" },
|
||||
gh: { ok: true, path: "/usr/bin/gh", version: "gh version 2.45.0" },
|
||||
jq: { ok: true, path: "/usr/bin/jq", version: "jq-1.7" },
|
||||
ssh: { ok: true, path: "/usr/bin/ssh", version: "OpenSSH_9.6" },
|
||||
curl: { ok: true, path: "/usr/bin/curl", version: "curl 8.5.0" },
|
||||
},
|
||||
unideskGhCli: { ok: true, path: "/workspace/unidesk/scripts/cli.ts", present: true },
|
||||
credentials: {
|
||||
ghTokenPresent: true,
|
||||
githubTokenPresent: false,
|
||||
ghHostsConfigPresent: false,
|
||||
gitCredentialsPresent: false,
|
||||
},
|
||||
git: {
|
||||
insideWorktree: true,
|
||||
branch: "feature/code-queue-pr-preflight",
|
||||
head: "abc1234",
|
||||
originMaster: "def5678",
|
||||
remoteOrigin: "git@github.com:pikasTech/unidesk.git",
|
||||
home: "/root",
|
||||
homeWritable: true,
|
||||
knownHostsPresent: true,
|
||||
privateKeyPresent: true,
|
||||
},
|
||||
githubContext: {
|
||||
host: "github.com",
|
||||
apiBaseUrl: "https://api.github.com",
|
||||
repo: "pikasTech/unidesk",
|
||||
issueProbeNumber: 20,
|
||||
},
|
||||
egress: {
|
||||
proxy: {
|
||||
selectedProxyHost: "missing-egress-proxy.unidesk.svc.cluster.local",
|
||||
selectedProxyPort: "18789",
|
||||
selectedProxyHostResolvable: false,
|
||||
},
|
||||
githubDefault: { command: "curl", args: ["-IsS", "https://github.com"], ok: false, exitCode: 6, signal: null, error: null, stdout: "", stderr: "Could not resolve proxy" },
|
||||
apiDefault: { command: "curl", args: ["-IsS", "https://api.github.com"], ok: false, exitCode: 6, signal: null, error: null, stdout: "", stderr: "Could not resolve proxy" },
|
||||
issueApi: null,
|
||||
},
|
||||
remote: {
|
||||
gitLsRemote: { command: "git", args: ["ls-remote", "--heads", "origin", "master"], ok: true, exitCode: 0, signal: null, error: null, stdout: "abc1234\trefs/heads/master\n", stderr: "" },
|
||||
gitHttpsLsRemote: null,
|
||||
githubSshAuthenticated: true,
|
||||
ghAuthStatus: { command: "gh", args: ["auth", "status"], ok: true, exitCode: 0, signal: null, error: null, stdout: "", stderr: "" },
|
||||
ghRepoView: { command: "gh", args: ["repo", "view", "pikasTech/unidesk"], ok: true, exitCode: 0, signal: null, error: null, stdout: "", stderr: "" },
|
||||
ghIssueView: { command: "gh", args: ["issue", "view", "20"], ok: true, exitCode: 0, signal: null, error: null, stdout: "", stderr: "" },
|
||||
ghPrReadOnly: { command: "gh", args: ["pr", "list"], ok: true, exitCode: 0, signal: null, error: null, stdout: "", stderr: "" },
|
||||
},
|
||||
limitations: [
|
||||
"configured GitHub egress proxy host is not resolvable: missing-egress-proxy.unidesk.svc.cluster.local",
|
||||
"GitHub HTTPS probe failed with the default environment/proxy",
|
||||
],
|
||||
risks: [],
|
||||
},
|
||||
ports: {},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
const proxyGapRecord = asRecord(proxyGap);
|
||||
assertCondition(proxyGapRecord.failureKind === "proxy-gap", "proxy failures should classify as proxy-gap", proxyGapRecord);
|
||||
assertCondition(proxyGapRecord.degradedReason === "configured GitHub egress proxy host is not resolvable: missing-egress-proxy.unidesk.svc.cluster.local", "proxy degraded reason should point at the proxy", proxyGapRecord);
|
||||
|
||||
let observedDryRunPath = "";
|
||||
const dryRunContract = await codexPrPreflightQueryForTest([
|
||||
"--remote",
|
||||
"--push-dry-run",
|
||||
"--push-dry-run-ref",
|
||||
"refs/heads/probe/code-queue-pr-capability",
|
||||
"--pr-create-dry-run",
|
||||
"--pr-create-dry-run-head",
|
||||
"code-queue/issue-35-pr-dry-run-probe",
|
||||
"--issue",
|
||||
"20",
|
||||
], {
|
||||
config: null,
|
||||
coreFetch: (path) => {
|
||||
observedDryRunPath = path;
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
runtimePreflight: {
|
||||
ok: false,
|
||||
checkedAt: "2026-05-20T00:00:00.000Z",
|
||||
cwd: "/workspace/unidesk",
|
||||
pid: 123,
|
||||
pullRequestDelivery: {
|
||||
ok: false,
|
||||
checkedAt: "2026-05-20T00:00:00.000Z",
|
||||
tools: {
|
||||
git: { ok: true, path: "/usr/bin/git", version: "git version 2.43.0" },
|
||||
gh: { ok: false, path: null, version: null },
|
||||
hub: { ok: false, path: null, version: null },
|
||||
jq: { ok: true, path: "/usr/bin/jq", version: "jq-1.7" },
|
||||
ssh: { ok: true, path: "/usr/bin/ssh", version: "OpenSSH_9.6" },
|
||||
curl: { ok: true, path: "/usr/bin/curl", version: "curl 8.5.0" },
|
||||
},
|
||||
unideskGhCli: { ok: true, path: "/workspace/unidesk/scripts/cli.ts", present: true },
|
||||
credentials: {
|
||||
ghTokenPresent: false,
|
||||
githubTokenPresent: false,
|
||||
ghHostsConfigPresent: false,
|
||||
gitCredentialsPresent: false,
|
||||
},
|
||||
git: {
|
||||
insideWorktree: true,
|
||||
branch: "code-queue/issue-35-pr-dry-run-probe",
|
||||
head: "abc1234",
|
||||
originMaster: "def5678",
|
||||
remoteOrigin: "git@github.com:pikasTech/unidesk.git",
|
||||
home: "/root",
|
||||
homeWritable: true,
|
||||
knownHostsPresent: true,
|
||||
privateKeyPresent: false,
|
||||
},
|
||||
githubContext: {
|
||||
host: "github.com",
|
||||
apiBaseUrl: "https://api.github.com",
|
||||
repo: "pikasTech/unidesk",
|
||||
issueProbeNumber: 20,
|
||||
},
|
||||
egress: {
|
||||
proxy: {
|
||||
selectedProxyHost: "d601-provider-egress-proxy.unidesk.svc.cluster.local",
|
||||
selectedProxyPort: "18789",
|
||||
selectedProxyHostResolvable: true,
|
||||
},
|
||||
githubDefault: { command: "curl", args: ["-IsS", "https://github.com"], ok: true, exitCode: 0, signal: null, error: null, stdout: "", stderr: "" },
|
||||
apiDefault: { command: "curl", args: ["-IsS", "https://api.github.com"], ok: true, exitCode: 0, signal: null, error: null, stdout: "", stderr: "" },
|
||||
issueApi: { command: "sh", args: ["-lc", "curl issue"], ok: false, exitCode: 1, signal: null, error: null, stdout: "http_status=404", stderr: "" },
|
||||
},
|
||||
remote: {
|
||||
gitLsRemote: { command: "git", args: ["ls-remote", "--heads", "origin", "master"], ok: true, exitCode: 0, signal: null, error: null, stdout: "abc1234\trefs/heads/master\n", stderr: "" },
|
||||
gitHttpsLsRemote: { command: "git", args: ["ls-remote", "--heads", "https://github.com/pikasTech/unidesk.git", "master"], ok: false, exitCode: 128, signal: null, error: null, stdout: "", stderr: "Authentication failed" },
|
||||
githubSshAuthenticated: true,
|
||||
ghAuthStatus: null,
|
||||
ghRepoView: null,
|
||||
ghIssueView: null,
|
||||
ghPrReadOnly: null,
|
||||
},
|
||||
pushDryRun: { command: "git", args: ["push", "--dry-run", "origin", "HEAD:refs/heads/probe/code-queue-pr-capability"], ok: false, exitCode: 128, signal: null, error: null, stdout: "", stderr: "Permission denied" },
|
||||
prCreateDryRun: { command: "sh", args: ["-lc", "bun scripts/cli.ts gh pr create --dry-run"], ok: false, exitCode: 1, signal: null, error: null, stdout: "", stderr: "GH_TOKEN/GITHUB_TOKEN missing" },
|
||||
limitations: [
|
||||
"GH_TOKEN/GITHUB_TOKEN is not present; gh cannot create PRs unless another gh credential store is mounted",
|
||||
"git push --dry-run failed for probe branch",
|
||||
"PR create dry-run body/command guard failed",
|
||||
],
|
||||
risks: [
|
||||
"system gh binary is missing; UniDesk REST gh CLI remains the supported PR create/comment path when scripts/cli.ts and GH_TOKEN/GITHUB_TOKEN are available",
|
||||
],
|
||||
},
|
||||
ports: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
assertCondition(observedDryRunPath === "/api/microservices/code-queue/proxy/api/runtime-preflight?remote=1&pushDryRun=1&pushDryRunRef=refs%2Fheads%2Fprobe%2Fcode-queue-pr-capability&prCreateDryRun=1&prCreateDryRunHead=code-queue%2Fissue-35-pr-dry-run-probe&issue=20", "combined dry-run query should pass all requested guards", { observedDryRunPath });
|
||||
const dryRunRecord = asRecord(dryRunContract);
|
||||
assertCondition(dryRunRecord.failureKind === "auth-missing", "missing runner token should remain auth-missing even when system gh is absent", dryRunRecord);
|
||||
const dryRunPreflight = asRecord(dryRunRecord.preflight);
|
||||
const dryRunAuthBroker = asRecord(dryRunPreflight.authBroker);
|
||||
assertCondition(dryRunAuthBroker.source === "broker/auth-broker-needed", "missing runner token should expose broker/auth-broker-needed", dryRunAuthBroker);
|
||||
assertCondition(dryRunAuthBroker.degradedReason === "auth-broker-needed", "auth broker degraded reason should be explicit", dryRunAuthBroker);
|
||||
const dryRunBrokerEvidence = asRecord(dryRunAuthBroker.evidence);
|
||||
assertCondition(dryRunBrokerEvidence.systemGhBinaryOk === false, "system gh absence should be reported separately", dryRunBrokerEvidence);
|
||||
assertCondition(dryRunBrokerEvidence.unideskGhCliOk === true, "UniDesk REST gh CLI should not be marked unavailable because system gh is missing", dryRunBrokerEvidence);
|
||||
assertCondition(dryRunBrokerEvidence.systemGhMissingMisclassifiedAsUniDeskCliMissing === false, "system gh absence must not be misclassified", dryRunBrokerEvidence);
|
||||
const dryRunPrContract = asRecord(dryRunPreflight.prCapabilityContract);
|
||||
assertCondition(asRecord(dryRunPrContract.pushDryRun).requested === true, "push dry-run should be requested", dryRunPrContract);
|
||||
assertCondition(asRecord(dryRunPrContract.pushDryRun).writesRemote === false, "push dry-run must be marked non-writing", dryRunPrContract);
|
||||
assertCondition(asRecord(dryRunPrContract.prCreateDryRun).requested === true, "PR create dry-run should be requested", dryRunPrContract);
|
||||
assertCondition(asRecord(dryRunPrContract.prCreateDryRun).writesRemote === false, "PR create dry-run must be marked non-writing", dryRunPrContract);
|
||||
assertCondition(asRecord(dryRunPrContract.prCreateDryRun).headBranch === "code-queue/issue-35-pr-dry-run-probe", "PR dry-run head should come from the option", dryRunPrContract);
|
||||
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
ok: true,
|
||||
checks: [
|
||||
"runner-like local target-stack absence does not block remote fallback",
|
||||
"remote control plane fallback preserves ready preflight",
|
||||
"missing remote control plane returns proxy-gap",
|
||||
"auth missing returns auth-missing",
|
||||
"missing remote control plane returns control-plane-missing",
|
||||
"auth missing returns auth-missing with broker/auth-broker-needed",
|
||||
"proxy failures return proxy-gap",
|
||||
"git remote failures return git-remote-gap",
|
||||
"combined push/PR create dry-run contract stays read-only and separates system gh from UniDesk gh CLI",
|
||||
],
|
||||
observedLocalPath,
|
||||
observedDryRunPath,
|
||||
}, null, 2)}\n`);
|
||||
}
|
||||
|
||||
|
||||
+70
-10
@@ -222,7 +222,7 @@ interface CodexPrPreflightOptions {
|
||||
full: boolean;
|
||||
}
|
||||
|
||||
type CodeQueuePrPreflightFailureKind = "auth-missing" | "proxy-gap" | "git-remote-gap" | "target-stack-not-running";
|
||||
type CodeQueuePrPreflightFailureKind = "auth-missing" | "proxy-gap" | "git-remote-gap" | "control-plane-missing" | "target-stack-not-running";
|
||||
|
||||
interface CodeQueuePrPreflightTransport {
|
||||
config?: UniDeskConfig | null;
|
||||
@@ -2400,13 +2400,21 @@ function compactToolStatus(value: unknown): Record<string, unknown> {
|
||||
}
|
||||
|
||||
function compactUniDeskGhCliStatus(value: unknown): Record<string, unknown> {
|
||||
const cli = asRecord(value) ?? {};
|
||||
const cli = asRecord(value);
|
||||
const observed = cli !== null;
|
||||
const ok = observed ? cli.ok === true : null;
|
||||
return {
|
||||
ok: cli.ok ?? false,
|
||||
path: cli.path ?? null,
|
||||
present: cli.present ?? false,
|
||||
ok,
|
||||
observed,
|
||||
path: observed ? cli.path ?? null : null,
|
||||
present: observed ? cli.present ?? false : null,
|
||||
role: "repo-native REST GitHub CLI used by bun scripts/cli.ts gh",
|
||||
requiresSystemGhBinary: false,
|
||||
unavailableReason: ok === true
|
||||
? null
|
||||
: observed
|
||||
? "scripts-cli-missing"
|
||||
: "runtime-preflight-did-not-report-unidesk-gh-cli",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2440,10 +2448,45 @@ function tokenCoverageStatus(credentials: Record<string, unknown>): Record<strin
|
||||
};
|
||||
}
|
||||
|
||||
function authBrokerNeededStatus(tokenCoverage: Record<string, unknown>, systemGhBinary: Record<string, unknown>, unideskGhCli: Record<string, unknown>): Record<string, unknown> {
|
||||
const missing = Array.isArray(tokenCoverage.missing) ? tokenCoverage.missing.map(String) : [];
|
||||
const needed = tokenCoverage.ok !== true;
|
||||
return {
|
||||
ok: !needed,
|
||||
source: needed ? "broker/auth-broker-needed" : tokenCoverage.source ?? "runner-env-token",
|
||||
needed,
|
||||
configured: false,
|
||||
runnerDisposition: needed ? "infra-blocked" : "ready",
|
||||
failureKind: needed ? "auth-missing" : null,
|
||||
degradedReason: needed ? "auth-broker-needed" : null,
|
||||
runnerEnvTokenRequiredWithoutBroker: true,
|
||||
brokerCredentialSource: null,
|
||||
valuesPrinted: false,
|
||||
evidence: {
|
||||
envTokenMissing: needed,
|
||||
missing,
|
||||
systemGhBinaryOk: systemGhBinary.ok === true,
|
||||
systemGhBinaryRequiredForWrites: false,
|
||||
unideskGhCliObserved: unideskGhCli.observed ?? false,
|
||||
unideskGhCliOk: unideskGhCli.ok ?? null,
|
||||
unideskGhCliRequiresSystemGhBinary: false,
|
||||
systemGhMissingMisclassifiedAsUniDeskCliMissing: false,
|
||||
},
|
||||
next: needed
|
||||
? [
|
||||
"inject GH_TOKEN or GITHUB_TOKEN into the Code Queue scheduler runtime secret",
|
||||
"or configure the planned auth-broker so PR preflight can use a broker-held GitHub credential without exposing runner env tokens",
|
||||
]
|
||||
: [],
|
||||
reference: "docs/reference/auth-broker.md#post-v1githubpr-preflight",
|
||||
};
|
||||
}
|
||||
|
||||
function compactPrRuntimePreflight(preflight: Record<string, unknown>, options: CodexPrPreflightOptions): Record<string, unknown> {
|
||||
const pull = asRecord(preflight.pullRequestDelivery) ?? {};
|
||||
const tools = asRecord(pull.tools) ?? {};
|
||||
const unideskGhCli = compactUniDeskGhCliStatus(pull.unideskGhCli);
|
||||
const systemGhBinary = compactToolStatus(tools.gh);
|
||||
const credentials = asRecord(pull.credentials) ?? {};
|
||||
const git = asRecord(pull.git) ?? {};
|
||||
const githubContext = asRecord(pull.githubContext) ?? {};
|
||||
@@ -2455,12 +2498,28 @@ function compactPrRuntimePreflight(preflight: Record<string, unknown>, options:
|
||||
const limitations = Array.isArray(pull.limitations) ? pull.limitations.map(String) : [];
|
||||
const risks = Array.isArray(pull.risks) ? pull.risks.map(String) : [];
|
||||
const ok = preflight.ok === true && tokenCoverage.ok === true;
|
||||
const failureKind = !tokenCoverage.ok
|
||||
? "auth-missing"
|
||||
: limitations.some((item) => item.includes("git ls-remote") || item.includes("git push --dry-run failed"))
|
||||
? "git-remote-gap"
|
||||
: !preflight.ok
|
||||
? "proxy-gap"
|
||||
: null;
|
||||
const degradedReason = failureKind === "auth-missing"
|
||||
? "GH_TOKEN/GITHUB_TOKEN missing"
|
||||
: failureKind === "git-remote-gap"
|
||||
? "git remote probe failed"
|
||||
: failureKind === "proxy-gap"
|
||||
? limitations.find((item) => item.includes("proxy") || item.includes("auth") || item.includes("egress") || item.includes("reachable")) ?? null
|
||||
: null;
|
||||
const defaultPushDryRunRef = "refs/heads/probe/code-queue-pr-capability-dryrun";
|
||||
const pushDryRunRef = options.pushDryRunRef ?? defaultPushDryRunRef;
|
||||
const targetBranch = "master";
|
||||
const expectedHeadBranch = options.prCreateDryRunHead ?? (typeof git.branch === "string" && git.branch.length > 0 ? git.branch : "<head-branch>");
|
||||
const result: Record<string, unknown> = {
|
||||
ok,
|
||||
failureKind,
|
||||
degradedReason,
|
||||
checkedAt: preflight.checkedAt ?? pull.checkedAt ?? null,
|
||||
runner: {
|
||||
serviceId: "code-queue",
|
||||
@@ -2470,6 +2529,7 @@ function compactPrRuntimePreflight(preflight: Record<string, unknown>, options:
|
||||
pid: preflight.pid ?? null,
|
||||
},
|
||||
tokenCoverage,
|
||||
authBroker: authBrokerNeededStatus(tokenCoverage, systemGhBinary, unideskGhCli),
|
||||
prCapabilityContract: {
|
||||
targetBranch,
|
||||
tokenSource: tokenCoverage.source,
|
||||
@@ -2510,8 +2570,8 @@ function compactPrRuntimePreflight(preflight: Record<string, unknown>, options:
|
||||
},
|
||||
tools: {
|
||||
git: compactToolStatus(tools.git),
|
||||
gh: compactToolStatus(tools.gh),
|
||||
systemGhBinary: compactToolStatus(tools.gh),
|
||||
gh: systemGhBinary,
|
||||
systemGhBinary,
|
||||
hub: compactToolStatus(tools.hub),
|
||||
jq: compactToolStatus(tools.jq),
|
||||
ssh: compactToolStatus(tools.ssh),
|
||||
@@ -2595,7 +2655,7 @@ function queryRemoteMainServerPrPreflight(optionArgs: string[], config: UniDeskC
|
||||
return {
|
||||
ok: false,
|
||||
runnerDisposition: "infra-blocked",
|
||||
failureKind: "proxy-gap",
|
||||
failureKind: "control-plane-missing",
|
||||
degradedReason: "remote-control-plane-unreachable",
|
||||
message,
|
||||
controlPlane: {
|
||||
@@ -2620,7 +2680,7 @@ function queryRemoteMainServerPrPreflight(optionArgs: string[], config: UniDeskC
|
||||
return {
|
||||
ok: false,
|
||||
runnerDisposition: "infra-blocked",
|
||||
failureKind: "proxy-gap",
|
||||
failureKind: "control-plane-missing",
|
||||
degradedReason: "remote-control-plane-unreachable",
|
||||
message,
|
||||
controlPlane: {
|
||||
@@ -2696,7 +2756,7 @@ function codeQueuePrPreflight(optionArgs: string[] = [], transport: CodeQueuePrP
|
||||
}
|
||||
if (localRecord?.ok !== true) {
|
||||
if (options.remote && localTargetStackMissing) {
|
||||
const failureKind: CodeQueuePrPreflightFailureKind = "proxy-gap";
|
||||
const failureKind: CodeQueuePrPreflightFailureKind = "control-plane-missing";
|
||||
const degradedReason = "remote-control-plane-unreachable";
|
||||
return {
|
||||
...(localRecord ?? {}),
|
||||
|
||||
Reference in New Issue
Block a user