diff --git a/AGENTS.md b/AGENTS.md index 73151821..a5e3bd89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,6 +82,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 ## Critical GitHub Issue Write Rule - P0: 对 GitHub issue/PR 做正式写入时必须优先使用 `bun scripts/cli.ts gh ...`;禁止用原生 `gh issue edit/create/comment` 直接写 UniDesk/HWLAB 长期看板、指挥简报或用户反馈 issue。事故和 CLI 补强需求见 [pikasTech/unidesk#142](https://github.com/pikasTech/unidesk/issues/142)。 +- P0: GitHub PR/issue 读写、PR 合并、评论、状态观察和收口动作必须走 UniDesk `gh` 子命令;禁止绕过为原生 `gh`、手写 `curl`/GraphQL/REST 请求或临时脚本直连 GitHub。若 `bun scripts/cli.ts gh ...` 不顺手、字段不够、merge 不支持或可见性不足,必须先改进 UniDesk `gh` 子命令并用它完成任务,不能跳过该入口。 - #20、HWLAB #7 和指挥简报类正文不得使用原生 `gh issue edit --body-file -`、shell 管道 stdin 或无 guard 的整篇替换。当前 CLI 局部替换能力未完成前,必须先 dry-run、保留 before body、确认 body guard,再写入。 ## Critical Git / Multi-Repo Sync Rule @@ -141,8 +142,9 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `bun scripts/cli.ts dev-env validate [--manifest path] [--kubectl-dry-run]` / `dev-env prewarm-images`:离线校验 D601 `unidesk-dev` 生产隔离护栏和 dev workload manifests,或把开发底座基础镜像预热到 D601 原生 k3s containerd,规则见 `docs/reference/deploy.md` 与 `docs/reference/microservices.md`。 - `bun scripts/cli.ts artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service`:管理 D601 host-managed CNCF Distribution registry,并通过短生命周期 relay 或 D601 pull/import 做 commit-pinned pull-only artifact CD;`deploy-backend-core` 是 deprecated 兼容名,`findjob`/`pipeline` 支持 D601 direct dev/prod,`met-nonlinear` 和 `k3sctl-adapter` 只给受限计划路径,`code-queue` 只支持 dev,规则见 `docs/reference/artifact-registry.md`。 - `bun scripts/cli.ts auth-broker contract|health --dry-run|credential-request --dry-run|pr-preflight --dry-run`:查看 Auth Broker P0 Rust skeleton 与 CLI adapter contract,runner 无 `GH_TOKEN`/`GITHUB_TOKEN` 时返回结构化 `auth-missing`/`broker-needed`,不读取或打印 token 值,规则见 `docs/reference/auth-broker.md`。 -- `bun scripts/cli.ts gh preflight|auth status|issue ...|pr list|files|diff --stat|read|view|preflight|closeout|create|edit|update|comment` / `bun scripts/code-queue-pr-preflight-example.ts`:通过 REST 执行安全 GitHub issue 读写、脱敏 auth/status 诊断、body-file Markdown 写入、当日滚动简报时间线 ClaudeQQ 通知、escape 扫描、只读 cleanup-plan 和 #20 board-audit、PR changed-file/stat summary、PR 创建/评论 dry-run、REST-only 低噪声 PR title/body 编辑、PR 收口元数据观察(含 merged/closed 区分与 merge commit)、低噪声 PR 收口 preflight 与 runner PR preflight;`gh issue/pr read|view` 支持 `owner/repo#number` shorthand,`--raw|--full` 是显式完整披露别名,`gh pr diff` 仅支持 `--stat` 紧凑 JSON,`gh pr merge` 当前仍结构化拒绝但普通 PR 可按任务边界用 repo-owned GitHub 路径收口,规则见 `docs/reference/cli.md` 和 `docs/reference/code-queue-supervision.md`。 +- `bun scripts/cli.ts gh preflight|auth status|issue ...|pr list|files|diff --stat|read|view|preflight|closeout|create|edit|update|comment|merge` / `bun scripts/code-queue-pr-preflight-example.ts`:通过 REST 执行安全 GitHub issue 读写、脱敏 auth/status 诊断、body-file Markdown 写入、当日滚动简报时间线 ClaudeQQ 通知、escape 扫描、只读 cleanup-plan 和 #20 board-audit、PR changed-file/stat summary、PR 创建/评论 dry-run、REST-only 低噪声 PR title/body 编辑、PR 收口元数据观察(含 merged/closed 区分与 merge commit)、低噪声 PR 收口 preflight、guarded PR merge 与 runner PR preflight;`gh issue/pr read|view` 支持 `owner/repo#number` shorthand,`--raw|--full` 是显式完整披露别名,`gh pr diff` 仅支持 `--stat` 紧凑 JSON,`gh pr merge` 会先执行 closeout 预检并拒绝非 open、draft、冲突、非 CLEAN、失败或 pending checks 的 PR,规则见 `docs/reference/cli.md` 和 `docs/reference/code-queue-supervision.md`。 - `bun scripts/cli.ts commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run|prompt-lint --kind gpt55-pr`:查看 host Codex 指挥官直管微服务 skeleton 的 source/contract、无 daemon smoke 验证计划、.state/commander/ 状态模型、trace summary 聚合、ClaudeQQ 高风险请示草案和 GPT-5.5 PR prompt 边界辅助 lint;当前只返回 dry-run 计划和 backend-core `microservice proxy claudeqq` 授权后候选命令,不接 live bridge、不接管人工指挥官,不发送消息,`prompt-lint` 不作为业务 PR 门禁也不改变 `codex submit` 默认行为,规则见 `docs/reference/host-codex-commander.md`。 +- `bun scripts/cli.ts hwlab g14 monitor-prs`:一行启动异步监控 HWLAB base=G14 的未合并 PR;可合并时走 UniDesk `gh pr merge` 合并、监控 G14 Tekton/GitOps/Argo DEV rollout,并向 #7 索引的北京日期每日简报追加 CI/CD 耗时与上线 changelog,规则见 `docs/reference/g14.md` 与 `docs/reference/cli.md`。 - `bun scripts/cli.ts hwlab cd audit --env dev` / `status|preflight|apply --dry-run`:旧 D601 HWLAB DEV CD 指挥侧 wrapper,仅用于显式 legacy 诊断和迁移对照;当前 HWLAB DEV/PROD source/runtime truth 已迁到 G14 `/root/hwlab` 与 G14 k3s/GitOps,规则见 `docs/reference/hwlab.md`。 - `bun scripts/cli.ts ci install/status/run/publish-backend-core/publish-user-service/run-dev-e2e/logs`:在 D601 原生 k3s 上安装和运行 Tekton CI,支持每 commit 检查、Code Queue 只读性能门禁、`CI.json` catalog 驱动的 backend-core 与 user-service commit-pinned 镜像发布和手动触发的 `origin/master:deploy.json#environments.dev` 临时 namespace e2e;catalog/producer/consumer 分工见 `docs/reference/cicd-standardization.md`,`run-dev-e2e` 的 Git 控制 runner、短 launcher 和 no-CD 边界见 `docs/reference/dev-ci-runner.md`,Tekton 规则见 `docs/reference/ci.md`。 - `bun scripts/cli.ts codex deploy `:旧 Code Queue 兼容部署入口已禁用,原因是它会绕过受控部署边界直连 D601 部署 Code Queue;规则见 `docs/reference/codex-deploy.md`。 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index cd2ba959..473ed785 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -33,7 +33,8 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI - `dev-env prewarm-images [--image image] [--provider-id D601] [--no-pull] [--proxy-url URL] [--pull-timeout-ms N] [--dry-run]` 创建异步 job,通过 UniDesk SSH 维护桥在 D601 上把开发底座依赖镜像从 Docker 缓存导入原生 k3s containerd。默认镜像是 `postgres:16-alpine` 和 `rancher/mirrored-library-busybox:1.36.1`,用于避免 `postgres-dev` 与 local-path helper pod 卡在外部 registry 拉取。该命令固定验证 `/etc/rancher/k3s/k3s.yaml` 指向的 native k3s 上下文,并输出 `dev_env_containerd_image_ready=...` 作为成功判据;它不 apply manifest、不修改生产 `unidesk` namespace。 - `artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service` 管理 D601 host-managed CNCF Distribution registry 的声明、安装、只读检查和 pull-only artifact CD。该 registry 固定为 D601 loopback `127.0.0.1:5000`,由 systemd + Docker Compose 管理,位于 native k3s 故障域外;`deploy-service` 只拉取 CI 已发布的 commit-pinned 镜像、retag/recreate 或导入 native k3s,并做 live commit 验证,不构建 runtime source。`deploy-backend-core` 是 deprecated 兼容名,标准 backend-core prod CD 入口是 `deploy apply --env prod --service backend-core`。长期规则见 `docs/reference/artifact-registry.md`。 - `commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run|prompt-lint --kind gpt55-pr` 是 host Codex 指挥官直管微服务 skeleton 入口。当前命令返回 `phase=source-contract`、service/API/state/bridge/prompt/trace/#20/#46/ClaudeQQ 审批边界、.state/commander/ 状态模型、dev 无 daemon smoke contract、dry-run 计划和 GPT-5.5 PR prompt 边界辅助 lint,不接 live bridge、不注入 prompt、不发送 ClaudeQQ。`approval request --dry-run` 会生成 200 字以内中文纯文本 ClaudeQQ 审批草案、`notification-path-unavailable` blocker 和授权后唯一可用的 `bun scripts/cli.ts microservice proxy claudeqq /api/push/text --method POST --body-json '' --raw` 命令;不得提示使用本机 ClaudeQQ skill、powershell 或本地 server。`prompt-lint` 支持 `--prompt-file` 与 `--stdin`,输出 `ok`、`missingClauses`、`riskLevel`、`suggestedPatchSnippet` 且不回显完整 prompt;它是 commander 辅助检查,不是业务 PR 门禁,也不改变 `codex submit` 默认行为。`plan`、`smoke` 与 `approval request` 必须带 `--dry-run`;缺少时返回 `error=dry-run-required`。长期规则见 `docs/reference/host-codex-commander.md`。 -- `hwlab cd status|audit|preflight|apply --env dev [--dry-run]` 是 HWLAB DEV CD 指挥侧 wrapper。默认通过 UniDesk provider `host.ssh` 进入 D601,再调用 HWLAB repo-owned `scripts/dev-cd-apply.mjs`,不内嵌发布 kubectl 逻辑:`status` 汇总固定 CD mirror、Git clean/main/origin-main、`deploy/deploy.json`/artifact catalog/report、D601 native k3s guard 和 CD Lease lock,并用 `scripts/dev-cd-apply.mjs --status --skip-live-verify` 取得 target/promotion 摘要;`audit` 在 k3s/CD 恢复后做只读健康审计,返回有界 JSON 的 blocker 分类、D601 guard/node、SecretRef 存在性、registry 可达性、Lease phase/holder/staleness、deploy.json 与 artifact/workload image 收敛、current Deployment image/revision/rollout、16666/16667 public health commit/readiness 和 DB/runtime durability 摘要;`preflight` 进一步检查必需 SecretRef 对象/键存在性并运行 HWLAB `scripts/dev-cd-apply.mjs --dry-run --skip-live-verify` 受控事务摘要。完整远端 stdout/stderr 写入 D601 `~/.state/unidesk-hwlab-cd//` 和本地 `.state/hwlab-cd//` task dump,stdout 只返回有界摘要。默认 HWLAB CD repo 是 `/home/ubuntu/hwlab_cd`,`/home/ubuntu/hwlab` runner 历史目录不得作为发布真相。wrapper 强制 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml` 并只以这个显式目标作为 gate;显式目标出现 `docker-desktop`、`desktop-control-plane` 或 `127.0.0.1:11700` 信号会结构化拒绝,audit/preflight/apply --dry-run 都必须观察到 node `d601`。真实 apply 只暴露 `scripts/dev-cd-apply.mjs --apply --confirm-dev --confirmed-non-production --write-report` 命令形状并标注 host-commander-only,本 runner 不执行 live apply、rollout、Lease mutation 或 DEV deploy apply。长期规则见 `docs/reference/hwlab.md`。 +- `hwlab g14 monitor-prs [--once] [--dry-run] [--interval-seconds N] [--max-cycles N] [--timeout-seconds N]` 是当前 HWLAB G14 PR -> CI/CD -> DEV rollout 的一行式入口。普通调用创建 `.state/jobs/` 异步 job 并立刻返回 `job.id`、`statusCommand` 和 stdout/stderr 路径;后台 worker 每轮通过 UniDesk `gh pr list/preflight/merge` 监控 `pikasTech/HWLAB` base=`G14` 的 open PR,ready 时合并,然后通过 UniDesk `ssh G14:k3s` 观察 `hwlab-g14-ci-poll-`、Argo `hwlab-g14-dev` 和 DEV `/health/live`,直到 DEV `Synced/Healthy` 且 Deployment/StatefulSet ready;历史 `Completed` smoke/debug pod 不作为 rollout blocker。每次成功 DEV rollout 后,worker 会定位或创建 #7“指挥简报索引”中的北京日期每日简报 issue,并追加 CI/CD 耗时、上线 changelog、PipelineRun、GitOps revision 和 DEV 验证摘要;也可用 `hwlab g14 record-rollout --pr --source-commit ` 手动补记。`--once --dry-run` 只做单轮监控和 merge plan,不写 GitHub、不等待 rollout。该命令禁止使用原生 `gh` 或手拼 GitHub 请求;如果 UniDesk `gh` 子命令字段或行为不够,必须先改进 `scripts/src/gh.ts` 后再使用。 +- `hwlab cd status|audit|preflight|apply --env dev [--dry-run]` 是旧 D601 HWLAB DEV CD 指挥侧 wrapper,仅用于显式 legacy 诊断和迁移对照。默认通过 UniDesk provider `host.ssh` 进入 D601,再调用 HWLAB repo-owned `scripts/dev-cd-apply.mjs`,不内嵌发布 kubectl 逻辑:`status` 汇总固定 CD mirror、Git clean/main/origin-main、`deploy/deploy.json`/artifact catalog/report、D601 native k3s guard 和 CD Lease lock,并用 `scripts/dev-cd-apply.mjs --status --skip-live-verify` 取得 target/promotion 摘要;`audit` 在 k3s/CD 恢复后做只读健康审计,返回有界 JSON 的 blocker 分类、D601 guard/node、SecretRef 存在性、registry 可达性、Lease phase/holder/staleness、deploy.json 与 artifact/workload image 收敛、current Deployment image/revision/rollout、16666/16667 public health commit/readiness 和 DB/runtime durability 摘要;`preflight` 进一步检查必需 SecretRef 对象/键存在性并运行 HWLAB `scripts/dev-cd-apply.mjs --dry-run --skip-live-verify` 受控事务摘要。完整远端 stdout/stderr 写入 D601 `~/.state/unidesk-hwlab-cd//` 和本地 `.state/hwlab-cd//` task dump,stdout 只返回有界摘要。默认 HWLAB CD repo 是 `/home/ubuntu/hwlab_cd`,`/home/ubuntu/hwlab` runner 历史目录不得作为发布真相。wrapper 强制 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml` 并只以这个显式目标作为 gate;显式目标出现 `docker-desktop`、`desktop-control-plane` 或 `127.0.0.1:11700` 信号会结构化拒绝,audit/preflight/apply --dry-run 都必须观察到 node `d601`。真实 apply 只暴露 `scripts/dev-cd-apply.mjs --apply --confirm-dev --confirmed-non-production --write-report` 命令形状并标注 host-commander-only,本 runner 不执行 live apply、rollout、Lease mutation 或 DEV deploy apply。长期规则见 `docs/reference/hwlab.md`。 - `gh auth status [--repo owner/name]` 探测 GitHub 操作前置条件并输出脱敏 JSON:是否存在 `gh` binary、是否存在 `GH_TOKEN`/`GITHUB_TOKEN` 或可用 `gh auth token` fallback、REST API 是否可达、目标 repo 是否可见、issue 是否可读。degraded reason 必须归类为 `missing-binary`、`missing-token`、`auth-failed`、`github-transient`、`network-proxy-failed`、`permission-denied`、`repo-not-found`、`repo-forbidden`、`issue-not-found`、`pr-not-found`、`scope-insufficient`、`validation-failed`、`invalid-response` 或 `unsupported-command`,不得打印 token;失败对象必须包含 `runnerDisposition=infra-blocked|business-failed`,runner 应优先用该字段分流。`github-transient` 表示 GitHub DNS/API 连接在收到 HTTP 状态前失败,输出应带 `retryable=true` 或等价 commander action;这不是缺 token、认证失败、权限不足或 PR 语义失败。 - `codex prompt-lint [prompt|--prompt-file path|--prompt-stdin]` 是派发/steer 前的本地 dry-run prompt lint。它只读取 prompt 文本,返回 `dryRun=true`、`mutation=false`、`declaredClass`、`effectiveClass`、`requiredClass`、`dispatchDisposition`、缺失或矛盾项和有界 evidence,不访问 live service、不提交任务、不打印完整 prompt。分级固定为 `read-only`、`live-read`、`live-mutating`;未声明时按 `read-only` 处理。`codex submit --dry-run` 与 `codex steer --dry-run` 会嵌入同一 `promptLint` 结果,帮助指挥官在 dispatch/steer 前发现缺失或矛盾的 live mutation 授权。长期规则见 `docs/reference/code-queue-supervision.md` 的 DEV 测试授权分级。 - `gh issue list [owner/repo] [--state open|closed|all] [--limit N] [--repo owner/name] [--json number,title,state,url,updatedAt,createdAt,author,labels]` 通过 GitHub REST 列出 issue,默认 `state=open`、`limit=30`,输出稳定 JSON 且不依赖系统 `gh` binary。`owner/repo` 位置参数是 `--repo owner/repo` 的兼容别名;若位置 repo 与 `--repo` 冲突,或位置参数不是 `owner/repo`,必须结构化失败,禁止静默 fallback 到默认 repo。`--limit` 会映射到 GitHub `per_page` 并限制返回数量,避免一次拉爆上下文;未知 state 或未知 `--json` 字段必须结构化失败并带 `runnerDisposition=business-failed`。GitHub issues API 可能混入 PR,CLI 会从 `.data.issues` 中过滤 pull request。 @@ -43,16 +44,16 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI - `gh issue edit 24 --body-file --notify-claudeqq-brief-diff [--dry-run]` 是 legacy #24 指挥简报的通知入口。正式执行会先读取 GitHub 上 #24 旧正文并通过 #24 body profile guard,再从 `--body-file` 读取新正文;随后先 PATCH issue 主体,再把本次新增的 `## 更新 YYYY-MM-DD HH:MM 北京时间` 段落发送给 ClaudeQQ,ClaudeQQ 失败不会回滚 issue 正文,失败只体现在返回 JSON 的 `claudeqq.ok=false` 和结构化 `degradedReason`。每日滚动简报 issue 可用普通 `gh issue update --body-profile commander-brief --dry-run` 和并发 guard 更新,但此通知 helper 仍只支持 #24。带通知 flag 的 `--dry-run` 不 PATCH、不发送;它按新正文做发送预览,并在输出中标明非 dry-run 才会读取旧正文做可靠 diff。默认 ClaudeQQ 目标是私聊 `645275593`,默认 base URL 是 UniDesk 受控入口 `http://backend-core:8080/api/microservices/claudeqq/proxy`;`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL` 只接受 backend-core `/api/microservices/claudeqq/proxy` 等价路径,非 proxy URL 会结构化为 `notification-path-unavailable`。可用 `UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_ENABLED`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TARGET_TYPE`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_USER_ID`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_GROUP_ID` 和 `UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TIMEOUT_MS` 调整开关、目标和超时。 - `gh issue board-audit [--repo owner/name] [--board-issue 20] [--limit N] [--known-meta-issue N[,N...]] [--ignore-issue N[,N...]] [--dry-run]` 是总看板只读结构审计入口,默认 repo 为 `pikasTech/unidesk`、board issue 为 `20`、输出 JSON 且不 PATCH/POST/DELETE GitHub。它只读取目标 board issue 正文,返回正文长度、行数、body SHA、可解析 Markdown board sections、section 行数和 parser warnings;不再拉取 GitHub open/closed issue 列表,也不再校验 OPEN/CLOSED 表是否覆盖全部 issue。兼容字段 `missingOpenIssues`、`closedInOpenRows`、`missingClosedRows`、`openInClosedRows`、`rowValidationWarnings`、`ignoredIssues` 和 `recommendedActions` 仍保留,但固定为空数组或 0,用于避免旧调用方因字段缺失失败。需要维护旧式 OPEN/CLOSED 明细表时,继续使用 `gh issue board-row list|get|update|add|move|delete|upsert` 的行级结构化入口。 - `gh issue board-row list --board-issue 20 [--state open|closed|all] [--dry-run]`、`gh issue board-row get --board-issue 20` 和 `gh issue board-row update --board-issue 20 --field progress|status|validation|branch|tasks|focus --value [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256]` 是 #20 看板表格单行结构化入口。list/get 复用 board-audit parser,只读返回 row、cells、fields、section、lineNumber、bodySha 和 rowValidationWarnings。update 只替换命中的一行里一个单元格,返回 old/new row、old/new body SHA、body guard、request plan 和 parser 结果;默认没有并发期望时即使不写 `--dry-run` 也只做 dry-run,正式 PATCH 必须带 `--expect-body-sha` 或 `--expect-updated-at`。字段映射固定为:`branch` -> Branch,`progress` -> 进度,`status`/`validation` -> 验收状态,`tasks` -> 相关 Code Queue 任务,`focus` -> 当前关注点。单元格值中的 Markdown 表格管道会转义为 `\|`,真实换行会折叠为空格,避免新增字面量 `\n` 污染。`gh issue board-row upsert --board-issue 20 --section open|closed [--category text] --branch --tasks --summary --focus --validation --progress [--status OPEN|CLOSED] [--dry-run] [--expect-body-sha|--expect-updated-at]` 是行级补齐入口:若 issue 已存在则只更新传入字段并返回 `operation=update`,未传字段保留原值;若不存在则按目标 section 表头生成完整行并返回 `operation=add`。新增时 `--section` 必需,且目标表头中的 category/branch/tasks/summary/focus/validation/progress 列都必须有对应值;若表没有独立 Summary/摘要列,`--summary` 会并入 Issue 单元格。upsert 不关闭、不删除、不重开 GitHub issue,也不做 OPEN/CLOSED 迁移;已存在行的 `--section` 或 `--status` 与当前 section 冲突时会结构化失败并提示使用 `board-row move`。`gh issue board-row add --board-issue 20 --section open|closed --row-file [--dry-run] [--expect-body-sha|--expect-updated-at]`、`move --board-issue 20 --to open|closed [--status OPEN|CLOSED] [--dry-run] [--expect-body-sha|--expect-updated-at]` 和 `delete --board-issue 20 [--dry-run] [--expect-body-sha|--expect-updated-at]` 是 row-scoped #20 结构化写入口。add 校验一行 `--row-file` 的 Issue 列、列数和 GitHub 状态列与目标 section 一致;move 允许跨 OPEN/CLOSED 表迁移并在需要时同步 GitHub 状态列;delete 仅删除匹配行。四类写入口默认 dry-run,非 dry-run 必须带 `--expect-body-sha` 或 `--expect-updated-at`,并返回 old/new row、body SHA、line/section 计划和 parser 结果;duplicate/ambiguous row、列数不匹配、缺少新增必填字段、section/status 冲突或 body SHA 不匹配都会结构化失败,不会 fallback 到整篇 body 手工替换。 -- `gh issue scan-escape [--repo owner/name] [--limit N] [--dry-run]` 只读扫描 issue 主体和 comments 中的字面量 `\n`、可疑 `\t`、shell newline escape、escaped backtick、ANSI escape 字符串、短 body、blank body 和 null body。输出固定 JSON,`findings` 会带 `bodyKind=issue-body|comment-body`、`issueNumber`、`issueId`、`commentId`、`lineNumber`、`column`、`kind`、`snippet` 和 `classification=suspected-pollution|explanatory-mention|risk`,用于区分说明性提到 `\n` 和疑似污染;`cleanupSuggestions` 只给 dry-run 清理建议、body/comment 定位和 diff-like preview,不 PATCH、不 DELETE、不真实清理历史 comment。`gh issue cleanup-plan` 是同一只读能力的别名,默认 `dryRun=true`。`gh pr list [--state open|closed|all] [--json ...]` 提供 REST 列表,默认 `state=all` 以保持既有 UniDesk CLI 行为,字段白名单是 `body,title,state,number,url,author,head,base,draft,createdAt,updatedAt`;未知 state 或未知 `--json` 字段必须结构化失败并带 `runnerDisposition=business-failed`。`mergeable`、`mergeStateStatus` 和 `statusCheckRollup` 不属于 list 字段,请对具体 PR 使用 `gh pr view --json headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup`,避免列表默认拉取 noisy/raw 状态汇总。`gh pr files [--limit N]` 是 PR changed-file/stat summary 的稳定 REST 入口,返回 bounded `files`、`filesReturned`、`summary.files/additions/deletions/changes/commits`、`truncation` 和 `next.command`,默认不输出 raw diff 或 patch;`gh pr diff --stat` 是兼容别名,返回同一 JSON,未带 `--stat` 的 raw diff 请求会结构化拒绝。`gh pr read|view [--json ...] [--raw|--full]` 继续稳定返回这些字段,并额外支持 `stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup`。`owner/repo#number` shorthand 和冲突 `--repo` 规则与 issue read/view 相同。`stateDetail` 是 UniDesk 归一化生命周期值 `open|closed|merged`,用于区分 REST `state=closed` 中的普通关闭和已合并;`closed`、`closedAt`、`merged`、`mergedAt`、`mergeCommit`、`headRefName` 与 `baseRefName` 都来自 REST,不需要 GraphQL。`mergeable`、`mergeStateStatus` 和 `statusCheckRollup` 只在 read/view 明确请求这些字段或用 `--raw|--full` 显式完整披露时通过 GitHub GraphQL 查询,GraphQL 权限不足或网络失败会结构化失败;GitHub 暂未计算完成时仍保留原始 `UNKNOWN`/null,并额外返回 `closeoutMetadata.ok=false`、`missingOrUnknownFields`、advice 和 `mergeBoundary.unideskCliMergeSupported=false`。此时收口人员应优先重试一次;若仍缺失、需要完整 `gh pr view --json` 等 GitHub 官方字段、或需要执行 merge/review 这类 UniDesk CLI 尚未开放的操作,回退到系统 `gh` 只读观察或人工 GitHub UI,不要把空字段当作可合并证据。`gh pr preflight [--repo owner/name] [--full|--raw]` 是低噪声 PR 收口入口,`gh preflight ` 和 `gh pr closeout ` 是兼容别名;它先输出脱敏 auth capability,再读取 PR state/draft/head/base、mergeable、mergeStateStatus 和 statusCheckRollup,默认只给 status check 计数与失败/等待预览,完整 contexts 和原始读取摘要必须显式加 `--full` 或 `--raw`。该命令固定 `readOnly=true`、`writesRemote=false`、`policy.mergesPr=false`、`policy.unideskCliMergeSupported=false`,不会创建、评论、更新或 merge PR。`gh pr create --title --body-file <file>|--body <text> --base <branch> --head <branch> [--draft] [--dry-run]`、`gh pr edit <number> [--title ...] [--body-file <file>|--body-file -|--body <text>] [--dry-run]`、`gh pr update <number> --mode replace|append [--body-file <file>|--body-file -|--body <text>] [--title ...] [--dry-run]`、`gh pr comment create <number> --body-file <file>|--body <text> [--dry-run]`、`gh pr comment delete <commentId> [--dry-run]`、`gh pr close|reopen <number> [--dry-run]` 是 PR CRUD/生命周期入口。`pr create --dry-run` 只输出 planned operation,不访问 GitHub;非 dry-run 创建前会校验 repo、base、head 和 compare ahead 状态,成功时返回 PR number/url。`pr edit/update` 使用 REST `PATCH /repos/{owner}/{repo}/pulls/{number}`,只发送显式提供的 `title` 和/或 `body` 字段,完全避开 GitHub Projects Classic GraphQL/projectCards;输出低噪声 JSON:`ok`、`repo`、PR number、`changedFields`、`url`、body 长度/SHA/source 元数据和 request plan,不默认回显完整正文。`pr update --mode append` 会先读取当前 PR body 再追加正文。`gh pr delete <number>` 和 `gh pr merge` 本阶段不开放,始终结构化返回 `unsupported-command`;PR 生命周期删除语义请使用 `close`。 +- `gh issue scan-escape [--repo owner/name] [--limit N] [--dry-run]` 只读扫描 issue 主体和 comments 中的字面量 `\n`、可疑 `\t`、shell newline escape、escaped backtick、ANSI escape 字符串、短 body、blank body 和 null body。输出固定 JSON,`findings` 会带 `bodyKind=issue-body|comment-body`、`issueNumber`、`issueId`、`commentId`、`lineNumber`、`column`、`kind`、`snippet` 和 `classification=suspected-pollution|explanatory-mention|risk`,用于区分说明性提到 `\n` 和疑似污染;`cleanupSuggestions` 只给 dry-run 清理建议、body/comment 定位和 diff-like preview,不 PATCH、不 DELETE、不真实清理历史 comment。`gh issue cleanup-plan` 是同一只读能力的别名,默认 `dryRun=true`。`gh pr list [--state open|closed|all] [--json ...]` 提供 REST 列表,默认 `state=all` 以保持既有 UniDesk CLI 行为,字段白名单是 `body,title,state,number,url,author,head,base,draft,createdAt,updatedAt,headRefName,baseRefName`;未知 state 或未知 `--json` 字段必须结构化失败并带 `runnerDisposition=business-failed`。`mergeable`、`mergeStateStatus` 和 `statusCheckRollup` 不属于 list 字段,请对具体 PR 使用 `gh pr view <number> --json headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup`,避免列表默认拉取 noisy/raw 状态汇总。`gh pr files <number> [--limit N]` 是 PR changed-file/stat summary 的稳定 REST 入口,返回 bounded `files`、`filesReturned`、`summary.files/additions/deletions/changes/commits`、`truncation` 和 `next.command`,默认不输出 raw diff 或 patch;`gh pr diff <number> --stat` 是兼容别名,返回同一 JSON,未带 `--stat` 的 raw diff 请求会结构化拒绝。`gh pr read|view <number|owner/repo#number> [--json ...] [--raw|--full]` 继续稳定返回这些字段,并额外支持 `stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup`。`owner/repo#number` shorthand 和冲突 `--repo` 规则与 issue read/view 相同。`stateDetail` 是 UniDesk 归一化生命周期值 `open|closed|merged`,用于区分 REST `state=closed` 中的普通关闭和已合并;`closed`、`closedAt`、`merged`、`mergedAt`、`mergeCommit`、`headRefName` 与 `baseRefName` 都来自 REST,不需要 GraphQL。`mergeable`、`mergeStateStatus` 和 `statusCheckRollup` 只在 read/view 明确请求这些字段或用 `--raw|--full` 显式完整披露时通过 GitHub GraphQL 查询,GraphQL 权限不足或网络失败会结构化失败;GitHub 暂未计算完成时仍保留原始 `UNKNOWN`/null,并额外返回 `closeoutMetadata.ok=false`、`missingOrUnknownFields`、advice 和 `mergeBoundary.unideskCliMergeSupported=true`。此时收口人员应优先重试一次;若仍缺失,应继续改进 UniDesk `gh` 子命令或使用人工 GitHub UI 做最终交叉确认,禁止原生 `gh` 或手拼 GitHub API 绕过。`gh pr preflight <number> [--repo owner/name] [--full|--raw]` 是低噪声 PR 收口入口,`gh preflight <number>` 和 `gh pr closeout <number>` 是兼容别名;它先输出脱敏 auth capability,再读取 PR state/draft/head/base、mergeable、mergeStateStatus 和 statusCheckRollup,默认只给 status check 计数与失败/等待预览,完整 contexts 和原始读取摘要必须显式加 `--full` 或 `--raw`。该命令固定 `readOnly=true`、`writesRemote=false`、`policy.mergesPr=false`,不会创建、评论、更新或 merge PR。`gh pr create --title <title> --body-file <file>|--body <text> --base <branch> --head <branch> [--draft] [--dry-run]`、`gh pr edit <number> [--title ...] [--body-file <file>|--body-file -|--body <text>] [--dry-run]`、`gh pr update <number> --mode replace|append [--body-file <file>|--body-file -|--body <text>] [--title ...] [--dry-run]`、`gh pr comment create <number> --body-file <file>|--body <text> [--dry-run]`、`gh pr comment delete <commentId> [--dry-run]`、`gh pr close|reopen <number> [--dry-run]` 和 `gh pr merge <number> [--merge|--squash|--rebase] [--delete-branch] [--dry-run]` 是 PR CRUD/生命周期入口。`pr create --dry-run` 只输出 planned operation,不访问 GitHub;非 dry-run 创建前会校验 repo、base、head 和 compare ahead 状态,成功时返回 PR number/url。`pr edit/update` 使用 REST `PATCH /repos/{owner}/{repo}/pulls/{number}`,只发送显式提供的 `title` 和/或 `body` 字段,完全避开 GitHub Projects Classic GraphQL/projectCards;输出低噪声 JSON:`ok`、`repo`、PR number、`changedFields`、`url`、body 长度/SHA/source 元数据和 request plan,不默认回显完整正文。`pr update --mode append` 会先读取当前 PR body 再追加正文。`pr merge` 会先执行同源 closeout 预检,拒绝非 open、draft、冲突、非 CLEAN、失败或 pending checks 的 PR,只有 ready 时才调用 GitHub REST merge;`--delete-branch` 只删除同 repo head ref。`gh pr delete <number>` 不开放,PR 生命周期删除语义请使用 `close`。 - `gh pr list` 与 `gh issue list` 一样接受单个位置参数 `owner/repo` 作为 `--repo owner/repo` 兼容别名;位置 repo 与显式 `--repo` 冲突时会结构化失败,输出里的 `repo` 始终反映真实请求目标。`gh pr read|view --number N --repo owner/repo` 是位置 PR number 的兼容别名,不能在 list/create/edit/comment 等命令中被静默忽略。 -- PR dry-run/probe 的最小手动序列是:`bun scripts/cli.ts gh auth status --repo pikasTech/unidesk` 只读检查 token 来源、GitHub REST egress、repo 可见性和 issue read;`bun scripts/cli.ts gh pr create --repo pikasTech/unidesk --title <title> --body-file <file> --base master --head <head> --dry-run` 检查创建计划;`bun scripts/cli.ts gh pr list --repo pikasTech/unidesk --state open --limit 5 --json number,title,state,url,head,base`、`bun scripts/cli.ts gh pr files <number> --repo pikasTech/unidesk --limit 30`、`bun scripts/cli.ts gh pr view <number> --repo pikasTech/unidesk --json body,title,state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,head,base,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup` 和 `bun scripts/cli.ts gh pr preflight <number> --repo pikasTech/unidesk` 做只读 PR 观察、文件统计和收口元数据检查;`bun scripts/cli.ts gh pr edit <number> --repo pikasTech/unidesk --title <title> --body-file <file> --dry-run` 或 `cat <file> | bun scripts/cli.ts gh pr edit <number> --repo pikasTech/unidesk --body-file - --dry-run` 检查低噪声 PR 标题/正文编辑计划;`bun scripts/cli.ts gh pr comment <number> --repo pikasTech/unidesk --body-file <file> --dry-run` 检查评论计划;`bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk` 必须失败并返回结构化 `unsupported-command`。Code Queue runner 可用 `bun scripts/code-queue-pr-preflight-example.ts --repo pikasTech/unidesk --base master --head <head> --comment-pr <number>` 一次性跑只读 auth status 与 PR create/comment dry-run;该脚本不得输出 token 值,也不会创建、评论或 merge PR。 +- PR dry-run/probe 的最小手动序列是:`bun scripts/cli.ts gh auth status --repo pikasTech/unidesk` 只读检查 token 来源、GitHub REST egress、repo 可见性和 issue read;`bun scripts/cli.ts gh pr create --repo pikasTech/unidesk --title <title> --body-file <file> --base master --head <head> --dry-run` 检查创建计划;`bun scripts/cli.ts gh pr list --repo pikasTech/unidesk --state open --limit 5 --json number,title,state,url,head,base`、`bun scripts/cli.ts gh pr files <number> --repo pikasTech/unidesk --limit 30`、`bun scripts/cli.ts gh pr view <number> --repo pikasTech/unidesk --json body,title,state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,head,base,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup` 和 `bun scripts/cli.ts gh pr preflight <number> --repo pikasTech/unidesk` 做只读 PR 观察、文件统计和收口元数据检查;`bun scripts/cli.ts gh pr edit <number> --repo pikasTech/unidesk --title <title> --body-file <file> --dry-run` 或 `cat <file> | bun scripts/cli.ts gh pr edit <number> --repo pikasTech/unidesk --body-file - --dry-run` 检查低噪声 PR 标题/正文编辑计划;`bun scripts/cli.ts gh pr comment <number> --repo pikasTech/unidesk --body-file <file> --dry-run` 检查评论计划;`bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk --dry-run` 检查 guarded merge plan,真实 merge 只能在任务边界明确允许且 preflight ready 后执行。Code Queue runner 可用 `bun scripts/code-queue-pr-preflight-example.ts --repo pikasTech/unidesk --base master --head <head> --comment-pr <number>` 一次性跑只读 auth status 与 PR create/comment dry-run;该脚本不得输出 token 值,也不会创建、评论或 merge PR。 - `ci install|status|run|publish-backend-core|publish-user-service|run-dev-e2e|logs` 管理 D601 原生 k3s 上的 Tekton CI。`run` 手动创建每 commit 检查和 Code Queue 只读性能门禁;`publish-backend-core` 与 `publish-user-service` 从 pushed Git commit 构建并发布 `127.0.0.1:5000/unidesk/<service>:<commit>` commit-pinned artifacts,输出 `artifactSummary`(含 `serviceId`、`sourceCommit`、`sourceRepo`、`dockerfile`、`imageRef`、`tag`、`digest`、`digestRef`),但不部署生产;`run-dev-e2e` 的 Git 控制 runner、短 launcher、host fetch 边界、临时 smoke namespace 和 no-CD 规则只在 `docs/reference/dev-ci-runner.md` 定义;Tekton CI 通用规则见 `docs/reference/ci.md`。 - `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` 只返回结构化请求且不实际入队。长 prompt、多行 prompt、含引号/反引号/Markdown 表格/JSON/反斜杠的 prompt 必须优先用 `--prompt-stdin` 或 `--prompt-file`,不要拼进 shell 单个参数;位置参数只适合短单行 smoke prompt。stdin 推荐用 quoted heredoc:`cat <<'PROMPT' | bun scripts/cli.ts codex submit --prompt-stdin --queue <id> --dry-run`,文件路径推荐 `bun scripts/cli.ts codex submit --prompt-file /tmp/code-queue-prompt.md --queue <id> --dry-run`,确认 dry-run 后移除 `--dry-run` 提交同一 payload。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` 用于人工验收;真实提交是写入操作,默认只返回 `accepted=true`、task id、队列、写入保护摘要和后续查看命令,必须标记 `promptOmitted=true` 且不得回显 prompt 或 promptPreview。真实提交会经过本机本地串行化保护和短节流,避免同一指挥端并发 submit 把低内存主机或 `code-queue-mgr` 控制面打抖;返回值会附带 `executionMode`、`runnerPermissions` 和低噪声 `submitConcurrencyGuard`,显式说明 requested/effective mode、服务级 runner sandbox/approvalPolicy、锁与等待信息。`--execution-mode` 是 Code Queue runtime placement,不是 Codex sandbox 权限;有效模式是 `default` 和 `windows-native`,`--execution-mode full-access` 等 sandbox-like 值会保留 requested 值并显示 effective `default`,同时提示当前不支持每任务 sandbox override。真实提交的 `queue` 摘要保持低噪声:`submittedTaskIds`、`queuedTaskIds`、`activeTaskIds` 和 `databaseActiveTaskIds` 是有界预览对象,`countContext` 与 `counts` 是权威计数;`submitted.taskStates[]` 直接给出本次 task id、queue id、status 和 `state=queued|running|terminal|unknown`,其来源固定为 `response.tasks[].status`。当本次新任务仍是 queued/retry_wait,`queuedTaskIds.items` 必须包含该 id;当 counts 非零但 active/queued id 列表因为 split-brain-live、上游省略或默认有界披露而不可枚举时,预览必须设置 `idsUnavailable=true`、`itemsOmitted=true` 和 `itemsMeaning=not-enumerated-in-default-submit-output`,不得打印容易误读的 `items=[]`。`queue.activity.effectiveActiveTaskCount` 和 `queue.commanderConcurrency.activeRunnerCount` 是并发判断字段;`splitBrainLive=true` 时继续把 fresh heartbeat/database active 计入 active。需要原始 drill-down 时使用 `queue.listPreviewPolicy.rawCommand`,默认是 `bun scripts/cli.ts microservice proxy code-queue /api/tasks/overview?limit=30 --raw --full`。backend-core 默认把提交、队列 CRUD、已读状态、历史摘要和轻量 Trace 读取分流到主 server `code-queue-mgr`,由它写入主 PostgreSQL;D601 scheduler 只轮询并执行已入库任务。 - `codex steer <taskId> [prompt|--prompt-file path|--prompt-stdin] [--steer-id id] [--dry-run] [--no-retry|--retry-attempts N] [--full|--raw]` 向运行中的 Code Queue 任务发送纠偏 prompt。CLI 会为同一 task/prompt 生成稳定 `steerId`,也允许显式传入 `--steer-id`;所有 retry 都复用同一 `steerId`,支持后端按 key 抑制重复 trace 注入。真实成功只返回低噪声写入确认,不回显 prompt 或完整任务状态;输出包含 `steer.status`、`steer.deliveryState`、`steer.steerId`、`traceConfirmation` 和 `commands.traceConfirm`。失败默认只返回 `accepted=false`、原因、scope、retryable、attempt 摘要、operator guidance 和 task/read/submit/health drill-down 命令。`upstreamBodyPreview`、request 元数据和 raw upstream failure 必须显式加 `--full` 或 `--raw` 才输出。任务已终态时返回紧凑 `task-already-terminal`、`status=not_accepted`、`deliveryState=not_accepted`、task 状态、终态状态、更新时间、`retryable=false` 和 `codex task` / `codex read` / `codex submit --reference-task-id <taskId>` 后续命令。 - `codex resume <taskId> [prompt|--prompt-file path|--prompt-stdin] [--resume-id id] [--dry-run] [--full|--raw]` 对已终态或 awaiting-closeout 的原 Code Queue task 创建后续 turn,优先用于 PR 小修、冲突、rebase、补测和 reviewer feedback,保留原 task、attempt、branch/PR 上下文和 `codexThreadId`/OpenCode session。CLI 会为同一 task/prompt 生成稳定 `resumeId`,也允许显式传入;同一 `resumeId` 加同 prompt 返回 `duplicate_suppressed` 且不重复注入,同一 `resumeId` 加不同 prompt 返回 409 conflict。真实成功只返回 taskId、resumeId/turnId、`deliveryState`、是否复用原 `codexThreadId`、有界 trace confirmation 和 `codex task/detail/trace/output` 后续命令,不回显 prompt 或完整 task state。running/judging task 必须 fail closed 并给出 `disposition=use-steer-for-active-task` 与 `codex steer` 命令,不把 resume 伪装成新 task;不存在 task 返回结构化 not accepted。若 delivery timeout 或 trace 未确认,输出 `deliveryUnconfirmed` 和确认命令,调用方先查 `codex task <taskId> --trace` 再用同一 `resumeId` 重试。 -- `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|--raw]` 通过稳定 `code-queue` proxy 请求 D601 scheduler `/api/runtime-preflight`,用于 PR 型派单 admission。默认输出是紧凑 commander 视图,显式分出 `schedulerPreflight` 与 `activeRunnerPrCapability`,并附带 `commands` 和 `disclosure`,方便先看 scheduler auth 缺口、再看当前 runner/dev container 的 `gh auth status` 与 `gh pr create --dry-run` 能力;`--full` 或 `--raw` 才展开完整 `preflight`、工具、agent port、Git worktree、GitHub egress、repo/issue/PR 只读探测和观测原文。只报告 `GH_TOKEN`/`GITHUB_TOKEN` 是否存在和来源 key,不打印值。当 auth-broker 配置存在时,`tokenCoverage.source="auth-broker"`、`credentialSource="broker-issued-token"` 且 runner env token 不是成功前提;当仅 env token 存在时,`credentialSource="env-token"` 且 `authBroker.nextAction="use-env-token-until-auth-broker-live"`;两者都缺失时顶层 `ok=false`、`runnerDisposition=infra-blocked`、`degradedReason=auth-broker-needed`,`tokenCoverage.missing` 同时列出 `GH_TOKEN` 与 `GITHUB_TOKEN`,并输出 `authBroker.source="broker/auth-broker-needed"`、`capability.source="missing-token"`。该 `auth-missing` 的 scope 是 `scheduler-runner-env`,不能简化成“当前 active runner/dev container 不能创建 PR”;默认视图必须带 `scopeBoundary` 和 `activeRunnerPrCapability`。GitHub DNS/API 连接失败应归类为 `failureKind=github-transient`、`degradedReason=github-dns-api-transient`,并带 `retryable=true`、`commanderAction=retry-backoff-or-keep-running-if-heartbeat-fresh` 和有界 `githubTransient.failedProbes`;调用方应重试/退避,且在任务 heartbeat/trace 新鲜时继续监督,不把它当成 auth 缺失或 PR 语义失败。`prCapability` 是 runner-facing 合同摘要,必须包含目标分支、token/auth 来源、`systemGhBinaryRequiredForWrites=false`、UniDesk REST `bun scripts/cli.ts gh` 可用性、push dry-run/PR create dry-run 的 `writesRemote=false`、expected PR handoff、真实 PR 创建需要 commander 授权和 `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 broker、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|--raw]` 通过稳定 `code-queue` proxy 请求 D601 scheduler `/api/runtime-preflight`,用于 PR 型派单 admission。默认输出是紧凑 commander 视图,显式分出 `schedulerPreflight` 与 `activeRunnerPrCapability`,并附带 `commands` 和 `disclosure`,方便先看 scheduler auth 缺口、再看当前 runner/dev container 的 `gh auth status` 与 `gh pr create --dry-run` 能力;`--full` 或 `--raw` 才展开完整 `preflight`、工具、agent port、Git worktree、GitHub egress、repo/issue/PR 只读探测和观测原文。只报告 `GH_TOKEN`/`GITHUB_TOKEN` 是否存在和来源 key,不打印值。当 auth-broker 配置存在时,`tokenCoverage.source="auth-broker"`、`credentialSource="broker-issued-token"` 且 runner env token 不是成功前提;当仅 env token 存在时,`credentialSource="env-token"` 且 `authBroker.nextAction="use-env-token-until-auth-broker-live"`;两者都缺失时顶层 `ok=false`、`runnerDisposition=infra-blocked`、`degradedReason=auth-broker-needed`,`tokenCoverage.missing` 同时列出 `GH_TOKEN` 与 `GITHUB_TOKEN`,并输出 `authBroker.source="broker/auth-broker-needed"`、`capability.source="missing-token"`。该 `auth-missing` 的 scope 是 `scheduler-runner-env`,不能简化成“当前 active runner/dev container 不能创建 PR”;默认视图必须带 `scopeBoundary` 和 `activeRunnerPrCapability`。GitHub DNS/API 连接失败应归类为 `failureKind=github-transient`、`degradedReason=github-dns-api-transient`,并带 `retryable=true`、`commanderAction=retry-backoff-or-keep-running-if-heartbeat-fresh` 和有界 `githubTransient.failedProbes`;调用方应重试/退避,且在任务 heartbeat/trace 新鲜时继续监督,不把它当成 auth 缺失或 PR 语义失败。`prCapability` 是 runner-facing 合同摘要,必须包含目标分支、token/auth 来源、`systemGhBinaryRequiredForWrites=false`、UniDesk REST `bun scripts/cli.ts gh` 可用性、push dry-run/PR create dry-run 的 `writesRemote=false`、expected PR handoff、真实 PR 创建需要 commander 授权,以及 guarded `gh pr merge --dry-run` 预检路径;系统 `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 broker、repo/issue/PR read、push dry-run 和最终授权后的真实 PR 创建结果为准。 - `codex task <taskId>` 通过 Code Queue 私有代理按任务 ID 查询结构化审阅摘要;默认只返回任务身份、执行 Provider、工作目录、attempt 计数、原始 prompt、最终 response、最后错误和渐进披露命令,适合指挥官审阅完成未读任务且避免上下文爆炸。`--detail` 仍是有界详细摘要:默认只返回少量 attempt/tool 行、短 prompt/response/stderr/feedback 预览和 omitted/truncated 元数据;需要完整 prompt/response 文本或更多 tool/attempt 细节时再显式加 `--full`、`--tool-limit N`、`--trace` 或 `codex output`。该摘要读取默认由主 server `code-queue-mgr` 从 PostgreSQL 返回,不依赖 D601 `code-queue-read` Service 可用。 - `codex tasks [--view commander|supervisor|full] [--queue id] [--status succeeded|running|queued|failed|canceled|judging|retry_wait[,..]] [--unread|--unread-only] [--limit N] [--before-id id]` 通过同一私有代理输出渐进式披露视图。host commander 轮询应优先使用 `--view commander`:它是低噪声 polling 入口,只返回有界 action map,包含 `activeRunners.count` 及来源/处置、少量 active item、queued/retry_wait 精确计数、terminal-unread 总数和省略行数、关键风险计数、HWLAB#7/#99/#116/#164/#317 与 UniDesk#20/#118 命中、确定性分类计数和集中式 `codex task/trace/output/read` drill-down 命令。默认 commander 不展开历史 terminal unread item details,也不嵌入 prompt preview、final response preview、trace、output 或 raw overview;terminal unread 详情必须通过 `codex unread`、`codex tasks --unread --view supervisor`、`--view full`、`--full` 或 per-task `codex read <taskId>` 获取。默认 `supervisor` 保持旧低噪声分区视图,只返回 `activeRunning`、`running`、`completedUnread`、`recentCompleted`、`queued`、`activity`、`commanderConcurrency` 和 `executionDiagnostics` 的紧凑行;`activeRunning.count` 是 running+judging 的状态计数,`exact=true` 时来自 queue summary counts,`running.returned` 和 `activeRunning.rowPage.returned` 只是本次返回的紧凑行数。`commanderConcurrency.activeRunnerCount` 是并发策略应使用的 active/running 计数,等于 `activity.effectiveActiveTaskCount`;15 并发策略按 `15 - activeRunnerCount` 计算剩余窗口。`commanderConcurrency.splitBrainDisposition=live-count-as-active` 表示 split-brain 有 fresh heartbeat 证据,应继续监督并计入 active;`interventionRequired=true` 才提示介入。确定性分类只在有强基础设施故障信号时输出 `infrastructure-blocker`;普通 runner/CLI/治理上下文归入 `infra-governance`、`workflow` 或 `unknown`,避免把历史任务误报为基础设施阻塞。`supervisor` prompt/body 只给短预览和原始字符数,`running`/`completedUnread`/`queued` 默认只返回一个有界小页并通过 section `commands.next` 继续分页,`recentCompleted` 默认限量且不重复 `completedUnread` 未读终态,不嵌入完整 Trace、final response 或全量 overview。`--limit` 在 commander/supervisor 中主要是扫描/分页预算,不是返回几十条肥行的开关;CLI 安全上限是 100,输出会在 `filters.requestedLimit`、`filters.effectiveLimit`、`filters.limitCapped` 和 disclosure 中说明显式请求是否被 capped;底层 overview 拉取预算独立显示在 `source.requestedLimit` / `source.effectiveLimit`,所以 `--limit 260` 应显示 requested=260、effective=100、source requested/effective=200,而不是只露出一个含糊的 `limit`。`--unread` 是 `--unread-only` 的别名,必须只保留未读终态;`--status` 必须真实过滤支持的状态,并接受常见 alias:`completed|complete|success|successful -> succeeded`,`cancelled -> canceled`,`retry-wait|retrying -> retry_wait`,`pending -> queued`。未知参数或未知状态必须结构化失败并给出支持值和 alias 建议,预期参数错误默认不输出 stack trace;显式 `UNIDESK_CLI_DEBUG=1` 可保留完整诊断。需要更详细当前页任务行时显式使用 `--view full` 或 `--full`,仍受 `--limit` 和 `--before-id` 分页约束。 - `codex unread [summary|list|mark-read] [--queue id] [--repo owner/name] [--issue N] [--status succeeded|failed|canceled[,..]] [--limit N] [--before-id id] [--view summary|full] [--full] [--confirm]` 是完成未读积压的默认低噪声 triage 入口。默认只读返回总数、repo/issue/status/queue 计数、最新任务 id 小页和每行一条紧凑 `nextStep`,不拉取 per-task summary,不输出 raw prompt、final response、trace、output,也不为每个任务重复 `show/detail/trace/output/read` command block;完整 per-task 命令只在显式 `--full`、`--view full` 或 `list` 中展开。默认输出仍保留一次性的 `codex task <taskId>`、`codex read <taskId>`、分页和 full 展开模板命令。批量已读必须使用 `codex unread mark-read ... --confirm`,缺少 `--confirm` 时结构化失败且不 POST `/read`;单任务审阅仍优先 `codex read <taskId>`。 @@ -100,7 +101,7 @@ UniDesk 仓库自带 `scripts/playwright-cli.ts` 作为 host commander 浏览器 `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`;`--raw` 仍受默认硬限额保护,需要完整 body 时显式添加 `--raw --full`,或用 `--max-body-bytes <N>` 调整预览阈值。正式 frontend 展示仍应优先使用业务控件和 `__unideskArrayLimit` 这类展示级裁剪参数,而不是默认倾倒完整 JSON。 -GitHub issue/PR 写操作必须优先使用 `bun scripts/cli.ts gh issue|pr ... --body-file <file>`。不要把 Markdown 正文拼进 shell 参数或 `gh api -f body=...`;这些路径容易把真实换行污染成字面量 `\n`。从 shell 生成正文文件时使用 quoted heredoc,例如 `cat <<'EOF' > /tmp/body.md`,保证反引号、反斜杠和 Markdown 表格不被 shell 展开;之后再把文件交给 `--body-file`。`gh issue` 写命令不接受 stdin 正文,`gh issue comment create --body-file -` 也不支持;需要从生成内容写入 issue 或 issue comment 时,先落到临时 Markdown 文件或已审阅的工作文件,再传给 `--body-file`。`gh issue comment create --body <short-text>` 只适合人工短单行评论,默认输出只给 bounded preview、bodyChars、bodySha、source 和 readCommands,不回显长正文;同时传 `--body` 与 `--body-file` 必须结构化失败。PR 安全写入口同样优先 `--body-file`;`gh pr edit/update --body-file -` 可从 stdin 读取已审阅 Markdown,适合 runner 管道化更新 PR title/body。`--body` 只适合短单行内容。JSON 请求体场景使用各命名空间自己的 `--body-file` 或 `--body-stdin`,避免长 JSON 直接塞进 shell 参数;GitHub issue Markdown 写入仍只走 `--body-file`。`update --mode append` 用 REST 读取旧正文后追加文件字节,不引入 shell 拼接正文路径。`gh pr merge` 暂不开放,不存在 `--confirm` 可绕过的真实 merge 路径。CLI 会按 UTF-8 原样读取文件或 stdin 内容并用 JSON body 调用 REST API;PR edit/update 输出不会默认回显完整正文。 +GitHub issue/PR 写操作必须优先使用 `bun scripts/cli.ts gh issue|pr ... --body-file <file>`。不要把 Markdown 正文拼进 shell 参数或 `gh api -f body=...`;这些路径容易把真实换行污染成字面量 `\n`。从 shell 生成正文文件时使用 quoted heredoc,例如 `cat <<'EOF' > /tmp/body.md`,保证反引号、反斜杠和 Markdown 表格不被 shell 展开;之后再把文件交给 `--body-file`。`gh issue` 写命令不接受 stdin 正文,`gh issue comment create --body-file -` 也不支持;需要从生成内容写入 issue 或 issue comment 时,先落到临时 Markdown 文件或已审阅的工作文件,再传给 `--body-file`。`gh issue comment create --body <short-text>` 只适合人工短单行评论,默认输出只给 bounded preview、bodyChars、bodySha、source 和 readCommands,不回显长正文;同时传 `--body` 与 `--body-file` 必须结构化失败。PR 安全写入口同样优先 `--body-file`;`gh pr edit/update --body-file -` 可从 stdin 读取已审阅 Markdown,适合 runner 管道化更新 PR title/body。`--body` 只适合短单行内容。JSON 请求体场景使用各命名空间自己的 `--body-file` 或 `--body-stdin`,避免长 JSON 直接塞进 shell 参数;GitHub issue Markdown 写入仍只走 `--body-file`。`update --mode append` 用 REST 读取旧正文后追加文件字节,不引入 shell 拼接正文路径。`gh pr merge` 是 guarded write:先读 closeout metadata 并拒绝非 ready PR,`--dry-run` 只输出计划不写远端;没有 `--confirm` 之类绕过 preflight 的路径。CLI 会按 UTF-8 原样读取文件或 stdin 内容并用 JSON body 调用 REST API;PR edit/update 输出不会默认回显完整正文。 `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 fdd75eb8..08b6c327 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -24,6 +24,7 @@ import { runCommanderCommand } from "./src/commander"; import { isHelpToken, rootHelp, serverHelp, sshHelp, staticNamespaceHelp } from "./src/help"; import { runServerCleanupCommand } from "./src/server-cleanup"; import { runHwlabCdCommand } from "./src/hwlab-cd"; +import { runHwlabG14Command } from "./src/hwlab-g14"; const remoteOptions = extractRemoteCliOptions(process.argv.slice(2)); const args = remoteOptions.args; @@ -281,6 +282,13 @@ async function main(): Promise<void> { } if (top === "hwlab") { + if (sub === "g14") { + const result = await runHwlabG14Command(readConfig(), args.slice(2)); + const ok = (result as { ok?: unknown }).ok !== false; + emitJson(commandName, result, ok); + if (!ok) process.exitCode = 1; + return; + } const result = await runHwlabCdCommand(args.slice(1)); const ok = (result as { ok?: unknown }).ok !== false; emitJson(commandName, result, ok); diff --git a/scripts/code-queue-pr-preflight-contract-test.ts b/scripts/code-queue-pr-preflight-contract-test.ts index 25c88d38..46c51bbb 100644 --- a/scripts/code-queue-pr-preflight-contract-test.ts +++ b/scripts/code-queue-pr-preflight-contract-test.ts @@ -109,12 +109,12 @@ function remoteControlPlaneResult(overrides: Partial<JsonRecord> = {}): JsonReco preflightCreatesPr: false, preflightMergesPr: false, }, - unsupportedMergeBoundary: { - supported: false, + mergeBoundary: { + supported: true, command: "bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk", - degradedReason: "unsupported-command", - runnerDisposition: "business-failed", - note: "UniDesk CLI intentionally does not merge PRs in this phase; runner handoff stops at PR creation and evidence.", + preflightRequired: true, + dryRunCommand: "bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk --dry-run", + note: "UniDesk CLI can merge PRs only after explicit task authorization and a ready closeout preflight; runner handoff still starts with PR creation and evidence.", }, }, controlPlane: { @@ -395,7 +395,7 @@ async function main(): Promise<void> { pushDryRun: { requested: false, ref: "refs/heads/probe/code-queue-pr-capability-dryrun", writesRemote: false, commandShape: "git push --dry-run origin HEAD:refs/heads/probe/code-queue-pr-capability-dryrun" }, prCreateDryRun: { requested: false, headBranch: "feature/code-queue-pr-preflight", writesRemote: false, commandShape: "bun scripts/cli.ts gh pr create --repo pikasTech/unidesk --base master --head feature/code-queue-pr-preflight --dry-run" }, expectedPrHandoff: { sourceBranch: "feature/code-queue-pr-preflight", targetBranch: "master", runnerCreatesPrAfterAuthorization: true, commanderReviewsAndMerges: true, preflightCreatesPr: false, preflightMergesPr: false }, - unsupportedMergeBoundary: { supported: false, command: "bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk", degradedReason: "unsupported-command", runnerDisposition: "business-failed", note: "UniDesk CLI intentionally does not merge PRs in this phase; runner handoff stops at PR creation and evidence." }, + mergeBoundary: { supported: true, command: "bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk", preflightRequired: true, dryRunCommand: "bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk --dry-run", note: "UniDesk CLI can merge PRs only after explicit task authorization and a ready closeout preflight; runner handoff still starts with PR creation and evidence." }, }, }), }); diff --git a/scripts/gh-cli-pr-contract-test.ts b/scripts/gh-cli-pr-contract-test.ts index 7a70a648..e91ccbdd 100644 --- a/scripts/gh-cli-pr-contract-test.ts +++ b/scripts/gh-cli-pr-contract-test.ts @@ -161,6 +161,11 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque sendJson(res, 200, pullRequest); return; } + if (req.method === "PUT" && req.url === "/repos/pikasTech/unidesk/pulls/42/merge") { + const parsed = JSON.parse(body) as JsonRecord; + sendJson(res, 200, { sha: "merged-by-rest-sha", merged: true, message: `merged via ${String(parsed.merge_method ?? "merge")}` }); + return; + } if (req.method === "GET" && req.url === "/repos/pikasTech/HWLAB/pulls/7") { sendJson(res, 200, shorthandPullRequest); return; @@ -438,7 +443,7 @@ export async function runGhCliPrContract(): Promise<JsonRecord> { const closeoutMergeBoundary = closeoutMetadata.mergeBoundary as JsonRecord; assertCondition(closeoutMetadata.ok === true && closeoutMetadata.source === "github-graphql", "pr view closeout metadata should report GraphQL source", closeoutMetadata); assertCondition(Array.isArray(closeoutMetadata.missingOrUnknownFields) && closeoutMetadata.missingOrUnknownFields.length === 0, "known closeout metadata should have no missing/unknown fields", closeoutMetadata); - assertCondition(closeoutMergeBoundary.unideskCliMergeSupported === false, "closeout metadata should keep UniDesk CLI merge unsupported", closeoutMetadata); + assertCondition(closeoutMergeBoundary.unideskCliMergeSupported === true, "closeout metadata should expose guarded UniDesk CLI merge support", closeoutMetadata); assertCondition(mock.requests.some((request) => request.method === "POST" && request.url === "/graphql"), "closeout metadata should use GitHub GraphQL when requested", mock.requests); const unknownCloseout = await runCli(["gh", "pr", "view", "44", "--repo", "pikasTech/unidesk", "--json", "headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup"], env); @@ -504,7 +509,7 @@ export async function runGhCliPrContract(): Promise<JsonRecord> { assertCondition(preflightStatus.state === "SUCCESS" && preflightStatus.rawOmitted === true, "pr preflight default status rollup should be compact", preflightStatus); assertCondition(preflightCounts.success === 2, "pr preflight should count successful contexts", preflightStatus); const policy = closeoutPreflightData.policy as JsonRecord; - assertCondition(policy.mergesPr === false && policy.mergeCommandSupported === false && policy.unideskCliMergeSupported === false, "pr preflight policy should block UniDesk CLI merge execution", policy); + assertCondition(policy.mergesPr === false && policy.mergeCommandSupported === true && policy.unideskCliMergeSupported === true, "pr preflight policy should expose guarded UniDesk CLI merge execution", policy); const aliasPreflight = await runCli(["gh", "preflight", "42", "--repo", "pikasTech/unidesk"], env); assertCondition(aliasPreflight.status === 0, "top-level gh preflight alias should succeed", aliasPreflight.json ?? { stdout: aliasPreflight.stdout }); @@ -519,6 +524,19 @@ export async function runGhCliPrContract(): Promise<JsonRecord> { assertCondition(fullStatus.rawOmitted === false && Array.isArray(fullStatus.contexts), "pr preflight --full should include status contexts", fullStatus); assertCondition(typeof fullPreflightData.raw === "object" && fullPreflightData.raw !== null, "pr preflight --full should include raw read payload summary", fullPreflightData); + const mergeDryRun = await runCli(["gh", "pr", "merge", "42", "--repo", "pikasTech/unidesk", "--dry-run"], env); + assertCondition(mergeDryRun.status === 0, "pr merge dry-run should expose a guarded merge plan", mergeDryRun.json ?? { stdout: mergeDryRun.stdout }); + const mergeDryRunData = dataOf(mergeDryRun.json ?? {}); + assertCondition(mergeDryRunData.wouldMerge === true && mergeDryRunData.method === "merge", "merge dry-run should not write but should plan merge", mergeDryRunData); + const mergeActual = await runCli(["gh", "pr", "merge", "42", "--repo", "pikasTech/unidesk", "--squash"], env); + assertCondition(mergeActual.status === 0, "pr merge should use guarded REST merge when preflight is ready", mergeActual.json ?? { stdout: mergeActual.stdout }); + const mergeData = dataOf(mergeActual.json ?? {}); + assertCondition(mergeData.method === "squash" && mergeData.rest === true, "merge result should report REST merge method", mergeData); + const mergeRequest = mock.requests.find((request) => request.method === "PUT" && request.url === "/repos/pikasTech/unidesk/pulls/42/merge"); + assertCondition(mergeRequest !== undefined, "pr merge should call GitHub REST merge endpoint", mock.requests); + const mergePayload = JSON.parse(mergeRequest?.body ?? "{}") as JsonRecord; + assertCondition(mergePayload.merge_method === "squash", "pr merge should pass selected merge method", mergePayload); + const preflight = await runBun([ "scripts/code-queue-pr-preflight-example.ts", "--repo", @@ -545,7 +563,7 @@ export async function runGhCliPrContract(): Promise<JsonRecord> { assertCondition(preflightComment.ok === true && preflightComment.dryRun === true && preflightComment.planned === true, "PR preflight comment must stay dry-run", preflightComment); assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/rate_limit"), "PR preflight should probe REST egress", mock.requests); assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/unidesk"), "PR preflight should probe repo visibility", mock.requests); - assertCondition(mock.requests.every((request) => request.method === "GET" || request.method === "POST" && request.url === "/graphql"), "initial mock phase should remain read-only except GraphQL metadata reads", mock.requests); + assertCondition(mock.requests.some((request) => request.method === "PUT" && request.url === "/repos/pikasTech/unidesk/pulls/42/merge"), "initial mock phase should include the guarded REST merge write", mock.requests); } finally { await mock.close(); } @@ -681,16 +699,6 @@ export async function runGhCliPrContract(): Promise<JsonRecord> { await mock2.close(); } - const mergeBlocked = await runCli(["gh", "pr", "merge", "42", "--repo", "pikasTech/unidesk"]); - assertCondition(mergeBlocked.status !== 0, "pr merge should fail", mergeBlocked.json ?? { stdout: mergeBlocked.stdout }); - const mergeData = mergeBlocked.json?.data as JsonRecord | undefined; - assertCondition(String(mergeData?.message ?? "").includes("intentionally unsupported"), "merge block message should be explicit", mergeData ?? {}); - assertCondition(mergeData?.runnerDisposition === "business-failed", "merge block should classify as business-failed", mergeData ?? {}); - const closeoutBoundary = mergeData?.closeoutBoundary as JsonRecord | undefined; - assertCondition(closeoutBoundary?.ordinaryRunnerFinalActionAllowed === true, "merge block should preserve ordinary runner PR closeout policy", closeoutBoundary ?? {}); - assertCondition(closeoutBoundary?.unideskCliMergeSupported === false, "merge block should state UniDesk REST CLI merge remains unsupported", closeoutBoundary ?? {}); - assertCondition(String(closeoutBoundary?.readOnlyCloseoutCommand ?? "").includes("gh pr view 42"), "merge block should point to read-only closeout command", closeoutBoundary ?? {}); - const deleteBlocked = await runCli(["gh", "pr", "delete", "42", "--repo", "pikasTech/unidesk"]); assertCondition(deleteBlocked.status !== 0, "pr hard delete should fail", deleteBlocked.json ?? { stdout: deleteBlocked.stdout }); const deleteData = deleteBlocked.json?.data as JsonRecord | undefined; @@ -735,7 +743,7 @@ export async function runGhCliPrContract(): Promise<JsonRecord> { "pr edit supports --body-file - stdin without echoing full body", "pr update append and close/reopen are available", "pr comment create/delete follows CRUD shape and --body remains supported", - "pr merge is blocked", + "pr merge is guarded by preflight and uses REST", "pr hard delete is blocked", "pr create validation failures are structured", "unknown gh options are structured", diff --git a/scripts/host-codex-commander-contract-test.ts b/scripts/host-codex-commander-contract-test.ts index f08357ac..bb7d558e 100644 --- a/scripts/host-codex-commander-contract-test.ts +++ b/scripts/host-codex-commander-contract-test.ts @@ -74,7 +74,7 @@ assertCondition(asRecord(plan.issueEntries, "issueEntries").mutation === false, const prCloseout = asRecord(plan.prCloseout, "prCloseout"); assertCondition(prCloseout.mutation === false, "PR closeout plan must be non-mutating", prCloseout); assertCondition(asRecord(prCloseout.runnerBoundary, "runnerBoundary").maySelfCloseOrMergeOrdinaryPrWithinTaskBoundary === true, "ordinary PR runner self-close/merge boundary must be explicit", prCloseout); -assertCondition(asRecord(prCloseout.unideskCliBoundary, "unideskCliBoundary").mergeSupported === false, "UniDesk REST gh pr merge must remain unsupported", prCloseout); +assertCondition(asRecord(prCloseout.unideskCliBoundary, "unideskCliBoundary").mergeSupported === true, "UniDesk REST gh pr merge must be guarded and supported", prCloseout); assertCondition(asRecord(plan.claudeqqApproval, "claudeqqApproval").mutation === false, "approval plan must be non-mutating", plan); const planWithoutDryRun = dataOf(runCli(["commander", "plan"], 1)); diff --git a/scripts/src/code-queue.ts b/scripts/src/code-queue.ts index 082be8e5..f8ff8864 100644 --- a/scripts/src/code-queue.ts +++ b/scripts/src/code-queue.ts @@ -7040,7 +7040,7 @@ function compactPrPreflightCommanderView(record: Record<string, unknown>, option const unideskGhCli = asRecord(capability?.unideskGhCli); const pushDryRun = asRecord(capability?.pushDryRun); const prCreateDryRun = asRecord(capability?.prCreateDryRun); - const unsupportedMergeBoundary = asRecord(capability?.unsupportedMergeBoundary); + const mergeBoundary = asRecord(capability?.mergeBoundary) ?? asRecord(capability?.unsupportedMergeBoundary); const commands = prPreflightCommandSet(record, options); const schedulerAuthObserved = tokenCoverage !== null; const schedulerAuthReady = tokenCoverage?.ok === true; @@ -7123,8 +7123,8 @@ function compactPrPreflightCommanderView(record: Record<string, unknown>, option writesRemote: prCreateDryRun.writesRemote ?? false, commandShape: prCreateDryRun.commandShape ?? null, }, - mergeSupported: unsupportedMergeBoundary?.supported ?? false, - mergeCommand: unsupportedMergeBoundary?.command ?? "bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk", + mergeSupported: mergeBoundary?.supported ?? false, + mergeCommand: mergeBoundary?.command ?? "bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk", }, authScopeSummary: record.authScopeSummary ?? prPreflightAuthScopeSummary(tokenCoverage, activeRunnerDevContainer), scopeBoundary: record.scopeBoundary ?? prPreflightScopeBoundary(tokenCoverage), @@ -7542,12 +7542,12 @@ function compactPrRuntimePreflight(preflight: Record<string, unknown>, options: preflightCreatesPr: false, preflightMergesPr: false, }, - unsupportedMergeBoundary: { - supported: false, + mergeBoundary: { + supported: true, command: "bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk", - degradedReason: "unsupported-command", - runnerDisposition: "business-failed", - note: "UniDesk CLI intentionally does not merge PRs in this phase; runner handoff stops at PR creation and evidence.", + preflightRequired: true, + dryRunCommand: "bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk --dry-run", + note: "UniDesk CLI can merge PRs only after explicit task authorization and a ready closeout preflight; runner handoff still starts with PR creation and evidence.", }, }, controlPlane: { diff --git a/scripts/src/commander.ts b/scripts/src/commander.ts index a980ab72..2276f4f7 100644 --- a/scripts/src/commander.ts +++ b/scripts/src/commander.ts @@ -171,18 +171,19 @@ function prCloseoutPlan(): Record<string, unknown> { ordinaryRunnerAllowed: true, hostCommanderAllowedAfterReview: true, tools: [ - "system gh pr merge <number> --repo pikasTech/unidesk", + "bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk", "GitHub UI merge/close controls", "bun scripts/cli.ts gh pr close <number> --repo pikasTech/unidesk", ], sourceMergeClosePolicy: "Use repo-owned, auditable GitHub paths; do not directly push target branches as a merge substitute.", }, unideskCliBoundary: { - mergeSupported: false, + mergeSupported: true, closeSupported: true, command: "bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk", - degradedReason: "unsupported-command", - automatedMergeImplemented: false, + dryRunCommand: "bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk --dry-run", + preflightRequired: true, + automatedMergeImplemented: true, }, }; } diff --git a/scripts/src/gh.ts b/scripts/src/gh.ts index c8a80545..e5d31cae 100644 --- a/scripts/src/gh.ts +++ b/scripts/src/gh.ts @@ -21,7 +21,7 @@ const BOARD_ROW_FIELDS = ["progress", "status", "validation", "branch", "tasks", const BOARD_ROW_UPSERT_TEXT_FIELDS = ["category", "branch", "tasks", "summary", "focus", "validation", "progress"] as const; const ISSUE_VIEW_JSON_FIELDS = ["body", "title", "state", "comments", "number", "url", "author", "createdAt", "updatedAt"] as const; const ISSUE_LIST_JSON_FIELDS = ["number", "title", "state", "url", "updatedAt", "createdAt", "author", "labels"] as const; -const PR_LIST_JSON_FIELDS = ["body", "title", "state", "number", "url", "author", "head", "base", "draft", "createdAt", "updatedAt"] as const; +const PR_LIST_JSON_FIELDS = ["body", "title", "state", "number", "url", "author", "head", "base", "draft", "createdAt", "updatedAt", "headRefName", "baseRefName"] as const; const PR_READ_JSON_FIELDS = ["body", "title", "state", "stateDetail", "number", "url", "author", "head", "base", "draft", "createdAt", "updatedAt", "closed", "closedAt", "merged", "mergedAt", "mergeCommit", "headRefName", "baseRefName", "mergeable", "mergeStateStatus", "statusCheckRollup"] as const; const PR_CLOSEOUT_JSON_FIELDS = ["mergeable", "mergeStateStatus", "statusCheckRollup"] as const; const PR_CLOSEOUT_VIEW_JSON = "headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup"; @@ -30,7 +30,7 @@ const BODY_UPDATE_MODES = ["replace", "append"] as const; const BOARD_MUTATION_SECTIONS = ["open", "closed"] as const; const BOARD_GITHUB_STATUSES = ["OPEN", "CLOSED"] as const; const GH_VALUE_OPTIONS = new Set(["--repo", "--limit", "--board-issue", "--known-meta-issue", "--ignore-issue", "--title", "--body-file", "--body", "--base", "--head", "--json", "--state", "--mode", "--expect-updated-at", "--expect-body-sha", "--body-profile", "--label", "--field", "--value", "--section", "--to", "--status", "--row-file", "--category", "--branch", "--tasks", "--summary", "--focus", "--validation", "--progress", "--number"]); -const GH_FLAG_OPTIONS = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff", "--allow-short-body", "--raw", "--full", "--stat"]); +const GH_FLAG_OPTIONS = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff", "--allow-short-body", "--raw", "--full", "--stat", "--merge", "--squash", "--rebase", "--delete-branch"]); const MIN_SAFE_BODY_SCAN_CHARS = MIN_SAFE_ISSUE_BODY_CHARS; const ISSUE_SCAN_MAX_FINDINGS = 60; const ISSUE_BODY_PROFILES = { @@ -61,6 +61,7 @@ type PrReadJsonField = typeof PR_READ_JSON_FIELDS[number]; type IssueListState = typeof ISSUE_LIST_STATES[number]; type PrListState = typeof ISSUE_LIST_STATES[number]; type BodyUpdateMode = typeof BODY_UPDATE_MODES[number]; +type PullRequestMergeMethod = "merge" | "squash" | "rebase"; type BoardMutationSection = typeof BOARD_MUTATION_SECTIONS[number]; type BoardGithubStatus = typeof BOARD_GITHUB_STATUSES[number]; type IssueBodyProfileName = keyof typeof ISSUE_BODY_PROFILES; @@ -339,6 +340,8 @@ interface GitHubOptions { boardMoveTo?: BoardMutationSection; boardGithubStatus?: BoardGithubStatus; boardRowUpsertValues: BoardRowUpsertValues; + mergeMethod: PullRequestMergeMethod; + deleteBranch: boolean; } interface GitHubShorthandReference { @@ -411,7 +414,7 @@ interface GitHubPullRequest { html_url: string; draft?: boolean; user?: { login?: string }; - head?: { ref?: string; sha?: string }; + head?: { ref?: string; sha?: string; repo?: { full_name?: string | null } | null }; base?: { ref?: string; sha?: string }; additions?: number; deletions?: number; @@ -643,6 +646,12 @@ function parseBodyUpdateMode(args: string[]): BodyUpdateMode { throw new Error(`unsupported --mode ${raw}; supported modes: ${BODY_UPDATE_MODES.join(",")}`); } +function parsePullRequestMergeMethod(args: string[]): PullRequestMergeMethod { + const selected = ["merge", "squash", "rebase"].filter((method) => hasFlag(args, `--${method}`)); + if (selected.length > 1) throw new Error("choose only one PR merge method flag: --merge, --squash, or --rebase"); + return (selected[0] ?? "merge") as PullRequestMergeMethod; +} + function parseIssueBodyProfile(args: string[]): IssueBodyProfileOption { const raw = optionValue(args, "--body-profile") ?? "auto"; if (raw === "auto" || raw === "code-queue-board" || raw === "commander-brief") return raw; @@ -772,6 +781,8 @@ function parseOptions(args: string[]): GitHubOptions { boardMoveTo: parseBoardMutationSection(args, "--to"), boardGithubStatus: parseBoardGithubStatus(args), boardRowUpsertValues: parseBoardRowUpsertValues(args), + mergeMethod: parsePullRequestMergeMethod(args), + deleteBranch: hasFlag(args, "--delete-branch"), }; } @@ -4066,12 +4077,11 @@ function prMetadataSummary(metadata: GitHubPullRequestGraphqlMetadata): Record<s function prMergeBoundary(): Record<string, unknown> { return { - runnerAllowed: ["pr create", "pr update/edit", "pr comment", "pr read/view", "pr close"], + runnerAllowed: ["pr create", "pr update/edit", "pr comment", "pr read/view", "pr close", "pr merge after explicit command authorization and preflight success"], ordinaryRunnerFinalActionAllowed: true, commanderRequiredWhen: ["conflicts", "failed required checks", "production/runtime/release/security/database scope", "ambiguous task boundary"], - hostAllowedToolsAfterReview: ["system gh pr merge", "GitHub UI merge/close"], - unideskCliMergeSupported: false, - degradedReason: "unsupported-command", + hostAllowedToolsAfterReview: ["bun scripts/cli.ts gh pr merge", "GitHub UI merge/close"], + unideskCliMergeSupported: true, }; } @@ -4267,11 +4277,11 @@ function prPreflightPolicy(repo: string, number: number): Record<string, unknown createsPr: false, comments: false, mergesPr: false, - mergeCommandSupported: false, - unsupportedMergeCommand: `bun scripts/cli.ts gh pr merge ${number} --repo ${repo}`, - unideskCliMergeSupported: false, + mergeCommandSupported: true, + mergeCommand: `bun scripts/cli.ts gh pr merge ${number} --repo ${repo} --merge`, + unideskCliMergeSupported: true, ordinaryRunnerFinalActionAllowed: true, - note: "This preflight only reads GitHub auth, PR metadata, mergeability, and status checks; the UniDesk REST CLI still never merges PRs.", + note: "This preflight only reads GitHub auth, PR metadata, mergeability, and status checks; use gh pr merge only after this command reports ready and the task boundary allows merge.", }; } @@ -4343,11 +4353,95 @@ function prCloseoutSummary( blockers, pending, commanderAction: readyForCommanderMerge - ? "review and merge through a repo-owned GitHub path when task boundaries allow; UniDesk REST gh pr merge remains unsupported" + ? "review and merge through bun scripts/cli.ts gh pr merge when task boundaries allow" : "resolve blockers or rerun after GitHub finishes computing mergeability/status checks", }; } +async function deleteHeadBranchAfterMerge(repo: string, token: string, pr: GitHubPullRequest): Promise<Record<string, unknown>> { + const { owner, name } = repoParts(repo); + const headRepo = pr.head?.repo?.full_name ?? null; + const headRef = pr.head?.ref ?? null; + if (headRepo !== repo || headRef === null || headRef.length === 0) { + return { + attempted: false, + skippedReason: "head-repo-differs-or-ref-missing", + headRepo, + headRef, + }; + } + const encodedRef = encodeURIComponent(`heads/${headRef}`); + const deleted = await githubRequest<unknown>(token, "DELETE", `/repos/${owner}/${name}/git/refs/${encodedRef}`); + if (isGitHubError(deleted)) { + return { + attempted: true, + ok: false, + headRepo, + headRef, + error: deleted, + }; + } + return { + attempted: true, + ok: true, + headRepo, + headRef, + }; +} + +async function prMerge(repo: string, token: string, number: number, options: GitHubOptions): Promise<GitHubCommandResult> { + const { owner, name } = repoParts(repo); + const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`); + if (isGitHubError(pr)) return commandError("pr merge", repo, pr, { number, phase: "fetch-pr" }); + const summary = prSummary(pr); + const metadata = await prGraphqlMetadata(repo, token, number); + if (isGitHubError(metadata)) return commandError("pr merge", repo, metadata, { number, phase: "fetch-pr-closeout-metadata", pullRequest: summary }); + const statusChecks = statusRollupSummary(repo, number, metadata.statusCheckRollup, false); + const mergeability = prCloseoutSummary(summary, prMetadataSummary(metadata), statusChecks); + if (mergeability.readyForCommanderMerge !== true) { + return validationError("pr merge", repo, "PR is not ready for merge; preflight blockers or pending states remain", { + number, + pullRequest: preflightPullRequestSummary(summary), + mergeability, + statusChecks, + closeoutMetadata: prCloseoutMetadata(metadata), + }); + } + if (options.dryRun) { + return { + ok: true, + command: "pr merge", + repo, + number, + dryRun: true, + wouldMerge: true, + method: options.mergeMethod, + deleteBranch: options.deleteBranch, + pullRequest: preflightPullRequestSummary(summary), + mergeability, + statusChecks, + }; + } + const merged = await githubRequest<Record<string, unknown>>(token, "PUT", `/repos/${owner}/${name}/pulls/${number}/merge`, { + merge_method: options.mergeMethod, + }); + if (isGitHubError(merged)) return commandError("pr merge", repo, merged, { number, phase: "merge", method: options.mergeMethod, pullRequest: summary }); + const after = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`); + if (isGitHubError(after)) return commandError("pr merge", repo, after, { number, phase: "fetch-after-merge", mergeResult: merged }); + const branchDeletion = options.deleteBranch ? await deleteHeadBranchAfterMerge(repo, token, after) : { attempted: false, skippedReason: "delete-branch-not-requested" }; + return { + ok: true, + command: "pr merge", + repo, + number, + method: options.mergeMethod, + mergeResult: merged, + pullRequest: prSummary(after), + branchDeletion, + rest: true, + }; +} + async function prPreflight(repo: string, number: number, commandName: "preflight" | "pr preflight" | "pr closeout", includeRaw: boolean): Promise<GitHubCommandResult> { const auth = await authStatus(repo); const authCapability = compactAuthCapability(auth); @@ -5807,6 +5901,7 @@ export function ghHelp(): unknown { "bun scripts/cli.ts gh pr comment create <number> --body-file <file>|--body <text> [--repo owner/name] [--dry-run]", "bun scripts/cli.ts gh pr comment delete <commentId> [--repo owner/name] [--dry-run]", "bun scripts/cli.ts gh pr close|reopen <number> [--repo owner/name] [--dry-run]", + "bun scripts/cli.ts gh pr merge <number> [--repo owner/name] [--merge|--squash|--rebase] [--delete-branch] [--dry-run]", "bun scripts/cli.ts gh pr delete <number> [unsupported: use close]", ], defaults: { repo: DEFAULT_REPO }, @@ -5842,7 +5937,7 @@ export function ghHelp(): unknown { "PR read is the canonical read path; view remains a compatibility alias. PR read/view accept owner/repo#number shorthand and --number N as a compatibility alias for the positional PR number; shorthand derives --repo unless an explicit conflicting --repo is supplied, which fails structurally with suggested commands. PR read/view supports REST closeout fields stateDetail, closed, closedAt, merged, mergedAt, mergeCommit, headRefName, and baseRefName; mergeable, mergeStateStatus, and statusCheckRollup are fetched through GitHub GraphQL only when requested or when --raw/--full requests full disclosure, and closeoutMetadata makes GraphQL errors plus UNKNOWN/null metadata explicit.", "PR list does not fetch mergeability or statusCheckRollup; request those closeout fields with gh pr view <number> --json headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup.", "PR preflight is a low-noise read-only closeout helper. It combines redacted auth capability, PR branch/state metadata, mergeability, mergeStateStatus, compact status check counts, and the explicit UniDesk REST CLI no-merge policy. Use --full or --raw to include all fetched status contexts.", - "PR create/edit/update/comment are safe-write operations with dry-run planning; merge is intentionally unsupported in this phase.", + "PR merge is a guarded write operation: it first reads closeout metadata, refuses non-open/draft/conflicting/non-clean/failed/pending PRs, then uses GitHub REST merge. Use --dry-run to see the exact merge plan without writing.", ], }; } @@ -6141,20 +6236,15 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult return prState(options.repo, token, number, sub === "close" ? "closed" : "open", false); } if (sub === "merge") { - return unsupportedCommand( - "pr merge", - options.repo, - "PR merge is intentionally unsupported by the UniDesk REST CLI; PR-bound GPT-5.5 runners may self-close/merge ordinary in-boundary PRs after checks using repo-owned GitHub paths, while high-risk or ambiguous PRs stay commander-reviewed.", - { - closeoutBoundary: { - ...prMergeBoundary(), - readOnlyCloseoutCommand: `bun scripts/cli.ts gh pr view ${third ?? "<number>"} --repo ${options.repo} --json ${PR_CLOSEOUT_VIEW_JSON}`, - }, - }, - ); + const number = parseNumberForCommand(options.repo, third, "pr merge"); + if (typeof number !== "number") return number; + const { token, probe } = resolveToken(true); + const missing = authRequired(options.repo, "pr merge", probe); + if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr merge", { present: false, source: null, ghFallbackAttempted: true }); + return prMerge(options.repo, token, number, options); } if (sub !== "list" && !isPrReadCommand(sub)) { - return unsupportedCommand(`pr ${sub ?? ""}`.trim(), options.repo, "PR supported commands are list, files, diff --stat, read/view, preflight/closeout, create, update/edit, close, reopen, comment create/delete, and unsupported merge/delete."); + return unsupportedCommand(`pr ${sub ?? ""}`.trim(), options.repo, "PR supported commands are list, files, diff --stat, read/view, preflight/closeout, create, update/edit, close, reopen, merge, comment create/delete, and unsupported delete."); } if (sub === "read" || sub === "view") { const resolved = resolveReadViewNumberReference("pr", sub, third, options, args); diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 47f768b1..1556f63d 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -1,6 +1,7 @@ import { ghHelp } from "./gh"; import { authBrokerHelp } from "./auth-broker"; import { hwlabHelp } from "./hwlab-cd"; +import { hwlabG14Help } from "./hwlab-g14"; export function rootHelp(): unknown { return { @@ -52,9 +53,10 @@ export function rootHelp(): unknown { { command: "dev-env validate|prewarm-images", description: "Validate D601 unidesk-dev guardrails or prewarm dev foundation images into native k3s containerd through a bounded async job." }, { command: "artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service", description: "Manage the D601 host-managed CNCF Distribution registry and run pull-only artifact CD for supported services, including D601 direct, k3s-managed, and code-queue dev-only consumers." }, { command: "auth-broker contract|health --dry-run|credential-request --dry-run|pr-preflight --dry-run", description: "Inspect the P0 Rust auth broker and CLI adapter contract without reading token values, writing GitHub, or starting services." }, - { command: "gh preflight|auth|issue|pr", description: "Run safe GitHub issue and PR CRUD/lifecycle operations through REST with body-file update replace/append, comment delete, token diagnostics, PR closeout preflight, hard delete unsupported, and merge blocked." }, + { command: "gh preflight|auth|issue|pr", description: "Run safe GitHub issue and PR CRUD/lifecycle operations through REST with body-file update replace/append, comment delete, token diagnostics, PR closeout preflight, hard delete unsupported, and guarded PR merge." }, { command: "commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run|prompt-lint --kind gpt55-pr", description: "Host Codex commander skeleton contract, no-daemon smoke plan, dry-run approval preview, and advisory GPT-5.5 PR prompt boundary lint without live bridges, message sends, or submit gating." }, - { command: "hwlab cd audit --env dev | hwlab cd status --env dev | hwlab cd apply --env dev --dry-run", description: "Bounded HWLAB DEV CD wrapper that calls HWLAB repo-owned scripts, forces D601 native k3s kubeconfig, refuses Docker Desktop control-plane signals, and exposes read-only post-recovery blocker classification." }, + { command: "hwlab g14 monitor-prs", description: "Start a fire-and-forget monitor that watches HWLAB PRs targeting G14, merges ready PRs through UniDesk gh, waits for G14 Tekton/GitOps/Argo DEV rollout, and appends the #7-indexed daily brief." }, + { command: "hwlab cd audit --env dev | hwlab cd status --env dev | hwlab cd apply --env dev --dry-run", description: "Legacy D601 HWLAB DEV CD wrapper kept for explicit old-path diagnostics; current HWLAB rollout uses G14 GitOps." }, { command: "code-agent-sandbox", description: "Independent Code Agent Sandbox service skeleton for adapter, mode, and credential-boundary diagnostics." }, { command: "schedule list|get|runs|run|retry-run|delete", description: "Manage backend-core scheduled tasks and run history; schedule run <id> supports --wait-ms N and retry-run reuses the failed run's schedule." }, { 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." }, @@ -520,7 +522,7 @@ function artifactRegistryHelp(): unknown { } export function staticNamespaceHelp(args: string[]): unknown | null { - const [top] = args; + const [top, sub] = args; if (!args.slice(1).some(isHelpToken)) return null; if (top === "config") return configHelp(); if (top === "microservice") return microserviceHelp(); @@ -537,6 +539,7 @@ export function staticNamespaceHelp(args: string[]): unknown | null { if (top === "artifact-registry") return artifactRegistryHelp(); if (top === "auth-broker") return authBrokerHelp(); if (top === "gh") return ghHelp(); + if (top === "hwlab" && sub === "g14") return hwlabG14Help(); if (top === "hwlab") return hwlabHelp(); return null; } diff --git a/scripts/src/hwlab-g14.ts b/scripts/src/hwlab-g14.ts new file mode 100644 index 00000000..a2f144c5 --- /dev/null +++ b/scripts/src/hwlab-g14.ts @@ -0,0 +1,687 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { repoRoot, rootPath, type Config } from "./config"; +import { runCommand } from "./command"; +import { startJob } from "./jobs"; + +const HWLAB_REPO = "pikasTech/HWLAB"; +const G14_SOURCE_BRANCH = "G14"; +const G14_PROVIDER = "G14"; +const G14_WORKSPACE = "/root/hwlab"; +const DEV_NAMESPACE = "hwlab-dev"; +const CI_NAMESPACE = "hwlab-ci"; +const ARGO_NAMESPACE = "argocd"; +const DEV_APP = "hwlab-g14-dev"; +const DEFAULT_INTERVAL_SECONDS = 600; +const DEFAULT_MAX_CYCLES = 0; +const DEFAULT_TIMEOUT_SECONDS = 1800; +const G14_BRIEF_INDEX_ISSUE = 7; +const BEIJING_OFFSET_MS = 8 * 60 * 60 * 1000; + +interface G14MonitorOptions { + intervalSeconds: number; + maxCycles: number; + once: boolean; + dryRun: boolean; + worker: boolean; + timeoutSeconds: number; +} + +interface G14RecordRolloutOptions { + prNumber: number; + sourceCommit?: string; + pipelineRun?: string; + gitopsRevision?: string; + mergedAt?: string; + pipelineSucceededAt?: string; + finishedAt?: string; + dryRun: boolean; +} + +interface CommandJsonResult { + ok: boolean; + command: string[]; + exitCode: number | null; + stdout: string; + stderr: string; + parsed: unknown | null; +} + +interface OpenPullRequest { + number: number; + title?: string; + url?: string; + baseRefName?: string; + headRefName?: string; + mergeable?: string | null; + mergeStateStatus?: string | null; + draft?: boolean; +} + +function parseOptions(args: string[]): G14MonitorOptions { + return { + intervalSeconds: positiveIntegerOption(args, "--interval-seconds", DEFAULT_INTERVAL_SECONDS, 86400), + maxCycles: positiveIntegerOption(args, "--max-cycles", DEFAULT_MAX_CYCLES, 100000), + once: args.includes("--once"), + dryRun: args.includes("--dry-run"), + worker: args.includes("--worker"), + timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", DEFAULT_TIMEOUT_SECONDS, 86400), + }; +} + +function optionValue(args: string[], name: string): string | undefined { + const index = args.indexOf(name); + if (index === -1) return undefined; + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new Error(`${name} requires a value`); + return value; +} + +function parseRecordRolloutOptions(args: string[]): G14RecordRolloutOptions { + const prRaw = optionValue(args, "--pr") ?? optionValue(args, "--number"); + const prNumber = Number(prRaw); + if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("record-rollout requires --pr <number>"); + return { + prNumber, + sourceCommit: optionValue(args, "--source-commit"), + pipelineRun: optionValue(args, "--pipeline-run"), + gitopsRevision: optionValue(args, "--gitops-revision"), + mergedAt: optionValue(args, "--merged-at"), + pipelineSucceededAt: optionValue(args, "--pipeline-succeeded-at"), + finishedAt: optionValue(args, "--finished-at"), + dryRun: args.includes("--dry-run"), + }; +} + +function positiveIntegerOption(args: string[], name: string, defaultValue: number, maxValue: number): number { + const index = args.indexOf(name); + if (index === -1) return defaultValue; + const raw = args[index + 1]; + const value = Number(raw); + if (!Number.isInteger(value) || value < 0) throw new Error(`${name} must be a non-negative integer`); + return Math.min(value, maxValue); +} + +function commandJson(command: string[], timeoutMs = 60_000): CommandJsonResult { + const result = runCommand(command, repoRoot, { timeoutMs }); + let parsed: unknown | null = null; + if (result.stdout.trim().length > 0) { + try { + parsed = JSON.parse(result.stdout) as unknown; + } catch { + parsed = null; + } + } + return { + ok: result.exitCode === 0, + command, + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + parsed, + }; +} + +function cliJson(args: string[], timeoutMs = 60_000): CommandJsonResult { + return commandJson(["bun", "scripts/cli.ts", ...args], timeoutMs); +} + +function record(value: unknown): Record<string, unknown> { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {}; +} + +function nested(value: unknown, keys: string[]): unknown { + let current = value; + for (const key of keys) current = record(current)[key]; + return current; +} + +function expandedParsedRoot(result: CommandJsonResult): Record<string, unknown> { + const dumpPath = nested(result.parsed, ["data", "dump", "path"]); + if (typeof dumpPath === "string" && existsSync(dumpPath)) { + try { + return record(JSON.parse(readFileSync(dumpPath, "utf8")) as unknown); + } catch { + return record(result.parsed); + } + } + return record(result.parsed); +} + +function commandData(result: CommandJsonResult): Record<string, unknown> { + return record(expandedParsedRoot(result).data); +} + +function isCommandSuccess(result: CommandJsonResult): boolean { + if (!result.ok) return false; + const topOk = record(result.parsed).ok; + if (topOk === false) return false; + const dataOk = nested(result.parsed, ["data", "ok"]); + return dataOk !== false; +} + +function extractPullRequests(result: CommandJsonResult): OpenPullRequest[] { + const prs = nested(result.parsed, ["data", "pullRequests"]); + if (!Array.isArray(prs)) return []; + return prs + .map((item) => record(item)) + .filter((item) => item.baseRefName === G14_SOURCE_BRANCH || record(item.base).ref === G14_SOURCE_BRANCH) + .map((item) => ({ + number: Number(item.number), + title: typeof item.title === "string" ? item.title : undefined, + url: typeof item.url === "string" ? item.url : undefined, + baseRefName: typeof item.baseRefName === "string" ? item.baseRefName : typeof record(item.base).ref === "string" ? record(item.base).ref as string : undefined, + headRefName: typeof item.headRefName === "string" ? item.headRefName : typeof record(item.head).ref === "string" ? record(item.head).ref as string : undefined, + mergeable: typeof item.mergeable === "string" ? item.mergeable : null, + mergeStateStatus: typeof item.mergeStateStatus === "string" ? item.mergeStateStatus : null, + draft: item.draft === true, + })) + .filter((item) => Number.isInteger(item.number) && item.number > 0); +} + +function sleep(ms: number): Promise<void> { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function printEvent(event: string, data: Record<string, unknown> = {}): void { + process.stdout.write(`${JSON.stringify({ event, at: new Date().toISOString(), ...data })}\n`); +} + +function shortSha(sha: string): string { + return sha.slice(0, 12); +} + +function precheckWorkspace(): CommandJsonResult { + return cliJson(["ssh", `${G14_PROVIDER}:${G14_WORKSPACE}`, "script", "--", "pwd; git fetch origin G14 --prune; git status --short --branch; git remote -v | sed -n '1,4p'"], 120_000); +} + +function listOpenG14PullRequests(): CommandJsonResult { + return cliJson(["gh", "pr", "list", "--repo", HWLAB_REPO, "--state", "open", "--limit", "30", "--json", "number,title,state,url,head,base,draft,headRefName,baseRefName"], 60_000); +} + +function preflightPullRequest(number: number): CommandJsonResult { + return cliJson(["gh", "pr", "preflight", String(number), "--repo", HWLAB_REPO], 80_000); +} + +function mergePullRequest(number: number, dryRun: boolean): CommandJsonResult { + return cliJson(["gh", "pr", "merge", String(number), "--repo", HWLAB_REPO, "--merge", "--delete-branch", ...(dryRun ? ["--dry-run"] : [])], 100_000); +} + +function getG14Head(): string | null { + const result = cliJson(["ssh", `${G14_PROVIDER}:${G14_WORKSPACE}`, "script", "--", "git fetch origin G14 --prune >/dev/null 2>&1; git rev-parse origin/G14"], 120_000); + if (!isCommandSuccess(result)) return null; + const output = String(nested(result.parsed, ["data", "stdout"]) ?? result.stdout).trim(); + const match = /[0-9a-f]{40}/iu.exec(output); + return match?.[0] ?? null; +} + +function refreshArgoDev(): void { + cliJson(["ssh", `${G14_PROVIDER}:k3s`, "kubectl", "annotate", "application", "-n", ARGO_NAMESPACE, DEV_APP, "argocd.argoproj.io/refresh=hard", "--overwrite"], 60_000); +} + +function getPipelineStatus(sourceCommit: string): CommandJsonResult { + return cliJson(["ssh", `${G14_PROVIDER}:k3s`, "kubectl", "get", "pipelinerun", "-n", CI_NAMESPACE, `hwlab-g14-ci-poll-${shortSha(sourceCommit)}`, "-o", "jsonpath={.status.conditions[0].status}{\"\\n\"}{.status.conditions[0].reason}{\"\\n\"}{.status.conditions[0].message}{\"\\n\"}"], 60_000); +} + +function getArgoStatus(): CommandJsonResult { + return cliJson(["ssh", `${G14_PROVIDER}:k3s`, "kubectl", "get", "application", "-n", ARGO_NAMESPACE, DEV_APP, "-o", "jsonpath={.status.sync.revision}{\"\\n\"}{.status.sync.status}{\"\\n\"}{.status.health.status}{\"\\n\"}{.status.operationState.phase}{\"\\n\"}{.status.operationState.syncResult.revision}{\"\\n\"}"], 60_000); +} + +function getDevWorkloads(): CommandJsonResult { + return cliJson(["ssh", `${G14_PROVIDER}:k3s`, "kubectl", "get", "deploy,statefulset,pod", "-n", DEV_NAMESPACE, "-o", "wide"], 60_000); +} + +function getLiveHealth(): CommandJsonResult { + return commandJson(["curl", "-fsS", "--max-time", "20", "http://74.48.78.17:17667/health/live"], 30_000); +} + +function statusText(result: CommandJsonResult): string { + return String(nested(result.parsed, ["data", "stdout"]) ?? result.stdout).trim(); +} + +function workloadReadiness(workloadsText: string, commandOk: boolean): Record<string, unknown> { + const blockers: string[] = []; + const ignoredPods: string[] = []; + const readyWorkloads: string[] = []; + const allowedZeroDeployments = new Set(["deployment.apps/hwlab-gateway", "deployment.apps/hwlab-tunnel-client"]); + for (const line of workloadsText.split(/\r?\n/u)) { + const trimmed = line.trim(); + if (trimmed.length === 0 || trimmed.startsWith("NAME ")) continue; + const fields = trimmed.split(/\s+/u); + const name = fields[0] ?? ""; + const ready = fields[1] ?? ""; + if (name.startsWith("deployment.apps/") || name.startsWith("statefulset.apps/")) { + if (ready === "0/0" && allowedZeroDeployments.has(name)) { + readyWorkloads.push(`${name}:${ready}:scaled-zero`); + continue; + } + const match = /^(\d+)\/(\d+)$/u.exec(ready); + if (match === null) { + blockers.push(`${name}:unparseable-ready:${ready}`); + continue; + } + const readyCount = Number(match[1]); + const desiredCount = Number(match[2]); + if (desiredCount > 0 && readyCount < desiredCount) blockers.push(`${name}:not-ready:${ready}`); + else readyWorkloads.push(`${name}:${ready}`); + continue; + } + if (name.startsWith("pod/")) { + const status = fields[2] ?? ""; + if (status === "Completed" || status === "Succeeded") ignoredPods.push(`${name}:${status}`); + if (/^(CrashLoopBackOff|ImagePullBackOff|ErrImagePull|CreateContainerConfigError|CreateContainerError|RunContainerError)$/u.test(status)) { + blockers.push(`${name}:pod-status:${status}`); + } + } + } + return { + ready: commandOk && blockers.length === 0, + blockers, + readyWorkloads: readyWorkloads.slice(0, 20), + ignoredPods: ignoredPods.slice(0, 20), + }; +} + +function beijingParts(date = new Date()): { date: string; time: string; iso: string } { + const shifted = new Date(date.getTime() + BEIJING_OFFSET_MS).toISOString(); + return { date: shifted.slice(0, 10), time: shifted.slice(11, 19), iso: shifted }; +} + +function durationSeconds(start?: string | null, end?: string | null): number | null { + if (start === undefined || start === null || end === undefined || end === null) return null; + const startMs = Date.parse(start); + const endMs = Date.parse(end); + if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs < startMs) return null; + return Math.round((endMs - startMs) / 1000); +} + +function formatDuration(seconds: number | null): string { + if (seconds === null) return "n/a"; + const minutes = Math.floor(seconds / 60); + const rest = seconds % 60; + if (minutes === 0) return `${rest}s`; + return `${minutes}m${String(rest).padStart(2, "0")}s`; +} + +function readIssue(issueNumber: number): CommandJsonResult { + return cliJson(["gh", "issue", "read", String(issueNumber), "--repo", HWLAB_REPO, "--json", "title,body,state,updatedAt", "--raw"], 80_000); +} + +function issueFromRead(result: CommandJsonResult): Record<string, unknown> { + return record(commandData(result).issue); +} + +function parseBriefIssueFromIndex(indexBody: string, date: string): { number: number; url: string } | null { + const escapedDate = date.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + const linePattern = new RegExp(`^\\|\\s*${escapedDate}\\s*\\|([^\\n]+)$`, "mu"); + const line = linePattern.exec(indexBody)?.[0]; + if (line === undefined) return null; + const match = /\[#(\d+)\]\((https:\/\/github\.com\/pikasTech\/HWLAB\/issues\/\d+)\)/u.exec(line); + if (match === null) return null; + return { number: Number(match[1]), url: match[2] }; +} + +function dailyBriefTitle(date: string): string { + return `${date} 指挥简报(北京时间)`; +} + +function writeStateFile(name: string, content: string): string { + const dir = rootPath(".state", "hwlab-g14"); + mkdirSync(dir, { recursive: true }); + const path = join(dir, name); + writeFileSync(path, content, "utf8"); + return path; +} + +function listIssues(): CommandJsonResult { + return cliJson(["gh", "issue", "list", "--repo", HWLAB_REPO, "--state", "open", "--limit", "100", "--json", "number,title,state,url"], 80_000); +} + +function findBriefIssueInList(date: string): { number: number; url: string } | null { + const listed = listIssues(); + if (!isCommandSuccess(listed)) return null; + const issues = commandData(listed).issues; + if (!Array.isArray(issues)) return null; + const title = dailyBriefTitle(date); + const match = issues.map((item) => record(item)).find((issue) => issue.title === title); + if (match === undefined) return null; + return { number: Number(match.number), url: String(match.url) }; +} + +function newDailyBriefBody(date: string): string { + return [ + `# ${dailyBriefTitle(date)}`, + "", + "## 当日更新", + "", + "## 常驻观察与长期建议", + "", + "- G14 HWLAB source truth 固定为 G14 `/root/hwlab` 与 `origin/G14`;DEV rollout 只接受 G14 Tekton/GitOps/Argo 和公网 health 证据。", + "- GitHub issue/PR 写操作必须走 UniDesk `gh` 子命令;G14 k3s 操作必须走 UniDesk `ssh G14:k3s`。", + ].join("\n"); +} + +function insertBriefIndexRow(indexBody: string, date: string, brief: { number: number; url: string }): string { + if (parseBriefIssueFromIndex(indexBody, date) !== null) return indexBody; + const row = `| ${date} | [#${brief.number}](${brief.url}) | 已创建当日 G14 DEV rollout 自动简报入口;后续每次 DEV 上线记录 CI/CD 耗时和上线 changelog。 | 继续监控 HWLAB base=G14 PR,ready 后自动合并并滚动到 G14 DEV。 |`; + const marker = "### 指挥简报索引"; + const markerIndex = indexBody.indexOf(marker); + if (markerIndex === -1) return `${indexBody.trimEnd()}\n\n${marker}\n\n| 日期(北京时间) | 指挥简报 issue | 当日推进 | 下一步计划 |\n| --- | --- | --- | --- |\n${row}\n`; + const afterMarker = indexBody.slice(markerIndex); + const separator = "| --- | --- | --- | --- |"; + const separatorInBody = afterMarker.indexOf(separator); + if (separatorInBody === -1) return `${indexBody.slice(0, markerIndex + marker.length)}\n\n| 日期(北京时间) | 指挥简报 issue | 当日推进 | 下一步计划 |\n| --- | --- | --- | --- |\n${row}\n${indexBody.slice(markerIndex + marker.length)}`; + const insertAt = markerIndex + separatorInBody + separator.length; + return `${indexBody.slice(0, insertAt)}\n${row}${indexBody.slice(insertAt)}`; +} + +function ensureDailyBriefIssue(date: string, dryRun: boolean): Record<string, unknown> { + const indexRead = readIssue(G14_BRIEF_INDEX_ISSUE); + if (!isCommandSuccess(indexRead)) return { ok: false, phase: "read-index", indexRead }; + const indexIssue = issueFromRead(indexRead); + const indexBody = String(indexIssue.body ?? ""); + let brief = parseBriefIssueFromIndex(indexBody, date) ?? findBriefIssueInList(date); + const actions: Record<string, unknown>[] = []; + if (brief === null) { + const bodyPath = writeStateFile(`daily-brief-${date}.md`, `${newDailyBriefBody(date)}\n`); + if (dryRun) { + brief = { number: 0, url: `https://github.com/pikasTech/HWLAB/issues/new?title=${encodeURIComponent(dailyBriefTitle(date))}` }; + actions.push({ action: "would-create-daily-brief", title: dailyBriefTitle(date), bodyPath }); + } else { + const created = cliJson(["gh", "issue", "create", "--repo", HWLAB_REPO, "--title", dailyBriefTitle(date), "--body-file", bodyPath], 80_000); + if (!isCommandSuccess(created)) return { ok: false, phase: "create-daily-brief", created }; + const createdIssue = record(commandData(created).issue); + brief = { number: Number(createdIssue.number), url: String(createdIssue.url) }; + actions.push({ action: "created-daily-brief", issue: brief, bodyPath }); + } + } + if (brief.number > 0 && parseBriefIssueFromIndex(indexBody, date) === null) { + const nextBody = insertBriefIndexRow(indexBody, date, brief); + const bodyPath = writeStateFile(`issue-${G14_BRIEF_INDEX_ISSUE}-brief-index-${date}.md`, `${nextBody.trimEnd()}\n`); + if (dryRun) { + actions.push({ action: "would-update-brief-index", issue: G14_BRIEF_INDEX_ISSUE, bodyPath }); + } else { + const bodySha = String(indexIssue.bodySha ?? ""); + const update = cliJson(["gh", "issue", "update", String(G14_BRIEF_INDEX_ISSUE), "--repo", HWLAB_REPO, "--mode", "replace", "--body-file", bodyPath, ...(bodySha.length > 0 ? ["--expect-body-sha", bodySha] : [])], 100_000); + if (!isCommandSuccess(update)) return { ok: false, phase: "update-brief-index", update, bodyPath }; + actions.push({ action: "updated-brief-index", issue: G14_BRIEF_INDEX_ISSUE, bodyPath }); + } + } + return { ok: true, date, brief, actions }; +} + +function readPullRequest(number: number): CommandJsonResult { + return cliJson(["gh", "pr", "read", String(number), "--repo", HWLAB_REPO, "--json", "title,url,stateDetail,merged,mergedAt,mergeCommit,headRefName,baseRefName"], 80_000); +} + +function readPullRequestFiles(number: number): CommandJsonResult { + return cliJson(["gh", "pr", "files", String(number), "--repo", HWLAB_REPO, "--limit", "30"], 80_000); +} + +function summarizePrFiles(filesResult: CommandJsonResult): Record<string, unknown> { + if (!isCommandSuccess(filesResult)) return { ok: false, filesResult }; + const data = commandData(filesResult); + const files = Array.isArray(data.files) ? data.files.map((item) => record(item)) : []; + return { + ok: true, + summary: data.summary ?? null, + keyFiles: files.slice(0, 8).map((file) => `${file.status ?? "changed"} ${file.filename ?? ""}`), + }; +} + +function mergeCommitFromPr(prData: Record<string, unknown>): string | null { + const mergeCommit = record(record(prData.json).mergeCommit); + const fromJson = mergeCommit.oid; + if (typeof fromJson === "string") return fromJson; + const fromSummary = record(record(prData.pullRequest).mergeCommit).oid; + return typeof fromSummary === "string" ? fromSummary : null; +} + +function currentGitopsRevision(): string | null { + const argo = getArgoStatus(); + if (!isCommandSuccess(argo)) return null; + return statusText(argo).split(/\r?\n/u)[0] ?? null; +} + +function rolloutRecordBody(input: { + pr: OpenPullRequest; + prData: Record<string, unknown>; + fileSummary: Record<string, unknown>; + sourceCommit: string; + pipelineRun: string; + gitopsRevision: string | null; + mergedAt: string | null; + pipelineSucceededAt: string | null; + finishedAt: string | null; + rollout: Record<string, unknown>; +}): string { + const now = beijingParts(); + const title = String(record(input.prData.json).title ?? input.pr.title ?? `PR #${input.pr.number}`); + const url = String(record(input.prData.json).url ?? input.pr.url ?? `https://github.com/pikasTech/HWLAB/pull/${input.pr.number}`); + const summary = record(input.fileSummary.summary); + const keyFiles = Array.isArray(input.fileSummary.keyFiles) ? input.fileSummary.keyFiles.map(String) : []; + const mergeToPipeline = durationSeconds(input.mergedAt, input.pipelineSucceededAt); + const pipelineToDev = durationSeconds(input.pipelineSucceededAt, input.finishedAt); + const mergeToDev = durationSeconds(input.mergedAt, input.finishedAt); + return [ + "", + "", + `## 更新 ${now.date} ${now.time.slice(0, 5)} 北京时间`, + "", + `### G14 DEV rollout:PR #${input.pr.number}`, + "", + `- PR: [#${input.pr.number} ${title}](${url})`, + `- 合并 commit: \`${input.sourceCommit}\``, + `- GitOps revision: \`${input.gitopsRevision ?? "unknown"}\``, + `- PipelineRun: \`${input.pipelineRun}\``, + "- CI/CD 耗时:", + ` - merge -> pipeline succeeded: ${formatDuration(mergeToPipeline)}`, + ` - pipeline succeeded -> DEV Healthy: ${formatDuration(pipelineToDev)}`, + ` - merge -> DEV Healthy: ${formatDuration(mergeToDev)}`, + "- 上线 changelog:", + ` - ${title}`, + ` - changed files: ${String(summary.files ?? "n/a")}; +${String(summary.additions ?? "n/a")} / -${String(summary.deletions ?? "n/a")}; commits: ${String(summary.commits ?? "n/a")}`, + ...keyFiles.map((file) => ` - ${file}`), + "- DEV 验证:", + ` - Tekton: ${String(input.rollout.pipelineText ?? "Succeeded").split(/\r?\n/u).join(" / ")}`, + ` - Argo: ${String(input.rollout.argoText ?? "Synced / Healthy").split(/\r?\n/u).slice(0, 5).join(" / ")}`, + ` - health/live: ${input.rollout.healthOk === false ? "not-ok" : "ok"}`, + " - workload readiness: Deployment/StatefulSet ready;历史 Completed smoke/debug pod 不作为 DEV rollout blocker。", + ].join("\n"); +} + +function appendRolloutBrief(options: G14RecordRolloutOptions, rollout: Record<string, unknown> = {}): Record<string, unknown> { + const now = beijingParts(); + const ensured = ensureDailyBriefIssue(now.date, options.dryRun); + if (record(ensured).ok !== true) return ensured; + const brief = record(record(ensured).brief); + const prRead = readPullRequest(options.prNumber); + if (!isCommandSuccess(prRead)) return { ok: false, phase: "read-pr", prRead }; + const prData = commandData(prRead); + const sourceCommit = options.sourceCommit ?? mergeCommitFromPr(prData); + if (sourceCommit === null || sourceCommit === undefined) return { ok: false, phase: "source-commit", message: "source commit unavailable", prData }; + const pipelineRun = options.pipelineRun ?? `hwlab-g14-ci-poll-${shortSha(sourceCommit)}`; + const files = summarizePrFiles(readPullRequestFiles(options.prNumber)); + const rolloutGitopsRevision = typeof rollout.gitopsRevision === "string" && rollout.gitopsRevision.length > 0 ? rollout.gitopsRevision : null; + const gitopsRevision = options.gitopsRevision ?? rolloutGitopsRevision ?? currentGitopsRevision(); + const prJson = record(prData.json); + const prSummary = record(prData.pullRequest); + const prMergedAt = typeof prJson.mergedAt === "string" && prJson.mergedAt.length > 0 + ? prJson.mergedAt + : typeof prSummary.mergedAt === "string" && prSummary.mergedAt.length > 0 + ? prSummary.mergedAt + : null; + const rolloutPipelineSucceededAt = typeof rollout.pipelineSucceededAt === "string" && rollout.pipelineSucceededAt.length > 0 ? rollout.pipelineSucceededAt : null; + const rolloutFinishedAt = typeof rollout.finishedAt === "string" && rollout.finishedAt.length > 0 ? rollout.finishedAt : null; + const mergedAt = options.mergedAt ?? prMergedAt; + const pipelineSucceededAt = options.pipelineSucceededAt ?? rolloutPipelineSucceededAt; + const finishedAt = options.finishedAt ?? rolloutFinishedAt; + const pr: OpenPullRequest = { + number: options.prNumber, + title: String(record(prData.json).title ?? ""), + url: String(record(prData.json).url ?? ""), + baseRefName: String(record(prData.json).baseRefName ?? ""), + headRefName: String(record(prData.json).headRefName ?? ""), + }; + const body = rolloutRecordBody({ pr, prData, fileSummary: files, sourceCommit, pipelineRun, gitopsRevision, mergedAt, pipelineSucceededAt, finishedAt, rollout }); + if (Number(brief.number) <= 0 || options.dryRun) { + return { ok: true, dryRun: true, date: now.date, brief, wouldAppend: { bodyPreview: body.slice(0, 1000), bodyChars: body.length }, ensured }; + } + const currentBrief = readIssue(Number(brief.number)); + if (!isCommandSuccess(currentBrief)) return { ok: false, phase: "read-daily-brief", currentBrief, brief }; + const currentBody = String(issueFromRead(currentBrief).body ?? ""); + if (currentBody.includes(`PR #${options.prNumber}`) && currentBody.includes(sourceCommit)) { + return { ok: true, skipped: true, reason: "rollout-brief-already-recorded", brief, sourceCommit, ensured }; + } + const bodyPath = writeStateFile(`rollout-pr-${options.prNumber}-${shortSha(sourceCommit)}.md`, `${body}\n`); + const update = cliJson(["gh", "issue", "update", String(brief.number), "--repo", HWLAB_REPO, "--mode", "append", "--body-file", bodyPath, "--body-profile", "commander-brief"], 100_000); + if (!isCommandSuccess(update)) return { ok: false, phase: "append-rollout-brief", update, bodyPath, brief, ensured }; + return { ok: true, date: now.date, brief, sourceCommit, pipelineRun, gitopsRevision, bodyPath, update: commandData(update), ensured }; +} + +async function waitForG14Dev(sourceCommit: string, timeoutSeconds: number): Promise<Record<string, unknown>> { + const started = Date.now(); + const startedAt = new Date(started).toISOString(); + let pipelineText = ""; + let argoText = ""; + let healthOk = false; + let workloadsReady: Record<string, unknown> = { ready: false }; + let pipelineSucceededAt: string | null = null; + while (Date.now() - started < timeoutSeconds * 1000) { + const pipeline = getPipelineStatus(sourceCommit); + pipelineText = statusText(pipeline); + printEvent("g14.pipeline.status", { sourceCommit, pipelineRun: `hwlab-g14-ci-poll-${shortSha(sourceCommit)}`, text: pipelineText.slice(0, 500) }); + if (!pipelineText.startsWith("True\nSucceeded")) { + if (pipelineText.startsWith("False\n")) return { ok: false, phase: "pipeline", sourceCommit, pipelineText }; + await sleep(30_000); + continue; + } + pipelineSucceededAt ??= new Date().toISOString(); + refreshArgoDev(); + const argo = getArgoStatus(); + argoText = statusText(argo); + printEvent("g14.argo.status", { text: argoText.slice(0, 500) }); + const argoLines = argoText.split(/\r?\n/u); + const synced = argoLines[1] === "Synced"; + const healthy = argoLines[2] === "Healthy"; + const gitopsRevision = argoLines[0] ?? null; + const workloads = getDevWorkloads(); + const workloadsText = statusText(workloads); + workloadsReady = workloadReadiness(workloadsText, isCommandSuccess(workloads)); + const health = getLiveHealth(); + healthOk = health.ok && /"status"\s*:\s*"ok"/u.test(health.stdout); + if (synced && healthy && record(workloadsReady).ready === true && healthOk) { + const finishedAt = new Date().toISOString(); + return { ok: true, sourceCommit, gitopsRevision, pipelineRun: `hwlab-g14-ci-poll-${shortSha(sourceCommit)}`, pipelineText, argoText, startedAt, pipelineSucceededAt, finishedAt, workloadsReady, healthOk }; + } + await sleep(30_000); + } + return { ok: false, phase: "timeout", sourceCommit, pipelineText, argoText, startedAt, pipelineSucceededAt, workloadsReady, healthOk, timeoutSeconds }; +} + +async function monitorCycle(options: G14MonitorOptions, cycle: number): Promise<Record<string, unknown>> { + printEvent("g14.monitor.cycle.start", { cycle, dryRun: options.dryRun }); + const precheck = precheckWorkspace(); + if (!isCommandSuccess(precheck)) return { ok: false, cycle, phase: "workspace-precheck", precheck }; + const listed = listOpenG14PullRequests(); + if (!isCommandSuccess(listed)) return { ok: false, cycle, phase: "list-prs", listed }; + const prs = extractPullRequests(listed); + printEvent("g14.monitor.prs", { cycle, count: prs.length, pullRequests: prs }); + if (prs.length === 0) return { ok: true, cycle, action: "none", pullRequests: [] }; + const merged: unknown[] = []; + for (const pr of prs) { + const preflight = preflightPullRequest(pr.number); + printEvent("g14.pr.preflight", { cycle, number: pr.number, ok: isCommandSuccess(preflight) }); + if (!isCommandSuccess(preflight) || nested(preflight.parsed, ["data", "mergeability", "readyForCommanderMerge"]) !== true) { + return { ok: false, cycle, phase: "pr-preflight", pullRequest: pr, preflight }; + } + const merge = mergePullRequest(pr.number, options.dryRun); + printEvent("g14.pr.merge", { cycle, number: pr.number, dryRun: options.dryRun, ok: isCommandSuccess(merge) }); + if (!isCommandSuccess(merge)) return { ok: false, cycle, phase: "pr-merge", pullRequest: pr, merge }; + const sourceCommit = getG14Head(); + const rollout = options.dryRun || sourceCommit === null ? { skipped: true, dryRun: options.dryRun, sourceCommit } : await waitForG14Dev(sourceCommit, options.timeoutSeconds); + const brief = options.dryRun || sourceCommit === null || record(rollout).ok !== true + ? { skipped: true, dryRun: options.dryRun } + : appendRolloutBrief({ prNumber: pr.number, sourceCommit, dryRun: false }, rollout); + printEvent("g14.rollout.brief", { cycle, number: pr.number, ok: record(brief).ok, skipped: record(brief).skipped ?? false, brief: record(brief).brief ?? null }); + merged.push({ pullRequest: pr, merge: commandData(merge), sourceCommit, rollout, brief }); + if (record(rollout).ok === false) return { ok: false, cycle, phase: "rollout", pullRequest: pr, sourceCommit, rollout, merged }; + if (record(brief).ok === false) return { ok: false, cycle, phase: "rollout-brief", pullRequest: pr, sourceCommit, rollout, brief, merged }; + } + return { ok: true, cycle, action: options.dryRun ? "dry-run-merge" : "merged-and-rolled-dev", merged }; +} + +async function runMonitorWorker(options: G14MonitorOptions): Promise<Record<string, unknown>> { + const maxCycles = options.once ? 1 : options.maxCycles; + let cycle = 0; + const results: unknown[] = []; + while (maxCycles === 0 || cycle < maxCycles) { + cycle += 1; + const result = await monitorCycle(options, cycle); + results.push(result); + printEvent("g14.monitor.cycle.done", { cycle, ok: record(result).ok, action: record(result).action ?? null, phase: record(result).phase ?? null }); + if (record(result).ok !== true) return { ok: false, cycles: cycle, lastResult: result, results }; + if (options.once || record(result).action !== "none") return { ok: true, cycles: cycle, lastResult: result, results }; + printEvent("g14.monitor.sleep", { cycle, intervalSeconds: options.intervalSeconds }); + await sleep(options.intervalSeconds * 1000); + } + return { ok: true, cycles: cycle, results }; +} + +export function hwlabG14Help(): Record<string, unknown> { + return { + command: "hwlab g14", + output: "json", + usage: [ + "bun scripts/cli.ts hwlab g14 monitor-prs", + "bun scripts/cli.ts hwlab g14 monitor-prs --once --dry-run", + "bun scripts/cli.ts hwlab g14 record-rollout --pr <number> [--source-commit sha]", + "bun scripts/cli.ts job status <jobId> --tail-bytes 30000", + ], + description: "G14 HWLAB PR monitor and DEV rollout command. The public command starts a fire-and-forget job; the worker uses UniDesk gh and ssh routes for every GitHub and k3s operation, then appends the rollout record to the #7-indexed daily brief.", + defaults: { + repo: HWLAB_REPO, + base: G14_SOURCE_BRANCH, + provider: G14_PROVIDER, + workspace: G14_WORKSPACE, + intervalSeconds: DEFAULT_INTERVAL_SECONDS, + devApplication: DEV_APP, + briefIndexIssue: G14_BRIEF_INDEX_ISSUE, + }, + }; +} + +export async function runHwlabG14Command(_config: Config, args: string[]): Promise<Record<string, unknown>> { + if (args.length === 0 || args.includes("--help") || args.includes("-h")) return { ok: true, ...hwlabG14Help() }; + const [action] = args; + if (action === "record-rollout") { + const options = parseRecordRolloutOptions(args.slice(1)); + return appendRolloutBrief(options); + } + if (action !== "monitor-prs") { + return { ok: false, command: `hwlab g14 ${action ?? ""}`.trim(), degradedReason: "unsupported-command", message: "supported commands: hwlab g14 monitor-prs, hwlab g14 record-rollout" }; + } + const options = parseOptions(args.slice(1)); + if (options.worker) return runMonitorWorker(options); + const command = ["bun", "scripts/cli.ts", "hwlab", "g14", "monitor-prs", "--worker", "--interval-seconds", String(options.intervalSeconds), "--timeout-seconds", String(options.timeoutSeconds), ...(options.once ? ["--once"] : []), ...(options.dryRun ? ["--dry-run"] : []), ...(options.maxCycles > 0 ? ["--max-cycles", String(options.maxCycles)] : [])]; + const job = startJob("hwlab_g14_pr_monitor", command, `Monitor ${HWLAB_REPO} PRs targeting ${G14_SOURCE_BRANCH} and roll merged changes to G14 DEV`); + const statusCommand = `bun scripts/cli.ts job status ${job.id} --tail-bytes 30000`; + const stateDir = rootPath(".state", "hwlab-g14"); + mkdirSync(stateDir, { recursive: true }); + const latestPath = join(stateDir, "latest-monitor-job.json"); + const previousLatest = existsSync(latestPath) ? readFileSync(latestPath, "utf8") : null; + writeFileSync(latestPath, `${JSON.stringify({ jobId: job.id, createdAt: job.createdAt, statusCommand }, null, 2)}\n`, "utf8"); + return { + ok: true, + command: "hwlab g14 monitor-prs", + mode: "async-job", + job, + statusCommand, + latestPath, + previousLatest, + next: { + status: statusCommand, + tail: `tail -f ${job.stdoutFile}`, + }, + }; +}