diff --git a/.agents/skills/unidesk-code-queue/SKILL.md b/.agents/skills/unidesk-code-queue/SKILL.md index f6977563..3ec9b518 100644 --- a/.agents/skills/unidesk-code-queue/SKILL.md +++ b/.agents/skills/unidesk-code-queue/SKILL.md @@ -1,11 +1,11 @@ --- name: unidesk-code-queue -description: UniDesk AgentRun-backed Code Queue CLI — Skill(cli-spec)。legacy `codex` 子命令只保留历史只读/残留停止/prompt-lint;新任务提交、Aipod/Artificer 派单、steer/send、events/logs/result、ack/cancel、dispatch、run/command/runner 状态 drill-down 和 HWLAB Code Agent/CaseRun follow-up 必须使用 `agentrun get|describe|events|logs|result|ack|cancel|dispatch|create|apply|steer|send` 资源原语。用户提到 codex、Code Queue、submit、steer、resume、tasks、unread、code-queue、aipod、Artificer、HWLAB Code Agent 时使用。 +description: UniDesk AgentRun-backed Code Queue CLI — Skill(cli-spec)。legacy `codex` 子命令只保留历史只读和残留停止;新任务提交、Aipod/Artificer 派单、steer/send、events/logs/result、ack/cancel、dispatch、run/command/runner 状态 drill-down 和 HWLAB Code Agent/CaseRun follow-up 必须使用 `agentrun get|describe|events|logs|result|ack|cancel|dispatch|create|apply|steer|send` 资源原语。用户提到 codex、Code Queue、submit、steer、resume、tasks、unread、code-queue、aipod、Artificer、HWLAB Code Agent 时使用。 --- # UniDesk Code Queue / AgentRun CLI -旧 Code Queue 已冻结新任务和写入口。`bun scripts/cli.ts codex ...` 现在只作为历史归档、只读排障、残留任务停止和 prompt-lint 入口;新的指挥官派单、Aipod/Artificer 执行、events/logs/result、ack/cancel、dispatch、steer/send 必须走 AgentRun 资源原语,并按 cli-spec 渐进披露。UniDesk 是 render-only client:默认输出是低噪声 human 表格/摘要,脚本读取显式使用 `-o json|yaml` 的稳定客户端 schema,`--raw` 只用于查看直连 AgentRun REST envelope。 +旧 Code Queue 已冻结新任务和写入口。`bun scripts/cli.ts codex ...` 现在只作为历史归档、只读排障和残留任务停止入口;新的指挥官派单、Aipod/Artificer 执行、events/logs/result、ack/cancel、dispatch、steer/send 必须走 AgentRun 资源原语,并按 cli-spec 渐进披露。UniDesk 是 render-only client:默认输出是低噪声 human 表格/摘要,脚本读取显式使用 `-o json|yaml` 的稳定客户端 schema,`--raw` 只用于查看直连 AgentRun REST envelope。 **固定入口前缀**: `cd /root/unidesk && bun scripts/cli.ts agentrun ...` @@ -211,11 +211,3 @@ bun scripts/cli.ts codex pr-preflight [--remote] [--push-dry-run ...] [--pr-crea ``` --- - -## Prompt Lint - -```bash -bun scripts/cli.ts codex prompt-lint [prompt|--prompt-file path|--prompt-stdin] -``` - -AgentRun 派单或 steer 前的本地 dry-run lint,返回分级(`read-only|live-read|live-mutating`)和缺失项。它只用于 AgentRun payload 入队前的 prompt 审查,不提交旧 Code Queue 任务。 diff --git a/AGENTS.md b/AGENTS.md index db24dd9b..051b26f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,11 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文件是项目顶级索引,也承担 `scripts/cli.ts` 的 CLI 使用说明入口。 +## P0 最高优先级:CLI 验证最小化规则 + +- P0: `scripts/cli.ts`、`scripts/src/**`、`trans`、`tran` 和轻量 helper 的 CLI 改动,除非用户明确要求,否则不做单元测试、合同测试或新增测试脚本;默认最多只做语法检查和必要的帮助/命令形态人工确认。 +- P0: 历史合同测试和 prompt lint 属于过度门禁,不再作为 CLI 变更的默认验证、派单前置或关闭依据;发现旧入口阻碍最新目标时直接删除,不做兼容保留。 + ## P0 最高优先级:自有配置 YAML 优先规则 - P0: UniDesk 自有配置一律优先使用 YAML(`.yaml`/`.yml`),包括 `config/` 下的运行面、平台基础设施、节点/lane、部署参数和可调版本配置;除非外部工具硬性要求 JSON/TOML/ENV 等格式,禁止新增 JSON 作为 UniDesk 自有配置真相。 @@ -38,7 +43,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - P0: 用户最新明确要求优先于旧测试、旧门禁、旧预检、旧断言、旧 guard 和旧 gate;开发过程中遇到任何阻碍最新目标的旧测试、旧门禁、旧预检或旧断言,一律拆除,禁止继续维护、修补、叠例外或用兼容路径绕行。 - P0: 短连接 CLI、临时工具、文档和轻量 helper 不套用常驻服务、镜像、Job、GitOps、PR、CI/CD 或重型发布流程的旧门禁;如果旧流程要求与最新架构定位冲突,以最新架构定位为准并删除旧流程入口。 - P0: 任何测试、预检或自检只允许表达当前最新目标行为;旧历史断言不得作为回归保护保留,避免把旧路线固化成长期摩擦。 -- P0: 不做合同测试。验证只保留两类:高频热点或明确曾修复 bug 回归风险时补最小单元测试;以及通过原入口/端到端 CLI 交互测试验证真实运行面。配置数值调整通常只跑对应 plan/sync/validate。 +- P0: 不做合同测试。CLI 改动除非用户明确要求,否则不做单元测试或新增测试脚本;非 CLI 的高频热点或明确曾修复 bug 回归风险才允许补最小单元测试。真实运行面问题通过原入口/端到端 CLI 交互验证,配置数值调整通常只跑对应 plan/sync/validate。 ## Critical Long-Term Reference Docs Rule @@ -67,7 +72,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - P0: 当用户明确给出最新验收要求或纠偏原则时,旧断言、旧门禁和旧兼容路径一律拆除;测试只按最新要求表达目标行为,不保留与最新目标无关或相冲突的历史断言。 - P0: 不做兼容迁移,不做分支/开关,不用 feature flag、legacy mode 或双路径长期并存来绕开最新要求;实现、测试和文档必须直接收敛到最新目标状态。 -- P0: 合同测试不是允许的测试形态;验证只使用最小单元测试或端到端 CLI 交互测试。历史上命名含 `contract` 的命令不得作为默认验证入口,业务策略和配置数值不得通过测试硬编码成额外门禁。 +- P0: 合同测试不是允许的测试形态;CLI 验证默认只做语法检查和必要命令形态确认,真实运行面问题走端到端 CLI 交互验证。历史上命名含 `contract` 的命令不得作为默认验证入口,业务策略和配置数值不得通过测试硬编码成额外门禁。 ## Critical Remote Patch Transport Rule @@ -85,7 +90,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 ## Critical HWLAB Issue Closure CLI Validation Rule -- P0: HWLAB/G14/v0.2 的用户反馈、CLI、Cloud Web、AgentRun、device-pod、公开 API 或运行面工作流 issue,关闭前必须完成用户入口或原入口的真实验证;仅有 targeted test、unit test、构建检查、PR 合并或源码层证据不得关闭 issue。 +- P0: HWLAB/G14/v0.2 的用户反馈、CLI、Cloud Web、AgentRun、device-pod、公开 API 或运行面工作流 issue,关闭前必须完成用户入口或原入口的真实验证;仅有源码检查、构建检查、PR 合并或源码层证据不得关闭 issue。 - P0: CLI 相关 issue 未完成目标 runtime 上的真实 CLI 验证时必须保持打开或重新打开;关闭评论必须写明实际 CLI/入口命令、目标 lane/URL/namespace、trace/session/thread/PipelineRun 等证据和结果,细则见 `docs/reference/g14.md`。 ## Critical CI/CD CLI Control Rule @@ -139,7 +144,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 ## Critical D601 UniDesk Workspace Rule -- P0: `D601:UniDesk` 的固定开发 workspace 是 D601 节点上的 `/home/ubuntu/workspace/unidesk-dev`,固定使用 `master` 分支和 `origin git@github.com:pikasTech/unidesk.git`;所有需要在 D601 上改 UniDesk 代码、跑轻量单元测试、做分布式敏捷实验补丁收敛或验证 Code Queue runner/trans/tran 的工作,都必须优先使用这个目录。 +- P0: `D601:UniDesk` 的固定开发 workspace 是 D601 节点上的 `/home/ubuntu/workspace/unidesk-dev`,固定使用 `master` 分支和 `origin git@github.com:pikasTech/unidesk.git`;所有需要在 D601 上改 UniDesk 代码、做轻量语法/命令形态验证、做分布式敏捷实验补丁收敛或验证 Code Queue runner/trans/tran 的工作,都必须优先使用这个目录。 - P0: 每次开始 `D601:UniDesk` 分布式开发、切换任务、恢复中断或上下文压缩后,必须重新读取目标 workspace 的 `/home/ubuntu/workspace/unidesk-dev/AGENTS.md`,并以该文件和其引用的 UniDesk repo 内规则为当前任务约束;禁止只凭压缩摘要或主 server 记忆继续改代码。 - P0: UniDesk CLI/trans/tran/SSH 透传客户端工具链改进可以直接在 master server `/root/unidesk` 做轻量源码修改、提交和推送;这不允许在 master server 运行仓库级 check、browser smoke、镜像构建或编译,细则见 `docs/reference/dev-environment.md`。 - `/home/ubuntu/cq-deploy`、`/root/unidesk`、`/app`、Code Queue pod 内 `/root/unidesk` 和 `/tmp/unidesk-*` 都是运行副本、部署副本或一次性实验面,不是 `D601:UniDesk` 日常开发 source truth;运行面热修可以直接作用于 pod/容器,但必须随后把持久化修复落回 fixed workspace 和 Git remote。 @@ -212,7 +217,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `bun scripts/cli.ts help`:输出所有可用命令的 JSON 索引,详细规范见 `docs/reference/cli.md`。 - `bun scripts/cli.ts --main-server-ip `:默认通过公网 frontend 登录态远程执行调试、用户服务(底层命令名 `microservice`)、Code Queue 查询与节点自测命令,不要求主 server SSH key,详细规范见 `docs/reference/cli.md`。 - `bun scripts/cli.ts config show`:校验并展示根目录 `config.json`,配置来源规则见 `docs/reference/config.md`。 -- `bun scripts/cli.ts check [--full|--files|--scripts-typecheck|--scripts-typecheck-timeout-ms N|--check-heartbeat-ms N|--gh-contracts|--components|--compose|--logs|--recovery-guardrails|--rust]` / `bun scripts/cli.ts check recovery-guardrails`:默认只运行轻量配置和 TypeScript 语法检查;`--scripts-typecheck` 长命令会输出 `unidesk.check.progress` 心跳并在最终 JSON 带有界 timeout/tail 详情;GitHub issue/PR live API check 必须显式用 `--gh-contracts` 或 `--full` 开启;`check recovery-guardrails` 只读低噪声报告 D601 reboot 后 k3s/Code Queue hostPath、`/proc/mounts`、CRI sandbox 和 ContainerCreating 风险;Rust backend-core 检查默认只能在 D601 CI/dev execution 中用 `UNIDESK_D601_RUST_CHECK=1` 开启,backend-core 主 server 上线受控编译例外不改变 `check --rust` guard,规则见 `docs/reference/cli.md`、`docs/reference/dev-environment.md` 和 `docs/reference/devops-hygiene.md`。 +- `bun scripts/cli.ts check [--full|--files|--scripts-typecheck|--scripts-typecheck-timeout-ms N|--check-heartbeat-ms N|--components|--compose|--logs|--recovery-guardrails|--rust]` / `bun scripts/cli.ts check recovery-guardrails`:默认只运行轻量配置和 TypeScript 语法检查;`--scripts-typecheck` 长命令只跑 scripts TypeScript 类型检查并输出 `unidesk.check.progress` 心跳,除非用户明确要求,不运行单元测试、合同测试或新增测试脚本;`check recovery-guardrails` 只读低噪声报告 D601 reboot 后 k3s/Code Queue hostPath、`/proc/mounts`、CRI sandbox 和 ContainerCreating 风险;Rust backend-core 检查默认只能在 D601 CI/dev execution 中用 `UNIDESK_D601_RUST_CHECK=1` 开启,backend-core 主 server 上线受控编译例外不改变 `check --rust` guard,规则见 `docs/reference/cli.md`、`docs/reference/dev-environment.md` 和 `docs/reference/devops-hygiene.md`。 - `bun scripts/cli.ts server start`:以异步 job 启动 database、backend-core、frontend、provider-gateway、code-queue-mgr 和主 server 用户服务,部署规则见 `docs/reference/deployment.md`。 - `bun scripts/cli.ts server status`:查询固定端口、swap 摘要、容器状态、健康检查和访问 URL,包含生产 frontend、dev frontend proxy 和 provider ingress,判定标准见 `docs/reference/deployment.md` 与 `docs/reference/dev-environment.md`。 - `bun scripts/cli.ts server swap status|ensure [--path /swapfile] [--size 2GiB] [--dry-run]`:以 JSON 查看或幂等创建主 server swapfile,`ensure` 输出 before/after、动作、持久化状态和 degraded/failed 详情,规则见 `docs/reference/deployment.md`。 @@ -232,13 +237,13 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `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 dry-run 形态,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|merge` / `bun scripts/code-queue-pr-preflight-example.ts`:通过 REST 执行安全 GitHub issue 读写、分页 issue list、inactive issue stale-close、脱敏 auth/status 诊断、heredoc/stdin 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 summary、无 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 commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run`:查看 host Codex 指挥官直管微服务 skeleton 的 source summary、无 daemon smoke 验证计划、.state/commander/ 状态模型、trace summary 聚合和 ClaudeQQ 高风险请示草案;当前只返回 dry-run 计划和 backend-core `microservice proxy claudeqq` 授权后候选命令,不接 live bridge、不接管人工指挥官,不发送消息,AgentRun 派单边界由指挥官直接审查,规则见 `docs/reference/host-codex-commander.md`。 - `bun scripts/cli.ts hwlab g14 retirement status|plan|execute --confirm`:受控退役 legacy G14 DEV/PROD Argo Application 和 namespace,并写本地退役 marker 记录执行证据;base=`G14` monitor 按退役合同固定阻止重启,`bun scripts/cli.ts hwlab g14 monitor-prs --lane v02|v03` 是当前 runtime lane PR 自动 CI/CD 入口,规则见 `docs/reference/g14.md` 与 `docs/reference/cli.md`。 - `bun scripts/cli.ts agentrun control-plane status|trigger-current|refresh|cleanup-runs|cleanup-released-pvs [--dry-run|--confirm]`:通过 G14 route 只读观察、手动触发、刷新 Argo 或清理 AgentRun `v0.1` completed CI workspace retention,规则见 `docs/reference/agentrun.md`、`docs/reference/gc.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 runtime truth 已迁到 G14 runtime lane,规则见 `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`。 -- `bun scripts/cli.ts codex prompt-lint [prompt|--prompt-file path|--prompt-stdin]` / `codex submit [prompt] [--prompt-file path|--prompt-stdin] [--queue ]` / `codex execution-plane [--full|--raw]` / `codex pr-preflight [--remote]`:`prompt-lint` 在派发/steer 前 dry-run 检查 runner prompt 的 DEV 测试授权分级(`read-only`/`live-read`/`live-mutating`)且不回显 prompt;`submit --dry-run` 同时给出 MiniMax/GPT/人工路由建议、该 lint 结果和 requested/effective execution mode;真实提交成功只返回写入确认、task id、服务级 runnerPermissions 和后续查看命令,不回显 prompt;`execution-plane` 通过 `trans D601:k3s` 只读观察 D601 原生 k3s 正式 Code Queue 执行面、旧 Compose 残留、commit/digest/worktree drift;`pr-preflight` 只读检查 D601 scheduler/runner 的 GitHub token、egress 和 PR 能力,PR 型派单前必须使用,规则见 `docs/reference/cli.md` 和 `docs/reference/code-queue-supervision.md`。 +- `bun scripts/cli.ts codex submit [prompt] [--prompt-file path|--prompt-stdin] [--queue ]` / `codex execution-plane [--full|--raw]` / `codex pr-preflight [--remote]`:`submit` 等旧写入口已冻结并返回 AgentRun 替代命令;`execution-plane` 通过 `trans D601:k3s` 只读观察 D601 原生 k3s 正式 Code Queue 执行面、旧 Compose 残留、commit/digest/worktree drift;`pr-preflight` 只读检查 D601 scheduler/runner 的 GitHub token、egress 和 PR 能力,PR 型派单前必须使用,规则见 `docs/reference/cli.md` 和 `docs/reference/code-queue-supervision.md`。 - `bun scripts/cli.ts codex task `:按 Code Queue 任务 ID 查询默认审阅摘要,只返回原始 prompt、最终 response、最后错误和渐进披露命令;`codex tasks --view commander` 是 host commander 推荐轮询入口,默认有界显示 active runner 精确计数、queued/retry_wait、terminal-unread、active 风险、分类和 drill-down 命令;`--view supervisor|full`、`codex output` 和大 `--limit` 仍默认有界,完整内容需显式 `--full`/`--full-text`/分页展开;`codex queues [--full] [--limit N] [--page N|--offset N]` 默认分页低噪声输出队列摘要,完整 upstream 只通过 raw command 显式获取。 - `bun scripts/cli.ts codex unread [--repo owner/name] [--issue N] [--limit N]`:只读汇总完成未读积压并给出 repo/issue/status/queue 计数和 drill-down/read 命令;批量已读必须显式 `codex unread mark-read ... --confirm`,规则见 `docs/reference/cli.md`。 - `bun scripts/cli.ts codex judge --attempt [--dry-run]`:按指定 task/attempt 用与队列 worker 相同的上下文构建和 MiniMax judge 调用路径单步复现完成判定;`--dry-run` 只输出 prompt/payload 诊断。 diff --git a/docs/reference/artifact-registry.md b/docs/reference/artifact-registry.md index 90732247..8b6716e8 100644 --- a/docs/reference/artifact-registry.md +++ b/docs/reference/artifact-registry.md @@ -107,9 +107,9 @@ bun scripts/cli.ts artifact-registry deploy-service --env prod --service claudeq `code-queue-mgr` 是主 server Compose sidecar,不是 D601 Code Queue scheduler/runner。`artifact-registry deploy-service --env prod --service code-queue-mgr --commit --dry-run` 必须显示 target 仅为 `composeService=code-queue-mgr` / `containerName=code-queue-mgr-backend`,并在 `excludedTargets` 中说明不会触碰 `code-queue` scheduler、runner、任务、interrupt 或 cancel 状态。真实 prod apply 仍受 supervisor-only gate 保护;未经单服务授权不得执行非 dry-run apply。 -`bun scripts/code-queue-cicd-dry-run-contract-test.ts` 是 Code Queue 自举边界的 focused contract:它验证 `deploy plan`、`deploy apply --dry-run` 和 `artifact-registry deploy-service --dry-run` 都只给出 DEV 计划证据,显示 `requiresSupervisorApproval` / `selfBootstrapGuard`,保持 pull-only/no-build 和 digest provenance,并证明 PROD unsupported 且不会影响生产 scheduler/runner/active tasks、interrupt 或 cancel。 +Code Queue 自举边界通过 `deploy plan`、`deploy apply --dry-run` 和 `artifact-registry deploy-service --dry-run` 验证:这些入口只给出 DEV 计划证据,显示 `requiresSupervisorApproval` / `selfBootstrapGuard`,保持 pull-only/no-build 和 digest provenance,并证明 PROD unsupported 且不会影响生产 scheduler/runner/active tasks、interrupt 或 cancel。除非用户明确要求,不为该 CLI 边界新增或运行合同测试。 -`bun scripts/code-queue-mgr-artifact-readiness-contract-test.ts` 是 Code Queue Manager supervisor gate 的 focused source/contract 检查:它验证 prod `deploy.json` pin 到包含 stats endpoint 的 `code-queue-mgr` commit、`CI.json` 仍使用 `ci publish-user-service` 发布 `unidesk/code-queue-mgr:`、Rust runtime 暴露 `/api/tasks/stats` 且不返回 `skipped` 统计、`artifact-registry deploy-service --env prod --service code-queue-mgr --dry-run` 与 `deploy apply --env prod --service code-queue-mgr --dry-run` 都只计划 `code-queue-mgr-backend` 单个 Compose service,并保持 supervisor-only live apply、`selfBootstrapGuard` 与 scheduler/runner/tasks/interrupt/cancel excluded target 合同。 +Code Queue Manager supervisor gate 通过 dry-run 和 live health 证据验证:prod `deploy.json` 必须 pin 到包含 stats endpoint 的 `code-queue-mgr` commit,`CI.json` 仍使用 `ci publish-user-service` 发布 `unidesk/code-queue-mgr:`,Rust runtime 暴露 `/api/tasks/stats` 且不返回 `skipped` 统计,`artifact-registry deploy-service --env prod --service code-queue-mgr --dry-run` 与 `deploy apply --env prod --service code-queue-mgr --dry-run` 都只计划 `code-queue-mgr-backend` 单个 Compose service,并保持 supervisor-only live apply、`selfBootstrapGuard` 与 scheduler/runner/tasks/interrupt/cancel excluded target 边界。 Code Queue DEV live apply is not an automation default. The reviewed path is: publish the commit-pinned artifact, run `deploy plan --env dev --service code-queue` and `deploy apply --env dev --service code-queue --dry-run` or the equivalent artifact-registry dry-run, then have a human operator or supervisor authorize any DEV apply outside the running Code Queue task. PROD has no apply authorization point in this phase. diff --git a/docs/reference/cicd-standardization.md b/docs/reference/cicd-standardization.md index f3962e58..0087d8b8 100644 --- a/docs/reference/cicd-standardization.md +++ b/docs/reference/cicd-standardization.md @@ -75,9 +75,7 @@ The drift contract is: - Runtime topology may be mirrored in dry-run output, but deployment commands must treat it as derived target metadata and keep `noRuntimeSourceBuild=true` for reviewed artifact consumers. - Upstream-image services must stay `blocked` in CI source-build publishing until an upstream digest or mirror digest consumer exists. -Lightweight evidence for this contract is `bun scripts/issue-60-cicd-drift-contract-test.ts`. The test parses local JSON/docs only; it does not publish images, deploy services, restart containers, run Playwright or run full e2e. - -Focused second-stage evidence is `bun scripts/issue-60-deploy-json-executor-preflight-contract-test.ts`. It runs only dry-run/contract paths, verifies the `dev/mdtodo` and `dev/decision-center` executors read image, target, port, memory and deploy metadata requirements from `deploy.json`, and simulates drift to assert field-level expected/actual error output. +Lightweight evidence for this boundary comes from config review, `deploy plan`, `deploy apply --dry-run`, and `ci publish-user-service --dry-run` output. These checks must stay non-mutating: no image publish, service deploy, restart, Playwright or full e2e. Unless the user explicitly asks for it, do not add or run unit tests or contract tests for this CLI boundary. ## Artifact Catalog @@ -175,22 +173,19 @@ This matrix is the single review surface for the remaining D601 service lane. It | `pipeline` | D601 `unidesk-direct` execution service in Docker Compose; control path is backend-core -> provider-gateway private HTTP proxy -> D601 loopback `/health` and `/api/` / `/oa/`. | `CI.json` source-build supported through `ci publish-user-service --service pipeline`; artifact is `127.0.0.1:5000/unidesk/pipeline:` from the external GitHub repo `Dockerfile`. | Reviewed D601 direct Compose artifact consumer for service `pipeline-control` / container `pipeline-v2-control`; CD is pull-only, retag, `docker compose up -d --no-build --no-deps --force-recreate pipeline-control`, then label and health verification. | Allowed after `deploy apply --env dev --service pipeline --dry-run` plus a matching registry artifact; live dev apply is permitted by policy. | Allowed after `deploy apply --env prod --service pipeline --dry-run` plus artifact/operator review; no public port or target-side build. | `/health` does not report deploy commit, so strict proof depends on image/container labels plus health. Registry health and artifact existence are required before live apply. | Add health deploy metadata and keep OA Event Flow integration checks as a focused post-apply smoke. | | `met-nonlinear` | D601 `unidesk-direct` GPU/business execution service in Docker Compose; control path is backend-core -> provider-gateway private HTTP proxy -> D601 loopback `/health` and `/api/`. | `CI.json` source-build supported through `ci publish-user-service --service met-nonlinear`; cataloged artifact uses `docker/unidesk/Dockerfile.ml` from `https://github.com/pikasTech/met_nonlinear`. | D601 direct Compose consumer is plan/dry-run only for service/container `met-nonlinear-ts`; dry-run exposes the no-build pull-only shape but returns `runtime-verification-blocked`. | Dry-run/read-only only. `deploy apply --env dev --service met-nonlinear --dry-run` must remain blocked until the running service image contract matches the published artifact. | Not authorized. Prod dry-run must remain `runtime-verification-blocked`; live prod apply is unsupported. | Published artifact is the ML image contract while the long-running service is `met-nonlinear-ts`, so CD cannot prove the running container image label equals the requested commit. | Split the TS server artifact from the ML image or publish a labeled artifact that exactly matches `met-nonlinear-ts`; then add live commit proof before enabling apply. | | `k3sctl-adapter` | UniDesk-managed D601 direct Compose control bridge, outside the native k3s fault domain; it is the control path for k3s-managed services and must not be moved into k3s. | `CI.json` source-build supported through `ci publish-user-service --service k3sctl-adapter`; artifact is `127.0.0.1:5000/unidesk/k3sctl-adapter:` from the UniDesk Dockerfile. | Artifact consumer exposes plan/dry-run only for service/container `k3sctl-adapter`; live replacement is supervisor-only because replacing the bridge can remove the repair path for k3s. | No normal dev target. DEV acceptance is read-only bridge health, service catalog/proxy checks and dry-run contract review only. | Dry-run/read-only only in this lane. Real prod replacement requires explicit supervisor confirmation, rollback proof and out-of-band recovery access. | Must remain recoverable while k3s may be broken; worker automation must not self-replace or k3s-manage the bridge. | Write a supervised bridge-upgrade runbook with rollback and out-of-band access checks; keep CLI dry-run as the standard preflight. | -| `code-queue` | Production execution plane is D601 native k3s (`unidesk` namespace) behind `k3sctl-adapter`; dev execution plane is `unidesk-dev` scheduler/read/write/provider-egress-proxy. Main-server `code-queue-mgr` is a separate control-plane sidecar. | `CI.json` source-build supported through `ci publish-user-service --service code-queue` for dev image validation only; artifact is `127.0.0.1:5000/unidesk/code-queue:`. | Reviewed dev-only k3s artifact consumer updates only `unidesk-dev` Code Queue objects. `deploy plan --env prod --service code-queue` and `artifact-registry deploy-service --env prod --service code-queue` must stay unsupported. | Allowed only as dry-run/source/contract evidence here; a later human-approved dev live apply may consume the artifact into `unidesk-dev` outside the running Code Queue task. | Not implemented and not authorized. No production artifact deploy, manifest mutation, scheduler/runner restart, interrupt or cancel is allowed. | Production still has hostPath/source and active-run safety boundaries; self-deploy would couple the deployment actor to the target being replaced. | Keep contract tests and dev dry-run coverage; design a separate supervisor-approved production CD consumer before any prod mutation is considered. | -| `decision-center` | D601 native k3s user service; dev runs `unidesk-dev/decision-center-dev`, prod runs `unidesk/decision-center`, both behind backend-core -> provider-gateway -> k3sctl-adapter -> Kubernetes API service proxy. | `CI.json` source-build supported through `ci publish-user-service --service decision-center`; artifact is `127.0.0.1:5000/unidesk/decision-center:` from the UniDesk Dockerfile. | Dev and prod are reviewed D601 k3s artifact consumers. Desired state, live health, and registry artifact must point at the same commit; drift where live is newer than `deploy.json` is corrected by repinning `deploy.json`, not by redeploying. | Closed for artifact CD when `deploy plan --env dev --service decision-center` is no-build and health reports matching `deploy.commit` / `deploy.requestedCommit`. Focused product gates remain record CRUD, diary lifecycle and frontend Decision Center visibility. | Closed for artifact CD when `deploy plan --env prod --service decision-center` is no-build and health reports matching `deploy.commit` / `deploy.requestedCommit`. Remaining acceptance is manual UI/product verification: health, records, diary editor, frontend page, no public business ports and live commit/artifact information. | Product completeness and manual UI acceptance can remain open, but they are not deployment drift. Registry artifact digest and health commit are the release evidence. | Keep `scripts/decision-center-desired-state-contract-test.ts` in the lightweight script gate so future desired-state edits cannot reintroduce source-build or stale-commit drift. | +| `code-queue` | Production execution plane is D601 native k3s (`unidesk` namespace) behind `k3sctl-adapter`; dev execution plane is `unidesk-dev` scheduler/read/write/provider-egress-proxy. Main-server `code-queue-mgr` is a separate control-plane sidecar. | `CI.json` source-build supported through `ci publish-user-service --service code-queue` for dev image validation only; artifact is `127.0.0.1:5000/unidesk/code-queue:`. | Reviewed dev-only k3s artifact consumer updates only `unidesk-dev` Code Queue objects. `deploy plan --env prod --service code-queue` and `artifact-registry deploy-service --env prod --service code-queue` must stay unsupported. | Allowed only as dry-run/source evidence here; a later human-approved dev live apply may consume the artifact into `unidesk-dev` outside the running Code Queue task. | Not implemented and not authorized. No production artifact deploy, manifest mutation, scheduler/runner restart, interrupt or cancel is allowed. | Production still has hostPath/source and active-run safety boundaries; self-deploy would couple the deployment actor to the target being replaced. | Keep dev dry-run coverage; design a separate supervisor-approved production CD consumer before any prod mutation is considered. | +| `decision-center` | D601 native k3s user service; dev runs `unidesk-dev/decision-center-dev`, prod runs `unidesk/decision-center`, both behind backend-core -> provider-gateway -> k3sctl-adapter -> Kubernetes API service proxy. | `CI.json` source-build supported through `ci publish-user-service --service decision-center`; artifact is `127.0.0.1:5000/unidesk/decision-center:` from the UniDesk Dockerfile. | Dev and prod are reviewed D601 k3s artifact consumers. Desired state, live health, and registry artifact must point at the same commit; drift where live is newer than `deploy.json` is corrected by repinning `deploy.json`, not by redeploying. | Closed for artifact CD when `deploy plan --env dev --service decision-center` is no-build and health reports matching `deploy.commit` / `deploy.requestedCommit`. Focused product gates remain record CRUD, diary lifecycle and frontend Decision Center visibility. | Closed for artifact CD when `deploy plan --env prod --service decision-center` is no-build and health reports matching `deploy.commit` / `deploy.requestedCommit`. Remaining acceptance is manual UI/product verification: health, records, diary editor, frontend page, no public business ports and live commit/artifact information. | Product completeness and manual UI acceptance can remain open, but they are not deployment drift. Registry artifact digest and health commit are the release evidence. | Keep dry-run and live health evidence sufficient so future desired-state edits cannot reintroduce source-build or stale-commit drift. | Minimum evidence for this lane is: | Evidence | Command | | --- | --- | -| Code Queue dev/prod boundary contract | `bun scripts/code-queue-cicd-dry-run-contract-test.ts` | | Code Queue dev target shape | `bun scripts/cli.ts deploy plan --env dev --service code-queue` | | Code Queue prod unsupported shape | `bun scripts/cli.ts deploy plan --env prod --service code-queue` | | D601 direct Compose dry-run for `findjob` / `pipeline` | `bun scripts/cli.ts deploy apply --env dev --service --dry-run` | | MET Nonlinear blocked dry-run | `bun scripts/cli.ts deploy apply --env dev --service met-nonlinear --dry-run` | | k3s control bridge dry-run | `bun scripts/cli.ts deploy apply --env prod --service k3sctl-adapter --dry-run` | | CI producer preflight | `bun scripts/cli.ts ci publish-user-service --service --commit --dry-run` | -| Decision Center desired/live no-build drift guard | `bun scripts/decision-center-desired-state-contract-test.ts` | -| Issue #60 phase-one deploy.json drift guard | `bun scripts/issue-60-cicd-drift-contract-test.ts` | ### Upstream Image Evidence @@ -298,15 +293,7 @@ Code Queue follows the standard artifact split only up to a dev-only consumer: | DEV live apply | Human operator after dry-run evidence | Pull/import the existing commit image, update only `unidesk-dev` Code Queue scheduler/read/write/provider-egress-proxy objects, verify health through the Kubernetes API service proxy | Touching production `unidesk` namespace, production PostgreSQL, production scheduler/runner, running task state or Code Queue Manager prod sidecar | | PROD | Not implemented | Structured unsupported / dry-run evidence only | Any production Code Queue artifact deploy, manifest mutation, rollout restart, scheduler/runner rebuild, task interrupt/cancel, or self-deploy by the running Code Queue task | -The operator review points are fixed. DEV requires an operator to compare the CI artifact summary with `deploy plan --env dev --service code-queue` or `deploy apply --env dev --service code-queue --dry-run`, then explicitly authorize a DEV apply outside the Code Queue task. PROD has no apply authorization point in this phase; even a manual request must first land a separate reviewed Code Queue production CD design. The current runner may produce plans, preflight output, docs and contract tests only. - -Lightweight contract evidence for this boundary is: - -```bash -bun scripts/code-queue-cicd-dry-run-contract-test.ts -``` - -The test checks that dev targets only `unidesk-dev`, prod exposes no runtime deploy target, production mutation is unsupported, and dry-run output forbids Code Queue self-deploy, scheduler/runner mutation, interrupt and cancel actions. +The operator review points are fixed. DEV requires an operator to compare the CI artifact summary with `deploy plan --env dev --service code-queue` or `deploy apply --env dev --service code-queue --dry-run`, then explicitly authorize a DEV apply outside the Code Queue task. PROD has no apply authorization point in this phase; even a manual request must first land a separate reviewed Code Queue production CD design. The current runner may produce plans, preflight output and docs only. Dry-run output must show that dev targets only `unidesk-dev`, prod exposes no runtime deploy target, production mutation is unsupported, and Code Queue self-deploy, scheduler/runner mutation, interrupt and cancel actions are forbidden. ## Validation Boundary @@ -336,22 +323,9 @@ This matrix describes the next promotion stage after dry-run coverage is in plac | `k3sctl-adapter` | `master` | source-build supported | plan/dry-run only | no normal dev target; only control-bridge health and recovery evidence | prod live apply requires supervisor confirmation | bridge recovery, k3s fault-domain isolation, no worker self-replacement | `GPT-5.5` | | `filebrowser` / `filebrowser-d601` | `master` | upstream-image blocked | pull-only mirror target | digest resolution, mirror governance and private proxy health only | not in this phase | upstream digest/mirror worker not yet implemented | `DeepSeek` for evidence summarization, `GPT-5.5` for blocker resolution | -Frontend lane closure evidence is intentionally lightweight and non-mutating: +Frontend lane closure evidence is intentionally lightweight and non-mutating: `deploy.json` dev/prod both request the same frontend commit, `CI.json` keeps the producer as `ci publish-user-service`, the D601 registry manifest digest is recorded, dev and prod health expose matching `deploy.commit` / `deploy.requestedCommit`, `ci publish-user-service --dry-run` is ready, and both CD dry-runs are artifact consumers with no source build. -```bash -bun scripts/frontend-artifact-lane-contract-test.ts -``` - -The contract fixes the current sample around one artifact lane: `deploy.json` dev/prod both request the same frontend commit, `CI.json` keeps the producer as `ci publish-user-service`, the D601 registry manifest digest is recorded, dev and prod health expose matching `deploy.commit` / `deploy.requestedCommit`, `ci publish-user-service --dry-run` is ready, and both CD dry-runs are artifact consumers with no source build. - -User-service artifact gap reviews must report the same normalized fields for each service: `desiredCommit`, `runtimeCommit`, `artifactExists`, `devStatus`, `prodStatus`, `blockedScopes` and `recommendedAction`. The issue #9 gap contract is intentionally lightweight and non-mutating: - -```bash -bun scripts/issue-9-mdtodo-health-metadata-contract-test.ts -bun scripts/issue-9-user-service-artifact-gap-contract-test.ts -``` - -The contract pins the current `mdtodo`, `claudeqq` and `todo-note` gap surface: `deploy.json` dev/prod desired commits, `CI.json` producer metadata, structured status fields and dev/prod artifact-consumer dry-runs. `mdtodo` also has a local health metadata contract that starts the service against a temporary Markdown workspace and verifies `/health.deploy` plus `/live.deploy` before publication. These tests do not publish artifacts, apply manifests, recreate services, restart services, run full check/e2e, or probe browser UI. +User-service artifact gap reviews must report the same normalized fields for each service: `desiredCommit`, `runtimeCommit`, `artifactExists`, `devStatus`, `prodStatus`, `blockedScopes` and `recommendedAction`. The review remains lightweight and non-mutating: `deploy.json` dev/prod desired commits, `CI.json` producer metadata, structured status fields, dev/prod artifact-consumer dry-runs, and service health metadata such as `/health.deploy` plus `/live.deploy` are the evidence. These checks do not publish artifacts, apply manifests, recreate services, restart services, run full check/e2e, or probe browser UI unless separately authorized. Planned parallelism for the next wave should be three lanes: diff --git a/docs/reference/cli.md b/docs/reference/cli.md index a4b3af87..9690e0d9 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -26,7 +26,7 @@ CI/CD、GitOps、rollout、artifact 发布、PR 合并后的 runtime lane 滚动 - 每个 CLI 命名空间必须支持 `help`、`--help` 或 `-h` 并返回 JSON,不得为了打印帮助而访问 runtime 服务、拉起交互会话或执行长时任务。 - `--main-server-ip ` 默认通过公网 frontend 登录态调用主 server 的同源 API 代理,不要求计算节点持有主 server SSH key;显式提供 `--main-server-key` 或 `--main-server-transport ssh` 时才使用旧 SSH 传输。远程 frontend 传输下的 `ssh ...` 必须复用同一套结构化 route parser,支持 `D601`、`G14`、host workspace、`D601:win`、`D601:win/c/test`、`D601:k3s` 和 `D601:k3s::` 这类定位路径;它不向调用容器下发 provider token,也不要求调用容器能解析 backend-core 内网 DNS。 - `config show` 读取并校验根目录 `config.json`,不从环境变量、默认值或隐藏文件静默补配置。 -- `check` 默认只执行轻量配置校验、Bun 版本检查和 Bun Transpiler 语法解析(覆盖 CLI 入口、主要 `scripts/` 模块和核心组件入口,不做类型推导);关键文件存在性、`scripts/` TypeScript 类型检查、GitHub CLI live API check、`src/components/` TypeScript 类型检查、Docker Compose config、日志轮转策略扫描和 D601 recovery guardrails 默认不启用,分别通过 `--files`、`--scripts-typecheck`、`--gh-contracts`、`--components`、`--compose`、`--logs`、`--recovery-guardrails` 开启,或用 `--full` 一次性开启。`--scripts-typecheck` 只表示 scripts TypeScript 与本地脚本形态,不应触发 GitHub issue/PR live API check 这类可能受网络/API 时延影响的检查;需要验证 GitHub CLI 时显式加 `--gh-contracts`,并以该 profile 的结果判定 GitHub CLI 变更。长命令项必须在 stderr 输出 `unidesk.check.progress` JSON lines,stdout 保持最终 JSON 结果,避免 post-task 或人工运行时长时间无可见进度。`typescript:scripts` 固定通过 `bun --bun tsc -p scripts/tsconfig.json --noEmit --pretty false` 执行,默认 `--scripts-typecheck-timeout-ms 120000`,可按目标运行面显式调小或调大但 CLI 会封顶;`--check-heartbeat-ms` 控制运行中心跳间隔,默认 `15000`。所有命令项的最终 item detail 必须包含 `durationMs`、`timeoutMs`、`heartbeatMs`、`exitCode`、`signal`、`timedOut`、stdout/stderr byte count、truncation flag 和有界 tail;超时必须返回 `timedOut=true`,不得只留下被外层命令杀死的空输出。`check recovery-guardrails` 是同一诊断的低噪声直接入口,报告 malformed `/proc/mounts`、kubelet validation risk、stale CRI sandbox count、Code Queue worktree/symlink、Code Queue/MDTODO hostPath 和 `ContainerCreating` 分类;它不得重启 k3s、删除 CRI sandbox、修改 hostPath、deploy/rollout 或 prune/reset。`--rust` 只允许在 D601 CI/dev execution 中配合 `UNIDESK_D601_RUST_CHECK=1` 使用,长期规则见 `docs/reference/dev-environment.md` 和 `docs/reference/devops-hygiene.md`。 +- `check` 默认只执行轻量配置校验、Bun 版本检查和 Bun Transpiler 语法解析(覆盖 CLI 入口、主要 `scripts/` 模块和核心组件入口,不做类型推导)。除非用户明确要求,CLI 改动不运行单元测试、合同测试或新增测试脚本;默认最多做语法检查和必要的帮助/命令形态人工确认。关键文件存在性、`scripts/` TypeScript 类型检查、`src/components/` TypeScript 类型检查、Docker Compose config、日志轮转策略扫描和 D601 recovery guardrails 默认不启用,分别通过 `--files`、`--scripts-typecheck`、`--components`、`--compose`、`--logs`、`--recovery-guardrails` 开启,或用 `--full` 一次性开启。`--scripts-typecheck` 只跑 scripts TypeScript 类型检查,不触发测试脚本或 GitHub issue/PR live API check。长命令项必须在 stderr 输出 `unidesk.check.progress` JSON lines,stdout 保持最终 JSON 结果,避免 post-task 或人工运行时长时间无可见进度。`typescript:scripts` 固定通过 `bun --bun tsc -p scripts/tsconfig.json --noEmit --pretty false` 执行,默认 `--scripts-typecheck-timeout-ms 120000`,可按目标运行面显式调小或调大但 CLI 会封顶;`--check-heartbeat-ms` 控制运行中心跳间隔,默认 `15000`。所有命令项的最终 item detail 必须包含 `durationMs`、`timeoutMs`、`heartbeatMs`、`exitCode`、`signal`、`timedOut`、stdout/stderr byte count、truncation flag 和有界 tail;超时必须返回 `timedOut=true`,不得只留下被外层命令杀死的空输出。`check recovery-guardrails` 是同一诊断的低噪声直接入口,报告 malformed `/proc/mounts`、kubelet validation risk、stale CRI sandbox count、Code Queue worktree/symlink、Code Queue/MDTODO hostPath 和 `ContainerCreating` 分类;它不得重启 k3s、删除 CRI sandbox、修改 hostPath、deploy/rollout 或 prune/reset。`--rust` 只允许在 D601 CI/dev execution 中配合 `UNIDESK_D601_RUST_CHECK=1` 使用,长期规则见 `docs/reference/dev-environment.md` 和 `docs/reference/devops-hygiene.md`。 - `server start` 创建异步 job,在后台执行 Docker 构建和启动;命令本身只负责返回 job id、日志路径和启动命令。 - `server stop` 创建异步 job,在后台停止固定 Compose project 中的全部 UniDesk 服务。 - `server status` 查询公开端口、受限宿主端口、内部端口、主机 swap 摘要、Compose 容器、core/frontend/dev-frontend/provider/database 健康检查和访问 URL;D601 Code Queue 使用的 PostgreSQL/OA Event Flow host mapping 必须出现在受限宿主端口而不是无条件公开入口中。低内存主 server 上 `swap.warning` 非空时,先执行 `server swap status` 或 `server swap ensure`。 @@ -50,7 +50,7 @@ CI/CD、GitOps、rollout、artifact 发布、PR 合并后的 runtime lane 滚动 - `dev-env validate [--manifest path] [--kubectl-dry-run]` 离线校验 D601 `unidesk-dev` namespace、dev PostgreSQL 底座和 dev workload manifest。默认检查 `src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-foundation.k8s.yaml`;也可显式校验 `src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-core.k8s.yaml` 或 `src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-code-queue.k8s.yaml`。所有 namespaced 对象必须只落到 `unidesk-dev`,foundation manifest 必须包含 `postgres-dev` StatefulSet/Service、dev secret/config、迁移 Job 和 DB URL guard,core manifest 必须包含 `backend-core-dev`/`frontend-dev` Deployment/Service,Code Queue dev manifest 必须包含 `code-queue-scheduler-dev`、`code-queue-read-dev`、`code-queue-write-dev`、dev provider egress proxy,以及只读挂载宿主 `/home/ubuntu/.agents/skills` 到容器 `/root/.agents/skills` 的 `skills-dir` volume。加 `--kubectl-dry-run` 时额外以 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml` 执行 `kubectl apply --dry-run=client --validate=false -f `,仍不 apply 资源;默认 `docker-desktop` kubeconfig 不得作为 D601 dry-run 目标。 - `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 plan、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 门禁;legacy `codex submit` 已冻结,新任务 payload 审查走 AgentRun `create/apply` 资源原语。`plan`、`smoke` 与 `approval request` 必须带 `--dry-run`;缺少时返回 `error=dry-run-required`。长期规则见 `docs/reference/host-codex-commander.md`。 +- `commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run` 是 host Codex 指挥官直管微服务 skeleton 入口。当前命令返回 `phase=source-contract`、service/API/state/bridge/prompt/trace/#20/#46/ClaudeQQ 审批边界、.state/commander/ 状态模型、dev 无 daemon smoke plan 和 dry-run 计划,不接 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。AgentRun 新任务 payload 边界由指挥官直接审查,不再通过额外本地派单审查命令;legacy `codex submit` 已冻结,新任务走 AgentRun `create/apply` 资源原语。`plan`、`smoke` 与 `approval request` 必须带 `--dry-run`;缺少时返回 `error=dry-run-required`。长期规则见 `docs/reference/host-codex-commander.md`。 - `hwlab g14 retirement status|plan|execute --confirm [--wait]` 是 legacy G14 DEV/PROD 的受控退役入口。`status` 只读报告 `argocd/hwlab-g14-dev`、`argocd/hwlab-g14-prod`、`hwlab-dev`、`hwlab-prod`、bounded legacy resource preview、受保护的 `hwlab-g14-v02`/`hwlab-node-v03` 和 `hwlab-v02`/`hwlab-v03`,以及 `.state/hwlab-g14/legacy-g14-retirement.json` marker。`plan` 是 dry-run,只列出 destructive targets 和 protected targets。`execute --confirm` 删除 legacy Argo Applications 与 legacy namespaces,取消本地 active `hwlab_g14_pr_monitor` job,并写 retirement marker 记录执行证据;`hwlab g14 monitor-prs --lane g14` 按退役合同固定结构化失败并指向 `retirement status` 和 runtime lane monitor `--lane v02|v03`。该入口禁止触碰 v0.2/v0.3 Application、namespace、PipelineRun、Secret、Git mirror 或 FRP desired state。 - `hwlab g14 monitor-prs --lane v02` 是 HWLAB `v0.2` 的 PR -> CI -> CD 自动化入口。它只监控 base=`v0.2` 的 open PR:每轮先用 UniDesk `gh pr preflight` 读取 GitHub CI/checks、mergeability 和冲突状态;pending 时在 PR 下写等待评论,blocked/conflict 时写阻塞评论;ready 时直接用 UniDesk `gh pr merge` 合并,不因为其他 commit 的运行中 PipelineRun 阻塞 merge 或 CI 启动。合并后执行受控 `control-plane trigger-current --lane v02 --confirm --wait`、轮询定点 `control-plane status --lane v02 --source-commit `,必要时执行 `git-mirror flush --confirm --wait`。v0.2 CD 采用 latest-only:旧 PipelineRun 不取消、不等待,但 promotion 写 `v0.2-gitops` 前必须重新确认 source head,stale commit 只能以 superseded/no-op 收口,不能回滚 runtime。不管 CD 成功、superseded、失败或超时,都在原 PR 下用 `gh pr comment create --body-stdin <<'EOF'` 追加语义化状态,正文固定包含起止时间、总耗时、冲突状态、CI/preflight conclusion、source commit、PipelineRun、targetValidation、Argo/webAssets 和 git mirror pendingFlush/githubInSync。评论去重状态写入 `.state/hwlab-g14/v02-pr-comment-signatures.json`,同一状态签名不会重复刷评论;v0.2 monitor 指针使用 `.state/hwlab-g14/latest-v02-monitor-job.json`、`latest-v02-once-job.json`、`latest-v02-dry-run-job.json` 和 `latest-v02-once-dry-run-job.json`,不会覆盖默认 G14 monitor 指针。`--lane v02 --once --dry-run` 只做单轮 preflight/merge/CD/comment plan,不写 GitHub、不触发 CD。 - `hwlab g14 monitor-prs --lane v03` 是 HWLAB `v0.3` 的 PR -> CI -> 自动合并 -> CD 入口。它只监控 base=`v0.3` 的 open PR:每轮先通过 UniDesk `gh pr preflight` 读取 GitHub checks、mergeability 和冲突状态;pending 时只在 PR 下写等待评论;失败 check、preflight blocker 或 conflict 时在 PR 下写阻塞评论,并按标题去重创建或更新 HWLAB failure issue。ready 时通过 UniDesk `gh pr merge` 合并,随后执行 runtime lane `control-plane trigger-current --lane v03 --confirm --wait`,轮询 `control-plane status --lane v03 --source-commit `,判定 PipelineRun `True`、Argo `Synced/Healthy`、`hwlab-v03` runtime workload 可见、20666/20667 public probes 通过,并在必要时执行 `git-mirror flush --lane v03 --confirm --wait`。CD 成功、失败或超时都会在原 PR 下写语义化状态评论;失败和超时同时创建或更新 failure issue,正文必须包含 PR、base/head、commit、PipelineRun、失败阶段、preflight/CD 摘要和下一步 CLI。评论去重状态写入 `.state/hwlab-g14/v03-pr-comment-signatures.json`,monitor 指针使用 `.state/hwlab-g14/latest-v03-monitor-job.json`、`latest-v03-once-job.json`、`latest-v03-dry-run-job.json` 和 `latest-v03-once-dry-run-job.json`。`--lane v03 --once --dry-run` 只做单轮 preflight/merge/CD/comment/issue plan,不写 GitHub、不触发 CD。 @@ -74,7 +74,6 @@ CI/CD、GitOps、rollout、artifact 发布、PR 合并后的 runtime lane 滚动 - `trans gh:/owner/repo ...` 把 GitHub issue/PR 映射成只读/受控写入的虚拟文本目录,适合日报、PR 正文和 issue 正文的小补丁维护:`trans gh:/pikasTech/HWLAB ls` 展示 `pr/` 与 `issue/`,`trans gh:/pikasTech/HWLAB/pr ls [--limit N] [--full]` 和 `trans gh:/pikasTech/HWLAB/issue ls [--limit N] [--full]` 展示条目状态、楼层数、正文长度和标题,`trans gh:/pikasTech/HWLAB/pr/507 ls` 展示单个 PR 的一楼正文文件,`trans gh:/pikasTech/HWLAB/505/1 cat|rg|patch-apply` 兼容旧式 issue/PR number route。`patch-apply` 使用 UniDesk 默认 apply-patch v2 的虚拟文件 executor,把正文一楼映射为 `body.md`,写回仍走 `bun scripts/cli.ts gh issue/pr update` 的 guard/concurrency 规则;`rm` 对正文一楼结构化拒绝,避免误删 issue/PR 正文。大正文读取必须展开 UniDesk gh dump 文件,否则 `cat/rg/patch-apply` 会误读为空,这是 `gh:` 虚拟文件接口的 P0 可见性契约。 - `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]` 是派发前的本地 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` 处理。新任务走 AgentRun `create/apply` 资源原语,指挥官应把 lint 结果纳入 task manifest 或 prompt 审查。长期规则见 `docs/reference/code-queue-supervision.md` 的 DEV 测试授权分级。 - `gh issue list [owner/repo] [--state open|closed|all] [--limit N] [--search text] [--label label[,label...]]... [--repo owner/name] [--json number,title,state,url,updatedAt,createdAt,author,labels] [--raw|--full]` 通过 GitHub REST 列出 issue,默认 `state=open`、`limit=30`,输出稳定 JSON 且不依赖系统 `gh` binary。`owner/repo` 位置参数是 `--repo owner/repo` 的兼容别名;若位置 repo 与 `--repo` 冲突,或位置参数不是 `owner/repo`,必须结构化失败,禁止静默 fallback 到默认 repo。`--limit` 是 CLI 返回上限,不等同 GitHub 单页 `per_page`:当 `--limit > 100` 或默认页中混入 PR 时,CLI 必须分页抓取 GitHub REST/Search page,过滤 PR 后再返回 issue,并在输出中披露 `pagination.fetchedPages/rawCount/hasMore`;`hasMore=true` 时只能说明当前有界扫描未穷尽,禁止把它当作“仓库没有更多 issue”。`--search` 使用 GitHub Search Issues API,并自动追加 `repo:/`、`type:issue` 和 state qualifier,用于创建新 issue 前做低摩擦查重;未知 state 或未知 `--json` 字段必须结构化失败并带 `runnerDisposition=business-failed`。`--label` 是 GitHub REST `labels=label1,label2` 或 Search `label:` 服务端过滤,支持重复 `--label` 和逗号分隔;filter 不在本命令上下文中使用(如 `issue read`、`pr list`)必须结构化失败并指明 `gh issue create/list/stale-close` 才是合法作用域。GitHub issues API 可能混入 PR,CLI 会从 `.data.issues` 中过滤 pull request。`--raw|--full` 在 `gh issue list` 上是绕过 20 KiB stdout 截断的显式开关:响应结果会带 `noDump=true`,`output.ts` 据此跳过 head/tail 替换并把完整数据 inline 输出;当响应未超阈值时 `--raw|--full` 行为等价默认。 - `gh issue lifecycle`:`--state` 只能作为 `gh issue list` / `gh issue board-row list` / `gh pr list` 的过滤参数;`gh issue update` / `gh issue edit` 只写 body/title,**不接受** `--state` 改 open/closed。把 `gh issue update --state closed` 落到错命令上时,CLI 必须返回 `validation-failed` 并显式提示 `gh issue close ` / `gh issue reopen `(PR 用 `gh pr close|reopen `),并把 5 条受支持命令放进 `supportedCommands`,禁止把"无 `--state` 改 issue 状态"的命令升级为"接受 `--state`"。`gh issue close|reopen` 成功输出默认是 compact issue 摘要,不得回显完整 `issue.body`;需要正文时后续使用返回的 `readCommands` 或 `gh issue view --json body|--full|--raw`。生命周期 close/reopen 的评论推荐用 `--comment-stdin <<'EOF'` 直接写 heredoc/stdin;短单行可用 `--comment`,已有复用文件才用 `--comment-file`。需要附长篇 CLI 验收证据时,先用 `gh issue comment create --body-stdin <<'EOF'` 写证据评论,再用 `gh issue close --comment <短引用>` 关闭。issue 硬删除走 `close`,PR 硬删除走 `close`,两者都没有"delete"语义。 - `gh issue comment create --repo owner/name --body-stdin`、`gh issue comment update|edit --repo owner/name --body-stdin`、`gh issue comment delete --repo owner/name`、`gh issue close --repo owner/name [--comment |--comment-stdin]`、`gh issue reopen --repo owner/name [--comment |--comment-stdin]`、`gh issue update --repo owner/name [--title ...] [--body-stdin]`、`gh issue edit ...`、`gh issue board-row get|update|add|move|delete|upsert --repo owner/name ...` 都接受与 `gh issue view|read`、`gh pr *` 一致的 `owner/repo#number` 位置 shorthand;shorthand 与显式 `--repo` 冲突时结构化失败并把两者都回显到错误对象里,避免静默改写目标 repo。`gh issue view|read`、`gh pr view|read|files|diff|preflight|closeout|comment create|comment update|comment edit|comment delete|close|reopen|merge|edit|update` 已长期支持该 shorthand;comment update/edit/delete 的 `--number` 表示 commentId,不是 issue/PR number。issue 写命令对齐后整个 `gh` 子命令在 shorthand 行为上保持一致,不再需要把 `pikasTech/HWLAB#621` 拆成 `621 --repo pikasTech/HWLAB`。来源:HWLAB #621 CLI 验收 `gh issue comment create pikasTech/HWLAB#621` 摩擦改进。 diff --git a/docs/reference/code-queue-supervision.md b/docs/reference/code-queue-supervision.md index 6a7522ce..272a1473 100644 --- a/docs/reference/code-queue-supervision.md +++ b/docs/reference/code-queue-supervision.md @@ -50,7 +50,7 @@ live-read browser audit 只用于观察已部署 UI,不授权写入。未获 | 分级 | 含义 | 常见允许动作 | 禁止动作 | | --- | --- | --- | --- | -| `read-only` | 不连接或不观察正在运行的 DEV 服务,只验证源码、本地 contract、fixture、mock、dry-run 或静态输出。 | `git diff`、`rg`、类型检查、unit/contract test、CLI `--dry-run`、生成计划或补文档。 | 访问 live service、触发任务、写数据库、部署、重启、rollout、真实硬件或虚拟硬件动作。 | +| `read-only` | 不连接或不观察正在运行的 DEV 服务,只验证源码、fixture、mock、dry-run 或静态输出。 | `git diff`、`rg`、语法检查、CLI `--dry-run`、生成计划或补文档。 | 访问 live service、触发任务、写数据库、部署、重启、rollout、真实硬件或虚拟硬件动作。 | | `live-read` | 读取正在运行的 DEV 服务、日志、health、status、metrics、Kubernetes 只读对象或只读 API,不改变 live 状态。 | `GET /health`、`GET /status`、只读 proxy、`kubectl get/describe/logs`、只读 CLI status/diagnostics。 | `POST/PUT/PATCH/DELETE`、`kubectl apply/delete/rollout restart`、触发 schedule/job/task、写 issue/PR 之外的 runtime 状态、任何会创建 operation/audit/evidence 的动作。 | | `live-mutating` | 在 DEV 环境执行会改变 live 状态的命令,即使目标是 smoke、复测或诊断。 | 经 prompt 明确授权的 dev deploy/apply/rollout、trigger/run/retry、task submit/steer、写配置、创建 operation/audit/evidence、HWLAB M3 DO/DI 链路触发。 | 任何未被 prompt 精确列出的 live mutation;生产写入、密钥读取、数据库手工 patch、Code Queue 高风险干预仍按更高安全边界处理。 | @@ -67,7 +67,7 @@ runner 收到未分类或含糊的 prompt 时,只能执行 `read-only` 范围 supervisor closeout 不能只看 runner 的成功自述,必须核对 prompt 授权和实际命令级别: -- `read-only` closeout 应证明没有 live service 写入,证据来自 diff、静态检查、unit/contract test 或 dry-run 输出。 +- `read-only` closeout 应证明没有 live service 写入,证据来自 diff、静态检查、语法检查或 dry-run 输出。 - `live-read` closeout 应记录读取的 DEV endpoint、service、namespace 或日志范围,并明确没有触发 runtime 状态变化。 - `live-mutating` closeout 应指出 prompt 中的明确授权、实际变更目标、operation/audit/evidence/task/job ID、回滚或恢复观察,以及 prod 未触碰。 - 如果 runner 在没有明确 prompt 授权时执行了 live mutation,即使 smoke 结果成功,也不能把任务验收为正常完成;指挥官应先核实 live 状态和 blast radius,再把它记录为治理缺陷或 follow-up,并修正后续 prompt 模板。 @@ -98,7 +98,7 @@ AgentRun 新派单和历史 Code Queue 审阅都按成本、可信度和 blast r 新任务模型由 AgentRun task payload 和 AgentRun runtime 配置决定;旧 Code Queue 的 `CODE_QUEUE_MODELS` 只作为历史任务审阅和残留运行面配置参考,长期合同至少包含 GPT-5.5、GPT-5.4、GPT-5.4 Mini、DeepSeek Chat、MiniMax M3 和 MiniMax M2.7 两路并行配置;`deepseek`/`deepseek-chat`、`minimax-m3` 与 `minimax-m2.7` 会走 OpenCode port,其余模型走 Codex port。PROD 集群把 `MINIMAX_MODEL` 切到 `MiniMax-M3`(M3 是新任务的默认 provider model),judge 与 opencode 跟随;M2.7 仍然作为并行配置存在,切换只需把 `MINIMAX_MODEL` 改成 `MiniMax-M2.7` 后 rollout restart。两者不存在自动 fallback 关系:M3 任务失败不会自动改派 M2.7,task 要用 M2.7 必须显式 `--model minimax-m2.7`。只有当执行面 `/health` 或等价配置已经显示 DeepSeek 模型可用、并完成轻量 runner smoke 后,才允许真实提交 `--model deepseek-chat`。 -`codex prompt-lint [prompt|--prompt-file path|--prompt-stdin]` 仍是派单前的本地 dry-run guardrail,用于检查 runner prompt 是否声明 `DEV test class`、允许的 live mutation、禁止动作和 closeout 字段。它只返回分类、缺失或矛盾项和有界 evidence,不提交任务、不连接 live service、不打印完整 prompt;新派单时由指挥官把 lint 结果纳入 AgentRun task payload 审查。 +AgentRun 派单边界由指挥官直接审查和负责,不再通过额外本地派单审查命令。新任务 prompt 仍必须自包含,并写清写入范围、验证范围、禁止动作、是否允许运行态变更、提交/推送/PR/merge 边界和 closeout 字段;这些要求属于调度质量责任,不是额外 CLI guard。 @@ -113,7 +113,7 @@ Device Pod 类 DS 验收不能只看最终回复。指挥官必须用 `codex tas | 首选模型 | 适用任务 | 必须满足 | 禁止下放 | | --- | --- | --- | --- | | GPT-5.5/Codex | 高风险、复杂、跨模块、运行态、CI/CD、release、deploy、安全、最终质量裁决 | 多信号诊断、可回滚边界、必要的轻量或 dev 验证 | 不因成本把运行态和生产风险降级 | -| DeepSeek/OpenCode | 中等复杂度的前端功能、局部用户服务模块、局部 CLI/helper、明确 contract guard 或 unit test | prompt 自包含、写入范围窄、无生产/密钥/DB 写入、验证命令明确、指挥官审阅 | 不处理 Code Queue runtime、backend-core、provider-gateway、k3sctl-adapter、release/v1 或部署变更 | +| DeepSeek/OpenCode | 中等复杂度的前端功能、局部用户服务模块、局部 CLI/helper、明确 dry-run 或语法验证 | prompt 自包含、写入范围窄、无生产/密钥/DB 写入、验证命令明确、指挥官审阅 | 不处理 Code Queue runtime、backend-core、provider-gateway、k3sctl-adapter、release/v1 或部署变更 | | MiniMax/OpenCode (M3 / M2.7 并行) | 只读调查、文档、简单前端/样式、低风险样板、轻量 dry-run/preflight 和小范围测试补齐 | issue 只作辅助引用、必须给出 diff/路径/命令证据、完成后保持未读待审 | 不处理共享核心、隐式远端状态、生产、密钥、DB、重启、复杂 bug 或最终裁决 | | 指挥官/人工 | 真实生产动作、运行中任务控制、密钥/数据库/破坏性 Git、批量已读和高风险恢复 | 用户授权、只读诊断、恢复方案、记录 issue/#20/#24 | 不把执行权交给普通 worker | @@ -125,8 +125,8 @@ MiniMax/OpenCode 可承担任务必须同时满足这些条件: | --- | --- | --- | --- | | 只读调查 | 查找文件、梳理现有实现、列出候选入口、对比文档口径 | 文件路径、行号、命令输出摘要 | 不得把 issue 内容当唯一输入,不得声称读取了无法验证的远端状态 | | 中文文档初稿或长期参考补丁 | `docs/reference/`、`AGENTS.md` 索引、一句话摘要、表格治理规则 | diff、文档位置、轻量格式/grep 检查 | 不写流水账,不改 release/v1 runtime | -| 轻量 CLI dry-run/preflight | 只读或 dry-run 输出、contract test、参数校验、错误分类 | dry-run JSON、contract test、`bun` 脚本验证 | 不改 runtime 调度核心,不触碰生产服务 | -| 局部测试补齐 | 单文件或小范围 contract/unit test,覆盖明确回归 | 测试命令、失败前提、通过输出 | 不跑 heavy check、E2E、Playwright | +| 轻量 CLI dry-run/preflight | 只读或 dry-run 输出、参数校验、错误分类 | dry-run JSON、语法检查或原入口命令输出 | 不改 runtime 调度核心,不触碰生产服务 | +| 局部回归验证 | 用户明确要求时才补最小测试,否则只做语法/命令形态检查 | 验证命令、失败前提、通过输出 | 不跑 heavy check、E2E、Playwright,不默认新增测试脚本 | | 小范围样板代码 | 非共享核心、可快速 review、可用类型检查或 dry-run 验证 | 修改文件、轻量类型/脚本验证、commit | 不跨多服务,不改凭证、网络、部署或数据库 | | 数据整理和看板候选草案 | 生成 #20/#24 更新草案、任务表、验收 checklist | 草案 diff、来源列表、人工待审标记 | 不直接替代指挥官审阅,不自动清空未读任务 | @@ -185,7 +185,7 @@ issue 内容必须自包含,至少写清楚背景、外部收益、当前观 GitHub issue/PR 操作应优先使用 UniDesk CLI 的安全入口:`bun scripts/cli.ts gh auth status`、`gh issue list/read/view/create/update/comment create/comment update/comment edit/comment delete/close/reopen/scan-escape/cleanup-plan/board-audit/board-row list/board-row get/board-row update`、`gh pr list/read/view/create/update/comment create/comment update/comment edit/comment delete/close/reopen`。该入口默认 repo 是 `pikasTech/unidesk`,支持 `--repo owner/name`,输出稳定 JSON,并把 `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` 等失败原因结构化。失败对象必须包含 `runnerDisposition=infra-blocked|business-failed`,runner 应用它区分基础设施阻塞和业务/参数失败。`github-transient` 专指 GitHub DNS 或 API 连接在收到 HTTP 状态前失败,例如 `Temporary failure in name resolution`、`Could not resolve host: github.com/api.github.com` 或 `error connecting to api.github.com`;它必须带 `retryable=true` 或等价 retry/backoff 指示,并且不是 `missing-token`、`auth-failed`、`scope-insufficient`、`validation-failed` 或 PR 语义失败。指挥官看到这类结果时,优先重试或退避;如果对应 Code Queue 任务 heartbeat/trace 仍新鲜,应保持任务运行并继续监督,不要立即 close/requeue 业务工作。runner 不应直接运行系统 `gh auth status` 并把输出贴入 Code Queue 日志;系统 `gh` 的 masked token 行仍会暴露 token 前缀和 scope 片段。需要验证当前 runner GitHub auth 时使用 `bun scripts/cli.ts gh auth status --repo pikasTech/unidesk` 或 `bun scripts/cli.ts codex pr-preflight --remote`,输出只能保留 token 是否存在、来源、长度和掩码,不得打印 token 值或 token 片段。Code Queue 输出层必须在保留 command output、trace、raw output 页面和 commander 摘要前 redaction `gh auth status` 风格 token 行,并给出 UniDesk CLI wrapper 提示。`gh issue list --state open --limit N --json number,title,state,closed,closedAt,url` 是有界 issue 发现入口,`--state` 只接受 `open|closed|all`,list 字段白名单是 `number,title,state,closed,closedAt,url,updatedAt,createdAt,author,labels`;未知 state 或未知字段必须失败,不能静默返回空数组。`gh issue view --json body,title,state,closed,closedAt` 是 canonical 入口,`read` 只保留为兼容别名,正文仍应从 `.data.issue.body` 读取。单 issue/PR/comment 数字目标命令兼容 `--number N`,但成功响应必须带 `standardSyntaxHint` 提示标准位置参数写法;comment update/edit/delete 中的 `--number` 表示 commentId。未知 `--json` 字段必须失败,不得让调用方把空正文或缺失生命周期字段误判为读取成功。`gh issue scan-escape --limit N [--dry-run]` 与 `gh issue cleanup-plan` 只读扫描 issue body/comments 的字面量 `\n`、shell escape、短 body、blank/null body,输出 `classification=suspected-pollution|explanatory-mention|risk`、body/comment id、预览和清理建议;说明性提到 `\n` 不应被当成污染,cleanup-plan 永远不真实清理历史评论。`gh issue board-audit --board-issue 20 --limit N --dry-run` 只读审计目标 board issue 正文结构,返回正文长度、行数、body SHA、可解析 Markdown board sections、section 行数和 parser warnings;它不再拉取 GitHub open/closed issue 列表,也不再校验 OPEN/CLOSED 表覆盖关系。兼容字段 `missingOpenIssues`、`closedInOpenRows`、`missingClosedRows`、`rowValidationWarnings`、`ignoredIssues` 和 `recommendedActions` 仍保留为空数组或 0。显式 `gh issue update --body-profile commander-brief` 可用于 #24 legacy 简报和每日滚动简报 issue;每日简报 issue 应用标题 `YYYY-MM-DD 指挥简报(北京时间)` 或在既有正文首行/关键 heading 中标明简报身份,且新正文必须包含 `## 常驻观察与长期建议`。对非简报 issue 使用该 profile 应失败为 `profile-issue-mismatch`。需要维护旧式 OPEN/CLOSED 明细表时,继续使用 `gh issue board-row list --board-issue 20 --state open|closed|all`、`gh issue board-row get --board-issue 20` 和 `gh issue board-row update --board-issue 20 --field progress|status|validation|branch|tasks|focus --value `;`board-row update` 只替换一行一个单元格,输出 old/new row、body SHA、body guard 和 request plan,且默认 dry-run,正式写入必须带 `--expect-body-sha` 或 `--expect-updated-at`。字段映射中 `status`/`validation` 都指向 `验收状态`,`tasks` 指向 `相关 Code Queue 任务`,`focus` 指向 `当前关注点`;单元格管道会转义、真实换行会折叠为空格,避免新增字面量 `\n`。`gh issue board-row upsert` 可更新既有行或按 section 生成完整新行;`board-row add/move/delete` 已支持行级新增、OPEN/CLOSED 迁移和删除,全部默认 dry-run,正式 PATCH 必须带 `--expect-body-sha` 或 `--expect-updated-at`。`gh pr list --json ...` 支持 `body,title,state,number,url,author,head,base,draft,createdAt,updatedAt` 字段白名单;`gh pr read|view --json ...` 还支持 `stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup`。`stateDetail=open|closed|merged` 用于区分 REST `state=closed` 中的普通关闭和已合并;`closed*`、`merged*`、`mergeCommit` 和分支名字段都来自 REST。只有 mergeability/check rollup 需要请求 GraphQL,适合 PR 收口前判断可合并性和检查汇总。GraphQL 权限不足、网络失败、GitHub 仍返回 `UNKNOWN`/null、或需要 UniDesk CLI 尚未开放的官方字段、review/merge 操作时,回退系统 `gh` 只读观察或 GitHub UI;不要把缺失元数据当成已可合并。issue/PR 创建、更新、评论创建、评论编辑、评论删除、关闭和重开使用 GitHub REST API;只要有 `GH_TOKEN` 或 `GITHUB_TOKEN`,就不依赖系统 `gh` binary。`gh` binary 只作为状态探测和 `gh auth token` fallback,不是写操作的主路径。GitHub 不支持 issue/PR 硬删除,`gh issue delete` 和 `gh pr delete` 必须结构化返回 `unsupported-command`;生命周期删除语义使用 `close`。`gh pr merge` 是 guarded write:先执行 closeout preflight,只在 open、非 draft、无冲突、merge state CLEAN 且 checks 无失败/pending 时调用 GitHub REST merge;`--dry-run` 只输出计划不写远端。 -CLI 是短 shout 的需求原语,不是长驻服务器进程。CLI 功能不好用、兼容性不足、安全 guard 不够或输出不利于 runner/指挥官使用时,应默认创建 GitHub issue 并用 Code Queue 推进;这类 CLI 问题走 `master`、remote commit、轻量 contract test 和文档更新,不套用 backend-core、Code Queue runtime 这类运行态服务的重部署门禁。若 CLI 缺陷已经阻塞当前指挥,可以先做最小安全绕行,同时把长期修复写入 issue 并派单。 +CLI 是短 shout 的需求原语,不是长驻服务器进程。CLI 功能不好用、兼容性不足、安全 guard 不够或输出不利于 runner/指挥官使用时,应默认创建 GitHub issue 并用 Code Queue 推进;这类 CLI 问题走 `master`、remote commit、语法/命令形态验证和文档更新,不套用 backend-core、Code Queue runtime 这类运行态服务的重部署门禁。除非用户明确要求,CLI 改动不做单元测试、合同测试或新增测试脚本。若 CLI 缺陷已经阻塞当前指挥,可以先做最小安全绕行,同时把长期修复写入 issue 并派单。 所有 GitHub Markdown 正文写入优先使用 `--body-stdin` 或 `--body-file `。不要使用 `gh issue comment --body`、`gh api -f body=...` 或把多行正文直接拼进 shell 参数;这些路径容易把真实换行、反引号和 Markdown 表格污染成字面量 `\n` 或 shell escape。从 shell 生成正文文件时使用 quoted heredoc,例如 `cat <<'EOF' > /tmp/body.md`,保证反引号和反斜杠不被展开;JSON 请求体场景优先使用对应 CLI 的 `--body-file` 或 `--body-stdin`,不要把长 JSON 塞进命令行参数。`gh issue comment create|update|edit` 和 `gh pr comment create|update|edit` 都支持 `--body-stdin` 作为多行 Markdown 的第一等入口,`--body` 仅适合短单行文本。`gh issue` 正文更新主入口仍是 `update --mode replace|append --body-stdin|--body-file`,`edit` 只是兼容别名;`append` 会先读取当前正文再追加文件字节,保留真实换行、反引号和 Markdown 表格,不走 shell 拼接。`gh issue update --body-file` 默认拒绝 `null`、空白和过短正文;#20 自动要求 `## 看板(OPEN)`,指挥简报 profile 自动要求 `## 常驻观察与长期建议`,并允许 #24 legacy 或每日滚动简报 issue。更新 body-only issue 前优先跑 `--dry-run`,查看旧/新正文长度、body SHA、关键标题、字面量 `\n` 和 shell 污染信号;正式写入长期正文时优先带上 `--expect-updated-at` 或 `--expect-body-sha`,避免旧缓存覆盖新正文。指挥简报更新正文时默认只写 GitHub issue,不自动向 ClaudeQQ 推送;#24 legacy 可用 `--notify-claudeqq-brief-diff` 通知 helper,如确需提醒用户,按本文的 ClaudeQQ 通知门槛单独发送。提交前或巡检时可用 `gh issue scan-escape --limit N --dry-run` 或 `gh issue cleanup-plan --limit N` 只读扫描污染并生成建议,不自动修复。 @@ -220,13 +220,7 @@ Git/PR 交付要求: - final response 必须报告 head branch、PR URL、远端 head commit、修改文件、验证命令、merge/close 状态和 SHA 或未 merge 原因。 ``` -GPT-5.5 PR/收口类 prompt 在提交前可先用 host commander 辅助 lint 做非阻塞检查: - -```bash -bun scripts/cli.ts commander prompt-lint --kind gpt55-pr --prompt-file /tmp/code-queue-prompt.md -``` - -该检查只服务于指挥官补齐派单边界,不是业务 PR 门禁;legacy `codex submit` 已冻结,新任务 payload 审查走 AgentRun 资源原语。输出只包含 `ok`、`missingClauses`、`riskLevel`、`suggestedPatchSnippet` 和 prompt shape,不回显完整 prompt;`data.ok=false` 表示建议补齐 PR/自合并/rebase/update 授权、artifact build/publish 授权、host-owned DEV rollout、未显式 `ROLLOUT_OK` 时禁止 runner rollout、以及 PROD/secret/DB/破坏性回滚边界。 +GPT-5.5 PR/收口类 prompt 由指挥官在提交前直接审查。legacy `codex submit` 已冻结,新任务 payload 只走 AgentRun `create/apply` 资源原语;不得再把额外本地审查命令当成派单 admission、业务 PR 门禁或生产风险豁免依据。 Runner preflight 优先使用执行面诊断入口: @@ -256,11 +250,10 @@ D601 Code Queue runner 的长期 skills source of truth 是宿主 `/home/ubuntu/ 执行面还必须提供 dry-run skills sync/preflight 合同:稳定入口是 `GET /api/skills-sync?dryRun=1`,CLI 入口是 `bun scripts/cli.ts codex skills-sync --dry-run [--full]`。该合同只描述受控 hostPath 生命周期,不复制文件、不从任意路径静默加载、不重启服务、不 rollout Pod、不读取 Secret。默认输出保持紧凑,必须报告 source、target、expected env/mount、required skill 列表、source/target skill counts、missing source/target skills、permission failure count、plannedActions 和修复指令;逐 skill 细节、完整 permission failure 和原始报告只能通过 `--full` 显式展开。非 dry-run 请求必须失败。 -受控生命周期是更新宿主 `/home/ubuntu/.agents/skills` 这一 approved source,然后让生产和 dev Code Queue Pod 通过 manifest 中的 read-only hostPath 挂载读取 `/root/.agents/skills`;provider dev container 还必须通过启动脚本把同一 source bind 到同一 target。在线热更新只能作为临时恢复手段,长期验收必须以 manifest/source-of-truth、runner container bind、dry-run sync contract、结构化 health/preflight 和合同测试为准。需要验证时优先运行: +受控生命周期是更新宿主 `/home/ubuntu/.agents/skills` 这一 approved source,然后让生产和 dev Code Queue Pod 通过 manifest 中的 read-only hostPath 挂载读取 `/root/.agents/skills`;provider dev container 还必须通过启动脚本把同一 source bind 到同一 target。在线热更新只能作为临时恢复手段,长期验收必须以 manifest/source-of-truth、runner container bind、dry-run sync surface 和结构化 health/preflight 为准。需要验证时优先运行: ```bash bun scripts/cli.ts codex skills-sync --dry-run -bun scripts/code-queue-runner-skills-contract-test.ts bun scripts/cli.ts codex pr-preflight --remote --issue ``` diff --git a/docs/reference/codex-deploy.md b/docs/reference/codex-deploy.md index 2347115b..092ea6a8 100644 --- a/docs/reference/codex-deploy.md +++ b/docs/reference/codex-deploy.md @@ -4,7 +4,7 @@ P0 控制面说明:D601 生产/DEV Kubernetes 入口只允许原生 k3s;Docker Desktop Kubernetes 已停用且不得重新启用。裸 `kubectl` 仍可能命中残留 `docker-desktop` kubeconfig,不能作为 Code Queue 恢复、部署或回滚证据。详细证据和治理计划见 [pikasTech/unidesk#138](https://github.com/pikasTech/unidesk/issues/138),2026-05-23 恢复过程见 [pikasTech/unidesk#118](https://github.com/pikasTech/unidesk/issues/118)。 -Code Queue 后续正式生产部署必须走一条受控 CD 路径并单独审查;当前阶段只提供 dev artifact consumer 的 dry-run/source/contract readiness。`deploy apply --env dev --service code-queue --dry-run` 或 `artifact-registry deploy-service --env dev --service code-queue --dry-run` 可以计划消费 D601 registry 中的 `unidesk/code-queue:`,但输出必须显示 `selfBootstrapGuard`、`requiresSupervisorApproval`、pull-only/no-build、image tag/digest provenance,并说明只会在获授权后更新 `unidesk-dev` Code Queue execution slice。非 dry-run DEV apply 必须由 human operator/supervisor 在 Code Queue 任务之外授权;当前 Code Queue runner 不能自己上线 Code Queue。`--env prod --service code-queue` 必须明确 unsupported,不能执行生产 artifact deploy、rollout 或 manifest 变更。persistent dev apply 的完整服务范围见 `docs/reference/dev-environment.md`,Code Queue temporary smoke 仍通过 `ci run-dev-e2e`,规则见 `docs/reference/dev-ci-runner.md`。 +Code Queue 后续正式生产部署必须走一条受控 CD 路径并单独审查;当前阶段只提供 dev artifact consumer 的 dry-run/source readiness。`deploy apply --env dev --service code-queue --dry-run` 或 `artifact-registry deploy-service --env dev --service code-queue --dry-run` 可以计划消费 D601 registry 中的 `unidesk/code-queue:`,但输出必须显示 `selfBootstrapGuard`、`requiresSupervisorApproval`、pull-only/no-build、image tag/digest provenance,并说明只会在获授权后更新 `unidesk-dev` Code Queue execution slice。非 dry-run DEV apply 必须由 human operator/supervisor 在 Code Queue 任务之外授权;当前 Code Queue runner 不能自己上线 Code Queue。`--env prod --service code-queue` 必须明确 unsupported,不能执行生产 artifact deploy、rollout 或 manifest 变更。persistent dev apply 的完整服务范围见 `docs/reference/dev-environment.md`,Code Queue temporary smoke 仍通过 `ci run-dev-e2e`,规则见 `docs/reference/dev-ci-runner.md`。 The reproducible dry-run delivery path is: @@ -13,7 +13,7 @@ The reproducible dry-run delivery path is: 3. A human operator reviews the dry-run and, if accepted, separately authorizes DEV apply. That apply may touch only `unidesk-dev` Code Queue execution objects. 4. PROD remains unsupported. A dry-run or plan may document the gap, but no Code Queue runner may perform prod apply, rollout restart, scheduler/runner rebuild, interrupt or cancel as part of delivering Code Queue itself. -This contract is checked by `bun scripts/code-queue-cicd-dry-run-contract-test.ts`. The test is intentionally lightweight and does not run full e2e, Playwright, Code Queue deployment, or task interruption flows. +除非用户明确要求,不为该 CLI 边界新增或运行单元测试/合同测试;验证使用上述 dry-run 入口和必要的语法检查。 ## Command diff --git a/docs/reference/dev-environment.md b/docs/reference/dev-environment.md index 3fa46f9f..c2048724 100644 --- a/docs/reference/dev-environment.md +++ b/docs/reference/dev-environment.md @@ -14,7 +14,7 @@ The dev environment lets users experience the next UniDesk version without inter ## D601 UniDesk Workspace -`D601:UniDesk` 的固定开发 workspace 是 D601 节点上的 `/home/ubuntu/workspace/unidesk-dev`,固定使用 `master` 分支和 `origin git@github.com:pikasTech/unidesk.git`。所有需要在 D601 上改 UniDesk 代码、跑轻量合同测试、验证 `trans`/`tran`/Code Queue runner、收敛分布式敏捷实验补丁的工作,都应先进入这个目录。 +`D601:UniDesk` 的固定开发 workspace 是 D601 节点上的 `/home/ubuntu/workspace/unidesk-dev`,固定使用 `master` 分支和 `origin git@github.com:pikasTech/unidesk.git`。所有需要在 D601 上改 UniDesk 代码、做轻量语法/命令形态验证、验证 `trans`/`tran`/Code Queue runner、收敛分布式敏捷实验补丁的工作,都应先进入这个目录。 每次开始 D601 UniDesk 分布式开发、切换任务、恢复中断或上下文压缩后,先通过 UniDesk SSH 维护桥执行: @@ -29,7 +29,7 @@ trans D601:/home/ubuntu/workspace/unidesk-dev git remote -v Master server 不作为 UniDesk 重型验证机。仓库级 check、Playwright/browser smoke、镜像构建、Rust/Go 编译和 Code Queue runner 实测必须放到 D601、CI runner 或其他获批执行面;master server 只做轻量源码编辑、Git 操作、状态观察和受控调度。唯一例外是 backend-core 主 server 上线:当用户或 issue 明确要求把当前 backend-core 修复上线到主 server 时,可以用 `CARGO_BUILD_JOBS=1`、`--jobs 1` 或 CLI 内置等价限流执行 backend-core 专属编译,并必须用异步 job/status/health 证据回写 issue。 -`scripts/cli.ts`、`scripts/trans`、`scripts/tran`、`scripts/src/ssh.ts` 和相邻的 `trans`/`tran`/SSH helper 是主 server 上人工与 Codex 高频使用的控制入口;这类客户端工具链改进可以直接在 master server `/root/unidesk` 轻量修改、提交并推送到 `origin/master`。该例外只覆盖 CLI/trans/tran 客户端源码、帮助、合同测试和对应 reference 文档,不覆盖 `src/components/provider-gateway` 行为变更、镜像构建、仓库级 check、浏览器 smoke 或其他重型验证。涉及 provider-gateway 代码时仍必须遵循 provider-gateway 版本和远程升级规则。 +`scripts/cli.ts`、`scripts/trans`、`scripts/tran`、`scripts/src/ssh.ts` 和相邻的 `trans`/`tran`/SSH helper 是主 server 上人工与 Codex 高频使用的控制入口;这类客户端工具链改进可以直接在 master server `/root/unidesk` 轻量修改、提交并推送到 `origin/master`。该例外只覆盖 CLI/trans/tran 客户端源码、帮助、语法/命令形态验证和对应 reference 文档,不覆盖 `src/components/provider-gateway` 行为变更、镜像构建、仓库级 check、浏览器 smoke 或其他重型验证。除非用户明确要求,CLI 改动不做单元测试、合同测试或新增测试脚本。涉及 provider-gateway 代码时仍必须遵循 provider-gateway 版本和远程升级规则。 当 `trans`/`tran`/SSH 透传的文件传输、stdin、chunk、编码、timeout 或 route/operation 解析出现高频摩擦时,先优化 CLI 客户端的分块、校验、重试、可观测输出和帮助文档,并用目标 provider/pod/Windows route 的最小闭环证明;只有证据显示 client 侧无法规避 provider-gateway 边界时,才进入 provider-gateway 变更流程。 diff --git a/docs/reference/g14.md b/docs/reference/g14.md index de095996..20cc7b47 100644 --- a/docs/reference/g14.md +++ b/docs/reference/g14.md @@ -90,7 +90,7 @@ bun scripts/cli.ts hwlab g14 control-plane status --lane v02 --source-commit /web/hwlab-cloud-web script -- 'bun run check'` for static unit/contract/layout checks and dist freshness. +- `trans G14:/root/hwlab-v02/.worktree//web/hwlab-cloud-web script -- 'bun run check'` for approved static source/layout checks and dist freshness. - `bun scripts/cli.ts hwlab g14 control-plane status --lane v02` for runtime, Argo, public endpoint, and GitOps alignment. If `origin/v0.2` moved through a parallel PR, use `--pipeline-run` or `--source-commit` and treat same-branch supersession as context rather than failure. - Public API probes for both `/health/live` and `/v1/live-builds`. `/health/live` proves live service health/revision, but Cloud Web build time, image tag/digest, source metadata, and actual runtime commit/revision should be read from `/v1/live-builds`. - A bounded browser/DOM probe against `http://74.48.78.17:19666/` that asserts the deployed page state relevant to the issue. -Cloud Web frontend regressions still use the two-layer validation rule. Deterministic client behavior, such as scroll-follow state machines, Markdown/HTML escaping, shared renderer output, persisted view mapping and DOM class/attribute decisions, should be reproduced first in source-level unit or contract tests; those tests may mock DOM nodes, API responses or renderer input because they are the fast regression guard. The deployed browser or Web-equivalent CLI layer must not mock the user entry, and should prove only the live integration that unit tests cannot prove: the public bundle is deployed, the real page dispatch path creates the expected DOM state, and the user-visible control behaves on the target lane. Do not move every frontend bug into CLI/browser smoke just because it is user-facing. +Cloud Web frontend regressions still use the two-layer validation rule when approved by the task: deterministic source-level checks can cover scroll-follow state machines, Markdown/HTML escaping, shared renderer output, persisted view mapping and DOM class/attribute decisions; the deployed browser or Web-equivalent CLI layer must not mock the user entry, and should prove only the live integration that source-level checks cannot prove: the public bundle is deployed, the real page dispatch path creates the expected DOM state, and the user-visible control behaves on the target lane. Do not move every frontend bug into CLI/browser smoke just because it is user-facing. Cloud Web message Markdown must go through a single shared React renderer component. Do not maintain a hand-written Markdown parser or a `dangerouslySetInnerHTML` message path for normal chat/workbench messages. The shared renderer's fast tests should cover at least GFM table rendering, inline/fenced code, emphasis/strong text and raw HTML escaping. Browser closeout should assert rendered DOM shape, such as `table`/`code`/`strong` counts and absence of injected `script` nodes or executed script flags, instead of comparing the full rendered HTML string. diff --git a/docs/reference/host-codex-commander.md b/docs/reference/host-codex-commander.md index b4f45be3..d32c5d50 100644 --- a/docs/reference/host-codex-commander.md +++ b/docs/reference/host-codex-commander.md @@ -27,14 +27,11 @@ bun scripts/cli.ts commander contract bun scripts/cli.ts commander plan --dry-run [--session-id primary] bun scripts/cli.ts commander smoke --dry-run [--session-id primary] bun scripts/cli.ts commander approval request --action --dry-run [--reason text] [--task-id id] -bun scripts/cli.ts commander prompt-lint --kind gpt55-pr (--prompt-file |--stdin) ``` `plan`、`smoke` 与 `approval request` 必须显式使用 `--dry-run`,缺失时返回 `error=dry-run-required`。 -`commander prompt-lint --kind gpt55-pr` 是指挥官派 GPT-5.5 PR/收口类任务前的本地辅助检查。它只读取 `--prompt-file` 或 `--stdin`,返回结构化 JSON 字段 `ok`、`missingClauses`、`riskLevel`、`suggestedPatchSnippet`、`promptShape.textEchoed=false` 和 `policy.advisoryOnly=true`;不会提交 Code Queue task、不会修改 scheduler、不会访问 live service,也不会回显完整 prompt。即使 lint 发现缺失条款,CLI envelope 仍保持成功退出,调用方应把 `data.ok=false` 当作派单前修补建议,而不是业务 PR 门禁或 `codex submit` admission 规则。 - -`gpt55-pr` 当前检查的硬边界包括:普通 PR 创建/更新、自合并/关闭、rebase/update/冲突处理授权;repo-owned CI/CD、build/publish artifact、DEV image/artifact tag/digest/report 授权;DEV deploy apply、rollout 和 live health verification 默认由 host commander 统一执行;未显式包含 `ROLLOUT_OK` 时 runner 不得竞争 DEV CD lock、deploy apply、rollout 或 live verification;禁止 PROD mutation、密钥读取/打印、数据库手工写入和破坏性回滚。缺失时 `suggestedPatchSnippet` 只给可追加的边界片段,不包含原 prompt。 +GPT-5.5 PR/收口类任务的边界由指挥官在 AgentRun 派单前直接审查,prompt 必须写清 PR 创建/更新、自合并/关闭、rebase/update/冲突处理、repo-owned CI/CD、artifact build/publish、DEV rollout、PROD/secret/DB/破坏性回滚禁止范围和 closeout 字段。 ## Operator-Run Stdio Loop @@ -86,14 +83,12 @@ host commander 不直接编辑 HWLAB 业务代码,不以本地热修绕过 HWL - approval draft preview:只运行 `commander approval request --dry-run` 或 `buildCommanderApprovalDraft`,确认 `requiresExplicitUserApproval=true`、`claudeqq.mutation=false`、`sendImplemented=false`、`dryRunNoClaudeQqSend=true`、`notificationDraft.message` 为 200 字以内中文纯文本、`notificationPath.error=notification-path-unavailable`,并返回 backend-core `microservice proxy claudeqq /api/push/text` 命令;禁止 POST ClaudeQQ。 - SSH bridge boundary:只检查 `commander plan --dry-run` 中 `bridge.mutation=false`、`startPlan.enabled=false` 和 `safetyBoundary.phaseOneMutationAllowed=false`;禁止打开 SSH、PTY 或 stdio bridge。 -历史命名的 dry-run 脚本是: +默认 dry-run 验证入口是: ```bash -bun scripts/host-codex-commander-no-daemon-smoke-contract-test.ts +bun scripts/cli.ts commander smoke --dry-run ``` -该脚本保留历史 `contract-test` 文件名,但只执行 CLI dry-run 和短命 source-level handler/helper,不启动长期进程,也不代表新增合同测试政策。 - ## HTTP | Method | Path | 说明 | diff --git a/docs/reference/pipeline-oa-event-flow.md b/docs/reference/pipeline-oa-event-flow.md index 2bf350a8..51c5b9f0 100644 --- a/docs/reference/pipeline-oa-event-flow.md +++ b/docs/reference/pipeline-oa-event-flow.md @@ -95,4 +95,4 @@ Pipeline 的最终控制模型必须是 100% OA 事件流驱动。开发过程 - 完成态还必须删除文件传输残留:`PIPELINE_MONITOR_APPEND_FILE`、`PIPELINE_MONITOR_STOP_FILE`、`PIPELINE_MONITOR_CONTROL_DIR`、`PIPELINE_NODE_PROMPT_APPEND_FILE`、`control-events.jsonl`、`runControlEventFile`、`appendJsonLine`、`readJsonLineRecords`、`readPromptAppendFile`、`parseCommandFile`、`parseMonitorCommandFile`、`readMonitorCommands`、`fallbackMonitorInterventions`、`syntheticMonitorStopPrompt` 以及 monitor 的 `/pipeline-state` 挂载都不得出现在运行代码中。 - E2E 必须覆盖有审核与无审核两条链路:无审核时由 OA 从 `node-finished` 推进下游;有审核时由 monitor 经 OA 控制事件推进下游;事件都必须带 `pipeline:{pipelineId}` 与 `epoch:{runId}` tag。 - Pipeline 甘特图 E2E 必须证明没有重复审核点、长任务观察有连线且无来源伪点、running node 有实时条、OpenCode step 空闲区间留白、控制箭头来自 OA 控制事件。 -- 代码检查应加入防回归搜索或等价单元测试,防止重新引入 `legacy audit policy flag` 权威字段、旧 audit-request 事件、`legacy batch completion gate` 推进逻辑或非 OA 控制写路径。 +- 代码检查应优先使用防回归搜索或原入口验证,防止重新引入 `legacy audit policy flag` 权威字段、旧 audit-request 事件、`legacy batch completion gate` 推进逻辑或非 OA 控制写路径;除非用户明确要求,不新增测试脚本。 diff --git a/docs/reference/secretary-reference.md b/docs/reference/secretary-reference.md index 29c21825..c89f1a0b 100644 --- a/docs/reference/secretary-reference.md +++ b/docs/reference/secretary-reference.md @@ -69,7 +69,7 @@ **遇到 CLI 报错或"看起来锁了"的现象,不要凭错误文案下结论;按"读源码 + 容器内实测 + git history + 复盘 issue"四步调查。** 第一步读 microservice 源码(UniDesk 主仓 vendoring 的镜像源或外部仓的 `server.ts` / `microservice_proxy.rs`),定位注册路由表、mode 标志位、错误文案生成点;第二步在容器内直接实测(`docker exec ... node ...` 或 `wget http://service:port/...`),拿到真实 200 / 4xx 响应,验证假设;第三步查 git history 看是谁、什么时候、为什么加的;第四步把"原诊断 + 真因 + 实测证据 + 修复点"蒸馏到 `docs/reference/` 并开/更新对应 issue(参考 [pikasTech/unidesk#188](https://github.com/pikasTech/unidesk/issues/188) 复盘结构)。特别警惕"错误文案暗示的因果"——例如 `404 {"error":"X is running in backend-only mode"}` 不代表 mode 锁了写,只代表请求路径没匹配上 catch-all;模式措辞误导是常见陷阱。 -**CLI 改进用直接交互调用验证,不写测试脚本**([cli-friction-no-over-testing](file:///home/ubuntu/.claude/projects/-home-ubuntu-unidesk/memory/cli-friction-no-over-testing.md) 原则)。一次性的根 CLI 调用范式、参数形态、shell escape 规避和端点可用性,直接 `bun scripts/cli.ts ...` 跑一次看 200 / body 就够了;远端透传范式直接 `trans ...` 验证。不要为一次性改进堆 `bun:test` / `mocha` / `jest` 的 contract test。contract test 只在结构性边界(如 action type 全清单、权限边界、长期回归)需要时才写。`docs/reference/cli.md` 已固化的 CLI 范式都经过 2026-06-01 交互验证(actions 端点 addTodo / toggleTodoCompleted / deleteTodo 全部 200 OK),见 cli.md 的 Todo Note 写操作段。 +**CLI 改进用直接交互调用验证,不写测试脚本**([cli-friction-no-over-testing](file:///home/ubuntu/.claude/projects/-home-ubuntu-unidesk/memory/cli-friction-no-over-testing.md) 原则)。一次性的根 CLI 调用范式、参数形态、shell escape 规避和端点可用性,直接 `bun scripts/cli.ts ...` 跑一次看 200 / body 就够了;远端透传范式直接 `trans ...` 验证。除非用户明确要求,不为 CLI 改动新增或运行 `bun:test` / `mocha` / `jest` / contract test;默认最多做语法检查和必要命令形态确认。`docs/reference/cli.md` 已固化的 CLI 范式都经过 2026-06-01 交互验证(actions 端点 addTodo / toggleTodoCompleted / deleteTodo 全部 200 OK),见 cli.md 的 Todo Note 写操作段。 **秘书的本份是维护 Todo Note;Decision Center diary 是用户自己的工作日志,秘书只读不写。** 收到用户口述的进展(完成/未完成/卡点/决策/排程登记),秘书维护 Todo Note(标 completed / 新增子 todo / 拆分 / 改 reminder),不写到 diary。Decision Center diary 是用户承载自己反思和工作日志的私域,秘书写进去等于抢用户的笔、污染第一人称叙述。例外只有读:秘书可以调用 `diary show` / `diary list` / `diary months` 对齐上下文,**不调用 `diary upsert` / `diary edit` / `diary import` / `diary today`**。当 Todo Note 写路径被外部阻塞(如 issue #188 的 backend-only mode)时,秘书必须先把修复升级到日程或明确告诉用户「无法维护 Todo Note,你的进展请你自己在 Todo Note 里勾完成」,不能绕道用 diary 替代。 diff --git a/docs/reference/user-service-delivery.md b/docs/reference/user-service-delivery.md index c420bb5b..3a3396c0 100644 --- a/docs/reference/user-service-delivery.md +++ b/docs/reference/user-service-delivery.md @@ -68,7 +68,7 @@ Frontend is the canonical user-service UI sample. It is not released by target-s - The minimal standard artifact command is `bun scripts/cli.ts ci publish-user-service --service frontend --commit --wait-ms 1200000`. - The expected artifact is `127.0.0.1:5000/unidesk/frontend:` plus its registry digest from the CI output. -- The lightweight closure contract is `bun scripts/frontend-artifact-lane-contract-test.ts`. It checks `deploy.json` dev/prod desired commits, the `CI.json` producer entry, the observed registry digest, dev/prod `/health.deploy.commit` and `deploy.requestedCommit`, producer dry-run readiness, and dev/prod CD dry-run no-build targets. +- Lightweight closure evidence checks `deploy.json` dev/prod desired commits, the `CI.json` producer entry, the observed registry digest, dev/prod `/health.deploy.commit` and `deploy.requestedCommit`, producer dry-run readiness, and dev/prod CD dry-run no-build targets. - Dev CD consumes the same artifact with `bun scripts/cli.ts deploy apply --env dev --service frontend`; it imports the image into D601 native k3s, rolls out `frontend-dev`, syncs auth/session metadata from main-server config, and verifies `/health.deploy.commit`. - Production CD consumes the same artifact with `bun scripts/cli.ts deploy apply --env prod --service frontend`; it recreates only the master-server Compose `frontend` service with `--no-build --no-deps --force-recreate` and verifies image labels plus `/health.deploy.commit`. - `server rebuild frontend` remains a maintenance/local rebuild path only. It is not the standard versioned release truth for frontend. @@ -164,7 +164,7 @@ MDTODO is a k3s-managed user-service artifact consumer. - The minimal standard artifact command is `bun scripts/cli.ts ci publish-user-service --service mdtodo --commit --wait-ms 1200000`. - The expected artifact is `127.0.0.1:5000/unidesk/mdtodo:` plus its registry digest from the CI output. -- The selected commit must include the `UNIDESK_DEPLOY_*` health metadata contract; `bun scripts/issue-9-mdtodo-health-metadata-contract-test.ts` is the focused local guard for `/health.deploy` and `/live.deploy`. +- The selected commit must include the `UNIDESK_DEPLOY_*` health metadata surface; `/health.deploy` and `/live.deploy` are verified through service health or dry-run/live entry evidence, not through default contract tests. - Dev CD must run before prod CD and lands in `unidesk-dev/mdtodo-dev`; production CD lands in `unidesk/mdtodo`. - Both paths must verify Deployment metadata and `/health` or `/live` deploy commit through the Kubernetes API service proxy. - No MDTODO release may add NodePort, hostPort, public business ports or provider-gateway direct business backends. diff --git a/scripts/agentrun-cli-contract-test.ts b/scripts/agentrun-cli-contract-test.ts deleted file mode 100644 index 40a79048..00000000 --- a/scripts/agentrun-cli-contract-test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { readFileSync } from "node:fs"; -import { agentRunHelp } from "./src/agentrun"; -import { rootHelp } from "./src/help"; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -const agentRunUsage = Array.isArray((agentRunHelp() as { usage?: unknown }).usage) - ? ((agentRunHelp() as { usage: unknown[] }).usage).map(String) - : []; - -assertCondition( - agentRunUsage.some((line) => line.includes("control-plane cleanup-runs --min-age-minutes 30 --limit 200 --dry-run")) - && agentRunUsage.some((line) => line.includes("control-plane cleanup-runs --min-age-minutes 30 --limit 200 --confirm")) - && agentRunUsage.some((line) => line.includes("control-plane cleanup-released-pvs --limit 200 --dry-run")) - && agentRunUsage.some((line) => line.includes("control-plane cleanup-released-pvs --limit 200 --confirm")), - "AgentRun help must expose controlled CI workspace retention commands", - agentRunUsage, -); - -assertCondition( - agentRunUsage.some((line) => line.includes("control-plane status --pipeline-run agentrun-v01-ci-")) - && agentRunUsage.some((line) => line.includes("control-plane status --source-commit ")), - "AgentRun help must expose targeted control-plane status drill-down options", - agentRunUsage, -); - -assertCondition( - agentRunUsage.some((line) => line.includes("agentrun get tasks --queue commander --limit 20")) - && agentRunUsage.some((line) => line.includes("agentrun describe task/")) - && agentRunUsage.some((line) => line.includes("agentrun events run/ --after-seq 0 --limit 100")) - && agentRunUsage.some((line) => line.includes("agentrun logs session/ --tail 100")), - "AgentRun help must expose Kubernetes-style resource observation primitives", - agentRunUsage, -); - -assertCondition( - !agentRunUsage.some((line) => line.includes("agentrun v01")), - "AgentRun help must hide the v01 lane from user-facing CLI entrypoints", - agentRunUsage, -); - -assertCondition( - agentRunUsage.some((line) => line.includes("agentrun result run/ --command ")) - && agentRunUsage.some((line) => line.includes("agentrun ack task/ --reader-id cli")) - && agentRunUsage.some((line) => line.includes("agentrun cancel task/ --reason --dry-run")) - && agentRunUsage.some((line) => line.includes("agentrun dispatch task/")) - && agentRunUsage.some((line) => line.includes("agentrun create task --aipod Artificer --prompt-stdin")) - && agentRunUsage.some((line) => line.includes("agentrun apply -f - --dry-run")) - && agentRunUsage.some((line) => line.includes("agentrun steer session/ --prompt-stdin")) - && agentRunUsage.some((line) => line.includes("agentrun send session/ --aipod Artificer --prompt-stdin")), - "AgentRun help must expose resource lifecycle control primitives", - agentRunUsage, -); - -assertCondition( - (agentRunHelp() as { output?: unknown }).output === "human by default; use -o json|yaml or --raw for machine/debug output", - "AgentRun help must declare human output as the default and machine output as opt-in", - agentRunHelp(), -); - -const globalHelp = JSON.stringify(rootHelp()); - -assertCondition( - globalHelp.includes("agentrun get|describe|events|logs|result|ack|cancel|dispatch|create|apply|steer|send"), - "global help must index AgentRun v0.1 entrypoints", - rootHelp(), -); - -const agentRunSource = readFileSync("scripts/src/agentrun.ts", "utf8"); -const runtimeJsonFallback = "\"node <<'NODE' || printf '{}\\\\n'\""; - -assertCondition( - agentRunSource.includes(runtimeJsonFallback) - && agentRunSource.includes("const sourceCommit = params.find((entry) => entry?.name === 'revision')?.value || null;"), - "AgentRun control-plane status must degrade empty runtime JSON snippets instead of failing the whole status probe", -); - -assertCondition( - agentRunSource.includes('type AgentRunBridgeCaptureBackend = "local-backend-core-broker" | "remote-frontend-websocket"') - && agentRunSource.includes('reason: "runner-environment"') - && agentRunSource.includes('degradedReason: "capture-backend-unavailable"') - && agentRunSource.includes('failureKind: "bridge-execution-environment"'), - "AgentRun CLI bridge must use the remote frontend backend in runner/no-Docker environments and classify bridge failures separately", -); - -assertCondition( - !agentRunSource.includes('degradedReason: "agentrun-cli-bridge-failed"'), - "AgentRun CLI bridge must not collapse official AgentRun failures into bridge failures", -); - -assertCondition( - agentRunSource.includes("const resourceArgs = action === undefined ? actionArgs : [action, ...actionArgs];") - && agentRunSource.includes("const options = parseResourceOptions(resourceArgs);") - && agentRunSource.includes("const file = options.file ?? requiredContext(\"apply\", \"-f |-\");"), - "AgentRun resource parser must parse verb-level flags such as apply -f - before requiring runtime config", -); - -assertCondition( - agentRunSource.includes('if (verb === "dispatch") return await resourceDispatch') - && agentRunSource.includes('bridgeActionArgs, options') - && agentRunSource.includes('runAgentRunRestCommand(config, "queue", ["dispatch"') - && agentRunSource.includes('taskListState(options)') - && agentRunSource.includes('["commander", "--reader-id", options.readerId'), - "AgentRun resources must wrap task dispatch and keep default get tasks on active list visibility", -); - -assertCondition( - agentRunSource.includes("function renderFailureLines(value: Record): string[]") - && agentRunSource.includes('lines.push(`Failure: ${failureKind}`)') - && agentRunSource.includes('lines.push(`Message: ${failureMessage}`)') - && agentRunSource.includes("const failure = renderFailureLines(data);") - && agentRunSource.includes("const failure = renderFailureLines(value);"), - "AgentRun resource human output must expose failure kind and message without requiring JSON output", -); - -assertCondition( - agentRunSource.includes("const effectiveLimit = options.tail ?? options.limit;") - && agentRunSource.includes("resourceLogsTailResult(config, ref.name, effectiveLimit, options.fullText)") - && agentRunSource.includes("clientTail: {") - && agentRunSource.includes("return renderEventLike(command, result, { ...options, limit: effectiveLimit }, \"Log\""), - "AgentRun logs must map --tail N into render-only client tailing for human and raw outputs", -); - -assertCondition( - agentRunSource.includes("function agentRunEventSummary(item: Record, payload: Record): string") - && agentRunSource.includes("push(payload.failureKind)") - && agentRunSource.includes("push(error.message)") - && agentRunSource.includes("push(error.additionalDetails)") - && agentRunSource.includes("push(payload.phase)") - && agentRunSource.includes("function agentRunEventCommandId(item: Record, payload: Record): string"), - "AgentRun logs/events must render payload error, backend phase, and command id summaries by default", -); - -assertCondition( - agentRunSource.includes("function commandOutputSummary(payload: Record): string | null") - && agentRunSource.includes("function summarizeStructuredCliOutput(value: Record): string | null") - && agentRunSource.includes("function parseJsonRecordFromText(raw: string): Record | null") - && agentRunSource.includes('if (type === "command_output")') - && agentRunSource.includes("push(commandOutputSummary(payload))") - && agentRunSource.includes('if (type !== "command_output")'), - "AgentRun command_output summaries must use a dedicated low-noise renderer instead of raw payload JSON", -); - -assertCondition( - agentRunSource.includes("isLowSignalJsonField") - && agentRunSource.includes('key === "generatedAt" || key === "cli" || key === "version" || key === "valuesRedacted" || key === "secretMaterialStored"') - && agentRunSource.includes('firstPathText(value, ["action", "operation", "command", "kind"])') - && agentRunSource.includes('"traceId", "lastTraceId", "trace.traceId", "trace.id", "body.traceId", "body.trace.id", "providerTrace.traceId", "runnerTrace.traceId"') - && agentRunSource.includes('"sessionId", "session.sessionId", "body.sessionId", "workspace.selectedAgentSessionId", "workspace.selectedConversation.sessionId"') - && agentRunSource.includes('"conversationId", "session.conversationId", "body.conversationId", "workspace.selectedConversationId", "workspace.selectedConversation.conversationId"') - && agentRunSource.includes('"providerProfile", "backendProfile", "profile", "session.providerProfile", "workspace.providerProfile"') - && agentRunSource.includes('"pipelineRun", "pipelineRunName", "pipelineRun.name", "pipeline.runName", "pipeline.name"') - && agentRunSource.includes("function summarizePartialJsonCommandOutput(raw: string): string | null") - && agentRunSource.includes("if (failure !== null || isFailureLikeStatus(status))"), - "AgentRun command_output summaries must prefer business fields and suppress metadata-only JSON headers", -); - -assertCondition( - agentRunSource.includes("function rerunWithoutDryRun(command: string): string") - && agentRunSource.includes("options.dryRun ? [rerunWithoutDryRun(command)] : undefined"), - "AgentRun dry-run resource mutations must return resource-command follow-up instead of official bridge internals", -); - -console.log(JSON.stringify({ - ok: true, - checks: [ - "AgentRun command help exposes cleanup-runs and cleanup-released-pvs", - "AgentRun command help exposes targeted control-plane status drill-down options", - "AgentRun command help exposes resource observation primitives", - "AgentRun command help hides the v01 lane from user-facing CLI entrypoints", - "AgentRun command help exposes resource lifecycle control primitives", - "AgentRun command help declares human output by default", - "global help indexes AgentRun v0.1 entrypoints", - "AgentRun control-plane status degrades empty runtime JSON snippets", - "AgentRun CLI bridge selects remote frontend backend in runner/no-Docker environments", - "AgentRun CLI bridge keeps AgentRun failures distinct from bridge failures", - "AgentRun resource parser supports apply -f -", - "AgentRun resource task dispatch and active task list visibility", - "AgentRun resource failure output is visible in human mode", - "AgentRun logs tail is enforced by the render-only client", - "AgentRun logs/events expose payload error and backend phase summaries", - "AgentRun command_output summaries use a dedicated low-noise renderer", - "AgentRun command_output summaries prefer business fields over metadata headers", - "AgentRun dry-run mutations keep resource-command follow-up", - ], -})); diff --git a/scripts/artifact-registry-local-provider-contract-test.ts b/scripts/artifact-registry-local-provider-contract-test.ts deleted file mode 100644 index b5581e75..00000000 --- a/scripts/artifact-registry-local-provider-contract-test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { readFileSync } from "node:fs"; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -const artifactRegistrySource = readFileSync("scripts/src/artifact-registry.ts", "utf8"); -const deploySource = readFileSync("scripts/src/deploy.ts", "utf8"); - -assertCondition( - artifactRegistrySource.includes('function isLocalProvider(providerId: string): boolean'), - "artifact-registry must define an explicit local provider predicate", -); -assertCondition( - artifactRegistrySource.includes('providerId === "local"') && artifactRegistrySource.includes('providerId === "D601-local"'), - "local provider predicate must accept only explicit local aliases", -); -assertCondition( - artifactRegistrySource.includes('runCommand(["bash", "-lc", script], repoRoot, { timeoutMs })'), - "local provider must execute the generated artifact script directly with bash -lc", -); -assertCondition( - artifactRegistrySource.includes('local bash -lc '), - "readonly command shape must disclose local execution instead of host.ssh", -); -assertCondition( - deploySource.includes('providerId: string;'), - "deploy options must carry providerId for artifact consumers", -); -assertCondition( - deploySource.includes('providerId: optionValue(args, ["--provider-id", "--provider"]) ?? "D601"'), - "deploy apply must parse provider-id with D601 as the default", -); -assertCondition( - deploySource.includes('"--provider-id", options.providerId'), - "deploy apply must forward provider-id to artifact-registry deploy-service", -); - -process.stdout.write(`${JSON.stringify({ - ok: true, - checks: [ - "artifact-registry supports an explicit local/D601-local provider for D601 host CLI execution", - "local provider runs the same generated scripts through bash -lc without provider SSH self-dispatch", - "deploy apply forwards --provider-id to artifact-registry consumers while defaulting to D601", - ], -}, null, 2)}\n`); diff --git a/scripts/artifact-registry-preflight-classification-contract-test.ts b/scripts/artifact-registry-preflight-classification-contract-test.ts deleted file mode 100644 index 6284398a..00000000 --- a/scripts/artifact-registry-preflight-classification-contract-test.ts +++ /dev/null @@ -1,328 +0,0 @@ -import { - artifactRegistryReadonlyAutoRemotePlan, - artifactRegistryReadonlyResultFromCommand, - buildArtifactRegistryReadonlyProbe, - parseArtifactRegistryOptions, - runArtifactRegistryCommand, -} from "./src/artifact-registry"; -import type { CommandResult } from "./src/command"; - -type JsonRecord = Record; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function asRecord(value: unknown, label: string): JsonRecord { - assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, { value }); - return value as JsonRecord; -} - -function asStringArray(value: unknown, label: string): string[] { - assertCondition(Array.isArray(value), `${label} must be an array`, { value }); - return (value as unknown[]).map(String); -} - -function command(overrides: Partial): CommandResult { - return { - command: ["frontend", "/api/dispatch", "D601", "host.ssh", "health"], - cwd: ".", - exitCode: 0, - stdout: "", - stderr: "", - signal: null, - timedOut: false, - ...overrides, - }; -} - -const options = parseArtifactRegistryOptions(["--provider-id", "D601"]); -const probe = buildArtifactRegistryReadonlyProbe("health", options); -assertCondition(Buffer.byteLength(probe.script, "utf8") < 4000, "readonly registry probe script must fit provider-gateway host.ssh command length limit", { - bytes: Buffer.byteLength(probe.script, "utf8"), - remoteCommandShape: probe.remoteCommandShape, -}); -const hashOutputIndex = probe.script.indexOf('kv config_hash "$config_hash"'); -const matchOutputIndex = probe.script.indexOf("kv config_hash_matches"); -assertCondition(matchOutputIndex > 0 && hashOutputIndex > matchOutputIndex, "readonly registry probe must emit match booleans before long hash values", { - hashOutputIndex, - matchOutputIndex, -}); - -const missing = asRecord(artifactRegistryReadonlyResultFromCommand(probe, command({ - exitCode: 1, - stderr: "provider does not declare host.ssh capability: D601\n", -})), "missing provider ssh result"); -assertCondition(missing.ok === false, "missing provider ssh command should fail", missing); -assertCondition(missing.failureClassification === "provider-ssh-command-missing", "missing provider ssh command classification mismatch", missing); -assertCondition(asStringArray(missing.failedScopes, "missing.failedScopes").includes("provider-ssh-command"), "missing provider ssh command scope should be reported", missing); -assertCondition(typeof missing.recommendedAction === "string" && missing.recommendedAction.length > 0, "missing provider ssh command should include recommendedAction", missing); -assertCondition(typeof missing.remoteCommandShape === "string" && missing.remoteCommandShape.includes("bash -lc"), "missing provider ssh command should include remoteCommandShape", missing); - -const commandShape = asRecord(artifactRegistryReadonlyResultFromCommand(probe, command({ - exitCode: 1, - stderr: "Error: host SSH command is too long: 4039 bytes\n", -})), "command shape result"); -assertCondition(commandShape.ok === false, "oversized host.ssh command should fail", commandShape); -assertCondition(commandShape.failureClassification === "ssh-helper-command-shape-incompatible", "oversized host.ssh command classification mismatch", commandShape); -assertCondition(asStringArray(commandShape.failedScopes, "commandShape.failedScopes").includes("ssh-helper-command-shape"), "oversized host.ssh command scope should be reported", commandShape); - -const timeout = asRecord(artifactRegistryReadonlyResultFromCommand(probe, command({ - exitCode: null, - stderr: "host.ssh task task_contract did not finish within 30000ms\n", - timedOut: true, -})), "timeout result"); -assertCondition(timeout.ok === false, "remote timeout should fail", timeout); -assertCondition(timeout.failureClassification === "remote-command-timeout", "remote timeout classification mismatch", timeout); -assertCondition(asStringArray(timeout.failedScopes, "timeout.failedScopes").includes("remote-command-timeout"), "remote timeout scope should be reported", timeout); -assertCondition(timeout.retryable === true, "remote timeout should be retryable", timeout); - -const successStdout = [ - "readonly=true", - "unit_path=/etc/systemd/system/unidesk-artifact-registry.service", - "compose_path=/home/ubuntu/.unidesk/artifact-registry/compose.yml", - "config_path=/home/ubuntu/.unidesk/artifact-registry/config.yml", - "storage_path=/home/ubuntu/.unidesk/registry-storage", - "unit_exists=true", - "compose_exists=true", - "config_exists=true", - "storage_exists=true", - "systemctl_available=true", - "unit_active=active", - "unit_enabled=enabled", - "docker_available=true", - "container_running=true", - "container_status=running", - "container_image=registry:2.8.3", - "container_restart_policy=unless-stopped", - "listener_count=1", - "bad_listener_count=0", - "loopback_only=true", - "curl_available=true", - "v2_http_code=200", - "config_hash=contract-config", - "compose_hash=contract-compose", - "unit_hash=contract-unit", - "expected_config_hash=contract-config", - "expected_compose_hash=contract-compose", - "expected_unit_hash=contract-unit", - "config_hash_matches=true", - "compose_hash_matches=true", - "unit_hash_matches=true", - "image_matches=true", - "", -].join("\n"); - -const success = asRecord(artifactRegistryReadonlyResultFromCommand(probe, command({ - stdout: successStdout, -})), "success result"); -assertCondition(success.ok === true, "healthy registry command should pass", success); -assertCondition(success.failureClassification === null, "healthy registry should not have a failure classification", success); -assertCondition(asStringArray(success.failedScopes, "success.failedScopes").length === 0, "healthy registry should not have failed scopes", success); -assertCondition(success.recommendedAction === "none", "healthy registry recommendedAction should be none", success); -assertCondition(success.remoteCommandShape === probe.remoteCommandShape, "healthy registry should echo remote command shape", success); - -const compactedSuccessStdout = [ - "readonly=true", - "unit_exists=true", - "compose_exists=true", - "config_exists=true", - "storage_exists=true", - "systemctl_available=true", - "unit_active=active", - "unit_enabled=enabled", - "docker_available=true", - "container_running=true", - "container_status=running", - "container_image=registry:2.8.3", - "container_restart_policy=unless-stopped", - "listener_count=1", - "bad_listener_count=0", - "loopback_only=true", - "curl_available=true", - "v2_http_code=200", - "config_hash_matches=true", - "compose_hash_matches=true", - "unit_hash_matches=true", - "image_matches=true", - "config_hash=contract-config", - "compose_hash=contract-compose", - "unit_hash=contract-unit...", - "", -].join("\n"); -const compactedSuccess = asRecord(artifactRegistryReadonlyResultFromCommand(probe, command({ - stdout: compactedSuccessStdout, -})), "compacted success result"); -assertCondition(compactedSuccess.ok === true, "registry health must survive backend-core task detail compaction when match flags are present", compactedSuccess); -assertCondition(asStringArray(compactedSuccess.failedScopes, "compactedSuccess.failedScopes").length === 0, "compacted healthy registry should not have failed scopes", compactedSuccess); - -const driftStdout = [ - "readonly=true", - "unit_exists=true", - "compose_exists=true", - "config_exists=true", - "storage_exists=true", - "systemctl_available=true", - "unit_active=active", - "unit_enabled=enabled", - "docker_available=true", - "container_running=true", - "container_status=running", - "container_image=registry:2.8.2", - "container_restart_policy=unless-stopped", - "listener_count=1", - "bad_listener_count=0", - "loopback_only=true", - "curl_available=true", - "v2_http_code=200", - "config_hash=old-config", - "compose_hash=old-compose", - "unit_hash=old-unit", - "config_hash_matches=false", - "compose_hash_matches=false", - "unit_hash_matches=false", - "image_matches=false", - "", -].join("\n"); - -const drift = asRecord(artifactRegistryReadonlyResultFromCommand(probe, command({ - stdout: driftStdout, -})), "registry drift result"); -assertCondition(drift.ok === false, "health should fail when rendered config/image drift exists", drift); -assertCondition(drift.failureClassification === "registry-unhealthy", "registry drift should classify as registry-unhealthy", drift); -const driftScopes = asStringArray(drift.failedScopes, "drift.failedScopes"); -assertCondition(driftScopes.includes("rendered-config"), "registry drift should include rendered-config scope", drift); -assertCondition(driftScopes.includes("registry-image"), "registry drift should include registry-image scope", drift); -assertCondition(!driftScopes.includes("control-plane-missing"), "registry drift must not be classified as control-plane missing", drift); - -async function main(): Promise { - const localMissingWithNoRemote = await runArtifactRegistryCommand(["health", "--provider-id", "D601"], { - env: { - CODE_QUEUE_DEV_CONTAINER_MASTER_HOST: "203.0.113.10", - CODE_QUEUE_SERVICE_ROLE: "scheduler", - }, - runRemoteScriptForTest: () => command({ - exitCode: 1, - stderr: "Error response from daemon: No such container: unidesk-backend-core\n", - }), - runCliForTest: () => command({ - exitCode: 1, - stdout: JSON.stringify({ - ok: false, - command: "artifact-registry health --provider-id D601", - data: { - transport: "frontend", - readonly: true, - dispatch: { ok: false, status: 502, body: { ok: false, error: "backend-core proxy unavailable" } }, - wait: null, - result: { - ok: false, - readonly: true, - installed: false, - healthy: false, - decision: "infra-blocked", - retryable: true, - runnerDisposition: "infra-blocked", - failureClassification: "control-plane-missing", - failedScopes: ["control-plane-missing", "backend-core-api"], - runtimeApiHealthy: false, - }, - }, - }), - }), - }); - const controlPlaneMissing = asRecord(localMissingWithNoRemote, "control-plane missing result"); - assertCondition(controlPlaneMissing.ok === false, "missing local and remote control planes should fail", controlPlaneMissing); - assertCondition(controlPlaneMissing.failureClassification === "control-plane-missing", "missing remote control plane should classify control-plane-missing", controlPlaneMissing); - assertCondition(asStringArray(controlPlaneMissing.failedScopes, "controlPlaneMissing.failedScopes").includes("control-plane-missing"), "control-plane missing scope should be reported", controlPlaneMissing); - assertCondition(asRecord(controlPlaneMissing.controlPlane, "controlPlane").localBackendCoreMissing === true, "local backend-core absence should remain evidence", controlPlaneMissing); - - const remoteFallback = await runArtifactRegistryCommand(["health", "--provider-id", "D601"], { - env: { - CODE_QUEUE_DEV_CONTAINER_MASTER_HOST: "74.48.78.17", - }, - runRemoteScriptForTest: () => command({ - exitCode: 1, - stderr: "Error response from daemon: No such container: unidesk-backend-core\n", - }), - runCliForTest: () => command({ - stdout: JSON.stringify({ - ok: true, - command: "artifact-registry health --provider-id D601", - data: { - transport: "frontend", - readonly: true, - result: success, - }, - }), - }), - }); - const remoteFallbackRecord = asRecord(remoteFallback, "remote fallback result"); - assertCondition(remoteFallbackRecord.ok === true, "remote fallback should return the remote registry result", remoteFallbackRecord); - const fallbackControlPlane = asRecord(remoteFallbackRecord.controlPlane, "remote fallback controlPlane"); - assertCondition(fallbackControlPlane.remoteFallbackUsed === true, "remote fallback should be marked", fallbackControlPlane); - assertCondition(fallbackControlPlane.localBackendCoreMissing === true, "local backend-core absence should remain evidence only", fallbackControlPlane); - assertCondition(asStringArray(remoteFallbackRecord.failedScopes, "remoteFallback.failedScopes").length === 0, "remote fallback should preserve registry scopes", remoteFallbackRecord); - - let remoteFirstLocalSshCalls = 0; - const remoteFirst = await runArtifactRegistryCommand(["health", "--provider-id", "D601"], { - env: { - CODE_QUEUE_SERVICE_ROLE: "scheduler", - CODE_QUEUE_DEV_CONTAINER_MASTER_HOST: "74.48.78.17", - }, - runRemoteScriptForTest: () => { - remoteFirstLocalSshCalls += 1; - return command({ exitCode: 1, stderr: "unexpected local ssh path" }); - }, - runCliForTest: () => command({ - stdout: JSON.stringify({ - ok: true, - command: "artifact-registry health --provider-id D601", - data: { - transport: "frontend", - readonly: true, - result: success, - }, - }), - }), - }); - const remoteFirstRecord = asRecord(remoteFirst, "remote first result"); - assertCondition(remoteFirstRecord.ok === true, "runner-like env should succeed through remote frontend first", remoteFirstRecord); - assertCondition(remoteFirstLocalSshCalls === 0, "runner-like env should not require local backend-core before remote frontend", { remoteFirstLocalSshCalls }); - assertCondition(asRecord(remoteFirstRecord.controlPlane, "remoteFirst.controlPlane").remoteFirst === true, "remote-first controlPlane should be marked", remoteFirstRecord.controlPlane); - - const autoPlan = artifactRegistryReadonlyAutoRemotePlan("health", options, { - CODE_QUEUE_SERVICE_ROLE: "scheduler", - CODE_QUEUE_DEV_CONTAINER_MASTER_HOST: "74.48.78.17", - }); - assertCondition(autoPlan.enabled === true, "runner-like env should auto-select remote frontend for readonly registry health", autoPlan); - assertCondition(String(autoPlan.command ?? "").includes("--main-server-ip 74.48.78.17"), "auto remote plan should expose command shape", autoPlan); - - process.stdout.write(`${JSON.stringify({ - ok: true, - checks: [ - "provider-ssh-command missing is classified distinctly", - "oversized host.ssh command shape is classified distinctly", - "remote host.ssh timeout is classified distinctly", - "successful registry readonly probe has no failed scopes", - "runner-like env uses remote frontend before local backend-core", - "local backend-core absence can fall back to remote frontend control plane", - "missing local and remote control planes classify as control-plane-missing", - "rendered-config and registry-image drift classify as registry-unhealthy", - ], - classifications: { - missing: missing.failureClassification, - commandShape: commandShape.failureClassification, - timeout: timeout.failureClassification, - success: success.failureClassification, - drift: drift.failureClassification, - controlPlaneMissing: controlPlaneMissing.failureClassification, - remoteFallback: remoteFallbackRecord.failureClassification, - remoteFirst: remoteFirstRecord.failureClassification, - }, - }, null, 2)}\n`); -} - -if (import.meta.main) { - await main(); -} diff --git a/scripts/artifact-registry-ssh-timeout-contract-test.ts b/scripts/artifact-registry-ssh-timeout-contract-test.ts deleted file mode 100644 index 1f1e4e8d..00000000 --- a/scripts/artifact-registry-ssh-timeout-contract-test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { readFileSync } from "node:fs"; -import { rootPath } from "./src/config"; - -const source = readFileSync(rootPath("scripts/src/artifact-registry.ts"), "utf8"); -const downloadRemoteFileSource = source.slice( - source.indexOf("function downloadRemoteFile("), - source.indexOf("async function runRemoteScriptBackground("), -); - -function assertCondition(condition: unknown, message: string): void { - if (!condition) throw new Error(message); -} - -assertCondition(!source.includes('docker save "$image" | gzip -1"'), "artifact registry must not stream docker save over ssh stdout"); -assertCondition(source.includes("downloadRemoteFile(options, remoteArchive, localArchive"), "compose artifact pull must use verified ssh download"); -assertCondition(source.includes("runRemoteScriptBackground(options, remoteScript"), "remote docker save must run as a background job"); -assertCondition(source.includes('runRemoteScriptBackground(options, deployScript, Math.max(options.timeoutMs, 420_000), "d601-k3s-deploy")'), "D601 k3s deploy must use background polling"); -assertCondition(source.includes('"ssh",\n options.providerId,\n "download"'), "download helper must route through UniDesk ssh download"); -assertCondition(downloadRemoteFileSource.includes('"--chunk-bytes",\n "1048576"'), "artifact ssh download chunk should use MiB-scale blocks after ssh data moved to tcp-pool"); -assertCondition(!downloadRemoteFileSource.includes('"45000"'), "artifact ssh download must not preserve the old provider bridge truncation boundary"); -assertCondition(downloadRemoteFileSource.includes("tee -a") && downloadRemoteFileSource.includes("UNIDESK_JOB_STDERR_FILE"), "artifact ssh download must stream progress stderr into async job logs"); -assertCondition(source.includes("UNIDESK_SSH_CLIENT_TOKEN") && source.includes("UNIDESK_SSH_CLIENT_ROUTE_ALLOWLIST"), "dev frontend artifact deploy must sync scoped ssh runtime keys"); - -console.log(JSON.stringify({ - ok: true, - test: "artifact-registry-ssh-timeout-contract", - assertions: [ - "no docker-save stdout stream over ssh", - "compose artifact uses verified ssh download", - "remote docker save and k3s deploy use background polling", - "artifact downloads use MiB-scale tcp-pool chunks instead of the old bridge truncation boundary", - "artifact download progress is visible in async job stderr", - "dev frontend artifact deploy syncs scoped ssh runtime keys" - ] -}, null, 2)); diff --git a/scripts/auth-broker-contract-test.ts b/scripts/auth-broker-contract-test.ts deleted file mode 100644 index 0d9cc6de..00000000 --- a/scripts/auth-broker-contract-test.ts +++ /dev/null @@ -1,362 +0,0 @@ -import { spawn } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; - -type RunnerDisposition = "ready" | "infra-blocked" | "business-failed"; - -interface FailureContract { - failureKind: string; - httpStatus: number; - runnerDisposition: RunnerDisposition; - retryable: boolean; -} - -const docPath = "docs/reference/auth-broker.md"; -const doc = readFileSync(docPath, "utf8"); -const rustMainPath = "src/components/microservices/auth-broker/src/main.rs"; -const rustCargoPath = "src/components/microservices/auth-broker/Cargo.toml"; -const cliAdapterPath = "scripts/src/auth-broker.ts"; -const configPath = "config.json"; -const deployPath = "deploy.json"; -const composePath = "docker-compose.yml"; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function assertDocContains(text: string): void { - assertCondition(doc.includes(text), `missing auth broker doc text: ${text}`); -} - -function runCli(args: string[], env: Record = {}): Promise<{ status: number | null; stdout: string; stderr: string; json: Record | null }> { - const childEnv = { ...process.env, ...env }; - delete childEnv.GH_TOKEN; - delete childEnv.GITHUB_TOKEN; - delete childEnv.UNIDESK_AUTH_BROKER_URL; - delete childEnv.AUTH_BROKER_URL; - delete childEnv.UNIDESK_AUTH_BROKER_GITHUB_CONFIGURED; - delete childEnv.AUTH_BROKER_GITHUB_CONFIGURED; - delete childEnv.UNIDESK_AUTH_BROKER_GITHUB_CREDENTIAL_REF; - delete childEnv.AUTH_BROKER_GITHUB_CREDENTIAL_REF; - if (env.GH_TOKEN !== undefined) childEnv.GH_TOKEN = env.GH_TOKEN; - if (env.GITHUB_TOKEN !== undefined) childEnv.GITHUB_TOKEN = env.GITHUB_TOKEN; - if (env.UNIDESK_AUTH_BROKER_URL !== undefined) childEnv.UNIDESK_AUTH_BROKER_URL = env.UNIDESK_AUTH_BROKER_URL; - if (env.AUTH_BROKER_URL !== undefined) childEnv.AUTH_BROKER_URL = env.AUTH_BROKER_URL; - if (env.UNIDESK_AUTH_BROKER_GITHUB_CONFIGURED !== undefined) childEnv.UNIDESK_AUTH_BROKER_GITHUB_CONFIGURED = env.UNIDESK_AUTH_BROKER_GITHUB_CONFIGURED; - if (env.AUTH_BROKER_GITHUB_CONFIGURED !== undefined) childEnv.AUTH_BROKER_GITHUB_CONFIGURED = env.AUTH_BROKER_GITHUB_CONFIGURED; - if (env.UNIDESK_AUTH_BROKER_GITHUB_CREDENTIAL_REF !== undefined) childEnv.UNIDESK_AUTH_BROKER_GITHUB_CREDENTIAL_REF = env.UNIDESK_AUTH_BROKER_GITHUB_CREDENTIAL_REF; - if (env.AUTH_BROKER_GITHUB_CREDENTIAL_REF !== undefined) childEnv.AUTH_BROKER_GITHUB_CREDENTIAL_REF = env.AUTH_BROKER_GITHUB_CREDENTIAL_REF; - return new Promise((resolve, reject) => { - const child = spawn("bun", ["scripts/cli.ts", ...args], { - cwd: process.cwd(), - env: childEnv, - }); - const stdoutChunks: Buffer[] = []; - const stderrChunks: Buffer[] = []; - child.stdout.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk))); - child.stderr.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk))); - child.on("error", reject); - child.on("close", (status) => { - const stdout = Buffer.concat(stdoutChunks).toString("utf8"); - let json: Record | null = null; - try { - json = JSON.parse(stdout) as Record; - } catch { - json = null; - } - resolve({ status, stdout, stderr: Buffer.concat(stderrChunks).toString("utf8"), json }); - }); - }); -} - -function dataOf(response: Record): Record { - assertCondition(typeof response.data === "object" && response.data !== null && !Array.isArray(response.data), "CLI response data should be object", response); - return response.data as Record; -} - -function asRecord(value: unknown, message: string): Record { - assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), message, value); - return value as Record; -} - -function assertServiceRegistration(value: unknown): void { - const registration = asRecord(value, "serviceRegistration must be an object"); - const config = asRecord(registration.config, "serviceRegistration.config must be an object"); - assertCondition(config.ok === true, "auth-broker should be registered in config.json", config); - assertCondition(config.providerId === "main-server", "auth-broker config provider should be main-server", config); - assertCondition(config.composeService === "auth-broker", "auth-broker compose service should be stable", config); - assertCondition(config.containerName === "auth-broker-backend", "auth-broker container name should be stable", config); - assertCondition(config.public === false, "auth-broker must not be public", config); - const compose = asRecord(registration.compose, "serviceRegistration.compose must be an object"); - assertCondition(compose.servicePresent === true, "auth-broker compose service should exist", compose); - assertCondition(compose.profileGated === true, "auth-broker compose service should require auth-broker profile", compose); - assertCondition(compose.publicPortPublished === false, "auth-broker compose service must not publish a public port", compose); - assertCondition(compose.mutatesDefaultRuntime === false, "auth-broker compose registration must not mutate default runtime", compose); - const deploy = asRecord(registration.deploy, "serviceRegistration.deploy must be an object"); - assertCondition(deploy.ok === true, "auth-broker should be registered in deploy.json prod and dev", deploy); - const prod = asRecord(deploy.prod, "serviceRegistration.deploy.prod must be an object"); - const dev = asRecord(deploy.dev, "serviceRegistration.deploy.dev must be an object"); - assertCondition(prod.present === true && dev.present === true, "deploy.json should include auth-broker in prod and dev", deploy); - const runtimeCredentialRef = asRecord(registration.runtimeCredentialRef, "runtimeCredentialRef must be an object"); - assertCondition(runtimeCredentialRef.valuesRead === false && runtimeCredentialRef.valuesPrinted === false, "runtime credential ref must be presence-only", runtimeCredentialRef); -} - -function extractComposeServiceBlock(composeText: string, serviceName: string): string { - const lines = composeText.split("\n"); - const startLine = lines.findIndex((line) => line === ` ${serviceName}:`); - if (startLine < 0) return ""; - let endLine = lines.length; - for (let index = startLine + 1; index < lines.length; index += 1) { - if (/^ [A-Za-z0-9][A-Za-z0-9_-]*:$/u.test(lines[index] ?? "")) { - endLine = index; - break; - } - } - return lines.slice(startLine, endLine).join("\n"); -} - -const requiredOperations = [ - "github.auth.status", - "github.issue.list", - "github.issue.read", - "github.pr.list", - "github.pr.read", - "github.pr.create", - "github.pr.comment.create", -]; - -const forbiddenBoundaries = [ - "gh pr merge", - "arbitrary `gh api`", - "Docker registry login", - "deploy commands", -]; - -const requiredAuditFields = [ - "requestId", - "observedAt", - "caller.plane", - "operation", - "repo", - "credentialRef", - "credentialValuePrinted", - "runnerDisposition", - "retryable", -]; - -const failureContracts: FailureContract[] = [ - { failureKind: "auth-not-configured", httpStatus: 503, runnerDisposition: "infra-blocked", retryable: false }, - { failureKind: "broker-unavailable", httpStatus: 503, runnerDisposition: "infra-blocked", retryable: true }, - { failureKind: "unauthorized-caller", httpStatus: 403, runnerDisposition: "infra-blocked", retryable: false }, - { failureKind: "repo-not-allowed", httpStatus: 403, runnerDisposition: "business-failed", retryable: false }, - { failureKind: "operation-not-allowed", httpStatus: 403, runnerDisposition: "business-failed", retryable: false }, - { failureKind: "dry-run-required", httpStatus: 409, runnerDisposition: "business-failed", retryable: false }, - { failureKind: "validation-failed", httpStatus: 400, runnerDisposition: "business-failed", retryable: false }, - { failureKind: "github-egress-failed", httpStatus: 502, runnerDisposition: "infra-blocked", retryable: true }, - { failureKind: "github-rate-limited", httpStatus: 429, runnerDisposition: "infra-blocked", retryable: true }, - { failureKind: "github-permission-denied", httpStatus: 403, runnerDisposition: "infra-blocked", retryable: false }, - { failureKind: "scope-insufficient", httpStatus: 403, runnerDisposition: "infra-blocked", retryable: false }, - { failureKind: "repo-not-found", httpStatus: 404, runnerDisposition: "business-failed", retryable: false }, - { failureKind: "upstream-invalid-response", httpStatus: 502, runnerDisposition: "infra-blocked", retryable: true }, -]; - -const samplePreflightResponse = { - ok: true, - runnerDisposition: "ready" as const, - failureKind: null, - degradedReason: null, - tokenCoverage: { - ok: true, - source: "auth-broker", - scope: "broker-held-github-credential", - runnerEnvTokenRequired: false, - valuesPrinted: false, - }, - prCapabilityContract: { - targetBranch: "master", - systemGhBinaryRequiredForWrites: false, - preflightCreatesPr: false, - preflightMergesPr: false, - brokerProxy: { - ok: true, - operations: ["github.auth.status", "github.pr.create"], - writesRemote: false, - }, - }, -}; - -function walk(value: unknown, path: string[] = []): void { - if (typeof value === "string") { - assertCondition(!/gh[pousr]_[A-Za-z0-9_]{20,}/u.test(value), "sample must not contain GitHub token-like values", { path, value }); - assertCondition(!/github_pat_[A-Za-z0-9_]+/u.test(value), "sample must not contain GitHub PAT-like values", { path, value }); - assertCondition(!/^Bearer\s+/iu.test(value), "sample must not contain Authorization header values", { path, value }); - return; - } - if (Array.isArray(value)) { - value.forEach((item, index) => walk(item, [...path, String(index)])); - return; - } - if (typeof value === "object" && value !== null) { - for (const [key, entry] of Object.entries(value)) { - assertCondition(!["token", "secret", "authorization", "cookie"].includes(key.toLowerCase()), "sample must not expose secret-bearing keys", { path, key }); - walk(entry, [...path, key]); - } - } -} - -async function main(): Promise { - for (const heading of ["## Existing Paths", "## API", "## Permission Boundary", "## Audit Fields", "## Failure Semantics", "## D601 Dev Acceptance"]) { - assertDocContains(heading); - } - assertDocContains("## P1 Source Registration"); - for (const path of [ - "scripts/src/gh.ts", - "scripts/src/auth-broker.ts", - "src/components/microservices/auth-broker/Cargo.toml", - "src/components/microservices/auth-broker/src/main.rs", - "config.json", - "deploy.json", - "docker-compose.yml", - "scripts/code-queue-pr-preflight-example.ts", - "src/components/microservices/code-queue/src/runtime-preflight.ts", - "scripts/src/code-queue.ts", - "src/components/microservices/code-queue/src/index.ts", - "src/components/microservices/code-queue/docker-compose.d601.yml", - "src/components/microservices/code-queue/Dockerfile", - ]) { - assertDocContains(path); - } - for (const operation of requiredOperations) assertDocContains(operation); - for (const boundary of forbiddenBoundaries) assertDocContains(boundary); - for (const field of requiredAuditFields) assertDocContains(field); - for (const failure of failureContracts) { - assertDocContains(failure.failureKind); - assertCondition(Number.isInteger(failure.httpStatus) && failure.httpStatus >= 400, "failure HTTP status must be an error status", failure); - } - assertCondition(new Set(failureContracts.map((item) => item.failureKind)).size === failureContracts.length, "failure kinds must be unique", failureContracts); - assertCondition(samplePreflightResponse.tokenCoverage.source === "auth-broker", "preflight should use broker token coverage", samplePreflightResponse.tokenCoverage); - assertCondition(samplePreflightResponse.tokenCoverage.runnerEnvTokenRequired === false, "runner env token must not be required", samplePreflightResponse.tokenCoverage); - assertCondition(samplePreflightResponse.prCapabilityContract.brokerProxy.writesRemote === false, "P0 preflight must not write remotely", samplePreflightResponse.prCapabilityContract); - assertCondition(samplePreflightResponse.prCapabilityContract.preflightMergesPr === false, "P0 preflight must not merge PRs", samplePreflightResponse.prCapabilityContract); - walk(samplePreflightResponse); - - for (const path of [rustCargoPath, rustMainPath, cliAdapterPath, configPath, deployPath, composePath]) { - assertCondition(existsSync(path), `required auth broker implementation file is missing: ${path}`); - } - const rustMain = readFileSync(rustMainPath, "utf8"); - const cliAdapter = readFileSync(cliAdapterPath, "utf8"); - const config = JSON.parse(readFileSync(configPath, "utf8")) as { microservices?: Array> }; - const deploy = JSON.parse(readFileSync(deployPath, "utf8")) as { environments?: Record> }> }; - const composeText = readFileSync(composePath, "utf8"); - const authBrokerComposeBlock = extractComposeServiceBlock(composeText, "auth-broker"); - assertCondition(rustMain.includes("GET") && rustMain.includes("/health"), "Rust skeleton should expose GET /health", rustMainPath); - assertCondition(rustMain.includes("/v1/github/gh"), "Rust skeleton should expose credential-request endpoint", rustMainPath); - assertCondition(rustMain.includes("/v1/github/pr-preflight"), "Rust skeleton should expose pr-preflight endpoint", rustMainPath); - assertCondition(rustMain.includes("--healthcheck"), "Rust skeleton should expose a process healthcheck mode", rustMainPath); - assertCondition(rustMain.includes("credential_value_printed: false"), "audit event must force credentialValuePrinted=false", rustMainPath); - assertCondition(!rustMain.includes("GH_TOKEN") && !rustMain.includes("GITHUB_TOKEN"), "Rust skeleton must not read runner token env keys", rustMainPath); - assertCondition(cliAdapter.includes("valuesRead: false") && cliAdapter.includes("valuesPrinted: false"), "CLI adapter must declare secret values unread/unprinted", cliAdapterPath); - assertCondition(cliAdapter.includes("broker-needed") && cliAdapter.includes("auth-missing"), "CLI adapter must expose broker-needed/auth-missing shape", cliAdapterPath); - const configService = config.microservices?.find((item) => item.id === "auth-broker"); - assertCondition(configService !== undefined, "config.json should register auth-broker microservice", configPath); - assertCondition(asRecord(configService?.backend, "auth-broker backend should be object").public === false, "auth-broker backend must be private", configService); - assertCondition(deploy.environments?.prod?.services?.some((item) => item.id === "auth-broker") === true, "deploy.json prod should include auth-broker", deployPath); - assertCondition(deploy.environments?.dev?.services?.some((item) => item.id === "auth-broker") === true, "deploy.json dev should include auth-broker", deployPath); - assertCondition(authBrokerComposeBlock.includes(" auth-broker:") && authBrokerComposeBlock.includes("profiles:") && authBrokerComposeBlock.includes("- auth-broker"), "docker-compose should include auth-broker behind an explicit profile", authBrokerComposeBlock); - assertCondition(!/^\s{4}ports:/mu.test(authBrokerComposeBlock), "auth-broker compose service must not publish ports", authBrokerComposeBlock); - - const noToken = await runCli(["auth-broker", "pr-preflight", "--repo", "pikasTech/unidesk", "--base", "master", "--head", "feature/auth-broker", "--issue", "59", "--dry-run"]); - assertCondition(noToken.status === 1, "missing broker endpoint should exit 1", { status: noToken.status, stdout: noToken.stdout, stderr: noToken.stderr }); - assertCondition(noToken.json?.ok === false, "missing token response envelope should fail", noToken.json); - const noTokenData = dataOf(noToken.json ?? {}); - assertCondition(noTokenData.failureKind === "auth-missing", "missing token should classify as auth-missing", noTokenData); - assertCondition(noTokenData.degradedReason === "broker-needed", "missing token should classify as broker-needed", noTokenData); - assertCondition(noTokenData.brokerNeeded === true, "missing token should set brokerNeeded", noTokenData); - const noTokenAuthBroker = noTokenData.authBroker as Record; - assertCondition(noTokenAuthBroker.source === "broker/auth-broker-needed", "missing broker endpoint should expose broker-needed auth source", noTokenAuthBroker); - assertCondition(noTokenAuthBroker.capability === "missing-token", "missing broker endpoint should expose missing-token capability", noTokenAuthBroker); - assertCondition(noTokenAuthBroker.nextAction === "configure-auth-broker", "missing broker endpoint should expose next action", noTokenAuthBroker); - assertServiceRegistration(noTokenData.serviceRegistration); - assertCondition(!noToken.stdout.includes("contract-secret-marker"), "missing-token response must not leak secret marker strings", noToken.stdout); - - const brokerReady = await runCli([ - "auth-broker", - "pr-preflight", - "--repo", - "pikasTech/unidesk", - "--base", - "master", - "--head", - "feature/auth-broker", - "--issue", - "59", - "--dry-run", - "--endpoint", - "http://user:pass@127.0.0.1:4291?credential=abc", - ]); - assertCondition(brokerReady.status === 0, "configured broker dry-run should exit 0", { status: brokerReady.status, stdout: brokerReady.stdout, stderr: brokerReady.stderr }); - assertCondition(brokerReady.json?.ok === true, "configured broker dry-run envelope should succeed", brokerReady.json); - const readyData = dataOf(brokerReady.json ?? {}); - const tokenCoverage = readyData.tokenCoverage as Record; - const brokerCoverage = readyData.brokerCoverage as Record; - const readyAuthBroker = readyData.authBroker as Record; - const prCapability = readyData.prCapabilityContract as Record; - const brokerProxy = prCapability.brokerProxy as Record; - assertServiceRegistration(readyData.serviceRegistration); - assertCondition(readyAuthBroker.source === "auth-broker", "ready auth broker source should be auth-broker", readyAuthBroker); - assertCondition(readyAuthBroker.capability === "broker-issued-token", "ready auth broker capability should be broker-issued-token", readyAuthBroker); - assertCondition(readyAuthBroker.nextAction === "use-auth-broker", "ready auth broker next action should use broker", readyAuthBroker); - assertCondition(tokenCoverage.source === "auth-broker", "ready token coverage should come from broker", tokenCoverage); - assertCondition(tokenCoverage.runnerEnvTokenRequired === false, "ready token coverage should not require runner env token", tokenCoverage); - assertCondition(tokenCoverage.valuesPrinted === false, "ready token coverage must not print values", tokenCoverage); - assertCondition(String(brokerCoverage.endpoint).includes("http://***:***@127.0.0.1:4291/?..."), "endpoint should be sanitized", brokerCoverage); - assertCondition(prCapability.targetBranch === "master", "P0 capability should preserve target branch", prCapability); - assertCondition(prCapability.authSource === "broker-issued-token", "P0 capability should expose broker-issued-token auth source", prCapability); - assertCondition(prCapability.realPrCreateRequiresCommanderAuthorization === true, "real PR create should require commander authorization", prCapability); - assertCondition(prCapability.preflightCreatesPr === false && prCapability.preflightMergesPr === false, "P0 PR preflight must not write or merge", prCapability); - assertCondition(brokerProxy.writesRemote === false, "P0 broker proxy should not write remote", brokerProxy); - assertCondition(Array.isArray(brokerProxy.operations) && brokerProxy.operations.includes("github.pr.create"), "P0 broker proxy should include PR create dry-run operation", brokerProxy); - walk(readyData); - - const credentialRefPresence = await runCli( - ["auth-broker", "health", "--dry-run", "--endpoint", "http://127.0.0.1:4291"], - { - UNIDESK_AUTH_BROKER_GITHUB_CONFIGURED: "true", - UNIDESK_AUTH_BROKER_GITHUB_CREDENTIAL_REF: "github:contract-secret-marker", - }, - ); - assertCondition(credentialRefPresence.status === 0, "configured credential-ref presence dry-run should exit 0", credentialRefPresence); - const credentialRefData = dataOf(credentialRefPresence.json ?? {}); - const registration = asRecord(credentialRefData.serviceRegistration, "credential-ref health should include service registration"); - const runtimeCredentialRef = asRecord(registration.runtimeCredentialRef, "health registration should include runtimeCredentialRef"); - const credentialRef = asRecord(runtimeCredentialRef.credentialRef, "runtimeCredentialRef.credentialRef should be object"); - assertCondition(runtimeCredentialRef.ok === true, "credential ref presence should be ready when configured flag and ref key are present", runtimeCredentialRef); - assertCondition(credentialRef.valuePreview === "github:", "credential ref preview should be sanitized", credentialRef); - assertCondition(!credentialRefPresence.stdout.includes("contract-secret-marker"), "credential ref dry-run must not print the raw credential ref value", credentialRefPresence.stdout); - walk(credentialRefData); - - process.stdout.write(`${JSON.stringify({ - ok: true, - docPath, - implementation: { - rustMainPath, - rustCargoPath, - cliAdapterPath, - configPath, - deployPath, - composePath, - }, - operations: requiredOperations, - failureKinds: failureContracts.map((item) => item.failureKind), - p0Safety: { - runnerEnvTokenRequired: false, - preflightWritesRemote: false, - secretValuesPrinted: false, - liveWritesDefault: "dry-run-required", - }, - }, null, 2)}\n`); -} - -main().catch((error) => { - process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`); - process.exitCode = 1; -}); diff --git a/scripts/backend-core-artifact-readiness-contract-test.ts b/scripts/backend-core-artifact-readiness-contract-test.ts deleted file mode 100644 index 8c549b63..00000000 --- a/scripts/backend-core-artifact-readiness-contract-test.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { readConfig } from "./src/config"; -import { runCiPublishBackendCoreDryRunPreflight, type PublishPreflightTransport } from "./src/ci"; -import { artifactRegistryReadonlyResultFromCommand, buildArtifactRegistryReadonlyProbe, parseArtifactRegistryOptions } from "./src/artifact-registry"; -import type { CommandResult } from "./src/command"; - -type JsonRecord = Record; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function asRecord(value: unknown, label: string): JsonRecord { - assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, { value }); - return value as JsonRecord; -} - -function asStringArray(value: unknown, label: string): string[] { - assertCondition(Array.isArray(value), `${label} must be an array`, { value }); - return (value as unknown[]).map(String); -} - -function command(overrides: Partial): CommandResult { - return { - command: ["frontend", "/api/dispatch", "D601", "host.ssh", "health"], - cwd: ".", - exitCode: 0, - stdout: "", - stderr: "", - signal: null, - timedOut: false, - ...overrides, - }; -} - -function registryDriftStdout(): string { - return [ - "readonly=true", - "unit_exists=true", - "compose_exists=true", - "config_exists=true", - "storage_exists=true", - "systemctl_available=true", - "unit_active=active", - "unit_enabled=enabled", - "docker_available=true", - "container_running=true", - "container_status=running", - "container_image=registry:2.8.3", - "container_restart_policy=unless-stopped", - "listener_count=1", - "bad_listener_count=0", - "loopback_only=true", - "curl_available=true", - "v2_http_code=200", - "config_hash=contract-config-observed", - "compose_hash=contract-compose", - "unit_hash=contract-unit-observed", - "expected_config_hash=contract-config-expected", - "expected_compose_hash=contract-compose", - "expected_unit_hash=contract-unit-expected", - "config_hash_matches=false", - "compose_hash_matches=true", - "unit_hash_matches=false", - "image_matches=false", - "", - ].join("\n"); -} - -async function main(): Promise { - const commit = "0123456789abcdef0123456789abcdef01234567"; - const config = readConfig(); - const registryOptions = parseArtifactRegistryOptions(["--provider-id", "D601"]); - const registryProbe = buildArtifactRegistryReadonlyProbe("health", registryOptions); - const registry = asRecord(artifactRegistryReadonlyResultFromCommand(registryProbe, command({ - stdout: registryDriftStdout(), - })), "registry health"); - - const transport: PublishPreflightTransport = { - kind: "remote-frontend", - remoteHost: "https://example.invalid", - coreFetch: async () => ({ - ok: true, - status: 200, - body: { ok: true, dbReady: true }, - }), - dispatchHostSsh: async (remoteCommand) => { - if (remoteCommand.includes("/v2/")) { - return { - ok: true, - taskId: "task-registry", - status: "succeeded", - stdout: registryDriftStdout(), - stderr: "", - exitCode: 0, - raw: {}, - }; - } - return { - ok: true, - taskId: "task-ci-runner", - status: "succeeded", - stdout: [ - "provider_host_ssh=ok", - "kubectl=ok", - "docker=ok", - "docker_socket=true", - "namespace=true", - "tekton_pipeline=true", - "tekton_task=true", - "service_account=true", - "pvc=true", - "source_parent_directory=true", - ].join("\n"), - stderr: "", - exitCode: 0, - raw: {}, - }; - }, - commandCwd: "/workspace/unidesk", - artifactRegistryCommand: (probe) => ["mock", probe.action, probe.remoteCommandShape], - }; - - const publish = asRecord(await runCiPublishBackendCoreDryRunPreflight(config, [ - "publish-backend-core", - "--commit", - commit, - "--dry-run", - ], transport), "publish dry-run"); - const publishRequirements = asRecord(publish.artifactRequirements, "publish artifactRequirements"); - const publishLabels = asRecord(publishRequirements.requiredLabels, "publish requiredLabels"); - const publishSummary = asRecord(publish.artifactSummary, "publish artifactSummary"); - const publishDevApplyPath = asRecord(publish.devApplyPath, "publish devApplyPath"); - - const applyResult = spawnSync("bun", [ - "scripts/cli.ts", - "deploy", - "apply", - "--env", - "dev", - "--service", - "backend-core", - "--commit", - commit, - "--dry-run", - ], { - cwd: process.cwd(), - encoding: "utf8", - maxBuffer: 8 * 1024 * 1024, - }); - assertCondition(applyResult.status === 0, "dev deploy dry-run should exit 0", { - status: applyResult.status, - stdoutTail: applyResult.stdout.slice(-2000), - stderrTail: applyResult.stderr.slice(-2000), - }); - const applyEnvelope = asRecord(JSON.parse(applyResult.stdout) as unknown, "deploy apply envelope"); - const applyData = asRecord(applyEnvelope.data, "deploy apply data"); - const applyItems = Array.isArray(applyData.results) ? applyData.results : []; - assertCondition(applyItems.length === 1, "dev deploy dry-run should return one backend-core item", applyData); - const apply = asRecord(applyItems[0], "deploy apply backend-core"); - const applySource = asRecord(apply.source, "deploy apply source"); - const applyRegistry = asRecord(apply.registry, "deploy apply registry"); - const applyBuild = asRecord(apply.build, "deploy apply build"); - const applyLabels = asRecord(apply.requiredLabels, "deploy apply requiredLabels"); - const applyProbe = asRecord(apply.registryProbe, "deploy apply registryProbe"); - const registryFailedScopes = asStringArray(registry.failedScopes, "registry failedScopes"); - const publishBlockedScopes = asStringArray(publish.blockedScopes, "publish blockedScopes"); - - assertCondition(registry.ok === false, "registry health drift should be blocking", registry); - assertCondition(registry.failureClassification === "registry-unhealthy", "registry drift should classify registry-unhealthy", registry); - assertCondition(registryFailedScopes.includes("rendered-config"), "registry drift must expose rendered-config", registry); - assertCondition(registryFailedScopes.includes("registry-image"), "registry drift must expose registry-image", registry); - assertCondition(publish.ok === false, "publish preflight must block on registry drift", publish); - assertCondition(publish.failureClassification === registry.failureClassification, "publish failure classification should match registry health", publish); - assertCondition(publishBlockedScopes.includes("rendered-config"), "publish blockedScopes must include registry rendered-config drift", publish); - assertCondition(publishBlockedScopes.includes("registry-image"), "publish blockedScopes must include registry image drift", publish); - - assertCondition(publish.sourceRepo === apply.sourceRepo, "sourceRepo should match between publish and dev deploy dry-run", { publish, apply }); - assertCondition(publish.targetCommit === apply.commit, "targetCommit should match dev deploy commit", { publish, apply }); - assertCondition(publishSummary.imageRef === apply.sourceImage, "publish artifact image must match dev deploy sourceImage", { publishSummary, apply }); - assertCondition(publish.registryTarget === "127.0.0.1:5000/unidesk/backend-core", "publish registry target mismatch", publish); - assertCondition(applyRegistry.imageRef === publishSummary.imageRef, "dev deploy registry image should match artifactSummary imageRef", { applyRegistry, publishSummary }); - assertCondition(applyRegistry.digest === null && publishSummary.digest === null, "dry-runs must not fake digest values", { applyRegistry, publishSummary }); - assertCondition(applyRegistry.digestHeader === "Docker-Content-Digest", "dev deploy must name digest header", applyRegistry); - assertCondition(applyProbe.digestHeader === "Docker-Content-Digest", "registry probe must name digest header", applyProbe); - assertCondition(applyBuild.willCompile === false, "dev deploy CD must not compile", applyBuild); - assertCondition(applyBuild.willRunCargoBuild === false, "dev deploy CD must not run cargo build", applyBuild); - assertCondition(applyBuild.willRunDockerBuild === false, "dev deploy CD must not run docker build", applyBuild); - assertCondition(applyBuild.producerBoundary === "ci publish-backend-core", "dev deploy producer boundary mismatch", applyBuild); - assertCondition(publishDevApplyPath.pullOnly === true, "publish devApplyPath must be pull-only", publishDevApplyPath); - assertCondition(String(publishDevApplyPath.dryRun ?? "").includes("deploy apply --env dev --service backend-core"), "publish devApplyPath must expose dev apply dry-run", publishDevApplyPath); - assertCondition(!String(publishDevApplyPath.apply ?? "").includes("--env prod"), "publish devApplyPath must not point to prod", publishDevApplyPath); - - for (const [key, value] of Object.entries(publishLabels)) { - assertCondition(applyLabels[key] === value, `deploy apply required label ${key} should match publish preflight`, { publishLabels, applyLabels }); - } - assertCondition(applySource.repo === publish.sourceRepo, "deploy apply source.repo should match publish sourceRepo", { applySource, publish }); - assertCondition(applySource.commit === publish.targetCommit, "deploy apply source.commit should match publish targetCommit", { applySource, publish }); - - process.stdout.write(`${JSON.stringify({ - ok: true, - checks: [ - "backend-core publish dry-run, registry health drift, and dev deploy dry-run agree on source repo, commit, registry image, labels, digest header, and no-build CD", - "registry drift remains a blocker with rendered-config and registry-image failed scopes", - "publish readiness exposes a dev apply dry-run path and never points to prod", - ], - blockedScopes: publishBlockedScopes, - }, null, 2)}\n`); -} - -if (import.meta.main) { - await main(); -} diff --git a/scripts/baidu-netdisk-artifact-guard-contract-test.ts b/scripts/baidu-netdisk-artifact-guard-contract-test.ts deleted file mode 100644 index 959bb95a..00000000 --- a/scripts/baidu-netdisk-artifact-guard-contract-test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { - baiduNetdiskAuthHealthGateStatus, - baiduNetdiskRuntimeSecretRequirements, - runtimeSecretContractFromEnvText, - runtimeSecretPresenceFromEnvText, -} from "./src/artifact-registry"; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -const secretEnvText = [ - "UNIDESK_BAIDU_NETDISK_CLIENT_ID=clientid-clientid-clientid-0000", - "UNIDESK_BAIDU_NETDISK_CLIENT_SECRET=\"clientsecret-clientsecret-0001\"", - "UNIDESK_BAIDU_NETDISK_TOKEN_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", -].join("\n"); - -const present = runtimeSecretPresenceFromEnvText(secretEnvText, baiduNetdiskRuntimeSecretRequirements); -assertCondition(present.every((item) => item.present), "all baidu-netdisk secrets should be present", present); -assertCondition(present.map((item) => item.length).join(",") === "31,30,64", "presence reports only lengths", present); -assertCondition(!JSON.stringify(present).includes("0123456789abcdef"), "secret values must not be exposed", present); - -const presentContract = runtimeSecretContractFromEnvText(secretEnvText, baiduNetdiskRuntimeSecretRequirements, { - path: "/root/unidesk/.state/docker-compose.env", - exists: true, - workDir: "/root/unidesk", - composeEnvFile: ".state/docker-compose.env", - composeService: "baidu-netdisk", - containerName: "baidu-netdisk-backend", -}); -assertCondition(presentContract.secretSource.kind === "compose-env-file", "secret source should name canonical compose env file", presentContract); -assertCondition(presentContract.requiredSecretsPresent === true, "present contract should pass", presentContract); -assertCondition(presentContract.missingSecretKeys.length === 0, "present contract should not list missing keys", presentContract); -assertCondition(presentContract.recommendedAction === "none", "present contract should recommend no action", presentContract); -assertCondition(presentContract.valuesPrinted === false, "present contract must not print values", presentContract); -assertCondition(!JSON.stringify(presentContract).includes("clientsecret"), "contract must not expose fake secret values", presentContract); - -const missing = runtimeSecretPresenceFromEnvText( - "UNIDESK_BAIDU_NETDISK_CLIENT_ID=clientid-clientid-clientid-0000\n", - baiduNetdiskRuntimeSecretRequirements, -); -assertCondition(!missing.every((item) => item.present), "missing env should fail presence contract", missing); -assertCondition( - missing.filter((item) => !item.present).map((item) => item.sourceEnvName).join(",") === "UNIDESK_BAIDU_NETDISK_CLIENT_SECRET,UNIDESK_BAIDU_NETDISK_TOKEN_KEY", - "missing env should identify absent keys without values", - missing, -); - -const missingContract = runtimeSecretContractFromEnvText( - "UNIDESK_BAIDU_NETDISK_CLIENT_ID=clientid-clientid-clientid-0000\n", - baiduNetdiskRuntimeSecretRequirements, - { - path: "/root/unidesk/.state/docker-compose.env", - exists: true, - workDir: "/root/unidesk", - composeEnvFile: ".state/docker-compose.env", - composeService: "baidu-netdisk", - containerName: "baidu-netdisk-backend", - }, -); -assertCondition(missingContract.requiredSecretsPresent === false, "missing contract should fail", missingContract); -assertCondition( - missingContract.missingSecretKeys.join(",") === "UNIDESK_BAIDU_NETDISK_CLIENT_SECRET,UNIDESK_BAIDU_NETDISK_TOKEN_KEY", - "missing contract should expose missing source keys", - missingContract, -); -assertCondition( - String(missingContract.recommendedAction).includes("canonical Compose env file"), - "missing contract should recommend the canonical source", - missingContract, -); - -const healthy = baiduNetdiskAuthHealthGateStatus({ - auth: { - configured: true, - clientIdConfigured: true, - clientSecretConfigured: true, - tokenKeyConfigured: true, - loggedIn: true, - }, -}); -assertCondition(healthy.ok, "healthy auth fields should pass", healthy); - -const degraded = baiduNetdiskAuthHealthGateStatus({ - auth: { - configured: false, - clientIdConfigured: true, - clientSecretConfigured: true, - tokenKeyConfigured: false, - loggedIn: true, - }, -}); -assertCondition(!degraded.ok, "missing auth fields should fail", degraded); -assertCondition( - degraded.failedFields.join(",") === "configured,tokenKeyConfigured", - "auth gate should report failed boolean fields", - degraded, -); - -process.stdout.write(`${JSON.stringify({ - ok: true, - checks: [ - "runtime secret presence reports booleans and lengths only", - "runtime secret contract exposes secretSource/requiredSecretsPresent/missingSecretKeys/recommendedAction without values", - "missing Baidu Netdisk env cannot pass the deploy contract", - "auth health gate requires configured/clientId/clientSecret/tokenKey/loggedIn", - ], - present, - presentContract, - missing: missing.map((item) => ({ ...item, length: item.length })), - missingContract, - degraded, -}, null, 2)}\n`); diff --git a/scripts/check-command-progress-contract-test.ts b/scripts/check-command-progress-contract-test.ts deleted file mode 100644 index a34df0f7..00000000 --- a/scripts/check-command-progress-contract-test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { strict as assert } from "node:assert"; -import { runCommandObserved, type CommandProgress } from "./src/command"; -import { repoRoot } from "./src/config"; - -const progressEvents: CommandProgress[] = []; -const startedAt = Date.now(); -const result = await runCommandObserved( - [ - "bun", - "--eval", - "setTimeout(() => {}, 10_000)", - ], - repoRoot, - { - timeoutMs: 250, - heartbeatMs: 50, - killAfterMs: 100, - onProgress: (progress) => progressEvents.push(progress), - }, -); - -assert.equal(result.timedOut, true); -assert.notEqual(result.signal, null); -assert.ok(result.durationMs !== undefined && result.durationMs < 2_000, `expected bounded duration, got ${result.durationMs}`); -assert.ok(Date.now() - startedAt < 2_000, "silent child should not block beyond timeout"); -assert.ok(progressEvents.length > 0, "silent child must still emit heartbeat progress"); -assert.ok(progressEvents.some((event) => event.elapsedMs > 0 && event.timeoutMs === 250), "heartbeat must expose elapsed and timeout"); -assert.equal(result.stdoutBytes, 0); -assert.equal(result.stderrBytes, 0); -assert.equal(result.stdoutTruncated, false); -assert.equal(result.stderrTruncated, false); - -const noisy = await runCommandObserved( - [ - "bun", - "--eval", - "console.log('x'.repeat(2048)); console.error('y'.repeat(2048));", - ], - repoRoot, - { timeoutMs: 5_000, maxCaptureChars: 128 }, -); - -assert.equal(noisy.exitCode, 0); -assert.ok(noisy.stdout.length <= 128); -assert.ok(noisy.stderr.length <= 128); -assert.equal(noisy.stdoutTruncated, true); -assert.equal(noisy.stderrTruncated, true); - -console.log("check command progress contract ok"); diff --git a/scripts/check-gh-contract-scope-contract-test.ts b/scripts/check-gh-contract-scope-contract-test.ts deleted file mode 100644 index 3e221076..00000000 --- a/scripts/check-gh-contract-scope-contract-test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { strict as assert } from "node:assert"; -import { parseCheckOptions, runChecks } from "./src/check"; - -const scriptsOnly = parseCheckOptions(["--scripts-typecheck"]); -assert.equal(scriptsOnly.scriptsTypecheck, true); -assert.equal(scriptsOnly.ghContracts, false); -assert.equal(scriptsOnly.scriptsTypecheckTimeoutMs, 120_000); -assert.equal(scriptsOnly.checkHeartbeatMs, 15_000); - -const full = parseCheckOptions(["--full"]); -assert.equal(full.scriptsTypecheck, true); -assert.equal(full.ghContracts, true); - -const explicit = parseCheckOptions(["--gh-contracts"]); -assert.equal(explicit.scriptsTypecheck, false); -assert.equal(explicit.ghContracts, true); - -const tunedVisibility = parseCheckOptions(["--scripts-typecheck", "--scripts-typecheck-timeout-ms", "1000", "--check-heartbeat-ms", "200"]); -assert.equal(tunedVisibility.scriptsTypecheck, true); -assert.equal(tunedVisibility.scriptsTypecheckTimeoutMs, 1000); -assert.equal(tunedVisibility.checkHeartbeatMs, 200); - -const minimalConfig = { - project: { name: "contract" }, - runtime: "contract", -} as never; -const result = await runChecks(minimalConfig, { ...scriptsOnly, scriptsTypecheck: false }); -const issueGuard = result.items.find((item) => item.name === "gh:issue-guard-contract"); -const prFiles = result.items.find((item) => item.name === "gh:pr-files-contract"); -const pr = result.items.find((item) => item.name === "gh:pr-contract"); - -for (const item of [issueGuard, prFiles, pr]) { - assert.ok(item, "GitHub contract item should be visible in check output"); - assert.equal(item?.ok, true); - assert.equal((item?.detail as { skipped?: boolean }).skipped, true); -} - -console.log("check gh contract scope contract ok"); diff --git a/scripts/ci-cargo-network-contract-test.ts b/scripts/ci-cargo-network-contract-test.ts deleted file mode 100644 index 712828f7..00000000 --- a/scripts/ci-cargo-network-contract-test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { readFileSync } from "node:fs"; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -const dockerfile = readFileSync("src/components/backend-core/Dockerfile", "utf8"); -const d601Pipeline = readFileSync("src/components/microservices/k3sctl-adapter/k3s/ci/unidesk-ci.pipeline.yaml", "utf8"); -const g14Pipeline = readFileSync("src/components/microservices/k3sctl-adapter/k3s/ci/unidesk-ci.pipeline.g14.yaml", "utf8"); - -for (const name of [ - "CARGO_HTTP_TIMEOUT", - "CARGO_HTTP_LOW_SPEED_LIMIT", - "CARGO_NET_RETRY", - "CARGO_HTTP_MULTIPLEXING", - "CARGO_REGISTRIES_CRATES_IO_PROTOCOL", -]) { - assertCondition(dockerfile.includes(`ARG ${name}=`), `backend-core Dockerfile must accept ${name}`, { name }); - assertCondition(dockerfile.includes(`${name}=${`$\{${name}\}`}`), `backend-core Dockerfile must export ${name}`, { name }); - assertCondition(d601Pipeline.includes(`--build-arg ${name}=`), `D601 CI pipeline must pass ${name}`, { name }); - assertCondition(g14Pipeline.includes(`--build-arg ${name}=`), `G14 CI pipeline must pass ${name}`, { name }); -} - -assertCondition( - dockerfile.includes("CARGO_HTTP_LOW_SPEED_LIMIT=1") && dockerfile.includes("CARGO_HTTP_TIMEOUT=180"), - "backend-core Dockerfile must raise Cargo low-speed tolerance for proxied CI builds", - dockerfile, -); - -console.log(JSON.stringify({ - ok: true, - checks: [ - "backend-core Dockerfile accepts Cargo HTTP/retry build args", - "D601/G14 CI pipelines pass Cargo HTTP/retry build args", - "backend-core CI build tolerates slow proxied crates.io downloads", - ], -})); diff --git a/scripts/ci-install-visibility-contract-test.ts b/scripts/ci-install-visibility-contract-test.ts deleted file mode 100644 index 5b409469..00000000 --- a/scripts/ci-install-visibility-contract-test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { readFileSync } from "node:fs"; -import { ciHelp } from "./src/ci"; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -const source = readFileSync("scripts/src/ci.ts", "utf8"); -const jobsSource = readFileSync("scripts/src/jobs.ts", "utf8"); -const help = JSON.stringify(ciHelp()); - -assertCondition( - source.includes("interface CiInstallOptions") - && source.includes("skipPrewarm: boolFlag(args, \"--skip-prewarm\")") - && source.includes("skipTektonInstall: boolFlag(args, \"--skip-tekton-install\")") - && source.includes("wait: boolFlag(args, \"--wait\")") - && source.includes("prewarm: options.skipPrewarm ? \"skipped\" : \"completed\"") - && source.includes("tektonInstall: options.skipTektonInstall ? \"skipped\" : \"completed\""), - "ci install must expose controlled manifest refresh skip modes", -); - -assertCondition( - source.includes("function installAsync(options: CiInstallOptions)") - && source.includes("\"ci_install\"") - && source.includes("ciInstallCommand(options)") - && source.includes("options.wait ? install(config, options) : installAsync(options)") - && source.includes("if (action === \"install-status\") return installStatus(nameArg ?? \"latest\")"), - "ci install must default to async job mode with status follow-up", -); - -assertCondition( - source.includes("event: \"ci.install.progress\"") - && source.includes("emitCiInstallProgress(\"apply-manifest\", \"started\"") - && source.includes("emitCiInstallProgress(\"upload-manifest\", \"started\"") - && source.includes("emitCiInstallProgress(\"kubectl-apply\", \"started\"") - && source.includes("emitCiInstallProgress(\"install\", \"failed\"") - && jobsSource.includes("summarizeCiInstallJobProgress") - && jobsSource.includes("parseJsonLineEvents(stderrTail, \"ci.install.progress\")"), - "ci install job status must expose stage progress events", -); - -assertCondition( - source.includes("timeoutMs: Math.min(Math.max(waitMs, 15_000), 45_000)") - && source.includes("maxResponseBytes: 3_000_000, timeoutMs: 15_000") - && source.includes("backend-core-dispatch-timeout"), - "ci dispatch submit must use bounded backend-core fetch timeout", -); - -assertCondition( - source.includes("const hostSshBase64UploadChunkChars = 3000") - && source.includes("chunks(encoded, hostSshBase64UploadChunkChars)") - && source.includes("provider-gateway rejects long host.ssh commands"), - "ci host.ssh base64 upload must stay below provider command length limits", -); - -assertCondition( - source.includes("runSshCommandCapture(config, `${target.providerId}:k3s`, [\"script\"], script)") - && source.includes("base64 -d > \\\"$tmp\\\" <<'UNIDESK_CI_MANIFEST_B64'") - && source.includes("test -s \\\"$tmp\\\"") - && source.includes("manifest_bytes="), - "ci manifest apply must embed YAML through k3s route script with byte visibility", -); - -assertCondition( - source.includes("ci_runtime_image_containerd_root_required=true") - && source.includes("failureLines") - && source.includes("rerun with: bun scripts/cli.ts ci install --skip-prewarm"), - "ci prewarm failure must expose concise root/containerd visibility and recovery", -); - -assertCondition( - help.includes("ci install --skip-prewarm") - && help.includes("ci install --skip-prewarm --skip-tekton-install") - && help.includes("ci install-status latest") - && help.includes("ci install --wait --skip-prewarm --skip-tekton-install") - && help.includes("async-job") - && help.includes("Use --wait only for explicit synchronous debugging") - && help.includes("Use --skip-prewarm only to refresh Tekton/CI manifests") - && help.includes("Use --skip-tekton-install with --skip-prewarm only when Tekton is already installed"), - "ci help must document manifest refresh boundaries", - ciHelp(), -); - -console.log(JSON.stringify({ - ok: true, - checks: [ - "ci install exposes controlled manifest refresh skip modes", - "ci install defaults to async job mode with status follow-up", - "ci install job status exposes stage progress events", - "ci dispatch submit uses bounded backend-core fetch timeout", - "ci host.ssh base64 upload stays below provider command length limits", - "ci manifest apply embeds YAML through k3s route script with byte visibility", - "ci prewarm failure exposes concise root/containerd recovery", - "ci help documents manifest refresh boundaries", - ], -})); diff --git a/scripts/ci-logs-visibility-contract-test.ts b/scripts/ci-logs-visibility-contract-test.ts deleted file mode 100644 index da1ab479..00000000 --- a/scripts/ci-logs-visibility-contract-test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { readFileSync } from "node:fs"; -import { ciHelp } from "./src/ci"; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -const help = JSON.stringify(ciHelp()); -const source = readFileSync("scripts/src/ci.ts", "utf8"); - -assertCondition( - help.includes("ci logs [--provider-id G14] [--tail-lines 80]"), - "ci help must expose bounded log tail control", - ciHelp(), -); - -assertCondition( - help.includes('"defaultCapture":"ssh-stream"') || help.includes('"defaultCapture": "ssh-stream"'), - "ci help must declare ssh-stream as the default logs capture path", - ciHelp(), -); - -assertCondition( - source.includes("interface CiLogsOptions") - && source.includes("function ciLogsOptions(args: string[]): CiLogsOptions") - && source.includes("ci logs --tail-lines must be an integer between 1 and 2000") - && source.includes('capture: args.includes("--dispatch-task") ? "dispatch-task" : "ssh-stream"'), - "ci logs must parse bounded tail options", -); - -assertCondition( - source.includes("function extractCiLogConditionHints(value: string): string[]") - && source.includes("function extractCiLogFailureHints(value: string): string[]") - && source.includes("conditionHints") - && source.includes("failureHints"), - "ci logs must expose condition and failure hints without requiring raw output", -); - -assertCondition( - source.includes("runSshCommandCapture(config, `${target.providerId}:k3s`, [\"script\"], script)") - && source.includes("runRemoteKubectl(script, 60_000, 45_000, target)") - && source.includes("kubectl get pipelinerun/${shellQuote(name)} -n unidesk-ci -o jsonpath") - && source.includes("kubectl logs -n unidesk-ci \"$pod\" --all-containers=true --tail=\"$tail_lines\""), - "ci logs must default to SSH stream capture while retaining dispatch-task fallback for Tekton conditions and bounded pod log tails", -); - -console.log(JSON.stringify({ - ok: true, - checks: [ - "ci logs help exposes bounded tail control", - "ci logs help declares ssh-stream default capture", - "ci logs parses bounded tail options", - "ci logs exposes condition and failure hints", - "ci logs defaults to SSH stream capture and retains dispatch-task fallback", - ], -})); diff --git a/scripts/ci-publish-backend-core-preflight-contract-test.ts b/scripts/ci-publish-backend-core-preflight-contract-test.ts deleted file mode 100644 index 7a0d7bab..00000000 --- a/scripts/ci-publish-backend-core-preflight-contract-test.ts +++ /dev/null @@ -1,323 +0,0 @@ -import { readConfig } from "./src/config"; -import { runCiPublishBackendCoreDryRunPreflight, type PublishPreflightTransport } from "./src/ci"; -import { autoRemoteCiPublishUserServiceDryRunPlan } from "./src/remote"; - -type JsonRecord = Record; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function asRecord(value: unknown, label: string): JsonRecord { - assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, { value }); - return value as JsonRecord; -} - -const commit = "0123456789abcdef0123456789abcdef01234567"; -const config = readConfig(); - -function healthyRegistryStdout(): string { - return [ - "systemctl_available=true", - "docker_available=true", - "curl_available=true", - "unit_exists=true", - "unit_active=active", - "unit_enabled=enabled", - "compose_exists=true", - "config_exists=true", - "storage_exists=true", - "container_running=true", - "container_status=running", - "container_image=registry:2.8.3", - "container_restart_policy=always", - "listener_count=1", - "bad_listener_count=0", - "loopback_only=true", - "v2_http_code=200", - "image_matches=true", - "config_hash_matches=true", - "compose_hash_matches=true", - "unit_hash_matches=true", - ].join("\n"); -} - -function readyTransport(kind: "remote-frontend" | "local-docker" = "remote-frontend"): PublishPreflightTransport { - return { - kind, - remoteHost: kind === "remote-frontend" ? "https://example.invalid" : null, - coreFetch: async () => ({ - ok: true, - status: 200, - body: { - ok: true, - dbReady: true, - }, - }), - dispatchHostSsh: async (command) => { - if (command.includes("kubectl get namespace unidesk-ci")) { - return { - ok: true, - taskId: "task-ci-runner", - status: "succeeded", - stdout: [ - "d601_native_k3s_guard=pass kubeconfig=/etc/rancher/k3s/k3s.yaml context=default server=https://127.0.0.1:6443 node=d601", - "provider_host_ssh=ok", - "kubectl=ok", - "docker=ok", - "docker_socket=true", - "namespace=true", - "tekton_pipeline=true", - "tekton_task=true", - "service_account=true", - "pvc=true", - "source_parent_directory=true", - ].join("\n"), - stderr: "", - exitCode: 0, - raw: {}, - }; - } - if (command.includes("/v2/")) { - return { - ok: true, - taskId: "task-registry", - status: "succeeded", - stdout: healthyRegistryStdout(), - stderr: "", - exitCode: 0, - raw: {}, - }; - } - return { - ok: true, - taskId: "task-host-ssh", - status: "succeeded", - stdout: "provider_host_ssh=ok\n", - stderr: "", - exitCode: 0, - raw: {}, - }; - }, - commandCwd: "/workspace/unidesk", - artifactRegistryCommand: (probe) => ["mock", probe.action, probe.remoteCommandShape], - }; -} - -const localMissingTransport: PublishPreflightTransport = { - kind: "local-docker", - coreFetch: async () => ({ - ok: false, - status: 503, - body: { - ok: false, - failureKind: "target-stack-not-running", - runnerDisposition: "infra-blocked", - degradedReason: "backend-core-container-missing", - message: "backend-core unavailable from runner-local Docker", - }, - }), - dispatchHostSsh: async (command, waitMs, remoteTimeoutMs) => ({ - ok: false, - taskId: null, - status: "infra-blocked", - stdout: "", - stderr: `backend-core bridge unavailable while dispatching readonly SSH task (${waitMs}/${remoteTimeoutMs})`, - exitCode: null, - raw: { - ok: false, - failureKind: "target-stack-not-running", - runnerDisposition: "infra-blocked", - command, - }, - }), - commandCwd: "/workspace/unidesk", - artifactRegistryCommand: (probe) => ["mock", probe.action, probe.remoteCommandShape], -}; - -const remoteInfraBlockedTransport: PublishPreflightTransport = { - kind: "remote-frontend", - remoteHost: "https://example.invalid", - coreFetch: async () => ({ - ok: true, - status: 200, - body: { - ok: true, - dbReady: true, - }, - }), - dispatchHostSsh: async (command) => { - if (command.includes("/v2/")) { - return { - ok: true, - taskId: "task-registry", - status: "succeeded", - stdout: [ - "systemctl_available=true", - "docker_available=true", - "curl_available=true", - "unit_exists=true", - "unit_active=active", - "unit_enabled=enabled", - "compose_exists=true", - "config_exists=true", - "storage_exists=true", - "container_running=false", - "container_status=exited", - "container_image=registry:2.8.3", - "container_restart_policy=always", - "listener_count=0", - "bad_listener_count=0", - "loopback_only=false", - "v2_http_code=000", - "image_matches=true", - "config_hash_matches=true", - "compose_hash_matches=true", - "unit_hash_matches=true", - ].join("\n"), - stderr: "", - exitCode: 0, - raw: {}, - }; - } - return { - ok: true, - taskId: "task-ci-runner", - status: "succeeded", - stdout: [ - "d601_native_k3s_guard=pass kubeconfig=/etc/rancher/k3s/k3s.yaml context=default server=https://127.0.0.1:6443 node=d601", - "provider_host_ssh=ok", - "kubectl=ok", - "docker=ok", - "docker_socket=true", - "namespace=true", - "tekton_pipeline=true", - "tekton_task=true", - "service_account=true", - "pvc=true", - "source_parent_directory=true", - ].join("\n"), - stderr: "", - exitCode: 0, - raw: {}, - }; - }, - commandCwd: "/workspace/unidesk", - artifactRegistryCommand: (probe) => ["mock", probe.action, probe.remoteCommandShape], -}; - -async function main(): Promise { - const autoRemote = autoRemoteCiPublishUserServiceDryRunPlan(config, [ - "ci", - "publish-backend-core", - "--commit", - commit, - "--dry-run", - ], { - CODE_QUEUE_SERVICE_ROLE: "scheduler", - CODE_QUEUE_DEV_CONTAINER_MASTER_HOST: "74.48.78.17", - }); - assertCondition(autoRemote.enabled === true, "Code Queue runner backend-core dry-run should auto-select remote frontend transport", autoRemote); - assertCondition(autoRemote.host === "74.48.78.17", "auto remote backend-core plan should use CODE_QUEUE_DEV_CONTAINER_MASTER_HOST", autoRemote); - assertCondition(String(autoRemote.command ?? "").includes("ci publish-backend-core"), "auto remote command should preserve backend-core publish dry-run", autoRemote); - - const localMissing = await runCiPublishBackendCoreDryRunPreflight(config, [ - "publish-backend-core", - "--commit", - commit, - "--dry-run", - ], localMissingTransport); - const localMissingRecord = asRecord(localMissing, "local missing backend-core preflight"); - const localMissingControlPlane = asRecord(localMissingRecord.remoteControlPlaneCandidate, "local missing control plane"); - const localBlockedScopes = Array.isArray(localMissingRecord.blockedScopes) ? localMissingRecord.blockedScopes as string[] : []; - assertCondition(localMissingRecord.ok === false, "local missing backend-core preflight should fail", localMissingRecord); - assertCondition(localMissingRecord.failureClassification === "local-docker-required", "local Docker absence should classify local-docker-required", localMissingRecord); - assertCondition(localMissingControlPlane.transport === "local-docker", "local missing control plane should state local transport", localMissingControlPlane); - assertCondition(localMissingControlPlane.remoteCapable === false, "local missing control plane should not claim remote capable", localMissingControlPlane); - assertCondition(String(localMissingControlPlane.remoteCommandShape ?? "").includes("publish-backend-core"), "fallback should expose backend-core remote command shape", localMissingControlPlane); - assertCondition(localBlockedScopes.includes("local-docker-control-plane"), "local missing blockedScopes should expose local-docker-control-plane", localMissingRecord); - assertCondition(asRecord(localMissingRecord.d601Ci, "local missing d601Ci").dryRunWillCompileRust === false, "local dry-run must not compile Rust", localMissingRecord); - - const result = await runCiPublishBackendCoreDryRunPreflight(config, [ - "publish-backend-core", - "--commit", - commit, - "--dry-run", - ], readyTransport()); - - const record = asRecord(result, "backend-core preflight"); - const source = asRecord(record.source, "source"); - const sourceAuth = asRecord(record.sourceAuth, "sourceAuth"); - const d601Ci = asRecord(record.d601Ci, "d601Ci"); - const ciRunner = asRecord(record.ciRunner, "ciRunner"); - const artifactSummary = asRecord(record.artifactSummary, "artifactSummary"); - const artifactRequirements = asRecord(record.artifactRequirements, "artifactRequirements"); - const requiredLabels = asRecord(artifactRequirements.requiredLabels, "requiredLabels"); - const devApplyPath = asRecord(record.devApplyPath, "devApplyPath"); - const controlledPublish = asRecord(record.controlledPublish, "controlledPublish"); - const controlPlane = asRecord(record.remoteControlPlaneCandidate, "remoteControlPlaneCandidate"); - const blockedScopes = Array.isArray(record.blockedScopes) ? record.blockedScopes as string[] : []; - - assertCondition(record.ok === true, "ready backend-core preflight should pass", record); - assertCondition(record.mode === "dry-run-preflight", "backend-core dry-run must be preflight mode", record); - assertCondition(record.targetCommit === commit, "target commit should be surfaced", record); - assertCondition(record.sourceRepo === "https://github.com/pikasTech/unidesk", "source repo should come from CI.json", record); - assertCondition(record.registryTarget === "127.0.0.1:5000/unidesk/backend-core", "registry target should be D601 backend-core repository", record); - assertCondition(record.wouldBuildOnD601 === true, "preflight should state real publish would build on D601", record); - assertCondition(record.dryRunBuildStarted === false, "dry-run must not start a build", record); - assertCondition(blockedScopes.length === 0, "ready preflight should have no blocked scopes", record); - assertCondition(record.failureClassification === null, "ready preflight should not classify a failure", record); - assertCondition(ciRunner.environment === "D601", "ciRunner should name D601", ciRunner); - assertCondition(controlPlane.transport === "remote-frontend", "ready remote preflight should expose remote frontend transport", controlPlane); - assertCondition(String(controlPlane.remoteCommandShape ?? "").includes("publish-backend-core"), "controlPlane command shape should be backend-core-specific", controlPlane); - assertCondition(source.repoFetchUrl === "git@github.com:pikasTech/unidesk.git", "source auth should use GitHub SSH fetch URL", source); - assertCondition(sourceAuth.egressProxy === "http://127.0.0.1:18789", "source auth should name provider egress proxy", sourceAuth); - assertCondition(d601Ci.wouldBuildOnD601 === true, "D601 CI runner preflight should expose D601 build boundary", d601Ci); - assertCondition(artifactSummary.imageRef === `127.0.0.1:5000/unidesk/backend-core:${commit}`, "artifact should be commit-pinned", artifactSummary); - assertCondition(artifactSummary.digest === null, "dry-run must not fake digest", artifactSummary); - assertCondition(requiredLabels["unidesk.ai/service-id"] === "backend-core", "service id label should be required", requiredLabels); - assertCondition(requiredLabels["unidesk.ai/source-commit"] === commit, "source commit label should be required", requiredLabels); - assertCondition(devApplyPath.pullOnly === true, "dev apply path should be pull-only", devApplyPath); - assertCondition(String(devApplyPath.apply ?? "").includes("deploy apply --env dev --service backend-core"), "dev apply command should be surfaced", devApplyPath); - assertCondition(!String(devApplyPath.apply ?? "").includes("--env prod"), "dev apply path must not point to prod", devApplyPath); - assertCondition(controlledPublish.environment === "D601", "controlled publish should name D601", controlledPublish); - assertCondition(String(record.boundary ?? "").includes("no Rust compile"), "boundary should explicitly forbid Rust compile during dry-run", record); - assertCondition(String(record.recommendedAction ?? "").includes("ci publish-backend-core"), "recommended action should name real publish command", record); - - const remoteBlocked = await runCiPublishBackendCoreDryRunPreflight(config, [ - "publish-backend-core", - "--commit", - commit, - "--dry-run", - ], remoteInfraBlockedTransport); - const remoteBlockedRecord = asRecord(remoteBlocked, "remote infra-blocked backend-core preflight"); - const remoteBlockedControlPlane = asRecord(remoteBlockedRecord.remoteControlPlaneCandidate, "remote infra-blocked control plane"); - const remoteBlockedScopes = Array.isArray(remoteBlockedRecord.blockedScopes) ? remoteBlockedRecord.blockedScopes as string[] : []; - assertCondition(remoteBlockedRecord.ok === false, "remote infra-blocked preflight should fail", remoteBlockedRecord); - assertCondition(remoteBlockedRecord.failureClassification === "registry-unhealthy" || remoteBlockedRecord.failureClassification === "registry-not-installed", "remote infra-blocked should classify registry failure, not local docker", remoteBlockedRecord); - assertCondition(remoteBlockedControlPlane.transport === "remote-frontend", "remote infra-blocked should keep remote frontend transport", remoteBlockedControlPlane); - assertCondition(!remoteBlockedScopes.includes("local-docker-control-plane"), "remote infra-blocked must not blame runner local Docker", remoteBlockedRecord); - assertCondition(remoteBlockedScopes.includes("registry-container") || remoteBlockedScopes.includes("registry-api") || remoteBlockedScopes.includes("provider-ssh-command"), "remote infra-blocked should expose registry/provider scope", remoteBlockedRecord); - assertCondition(String(remoteBlockedRecord.recommendedAction ?? "").includes("registry"), "remote infra-blocked should recommend registry recovery", remoteBlockedRecord); - - process.stdout.write(`${JSON.stringify({ - ok: true, - checks: [ - "runner backend-core dry-run auto-selects the remote frontend control plane", - "local-docker-required fallback is explicit and exposes a backend-core remote command shape", - "remote ready preflight reports D601/registry/artifact requirements without starting CI", - "remote infra-blocked preflight does not misclassify runner-local Docker absence", - "backend-core dry-run exposes target commit, source repo, D601 runner and registry target", - "backend-core dry-run reports wouldBuildOnD601 without starting a build", - "artifact labels and digest requirements are visible without faking a digest", - "standard path is publish artifact, verify labels/digest, then dev pull-only apply", - ], - localBlockedScopes, - readyBlockedScopes: record.blockedScopes, - remoteBlockedScopes, - }, null, 2)}\n`); -} - -if (import.meta.main) { - await main(); -} diff --git a/scripts/ci-publish-user-service-preflight-contract-test.ts b/scripts/ci-publish-user-service-preflight-contract-test.ts deleted file mode 100644 index 456a96c5..00000000 --- a/scripts/ci-publish-user-service-preflight-contract-test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { readConfig } from "./src/config"; -import { runCiPublishUserServiceDryRunPreflight, type PublishPreflightTransport } from "./src/ci"; -import { autoRemoteCiPublishUserServiceDryRunPlan } from "./src/remote"; - -type JsonRecord = Record; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function asRecord(value: unknown, label: string): JsonRecord { - assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, { value }); - return value as JsonRecord; -} - -const commit = "0123456789abcdef0123456789abcdef01234567"; -const config = readConfig(); - -const infraBlockedTransport: PublishPreflightTransport = { - coreFetch: async (path) => ({ - ok: false, - status: 503, - body: { - ok: false, - failureKind: "target-stack-not-running", - runnerDisposition: "infra-blocked", - degradedReason: "backend-core-container-missing", - message: `backend-core unavailable for ${path}`, - }, - }), - dispatchHostSsh: async (command, waitMs, remoteTimeoutMs) => ({ - ok: false, - taskId: null, - status: "infra-blocked", - stdout: "", - stderr: `backend-core bridge unavailable while dispatching readonly SSH task (${waitMs}/${remoteTimeoutMs})`, - exitCode: null, - raw: { - ok: false, - failureKind: "target-stack-not-running", - runnerDisposition: "infra-blocked", - command, - }, - }), - commandCwd: "/workspace/unidesk", - artifactRegistryCommand: (probe) => [process.execPath, "scripts/cli.ts", "ssh", probe.providerId, "argv", "bash", "-lc", probe.script], -}; - -async function main(): Promise { - const autoRemote = autoRemoteCiPublishUserServiceDryRunPlan(config, [ - "ci", - "publish-user-service", - "--service", - "frontend", - "--commit", - commit, - "--dry-run", - ], { - CODE_QUEUE_SERVICE_ROLE: "scheduler", - CODE_QUEUE_DEV_CONTAINER_MASTER_HOST: "74.48.78.17", - }); - assertCondition(autoRemote.enabled === true, "Code Queue runner publish dry-run should auto-select remote frontend transport", autoRemote); - assertCondition(autoRemote.host === "74.48.78.17", "auto remote plan should use CODE_QUEUE_DEV_CONTAINER_MASTER_HOST", autoRemote); - assertCondition(autoRemote.transport === "frontend", "auto remote plan should use frontend transport", autoRemote); - assertCondition(String(autoRemote.command ?? "").includes("--main-server-ip 74.48.78.17"), "auto remote plan should expose command shape", autoRemote); - - const nonRunner = autoRemoteCiPublishUserServiceDryRunPlan(config, [ - "ci", - "publish-user-service", - "--service", - "frontend", - "--commit", - commit, - "--dry-run", - ], {}); - assertCondition(nonRunner.enabled === false, "non-runner local CLI should not auto-remote without a host hint", nonRunner); - assertCondition(nonRunner.failureClassification === "local-docker-required", "non-runner disabled plan should classify local docker requirement", nonRunner); - - const result = await runCiPublishUserServiceDryRunPreflight(config, [ - "publish-user-service", - "--service", - "frontend", - "--commit", - commit, - "--dry-run", - ], infraBlockedTransport); - - const record = asRecord(result, "preflight"); - const source = asRecord(record.source, "source"); - const channels = Array.isArray(record.channels) ? record.channels.map((item) => asRecord(item, "channel")) : []; - const controlChannels = Array.isArray(record.controlChannels) ? record.controlChannels.map((item) => asRecord(item, "control channel")) : []; - const registry = asRecord(record.registry, "registry"); - const controlledPublish = asRecord(record.controlledPublish, "controlledPublish"); - const controlPlane = asRecord(record.controlPlane, "controlPlane"); - const backendCore = asRecord(channels.find((item) => item.channel === "backend-core-api")?.detail, "backendCore detail"); - const backendCoreTransport = asRecord(backendCore.detail, "backendCore transport payload"); - const backendCoreBody = asRecord(backendCoreTransport.body, "backendCore body payload"); - const providerDispatch = asRecord(channels.find((item) => item.channel === "provider-dispatch")?.detail, "providerDispatch detail"); - const missingChannels = Array.isArray(record.missingChannels) ? record.missingChannels as string[] : []; - const missingControlChannels = Array.isArray(record.missingControlChannels) ? record.missingControlChannels as string[] : []; - - assertCondition(record.ok === false, "infra-blocked preflight should fail", record); - assertCondition(record.mode === "dry-run-preflight", "dry-run preflight mode should be reported", record); - assertCondition(record.runnerDisposition === "infra-blocked", "runnerDisposition should be infra-blocked", record); - assertCondition(record.failureClassification === "local-docker-required", "local backend-core absence should classify as local-docker-required", record); - assertCondition(controlPlane.transport === "local-docker", "local preflight should expose local-docker transport", controlPlane); - assertCondition(controlPlane.remoteCapable === false, "local preflight should not claim remote-capable transport", controlPlane); - assertCondition(controlPlane.preferredRunnerPath === "frontend-private-proxy", "controlPlane should name frontend private proxy as preferred path", controlPlane); - assertCondition(Array.isArray(record.missingChannels), "missingChannels should be an array", record); - assertCondition(Array.isArray(record.missingControlChannels), "missingControlChannels should be an array", record); - assertCondition(Array.isArray(record.failedScopes), "failedScopes should be an array", record); - assertCondition((record.failedScopes as unknown[]).includes("local-docker-control-plane"), "failedScopes should expose publish-blocking registry scope", record); - assertCondition(missingChannels.includes("backend-core-api"), "backend-core-api should be missing", record); - assertCondition(missingChannels.includes("database"), "database should be missing", record); - assertCondition(missingChannels.includes("provider-dispatch"), "provider-dispatch should be missing", record); - assertCondition(missingChannels.includes("provider-host-ssh"), "provider-host-ssh should be missing", record); - assertCondition(missingChannels.includes("artifact-registry"), "artifact-registry should be missing", record); - assertCondition(missingControlChannels.join(",") === "backend-core,database,provider,registry", "missingControlChannels should name runner-facing domains", record); - assertCondition(controlChannels.length === 4, "controlChannels should report four runner-facing domains", controlChannels); - assertCondition(controlChannels.every((item) => item.ok === false), "infra-blocked transport should fail every control channel", controlChannels); - assertCondition( - (controlChannels.find((item) => item.channel === "provider")?.probes as unknown[]).join(",") === "provider-dispatch,provider-host-ssh", - "provider control channel should map provider dispatch and host ssh probes", - controlChannels, - ); - assertCondition(!JSON.stringify(record).includes("No such container: unidesk-database"), "raw container error should not leak", record); - assertCondition(backendCoreBody.failureKind === "target-stack-not-running", "backend-core detail should classify target-stack-not-running", backendCoreBody); - assertCondition(providerDispatch.status === "infra-blocked", "provider dispatch should be infra-blocked", providerDispatch); - assertCondition(registry.ok === false, "registry channel should fail without backend-core bridge", registry); - assertCondition(Array.isArray(channels) && channels.length >= 5, "expected five channel probes", channels); - assertCondition(source.mode === "planned-only", "source should remain planned-only", source); - assertCondition(source.repoFetchUrl === "git@github.com:pikasTech/unidesk.git", "source repo should use CI catalog ssh form", source); - assertCondition(asRecord(record.artifactSummary, "artifactSummary").imageRef === `127.0.0.1:5000/unidesk/frontend:${commit}`, "artifact ref should remain commit-pinned", record.artifactSummary); - assertCondition(controlledPublish.environment === "D601", "controlledPublish should name the controlled environment", controlledPublish); - assertCondition(controlledPublish.namespace === "unidesk-ci", "controlledPublish should name the Tekton namespace", controlledPublish); - assertCondition(controlledPublish.pipeline === "unidesk-user-service-artifact-publish", "controlledPublish should name the user-service pipeline", controlledPublish); - assertCondition(String(controlledPublish.command ?? "").includes("--wait-ms 1200000"), "controlledPublish should provide the real publish command shape", controlledPublish); - assertCondition(String(record.boundary ?? "").includes("read-only"), "boundary should state preflight is read-only", record); - - process.stdout.write(`${JSON.stringify({ - ok: true, - checks: [ - "dry-run preflight returns infra-blocked when backend-core/database/provider channels are absent", - "missing channel list names the absent channels", - "artifact summary remains commit-pinned and read-only", - "missingControlChannels maps detailed probes to backend-core/database/provider/registry", - "controlledPublish identifies D601 unidesk-ci as the only place for the real publish", - "runner-like environments auto-select the existing frontend remote transport", - "local backend-core absence is classified as local-docker-required", - ], - missingChannels: record.missingChannels, - missingControlChannels: record.missingControlChannels, - registry, - }, null, 2)}\n`); -} - -if (import.meta.main) { - await main(); -} diff --git a/scripts/claudeqq-artifact-event-contract-test.ts b/scripts/claudeqq-artifact-event-contract-test.ts deleted file mode 100644 index 76290301..00000000 --- a/scripts/claudeqq-artifact-event-contract-test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { readFileSync } from "node:fs"; -import { rootPath } from "./src/config"; -import { runArtifactRegistryCommand } from "./src/artifact-registry"; - -const serviceId = "claudeqq"; -const desiredCommit = "203b1f46684c91340ecbbd8a74502bd55e4f2011"; -const sourceRepo = "https://gitee.com/lyon1998/agent_skills"; -const dockerfile = "claudeqq/Dockerfile"; -const eventPaths = ["/api/events/recent", "/api/events/subscriptions"]; - -type JsonRecord = Record; - -function assertCondition(condition: boolean, message: string): void { - if (!condition) throw new Error(message); -} - -function asRecord(value: unknown, path: string): JsonRecord { - assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${path} must be an object`); - return value as JsonRecord; -} - -function asArray(value: unknown, path: string): unknown[] { - assertCondition(Array.isArray(value), `${path} must be an array`); - return value as unknown[]; -} - -function stringField(value: unknown, path: string): string { - assertCondition(typeof value === "string" && value.length > 0, `${path} must be a non-empty string`); - return value as string; -} - -function serviceById(environment: JsonRecord, id: string, path: string): JsonRecord { - const services = asArray(environment.services, `${path}.services`).map((item, index) => asRecord(item, `${path}.services[${index}]`)); - const service = services.find((item) => item.id === id); - assertCondition(service !== undefined, `${path}.services must include ${id}`); - return service!; -} - -function assertDeployJson(): void { - const deploy = asRecord(JSON.parse(readFileSync(rootPath("deploy.json"), "utf8")), "deploy.json"); - const environments = asRecord(deploy.environments, "deploy.json.environments"); - for (const environment of ["dev", "prod"] as const) { - const service = serviceById(asRecord(environments[environment], `deploy.json.environments.${environment}`), serviceId, `deploy.json.environments.${environment}`); - assertCondition(service.repo === sourceRepo, `${environment} deploy.json claudeqq repo must match source repo`); - assertCondition(service.commitId === desiredCommit, `${environment} deploy.json claudeqq commit must match desired commit`); - } -} - -function assertArtifactCatalog(): void { - const catalog = asRecord(JSON.parse(readFileSync(rootPath("CI.json"), "utf8")), "CI.json"); - const artifacts = asArray(catalog.artifacts, "CI.json.artifacts").map((item, index) => asRecord(item, `CI.json.artifacts[${index}]`)); - const artifact = artifacts.find((item) => item.serviceId === serviceId); - assertCondition(artifact !== undefined, "CI.json must include claudeqq artifact producer"); - assertCondition(artifact!.kind === "source-build", "claudeqq artifact must be source-build"); - assertCondition(artifact!.status === "supported", "claudeqq artifact must be supported"); - assertCondition(artifact!.producer === "ci publish-user-service", "claudeqq artifact producer must be ci publish-user-service"); - const source = asRecord(artifact!.source, "CI.json claudeqq source"); - assertCondition(source.repo === sourceRepo, "claudeqq artifact source repo must match agent_skills"); - assertCondition(source.dockerfile === dockerfile, "claudeqq artifact dockerfile must be claudeqq/Dockerfile"); - const image = asRecord(artifact!.image, "CI.json claudeqq image"); - assertCondition(image.repository === "unidesk/claudeqq", "claudeqq artifact image repository must be unidesk/claudeqq"); -} - -function assertAdapterSourceContract(): void { - const adapter = readFileSync(rootPath("src/components/microservices/claudeqq/adapter.js"), "utf8"); - const imageDockerfile = readFileSync(rootPath("src/components/microservices/claudeqq/Dockerfile"), "utf8"); - for (const eventPath of eventPaths) { - assertCondition(adapter.includes(eventPath), `adapter must expose ${eventPath}`); - } - assertCondition(adapter.includes("claudeqq-event-api-v1"), "adapter health metadata must name the event API contract"); - assertCondition(adapter.includes("adapter-readonly-fallback"), "adapter must include read-only event fallback"); - assertCondition(adapter.includes("CLAUDEQQ_HOST: upstreamHost"), "adapter must bind the upstream server to the private upstream host"); - assertCondition(adapter.includes("CLAUDEQQ_PORT: String(upstreamPort)"), "adapter must bind the upstream server to the private upstream port"); - assertCondition(adapter.includes("POST /api/events/subscriptions"), "adapter health metadata must document event subscription mutation paths"); - assertCondition(imageDockerfile.includes('CMD ["node", "unidesk-adapter.cjs"]'), "claudeqq image must start the UniDesk adapter by default"); -} - -async function assertDryRun(environment: "dev" | "prod"): Promise { - const plan = asRecord(await runArtifactRegistryCommand([ - "deploy-service", - "--env", - environment, - "--service", - serviceId, - "--commit", - desiredCommit, - "--dry-run", - ]), `artifact dry-run ${environment}`); - - assertCondition(plan.ok === true, `${environment} dry-run must be ok`); - assertCondition(plan.supported === true, `${environment} dry-run must be supported`); - assertCondition(plan.dryRun === true, `${environment} dry-run must report dryRun=true`); - assertCondition(plan.mutation === false, `${environment} dry-run must not mutate`); - assertCondition(plan.serviceId === serviceId, `${environment} dry-run serviceId must be claudeqq`); - assertCondition(plan.commit === desiredCommit, `${environment} dry-run commit must match desired commit`); - assertCondition(plan.sourceRepo === sourceRepo, `${environment} dry-run source repo must match`); - - const source = asRecord(plan.source, `${environment}.source`); - assertCondition(source.repo === sourceRepo, `${environment} dry-run source.repo must match`); - assertCondition(source.commit === desiredCommit, `${environment} dry-run source.commit must match`); - assertCondition(source.dockerfile === dockerfile, `${environment} dry-run source.dockerfile must match`); - - const build = asRecord(plan.build, `${environment}.build`); - assertCondition(build.willCompile === false, `${environment} dry-run must not compile on the runtime target`); - assertCondition(build.willRunDockerBuild === false, `${environment} dry-run must not docker build on the runtime target`); - assertCondition(build.willRunDockerComposeBuild === false, `${environment} dry-run must not docker compose build on the runtime target`); - - const labels = asRecord(plan.requiredLabels, `${environment}.requiredLabels`); - assertCondition(labels["unidesk.ai/service-id"] === serviceId, `${environment} labels must include service id`); - assertCondition(labels["unidesk.ai/source-repo"] === sourceRepo, `${environment} labels must include source repo`); - assertCondition(labels["unidesk.ai/source-commit"] === desiredCommit, `${environment} labels must include source commit`); - assertCondition(labels["unidesk.ai/dockerfile"] === dockerfile, `${environment} labels must include dockerfile`); - - const target = asRecord(plan.target, `${environment}.target`); - assertCondition(target.kind === "d601-k3s", `${environment} target kind must be d601-k3s`); - assertCondition(target.namespace === (environment === "dev" ? "unidesk-dev" : "unidesk"), `${environment} namespace must match`); - assertCondition(target.deployment === (environment === "dev" ? "claudeqq-dev" : "claudeqq"), `${environment} deployment must match`); - assertCondition(target.service === (environment === "dev" ? "claudeqq-dev" : "claudeqq"), `${environment} service must match`); - assertCondition(target.runtimeImage === `unidesk-claudeqq:${desiredCommit}`, `${environment} runtime image must be commit-pinned`); - - const validation = asArray(plan.validation, `${environment}.validation`).map((item, index) => stringField(item, `${environment}.validation[${index}]`)); - assertCondition(validation.some((line) => line.includes("service health via Kubernetes API service proxy")), `${environment} dry-run must require API service proxy health`); - assertCondition(validation.some((line) => line.includes("source repo")), `${environment} dry-run must require source repo label validation`); -} - -assertDeployJson(); -assertArtifactCatalog(); -assertAdapterSourceContract(); -await assertDryRun("dev"); -await assertDryRun("prod"); - -console.log(JSON.stringify({ - ok: true, - serviceId, - desiredCommit, - eventPaths, - checks: [ - "deploy.json desired dev/prod commit", - "CI.json artifact producer contract", - "adapter read-only event API fallback and health metadata", - "dev/prod artifact-registry deploy-service dry-runs", - ], -}, null, 2)); diff --git a/scripts/cli-light-import-boundary-contract-test.ts b/scripts/cli-light-import-boundary-contract-test.ts deleted file mode 100644 index e395ecfb..00000000 --- a/scripts/cli-light-import-boundary-contract-test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { readFileSync } from "node:fs"; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function source(path: string): string { - return readFileSync(path, "utf8"); -} - -function staticImports(text: string): string[] { - return [...text.matchAll(/^import\s+(?:type\s+)?(?:[\s\S]*?)\s+from\s+["']([^"']+)["'];/gm)].map((match) => match[1] ?? ""); -} - -const cli = source("scripts/cli.ts"); -const help = source("scripts/src/help.ts"); - -const cliStaticImports = staticImports(cli); -const helpStaticImports = staticImports(help); - -const forbiddenCliStaticImports = ["./src/e2e", "./src/hwlab-cd", "./src/hwlab-g14", "./src/hwlab-node", "./src/agentrun", "./src/platform-infra"]; -const forbiddenHelpStaticImports = ["./hwlab-cd", "./hwlab-g14", "./hwlab-node", "./agentrun", "./platform-infra"]; - -for (const modulePath of forbiddenCliStaticImports) { - assertCondition(!cliStaticImports.includes(modulePath), "ordinary CLI entry must not statically import heavy command modules", { modulePath, cliStaticImports }); -} - -for (const modulePath of forbiddenHelpStaticImports) { - assertCondition(!helpStaticImports.includes(modulePath), "generic help module must not statically import heavy command modules", { modulePath, helpStaticImports }); -} - -for (const modulePath of forbiddenCliStaticImports) { - assertCondition(cli.includes(`await import("${modulePath}")`), "heavy CLI command module must still be loaded on demand", { modulePath }); -} - -for (const modulePath of forbiddenHelpStaticImports) { - assertCondition(help.includes(`await import("${modulePath}")`), "heavy help module must still be loaded on demand", { modulePath }); -} - -const ghBranchIndex = cli.indexOf('if (top === "gh")'); -const configReadIndex = cli.indexOf("const config = readConfig();"); -assertCondition(ghBranchIndex !== -1 && configReadIndex !== -1 && ghBranchIndex < configReadIndex, "gh commands must dispatch before generic config/runtime initialization", { - ghBranchIndex, - configReadIndex, -}); - -console.log(JSON.stringify({ - ok: true, - checks: [ - "cli static imports exclude e2e/HWLAB/G14/AgentRun/platform-infra modules", - "help static imports exclude HWLAB/G14/AgentRun/platform-infra modules", - "heavy command modules remain available through dynamic imports", - "gh dispatch remains before generic config initialization", - ], -})); diff --git a/scripts/cli.ts b/scripts/cli.ts index 791fe08f..43d58d5e 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -294,10 +294,6 @@ async function main(): Promise { if (top === "commander") { const result = runCommanderCommand(args.slice(1)); - if (sub === "prompt-lint") { - emitJson(commandName, result, true); - return; - } const ok = (result as { ok?: unknown }).ok !== false; emitJson(commandName, result, ok); if (!ok) process.exitCode = 1; diff --git a/scripts/code-queue-cicd-dry-run-contract-test.ts b/scripts/code-queue-cicd-dry-run-contract-test.ts deleted file mode 100644 index d62850b1..00000000 --- a/scripts/code-queue-cicd-dry-run-contract-test.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; - -type JsonRecord = Record; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function asRecord(value: unknown, label: string): JsonRecord { - assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, { value }); - return value as JsonRecord; -} - -function asStringArray(value: unknown, label: string): string[] { - assertCondition(Array.isArray(value) && value.every((item) => typeof item === "string"), `${label} must be a string array`, value); - return value as string[]; -} - -function runCli(args: string[], expectStatus: number): JsonRecord { - const result = spawnSync("bun", ["scripts/cli.ts", ...args], { - cwd: process.cwd(), - encoding: "utf8", - maxBuffer: 8 * 1024 * 1024, - }); - assertCondition(result.status === expectStatus, `cli status mismatch for ${args.join(" ")}`, { - status: result.status, - stdout: result.stdout.slice(-3000), - stderr: result.stderr.slice(-3000), - }); - return asRecord(JSON.parse(result.stdout) as unknown, "cli envelope"); -} - -function firstService(envelope: JsonRecord, label: string): JsonRecord { - const data = asRecord(envelope.data, `${label} data`); - const services = Array.isArray(data.services) ? data.services : []; - assertCondition(services.length === 1, `${label} should return exactly one service`, data); - return asRecord(services[0], `${label} service`); -} - -function includes(value: unknown, expected: string): boolean { - return Array.isArray(value) && value.some((item) => item === expected); -} - -const commit = "0123456789abcdef0123456789abcdef01234567"; - -const devPlan = firstService(runCli(["deploy", "plan", "--env", "dev", "--service", "code-queue"], 0), "dev plan"); -const devArtifact = asRecord(devPlan.artifactConsumer, "dev artifactConsumer"); -const devTarget = asRecord(devPlan.target, "dev target"); -const devBoundary = asRecord(devPlan.boundary, "dev boundary"); -const devCd = asRecord(devBoundary.cdConsumer, "dev cdConsumer"); -const devPlanLiveApply = asRecord(devPlan.liveApply, "dev plan liveApply"); -const devPlanGuard = asRecord(devBoundary.selfBootstrapGuard, "dev boundary selfBootstrapGuard"); -const devArtifactGuard = asRecord(devArtifact.selfBootstrapGuard, "dev artifact selfBootstrapGuard"); -const devBuild = asRecord(devArtifact.build, "dev artifact build"); -const devRegistry = asRecord(devArtifact.registry, "dev artifact registry"); - -assertCondition(devArtifact.consumerKind === "d601-k3s-managed", "dev Code Queue must be a k3s-managed artifact consumer", devArtifact); -assertCondition(devArtifact.noRuntimeSourceBuild === true, "dev Code Queue must not build source on the runtime target", devArtifact); -assertCondition(devArtifact.dryRunOnly === true, "dev Code Queue artifact consumer must be dry-run-only until human authorization", devArtifact); -assertCondition(String(devArtifact.blockedReason ?? "").includes("self-bootstrap"), "dev Code Queue blocked reason should name self-bootstrap", devArtifact); -assertCondition(devArtifact.requiresSupervisorApproval === true, "dev Code Queue artifact consumer should require supervisor approval", devArtifact); -assertCondition(devBuild.willRunDockerBuild === false && devBuild.willRunDockerComposeBuild === false, "dev Code Queue CD must be pull-only/no-build", devBuild); -assertCondition(devRegistry.tag === devPlan.commitId && String(devRegistry.imageRef ?? "").endsWith(`:${devPlan.commitId}`), "dev Code Queue registry plan must expose commit tag/image", devRegistry); -assertCondition(devTarget.namespace === "unidesk-dev", "dev Code Queue target namespace must be unidesk-dev", devTarget); -assertCondition(devTarget.deployment === "code-queue-scheduler-dev", "dev Code Queue should target the dev scheduler deployment", devTarget); -assertCondition(devPlanLiveApply.allowed === false, "dev Code Queue live apply must be blocked for Code Queue automation", devPlanLiveApply); -assertCondition(devPlanLiveApply.requiresSupervisorApproval === true, "dev Code Queue live apply must require supervisor approval", devPlanLiveApply); -assertCondition(devPlanGuard.selfBootstrapBlocked === true && devArtifactGuard.selfBootstrapBlocked === true, "dev Code Queue must expose self-bootstrap guards", { devPlanGuard, devArtifactGuard }); -assertCondition(devCd.prodMutationAllowed === false, "dev Code Queue boundary must prohibit production mutation", devCd); -assertCondition(devCd.liveApplyAllowed === false, "dev Code Queue boundary must not advertise direct live apply", devCd); -assertCondition(devCd.liveApplyCommandShape === null, "dev Code Queue boundary must not advertise a run-now command", devCd); -assertCondition(devCd.requiresSupervisorApproval === true, "dev Code Queue boundary should require supervisor approval", devCd); -assertCondition(String(devCd.manualAuthorizationPoint ?? "").includes("DEV apply"), "dev Code Queue boundary should expose the DEV manual authorization point", devCd); -assertCondition(asRecord(devBoundary.ciProducer, "dev ciProducer").allowed === true, "Code Queue CI producer should be allowed for image publication", devBoundary); -assertCondition(includes(devTarget.forbiddenActions, "docker build"), "dev target must forbid runtime docker build", devTarget); -assertCondition(includes(devTarget.forbiddenActions, "NodePort"), "dev target must forbid NodePort", devTarget); - -const prodPlan = firstService(runCli(["deploy", "plan", "--env", "prod", "--service", "code-queue"], 1), "prod plan"); -const prodArtifact = asRecord(prodPlan.artifactConsumer, "prod artifactConsumer"); -const prodTarget = asRecord(prodPlan.target, "prod target"); -const prodBoundary = asRecord(prodPlan.boundary, "prod boundary"); -const prodCd = asRecord(prodBoundary.cdConsumer, "prod cdConsumer"); -const prodGuard = asRecord(prodBoundary.selfBootstrapGuard, "prod boundary selfBootstrapGuard"); -const prodLiveApply = asRecord(prodPlan.liveApply, "prod liveApply"); -const prodUnsupported = asRecord(prodPlan.unsupported, "prod unsupported"); -const prodForbidden = asStringArray(prodTarget.forbiddenActions, "prod forbiddenActions"); - -assertCondition(prodPlan.deploymentPath === "unsupported", "prod Code Queue deployment path must be unsupported", prodPlan); -assertCondition(prodArtifact.consumerKind === "unsupported", "prod Code Queue artifact consumer must be unsupported", prodArtifact); -assertCondition(prodArtifact.registryImage === null, "prod Code Queue plan must not advertise a production registry image target", prodArtifact); -assertCondition(prodArtifact.noRuntimeSourceBuild === true, "prod Code Queue plan must still block runtime source builds", prodArtifact); -assertCondition(prodArtifact.requiresSupervisorApproval === true, "prod Code Queue artifact consumer should require supervisor approval", prodArtifact); -assertCondition(prodTarget.runtimeHost === null, "prod Code Queue plan must not expose a runtime host target", prodTarget); -assertCondition(prodTarget.deployCommandShape === "none", "prod Code Queue plan must not expose a deploy command shape", prodTarget); -assertCondition(prodLiveApply.allowed === false, "prod Code Queue live apply must be blocked", prodLiveApply); -assertCondition(String(prodLiveApply.reason ?? "").includes("production CD is intentionally unsupported"), "prod blocked reason should name the intentional CD gap", prodLiveApply); -assertCondition(String(prodUnsupported.reason ?? "").includes("prod artifact deploy"), "prod unsupported reason should mention prod artifact deploy", prodUnsupported); -assertCondition(prodCd.prodMutationAllowed === false, "prod Code Queue boundary must prohibit production mutation", prodCd); -assertCondition(prodCd.liveApplyCommandShape === null, "prod Code Queue boundary must not advertise a live apply command", prodCd); -assertCondition(prodCd.requiresSupervisorApproval === true, "prod Code Queue boundary should require supervisor approval", prodCd); -assertCondition(prodGuard.selfBootstrapBlocked === true, "prod Code Queue boundary should expose self-bootstrap guard", prodGuard); -assertCondition(String(prodCd.manualAuthorizationPoint ?? "").includes("future supervisor-approved"), "prod boundary should require a future supervisor-approved design", prodCd); -assertCondition(prodForbidden.includes("production namespace mutation"), "prod forbidden actions must include production namespace mutation", prodTarget); -assertCondition(prodForbidden.includes("interrupt running Code Queue tasks"), "prod forbidden actions must include task interrupt", prodTarget); -assertCondition(prodForbidden.includes("cancel running Code Queue tasks"), "prod forbidden actions must include task cancel", prodTarget); -assertCondition(JSON.stringify(prodTarget).includes("code-queue-read"), "prod excluded targets should name production Code Queue deployments", prodTarget); - -const devArtifactDryRun = asRecord(runCli([ - "artifact-registry", - "deploy-service", - "--env", - "dev", - "--service", - "code-queue", - "--commit", - commit, - "--dry-run", -], 0).data, "dev artifact-registry dry-run"); -const artifactTarget = asRecord(devArtifactDryRun.target, "artifact dry-run target"); -const artifactRegistry = asRecord(devArtifactDryRun.registry, "artifact dry-run registry"); -const artifactBuild = asRecord(devArtifactDryRun.build, "artifact dry-run build"); -const artifactLiveApply = asRecord(devArtifactDryRun.liveApply, "artifact dry-run liveApply"); -const artifactGuard = asRecord(devArtifactDryRun.selfBootstrapGuard, "artifact dry-run selfBootstrapGuard"); -const artifactAffectedRuntime = asRecord(devArtifactDryRun.affectedRuntime, "artifact dry-run affectedRuntime"); -const artifactExcludedTargets = JSON.stringify(devArtifactDryRun.excludedTargets); -assertCondition(devArtifactDryRun.mutation === false, "artifact-registry dev dry-run must be non-mutating", devArtifactDryRun); -assertCondition(devArtifactDryRun.requiresSupervisorApproval === true, "artifact-registry dev dry-run must require supervisor approval", devArtifactDryRun); -assertCondition(artifactRegistry.imageRef === `127.0.0.1:5000/unidesk/code-queue:${commit}`, "artifact-registry dev dry-run should expose commit-pinned image", artifactRegistry); -assertCondition(artifactRegistry.digest === null && String(artifactRegistry.digestSource ?? "").includes("manifest HEAD"), "artifact-registry dev dry-run should expose digest provenance", artifactRegistry); -assertCondition(artifactBuild.willCompile === false && artifactBuild.willRunDockerBuild === false && artifactBuild.willRunDockerComposeBuild === false, "artifact-registry dev dry-run must be pull-only/no-build", artifactBuild); -assertCondition(artifactLiveApply.allowed === false && artifactLiveApply.policy === "supervisor-only", "artifact-registry dev dry-run must block self-bootstrap live apply", artifactLiveApply); -assertCondition(artifactLiveApply.requiresSupervisorApproval === true, "artifact-registry dev liveApply should require supervisor approval", artifactLiveApply); -assertCondition(artifactGuard.selfBootstrapBlocked === true, "artifact-registry dev dry-run must expose self-bootstrap guard", artifactGuard); -assertCondition(artifactAffectedRuntime.productionNamespaceAffected === false, "artifact-registry dev dry-run must not affect production namespace", artifactAffectedRuntime); -assertCondition(artifactAffectedRuntime.activeTaskInterruptCancelAffected === false, "artifact-registry dev dry-run must not affect active task control", artifactAffectedRuntime); -assertCondition(artifactExcludedTargets.includes("active tasks") && artifactExcludedTargets.includes("cancel"), "artifact-registry dev dry-run should exclude active task interrupt/cancel", devArtifactDryRun); -assertCondition(artifactTarget.namespace === "unidesk-dev", "artifact-registry dev dry-run must target unidesk-dev", artifactTarget); - -const prodArtifactDryRun = asRecord(runCli([ - "artifact-registry", - "deploy-service", - "--env", - "prod", - "--service", - "code-queue", - "--commit", - commit, - "--dry-run", -], 1).data, "prod artifact-registry dry-run"); -assertCondition(prodArtifactDryRun.error === "unsupported-environment", "artifact-registry prod code-queue should be unsupported", prodArtifactDryRun); -assertCondition(Array.isArray(prodArtifactDryRun.supportedEnvironments) && prodArtifactDryRun.supportedEnvironments.length === 1 && prodArtifactDryRun.supportedEnvironments[0] === "dev", "artifact-registry prod code-queue should expose only dev as supported", prodArtifactDryRun); -assertCondition(prodArtifactDryRun.requiresSupervisorApproval === true, "artifact-registry prod code-queue should require supervisor approval before any future design", prodArtifactDryRun); -assertCondition(asRecord(prodArtifactDryRun.selfBootstrapGuard, "prod artifact selfBootstrapGuard").selfBootstrapBlocked === true, "artifact-registry prod code-queue should expose self-bootstrap guard", prodArtifactDryRun); -assertCondition(JSON.stringify(prodArtifactDryRun).includes("production artifact deploy") && JSON.stringify(prodArtifactDryRun).includes("active task"), "artifact-registry prod code-queue should explain prod deploy and active-task boundaries", prodArtifactDryRun); - -const ciSource = readFileSync("scripts/src/ci.ts", "utf8"); -assertCondition(ciSource.includes('type CiPublishTransport = "auto" | "tekton" | "direct-docker"'), "ci publish-user-service should expose an explicit transport selector"); -assertCondition(ciSource.includes('options.transport === "direct-docker"'), "ci publish-user-service should support direct-docker artifact publish"); -assertCondition(ciSource.includes('options.transport === "auto" && options.serviceId === "code-queue"'), "auto transport should select direct-docker for Code Queue artifacts"); -assertCondition(ciSource.includes("dependsOnLocalUnideskDatabase: false"), "direct-docker publish must not depend on local unidesk-database dispatch"); -assertCondition(ciSource.includes("codeQueueDirectDockerBaseImage") && ciSource.includes("CODE_QUEUE_BASE_IMAGE"), "direct-docker Code Queue publish must use the warmed D601 base image"); -assertCondition(ciSource.includes("directDockerBuildNetworkArgs"), "direct-docker Code Queue publish must own scoped build network args"); -assertCondition(ciSource.includes('"--network"') && ciSource.includes('"host"'), "direct-docker Code Queue publish must use host networking for the D601 provider proxy"); -assertCondition(ciSource.includes("providerGatewayWsEgressProxyUrl") && ciSource.includes("HTTP_PROXY") && ciSource.includes("HTTPS_PROXY"), "direct-docker Code Queue publish must pass scoped provider-gateway proxy build args"); -assertCondition(ciSource.includes("directDockerRegistryCurl"), "direct-docker Code Queue publish must probe the registry through the Docker host namespace when needed"); -assertCondition(ciSource.includes('"--pull"') && ciSource.includes('"never"'), "direct-docker registry probe must not pull helper images"); -assertCondition(ciSource.includes("code-queue-base-image-missing"), "direct-docker Code Queue publish should fail fast when the warmed base image is missing"); -assertCondition(ciSource.includes("no deploy apply, no rollout, no Code Queue restart, no active task mutation"), "direct-docker publish boundary should forbid runtime mutation"); -assertCondition(ciSource.includes("repo-owned Docker artifact publish without backend-core/database dispatch"), "ci help should describe the repo-owned direct-docker delivery path"); - -process.stdout.write(`${JSON.stringify({ - ok: true, - checks: [ - "deploy plan exposes Code Queue CI producer and dev CD consumer boundary", - "dev Code Queue dry-run targets only unidesk-dev and forbids runtime builds/public ports", - "prod Code Queue plan is unsupported and exposes no runtime deploy target", - "prod Code Queue boundary forbids self-deploy, prod mutation, interrupt and cancel actions", - "artifact-registry dev dry-run is non-mutating while prod remains unsupported", - "ci publish-user-service exposes direct-docker Code Queue artifact publish without local database dispatch", - "direct-docker Code Queue artifact publish uses scoped provider-gateway build proxy args", - "direct-docker Code Queue artifact publish probes the loopback registry through Docker host networking", - ], -}, null, 2)}\n`); diff --git a/scripts/code-queue-cli-disclosure-contract-test.ts b/scripts/code-queue-cli-disclosure-contract-test.ts deleted file mode 100644 index ee4bc362..00000000 --- a/scripts/code-queue-cli-disclosure-contract-test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { codexOutputQuery, codexTaskQuery } from "./src/code-queue"; - -type JsonRecord = Record; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function asRecord(value: unknown): JsonRecord { - assertCondition(value !== null && typeof value === "object" && !Array.isArray(value), "expected object", { value }); - return value as JsonRecord; -} - -function asArray(value: unknown): unknown[] { - assertCondition(Array.isArray(value), "expected array", { value }); - return value as unknown[]; -} - -function longText(marker: string, repeat = 220): string { - return Array.from({ length: repeat }, (_, index) => `${marker}-${String(index + 1).padStart(3, "0")} ${"abcdefghijklmnopqrstuvwxyz0123456789".repeat(3)}`).join("\n"); -} - -function detailFixture(path: string): JsonRecord { - assertCondition(path.includes("/summary"), "detail fixture should only fetch summary", { path }); - return { - ok: true, - status: 200, - body: { - ok: true, - summary: { - id: "codex_disclosure_fixture", - queueId: "noise", - status: "failed", - providerId: "D601", - model: "gpt-5.5", - cwd: "/workspace", - prompt: longText("prompt-tail-marker"), - basePrompt: longText("base-tail-marker"), - transcriptCount: 300, - outputCount: 180, - eventCount: 40, - lastAssistantMessage: { - at: "2026-05-23T00:00:00.000Z", - seq: 300, - source: "assistant", - text: longText("assistant-tail-marker"), - }, - toolSummary: { - count: 12, - returned: 8, - limit: 8, - truncated: true, - items: Array.from({ length: 8 }, (_, index) => ({ - seq: index + 1, - at: "2026-05-23T00:00:00.000Z", - kind: "ran", - title: `tool-${index + 1}`, - status: "ok", - commandPreview: longText(`command-tail-marker-${index + 1}`, 20), - outputPreview: longText(`tool-output-tail-marker-${index + 1}`, 20), - rawSeqs: [index + 1], - })), - }, - attempts: Array.from({ length: 8 }, (_, index) => ({ - index: index + 1, - mode: index === 0 ? "initial" : "retry", - terminalStatus: "failed", - stderrTail: longText(`stderr-tail-marker-${index + 1}`, 20), - finalResponse: longText(`attempt-response-tail-marker-${index + 1}`, 30), - feedbackPromptPreview: longText(`feedback-tail-marker-${index + 1}`, 20), - runnerErrorClassification: { scope: "runner-local", globalBlocker: false }, - })), - }, - }, - }; -} - -function outputFixture(path: string): JsonRecord { - assertCondition(path.includes("/output"), "output fixture should fetch output", { path }); - assertCondition(path.includes("limit=20"), "default output should cap large requested limit to 20", { path }); - assertCondition(path.includes("maxTextChars=500"), "default output should cap text preview chars", { path }); - const output = Array.from({ length: 20 }, (_, index) => ({ - seq: index + 101, - at: "2026-05-23T00:00:00.000Z", - channel: index % 2 === 0 ? "command" : "assistant", - method: "fixture", - text: longText(`raw-output-tail-marker-${index + 1}`, 40), - })); - return { - ok: true, - status: 200, - body: { - ok: true, - taskId: "codex_disclosure_fixture", - queueId: "noise", - status: "running", - updatedAt: "2026-05-23T00:00:00.000Z", - mode: "tail", - limit: 20, - total: 240, - maxSeq: 1200, - afterSeq: 0, - nextAfterSeq: 120, - previousBeforeSeq: 101, - hasMore: true, - hasBefore: true, - output, - }, - }; -} - -export function runCodeQueueCliDisclosureContract(): JsonRecord { - const detail = codexTaskQuery("codex_disclosure_fixture", ["--detail"], detailFixture) as JsonRecord; - const detailJson = JSON.stringify(detail); - const summary = asRecord(detail.summary); - const attempts = asRecord(summary.attempts); - const attemptRecords = asArray(attempts.attemptRecords); - const toolSummary = asRecord(summary.toolSummary); - const toolItems = asArray(toolSummary.items); - - assertCondition(attemptRecords.length === 3, "detail should cap attempt records by default", attempts); - assertCondition(attempts.attemptRecordCount === 8 && attempts.attemptRecordsTruncated === true, "detail should expose omitted attempt metadata", attempts); - assertCondition(detailJson.includes("detailOutputPolicy"), "detail should disclose progressive detail policy", summary); - assertCondition(!detailJson.includes("prompt-tail-marker-220"), "detail should not include full prompt tail by default", summary); - assertCondition(!detailJson.includes("assistant-tail-marker-220"), "detail should not include full assistant tail by default", summary); - assertCondition(!detailJson.includes("attempt-response-tail-marker-1-030"), "detail should not include full attempt response by default", attempts); - assertCondition(toolItems.length === 3 && toolSummary.truncated === true, "detail should cap tool summary rows by default", toolSummary); - assertCondition(!detailJson.includes("tool-output-tail-marker-1-020"), "detail should compact tool output previews", toolSummary); - - const output = codexOutputQuery("codex_disclosure_fixture", ["--tail", "--limit", "120"], outputFixture) as JsonRecord; - const page = asRecord(output.outputPage); - const outputRows = asArray(page.output).map(asRecord); - const outputJson = JSON.stringify(output); - const disclosure = asRecord(page.disclosure); - assertCondition(page.requestedLimit === 120 && page.limit === 20 && page.returned === 20, "output should cap large requested limit by default", page); - assertCondition(disclosure.limitCapped === true && disclosure.fullText === false, "output should disclose capped default policy", disclosure); - assertCondition(outputRows.every((row) => row.textTruncated === true && Number(row.textChars) > String(row.text).length), "output rows should expose bounded text previews", page); - assertCondition(!outputJson.includes("raw-output-tail-marker-1-040"), "output should not include full raw text tail by default", page); - assertCondition(String(asRecord(page.commands).fullText).includes("--full-text"), "output should provide explicit full-text command", page); - - return { - ok: true, - checks: [ - "codex task --detail caps attempts and long text by default", - "codex output caps large requested limits by default", - "codex output preserves progressive full-text command", - ], - detailChars: detailJson.length, - outputChars: outputJson.length, - }; -} - -if (import.meta.main) { - process.stdout.write(`${JSON.stringify(runCodeQueueCliDisclosureContract(), null, 2)}\n`); -} diff --git a/scripts/code-queue-cli-read-terminal-contract-test.ts b/scripts/code-queue-cli-read-terminal-contract-test.ts deleted file mode 100644 index bd5f01c5..00000000 --- a/scripts/code-queue-cli-read-terminal-contract-test.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { codexReadTaskForTest } from "./src/code-queue"; - -type JsonRecord = Record; -type FetchCall = { path: string; init?: { method?: string; body?: unknown } }; - -const promptSecret = "PROMPT_BODY_SHOULD_NOT_LEAK_FROM_CODEX_READ"; -const toolSecret = "TOOL_LOG_SHOULD_NOT_LEAK_FROM_CODEX_READ"; -const feedbackSecret = "FEEDBACK_PROMPT_SHOULD_NOT_LEAK_FROM_CODEX_READ"; -const referenceSecret = "REFERENCE_INJECTION_BASE_PROMPT_SHOULD_NOT_LEAK_FROM_CODEX_READ"; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function asRecord(value: unknown, label: string): JsonRecord { - assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, value); - return value as JsonRecord; -} - -function asArray(value: unknown, label: string): unknown[] { - assertCondition(Array.isArray(value), `${label} must be an array`, value); - return value as unknown[]; -} - -function taskIdFromPath(path: string): string { - const match = path.match(/\/api\/tasks\/([^/?]+)/u); - return decodeURIComponent(match?.[1] ?? "unknown"); -} - -function summaryFor(taskId: string, status: "succeeded" | "failed"): JsonRecord { - const failed = status === "failed"; - const finalResponse = failed - ? "Failure summary: tests did not pass, but the runner reported the exact failing command." - : "Final response: implemented the terminal read fix and validated the focused contract."; - return { - id: taskId, - queueId: "hwlab", - status, - providerId: "D601", - executionMode: "container", - model: "gpt-5.5", - agentPort: "codex", - cwd: "/workspace/unidesk", - reasoningEffort: "medium", - maxAttempts: 2, - currentAttempt: failed ? 2 : 1, - currentMode: failed ? "retry" : "initial", - judgeFailCount: failed ? 2 : 0, - judgeFailRetryLimit: 3, - codexThreadId: `thread_${taskId}`, - activeTurnId: null, - createdAt: "2026-05-22T00:00:00.000Z", - startedAt: "2026-05-22T00:01:00.000Z", - updatedAt: "2026-05-22T00:03:00.000Z", - finishedAt: "2026-05-22T00:03:00.000Z", - timing: { totalMs: 120000 }, - initialPrompt: `${promptSecret}\nPlease fix the task.`, - prompt: `${promptSecret}\nPlease fix the task.`, - referenceTaskIds: ["codex_reference_task"], - referenceInjection: { - version: 2, - injectedAt: "2026-05-22T00:00:30.000Z", - basePrompt: referenceSecret, - directReferenceTaskIds: ["codex_reference_task"], - maxRounds: 3, - truncated: false, - itemCount: 1, - items: [{ - round: 1, - roundIndex: 0, - taskId: "codex_reference_task", - viaTaskId: null, - status: "succeeded", - providerId: "D601", - executionMode: "container", - model: "gpt-5.5", - cwd: "/workspace/unidesk", - createdAt: "2026-05-21T00:00:00.000Z", - updatedAt: "2026-05-21T00:03:00.000Z", - promptChars: 9999, - finalResponseChars: 1234, - finalResponseAt: "2026-05-21T00:03:00.000Z", - finalResponseSource: "finalResponse", - referenceTaskIds: [], - cliHint: "bun scripts/cli.ts codex task codex_reference_task", - }], - }, - lastAssistantMessage: { - at: "2026-05-22T00:03:00.000Z", - seq: 41, - source: "finalResponse", - text: finalResponse, - }, - toolSummary: { - count: 3, - returned: 1, - limit: 1, - truncated: true, - items: [{ seq: 39, kind: "ran", outputPreview: toolSecret }], - }, - attempts: [ - { - index: failed ? 2 : 1, - mode: failed ? "retry" : "initial", - terminalStatus: failed ? "failed" : "completed", - appServerExitCode: failed ? 1 : 0, - appServerSignal: null, - error: failed ? "focused contract failed" : null, - stderrTail: failed ? "bun scripts/code-queue-cli-read-terminal-contract-test.ts failed" : "", - startedAt: "2026-05-22T00:01:00.000Z", - finishedAt: "2026-05-22T00:03:00.000Z", - outputStartSeq: 1, - outputEndSeq: 42, - finalResponse, - finalResponsePreview: finalResponse, - finalResponseChars: finalResponse.length, - feedbackPromptPreview: feedbackSecret, - judge: failed ? { decision: "fail", confidence: 0.88, reason: "contract failed" } : { decision: "complete", confidence: 0.97, reason: "verified" }, - runnerErrorClassification: failed ? { class: "test-failure", retryable: false } : null, - }, - ], - lastJudge: failed ? { decision: "fail", confidence: 0.88, reason: "contract failed", source: "minimax" } : { decision: "complete", confidence: 0.97, reason: "verified", source: "minimax" }, - lastError: failed ? "focused contract failed" : null, - cancelRequested: false, - transcriptCount: 12, - transcriptMaxSeq: 42, - outputCount: 42, - retainedOutputCount: 20, - outputMaxSeq: 42, - eventCount: 5, - }; -} - -function readTerminalFixture(calls: FetchCall[]): (path: string, init?: { method?: string; body?: unknown }) => unknown { - return (path, init) => { - calls.push({ path, init }); - const taskId = taskIdFromPath(path); - const status = taskId.includes("failed") ? "failed" : "succeeded"; - if (path.includes("/summary")) { - return { ok: true, status: 200, body: { ok: true, summary: summaryFor(taskId, status) } }; - } - if (path.includes("/read")) { - return { - ok: true, - status: 200, - body: { - ok: true, - task: { - id: taskId, - queueId: "hwlab", - status, - readAt: "2026-05-22T00:04:00.000Z", - terminalUnread: false, - }, - queue: { counts: { [status]: 1 }, unreadTerminal: 0 }, - }, - }; - } - throw new Error(`unexpected path ${path}`); - }; -} - -function missingTaskFixture(calls: FetchCall[]): (path: string, init?: { method?: string; body?: unknown }) => unknown { - return (path, init) => { - calls.push({ path, init }); - return { ok: true, status: 404, body: { ok: false, error: "task not found" } }; - }; -} - -function assertTerminalReadShape(result: unknown, taskId: string, status: "succeeded" | "failed"): void { - const data = asRecord(result, "result"); - const task = asRecord(data.task, "task"); - const finalResponse = asRecord(task.finalResponse, "finalResponse"); - const attempts = asRecord(task.attempts, "attempts"); - const lastAttempt = asRecord(attempts.lastAttempt, "lastAttempt"); - const read = asRecord(data.read, "read"); - const disclosure = asRecord(task.disclosure, "disclosure"); - const body = JSON.stringify(result); - - assertCondition(task.id === taskId, "read result must preserve task id", task); - assertCondition(task.queueId === "hwlab", "read result must preserve queue id", task); - assertCondition(task.status === status, "read result must preserve terminal status", task); - assertCondition(task.model === "gpt-5.5" && task.providerId === "D601" && task.cwd === "/workspace/unidesk", "read result must preserve stable execution metadata", task); - assertCondition(task.createdAt === "2026-05-22T00:00:00.000Z", "read result must include createdAt", task); - assertCondition(task.startedAt === "2026-05-22T00:01:00.000Z", "read result must include startedAt", task); - assertCondition(task.updatedAt === "2026-05-22T00:03:00.000Z", "read result must include updatedAt", task); - assertCondition(task.finishedAt === "2026-05-22T00:03:00.000Z", "read result must include finishedAt", task); - assertCondition(task.readAt === "2026-05-22T00:04:00.000Z" && task.terminalUnread === false, "read result must preserve read acknowledgement", task); - assertCondition(read.marked === true && read.terminalUnread === false, "top-level read acknowledgement must be stable", read); - assertCondition(String(finalResponse.text ?? "").includes(status === "failed" ? "Failure summary" : "Final response"), "read result must include final response text", finalResponse); - assertCondition(finalResponse.chars === String(finalResponse.text ?? "").length && finalResponse.truncated === false, "read result must include bounded final response preview metadata", finalResponse); - assertCondition(lastAttempt.terminalStatus === (status === "failed" ? "failed" : "completed"), "read result must include terminal attempt summary", lastAttempt); - assertCondition(disclosure.promptIncluded === false && disclosure.toolLogsIncluded === false && disclosure.finalResponseIncluded === true, "read disclosure policy must be explicit", disclosure); - const commands = asRecord(task.commands, "task.commands"); - assertCondition(String(commands.detail ?? "") === `bun scripts/cli.ts codex task ${taskId} --detail`, "read result must include detail drill-down command", commands); - assertCondition(String(commands.trace ?? "").includes(`codex task ${taskId} --trace`), "read result must include trace drill-down command", commands); - assertCondition(String(commands.output ?? "").includes(`codex output ${taskId}`), "read result must include output drill-down command", commands); - assertCondition(!body.includes(promptSecret), "read result must not leak prompt body", body); - assertCondition(!body.includes(toolSecret), "read result must not leak tool logs", body); - assertCondition(!body.includes(feedbackSecret), "read result must not leak feedback prompt body", body); - assertCondition(!body.includes(referenceSecret), "read result must not leak reference injection base prompt", body); - if (status === "failed") { - assertCondition(task.lastError === "focused contract failed", "failed read must include lastError", task); - assertCondition(String(asRecord(lastAttempt.stderrTail, "stderrTail").text ?? "").includes("contract-test"), "failed read must include stderr tail", lastAttempt); - assertCondition(asRecord(lastAttempt.runnerErrorClassification, "runnerErrorClassification").class === "test-failure", "failed read must include runner error classification", lastAttempt); - } -} - -function run(): JsonRecord { - const succeededCalls: FetchCall[] = []; - const succeeded = codexReadTaskForTest("codex_succeeded_terminal", readTerminalFixture(succeededCalls)); - assertTerminalReadShape(succeeded, "codex_succeeded_terminal", "succeeded"); - assertCondition(succeededCalls.length === 2, "succeeded read must fetch summary then mark read", succeededCalls); - assertCondition(succeededCalls[0]?.path.includes("/summary?toolLimit=3") && succeededCalls[1]?.path.includes("/read"), "succeeded read call order must preserve body before mutation", succeededCalls); - assertCondition(succeededCalls[1]?.init?.method === "POST", "read mutation must use POST", succeededCalls); - - const failedCalls: FetchCall[] = []; - const failed = codexReadTaskForTest("codex_failed_terminal", readTerminalFixture(failedCalls)); - assertTerminalReadShape(failed, "codex_failed_terminal", "failed"); - - const missingCalls: FetchCall[] = []; - let missingError: Error | null = null; - try { - codexReadTaskForTest("codex_missing_terminal", missingTaskFixture(missingCalls)); - } catch (error) { - missingError = error instanceof Error ? error : new Error(String(error)); - } - assertCondition(missingError !== null && missingError.message.includes("task not found"), "missing task must fail with task not found", missingError?.message); - assertCondition(missingCalls.length === 1 && missingCalls[0]?.path.includes("/summary"), "missing task must not issue read mutation after failed lookup", missingCalls); - - return { - ok: true, - checks: [ - "succeeded terminal read returns status, queue, timestamps, final response preview, and drill-down commands from summary before marking read", - "failed terminal read returns final response, lastError, stderr tail, and runner classification", - "missing task fails before issuing a read mutation", - "prompt, tool logs, and feedback prompts stay behind progressive disclosure commands", - ], - }; -} - -process.stdout.write(`${JSON.stringify(run(), null, 2)}\n`); diff --git a/scripts/code-queue-cli-submit-prompt-contract-test.ts b/scripts/code-queue-cli-submit-prompt-contract-test.ts deleted file mode 100644 index 60a72ac6..00000000 --- a/scripts/code-queue-cli-submit-prompt-contract-test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { compactSubmitSuccessResponseForTest } from "./src/code-queue"; - -type JsonRecord = Record; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function runCli(args: string[], stdin?: string): { status: number | null; stdout: string; stderr: string; json: JsonRecord | null } { - const result = spawnSync("bun", ["scripts/cli.ts", ...args], { - cwd: process.cwd(), - input: stdin, - encoding: "utf8", - }); - const stdout = String(result.stdout || ""); - let json: JsonRecord | null = null; - try { - json = JSON.parse(stdout) as JsonRecord; - } catch { - json = null; - } - return { - status: result.status, - stdout, - stderr: String(result.stderr || ""), - json, - }; -} - -function nestedRecord(value: unknown, path: string[]): JsonRecord { - let current: unknown = value; - for (const key of path) { - assertCondition(current !== null && typeof current === "object" && !Array.isArray(current), "expected object while traversing JSON", { path, key, current }); - current = (current as JsonRecord)[key]; - } - assertCondition(current !== null && typeof current === "object" && !Array.isArray(current), "expected nested object", { path, current }); - return current as JsonRecord; -} - -function stringArray(value: unknown): string[] { - return Array.isArray(value) ? value.map((item) => String(item)) : []; -} - -function assertLegacyFrozenWrite(result: { status: number | null; stdout: string; stderr: string; json: JsonRecord | null }, command: string): void { - assertCondition(result.status !== 0 && result.json?.ok === false, `${command} should be frozen`, result.json ?? { stdout: result.stdout, stderr: result.stderr }); - const data = nestedRecord(result.json?.data, []); - assertCondition(data.ok === false, `${command} frozen payload should be ok=false`, data); - assertCondition(data.frozen === true, `${command} frozen payload should expose frozen=true`, data); - assertCondition(data.mutation === false, `${command} frozen payload should be non-mutating`, data); - assertCondition(data.degradedReason === "legacy-code-queue-frozen", `${command} should use the legacy frozen reason`, data); - assertCondition(data.command === command, `${command} frozen payload should identify the command`, data); - const replacement = nestedRecord(data, ["replacement"]); - assertCondition(String(replacement.queueSubmit || "").includes("agentrun queue submit"), `${command} should point to AgentRun queue submit`, replacement); - assertCondition(String(replacement.sessionsSteer || "").includes("agentrun sessions steer"), `${command} should point to AgentRun sessions steer`, replacement); - const legacy = nestedRecord(data, ["legacy"]); - assertCondition(legacy.noDoubleWrite === true, `${command} should document no double-write`, legacy); -} - -function assertDryRunPrompt(response: JsonRecord, expectedText: string): void { - assertCondition(response.ok === true, "submit dry-run should succeed", response); - const data = nestedRecord(response.data, []); - assertCondition(data.dryRun === true, "submit dry-run should expose dryRun=true", data); - const request = nestedRecord(response.data, ["request"]); - const prompt = nestedRecord(request, ["prompt"]); - assertCondition(prompt.text === expectedText, "submit dry-run prompt text mismatch", prompt); - assertCondition(prompt.chars === expectedText.length, "submit dry-run prompt char count mismatch", prompt); - assertCondition(prompt.truncated === false, "submit dry-run prompt must expose the full prompt", prompt); -} - -export function runCodeQueueCliSubmitPromptContract(): JsonRecord { - const multilinePrompt = [ - "Goal: verify stdin prompt path", - "JSON: {\"quote\":\"'single' and \\\"double\\\"\"}", - "Markdown: `code` | table | value", - "Backslash: C:\\tmp\\prompt", - "", - ].join("\n"); - - const stdin = runCli(["codex", "submit", "--prompt-stdin", "--queue", "prompt-contract", "--dry-run"], multilinePrompt); - assertLegacyFrozenWrite(stdin, "codex submit"); - assertCondition(!stdin.stdout.includes(multilinePrompt), "frozen submit must not echo stdin prompt", { stdout: stdin.stdout }); - - const tmp = mkdtempSync(join(tmpdir(), "unidesk-code-queue-submit-")); - const promptFile = join(tmp, "prompt.md"); - const filePrompt = `${multilinePrompt}file prompt tail\n`; - writeFileSync(promptFile, filePrompt, "utf8"); - try { - const fromFile = runCli(["codex", "submit", "--prompt-file", promptFile, "--queue", "prompt-contract", "--dry-run"]); - assertLegacyFrozenWrite(fromFile, "codex submit"); - assertCondition(!fromFile.stdout.includes("file prompt tail"), "frozen submit must not echo file prompt text", { stdout: fromFile.stdout }); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - - const positional = runCli(["codex", "submit", "short smoke prompt", "--dry-run"]); - assertLegacyFrozenWrite(positional, "codex submit"); - assertCondition(String(positional.json?.command || "").includes(""), "outer command should redact positional submit prompt", positional.json ?? {}); - assertCondition(!String(positional.json?.command || "").includes("short smoke prompt"), "outer command must not echo positional submit prompt", positional.json ?? {}); - - const duplicateSource = runCli(["codex", "submit", "positional", "--prompt-stdin", "--dry-run"], "stdin\n"); - assertLegacyFrozenWrite(duplicateSource, "codex submit"); - - const longSubmittedPrompt = `${multilinePrompt}${"submitted prompt body must not be echoed\n".repeat(80)}`; - const submitSuccess = compactSubmitSuccessResponseForTest({ - tasks: [{ - id: "codex_submit_success_contract", - queueId: "prompt-contract", - status: "queued", - providerId: "D601", - model: "gpt-5.5", - cwd: "/workspace", - prompt: longSubmittedPrompt, - maxAttempts: 99, - createdAt: "2026-05-22T00:00:00.000Z", - updatedAt: "2026-05-22T00:00:00.000Z", - }], - queue: { - total: 1, - queueCount: 1, - counts: { queued: 1 }, - queuedTaskIds: ["codex_submit_success_contract"], - }, - }, { ok: true, status: 200 }, { mode: "local-atomic-directory-submit-serialization", acquiredAfterMs: 1, heldMs: 2, throttleMs: 2000 }); - const submitSuccessJson = JSON.stringify(submitSuccess); - const submitted = nestedRecord(submitSuccess, ["submitted"]); - assertCondition(submitted.accepted === true, "submit success should confirm accepted write", submitSuccess); - assertCondition((submitted.taskIds as unknown[]).includes("codex_submit_success_contract"), "submit success should expose task id", submitSuccess); - assertCondition(submitSuccessJson.includes("promptOmitted"), "submit success should explicitly mark prompt omitted", submitSuccess); - assertCondition(!submitSuccessJson.includes("submitted prompt body must not be echoed"), "submit success must not echo prompt text", submitSuccess); - assertCondition(!submitSuccessJson.includes("promptPreview"), "submit success must not include promptPreview", submitSuccess); - - const help = runCli(["codex", "submit", "--help"]); - assertCondition(help.status === 0 && help.json?.ok === true, "codex submit help should succeed", help.json ?? { stdout: help.stdout }); - const data = nestedRecord(help.json?.data, []); - const usage = stringArray(data.usage); - const promptInput = nestedRecord(data, ["promptInput"]); - const recommended = stringArray(promptInput.recommended); - const examples = nestedRecord(data, ["examples"]); - const submitSummary = nestedRecord(data, ["submitSummary"]); - assertCondition(usage.some((line) => line.includes("codex submit # frozen legacy write entry")), "help usage should document frozen legacy submit", { usage }); - assertCondition(recommended.includes("--prompt-stdin") && recommended.includes("--prompt-file"), "help should recommend stdin and file prompt sources", promptInput); - assertCondition(String(promptInput.sourceRule || "").includes("Exactly one prompt source"), "help should document exact prompt source rule", promptInput); - assertCondition(String(submitSummary.default || "").includes("legacy-code-queue-frozen"), "help submit summary should document frozen reason", submitSummary); - assertCondition(String(submitSummary.replacement || "").includes("agentrun queue submit"), "help should point new submissions at AgentRun", submitSummary); - assertCondition(String(examples.agentRunSubmit || "").includes("agentrun queue submit"), "help examples should include AgentRun submit", examples); - - return { - ok: true, - checks: [ - "legacy submit --prompt-stdin is frozen and does not echo prompt text", - "legacy submit --prompt-file is frozen and does not echo prompt text", - "submit positional prompt is redacted from the outer command envelope", - "legacy submit duplicate prompt sources still fail at the frozen boundary", - "submit success confirms write without echoing prompt", - "codex submit help documents frozen legacy submit and AgentRun replacement", - ], - }; -} - -if (import.meta.main) { - process.stdout.write(`${JSON.stringify(runCodeQueueCliSubmitPromptContract(), null, 2)}\n`); -} diff --git a/scripts/code-queue-commander-view-contract-test.ts b/scripts/code-queue-commander-view-contract-test.ts deleted file mode 100644 index cbbb7bb3..00000000 --- a/scripts/code-queue-commander-view-contract-test.ts +++ /dev/null @@ -1,386 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { codexTasksQueryForTest } from "./src/code-queue"; - -type JsonRecord = Record; -type RequestRecord = { path: string; method: string }; - -function assertCondition(condition: unknown, message: string, detail: JsonRecord = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function asRecord(value: unknown): JsonRecord { - assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), "expected JSON object", { value }); - return value as JsonRecord; -} - -function asArray(value: unknown): unknown[] { - assertCondition(Array.isArray(value), "expected JSON array", { value }); - return value as unknown[]; -} - -function longText(marker: string, repeat: number): string { - return Array.from({ length: repeat }, (_, index) => `${marker}-${index} status evidence command output final response prompt body should stay capped`).join("\n"); -} - -function task(id: string, status: string, updatedAt: string, prompt: string, readAt: string | null = null, finalText = ""): JsonRecord { - return { - id, - queueId: "default", - status, - currentAttempt: status === "queued" || status === "retry_wait" ? 0 : 1, - updatedAt, - finishedAt: status === "succeeded" || status === "failed" || status === "canceled" ? updatedAt : null, - readAt, - prompt: `${prompt}\n${longText(`raw-prompt-${id}`, 80)}`, - basePrompt: `${prompt}\n${longText(`base-prompt-${id}`, 60)}`, - displayPrompt: `${prompt}\n${longText(`display-prompt-${id}`, 70)}`, - lastAssistantMessage: finalText.length === 0 ? null : { - at: updatedAt, - seq: 42, - source: "finalResponse", - text: `${finalText}\n${longText(`assistant-${id}`, 100)}`, - }, - }; -} - -function summaryForTask(taskId: string): JsonRecord { - const finalText = taskId === "task-running-risk" - ? "Blocked by provider auth token timeout and cannot proceed without commander authorization." - : taskId === "task-failed-unread" - ? "CI failed and final response reports missing e2e evidence." - : taskId === "task-running-watch" - ? "Implementation finished but task is still awaiting judge." - : "Completed with compact evidence."; - return { - ok: true, - status: 200, - body: { - ok: true, - summary: { - id: taskId, - queueId: "default", - status: taskId.includes("running") ? "running" : taskId.includes("failed") ? "failed" : "succeeded", - currentAttempt: 1, - maxAttempts: 99, - prompt: longText(`summary-prompt-${taskId}`, 90), - basePrompt: longText(`summary-base-${taskId}`, 70), - lastAssistantMessage: { - at: "2026-05-22T00:59:00.000Z", - seq: 120, - source: "finalResponse", - text: `${finalText}\n${longText(`summary-final-${taskId}`, 120)}`, - }, - }, - }, - }; -} - -function noisyCommanderFixture(path: string, requests: RequestRecord[] = []): JsonRecord { - requests.push({ path, method: "GET" }); - if (path.includes("/summary")) { - const taskId = decodeURIComponent(path.split("/api/tasks/")[1]?.split("/")[0] ?? "unknown"); - return summaryForTask(taskId); - } - assertCondition(path.startsWith("/api/microservices/code-queue/proxy/api/tasks/overview"), "unexpected path", { path }); - return { - ok: true, - status: 200, - body: { - ok: true, - queue: { - counts: { - running: 12, - judging: 2, - queued: 18, - retry_wait: 4, - succeeded: 28, - failed: 3, - canceled: 1, - }, - unreadTerminal: 8, - maxActiveQueues: 15, - executionDiagnostics: { - now: "2026-05-22T01:00:00.000Z", - state: "stale-active", - effectiveLiveness: "at-risk", - recommendedAction: "investigate-heartbeat-risk", - databaseActiveTaskCount: 14, - databaseActiveTaskIds: ["task-running-risk", "task-running-watch"], - activeHeartbeatCount: 13, - heartbeatFreshTaskIds: ["task-running-watch"], - heartbeatRiskTaskIds: ["task-running-risk"], - heartbeatExpiredTaskIds: ["task-running-risk"], - heartbeatMissingTaskIds: [], - staleRecoveryCandidateTaskIds: ["task-running-risk"], - traceGapTaskIds: ["task-running-risk", "task-running-watch"], - reasons: [longText("diagnostic-reason", 30), longText("diagnostic-reason-two", 30)], - }, - }, - pagination: { - limit: 200, - returned: 12, - total: 68, - hasMore: true, - nextBeforeId: "task-oldest-page", - includeActive: true, - }, - tasks: [ - task("task-running-risk", "running", "2026-05-22T00:00:00.000Z", "HWLAB#7 backend-core provider token blocker for M3 hardware workbench", null, "Blocked by provider auth token timeout."), - task("task-running-watch", "judging", "2026-05-22T00:52:00.000Z", "pikasTech/HWLAB#164 user-facing patch-panel verification", null, "Final response ready while judge is pending."), - task("task-failed-unread", "failed", "2026-05-22T00:50:00.000Z", "UniDesk#20 CI e2e evidence gate for commander view", null, "CI failed and needs read closeout."), - task("task-succeeded-unread", "succeeded", "2026-05-22T00:49:00.000Z", "pikasTech/HWLAB#317 deployment artifact digest publish evidence", null, "Artifact published."), - task("task-canceled-unread", "canceled", "2026-05-22T00:48:00.000Z", "UniDesk#118 diagnostics gate report stale commander loop", null, "Canceled after blocker."), - task("task-queued-priority", "queued", "2026-05-22T00:47:00.000Z", "HWLAB#99 business user-facing dashboard fix waiting for runner"), - task("task-retry-priority", "retry_wait", "2026-05-22T00:46:00.000Z", "HWLAB#116 infrastructure blocker retry_wait due to github transient"), - task("task-recent-read-docs", "succeeded", "2026-05-22T00:45:00.000Z", "docs governance reference update", "2026-05-22T00:45:01.000Z"), - task("task-recent-read-business", "succeeded", "2026-05-22T00:44:00.000Z", "business user-facing workbench UI fix", "2026-05-22T00:44:01.000Z"), - task("task-recent-read-evidence", "succeeded", "2026-05-22T00:43:00.000Z", "ci e2e evidence smoke report", "2026-05-22T00:43:01.000Z"), - task("task-recent-read-artifact", "succeeded", "2026-05-22T00:42:00.000Z", "deployment artifact registry digest", "2026-05-22T00:42:01.000Z"), - task("task-recent-read-diagnostic", "succeeded", "2026-05-22T00:41:00.000Z", "diagnostics gate report", "2026-05-22T00:41:01.000Z"), - ], - }, - }; -} - -function readyCommanderFixture(path: string): JsonRecord { - if (path.includes("/summary")) { - const taskId = decodeURIComponent(path.split("/api/tasks/")[1]?.split("/")[0] ?? "unknown"); - return { - ok: true, - status: 200, - body: { - ok: true, - summary: { - id: taskId, - queueId: "default", - status: "succeeded", - currentAttempt: 1, - maxAttempts: 1, - prompt: "D601 Code Queue GPT-5.5 runner completed normal workflow with ready platform status.", - lastAssistantMessage: { - at: "2026-05-22T00:59:00.000Z", - seq: 12, - source: "finalResponse", - text: "Completed routine workflow with ready platform status and no follow-up incident.", - }, - }, - }, - }; - } - assertCondition(path.startsWith("/api/microservices/code-queue/proxy/api/tasks/overview"), "unexpected ready fixture path", { path }); - const tasks = Array.from({ length: 12 }, (_, index) => task( - `task-ready-${index + 1}`, - "succeeded", - `2026-05-22T00:${String(40 - index).padStart(2, "0")}:00.000Z`, - index === 2 - ? "D601 Code Queue GPT-5.5 runner commander audit: infrastructure.status=ready riskCounts.infrastructureBlocker=0; do not classify all history as infrastructure-blocker" - : index % 3 === 0 - ? "D601 Code Queue GPT-5.5 runner workflow fix for UniDesk#20 commander CLI behavior" - : index % 3 === 1 - ? "D601 Code Queue GPT-5.5 runner user-facing HWLAB workbench implementation" - : "D601 Code Queue GPT-5.5 runner routine unknown historical task", - "2026-05-22T01:00:00.000Z", - "Routine final response.", - )); - return { - ok: true, - status: 200, - body: { - ok: true, - queue: { - counts: { - running: 0, - judging: 0, - queued: 0, - retry_wait: 0, - succeeded: tasks.length, - failed: 0, - canceled: 0, - }, - unreadTerminal: 0, - maxActiveQueues: 15, - storage: { - postgresReady: true, - health: { - status: "ready", - degraded: false, - signals: [], - }, - }, - executionDiagnostics: { - now: "2026-05-22T01:00:00.000Z", - state: "ready", - effectiveLiveness: "idle", - recommendedAction: "continue-supervision", - databaseActiveTaskCount: 0, - schedulerActiveRunSlotCount: 0, - activeHeartbeatCount: 0, - heartbeatRiskTaskIds: [], - staleRecoveryCandidateTaskIds: [], - traceGapTaskIds: [], - }, - }, - pagination: { - limit: 200, - returned: tasks.length, - total: tasks.length, - hasMore: false, - nextBeforeId: null, - includeActive: true, - }, - tasks, - }, - }; -} - -export function runCodeQueueCommanderViewContract(): JsonRecord { - const commanderRequests: RequestRecord[] = []; - const commanderLimit8Requests: RequestRecord[] = []; - const fetchCommander = (path: string): JsonRecord => noisyCommanderFixture(path, commanderRequests); - const fetchCommanderLimit8 = (path: string): JsonRecord => noisyCommanderFixture(path, commanderLimit8Requests); - const fetchNoisy = (path: string): JsonRecord => noisyCommanderFixture(path); - const commander = codexTasksQueryForTest(["--view", "commander", "--limit", "260"], fetchCommander); - const commanderTerminalAliases = codexTasksQueryForTest(["--view", "commander", "--status", "completed,cancelled", "--limit", "8"], fetchNoisy); - const supervisor = codexTasksQueryForTest(["--view", "supervisor", "--limit", "260"], fetchNoisy); - const full = codexTasksQueryForTest(["--view", "full", "--limit", "260"], fetchNoisy); - const commanderLimit8 = codexTasksQueryForTest(["--view", "commander", "--limit", "8"], fetchCommanderLimit8); - const readyCommander = codexTasksQueryForTest(["--view", "commander", "--limit", "120"], readyCommanderFixture); - const fullLimit8 = codexTasksQueryForTest(["--view", "full", "--limit", "8"], fetchNoisy); - const unreadLimit8 = codexTasksQueryForTest(["--unread", "--limit", "8"], fetchNoisy); - const commanderBody = JSON.stringify(commander); - const commanderTerminalAliasBody = JSON.stringify(commanderTerminalAliases); - const commanderLimit8Body = JSON.stringify(commanderLimit8); - const fullLimit8Body = JSON.stringify(fullLimit8); - const unreadLimit8Body = JSON.stringify(unreadLimit8); - const fullBody = JSON.stringify(full); - const commanderView = asRecord(asRecord(commander).commander); - const commanderTerminalAliasView = asRecord(asRecord(commanderTerminalAliases).commander); - const commanderLimit8View = asRecord(asRecord(commanderLimit8).commander); - const readyCommanderView = asRecord(asRecord(readyCommander).commander); - const supervisorView = asRecord(asRecord(supervisor).supervisor); - const filters = asRecord(commanderView.filters); - const activeRunners = asRecord(commanderView.activeRunners); - const backlog = asRecord(commanderView.queueBacklog); - const terminalUnread = asRecord(commanderView.terminalUnread); - const riskCounts = asRecord(commanderView.riskCounts); - const attentionCounts = asRecord(riskCounts.attention); - const highPriorityIssues = asRecord(commanderView.highPriorityIssues); - const classification = asRecord(commanderView.classification); - const byCategory = asRecord(classification.byCategory); - const readyRiskCounts = asRecord(readyCommanderView.riskCounts); - const readyClassification = asRecord(readyCommanderView.classification); - const readyByCategory = asRecord(readyClassification.byCategory); - const readyInfrastructure = asRecord(readyCommanderView.infrastructure); - const commands = asRecord(commanderView.commands); - const attention = asRecord(commanderView.attention); - const attentionItems = asArray(attention.items).map(asRecord); - const sections = asRecord(commanderView.sections); - const terminalUnreadSection = asRecord(sections.terminalUnread); - const recentCompletedSection = asRecord(sections.recentCompleted); - const recentIds = asArray(recentCompletedSection.items).map((item) => String(asRecord(item).id ?? "")); - const terminalIds = asArray(terminalUnreadSection.items).map((item) => String(asRecord(item).id ?? "")); - const terminalAliasFilters = asRecord(commanderTerminalAliasView.filters); - const terminalAliasSections = asRecord(commanderTerminalAliasView.sections); - const terminalAliasRecentCompleted = asRecord(terminalAliasSections.recentCompleted); - const terminalAliasCommands = asRecord(commanderTerminalAliasView.commands); - const activeItems = asArray(activeRunners.items).map(asRecord); - const runningRisk = attentionItems.find((item) => item.id === "task-running-risk") ?? {}; - const limit8ActiveRunners = asRecord(commanderLimit8View.activeRunners); - const limit8Sections = asRecord(commanderLimit8View.sections); - const limit8TerminalUnread = asRecord(limit8Sections.terminalUnread); - const limit8Commands = asRecord(commanderLimit8View.commands); - const limit8Attention = asRecord(commanderLimit8View.attention); - const limit8AttentionItems = asArray(limit8Attention.items).map(asRecord); - - assertCondition(commanderBody.length < 30_000, "commander output should stay under the noisy fixture budget", { chars: commanderBody.length }); - assertCondition(commanderBody.length < fullBody.length * 0.65, "commander output should stay materially smaller than full output", { commanderChars: commanderBody.length, fullChars: fullBody.length }); - assertCondition(filters.requestedLimit === 260 && filters.effectiveLimit === 100 && filters.limitCapped === true, "commander view should disclose requested/effective limit cap", filters); - assertCondition(activeRunners.count === 14 && activeRunners.exact === true && activeRunners.source === "database-active", "commander view should expose exact active runner count and source/disposition", activeRunners); - assertCondition(backlog.queued === 18 && backlog.retryWait === 4 && backlog.total === 22 && backlog.exact === true, "commander view should expose queued/retry_wait exact counts", backlog); - assertCondition(terminalUnread.total === 8 && terminalUnread.rowsReturned === 3 && terminalUnread.rowsOmitted === 5 && terminalUnread.exact === true, "commander view should expose terminal unread count plus omitted rows", terminalUnread); - assertCondition(activeItems.some((item) => item.id === "task-running-risk") && activeItems.some((item) => item.id === "task-running-watch"), "commander activeRunners should include compact active task items", activeRunners); - assertCondition(attentionCounts.total === 4 && attentionCounts.returned === 4 && attentionCounts.omitted === 0, "commander attention counts should preserve non-terminal attention totals", attentionCounts); - assertCondition(highPriorityIssues.present === true && highPriorityIssues.matchedCount === 7, "commander should surface tracked high-priority issues", highPriorityIssues); - assertCondition(Number(byCategory["user-facing"] ?? 0) >= 1 - && Number(byCategory["cd-artifact"] ?? 0) >= 1 - && Number(byCategory["noise-report"] ?? 0) >= 1 - && Number(byCategory["infra-governance"] ?? 0) >= 1 - && Number(byCategory["infrastructure-blocker"] ?? 0) >= 1, "deterministic classifier should cover requested categories", byCategory); - assertCondition(classification.deterministic === true, "classification metadata should be deterministic", classification); - assertCondition(Number(readyRiskCounts.infrastructureBlocker ?? 0) === 0, "ready commander page should not report infrastructure blocker risk", readyRiskCounts); - assertCondition(readyInfrastructure.infrastructureBlocker === false && readyInfrastructure.status === "ready", "ready commander page should surface ready infrastructure", readyInfrastructure); - assertCondition(Number(readyByCategory["infrastructure-blocker"] ?? 0) === 0, "runner/governance boilerplate must not classify historical tasks as infrastructure-blocker", readyByCategory); - assertCondition(Number(readyByCategory["workflow"] ?? 0) + Number(readyByCategory["user-facing"] ?? 0) + Number(readyByCategory["infra-governance"] ?? 0) + Number(readyByCategory["unknown"] ?? 0) === 12, "ready fixture tasks should be split without blocker overreporting", readyByCategory); - assertCondition(String(commands.refresh ?? "").includes("--view commander"), "commander refresh command should preserve explicit commander view", commands); - assertCondition(String(commands.supervisor ?? "").startsWith("bun scripts/cli.ts codex tasks") && !String(commands.supervisor ?? "").includes("--view commander"), "commander should keep supervisor drilldown command", commands); - assertCondition(String(commands.full ?? "").includes("--view full"), "commander should keep full drilldown command", commands); - assertCondition(String(commands.rawOverview ?? "").includes("microservice proxy code-queue") && String(commands.rawOverview ?? "").includes("--raw"), "commander should expose raw overview drilldown", commands); - assertCondition(String(commands.traceTemplate ?? "").includes("codex task --trace"), "commander should expose trace drilldown template", commands); - assertCondition(String(commands.outputTemplate ?? "").includes("codex output "), "commander should expose output drilldown template", commands); - assertCondition(String(commands.showTemplate ?? "").includes("codex task "), "commander should include task drilldown template for attention rows", commands); - assertCondition(asArray(runningRisk.riskSignals).includes("stale-recovery-candidate") && asArray(runningRisk.riskSignals).includes("blocked"), "active risk row should expose stale/blocker signals", runningRisk); - assertCondition(!attentionItems.some((item) => item.id === "task-failed-unread"), "default commander attention should not expand terminal unread items", { attentionItems }); - assertCondition(!commanderBody.includes("raw-prompt-task-running-risk-20"), "commander output should not dump long raw prompt bodies", { chars: commanderBody.length }); - assertCondition(!commanderBody.includes("summary-final-task-running-risk-20"), "commander output should not dump long final response bodies", { chars: commanderBody.length }); - assertCondition(!commanderBody.includes("\"prompt\""), "commander output should not include prompt preview fields by default", { commanderBody }); - assertCondition(!commanderBody.includes("\"last\""), "commander output should not include final-response preview fields by default", { commanderBody }); - assertCondition(!recentIds.some((id) => terminalIds.includes(id)), "recentCompleted section must not duplicate terminalUnread rows", { recentIds, terminalIds }); - assertCondition(recentIds.length === 3, "recentCompleted commander section should be independently capped", { recentIds }); - assertCondition(terminalUnreadSection.returned === 0 && asArray(terminalUnreadSection.items).length === 0, "default commander terminal unread section should omit item details", terminalUnreadSection); - assertCondition(String(asRecord(terminalUnreadSection.commands).unread ?? "").includes("codex unread"), "terminal unread section should point to codex unread drill-down", terminalUnreadSection); - assertCondition(JSON.stringify(terminalAliasFilters.status) === JSON.stringify(["succeeded", "canceled"]), "completed/cancelled status aliases should normalize to succeeded/canceled", terminalAliasFilters); - assertCondition(!commanderTerminalAliasBody.includes("task-failed-unread"), "completed/cancelled aliases should filter out failed tasks", { commanderTerminalAliasBody }); - assertCondition(String(terminalAliasCommands.refresh ?? "").includes("--status succeeded,canceled"), "normalized status aliases should be preserved in drill-down commands", terminalAliasCommands); - assertCondition(asArray(terminalAliasRecentCompleted.items).some((item) => asRecord(item).id === "task-recent-read-docs"), "completed alias should include read succeeded tasks in recent completed", terminalAliasRecentCompleted); - - const badStatus = spawnSync("bun", ["scripts/cli.ts", "codex", "tasks", "--status", "done", "--limit", "1"], { - cwd: process.cwd(), - encoding: "utf8", - }); - const badStatusJson = JSON.parse(badStatus.stdout) as JsonRecord; - assertCondition(badStatus.status !== 0, "unsupported codex tasks --status should fail", { status: badStatus.status, stdout: badStatus.stdout, stderr: badStatus.stderr }); - const badStatusError = asRecord(badStatusJson.error); - assertCondition(badStatusError.degradedReason === "validation-failed", "unsupported status should return structured validation error", badStatusError); - assertCondition(Array.isArray(badStatusError.supported) && asArray(badStatusError.supported).includes("succeeded"), "unsupported status should list supported values", badStatusError); - assertCondition(Array.isArray(badStatusError.aliases) && asArray(badStatusError.aliases).includes("completed->succeeded") && asArray(badStatusError.aliases).includes("cancelled->canceled"), "unsupported status should list common aliases", badStatusError); - assertCondition(!badStatus.stdout.includes("stack") && !badStatus.stdout.includes("at parseTasksOptions"), "expected codex tasks parameter errors should not print stack traces by default", { stdout: badStatus.stdout }); - assertCondition(asRecord(supervisorView.completedUnread).count === 3 && asRecord(supervisorView.recentCompleted).count === 5, "supervisor view should remain available and keep separate unread/recent sections", supervisorView); - assertCondition(commanderLimit8Body.length < 16_000, "commander --limit 8 output should stay compact for polling", { chars: commanderLimit8Body }); - assertCondition(asRecord(commanderLimit8View.filters).requestedLimit === 8, "commander --limit 8 should preserve requested limit disclosure", commanderLimit8View); - assertCondition(asArray(limit8ActiveRunners.items).some((item) => asRecord(item).id === "task-running-risk"), "commander --limit 8 should keep active items", limit8ActiveRunners); - assertCondition(limit8TerminalUnread.returned === 0 && asArray(limit8TerminalUnread.items).length === 0, "commander --limit 8 should not expand terminal unread item details", limit8TerminalUnread); - assertCondition(!limit8AttentionItems.some((item) => String(item.id ?? "").includes("unread")), "commander --limit 8 attention should omit terminal unread rows", { limit8AttentionItems }); - assertCondition(String(limit8Commands.unread ?? "").includes("codex unread"), "commander --limit 8 should keep unread drill-down command", limit8Commands); - assertCondition(String(limit8Commands.full ?? "").includes("--view full"), "commander --limit 8 should keep full drill-down command", limit8Commands); - assertCondition(!commanderLimit8Body.includes("RAW_PROMPT_SHOULD_NOT_LEAK") && !commanderLimit8Body.includes("raw-prompt-task-failed-unread"), "commander --limit 8 should not print unread prompt details", { commanderLimit8Body }); - assertCondition(!commanderLimit8Body.includes("summary-final-task-failed-unread"), "commander --limit 8 should not print unread final-response details", { commanderLimit8Body }); - assertCondition(fullLimit8Body.includes("raw-prompt-task-failed-unread") || fullLimit8Body.includes("display-prompt-task-failed-unread"), "--view full should still expose task detail previews", { fullLimit8Body }); - assertCondition(unreadLimit8Body.includes("task-failed-unread") && unreadLimit8Body.includes("readTemplate"), "supervisor unread drill-down should still expose terminal unread task ids", { unreadLimit8Body }); - assertCondition(!commanderLimit8Requests.some((request) => request.path.includes("task-failed-unread") && request.path.includes("/summary")), "default commander --limit 8 should not fetch terminal unread summaries", { commanderLimit8Requests }); - - return { - ok: true, - checks: [ - "commander view is explicit and bounded", - "exact active/queued/retry_wait/terminal-unread counts are preserved", - "attention rows expose active, queued/retry_wait and blocker signals", - "high-priority issue refs are surfaced", - "deterministic classifier emits requested categories", - "ready infrastructure pages do not classify all historical runner tasks as infrastructure-blocker", - "drilldown commands are present without prompt/final-response flood", - "commander --limit 8 omits terminal unread details and prompt previews", - "codex tasks --status completed,cancelled aliases normalize to succeeded,canceled", - "codex tasks invalid --status returns compact structured suggestions without stack noise", - "full and unread drill-down paths still expose details", - "recent completed does not duplicate terminal unread", - "supervisor/full views remain available", - ], - commanderChars: commanderBody.length, - commanderLimit8Chars: commanderLimit8Body.length, - fullChars: fullBody.length, - }; -} - -if (import.meta.main) { - process.stdout.write(`${JSON.stringify(runCodeQueueCommanderViewContract(), null, 2)}\n`); -} diff --git a/scripts/code-queue-gh-auth-redaction-contract-test.ts b/scripts/code-queue-gh-auth-redaction-contract-test.ts deleted file mode 100644 index e2e13b71..00000000 --- a/scripts/code-queue-gh-auth-redaction-contract-test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { appendOutput, configureTaskOutput, taskFullOutput } from "../src/components/microservices/code-queue/src/task-output"; -import { sanitizeTaskOutputText } from "../src/components/microservices/code-queue/src/output-redaction"; -import type { JsonValue, QueueTask } from "../src/components/microservices/code-queue/src/types"; - -type JsonRecord = Record; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function fixtureTask(): QueueTask { - const at = "2026-05-23T00:00:00.000Z"; - return { - id: "codex_gh_auth_redaction_contract", - queueId: "default", - queueEnteredAt: at, - prompt: "redaction fixture", - basePrompt: "redaction fixture", - referenceTaskIds: [], - referenceInjection: null, - providerId: "D601", - cwd: "/workspace", - model: "gpt-5.5", - reasoningEffort: null, - executionMode: "default", - maxAttempts: 1, - status: "running", - createdAt: at, - updatedAt: at, - startedAt: at, - finishedAt: null, - readAt: null, - currentAttempt: 1, - currentMode: "initial", - codexThreadId: null, - activeTurnId: null, - finalResponse: "", - lastError: null, - lastJudge: null, - judgeFailCount: 0, - promptHistory: [], - output: [], - events: [], - attempts: [], - cancelRequested: false, - nextPrompt: null, - nextMode: null, - }; -} - -function assertNoTokenFragments(value: string, label: string): void { - assertCondition(!/\bgh[pousr]_[A-Za-z0-9_]{6,}\b/u.test(value), `${label} must redact gh token-like values`, value); - assertCondition(!/\bgithub_pat_[A-Za-z0-9_]{6,}\b/u.test(value), `${label} must redact GitHub PAT-like values`, value); - assertCondition(!/Token:\s*\S+/iu.test(value), `${label} must not expose raw gh auth token lines`, value); - assertCondition(!/Token scopes?:\s*\S+/iu.test(value), `${label} must not expose raw gh auth scope lines`, value); -} - -export function runCodeQueueGhAuthRedactionContract(): JsonRecord { - const tmp = mkdtempSync(join(tmpdir(), "code-queue-gh-auth-redaction-")); - const task = fixtureTask(); - let seq = 0; - try { - configureTaskOutput({ - config: { maxInMemoryOutputRecords: 1000, outputArchiveDir: tmp }, - allocateSeq: () => { - seq += 1; - return seq; - }, - errorToJson: (error: unknown): JsonValue => error instanceof Error ? { message: error.message } : String(error), - logger: () => undefined, - markTaskDirty: () => undefined, - nowIso: () => "2026-05-23T00:00:01.000Z", - schedulePersistState: () => undefined, - }); - - const rawGhAuth = [ - "github.com", - " \u2713 Logged in to github.com account example (keyring)", - " - Active account: true", - " - Git operations protocol: ssh", - " - Token: ghp_abcdef1234567890abcdef1234567890", - " - Token scopes: 'repo', 'read:org'", - "generic token=github_pat_abcdef1234567890abcdef1234567890", - ].join("\n"); - const sanitized = sanitizeTaskOutputText(rawGhAuth); - assertNoTokenFragments(sanitized, "direct sanitizer output"); - assertCondition(sanitized.includes("[redacted gh auth status line]"), "sanitizer must redact gh auth status lines", sanitized); - assertCondition(sanitized.includes("bun scripts/cli.ts gh auth status"), "sanitizer must include UniDesk gh wrapper hint", sanitized); - - appendOutput(task, "command", `${rawGhAuth}\n`, "item/commandExecution/outputDelta", "call-gh-auth", true); - const retained = JSON.stringify(task.output); - const archived = readFileSync(join(tmp, `${task.id}.jsonl`), "utf8"); - const full = JSON.stringify(taskFullOutput(task)); - assertNoTokenFragments(retained, "retained output"); - assertNoTokenFragments(archived, "archived output"); - assertNoTokenFragments(full, "full output replay"); - assertCondition(full.includes("bun scripts/cli.ts gh auth status"), "full output replay must keep wrapper hint", full); - - return { - ok: true, - checks: [ - "raw gh auth status token and scope lines are redacted", - "token-like GitHub values are redacted before retained output persistence", - "output archive replay remains redacted", - "redacted output nudges runner toward bun scripts/cli.ts gh auth status", - ], - }; - } finally { - rmSync(tmp, { recursive: true, force: true }); - } -} - -try { - process.stdout.write(`${JSON.stringify(runCodeQueueGhAuthRedactionContract(), null, 2)}\n`); -} catch (error) { - process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`); - process.exit(1); -} diff --git a/scripts/code-queue-mgr-artifact-readiness-contract-test.ts b/scripts/code-queue-mgr-artifact-readiness-contract-test.ts deleted file mode 100644 index f70627e8..00000000 --- a/scripts/code-queue-mgr-artifact-readiness-contract-test.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; -import { rootPath } from "./src/config"; -import { runArtifactRegistryCommand } from "./src/artifact-registry"; - -type JsonRecord = Record; - -const serviceId = "code-queue-mgr"; -const repo = "https://github.com/pikasTech/unidesk"; -const commit = "fee1b1b710151d827749cc4b0662b1560cbe1fd6"; -const dockerfile = "src/components/microservices/code-queue-mgr/Dockerfile"; -const repository = "unidesk/code-queue-mgr"; -const imageRef = `127.0.0.1:5000/${repository}:${commit}`; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function asRecord(value: unknown, label: string): JsonRecord { - assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, { value }); - return value as JsonRecord; -} - -function asArray(value: unknown, label: string): unknown[] { - assertCondition(Array.isArray(value), `${label} must be an array`, { value }); - return value as unknown[]; -} - -function strings(value: unknown, label: string): string[] { - return asArray(value, label).map(String); -} - -function manifestService(environment: "dev" | "prod", id: string): JsonRecord { - const manifest = asRecord(JSON.parse(readFileSync(rootPath("deploy.json"), "utf8")) as unknown, "deploy.json"); - const environments = asRecord(manifest.environments, "deploy.json.environments"); - const env = asRecord(environments[environment], `deploy.json.environments.${environment}`); - const services = asArray(env.services, `deploy.json.environments.${environment}.services`); - const found = services.map((item, index) => asRecord(item, `${environment}.services[${index}]`)).find((item) => item.id === id); - assertCondition(found !== undefined, `deploy.json ${environment} must include ${id}`, env); - return found as JsonRecord; -} - -function ciArtifact(id: string): JsonRecord { - const catalog = asRecord(JSON.parse(readFileSync(rootPath("CI.json"), "utf8")) as unknown, "CI.json"); - const artifacts = asArray(catalog.artifacts, "CI.json.artifacts").map((item, index) => asRecord(item, `CI.json.artifacts[${index}]`)); - const found = artifacts.find((item) => item.serviceId === id); - assertCondition(found !== undefined, `CI.json must include ${id}`, catalog); - return found as JsonRecord; -} - -function assertDesiredStateAndProducer(): void { - const prod = manifestService("prod", serviceId); - const dev = manifestService("dev", serviceId); - const artifact = ciArtifact(serviceId); - const source = asRecord(artifact.source, "CI.json code-queue-mgr.source"); - const image = asRecord(artifact.image, "CI.json code-queue-mgr.image"); - - assertCondition(prod.repo === repo, "prod code-queue-mgr repo mismatch", prod); - assertCondition(prod.commitId === commit, "prod code-queue-mgr must stay pinned to the stats endpoint commit", prod); - assertCondition(dev.repo === repo, "dev code-queue-mgr repo mismatch", dev); - assertCondition(artifact.kind === "source-build", "code-queue-mgr artifact must be source-build", artifact); - assertCondition(artifact.status === "supported", "code-queue-mgr artifact publish must be supported", artifact); - assertCondition(artifact.producer === "ci publish-user-service", "code-queue-mgr must publish through user-service artifact CI", artifact); - assertCondition(source.repo === repo, "code-queue-mgr CI source repo mismatch", source); - assertCondition(source.dockerfile === dockerfile, "code-queue-mgr CI Dockerfile mismatch", source); - assertCondition(image.repository === repository, "code-queue-mgr registry repository mismatch", image); -} - -function assertStatsEndpointSourceContract(): void { - const dockerfileText = readFileSync(rootPath(dockerfile), "utf8"); - const rustSource = readFileSync(rootPath("src/components/microservices/code-queue-mgr/src-rs/main.rs"), "utf8"); - const statsRouteIndex = rustSource.indexOf('(Method::Get, "/api/tasks/stats")'); - const statsRouteSnippet = statsRouteIndex >= 0 ? rustSource.slice(statsRouteIndex, statsRouteIndex + 900) : ""; - - assertCondition(dockerfileText.includes("COPY src/components/microservices/code-queue-mgr/src-rs ./src-rs"), "code-queue-mgr artifact must build the Rust runtime source", { dockerfile }); - assertCondition(dockerfileText.includes('CMD ["code-queue-mgr"]'), "code-queue-mgr artifact must run the Rust binary", { dockerfile }); - assertCondition(statsRouteIndex >= 0, "Rust mgr must expose /api/tasks/stats", { path: "src-rs/main.rs" }); - assertCondition(statsRouteSnippet.includes("task_statistics_summary"), "Rust mgr stats route must use task_statistics_summary", { statsRouteSnippet }); - assertCondition(rustSource.includes("stats_summary_contract_exposes_daily_buckets_and_totals"), "Rust mgr must keep the daily bucket stats unit contract", { path: "src-rs/main.rs" }); - assertCondition(!statsRouteSnippet.includes("skipped"), "Rust mgr stats route must not ship skipped statistics", { statsRouteSnippet }); -} - -function assertCommonDryRun(plan: JsonRecord, deployRef: string): void { - const source = asRecord(plan.source, "dry-run source"); - const registry = asRecord(plan.registry, "dry-run registry"); - const build = asRecord(plan.build, "dry-run build"); - const labels = asRecord(plan.requiredLabels, "dry-run labels"); - const probe = asRecord(plan.registryProbe, "dry-run registryProbe"); - const target = asRecord(plan.target, "dry-run target"); - const liveApply = asRecord(plan.liveApply, "dry-run liveApply"); - const guard = asRecord(plan.selfBootstrapGuard, "dry-run selfBootstrapGuard"); - const validation = strings(plan.validation, "dry-run validation"); - const excludedTargets = asArray(plan.excludedTargets, "dry-run excludedTargets").map((item, index) => asRecord(item, `excludedTargets[${index}]`)); - const excludedText = JSON.stringify(excludedTargets); - - assertCondition(plan.ok === true && plan.supported === true, "code-queue-mgr dry-run must be supported", plan); - assertCondition(plan.dryRun === true && plan.mutation === false, "code-queue-mgr dry-run must be non-mutating", plan); - assertCondition(plan.environment === "prod", "code-queue-mgr dry-run must target prod", plan); - assertCondition(plan.providerId === "D601", "code-queue-mgr dry-run provider mismatch", plan); - assertCondition(plan.serviceId === serviceId, "code-queue-mgr dry-run service mismatch", plan); - assertCondition(plan.commit === commit, "code-queue-mgr dry-run commit mismatch", plan); - assertCondition(plan.sourceRepo === repo, "code-queue-mgr dry-run source repo mismatch", plan); - assertCondition(plan.deployRef === deployRef, "code-queue-mgr dry-run deployRef mismatch", plan); - assertCondition(plan.sourceImage === imageRef, "code-queue-mgr dry-run source image mismatch", plan); - - assertCondition(source.repo === repo && source.commit === commit && source.dockerfile === dockerfile, "code-queue-mgr source boundary mismatch", source); - assertCondition(registry.imageRef === imageRef, "code-queue-mgr registry image mismatch", registry); - assertCondition(registry.digest === null, "code-queue-mgr dry-run must not fake registry digest", registry); - assertCondition(String(registry.digestSource ?? "").includes("live apply must read this digest"), "code-queue-mgr digest source must name live registry HEAD", registry); - assertCondition(probe.method === "HEAD", "code-queue-mgr registry probe must be HEAD-only", probe); - - assertCondition(build.willCompile === false, "code-queue-mgr CD must not compile", build); - assertCondition(build.willRunCargoBuild === false, "code-queue-mgr CD must not run cargo build", build); - assertCondition(build.willRunDockerBuild === false, "code-queue-mgr CD must not run docker build", build); - assertCondition(build.willRunDockerComposeBuild === false, "code-queue-mgr CD must not run docker compose build", build); - assertCondition(build.producerBoundary === "ci publish-user-service", "code-queue-mgr producer boundary mismatch", build); - - assertCondition(labels["unidesk.ai/service-id"] === serviceId, "code-queue-mgr label service mismatch", labels); - assertCondition(labels["unidesk.ai/source-repo"] === repo, "code-queue-mgr label repo mismatch", labels); - assertCondition(labels["unidesk.ai/source-commit"] === commit, "code-queue-mgr label commit mismatch", labels); - assertCondition(labels["unidesk.ai/dockerfile"] === dockerfile, "code-queue-mgr label Dockerfile mismatch", labels); - - assertCondition(target.kind === "compose", "code-queue-mgr target must be main-server Compose", target); - assertCondition(target.runtimeHost === "main-server", "code-queue-mgr runtime host mismatch", target); - assertCondition(target.composeService === "code-queue-mgr", "code-queue-mgr Compose service mismatch", target); - assertCondition(target.containerName === "code-queue-mgr-backend", "code-queue-mgr container mismatch", target); - assertCondition(target.targetImage === "code-queue-mgr", "code-queue-mgr target image mismatch", target); - assertCondition(target.runtimeImage === `code-queue-mgr:${commit}`, "code-queue-mgr runtime image mismatch", target); - assertCondition(target.deployEnvPrefix === "UNIDESK_CODE_QUEUE_MGR_DEPLOY", "code-queue-mgr deploy env prefix mismatch", target); - assertCondition(target.deployCommandShape === "docker compose up -d --no-build --no-deps --force-recreate code-queue-mgr", "code-queue-mgr deploy command must be single-service no-build/no-deps recreate", target); - - assertCondition(liveApply.policy === "supervisor-only", "code-queue-mgr prod live apply must be supervisor-only", liveApply); - assertCondition(liveApply.allowed === false, "code-queue-mgr prod live apply must be blocked in automation", liveApply); - assertCondition(liveApply.requiresSupervisorApproval === true, "code-queue-mgr prod live apply must require supervisor approval", liveApply); - assertCondition(String(liveApply.reason ?? "").includes("explicit supervisor confirmation"), "code-queue-mgr live apply reason must name supervisor confirmation", liveApply); - assertCondition(plan.requiresSupervisorApproval === true, "code-queue-mgr prod dry-run must expose top-level supervisor approval requirement", plan); - assertCondition(guard.selfBootstrapBlocked === true, "code-queue-mgr prod dry-run must expose self-bootstrap guard", guard); - assertCondition(String(guard.targetScope ?? "").includes("code-queue-mgr-backend"), "code-queue-mgr guard must name the control-plane sidecar target", guard); - assertCondition(validation.some((line) => line.includes("deploy.commit/deploy.requestedCommit")), "code-queue-mgr validation must require health deploy commit metadata", validation); - assertCondition(excludedText.includes("code-queue"), "code-queue-mgr excluded targets must include code-queue", excludedTargets); - assertCondition(excludedText.includes("scheduler"), "code-queue-mgr excluded targets must mention scheduler", excludedTargets); - assertCondition(excludedText.includes("runner"), "code-queue-mgr excluded targets must mention runner", excludedTargets); - assertCondition(excludedText.includes("tasks"), "code-queue-mgr excluded targets must mention tasks", excludedTargets); - assertCondition(excludedText.includes("interrupts"), "code-queue-mgr excluded targets must mention interrupts", excludedTargets); - assertCondition(excludedText.includes("cancellations"), "code-queue-mgr excluded targets must mention cancellations", excludedTargets); - assertCondition(!JSON.stringify(plan).includes("server rebuild"), "code-queue-mgr dry-run must not mention server rebuild", plan); - assertCondition(!JSON.stringify(plan).includes("docker compose build"), "code-queue-mgr dry-run must not mention compose build", plan); -} - -function deployApplyDryRun(): JsonRecord { - const result = spawnSync("bun", ["scripts/cli.ts", "deploy", "apply", "--env", "prod", "--service", serviceId, "--dry-run"], { - cwd: process.cwd(), - encoding: "utf8", - maxBuffer: 8 * 1024 * 1024, - }); - assertCondition(result.status === 0, "deploy apply dry-run should exit 0 for prod code-queue-mgr", { - status: result.status, - stdoutTail: result.stdout.slice(-2000), - stderrTail: result.stderr.slice(-2000), - }); - const envelope = asRecord(JSON.parse(result.stdout) as unknown, "deploy apply envelope"); - assertCondition(envelope.ok === true, "deploy apply envelope must be ok", envelope); - const data = asRecord(envelope.data, "deploy apply data"); - const results = asArray(data.results, "deploy apply results"); - assertCondition(data.action === "apply", "deploy apply dry-run action mismatch", data); - assertCondition(data.environment === "prod", "deploy apply dry-run environment mismatch", data); - assertCondition(data.executor === "d601-registry-artifact-consumer", "deploy apply dry-run executor mismatch", data); - assertCondition(data.dryRun === true, "deploy apply dry-run must report dryRun=true", data); - assertCondition(results.length === 1, "deploy apply dry-run must return exactly one service result", data); - return asRecord(results[0], "deploy apply code-queue-mgr result"); -} - -async function main(): Promise { - assertDesiredStateAndProducer(); - assertStatsEndpointSourceContract(); - - const artifactDryRun = asRecord(await runArtifactRegistryCommand([ - "deploy-service", - "--env", - "prod", - "--service", - serviceId, - "--commit", - commit, - "--dry-run", - ]), "artifact-registry dry-run"); - assertCommonDryRun(artifactDryRun, "deploy.json#environments.prod.services.code-queue-mgr"); - - const deployDryRun = deployApplyDryRun(); - assertCommonDryRun(deployDryRun, "origin/master:deploy.json#environments.prod.services.code-queue-mgr"); - - process.stdout.write(`${JSON.stringify({ - ok: true, - checks: [ - "prod deploy.json pins code-queue-mgr to the stats endpoint commit", - "CI.json publishes code-queue-mgr through ci publish-user-service", - "Dockerfile builds and runs the Rust mgr that exposes /api/tasks/stats without skipped=true", - "artifact-registry prod dry-run is non-mutating and single-service Compose only", - "deploy apply prod dry-run is non-mutating, supervisor-only, and excludes scheduler/runner/tasks/interrupt/cancel", - ], - serviceId, - commit, - artifact: imageRef, - target: asRecord(deployDryRun.target, "deploy dry-run target"), - liveApply: asRecord(deployDryRun.liveApply, "deploy dry-run liveApply"), - }, null, 2)}\n`); -} - -if (import.meta.main) { - await main(); -} diff --git a/scripts/code-queue-postgres-rotation-contract-test.ts b/scripts/code-queue-postgres-rotation-contract-test.ts deleted file mode 100644 index 96b34b2e..00000000 --- a/scripts/code-queue-postgres-rotation-contract-test.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { codexTasksQueryForTest } from "./src/code-queue"; -import { databaseStorageHealth, classifyTransientDatabaseError, restoreDirtyFlushBatch, takeDirtyFlushBatch } from "../src/components/microservices/code-queue/src/database-resilience"; -import { classifyRunnerError } from "../src/components/microservices/code-queue/src/runner-error-classifier"; - -type JsonRecord = Record; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function asRecord(value: unknown, label: string): JsonRecord { - assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, value); - return value as JsonRecord; -} - -function asArray(value: unknown, label: string): unknown[] { - assertCondition(Array.isArray(value), `${label} must be an array`, value); - return value as unknown[]; -} - -function postgresSocketWriteError(): Error { - const error = new TypeError("null is not an object (evaluating 'socket.write')"); - error.stack = [ - "TypeError: null is not an object (evaluating 'socket.write')", - " at nextWrite (/app/node_modules/postgres/src/connection.js:255:22)", - " at /app/src/components/microservices/code-queue/src/index.ts:4290:15", - ].join("\n"); - Object.assign(error, { code: "CONNECTION_CLOSED", errno: "CONNECTION_CLOSED" }); - return error; -} - -function assertDatabaseClassification(): void { - const classification = classifyTransientDatabaseError(postgresSocketWriteError()); - assertCondition(classification.transient === true, "socket.write null must be classified as transient database failure", classification); - assertCondition(classification.retryable === true, "socket.write null must be retryable", classification); - assertCondition(classification.kind === "socket-write-null", "socket.write null kind must be explicit", classification); - assertCondition(classification.infrastructureBlocker === true, "socket.write null must be infrastructure-blocker", classification); - assertCondition(classification.evidence.some((item) => String(item).includes("socket.write")), "classification should expose bounded socket.write evidence", classification); - const codeOnly = Object.assign(new Error("write CONNECTION_CLOSED"), { code: "CONNECTION_CLOSED" }); - assertCondition(classifyTransientDatabaseError(codeOnly).kind === "connection-closed", "Postgres CONNECTION_CLOSED code should classify even if stack is stripped", classifyTransientDatabaseError(codeOnly)); -} - -function assertDirtyBatchRestore(): void { - const taskIds = new Set(["task-b", "task-a"]); - const queueIds = new Set(["queue-b", "queue-a"]); - const batch = takeDirtyFlushBatch(taskIds, queueIds, "2026-05-23T00:00:00.000Z"); - assertCondition(taskIds.size === 0 && queueIds.size === 0, "taking a dirty flush batch should clear source sets", { taskIds: Array.from(taskIds), queueIds: Array.from(queueIds) }); - assertCondition(JSON.stringify(batch.taskIds) === JSON.stringify(["task-a", "task-b"]), "dirty task ids should be sorted for deterministic flush", batch); - restoreDirtyFlushBatch(batch, taskIds, queueIds); - assertCondition(taskIds.has("task-a") && taskIds.has("task-b") && queueIds.has("queue-a") && queueIds.has("queue-b"), "failed dirty flush must restore all ids for retry", { - taskIds: Array.from(taskIds), - queueIds: Array.from(queueIds), - }); -} - -function assertStorageHealth(): JsonRecord { - const health = asRecord(databaseStorageHealth({ - postgresReady: true, - dirtyTaskCount: 2, - dirtyQueueCount: 1, - lastError: postgresSocketWriteError(), - flushInFlight: false, - clientRotationCount: 2, - lastClientRotationAt: "2026-05-23T00:00:01.000Z", - lastClientRotationReason: "flush-dirty-tasks", - transientUncaughtSuppressedCount: 1, - lastTransientUncaughtSuppressedAt: "2026-05-23T00:00:02.000Z", - consecutiveFlushFailures: 1, - lastFlushFailureAt: "2026-05-23T00:00:03.000Z", - nextFlushRetryAt: "2026-05-23T00:00:04.000Z", - }), "database storage health"); - assertCondition(health.status === "degraded", "storage health should be degraded after transient flush failure", health); - assertCondition(health.infrastructureBlocker === true, "storage health should mark infrastructure-blocker", health); - assertCondition(health.lastErrorKind === "socket-write-null", "storage health should expose transient kind", health); - const signals = asArray(health.signals, "health.signals"); - assertCondition(signals.length === 1, "storage health should include one bounded actionable signal", health); - const signal = asRecord(signals[0], "health.signals[0]"); - assertCondition(signal.category === "infrastructure-blocker", "storage signal should be an infrastructure-blocker", signal); - assertCondition(String(signal.commanderAction ?? "").includes("Code Queue infrastructure"), "storage signal should tell commander this is infrastructure", signal); - return health; -} - -function fixtureResponse(path: string): JsonRecord { - if (path.includes("/summary")) { - const taskId = decodeURIComponent(path.split("/api/tasks/")[1]?.split("/")[0] ?? "unknown"); - return { - ok: true, - status: 200, - body: { - ok: true, - summary: { - id: taskId, - queueId: "default", - status: "retry_wait", - currentAttempt: 2, - maxAttempts: 99, - prompt: "UniDesk#20 Code Queue scheduler Postgres connection rotation crash", - basePrompt: "UniDesk#20 Code Queue scheduler Postgres connection rotation crash", - lastError: "database flush degraded after CONNECTION_CLOSED socket.write null", - lastAssistantMessage: { - at: "2026-05-23T00:00:00.000Z", - seq: 44, - source: "transcript", - text: "Blocked by Postgres CONNECTION_CLOSED while flushing dirty tasks.", - }, - }, - }, - }; - } - assertCondition(path.startsWith("/api/microservices/code-queue/proxy/api/tasks/overview"), "unexpected Code Queue path", { path }); - const health = assertStorageHealth(); - return { - ok: true, - status: 200, - body: { - ok: true, - queue: { - counts: { retry_wait: 1, queued: 0, running: 0, judging: 0 }, - storage: { - postgresReady: true, - dirtyTaskCount: 2, - dirtyQueueCount: 1, - lastError: "CONNECTION_CLOSED null socket.write", - health, - }, - executionDiagnostics: { - state: "degraded", - degraded: true, - effectiveLiveness: "degraded", - recommendedAction: "observe-degraded", - databaseActiveTaskCount: 0, - schedulerActiveRunSlotCount: 0, - activeHeartbeatCount: 0, - heartbeatRiskTaskIds: [], - staleRecoveryCandidateTaskIds: [], - traceGapTaskIds: [], - }, - }, - pagination: { - limit: 20, - returned: 1, - total: 1, - hasMore: false, - nextBeforeId: null, - includeActive: true, - }, - tasks: [ - { - id: "codex_pg_rotation", - queueId: "default", - status: "retry_wait", - currentAttempt: 2, - updatedAt: "2026-05-23T00:00:10.000Z", - prompt: "UniDesk#20 Code Queue scheduler Postgres connection rotation crash", - basePrompt: "UniDesk#20 Code Queue scheduler Postgres connection rotation crash", - displayPrompt: "UniDesk#20 Code Queue scheduler Postgres connection rotation crash", - lastError: "database flush degraded after CONNECTION_CLOSED socket.write null", - }, - ], - }, - }; -} - -function assertCommanderInfrastructureSignal(): void { - const result = asRecord(codexTasksQueryForTest(["--view", "commander", "--limit", "20"], fixtureResponse), "commander result"); - const commander = asRecord(result.commander, "result.commander"); - const infrastructure = asRecord(commander.infrastructure, "commander.infrastructure"); - assertCondition(infrastructure.infrastructureBlocker === true, "commander view should surface storage issue as infrastructure-blocker", infrastructure); - assertCondition(infrastructure.status === "degraded", "commander infrastructure status should be degraded", infrastructure); - assertCondition(String(infrastructure.actionable ?? "").includes("Code Queue infrastructure-blocker"), "commander infrastructure signal should be actionable", infrastructure); - const signals = asArray(infrastructure.signals, "commander.infrastructure.signals"); - assertCondition(signals.length === 1, "commander infrastructure signals should be bounded", infrastructure); - const signal = asRecord(signals[0], "commander.infrastructure.signals[0]"); - assertCondition(signal.category === "infrastructure-blocker", "commander signal category should be infrastructure-blocker", signal); - const riskCounts = asRecord(commander.riskCounts, "commander.riskCounts"); - assertCondition(riskCounts.infrastructureBlocker === 1, "commander risk counts should expose infrastructure blocker", riskCounts); - const classification = asRecord(commander.classification, "commander.classification"); - const byCategory = asRecord(classification.byCategory, "commander.classification.byCategory"); - assertCondition(Number(byCategory["infrastructure-blocker"] ?? 0) >= 1, "task classification should identify postgres scheduler crash as infrastructure-blocker", classification); -} - -function assertStaleBadResumeClassification(): void { - const classification = classifyRunnerError("app-server error: no rollout found for thread id thread_bad_resume_123", "D601"); - assertCondition(classification.staleBadResume === true, "no rollout found for thread id should be stale bad-resume", classification); - assertCondition(classification.failureKind === "stale-bad-resume-thread-rollout-missing", "bad resume failure kind should be explicit", classification); - assertCondition(classification.retryable === true, "bad resume should be retryable after superseding stale thread", classification); - assertCondition(classification.disposition === "service-degraded", "bad resume should be service-degraded, not business failure", classification); -} - -assertDatabaseClassification(); -assertDirtyBatchRestore(); -assertStorageHealth(); -assertCommanderInfrastructureSignal(); -assertStaleBadResumeClassification(); - -process.stdout.write(`${JSON.stringify({ - ok: true, - checks: [ - "CONNECTION_CLOSED socket.write null is classified as transient postgres infrastructure", - "CONNECTION_CLOSED code-only errors are retryable", - "dirty flush batch restore keeps task/queue ids dirty for retry", - "storage health emits bounded infrastructure-blocker signal", - "codex tasks --view commander surfaces the storage signal", - "stale bad-resume no rollout found for thread id is retryable and superseded", - ], -}, null, 2)}\n`); diff --git a/scripts/code-queue-pr-preflight-contract-test.ts b/scripts/code-queue-pr-preflight-contract-test.ts deleted file mode 100644 index 74c585f3..00000000 --- a/scripts/code-queue-pr-preflight-contract-test.ts +++ /dev/null @@ -1,1081 +0,0 @@ -import { codexPrPreflightQueryForTest } from "./src/code-queue"; -import type { UniDeskConfig } from "./src/config"; - -type JsonRecord = Record; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function asRecord(value: unknown): JsonRecord { - assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), "expected JSON object", { value }); - return value as JsonRecord; -} - -function localBackendCoreMissingFixture(): JsonRecord { - return { - ok: false, - failureKind: "target-stack-not-running", - degradedReason: "backend-core-container-missing", - runnerDisposition: "infra-blocked", - message: "backend-core/database target containers are not running; only verify-only containers were observed.", - targetStack: { - expectedContainers: ["unidesk-backend-core", "unidesk-database", "baidu-netdisk-backend"], - missingContainers: ["unidesk-backend-core", "unidesk-database", "baidu-netdisk-backend"], - verifyOnlyObserved: true, - }, - readOnlyCommands: [ - "bun scripts/cli.ts server status", - "bun scripts/cli.ts schedule list", - "bun scripts/cli.ts schedule runs --limit 20", - ], - authorizationRequiredForRecovery: ["restore runtime secret coverage", "start the target stack"], - }; -} - -function remoteControlPlaneResult(overrides: Partial = {}): JsonRecord { - return { - ok: true, - runnerDisposition: "ready", - failureKind: null, - degradedReason: null, - upstream: { ok: true, status: 200 }, - controlPlane: { - mode: "remote-frontend", - host: "74.48.78.17", - frontendUrl: "http://74.48.78.17:18081", - localBackendCoreMissing: true, - remoteFallbackUsed: true, - }, - preflight: { - ok: true, - runnerDisposition: "ready", - failureKind: null, - degradedReason: null, - checkedAt: "2026-05-20T00:00:00.000Z", - runner: { - serviceId: "code-queue", - plane: "D601 k3s scheduler/runner", - queueScope: "all queues executed by the scheduler, including default", - cwd: "/workspace/unidesk", - pid: 123, - }, - tokenCoverage: { - ok: true, - source: "GH_TOKEN", - ghTokenPresent: true, - githubTokenPresent: false, - ghCredentialStorePresent: false, - runnerDisposition: "ready", - missing: [], - scope: "scheduler-runner-env", - }, - authBroker: { - ok: true, - source: "GH_TOKEN", - needed: false, - configured: false, - runnerDisposition: "ready", - failureKind: null, - degradedReason: null, - runnerEnvTokenRequiredWithoutBroker: true, - brokerCredentialSource: null, - valuesPrinted: false, - evidence: { - envTokenMissing: false, - missing: [], - systemGhBinaryOk: true, - systemGhBinaryRequiredForWrites: false, - unideskGhCliObserved: true, - unideskGhCliOk: true, - unideskGhCliRequiresSystemGhBinary: false, - systemGhMissingMisclassifiedAsUniDeskCliMissing: false, - }, - next: [], - reference: "docs/reference/auth-broker.md#post-v1githubpr-preflight", - }, - prCapabilityContract: { - targetBranch: "master", - tokenSource: "GH_TOKEN", - systemGhBinaryRequiredForWrites: false, - unideskGhCli: { ok: true, path: "/workspace/unidesk/scripts/cli.ts", present: true, role: "repo-native REST GitHub CLI used by bun scripts/cli.ts gh", requiresSystemGhBinary: false }, - 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, - }, - mergeBoundary: { - supported: true, - command: "bun scripts/cli.ts gh pr merge --repo pikasTech/unidesk", - preflightRequired: true, - dryRunCommand: "bun scripts/cli.ts gh pr merge --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: { - mode: "local-backend-core", - localBackendCoreMissing: false, - remoteFallbackUsed: false, - }, - tools: { - git: { ok: true, path: "/usr/bin/git", version: "git version 2.43.0" }, - gh: { ok: true, path: "/usr/bin/gh", version: "gh version 2.45.0" }, - systemGhBinary: { ok: true, path: "/usr/bin/gh", version: "gh version 2.45.0" }, - hub: { ok: false, path: null, version: null }, - jq: { ok: true, path: "/usr/bin/jq", version: "jq-1.7" }, - ssh: { ok: true, path: "/usr/bin/ssh", version: "OpenSSH_9.6" }, - curl: { ok: true, path: "/usr/bin/curl", version: "curl 8.5.0" }, - unideskGhCli: { ok: true, path: "/workspace/unidesk/scripts/cli.ts", present: true, role: "repo-native REST GitHub CLI used by bun scripts/cli.ts gh", requiresSystemGhBinary: false }, - }, - agentPorts: { - codex: { ok: true, commandPath: "/usr/local/bin/codex", version: "codex 0.128.0", errors: [] }, - opencode: { ok: true, commandPath: "/usr/local/bin/opencode", version: "opencode 1.14.48", errors: [] }, - }, - git: { - insideWorktree: true, - branch: "feature/code-queue-pr-preflight", - head: "abc1234", - originMaster: "def5678", - remoteOrigin: "git@github.com:pikasTech/unidesk.git", - home: "/root", - homeWritable: true, - knownHostsPresent: true, - privateKeyPresent: true, - }, - githubContext: { - host: "github.com", - apiBaseUrl: "https://api.github.com", - repo: "pikasTech/unidesk", - issueProbeNumber: 35, - }, - egress: { - proxy: { - selectedProxyHost: "d601-provider-egress-proxy.unidesk.svc.cluster.local", - selectedProxyPort: "18789", - selectedProxyHostResolvable: true, - }, - githubDefault: { command: "curl", args: ["-IsS", "https://github.com"], ok: true, exitCode: 0, signal: null, error: null, stdout: "", stderr: "" }, - apiDefault: { command: "curl", args: ["-IsS", "https://api.github.com"], ok: true, exitCode: 0, signal: null, error: null, stdout: "", stderr: "" }, - issueApi: null, - }, - remote: { - gitLsRemote: { command: "git", args: ["ls-remote", "--heads", "origin", "master"], ok: true, exitCode: 0, signal: null, error: null, stdout: "abc1234\trefs/heads/master\n", stderr: "" }, - gitHttpsLsRemote: null, - githubSshAuthenticated: true, - ghAuthStatus: { command: "gh", args: ["auth", "status"], ok: true, exitCode: 0, signal: null, error: null, stdout: "", stderr: "" }, - ghRepoView: { command: "gh", args: ["repo", "view", "pikasTech/unidesk"], ok: true, exitCode: 0, signal: null, error: null, stdout: "", stderr: "" }, - ghIssueView: { command: "gh", args: ["issue", "view", "35"], ok: true, exitCode: 0, signal: null, error: null, stdout: "", stderr: "" }, - ghPrReadOnly: { command: "gh", args: ["pr", "list"], ok: true, exitCode: 0, signal: null, error: null, stdout: "", stderr: "" }, - }, - pushDryRun: null, - prCreateDryRun: null, - limitations: [], - risks: [], - recoveryHint: "Runner PR workflow has env-token coverage for the scheduler.", - commands: { - local: "bun scripts/cli.ts gh auth status --repo pikasTech/unidesk", - runner: "bun scripts/cli.ts codex pr-preflight --remote", - runnerPushDryRun: "bun scripts/cli.ts codex pr-preflight --remote --push-dry-run --push-dry-run-ref refs/heads/probe/code-queue-pr-capability", - runnerPrCreateDryRun: "bun scripts/cli.ts codex pr-preflight --remote --pr-create-dry-run --pr-create-dry-run-head ", - rawProxy: "bun scripts/cli.ts microservice proxy code-queue /api/runtime-preflight?remote=1 --raw", - }, - }, - ...overrides, - }; -} - -function rawRuntimePreflightFixture(overrides: Partial = {}): JsonRecord { - return { - ok: true, - checkedAt: "2026-05-20T00:00:00.000Z", - cwd: "/workspace/unidesk", - pid: 123, - pullRequestDelivery: { - ok: true, - checkedAt: "2026-05-20T00:00:00.000Z", - tools: { - git: { ok: true, path: "/usr/bin/git", version: "git version 2.43.0" }, - gh: { ok: false, path: null, version: null }, - hub: { ok: false, path: null, version: null }, - jq: { ok: true, path: "/usr/bin/jq", version: "jq-1.7" }, - ssh: { ok: true, path: "/usr/bin/ssh", version: "OpenSSH_9.6" }, - curl: { ok: true, path: "/usr/bin/curl", version: "curl 8.5.0" }, - }, - unideskGhCli: { ok: true, path: "/workspace/unidesk/scripts/cli.ts", present: true }, - credentials: { - ghTokenPresent: false, - githubTokenPresent: false, - ghHostsConfigPresent: false, - gitCredentialsPresent: false, - }, - authBroker: { - ok: false, - configured: false, - source: "broker/auth-broker-needed", - endpointEnvKey: null, - runnerEnvTokenRequired: false, - credentialSource: null, - failureKind: "auth-missing", - degradedReason: "auth-broker-needed", - capability: { - source: "missing-token", - githubRestAuth: false, - operations: ["github.auth.status", "github.issue.read", "github.pr.read", "github.pr.create"], - systemGhBinaryRequiredForWrites: false, - preflightWritesRemote: false, - preflightCreatesPr: false, - preflightMergesPr: false, - realPrCreateRequiresCommanderAuthorization: true, - valuesPrinted: false, - }, - nextAction: "configure-auth-broker-or-env-token", - next: ["configure UNIDESK_AUTH_BROKER_URL or AUTH_BROKER_URL for broker-backed runner auth"], - valuesRead: false, - valuesPrinted: false, - }, - git: { - insideWorktree: true, - branch: "code-queue/issue-35-pr-dry-run-probe", - head: "abc1234", - originMaster: "def5678", - remoteOrigin: "git@github.com:pikasTech/unidesk.git", - home: "/root", - homeWritable: true, - knownHostsPresent: true, - privateKeyPresent: true, - }, - githubContext: { - host: "github.com", - apiBaseUrl: "https://api.github.com", - repo: "pikasTech/unidesk", - issueProbeNumber: 20, - }, - egress: { - proxy: { - selectedProxyHost: "d601-provider-egress-proxy.unidesk.svc.cluster.local", - selectedProxyPort: "18789", - selectedProxyHostResolvable: true, - }, - githubDefault: { command: "curl", args: ["-IsS", "https://github.com"], ok: true, exitCode: 0, signal: null, error: null, stdout: "", stderr: "" }, - apiDefault: { command: "curl", args: ["-IsS", "https://api.github.com"], ok: true, exitCode: 0, signal: null, error: null, stdout: "", stderr: "" }, - issueApi: null, - }, - remote: { - gitLsRemote: { command: "git", args: ["ls-remote", "--heads", "origin", "master"], ok: true, exitCode: 0, signal: null, error: null, stdout: "abc1234\trefs/heads/master\n", stderr: "" }, - gitHttpsLsRemote: null, - githubSshAuthenticated: true, - ghAuthStatus: null, - ghRepoView: null, - ghIssueView: null, - ghPrReadOnly: null, - }, - limitations: [], - risks: [ - "system gh binary is missing; UniDesk REST gh CLI remains the supported PR create/comment path when scripts/cli.ts and GH_TOKEN/GITHUB_TOKEN or auth-broker are available", - ], - }, - ports: {}, - ...overrides, - }; -} - -async function main(): Promise { - let observedLocalPath = ""; - const remoteFallback = await codexPrPreflightQueryForTest(["--remote", "--issue", "35"], { - config: { - project: { name: "unidesk", timezone: "Etc/UTC" }, - runtime: { typescript: "bun", bunVersion: "1.3.13" }, - network: { - host: "0.0.0.0", - publicHost: "74.48.78.17", - core: { port: 18080, containerPort: 8080 }, - frontend: { port: 18081, containerPort: 8080 }, - devFrontend: { port: 18083, containerPort: 8080 }, - database: { port: 15432, containerPort: 5432 }, - providerIngress: { port: 18082, containerPort: 8081 }, - providerData: { port: 18084, containerPort: 8082 }, - }, - database: { user: "unidesk", password: "", name: "unidesk", volume: "unidesk_pgdata_10gb", volumeSize: "15GB" }, - providerGateway: { - id: "main-server", - name: "Main Server Provider", - token: "", - labels: { host: "main-server", role: "self-provider", docker: true }, - heartbeatIntervalMs: 15000, - reconnectBaseMs: 1000, - reconnectMaxMs: 30000, - metrics: { diskPath: "/" }, - upgrade: { hostProjectRoot: "/root/unidesk", workspacePath: "/workspace", composeFile: "docker-compose.yml", composeEnvFile: ".state/docker-compose.env", composeProject: "unidesk", service: "provider-gateway", runnerImage: "unidesk_provider-gateway" }, - }, - docker: { composeFile: "docker-compose.yml", projectName: "unidesk" }, - microservices: [], - paths: { stateDir: ".state", logsDir: "logs", docsReferenceDir: "docs/reference" }, - sshForwarding: { mode: "ws", keyDir: "/root/.ssh", host: "main-server", port: 22, user: "root" }, - auth: { username: "admin", password: "", sessionSecret: "", sessionTtlSeconds: 86400 }, - }, - coreFetch: (path) => { - observedLocalPath = path; - return localBackendCoreMissingFixture(); - }, - remoteMainServerPrPreflight: () => remoteControlPlaneResult({ - controlPlane: { - mode: "remote-frontend", - host: "74.48.78.17", - frontendUrl: "http://74.48.78.17:18081", - localBackendCoreMissing: true, - remoteFallbackUsed: true, - }, - failureKind: null, - degradedReason: null, - }), - }); - assertCondition(observedLocalPath === "/api/microservices/code-queue/proxy/api/runtime-preflight?remote=1&issue=35", "runner-like local path should stay on the stable proxy", { observedLocalPath }); - const fallback = asRecord(remoteFallback); - assertCondition(fallback.ok === true, "remote fallback should succeed", fallback); - assertCondition(fallback.runnerDisposition === "ready", "remote fallback should stay ready", fallback); - assertCondition(fallback.controlPlane && asRecord(fallback.controlPlane).remoteFallbackUsed === true, "remote fallback should be marked", fallback.controlPlane); - assertCondition(fallback.failureKind === null, "remote fallback should not invent a failure kind when remote control plane is healthy", fallback); - const fallbackLocalGap = asRecord(fallback.localObservationGap); - assertCondition(fallbackLocalGap.kind === "runner-local-observation-gap", "healthy remote fallback should classify local backend-core absence as runner-local observation gap", fallbackLocalGap); - assertCondition(fallbackLocalGap.schedulerStoppage === false, "local observation gap must not imply scheduler stoppage", fallbackLocalGap); - assertCondition(fallback.localObservation === undefined, "healthy remote fallback default output should omit full local observation", fallback); - assertCondition(fallback.remoteObservation === undefined, "healthy remote fallback default output should omit full remote observation", fallback); - assertCondition(asRecord(fallback.disclosure).fullObservationsOmitted === true, "healthy remote fallback should disclose omitted full observations", fallback.disclosure); - assertCondition(asRecord(fallback.localObservationSummary).failureKind === "target-stack-not-running", "healthy remote fallback should keep bounded local observation summary", fallback.localObservationSummary); - assertCondition(asRecord(fallback.remoteObservationSummary).ok === true, "healthy remote fallback should keep bounded remote observation summary", fallback.remoteObservationSummary); - assertCondition(fallback.preflight === undefined, "remote fallback default output should omit detailed preflight", fallback); - const fallbackSchedulerPreflight = asRecord(fallback.schedulerPreflight); - assertCondition(fallbackSchedulerPreflight.authReady === true, "remote fallback scheduler summary should stay ready", fallbackSchedulerPreflight); - assertCondition(fallbackSchedulerPreflight.authSource === "GH_TOKEN", "token source should be GH_TOKEN", fallbackSchedulerPreflight); - assertCondition(asRecord(fallback.prCapability).targetBranch === "master", "target branch should stay master", fallback.prCapability); - - const authMissing = await codexPrPreflightQueryForTest(["--remote"], { - config: null, - coreFetch: () => localBackendCoreMissingFixture(), - }); - const remoteControlPlaneMissingRecord = asRecord(authMissing); - assertCondition(remoteControlPlaneMissingRecord.ok === false, "missing control plane should fail", remoteControlPlaneMissingRecord); - assertCondition(remoteControlPlaneMissingRecord.failureKind === "control-plane-missing", "missing control plane should classify as control-plane-missing", remoteControlPlaneMissingRecord); - assertCondition(remoteControlPlaneMissingRecord.degradedReason === "remote-control-plane-unreachable", "missing control plane should classify as remote-control-plane-unreachable", remoteControlPlaneMissingRecord); - assertCondition(remoteControlPlaneMissingRecord.runnerDisposition === "infra-blocked", "missing remote control plane keeps legacy runnerDisposition compatibility", remoteControlPlaneMissingRecord); - assertCondition(remoteControlPlaneMissingRecord.blockingDisposition === "control-plane-observation-gap", "missing remote control plane should expose observation gap blocking disposition", remoteControlPlaneMissingRecord); - const remoteControlPlaneGap = asRecord(remoteControlPlaneMissingRecord.observationGap); - assertCondition(remoteControlPlaneGap.kind === "control-plane-observation-gap", "missing remote control plane should expose control-plane observation gap", remoteControlPlaneGap); - assertCondition(remoteControlPlaneGap.schedulerStoppage === false, "control-plane observation gap must not imply scheduler stoppage", remoteControlPlaneGap); - assertCondition(asRecord(remoteControlPlaneMissingRecord.controlPlane).localBackendCoreMissing === true, "local backend-core absence should remain evidence only", remoteControlPlaneMissingRecord.controlPlane); - - const directAuthMissing = await codexPrPreflightQueryForTest(["--remote"], { - config: { network: { publicHost: "74.48.78.17", frontend: { port: 18081 } } } as unknown as UniDeskConfig, - coreFetch: () => localBackendCoreMissingFixture(), - remoteMainServerPrPreflight: () => remoteControlPlaneResult({ - ok: false, - failureKind: "auth-missing", - degradedReason: "GH_TOKEN/GITHUB_TOKEN missing", - runnerDisposition: "infra-blocked", - message: "GH_TOKEN/GITHUB_TOKEN missing in remote control plane", - tokenCoverage: { - ok: false, - source: null, - ghTokenPresent: false, - githubTokenPresent: false, - ghCredentialStorePresent: false, - runnerDisposition: "infra-blocked", - missing: ["GH_TOKEN", "GITHUB_TOKEN"], - scope: "scheduler-runner-env", - }, - prCapabilityContract: { - targetBranch: "master", - tokenSource: null, - systemGhBinaryRequiredForWrites: false, - unideskGhCli: { ok: true, path: "/workspace/unidesk/scripts/cli.ts", present: true, role: "repo-native REST GitHub CLI used by bun scripts/cli.ts gh", requiresSystemGhBinary: false }, - 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 }, - mergeBoundary: { supported: true, command: "bun scripts/cli.ts gh pr merge --repo pikasTech/unidesk", preflightRequired: true, dryRunCommand: "bun scripts/cli.ts gh pr merge --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." }, - }, - }), - }); - const directAuthMissingRecord = asRecord(directAuthMissing); - assertCondition(directAuthMissingRecord.ok === false, "auth-missing remote result should fail", directAuthMissingRecord); - assertCondition(directAuthMissingRecord.failureKind === "auth-missing", "missing token should classify as auth-missing", directAuthMissingRecord); - assertCondition(directAuthMissingRecord.degradedReason === "GH_TOKEN/GITHUB_TOKEN missing", "auth missing should state token gap", directAuthMissingRecord); - const directAuthObservationGap = asRecord(directAuthMissingRecord.observationGap); - assertCondition(directAuthObservationGap.kind === "runner-local-observation-gap", "auth missing after remote fallback should keep local backend-core absence scoped as runner-local observation gap", directAuthObservationGap); - const directAuthSummary = asRecord(directAuthMissingRecord.authScopeSummary); - const directAuthScopeBoundary = asRecord(directAuthMissingRecord.scopeBoundary); - const directAuthActiveRunner = asRecord(directAuthMissingRecord.activeRunnerPrCapability); - const directAuthRecommendedActions = Array.isArray(directAuthMissingRecord.recommendedActions) ? directAuthMissingRecord.recommendedActions : []; - assertCondition(directAuthSummary.schedulerAuthMissingIsScoped === true, "remote auth-missing should lead with scheduler-scoped auth summary", directAuthSummary); - assertCondition(String(directAuthSummary.interpretation ?? "").includes("does not prove"), "remote auth summary must not imply active runner PR incapability", directAuthSummary); - assertCondition(directAuthScopeBoundary.scopesAreIndependent === true, "remote auth-missing must distinguish scheduler env from active runner dev container", directAuthScopeBoundary); - assertCondition(directAuthScopeBoundary.schedulerAuthMissingDoesNotMeanActiveRunnerCannotCreatePr === true, "remote auth-missing should expose the explicit PR capability boundary", directAuthScopeBoundary); - assertCondition(String(directAuthScopeBoundary.authMissingInterpretation ?? "").includes("do not simplify"), "remote auth-missing must warn against overbroad interpretation", directAuthScopeBoundary); - assertCondition(directAuthActiveRunner.independentOfSchedulerPreflight === true, "active runner token capability must be a separate scope", directAuthActiveRunner); - assertCondition(Array.isArray(directAuthMissingRecord.recommendedActions), "remote auth-missing should expose bounded recommended actions", directAuthMissingRecord.recommendedActions); - assertCondition(directAuthRecommendedActions.length === 3, "remote auth-missing recommended actions should stay bounded", directAuthRecommendedActions); - assertCondition(directAuthRecommendedActions.some((action) => asRecord(action).action === "verify-current-runner-auth"), "remote auth-missing should recommend active runner auth status first", directAuthRecommendedActions); - assertCondition(directAuthRecommendedActions.some((action) => String(asRecord(action).command ?? "").includes("gh pr create") && asRecord(action).writesRemote === false), "remote auth-missing should recommend PR create dry-run", directAuthRecommendedActions); - assertCondition(directAuthMissingRecord.localObservation === undefined, "auth-missing remote fallback default output should omit full local observation", directAuthMissingRecord); - assertCondition(directAuthMissingRecord.remoteObservation === undefined, "auth-missing remote fallback default output should omit full remote observation", directAuthMissingRecord); - assertCondition(directAuthMissingRecord.preflight === undefined, "auth-missing remote fallback default output should omit detailed preflight", directAuthMissingRecord); - assertCondition(asRecord(directAuthMissingRecord.disclosure).fullObservationsOmitted === true, "auth-missing remote fallback should disclose omitted full observations", directAuthMissingRecord.disclosure); - assertCondition(asRecord(directAuthMissingRecord.disclosure).fullDetailOmitted === true, "auth-missing remote fallback should disclose omitted full detail", directAuthMissingRecord.disclosure); - assertCondition(asRecord(directAuthMissingRecord.localObservationSummary).failureKind === "target-stack-not-running", "auth-missing remote fallback should keep bounded local observation summary", directAuthMissingRecord.localObservationSummary); - assertCondition(asRecord(asRecord(directAuthMissingRecord.remoteObservationSummary).tokenCoverage).scope === "scheduler-runner-env", "auth-missing remote fallback should keep bounded remote token scope", directAuthMissingRecord.remoteObservationSummary); - assertCondition(JSON.stringify(directAuthMissingRecord).length < 12000, "auth-missing remote fallback default output should stay compact", { chars: JSON.stringify(directAuthMissingRecord).length }); - - const directAuthMissingFull = await codexPrPreflightQueryForTest(["--remote", "--full"], { - config: { network: { publicHost: "74.48.78.17", frontend: { port: 18081 } } } as unknown as UniDeskConfig, - coreFetch: () => localBackendCoreMissingFixture(), - remoteMainServerPrPreflight: () => remoteControlPlaneResult({ - ok: false, - failureKind: "auth-missing", - degradedReason: "GH_TOKEN/GITHUB_TOKEN missing", - runnerDisposition: "infra-blocked", - tokenCoverage: { - ok: false, - source: null, - missing: ["GH_TOKEN", "GITHUB_TOKEN"], - scope: "scheduler-runner-env", - }, - }), - }); - const directAuthMissingFullRecord = asRecord(directAuthMissingFull); - assertCondition(directAuthMissingFullRecord.localObservation !== undefined, "--full should retain full local observation", directAuthMissingFullRecord); - assertCondition(directAuthMissingFullRecord.remoteObservation !== undefined, "--full should retain full remote observation", directAuthMissingFullRecord); - - const gitRemoteGap = remoteControlPlaneResult({ - ok: false, - failureKind: "git-remote-gap", - degradedReason: "git remote probe failed", - runnerDisposition: "infra-blocked", - message: "git ls-remote probe failed", - }); - const gitRemoteGapRecord = asRecord(gitRemoteGap); - assertCondition(gitRemoteGapRecord.failureKind === "git-remote-gap", "git probe failures should stay structured", gitRemoteGapRecord); - - const localOnlyObservationGap = await codexPrPreflightQueryForTest([], { - config: null, - coreFetch: () => localBackendCoreMissingFixture(), - }); - const localOnlyObservationGapRecord = asRecord(localOnlyObservationGap); - assertCondition(localOnlyObservationGapRecord.ok === false, "local-only backend-core absence should fail the preflight", localOnlyObservationGapRecord); - assertCondition(localOnlyObservationGapRecord.failureKind === "target-stack-not-running", "local-only backend-core absence should preserve target-stack evidence", localOnlyObservationGapRecord); - assertCondition(localOnlyObservationGapRecord.runnerDisposition === "infra-blocked", "local-only backend-core absence keeps legacy runnerDisposition compatibility", localOnlyObservationGapRecord); - assertCondition(localOnlyObservationGapRecord.blockingDisposition === "runner-local-observation-gap", "local-only backend-core absence should expose runner-local blocking disposition", localOnlyObservationGapRecord); - const localOnlyGap = asRecord(localOnlyObservationGapRecord.observationGap); - assertCondition(localOnlyGap.kind === "runner-local-observation-gap", "local-only backend-core absence should include observationGap detail", localOnlyGap); - assertCondition(localOnlyGap.schedulerStoppage === false, "local-only backend-core absence must not imply scheduler stoppage", localOnlyGap); - - const proxyGap = await codexPrPreflightQueryForTest(["--remote"], { - config: null, - coreFetch: () => ({ - ok: true, - status: 200, - body: { - runtimePreflight: { - ok: false, - checkedAt: "2026-05-20T00:00:00.000Z", - cwd: "/workspace/unidesk", - pid: 123, - pullRequestDelivery: { - ok: false, - checkedAt: "2026-05-20T00:00:00.000Z", - tools: { - git: { ok: true, path: "/usr/bin/git", version: "git version 2.43.0" }, - gh: { ok: true, path: "/usr/bin/gh", version: "gh version 2.45.0" }, - jq: { ok: true, path: "/usr/bin/jq", version: "jq-1.7" }, - ssh: { ok: true, path: "/usr/bin/ssh", version: "OpenSSH_9.6" }, - curl: { ok: true, path: "/usr/bin/curl", version: "curl 8.5.0" }, - }, - unideskGhCli: { ok: true, path: "/workspace/unidesk/scripts/cli.ts", present: true }, - credentials: { - ghTokenPresent: true, - githubTokenPresent: false, - ghHostsConfigPresent: false, - gitCredentialsPresent: false, - }, - git: { - insideWorktree: true, - branch: "feature/code-queue-pr-preflight", - head: "abc1234", - originMaster: "def5678", - remoteOrigin: "git@github.com:pikasTech/unidesk.git", - home: "/root", - homeWritable: true, - knownHostsPresent: true, - privateKeyPresent: true, - }, - githubContext: { - host: "github.com", - apiBaseUrl: "https://api.github.com", - repo: "pikasTech/unidesk", - issueProbeNumber: 20, - }, - egress: { - proxy: { - selectedProxyHost: "missing-egress-proxy.unidesk.svc.cluster.local", - selectedProxyPort: "18789", - selectedProxyHostResolvable: false, - }, - githubDefault: { command: "curl", args: ["-IsS", "https://github.com"], ok: false, exitCode: 6, signal: null, error: null, stdout: "", stderr: "Could not resolve proxy" }, - apiDefault: { command: "curl", args: ["-IsS", "https://api.github.com"], ok: false, exitCode: 6, signal: null, error: null, stdout: "", stderr: "Could not resolve proxy" }, - issueApi: null, - }, - remote: { - gitLsRemote: { command: "git", args: ["ls-remote", "--heads", "origin", "master"], ok: true, exitCode: 0, signal: null, error: null, stdout: "abc1234\trefs/heads/master\n", stderr: "" }, - gitHttpsLsRemote: null, - githubSshAuthenticated: true, - ghAuthStatus: { command: "gh", args: ["auth", "status"], ok: true, exitCode: 0, signal: null, error: null, stdout: "", stderr: "" }, - ghRepoView: { command: "gh", args: ["repo", "view", "pikasTech/unidesk"], ok: true, exitCode: 0, signal: null, error: null, stdout: "", stderr: "" }, - ghIssueView: { command: "gh", args: ["issue", "view", "20"], ok: true, exitCode: 0, signal: null, error: null, stdout: "", stderr: "" }, - ghPrReadOnly: { command: "gh", args: ["pr", "list"], ok: true, exitCode: 0, signal: null, error: null, stdout: "", stderr: "" }, - }, - limitations: [ - "configured GitHub egress proxy host is not resolvable: missing-egress-proxy.unidesk.svc.cluster.local", - "GitHub HTTPS probe failed with the default environment/proxy", - ], - risks: [], - }, - ports: {}, - }, - }, - }), - }); - const proxyGapRecord = asRecord(proxyGap); - assertCondition(proxyGapRecord.failureKind === "proxy-gap", "proxy failures should classify as proxy-gap", proxyGapRecord); - assertCondition(proxyGapRecord.degradedReason === "configured GitHub egress proxy host is not resolvable: missing-egress-proxy.unidesk.svc.cluster.local", "proxy degraded reason should point at the proxy", proxyGapRecord); - - const githubTransientContract = await codexPrPreflightQueryForTest(["--remote"], { - config: null, - coreFetch: () => ({ - ok: true, - status: 200, - body: { - runtimePreflight: rawRuntimePreflightFixture({ - ok: false, - pullRequestDelivery: { - ...asRecord(rawRuntimePreflightFixture().pullRequestDelivery), - ok: false, - credentials: { - ghTokenPresent: true, - githubTokenPresent: false, - ghHostsConfigPresent: false, - gitCredentialsPresent: false, - }, - egress: { - proxy: { - selectedProxyHost: "d601-provider-egress-proxy.unidesk.svc.cluster.local", - selectedProxyPort: "18789", - selectedProxyHostResolvable: true, - }, - githubDefault: { command: "curl", args: ["-IsS", "https://github.com"], ok: false, exitCode: 6, signal: null, error: null, stdout: "", stderr: "curl: (6) Could not resolve host: github.com" }, - apiDefault: { command: "curl", args: ["-IsS", "https://api.github.com"], ok: false, exitCode: 6, signal: null, error: null, stdout: "", stderr: "curl: (6) Could not resolve host: api.github.com" }, - issueApi: null, - }, - remote: { - gitLsRemote: { command: "git", args: ["ls-remote", "--heads", "origin", "master"], ok: false, exitCode: 128, signal: null, error: null, stdout: "", stderr: "ssh: Could not resolve hostname github.com: Temporary failure in name resolution" }, - gitHttpsLsRemote: null, - githubSshAuthenticated: false, - ghAuthStatus: { command: "gh", args: ["auth", "status"], ok: false, exitCode: 1, signal: null, error: null, stdout: "", stderr: "error connecting to api.github.com" }, - ghRepoView: null, - ghIssueView: null, - ghPrReadOnly: null, - }, - limitations: [ - "GitHub HTTPS probe failed with the default environment/proxy", - "GitHub API probe failed with the default environment/proxy", - "git ls-remote origin master failed", - ], - risks: [], - }, - }), - }, - }), - }); - const githubTransientRecord = asRecord(githubTransientContract); - assertCondition(githubTransientRecord.failureKind === "github-transient", "GitHub DNS/API failures should classify separately from auth and semantic failures", githubTransientRecord); - assertCondition(githubTransientRecord.degradedReason === "github-dns-api-transient", "GitHub transient degraded reason should be stable", githubTransientRecord); - assertCondition(githubTransientRecord.runnerDisposition === "infra-blocked", "GitHub transient keeps infra-blocked disposition for legacy callers", githubTransientRecord); - assertCondition(githubTransientRecord.retryable === true, "GitHub transient should expose top-level retryable=true", githubTransientRecord); - assertCondition(githubTransientRecord.commanderAction === "retry-backoff-or-keep-running-if-heartbeat-fresh", "GitHub transient should expose top-level commander action", githubTransientRecord); - assertCondition(githubTransientRecord.preflight === undefined, "GitHub transient default output should omit detailed preflight", githubTransientRecord); - const githubTransient = asRecord(githubTransientRecord.githubTransient); - assertCondition(githubTransient.kind === "github-transient", "GitHub transient evidence should identify kind", githubTransient); - assertCondition(githubTransient.notAuthMissing === true, "GitHub transient must not be auth-missing", githubTransient); - assertCondition(githubTransient.notPrSemanticFailure === true, "GitHub transient must not be PR semantic failure", githubTransient); - assertCondition(Array.isArray(githubTransient.failedProbes) && githubTransient.failedProbes.length <= 4, "GitHub transient evidence should stay bounded", githubTransient); - assertCondition(String(githubTransient.commanderAction ?? "").includes("keep the task running"), "GitHub transient action should preserve fresh-heartbeat tasks", githubTransient); - const githubTransientFullRecord = asRecord(await codexPrPreflightQueryForTest(["--remote", "--full"], { - config: null, - coreFetch: () => ({ - ok: true, - status: 200, - body: { - runtimePreflight: rawRuntimePreflightFixture({ - ok: false, - pullRequestDelivery: { - ...asRecord(rawRuntimePreflightFixture().pullRequestDelivery), - ok: false, - credentials: { - ghTokenPresent: true, - githubTokenPresent: false, - ghHostsConfigPresent: false, - gitCredentialsPresent: false, - }, - egress: { - proxy: { - selectedProxyHost: "d601-provider-egress-proxy.unidesk.svc.cluster.local", - selectedProxyPort: "18789", - selectedProxyHostResolvable: true, - }, - githubDefault: { command: "curl", args: ["-IsS", "https://github.com"], ok: false, exitCode: 6, signal: null, error: null, stdout: "", stderr: "curl: (6) Could not resolve host: github.com" }, - apiDefault: { command: "curl", args: ["-IsS", "https://api.github.com"], ok: false, exitCode: 6, signal: null, error: null, stdout: "", stderr: "curl: (6) Could not resolve host: api.github.com" }, - issueApi: null, - }, - remote: { - gitLsRemote: { command: "git", args: ["ls-remote", "--heads", "origin", "master"], ok: false, exitCode: 128, signal: null, error: null, stdout: "", stderr: "ssh: Could not resolve hostname github.com: Temporary failure in name resolution" }, - gitHttpsLsRemote: null, - githubSshAuthenticated: false, - ghAuthStatus: { command: "gh", args: ["auth", "status"], ok: false, exitCode: 1, signal: null, error: null, stdout: "", stderr: "error connecting to api.github.com" }, - ghRepoView: null, - ghIssueView: null, - ghPrReadOnly: null, - }, - limitations: [ - "GitHub HTTPS probe failed with the default environment/proxy", - "GitHub API probe failed with the default environment/proxy", - "git ls-remote origin master failed", - ], - risks: [], - }, - }), - }, - }), - })); - const githubTransientPreflight = asRecord(githubTransientFullRecord.preflight); - assertCondition(githubTransientPreflight.retryable === true, "GitHub transient full preflight should be retryable", githubTransientPreflight); - assertCondition(githubTransientPreflight.commanderAction === "retry-backoff-or-keep-running-if-heartbeat-fresh", "GitHub transient full preflight should expose bounded commander action", githubTransientPreflight); - - const k3sTunnelTimeout = { - ok: false, - status: 504, - body: { - ok: false, - error: "provider HTTP tunnel timed out or disconnected", - stage: "http-tunnel-wait", - serviceId: "k3sctl-adapter", - providerId: "D601", - requestId: "req-k3s-timeout", - detail: { retryable: true }, - }, - }; - const fallbackRuntime = rawRuntimePreflightFixture({ - pullRequestDelivery: { - ...asRecord(rawRuntimePreflightFixture().pullRequestDelivery), - ok: true, - credentials: { - ghTokenPresent: true, - githubTokenPresent: false, - ghHostsConfigPresent: false, - gitCredentialsPresent: false, - }, - }, - }); - const fallbackCalls: string[] = []; - const k3sFallbackRecord = asRecord(await codexPrPreflightQueryForTest(["--remote", "--issue", "20"], { - coreFetch: (path) => { - fallbackCalls.push(path); - if (path.includes("remote=1")) return k3sTunnelTimeout; - return { ok: true, status: 200, body: { runtimePreflight: fallbackRuntime } }; - }, - })); - assertCondition(fallbackCalls.length === 2, "k3s tunnel timeout should trigger exactly one fallback runtime-preflight fetch", fallbackCalls); - assertCondition(fallbackCalls[0] === "/api/microservices/code-queue/proxy/api/runtime-preflight?remote=1&issue=20", "primary path should request remote runtime probes", fallbackCalls); - assertCondition(fallbackCalls[1] === "/api/microservices/code-queue/proxy/api/runtime-preflight?issue=20", "fallback path should omit remote=1 but keep issue query", fallbackCalls); - assertCondition(k3sFallbackRecord.ok === true, "transport-degraded fallback should preserve observed runtime preflight result", k3sFallbackRecord); - assertCondition(k3sFallbackRecord.failureKind === null, "healthy fallback runtime should not be misclassified as infra-blocked", k3sFallbackRecord); - const k3sFallbackSummary = asRecord(k3sFallbackRecord.summary); - const k3sFallbackTransport = asRecord(k3sFallbackRecord.transportObservation); - const k3sFallbackRuntime = asRecord(k3sFallbackRecord.runtimeObservation); - assertCondition(k3sFallbackSummary.transportState === "transport-degraded", "summary should distinguish degraded transport from runtime observation", k3sFallbackSummary); - assertCondition(k3sFallbackSummary.runtimePreflightState === "runtime-preflight-observed", "summary should expose observed runtime preflight after fallback", k3sFallbackSummary); - assertCondition(k3sFallbackTransport.fallbackUsed === true && k3sFallbackTransport.state === "transport-degraded", "transport observation should mark fallback used", k3sFallbackTransport); - assertCondition(asRecord(k3sFallbackTransport.primary).stage === "http-tunnel-wait", "primary transport summary should retain bounded tunnel stage", k3sFallbackTransport.primary); - assertCondition(asRecord(k3sFallbackTransport.primary).serviceId === "k3sctl-adapter", "primary transport summary should retain service id", k3sFallbackTransport.primary); - assertCondition(k3sFallbackRuntime.source === "fallback-runtime-preflight" && k3sFallbackRuntime.observed === true, "runtime observation should identify fallback source", k3sFallbackRuntime); - assertCondition(k3sFallbackRecord.preflight === undefined, "transport fallback default output should omit detailed preflight", k3sFallbackRecord); - assertCondition(JSON.stringify(k3sFallbackRecord).length < 14000, "transport fallback default output should stay compact", { chars: JSON.stringify(k3sFallbackRecord).length }); - - const bothPathsFailed = asRecord(await codexPrPreflightQueryForTest(["--remote"], { - coreFetch: (path) => path.includes("remote=1") - ? k3sTunnelTimeout - : { - ok: false, - status: 503, - body: { - ok: false, - error: "code-queue runtime unavailable", - stage: "runtime-preflight", - serviceId: "code-queue", - }, - }, - })); - assertCondition(bothPathsFailed.ok === false, "both unavailable paths should fail", bothPathsFailed); - assertCondition(bothPathsFailed.failureKind === "proxy-gap", "both unavailable paths should remain an infra/proxy observation gap", bothPathsFailed); - assertCondition(bothPathsFailed.degradedReason === "runtime-preflight-transport-unavailable", "both unavailable paths should expose stable degraded reason", bothPathsFailed); - assertCondition(bothPathsFailed.runnerDisposition === "infra-blocked", "both unavailable paths remain infra-blocked", bothPathsFailed); - assertCondition(bothPathsFailed.blockingDisposition === "control-plane-observation-gap", "both unavailable paths should not pretend runner capability was observed", bothPathsFailed); - const bothSummary = asRecord(bothPathsFailed.summary); - const bothTransport = asRecord(bothPathsFailed.transportObservation); - const bothRuntime = asRecord(bothPathsFailed.runtimeObservation); - const bothScheduler = asRecord(bothPathsFailed.schedulerPreflight); - assertCondition(bothSummary.transportState === "transport-blocked", "both paths failed summary should report transport-blocked", bothSummary); - assertCondition(bothSummary.runtimePreflightState === "runtime-preflight-unobserved", "both paths failed summary should report runtime unobserved", bothSummary); - assertCondition(bothSummary.schedulerPreflightAuthReady === null, "transport-blocked should not infer scheduler auth missing", bothSummary); - assertCondition(bothTransport.fallbackUsed === false && bothRuntime.observed === false, "both paths failed should expose fallback attempted but runtime unobserved", { bothTransport, bothRuntime }); - assertCondition(bothScheduler.authReady === null && Array.isArray(bothScheduler.missing) && bothScheduler.missing.length === 0, "transport-blocked scheduler preflight should be unknown, not auth-missing", bothScheduler); - - const skillsFallbackRecord = asRecord(await codexPrPreflightQueryForTest(["--remote"], { - coreFetch: (path) => path.includes("remote=1") - ? k3sTunnelTimeout - : { - ok: true, - status: 200, - body: { - runtimePreflight: rawRuntimePreflightFixture({ - ok: false, - skills: { - ok: false, - runnerUsable: false, - contractOk: false, - blocker: "skills-target-missing", - degradedReason: "skills-target-missing", - resolution: { hostRolloutRequired: true }, - valuesPrinted: false, - }, - skillsSync: { - ok: false, - degraded: true, - blocker: "skills-target-missing", - dryRun: true, - mutation: false, - valuesPrinted: false, - }, - pullRequestDelivery: { - ...asRecord(rawRuntimePreflightFixture().pullRequestDelivery), - ok: true, - credentials: { - ghTokenPresent: true, - githubTokenPresent: false, - ghHostsConfigPresent: false, - gitCredentialsPresent: false, - }, - }, - }), - }, - }, - })); - assertCondition(skillsFallbackRecord.ok === false, "skills blocker observed through fallback should fail preflight", skillsFallbackRecord); - assertCondition(skillsFallbackRecord.failureKind === "runner-skills-blocker", "skills-target-missing should classify as runner capability degraded, not proxy failure", skillsFallbackRecord); - assertCondition(skillsFallbackRecord.degradedReason === "skills-target-missing", "skills degraded reason should preserve skills-target-missing", skillsFallbackRecord); - assertCondition(skillsFallbackRecord.blockingDisposition === "runner-capability-degraded", "skills blocker should expose runner capability degraded disposition", skillsFallbackRecord); - assertCondition(asRecord(skillsFallbackRecord.summary).transportState === "transport-degraded", "skills fallback should still expose degraded transport", skillsFallbackRecord.summary); - assertCondition(asRecord(skillsFallbackRecord.summary).status === "runner-capability-degraded", "skills fallback summary should separate runner capability degradation", skillsFallbackRecord.summary); - assertCondition(asRecord(skillsFallbackRecord.transportObservation).fallbackUsed === true, "skills fallback should mark transport fallback used", skillsFallbackRecord.transportObservation); - assertCondition(asRecord(skillsFallbackRecord.runtimeObservation).observed === true, "skills fallback should report runtime observed", skillsFallbackRecord.runtimeObservation); - assertCondition(asRecord(skillsFallbackRecord.skillsContract).degradedReason === "skills-target-missing", "skills contract should expose missing target", skillsFallbackRecord.skillsContract); - - let observedDryRunPath = ""; - const dryRunContract = await codexPrPreflightQueryForTest([ - "--remote", - "--push-dry-run", - "--push-dry-run-ref", - "refs/heads/probe/code-queue-pr-capability", - "--pr-create-dry-run", - "--pr-create-dry-run-head", - "code-queue/issue-35-pr-dry-run-probe", - "--issue", - "20", - ], { - config: null, - coreFetch: (path) => { - observedDryRunPath = path; - return { - ok: true, - status: 200, - body: { - runtimePreflight: { - ok: false, - checkedAt: "2026-05-20T00:00:00.000Z", - cwd: "/workspace/unidesk", - pid: 123, - pullRequestDelivery: { - ok: false, - checkedAt: "2026-05-20T00:00:00.000Z", - tools: { - git: { ok: true, path: "/usr/bin/git", version: "git version 2.43.0" }, - gh: { ok: false, path: null, version: null }, - hub: { ok: false, path: null, version: null }, - jq: { ok: true, path: "/usr/bin/jq", version: "jq-1.7" }, - ssh: { ok: true, path: "/usr/bin/ssh", version: "OpenSSH_9.6" }, - curl: { ok: true, path: "/usr/bin/curl", version: "curl 8.5.0" }, - }, - unideskGhCli: { ok: true, path: "/workspace/unidesk/scripts/cli.ts", present: true }, - credentials: { - ghTokenPresent: false, - githubTokenPresent: false, - ghHostsConfigPresent: false, - gitCredentialsPresent: false, - }, - git: { - insideWorktree: true, - branch: "code-queue/issue-35-pr-dry-run-probe", - head: "abc1234", - originMaster: "def5678", - remoteOrigin: "git@github.com:pikasTech/unidesk.git", - home: "/root", - homeWritable: true, - knownHostsPresent: true, - privateKeyPresent: false, - }, - githubContext: { - host: "github.com", - apiBaseUrl: "https://api.github.com", - repo: "pikasTech/unidesk", - issueProbeNumber: 20, - }, - egress: { - proxy: { - selectedProxyHost: "d601-provider-egress-proxy.unidesk.svc.cluster.local", - selectedProxyPort: "18789", - selectedProxyHostResolvable: true, - }, - githubDefault: { command: "curl", args: ["-IsS", "https://github.com"], ok: true, exitCode: 0, signal: null, error: null, stdout: "", stderr: "" }, - apiDefault: { command: "curl", args: ["-IsS", "https://api.github.com"], ok: true, exitCode: 0, signal: null, error: null, stdout: "", stderr: "" }, - issueApi: { command: "sh", args: ["-lc", "curl issue"], ok: false, exitCode: 1, signal: null, error: null, stdout: "http_status=404", stderr: "" }, - }, - remote: { - gitLsRemote: { command: "git", args: ["ls-remote", "--heads", "origin", "master"], ok: true, exitCode: 0, signal: null, error: null, stdout: "abc1234\trefs/heads/master\n", stderr: "" }, - gitHttpsLsRemote: { command: "git", args: ["ls-remote", "--heads", "https://github.com/pikasTech/unidesk.git", "master"], ok: false, exitCode: 128, signal: null, error: null, stdout: "", stderr: "Authentication failed" }, - githubSshAuthenticated: true, - ghAuthStatus: null, - ghRepoView: null, - ghIssueView: null, - ghPrReadOnly: null, - }, - pushDryRun: { command: "git", args: ["push", "--dry-run", "origin", "HEAD:refs/heads/probe/code-queue-pr-capability"], ok: false, exitCode: 128, signal: null, error: null, stdout: "", stderr: "Permission denied" }, - prCreateDryRun: { command: "sh", args: ["-lc", "bun scripts/cli.ts gh pr create --dry-run"], ok: false, exitCode: 1, signal: null, error: null, stdout: "", stderr: "GH_TOKEN/GITHUB_TOKEN missing" }, - limitations: [ - "GH_TOKEN/GITHUB_TOKEN is not present; gh cannot create PRs unless another gh credential store is mounted", - "git push --dry-run failed for probe branch", - "PR create dry-run body/command guard failed", - ], - risks: [ - "system gh binary is missing; UniDesk REST gh CLI remains the supported PR create/comment path when scripts/cli.ts and GH_TOKEN/GITHUB_TOKEN are available", - ], - }, - ports: {}, - }, - }, - }; - }, - }); - assertCondition(observedDryRunPath === "/api/microservices/code-queue/proxy/api/runtime-preflight?remote=1&pushDryRun=1&pushDryRunRef=refs%2Fheads%2Fprobe%2Fcode-queue-pr-capability&prCreateDryRun=1&prCreateDryRunHead=code-queue%2Fissue-35-pr-dry-run-probe&issue=20", "combined dry-run query should pass all requested guards", { observedDryRunPath }); - const dryRunRecord = asRecord(dryRunContract); - assertCondition(dryRunRecord.failureKind === "auth-missing", "missing runner token should remain auth-missing even when system gh is absent", dryRunRecord); - assertCondition(dryRunRecord.preflight === undefined, "combined dry-run default output should omit detailed preflight", dryRunRecord); - const dryRunSchedulerPreflight = asRecord(dryRunRecord.schedulerPreflight); - const dryRunAuthBroker = asRecord(dryRunSchedulerPreflight.authBroker); - const dryRunScopeBoundary = asRecord(dryRunRecord.scopeBoundary); - const dryRunActiveRunner = asRecord(dryRunRecord.activeRunnerPrCapability); - assertCondition(dryRunAuthBroker.source === "broker/auth-broker-needed", "missing runner token should expose broker/auth-broker-needed", dryRunAuthBroker); - assertCondition(dryRunSchedulerPreflight.degradedReason === "auth-broker-needed", "auth broker degraded reason should be explicit", dryRunSchedulerPreflight); - assertCondition(dryRunScopeBoundary.scopesAreIndependent === true, "local compact preflight should expose independent auth scopes", dryRunScopeBoundary); - assertCondition(dryRunActiveRunner.scope === "current-cli-process", "compact default should expose current CLI process capability", dryRunActiveRunner); - const dryRunPrContract = asRecord(dryRunRecord.prCapability); - assertCondition(dryRunPrContract.systemGhBinaryRequiredForWrites === false, "system gh absence must not be required for UniDesk REST gh writes", dryRunPrContract); - assertCondition(dryRunPrContract.unideskGhCliOk === true, "UniDesk REST gh CLI should not be marked unavailable because system gh is missing", dryRunPrContract); - assertCondition(asRecord(dryRunPrContract.pushDryRun).requested === true, "push dry-run should be requested", dryRunPrContract); - assertCondition(asRecord(dryRunPrContract.pushDryRun).writesRemote === false, "push dry-run must be marked non-writing", dryRunPrContract); - assertCondition(asRecord(dryRunPrContract.prCreateDryRun).requested === true, "PR create dry-run should be requested", dryRunPrContract); - assertCondition(asRecord(dryRunPrContract.prCreateDryRun).writesRemote === false, "PR create dry-run must be marked non-writing", dryRunPrContract); - assertCondition(dryRunPrContract.sourceBranch === "code-queue/issue-35-pr-dry-run-probe", "PR dry-run head should come from the option", dryRunPrContract); - - const brokerIssuedContract = await codexPrPreflightQueryForTest(["--remote"], { - config: null, - coreFetch: () => ({ - ok: true, - status: 200, - body: { - runtimePreflight: rawRuntimePreflightFixture({ - pullRequestDelivery: { - ...asRecord(rawRuntimePreflightFixture().pullRequestDelivery), - ok: true, - authBroker: { - ok: true, - configured: true, - source: "auth-broker", - endpointEnvKey: "UNIDESK_AUTH_BROKER_URL", - runnerEnvTokenRequired: false, - credentialSource: "broker-held-github-credential", - failureKind: null, - degradedReason: null, - capability: { - source: "broker-issued-token", - githubRestAuth: true, - operations: ["github.auth.status", "github.issue.read", "github.pr.read", "github.pr.create"], - systemGhBinaryRequiredForWrites: false, - preflightWritesRemote: false, - preflightCreatesPr: false, - preflightMergesPr: false, - realPrCreateRequiresCommanderAuthorization: true, - valuesPrinted: false, - }, - nextAction: "use-auth-broker", - next: ["keep PR preflight read-only; create a real PR only after commander authorization"], - valuesRead: false, - valuesPrinted: false, - }, - }, - }), - }, - }), - }); - const brokerIssuedRecord = asRecord(brokerIssuedContract); - assertCondition(brokerIssuedRecord.ok === true, "broker-issued token branch should be ready", brokerIssuedRecord); - assertCondition(brokerIssuedRecord.preflight === undefined, "broker-issued default output should omit detailed preflight", brokerIssuedRecord); - const brokerIssuedScheduler = asRecord(brokerIssuedRecord.schedulerPreflight); - const brokerIssuedAuthBroker = asRecord(brokerIssuedScheduler.authBroker); - const brokerIssuedPrContract = asRecord(brokerIssuedRecord.prCapability); - assertCondition(brokerIssuedScheduler.authSource === "auth-broker", "broker-issued branch should use auth-broker token coverage", brokerIssuedScheduler); - assertCondition(brokerIssuedScheduler.credentialSource === "broker-issued-token", "broker-issued branch should expose broker-issued-token capability", brokerIssuedScheduler); - assertCondition(brokerIssuedAuthBroker.source === "auth-broker", "broker-issued branch should expose authBroker.source", brokerIssuedAuthBroker); - assertCondition(brokerIssuedAuthBroker.nextAction === "use-auth-broker", "broker-issued branch should expose nextAction", brokerIssuedAuthBroker); - assertCondition(brokerIssuedAuthBroker.capabilitySource === "broker-issued-token", "broker-issued branch should expose broker-issued capability", brokerIssuedAuthBroker); - assertCondition(brokerIssuedPrContract.systemGhBinaryRequiredForWrites === false, "broker-issued branch should not require system gh binary", brokerIssuedPrContract); - assertCondition(brokerIssuedPrContract.realPrCreateRequiresCommanderAuthorization === true, "real PR creation should still require commander authorization", brokerIssuedPrContract); - - const envTokenContract = await codexPrPreflightQueryForTest(["--remote"], { - config: null, - coreFetch: () => ({ - ok: true, - status: 200, - body: { - runtimePreflight: rawRuntimePreflightFixture({ - pullRequestDelivery: { - ...asRecord(rawRuntimePreflightFixture().pullRequestDelivery), - ok: true, - credentials: { - ghTokenPresent: true, - githubTokenPresent: false, - ghHostsConfigPresent: false, - gitCredentialsPresent: false, - }, - authBroker: { - ok: false, - configured: false, - source: "broker/auth-broker-needed", - endpointEnvKey: null, - runnerEnvTokenRequired: false, - credentialSource: null, - failureKind: "auth-missing", - degradedReason: "auth-broker-needed", - capability: { - source: "missing-token", - githubRestAuth: false, - operations: ["github.auth.status", "github.issue.read", "github.pr.read", "github.pr.create"], - systemGhBinaryRequiredForWrites: false, - preflightWritesRemote: false, - preflightCreatesPr: false, - preflightMergesPr: false, - realPrCreateRequiresCommanderAuthorization: true, - valuesPrinted: false, - }, - nextAction: "configure-auth-broker-or-env-token", - next: ["configure UNIDESK_AUTH_BROKER_URL or AUTH_BROKER_URL for broker-backed runner auth"], - valuesRead: false, - valuesPrinted: false, - }, - }, - }), - }, - }), - }); - const envTokenRecord = asRecord(envTokenContract); - assertCondition(envTokenRecord.ok === true, "env token branch should be ready", envTokenRecord); - assertCondition(envTokenRecord.preflight === undefined, "env-token default output should omit detailed preflight", envTokenRecord); - const envTokenScheduler = asRecord(envTokenRecord.schedulerPreflight); - const envTokenAuthBroker = asRecord(envTokenScheduler.authBroker); - assertCondition(envTokenScheduler.authSource === "GH_TOKEN", "env token branch should expose GH_TOKEN source", envTokenScheduler); - assertCondition(envTokenScheduler.credentialSource === "env-token", "env token branch should expose env-token capability", envTokenScheduler); - assertCondition(envTokenAuthBroker.source === "GH_TOKEN", "env token branch should not pretend broker is configured", envTokenAuthBroker); - assertCondition(envTokenAuthBroker.nextAction === "use-env-token-until-auth-broker-live", "env token branch should still point at broker migration", envTokenAuthBroker); - - const missingTokenContract = await codexPrPreflightQueryForTest(["--remote"], { - config: null, - coreFetch: () => ({ - ok: true, - status: 200, - body: { runtimePreflight: rawRuntimePreflightFixture() }, - }), - }); - const missingTokenRecord = asRecord(missingTokenContract); - assertCondition(missingTokenRecord.ok === false, "missing-token branch should fail", missingTokenRecord); - assertCondition(missingTokenRecord.failureKind === "auth-missing", "missing-token branch should classify auth-missing", missingTokenRecord); - assertCondition(missingTokenRecord.degradedReason === "auth-broker-needed", "missing-token branch should expose broker-needed degraded reason", missingTokenRecord); - const missingTokenTopSummary = asRecord(missingTokenRecord.authScopeSummary); - const missingTokenTopScopeBoundary = asRecord(missingTokenRecord.scopeBoundary); - const missingTokenTopActions = Array.isArray(missingTokenRecord.recommendedActions) ? missingTokenRecord.recommendedActions : []; - assertCondition(missingTokenRecord.preflight === undefined, "missing-token default output should omit detailed preflight", missingTokenRecord); - const missingTokenScheduler = asRecord(missingTokenRecord.schedulerPreflight); - const missingTokenAuthBroker = asRecord(missingTokenScheduler.authBroker); - const missingTokenScopeBoundary = asRecord(missingTokenRecord.scopeBoundary); - const missingTokenActiveRunner = asRecord(missingTokenRecord.activeRunnerPrCapability); - const missingTokenActions = Array.isArray(missingTokenRecord.recommendedActions) ? missingTokenRecord.recommendedActions : []; - const missingTokenPrCapability = asRecord(missingTokenRecord.prCapability); - assertCondition(missingTokenTopSummary.schedulerAuthMissingIsScoped === true, "missing-token top-level summary should expose scoped scheduler auth missing", missingTokenTopSummary); - assertCondition(missingTokenTopScopeBoundary.schedulerAuthMissingDoesNotMeanActiveRunnerCannotCreatePr === true, "missing-token top-level boundary should be prominent", missingTokenTopScopeBoundary); - assertCondition(Array.isArray(missingTokenRecord.recommendedActions) && missingTokenTopActions.length === 3, "missing-token top-level recommended actions should stay bounded", missingTokenRecord.recommendedActions); - assertCondition(missingTokenAuthBroker.source === "broker/auth-broker-needed", "missing-token branch should expose broker/auth-broker-needed", missingTokenAuthBroker); - assertCondition(missingTokenAuthBroker.nextAction === "configure-auth-broker-or-env-token", "missing-token branch should expose nextAction", missingTokenAuthBroker); - assertCondition(missingTokenAuthBroker.capabilitySource === "missing-token", "missing-token branch should expose missing-token capability", missingTokenAuthBroker); - assertCondition(missingTokenPrCapability.systemGhBinaryRequiredForWrites === false, "missing-token branch should still not require system gh binary for UniDesk gh CLI", missingTokenPrCapability); - assertCondition(missingTokenTopSummary.schedulerAuthMissingIsScoped === true, "missing-token compact summary should expose scoped scheduler auth missing", missingTokenTopSummary); - assertCondition(String(missingTokenScopeBoundary.currentRunnerCheck ?? "").includes("gh auth status"), "missing-token branch should point to active runner auth check", missingTokenScopeBoundary); - assertCondition(String(missingTokenScopeBoundary.currentRunnerCheck ?? "").includes("gh pr create --dry-run"), "missing-token branch should point to active runner PR create dry-run", missingTokenScopeBoundary); - assertCondition(missingTokenActiveRunner.independentOfSchedulerPreflight === true, "missing-token branch should not overstate active runner PR capability", missingTokenActiveRunner); - assertCondition(Array.isArray(missingTokenRecord.recommendedActions) && missingTokenActions.length === 3, "missing-token compact recommended actions should stay bounded", missingTokenRecord.recommendedActions); - assertCondition(missingTokenActions.some((action) => String(asRecord(action).command ?? "").includes("gh auth status")), "missing-token branch should recommend gh auth status", missingTokenActions); - assertCondition(missingTokenActions.some((action) => String(asRecord(action).command ?? "").includes("gh pr create") && asRecord(action).writesRemote === false), "missing-token branch should recommend PR create dry-run without writes", missingTokenActions); - - process.stdout.write(`${JSON.stringify({ - ok: true, - checks: [ - "runner-like local target-stack absence does not block remote fallback", - "remote control plane fallback preserves ready preflight", - "missing remote control plane returns control-plane-observation-gap", - "local backend-core absence returns runner-local-observation-gap", - "auth missing returns auth-missing with broker/auth-broker-needed", - "proxy failures return proxy-gap", - "GitHub DNS/API failures return github-transient with retry/backoff guidance", - "k3sctl HTTP tunnel 504 falls back to lightweight runtime-preflight observation", - "double runtime-preflight transport failure remains infra-blocked without inferring auth gaps", - "skills-target-missing fallback is runner-capability-degraded, not proxy unavailable", - "git remote failures return git-remote-gap", - "combined push/PR create dry-run contract stays read-only and separates system gh from UniDesk gh CLI", - "broker-issued token, env-token, and missing-token branches expose authBroker source/capability/nextAction", - ], - observedLocalPath, - observedDryRunPath, - }, null, 2)}\n`); -} - -if (import.meta.main) { - await main(); -} diff --git a/scripts/code-queue-prompt-lint-contract-test.ts b/scripts/code-queue-prompt-lint-contract-test.ts deleted file mode 100644 index 07161627..00000000 --- a/scripts/code-queue-prompt-lint-contract-test.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { codexPromptLiveAuthorizationLintForTest } from "./src/code-queue"; - -type JsonRecord = Record; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function asRecord(value: unknown): JsonRecord { - assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), "expected JSON object", { value }); - return value as JsonRecord; -} - -function nestedRecord(value: unknown, path: string[]): JsonRecord { - let current: unknown = value; - for (const key of path) { - current = asRecord(current)[key]; - } - return asRecord(current); -} - -function stringArray(value: unknown): string[] { - return Array.isArray(value) ? value.map((item) => String(item)) : []; -} - -function assertLegacyFrozenWrite(result: { status: number | null; stdout: string; stderr: string; json: JsonRecord | null }, command: string): void { - assertCondition(result.status !== 0 && result.json?.ok === false, `${command} should be frozen`, result.json ?? { stdout: result.stdout, stderr: result.stderr }); - const data = nestedRecord(result.json?.data, []); - assertCondition(data.frozen === true, `${command} frozen payload should expose frozen=true`, data); - assertCondition(data.mutation === false, `${command} frozen payload should be non-mutating`, data); - assertCondition(data.degradedReason === "legacy-code-queue-frozen", `${command} should use the legacy frozen reason`, data); - const replacement = nestedRecord(data, ["replacement"]); - assertCondition(String(replacement.queueSubmit || "").includes("agentrun queue submit"), `${command} should point to AgentRun queue submit`, replacement); - assertCondition(String(replacement.sessionsSteer || "").includes("agentrun sessions steer"), `${command} should point to AgentRun sessions steer`, replacement); -} - -function runCli(args: string[], stdin?: string): { status: number | null; stdout: string; stderr: string; json: JsonRecord | null } { - const result = spawnSync("bun", ["scripts/cli.ts", ...args], { - cwd: process.cwd(), - input: stdin, - encoding: "utf8", - }); - const stdout = String(result.stdout || ""); - let json: JsonRecord | null = null; - try { - json = JSON.parse(stdout) as JsonRecord; - } catch { - json = null; - } - return { - status: result.status, - stdout, - stderr: String(result.stderr || ""), - json, - }; -} - -const readOnlyPrompt = ` -DEV test class: read-only -允许的 live mutation: none -禁止动作: prod mutation, secret values, database patch, Code Queue backend restart, interrupt/cancel. -closeout fields: report class, live mutation false, commands, target environment, evidence, residual risk. - -Task: update docs and run contract tests only. -Validation: git diff --check and bun scripts/code-queue-prompt-lint-contract-test.ts. -`; - -const unclassifiedM3SmokePrompt = ` -Run the HWLAB M3 smoke on DEV and verify res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1. -Do not touch production. -`; - -const liveReadPrompt = ` -DEV test class: live-read -允许的 live mutation: none -禁止动作: no POST/PUT/PATCH/DELETE, no deploy, no task trigger, no prod mutation. -closeout fields: report class, live mutation false, endpoints read, namespace/service, evidence, residual risk. - -Read live DEV health and status only: GET /health and kubectl get pods. -`; - -const liveMutatingPrompt = ` -DEV test class: live-mutating -允许的 live mutation: run exactly one DEV HWLAB M3 operation for res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1, record operation/audit/evidence ids, then observe recovery. -禁止动作: no prod mutation, no secret values, no database manual patch, no Code Queue backend restart, no unrelated interrupt/cancel. -closeout fields: report class, live mutation true, command summary, DEV target, operation/audit/evidence ids, rollback observation, residual risk. -`; - -const secretBearingPrompt = ` -DEV test class: live-mutating -允许的 live mutation: POST /api/run?token=ds-secret-must-not-print once in DEV only. -禁止动作: no prod mutation, no database patch. -closeout fields: report class, live mutation true, command summary and evidence id. -`; - -function assertLintShape(lint: JsonRecord): void { - assertCondition(lint.dryRun === true, "lint must be dry-run", lint); - assertCondition(lint.mutation === false, "lint must be non-mutating", lint); - assertCondition(asRecord(lint.policy).printsPromptText === false, "lint policy must not print full prompt", lint); - assertCondition(asRecord(lint.promptShape).textEchoed === false, "lint shape must not echo prompt text", lint); - assertCondition(Array.isArray(lint.signals), "lint must expose signals", lint); - const json = JSON.stringify(lint); - assertCondition(!json.includes("ds-secret-must-not-print"), "lint must not print secret marker", lint); -} - -export function runCodeQueuePromptLintContract(): JsonRecord { - const readOnly = asRecord(codexPromptLiveAuthorizationLintForTest(readOnlyPrompt)); - assertLintShape(readOnly); - assertCondition(readOnly.ok === true, "well-formed read-only prompt should pass", readOnly); - assertCondition(readOnly.declaredClass === "read-only", "read-only prompt should declare read-only", readOnly); - assertCondition(readOnly.effectiveClass === "read-only", "read-only effective class mismatch", readOnly); - assertCondition(readOnly.requiredClass === "read-only", "read-only required class mismatch", readOnly); - assertCondition(readOnly.dispatchDisposition === "ready", "read-only prompt should be dispatch-ready", readOnly); - - const liveRead = asRecord(codexPromptLiveAuthorizationLintForTest(liveReadPrompt)); - assertLintShape(liveRead); - assertCondition(liveRead.ok === true, "well-formed live-read prompt should pass", liveRead); - assertCondition(liveRead.declaredClass === "live-read", "live-read prompt should declare live-read", liveRead); - assertCondition(liveRead.requiredClass === "live-read", "live-read required class mismatch", liveRead); - assertCondition(liveRead.dispatchDisposition === "ready", "live-read prompt should be dispatch-ready", liveRead); - - const unclassifiedM3 = asRecord(codexPromptLiveAuthorizationLintForTest(unclassifiedM3SmokePrompt)); - assertLintShape(unclassifiedM3); - assertCondition(unclassifiedM3.ok === false, "unclassified M3 smoke should fail lint", unclassifiedM3); - assertCondition(unclassifiedM3.declaredClass === null, "unclassified prompt should have no declared class", unclassifiedM3); - assertCondition(unclassifiedM3.effectiveClass === "read-only", "unclassified prompt should default to read-only", unclassifiedM3); - assertCondition(unclassifiedM3.requiredClass === "live-mutating", "M3 smoke should require live-mutating", unclassifiedM3); - assertCondition(unclassifiedM3.dispatchDisposition === "needs-authorization", "unclassified live mutation should need authorization", unclassifiedM3); - assertCondition(stringArray(unclassifiedM3.missingOrContradictory).some((item) => item.includes("missing DEV test class")), "unclassified prompt should report missing class", unclassifiedM3); - - const liveMutating = asRecord(codexPromptLiveAuthorizationLintForTest(liveMutatingPrompt)); - assertLintShape(liveMutating); - assertCondition(liveMutating.ok === true, "well-formed live-mutating prompt should pass", liveMutating); - assertCondition(liveMutating.declaredClass === "live-mutating", "live-mutating prompt should declare live-mutating", liveMutating); - assertCondition(liveMutating.requiredClass === "live-mutating", "live-mutating required class mismatch", liveMutating); - assertCondition(liveMutating.liveMutationAuthorized === true, "live-mutating prompt should be authorized when allowed mutation is enumerated", liveMutating); - - const secretBearing = asRecord(codexPromptLiveAuthorizationLintForTest(secretBearingPrompt)); - assertLintShape(secretBearing); - assertCondition(secretBearing.requiredClass === "live-mutating", "secret-bearing live mutation should still classify", secretBearing); - assertCondition(!JSON.stringify(secretBearing).includes("ds-secret-must-not-print"), "prompt lint evidence must redact secret-looking values", secretBearing); - - const tmp = mkdtempSync(join(tmpdir(), "unidesk-code-queue-prompt-lint-")); - const promptFile = join(tmp, "prompt.md"); - writeFileSync(promptFile, liveMutatingPrompt, "utf8"); - try { - const cliLint = runCli(["codex", "prompt-lint", "--prompt-file", promptFile]); - assertCondition(cliLint.status === 0 && cliLint.json?.ok === true, "prompt-lint CLI should succeed for authorized live-mutating prompt", cliLint.json ?? { stdout: cliLint.stdout }); - const lintData = nestedRecord(cliLint.json?.data, []); - assertCondition(lintData.dryRun === true && lintData.mutation === false, "prompt-lint CLI should be dry-run and non-mutating", lintData); - assertCondition(lintData.declaredClass === "live-mutating", "prompt-lint CLI should classify live-mutating", lintData); - assertCondition(!JSON.stringify(lintData).includes("run exactly one DEV HWLAB M3 operation"), "prompt-lint CLI should not echo full prompt text", lintData); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - - const submitDryRun = runCli(["codex", "submit", "--prompt-stdin", "--dry-run"], unclassifiedM3SmokePrompt); - assertLegacyFrozenWrite(submitDryRun, "codex submit"); - assertCondition(!submitDryRun.stdout.includes("res_boxsimu_1"), "frozen submit must not echo prompt text", { stdout: submitDryRun.stdout }); - - const steerDryRun = runCli(["codex", "steer", "codex_test_task", "--prompt-stdin", "--dry-run"], unclassifiedM3SmokePrompt); - assertLegacyFrozenWrite(steerDryRun, "codex steer"); - assertCondition(!steerDryRun.stdout.includes("res_boxsimu_1"), "frozen steer must not echo prompt text", { stdout: steerDryRun.stdout }); - - const help = runCli(["codex", "help"]); - assertCondition(help.status === 0 && help.json?.ok === true, "codex help should succeed", help.json ?? { stdout: help.stdout }); - const helpData = nestedRecord(help.json?.data, []); - const usage = stringArray(helpData.usage); - assertCondition(usage.some((line) => line.includes("codex prompt-lint")), "codex help should list prompt-lint", helpData); - const authorizationHelp = nestedRecord(helpData, ["promptLiveAuthorization"]); - assertCondition(stringArray(authorizationHelp.classes).includes("live-mutating"), "help should document live-mutating class", authorizationHelp); - assertCondition(authorizationHelp.defaultWhenMissing === "read-only", "help should document read-only default", authorizationHelp); - - return { - ok: true, - checks: [ - "prompt-lint classifies read-only/live-read/live-mutating prompts", - "unclassified HWLAB M3 smoke defaults read-only but requires live-mutating authorization", - "prompt-lint evidence redacts secret-looking values", - "prompt-lint CLI is dry-run, non-mutating, and does not echo full prompt text", - "legacy submit --dry-run is frozen and points to AgentRun", - "legacy steer --dry-run is frozen and points to AgentRun", - "codex help documents prompt-lint and authorization classes", - ], - }; -} - -if (import.meta.main) { - process.stdout.write(`${JSON.stringify(runCodeQueuePromptLintContract(), null, 2)}\n`); -} diff --git a/scripts/code-queue-queues-shape-contract-test.ts b/scripts/code-queue-queues-shape-contract-test.ts deleted file mode 100644 index cf2ee188..00000000 --- a/scripts/code-queue-queues-shape-contract-test.ts +++ /dev/null @@ -1,570 +0,0 @@ -import { codexQueuesQueryForTest } from "./src/code-queue"; - -type JsonRecord = Record; - -function assertCondition(condition: unknown, message: string, detail: JsonRecord = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function asRecord(value: unknown): JsonRecord { - assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), "expected JSON object", { value }); - return value as JsonRecord; -} - -function asArray(value: unknown): unknown[] { - assertCondition(Array.isArray(value), "expected JSON array", { value }); - return value as unknown[]; -} - -function manyIds(prefix: string, count: number): string[] { - return Array.from({ length: count }, (_, index) => `${prefix}-${String(index + 1).padStart(2, "0")}`); -} - -function fixtureResponse(): JsonRecord { - return { - ok: true, - status: 200, - body: { - ok: true, - queue: { - total: 4, - queueCount: 3, - activeQueueIds: ["alpha"], - activeTaskIds: ["task-running"], - queuedTaskIds: ["task-queued"], - counts: { running: 1, queued: 2, succeeded: 1 }, - unreadTerminal: 1, - executionDiagnostics: { - state: "split-brain", - splitBrain: true, - heartbeatFreshTaskIds: ["task-running"], - databaseActiveTaskCount: 1, - databaseActiveTaskIds: ["task-running"], - schedulerActiveRunSlotCount: 0, - schedulerActiveTaskIds: [], - }, - }, - queues: [ - { - id: "alpha", - name: "Alpha", - total: 1, - counts: { running: 1, queued: 0 }, - unreadTerminal: 0, - activeTaskId: "task-running", - runnableTaskId: null, - updatedAt: "2026-05-20T00:00:00.000Z", - }, - { - id: "beta", - name: "Beta", - total: 2, - counts: { running: 0, queued: 2 }, - unreadTerminal: 0, - activeTaskId: null, - runnableTaskId: "task-queued", - updatedAt: "2026-05-20T00:01:00.000Z", - }, - { - id: "gamma", - name: "Gamma", - total: 1, - counts: { succeeded: 1 }, - unreadTerminal: 1, - activeTaskId: null, - runnableTaskId: null, - updatedAt: "2026-05-20T00:02:00.000Z", - }, - ], - }, - }; -} - -function splitBrainLiveResponse(): JsonRecord { - const liveTaskIds = manyIds("task-running", 8); - return { - ok: true, - status: 200, - body: { - ok: true, - queue: { - total: 8, - queueCount: 2, - activeQueueIds: [], - activeTaskIds: [], - queuedTaskIds: [], - counts: { running: 8 }, - unreadTerminal: 0, - executionDiagnostics: { - state: "split-brain", - splitBrain: true, - splitBrainLive: true, - effectiveLiveness: "live", - recommendedAction: "continue-supervision", - databaseActiveTaskCount: 8, - databaseActiveTaskIds: liveTaskIds, - schedulerActiveRunSlotCount: 0, - schedulerActiveTaskIds: [], - activeHeartbeatCount: 8, - activeHeartbeatTaskIds: liveTaskIds, - heartbeatFreshTaskIds: liveTaskIds, - heartbeatExpiredTaskIds: [], - heartbeatMissingTaskIds: [], - staleRecoveryCandidateTaskIds: [], - heartbeatRiskTaskIds: [], - }, - }, - queues: [ - { - id: "alpha", - name: "Alpha", - total: 4, - counts: { running: 4 }, - unreadTerminal: 0, - activeTaskId: null, - runnableTaskId: null, - updatedAt: "2026-05-20T00:00:00.000Z", - }, - { - id: "beta", - name: "Beta", - total: 4, - counts: { running: 4 }, - unreadTerminal: 0, - activeTaskId: null, - runnableTaskId: null, - updatedAt: "2026-05-20T00:01:00.000Z", - }, - ], - }, - }; -} - -function heartbeatRiskResponse(): JsonRecord { - const staleTaskIds = manyIds("task-stale", 4); - return { - ok: true, - status: 200, - body: { - ok: true, - queue: { - total: 6, - queueCount: 3, - activeQueueIds: [], - activeTaskIds: [], - queuedTaskIds: ["task-queued-risk"], - counts: { running: 4, queued: 1, retry_wait: 1 }, - unreadTerminal: 0, - executionDiagnostics: { - state: "stale-active", - effectiveLiveness: "at-risk", - recommendedAction: "investigate-heartbeat-risk", - now: "2026-05-20T00:30:00.000Z", - databaseActiveTaskCount: 4, - databaseActiveTaskIds: staleTaskIds, - schedulerActiveRunSlotCount: 0, - schedulerActiveTaskIds: [], - activeHeartbeatCount: 4, - activeHeartbeatTaskIds: staleTaskIds, - heartbeatFreshTaskIds: [], - heartbeatExpiredTaskIds: staleTaskIds, - heartbeatMissingTaskIds: [], - staleRecoveryCandidateTaskIds: staleTaskIds, - heartbeatRiskTaskIds: staleTaskIds, - lastSchedulerHeartbeatAt: "2026-05-20T00:15:00.000Z", - lastObservedAgentEventAt: "2026-05-20T00:14:30.000Z", - reasons: ["heartbeat expired for database-active tasks"], - }, - }, - queues: [ - { - id: "risk-a", - name: "Risk A", - total: 3, - counts: { running: 3 }, - unreadTerminal: 0, - activeTaskId: null, - runnableTaskId: null, - updatedAt: "2026-05-20T00:20:00.000Z", - }, - { - id: "risk-b", - name: "Risk B", - total: 1, - counts: { running: 1 }, - unreadTerminal: 0, - activeTaskId: null, - runnableTaskId: null, - updatedAt: "2026-05-20T00:19:00.000Z", - }, - { - id: "risk-queued", - name: "Risk Queued", - total: 2, - counts: { queued: 1, retry_wait: 1 }, - unreadTerminal: 0, - activeTaskId: null, - runnableTaskId: "task-queued-risk", - updatedAt: "2026-05-20T00:18:00.000Z", - }, - ], - }, - }; -} - -function noActiveResponse(): JsonRecord { - return { - ok: true, - status: 200, - body: { - ok: true, - queue: { - total: 2, - queueCount: 2, - activeQueueIds: [], - activeTaskIds: [], - queuedTaskIds: [], - counts: { succeeded: 2 }, - unreadTerminal: 1, - executionDiagnostics: { - state: "healthy", - effectiveLiveness: "healthy", - recommendedAction: "none", - databaseActiveTaskCount: 0, - databaseActiveTaskIds: [], - schedulerActiveRunSlotCount: 0, - schedulerActiveTaskIds: [], - activeHeartbeatCount: 0, - activeHeartbeatTaskIds: [], - heartbeatFreshTaskIds: [], - heartbeatExpiredTaskIds: [], - heartbeatMissingTaskIds: [], - staleRecoveryCandidateTaskIds: [], - heartbeatRiskTaskIds: [], - }, - }, - queues: [ - { - id: "done-a", - name: "Done A", - total: 1, - counts: { succeeded: 1 }, - unreadTerminal: 1, - activeTaskId: null, - runnableTaskId: null, - updatedAt: "2026-05-20T00:05:00.000Z", - }, - { - id: "done-b", - name: "Done B", - total: 1, - counts: { succeeded: 1 }, - unreadTerminal: 0, - activeTaskId: null, - runnableTaskId: null, - updatedAt: "2026-05-20T00:04:00.000Z", - }, - ], - }, - }; -} - -function activeBelowTargetResponse(): JsonRecord { - return { - ok: true, - status: 200, - body: { - ok: true, - queue: { - total: 8, - queueCount: 3, - activeQueueIds: ["feature-a", "feature-b"], - activeTaskIds: ["task-active-a", "task-active-b"], - queuedTaskIds: ["task-queued-a", "task-queued-b", "task-queued-c"], - counts: { running: 2, queued: 3, retry_wait: 1, succeeded: 2 }, - unreadTerminal: 0, - executionDiagnostics: { - state: "healthy", - effectiveLiveness: "healthy", - recommendedAction: "none", - databaseActiveTaskCount: 2, - databaseActiveTaskIds: ["task-active-a", "task-active-b"], - schedulerActiveRunSlotCount: 2, - schedulerActiveTaskIds: ["task-active-a", "task-active-b"], - activeHeartbeatCount: 2, - activeHeartbeatTaskIds: ["task-active-a", "task-active-b"], - heartbeatFreshTaskIds: ["task-active-a", "task-active-b"], - heartbeatExpiredTaskIds: [], - heartbeatMissingTaskIds: [], - staleRecoveryCandidateTaskIds: [], - heartbeatRiskTaskIds: [], - lastSchedulerHeartbeatAt: "2026-05-20T01:00:00.000Z", - lastObservedAgentEventAt: "2026-05-20T01:00:05.000Z", - }, - }, - queues: [ - { - id: "feature-a", - name: "Feature A", - total: 3, - counts: { running: 1, queued: 1, succeeded: 1 }, - unreadTerminal: 0, - activeTaskId: "task-active-a", - runnableTaskId: null, - updatedAt: "2026-05-20T01:01:00.000Z", - }, - { - id: "feature-b", - name: "Feature B", - total: 3, - counts: { running: 1, retry_wait: 1, succeeded: 1 }, - unreadTerminal: 0, - activeTaskId: "task-active-b", - runnableTaskId: "task-queued-b", - updatedAt: "2026-05-20T01:00:30.000Z", - }, - { - id: "feature-c", - name: "Feature C", - total: 2, - counts: { queued: 2 }, - unreadTerminal: 0, - activeTaskId: null, - runnableTaskId: "task-queued-c", - updatedAt: "2026-05-20T01:00:10.000Z", - }, - ], - tasks: [ - { - id: "task-active-a", - queueId: "feature-a", - status: "running", - displayPrompt: "Implement feature A polish and contract tests", - }, - { - id: "task-active-b", - queueId: "feature-b", - status: "running", - displayPrompt: "Fix feature B queue handling", - }, - { - id: "task-queued-a", - queueId: "feature-a", - status: "queued", - displayPrompt: "Queued feature A follow-up", - }, - ], - }, - }; -} - -function assertQueuesShape(label: string, result: unknown, expectedView: string): void { - const data = asRecord(result); - const queues = asRecord(data.queues); - assertCondition(queues.view === expectedView, `${label} view mismatch`, queues); - const items = asArray(queues.items); - assertCondition(items.length === 3, `${label} must expose queue rows at data.queues.items[]`, queues); - const first = asRecord(items[0]); - assertCondition(first.id === "alpha", `${label} first item id mismatch`, first); - const firstCounts = asRecord(first.counts); - assertCondition(firstCounts.running === 1, `${label} item counts should be preserved`, first); - const counts = asRecord(queues.counts); - assertCondition(counts.running === 1 && counts.queued === 2, `${label} global counts should be preserved`, counts); - const diagnostics = asRecord(queues.executionDiagnostics); - assertCondition(diagnostics.state === "split-brain", `${label} executionDiagnostics should be preserved`, diagnostics); - assertCondition(diagnostics.splitBrainLive === true, `${label} split-brain live should remain explicitly true`, diagnostics); - assertCondition(diagnostics.effectiveLiveness === "live", `${label} diagnostics should retain derived liveness`, diagnostics); - assertCondition(diagnostics.recommendedAction === "continue-supervision", `${label} split-brain live should continue supervision`, diagnostics); - const liveness = asRecord(diagnostics.liveness); - assertCondition(liveness.effectiveLiveness === "live", `${label} liveness summary should foreground effective live state`, liveness); - assertCondition(liveness.recommendedAction === "continue-supervision", `${label} liveness summary should foreground recommended action`, liveness); - assertCondition(liveness.activeHeartbeatCount === 1, `${label} liveness summary should derive active heartbeat count from fresh heartbeat ids`, liveness); - assertCondition(liveness.schedulerActiveRunSlotCount === 0, `${label} liveness summary should keep master active slot zero visible`, liveness); - assertCondition(asArray(liveness.heartbeatFreshTaskIds).length === 1, `${label} liveness summary should include bounded fresh heartbeat task ids`, liveness); - assertCondition(String(liveness.interpretation ?? "").includes("heartbeat is fresh"), `${label} liveness interpretation should explain slot-zero split-brain`, liveness); - const activity = asRecord(queues.activity); - assertCondition(activity.effectiveActiveTaskCount === 1, `${label} activity should foreground effective active task count`, activity); - assertCondition(activity.databaseRunningTaskCount === 1, `${label} activity should distinguish database running tasks`, activity); - assertCondition(activity.heartbeatFreshActiveTaskCount === 1, `${label} activity should distinguish heartbeat-fresh active runners`, activity); - assertCondition(activity.schedulerLocalActiveQueueCount === 1, `${label} activity should distinguish scheduler-local active queues`, activity); - const commanderConcurrency = asRecord(activity.commanderConcurrency); - assertCondition(commanderConcurrency.activeRunnerCount === 1, `${label} activity should expose commander-facing active runner count`, commanderConcurrency); - assertCondition(commanderConcurrency.activeRunnerCountField === "activity.effectiveActiveTaskCount", `${label} activity should name the active runner count field`, commanderConcurrency); - assertCondition(activity.activeQueueIdsScope === "scheduler-local-active-run-slots", `${label} activity should label activeQueueIds scope`, activity); - assertCondition(Array.isArray(queues.activeTaskIds), `${label} activeTaskIds should be present`, queues); - assertCondition(Array.isArray(queues.queuedTaskIds), `${label} queuedTaskIds should be present`, queues); - const commander = asRecord(queues.commander); - assertCondition(commander.activeRunnerCount === 1, `${label} commander block should be near the front and expose active runner count`, commander); - assertCondition(commander.target === 15 && commander.slotDeficit === 14, `${label} commander block should expose 15 target slot deficit`, commander); - assertCondition(commander.queuedCount === 2, `${label} commander block should expose queued count`, commander); - assertCondition(asArray(asRecord(commander.runningTasks).items).length >= 1, `${label} commander runningTasks should expose active task ids`, commander); - assertCondition(asArray(asRecord(asRecord(commander.heartbeat).fresh).items).includes("task-running"), `${label} commander heartbeat should foreground fresh ids`, commander); -} - -function assertSplitBrainLiveActivity(label: string, result: unknown): void { - const queues = asRecord(asRecord(result).queues); - const totals = asRecord(queues.totals); - assertCondition(totals.activeQueueCount === 0, `${label} scheduler-local active queue count should be zero`, totals); - assertCondition(totals.schedulerLocalActiveQueueCount === 0, `${label} should preserve zero scheduler-local active queues`, totals); - assertCondition(totals.runnableQueueCount === 0, `${label} runnable queue count should be zero`, totals); - assertCondition(totals.databaseRunningTaskCount === 8, `${label} should foreground DB running task count`, totals); - assertCondition(totals.databaseActiveTaskCount === 8, `${label} should foreground DB active task count`, totals); - assertCondition(totals.heartbeatFreshActiveTaskCount === 8, `${label} should foreground heartbeat-effective active runners`, totals); - assertCondition(totals.commanderActiveRunnerCount === 8, `${label} should mirror commander active count in totals`, totals); - assertCondition(totals.effectiveActiveTaskCount === 8, `${label} should foreground effective active task count`, totals); - assertCondition(asArray(queues.activeQueueIds).length === 0, `${label} activeQueueIds should remain the scheduler-local list`, queues); - assertCondition(queues.activeQueueIdsScope === "scheduler-local-active-run-slots", `${label} activeQueueIds should be scoped`, queues); - assertCondition(String(queues.activeQueueIdsNote ?? "").includes("scheduler-local only"), `${label} activeQueueIds note should explain local-only semantics`, queues); - const activity = asRecord(queues.activity); - assertCondition(activity.effectiveActiveTaskCount === 8, `${label} activity should expose effective active count`, activity); - assertCondition(activity.effectiveActiveSource === "heartbeat-fresh", `${label} activity should choose heartbeat-fresh source`, activity); - assertCondition(activity.databaseRunningTaskCount === 8, `${label} activity should expose DB running count`, activity); - assertCondition(activity.heartbeatFreshActiveTaskCount === 8, `${label} activity should expose heartbeat-effective active count`, activity); - assertCondition(activity.schedulerLocalActiveQueueCount === 0, `${label} activity should expose scheduler-local queue count`, activity); - assertCondition(activity.schedulerLocalActiveRunSlotCount === 0, `${label} activity should expose scheduler-local slot count`, activity); - assertCondition(activity.runnableQueueCount === 0, `${label} activity should expose runnable queue count`, activity); - assertCondition(activity.splitBrainLive === true, `${label} activity should preserve split-brain live`, activity); - assertCondition(activity.splitBrainDisposition === "live-count-as-active", `${label} activity should count live split-brain as active`, activity); - const commanderConcurrency = asRecord(queues.commanderConcurrency); - assertCondition(commanderConcurrency.activeRunnerCount === 8, `${label} should expose commander-facing active runner count`, commanderConcurrency); - assertCondition(commanderConcurrency.activeRunnerCountField === "activity.effectiveActiveTaskCount", `${label} should name the active runner count field`, commanderConcurrency); - assertCondition(commanderConcurrency.splitBrainDisposition === "live-count-as-active", `${label} should classify live split-brain capacity`, commanderConcurrency); - assertCondition(commanderConcurrency.interventionRequired === false, `${label} should not require intervention for fresh split-brain`, commanderConcurrency); - assertCondition(String(commanderConcurrency.decisionRule ?? "").includes("15 - activeRunnerCount"), `${label} should give 15-concurrency arithmetic`, commanderConcurrency); - assertCondition(String(activity.activeQueueIdsNote ?? "").includes("zero local queue ids does not mean zero active runners"), `${label} activity note should prevent zero-active misread`, activity); - assertCondition(String(activity.interpretation ?? "").includes("continue supervision"), `${label} activity interpretation should keep supervision action`, activity); - const commander = asRecord(queues.commander); - assertCondition(commander.activeRunnerCount === 8, `${label} commander should foreground heartbeat-effective active count`, commander); - assertCondition(commander.slotDeficit === 7 && commander.target === 15, `${label} commander should compute below-target slot deficit`, commander); - assertCondition(asRecord(commander.runningTasks).count === 8, `${label} commander runningTasks should preserve live active count`, commander); - assertCondition(asRecord(asRecord(commander.heartbeat).fresh).count === 8, `${label} commander heartbeat should preserve fresh heartbeat count`, commander); - assertCondition(asRecord(asRecord(commander.heartbeat).risk).count === 0, `${label} commander heartbeat risk should be zero for live split-brain`, commander); -} - -function assertHeartbeatRiskCommander(label: string, result: unknown): void { - const queues = asRecord(asRecord(result).queues); - const commander = asRecord(queues.commander); - const heartbeat = asRecord(commander.heartbeat); - const risk = asRecord(heartbeat.risk); - const stale = asRecord(heartbeat.staleRecoveryCandidates); - assertCondition(commander.activeRunnerCount === 4, `${label} should count database-active stale tasks as active before recovery`, commander); - assertCondition(commander.slotDeficit === 11 && commander.target === 15, `${label} should compute slot deficit from target 15`, commander); - assertCondition(commander.queuedCount === 2, `${label} should expose queued + retry_wait count`, commander); - assertCondition(commander.attentionRequired === true && commander.interventionRequired === false, `${label} should require attention but not high-risk intervention from one snapshot`, commander); - assertCondition(heartbeat.effectiveLiveness === "at-risk", `${label} heartbeat liveness should be at-risk`, heartbeat); - assertCondition(risk.count === 4 && asArray(risk.items).length > 0, `${label} should expose bounded heartbeat risk ids`, risk); - assertCondition(stale.count === 4 && String(stale.command ?? "").includes("dryRun=1"), `${label} should expose stale recovery candidates and dry-run command`, stale); -} - -function assertNoActiveCommander(label: string, result: unknown): void { - const commander = asRecord(asRecord(asRecord(result).queues).commander); - assertCondition(commander.activeRunnerCount === 0, `${label} should expose zero active runners`, commander); - assertCondition(commander.slotDeficit === 15 && commander.slotDeficitState === "below-target", `${label} should expose full 15-slot deficit`, commander); - assertCondition(commander.queuedCount === 0, `${label} queued count should be zero`, commander); - assertCondition(asRecord(commander.runningTasks).count === 0, `${label} running task list should be empty`, commander); -} - -function assertBelowTargetCommander(label: string, result: unknown): void { - const commander = asRecord(asRecord(asRecord(result).queues).commander); - const runningTasks = asRecord(commander.runningTasks); - const runningItems = asArray(runningTasks.items).map(asRecord); - const activeQueues = asRecord(commander.activeQueues); - const runnableQueues = asRecord(commander.runnableQueues); - assertCondition(commander.activeRunnerCount === 2, `${label} should foreground active runner count`, commander); - assertCondition(commander.slotDeficit === 13 && commander.target === 15, `${label} should expose deficit below target`, commander); - assertCondition(commander.queuedCount === 4, `${label} should include queued and retry_wait`, commander); - assertCondition(runningItems.some((item) => item.id === "task-active-a" && item.name === "Implement feature A polish and contract tests"), `${label} should include active task names when upstream task rows are available`, runningTasks); - assertCondition(runningItems.some((item) => asArray(item.queueIds).includes("feature-a") && asArray(item.queueNames).includes("Feature A")), `${label} should include active task queues`, runningTasks); - assertCondition(activeQueues.count === 2 && runnableQueues.count === 3, `${label} should keep active and queued queue rows separate`, commander); - assertCondition(String(asRecord(commander.commands).running ?? "").includes("--status running,judging"), `${label} should expose running drill-down command`, commander); -} - -export function runCodeQueueQueuesShapeContract(): JsonRecord { - const fetcher = (path: string): JsonRecord => { - assertCondition(path === "/api/microservices/code-queue/proxy/api/queues", "codex queues should use stable proxy path", { path }); - return fixtureResponse(); - }; - const splitBrainFetcher = (path: string): JsonRecord => { - assertCondition(path === "/api/microservices/code-queue/proxy/api/queues", "codex queues should use stable proxy path", { path }); - return splitBrainLiveResponse(); - }; - const heartbeatRiskFetcher = (path: string): JsonRecord => { - assertCondition(path === "/api/microservices/code-queue/proxy/api/queues", "codex queues should use stable proxy path", { path }); - return heartbeatRiskResponse(); - }; - const noActiveFetcher = (path: string): JsonRecord => { - assertCondition(path === "/api/microservices/code-queue/proxy/api/queues", "codex queues should use stable proxy path", { path }); - return noActiveResponse(); - }; - const activeBelowTargetFetcher = (path: string): JsonRecord => { - assertCondition(path === "/api/microservices/code-queue/proxy/api/queues", "codex queues should use stable proxy path", { path }); - return activeBelowTargetResponse(); - }; - - const summary = codexQueuesQueryForTest([], fetcher); - assertQueuesShape("summary", summary, "summary"); - const summaryQueues = asRecord(asRecord(summary).queues); - assertCondition(summaryQueues.deprecatedFullArray === undefined, "summary should not expose deprecated full array compatibility field", summaryQueues); - assertCondition(summaryQueues.summaryMode === "commander-first", "summary should default to commander-first mode", summaryQueues); - - const explicitCommander = codexQueuesQueryForTest(["--commander"], fetcher); - const explicitCommanderQueues = asRecord(asRecord(explicitCommander).queues); - assertCondition(explicitCommanderQueues.summaryMode === "commander-first", "--commander should keep commander-first mode", explicitCommanderQueues); - - const full = codexQueuesQueryForTest(["--full"], fetcher); - assertQueuesShape("full", full, "full"); - const fullQueues = asRecord(asRecord(full).queues); - assertCondition(!Array.isArray(fullQueues), "full queues payload must be an object, not the deprecated array shape", fullQueues); - assertCondition(fullQueues.bounded === true, "full queues output should now be paged by default", fullQueues); - assertCondition(fullQueues.deprecatedFullArray === undefined, "full should not expose deprecated unbounded array by default", fullQueues); - const compatibility = asRecord(fullQueues.compatibility); - assertCondition(compatibility.stablePath === "data.queues.items[]", "compatibility metadata should document stable path", compatibility); - assertCondition(compatibility.deprecated === true, "compatibility metadata should mark old array path deprecated", compatibility); - assertCondition(compatibility.deprecatedFullArrayOmitted === true, "compatibility metadata should explain deprecated array omission", compatibility); - - const limitedFull = codexQueuesQueryForTest(["--full", "--limit", "2"], fetcher); - const limitedFullQueues = asRecord(asRecord(limitedFull).queues); - assertCondition(limitedFullQueues.bounded === true, "full with explicit --limit should be bounded", limitedFullQueues); - assertCondition(asArray(limitedFullQueues.items).length === 2, "full with explicit --limit should limit data.queues.items[]", limitedFullQueues); - assertCondition(limitedFullQueues.hasMore === true, "limited full should expose next page", limitedFullQueues); - const limitedCommands = asRecord(limitedFullQueues.commands); - assertCondition(String(limitedCommands.next ?? "").includes("--offset 2"), "limited full should expose offset pagination command", limitedCommands); - - const offsetFull = codexQueuesQueryForTest(["--full", "--limit", "2", "--offset", "2"], fetcher); - const offsetFullQueues = asRecord(asRecord(offsetFull).queues); - assertCondition(offsetFullQueues.offset === 2, "offset full should preserve offset", offsetFullQueues); - assertCondition(offsetFullQueues.hasPrevious === true, "offset full should expose previous page", offsetFullQueues); - assertCondition(asRecord(asArray(offsetFullQueues.items)[0]).id === "gamma", "offset full should return second page rows", offsetFullQueues); - - const splitSummary = codexQueuesQueryForTest([], splitBrainFetcher); - assertSplitBrainLiveActivity("split-brain summary", splitSummary); - const splitFull = codexQueuesQueryForTest(["--full"], splitBrainFetcher); - assertSplitBrainLiveActivity("split-brain full", splitFull); - const heartbeatRisk = codexQueuesQueryForTest([], heartbeatRiskFetcher); - assertHeartbeatRiskCommander("heartbeat-risk summary", heartbeatRisk); - const noActive = codexQueuesQueryForTest([], noActiveFetcher); - assertNoActiveCommander("no-active summary", noActive); - const belowTarget = codexQueuesQueryForTest([], activeBelowTargetFetcher); - assertBelowTargetCommander("active-below-target summary", belowTarget); - - return { - ok: true, - checks: [ - "summary data.queues.items[] shape", - "summary queue metadata", - "full data.queues.items[] shape", - "full queue metadata", - "deprecated full array omitted from default output", - "full explicit limit remains bounded and paged", - "offset pagination", - "split-brain live activity counts distinguish scheduler-local queues, DB running tasks, and heartbeat-fresh runners", - "commander concurrency block names the active runner count and 15-concurrency rule", - "queues commander-first summary foregrounds active runner count, target 15 deficit, running task ids/names/queues, heartbeat risk, stale recovery candidates, and queued count", - "queues commander fixtures cover split-brain live, heartbeat risk, no active, and active below target", - ], - }; -} - -if (import.meta.main) { - process.stdout.write(`${JSON.stringify(runCodeQueueQueuesShapeContract(), null, 2)}\n`); -} diff --git a/scripts/code-queue-resume-contract-test.ts b/scripts/code-queue-resume-contract-test.ts deleted file mode 100644 index e2633578..00000000 --- a/scripts/code-queue-resume-contract-test.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { writeFileSync, unlinkSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { codexResumeTaskForTest } from "./src/code-queue"; -import { findResumeTraceConfirmation, resumeDuplicateDecision, resumeTraceText } from "../src/components/microservices/code-queue/src/resume-confirmation"; -import type { QueueTask } from "../src/components/microservices/code-queue/src/types"; - -type JsonRecord = Record; - -function assertCondition(condition: unknown, message: string, detail: JsonRecord = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function nestedRecord(value: unknown, path: string[]): JsonRecord { - let current: unknown = value; - for (const key of path) { - assertCondition(current !== null && typeof current === "object" && !Array.isArray(current), "expected object while traversing JSON", { path, key, current }); - current = (current as JsonRecord)[key]; - } - assertCondition(current !== null && typeof current === "object" && !Array.isArray(current), "expected nested object", { path, current }); - return current as JsonRecord; -} - -function runCli(args: string[], stdin?: string): { status: number | null; stdout: string; stderr: string; json: JsonRecord | null } { - const result = spawnSync("bun", ["scripts/cli.ts", ...args], { - cwd: process.cwd(), - input: stdin, - encoding: "utf8", - }); - const stdout = String(result.stdout || ""); - let json: JsonRecord | null = null; - try { - json = JSON.parse(stdout) as JsonRecord; - } catch { - json = null; - } - return { status: result.status, stdout, stderr: String(result.stderr || ""), json }; -} - -function fixtureTask(): QueueTask { - const at = "2026-05-23T00:00:00.000Z"; - return { - id: "codex_resume_fixture", - queueId: "default", - queueEnteredAt: at, - prompt: "base", - basePrompt: "base", - referenceTaskIds: [], - referenceInjection: null, - providerId: "D601", - cwd: "/workspace/unidesk", - model: "gpt-5.5", - reasoningEffort: null, - executionMode: "default", - maxAttempts: 99, - status: "succeeded", - createdAt: at, - updatedAt: at, - startedAt: at, - finishedAt: "2026-05-23T00:10:00.000Z", - readAt: null, - currentAttempt: 1, - currentMode: "initial", - codexThreadId: "thread_resume_fixture", - activeTurnId: null, - finalResponse: "done", - lastError: null, - lastJudge: null, - judgeFailCount: 0, - promptHistory: [], - output: [], - events: [], - attempts: [], - cancelRequested: false, - nextPrompt: null, - nextMode: null, - }; -} - -function deterministicResumeId(taskId: string, prompt: string): string { - return `resume_${Bun.SHA256.hash(`unidesk-code-queue-resume:v1\0${taskId}\0${prompt}`, "hex").slice(0, 24)}`; -} - -function assertLegacyFrozenWrite(result: { status: number | null; stdout: string; stderr: string; json: JsonRecord | null }, command: string): void { - assertCondition(result.status !== 0 && result.json?.ok === false, `${command} should be frozen`, result.json ?? { stdout: result.stdout, stderr: result.stderr }); - const data = nestedRecord(result.json?.data, []); - assertCondition(data.ok === false, `${command} frozen payload should be ok=false`, data); - assertCondition(data.frozen === true, `${command} frozen payload should expose frozen=true`, data); - assertCondition(data.mutation === false, `${command} frozen payload should be non-mutating`, data); - assertCondition(data.degradedReason === "legacy-code-queue-frozen", `${command} should use the legacy frozen reason`, data); - assertCondition(data.command === command, `${command} frozen payload should identify the command`, data); - const replacement = nestedRecord(data, ["replacement"]); - assertCondition(String(replacement.sessionsSteer || "").includes("agentrun sessions steer"), `${command} should point to AgentRun sessions steer`, replacement); - const legacy = nestedRecord(data, ["legacy"]); - assertCondition(legacy.noDoubleWrite === true, `${command} should document no double-write`, legacy); -} - -function assertDryRunPrompt(response: JsonRecord, expectedText: string): void { - assertCondition(response.ok === true, "CLI dry-run should succeed", response); - const data = nestedRecord(response.data, []); - assertCondition(data.dryRun === true, "dry-run response should expose dryRun=true", data); - const request = nestedRecord(response.data, ["request"]); - assertCondition(request.method === "POST", "dry-run should expose request method", request); - assertCondition(request.path === "/api/tasks/codex_test_task/resume", "dry-run should expose resume path", request); - assertCondition(request.stableProxyPath === "/api/microservices/code-queue/proxy/api/tasks/codex_test_task/resume", "dry-run should expose stable proxy path", request); - assertCondition(request.resumeId === deterministicResumeId("codex_test_task", expectedText), "dry-run should expose deterministic resumeId", request); - const prompt = nestedRecord(response.data, ["request", "body", "prompt"]); - assertCondition(prompt.text === expectedText, "dry-run prompt text mismatch", prompt); - assertCondition(prompt.chars === expectedText.length, "dry-run prompt char count mismatch", prompt); - const commands = nestedRecord(response.data, ["commands"]); - assertCondition(String(commands.run || "").includes(`--resume-id ${String(request.resumeId)}`), "dry-run should expose same resumeId run command", commands); -} - -export function runCodeQueueResumeContract(): JsonRecord { - const positional = runCli(["codex", "resume", "codex_test_task", "fix the PR conflict", "--dry-run"]); - assertLegacyFrozenWrite(positional, "codex resume"); - assertCondition(String(positional.json?.command || "").includes(""), "outer command should redact positional resume prompt", positional.json ?? {}); - assertCondition(!String(positional.json?.command || "").includes("fix the PR conflict"), "outer command must not echo positional resume prompt", positional.json ?? {}); - - const stdin = runCli(["codex", "resume", "codex_test_task", "--prompt-stdin", "--dry-run"], "stdin resume prompt\n"); - assertLegacyFrozenWrite(stdin, "codex resume"); - assertCondition(!stdin.stdout.includes("stdin resume prompt"), "frozen resume must not echo stdin prompt", { stdout: stdin.stdout }); - - const promptFile = join(tmpdir(), `unidesk-code-queue-resume-${process.pid}.txt`); - writeFileSync(promptFile, "file resume prompt", "utf8"); - try { - const fromFile = runCli(["codex", "resume", "codex_test_task", "--prompt-file", promptFile, "--dry-run"]); - assertLegacyFrozenWrite(fromFile, "codex resume"); - assertCondition(!fromFile.stdout.includes("file resume prompt"), "frozen resume must not echo file prompt", { stdout: fromFile.stdout }); - } finally { - unlinkSync(promptFile); - } - - const duplicateSource = runCli(["codex", "resume", "codex_test_task", "positional", "--prompt-stdin", "--dry-run"], "stdin\n"); - assertLegacyFrozenWrite(duplicateSource, "codex resume"); - - const help = runCli(["codex", "help"]); - const usage = Array.isArray(nestedRecord(help.json?.data, []).usage) ? nestedRecord(help.json?.data, []).usage as unknown[] : []; - assertCondition(usage.some((line) => String(line).includes("codex resume ")), "codex help should list resume", { usage: usage.map(String) }); - - let dryRunFetchCount = 0; - const dryRunDirect = codexResumeTaskForTest("direct_task", ["do not send", "--dry-run"], () => { - dryRunFetchCount += 1; - return { ok: true, status: 200, body: { ok: true } }; - }); - assertCondition(dryRunFetchCount === 0, "dry-run must not call stable proxy helper", { dryRunFetchCount, dryRunDirect }); - - const longPrompt = `${"x".repeat(480)}-tail-secret-marker`; - const longDryRun = codexResumeTaskForTest("direct_task", [longPrompt, "--dry-run"], () => { - throw new Error("dry-run should not fetch"); - }) as JsonRecord; - const longPreview = nestedRecord(longDryRun, ["request", "body", "prompt"]); - assertCondition(longPreview.truncated === true, "long dry-run prompt should be truncated", longPreview); - assertCondition(!String(longPreview.text || "").includes("tail-secret-marker"), "long dry-run must not leak prompt tail", longPreview); - - let fetchPath = ""; - let fetchMethod = ""; - let fetchPrompt = ""; - let fetchResumeId = ""; - const success = codexResumeTaskForTest("direct_task", ["resume this context"], (path, init) => { - fetchPath = path; - fetchMethod = String(init?.method || ""); - fetchPrompt = String((init?.body as JsonRecord | undefined)?.prompt || ""); - fetchResumeId = String((init?.body as JsonRecord | undefined)?.resumeId || ""); - return { - ok: true, - status: 202, - body: { - ok: true, - accepted: true, - duplicateSuppressed: false, - deliveryState: "queued_for_existing_thread", - resumeId: fetchResumeId, - turnId: 9, - reuseOriginalThread: true, - originalCodexThreadId: "thread_original", - codexThreadId: "thread_original", - traceConfirmation: { - taskId: "direct_task", - resumeId: fetchResumeId, - found: true, - accepted: true, - deliveryState: "queued_for_existing_thread", - matchCount: 1, - trace: { seq: 9, at: "2026-05-23T00:00:09.000Z", method: "turn/resume", resumeId: fetchResumeId, promptChars: 19, promptHash: "hash", promptOmitted: true, source: "output" }, - duplicateSuppressionKey: fetchResumeId, - promptOmitted: true, - }, - task: { id: "direct_task", status: "queued", codexThreadId: "thread_original", currentAttempt: 1, currentMode: "initial", prompt: "hidden" }, - queue: { activeTaskIds: [], queuedTaskIds: ["direct_task"] }, - }, - }; - }) as JsonRecord; - assertCondition(fetchPath === "/api/microservices/code-queue/proxy/api/tasks/direct_task/resume", "non-dry-run should use stable resume path", { fetchPath }); - assertCondition(fetchMethod === "POST", "non-dry-run should POST", { fetchMethod }); - assertCondition(fetchPrompt === "resume this context", "non-dry-run should send raw prompt in body", { fetchPrompt }); - assertCondition(fetchResumeId === deterministicResumeId("direct_task", "resume this context"), "non-dry-run should send deterministic resumeId", { fetchResumeId }); - assertCondition(nestedRecord(success, ["resume"]).accepted === true, "successful resume should report accepted=true", success); - assertCondition(nestedRecord(success, ["resume"]).reusedCodexThread === true, "successful resume should report thread reuse", success); - assertCondition(nestedRecord(success, ["resume"]).promptOmitted === true, "successful resume should mark prompt omitted", success); - assertCondition(nestedRecord(success, ["resume"]).deliveryState === "queued_for_existing_thread", "successful resume should expose delivery state", success); - assertCondition(!JSON.stringify(success).includes("resume this context"), "successful resume must not echo prompt text", success); - - const explicitResumeId = "resume_manual_12345"; - const duplicateSuppressed = codexResumeTaskForTest("direct_task", ["same prompt", "--resume-id", explicitResumeId], (_path, init) => { - assertCondition((init?.body as JsonRecord | undefined)?.resumeId === explicitResumeId, "explicit resumeId should be sent unchanged", (init?.body as JsonRecord | undefined) ?? {}); - return { - ok: true, - status: 200, - body: { - ok: true, - accepted: true, - duplicateSuppressed: true, - deliveryState: "duplicate_suppressed", - resumeId: explicitResumeId, - reuseOriginalThread: true, - originalCodexThreadId: "thread_original", - codexThreadId: "thread_original", - traceConfirmation: { - taskId: "direct_task", - resumeId: explicitResumeId, - found: true, - accepted: true, - deliveryState: "queued_for_existing_thread", - matchCount: 1, - trace: { seq: 11, at: "2026-05-23T00:00:11.000Z", method: "turn/resume", resumeId: explicitResumeId, promptChars: 11, promptHash: "hash2", promptOmitted: true, source: "output" }, - duplicateSuppressionKey: explicitResumeId, - }, - task: { id: "direct_task", status: "queued", codexThreadId: "thread_original", prompt: "hidden" }, - queue: { queuedTaskIds: ["direct_task"] }, - }, - }; - }) as JsonRecord; - assertCondition(nestedRecord(duplicateSuppressed, ["resume"]).status === "duplicate_suppressed", "duplicate resume should expose suppression status", duplicateSuppressed); - assertCondition(nestedRecord(duplicateSuppressed, ["resume"]).duplicateSuppressed === true, "duplicate resume should expose duplicateSuppressed", duplicateSuppressed); - - const conflictPrompt = "changed resume request requested-secret-marker"; - const conflict = codexResumeTaskForTest("direct_task", [conflictPrompt, "--resume-id", explicitResumeId], () => ({ - ok: false, - status: 409, - body: { - ok: false, - error: "resumeId already exists with a different prompt hash", - accepted: false, - deliveryState: "not_accepted", - resumeId: explicitResumeId, - existingPromptHash: "old", - requestedPromptHash: "new", - traceConfirmation: { - taskId: "direct_task", - resumeId: explicitResumeId, - found: true, - accepted: true, - deliveryState: "queued_for_existing_thread", - matchCount: 1, - trace: { seq: 11, at: "2026-05-23T00:00:11.000Z", method: "turn/resume", resumeId: explicitResumeId, promptChars: 11, promptHash: "old", promptOmitted: true, source: "output" }, - }, - task: { id: "direct_task", status: "queued", prompt: `${"hidden ".repeat(80)}task-secret-marker` }, - }, - })) as JsonRecord; - assertCondition(conflict.ok === false, "resumeId conflict should fail", conflict); - assertCondition(nestedRecord(conflict, ["resume"]).status === "not_accepted", "conflict should expose not_accepted", conflict); - assertCondition(!JSON.stringify(conflict).includes("requested-secret-marker"), "conflict must not echo requested prompt", conflict); - assertCondition(!JSON.stringify(conflict).includes("task-secret-marker"), "conflict must not echo full task prompt by default", conflict); - - const runningRejection = codexResumeTaskForTest("running_task", ["use steer instead"], () => ({ - ok: false, - status: 409, - body: { - ok: false, - error: "task is active: running", - accepted: false, - deliveryState: "not_accepted", - disposition: "use-steer-for-active-task", - resumeId: deterministicResumeId("running_task", "use steer instead"), - task: { id: "running_task", status: "running", terminalStatus: null, prompt: "hidden active task" }, - }, - })) as JsonRecord; - assertCondition(runningRejection.ok === false, "running task resume should fail closed", runningRejection); - assertCondition(nestedRecord(runningRejection, ["resume"]).reason === "use-steer-for-active-task", "running resume should route to steer", runningRejection); - assertCondition(String(nestedRecord(runningRejection, ["commands"]).steer || "").includes("codex steer running_task"), "running rejection should provide steer command", runningRejection); - - const notFound = codexResumeTaskForTest("missing_task", ["prompt"], () => ({ ok: false, status: 404, body: { ok: false, error: "task not found" } })) as JsonRecord; - assertCondition(notFound.ok === false, "missing task resume should fail", notFound); - assertCondition(nestedRecord(notFound, ["resume"]).status === "not_accepted", "missing task should be not accepted", notFound); - - const task = fixtureTask(); - const resumeId = "resume_contract_12345"; - const prompt = "continue the same PR"; - task.output.push({ seq: 9, at: "2026-05-23T00:00:09.000Z", channel: "user", method: "turn/resume", itemId: resumeId, text: resumeTraceText(resumeId, prompt) }); - const confirmation = findResumeTraceConfirmation(task, resumeId); - assertCondition(confirmation.found === true && confirmation.accepted === true, "confirmation should find resume trace by resumeId", confirmation as unknown as JsonRecord); - assertCondition(confirmation.trace?.promptChars === prompt.length, "confirmation should expose prompt chars without prompt text", (confirmation.trace ?? {}) as unknown as JsonRecord); - assertCondition(!JSON.stringify(confirmation).includes(prompt), "confirmation must not echo prompt text", confirmation as unknown as JsonRecord); - const duplicate = resumeDuplicateDecision(task, resumeId, prompt); - assertCondition(duplicate.duplicate === true && duplicate.conflict === false, "same resumeId and prompt should be duplicate-suppressed", duplicate as unknown as JsonRecord); - const localConflict = resumeDuplicateDecision(task, resumeId, "changed prompt"); - assertCondition(localConflict.duplicate === false && localConflict.conflict === true, "same resumeId with changed prompt should conflict", localConflict as unknown as JsonRecord); - const newlineResumeId = "resume_contract_newline"; - const newlinePrompt = "keep trailing newline\n"; - task.output.push({ seq: 10, at: "2026-05-23T00:00:10.000Z", channel: "user", method: "turn/resume", itemId: newlineResumeId, text: resumeTraceText(newlineResumeId, newlinePrompt) }); - const newlineDuplicate = resumeDuplicateDecision(task, newlineResumeId, newlinePrompt); - assertCondition(newlineDuplicate.duplicate === true && newlineDuplicate.conflict === false, "duplicate suppression should use exact prompt hash, including trailing newline", newlineDuplicate as unknown as JsonRecord); - - return { - ok: true, - checks: [ - "legacy resume positional/stdin/file dry-runs are frozen", - "bounded disclosure and outer command redaction", - "non-dry-run sends resumeId and omits prompt from output", - "terminal resume accepted with thread reuse metadata", - "duplicate suppression and conflict behavior", - "running task fails closed with steer command", - "missing task fails closed", - "local resume trace confirmation helpers", - "exact prompt hash survives trailing newline", - ], - }; -} - -if (import.meta.main) { - process.stdout.write(`${JSON.stringify(runCodeQueueResumeContract(), null, 2)}\n`); -} diff --git a/scripts/code-queue-runner-skills-contract-test.ts b/scripts/code-queue-runner-skills-contract-test.ts deleted file mode 100644 index 3679f28e..00000000 --- a/scripts/code-queue-runner-skills-contract-test.ts +++ /dev/null @@ -1,748 +0,0 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { spawnSync } from "node:child_process"; -import { collectSkillAvailability, collectSkillSyncPreflight } from "../src/components/microservices/code-queue/src/skill-availability"; -import { buildDevContainerPlan, configureProviderRuntime, providerRuntimeForTest } from "../src/components/microservices/code-queue/src/provider-runtime"; -import { codexPrPreflightQueryForTest } from "./src/code-queue"; -import { summarizeMicroserviceObservation } from "./src/microservices"; - -type JsonRecord = Record; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function asRecord(value: unknown, label: string): JsonRecord { - assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, value); - return value as JsonRecord; -} - -function countOccurrences(haystack: string, needle: string): number { - return haystack.split(needle).length - 1; -} - -function gitShowText(commit: string, path: string): string { - const result = spawnSync("git", ["show", `${commit}:${path}`], { - cwd: process.cwd(), - encoding: "utf8", - maxBuffer: 2 * 1024 * 1024, - }); - assertCondition(result.status === 0, `git show should read ${path} at ${commit}`, { - status: result.status, - stderr: result.stderr.slice(-1000), - }); - return result.stdout; -} - -function createSkillSet(root: string, skills: string[]): void { - for (const skill of skills) { - const dir = join(root, skill); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, "SKILL.md"), `---\nname: ${skill}\n---\n# ${skill}\n`); - } -} - -const productionManifest = readFileSync("src/components/microservices/k3sctl-adapter/k3s/code-queue.k8s.yaml", "utf8"); -const devManifest = readFileSync("src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-code-queue.k8s.yaml", "utf8"); -const deployJson = JSON.parse(readFileSync("deploy.json", "utf8")) as { - environments?: { - dev?: { - services?: Array>; - }; - }; -}; -const runtimePreflight = readFileSync("src/components/microservices/code-queue/src/runtime-preflight.ts", "utf8"); -const indexSource = readFileSync("src/components/microservices/code-queue/src/index.ts", "utf8"); -const skillModule = readFileSync("src/components/microservices/code-queue/src/skill-availability.ts", "utf8"); -const providerRuntimeSource = readFileSync("src/components/microservices/code-queue/src/provider-runtime.ts", "utf8"); -const codeQueueDockerfile = readFileSync("src/components/microservices/code-queue/Dockerfile", "utf8"); -const hwpodWrapper = readFileSync("scripts/hwpod", "utf8"); -const codeQueueCli = readFileSync("scripts/src/code-queue.ts", "utf8"); -const microserviceCli = readFileSync("scripts/src/microservices.ts", "utf8"); -const helpSource = readFileSync("scripts/src/help.ts", "utf8"); -const promptSource = readFileSync("src/components/microservices/code-queue/src/prompts.ts", "utf8"); -const docsReference = readFileSync("docs/reference/code-queue-supervision.md", "utf8"); -const forbiddenPathLiteral = [".ag", "nets/skills"].join(""); -const forbiddenTargetPath = [`/root/${[".ag", "nets"].join("")}`, "skills"].join("/"); -const forbiddenSourcePath = [`/home/ubuntu/${[".ag", "nets"].join("")}`, "skills"].join("/"); - -assertCondition(!productionManifest.includes(forbiddenPathLiteral), "production manifest must not propagate misspelled skills path"); -assertCondition(!devManifest.includes(forbiddenPathLiteral), "dev manifest must not propagate misspelled skills path"); -assertCondition(!promptSource.includes(forbiddenPathLiteral), "runner prompt must not mention misspelled skills path"); -assertCondition(!skillModule.includes(forbiddenPathLiteral), "skill availability implementation must not propagate misspelled skills path literal"); -assertCondition(!docsReference.includes(forbiddenPathLiteral), "reference docs must not propagate misspelled skills path literal"); -assertCondition(countOccurrences(productionManifest, "name: UNIDESK_SKILLS_PATH") === 3, "production read/write/scheduler must set UNIDESK_SKILLS_PATH", { - count: countOccurrences(productionManifest, "name: UNIDESK_SKILLS_PATH"), -}); -assertCondition(countOccurrences(productionManifest, "name: CODE_QUEUE_RUNNER_SKILLS_SOURCE_PATH") === 3, "production read/write/scheduler must expose the approved runner skills source path", { - count: countOccurrences(productionManifest, "name: CODE_QUEUE_RUNNER_SKILLS_SOURCE_PATH"), -}); -assertCondition(countOccurrences(productionManifest, "mountPath: /root/.agents/skills") === 3, "production read/write/scheduler must mount skills target", { - count: countOccurrences(productionManifest, "mountPath: /root/.agents/skills"), -}); -assertCondition(countOccurrences(productionManifest, "path: /home/ubuntu/.agents/skills") === 3, "production read/write/scheduler must use hostPath source of truth", { - count: countOccurrences(productionManifest, "path: /home/ubuntu/.agents/skills"), -}); -assertCondition(countOccurrences(productionManifest, "name: skills-dir") >= 6, "production manifest must define skills-dir mounts and volumes", { - count: countOccurrences(productionManifest, "name: skills-dir"), -}); -assertCondition(devManifest.includes("path: /home/ubuntu/.agents/skills"), "dev manifest should keep the same hostPath source of truth"); -assertCondition(countOccurrences(devManifest, "name: CODE_QUEUE_RUNNER_SKILLS_SOURCE_PATH") === 3, "dev read/write/scheduler must expose the approved runner skills source path", { - count: countOccurrences(devManifest, "name: CODE_QUEUE_RUNNER_SKILLS_SOURCE_PATH"), -}); -assertCondition(codeQueueDockerfile.includes("COPY scripts/hwpod /usr/local/bin/hwpod"), "Code Queue image must install the hwpod short alias", codeQueueDockerfile); -assertCondition(codeQueueDockerfile.includes("chmod 755 /usr/local/bin/tran /usr/local/bin/hwpod"), "Code Queue image must make hwpod executable", codeQueueDockerfile); -assertCondition(hwpodWrapper.includes("DEVICE_POD_CLI") && hwpodWrapper.includes("UNIDESK_SKILLS_PATH") && hwpodWrapper.includes("skills/device-pod-cli/scripts/device-pod-cli.mjs"), "hwpod wrapper must resolve generic device-pod-cli locations", hwpodWrapper); -assertCondition(hwpodWrapper.includes("tools/device-pod-cli.mjs") && hwpodWrapper.includes("exec node"), "hwpod wrapper must support repo-local tools/device-pod-cli.mjs and exec through node", hwpodWrapper); - -const devCodeQueueDeploy = (deployJson.environments?.dev?.services ?? []).find((service) => service.id === "code-queue"); -assertCondition(devCodeQueueDeploy !== undefined, "deploy.json dev environment must include code-queue"); -const devCodeQueueCommit = String(devCodeQueueDeploy?.commitId ?? ""); -assertCondition(/^[0-9a-f]{40}$/u.test(devCodeQueueCommit), "deploy.json dev code-queue commit must be a full SHA", devCodeQueueDeploy); -assertCondition(devCodeQueueCommit !== "0cf73d817f14032ad6038fd47ec402c87bf059bb", "deploy.json dev code-queue must not pin the pre-skills-mount source commit", devCodeQueueDeploy); -assertCondition(asRecord(devCodeQueueDeploy?.artifact, "dev code-queue artifact").repository === "unidesk/code-queue", "deploy.json dev code-queue must own artifact repository", devCodeQueueDeploy); -const devCodeQueueConsumer = asRecord(devCodeQueueDeploy?.consumer, "dev code-queue consumer"); -assertCondition(devCodeQueueConsumer.kind === "d601-k3s-managed", "deploy.json dev code-queue must use the D601 k3s artifact consumer", devCodeQueueConsumer); -const devCodeQueueTarget = asRecord(devCodeQueueConsumer.target, "dev code-queue consumer target"); -assertCondition(devCodeQueueTarget.manifestRepoPath === "src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-code-queue.k8s.yaml", "deploy.json dev code-queue must point at the dev k3s manifest", devCodeQueueTarget); - -const pinnedDevManifest = gitShowText(devCodeQueueCommit, "src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-code-queue.k8s.yaml"); -const pinnedRuntimePreflight = gitShowText(devCodeQueueCommit, "src/components/microservices/code-queue/src/runtime-preflight.ts"); -const pinnedIndexSource = gitShowText(devCodeQueueCommit, "src/components/microservices/code-queue/src/index.ts"); -const pinnedProviderRuntime = gitShowText(devCodeQueueCommit, "src/components/microservices/code-queue/src/provider-runtime.ts"); -assertCondition(countOccurrences(pinnedDevManifest, "path: /home/ubuntu/.agents/skills") === 3, "deploy.json dev code-queue commit must include source skills hostPath for scheduler/read/write", { - commit: devCodeQueueCommit, -}); -assertCondition(countOccurrences(pinnedDevManifest, "mountPath: /root/.agents/skills") === 3, "deploy.json dev code-queue commit must mount skills target for scheduler/read/write", { - commit: devCodeQueueCommit, -}); -assertCondition(!pinnedDevManifest.includes(forbiddenPathLiteral), "deploy.json dev code-queue commit must not include the misspelled skills path"); -assertCondition(pinnedRuntimePreflight.includes("skills.contractOk && ports.codex.ok"), "deploy.json dev code-queue commit runtime-preflight must require target projection contract"); -assertCondition(pinnedIndexSource.includes("skills.contractOk === true"), "deploy.json dev code-queue commit dev-ready must require target projection contract"); -assertCondition(pinnedIndexSource.includes("return config.skillsPath"), "deploy.json dev code-queue commit must keep runner UNIDESK_SKILLS_PATH on the configured target"); -assertCondition(pinnedProviderRuntime.includes("SKILLS_MOUNT_ARGS=(-v \"$SKILLS_SOURCE\":\"$SKILLS_TARGET\":ro)"), "deploy.json dev code-queue commit must bind D601 host skills into provider dev containers", { - commit: devCodeQueueCommit, -}); -assertCondition(pinnedProviderRuntime.includes("-e UNIDESK_SKILLS_PATH=\"$SKILLS_TARGET\""), "deploy.json dev code-queue commit must pass target skills env into provider dev containers", { - commit: devCodeQueueCommit, -}); - -configureProviderRuntime({ - config: { - codexHome: "/var/lib/unidesk/code-queue/codex-home", - defaultWorkdir: "/workspace", - devContainerDefaultProviderId: "D601", - devContainerImage: "unidesk-code-queue:d601", - devContainerMasterHost: "74.48.78.17", - devContainerWorkdir: "/home/ubuntu", - executionProviderIds: ["D601"], - mainProviderId: "D601-main", - remoteCodexEnvKeys: [], - remoteDefaultWorkdir: "/home/ubuntu", - runnerSkillsSourcePath: "/home/ubuntu/.agents/skills", - skillsPath: "/root/.agents/skills", - sourceCodexConfig: "/root/.codex/config.toml", - windowsNativeCodexBridgeDir: "/home/ubuntu/.unidesk/code-queue/windows-native-codex", - windowsNativeCodexCommand: "codex app-server --listen stdio://", - windowsNativeCodexConnectHost: "host.docker.internal", - windowsNativeCodexDefaultWorkdir: "/mnt/f/Work/ConStart", - windowsNativeCodexIdleTimeoutMs: 600_000, - }, - safePreview: (value: string, max = 1000) => value.slice(0, max), -}); -const devContainerPlan = buildDevContainerPlan("D601", { workdir: "/home/ubuntu" }); -const devContainerStartScript = providerRuntimeForTest.remoteContainerStartScript(devContainerPlan, false); -assertCondition(providerRuntimeSource.includes("hwpodWrapperSource") && providerRuntimeSource.includes("/usr/local/bin/hwpod") && providerRuntimeSource.includes("hwpod=$(command -v hwpod)"), "provider dev container runtime prepare must install and report hwpod", providerRuntimeSource); -assertCondition(devContainerStartScript.includes("SKILLS_SOURCE='/home/ubuntu/.agents/skills'"), "provider dev container start must use the D601 host skills source", devContainerStartScript); -assertCondition(devContainerStartScript.includes("SKILLS_TARGET='/root/.agents/skills'"), "provider dev container start must use the runner target skills path", devContainerStartScript); -assertCondition(devContainerStartScript.includes('-v "$SKILLS_SOURCE":"$SKILLS_TARGET":ro'), "provider dev container must bind source skills read-only to target", devContainerStartScript); -assertCondition(devContainerStartScript.includes('-e UNIDESK_SKILLS_PATH="$SKILLS_TARGET"'), "provider dev container must export UNIDESK_SKILLS_PATH in docker run", devContainerStartScript); -assertCondition(devContainerStartScript.includes('test -r "$UNIDESK_SKILLS_PATH/docs-spec/SKILL.md"'), "provider dev container readiness must verify required docs-spec skill at target", devContainerStartScript); -assertCondition(devContainerStartScript.includes('test -r "$UNIDESK_SKILLS_PATH/cli-spec/SKILL.md"'), "provider dev container readiness must verify required cli-spec skill at target", devContainerStartScript); -assertCondition(devContainerStartScript.includes('test -r "$UNIDESK_SKILLS_PATH/frontend-design/SKILL.md"'), "provider dev container readiness must verify required frontend-design skill at target", devContainerStartScript); -assertCondition(devContainerStartScript.includes("playwright-cli/SKILL.md") && devContainerStartScript.includes("playwright/SKILL.md"), "provider dev container readiness must accept the playwright-cli alias", devContainerStartScript); -assertCondition(devContainerStartScript.includes("reuse_ready") && devContainerStartScript.includes('test "$UNIDESK_SKILLS_PATH" = "/root/.agents/skills"'), "provider dev container reuse must revalidate target skills before keeping an old container", devContainerStartScript); -const remoteCodexCommand = providerRuntimeForTest.remoteAppServerCommand({ - id: "task", - queueId: "default", - prompt: "", - status: "running", - cwd: "/home/ubuntu", - providerId: "D601", - model: "gpt-5.5", - executionMode: "default", - currentAttempt: 1, - attempts: 1, - maxAttempts: 1, - codexThreadId: null, - reasoningEffort: null, - createdAt: "2026-05-24T00:00:00.000Z", - updatedAt: "2026-05-24T00:00:00.000Z", - startedAt: "2026-05-24T00:00:00.000Z", - completedAt: null, - lastActivityAt: "2026-05-24T00:00:00.000Z", - branch: null, - commitSha: null, - title: null, - error: null, - judge: null, - outputSeq: 0, - outputs: [], - events: [], - metadata: {}, -} as never); -assertCondition(remoteCodexCommand.includes("export UNIDESK_SKILLS_PATH=") && remoteCodexCommand.includes("/root/.agents/skills"), "remote codex app-server must receive the target skills env", remoteCodexCommand); - -const available = collectSkillAvailability({ - source: "/home/ubuntu/.agents/skills", - target: "/home/ubuntu/.agents/skills", - requiredSkills: ["docs-spec", "cli-spec", "frontend-design", "playwright-cli"], -}); -assertCondition(available.source === "/home/ubuntu/.agents/skills", "skill report must expose source"); -assertCondition(available.target === "/home/ubuntu/.agents/skills", "skill report must expose target"); -assertCondition(typeof available.resolvedPath === "string" && available.resolvedPath.length > 0, "skill report must expose resolved path", available); -assertCondition(asRecord(available.resolution, "available.resolution").passesToRunnerEnv === true, "skill report must expose runner env path resolution", available.resolution); -assertCondition(Array.isArray(available.requiredSkills) && available.requiredSkills.includes("docs-spec"), "skill report must expose requiredSkills"); -assertCondition(Array.isArray(available.missingSkills), "skill report must expose missingSkills"); -assertCondition(asRecord(available.version, "available.version").selectedFingerprint !== undefined, "skill report must expose selected skills fingerprint", available.version); -assertCondition(asRecord(available.version, "available.version").sourceLatestMtime !== undefined, "skill report must expose source skills mtime", available.version); -assertCondition(available.valuesPrinted === false, "skill report must declare valuesPrinted=false"); -assertCondition(asRecord(available.pathSpelling, "pathSpelling").forbiddenPathMustNotBeUsed === true, "skill report must flag misspelled path risk without spreading the literal path"); -assertCondition(!JSON.stringify(available).includes(forbiddenPathLiteral), "skill report must not propagate misspelled path literal"); -assertCondition(!JSON.stringify(available).includes("GH_TOKEN"), "skill report must not include secret environment names unrelated to skills"); - -const tmpRoot = mkdtempSync(join(tmpdir(), "unidesk-codequeue-skills-")); -const fixtureSource = join(tmpRoot, "source"); -const fixtureMissingTarget = join(tmpRoot, "target-missing"); -const fixtureSymlinkTarget = join(tmpRoot, "target-symlink"); -const fixtureMissingSource = join(tmpRoot, "source-missing"); -const fixtureApprovedSource = join(tmpRoot, "approved-source"); -const fixtureArbitraryTarget = join(tmpRoot, "arbitrary-target"); -mkdirSync(fixtureSource, { recursive: true }); -mkdirSync(fixtureApprovedSource, { recursive: true }); -createSkillSet(fixtureSource, ["docs-spec", "cli-spec"]); -createSkillSet(fixtureApprovedSource, ["docs-spec", "cli-spec"]); -symlinkSync(fixtureSource, fixtureSymlinkTarget, "dir"); - -const missingTargetWithSource = collectSkillAvailability({ - source: fixtureSource, - target: fixtureMissingTarget, - requiredSkills: ["docs-spec", "cli-spec"], -}); -assertCondition(missingTargetWithSource.ok === false, "source exists target missing must keep runner unavailable until target is projected", missingTargetWithSource); -assertCondition(missingTargetWithSource.runnerUsable === false, "source must not be passed as the runner skills path when target is missing", missingTargetWithSource); -assertCondition(missingTargetWithSource.contractOk === false, "missing target must mark target projection contract degraded", missingTargetWithSource); -assertCondition(missingTargetWithSource.degraded === true, "missing target should remain degraded for host rollout", missingTargetWithSource); -assertCondition(missingTargetWithSource.blocker === "skills-target-missing", "missing target should preserve target missing degraded reason", missingTargetWithSource); -assertCondition(missingTargetWithSource.degradedReason === "skills-target-missing", "missing target should expose bounded degraded reason", missingTargetWithSource); -assertCondition(missingTargetWithSource.resolvedPath === fixtureMissingTarget, "missing target should keep runner path at the expected target", missingTargetWithSource); -assertCondition(missingTargetWithSource.resolvedPathSource === "missing", "missing target should not expose source fallback resolution", missingTargetWithSource); -assertCondition(missingTargetWithSource.skillCount === 0 && missingTargetWithSource.sourceSkillCount === 2 && missingTargetWithSource.targetSkillCount === 0, "missing target should expose bounded source and target counts", missingTargetWithSource); -assertCondition(asRecord(missingTargetWithSource.resolution, "missingTargetWithSource.resolution").runnerEnvValue === fixtureMissingTarget, "missing target should not pass source path to runner env", missingTargetWithSource.resolution); -assertCondition(asRecord(missingTargetWithSource.resolution, "missingTargetWithSource.resolution").hostRolloutRequired === true, "missing target should require host rollout repair", missingTargetWithSource.resolution); - -const symlinkOk = collectSkillAvailability({ - source: fixtureSource, - target: fixtureSymlinkTarget, - requiredSkills: ["docs-spec", "cli-spec"], -}); -assertCondition(symlinkOk.ok === true && symlinkOk.contractOk === true, "target symlink to source should satisfy runner and contract", symlinkOk); -assertCondition(symlinkOk.resolvedPath === fixtureSymlinkTarget, "target symlink should keep target as runner path", symlinkOk); -assertCondition(symlinkOk.resolvedPathSource === "target-symlink", "target symlink should expose target-symlink source", symlinkOk); -assertCondition(symlinkOk.targetSymlink === true, "target symlink should be reported", symlinkOk); -assertCondition(asRecord(symlinkOk.resolution, "symlinkOk.resolution").hostRolloutRequired === false, "target symlink should not require rollout repair", symlinkOk.resolution); - -const missingBoth = collectSkillAvailability({ - source: fixtureMissingSource, - target: fixtureMissingTarget, - requiredSkills: ["docs-spec", "cli-spec"], -}); -assertCondition(missingBoth.ok === false && missingBoth.runnerUsable === false, "missing source and target should fail runner availability", missingBoth); -assertCondition(missingBoth.blocker === "skills-source-and-target-missing", "missing both should expose dedicated blocker", missingBoth); -assertCondition(missingBoth.resolvedPathSource === "missing", "missing both should expose missing resolution", missingBoth); - -const missing = collectSkillAvailability({ - source: fixtureApprovedSource, - target: fixtureArbitraryTarget, - requiredSkills: ["docs-spec", "cli-spec"], -}); -assertCondition(missing.ok === false, "approved source must not keep missing-target runner usable"); -assertCondition(missing.runnerUsable === false, "missing target with approved source should expose runner unavailable"); -assertCondition(missing.contractOk === false, "missing target with approved source should expose hostPath contract degraded"); -assertCondition(missing.degraded === true, "missing target should be degraded"); -assertCondition(missing.blocker === "skills-target-missing", "missing target should expose blocker", missing); -assertCondition(missing.targetMissingSkills.includes("docs-spec") && missing.targetMissingSkills.includes("cli-spec"), "missing target should list target missing skills", missing); -assertCondition(missing.resolvedPath === fixtureArbitraryTarget, "missing target should keep the configured target path", missing); -assertCondition(missing.resolvedPathSource === "missing", "missing target should not expose source fallback", missing); -assertCondition(missing.valuesPrinted === false, "missing report must also declare valuesPrinted=false"); - -const typoTarget = collectSkillAvailability({ - source: "/home/ubuntu/.agents/skills", - target: forbiddenTargetPath, - requiredSkills: ["docs-spec", "cli-spec"], -}); -assertCondition(typoTarget.ok === false, "misspelled target should fail", typoTarget); -assertCondition(typoTarget.degraded === true, "misspelled target should be degraded", typoTarget); -assertCondition(typoTarget.blocker === "forbidden-skills-path-configured", "misspelled target should expose dedicated blocker", typoTarget); -assertCondition(asRecord(typoTarget.pathSpelling, "typoTarget.pathSpelling").forbiddenPathConfigured === true, "misspelled target should mark configured typo", typoTarget.pathSpelling); -assertCondition(JSON.stringify(asRecord(typoTarget.pathSpelling, "typoTarget.pathSpelling").forbiddenPathRoles).includes("target"), "misspelled target should classify target role", typoTarget.pathSpelling); -assertCondition(typoTarget.valuesPrinted === false, "misspelled target report must declare valuesPrinted=false"); - -const syncDryRun = collectSkillSyncPreflight({ - source: fixtureApprovedSource, - target: fixtureArbitraryTarget, - requiredSkills: ["docs-spec", "cli-spec"], -}); -assertCondition(syncDryRun.dryRun === true && syncDryRun.mutation === false, "skills sync contract must be dry-run and non-mutating", syncDryRun); -assertCondition(syncDryRun.syncMode === "hostPath-read-only-projection", "skills sync must describe the hostPath projection lifecycle", syncDryRun); -assertCondition(syncDryRun.source.path === fixtureApprovedSource, "skills sync must expose source", syncDryRun.source); -assertCondition(syncDryRun.target.path === fixtureArbitraryTarget, "skills sync must expose target", syncDryRun.target); -assertCondition(syncDryRun.expected.source === "/home/ubuntu/.agents/skills", "skills sync must expose stable expected source", syncDryRun.expected); -assertCondition(syncDryRun.expected.target === "/root/.agents/skills", "skills sync must expose stable expected target", syncDryRun.expected); -assertCondition(syncDryRun.expected.env === "UNIDESK_SKILLS_PATH" && syncDryRun.expected.envValue === "/root/.agents/skills", "skills sync must expose env contract", syncDryRun.expected); -assertCondition(syncDryRun.counts.requiredSkills === 2, "skills sync must expose required skill count", syncDryRun.counts); -assertCondition(syncDryRun.counts.targetSkills === 0 && syncDryRun.counts.missingTargetSkills === 2, "skills sync must expose target counts and missing count", syncDryRun.counts); -assertCondition(asRecord(syncDryRun.version, "syncDryRun.version").sourceFingerprint !== undefined, "skills sync must expose source fingerprint", syncDryRun.version); -assertCondition(asRecord(syncDryRun.version, "syncDryRun.version").targetLatestMtime !== undefined, "skills sync must expose target mtime", syncDryRun.version); -assertCondition(syncDryRun.missing.targetSkills.includes("docs-spec") && syncDryRun.missing.targetSkills.includes("cli-spec"), "skills sync must expose missing target skills", syncDryRun.missing); -assertCondition(["unapproved-source", "unapproved-target"].includes(String(syncDryRun.blocker)), "arbitrary source or target paths must be blocked before silent copying", syncDryRun); -assertCondition(syncDryRun.plannedActions.copy === false && syncDryRun.plannedActions.copyFromArbitraryPath === false, "skills sync dry-run must not plan arbitrary copy", syncDryRun.plannedActions); -assertCondition(syncDryRun.plannedActions.restartRequired === false && syncDryRun.plannedActions.readsSecrets === false, "skills sync dry-run must not require restart or read secrets", syncDryRun.plannedActions); -assertCondition(Array.isArray(syncDryRun.instructions) && syncDryRun.instructions.some((item) => item.includes("read-only hostPath projection")), "skills sync must include lifecycle instructions", syncDryRun.instructions); -assertCondition(syncDryRun.valuesPrinted === false, "skills sync must declare valuesPrinted=false", syncDryRun); -assertCondition(!JSON.stringify(syncDryRun).includes(forbiddenPathLiteral), "skills sync report must not propagate misspelled path literal"); - -const redactionProbe = collectSkillAvailability({ - source: fixtureSource, - target: fixtureMissingTarget, - requiredSkills: ["docs-spec", "cli-spec"], -}); -assertCondition(!JSON.stringify(redactionProbe).includes("ghp_"), "skill report must not include token-like values", redactionProbe); -assertCondition(!JSON.stringify(redactionProbe).includes("github_pat_"), "skill report must not include GitHub PAT-like values", redactionProbe); - -const missingTargetSync = collectSkillSyncPreflight({ target: "/path/that/does/not/exist/for-code-queue-skills-test" }); -assertCondition(missingTargetSync.blocker === "unapproved-target", "non-default target must be rejected as unapproved", missingTargetSync); - -const typoTargetSync = collectSkillSyncPreflight({ target: forbiddenTargetPath }); -assertCondition(typoTargetSync.blocker === "forbidden-skills-path-configured", "misspelled sync target must be rejected as a typo before generic target approval", typoTargetSync); -assertCondition(asRecord(typoTargetSync.pathSpelling, "typoTargetSync.pathSpelling").forbiddenPathConfigured === true, "misspelled sync target should mark configured typo", typoTargetSync.pathSpelling); -assertCondition(JSON.stringify(asRecord(typoTargetSync.pathSpelling, "typoTargetSync.pathSpelling").forbiddenPathRoles).includes("target"), "misspelled sync target should classify target role", typoTargetSync.pathSpelling); -assertCondition(typoTargetSync.valuesPrinted === false, "misspelled sync target must declare valuesPrinted=false"); - -const typoSourceSync = collectSkillSyncPreflight({ source: forbiddenSourcePath }); -assertCondition(typoSourceSync.blocker === "forbidden-skills-path-configured", "misspelled sync source must be rejected as a typo before generic source approval", typoSourceSync); -assertCondition(asRecord(typoSourceSync.pathSpelling, "typoSourceSync.pathSpelling").forbiddenPathConfigured === true, "misspelled sync source should mark configured typo", typoSourceSync.pathSpelling); -assertCondition(JSON.stringify(asRecord(typoSourceSync.pathSpelling, "typoSourceSync.pathSpelling").forbiddenPathRoles).includes("source"), "misspelled sync source should classify source role", typoSourceSync.pathSpelling); - -assertCondition(runtimePreflight.includes("skills: SkillAvailabilityReport"), "runtime preflight type must include skills report"); -assertCondition(runtimePreflight.includes("skillsSync: SkillSyncPreflightReport"), "runtime preflight type must include skills sync report"); -assertCondition(runtimePreflight.includes("collectSkillAvailability"), "runtime preflight must collect skills availability"); -assertCondition(runtimePreflight.includes("collectSkillSyncPreflight"), "runtime preflight must collect skills sync preflight"); -assertCondition(runtimePreflight.includes("skills.contractOk && ports.codex.ok"), "runtime preflight ok must depend on the read-only target projection contract"); -assertCondition(indexSource.includes("skills.contractOk === true"), "dev-ready must gate on structured target projection contract"); -assertCondition(indexSource.includes("resolvedRunnerSkillsPath"), "runtime must pass resolved skills path to code agents"); -assertCondition(indexSource.includes("runnerSkillsBlocker"), "scheduler must check skills before starting code agents"); -assertCondition(indexSource.includes("task_blocked_by_runner_skills"), "scheduler must emit structured runner skills blockers"); -assertCondition(indexSource.includes("runnerDisposition: \"infra-blocked\""), "runner skills blocker must classify infra-blocked"); -assertCondition(indexSource.includes("collectSkillsSyncPreflight"), "runtime index must expose skills sync preflight"); -assertCondition(indexSource.includes("/api/skills-sync"), "runtime must expose a dry-run skills sync endpoint"); -assertCondition(indexSource.includes("pass dryRun=1"), "skills sync endpoint must reject non-dry-run calls"); -assertCondition(codeQueueCli.includes("failureKind: \"dry-run-required\""), "codex skills-sync CLI must require --dry-run with structured output"); -assertCondition(codeQueueCli.includes("codex skills-sync is dry-run only; pass --dry-run"), "codex skills-sync CLI must explain the dry-run requirement"); -assertCondition(codeQueueCli.includes("Code Queue skills sync dry-run could not reach the control plane"), "codex skills-sync CLI must return structured control-plane failure output"); -assertCondition(codeQueueCli.includes("compact-skills-sync-control-plane-failure"), "codex skills-sync CLI must keep control-plane failure output compact"); -assertCondition(codeQueueCli.includes("compactSkillsSyncStatus"), "codex CLI must compact skills sync output"); -assertCondition(codeQueueCli.includes("runner-skills-blocker"), "codex preflight must classify skill lifecycle blockers"); -assertCondition(codeQueueCli.includes("forbiddenPathConfigured"), "codex CLI must preserve configured typo classification in compact output"); -assertCondition(microserviceCli.includes("compactSkillSync"), "microservice health summary must compact skills sync output"); -assertCondition(microserviceCli.includes("forbiddenPathConfigured"), "microservice health summary must preserve configured typo classification"); -assertCondition(helpSource.includes("codex skills-sync --dry-run"), "CLI help must document the skills sync dry-run command"); -assertCondition(docsReference.includes("codex skills-sync --dry-run"), "reference docs must document the skills sync dry-run command"); -assertCondition(docsReference.includes("forbidden-skills-path-configured"), "reference docs must document configured typo blocker"); - -const skillsPreflightTransport = { - config: null, - coreFetch: () => ({ - ok: true, - status: 200, - body: { - runtimePreflight: { - ok: false, - checkedAt: "2026-05-23T00:00:00.000Z", - cwd: "/workspace/unidesk", - pid: 601, - skills: missing, - skillsSync: syncDryRun, - ports: {}, - pullRequestDelivery: { - ok: true, - checkedAt: "2026-05-23T00:00:00.000Z", - tools: {}, - unideskGhCli: { ok: true, path: "/workspace/unidesk/scripts/cli.ts", present: true }, - authBroker: { ok: true, configured: true, source: "auth-broker" }, - credentials: { - ghTokenPresent: false, - githubTokenPresent: false, - ghHostsConfigPresent: false, - gitCredentialsPresent: false, - }, - git: { - insideWorktree: true, - branch: "code-queue/issue-68-runner-skills-lifecycle", - head: "abc1234", - originMaster: "def5678", - remoteOrigin: "git@github.com:pikasTech/unidesk.git", - home: "/root", - homeWritable: true, - knownHostsPresent: true, - privateKeyPresent: true, - }, - githubContext: { - host: "github.com", - apiBaseUrl: "https://api.github.com", - repo: "pikasTech/unidesk", - issueProbeNumber: 68, - }, - egress: { proxy: {} }, - remote: null, - limitations: [], - risks: [], - }, - }, - }, - }), -}; -const defaultPreflightSummary = asRecord(codexPrPreflightQueryForTest(["--remote"], skillsPreflightTransport), "default preflight summary"); -assertCondition(defaultPreflightSummary.failureKind === "runner-skills-blocker", "missing target should classify as runner skills blocker even when source exists", defaultPreflightSummary); -assertCondition(asRecord(defaultPreflightSummary.skillsContract, "defaultPreflightSummary.skillsContract").hostRolloutRequired === true, "default preflight should expose host rollout blocker separately", defaultPreflightSummary); -assertCondition(asRecord(defaultPreflightSummary.skillsContract, "defaultPreflightSummary.skillsContract").source === missing.source, "default preflight should expose skills source in the bounded contract", defaultPreflightSummary.skillsContract); -assertCondition(asRecord(defaultPreflightSummary.skillsContract, "defaultPreflightSummary.skillsContract").target === missing.target, "default preflight should expose skills target in the bounded contract", defaultPreflightSummary.skillsContract); -assertCondition(Array.isArray(asRecord(defaultPreflightSummary.skillsContract, "defaultPreflightSummary.skillsContract").requiredSkills), "default preflight should expose requiredSkills in the bounded contract", defaultPreflightSummary.skillsContract); -assertCondition(Array.isArray(asRecord(defaultPreflightSummary.skillsContract, "defaultPreflightSummary.skillsContract").missingSkills), "default preflight should expose missingSkills in the bounded contract", defaultPreflightSummary.skillsContract); -assertCondition(asRecord(defaultPreflightSummary.skillsContract, "defaultPreflightSummary.skillsContract").repairHint !== null, "default preflight should expose repairHint in the bounded contract", defaultPreflightSummary.skillsContract); -assertCondition(asRecord(defaultPreflightSummary.skillsContract, "defaultPreflightSummary.skillsContract").valuesPrinted === false, "default preflight skills contract must declare valuesPrinted=false", defaultPreflightSummary.skillsContract); -assertCondition(defaultPreflightSummary.preflight === undefined, "default PR preflight should omit detailed preflight internals", defaultPreflightSummary); -assertCondition(asRecord(defaultPreflightSummary.disclosure, "defaultPreflightSummary.disclosure").fullDetailOmitted === true, "default PR preflight should disclose full detail omission", defaultPreflightSummary.disclosure); -assertCondition(String(asRecord(defaultPreflightSummary.disclosure, "defaultPreflightSummary.disclosure").expandWith ?? "").includes("--full"), "default PR preflight should point to --full expansion", defaultPreflightSummary.disclosure); - -const preflightSummary = asRecord(codexPrPreflightQueryForTest(["--remote", "--full"], skillsPreflightTransport), "full preflight summary"); -const preflight = asRecord(preflightSummary.preflight, "preflight"); -const preflightSkills = asRecord(preflight.skills, "preflight.skills"); -const preflightSkillsSync = asRecord(preflight.skillsSync, "preflight.skillsSync"); -assertCondition(preflightSummary.failureKind === "runner-skills-blocker", "full preflight should classify missing target as runner blocker", preflightSummary); -assertCondition(asRecord(preflightSummary.skillsContract, "preflightSummary.skillsContract").degradedReason === "skills-target-missing", "full preflight should expose target missing as contract degraded reason", preflightSummary); -assertCondition(preflightSkills.target === fixtureArbitraryTarget, "full preflight must show skills target", preflightSkills); -assertCondition(preflightSkills.resolvedPath === fixtureArbitraryTarget, "full preflight must keep resolved path at the target", preflightSkills); -assertCondition(preflightSkills.resolvedPathSource === "missing", "full preflight must not show source fallback resolution", preflightSkills); -assertCondition(preflightSkillsSync.dryRun === true && preflightSkillsSync.mutation === false, "full preflight must show non-mutating skills sync dry-run", preflightSkillsSync); -assertCondition(asRecord(preflightSkillsSync.counts, "preflight.skillsSync.counts").missingTargetSkills === 2, "full preflight must show missing target count", preflightSkillsSync); -assertCondition(asRecord(preflightSkillsSync.plannedActions, "preflight.skillsSync.plannedActions").copy === false, "full preflight must show no copy action", preflightSkillsSync); -assertCondition(preflightSkillsSync.valuesPrinted === false, "full preflight skills sync must declare valuesPrinted=false", preflightSkillsSync); -assertCondition(!JSON.stringify(preflightSkillsSync).includes(forbiddenPathLiteral), "full preflight must not propagate misspelled path literal"); - -const legacyRuntimeSkills = { - ok: false, - runnerUsable: false, - contractOk: false, - path: "/root/.agents/skills", - resolvedPath: "/root/.agents/skills", - resolvedPathSource: null, - resolution: null, - source: "/home/ubuntu/.agents/skills", - target: "/root/.agents/skills", - exists: false, - available: false, - degraded: true, - blocker: "skills-target-missing", - degradedReason: "skills-target-missing", - readonly: false, - skillCount: 0, - requiredSkills: ["docs-spec", "cli-spec", "frontend-design", "playwright-cli"], - missingSkills: ["docs-spec", "cli-spec", "frontend-design", "playwright-cli"], - valuesPrinted: false, - pathSpelling: { - expectedTarget: "/root/.agents/skills", - forbiddenPathChecked: true, - forbiddenPathExists: false, - forbiddenPathConfigured: false, - forbiddenPathRoles: [], - forbiddenPathMustNotBeUsed: true, - }, - repairHint: "Mount /home/ubuntu/.agents/skills read-only at /root/.agents/skills, set UNIDESK_SKILLS_PATH=/root/.agents/skills, and remove any forbidden skills path spelling.", -}; -const legacyRuntimeSkillsSync = { - ok: false, - degraded: true, - blocker: "skills-target-missing", - checkedAt: "2026-05-23T00:00:00.000Z", - mode: "dry-run", - dryRun: true, - mutation: false, - syncMode: "hostPath-read-only-projection", - source: { - path: "/home/ubuntu/.agents/skills", - approved: true, - exists: true, - directory: true, - readable: true, - writable: true, - readonly: false, - mountPoint: "/home/ubuntu", - symlink: false, - realPath: null, - skillCount: 49, - version: null, - requiredSkills: ["docs-spec", "cli-spec", "frontend-design", "playwright-cli"], - missingSkills: [], - error: null, - }, - target: { - path: "/root/.agents/skills", - approved: true, - exists: false, - directory: false, - readable: false, - writable: false, - readonly: false, - mountPoint: "/", - symlink: false, - realPath: null, - skillCount: 0, - version: null, - requiredSkills: ["docs-spec", "cli-spec", "frontend-design", "playwright-cli"], - missingSkills: ["docs-spec", "cli-spec", "frontend-design", "playwright-cli"], - error: null, - }, - expected: { - source: "/home/ubuntu/.agents/skills", - target: "/root/.agents/skills", - env: "UNIDESK_SKILLS_PATH", - envValue: "/root/.agents/skills", - mount: "/home/ubuntu/.agents/skills mounted read-only to /root/.agents/skills", - requiredSkills: ["docs-spec", "cli-spec", "frontend-design", "playwright-cli"], - }, - counts: { - sourceSkills: 49, - targetSkills: 0, - requiredSkills: 4, - missingSourceSkills: 0, - missingTargetSkills: 4, - }, - version: null, - missing: { - sourceSkills: [], - targetSkills: ["docs-spec", "cli-spec", "frontend-design", "playwright-cli"], - }, - permissionFailures: [], - pathSpelling: { - expectedTarget: "/root/.agents/skills", - forbiddenPathChecked: true, - forbiddenPathExists: false, - forbiddenPathConfigured: false, - forbiddenPathRoles: [], - forbiddenPathMustNotBeUsed: true, - }, - plannedActions: { - copy: false, - writesSource: false, - writesTarget: false, - restartRequired: false, - readsSecrets: false, - copyFromArbitraryPath: false, - }, - commands: { - dryRun: "bun scripts/cli.ts codex skills-sync --dry-run", - full: "bun scripts/cli.ts codex skills-sync --dry-run --full", - health: "bun scripts/cli.ts microservice health code-queue", - runtimePreflight: "bun scripts/cli.ts codex pr-preflight --remote", - contractTest: "bun scripts/code-queue-runner-skills-contract-test.ts", - }, - valuesPrinted: false, -}; -const legacyRuntimePreflightTransport = { - config: null, - coreFetch: () => ({ - ok: true, - status: 200, - body: { - runtimePreflight: { - ok: false, - checkedAt: "2026-05-23T00:00:00.000Z", - cwd: "/workspace/unidesk", - pid: 601, - skills: legacyRuntimeSkills, - skillsSync: legacyRuntimeSkillsSync, - ports: {}, - pullRequestDelivery: { - ok: true, - checkedAt: "2026-05-23T00:00:00.000Z", - tools: {}, - unideskGhCli: { ok: true, path: "/workspace/unidesk/scripts/cli.ts", present: true }, - authBroker: { ok: true, configured: true, source: "auth-broker" }, - credentials: { - ghTokenPresent: true, - githubTokenPresent: false, - ghHostsConfigPresent: false, - gitCredentialsPresent: false, - }, - git: { - insideWorktree: true, - branch: "code-queue/issue-68-runner-skills-lifecycle", - head: "abc1234", - originMaster: "def5678", - remoteOrigin: "git@github.com:pikasTech/unidesk.git", - home: "/root", - homeWritable: true, - knownHostsPresent: true, - privateKeyPresent: true, - }, - githubContext: { - host: "github.com", - apiBaseUrl: "https://api.github.com", - repo: "pikasTech/unidesk", - issueProbeNumber: 68, - }, - egress: { proxy: {} }, - remote: null, - limitations: [], - risks: [], - }, - }, - }, - }), -}; -const legacyDefaultPreflight = asRecord(codexPrPreflightQueryForTest(["--remote"], legacyRuntimePreflightTransport), "legacy default preflight"); -const legacySkillsContract = asRecord(legacyDefaultPreflight.skillsContract, "legacyDefaultPreflight.skillsContract"); -assertCondition(legacyDefaultPreflight.failureKind === "runner-skills-blocker", "legacy runtime shape should still classify missing target as runner skills blocker", legacyDefaultPreflight); -assertCondition(legacySkillsContract.source === "/home/ubuntu/.agents/skills", "legacy runtime shape should expose source path from skillsSync", legacySkillsContract); -assertCondition(legacySkillsContract.target === "/root/.agents/skills", "legacy runtime shape should expose target path from skillsSync", legacySkillsContract); -assertCondition(legacySkillsContract.hostRolloutRequired === true, "legacy runtime shape with source available and target missing should require host rollout", legacySkillsContract); -assertCondition(legacySkillsContract.degradedReason === "skills-target-missing", "legacy runtime shape should keep actionable degraded reason", legacySkillsContract); -assertCondition(Array.isArray(legacySkillsContract.requiredSkills) && legacySkillsContract.requiredSkills.includes("docs-spec"), "legacy runtime shape should expose requiredSkills", legacySkillsContract); -assertCondition(Array.isArray(legacySkillsContract.missingSkills) && legacySkillsContract.missingSkills.includes("docs-spec"), "legacy runtime shape should expose missingSkills", legacySkillsContract); -assertCondition(legacySkillsContract.sourceSkillCount === 49 && legacySkillsContract.targetSkillCount === 0, "legacy runtime shape should expose source/target skill counts", legacySkillsContract); -assertCondition(legacySkillsContract.repairHint !== null, "legacy runtime shape should expose repairHint", legacySkillsContract); -assertCondition(legacySkillsContract.valuesPrinted === false, "legacy runtime shape contract must declare valuesPrinted=false", legacySkillsContract); - -const typoPreflightTransport = { - config: null, - coreFetch: () => ({ - ok: true, - status: 200, - body: { - runtimePreflight: { - ok: false, - checkedAt: "2026-05-23T00:00:00.000Z", - cwd: "/workspace/unidesk", - pid: 601, - skills: typoTarget, - skillsSync: typoTargetSync, - ports: {}, - pullRequestDelivery: { - ok: true, - checkedAt: "2026-05-23T00:00:00.000Z", - tools: {}, - unideskGhCli: { ok: true, path: "/workspace/unidesk/scripts/cli.ts", present: true }, - authBroker: { ok: true, configured: true, source: "auth-broker" }, - credentials: { - ghTokenPresent: true, - githubTokenPresent: false, - ghHostsConfigPresent: false, - gitCredentialsPresent: false, - }, - git: { - insideWorktree: true, - branch: "code-queue/issue-68-runner-skills-lifecycle", - head: "abc1234", - originMaster: "def5678", - remoteOrigin: "git@github.com:pikasTech/unidesk.git", - home: "/root", - homeWritable: true, - knownHostsPresent: true, - privateKeyPresent: true, - }, - githubContext: { - host: "github.com", - apiBaseUrl: "https://api.github.com", - repo: "pikasTech/unidesk", - issueProbeNumber: 68, - }, - egress: { proxy: {} }, - remote: null, - limitations: [], - risks: [], - }, - }, - }, - }), -}; -const typoPreflightSummary = asRecord(codexPrPreflightQueryForTest(["--remote"], typoPreflightTransport), "typo preflight summary"); -assertCondition(typoPreflightSummary.failureKind === "runner-skills-blocker", "typo preflight should classify configured typo as runner skills blocker", typoPreflightSummary); -assertCondition(typoPreflightSummary.degradedReason === "forbidden-skills-path-configured", "typo preflight degraded reason should preserve configured typo blocker", typoPreflightSummary); -assertCondition(typoPreflightSummary.preflight === undefined, "typo default preflight should remain bounded", typoPreflightSummary); -const typoFullPreflightSummary = asRecord(codexPrPreflightQueryForTest(["--remote", "--full"], typoPreflightTransport), "typo full preflight summary"); -const typoFullPreflight = asRecord(typoFullPreflightSummary.preflight, "typoFullPreflightSummary.preflight"); -const typoPreflightSkills = asRecord(typoFullPreflight.skills, "typoFullPreflight.skills"); -const typoPreflightPathSpelling = asRecord(typoPreflightSkills.pathSpelling, "typoFullPreflight.skills.pathSpelling"); -assertCondition(typoPreflightPathSpelling.forbiddenPathConfigured === true, "typo full preflight output must expose configured typo classification", typoPreflightPathSpelling); - -const healthSummary = asRecord(summarizeMicroserviceObservation("health", "code-queue", { - ok: true, - status: 200, - body: { - ok: false, - service: "code-queue", - skills: missing, - skillsSync: syncDryRun, - }, -}, []), "microservice health summary"); -const microservice = asRecord(healthSummary.microservice, "microservice"); -const healthCompact = asRecord(microservice.summary, "microservice.summary"); -const healthSkills = asRecord(healthCompact.skills, "microservice.summary.skills"); -const healthSkillsSync = asRecord(healthCompact.skillsSync, "microservice.summary.skillsSync"); -assertCondition(healthSkills.target === fixtureArbitraryTarget, "compact health must show skills target", healthSkills); -assertCondition(healthSkillsSync.dryRun === true && healthSkillsSync.mutation === false, "compact health must show dry-run skills sync", healthSkillsSync); -assertCondition(asRecord(healthSkillsSync.counts, "microservice.summary.skillsSync.counts").missingTargetSkills === 2, "compact health must show missing target count", healthSkillsSync); -assertCondition(asRecord(healthSkillsSync.plannedActions, "microservice.summary.skillsSync.plannedActions").copyFromArbitraryPath === false, "compact health must show arbitrary copy is blocked", healthSkillsSync); -assertCondition(!JSON.stringify(healthSkillsSync).includes(forbiddenPathLiteral), "compact health must not propagate misspelled path literal"); - -const typoHealthSummary = asRecord(summarizeMicroserviceObservation("health", "code-queue", { - ok: true, - status: 200, - body: { - ok: false, - service: "code-queue", - skills: typoTarget, - skillsSync: typoTargetSync, - }, -}, []), "microservice typo health summary"); -const typoHealthSkills = asRecord(asRecord(asRecord(typoHealthSummary.microservice, "typoHealthSummary.microservice").summary, "typoHealthSummary.microservice.summary").skills, "typoHealthSummary.skills"); -const typoHealthPathSpelling = asRecord(typoHealthSkills.pathSpelling, "typoHealthSummary.skills.pathSpelling"); -assertCondition(typoHealthSkills.blocker === "forbidden-skills-path-configured", "compact health must preserve configured typo blocker", typoHealthSkills); -assertCondition(typoHealthPathSpelling.forbiddenPathConfigured === true, "compact health must expose configured typo classification", typoHealthPathSpelling); - -process.stdout.write(`${JSON.stringify({ - ok: true, - checks: [ - "production Code Queue mounts /home/ubuntu/.agents/skills read-only at /root/.agents/skills", - "provider dev containers bind /home/ubuntu/.agents/skills read-only at /root/.agents/skills and pass UNIDESK_SKILLS_PATH to Codex/OpenCode", - "Code Queue image and provider dev containers expose hwpod as the short device-pod-cli alias without binding it to a specific pod", - "deploy.json dev Code Queue pins a commit whose manifest and runtime require the target skills projection", - "skill availability report exposes source, target, requiredSkills, missingSkills, version fingerprint/mtime, degraded/blocker and valuesPrinted=false", - "skills sync dry-run reports source, target, counts, version fingerprint/mtime, missing skills, permission failures, instructions and no-copy actions", - "scheduler blocks runner startup with structured infra-blocked output when required skills are unavailable", - "runtime-preflight, dev-ready, health and PR preflight require the target projection instead of source fallback", - "default health/preflight summaries expose bounded skills lifecycle evidence and --full expansion", - "misspelled skills paths are rejected with forbidden-skills-path-configured before generic missing/unapproved path blockers", - ], - observedRunner: { - source: available.source, - target: available.target, - ok: available.ok, - missingSkills: available.missingSkills, - syncDryRunOk: syncDryRun.ok, - syncDryRunBlocker: syncDryRun.blocker, - valuesPrinted: available.valuesPrinted, - }, -}, null, 2)}\n`); diff --git a/scripts/code-queue-steer-confirmation-contract-test.ts b/scripts/code-queue-steer-confirmation-contract-test.ts deleted file mode 100644 index e6b1780a..00000000 --- a/scripts/code-queue-steer-confirmation-contract-test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { findSteerTraceConfirmation, steerDuplicateDecision, steerTraceText } from "../src/components/microservices/code-queue/src/steer-confirmation"; -import type { QueueTask } from "../src/components/microservices/code-queue/src/types"; - -type JsonRecord = Record; - -function assertCondition(condition: unknown, message: string, detail: JsonRecord = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function fixtureTask(): QueueTask { - const at = "2026-05-23T00:00:00.000Z"; - return { - id: "codex_steer_confirm_fixture", - queueId: "default", - queueEnteredAt: at, - prompt: "base", - basePrompt: "base", - referenceTaskIds: [], - referenceInjection: null, - providerId: "D601", - cwd: "/workspace", - model: "gpt-5.5", - reasoningEffort: null, - executionMode: "default", - maxAttempts: 99, - status: "running", - createdAt: at, - updatedAt: at, - startedAt: at, - finishedAt: null, - readAt: null, - currentAttempt: 1, - currentMode: "initial", - codexThreadId: "thread_fixture", - activeTurnId: "turn_fixture", - finalResponse: "", - lastError: null, - lastJudge: null, - judgeFailCount: 0, - promptHistory: [], - output: [], - events: [], - attempts: [], - cancelRequested: false, - nextPrompt: null, - nextMode: null, - }; -} - -export function runCodeQueueSteerConfirmationContract(): JsonRecord { - const task = fixtureTask(); - const steerId = "steer_contract_12345"; - const prompt = "correct this running task"; - task.output.push({ seq: 7, at: "2026-05-23T00:00:07.000Z", channel: "user", method: "turn/steer", itemId: steerId, text: steerTraceText(steerId, prompt) }); - task.promptHistory.push({ seq: 7, at: "2026-05-23T00:00:07.000Z", method: "turn/steer", text: prompt, steerId }); - - const confirmation = findSteerTraceConfirmation(task, steerId); - assertCondition(confirmation.found === true && confirmation.accepted === true, "confirmation should find steer trace by steerId", confirmation as unknown as JsonRecord); - assertCondition(confirmation.matches.length === 1, "confirmation should coalesce promptHistory/output duplicates by seq", confirmation as unknown as JsonRecord); - assertCondition(confirmation.trace?.promptChars === prompt.length, "confirmation should expose prompt chars without prompt text", (confirmation.trace ?? {}) as unknown as JsonRecord); - assertCondition(JSON.stringify(confirmation).includes(prompt) === false, "confirmation must not echo prompt text", confirmation as unknown as JsonRecord); - - const duplicate = steerDuplicateDecision(task, steerId, prompt); - assertCondition(duplicate.duplicate === true && duplicate.conflict === false, "same steerId and prompt should be duplicate-suppressed", duplicate as unknown as JsonRecord); - - const conflict = steerDuplicateDecision(task, steerId, "different prompt"); - assertCondition(conflict.duplicate === false && conflict.conflict === true, "same steerId with different prompt should be rejected as conflict", conflict as unknown as JsonRecord); - - const missing = findSteerTraceConfirmation(task, "steer_missing_12345"); - assertCondition(missing.found === false && missing.deliveryState === "unknown", "missing steerId should remain unknown", missing as unknown as JsonRecord); - - return { - ok: true, - checks: [ - "trace confirmation finds steer by steerId", - "promptHistory/output duplicate seq is coalesced", - "duplicate suppression requires same prompt hash", - "steerId conflict is detectable", - "missing steerId returns unknown", - ], - }; -} - -if (import.meta.main) { - process.stdout.write(`${JSON.stringify(runCodeQueueSteerConfirmationContract(), null, 2)}\n`); -} diff --git a/scripts/code-queue-submit-execution-mode-contract-test.ts b/scripts/code-queue-submit-execution-mode-contract-test.ts deleted file mode 100644 index 5a55268c..00000000 --- a/scripts/code-queue-submit-execution-mode-contract-test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { - normalizeCodeExecutionMode, - normalizeRequestedCodeExecutionMode, - requestedCodeExecutionModeIsRecognized, -} from "../src/components/microservices/code-queue/src/code-agent/common"; -import { compactSubmitSuccessResponseForTest } from "./src/code-queue"; - -type JsonRecord = Record; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function runCli(args: string[]): { status: number | null; stdout: string; stderr: string; json: JsonRecord | null } { - const result = spawnSync("bun", ["scripts/cli.ts", ...args], { - cwd: process.cwd(), - encoding: "utf8", - }); - const stdout = String(result.stdout || ""); - let json: JsonRecord | null = null; - try { - json = JSON.parse(stdout) as JsonRecord; - } catch { - json = null; - } - return { - status: result.status, - stdout, - stderr: String(result.stderr || ""), - json, - }; -} - -function nestedRecord(value: unknown, path: string[]): JsonRecord { - let current: unknown = value; - for (const key of path) { - assertCondition(current !== null && typeof current === "object" && !Array.isArray(current), "expected object while traversing JSON", { path, key, current }); - current = (current as JsonRecord)[key]; - } - assertCondition(current !== null && typeof current === "object" && !Array.isArray(current), "expected nested object", { path, current }); - return current as JsonRecord; -} - -function asArray(value: unknown): unknown[] { - assertCondition(Array.isArray(value), "expected JSON array", { value }); - return value as unknown[]; -} - -function assertSecretFree(output: string): void { - const forbidden = ["GH_TOKEN=", "GITHUB_TOKEN=", "OPENAI_API_KEY=", "CRS_OAI_KEY=", "DEEPSEEK_API_KEY=", "MINIMAX_API_KEY="]; - for (const needle of forbidden) { - assertCondition(!output.includes(needle), "submit execution-mode contract must not print credential assignments", { needle }); - } -} - -function assertLegacyFrozenWrite(result: { status: number | null; stdout: string; stderr: string; json: JsonRecord | null }, command: string): void { - assertCondition(result.status !== 0 && result.json?.ok === false, `${command} should be frozen`, result.json ?? { stdout: result.stdout, stderr: result.stderr }); - const data = nestedRecord(result.json?.data, []); - assertCondition(data.ok === false, `${command} frozen payload should be ok=false`, data); - assertCondition(data.frozen === true, `${command} frozen payload should expose frozen=true`, data); - assertCondition(data.mutation === false, `${command} frozen payload should be non-mutating`, data); - assertCondition(data.degradedReason === "legacy-code-queue-frozen", `${command} should use the legacy frozen reason`, data); - const replacement = nestedRecord(data, ["replacement"]); - assertCondition(String(replacement.queueSubmit || "").includes("agentrun queue submit"), `${command} should point to AgentRun queue submit`, replacement); - const legacy = nestedRecord(data, ["legacy"]); - assertCondition(legacy.noDoubleWrite === true, `${command} should document no double-write`, legacy); -} - -export function runCodeQueueSubmitExecutionModeContract(): JsonRecord { - assertCondition(normalizeRequestedCodeExecutionMode("full-access") === "full-access", "shared parser should preserve short requested mode ids"); - assertCondition(normalizeCodeExecutionMode("full-access") === "default", "shared execution-mode normalizer should keep full-access on effective default"); - assertCondition(requestedCodeExecutionModeIsRecognized("full-access") === false, "shared recognition helper should reject full-access as a runtime mode"); - assertCondition(requestedCodeExecutionModeIsRecognized("default") === true, "shared recognition helper should accept default mode"); - - const defaultMode = runCli(["codex", "submit", "execution mode default smoke", "--dry-run"]); - assertLegacyFrozenWrite(defaultMode, "codex submit"); - assertSecretFree(defaultMode.stdout); - - const fullAccess = runCli(["codex", "submit", "execution mode full access smoke", "--execution-mode", "full-access", "--dry-run"]); - assertLegacyFrozenWrite(fullAccess, "codex submit"); - assertSecretFree(fullAccess.stdout); - - const promptText = "submitted full-access prompt body must stay omitted"; - const submitted = compactSubmitSuccessResponseForTest({ - tasks: [{ - id: "codex_exec_mode_contract", - queueId: "commander-efficiency", - status: "queued", - providerId: "D601", - model: "gpt-5.5", - cwd: "/workspace", - prompt: promptText, - executionMode: "default", - requestedExecutionMode: "full-access", - maxAttempts: 99, - createdAt: "2026-05-23T00:00:00.000Z", - updatedAt: "2026-05-23T00:00:00.000Z", - }], - queue: { - total: 1, - queueCount: 1, - counts: { queued: 1 }, - queuedTaskIds: ["codex_exec_mode_contract"], - runnerPermissions: { - observed: true, - scope: "code-queue-service-config", - sandbox: "danger-full-access", - approvalPolicy: "never", - perTaskOverrideSupported: false, - secretsPrinted: false, - }, - }, - }, { ok: true, status: 200 }, { mode: "local-atomic-directory-submit-serialization", acquiredAfterMs: 1, heldMs: 2, throttleMs: 2000 }); - const submittedExecutionMode = nestedRecord(submitted, ["executionMode"]); - const submittedPermissions = nestedRecord(submitted, ["runnerPermissions"]); - const firstTask = nestedRecord(asArray(nestedRecord(submitted, ["submitted"]).tasks)[0], []); - const taskExecutionMode = nestedRecord(firstTask, ["executionModeRequest"]); - const queuePermissions = nestedRecord(submitted, ["queue", "runnerPermissions"]); - const submittedJson = JSON.stringify(submitted); - assertCondition(submittedExecutionMode.requested === "full-access" && submittedExecutionMode.effective === "default", "real submit summary should show requested/effective mode", submittedExecutionMode); - assertCondition(submittedPermissions.observed === true && submittedPermissions.sandbox === "danger-full-access" && submittedPermissions.approvalPolicy === "never", "real submit summary should expose observed service-level runner permissions", submittedPermissions); - assertCondition(submittedPermissions.perTaskOverrideSupported === false, "real submit summary should not imply per-task sandbox override", submittedPermissions); - assertCondition(firstTask.requestedExecutionMode === "full-access" && firstTask.executionMode === "default", "submitted task should carry requested and effective mode", firstTask); - assertCondition(taskExecutionMode.warning === submittedExecutionMode.warning, "task-level execution mode summary should match top-level warning", { taskExecutionMode, submittedExecutionMode }); - assertCondition(queuePermissions.sandbox === "danger-full-access", "queue summary should keep runner permissions visible", queuePermissions); - assertCondition(!submittedJson.includes(promptText), "real submit summary must keep prompt text omitted", submitted); - assertCondition(!submittedJson.includes("promptPreview"), "real submit summary must not reintroduce promptPreview", submitted); - - return { - ok: true, - checks: [ - "legacy codex submit dry-run is frozen and points to AgentRun", - "legacy --execution-mode full-access submit dry-run is frozen without printing credentials", - "real submit summary fixture exposes requested/effective mode plus observed runnerPermissions without prompt echo", - "shared execution-mode helpers preserve requested full-access while normalizing effective runtime to default", - "execution-mode frozen output does not print credential assignments", - ], - }; -} - -if (import.meta.main) { - process.stdout.write(`${JSON.stringify(runCodeQueueSubmitExecutionModeContract(), null, 2)}\n`); -} diff --git a/scripts/code-queue-submit-routing-contract-test.ts b/scripts/code-queue-submit-routing-contract-test.ts deleted file mode 100644 index 2c4a4e67..00000000 --- a/scripts/code-queue-submit-routing-contract-test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { codeModelProviderSourceContract } from "../src/components/microservices/code-queue/src/code-agent/common"; -import { codexSubmitModelRegistryForTest, codexSubmitRoutingRecommendationForTest } from "./src/code-queue"; - -type JsonRecord = Record; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function asRecord(value: unknown): JsonRecord { - assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), "expected JSON object", { value }); - return value as JsonRecord; -} - -const lowRiskPrompt = ` -目标:更新 docs/reference/code-queue-supervision.md 中的 MiniMax 派单规则。 -范围:只改中文长期文档和一个轻量 dry-run contract test,不触碰 runtime 调度核心。 -禁止:不要重启服务,不要读取密钥,不要写数据库,不要部署 prod。 -验证:运行 bun scripts/code-queue-submit-routing-contract-test.ts,并在 final response 给出验证证据、commit 和风险。 -背景:本 prompt 是完整需求来源,GitHub issue 只能作为辅助引用,不能作为唯一来源。需要 dry-run/preflight 输出帮助指挥官判断 runner/model。请保持改动低风险、可审阅、可回滚,并让指挥官完成后审阅未读任务。 -`; - -const runtimePrompt = ` -目标:修复 Code Queue runtime scheduler 的 active run 状态机。 -范围:src/components/microservices/code-queue/src/index.ts 和 runtime-preflight。 -禁止:不要部署 prod。 -验证:需要证明 scheduler heartbeat、active run、OpenCode session recovery 都正确。 -`; - -const mediumPrompt = ` -目标:实现一个前端 React 控制台组件的小功能,给 Code Queue 任务列表增加可折叠的验证证据摘要。 -范围:只改用户界面模块中的一个 TSX 组件和一个相邻的轻量 contract guard,不触碰 backend-core、Code Queue runtime、provider-gateway、k3sctl-adapter、部署配置或数据库 schema。 -禁止:不要部署 prod,不要重启服务,不要读取密钥,不要写数据库,不要修改 release/v1,不要跑 heavy check/e2e/Playwright。 -验证:运行针对该组件或 contract guard 的轻量脚本,final response 必须报告修改文件、验证命令、输出摘要、commit 和遗留风险。 -背景:本 prompt 是完整需求来源,GitHub issue 只能作为辅助引用,不能作为唯一来源。这个任务有真实代码变更和 UI 状态判断,复杂度高于只读文档,但写入边界局部、可审阅、可用轻量测试复核。 -`; - -const commanderOnlyPrompt = ` -目标:在 production 上 deploy apply 并 restart code-queue,必要时读取 secret token 和写 PostgreSQL 修复任务状态。 -验证:live health。 -`; - -export function runCodeQueueSubmitRoutingContract(): JsonRecord { - const lowRisk = codexSubmitRoutingRecommendationForTest(lowRiskPrompt); - assertCondition(lowRisk.route === "minimax-opencode", "low-risk self-contained prompt should be a MiniMax candidate", lowRisk); - assertCondition(lowRisk.recommendedRunner === "opencode", "MiniMax candidate should recommend OpenCode", lowRisk); - assertCondition(lowRisk.recommendedModel === "minimax-m3", "MiniMax candidate should recommend minimax-m3 as new default", lowRisk); - assertCondition(asRecord(lowRisk.riskControls).promptSelfContained === true, "low-risk prompt should be self-contained", lowRisk); - assertCondition(asRecord(lowRisk.riskControls).issueIsNotOnlySource === true, "issue must not be the only source", lowRisk); - - const runtime = codexSubmitRoutingRecommendationForTest(runtimePrompt); - assertCondition(runtime.route === "gpt-5.5-codex", "runtime/core work should stay on GPT-5.5", runtime); - assertCondition(runtime.recommendedRunner === "codex", "runtime/core work should recommend Codex runner", runtime); - assertCondition(runtime.recommendedModel === "gpt-5.5", "runtime/core work should recommend GPT-5.5", runtime); - - const medium = codexSubmitRoutingRecommendationForTest(mediumPrompt, "deepseek"); - assertCondition(medium.route === "deepseek-opencode", "medium bounded frontend work should recommend DeepSeek/OpenCode", medium); - assertCondition(medium.recommendedRunner === "opencode", "DeepSeek work should use OpenCode runner", medium); - assertCondition(medium.recommendedModel === "deepseek-chat", "DeepSeek candidate should recommend deepseek-chat", medium); - assertCondition(asRecord(medium.riskControls).mediumComplexityCandidate === true, "medium prompt should satisfy medium complexity controls", medium); - assertCondition(asRecord(medium.explicitRequest).model === "deepseek-chat", "explicit deepseek alias should normalize to deepseek-chat", medium); - assertCondition(asRecord(medium.explicitRequest).runner === "opencode", "explicit deepseek alias should route to OpenCode", medium); - const policyContract = asRecord(medium.policyContract); - assertCondition(asRecord(policyContract.concurrency).gpt55Routine === 5, "policy contract should expose GPT-5.5 routine concurrency", policyContract); - assertCondition(asRecord(policyContract.concurrency).gpt55BurstMax === 10, "policy contract should expose GPT-5.5 burst concurrency", policyContract); - assertCondition(asRecord(policyContract.concurrency).minimaxSimpleMax === 10, "policy contract should expose MiniMax simple concurrency", policyContract); - assertCondition(asRecord(policyContract.concurrency).deepseekMediumDefault === 5, "policy contract should expose DeepSeek medium default concurrency", policyContract); - const modelTiers = asRecord(policyContract).modelTiers as unknown[]; - assertCondition(Array.isArray(modelTiers), "policy contract should expose model tiers", policyContract); - const deepseekTier = modelTiers.map(asRecord).find((tier) => tier.model === "deepseek-chat"); - assertCondition(deepseekTier?.runner === "opencode", "DeepSeek policy tier should use OpenCode", policyContract); - const externalProvider429 = asRecord(policyContract.externalProvider429); - assertCondition(externalProvider429.commanderAction === "wait-while-exponential-backoff-is-healthy", "429 policy should tell commander to wait on healthy backoff", policyContract); - assertCondition(Array.isArray(externalProvider429.interveneWhen), "429 policy should expose intervention conditions", policyContract); - - const registry = codexSubmitModelRegistryForTest(["gpt-5.5", "deepseek", "minimax-m3", "minimax-m2.7"]); - const modelPorts = asRecord(registry.modelPorts); - assertCondition(modelPorts["deepseek-chat"] === "opencode", "modelPorts should route deepseek-chat to OpenCode", registry); - assertCondition(modelPorts["minimax-m3"] === "opencode", "modelPorts should keep minimax-m3 on OpenCode", registry); - assertCondition(modelPorts["minimax-m2.7"] === "opencode", "modelPorts should keep minimax-m2.7 on OpenCode", registry); - assertCondition(modelPorts["gpt-5.5"] === "codex", "modelPorts should keep default GPT on Codex", registry); - assertCondition(registry.opencodeModels.includes("deepseek-chat"), "opencodeModels should include deepseek-chat", registry); - assertCondition(registry.opencodeModels.includes("minimax-m3"), "opencodeModels should include minimax-m3", registry); - assertCondition(registry.opencodeModels.includes("minimax-m2.7"), "opencodeModels should include minimax-m2.7", registry); - assertCondition(registry.codexModels.includes("gpt-5.5"), "codexModels should include default GPT", registry); - const providerSource = codeModelProviderSourceContract({ - codeModels: ["gpt-5.5", "deepseek", "minimax-m3", "minimax-m2.7"], - deepseekApiBase: "https://api.deepseek.example", - deepseekApiKey: "ds-secret-must-not-print", - deepseekModel: "deepseek-chat", - minimaxApiBase: "https://api.minimax.example", - minimaxApiKey: "", - minimaxModel: "MiniMax-M2.7", - minimaxM3Model: "MiniMax-M3", - }, { - DEEPSEEK_API_KEY: "ds-secret-must-not-print", - DEEPSEEK_API_BASE: "https://api.deepseek.example", - DEEPSEEK_MODEL: "deepseek-chat", - }); - const providerSourceJson = JSON.stringify(providerSource); - assertCondition(!providerSourceJson.includes("ds-secret-must-not-print"), "provider source contract must not print API key values", providerSource); - assertCondition(!providerSourceJson.includes("https://api.deepseek.example"), "provider source contract must not print baseURL values", providerSource); - assertCondition(asRecord(providerSource).valuesPrinted === false, "provider source contract must declare valuesPrinted=false", providerSource); - const providers = asRecord(providerSource.providers); - const deepseekProvider = asRecord(providers.deepseek); - const deepseekCredentialSource = asRecord(deepseekProvider.credentialSource); - assertCondition(deepseekProvider.runner === "opencode", "DeepSeek provider source should use OpenCode", providerSource); - assertCondition(deepseekProvider.publicModel === "deepseek-chat", "DeepSeek provider source should expose public model alias", providerSource); - assertCondition(asRecord(deepseekCredentialSource.apiKey).ref === "env:DEEPSEEK_API_KEY", "DeepSeek apiKey source should expose env ref only", providerSource); - assertCondition(asRecord(deepseekCredentialSource.apiKey).present === true, "DeepSeek apiKey presence should be true when configured", providerSource); - assertCondition(asRecord(deepseekCredentialSource.baseURL).ref === "env:DEEPSEEK_API_BASE", "DeepSeek baseURL source should expose env ref only", providerSource); - - const commanderOnly = codexSubmitRoutingRecommendationForTest(commanderOnlyPrompt); - assertCondition(commanderOnly.route === "commander-human-only", "prod restart/secrets/DB work should be commander-only", commanderOnly); - assertCondition(commanderOnly.recommendedRunner === "commander", "commander-only work should not recommend a runner", commanderOnly); - assertCondition(commanderOnly.recommendedModel === null, "commander-only work should not recommend a model", commanderOnly); - - const explicitGpt = codexSubmitRoutingRecommendationForTest(lowRiskPrompt, "gpt-5.5"); - const explicitRequest = asRecord(explicitGpt.explicitRequest); - assertCondition(explicitRequest.runner === "codex", "explicit gpt model should map to Codex", explicitGpt); - assertCondition(String(explicitRequest.note ?? "").includes("differs"), "explicit model mismatch should be visible", explicitGpt); - assertCondition(asRecord(explicitGpt.routingPolicy).doesNotChangeSubmittedPayload === true, "dry-run recommendation must not rewrite payload", explicitGpt); - - return { - ok: true, - checks: [ - "low-risk self-contained prompts recommend minimax-m3/OpenCode (new default)", - "runtime/core work recommends GPT-5.5/Codex", - "medium bounded frontend work recommends deepseek-chat/OpenCode", - "model registry maps deepseek-chat, minimax-m3, and minimax-m2.7 to OpenCode and GPT-5.5 to Codex", - "model provider source contract exposes DeepSeek refs/presence without secret values", - "dry-run policy contract exposes model-tier concurrency", - "prod/restart/secret/DB work is commander-only", - "explicit --model mismatch is visible and payload is unchanged", - ], - }; -} - -if (import.meta.main) { - process.stdout.write(`${JSON.stringify(runCodeQueueSubmitRoutingContract(), null, 2)}\n`); -} diff --git a/scripts/code-queue-submit-summary-contract-test.ts b/scripts/code-queue-submit-summary-contract-test.ts deleted file mode 100644 index 23583a1d..00000000 --- a/scripts/code-queue-submit-summary-contract-test.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { compactSubmitSuccessResponseForTest } from "./src/code-queue"; - -type JsonRecord = Record; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function asRecord(value: unknown): JsonRecord { - assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), "expected JSON object", { value }); - return value as JsonRecord; -} - -function asArray(value: unknown): unknown[] { - assertCondition(Array.isArray(value), "expected JSON array", { value }); - return value as unknown[]; -} - -function assertNoItemsArrayWhenUnavailable(value: JsonRecord, label: string): void { - assertCondition(value.idsUnavailable === true, `${label} should mark non-enumerated ids unavailable`, value); - assertCondition(!Object.prototype.hasOwnProperty.call(value, "items"), `${label} must not emit items=[] when count is nonzero but ids are unavailable`, value); - assertCondition(value.itemsOmitted === true, `${label} should explicitly mark items omitted`, value); - assertCondition(String(value.itemsMeaning || "") === "not-enumerated-in-default-submit-output", `${label} should explain empty-list semantics`, value); - assertCondition(String(value.rawCommand || "").includes("microservice proxy code-queue /api/tasks/overview"), `${label} should provide raw drill-down`, value); -} - -function task(id: string, status: string, queueId = "commander-efficiency"): JsonRecord { - return { - id, - queueId, - status, - prompt: `Focused submit summary contract for ${id}`, - displayPrompt: `Focused submit summary contract for ${id}`, - providerId: "D601", - model: "gpt-5.5", - currentAttempt: status === "queued" ? 0 : 1, - maxAttempts: 99, - createdAt: "2026-05-23T00:00:00.000Z", - updatedAt: "2026-05-23T00:00:00.000Z", - }; -} - -function manyIds(prefix: string, count: number): string[] { - return Array.from({ length: count }, (_, index) => `${prefix}-${String(index + 1).padStart(2, "0")}`); -} - -export function runCodeQueueSubmitSummaryContract(): JsonRecord { - const submittedId = "codex_submitted_queued"; - const activeIds = manyIds("codex_running", 18); - const response = compactSubmitSuccessResponseForTest({ - tasks: [task(submittedId, "queued")], - queue: { - counts: { running: 18, queued: 5, succeeded: 9 }, - activeTaskIds: [], - queuedTaskIds: { items: [], count: 5, returned: 0, truncated: true }, - databaseActiveTaskIds: activeIds, - databaseActiveTaskCount: activeIds.length, - executionDiagnostics: { - state: "healthy", - databaseActiveTaskIds: activeIds, - databaseActiveTaskCount: activeIds.length, - schedulerActiveRunSlotCount: 0, - schedulerActiveTaskIds: [], - heartbeatFreshTaskIds: activeIds, - }, - }, - }, { ok: true, status: 200 }, { mode: "local-atomic-directory-submit-serialization", acquiredAfterMs: 1, heldMs: 2, throttleMs: 2000 }); - - const data = asRecord(response); - const submitted = asRecord(data.submitted); - const submittedTasks = asArray(submitted.tasks); - const submittedTask = asRecord(submittedTasks[0]); - const taskStates = asArray(submitted.taskStates); - const submittedState = asRecord(taskStates[0]); - const queue = asRecord(data.queue); - const queuedTaskIds = asRecord(queue.queuedTaskIds); - const activeTaskIds = asRecord(queue.activeTaskIds); - const databaseActiveTaskIds = asRecord(queue.databaseActiveTaskIds); - const submittedTaskIds = asRecord(queue.submittedTaskIds); - const countContext = asRecord(queue.countContext); - const listPreviewPolicy = asRecord(queue.listPreviewPolicy); - const omittedCounts = asRecord(listPreviewPolicy.omittedCounts); - const stateDisclosure = asRecord(queue.stateDisclosure); - const activity = asRecord(queue.activity); - const responseJson = JSON.stringify(response); - - assertCondition(submittedTask.id === submittedId && submittedTask.status === "queued", "submit response should keep the newly queued task", submittedTask); - assertCondition(submittedState.id === submittedId && submittedState.status === "queued" && submittedState.state === "queued", "submitted task state should be explicit and authoritative", submittedState); - assertCondition(String(submitted.stateSource || "").includes("response.tasks"), "submitted state source should point at response.tasks", submitted); - assertCondition(asArray(submittedTaskIds.items).includes(submittedId), "submittedTaskIds should expose the just-submitted id", submittedTaskIds); - assertCondition(asArray(queuedTaskIds.items).includes(submittedId), "queuedTaskIds preview should force-include the just-submitted queued task", queuedTaskIds); - assertCondition(queuedTaskIds.count === 5 && queuedTaskIds.returned === 1 && queuedTaskIds.omitted === 4, "queuedTaskIds should preserve aggregate queued count without dumping all ids", queuedTaskIds); - assertCondition(String(queuedTaskIds.source || "").includes("submittedTaskIds"), "queuedTaskIds source should explain submitted-task fallback", queuedTaskIds); - assertCondition(String(queuedTaskIds.note || "").includes("count remains authoritative"), "queuedTaskIds should explain aggregate-count fallback", queuedTaskIds); - - assertCondition(asArray(activeTaskIds.items).length === 15, "activeTaskIds preview should stay bounded", activeTaskIds); - assertCondition(activeTaskIds.count === 18 && activeTaskIds.omitted === 3 && activeTaskIds.truncated === true, "activeTaskIds should preserve active count and truncation", activeTaskIds); - assertCondition(String(activeTaskIds.source || "").includes("databaseActiveTaskIds"), "activeTaskIds should fall back to database active ids when upstream activeTaskIds is empty", activeTaskIds); - assertCondition(databaseActiveTaskIds.count === 18 && databaseActiveTaskIds.returned === 15, "databaseActiveTaskIds preview should preserve count context", databaseActiveTaskIds); - assertCondition(countContext.running === 18 && countContext.active === 18 && countContext.databaseActive === 18, "countContext should expose accurate active counts", countContext); - assertCondition(activity.effectiveActiveTaskCount === 18, "submit queue activity should expose commander effective active count", activity); - - assertCondition(listPreviewPolicy.bounded === true && listPreviewPolicy.countsAreAuthoritative === true, "list preview policy should document bounded low-noise output", listPreviewPolicy); - assertCondition(listPreviewPolicy.truncated === true && omittedCounts.activeTaskIds === 3 && omittedCounts.queuedTaskIds === 4, "list preview policy should disclose omitted counts", listPreviewPolicy); - assertCondition(String(listPreviewPolicy.emptyItemsSemantics || "").includes("idsUnavailable=true"), "list preview policy should document nonzero-count unavailable ids", listPreviewPolicy); - assertCondition(String(stateDisclosure.submittedStatusSource || "").includes("response.tasks"), "stateDisclosure should name submitted status source", stateDisclosure); - assertCondition(String(listPreviewPolicy.note || "").includes("Low-noise mutation output omits"), "list preview policy should include a clear truncation note", listPreviewPolicy); - assertCondition(submitted.promptOmitted === true && !responseJson.includes("Focused submit summary contract"), "submit confirmation should not leak prompt text", response); - assertCondition(responseJson.length < 12_000, "submit confirmation should remain low-noise", { chars: responseJson.length }); - - const activeIdsOmitted = compactSubmitSuccessResponseForTest({ - tasks: [task("codex_submitted_queued_while_running", "queued", "live-fast-lane")], - queue: { - counts: { running: 9, queued: 1 }, - activeTaskIds: { items: [], count: 9, returned: 0, truncated: true }, - queuedTaskIds: { items: [], count: 1, returned: 0, truncated: true }, - databaseActiveTaskCount: 9, - executionDiagnostics: { - state: "split-brain", - splitBrain: true, - splitBrainLive: true, - effectiveLiveness: "live", - recommendedAction: "continue-supervision", - databaseActiveTaskCount: 9, - schedulerActiveRunSlotCount: 0, - schedulerActiveTaskIds: [], - activeHeartbeatCount: 9, - heartbeatFreshTaskIds: [], - }, - }, - }, { ok: true, status: 200 }, { mode: "local-atomic-directory-submit-serialization", acquiredAfterMs: 1, heldMs: 2, throttleMs: 2000 }); - const omittedQueue = asRecord(asRecord(activeIdsOmitted).queue); - const omittedActive = asRecord(omittedQueue.activeTaskIds); - const forcedQueued = asRecord(omittedQueue.queuedTaskIds); - const omittedPolicy = asRecord(omittedQueue.listPreviewPolicy); - const unavailable = asRecord(omittedPolicy.unavailableIdLists); - assertCondition(omittedActive.count === 9 && omittedActive.returned === 0, "running-count nonzero should be preserved even with omitted active ids", omittedActive); - assertNoItemsArrayWhenUnavailable(omittedActive, "activeTaskIds"); - assertCondition(asArray(forcedQueued.items).includes("codex_submitted_queued_while_running"), "new queued submitted task should be force-included even if upstream queued ids were omitted", forcedQueued); - assertCondition(unavailable.activeTaskIds === true && unavailable.queuedTaskIds === false, "listPreviewPolicy should summarize unavailable active id list", unavailable); - - const liveSplitBrain = asRecord(omittedQueue.executionDiagnostics); - const liveStateDisclosure = asRecord(omittedQueue.stateDisclosure); - const liveActivity = asRecord(omittedQueue.activity); - assertCondition(liveSplitBrain.splitBrainLive === true && liveSplitBrain.effectiveLiveness === "live", "split-brain-live heartbeat context should stay explicit", liveSplitBrain); - assertCondition(liveActivity.splitBrainDisposition === "live-count-as-active", "split-brain-live should be counted as active in activity", liveActivity); - assertCondition(String(liveStateDisclosure.splitBrainDisposition || "").includes("continue supervision"), "stateDisclosure should explain split-brain-live disposition", liveStateDisclosure); - assertCondition(String(liveStateDisclosure.idsUnavailableMeaning || "").includes("not that there are no tasks"), "stateDisclosure should prevent empty-list misread", liveStateDisclosure); - - const transientRiskSubmit = compactSubmitSuccessResponseForTest({ - tasks: [task("codex_submitted_queued_during_stale_snapshot", "queued", "high-concurrency")], - queue: { - counts: { running: 7, queued: 1 }, - activeTaskIds: { items: [], count: 7, returned: 0, truncated: true }, - queuedTaskIds: { items: [], count: 1, returned: 0, truncated: true }, - databaseActiveTaskCount: 7, - executionDiagnostics: { - state: "stale-active", - effectiveLiveness: "at-risk", - recommendedAction: "investigate-heartbeat-risk", - databaseActiveTaskCount: 7, - databaseActiveTaskIds: manyIds("stale-db-active", 7), - schedulerActiveRunSlotCount: 0, - activeHeartbeatCount: 7, - lastSchedulerHeartbeatAt: "2026-05-22T23:50:00.000Z", - lastObservedAgentEventAt: "2026-05-22T23:49:30.000Z", - heartbeatExpiredTaskIds: manyIds("stale-db-active", 7), - staleRecoveryCandidateTaskIds: manyIds("stale-db-active", 7), - heartbeatRiskTaskIds: manyIds("stale-db-active", 7), - }, - }, - }, { ok: true, status: 200 }, { mode: "local-atomic-directory-submit-serialization", acquiredAfterMs: 1, heldMs: 2, throttleMs: 2000 }); - const transientQueue = asRecord(asRecord(transientRiskSubmit).queue); - const transientActivity = asRecord(transientQueue.activity); - const transientConcurrency = asRecord(transientQueue.commanderConcurrency); - const transientRecovery = asRecord(transientActivity.recovery); - const transientStateDisclosure = asRecord(transientQueue.stateDisclosure); - const transientDiagnostics = asRecord(transientQueue.executionDiagnostics); - const transientDiagnosticsRecovery = asRecord(transientDiagnostics.recovery); - assertCondition(transientActivity.heartbeatRiskTaskCount === 7 && transientActivity.staleRecoveryCandidateTaskCount === 7, "submit activity should keep stale-active candidates visible", transientActivity); - assertCondition(transientRecovery.disposition === "transient-needs-repoll", "submit stale snapshot should be classified as transient until re-polled", transientRecovery); - assertCondition(transientRecovery.hint === "re-poll supervisor before recovery", "submit stale snapshot should emit the re-poll hint", transientRecovery); - assertCondition(transientRecovery.boundedSnapshot === true && transientRecovery.snapshotRole === "submit-confirmation", "submit recovery context should identify bounded confirmation snapshot", transientRecovery); - assertCondition(transientRecovery.lastObservedAgentEventBeforeSubmit === true, "submit recovery context should compare agent event time with submit time", transientRecovery); - assertCondition(transientRecovery.recoveryMutationAllowedByThisSnapshot === false, "single submit snapshot must not allow recovery mutation", transientRecovery); - assertCondition(transientConcurrency.attentionRequired === true && transientConcurrency.interventionRequired === false, "submit heartbeat risk should require attention but not direct high-risk intervention", transientConcurrency); - assertCondition(transientStateDisclosure.transientRiskHint === "re-poll supervisor before recovery", "stateDisclosure should repeat low-noise re-poll hint", transientStateDisclosure); - assertCondition(transientDiagnosticsRecovery.snapshotRole === "submit-confirmation" && transientDiagnosticsRecovery.hint === "re-poll supervisor before recovery", "submit execution diagnostics should carry submit snapshot recovery semantics", transientDiagnosticsRecovery); - - const queuedIdsOmitted = compactSubmitSuccessResponseForTest({ - tasks: [task("codex_submitted_already_running", "running", "live-fast-lane")], - queue: { - counts: { running: 1, queued: 3 }, - activeTaskIds: { items: [], count: 1, returned: 0, truncated: true }, - queuedTaskIds: { items: [], count: 3, returned: 0, truncated: true }, - executionDiagnostics: { - state: "healthy", - databaseActiveTaskCount: 1, - databaseActiveTaskIds: ["codex_submitted_already_running"], - activeHeartbeatCount: 1, - heartbeatFreshTaskIds: ["codex_submitted_already_running"], - }, - }, - }, { ok: true, status: 200 }, { mode: "local-atomic-directory-submit-serialization", acquiredAfterMs: 1, heldMs: 2, throttleMs: 2000 }); - const queuedOmittedQueue = asRecord(asRecord(queuedIdsOmitted).queue); - const queuedOmitted = asRecord(queuedOmittedQueue.queuedTaskIds); - const queuedOmittedPolicy = asRecord(queuedOmittedQueue.listPreviewPolicy); - const queuedUnavailable = asRecord(queuedOmittedPolicy.unavailableIdLists); - assertCondition(queuedOmitted.count === 3 && queuedOmitted.returned === 0, "queued-count nonzero should be preserved even with omitted queued ids", queuedOmitted); - assertNoItemsArrayWhenUnavailable(queuedOmitted, "queuedTaskIds"); - assertCondition(queuedUnavailable.queuedTaskIds === true, "listPreviewPolicy should summarize unavailable queued id list", queuedUnavailable); - - return { - ok: true, - checks: [ - "newly queued submitted task is included in queuedTaskIds preview", - "running count context falls back to database active ids", - "nonzero count with omitted id lists uses idsUnavailable instead of items=[]", - "split-brain-live submit summary says continue supervision and count as active", - "stale submit snapshots preserve candidates but require re-poll before recovery", - "bounded id previews disclose omitted counts", - "submit confirmation omits prompt text and remains low-noise", - ], - }; -} - -if (import.meta.main) { - process.stdout.write(`${JSON.stringify(runCodeQueueSubmitSummaryContract(), null, 2)}\n`); -} diff --git a/scripts/code-queue-supervisor-disclosure-contract-test.ts b/scripts/code-queue-supervisor-disclosure-contract-test.ts deleted file mode 100644 index 6fb212d4..00000000 --- a/scripts/code-queue-supervisor-disclosure-contract-test.ts +++ /dev/null @@ -1,461 +0,0 @@ -import { codexTasksQueryForTest } from "./src/code-queue"; - -type JsonRecord = Record; - -function assertCondition(condition: unknown, message: string, detail: JsonRecord = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function asRecord(value: unknown): JsonRecord { - assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), "expected JSON object", { value }); - return value as JsonRecord; -} - -function asArray(value: unknown): unknown[] { - assertCondition(Array.isArray(value), "expected JSON array", { value }); - return value as unknown[]; -} - -function longText(marker: string, repeat: number): string { - return Array.from({ length: repeat }, (_, index) => `${marker}-${index} #132 Gate report diagnostic review evidence direct workbench fix`).join("\n"); -} - -function manyIds(prefix: string, count: number): string[] { - return Array.from({ length: count }, (_, index) => `${prefix}-${String(index + 1).padStart(3, "0")}`); -} - -function task(id: string, status: string, updatedAt: string, readAt: string | null = null): JsonRecord { - return { - id, - queueId: "default", - status, - currentAttempt: status === "running" ? 2 : 1, - updatedAt, - finishedAt: status === "succeeded" ? updatedAt : null, - readAt, - prompt: longText(`prompt-${id}`, 90), - basePrompt: longText(`base-${id}`, 70), - displayPrompt: longText(`display-${id}`, 80), - lastAssistantMessage: { - at: updatedAt, - seq: 99, - source: "assistant", - text: longText(`assistant-${id}`, 120), - }, - }; -} - -function fixtureResponse(path: string): JsonRecord { - if (path.includes("/summary")) { - const taskId = decodeURIComponent(path.split("/api/tasks/")[1]?.split("/")[0] ?? "unknown"); - return { - ok: true, - status: 200, - body: { - ok: true, - summary: { - id: taskId, - queueId: "default", - status: taskId.includes("running") ? "running" : "succeeded", - currentAttempt: 1, - maxAttempts: 99, - prompt: longText(`summary-prompt-${taskId}`, 100), - basePrompt: longText(`summary-base-${taskId}`, 80), - lastAssistantMessage: { - at: "2026-05-22T00:00:00.000Z", - seq: 120, - source: "finalResponse", - text: longText(`summary-assistant-${taskId}`, 130), - }, - commands: { - show: `bun scripts/cli.ts codex task ${taskId}`, - trace: `bun scripts/cli.ts codex task ${taskId} --trace --tail --limit 80`, - }, - }, - }, - }; - } - assertCondition(path.startsWith("/api/microservices/code-queue/proxy/api/tasks/overview"), "unexpected path", { path }); - return { - ok: true, - status: 200, - body: { - ok: true, - queue: { - counts: { - running: 15, - judging: 0, - queued: 4, - retry_wait: 1, - succeeded: 13, - }, - maxActiveQueues: 12, - executionDiagnostics: { - state: "split-brain", - splitBrain: true, - effectiveLiveness: "live", - splitBrainLive: true, - recommendedAction: "continue-supervision", - livenessSummary: longText("split-brain-live-summary", 45), - databaseActiveTaskCount: 80, - databaseActiveTaskIds: manyIds("db-active", 80), - schedulerActiveRunSlotCount: 30, - schedulerActiveTaskIds: manyIds("scheduler-active", 30), - activeHeartbeatCount: 80, - activeHeartbeatTaskIds: manyIds("active-heartbeat", 80), - heartbeatFreshTaskIds: manyIds("fresh-heartbeat", 80), - heartbeatExpiredTaskIds: [], - heartbeatMissingTaskIds: [], - staleRecoveryCandidateTaskIds: [], - heartbeatRiskTaskIds: [], - traceGapTaskIds: manyIds("trace-gap", 60), - traceGapNotStaleTaskIds: manyIds("trace-gap-fresh", 40), - reasons: Array.from({ length: 24 }, (_, index) => longText(`diagnostic-reason-${index + 1}`, 10)), - oaPublisher: { - pendingTaskIds: manyIds("oa-pending", 80), - lastError: longText("oa-publisher-error", 60), - }, - }, - }, - pagination: { - limit: 200, - returned: 15, - total: 33, - hasMore: false, - nextBeforeId: null, - includeActive: true, - }, - tasks: [ - task("task-running", "running", "2026-05-22T00:09:00.000Z"), - task("task-succeeded-1", "succeeded", "2026-05-22T00:08:00.000Z"), - task("task-succeeded-2", "succeeded", "2026-05-22T00:07:00.000Z"), - task("task-succeeded-3", "succeeded", "2026-05-22T00:06:00.000Z"), - task("task-succeeded-4", "succeeded", "2026-05-22T00:05:00.000Z"), - task("task-succeeded-5", "succeeded", "2026-05-22T00:04:00.000Z"), - task("task-succeeded-6", "succeeded", "2026-05-22T00:03:00.000Z"), - task("task-succeeded-7", "succeeded", "2026-05-22T00:02:00.000Z"), - task("task-read-1", "succeeded", "2026-05-22T00:01:50.000Z", "2026-05-22T00:01:55.000Z"), - task("task-read-2", "succeeded", "2026-05-22T00:01:40.000Z", "2026-05-22T00:01:45.000Z"), - task("task-read-3", "succeeded", "2026-05-22T00:01:30.000Z", "2026-05-22T00:01:35.000Z"), - task("task-read-4", "succeeded", "2026-05-22T00:01:20.000Z", "2026-05-22T00:01:25.000Z"), - task("task-read-5", "succeeded", "2026-05-22T00:01:10.000Z", "2026-05-22T00:01:15.000Z"), - task("task-read-6", "succeeded", "2026-05-22T00:01:05.000Z", "2026-05-22T00:01:09.000Z"), - task("task-queued", "queued", "2026-05-22T00:01:00.000Z"), - ], - }, - }; -} - -function manyRunningFixtureResponse(path: string): JsonRecord { - if (path.includes("/summary")) return fixtureResponse(path); - assertCondition(path.startsWith("/api/microservices/code-queue/proxy/api/tasks/overview"), "unexpected path", { path }); - const tasks = Array.from({ length: 40 }, (_, index) => task( - `task-running-${String(index + 1).padStart(2, "0")}`, - "running", - `2026-05-22T00:${String(59 - index).padStart(2, "0")}:00.000Z`, - )); - return { - ok: true, - status: 200, - body: { - ok: true, - queue: { - counts: { - running: 40, - judging: 0, - queued: 7, - retry_wait: 2, - }, - maxActiveQueues: 50, - executionDiagnostics: { - state: "healthy", - databaseActiveTaskCount: 40, - databaseActiveTaskIds: manyIds("running-active", 40), - activeHeartbeatCount: 40, - activeHeartbeatTaskIds: manyIds("running-heartbeat", 40), - heartbeatFreshTaskIds: manyIds("running-fresh", 40), - }, - }, - pagination: { - limit: 200, - returned: 40, - total: 40, - hasMore: false, - nextBeforeId: null, - includeActive: true, - }, - tasks, - }, - }; -} - -function splitBrainLiveSupervisorFixtureResponse(path: string): JsonRecord { - if (path.includes("/summary")) return fixtureResponse(path); - assertCondition(path.startsWith("/api/microservices/code-queue/proxy/api/tasks/overview"), "unexpected path", { path }); - const liveTaskIds = manyIds("split-live", 8); - const tasks = liveTaskIds.map((taskId, index) => task( - taskId, - "running", - `2026-05-22T01:${String(50 - index).padStart(2, "0")}:00.000Z`, - )); - return { - ok: true, - status: 200, - body: { - ok: true, - queue: { - counts: { running: 8 }, - activeQueueIds: [], - activeTaskIds: [], - activeRunSlotCount: 0, - databaseActiveTaskCount: 8, - executionDiagnostics: { - state: "split-brain", - splitBrain: true, - splitBrainLive: true, - effectiveLiveness: "live", - recommendedAction: "continue-supervision", - databaseActiveTaskCount: 8, - databaseActiveTaskIds: liveTaskIds, - schedulerActiveRunSlotCount: 0, - schedulerActiveTaskIds: [], - activeHeartbeatCount: 8, - activeHeartbeatTaskIds: liveTaskIds, - heartbeatFreshTaskIds: liveTaskIds, - heartbeatExpiredTaskIds: [], - heartbeatMissingTaskIds: [], - staleRecoveryCandidateTaskIds: [], - heartbeatRiskTaskIds: [], - }, - }, - pagination: { - limit: 200, - returned: 8, - total: 8, - hasMore: false, - nextBeforeId: null, - includeActive: true, - }, - tasks, - }, - }; -} - -function staleSnapshotSupervisorFixtureResponse(path: string): JsonRecord { - if (path.includes("/summary")) return fixtureResponse(path); - assertCondition(path.startsWith("/api/microservices/code-queue/proxy/api/tasks/overview"), "unexpected path", { path }); - const staleTaskIds = manyIds("stale-active", 7); - const tasks = [ - ...staleTaskIds.map((taskId, index) => task( - taskId, - "running", - `2026-05-22T02:${String(50 - index).padStart(2, "0")}:00.000Z`, - )), - task("stale-snapshot-new-queued", "queued", "2026-05-22T02:55:00.000Z"), - ]; - return { - ok: true, - status: 200, - body: { - ok: true, - queue: { - counts: { running: 7, queued: 1 }, - activeQueueIds: [], - activeTaskIds: { items: [], count: 7, returned: 0, truncated: true }, - activeRunSlotCount: 0, - databaseActiveTaskCount: 7, - executionDiagnostics: { - state: "stale-active", - splitBrain: false, - effectiveLiveness: "at-risk", - recommendedAction: "investigate-heartbeat-risk", - now: "2026-05-22T03:00:00.000Z", - databaseActiveTaskCount: 7, - databaseActiveTaskIds: staleTaskIds, - schedulerActiveRunSlotCount: 0, - schedulerActiveTaskIds: [], - activeHeartbeatCount: 7, - activeHeartbeatTaskIds: staleTaskIds, - heartbeatFreshTaskIds: [], - heartbeatExpiredTaskIds: staleTaskIds, - heartbeatMissingTaskIds: [], - staleRecoveryCandidateTaskIds: staleTaskIds, - heartbeatRiskTaskIds: staleTaskIds, - lastSchedulerHeartbeatAt: "2026-05-22T02:45:00.000Z", - lastObservedAgentEventAt: "2026-05-22T02:44:30.000Z", - reasons: ["owner heartbeat is expired and scheduler has no local active run for at least one database-active task"], - }, - }, - pagination: { - limit: 200, - returned: 8, - total: 8, - hasMore: false, - nextBeforeId: null, - includeActive: true, - }, - tasks, - }, - }; -} - -export function runCodeQueueSupervisorDisclosureContract(): JsonRecord { - const supervisor = codexTasksQueryForTest(["--view", "supervisor", "--limit", "20"], fixtureResponse); - const cappedLimit = codexTasksQueryForTest(["--view", "supervisor", "--limit", "260"], fixtureResponse); - const full = codexTasksQueryForTest(["--view", "full", "--limit", "20"], fixtureResponse); - const cappedFull = codexTasksQueryForTest(["--view", "full", "--limit", "260"], fixtureResponse); - const runningFiltered = codexTasksQueryForTest(["--status", "running", "--limit", "40"], manyRunningFixtureResponse); - const unreadFiltered = codexTasksQueryForTest(["--unread", "--limit", "20"], fixtureResponse); - const splitBrainLive = codexTasksQueryForTest(["--view", "supervisor", "--limit", "20"], splitBrainLiveSupervisorFixtureResponse); - const staleSnapshot = codexTasksQueryForTest(["--view", "supervisor", "--limit", "20"], staleSnapshotSupervisorFixtureResponse); - - const supervisorBody = JSON.stringify(supervisor); - const fullBody = JSON.stringify(full); - const runningFilteredBody = JSON.stringify(runningFiltered); - const unreadFilteredBody = JSON.stringify(unreadFiltered); - const supervisorData = asRecord(supervisor); - const supervisorView = asRecord(supervisorData.supervisor); - const cappedSupervisorView = asRecord(asRecord(cappedLimit).supervisor); - const runningFilteredView = asRecord(asRecord(runningFiltered).supervisor); - const runningFilteredSection = asRecord(runningFilteredView.running); - const unreadFilteredView = asRecord(asRecord(unreadFiltered).supervisor); - const unreadFilteredSection = asRecord(unreadFilteredView.completedUnread); - const disclosure = asRecord(supervisorView.disclosure); - const runningItem = asRecord(asArray(asRecord(supervisorView.running).items)[0]); - const recentCompleted = asRecord(supervisorView.recentCompleted); - const recentItems = asArray(recentCompleted.items); - const fullItem = asRecord(asArray(asRecord(asRecord(full).tasks).items)[0]); - const completedUnread = asRecord(supervisorView.completedUnread); - const fullTasks = asRecord(asRecord(full).tasks); - const cappedFullTasks = asRecord(asRecord(cappedFull).tasks); - const diagnostics = asRecord(supervisorView.executionDiagnostics); - const filters = asRecord(supervisorView.filters); - const activeRunning = asRecord(supervisorView.activeRunning); - const activeRunningRowPage = asRecord(activeRunning.rowPage); - const activeRunningRedline = asRecord(activeRunning.redline); - const activeRunningCommands = asRecord(activeRunning.commands); - const counts = asRecord(supervisorView.counts); - const outputBudget = asRecord(asRecord(disclosure.outputBudget)); - const listBudget = asRecord(diagnostics.listBudget); - const omittedCounts = asRecord(listBudget.omittedCounts); - const splitBrainLiveView = asRecord(asRecord(splitBrainLive).supervisor); - const splitBrainLiveActivity = asRecord(splitBrainLiveView.activity); - const splitBrainLiveConcurrency = asRecord(splitBrainLiveView.commanderConcurrency); - const splitBrainLiveCounts = asRecord(splitBrainLiveView.counts); - const staleSnapshotView = asRecord(asRecord(staleSnapshot).supervisor); - const staleSnapshotActivity = asRecord(staleSnapshotView.activity); - const staleSnapshotConcurrency = asRecord(staleSnapshotView.commanderConcurrency); - const staleSnapshotDiagnostics = asRecord(staleSnapshotView.executionDiagnostics); - const staleSnapshotRecovery = asRecord(staleSnapshotActivity.recovery); - const staleSnapshotDiagnosticsRecovery = asRecord(staleSnapshotDiagnostics.recovery); - const cappedFilters = asRecord(cappedSupervisorView.filters); - const cappedSource = asRecord(cappedSupervisorView.source); - const cappedLimitPolicy = asRecord(asRecord(cappedSupervisorView.disclosure).limitPolicy); - const cappedCommands = asRecord(cappedSupervisorView.commands); - const cappedFullFilters = asRecord(cappedFullTasks.filters); - const cappedFullSource = asRecord(cappedFullTasks.source); - - assertCondition(supervisorBody.length < fullBody.length * 0.55, "supervisor output should be materially smaller than full output", { supervisorChars: supervisorBody.length, fullChars: fullBody.length }); - assertCondition(supervisorBody.length < 45_000, "supervisor output should remain bounded even with large diagnostics", { supervisorChars: supervisorBody.length }); - assertCondition(cappedFilters.requestedLimit === 260 && cappedFilters.effectiveLimit === 100 && cappedFilters.limit === 100 && cappedFilters.limitCapped === true, "supervisor filters should disclose requested and capped effective limit", cappedFilters); - assertCondition(cappedSource.requestedLimit === 200 && cappedSource.effectiveLimit === 200 && cappedSource.limit === 200 && cappedSource.returned === 15, "supervisor source should disclose independent overview fetch limit", cappedSource); - assertCondition(cappedLimitPolicy.requestedLimit === 260 && cappedLimitPolicy.effectiveLimit === 100 && cappedLimitPolicy.sourceFetchLimit === 200 && cappedLimitPolicy.sourceEffectiveLimit === 200, "supervisor disclosure should summarize requested/effective/source limits", cappedLimitPolicy); - assertCondition(String(cappedCommands.refresh ?? "").includes("--limit 260") && String(cappedCommands.byStatus ?? "").includes("--limit 260"), "supervisor follow-up commands should preserve requested limit", cappedCommands); - assertCondition(cappedFullFilters.requestedLimit === 260 && cappedFullFilters.effectiveLimit === 100 && cappedFullFilters.limitCapped === true, "full view filters should disclose capped requested limit", cappedFullFilters); - assertCondition(cappedFullSource.requestedLimit === 200 && cappedFullSource.effectiveLimit === 200, "full view source should disclose independent overview fetch limit", cappedFullSource); - assertCondition(recentItems.length === 3, "recentCompleted should be capped below --limit by default", { returned: recentItems.length }); - assertCondition(asArray(completedUnread.items).length === 3, "completedUnread should be locally paged and kept separate from recentCompleted", completedUnread); - assertCondition(recentItems.every((item) => asRecord(item).unreadTerminal === false), "recentCompleted should not duplicate unread terminal tasks", { recentItems }); - assertCondition(diagnostics.databaseActiveTaskIds === undefined, "supervisor diagnostics should not expose verbose databaseActiveTaskIds by default", diagnostics); - assertCondition(omittedCounts.databaseActiveTaskIds === 77, "diagnostic omitted counts should preserve full visibility metadata", omittedCounts); - assertCondition(diagnostics.effectiveLiveness === "live", "supervisor liveness summary should keep split-brain live explicit", diagnostics); - assertCondition(diagnostics.recommendedAction === "continue-supervision", "supervisor liveness summary should recommend continued supervision", diagnostics); - assertCondition(diagnostics.splitBrainLive === true, "supervisor liveness summary should mark splitBrainLive", diagnostics); - assertCondition(diagnostics.activeHeartbeatCount === 80, "supervisor liveness summary should foreground active heartbeat count", diagnostics); - assertCondition(asArray(diagnostics.heartbeatFreshTaskIds).length === 3, "supervisor diagnostics should keep heartbeatFreshTaskIds bounded", diagnostics); - assertCondition(String(diagnostics.interpretation ?? "").includes("continue supervision"), "supervisor liveness interpretation should not imply scheduler stoppage", diagnostics); - assertCondition(asArray(diagnostics.reasons).length === 2, "diagnostic reasons should be capped", diagnostics); - assertCondition(diagnostics.livenessSummary === undefined, "supervisor diagnostics should omit liveness summary preview by default", diagnostics); - assertCondition(listBudget.truncated === true && typeof listBudget.rawCommand === "string", "diagnostic list budget should disclose raw command", listBudget); - assertCondition(asArray(runningItem.issues).includes("#132"), "supervisor row should expose issue refs for triage", runningItem); - assertCondition(runningItem.status === "running", "fixture running row should keep raw scheduler status", runningItem); - assertCondition(String(runningItem.statusLabel ?? "").includes("awaiting terminal/judge"), "running finalResponse row should expose awaiting terminal/judge label", runningItem); - assertCondition(runningItem.awaitingTerminalJudge === true && runningItem.closeoutState === "awaiting-terminal-or-judge", "running finalResponse row should be marked as not ready for closeout", runningItem); - assertCondition(String(runningItem.closeoutHint ?? "").includes("wait for terminal status and judge"), "running finalResponse row should explain commander interpretation", runningItem); - assertCondition(Number(runningItem.promptChars) > String(runningItem.prompt ?? "").length && runningItem.promptTruncated === true, "supervisor prompt must be a short flat preview with original char count", runningItem); - assertCondition(Number(runningItem.lastChars) > String(runningItem.last ?? "").length && runningItem.lastTruncated === true, "supervisor body must be a short flat preview with original char count", runningItem); - assertCondition(runningItem.commands === undefined && runningItem.promptPreview === undefined && runningItem.lastAssistantMessage === undefined, "supervisor rows must not expose repeated commands or legacy long list fields", runningItem); - assertCondition(asRecord(fullItem.promptPreview).chars !== undefined && fullItem.lastAssistantMessage !== undefined, "full view must retain detailed task row fields", fullItem); - assertCondition(fullItem.status === "running" && String(fullItem.statusLabel ?? "").includes("awaiting terminal/judge"), "full view should keep raw status while exposing derived closeout label", fullItem); - assertCondition(fullItem.awaitingTerminalJudge === true && fullItem.closeoutState === "awaiting-terminal-or-judge", "full view should expose awaiting terminal/judge state", fullItem); - assertCondition(fullTasks.returned === 15, "full view must not inherit supervisor recentCompleted cap", fullTasks); - assertCondition(filters.requestedLimit === 20 && filters.limit === 20 && filters.limitCapped === false, "supervisor filters should disclose requested vs effective limit", filters); - assertCondition(outputBudget.requestedLimit === 20 && outputBudget.effectiveLimit === 20 && outputBudget.sectionReturnedLimit === 3, "supervisor must expose output budget metadata", outputBudget); - assertCondition(activeRunning.count === 15 && activeRunning.exact === true && activeRunning.source === "queue-summary-counts", "activeRunning should expose exact running+judging count from queue summary", activeRunning); - assertCondition(activeRunningRowPage.returned === 1 && activeRunningRowPage.returnedLimit === 3 && String(activeRunningRowPage.distinction ?? "").includes("row page"), "activeRunning row page should distinguish returned rows from active count", activeRunningRowPage); - assertCondition(activeRunningRedline.countField === "supervisor.activeRunning.count" && activeRunningRedline.hardRedline === 15 && activeRunningRedline.state === "at-or-over-hard-redline", "activeRunning redline should name count field and interpretation", activeRunningRedline); - assertCondition(counts.activeRunningCount === 15 && counts.activeRunningExact === true && counts.activeRunningRowsReturned === 1, "supervisor counts should separate active count from returned running rows", counts); - assertCondition(String(activeRunningCommands.running ?? "").includes("--status running,judging"), "activeRunning should provide running drilldown", activeRunningCommands); - assertCondition(asArray(runningFilteredSection.items).length === 3, "running status filter should be locally paged below --limit", runningFilteredSection); - assertCondition(runningFilteredSection.count === 40 && runningFilteredSection.hasMore === true, "running status filter should preserve count and hasMore", runningFilteredSection); - assertCondition(String(asRecord(runningFilteredSection.commands).next ?? "").includes("--before-id task-running-03"), "running status filter should provide next page command", runningFilteredSection); - assertCondition(runningFilteredBody.length < 14_000, "running status filter output should remain bounded", { chars: runningFilteredBody.length }); - assertCondition(asArray(unreadFilteredSection.items).length <= 3, "unread list should be locally paged below --limit", unreadFilteredSection); - assertCondition(unreadFilteredBody.length < 14_000, "unread output should remain bounded", { chars: unreadFilteredBody.length }); - assertCondition(splitBrainLiveCounts.running === 8, "split-brain supervisor should preserve DB running task count", splitBrainLiveCounts); - assertCondition(splitBrainLiveCounts.commanderActiveRunnerCount === 8, "split-brain supervisor should mirror commander active count in counts", splitBrainLiveCounts); - assertCondition(splitBrainLiveCounts.effectiveActive === 8, "split-brain supervisor should foreground effective active count", splitBrainLiveCounts); - assertCondition(splitBrainLiveCounts.databaseRunning === 8, "split-brain supervisor should distinguish database running tasks", splitBrainLiveCounts); - assertCondition(splitBrainLiveCounts.heartbeatFreshActive === 8, "split-brain supervisor should distinguish heartbeat-effective active runners", splitBrainLiveCounts); - assertCondition(splitBrainLiveCounts.schedulerLocalActiveQueues === 0, "split-brain supervisor should preserve zero scheduler-local active queues", splitBrainLiveCounts); - assertCondition(splitBrainLiveActivity.effectiveActiveTaskCount === 8, "split-brain supervisor activity should expose effective active count", splitBrainLiveActivity); - assertCondition(splitBrainLiveActivity.effectiveActiveSource === "heartbeat-fresh", "split-brain supervisor activity should prefer heartbeat-fresh source", splitBrainLiveActivity); - assertCondition(splitBrainLiveActivity.databaseRunningTaskCount === 8, "split-brain supervisor activity should expose DB running count", splitBrainLiveActivity); - assertCondition(splitBrainLiveActivity.heartbeatFreshActiveTaskCount === 8, "split-brain supervisor activity should expose heartbeat-fresh active count", splitBrainLiveActivity); - assertCondition(splitBrainLiveActivity.schedulerLocalActiveQueueCount === 0, "split-brain supervisor activity should expose scheduler-local queue count", splitBrainLiveActivity); - assertCondition(splitBrainLiveActivity.schedulerLocalActiveRunSlotCount === 0, "split-brain supervisor activity should expose scheduler-local slot count", splitBrainLiveActivity); - assertCondition(splitBrainLiveActivity.splitBrainLive === true, "split-brain supervisor activity should mark live split-brain", splitBrainLiveActivity); - assertCondition(splitBrainLiveActivity.splitBrainDisposition === "live-count-as-active", "split-brain supervisor activity should classify live split-brain as active capacity", splitBrainLiveActivity); - assertCondition(splitBrainLiveActivity.commanderConcurrency !== undefined, "split-brain supervisor activity should include commander concurrency guidance", splitBrainLiveActivity); - assertCondition(splitBrainLiveConcurrency.activeRunnerCount === 8, "split-brain supervisor should expose commander-facing active runner count", splitBrainLiveConcurrency); - assertCondition(splitBrainLiveConcurrency.activeRunnerCountField === "activity.effectiveActiveTaskCount", "split-brain supervisor should name the field to use", splitBrainLiveConcurrency); - assertCondition(splitBrainLiveConcurrency.splitBrainDisposition === "live-count-as-active", "split-brain supervisor should explain live split-brain disposition", splitBrainLiveConcurrency); - assertCondition(splitBrainLiveConcurrency.interventionRequired === false, "fresh split-brain supervisor should not require intervention", splitBrainLiveConcurrency); - assertCondition(String(splitBrainLiveConcurrency.decisionRule ?? "").includes("15 - activeRunnerCount"), "split-brain supervisor should give 15-concurrency arithmetic", splitBrainLiveConcurrency); - assertCondition(String(splitBrainLiveActivity.activeQueueIdsNote ?? "").includes("zero local queue ids does not mean zero active runners"), "split-brain supervisor activity should explain activeQueueIds are local-only", splitBrainLiveActivity); - assertCondition(String(splitBrainLiveActivity.interpretation ?? "").includes("continue supervision"), "split-brain supervisor activity should not imply scheduler stoppage", splitBrainLiveActivity); - assertCondition(staleSnapshotActivity.heartbeatRiskTaskCount === 7 && staleSnapshotActivity.staleRecoveryCandidateTaskCount === 7, "stale supervisor snapshot should keep heartbeat-risk candidates visible", staleSnapshotActivity); - assertCondition(staleSnapshotActivity.effectiveActiveTaskCount === 7 && staleSnapshotActivity.databaseRunningTaskCount === 7, "stale supervisor snapshot should preserve database-active running count", staleSnapshotActivity); - assertCondition(staleSnapshotRecovery.disposition === "transient-needs-repoll" && staleSnapshotRecovery.hint === "re-poll supervisor before recovery", "stale supervisor snapshot should ask for re-poll before recovery", staleSnapshotRecovery); - assertCondition(staleSnapshotRecovery.snapshotRole === "supervisor-poll" && staleSnapshotRecovery.boundedSnapshot === false, "supervisor recovery context should distinguish poll from submit confirmation", staleSnapshotRecovery); - assertCondition(staleSnapshotRecovery.recoveryMutationAllowedByThisSnapshot === false, "single supervisor poll must not allow recovery mutation", staleSnapshotRecovery); - assertCondition(staleSnapshotConcurrency.attentionRequired === true && staleSnapshotConcurrency.interventionRequired === false, "stale supervisor snapshot should require attention but not immediate high-risk intervention", staleSnapshotConcurrency); - assertCondition(staleSnapshotDiagnostics.effectiveLiveness === "at-risk" && staleSnapshotDiagnostics.recommendedAction === "investigate-heartbeat-risk", "stale supervisor diagnostics should keep at-risk visibility", staleSnapshotDiagnostics); - assertCondition(staleSnapshotDiagnosticsRecovery.hint === "re-poll supervisor before recovery", "stale supervisor diagnostics should carry the re-poll hint", staleSnapshotDiagnosticsRecovery); - - return { - ok: true, - checks: [ - "supervisor output materially smaller than full", - "recentCompleted capped", - "explicit --limit cap disclosed", - "running/unread locally paged", - "split-brain diagnostics capped", - "active running exact count exposed", - "requested/effective/returned limits disclosed", - "prompt/body previews bounded", - "running finalResponse rows labeled awaiting terminal/judge", - "drill-down commands preserved", - "full view remains detailed", - "split-brain live supervisor activity distinguishes scheduler-local, database, and heartbeat counts", - "commander concurrency block names the active runner count and 15-concurrency rule", - "stale-active supervisor snapshot remains visible but requires re-poll before recovery", - ], - supervisorChars: supervisorBody.length, - fullChars: fullBody.length, - }; -} - -if (import.meta.main) { - process.stdout.write(`${JSON.stringify(runCodeQueueSupervisorDisclosureContract(), null, 2)}\n`); -} diff --git a/scripts/code-queue-trace-summary-contract-test.ts b/scripts/code-queue-trace-summary-contract-test.ts deleted file mode 100644 index 8e05546d..00000000 --- a/scripts/code-queue-trace-summary-contract-test.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { configureTaskView, taskTraceSummaryFixtureResponse } from "../src/components/microservices/code-queue/src/task-view"; -import { configureTaskOutput } from "../src/components/microservices/code-queue/src/task-output"; -import { configureJudge } from "../src/components/microservices/code-queue/src/judge"; -import type { OaTraceStepSummary } from "../src/components/microservices/code-queue/src/oa-events"; -import type { JsonValue, PromptHistoryItem, QueueTask, QueuedStatusReason } from "../src/components/microservices/code-queue/src/types"; - -type JsonRecord = Record; - -function assertCondition(condition: unknown, message: string, detail: JsonRecord = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function asRecord(value: unknown): JsonRecord | null { - return typeof value === "object" && value !== null && !Array.isArray(value) ? value as JsonRecord : null; -} - -function pageBySeq( - items: T[], - _url: URL, - _limit: number, -): { mode: "tail" | "after" | "before"; afterSeq: number; beforeSeq: number | null; nextAfterSeq: number; previousBeforeSeq: number | null; hasMore: boolean; hasBefore: boolean; chunk: T[] } { - return { - mode: "tail", - afterSeq: 0, - beforeSeq: null, - nextAfterSeq: items.at(-1)?.seq ?? 0, - previousBeforeSeq: null, - hasMore: false, - hasBefore: false, - chunk: items, - }; -} - -function configureFixtureTaskView(): void { - configureTaskOutput({ - config: { maxInMemoryOutputRecords: 1000, outputArchiveDir: "/tmp/code-queue-trace-summary-contract/output" }, - allocateSeq: () => 1000, - errorToJson: (error: unknown): JsonValue => error instanceof Error ? { message: error.message } : String(error), - logger: () => undefined, - markTaskDirty: () => undefined, - nowIso: () => "2026-05-19T00:10:00.000Z", - schedulePersistState: () => undefined, - }); - configureJudge({ - config: { - minimaxApiKey: "", - minimaxApiBase: "", - minimaxModel: "minimax-m1", - judgeTimeoutMs: 1000, - judgeRepairAttempts: 0, - judgeMaxTokens: 1000, - }, - logger: () => undefined, - safePreview: (value: string, max = 300) => value.length > max ? `${value.slice(0, max)}...` : value, - userPromptForDisplay: (prompt: string) => prompt, - taskFullOutput: (task: QueueTask) => task.output, - taskReferenceIds: (task: QueueTask) => task.referenceTaskIds, - extractRecord: (value: unknown) => typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null, - extractString: (value: unknown, key: string) => { - const record = typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; - const item = record?.[key]; - return typeof item === "string" ? item : null; - }, - promptLineCount: (text: string) => text.length > 0 ? text.split(/\r\n|\r|\n/u).length : 0, - judgeFailRetryLimit: 3, - }); - configureTaskView({ - config: { codexHome: "/tmp/code-queue-trace-summary-contract" }, - errorToJson: (error: unknown): JsonValue => error instanceof Error ? { message: error.message } : String(error), - jsonResponse: (body: unknown, status = 200): Response => Response.json(body, { status }), - logger: () => undefined, - mergePromptHistory: (items: PromptHistoryItem[]) => items, - nowIso: () => "2026-05-19T00:10:00.000Z", - outputPromptHistory: () => [], - pageBySeq, - parseLimit: () => 100, - parseSeqParam: () => null, - queueIdOf: (task: QueueTask) => task.queueId, - queuedStatusReason: (): QueuedStatusReason | null => null, - queuedTaskPromptEditable: () => false, - taskQueueEnteredAt: (task: QueueTask) => task.queueEnteredAt, - }); -} - -function fixtureTask(): QueueTask { - const at = "2026-05-19T00:00:00.000Z"; - return { - id: "codex_trace_contract", - queueId: "default", - queueEnteredAt: at, - prompt: "Trace summary contract fixture", - basePrompt: "Trace summary contract fixture", - referenceTaskIds: [], - referenceInjection: null, - providerId: "D601", - cwd: "/workspace", - model: "gpt-5.5", - reasoningEffort: null, - executionMode: "default", - maxAttempts: 99, - status: "running", - createdAt: at, - updatedAt: "2026-05-19T00:06:30.000Z", - startedAt: at, - finishedAt: null, - readAt: null, - currentAttempt: 2, - currentMode: "retry", - codexThreadId: "thread_trace_contract", - activeTurnId: "turn_trace_contract", - finalResponse: "", - lastError: null, - lastJudge: { decision: "retry", confidence: 1, reason: "attempt 1 asked for retry", source: "fallback" }, - judgeFailCount: 0, - promptHistory: [], - output: [ - { seq: 1, at, channel: "user", text: "Trace summary contract fixture", method: "enqueue" }, - { seq: 2, at: "2026-05-19T00:00:10.000Z", channel: "system", text: "attempt 1 / 99", method: "queue" }, - { seq: 3, at: "2026-05-19T00:01:00.000Z", channel: "command", text: "rg trace-summary src/components/microservices/code-queue/src", method: "item/started", itemId: "call-1" }, - { seq: 4, at: "2026-05-19T00:02:00.000Z", channel: "system", text: "judge=retry confidence=1 source=fallback: attempt 1 asked for retry", method: "judge" }, - ], - events: [], - attempts: [ - { - index: 1, - mode: "initial", - startedAt: "2026-05-19T00:00:10.000Z", - finishedAt: "2026-05-19T00:02:00.000Z", - terminalStatus: "completed", - transportClosedBeforeTerminal: false, - appServerExitCode: 0, - appServerSignal: null, - error: null, - finalResponse: "Attempt 1 response", - finalResponsePreview: "Attempt 1 response", - finalResponseChars: 18, - stderrTail: "", - judge: { decision: "retry", confidence: 1, reason: "attempt 1 asked for retry", source: "fallback" }, - judgeAt: "2026-05-19T00:02:00.000Z", - judgeSeq: 4, - outputStartSeq: 2, - outputEndSeq: 4, - }, - ], - cancelRequested: false, - nextPrompt: null, - nextMode: null, - }; -} - -function attempt2Steps(): OaTraceStepSummary[] { - return [ - { - eventSequence: 20, - seq: 20, - at: "2026-05-19T00:06:00.000Z", - kind: "ran", - title: "Run", - status: "item/started", - summaryLines: ["attempt 2 / 99", "pnpm test"], - rawSeqs: [20], - scopeId: "task:codex_trace_contract:attempt:2", - attemptIndex: 2, - source: "oa-event-flow", - }, - { - eventSequence: 21, - seq: 21, - at: "2026-05-19T00:06:20.000Z", - kind: "explored", - title: "Read", - status: "item/completed", - summaryLines: ["src/components/microservices/code-queue/src/task-view.ts"], - rawSeqs: [21], - scopeId: "task:codex_trace_contract:attempt:2", - attemptIndex: 2, - source: "oa-event-flow", - }, - ]; -} - -export function runCodeQueueTraceSummaryContract(): JsonRecord { - configureFixtureTaskView(); - const task = fixtureTask(); - const steps = attempt2Steps(); - const summary = taskTraceSummaryFixtureResponse(task, { - stats: null, - taskStats: null, - allSteps: [ - { - eventSequence: 1, - seq: 1, - at: "2026-05-19T00:00:10.000Z", - kind: "message", - title: "Assistant message", - status: "item/completed", - summaryLines: ["Attempt 1 judge complete"], - rawSeqs: [4], - scopeId: "task:codex_trace_contract", - attemptIndex: null, - source: "oa-event-flow", - }, - ...steps, - ], - attemptSteps: new Map([[2, steps]]), - }) as JsonRecord; - const attempts = Array.isArray(summary.attempts) ? summary.attempts.map(asRecord).filter((item): item is JsonRecord => item !== null) : []; - const attempt2 = attempts.find((attempt) => Number(attempt.index) === 2) ?? null; - const taskStats = asRecord(summary.traceStats); - const taskExecution = asRecord(summary.execution); - const attempt2Stats = asRecord(attempt2?.traceStats); - const attempt2Execution = asRecord(attempt2?.execution); - - assertCondition(summary.currentAttempt === 2, "summary must retain currentAttempt=2", summary); - assertCondition(summary.statsSource === "raw-trace-fallback", "summary must distinguish raw trace fallback from empty STEP", summary); - assertCondition(summary.traceStatsState === "degraded", "summary must mark OA stats sync degraded", summary); - assertCondition(summary.traceStatsReason === "oa-event-flow-stats-unavailable-raw-trace-present", "summary must explain degraded OA sync", summary); - assertCondition(taskStats?.source === "oa-event-flow" && taskStats?.sourceHint === "raw-trace-fallback", "summary must expose countable synthetic stats with source hint", taskStats ?? {}); - assertCondition(taskExecution?.statsSource === "oa-event-flow" && taskExecution?.traceStatsState === "degraded", "execution summary must stay countable while degraded", taskExecution ?? {}); - assertCondition(Number(summary.stepCount ?? 0) > 0, "summary fallback STEP count must be visible", summary); - assertCondition(attempt2 !== null, "summary must materialize the latest running retry attempt", { attempts }); - assertCondition(Number(attempt2?.stepCount ?? 0) > 0, "attempt 2 must expose live fallback STEP count", attempt2 ?? {}); - assertCondition(attempt2Stats?.source === "oa-event-flow" && attempt2Stats?.sourceHint === "raw-trace-fallback", "attempt 2 fallback stats must remain countable", attempt2Stats ?? {}); - assertCondition(attempt2Execution?.statsSource === "oa-event-flow" && attempt2Execution?.traceStatsState === "degraded", "attempt 2 execution must be countable while degraded", attempt2Execution ?? {}); - - return { - ok: true, - checks: [ - { name: "code-queue:trace-summary-latest-attempt-visible", ok: true }, - { name: "code-queue:trace-summary-raw-trace-step-fallback", ok: true }, - ], - taskId: task.id, - stepCount: summary.stepCount, - statsSource: summary.statsSource, - traceStatsState: summary.traceStatsState, - attempt2StepCount: attempt2?.stepCount, - }; -} - -if (import.meta.main) { - process.stdout.write(`${JSON.stringify(runCodeQueueTraceSummaryContract(), null, 2)}\n`); -} diff --git a/scripts/code-queue-unread-triage-contract-test.ts b/scripts/code-queue-unread-triage-contract-test.ts deleted file mode 100644 index 5a0fe8d0..00000000 --- a/scripts/code-queue-unread-triage-contract-test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { codexUnreadTriageForTest } from "./src/code-queue"; - -type JsonRecord = Record; -type RequestRecord = { path: string; method: string; body: unknown }; - -function assertCondition(condition: unknown, message: string, detail: JsonRecord = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function asRecord(value: unknown): JsonRecord { - assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), "expected JSON object", { value }); - return value as JsonRecord; -} - -function asArray(value: unknown): unknown[] { - assertCondition(Array.isArray(value), "expected JSON array", { value }); - return value as unknown[]; -} - -function task(id: string, queueId: string, status: string, updatedAt: string, readAt: string | null, prompt: string): JsonRecord { - return { - id, - queueId, - status, - currentAttempt: 1, - updatedAt, - finishedAt: status === "running" ? null : updatedAt, - readAt, - displayPrompt: prompt, - basePrompt: prompt, - prompt, - cwd: "/root/unidesk", - referenceTaskIds: [], - lastError: null, - }; -} - -function fixtureResponse(path: string, init?: { method?: string; body?: unknown }, requests: RequestRecord[] = []): JsonRecord { - const method = init?.method ?? "GET"; - requests.push({ path, method, body: init?.body }); - if (path.endsWith("/read")) { - const taskId = decodeURIComponent(path.split("/api/tasks/")[1]?.split("/")[0] ?? "unknown"); - return { - ok: true, - status: 200, - body: { - ok: true, - task: task(taskId, "default", "succeeded", "2026-05-22T00:10:00.000Z", "2026-05-22T00:10:01.000Z", "read response prompt should stay compact"), - queue: { counts: { succeeded: 1 }, unreadTerminal: 0 }, - }, - }; - } - assertCondition(path.startsWith("/api/microservices/code-queue/proxy/api/tasks/overview"), "unexpected path", { path, method }); - return { - ok: true, - status: 200, - body: { - ok: true, - queue: { unreadTerminal: 4 }, - pagination: { - limit: 200, - returned: 6, - total: 6, - hasMore: false, - nextBeforeId: null, - includeActive: true, - }, - tasks: [ - task("task-new-1", "default", "succeeded", "2026-05-22T00:05:00.000Z", null, "pikasTech/unidesk#20 closeout RAW_PROMPT_SHOULD_NOT_LEAK"), - task("task-new-2", "review", "failed", "2026-05-22T00:04:00.000Z", null, "https://github.com/pikasTech/unidesk/issues/20 failed task RAW_PROMPT_SHOULD_NOT_LEAK"), - task("task-new-3", "review", "canceled", "2026-05-22T00:03:00.000Z", null, "pikasTech/unidesk#21 canceled task RAW_PROMPT_SHOULD_NOT_LEAK"), - task("task-unknown", "misc", "succeeded", "2026-05-22T00:02:00.000Z", null, "no repo marker RAW_PROMPT_SHOULD_NOT_LEAK"), - task("task-read", "default", "succeeded", "2026-05-22T00:01:00.000Z", "2026-05-22T00:01:01.000Z", "pikasTech/unidesk#20 already read RAW_PROMPT_SHOULD_NOT_LEAK"), - task("task-running", "default", "running", "2026-05-22T00:00:00.000Z", null, "pikasTech/unidesk#20 running RAW_PROMPT_SHOULD_NOT_LEAK"), - ], - }, - }; -} - -function countItems(bucket: JsonRecord): JsonRecord[] { - return asArray(bucket.items).map(asRecord); -} - -function itemCount(bucket: JsonRecord, key: string): number { - const row = countItems(bucket).find((item) => item.key === key); - return typeof row?.count === "number" ? row.count : 0; -} - -export function runCodeQueueUnreadTriageContract(): JsonRecord { - const requests: RequestRecord[] = []; - const fetcher = (path: string, init?: { method?: string; body?: unknown }): JsonRecord => fixtureResponse(path, init, requests); - - const summary = codexUnreadTriageForTest(["--limit", "2"], fetcher); - const summaryBody = JSON.stringify(summary); - const triage = asRecord(asRecord(summary).unreadTriage); - const counts = asRecord(triage.counts); - const newest = asRecord(triage.newest); - const newestItems = asArray(newest.items).map(asRecord); - const commands = asRecord(triage.commands); - - assertCondition(asRecord(summary).ok === true, "default unread triage should succeed", asRecord(summary)); - assertCondition(triage.readOnly === true && triage.bounded === true, "default unread triage must be read-only and bounded", triage); - assertCondition(counts.totalUnreadTerminal === 4, "counts should include only unread terminal tasks", counts); - assertCondition(itemCount(asRecord(counts.byRepo), "pikasTech/unidesk") === 3, "repo counts should group owner/name refs", counts); - assertCondition(itemCount(asRecord(counts.byIssue), "#20") === 2, "issue counts should group issue refs", counts); - assertCondition(itemCount(asRecord(counts.byStatus), "succeeded") === 2, "status counts should include terminal statuses", counts); - assertCondition(itemCount(asRecord(counts.byQueue), "review") === 2, "queue counts should include queues", counts); - assertCondition(newest.returned === 2 && newest.hasMore === true, "newest items should obey --limit and expose pagination", newest); - assertCondition(newestItems.every((item) => item.commands === undefined && typeof item.nextStep === "string"), "default unread rows must stay compact without repeated per-task command blocks", { newestItems }); - assertCondition(typeof commands.perTaskRead === "string" && String(commands.perTaskRead).includes("codex read "), "triage should preserve per-task read drill-down", commands); - assertCondition(typeof commands.full === "string" && String(commands.full).includes("codex unread") && String(commands.full).includes("--full"), "triage should expose one full-view expansion command", commands); - assertCondition(!summaryBody.includes("RAW_PROMPT_SHOULD_NOT_LEAK"), "triage output must not dump raw prompt text", { summaryBody }); - assertCondition(!requests.some((request) => request.path.includes("/summary")), "triage must not fetch per-task summaries by default", { requests }); - assertCondition(!requests.some((request) => request.method === "POST"), "default triage must not mutate", { requests }); - - const full = codexUnreadTriageForTest(["--view", "full", "--limit", "2"], fetcher); - const fullBody = JSON.stringify(full); - const fullTriage = asRecord(asRecord(full).unreadTriage); - const fullNewest = asRecord(fullTriage.newest); - const fullItems = asArray(fullNewest.items).map(asRecord); - const fullItemCommands = fullItems.map((item) => asRecord(item.commands)); - assertCondition(asRecord(fullTriage.filters).view === "full", "codex unread --view full should disclose full view", fullTriage); - assertCondition(fullItems.length === 2 && fullItemCommands.every((item) => typeof item.detail === "string" && typeof item.read === "string"), "explicit full unread view should expand per-task commands", { fullItems }); - assertCondition(summaryBody.length < fullBody.length, "default unread summary should stay smaller than explicit full view", { summaryChars: summaryBody.length, fullChars: fullBody.length }); - - const guardStart = requests.length; - const guarded = codexUnreadTriageForTest(["mark-read", "--repo", "pikasTech/unidesk", "--issue", "20", "--limit", "2"], fetcher); - const guardedTriage = asRecord(asRecord(guarded).unreadTriage); - const guardedMutation = asRecord(guardedTriage.mutation); - assertCondition(asRecord(guarded).ok === false, "batch mark-read without --confirm should fail closed", asRecord(guarded)); - assertCondition(guardedMutation.blocked === true && guardedMutation.confirmed === false, "confirm guard should describe blocked mutation", guardedMutation); - assertCondition(!requests.slice(guardStart).some((request) => request.method === "POST"), "missing --confirm must not POST read calls", { requests: requests.slice(guardStart) }); - - const confirmStart = requests.length; - const confirmed = codexUnreadTriageForTest(["mark-read", "--repo", "pikasTech/unidesk", "--issue", "20", "--limit", "2", "--confirm"], fetcher); - const confirmedTriage = asRecord(asRecord(confirmed).unreadTriage); - const confirmedMutation = asRecord(confirmedTriage.mutation); - const readPosts = requests.slice(confirmStart).filter((request) => request.method === "POST" && request.path.endsWith("/read")); - assertCondition(asRecord(confirmed).ok === true, "confirmed batch mark-read should succeed", asRecord(confirmed)); - assertCondition(confirmedTriage.readOnly === false && confirmedMutation.confirmed === true, "confirmed mutation should be explicit", confirmedMutation); - assertCondition(confirmedMutation.attempted === 2 && confirmedMutation.succeeded === 2, "confirmed mutation should respect filters and limit", confirmedMutation); - assertCondition(readPosts.length === 2 && readPosts[0]?.path.includes("task-new-1") && readPosts[1]?.path.includes("task-new-2"), "confirmed mutation should POST newest filtered task reads only", { readPosts }); - - return { - ok: true, - checks: [ - "default unread triage is read-only", - "repo/issue/status/queue counts are present", - "newest items are bounded", - "default rows avoid repeated per-task command blocks", - "--view full expands per-task commands", - "raw prompt text is omitted", - "batch mark-read requires --confirm", - "confirmed batch read respects filters and limit", - ], - summaryChars: summaryBody.length, - fullChars: fullBody.length, - }; -} - -if (import.meta.main) { - process.stdout.write(`${JSON.stringify(runCodeQueueUnreadTriageContract(), null, 2)}\n`); -} diff --git a/scripts/d601-host-compose-status-contract-test.ts b/scripts/d601-host-compose-status-contract-test.ts deleted file mode 100644 index df328d88..00000000 --- a/scripts/d601-host-compose-status-contract-test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { readConfig } from "./src/config"; -import { deprecatedD601HostComposeEntryForTest, type ComposeRuntimeEnv, type ContainerStatus } from "./src/docker"; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -const runtimeEnv: ComposeRuntimeEnv = { - envFile: "/home/ubuntu/workspace/unidesk-dev/.state/docker-compose.env", - logDir: "not-created-on-deprecated-d601-host-compose-entry", - logDay: "20260610", - logPrefix: "20260610_120000", -}; - -const config = readConfig(); - -const deprecated = deprecatedD601HostComposeEntryForTest(config, runtimeEnv, [], "/home/ubuntu/workspace/unidesk-dev"); -assertCondition(deprecated?.ok === false, "D601 host compose entry should be a controlled failure", deprecated); -assertCondition(deprecated.error === "deprecated-host-compose-entry", "D601 host compose entry should expose the deprecation classifier", deprecated); -assertCondition(deprecated.runnerDisposition === "business-failed", "D601 host compose entry should be classified as business-failed", deprecated); -assertCondition(deprecated.decision.hostComposeRetained === false, "D601 host compose entry should be explicitly deprecated", deprecated.decision); -assertCondition(deprecated.correctEntrypoints.d601NativeK3s.includes("trans D601:k3s"), "D601 status should point to native k3s as the D601 acceptance entry", deprecated.correctEntrypoints); -assertCondition(deprecated.correctEntrypoints.mainServerStatus.includes("/root/unidesk"), "D601 status should point main-server compose checks at /root/unidesk", deprecated.correctEntrypoints); -assertCondition(deprecated.evidence.logDir === runtimeEnv.logDir, "D601 status should preserve repo-local runtime evidence", deprecated.evidence); -assertCondition(!JSON.stringify(deprecated).includes("/workspace/unidesk/logs"), "D601 status should not surface the stale /workspace/unidesk/logs permission path", deprecated); -assertCondition(deprecated.evidence.conflictingListeners.every((item) => item.expected === "ignored-on-deprecated-d601-host-compose-entry"), "D601 status should mark occupied public ports as ignored for this deprecated entry", deprecated.evidence.conflictingListeners); - -const legacyOperatorPath = deprecatedD601HostComposeEntryForTest(config, runtimeEnv, [], "/home/ubuntu/unidesk"); -assertCondition(legacyOperatorPath?.error === "deprecated-host-compose-entry", "legacy D601 operator checkout should receive the same deprecation classifier", legacyOperatorPath); - -const staleWorkspacePath = deprecatedD601HostComposeEntryForTest(config, runtimeEnv, [], "/workspace/unidesk"); -assertCondition(staleWorkspacePath?.error === "deprecated-host-compose-entry", "stale /workspace D601 checkout should be classified before log directory writes", staleWorkspacePath); -assertCondition(!JSON.stringify(staleWorkspacePath).includes("/workspace/unidesk/logs"), "stale /workspace D601 checkout should not surface the stale log permission path", staleWorkspacePath); - -const canonicalMainServer = deprecatedD601HostComposeEntryForTest(config, runtimeEnv, [], "/root/unidesk"); -assertCondition(canonicalMainServer === null, "canonical main-server checkout should keep the normal compose path", canonicalMainServer); - -const existingContainer: ContainerStatus = { - id: "abc123", - name: "unidesk-backend-core", - image: "unidesk/backend-core:test", - status: "Up", - ports: "", -}; -const activeCompose = deprecatedD601HostComposeEntryForTest(config, runtimeEnv, [existingContainer], "/home/ubuntu/workspace/unidesk-dev"); -assertCondition(activeCompose === null, "D601 checkout with compose containers should keep the normal compose path", activeCompose); - -console.log(JSON.stringify({ ok: true, contract: "d601-host-compose-status" }, null, 2)); diff --git a/scripts/d601-recovery-guardrails-contract-test.ts b/scripts/d601-recovery-guardrails-contract-test.ts deleted file mode 100644 index 76a2b698..00000000 --- a/scripts/d601-recovery-guardrails-contract-test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { readConfig } from "./src/config"; -import { extractHostPathEntries, parseContainerCreatingReport, parseProcMounts, runD601RecoveryGuardrails, type RecoveryGuardrailsFixture } from "./src/recovery-guardrails"; - -type JsonRecord = Record; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -const malformedProcMounts = [ - "proc /proc proc rw,nosuid,nodev,noexec,relatime 0 0", - "drvfs /Docker/host 9p rw,dirsync,aname=drvfs;path=C:\\Program Files\\Docker\\Docker Desktop\\host 0 0", -].join("\n"); - -const manifest = ` -apiVersion: apps/v1 -kind: Deployment -metadata: - name: code-queue-scheduler-dev - namespace: unidesk-dev -spec: - template: - spec: - volumes: - - name: repo - hostPath: - path: /home/ubuntu/unidesk-dev-code-queue-deploy/code-queue - type: Directory - - name: state - hostPath: - path: /home/ubuntu/unidesk-dev-code-queue-deploy/state/code-queue - type: DirectoryOrCreate ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: mdtodo-dev - namespace: unidesk-dev -spec: - template: - spec: - volumes: - - name: workspace - hostPath: - path: /home/ubuntu/unidesk-dev-mdtodo-workspace - type: Directory - - name: logs - hostPath: - path: /home/ubuntu/cq-deploy/.state/mdtodo-dev/logs - type: DirectoryOrCreate -`; - -const kubectlPods = { - items: [ - { - metadata: { namespace: "unidesk-dev", name: "code-queue-scheduler-dev-abc" }, - status: { - containerStatuses: [{ - name: "code-queue", - state: { - waiting: { - reason: "ContainerCreating", - message: "MountVolume.SetUp failed for volume \"repo\": hostPath type check failed: /home/ubuntu/unidesk-dev-code-queue-deploy/code-queue is not a directory", - }, - }, - }], - }, - }, - { - metadata: { namespace: "unidesk-dev", name: "mdtodo-dev-def" }, - status: { - containerStatuses: [{ - name: "mdtodo", - state: { waiting: { reason: "ContainerCreating", message: "" } }, - }], - }, - }, - ], -}; - -const staleCriPods = { - items: [ - { - id: "sandbox-stale", - metadata: { name: "code-queue-scheduler-dev-abc", namespace: "unidesk-dev" }, - state: "SANDBOX_NOTREADY", - createdAt: "2026-05-23T09:00:00.000Z", - }, - ], -}; - -export function runD601RecoveryGuardrailsContract(): JsonRecord { - const procMounts = parseProcMounts(malformedProcMounts, "fixture:/proc/mounts"); - assertCondition(procMounts.ok === false, "malformed /proc/mounts fixture should fail", procMounts); - assertCondition(procMounts.malformedLines.length === 1, "one malformed mount line expected", procMounts); - assertCondition(procMounts.malformedLines[0]?.dockerDesktopHost9p === true, "malformed line should be classified as Docker Desktop /Docker/host 9p", procMounts); - - const entries = extractHostPathEntries("fixture.k8s.yaml", manifest); - assertCondition(entries.some((entry) => entry.hostPath === "/home/ubuntu/unidesk-dev-code-queue-deploy/code-queue"), "hostPath parser should extract repo path", entries); - assertCondition(entries.some((entry) => entry.hostPath === "/home/ubuntu/unidesk-dev-mdtodo-workspace"), "hostPath parser should extract MDTODO workspace path", entries); - - const fixture: RecoveryGuardrailsFixture = { - observedAt: "2026-05-23T10:00:00.000Z", - procMountsText: malformedProcMounts, - criPodsJsonText: JSON.stringify(staleCriPods), - kubectlPodsJsonText: JSON.stringify(kubectlPods), - deployWorktreePath: "/home/ubuntu/unidesk-code-queue-deploy", - compatibilitySymlinkPath: "/home/ubuntu/cq-deploy", - manifestPaths: ["fixture.k8s.yaml"], - manifestTexts: { "fixture.k8s.yaml": manifest }, - pathStates: { - "/home/ubuntu/unidesk-code-queue-deploy": { kind: "missing" }, - "/home/ubuntu/cq-deploy": { kind: "symlink", target: "/home/ubuntu/unidesk-code-queue-deploy", targetExists: false }, - "/home/ubuntu/unidesk-dev-code-queue-deploy/code-queue": { kind: "missing" }, - "/home/ubuntu/unidesk-dev-code-queue-deploy/state/code-queue": { kind: "missing" }, - "/home/ubuntu/unidesk-dev-mdtodo-workspace": { kind: "missing" }, - "/home/ubuntu/cq-deploy/.state/mdtodo-dev/logs": { kind: "missing" }, - }, - }; - const result = runD601RecoveryGuardrails(readConfig(), fixture); - assertCondition(result.scope.liveMutationAllowed === false && result.mutation === false, "guardrail result must be read-only", result.scope); - assertCondition(result.ok === false, "fixture should produce red guardrails", result.redlineSummary); - assertCondition(result.redlineSummary.requiresManualHostHotfix.includes("proc-mounts-malformed-kubelet-risk"), "malformed mount risk should require manual host hotfix", result.redlineSummary); - assertCondition(result.redlineSummary.requiresManualHostHotfix.includes("code-queue-deploy-worktree-not-ready"), "missing worktree/symlink target should require manual host hotfix", result.redlineSummary); - assertCondition(result.redlineSummary.requiresManualHostHotfix.includes("required-hostpaths-not-ready"), "missing hostPath should require manual host hotfix", result.redlineSummary); - assertCondition(result.redlineSummary.requiresManualHostHotfix.includes("opaque-containercreating-hostpath-risk"), "ContainerCreating hostPath risk should require manual host hotfix", result.redlineSummary); - assertCondition(result.checks.codeQueueDeployWorktree.canonicalPath.exists === false, "canonical deploy worktree should report missing", result.checks.codeQueueDeployWorktree); - assertCondition(result.checks.codeQueueDeployWorktree.compatibilitySymlink.isSymlink === true, "compat symlink should be visible", result.checks.codeQueueDeployWorktree); - assertCondition(result.checks.codeQueueDeployWorktree.compatibilitySymlink.targetExists === false, "compat symlink target should report missing", result.checks.codeQueueDeployWorktree); - assertCondition(result.checks.hostPaths.missing.some((problem) => problem.entry.hostPath === "/home/ubuntu/unidesk-dev-code-queue-deploy/code-queue"), "missing Directory hostPath should be red", result.checks.hostPaths.missing); - assertCondition(result.checks.hostPaths.directoryOrCreateMissing.some((problem) => problem.entry.hostPath === "/home/ubuntu/unidesk-dev-code-queue-deploy/state/code-queue"), "DirectoryOrCreate missing should be surfaced separately", result.checks.hostPaths.directoryOrCreateMissing); - assertCondition(result.checks.mdtodoAdjacentHostPaths.opaqueRisk === true, "MDTODO adjacent hostPath readiness should be opaque when workspace/logs missing", result.checks.mdtodoAdjacentHostPaths); - assertCondition(result.checks.criSandboxes.staleNotReadyCount === 1, "stale CRI sandbox should be counted", result.checks.criSandboxes); - assertCondition(result.checks.containerCreating.hostPathContainerCreating.length === 1, "explicit hostPath ContainerCreating should be classified", result.checks.containerCreating); - assertCondition(result.checks.containerCreating.opaqueContainerCreating.length === 1, "empty-message ContainerCreating should be classified as opaque when hostPaths are missing", result.checks.containerCreating); - assertCondition(result.redlineSummary.forbiddenAutomaticActions.includes("crictl rmp"), "CRI deletion should be explicitly forbidden", result.redlineSummary); - assertCondition(result.redlineSummary.forbiddenAutomaticActions.includes("systemctl restart k3s"), "k3s restart should be explicitly forbidden", result.redlineSummary); - - const containerCreatingOnly = parseContainerCreatingReport(JSON.stringify(kubectlPods), result.checks.hostPaths, "fixture", null); - assertCondition(containerCreatingOnly.ok === false, "ContainerCreating parser should fail when opaque/hostPath findings exist", containerCreatingOnly); - - return { - ok: true, - checks: [ - "malformed /proc/mounts Docker Desktop /Docker/host 9p line detected", - "missing Code Queue worktree symlink and target reported", - "missing hostPath and DirectoryOrCreate readiness reported", - "opaque and explicit hostPath ContainerCreating classified", - "stale CRI sandbox count reported without cleanup", - "destructive prune/reset/restart/delete actions forbidden", - ], - }; -} - -if (import.meta.main) { - process.stdout.write(`${JSON.stringify(runD601RecoveryGuardrailsContract(), null, 2)}\n`); -} diff --git a/scripts/decision-center-desired-state-contract-test.ts b/scripts/decision-center-desired-state-contract-test.ts deleted file mode 100644 index 02968838..00000000 --- a/scripts/decision-center-desired-state-contract-test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { readFileSync } from "node:fs"; -import { spawnSync } from "node:child_process"; -import { rootPath } from "./src/config"; - -type JsonRecord = Record; - -const verifiedDecisionCenterCommit = "3ca82e9946ac4bc4a7e059df79c01e21407efb6f"; -const verifiedFrontendCommit = "7b9aa4261c216586954cdf926ce2c914a9db5ae3"; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function asRecord(value: unknown, label: string): JsonRecord { - assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, value); - return value as JsonRecord; -} - -function asArray(value: unknown, label: string): unknown[] { - assertCondition(Array.isArray(value), `${label} must be an array`, value); - return value as unknown[]; -} - -function findService(environment: "dev" | "prod", serviceId: string): JsonRecord { - const manifest = asRecord(JSON.parse(readFileSync(rootPath("deploy.json"), "utf8")) as unknown, "deploy.json"); - const environments = asRecord(manifest.environments, "deploy.json.environments"); - const env = asRecord(environments[environment], `deploy.json.environments.${environment}`); - const services = asArray(env.services, `deploy.json.environments.${environment}.services`); - const service = services.map((item, index) => asRecord(item, `${environment}.services[${index}]`)) - .find((item) => item.id === serviceId); - assertCondition(service !== undefined, `${environment}/${serviceId} must exist in deploy.json`); - return service as JsonRecord; -} - -function runDeployPlan(environment: "dev" | "prod", serviceId: string): JsonRecord { - const result = spawnSync("bun", [ - "scripts/cli.ts", - "artifact-registry", - "deploy-service", - "--env", - environment, - "--service", - serviceId, - "--commit", - verifiedDecisionCenterCommit, - "--dry-run", - ], { - cwd: rootPath(), - encoding: "utf8", - maxBuffer: 8 * 1024 * 1024, - }); - assertCondition(result.status === 0, `artifact consumer dry-run should exit 0 for ${environment}/${serviceId}`, { - status: result.status, - stdout: result.stdout.slice(-2000), - stderr: result.stderr.slice(-2000), - }); - const envelope = asRecord(JSON.parse(result.stdout) as unknown, "artifact consumer envelope"); - return asRecord(envelope.data, "artifact consumer dry-run data"); -} - -function assertNoBuildK3sDecisionCenter(environment: "dev" | "prod", expectedDeployment: string, expectedNamespace: string): void { - const service = findService(environment, "decision-center"); - assertCondition(service.commitId === verifiedDecisionCenterCommit, `${environment}/decision-center desired commit must match verified live commit`, service); - - const plan = runDeployPlan(environment, "decision-center"); - const registry = asRecord(plan.registry, `${environment}/decision-center registry`); - const build = asRecord(plan.build, `${environment}/decision-center build`); - const target = asRecord(plan.target, `${environment}/decision-center target`); - - assertCondition(plan.ok === true && plan.dryRun === true && plan.mutation === false, `${environment}/decision-center dry-run must be non-mutating`, plan); - assertCondition(plan.commit === verifiedDecisionCenterCommit, `${environment}/decision-center dry-run commit mismatch`, plan); - assertCondition(plan.serviceId === "decision-center", `${environment}/decision-center service id mismatch`, plan); - assertCondition(plan.sourceImage === `127.0.0.1:5000/unidesk/decision-center:${verifiedDecisionCenterCommit}`, `${environment}/decision-center source image mismatch`, plan); - assertCondition(registry.repository === "unidesk/decision-center", `${environment}/decision-center registry repository mismatch`, registry); - assertCondition(registry.tag === verifiedDecisionCenterCommit, `${environment}/decision-center registry tag mismatch`, registry); - assertCondition(build.producerBoundary === "ci publish-user-service", `${environment}/decision-center producer boundary mismatch`, build); - assertCondition(build.willCompile === false, `${environment}/decision-center CD must not compile`, build); - assertCondition(build.willRunDockerBuild === false, `${environment}/decision-center CD must not docker build`, build); - assertCondition(build.willRunDockerComposeBuild === false, `${environment}/decision-center CD must not compose build`, build); - assertCondition(target.kind === "d601-k3s", `${environment}/decision-center target must be D601 k3s`, target); - assertCondition(target.namespace === expectedNamespace, `${environment}/decision-center namespace mismatch`, target); - assertCondition(target.deployment === expectedDeployment, `${environment}/decision-center deployment mismatch`, target); - assertCondition(String(target.deployCommandShape ?? "").includes("kubectl set image"), `${environment}/decision-center command shape must be k3s artifact update`, target); - assertCondition(!JSON.stringify(plan).includes("server rebuild"), `${environment}/decision-center plan must not mention server rebuild`, plan); -} - -for (const environment of ["dev", "prod"] as const) { - const frontend = findService(environment, "frontend"); - assertCondition(frontend.commitId === verifiedFrontendCommit, `${environment}/frontend desired commit must stay aligned to verified UI artifact`, frontend); -} - -assertNoBuildK3sDecisionCenter("dev", "decision-center-dev", "unidesk-dev"); -assertNoBuildK3sDecisionCenter("prod", "decision-center", "unidesk"); - -process.stdout.write(`${JSON.stringify({ - ok: true, - verifiedDecisionCenterCommit, - verifiedFrontendCommit, - checks: [ - "decision-center dev/prod desired commits match the verified live/artifact commit", - "frontend dev/prod desired commits remain aligned to the same verified UI artifact", - "decision-center dev/prod dry-run plans are D601 k3s artifact consumers", - "decision-center dry-run plans declare no compile, docker build, compose build, or server rebuild path", - ], -}, null, 2)}\n`); diff --git a/scripts/decision-center-diary-summary-contract-test.ts b/scripts/decision-center-diary-summary-contract-test.ts deleted file mode 100644 index 9a26b018..00000000 --- a/scripts/decision-center-diary-summary-contract-test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { runDecisionCenterCommandAsync } from "./src/decision-center"; - -type JsonRecord = Record; - -interface FetchCall { - path: string; - init?: { method?: string; body?: unknown }; -} - -function assertCondition(condition: unknown, message: string, detail: JsonRecord = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function source(path: string): string { - return readFileSync(path, "utf8"); -} - -function asRecord(value: unknown): JsonRecord { - return typeof value === "object" && value !== null && !Array.isArray(value) ? value as JsonRecord : {}; -} - -function includesAll(text: string, snippets: string[]): boolean { - return snippets.every((snippet) => text.includes(snippet)); -} - -function makeFetcher(calls: FetchCall[]) { - return async (path: string, init?: { method?: string; body?: unknown }): Promise => { - calls.push({ path, init }); - return { ok: true, status: 200, body: { ok: true, action: "updated", entry: { id: "diary_issue_191" } } }; - }; -} - -async function assertCliPayloadContract(): Promise { - const checks: string[] = []; - const config = {} as Parameters[0]; - const tempDir = mkdtempSync(join(tmpdir(), "unidesk-diary-summary-")); - try { - const bodyFile = join(tempDir, "body.md"); - const bodyText = "# 2099-12-31\n\n## 进展\n- body from file\n"; - writeFileSync(bodyFile, bodyText, "utf8"); - - { - const calls: FetchCall[] = []; - const result = asRecord(await runDecisionCenterCommandAsync(config, [ - "diary", - "upsert", - "2099-12-31", - "--title", - "Issue 191 Repro", - "--summary", - "explicit summary stays separate", - "--body-file", - bodyFile, - "--source-file", - "issue-191-contract", - ], makeFetcher(calls))); - const call = calls[0]; - const body = asRecord(call?.init?.body); - const bodySource = asRecord(result.bodySource); - assertCondition(call?.path === "/api/microservices/decision-center/proxy/api/diary/entries/2099-12-31", "diary upsert must address date route", { call }); - assertCondition(call?.init?.method === "PUT", "diary upsert must use PUT", { call }); - assertCondition(body.summary === "explicit summary stays separate", "CLI must send explicit summary as its own payload field", { body }); - assertCondition(body.body === bodyText, "CLI must keep body populated from --body-file", { body }); - assertCondition(body.sourceFile === "issue-191-contract", "CLI must keep sourceFile disambiguation", { body }); - assertCondition(bodySource.kind === "file" && bodySource.path === bodyFile, "CLI must disclose file body source", { bodySource }); - checks.push("cli-diary-upsert-summary-plus-body-file-payload"); - } - - { - const calls: FetchCall[] = []; - const result = asRecord(await runDecisionCenterCommandAsync(config, [ - "diary", - "upsert", - "2099-12-31", - "--summary", - "summary only update", - ], makeFetcher(calls))); - const body = asRecord(calls[0]?.init?.body); - const bodySource = asRecord(result.bodySource); - assertCondition(body.summary === "summary only update", "CLI must support summary-only diary updates", { body }); - assertCondition(!("body" in body), "summary-only updates must not synthesize body from summary", { body }); - assertCondition(bodySource.kind === "none", "summary-only updates must disclose no body source", { bodySource }); - checks.push("cli-diary-upsert-summary-only-payload"); - } - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } - return checks; -} - -export async function runDecisionCenterDiarySummaryContract(): Promise { - const service = source("src/components/microservices/decision-center/src/index.ts"); - const cli = source("scripts/src/decision-center.ts"); - - assertCondition( - includesAll(cli, [ - "function optionalBodyFromArgs(args: string[], command: string)", - "const summary = optionValue(args, [\"--summary\"])", - "if (summary !== undefined) payload.summary = summary", - "bodySource: { kind: \"none\" }", - ]), - "CLI must route --summary independently from body input", - ); - - assertCondition( - includesAll(service, [ - "summary TEXT NOT NULL DEFAULT ''", - "ALTER TABLE decision_center_diary_entries ADD COLUMN IF NOT EXISTS summary TEXT NOT NULL DEFAULT ''", - "summary: row.summary || summaryFromBody(body)", - "function normalizeDiarySummary(value: unknown, body: string): string", - "const summaryProvided = \"summary\" in input", - "normalizeDiarySummary(input.summary, body)", - "id, entry_date, month, title, summary, body, source_file", - "summary = ${summary}", - "OR summary IS DISTINCT FROM ${summary}", - ]), - "Decision Center backend must persist explicit diary summary separately from body", - ); - - const cliChecks = await assertCliPayloadContract(); - return { - ok: true, - checks: [ - "cli-summary-independent-field-contract", - "backend-diary-summary-column-contract", - ...cliChecks, - ], - }; -} - -if (import.meta.main) { - process.stdout.write(`${JSON.stringify(await runDecisionCenterDiarySummaryContract(), null, 2)}\n`); -} diff --git a/scripts/decision-center-document-contract-test.ts b/scripts/decision-center-document-contract-test.ts deleted file mode 100644 index d0cf5a53..00000000 --- a/scripts/decision-center-document-contract-test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { readFileSync } from "node:fs"; -import { - buildDocumentNumber, - extractDocumentNumberFromLegacy, - parseDocumentNumber, -} from "../src/components/microservices/decision-center/src/document-contract"; -import { runDecisionCenterCommandAsync } from "./src/decision-center"; - -type JsonRecord = Record; - -interface FetchCall { - path: string; - init?: { method?: string; body?: unknown }; -} - -function assertCondition(condition: unknown, message: string, detail: JsonRecord = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function source(path: string): string { - return readFileSync(path, "utf8"); -} - -function asRecord(value: unknown): JsonRecord { - return typeof value === "object" && value !== null && !Array.isArray(value) ? value as JsonRecord : {}; -} - -function makeFetcher(calls: FetchCall[]) { - return async (path: string, init?: { method?: string; body?: unknown }): Promise => { - calls.push({ path, init }); - return { ok: true, status: init?.method === "POST" ? 201 : 200, body: { ok: true, record: { id: "dc_test", docNo: "DC-GOAL-P0-2026-001" } } }; - }; -} - -async function assertCliContract(): Promise { - const checks: string[] = []; - const config = {} as Parameters[0]; - - { - const calls: FetchCall[] = []; - await runDecisionCenterCommandAsync(config, [ - "requirement", - "create", - "--title", - "Doc Create", - "--body", - "body", - "--type", - "goal", - "--priority", - "P0", - "--doc-type", - "GOAL", - "--doc-priority", - "P0", - "--doc-year", - "2026", - "--signer", - "Decision Center", - "--issued-at", - "2026-05-21", - "--effective-scope", - "unidesk", - ], makeFetcher(calls)); - const call = calls[0]; - const body = asRecord(call?.init?.body); - assertCondition(call?.path === "/api/microservices/decision-center/proxy/api/requirements", "create must call requirements collection route", { call }); - assertCondition(call?.init?.method === "POST", "create must use POST", { call }); - assertCondition(body.docType === "GOAL" && body.docPriority === "P0" && body.docYear === 2026, "create must include document allocation fields", { body }); - assertCondition(body.signer === "Decision Center" && body.issuedAt === "2026-05-21" && body.effectiveScope === "unidesk", "create must include document metadata fields", { body }); - checks.push("cli-create-document-fields"); - } - - { - const calls: FetchCall[] = []; - await runDecisionCenterCommandAsync(config, [ - "requirement", - "upsert", - "--id", - "dc_goal_big_paper_submission", - "--title", - "Doc Upsert", - "--body", - "body", - "--doc-no", - "dc-goal-p0-2026-001", - "--supersedes", - "DC-DCSN-P0-2026-001", - ], makeFetcher(calls)); - const call = calls[0]; - const body = asRecord(call?.init?.body); - assertCondition(call?.init?.method === "PUT", "upsert must use PUT", { call }); - assertCondition(body.docNo === "DC-GOAL-P0-2026-001", "upsert must normalize explicit docNo", { body }); - assertCondition(Array.isArray(body.supersedes) && body.supersedes[0] === "DC-DCSN-P0-2026-001", "upsert must include supersedes list", { body }); - checks.push("cli-upsert-document-fields"); - } - - { - const calls: FetchCall[] = []; - await runDecisionCenterCommandAsync(config, [ - "requirement", - "update", - "DC-GOAL-P0-2026-001", - "--signer", - "Signer B", - "--superseded-by", - "DC-GOAL-P0-2026-002", - ], makeFetcher(calls)); - const call = calls[0]; - const body = asRecord(call?.init?.body); - assertCondition(call?.path === "/api/microservices/decision-center/proxy/api/requirements/DC-GOAL-P0-2026-001", "update must address records by docNo", { call }); - assertCondition(call?.init?.method === "PUT", "update must use PUT", { call }); - assertCondition(body.signer === "Signer B", "update must include signer", { body }); - assertCondition(Array.isArray(body.supersededBy) && body.supersededBy[0] === "DC-GOAL-P0-2026-002", "update must include supersededBy list", { body }); - checks.push("cli-update-document-fields"); - } - - { - const calls: FetchCall[] = []; - await runDecisionCenterCommandAsync(config, [ - "requirement", - "list", - "--doc-no", - "DC-GOAL-P0-2026-001", - "--doc-type", - "GOAL", - "--doc-priority", - "P0", - "--year", - "2026", - ], makeFetcher(calls)); - const path = calls[0]?.path ?? ""; - assertCondition(path.includes("/api/requirements?"), "list must call requirements query route", { path }); - assertCondition(path.includes("docNo=DC-GOAL-P0-2026-001"), "list must filter by docNo", { path }); - assertCondition(path.includes("docType=GOAL") && path.includes("docPriority=P0") && path.includes("docYear=2026"), "list must filter by document components", { path }); - checks.push("cli-list-document-query"); - } - - { - const calls: FetchCall[] = []; - await runDecisionCenterCommandAsync(config, ["requirement", "show", "DC-GOAL-P0-2026-001"], makeFetcher(calls)); - assertCondition(calls[0]?.path === "/api/microservices/decision-center/proxy/api/requirements/DC-GOAL-P0-2026-001", "show must support docNo path keys", { call: calls[0] }); - checks.push("cli-show-document-key"); - } - - return checks; -} - -export async function runDecisionCenterDocumentContract(): Promise { - const service = source("src/components/microservices/decision-center/src/index.ts"); - const cli = source("scripts/src/decision-center.ts"); - const cliEntry = source("scripts/cli.ts"); - const remote = source("scripts/src/remote.ts"); - const doc = parseDocumentNumber("dc-goal-p0-2026-1"); - assertCondition(doc.docNo === "DC-GOAL-P0-2026-001", "docNo parser must normalize sequence width", { doc }); - assertCondition(buildDocumentNumber("DCSN", "P0", 2026, 1) === "DC-DCSN-P0-2026-001", "docNo builder must produce canonical sequence"); - - const legacyCases = [ - { id: "dc_decision_thesis_unidesk_integration_rule", expected: "DC-DCSN-P0-2026-001" }, - { id: "x", title: "DC-GOAL-P0-2026-001 big paper", expected: "DC-GOAL-P0-2026-001" }, - { id: "x", body: "doc-no: DC-GOAL-P0-2026-002\n\nBody stays unchanged.", expected: "DC-GOAL-P0-2026-002" }, - { id: "x", tags: ["doc-no:DC-DCSN-P0-2026-001"], expected: "DC-DCSN-P0-2026-001" }, - { id: "dc_goal_big_paper_submission", expected: "DC-GOAL-P0-2026-001" }, - { id: "dc_goal_small_paper_submission_gate", expected: "DC-GOAL-P0-2026-002" }, - ]; - for (const item of legacyCases) { - const extracted = extractDocumentNumberFromLegacy(item); - assertCondition(extracted?.docNo === item.expected, "legacy document number extraction failed", { item, extracted }); - } - - assertCondition(service.includes("CREATE UNIQUE INDEX IF NOT EXISTS idx_decision_center_records_doc_no_unique"), "service must enforce docNo uniqueness"); - assertCondition(service.includes("doc_no = 'DC-' || doc_type || '-' || doc_priority || '-' || doc_year::text || '-' || lpad(doc_seq::text, 3, '0')"), "schema must keep docNo and parsed components consistent"); - assertCondition(service.includes("clearInvalidDocumentNumbers") && service.indexOf("await clearInvalidDocumentNumbers();") < service.indexOf("ADD CONSTRAINT decision_center_records_doc_shape_check"), "schema migration must clear invalid doc fields before adding the document shape check"); - assertCondition(service.includes("document number already exists") && service.includes("code: \"doc_no_conflict\""), "service must expose structured duplicate docNo errors"); - assertCondition(service.includes("nextDocumentSequence") && service.includes("LOCK TABLE decision_center_records IN SHARE ROW EXCLUSIVE MODE"), "service must allocate next doc sequence under table lock"); - assertCondition(service.includes("getRecordByIdOrDocNo(id, docNo)") && service.includes("doc_no = ${docNo || null}"), "requirement upsert must resolve existing records by id or docNo"); - assertCondition(service.includes("clearDuplicateDocumentNumbers") && service.indexOf("await backfillLegacyDocumentNumbers();") < service.indexOf("CREATE UNIQUE INDEX IF NOT EXISTS idx_decision_center_records_doc_no_unique"), "schema migration must de-duplicate/backfill before creating docNo unique index"); - assertCondition(service.includes("backfillLegacyDocumentNumbers") && service.includes("updated_at = updated_at"), "service must run idempotent legacy backfill without touching body/title/tags"); - assertCondition(service.includes("const documentFields = [\"docNo\"") && service.includes("delete inheritedBase[field]"), "meeting import must persist its own document fields without cloning them to child decisions"); - assertCondition(service.includes("WHERE id = ${id}") && service.includes("doc_no = ${docNo || null}"), "service get route must support id or docNo lookup"); - assertCondition(service.includes("ORDER BY") && service.includes("doc_seq ASC NULLS LAST"), "service list route must sort by document number components"); - assertCondition(cli.includes("--doc-no") && cli.includes("--doc-type") && cli.includes("--doc-priority") && cli.includes("--issued-at"), "CLI must expose document options"); - assertCondition(cliEntry.includes("const result = await runDecisionCenterCommand(config, args.slice(1))") && cliEntry.includes("if (!ok) process.exitCode = 1"), "local CLI must propagate decision ok:false as process failure"); - assertCondition(remote.includes("const result = await runDecisionCenterCommandAsync(config, args.slice(1), fetcher)") && remote.includes("return ok ? 0 : 1"), "remote CLI must propagate decision ok:false as process failure"); - - const cliChecks = await assertCliContract(); - return { - ok: true, - checks: [ - "doc-number-parse-and-build", - "doc-no-unique-structured-error-contract", - "docNo-component-consistency-check", - "invalid-docNo-migration-guard", - "doc-sequence-auto-allocation-contract", - "upsert-by-docNo-contract", - "duplicate-docNo-migration-guard", - "legacy-doc-no-title-tag-body-idempotent-backfill", - "meeting-import-document-field-contract", - "service-docNo-and-component-query-contract", - "service-docNo-sort-contract", - "cli-structured-error-exit-contract", - ...cliChecks, - ], - }; -} - -if (import.meta.main) { - process.stdout.write(`${JSON.stringify(await runDecisionCenterDocumentContract(), null, 2)}\n`); -} diff --git a/scripts/decision-center-query-contract-test.ts b/scripts/decision-center-query-contract-test.ts deleted file mode 100644 index a42ed4b9..00000000 --- a/scripts/decision-center-query-contract-test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { readFileSync } from "node:fs"; - -type JsonRecord = Record; - -function assertCondition(condition: unknown, message: string, detail: JsonRecord = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function source(path: string): string { - return readFileSync(path, "utf8"); -} - -function includesAll(text: string, snippets: string[]): boolean { - return snippets.every((snippet) => text.includes(snippet)); -} - -export function runDecisionCenterQueryContract(): JsonRecord { - const service = source("src/components/microservices/decision-center/src/index.ts"); - const cli = source("scripts/src/decision-center.ts"); - const frontend = source("src/components/frontend/src/decision-center.tsx"); - - assertCondition( - includesAll(service, [ - 'url.pathname === "/api/requirements" && method === "GET"', - "listRecords(url, { requirementOnly: true })", - "type IN ('decision', 'goal', 'external_goal', 'internal_goal', 'blocker', 'debt', 'experiment')", - "doc_no", - "doc_type", - "doc_priority", - "doc_year", - "doc_seq", - "signer", - "issued_at", - "effective_scope", - "supersedes", - "superseded_by", - ]), - "requirements list route must stay on the records model, exclude meetings, and expose document fields", - ); - - assertCondition( - includesAll(service, [ - "CASE WHEN ${includeBody}::boolean THEN body ELSE left(body, 4000) END AS body", - "return rows.map((row) => recordFromRow(row, { includeBody }));", - "body: includeBody ? body : \"\"", - ]), - "record list must be body-light by default while preserving summaries", - ); - - assertCondition( - includesAll(service, [ - "sourceFileFilterFromUrl(url)", - "url.searchParams.get(\"sourceFile\") ?? url.searchParams.get(\"sourcePath\") ?? url.searchParams.get(\"source\")", - "AND (${sourceFile || null}::text IS NULL OR source_file = ${sourceFile || null})", - "getDiaryEntry(key, { sourceFile: sourceFileFilterFromUrl(url) })", - ]), - "diary date lookup must support sourceFile disambiguation for same-day entries", - ); - assertCondition( - service.split("AND (${sourceFile || null}::text IS NULL OR source_file = ${sourceFile || null})").length >= 3, - "diary sourceFile filter must cover both read and date-key upsert lookup paths", - ); - - assertCondition( - includesAll(service, [ - "CASE WHEN ${includeBody}::boolean THEN body ELSE left(body, 4000) END AS body", - "return rows.map((row) => diaryEntryFromRow(row, { includeBody }));", - ]), - "diary list must be body-light by default while preserving summaries", - ); - - assertCondition( - includesAll(cli, [ - "if (args.includes(\"--include-body\")) params.set(\"includeBody\", \"true\")", - "function diaryShowQuery(args: string[]): string", - "params.set(\"sourceFile\", sourceFile)", - "showDiary(diaryId, args.slice(3))", - "`/api/requirements${query ? `?${query}` : \"\"}`", - "parseDocumentNo(optionValue(args, [\"--doc-no\", \"--docNo\", \"--document-no\", \"--documentNo\"])", - "params.set(\"docNo\", docNo)", - "payload.docType = docType", - "payload.signer = signer", - ]), - "CLI must expose bounded list opt-in, diary source disambiguation, and document fields", - ); - - assertCondition( - includesAll(frontend, [ - "function diaryEntryLookupPath(entry: any): string", - "const key = entry?.id || entry?.date", - "if (entry?.sourceFile) params.set(\"sourceFile\", String(entry.sourceFile))", - "decisionApi(apiBaseUrl, diaryEntryLookupPath(entry))", - "if (!record?.id || record?.body) return", - "`/api/records/${encodeURIComponent(record.id)}`", - ]), - "frontend must select exact diary rows and fetch full record bodies before editing list results", - ); - - return { - ok: true, - checks: [ - "requirements-route", - "body-light-record-list-query", - "body-light-diary-list-query", - "diary-source-disambiguation", - "cli-bounded-list-diary-source-and-document-query", - "frontend-exact-diary-row-and-record-edit-body", - ], - }; -} - -if (import.meta.main) { - process.stdout.write(`${JSON.stringify(runDecisionCenterQueryContract(), null, 2)}\n`); -} diff --git a/scripts/decision-center-workspace-contract-test.ts b/scripts/decision-center-workspace-contract-test.ts deleted file mode 100644 index cb852e70..00000000 --- a/scripts/decision-center-workspace-contract-test.ts +++ /dev/null @@ -1,340 +0,0 @@ -import { readFileSync } from "node:fs"; - -type JsonRecord = Record; -type DocTypeCode = "DCSN" | "GOAL" | "PLAN" | "RPRT" | "ACTN" | "ISSU" | "RETR" | "RQST" | "RESP" | "MINS"; - -interface DocMetadata { - docNo: string; - docType: DocTypeCode | ""; - priority: string; - year: string; - sequence: string; -} - -const docTypeLabels: Record = { - DCSN: "决策/决议", - GOAL: "目标", - PLAN: "计划", - RPRT: "报告", - ACTN: "行动", - ISSU: "问题", - RETR: "复盘", - RQST: "请示", - RESP: "批复/答复", - MINS: "会议纪要", -}; - -const docTypeOrder: DocTypeCode[] = ["DCSN", "GOAL", "PLAN", "RPRT", "ACTN", "ISSU", "RETR", "RQST", "RESP", "MINS"]; -const docTypeSet = new Set(docTypeOrder); -const docNoPattern = /\bDC[-−–—]([A-Z]{2,5})[-−–—]([A-Z][0-9])[-−–—](\d{4})[-−–—](\d{1,6})\b/iu; - -function assertCondition(condition: unknown, message: string, detail: JsonRecord = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function source(path: string): string { - return readFileSync(path, "utf8"); -} - -function includesAll(text: string, snippets: string[]): boolean { - return snippets.every((snippet) => text.includes(snippet)); -} - -function assertEqual(actual: T, expected: T, message: string, detail: JsonRecord = {}): void { - assertCondition(Object.is(actual, expected), message, { ...detail, actual, expected }); -} - -function stringField(record: JsonRecord, keys: string[]): string { - for (const key of keys) { - const value = record[key]; - if (typeof value === "string" && value.trim()) return value.trim(); - if (typeof value === "number" && Number.isFinite(value)) return String(value); - } - return ""; -} - -function recordTags(record: JsonRecord): string[] { - const tags = record.tags; - return Array.isArray(tags) ? tags.map((tag) => String(tag)) : []; -} - -function tagValues(record: JsonRecord, prefix: string): string[] { - const normalized = `${prefix.toLowerCase()}:`; - return recordTags(record) - .map((tag) => tag.trim()) - .filter((tag) => tag.toLowerCase().startsWith(normalized)) - .map((tag) => tag.slice(normalized.length).trim()) - .filter(Boolean); -} - -function normalizeDocType(value: unknown): DocTypeCode | "" { - const upper = String(value || "").trim().toUpperCase(); - return docTypeSet.has(upper) ? upper as DocTypeCode : ""; -} - -function normalizeSequence(value: unknown): string { - const raw = String(value || "").trim(); - if (!/^\d+$/u.test(raw)) return ""; - return String(Number(raw)).padStart(3, "0"); -} - -function parseDocNo(value: unknown): DocMetadata | null { - const match = String(value || "").match(docNoPattern); - if (match === null) return null; - const docType = normalizeDocType(match[1]); - const priority = String(match[2] || "").toUpperCase(); - const year = String(match[3] || ""); - const sequence = normalizeSequence(match[4]); - if (!docType || !/^P[0-3]$/u.test(priority) || !year || !sequence) return null; - return { - docNo: `DC-${docType}-${priority}-${year}-${sequence}`, - docType, - priority, - year, - sequence, - }; -} - -function mergeParsed(metadata: DocMetadata, parsed: DocMetadata | null): void { - if (parsed === null) return; - if (!metadata.docNo) metadata.docNo = parsed.docNo; - if (!metadata.docType) metadata.docType = parsed.docType; - if (!metadata.priority) metadata.priority = parsed.priority; - if (!metadata.year) metadata.year = parsed.year; - if (!metadata.sequence) metadata.sequence = parsed.sequence; -} - -function completeDocNo(metadata: DocMetadata): string { - return metadata.docType && metadata.priority && metadata.year && metadata.sequence - ? `DC-${metadata.docType}-${metadata.priority}-${metadata.year}-${metadata.sequence}` - : ""; -} - -function bodyFirstWindow(record: JsonRecord): string { - const body = stringField(record, ["body", "summary", "markdown"]); - return body.replace(/\r\n?/gu, "\n").split(/\n\s*\n/gu).map((part) => part.trim()).find(Boolean) || ""; -} - -function extractDocMetadata(record: JsonRecord): DocMetadata { - const metadata: DocMetadata = { docNo: "", docType: "", priority: "", year: "", sequence: "" }; - mergeParsed(metadata, parseDocNo(stringField(record, ["docNo", "documentNo", "documentNumber", "documentId"]))); - metadata.docType ||= normalizeDocType(stringField(record, ["docType", "documentType", "documentKind"])); - metadata.priority ||= stringField(record, ["docPriority", "priority", "level"]).toUpperCase(); - metadata.year ||= stringField(record, ["docYear", "year"]); - metadata.sequence ||= normalizeSequence(stringField(record, ["docSeq", "sequence", "seq", "docSequence", "documentSequence"])); - metadata.docNo ||= completeDocNo(metadata); - mergeParsed(metadata, parseDocNo(stringField(record, ["title"]))); - for (const value of tagValues(record, "doc-no")) mergeParsed(metadata, parseDocNo(value)); - metadata.docType ||= normalizeDocType(tagValues(record, "doc-type")[0]); - metadata.priority ||= String(tagValues(record, "doc-priority")[0] || "").toUpperCase(); - metadata.year ||= tagValues(record, "doc-year")[0] || ""; - metadata.sequence ||= normalizeSequence(tagValues(record, "doc-sequence")[0]); - metadata.docNo ||= completeDocNo(metadata); - mergeParsed(metadata, parseDocNo(bodyFirstWindow(record))); - metadata.docNo ||= completeDocNo(metadata); - return metadata; -} - -function extractDocNo(record: JsonRecord): string { - return extractDocMetadata(record).docNo; -} - -function groupCount(records: JsonRecord[], type: string): number { - const groups = new Map(); - for (const code of docTypeOrder) groups.set(code, []); - for (const record of records) { - const code = extractDocMetadata(record).docType || "DCSN"; - groups.get(code)?.push(record); - } - return groups.get(type as DocTypeCode)?.length || 0; -} - -export function runDecisionCenterWorkspaceContract(): JsonRecord { - const frontend = source("src/components/frontend/src/decision-center.tsx"); - const css = source("src/components/frontend/public/style.css"); - - const titleDcsn = { - id: "title-dcsn", - title: "DC-DCSN-P0-2026-001 决策记录", - tags: [], - }; - const tagGoal = { - id: "tag-goal", - title: "目标文书", - tags: ["doc-no:DC-GOAL-P0-2026-002"], - }; - const bodyRprt = { - id: "body-rprt", - title: "报告正文", - body: "DC-RPRT-P2-2026-003\n\n报告正文第一段。", - tags: [], - }; - const tagRetr = { - id: "tag-retr", - title: "复盘文书", - tags: ["doc-type:RETR"], - }; - const structuredPlan = { - id: "structured-plan", - title: "结构化计划文书", - docType: "PLAN", - priority: "P1", - year: 2026, - sequence: 4, - }; - - assertEqual(extractDocNo(titleDcsn), "DC-DCSN-P0-2026-001", "title prefix must extract complete DCSN doc number"); - assertEqual(extractDocMetadata(titleDcsn).docType, "DCSN", "title prefix must extract DCSN doc type"); - assertEqual(extractDocNo(tagGoal), "DC-GOAL-P0-2026-002", "doc-no tag must extract complete GOAL doc number"); - assertEqual(extractDocMetadata(tagGoal).docType, "GOAL", "doc-no tag must extract GOAL doc type"); - assertEqual(extractDocNo(bodyRprt), "DC-RPRT-P2-2026-003", "body first paragraph must fallback extract complete RPRT doc number"); - assertEqual(extractDocMetadata(bodyRprt).priority, "P2", "body fallback must extract RPRT priority"); - assertEqual(extractDocMetadata(tagRetr).docType, "RETR", "doc-type tag must extract RETR doc type"); - assertEqual(docTypeLabels.RETR, "复盘", "RETR label must be 复盘"); - assertEqual(extractDocNo(structuredPlan), "DC-PLAN-P1-2026-004", "structured fields must compose complete PLAN doc number"); - assertEqual(groupCount([titleDcsn, tagGoal, bodyRprt, tagRetr, structuredPlan], "DCSN"), 1, "DCSN records must be grouped by parsed type"); - assertEqual(groupCount([titleDcsn, tagGoal, bodyRprt, tagRetr, structuredPlan], "GOAL"), 1, "GOAL records must be grouped by parsed type"); - assertEqual(groupCount([titleDcsn, tagGoal, bodyRprt, tagRetr, structuredPlan], "RPRT"), 1, "RPRT records must be grouped by parsed type"); - assertEqual(groupCount([titleDcsn, tagGoal, bodyRprt, tagRetr, structuredPlan], "RETR"), 1, "RETR records must be grouped by parsed type"); - assertEqual(groupCount([titleDcsn, tagGoal, bodyRprt, tagRetr, structuredPlan], "PLAN"), 1, "PLAN records must be grouped by structured type"); - - assertCondition( - includesAll(frontend, [ - "export function extractDocMetadata(record: any): DocMetadata", - "export function extractDocNo(record: any): string", - "export function buildDocTypeTree(records: any[]): Array<{ type: DocTypeCode", - "function buildTagGroups(records: any[]): Array<{ tag: string", - "DOC_TYPE_CODES = [\"DCSN\", \"GOAL\", \"PLAN\", \"RPRT\", \"ACTN\", \"ISSU\", \"RETR\", \"RQST\", \"RESP\", \"MINS\"]", - "docTypeLabels: Record", - ]), - "frontend must implement exported doc metadata extraction, doc-type tree, and tag grouping", - ); - - assertCondition( - includesAll(frontend, [ - "function DocTreeNode({ record, depth, onSelect, selectedId }: AnyRecord)", - "function DocTreeGroup({ group, onSelect, selectedId, defaultOpen }: AnyRecord)", - "function TagTreeGroup({ group, onSelect, selectedId, defaultOpen }: AnyRecord)", - "function DocTypeTree({ records, onSelect, selectedId, activeGrouping }: AnyRecord)", - ]), - "frontend must implement DocTreeNode, DocTreeGroup, TagTreeGroup, and DocTypeTree components", - ); - - assertCondition( - includesAll(frontend, [ - "function DocDetailSidebar({ record, onSelect, onSave, saving, error, saveMsg, onRaw }: AnyRecord)", - "function DocEditor({ form, saving, onChange, onSubmit }: AnyRecord)", - "function DocViewer({ record }: AnyRecord)", - "function DocDetailHeader({ record, onBack }: AnyRecord)", - ]), - "frontend must implement DocDetailSidebar, DocEditor, DocViewer, and DocDetailHeader", - ); - - assertCondition( - includesAll(frontend, [ - "function DocWorkspace({ records, selectedRecord, onSelect, onSave, saving, saveError, saveMsg, onRaw }: AnyRecord)", - "h(DocWorkspace, {", - "activeView === \"workspace\"", - "decision-tab-workspace", - ]), - "frontend must implement DocWorkspace component and workspace tab", - ); - - assertCondition( - includesAll(frontend, [ - "mode === \"view\"", - "mode === \"edit\"", - "setMode(\"view\")", - "setMode(\"edit\")", - "doc-sidebar-mode-tabs", - ]), - "frontend must implement read/edit mode toggle in DocDetailSidebar", - ); - - assertCondition( - includesAll(frontend, [ - "selectedRecord?.id", - "setState((prev: any) => ({ ...prev, selectedDoc: record", - "state.selectedDoc", - ]), - "frontend must track selectedDoc in state and sync on selection", - ); - - assertCondition( - includesAll(frontend, [ - "await onSave(form)", - "recordPayloadFromForm(form)", - "setRecordSaveState({ saving: false, error: \"\", message:", - "setState((prev: any) => ({ ...prev, selectedDoc: saved", - ]), - "frontend must call onSave, payload conversion, error handling, and sync selectedDoc after save", - ); - - assertCondition( - css.includes(".doc-workspace"), - "CSS must include .doc-workspace layout", - ); - - assertCondition( - css.includes(".doc-sidebar"), - "CSS must include .doc-sidebar", - ); - - assertCondition( - css.includes("grid-template-columns: minmax(200px, 260px) minmax(0, 1fr) minmax(0, 50%)"), - "CSS must implement three-column layout with right sidebar at 50%", - ); - - assertCondition( - includesAll(css, [ - ".doc-type-tree", - ".doc-tree-group", - ".doc-tree-node", - ".doc-list-item", - ".doc-full-markdown", - ".doc-editor-form", - ]), - "CSS must include all workspace component styles", - ); - - assertCondition( - includesAll(frontend, [ - "extractDocMetadata(record)", - "recordTagValues(record, \"doc-no\")", - "firstBodyWindow(record)", - ]), - "extractDocNo must use metadata extraction from title, doc-no tag, and body fallback", - ); - - assertCondition( - includesAll(frontend, [ - "grouping === \"type\"", - "grouping === \"tag\"", - "setGrouping(\"type\")", - "setGrouping(\"tag\")", - ]), - "workspace must support type and tag grouping toggle", - ); - - return { - ok: true, - checks: [ - "doc-no-extraction-functions", - "doc-type-tree-components", - "doc-detail-sidebar-components", - "doc-workspace-component", - "read-edit-mode-toggle", - "selected-doc-state-sync", - "save-and-sync", - "workspace-css-layout", - "workspace-css-components", - "three-column-layout-50-percent", - "doc-metadata-fixtures", - "type-tag-grouping-toggle", - ], - }; -} - -if (import.meta.main) { - process.stdout.write(`${JSON.stringify(runDecisionCenterWorkspaceContract(), null, 2)}\n`); -} diff --git a/scripts/deploy-artifact-matrix-contract-test.ts b/scripts/deploy-artifact-matrix-contract-test.ts deleted file mode 100644 index c3f4b204..00000000 --- a/scripts/deploy-artifact-matrix-contract-test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { spawnSync } from "node:child_process"; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function asRecord(value: unknown, label: string): Record { - assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, value); - return value as Record; -} - -function runDeployPlan(environment: "dev" | "prod", serviceId: string): Record { - const result = spawnSync("bun", ["scripts/cli.ts", "deploy", "plan", "--env", environment, "--service", serviceId], { - cwd: process.cwd(), - encoding: "utf8", - maxBuffer: 8 * 1024 * 1024, - }); - assertCondition(result.status === 0, `deploy plan should exit 0 for ${environment}/${serviceId}`, { - status: result.status, - stdout: result.stdout.slice(-2000), - stderr: result.stderr.slice(-2000), - }); - const envelope = asRecord(JSON.parse(result.stdout) as unknown, "cli envelope"); - assertCondition(envelope.ok === true, `deploy plan envelope should be ok for ${environment}/${serviceId}`, envelope); - const data = asRecord(envelope.data, "deploy plan data"); - const services = Array.isArray(data.services) ? data.services : []; - assertCondition(services.length === 1, `deploy plan should return one service for ${environment}/${serviceId}`, data); - return asRecord(services[0], "service plan"); -} - -function listIncludes(value: unknown, expected: string): boolean { - return Array.isArray(value) && value.some((item) => item === expected); -} - -function assertMainServerComposeConsumer( - environment: "dev" | "prod", - serviceId: string, - expectedComposeService: string, - expectedContainerName: string, -): void { - const plan = runDeployPlan(environment, serviceId); - const artifact = asRecord(plan.artifactConsumer, `${serviceId} ${environment} artifactConsumer`); - const target = asRecord(plan.target, `${serviceId} ${environment} target`); - const registry = asRecord(artifact.registry, `${serviceId} ${environment} registry`); - const build = asRecord(artifact.build, `${serviceId} ${environment} build`); - assertCondition(plan.deploymentPath === "d601-registry-artifact-consumer", `${serviceId} ${environment} deployment path must be artifact consumer`, plan); - assertCondition(artifact.consumerKind === "main-server-compose", `${serviceId} ${environment} must be a main-server Compose artifact consumer`, artifact); - assertCondition(artifact.noRuntimeSourceBuild === true, `${serviceId} ${environment} plan must declare no runtime source build`, artifact); - assertCondition(artifact.dryRunOnly === false, `${serviceId} ${environment} should not be dry-run-only`, artifact); - assertCondition(String(artifact.registryImage ?? "").includes(`127.0.0.1:5000/unidesk/${serviceId}:`), `${serviceId} registry image missing`, artifact); - assertCondition(registry.repository === `unidesk/${serviceId}`, `${serviceId} registry repository mismatch`, registry); - assertCondition(registry.digest === null, `${serviceId} plan must not fake digest`, registry); - assertCondition(build.willRunDockerBuild === false, `${serviceId} CD must not run docker build`, build); - assertCondition(build.willRunDockerComposeBuild === false, `${serviceId} CD must not run docker compose build`, build); - assertCondition(build.producerBoundary === "ci publish-user-service", `${serviceId} producer boundary mismatch`, build); - assertCondition(target.runtimeHost === "main-server", `${serviceId} target should be main-server`, target); - assertCondition(target.composeService === expectedComposeService, `${serviceId} compose service mismatch`, target); - assertCondition(target.containerName === expectedContainerName, `${serviceId} container mismatch`, target); - assertCondition(listIncludes(target.forbiddenActions, "docker build"), `${serviceId} plan should forbid docker build`, target); - assertCondition(listIncludes(target.forbiddenActions, "docker compose build"), `${serviceId} plan should forbid compose build`, target); - if (serviceId === "baidu-netdisk") { - const runtimeSecrets = asRecord(artifact.runtimeSecrets, `${serviceId} ${environment} runtimeSecrets`); - const secretSource = asRecord(runtimeSecrets.secretSource, `${serviceId} ${environment} secretSource`); - const requirements = Array.isArray(runtimeSecrets.requirements) ? runtimeSecrets.requirements.map((item, index) => asRecord(item, `${serviceId} ${environment} requirement ${index}`)) : []; - assertCondition(runtimeSecrets.check === "runtime-secret-presence", `${serviceId} should expose secret presence check`, runtimeSecrets); - assertCondition(secretSource.kind === "compose-env-file", `${serviceId} should name compose env secret source`, secretSource); - assertCondition(secretSource.valuesPrinted === false && runtimeSecrets.valuesPrinted === false, `${serviceId} must not print secret values`, runtimeSecrets); - assertCondition(typeof runtimeSecrets.requiredSecretsPresent === "boolean", `${serviceId} requiredSecretsPresent should be boolean`, runtimeSecrets); - assertCondition(Array.isArray(runtimeSecrets.missingSecretKeys), `${serviceId} missingSecretKeys should be an array`, runtimeSecrets); - assertCondition(typeof runtimeSecrets.recommendedAction === "string" && runtimeSecrets.recommendedAction.length > 0, `${serviceId} recommendedAction should be explicit`, runtimeSecrets); - assertCondition(requirements.length === 3, `${serviceId} should list three required source secrets`, runtimeSecrets); - assertCondition(requirements.every((item) => item.valuePrinted === false), `${serviceId} requirements must not print values`, requirements); - assertCondition(!JSON.stringify(runtimeSecrets).includes("0123456789abcdef"), `${serviceId} must not leak secret-looking values`, runtimeSecrets); - } -} - -assertMainServerComposeConsumer("dev", "baidu-netdisk", "baidu-netdisk", "baidu-netdisk-backend"); -assertMainServerComposeConsumer("prod", "baidu-netdisk", "baidu-netdisk", "baidu-netdisk-backend"); -assertMainServerComposeConsumer("dev", "project-manager", "project-manager", "project-manager-backend"); -assertMainServerComposeConsumer("prod", "project-manager", "project-manager", "project-manager-backend"); - -const findjob = runDeployPlan("dev", "findjob"); -const findjobArtifact = asRecord(findjob.artifactConsumer, "findjob artifactConsumer"); -const findjobTarget = asRecord(findjob.target, "findjob target"); -assertCondition(findjobArtifact.consumerKind === "d601-direct-compose", "findjob dev must be a D601 direct Compose artifact consumer", findjobArtifact); -assertCondition(findjobArtifact.noRuntimeSourceBuild === true, "findjob dry-run must declare no runtime source build", findjobArtifact); -assertCondition(findjobTarget.runtimeHost === "D601", "findjob target should be D601", findjobTarget); -assertCondition(findjobTarget.composeService === "server", "findjob compose service should be server", findjobTarget); -assertCondition(listIncludes(findjobTarget.forbiddenActions, "docker compose build"), "findjob plan should forbid Compose build", findjobTarget); - -const backendCoreDev = runDeployPlan("dev", "backend-core"); -const backendCoreArtifact = asRecord(backendCoreDev.artifactConsumer, "backend-core dev artifactConsumer"); -const backendCoreTarget = asRecord(backendCoreDev.target, "backend-core dev target"); -const backendCoreRegistry = asRecord(backendCoreArtifact.registry, "backend-core dev registry"); -const backendCoreSource = asRecord(backendCoreArtifact.source, "backend-core dev source"); -const backendCoreBuild = asRecord(backendCoreArtifact.build, "backend-core dev build"); -assertCondition(backendCoreDev.deploymentPath === "d601-registry-artifact-consumer", "backend-core dev deployment path must be artifact consumer", backendCoreDev); -assertCondition(backendCoreArtifact.consumerKind === "d601-k3s-managed", "backend-core dev must be a D601 k3s-managed artifact consumer", backendCoreArtifact); -assertCondition(backendCoreArtifact.noRuntimeSourceBuild === true, "backend-core dev plan must declare no runtime source build", backendCoreArtifact); -assertCondition(String(backendCoreArtifact.registryImage ?? "").includes("127.0.0.1:5000/unidesk/backend-core:"), "backend-core dev registry image missing", backendCoreArtifact); -assertCondition(backendCoreRegistry.repository === "unidesk/backend-core", "backend-core dev registry repository mismatch", backendCoreRegistry); -assertCondition(backendCoreRegistry.digest === null, "backend-core dev plan must not fake digest", backendCoreRegistry); -assertCondition(String(backendCoreRegistry.digestSource ?? "").includes("manifest HEAD"), "backend-core dev digest source should name manifest HEAD", backendCoreRegistry); -assertCondition(backendCoreSource.repo === "https://github.com/pikasTech/unidesk", "backend-core dev source repo mismatch", backendCoreSource); -assertCondition(backendCoreBuild.willCompile === false, "backend-core dev plan must not compile in CD", backendCoreBuild); -assertCondition(backendCoreBuild.willRunCargoBuild === false, "backend-core dev plan must not run cargo build in CD", backendCoreBuild); -assertCondition(backendCoreBuild.willRunDockerBuild === false, "backend-core dev plan must not run docker build in CD", backendCoreBuild); -assertCondition(backendCoreBuild.producerBoundary === "ci publish-backend-core", "backend-core dev producer boundary mismatch", backendCoreBuild); -assertCondition(backendCoreTarget.namespace === "unidesk-dev", "backend-core dev target namespace should be unidesk-dev", backendCoreTarget); -assertCondition(backendCoreTarget.deployment === "backend-core-dev", "backend-core dev deployment should be backend-core-dev", backendCoreTarget); -assertCondition(backendCoreTarget.service === "backend-core-dev", "backend-core dev service should be backend-core-dev", backendCoreTarget); -assertCondition(backendCoreTarget.targetImage === "unidesk-backend-core:dev", "backend-core dev target image mismatch", backendCoreTarget); -assertCondition(listIncludes(backendCoreTarget.forbiddenActions, "docker build"), "backend-core dev plan should forbid docker build", backendCoreTarget); -assertCondition(listIncludes(backendCoreTarget.forbiddenActions, "docker compose build"), "backend-core dev plan should forbid compose build", backendCoreTarget); - -const mdtodo = runDeployPlan("prod", "mdtodo"); -const mdtodoArtifact = asRecord(mdtodo.artifactConsumer, "mdtodo artifactConsumer"); -const mdtodoTarget = asRecord(mdtodo.target, "mdtodo target"); -assertCondition(mdtodoArtifact.consumerKind === "d601-k3s-managed", "mdtodo prod must be a D601 k3s-managed artifact consumer", mdtodoArtifact); -assertCondition(mdtodoArtifact.noRuntimeSourceBuild === true, "mdtodo dry-run must declare no runtime source build", mdtodoArtifact); -assertCondition(mdtodoTarget.namespace === "unidesk", "mdtodo prod target namespace should be unidesk", mdtodoTarget); -assertCondition(listIncludes(mdtodoTarget.forbiddenActions, "NodePort"), "mdtodo plan should forbid NodePort", mdtodoTarget); - -const metNonlinear = runDeployPlan("prod", "met-nonlinear"); -const metArtifact = asRecord(metNonlinear.artifactConsumer, "met-nonlinear artifactConsumer"); -assertCondition(metArtifact.consumerKind === "d601-direct-compose", "met-nonlinear should remain D601 direct Compose", metArtifact); -assertCondition(metArtifact.dryRunOnly === true, "met-nonlinear must remain dry-run only", metArtifact); -assertCondition(String(metArtifact.blockedReason ?? "").includes("runtime-verification-blocked"), "met-nonlinear blocked reason should mention runtime verification", metArtifact); - -const k3sctl = runDeployPlan("prod", "k3sctl-adapter"); -const k3sctlArtifact = asRecord(k3sctl.artifactConsumer, "k3sctl-adapter artifactConsumer"); -const k3sctlTarget = asRecord(k3sctl.target, "k3sctl-adapter target"); -assertCondition(k3sctlArtifact.consumerKind === "d601-direct-compose", "k3sctl-adapter should be D601 direct Compose", k3sctlArtifact); -assertCondition(k3sctlArtifact.dryRunOnly === true, "k3sctl-adapter must be dry-run only for worker automation", k3sctlArtifact); -assertCondition(String(k3sctlArtifact.blockedReason ?? "").includes("supervisor"), "k3sctl-adapter blocked reason should mention supervisor confirmation", k3sctlArtifact); -assertCondition(k3sctlTarget.composeService === "k3sctl-adapter", "k3sctl target service should be k3sctl-adapter", k3sctlTarget); - -process.stdout.write(`${JSON.stringify({ - ok: true, - checks: [ - "deploy plan models dev backend-core as a no-build D601 k3s artifact consumer", - "dev backend-core plan exposes registry/source/build boundaries and target metadata", - "baidu-netdisk and project-manager dev/prod plans are no-build main-server Compose artifact consumers", - "deploy plan distinguishes D601 direct Compose from D601 k3s-managed artifact consumers", - "deploy plan exposes no-runtime-source-build and forbidden build/public-port actions", - "met-nonlinear remains runtime-verification-blocked", - "k3sctl-adapter remains supervisor-only dry-run", - ], -}, null, 2)}\n`); diff --git a/scripts/frontend-artifact-lane-contract-test.ts b/scripts/frontend-artifact-lane-contract-test.ts deleted file mode 100644 index 9e82941a..00000000 --- a/scripts/frontend-artifact-lane-contract-test.ts +++ /dev/null @@ -1,304 +0,0 @@ -import { readFileSync } from "node:fs"; -import { rootPath } from "./src/config"; -import { runArtifactRegistryCommand } from "./src/artifact-registry"; - -type JsonRecord = Record; - -const frontendCommit = "b5486a61ab0aa6c227366a95d1afa68281584359"; -const frontendDigest = "sha256:76d7c47e797605470959ca2274f116149bdc367e6fa155913d19f42516e5b9e4"; -const frontendRepo = "https://github.com/pikasTech/unidesk"; -const frontendDockerfile = "src/components/frontend/Dockerfile"; -const frontendRegistryRepository = "unidesk/frontend"; -const frontendImageRef = `127.0.0.1:5000/${frontendRegistryRepository}:${frontendCommit}`; - -const observedEvidence = { - registry: { - imageRef: frontendImageRef, - contentType: "application/vnd.docker.distribution.manifest.v2+json", - digest: frontendDigest, - }, - health: { - dev: { - ok: true, - service: "unidesk-frontend", - deploy: { - serviceId: "frontend", - repo: frontendRepo, - commit: frontendCommit, - requestedCommit: frontendCommit, - }, - environment: "dev", - namespace: "unidesk-dev", - databaseName: "unidesk_dev", - serviceId: "frontend", - deployRef: "origin/master:deploy.json#environments.dev.services.frontend", - }, - prod: { - ok: true, - service: "unidesk-frontend", - deploy: { - serviceId: "frontend", - repo: frontendRepo, - commit: frontendCommit, - requestedCommit: frontendCommit, - }, - }, - }, - publishDryRun: { - ok: true, - mode: "dry-run-preflight", - runnerDisposition: "ready", - supportedArtifactPublish: true, - serviceId: "frontend", - commit: frontendCommit, - missingControlChannels: [], - controlChannels: [ - { channel: "backend-core", ok: true }, - { channel: "database", ok: true }, - { channel: "provider", ok: true }, - { channel: "registry", ok: true }, - ], - registry: { - runtimeApiHealthy: true, - decision: "service-degraded", - failedScopes: ["rendered-config", "registry-image"], - healthyScopes: ["systemd", "docker", "registry-container", "loopback-listener", "registry-api", "storage"], - }, - artifactSummary: { - serviceId: "frontend", - sourceCommit: frontendCommit, - sourceRepo: frontendRepo, - dockerfile: frontendDockerfile, - registry: "127.0.0.1:5000", - repository: "127.0.0.1:5000/unidesk/frontend", - tag: frontendCommit, - imageRef: frontendImageRef, - digest: null, - digestRef: null, - }, - controlledPublish: { - environment: "D601", - namespace: "unidesk-ci", - pipeline: "unidesk-user-service-artifact-publish", - command: `bun scripts/cli.ts ci publish-user-service --service frontend --commit ${frontendCommit} --wait-ms 1200000`, - requiresReadyControlChannels: ["backend-core", "database", "provider", "registry"], - }, - boundary: "preflight is read-only: no D601 source export, no Tekton PipelineRun, no image push, no deploy apply, no service restart", - }, -} satisfies JsonRecord; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function asRecord(value: unknown, label: string): JsonRecord { - assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, { value }); - return value as JsonRecord; -} - -function asArray(value: unknown, label: string): unknown[] { - assertCondition(Array.isArray(value), `${label} must be an array`, { value }); - return value as unknown[]; -} - -function stringArray(value: unknown, label: string): string[] { - return asArray(value, label).map(String); -} - -function manifestService(manifest: JsonRecord, environment: "dev" | "prod", serviceId: string): JsonRecord { - const environments = asRecord(manifest.environments, "deploy.json.environments"); - const env = asRecord(environments[environment], `deploy.json.environments.${environment}`); - const services = asArray(env.services, `deploy.json.environments.${environment}.services`); - const service = services.map((item, index) => asRecord(item, `${environment}.services[${index}]`)).find((item) => item.id === serviceId); - assertCondition(service !== undefined, `deploy.json ${environment} must include ${serviceId}`, env); - return service as JsonRecord; -} - -function assertDeployJson(): void { - const manifest = asRecord(JSON.parse(readFileSync(rootPath("deploy.json"), "utf8")) as unknown, "deploy.json"); - assertCondition(manifest.schemaVersion === 2, "deploy.json must use schemaVersion=2", manifest); - for (const environment of ["dev", "prod"] as const) { - const service = manifestService(manifest, environment, "frontend"); - assertCondition(service.repo === frontendRepo, `${environment} frontend repo must stay on UniDesk`, service); - assertCondition(service.commitId === frontendCommit, `${environment} frontend desired commit must match the reviewed artifact`, service); - } -} - -function assertCiCatalog(): void { - const catalog = asRecord(JSON.parse(readFileSync(rootPath("CI.json"), "utf8")) as unknown, "CI.json"); - const artifacts = asArray(catalog.artifacts, "CI.json.artifacts").map((item, index) => asRecord(item, `CI.json.artifacts[${index}]`)); - const frontend = artifacts.find((item) => item.serviceId === "frontend"); - assertCondition(frontend !== undefined, "CI.json must include frontend", catalog); - const artifact = frontend as JsonRecord; - const source = asRecord(artifact.source, "CI.json frontend.source"); - const image = asRecord(artifact.image, "CI.json frontend.image"); - assertCondition(artifact.kind === "source-build", "frontend CI producer must be source-build", artifact); - assertCondition(artifact.status === "supported", "frontend CI producer must be supported", artifact); - assertCondition(artifact.producer === "ci publish-user-service", "frontend CI producer must use publish-user-service", artifact); - assertCondition(source.repo === frontendRepo, "frontend source repo mismatch", source); - assertCondition(source.dockerfile === frontendDockerfile, "frontend Dockerfile mismatch", source); - assertCondition(image.repository === frontendRegistryRepository, "frontend registry repository mismatch", image); -} - -function assertObservedHealth(environment: "dev" | "prod"): void { - const health = asRecord(asRecord(observedEvidence.health, "health evidence")[environment], `${environment} health`); - const deploy = asRecord(health.deploy, `${environment} health.deploy`); - assertCondition(health.ok === true, `${environment} health must be ok`, health); - assertCondition(health.service === "unidesk-frontend", `${environment} health service mismatch`, health); - assertCondition(deploy.serviceId === "frontend", `${environment} health deploy service id mismatch`, deploy); - assertCondition(deploy.repo === frontendRepo, `${environment} health deploy repo mismatch`, deploy); - assertCondition(deploy.commit === frontendCommit, `${environment} health deploy commit mismatch`, deploy); - assertCondition(deploy.requestedCommit === frontendCommit, `${environment} health requested commit mismatch`, deploy); - if (environment === "dev") { - assertCondition(health.environment === "dev", "dev health must expose dev environment", health); - assertCondition(health.namespace === "unidesk-dev", "dev health must expose unidesk-dev namespace", health); - assertCondition(health.deployRef === "origin/master:deploy.json#environments.dev.services.frontend", "dev health deployRef mismatch", health); - } -} - -function assertRegistryDigest(): void { - const registry = asRecord(observedEvidence.registry, "registry evidence"); - assertCondition(registry.imageRef === frontendImageRef, "registry image ref mismatch", registry); - assertCondition(registry.contentType === "application/vnd.docker.distribution.manifest.v2+json", "registry digest must come from the v2 manifest", registry); - assertCondition(registry.digest === frontendDigest, "registry digest mismatch", registry); - assertCondition(/^sha256:[0-9a-f]{64}$/u.test(String(registry.digest)), "registry digest must be a sha256 manifest digest", registry); -} - -function assertPublishDryRunReady(): void { - const preflight = asRecord(observedEvidence.publishDryRun, "publish dry-run evidence"); - const missingControlChannels = stringArray(preflight.missingControlChannels, "publish missingControlChannels"); - const artifactSummary = asRecord(preflight.artifactSummary, "publish artifactSummary"); - const controlledPublish = asRecord(preflight.controlledPublish, "publish controlledPublish"); - const registry = asRecord(preflight.registry, "publish registry"); - const controlChannels = asArray(preflight.controlChannels, "publish controlChannels").map((item, index) => asRecord(item, `publish controlChannels[${index}]`)); - - assertCondition(preflight.ok === true, "frontend publish dry-run must be ready", preflight); - assertCondition(preflight.mode === "dry-run-preflight", "frontend publish dry-run mode mismatch", preflight); - assertCondition(preflight.runnerDisposition === "ready", "frontend publish dry-run runnerDisposition mismatch", preflight); - assertCondition(preflight.supportedArtifactPublish === true, "frontend publish must be supported", preflight); - assertCondition(missingControlChannels.length === 0, "frontend publish dry-run must not miss control channels", preflight); - for (const channel of ["backend-core", "database", "provider", "registry"]) { - assertCondition(controlChannels.some((item) => item.channel === channel && item.ok === true), `publish dry-run ${channel} channel must be ready`, controlChannels); - } - assertCondition(registry.runtimeApiHealthy === true, "registry runtime API must be healthy for publish readiness", registry); - assertCondition(stringArray(registry.healthyScopes, "publish registry.healthyScopes").includes("registry-api"), "registry-api scope must be healthy", registry); - assertCondition(artifactSummary.imageRef === frontendImageRef, "publish artifact image must be commit-pinned", artifactSummary); - assertCondition(artifactSummary.digest === null && artifactSummary.digestRef === null, "publish dry-run must not fake a digest", artifactSummary); - assertCondition(controlledPublish.environment === "D601", "controlled publish environment mismatch", controlledPublish); - assertCondition(controlledPublish.namespace === "unidesk-ci", "controlled publish namespace mismatch", controlledPublish); - assertCondition(controlledPublish.pipeline === "unidesk-user-service-artifact-publish", "controlled publish pipeline mismatch", controlledPublish); - assertCondition(stringArray(controlledPublish.requiresReadyControlChannels, "controlled publish channels").join(",") === "backend-core,database,provider,registry", "controlled publish required channel order mismatch", controlledPublish); - assertCondition(String(preflight.boundary).includes("read-only"), "publish dry-run boundary must be read-only", preflight); - assertCondition(!String(preflight.boundary).includes("service restart") || String(preflight.boundary).includes("no service restart"), "publish dry-run boundary must forbid restarts", preflight); -} - -function assertNoRuntimeBuild(plan: JsonRecord, environment: "dev" | "prod"): void { - const build = asRecord(plan.build, `${environment} build`); - const registry = asRecord(plan.registry, `${environment} registry`); - const source = asRecord(plan.source, `${environment} source`); - const target = asRecord(plan.target, `${environment} target`); - const validation = stringArray(plan.validation, `${environment} validation`); - const labels = asRecord(plan.requiredLabels, `${environment} labels`); - const registryProbe = asRecord(plan.registryProbe, `${environment} registryProbe`); - - assertCondition(plan.ok === true && plan.supported === true, `${environment} frontend dry-run must be supported`, plan); - assertCondition(plan.dryRun === true && plan.mutation === false, `${environment} frontend dry-run must be non-mutating`, plan); - assertCondition(plan.environment === environment, `${environment} dry-run environment mismatch`, plan); - assertCondition(plan.providerId === "D601", `${environment} dry-run provider mismatch`, plan); - assertCondition(plan.serviceId === "frontend", `${environment} dry-run service id mismatch`, plan); - assertCondition(plan.commit === frontendCommit, `${environment} dry-run commit mismatch`, plan); - assertCondition(plan.sourceImage === frontendImageRef, `${environment} dry-run source image mismatch`, plan); - assertCondition(source.repo === frontendRepo && source.commit === frontendCommit && source.dockerfile === frontendDockerfile, `${environment} source boundary mismatch`, source); - assertCondition(registry.imageRef === frontendImageRef, `${environment} registry image ref mismatch`, registry); - assertCondition(registry.digest === null, `${environment} dry-run must not fake registry digest`, registry); - assertCondition(String(registry.digestSource ?? "").includes("live apply must read this digest"), `${environment} digest source must name live registry HEAD`, registry); - assertCondition(registryProbe.method === "HEAD", `${environment} registry probe must be HEAD-only`, registryProbe); - assertCondition(labels["unidesk.ai/service-id"] === "frontend", `${environment} service label mismatch`, labels); - assertCondition(labels["unidesk.ai/source-commit"] === frontendCommit, `${environment} source commit label mismatch`, labels); - assertCondition(labels["unidesk.ai/dockerfile"] === frontendDockerfile, `${environment} Dockerfile label mismatch`, labels); - assertCondition(build.willCompile === false, `${environment} CD must not compile`, build); - assertCondition(build.willRunCargoBuild === false, `${environment} CD must not run cargo build`, build); - assertCondition(build.willRunDockerBuild === false, `${environment} CD must not run docker build`, build); - assertCondition(build.willRunDockerComposeBuild === false, `${environment} CD must not run docker compose build`, build); - assertCondition(build.producerBoundary === "ci publish-user-service", `${environment} producer boundary mismatch`, build); - assertCondition(String(plan.boundary ?? "").includes("artifact-consumer only"), `${environment} boundary must say artifact-consumer only`, plan); - assertCondition(String(plan.boundary ?? "").includes("never builds source"), `${environment} boundary must forbid runtime source builds`, plan); - - if (environment === "dev") { - assertCondition(target.kind === "d601-k3s", "dev frontend target must be D601 k3s", target); - assertCondition(target.namespace === "unidesk-dev", "dev frontend namespace mismatch", target); - assertCondition(target.deployment === "frontend-dev", "dev frontend deployment mismatch", target); - assertCondition(target.service === "frontend-dev", "dev frontend service mismatch", target); - assertCondition(target.runtimeImage === `unidesk-frontend:${frontendCommit}`, "dev frontend runtime image mismatch", target); - assertCondition(validation.some((line) => line.includes("Kubernetes API service proxy")), "dev validation must use Kubernetes API service proxy", validation); - } else { - assertCondition(target.kind === "compose", "prod frontend target must be Compose", target); - assertCondition(target.runtimeHost === "main-server", "prod frontend runtime host mismatch", target); - assertCondition(target.composeService === "frontend", "prod frontend Compose service mismatch", target); - assertCondition(target.containerName === "unidesk-frontend", "prod frontend container mismatch", target); - assertCondition(target.deployCommandShape === "docker compose up -d --no-build --no-deps --force-recreate frontend", "prod frontend deploy command shape mismatch", target); - assertCondition(validation.some((line) => line.includes("deploy.commit/deploy.requestedCommit")), "prod validation must require health commit metadata", validation); - } -} - -async function main(): Promise { - assertDeployJson(); - assertCiCatalog(); - assertRegistryDigest(); - assertObservedHealth("dev"); - assertObservedHealth("prod"); - assertPublishDryRunReady(); - - const devDryRun = asRecord(await runArtifactRegistryCommand([ - "deploy-service", - "--env", - "dev", - "--service", - "frontend", - "--commit", - frontendCommit, - "--dry-run", - ]), "dev artifact dry-run"); - const prodDryRun = asRecord(await runArtifactRegistryCommand([ - "deploy-service", - "--env", - "prod", - "--service", - "frontend", - "--commit", - frontendCommit, - "--dry-run", - ]), "prod artifact dry-run"); - - assertNoRuntimeBuild(devDryRun, "dev"); - assertNoRuntimeBuild(prodDryRun, "prod"); - - process.stdout.write(`${JSON.stringify({ - ok: true, - checks: [ - "deploy.json dev/prod desired frontend commit matches the reviewed artifact", - "CI.json frontend producer remains ci publish-user-service source-build", - "registry v2 manifest digest is pinned as contract evidence", - "dev and prod health report matching deploy.commit/requestedCommit", - "publish-user-service dry-run is ready and read-only", - "dev CD dry-run is D601 k3s artifact-only and non-mutating", - "prod CD dry-run is main-server Compose artifact-only and non-mutating", - ], - frontend: { - commit: frontendCommit, - artifact: frontendImageRef, - digest: frontendDigest, - devHealthCommit: frontendCommit, - prodHealthCommit: frontendCommit, - uiAcceptanceIndependentOfCiCd: true, - }, - dryRunTargets: { - dev: asRecord(devDryRun.target, "dev target"), - prod: asRecord(prodDryRun.target, "prod target"), - }, - }, null, 2)}\n`); -} - -if (import.meta.main) { - await main(); -} diff --git a/scripts/gc-remote-growth-contract-test.ts b/scripts/gc-remote-growth-contract-test.ts deleted file mode 100644 index 8e8ae015..00000000 --- a/scripts/gc-remote-growth-contract-test.ts +++ /dev/null @@ -1,109 +0,0 @@ -const sourceText = await Bun.file(new URL("./src/gc-remote.ts", import.meta.url)).text(); - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function functionBody(name: string): string { - const marker = `def ${name}(`; - const start = sourceText.indexOf(marker); - if (start < 0) return ""; - const next = sourceText.indexOf("\ndef ", start + marker.length); - return sourceText.slice(start, next < 0 ? sourceText.length : next); -} - -assertCondition( - sourceText.includes('if (subaction === "snapshot" || subaction === "growth")') - && sourceText.includes('if (subaction === "trend")') - && sourceText.includes('supportedActions: ["plan", "snapshot", "trend", "run", "status"]'), - "gc remote must expose snapshot/growth and trend actions", -); - -assertCondition( - sourceText.includes("historyLimit: number") - && sourceText.includes("saveSnapshot: boolean") - && sourceText.includes("--history-limit") - && sourceText.includes("--no-save"), - "gc remote snapshot must expose bounded history and no-save options", -); - -const snapshotBody = functionBody("collect_growth_snapshot"); -assertCondition( - snapshotBody.includes('"action": "gc remote snapshot"') - && snapshotBody.includes('"diagnosticStateMutation"') - && snapshotBody.includes("disk_source_snapshot()") - && snapshotBody.includes("ci_storage_snapshot()") - && snapshotBody.includes("registry_growth_snapshot()") - && snapshotBody.includes("containerd_breakdown_snapshot()"), - "growth snapshot must include disk sources, CI PVC ownership, registry, containerd and diagnostic-state disclosure", - snapshotBody.slice(0, 1200), -); - -const storageBody = functionBody("ci_storage_snapshot"); -assertCondition( - storageBody.includes('"hwlab"') - && storageBody.includes('"agentrun"') - && storageBody.includes('"byOwnerGroup"') - && storageBody.includes("hwlab g14 control-plane cleanup-runs") - && storageBody.includes("agentrun control-plane cleanup-runs") - && storageBody.includes("hostPath") - && storageBody.includes("activeMountPods") - && storageBody.includes("estimatedBytes") - && storageBody.includes('phase in set(["Succeeded", "Failed"])'), - "CI storage snapshot must keep HWLAB and AgentRun owner handoff plus reclaim visibility", - storageBody.slice(0, 1600), -); - -const registryBody = functionBody("registry_growth_snapshot"); -assertCondition( - registryBody.includes('"dryRun": "daily or before/after every v0.2 CI/CD burst"') - && registryBody.includes('"maintenanceRun": "weekly, or when root >=80%, or when registry growth exceeds the agreed daily threshold"') - && registryBody.includes("plan_registry_retention()") - && registryBody.includes("protected tags") - && registryBody.includes("newest N tags per repo"), - "registry growth snapshot must disclose dry-run cadence, maintenance trigger and retention protections", - registryBody, -); - -const containerdBody = functionBody("containerd_breakdown_snapshot"); -assertCondition( - containerdBody.includes('"state": "observation-only"') - && containerdBody.includes('"cleanupSupported": False') - && containerdBody.includes("reference-safe image/content classifier"), - "containerd section must stay observation-only until a safe classifier exists", - containerdBody, -); - -const trendBody = functionBody("growth_trend_payload"); -assertCondition( - trendBody.includes('"latestDelta"') - && trendBody.includes('"windowDelta"') - && trendBody.includes('"short-window-rate-noisy"') - && trendBody.includes('"topGrowingBytes"') - && trendBody.includes('"registryCounters"'), - "growth trend must expose latest/window delta and registry counters", - trendBody, -); - -const compactPointBody = functionBody("compact_growth_point"); -assertCondition( - compactPointBody.includes('"sourceCount"') - && compactPointBody.includes('"registry"') - && compactPointBody.includes('"ciStorage"') - && compactPointBody.includes('"containerd"') - && sourceText.includes("[compact_growth_point(item) for item in history[-min(len(history), 3):]]"), - "growth trend must default to compact history points unless --full is requested", - compactPointBody, -); - -console.log(JSON.stringify({ - ok: true, - checks: [ - "gc remote exposes snapshot/growth and trend actions", - "snapshot history is bounded and can be disabled with --no-save", - "snapshot includes source sizes, owner-aware CI PVCs, registry cadence and containerd observation", - "HWLAB and AgentRun retention handoff commands are explicit", - "trend includes latest/window deltas and registry counters", - "trend returns compact history points by default", - ], -})); diff --git a/scripts/gc-remote-registry-maintenance-contract-test.ts b/scripts/gc-remote-registry-maintenance-contract-test.ts deleted file mode 100644 index ccfcd50d..00000000 --- a/scripts/gc-remote-registry-maintenance-contract-test.ts +++ /dev/null @@ -1,34 +0,0 @@ -const sourceText = await Bun.file(new URL("./src/gc-remote.ts", import.meta.url)).text(); - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function functionBody(name: string): string { - const marker = `def ${name}():`; - const start = sourceText.indexOf(marker); - if (start < 0) return ""; - const next = sourceText.indexOf("\ndef ", start + marker.length); - return sourceText.slice(start, next < 0 ? sourceText.length : next); -} - -for (const name of ["execute_registry_retention", "execute_registry_garbage_collect_only"]) { - const body = functionBody(name); - const suspendIndex = body.indexOf("patch_cronjob_suspend(name, True)"); - const waitIndex = body.indexOf("idle_after_suspend = wait_no_active_hwlab_ci(180)"); - const refusalIndex = body.indexOf("refusing registry maintenance because hwlab-ci did not become idle after suspend"); - const preRefusalIndex = body.indexOf("refusing registry maintenance while hwlab-ci PipelineRun/TaskRun is active"); - assertCondition( - suspendIndex >= 0 && waitIndex > suspendIndex && refusalIndex > waitIndex && preRefusalIndex < 0, - "registry maintenance must suspend poller CronJobs before refusing on active hwlab-ci objects", - { name, suspendIndex, waitIndex, refusalIndex, preRefusalIndex }, - ); -} - -console.log(JSON.stringify({ - ok: true, - checks: [ - "registry retention suspends poller CronJobs before active-CI idle wait", - "registry GC-only suspends poller CronJobs before active-CI idle wait", - ], -})); diff --git a/scripts/gh-cli-issue-guard-contract-test.ts b/scripts/gh-cli-issue-guard-contract-test.ts deleted file mode 100644 index 7c6182ab..00000000 --- a/scripts/gh-cli-issue-guard-contract-test.ts +++ /dev/null @@ -1,1915 +0,0 @@ -import { spawn } from "node:child_process"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; -import type { AddressInfo } from "node:net"; - -type JsonRecord = Record; - -interface MockRequest { - method: string; - url: string; - body: string; -} - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function runCli(args: string[], env: Record = {}, stdin?: string): Promise<{ status: number | null; stdout: string; stderr: string; json: JsonRecord | null }> { - return new Promise((resolve, reject) => { - const child = spawn("bun", ["scripts/cli.ts", ...args], { - cwd: process.cwd(), - env: { ...process.env, ...env }, - stdio: ["pipe", "pipe", "pipe"], - }); - const stdoutChunks: Buffer[] = []; - const stderrChunks: Buffer[] = []; - child.stdout.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk))); - child.stderr.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk))); - child.on("error", reject); - child.on("close", (status) => { - const stdout = Buffer.concat(stdoutChunks).toString("utf8"); - let json: JsonRecord | null = null; - try { - json = JSON.parse(stdout) as JsonRecord; - } catch { - json = null; - } - resolve({ - status, - stdout, - stderr: Buffer.concat(stderrChunks).toString("utf8"), - json, - }); - }); - if (stdin !== undefined) child.stdin.end(stdin); - else child.stdin.end(); - }); -} - -function dataOf(response: JsonRecord): JsonRecord { - assertCondition(response.ok === true, "CLI command should succeed", response); - assertCondition(typeof response.data === "object" && response.data !== null && !Array.isArray(response.data), "response data should be object", response); - return response.data as JsonRecord; -} - -function failedDataOf(response: JsonRecord): JsonRecord { - assertCondition(response.ok === false, "CLI command should fail", response); - assertCondition(typeof response.data === "object" && response.data !== null && !Array.isArray(response.data), "failure data should be object", response); - return response.data as JsonRecord; -} - -function failureMessageOf(data: JsonRecord): string { - return String((data.details as JsonRecord | undefined)?.message ?? data.message ?? ""); -} - -function collectBody(req: IncomingMessage): Promise { - return new Promise((resolve) => { - const chunks: Buffer[] = []; - req.on("data", (chunk) => chunks.push(Buffer.from(chunk))); - req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); - }); -} - -function sendJson(res: ServerResponse, status: number, payload: unknown): void { - res.statusCode = status; - res.setHeader("content-type", "application/json"); - res.end(JSON.stringify(payload)); -} - -function mockUrl(rawUrl: string | undefined): URL { - return new URL(rawUrl ?? "/", "http://mock.local"); -} - -async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockRequest[]; close: () => Promise }> { - const requests: MockRequest[] = []; - let shorthandIssueBody = "HWLAB-style shorthand body fixture\n\nThis is generic CLI coverage, not product data."; - let shorthandIssueUpdatedAt = "2026-05-20T03:00:00Z"; - const issue = { - id: 2000, - number: 20, - title: "长期总看板", - body: "# Code Queue\n\n## 看板(OPEN)\n\n- old item\n", - state: "open", - html_url: "https://github.com/pikasTech/unidesk/issues/20", - comments: 1, - user: { login: "tester" }, - created_at: "2026-05-20T00:00:00Z", - updated_at: "2026-05-20T01:00:00Z", - closed_at: null, - }; - const shorthandIssue = { - id: 7000, - number: 7, - title: "generic shorthand fixture", - body: shorthandIssueBody, - state: "open", - html_url: "https://github.com/pikasTech/HWLAB/issues/7", - comments: 1, - user: { login: "tester" }, - created_at: "2026-05-20T02:00:00Z", - updated_at: shorthandIssueUpdatedAt, - closed_at: null, - }; - const boardIssueBodyInitial = [ - "# Code Queue", - "", - "## 看板(OPEN)", - "", - "| Issue | GitHub 状态 | Branch | 验收状态 | 相关 Code Queue 任务 | 当前关注点 | 进度 |", - "| --- | --- | --- | --- | --- | --- | --- |", - "| #20 | OPEN | master | meta | governance | 关注:#19 / #26 / #30 | active |", - "| #35 | OPEN | master | pass | cq-35 | 当前关注点:#19 / #26 / #30 | doing |", - "| [#45](https://github.com/pikasTech/unidesk/issues/45) #20 总看板缺少自动覆盖审计 | OPEN | master | pass | cq-45 | 复核:#20 / #24 | doing |", - "| #40 | OPEN | — | — | — | 复核:#19 / #26 / #30 | — |", - "", - "## 看板(CLOSED)", - "", - "| Issue | GitHub 状态 | Branch | 验收状态 | 相关 Code Queue 任务 | 当前关注点 | 进度 |", - "| --- | --- | --- | --- | --- | --- | --- |", - "| #24 | CLOSED | master | meta | brief | 常驻:#24 / #20 | active |", - "| [#18](https://github.com/pikasTech/unidesk/issues/18) 基于 #4 评审结论修复 | CLOSED | master | pass | — | 历史治理归档 | done |", - "| #36 | CLOSED | master | pending | cq-36 | 复核:#35 / #40 | done |", - "", - ].join("\n"); - let boardIssueBody = boardIssueBodyInitial; - let boardIssueUpdatedAt = "2026-05-20T01:00:00Z"; - const upsertBoardIssueBodyInitial = [ - "# Upsert Board", - "", - "## 看板(OPEN)", - "", - "| Issue | GitHub 状态 | Category | Branch | Summary | 验收状态 | 相关 Code Queue 任务 | 当前关注点 | 进度 |", - "| --- | --- | --- | --- | --- | --- | --- | --- | --- |", - "| #70 | OPEN | cli | master | existing summary | pass | cq-70 | existing focus | doing |", - "| #72 | OPEN | cli | master | duplicate open | pass | cq-72 | duplicate open | doing |", - "| #79 | OPEN | defect | master | bad row | pass | cq-79 | missing progress |", - "", - "## 看板(CLOSED)", - "", - "| Issue | GitHub 状态 | Category | Branch | Summary | 验收状态 | 相关 Code Queue 任务 | 当前关注点 | 进度 |", - "| --- | --- | --- | --- | --- | --- | --- | --- | --- |", - "| #71 | CLOSED | ops | master | closed summary | pass | — | archived focus | done |", - "| #72 | CLOSED | ops | master | duplicate closed | pass | — | duplicate closed | done |", - "", - "## 更新 2026-05-21 10:00 北京时间", - "", - "- 表后的更新段落必须保留。", - "", - ].join("\n"); - let upsertBoardIssueBody = upsertBoardIssueBodyInitial; - let upsertBoardIssueUpdatedAt = "2026-05-20T01:30:00Z"; - const upsertBoardIssue = { - ...issue, - id: 2062, - number: 62, - title: "Upsert board fixture", - body: upsertBoardIssueBody, - html_url: "https://github.com/pikasTech/unidesk/issues/62", - updated_at: upsertBoardIssueUpdatedAt, - }; - const legacyBoardIssue = { - ...issue, - id: 2600, - number: 60, - title: "遗留总看板", - body: [ - "# Code Queue", - "", - "## 看板(OPEN)", - "", - "| 当前项 | Branch | 验收状态 | 相关任务 | 进度 |", - "| --- | --- | --- | --- | --- |", - "| #101 / #102 说明:#101 / #102 | master | pass | cq-legacy | doing |", - "", - ].join("\n"), - html_url: "https://github.com/pikasTech/unidesk/issues/60", - }; - const legacyCommanderBriefIssue = { - ...issue, - id: 2024, - number: 24, - title: "指挥简报", - body: [ - "# 指挥简报", - "", - "## 常驻观察与长期建议", - "", - "- 维持 Code Queue 指挥态势。", - "", - "## 更新 2026-05-20 17:28 北京时间", - "", - "- 已完成历史简报入口维护。", - "", - ].join("\n"), - html_url: "https://github.com/pikasTech/unidesk/issues/24", - }; - const dailyCommanderBriefIssue = { - ...issue, - id: 2046, - number: 46, - title: "2026-05-21 指挥简报(北京时间)", - body: [ - "# 2026-05-21 指挥简报(北京时间)", - "", - "## 常驻观察与长期建议", - "", - "- 今日滚动简报使用每日 issue 主体维护。", - "", - "## 更新 2026-05-21 09:00 北京时间", - "", - "- 启动当日队列监督。", - "", - ].join("\n"), - html_url: "https://github.com/pikasTech/unidesk/issues/46", - }; - const nonBriefIssue = { - ...issue, - id: 2047, - number: 47, - title: "普通任务 issue", - body: "# 普通任务\n\n## 背景\n\n- 不是指挥简报。\n", - html_url: "https://github.com/pikasTech/unidesk/issues/47", - }; - const largeIssueBody = Array.from({ length: 900 }, (_, index) => `large-output-line-${String(index + 1).padStart(4, "0")} ${"x".repeat(60)}`).join("\n"); - const largeIssue = { - ...issue, - id: 2090, - number: 90, - title: "large issue output fixture", - body: largeIssueBody, - html_url: "https://github.com/pikasTech/unidesk/issues/90", - updated_at: "2026-05-20T09:00:00Z", - }; - const issueList = [ - { - id: 2001, - number: 35, - title: "master:补齐 UniDesk CLI gh issue list 与 PR 驱动最小闭环前置能力", - body: "issue list body", - state: "open", - html_url: "https://github.com/pikasTech/unidesk/issues/35", - comments: 0, - user: { login: "commander" }, - labels: [{ name: "cli", color: "1d76db", description: "CLI work" }], - created_at: "2026-05-20T02:00:00Z", - updated_at: "2026-05-20T03:00:00Z", - closed_at: null, - }, - { - id: 2002, - number: 36, - title: "second issue", - body: "second body", - state: "open", - html_url: "https://github.com/pikasTech/unidesk/issues/36", - comments: 0, - user: { login: "runner" }, - labels: [], - created_at: "2026-05-20T02:05:00Z", - updated_at: "2026-05-20T03:05:00Z", - closed_at: null, - }, - { - id: 3001, - number: 37, - title: "pull request should be filtered", - body: "pr body", - state: "open", - html_url: "https://github.com/pikasTech/unidesk/pull/37", - comments: 0, - user: { login: "runner" }, - labels: [], - pull_request: { html_url: "https://github.com/pikasTech/unidesk/pull/37" }, - created_at: "2026-05-20T02:10:00Z", - updated_at: "2026-05-20T03:10:00Z", - }, - ]; - const hwlabIssueList = [ - { - id: 7001, - number: 7, - title: "HWLAB generic issue fixture", - body: "HWLAB issue list body fixture", - state: "open", - html_url: "https://github.com/pikasTech/HWLAB/issues/7", - comments: 0, - user: { login: "tester" }, - labels: [], - created_at: "2026-05-20T02:30:00Z", - updated_at: "2026-05-20T03:30:00Z", - closed_at: null, - }, - ]; - const scanIssues = [ - { - id: 2501, - number: 51, - title: "polluted issue", - body: "## Update\\n- item with `code`\\n| a | b |\\n", - state: "open", - html_url: "https://github.com/pikasTech/unidesk/issues/51", - comments: 1, - user: { login: "runner" }, - labels: [], - created_at: "2026-05-20T04:00:00Z", - updated_at: "2026-05-20T04:30:00Z", - }, - { - id: 2502, - number: 52, - title: "explanatory issue", - body: "文档说明:字面量 `\\n` 只是在示例中提到,不代表正文污染。\n", - state: "open", - html_url: "https://github.com/pikasTech/unidesk/issues/52", - comments: 1, - user: { login: "runner" }, - labels: [], - created_at: "2026-05-20T04:05:00Z", - updated_at: "2026-05-20T04:35:00Z", - }, - { - id: 2503, - number: 53, - title: "null body issue", - body: null, - state: "open", - html_url: "https://github.com/pikasTech/unidesk/issues/53", - comments: 0, - user: { login: "runner" }, - labels: [], - created_at: "2026-05-20T04:10:00Z", - updated_at: "2026-05-20T04:40:00Z", - }, - ]; - const boardOpenIssues = (): JsonRecord[] => [ - { - id: 2000, - number: 20, - title: "长期总看板", - body: boardIssueBody, - state: "open", - html_url: "https://github.com/pikasTech/unidesk/issues/20", - comments: 1, - user: { login: "tester" }, - labels: [], - created_at: "2026-05-20T00:00:00Z", - updated_at: boardIssueUpdatedAt, - }, - issueList[0], - issueList[1], - { - id: 2004, - number: 24, - title: "指挥简报", - body: "brief", - state: "open", - html_url: "https://github.com/pikasTech/unidesk/issues/24", - comments: 0, - user: { login: "tester" }, - labels: [], - created_at: "2026-05-20T01:00:00Z", - updated_at: "2026-05-20T02:00:00Z", - }, - { - id: 2045, - number: 45, - title: "#20 总看板缺少自动覆盖审计", - body: "audit body", - state: "open", - html_url: "https://github.com/pikasTech/unidesk/issues/45", - comments: 0, - user: { login: "tester" }, - labels: [], - created_at: "2026-05-20T01:30:00Z", - updated_at: "2026-05-20T02:30:00Z", - }, - dailyCommanderBriefIssue, - ]; - const legacyBoardOpenIssues = [ - { - id: 2600, - number: 60, - title: "遗留总看板", - body: legacyBoardIssue.body, - state: "open", - html_url: "https://github.com/pikasTech/unidesk/issues/60", - comments: 0, - user: { login: "tester" }, - labels: [], - created_at: "2026-05-20T01:00:00Z", - updated_at: "2026-05-20T02:00:00Z", - }, - issueList[0], - issueList[1], - ]; - const boardClosedIssues = [ - { - id: 2018, - number: 18, - title: "基于 #4 评审结论修复", - body: "closed body", - state: "closed", - html_url: "https://github.com/pikasTech/unidesk/issues/18", - comments: 0, - user: { login: "runner" }, - labels: [], - created_at: "2026-05-18T02:00:00Z", - updated_at: "2026-05-20T03:00:00Z", - closed_at: "2026-05-20T03:15:00Z", - }, - { - id: 2040, - number: 40, - title: "closed issue still in open", - body: "closed body", - state: "closed", - html_url: "https://github.com/pikasTech/unidesk/issues/40", - comments: 0, - user: { login: "runner" }, - labels: [], - created_at: "2026-05-19T02:00:00Z", - updated_at: "2026-05-20T03:00:00Z", - closed_at: "2026-05-20T03:20:00Z", - }, - { - id: 2041, - number: 41, - title: "closed issue missing from closed table", - body: "closed body", - state: "closed", - html_url: "https://github.com/pikasTech/unidesk/issues/41", - comments: 0, - user: { login: "runner" }, - labels: [], - created_at: "2026-05-19T02:05:00Z", - updated_at: "2026-05-20T03:05:00Z", - closed_at: "2026-05-20T03:25:00Z", - }, - ]; - const comments = [ - { - id: 1, - body: "comment body", - html_url: "https://github.com/pikasTech/unidesk/issues/20#issuecomment-1", - user: { login: "tester" }, - created_at: "2026-05-20T00:30:00Z", - updated_at: "2026-05-20T00:30:00Z", - }, - ]; - const scanComments: Record = { - 51: [ - { - id: 5101, - body: "comment line 1\\ncomment line 2\\twith tab", - html_url: "https://github.com/pikasTech/unidesk/issues/51#issuecomment-5101", - user: { login: "runner" }, - created_at: "2026-05-20T04:40:00Z", - updated_at: "2026-05-20T04:40:00Z", - }, - ], - 52: [ - { - id: 5201, - body: "说明性提到字面量 `\\n`,用于描述问题本身。", - html_url: "https://github.com/pikasTech/unidesk/issues/52#issuecomment-5201", - user: { login: "runner" }, - created_at: "2026-05-20T04:45:00Z", - updated_at: "2026-05-20T04:45:00Z", - }, - ], - 53: [], - }; - let unideskAllIssuesRequestCount = 0; - const server = createServer(async (req, res) => { - const body = await collectBody(req); - requests.push({ method: req.method ?? "", url: req.url ?? "", body }); - const url = mockUrl(req.url); - const state = url.searchParams.get("state") ?? ""; - const perPage = url.searchParams.get("per_page") ?? ""; - const page = url.searchParams.get("page") ?? "1"; - if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/20") { - sendJson(res, 200, { ...issue, body: boardIssueBody, updated_at: boardIssueUpdatedAt }); - return; - } - if (req.method === "GET" && req.url === "/repos/pikasTech/HWLAB/issues/7") { - sendJson(res, 200, { ...shorthandIssue, body: shorthandIssueBody, updated_at: shorthandIssueUpdatedAt }); - return; - } - if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/24") { - sendJson(res, 200, legacyCommanderBriefIssue); - return; - } - if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/46") { - sendJson(res, 200, dailyCommanderBriefIssue); - return; - } - if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/47") { - sendJson(res, 200, nonBriefIssue); - return; - } - if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/90") { - sendJson(res, 200, largeIssue); - return; - } - if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/60") { - sendJson(res, 200, legacyBoardIssue); - return; - } - if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/62") { - sendJson(res, 200, { ...upsertBoardIssue, body: upsertBoardIssueBody, updated_at: upsertBoardIssueUpdatedAt }); - return; - } - if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/20/comments?per_page=100") { - sendJson(res, 200, comments); - return; - } - if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/90/comments?per_page=100") { - sendJson(res, 200, []); - return; - } - if (req.method === "GET" && req.url === "/repos/pikasTech/HWLAB/issues/7/comments?per_page=100") { - sendJson(res, 200, [{ id: 7001, body: "shorthand comment", html_url: "https://github.com/pikasTech/HWLAB/issues/7#issuecomment-7001", user: { login: "tester" }, created_at: "2026-05-20T03:10:00Z", updated_at: "2026-05-20T03:10:00Z" }]); - return; - } - if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/60/comments?per_page=100") { - sendJson(res, 200, []); - return; - } - if (req.method === "GET" && url.pathname === "/repos/pikasTech/unidesk/issues" && state === "open" && perPage === "100" && page === "1") { - sendJson(res, 200, issueList); - return; - } - if (req.method === "GET" && url.pathname === "/search/issues" && url.searchParams.get("q") === "AgentRun final response repo:pikasTech/unidesk type:issue" && perPage === "100" && page === "1") { - sendJson(res, 200, { total_count: 1, incomplete_results: false, items: issueList.slice(0, 1) }); - return; - } - if (req.method === "GET" && url.pathname === "/repos/pikasTech/HWLAB/issues" && state === "open" && perPage === "100" && page === "1") { - sendJson(res, 200, hwlabIssueList); - return; - } - if (req.method === "GET" && url.pathname === "/repos/pikasTech/unidesk/issues" && state === "all" && perPage === "100" && page === "1") { - unideskAllIssuesRequestCount += 1; - sendJson(res, 200, unideskAllIssuesRequestCount === 1 ? issueList : scanIssues); - return; - } - if (req.method === "GET" && url.pathname === "/repos/pikasTech/unidesk/issues" && state === "closed" && perPage === "100" && page === "1") { - sendJson(res, 200, boardClosedIssues); - return; - } - for (const [issueNumber, issueComments] of Object.entries(scanComments)) { - if (req.method === "GET" && req.url === `/repos/pikasTech/unidesk/issues/${issueNumber}/comments?per_page=100`) { - sendJson(res, 200, issueComments); - return; - } - } - if (req.method === "PATCH" && req.url === "/repos/pikasTech/unidesk/issues/20") { - const parsed = JSON.parse(body) as JsonRecord; - boardIssueBody = String(parsed.body ?? boardIssueBody); - const patchedState = typeof parsed.state === "string" ? parsed.state : issue.state; - boardIssueUpdatedAt = "2026-05-20T01:05:00Z"; - sendJson(res, 200, { ...issue, body: boardIssueBody, state: patchedState, updated_at: boardIssueUpdatedAt }); - return; - } - if (req.method === "PATCH" && req.url === "/repos/pikasTech/unidesk/issues/62") { - const parsed = JSON.parse(body) as JsonRecord; - upsertBoardIssueBody = String(parsed.body ?? upsertBoardIssueBody); - upsertBoardIssueUpdatedAt = "2026-05-20T01:35:00Z"; - sendJson(res, 200, { ...upsertBoardIssue, body: upsertBoardIssueBody, updated_at: upsertBoardIssueUpdatedAt }); - return; - } - if (req.method === "PATCH" && req.url === "/repos/pikasTech/HWLAB/issues/7") { - const parsed = JSON.parse(body) as JsonRecord; - shorthandIssueBody = String(parsed.body ?? shorthandIssueBody); - shorthandIssueUpdatedAt = "2026-05-20T03:05:00Z"; - sendJson(res, 200, { ...shorthandIssue, body: shorthandIssueBody, updated_at: shorthandIssueUpdatedAt }); - return; - } - if (req.method === "POST" && req.url === "/repos/pikasTech/unidesk/issues/20/comments") { - const parsed = JSON.parse(body) as JsonRecord; - sendJson(res, 201, { id: 9001, body: String(parsed.body ?? ""), html_url: "https://github.com/pikasTech/unidesk/issues/20#issuecomment-9001", user: { login: "tester" }, created_at: "2026-05-20T06:00:00Z", updated_at: "2026-05-20T06:00:00Z" }); - return; - } - if (req.method === "POST" && req.url === "/repos/pikasTech/unidesk/issues/36/comments") { - const parsed = JSON.parse(body) as JsonRecord; - sendJson(res, 201, { id: 9002, body: String(parsed.body ?? ""), html_url: "https://github.com/pikasTech/unidesk/issues/36#issuecomment-9002", user: { login: "tester" }, created_at: "2026-05-20T06:02:00Z", updated_at: "2026-05-20T06:02:00Z" }); - return; - } - if (req.method === "PATCH" && req.url === "/repos/pikasTech/unidesk/issues/comments/9002") { - const parsed = JSON.parse(body) as JsonRecord; - sendJson(res, 200, { id: 9002, body: String(parsed.body ?? ""), html_url: "https://github.com/pikasTech/unidesk/issues/36#issuecomment-9002", user: { login: "tester" }, created_at: "2026-05-20T06:02:00Z", updated_at: "2026-05-20T06:04:00Z" }); - return; - } - if (req.method === "POST" && req.url === "/repos/pikasTech/unidesk/issues") { - const parsed = JSON.parse(body) as JsonRecord; - const labels = Array.isArray(parsed.labels) ? parsed.labels.map(String) : []; - if (labels.includes("missing-label")) { - sendJson(res, 422, { - message: "Validation Failed", - errors: [{ resource: "Issue", field: "labels", code: "invalid", value: "missing-label" }], - documentation_url: "https://docs.github.com/rest/issues/issues#create-an-issue", - }); - return; - } - sendJson(res, 201, { - id: 9100, - number: 91, - title: String(parsed.title ?? ""), - body: String(parsed.body ?? ""), - state: "open", - html_url: "https://github.com/pikasTech/unidesk/issues/91", - comments: 0, - user: { login: "tester" }, - labels: labels.map((name) => ({ name })), - created_at: "2026-05-20T06:05:00Z", - updated_at: "2026-05-20T06:05:00Z", - }); - return; - } - if (req.method === "DELETE" && req.url === "/repos/pikasTech/unidesk/issues/comments/9001") { - res.statusCode = 204; - res.end(); - return; - } - sendJson(res, 404, { message: "not found" }); - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - const address = server.address(); - assertCondition(typeof address === "object" && address !== null, "mock server should expose address"); - const port = (address as AddressInfo).port; - assertCondition(typeof port === "number", "mock server should expose port"); - return { - baseUrl: `http://127.0.0.1:${port}`, - requests, - close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())), - }; -} - -export async function runGhCliIssueGuardContract(): Promise { - const help = await runCli(["gh", "help"]); - assertCondition(help.status === 0, "gh help should succeed", help.json ?? { stdout: help.stdout }); - const helpData = dataOf(help.json ?? {}); - const usage = Array.isArray(helpData.usage) ? helpData.usage.map((value) => String(value)) : []; - const notes = Array.isArray(helpData.notes) ? helpData.notes.map((value) => String(value)) : []; - assertCondition(usage.some((line) => line.includes("gh issue list")), "gh help should list issue list", { usage }); - assertCondition(usage.some((line) => line.includes("gh issue view") && line.includes("number|url|owner/repo#number")), "gh help should list standard issue view target forms", { usage }); - assertCondition(usage.some((line) => line.includes("gh issue read") && line.includes("compatibility alias for issue view")), "gh help should list issue read compatibility alias", { usage }); - assertCondition(usage.some((line) => line.includes("gh issue comment create") && line.includes("--body ")), "gh help should list short inline issue comment body", { usage }); - assertCondition(usage.some((line) => line.includes("owner/repo#number") && line.includes("--raw|--full")), "gh help should document issue shorthand and raw/full disclosure", { usage }); - assertCondition(usage.some((line) => line.includes("gh issue board-row list")), "gh help should list board-row list", { usage }); - assertCondition(usage.some((line) => line.includes("gh issue board-row update")), "gh help should list board-row update", { usage }); - assertCondition(usage.some((line) => line.includes("gh issue board-row add")), "gh help should list board-row add", { usage }); - assertCondition(usage.some((line) => line.includes("gh issue board-row upsert")), "gh help should list board-row upsert", { usage }); - assertCondition(usage.some((line) => line.includes("gh issue board-row move")), "gh help should list board-row move", { usage }); - assertCondition(usage.some((line) => line.includes("gh issue board-row delete")), "gh help should list board-row delete", { usage }); - assertCondition(usage.some((line) => line.includes("gh issue list") && line.includes("--search text")), "gh help should list issue list search", { usage }); - assertCondition(notes.some((line) => line.includes("issue view is the canonical")), "gh help should state issue view is canonical", { notes }); - assertCondition(notes.some((line) => line.includes("read remains") && line.includes("compatibility alias")), "gh help should state issue read is alias", { notes }); - assertCondition(notes.some((line) => line.includes("GitHub issue URLs") && line.includes("owner/repo#number shorthand")), "gh help should explain issue view/read URL and shorthand targets", { notes }); - assertCondition(notes.some((line) => line.includes("--number is accepted on single issue/comment numeric target commands") && line.includes("Comment delete treats --number as commentId")), "gh help should document issue --number compatibility scope", { notes }); - assertCondition(notes.some((line) => line.includes("--raw and --full are explicit full-disclosure aliases")), "gh help should explain raw/full read disclosure", { notes }); - assertCondition(notes.some((line) => line.includes("issue comment create/update/edit accept --body-stdin") && line.includes("--body only for short single-line text")), "gh help should document issue comment heredoc stdin and inline safety limits", { notes }); - assertCondition(notes.some((line) => line.includes("board-row update changes one table cell")), "gh help should describe board-row update safety", { notes }); - assertCondition(notes.some((line) => line.includes("board-row upsert updates an existing row")), "gh help should describe board-row upsert safety", { notes }); - assertCondition(notes.some((line) => line.includes("board-row add/move/delete are row-scoped")), "gh help should describe board-row row mutation safety", { notes }); - - const mock = await startMockGitHub(); - const tmp = mkdtempSync(join(tmpdir(), "unidesk-gh-issue-guard-")); - const startedAt = Date.now(); - const heartbeat = setInterval(() => { - const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000); - process.stderr.write(`[gh-issue-contract] elapsed=${elapsedSeconds}s requests=${mock.requests.length}\n`); - }, 10_000); - const env = { - GH_TOKEN: "contract-token-should-not-print", - UNIDESK_GITHUB_API_URL: mock.baseUrl, - }; - try { - const listOpen = await runCli(["gh", "issue", "list", "--repo", "pikasTech/unidesk", "--state", "open", "--limit", "2", "--json", "number,title,state,url"], env); - assertCondition(listOpen.status === 0, "issue list should support state/limit/json", listOpen.json ?? { stdout: listOpen.stdout }); - const listOpenData = dataOf(listOpen.json ?? {}); - assertCondition(listOpenData.state === "open", "issue list should preserve state", listOpenData); - assertCondition(listOpenData.limit === 2, "issue list should preserve limit", listOpenData); - assertCondition(listOpenData.count === 2, "issue list should return bounded issues", listOpenData); - const listOpenIssues = listOpenData.issues as JsonRecord[]; - assertCondition(Array.isArray(listOpenIssues), "issue list should expose issues array", listOpenData); - assertCondition(listOpenIssues[0]?.number === 35, "issue list should expose number field", listOpenData); - assertCondition(listOpenIssues[0]?.title === "master:补齐 UniDesk CLI gh issue list 与 PR 驱动最小闭环前置能力", "issue list should expose title field", listOpenData); - assertCondition(!("labels" in listOpenIssues[0]), "issue list --json should select only requested fields", listOpenIssues[0]); - - const listOpenLifecycle = await runCli(["gh", "issue", "list", "--repo", "pikasTech/unidesk", "--state", "open", "--limit", "2", "--json", "number,state,closed,closedAt"], env); - assertCondition(listOpenLifecycle.status === 0, "issue list lifecycle fields should succeed", listOpenLifecycle.json ?? { stdout: listOpenLifecycle.stdout }); - const listOpenLifecycleData = dataOf(listOpenLifecycle.json ?? {}); - const listOpenLifecycleIssues = listOpenLifecycleData.issues as JsonRecord[]; - assertCondition(listOpenLifecycleIssues[0]?.closed === false && listOpenLifecycleIssues[0]?.closedAt === null, "open issue list rows should expose closed=false and closedAt=null", listOpenLifecycleData); - - const listClosedLifecycle = await runCli(["gh", "issue", "list", "--repo", "pikasTech/unidesk", "--state", "closed", "--limit", "2", "--json", "number,state,closed,closedAt"], env); - assertCondition(listClosedLifecycle.status === 0, "closed issue list lifecycle fields should succeed", listClosedLifecycle.json ?? { stdout: listClosedLifecycle.stdout }); - const listClosedLifecycleData = dataOf(listClosedLifecycle.json ?? {}); - const listClosedLifecycleIssues = listClosedLifecycleData.issues as JsonRecord[]; - assertCondition(listClosedLifecycleIssues[0]?.state === "closed" && listClosedLifecycleIssues[0]?.closed === true && listClosedLifecycleIssues[0]?.closedAt === "2026-05-20T03:15:00Z", "closed issue list rows should expose closed=true and closedAt", listClosedLifecycleData); - - const acceptanceList = await runCli(["gh", "issue", "list", "--repo", "pikasTech/unidesk", "--state", "open", "--limit", "5", "--json", "number,title,state,url"], env); - assertCondition(acceptanceList.status === 0, "acceptance issue list command should succeed under mock GitHub", acceptanceList.json ?? { stdout: acceptanceList.stdout }); - const acceptanceListData = dataOf(acceptanceList.json ?? {}); - assertCondition(acceptanceListData.limit === 5, "acceptance issue list command should preserve limit=5", acceptanceListData); - assertCondition(acceptanceListData.count === 2, "acceptance issue list command should filter PRs and keep issues", acceptanceListData); - - const listDefaultState = await runCli(["gh", "issue", "list", "--repo", "pikasTech/unidesk", "--limit", "2", "--json", "number,title,state,url"], env); - assertCondition(listDefaultState.status === 0, "issue list default state should still succeed", listDefaultState.json ?? { stdout: listDefaultState.stdout }); - const listDefaultStateData = dataOf(listDefaultState.json ?? {}); - assertCondition(listDefaultStateData.state === "open", "issue list should keep default state=open", listDefaultStateData); - assertCondition(mock.requests.some((request) => { - const requestedUrl = mockUrl(request.url); - return request.method === "GET" && requestedUrl.pathname === "/repos/pikasTech/unidesk/issues" && requestedUrl.searchParams.get("state") === "open"; - }), "issue list default should query state=open", mock.requests); - - const searchList = await runCli(["gh", "issue", "list", "--repo", "pikasTech/unidesk", "--state", "all", "--limit", "4", "--search", "AgentRun final response", "--json", "number,title,state,url"], env); - assertCondition(searchList.status === 0, "issue list should support search query", searchList.json ?? { stdout: searchList.stdout }); - const searchListData = dataOf(searchList.json ?? {}); - assertCondition(searchListData.search === "AgentRun final response", "issue list should expose search query", searchListData); - assertCondition(mock.requests.some((request) => { - const requestedUrl = mockUrl(request.url); - return request.method === "GET" && requestedUrl.pathname === "/search/issues" && requestedUrl.searchParams.get("q") === "AgentRun final response repo:pikasTech/unidesk type:issue"; - }), "issue list search should use GitHub Search Issues API with repo/type qualifiers", mock.requests); - - const positionalRepoList = await runCli(["gh", "issue", "list", "pikasTech/HWLAB", "--state", "open", "--limit", "2", "--json", "number,title,state,url"], env); - assertCondition(positionalRepoList.status === 0, "issue list positional owner/repo should succeed", positionalRepoList.json ?? { stdout: positionalRepoList.stdout }); - const positionalRepoListData = dataOf(positionalRepoList.json ?? {}); - assertCondition(positionalRepoListData.repo === "pikasTech/HWLAB", "issue list positional repo should become the actual request repo", positionalRepoListData); - const positionalRepoIssues = positionalRepoListData.issues as JsonRecord[]; - assertCondition(Array.isArray(positionalRepoIssues) && positionalRepoIssues[0]?.number === 7, "issue list positional repo should return HWLAB fixture issue", positionalRepoListData); - assertCondition(mock.requests.some((request) => { - const requestedUrl = mockUrl(request.url); - return request.method === "GET" && requestedUrl.pathname === "/repos/pikasTech/HWLAB/issues" && requestedUrl.searchParams.get("state") === "open"; - }), "issue list positional repo should query derived repo REST path", mock.requests); - - const positionalRepoConflict = await runCli(["gh", "issue", "list", "pikasTech/HWLAB", "--repo", "pikasTech/unidesk", "--state", "open"], env); - assertCondition(positionalRepoConflict.status !== 0, "issue list conflicting positional repo and --repo should fail", positionalRepoConflict.json ?? { stdout: positionalRepoConflict.stdout }); - const positionalRepoConflictData = failedDataOf(positionalRepoConflict.json ?? {}); - assertCondition(positionalRepoConflictData.degradedReason === "validation-failed", "issue list repo conflict should be validation-failed", positionalRepoConflictData); - assertCondition(String((positionalRepoConflictData.details as JsonRecord)?.message ?? "").includes("positional repo pikasTech/HWLAB"), "issue list repo conflict should name positional repo", positionalRepoConflictData); - - const listDefaultFields = await runCli(["gh", "issue", "list", "--repo", "pikasTech/unidesk", "--state", "all", "--limit", "3"], env); - assertCondition(listDefaultFields.status === 0, "issue list should support default fields", listDefaultFields.json ?? { stdout: listDefaultFields.stdout }); - const listDefaultData = dataOf(listDefaultFields.json ?? {}); - assertCondition(listDefaultData.count === 2, "issue list should filter pull requests from GitHub issues endpoint", listDefaultData); - const defaultIssues = listDefaultData.issues as JsonRecord[]; - const firstLabels = defaultIssues[0]?.labels as JsonRecord[]; - assertCondition(Array.isArray(firstLabels) && firstLabels[0]?.name === "cli", "issue list default fields should include labels", listDefaultData); - assertCondition(defaultIssues.every((item) => typeof item.number === "number" && typeof item.url === "string"), "issue list default fields should expose stable JSON", listDefaultData); - - const largeRead = await runCli(["gh", "issue", "read", "90", "--repo", "pikasTech/unidesk", "--full"], env); - assertCondition(largeRead.status === 0, "large issue read should succeed", largeRead.json ?? { stdout: largeRead.stdout }); - assertCondition(largeRead.stdout.length < 20_000, "large issue read stdout should stay bounded", { bytes: largeRead.stdout.length }); - const largeReadData = dataOf(largeRead.json ?? {}); - assertCondition(largeReadData.outputTruncated === true, "large issue read should be dumped instead of printed fully", largeReadData); - const dump = largeReadData.dump as JsonRecord; - assertCondition(typeof dump.path === "string" && existsSync(String(dump.path)), "large issue dump file should exist", dump); - assertCondition(Number(dump.bytes ?? 0) > 20_000, "dump should record full output size", dump); - assertCondition(Number(dump.lines ?? 0) > 20, "dump should record total line count", dump); - assertCondition(String(dump.head ?? "").length > 0 && String(dump.tail ?? "").length > 0, "dump should include head and tail previews", dump); - const dumpText = readFileSync(String(dump.path), "utf8"); - assertCondition(dumpText.includes("large-output-line-0900"), "dump file should contain full original JSON", { path: dump.path, tail: dumpText.slice(-500) }); - - const scanEscape = await runCli(["gh", "issue", "scan-escape", "--repo", "pikasTech/unidesk", "--limit", "4", "--dry-run"], env); - assertCondition(scanEscape.status === 0, "issue scan-escape dry-run should succeed", scanEscape.json ?? { stdout: scanEscape.stdout }); - const scanData = dataOf(scanEscape.json ?? {}); - assertCondition(scanData.dryRun === true && scanData.planned === true, "scan-escape dry-run should be explicit", scanData); - const scanSummary = scanData.summary as JsonRecord; - assertCondition(Number(scanSummary.suspectedPollution ?? 0) >= 2, "scan should find suspected pollution in body/comment", scanSummary); - assertCondition(Number(scanSummary.explanatoryMention ?? 0) >= 1, "scan should classify explanatory literal backslash-n separately", scanSummary); - assertCondition(Number(scanSummary.bodyRisks ?? 0) >= 1, "scan should report null/short body risks", scanSummary); - const scanFindings = scanData.findings as JsonRecord[]; - assertCondition(Array.isArray(scanFindings), "scan should expose findings array", scanData); - assertCondition(scanFindings.some((finding) => finding.issueNumber === 51 && finding.classification === "suspected-pollution" && finding.bodyKind === "issue-body" && typeof finding.bodyId === "string"), "polluted issue body should be suspected with body id", scanFindings); - assertCondition(scanFindings.some((finding) => finding.commentId === 5101 && finding.classification === "suspected-pollution" && finding.bodyKind === "comment-body" && String(finding.bodyId ?? "").includes("comment:5101")), "polluted comment should include comment id and body id", scanFindings); - assertCondition(scanFindings.some((finding) => finding.issueNumber === 52 && finding.classification === "explanatory-mention"), "explanatory literal backslash-n should not be pollution", scanFindings); - assertCondition(scanFindings.some((finding) => finding.issueNumber === 53 && finding.kind === "null-body" && finding.classification === "risk"), "null body should be guarded as risk", scanFindings); - const cleanupSuggestions = scanData.cleanupSuggestions as JsonRecord[]; - assertCondition(Array.isArray(cleanupSuggestions), "scan should expose cleanupSuggestions", scanData); - assertCondition(cleanupSuggestions.some((suggestion) => suggestion.issueNumber === 51 && suggestion.type === "issue-body" && typeof suggestion.bodyId === "string" && suggestion.action === "rewrite-issue-body-with-body-stdin"), "issue body cleanup suggestion should use heredoc/stdin rewrite with body id", cleanupSuggestions); - assertCondition(cleanupSuggestions.some((suggestion) => suggestion.commentId === 5101 && suggestion.type === "comment-body" && String(suggestion.bodyId ?? "").includes("comment:5101") && suggestion.action === "review-comment-manually"), "comment cleanup suggestion should be manual review with body id", cleanupSuggestions); - assertCondition(cleanupSuggestions.every((suggestion) => suggestion.issueNumber !== 52), "explanatory mention should not create cleanup suggestion", cleanupSuggestions); - const scanPatchCount = mock.requests.filter((request) => request.method === "PATCH" || request.method === "DELETE" || request.method === "POST").length; - assertCondition(scanPatchCount === 0, "scan-escape must not write GitHub", { requests: mock.requests }); - - const cleanupPlan = await runCli(["gh", "issue", "cleanup-plan", "--repo", "pikasTech/unidesk", "--limit", "4"], env); - assertCondition(cleanupPlan.status === 0, "issue cleanup-plan should succeed as read-only alias", cleanupPlan.json ?? { stdout: cleanupPlan.stdout }); - const cleanupPlanData = dataOf(cleanupPlan.json ?? {}); - assertCondition(cleanupPlanData.command === "issue cleanup-plan" && cleanupPlanData.dryRun === true, "cleanup-plan should remain dry-run", cleanupPlanData); - - const boardAuditRequestCountBefore = mock.requests.length; - const boardAudit = await runCli(["gh", "issue", "board-audit", "--repo", "pikasTech/unidesk", "--limit", "100", "--dry-run"], env); - assertCondition(boardAudit.status === 0, "issue board-audit should succeed as a read-only audit", boardAudit.json ?? { stdout: boardAudit.stdout, stderr: boardAudit.stderr }); - const boardAuditData = dataOf(boardAudit.json ?? {}); - assertCondition(boardAuditData.command === "issue board-audit" && boardAuditData.dryRun === true && boardAuditData.readOnly === true, "board-audit should be explicit read-only dry-run", boardAuditData); - const boardAuditIssue = boardAuditData.boardIssue as JsonRecord; - assertCondition(typeof boardAuditIssue.bodySha === "string" && String(boardAuditIssue.bodySha).length === 64, "board-audit should expose board body sha", boardAuditIssue); - const boardAuditSummary = boardAuditData.summary as JsonRecord; - assertCondition(boardAuditSummary.openIssues === null && boardAuditSummary.closedIssues === null, "board-audit should not fetch GitHub open/closed issue lists", boardAuditSummary); - assertCondition(boardAuditSummary.openRows === 4 && boardAuditSummary.closedRows === 3 && boardAuditSummary.parsedSections === 2, "board-audit should report parsed board structure", boardAuditSummary); - const boardAuditValidation = boardAuditData.validation as JsonRecord; - const openClosedCoverage = boardAuditValidation.openClosedCoverage as JsonRecord; - assertCondition(openClosedCoverage.enabled === false, "board-audit should disable OPEN/CLOSED coverage validation", boardAuditValidation); - const missingOpenIssues = boardAuditData.missingOpenIssues as JsonRecord[]; - assertCondition(Array.isArray(missingOpenIssues) && missingOpenIssues.length === 0, "board-audit should not report missing OPEN rows", missingOpenIssues); - const closedInOpenRows = boardAuditData.closedInOpenRows as JsonRecord[]; - assertCondition(Array.isArray(closedInOpenRows) && closedInOpenRows.length === 0, "board-audit should not report closed issues in OPEN rows", closedInOpenRows); - const missingClosedRows = boardAuditData.missingClosedRows as JsonRecord[]; - assertCondition(Array.isArray(missingClosedRows) && missingClosedRows.length === 0, "board-audit should not report missing CLOSED rows", missingClosedRows); - const openInClosedRows = boardAuditData.openInClosedRows as JsonRecord[]; - assertCondition(Array.isArray(openInClosedRows) && openInClosedRows.length === 0, "board-audit should not report open issues in CLOSED rows", openInClosedRows); - const rowValidationWarnings = boardAuditData.rowValidationWarnings as JsonRecord[]; - assertCondition(Array.isArray(rowValidationWarnings) && rowValidationWarnings.length === 0, "board-audit should not report required-column row warnings", rowValidationWarnings); - const parserWarnings = boardAuditData.parserWarnings as JsonRecord[]; - assertCondition(Array.isArray(parserWarnings), "board-audit should expose parser warnings separately", boardAuditData); - const ignoredIssues = boardAuditData.ignoredIssues as JsonRecord[]; - assertCondition(Array.isArray(ignoredIssues) && ignoredIssues.length === 0, "board-audit should not produce ignored issue coverage output", ignoredIssues); - const recommendedActions = boardAuditData.recommendedActions as JsonRecord[]; - assertCondition(Array.isArray(recommendedActions) && recommendedActions.length === 0, "board-audit should not emit OPEN/CLOSED coverage actions", recommendedActions); - const boardAuditGetRequests = mock.requests.slice(boardAuditRequestCountBefore).filter((request) => request.method === "GET"); - assertCondition(boardAuditGetRequests.length === 1 && boardAuditGetRequests[0]?.url === "/repos/pikasTech/unidesk/issues/20", "board-audit should only fetch the board issue body", boardAuditGetRequests); - const boardAuditWriteCount = mock.requests.slice(boardAuditRequestCountBefore).filter((request) => request.method === "PATCH" || request.method === "DELETE" || request.method === "POST").length; - assertCondition(boardAuditWriteCount === 0, "board-audit must not write GitHub", { requests: mock.requests.slice(boardAuditRequestCountBefore) }); - - const legacyBoardAuditRequestCountBefore = mock.requests.length; - const legacyBoardAudit = await runCli(["gh", "issue", "board-audit", "--repo", "pikasTech/unidesk", "--board-issue", "60", "--limit", "60", "--dry-run"], env); - assertCondition(legacyBoardAudit.status === 0, "legacy board-audit fixture should succeed", legacyBoardAudit.json ?? { stdout: legacyBoardAudit.stdout, stderr: legacyBoardAudit.stderr }); - const legacyBoardAuditData = dataOf(legacyBoardAudit.json ?? {}); - const legacyWarnings = legacyBoardAuditData.parserWarnings as JsonRecord[]; - assertCondition(Array.isArray(legacyWarnings) && legacyWarnings.some((warning) => warning.kind === "multiple-issue-references" && warning.issueNumber === 101), "legacy board-audit fixture should keep parser warnings when no Issue column exists", legacyWarnings); - const legacyBoardWriteCount = mock.requests.slice(legacyBoardAuditRequestCountBefore).filter((request) => request.method === "PATCH" || request.method === "DELETE" || request.method === "POST").length; - assertCondition(legacyBoardWriteCount === 0, "legacy board-audit must not write GitHub", { requests: mock.requests.slice(legacyBoardAuditRequestCountBefore) }); - - const boardRowListRequestCountBefore = mock.requests.length; - const boardRowList = await runCli(["gh", "issue", "board-row", "list", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--state", "open", "--dry-run"], env); - assertCondition(boardRowList.status === 0, "board-row list should succeed", boardRowList.json ?? { stdout: boardRowList.stdout, stderr: boardRowList.stderr }); - const boardRowListData = dataOf(boardRowList.json ?? {}); - assertCondition(boardRowListData.command === "issue board-row list" && boardRowListData.readOnly === true && boardRowListData.dryRun === true, "board-row list should be read-only", boardRowListData); - assertCondition(boardRowListData.state === "open" && boardRowListData.count === 4, "board-row list should filter OPEN rows", boardRowListData); - const boardRowListBoardIssue = boardRowListData.boardIssue as JsonRecord; - assertCondition(typeof boardRowListBoardIssue.bodySha === "string" && String(boardRowListBoardIssue.bodySha).length === 64, "board-row list should expose board body sha", boardRowListBoardIssue); - const boardRowListRows = boardRowListData.rows as JsonRecord[]; - assertCondition(Array.isArray(boardRowListRows) && boardRowListRows.some((row) => row.issueNumber === 45), "board-row list should use primary markdown issue link row keys", boardRowListRows); - const listWriteCount = mock.requests.slice(boardRowListRequestCountBefore).filter((request) => request.method === "PATCH" || request.method === "DELETE" || request.method === "POST").length; - assertCondition(listWriteCount === 0, "board-row list must not write GitHub", { requests: mock.requests.slice(boardRowListRequestCountBefore) }); - - const boardRowGet = await runCli(["gh", "issue", "board-row", "get", "35", "--repo", "pikasTech/unidesk", "--board-issue", "20"], env); - assertCondition(boardRowGet.status === 0, "board-row get should succeed", boardRowGet.json ?? { stdout: boardRowGet.stdout, stderr: boardRowGet.stderr }); - const boardRowGetData = dataOf(boardRowGet.json ?? {}); - const boardRowGetRow = boardRowGetData.row as JsonRecord; - const boardRowGetFields = boardRowGetRow.fields as JsonRecord; - assertCondition(boardRowGetRow.issueNumber === 35 && boardRowGetRow.section === "open", "board-row get should return the target row", boardRowGetData); - assertCondition(Array.isArray(boardRowGetRow.cells) && boardRowGetRow.cells[1] === "OPEN", "board-row get should expose the GitHub status column", boardRowGetRow); - assertCondition(boardRowGetFields.branch === "master" && boardRowGetFields.status === "pass" && boardRowGetFields.validation === "pass" && boardRowGetFields.tasks === "cq-35" && String(boardRowGetFields.focus ?? "").includes("当前关注点"), "board-row get should expose canonical field aliases", boardRowGetFields); - const boardRowGetHint = boardRowGetData.codeQueueBoardHint as JsonRecord; - assertCondition(boardRowGetHint.detected === false && String(boardRowGetHint.warning ?? "").includes("governance board only"), "board-row get should remind callers that #20 is governance-only", boardRowGetHint); - - const boardRowUpsertUpdateRequestCountBefore = mock.requests.length; - const boardRowUpsertUpdate = await runCli([ - "gh", "issue", "board-row", "upsert", "70", - "--repo", "pikasTech/unidesk", - "--board-issue", "62", - "--section", "open", - "--summary", "中文 `code` [#20](https://github.com/pikasTech/unidesk/issues/20) A | B\nsecond line", - "--focus", "焦点 A | B\n下一行", - "--validation", "manual pass", - "--progress", "reviewing", - "--dry-run", - ], env); - assertCondition(boardRowUpsertUpdate.status === 0, "board-row upsert existing row should dry-run update", boardRowUpsertUpdate.json ?? { stdout: boardRowUpsertUpdate.stdout, stderr: boardRowUpsertUpdate.stderr }); - const boardRowUpsertUpdateData = dataOf(boardRowUpsertUpdate.json ?? {}); - assertCondition(boardRowUpsertUpdateData.command === "issue board-row upsert" && boardRowUpsertUpdateData.operation === "update" && boardRowUpsertUpdateData.dryRun === true && boardRowUpsertUpdateData.planned === true, "upsert existing row should report update operation", boardRowUpsertUpdateData); - const boardRowUpsertUpdatePlan = boardRowUpsertUpdateData.upsert as JsonRecord; - assertCondition(boardRowUpsertUpdatePlan.operation === "update" && boardRowUpsertUpdatePlan.section === "open", "upsert update plan should stay in existing section", boardRowUpsertUpdatePlan); - assertCondition(boardRowUpsertUpdatePlan.oldRow === "| #70 | OPEN | cli | master | existing summary | pass | cq-70 | existing focus | doing |", "upsert update should expose old row", boardRowUpsertUpdatePlan); - assertCondition(boardRowUpsertUpdatePlan.newRow === "| #70 | OPEN | cli | master | 中文 `code` [#20](https://github.com/pikasTech/unidesk/issues/20) A \\| B second line | manual pass | cq-70 | 焦点 A \\| B 下一行 | reviewing |", "upsert update should escape pipes, preserve backticks/link text, and fold real newlines", boardRowUpsertUpdatePlan); - const boardRowUpsertUpdateSafety = boardRowUpsertUpdateData.bodyOnlySafety as JsonRecord; - const boardRowUpsertUpdateNewBody = boardRowUpsertUpdateSafety.newBody as JsonRecord; - assertCondition(boardRowUpsertUpdateNewBody.containsLiteralBackslashN === false && boardRowUpsertUpdateNewBody.containsBackticks === true && boardRowUpsertUpdateNewBody.containsMarkdownTable === true, "upsert update should preserve markdown safety signals", boardRowUpsertUpdateNewBody); - const boardRowUpsertUpdateWriteCount = mock.requests.slice(boardRowUpsertUpdateRequestCountBefore).filter((request) => request.method === "PATCH").length; - assertCondition(boardRowUpsertUpdateWriteCount === 0, "board-row upsert update dry-run must not PATCH GitHub", { requests: mock.requests.slice(boardRowUpsertUpdateRequestCountBefore) }); - - const boardRowUpsertAddRequestCountBefore = mock.requests.length; - const boardRowUpsertAdd = await runCli([ - "gh", "issue", "board-row", "upsert", "73", - "--repo", "pikasTech/unidesk", - "--board-issue", "62", - "--section", "open", - "--category", "cli", - "--branch", "master", - "--tasks", "cq-73", - "--summary", "新增中文 `code` [#20](https://github.com/pikasTech/unidesk/issues/20) A | B\n真实换行", - "--focus", "关注 | 重点\nnext", - "--validation", "pending", - "--progress", "queued", - "--dry-run", - ], env); - assertCondition(boardRowUpsertAdd.status === 0, "board-row upsert missing row should dry-run add", boardRowUpsertAdd.json ?? { stdout: boardRowUpsertAdd.stdout, stderr: boardRowUpsertAdd.stderr }); - const boardRowUpsertAddData = dataOf(boardRowUpsertAdd.json ?? {}); - assertCondition(boardRowUpsertAddData.command === "issue board-row upsert" && boardRowUpsertAddData.operation === "add" && boardRowUpsertAddData.dryRun === true && boardRowUpsertAddData.planned === true, "upsert missing row should report add operation", boardRowUpsertAddData); - const boardRowUpsertAddPlan = boardRowUpsertAddData.upsert as JsonRecord; - assertCondition(boardRowUpsertAddPlan.operation === "add" && boardRowUpsertAddPlan.section === "open" && Number(boardRowUpsertAddPlan.insertAfterLine ?? 0) > 0, "upsert add should expose insertion plan", boardRowUpsertAddPlan); - assertCondition(boardRowUpsertAddPlan.newRow === "| #73 | OPEN | cli | master | 新增中文 `code` [#20](https://github.com/pikasTech/unidesk/issues/20) A \\| B 真实换行 | pending | cq-73 | 关注 \\| 重点 next | queued |", "upsert add should generate a full escaped row", boardRowUpsertAddPlan); - const boardRowUpsertAddNewBody = ((boardRowUpsertAddData.bodyOnlySafety as JsonRecord).newBody as JsonRecord); - assertCondition(boardRowUpsertAddNewBody.containsLiteralBackslashN === false && boardRowUpsertAddNewBody.containsBackticks === true, "upsert add should not introduce literal backslash-n", boardRowUpsertAddNewBody); - const boardRowUpsertAddWriteCount = mock.requests.slice(boardRowUpsertAddRequestCountBefore).filter((request) => request.method === "PATCH").length; - assertCondition(boardRowUpsertAddWriteCount === 0, "board-row upsert add dry-run must not PATCH GitHub", { requests: mock.requests.slice(boardRowUpsertAddRequestCountBefore) }); - - const boardRowUpsertNoGuardRequestCountBefore = mock.requests.length; - const boardRowUpsertNoGuard = await runCli([ - "gh", "issue", "board-row", "upsert", "74", - "--repo", "pikasTech/unidesk", - "--board-issue", "62", - "--section", "open", - "--category", "cli", - "--branch", "master", - "--tasks", "cq-74", - "--summary", "formal write omitted guard", - "--focus", "focus", - "--validation", "pending", - "--progress", "queued", - ], env); - assertCondition(boardRowUpsertNoGuard.status === 0, "board-row upsert without guard should stay on the dry-run path", boardRowUpsertNoGuard.json ?? { stdout: boardRowUpsertNoGuard.stdout, stderr: boardRowUpsertNoGuard.stderr }); - const boardRowUpsertNoGuardData = dataOf(boardRowUpsertNoGuard.json ?? {}); - assertCondition(boardRowUpsertNoGuardData.dryRun === true && boardRowUpsertNoGuardData.planned === true && boardRowUpsertNoGuardData.operation === "add", "upsert without guard should not PATCH GitHub", boardRowUpsertNoGuardData); - const boardRowUpsertNoGuardWriteCount = mock.requests.slice(boardRowUpsertNoGuardRequestCountBefore).filter((request) => request.method === "PATCH").length; - assertCondition(boardRowUpsertNoGuardWriteCount === 0, "board-row upsert without guard must not PATCH GitHub", { requests: mock.requests.slice(boardRowUpsertNoGuardRequestCountBefore) }); - - const boardRowUpsertListBeforeWrite = await runCli(["gh", "issue", "board-row", "list", "--repo", "pikasTech/unidesk", "--board-issue", "62", "--state", "all"], env); - assertCondition(boardRowUpsertListBeforeWrite.status === 0, "upsert board list before guarded write should succeed", boardRowUpsertListBeforeWrite.json ?? { stdout: boardRowUpsertListBeforeWrite.stdout, stderr: boardRowUpsertListBeforeWrite.stderr }); - const boardRowUpsertBoardSha = String((dataOf(boardRowUpsertListBeforeWrite.json ?? {}).boardIssue as JsonRecord).bodySha ?? ""); - const boardRowUpsertWriteRequestCountBefore = mock.requests.length; - const boardRowUpsertWrite = await runCli([ - "gh", "issue", "board-row", "upsert", "73", - "--repo", "pikasTech/unidesk", - "--board-issue", "62", - "--section", "open", - "--category", "cli", - "--branch", "master", - "--tasks", "cq-73", - "--summary", "guarded add keeps table trailer", - "--focus", "focus", - "--validation", "pending", - "--progress", "queued", - "--expect-body-sha", boardRowUpsertBoardSha, - ], env); - assertCondition(boardRowUpsertWrite.status === 0, "board-row upsert with expect body sha should PATCH", boardRowUpsertWrite.json ?? { stdout: boardRowUpsertWrite.stdout, stderr: boardRowUpsertWrite.stderr }); - const boardRowUpsertWriteData = dataOf(boardRowUpsertWrite.json ?? {}); - assertCondition(boardRowUpsertWriteData.dryRun === false && boardRowUpsertWriteData.rest === true && boardRowUpsertWriteData.operation === "add", "upsert guarded write should report real add", boardRowUpsertWriteData); - const boardRowUpsertWriteRequests = mock.requests.slice(boardRowUpsertWriteRequestCountBefore).filter((request) => request.method === "PATCH" && request.url === "/repos/pikasTech/unidesk/issues/62"); - assertCondition(boardRowUpsertWriteRequests.length === 1, "board-row upsert should send exactly one PATCH", { requests: mock.requests.slice(boardRowUpsertWriteRequestCountBefore) }); - const boardRowUpsertWritePayload = JSON.parse(boardRowUpsertWriteRequests[0]?.body ?? "{}") as JsonRecord; - const boardRowUpsertWrittenBody = String(boardRowUpsertWritePayload.body ?? ""); - assertCondition(boardRowUpsertWrittenBody.includes("| #73 | OPEN | cli | master | guarded add keeps table trailer | pending | cq-73 | focus | queued |"), "upsert write payload should include generated row", boardRowUpsertWritePayload); - assertCondition(boardRowUpsertWrittenBody.includes("## 看板(CLOSED)") && boardRowUpsertWrittenBody.includes("## 更新 2026-05-21 10:00 北京时间") && boardRowUpsertWrittenBody.includes("- 表后的更新段落必须保留。"), "upsert write should preserve CLOSED table and post-table update section", boardRowUpsertWritePayload); - - const boardRowUpsertStale = await runCli([ - "gh", "issue", "board-row", "upsert", "76", - "--repo", "pikasTech/unidesk", - "--board-issue", "62", - "--section", "open", - "--category", "cli", - "--branch", "master", - "--tasks", "cq-76", - "--summary", "stale guard must fail", - "--focus", "focus", - "--validation", "pending", - "--progress", "queued", - "--expect-body-sha", boardRowUpsertBoardSha, - ], env); - assertCondition(boardRowUpsertStale.status !== 0, "stale board-row upsert should fail structurally", boardRowUpsertStale.json ?? { stdout: boardRowUpsertStale.stdout, stderr: boardRowUpsertStale.stderr }); - const boardRowUpsertStaleData = failedDataOf(boardRowUpsertStale.json ?? {}); - assertCondition(boardRowUpsertStaleData.degradedReason === "validation-failed" && boardRowUpsertStaleData.command === "issue board-row upsert", "stale board-row upsert should be validation-failed", boardRowUpsertStaleData); - - const boardRowUpsertDuplicate = await runCli([ - "gh", "issue", "board-row", "upsert", "72", - "--repo", "pikasTech/unidesk", - "--board-issue", "62", - "--section", "open", - "--focus", "duplicate should fail", - "--dry-run", - ], env); - assertCondition(boardRowUpsertDuplicate.status !== 0, "duplicate board-row upsert should fail structurally", boardRowUpsertDuplicate.json ?? { stdout: boardRowUpsertDuplicate.stdout, stderr: boardRowUpsertDuplicate.stderr }); - const boardRowUpsertDuplicateData = failedDataOf(boardRowUpsertDuplicate.json ?? {}); - assertCondition(String((boardRowUpsertDuplicateData.details as JsonRecord).message ?? "").includes("ambiguous"), "duplicate upsert should report ambiguous row", boardRowUpsertDuplicateData); - - const boardRowUpsertSectionConflict = await runCli([ - "gh", "issue", "board-row", "upsert", "70", - "--repo", "pikasTech/unidesk", - "--board-issue", "62", - "--section", "closed", - "--focus", "migration belongs to move", - "--dry-run", - ], env); - assertCondition(boardRowUpsertSectionConflict.status !== 0, "upsert section conflict should fail structurally", boardRowUpsertSectionConflict.json ?? { stdout: boardRowUpsertSectionConflict.stdout, stderr: boardRowUpsertSectionConflict.stderr }); - const boardRowUpsertSectionConflictData = failedDataOf(boardRowUpsertSectionConflict.json ?? {}); - assertCondition(String((boardRowUpsertSectionConflictData.details as JsonRecord).message ?? "").includes("use gh issue board-row move"), "upsert section conflict should point to move", boardRowUpsertSectionConflictData); - - const boardRowUpsertMissingField = await runCli([ - "gh", "issue", "board-row", "upsert", "75", - "--repo", "pikasTech/unidesk", - "--board-issue", "62", - "--section", "open", - "--category", "cli", - "--branch", "master", - "--tasks", "cq-75", - "--summary", "missing progress field", - "--focus", "focus", - "--validation", "pending", - "--dry-run", - ], env); - assertCondition(boardRowUpsertMissingField.status !== 0, "upsert add missing required generated cell should fail", boardRowUpsertMissingField.json ?? { stdout: boardRowUpsertMissingField.stdout, stderr: boardRowUpsertMissingField.stderr }); - const boardRowUpsertMissingFieldData = failedDataOf(boardRowUpsertMissingField.json ?? {}); - const boardRowUpsertMissingFieldDetails = boardRowUpsertMissingFieldData.details as JsonRecord; - assertCondition(String(boardRowUpsertMissingFieldDetails.message ?? "").includes("requires values") && Array.isArray(boardRowUpsertMissingFieldData.missingFields) && (boardRowUpsertMissingFieldData.missingFields as unknown[]).includes("progress"), "upsert missing field should be structured", boardRowUpsertMissingFieldData); - - const boardRowUpsertColumnMismatch = await runCli([ - "gh", "issue", "board-row", "upsert", "79", - "--repo", "pikasTech/unidesk", - "--board-issue", "62", - "--focus", "bad existing row", - "--dry-run", - ], env); - assertCondition(boardRowUpsertColumnMismatch.status !== 0, "upsert existing row with column mismatch should fail", boardRowUpsertColumnMismatch.json ?? { stdout: boardRowUpsertColumnMismatch.stdout, stderr: boardRowUpsertColumnMismatch.stderr }); - const boardRowUpsertColumnMismatchData = failedDataOf(boardRowUpsertColumnMismatch.json ?? {}); - assertCondition(String((boardRowUpsertColumnMismatchData.details as JsonRecord).message ?? "").includes("column count"), "upsert column mismatch should be structured", boardRowUpsertColumnMismatchData); - - const boardRowDryRunRequestCountBefore = mock.requests.length; - const boardRowDryRun = await runCli(["gh", "issue", "board-row", "update", "35", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--field", "focus", "--value", "复核 A | B\nsecond line"], env); - assertCondition(boardRowDryRun.status === 0, "board-row update should default to dry-run without concurrency expectation", boardRowDryRun.json ?? { stdout: boardRowDryRun.stdout, stderr: boardRowDryRun.stderr }); - const boardRowDryRunData = dataOf(boardRowDryRun.json ?? {}); - assertCondition(boardRowDryRunData.command === "issue board-row update" && boardRowDryRunData.dryRun === true && boardRowDryRunData.planned === true, "board-row update should default to dry-run", boardRowDryRunData); - const dryRunUpdate = boardRowDryRunData.update as JsonRecord; - assertCondition(dryRunUpdate.oldRow === "| #35 | OPEN | master | pass | cq-35 | 当前关注点:#19 / #26 / #30 | doing |", "board-row dry-run should expose old row", dryRunUpdate); - assertCondition(dryRunUpdate.newRow === "| #35 | OPEN | master | pass | cq-35 | 复核 A \\| B second line | doing |", "board-row dry-run should escape table pipes and fold cell newlines", dryRunUpdate); - const dryRunGuard = boardRowDryRunData.guard as JsonRecord; - assertCondition(dryRunGuard.ok === true, "board-row dry-run should include body guard result", dryRunGuard); - const dryRunSafety = boardRowDryRunData.bodyOnlySafety as JsonRecord; - const dryRunOldBody = dryRunSafety.oldBody as JsonRecord; - const dryRunNewBody = dryRunSafety.newBody as JsonRecord; - assertCondition(typeof dryRunOldBody.bodySha === "string" && String(dryRunOldBody.bodySha).length === 64, "board-row dry-run should expose old body sha", dryRunSafety); - assertCondition(dryRunNewBody.containsLiteralBackslashN === false && dryRunNewBody.shellPollution && typeof dryRunNewBody.shellPollution === "object", "board-row dry-run must not introduce literal backslash-n pollution", dryRunNewBody); - const defaultDryRunWriteCount = mock.requests.slice(boardRowDryRunRequestCountBefore).filter((request) => request.method === "PATCH").length; - assertCondition(defaultDryRunWriteCount === 0, "board-row default dry-run must not PATCH GitHub", { requests: mock.requests.slice(boardRowDryRunRequestCountBefore) }); - - const boardRowValidationDryRun = await runCli(["gh", "issue", "board-row", "update", "35", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--field", "validation", "--value", "manual pass", "--dry-run"], env); - assertCondition(boardRowValidationDryRun.status === 0, "board-row update validation alias should dry-run", boardRowValidationDryRun.json ?? { stdout: boardRowValidationDryRun.stdout, stderr: boardRowValidationDryRun.stderr }); - const boardRowValidationData = dataOf(boardRowValidationDryRun.json ?? {}); - const validationUpdate = boardRowValidationData.update as JsonRecord; - assertCondition(validationUpdate.targetColumn === "acceptance" && validationUpdate.targetColumnIndex === 3, "validation field should map to 验收状态/acceptance column", validationUpdate); - - const boardRowPollutedValue = await runCli(["gh", "issue", "board-row", "update", "35", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--field", "focus", "--value", "bad\\nvalue", "--dry-run"], env); - assertCondition(boardRowPollutedValue.status !== 0, "board-row update should reject literal backslash-n values", boardRowPollutedValue.json ?? { stdout: boardRowPollutedValue.stdout, stderr: boardRowPollutedValue.stderr }); - const boardRowPollutedData = failedDataOf(boardRowPollutedValue.json ?? {}); - const boardRowPollutedDetails = boardRowPollutedData.details as JsonRecord; - assertCondition(boardRowPollutedData.degradedReason === "validation-failed" && String(boardRowPollutedDetails.message ?? "").includes("--value contains literal shell escape"), "board-row polluted value should fail before planning", boardRowPollutedData); - - const boardRowPatchRequestCountBefore = mock.requests.length; - const oldBodySha = String(dryRunOldBody.bodySha); - const boardRowPatch = await runCli(["gh", "issue", "board-row", "update", "35", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--field", "focus", "--value", "复核 A | B\nsecond line", "--expect-body-sha", oldBodySha], env); - assertCondition(boardRowPatch.status === 0, "board-row update with expect body sha should PATCH", boardRowPatch.json ?? { stdout: boardRowPatch.stdout, stderr: boardRowPatch.stderr }); - const boardRowPatchData = dataOf(boardRowPatch.json ?? {}); - assertCondition(boardRowPatchData.dryRun === false && boardRowPatchData.rest === true, "board-row patch should report a real REST update", boardRowPatchData); - const boardRowPatchRequests = mock.requests.slice(boardRowPatchRequestCountBefore).filter((request) => request.method === "PATCH" && request.url === "/repos/pikasTech/unidesk/issues/20"); - assertCondition(boardRowPatchRequests.length === 1, "board-row patch should send exactly one PATCH", { requests: mock.requests.slice(boardRowPatchRequestCountBefore) }); - const boardRowPatchPayload = JSON.parse(boardRowPatchRequests[0]?.body ?? "{}") as JsonRecord; - assertCondition(typeof boardRowPatchPayload.body === "string", "board-row patch payload should carry body string", boardRowPatchPayload); - const patchedBody = String(boardRowPatchPayload.body ?? ""); - assertCondition(patchedBody.includes("| #35 | OPEN | master | pass | cq-35 | 复核 A \\| B second line | doing |"), "board-row patch payload should contain escaped updated row", patchedBody); - assertCondition(!patchedBody.includes("\\n"), "board-row patch payload should not add literal backslash-n pollution to markdown body", patchedBody); - - const addRowFile = join(tmp, "board-add-row.md"); - writeFileSync(addRowFile, "| #91 | OPEN | master | pass | cq-91 | 新增 open row | doing |\n", "utf8"); - const noGuardRowFile = join(tmp, "board-add-row-no-guard.md"); - writeFileSync(noGuardRowFile, "| #94 | OPEN | master | pass | cq-94 | no guard row | doing |\n", "utf8"); - const mismatchRowFile = join(tmp, "board-add-row-mismatch.md"); - writeFileSync(mismatchRowFile, "| #92 | OPEN | master | pass | cq-92 | too few |\n", "utf8"); - const statusMismatchRowFile = join(tmp, "board-add-row-status-mismatch.md"); - writeFileSync(statusMismatchRowFile, "| #93 | OPEN | master | pass | cq-93 | status mismatch row | doing |\n", "utf8"); - - const boardRowAddNoGuardRequestCountBefore = mock.requests.length; - const boardRowAddNoGuard = await runCli(["gh", "issue", "board-row", "add", "94", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--section", "open", "--row-file", noGuardRowFile], env); - assertCondition(boardRowAddNoGuard.status === 0, "board-row add without guard should stay on the dry-run path", boardRowAddNoGuard.json ?? { stdout: boardRowAddNoGuard.stdout, stderr: boardRowAddNoGuard.stderr }); - const boardRowAddNoGuardData = dataOf(boardRowAddNoGuard.json ?? {}); - assertCondition(boardRowAddNoGuardData.dryRun === true && boardRowAddNoGuardData.planned === true, "board-row add without guard should not PATCH GitHub", boardRowAddNoGuardData); - const boardRowAddNoGuardPlan = boardRowAddNoGuardData.add as JsonRecord; - assertCondition(boardRowAddNoGuardPlan.section === "open" && Number(boardRowAddNoGuardPlan.insertAfterLine ?? 0) > 0, "board-row add without guard should still return an insertion plan", boardRowAddNoGuardPlan); - const boardRowAddNoGuardWriteCount = mock.requests.slice(boardRowAddNoGuardRequestCountBefore).filter((request) => request.method === "PATCH").length; - assertCondition(boardRowAddNoGuardWriteCount === 0, "board-row add without guard must not PATCH GitHub", { requests: mock.requests.slice(boardRowAddNoGuardRequestCountBefore) }); - - const boardRowAddDryRunRequestCountBefore = mock.requests.length; - const boardRowAddDryRun = await runCli(["gh", "issue", "board-row", "add", "91", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--section", "open", "--row-file", addRowFile, "--dry-run"], env); - assertCondition(boardRowAddDryRun.status === 0, "board-row add dry-run should succeed", boardRowAddDryRun.json ?? { stdout: boardRowAddDryRun.stdout, stderr: boardRowAddDryRun.stderr }); - const boardRowAddDryRunData = dataOf(boardRowAddDryRun.json ?? {}); - assertCondition(boardRowAddDryRunData.command === "issue board-row add" && boardRowAddDryRunData.dryRun === true && boardRowAddDryRunData.planned === true, "board-row add should default to dry-run", boardRowAddDryRunData); - const boardRowAddDryRunPlan = boardRowAddDryRunData.add as JsonRecord; - const boardRowAddDryRunValidation = boardRowAddDryRunPlan.validation as JsonRecord; - assertCondition(boardRowAddDryRunPlan.section === "open" && boardRowAddDryRunValidation.actualStatus === "OPEN", "board-row add dry-run should validate the target section and GitHub status", boardRowAddDryRunPlan); - const boardRowAddDryRunPatchCount = mock.requests.slice(boardRowAddDryRunRequestCountBefore).filter((request) => request.method === "PATCH").length; - assertCondition(boardRowAddDryRunPatchCount === 0, "board-row add dry-run must not PATCH GitHub", { requests: mock.requests.slice(boardRowAddDryRunRequestCountBefore) }); - - const boardRowListBeforeAdd = await runCli(["gh", "issue", "board-row", "list", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--state", "open"], env); - assertCondition(boardRowListBeforeAdd.status === 0, "board-row list before add should succeed", boardRowListBeforeAdd.json ?? { stdout: boardRowListBeforeAdd.stdout }); - const boardRowListBeforeAddData = dataOf(boardRowListBeforeAdd.json ?? {}); - const boardRowAddBoardSha = String((boardRowListBeforeAddData.boardIssue as JsonRecord).bodySha ?? ""); - const boardRowAddRequestCountBefore = mock.requests.length; - const boardRowAdd = await runCli(["gh", "issue", "board-row", "add", "91", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--section", "open", "--row-file", addRowFile, "--expect-body-sha", boardRowAddBoardSha], env); - assertCondition(boardRowAdd.status === 0, "board-row add with expect body sha should PATCH", boardRowAdd.json ?? { stdout: boardRowAdd.stdout, stderr: boardRowAdd.stderr }); - const boardRowAddData = dataOf(boardRowAdd.json ?? {}); - assertCondition(boardRowAddData.dryRun === false && boardRowAddData.rest === true, "board-row add should report a real REST update", boardRowAddData); - const boardRowAddPlan = boardRowAddData.add as JsonRecord; - const boardRowAddValidation = boardRowAddPlan.validation as JsonRecord; - assertCondition(boardRowAddPlan.section === "open" && boardRowAddValidation.expectedStatus === "OPEN" && boardRowAddValidation.actualStatus === "OPEN", "board-row add should validate section/status alignment", boardRowAddPlan); - const boardRowAddRequests = mock.requests.slice(boardRowAddRequestCountBefore).filter((request) => request.method === "PATCH" && request.url === "/repos/pikasTech/unidesk/issues/20"); - assertCondition(boardRowAddRequests.length === 1, "board-row add should send exactly one PATCH", { requests: mock.requests.slice(boardRowAddRequestCountBefore) }); - const boardRowAddPayload = JSON.parse(boardRowAddRequests[0]?.body ?? "{}") as JsonRecord; - assertCondition(String(boardRowAddPayload.body ?? "").includes("| #91 | OPEN | master | pass | cq-91 | 新增 open row | doing |"), "board-row add payload should contain the inserted row", boardRowAddPayload); - - const boardRowGetAdded = await runCli(["gh", "issue", "board-row", "get", "91", "--repo", "pikasTech/unidesk", "--board-issue", "20"], env); - assertCondition(boardRowGetAdded.status === 0, "board-row get should find the added row", boardRowGetAdded.json ?? { stdout: boardRowGetAdded.stdout, stderr: boardRowGetAdded.stderr }); - const boardRowGetAddedData = dataOf(boardRowGetAdded.json ?? {}); - const boardRowGetAddedRow = boardRowGetAddedData.row as JsonRecord; - assertCondition(boardRowGetAddedRow.issueNumber === 91 && boardRowGetAddedRow.section === "open", "board-row get should see the added issue in OPEN", boardRowGetAddedData); - const boardRowListAfterAdd = await runCli(["gh", "issue", "board-row", "list", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--state", "open"], env); - assertCondition(boardRowListAfterAdd.status === 0, "board-row list after add should succeed", boardRowListAfterAdd.json ?? { stdout: boardRowListAfterAdd.stdout }); - const boardRowListAfterAddData = dataOf(boardRowListAfterAdd.json ?? {}); - const boardRowListAfterAddRows = boardRowListAfterAddData.rows as JsonRecord[]; - assertCondition(boardRowListAfterAddData.count === 5 && boardRowListAfterAddRows.some((row) => row.issueNumber === 91), "board-row add should make the new row visible to the parser", boardRowListAfterAddData); - - const boardRowAddDuplicate = await runCli(["gh", "issue", "board-row", "add", "91", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--section", "open", "--row-file", addRowFile], env); - assertCondition(boardRowAddDuplicate.status !== 0, "duplicate board-row add should fail structurally", boardRowAddDuplicate.json ?? { stdout: boardRowAddDuplicate.stdout, stderr: boardRowAddDuplicate.stderr }); - const boardRowAddDuplicateData = failedDataOf(boardRowAddDuplicate.json ?? {}); - assertCondition(String((boardRowAddDuplicateData.details as JsonRecord).message ?? "").includes("already exists"), "duplicate add should report duplicate row", boardRowAddDuplicateData); - - const boardRowAddMismatch = await runCli(["gh", "issue", "board-row", "add", "92", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--section", "open", "--row-file", mismatchRowFile], env); - assertCondition(boardRowAddMismatch.status !== 0, "column count mismatch should fail structurally", boardRowAddMismatch.json ?? { stdout: boardRowAddMismatch.stdout, stderr: boardRowAddMismatch.stderr }); - const boardRowAddMismatchData = failedDataOf(boardRowAddMismatch.json ?? {}); - assertCondition(String((boardRowAddMismatchData.details as JsonRecord).message ?? "").includes("column count"), "column mismatch should be reported", boardRowAddMismatchData); - - const boardRowAddStatusConflict = await runCli(["gh", "issue", "board-row", "add", "93", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--section", "closed", "--row-file", statusMismatchRowFile], env); - assertCondition(boardRowAddStatusConflict.status !== 0, "section/status mismatch should fail structurally", boardRowAddStatusConflict.json ?? { stdout: boardRowAddStatusConflict.stdout, stderr: boardRowAddStatusConflict.stderr }); - const boardRowAddStatusConflictData = failedDataOf(boardRowAddStatusConflict.json ?? {}); - assertCondition(String((boardRowAddStatusConflictData.details as JsonRecord).message ?? "").includes("GitHub 状态"), "section/status mismatch should be reported", boardRowAddStatusConflictData); - - const boardRowDeleteNoGuardRequestCountBefore = mock.requests.length; - const boardRowDeleteNoGuard = await runCli(["gh", "issue", "board-row", "delete", "35", "--repo", "pikasTech/unidesk", "--board-issue", "20"], env); - assertCondition(boardRowDeleteNoGuard.status === 0, "board-row delete without guard should stay on the dry-run path", boardRowDeleteNoGuard.json ?? { stdout: boardRowDeleteNoGuard.stdout, stderr: boardRowDeleteNoGuard.stderr }); - const boardRowDeleteNoGuardData = dataOf(boardRowDeleteNoGuard.json ?? {}); - assertCondition(boardRowDeleteNoGuardData.dryRun === true && boardRowDeleteNoGuardData.planned === true, "board-row delete without guard should not PATCH GitHub", boardRowDeleteNoGuardData); - const boardRowDeleteNoGuardPlan = boardRowDeleteNoGuardData.delete as JsonRecord; - assertCondition(boardRowDeleteNoGuardPlan.section === "open" && Number(boardRowDeleteNoGuardPlan.lineNumber ?? 0) > 0, "board-row delete without guard should return the matched row plan", boardRowDeleteNoGuardPlan); - const boardRowDeleteNoGuardLinePlan = boardRowDeleteNoGuardPlan.linePlan as JsonRecord; - assertCondition(boardRowDeleteNoGuardLinePlan.action === "remove" && Number(boardRowDeleteNoGuardLinePlan.lineNumber ?? 0) > 0, "board-row delete should expose a line plan", boardRowDeleteNoGuardLinePlan); - const boardRowDeleteNoGuardWriteCount = mock.requests.slice(boardRowDeleteNoGuardRequestCountBefore).filter((request) => request.method === "PATCH").length; - assertCondition(boardRowDeleteNoGuardWriteCount === 0, "board-row delete without guard must not PATCH GitHub", { requests: mock.requests.slice(boardRowDeleteNoGuardRequestCountBefore) }); - - const boardRowDeleteStale = await runCli(["gh", "issue", "board-row", "delete", "91", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--expect-body-sha", boardRowAddBoardSha], env); - assertCondition(boardRowDeleteStale.status !== 0, "stale board-row delete should fail structurally", boardRowDeleteStale.json ?? { stdout: boardRowDeleteStale.stdout, stderr: boardRowDeleteStale.stderr }); - const boardRowDeleteStaleData = failedDataOf(boardRowDeleteStale.json ?? {}); - assertCondition(boardRowDeleteStaleData.degradedReason === "validation-failed", "stale board-row delete should be validation-failed", boardRowDeleteStaleData); - - const boardRowDeleteBoardSha = String((boardRowListAfterAddData.boardIssue as JsonRecord).bodySha ?? ""); - const boardRowDeleteRequestCountBefore = mock.requests.length; - const boardRowDelete = await runCli(["gh", "issue", "board-row", "delete", "91", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--expect-body-sha", boardRowDeleteBoardSha], env); - assertCondition(boardRowDelete.status === 0, "board-row delete with expect body sha should PATCH", boardRowDelete.json ?? { stdout: boardRowDelete.stdout, stderr: boardRowDelete.stderr }); - const boardRowDeleteData = dataOf(boardRowDelete.json ?? {}); - assertCondition(boardRowDeleteData.dryRun === false && boardRowDeleteData.rest === true, "board-row delete should report a real REST update", boardRowDeleteData); - const boardRowDeleteRequests = mock.requests.slice(boardRowDeleteRequestCountBefore).filter((request) => request.method === "PATCH" && request.url === "/repos/pikasTech/unidesk/issues/20"); - assertCondition(boardRowDeleteRequests.length === 1, "board-row delete should send exactly one PATCH", { requests: mock.requests.slice(boardRowDeleteRequestCountBefore) }); - const boardRowDeletePayload = JSON.parse(boardRowDeleteRequests[0]?.body ?? "{}") as JsonRecord; - assertCondition(!String(boardRowDeletePayload.body ?? "").includes("#91"), "board-row delete payload should remove the row", boardRowDeletePayload); - const boardRowGetDeleted = await runCli(["gh", "issue", "board-row", "get", "91", "--repo", "pikasTech/unidesk", "--board-issue", "20"], env); - assertCondition(boardRowGetDeleted.status !== 0, "deleted board-row should no longer be discoverable", boardRowGetDeleted.json ?? { stdout: boardRowGetDeleted.stdout, stderr: boardRowGetDeleted.stderr }); - const boardRowGetDeletedData = failedDataOf(boardRowGetDeleted.json ?? {}); - assertCondition(String((boardRowGetDeletedData.details as JsonRecord).message ?? "").includes("was not found"), "deleted board-row should report missing row", boardRowGetDeletedData); - const boardRowListAfterDelete = await runCli(["gh", "issue", "board-row", "list", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--state", "open"], env); - assertCondition(boardRowListAfterDelete.status === 0, "board-row list after delete should succeed", boardRowListAfterDelete.json ?? { stdout: boardRowListAfterDelete.stdout }); - const boardRowListAfterDeleteData = dataOf(boardRowListAfterDelete.json ?? {}); - const boardRowListAfterDeleteNumbers = (boardRowListAfterDeleteData.rows as JsonRecord[]).map((row) => row.issueNumber); - assertCondition(boardRowListAfterDeleteData.count === 4 && JSON.stringify(boardRowListAfterDeleteNumbers) === JSON.stringify([20, 35, 45, 40]), "board-row delete should preserve other row order", boardRowListAfterDeleteData); - - const boardRowMoveDryRun = await runCli(["gh", "issue", "board-row", "move", "35", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--to", "closed", "--status", "CLOSED", "--dry-run"], env); - assertCondition(boardRowMoveDryRun.status === 0, "board-row move dry-run should succeed", boardRowMoveDryRun.json ?? { stdout: boardRowMoveDryRun.stdout, stderr: boardRowMoveDryRun.stderr }); - const boardRowMoveDryRunData = dataOf(boardRowMoveDryRun.json ?? {}); - assertCondition(boardRowMoveDryRunData.command === "issue board-row move" && boardRowMoveDryRunData.dryRun === true && boardRowMoveDryRunData.planned === true, "board-row move should default to dry-run", boardRowMoveDryRunData); - const boardRowMoveDryRunPlan = boardRowMoveDryRunData.move as JsonRecord; - const boardRowMoveDryRunStatus = boardRowMoveDryRunPlan.status as JsonRecord; - assertCondition(boardRowMoveDryRunPlan.from === "open" && boardRowMoveDryRunPlan.to === "closed" && boardRowMoveDryRunStatus.requested === "CLOSED", "board-row move dry-run should plan the cross-section migration", boardRowMoveDryRunPlan); - - const boardRowMoveConflict = await runCli(["gh", "issue", "board-row", "move", "35", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--to", "closed", "--status", "OPEN", "--dry-run"], env); - assertCondition(boardRowMoveConflict.status !== 0, "board-row move status conflict should fail structurally", boardRowMoveConflict.json ?? { stdout: boardRowMoveConflict.stdout, stderr: boardRowMoveConflict.stderr }); - const boardRowMoveConflictData = failedDataOf(boardRowMoveConflict.json ?? {}); - assertCondition(String((boardRowMoveConflictData.details as JsonRecord).message ?? "").includes("conflicts with --to"), "board-row move should report status conflict", boardRowMoveConflictData); - - const boardRowListBeforeMove = await runCli(["gh", "issue", "board-row", "list", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--state", "open"], env); - assertCondition(boardRowListBeforeMove.status === 0, "board-row list before move should succeed", boardRowListBeforeMove.json ?? { stdout: boardRowListBeforeMove.stdout }); - const boardRowMoveBoardSha = String((dataOf(boardRowListBeforeMove.json ?? {}).boardIssue as JsonRecord).bodySha ?? ""); - const boardRowMoveRequestCountBefore = mock.requests.length; - const boardRowMove = await runCli(["gh", "issue", "board-row", "move", "35", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--to", "closed", "--status", "CLOSED", "--expect-body-sha", boardRowMoveBoardSha], env); - assertCondition(boardRowMove.status === 0, "board-row move with expect body sha should PATCH", boardRowMove.json ?? { stdout: boardRowMove.stdout, stderr: boardRowMove.stderr }); - const boardRowMoveData = dataOf(boardRowMove.json ?? {}); - assertCondition(boardRowMoveData.dryRun === false && boardRowMoveData.rest === true, "board-row move should report a real REST update", boardRowMoveData); - const boardRowMovePlan = boardRowMoveData.move as JsonRecord; - const boardRowMoveStatus = boardRowMovePlan.status as JsonRecord; - assertCondition(boardRowMovePlan.from === "open" && boardRowMovePlan.to === "closed" && boardRowMoveStatus.new === "CLOSED", "board-row move should update the GitHub status column", boardRowMovePlan); - const boardRowMoveLinePlan = boardRowMovePlan.linePlan as JsonRecord; - const boardRowMoveSectionPlan = boardRowMovePlan.sectionPlan as JsonRecord; - assertCondition(boardRowMoveLinePlan.action === "move" && Number(boardRowMoveLinePlan.sourceLineNumber ?? 0) > 0 && Number(boardRowMoveLinePlan.newLineNumber ?? 0) > 0, "board-row move should expose a line plan", boardRowMoveLinePlan); - assertCondition(boardRowMoveSectionPlan.from === "open" && boardRowMoveSectionPlan.to === "closed", "board-row move should expose a section plan", boardRowMoveSectionPlan); - const boardRowMoveRequests = mock.requests.slice(boardRowMoveRequestCountBefore).filter((request) => request.method === "PATCH" && request.url === "/repos/pikasTech/unidesk/issues/20"); - assertCondition(boardRowMoveRequests.length === 1, "board-row move should send exactly one PATCH", { requests: mock.requests.slice(boardRowMoveRequestCountBefore) }); - const boardRowMovePayload = JSON.parse(boardRowMoveRequests[0]?.body ?? "{}") as JsonRecord; - assertCondition(String(boardRowMovePayload.body ?? "").includes("| #35 | CLOSED | master | pass | cq-35 |"), "board-row move payload should move the row into CLOSED", boardRowMovePayload); - const boardRowOpenAfterMove = await runCli(["gh", "issue", "board-row", "list", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--state", "open"], env); - const boardRowOpenAfterMoveData = dataOf(boardRowOpenAfterMove.json ?? {}); - assertCondition(boardRowOpenAfterMoveData.count === 3 && !(boardRowOpenAfterMoveData.rows as JsonRecord[]).some((row) => row.issueNumber === 35), "board-row move should remove the row from OPEN", boardRowOpenAfterMoveData); - const boardRowClosedAfterMove = await runCli(["gh", "issue", "board-row", "list", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--state", "closed"], env); - const boardRowClosedAfterMoveData = dataOf(boardRowClosedAfterMove.json ?? {}); - const boardRowClosedAfterMoveRows = boardRowClosedAfterMoveData.rows as JsonRecord[]; - assertCondition(boardRowClosedAfterMoveData.count === 4 && boardRowClosedAfterMoveRows.some((row) => row.issueNumber === 35), "board-row move should add the row to CLOSED", boardRowClosedAfterMoveData); - const boardRowMoved = await runCli(["gh", "issue", "board-row", "get", "35", "--repo", "pikasTech/unidesk", "--board-issue", "20"], env); - const boardRowMovedData = dataOf(boardRowMoved.json ?? {}); - const boardRowMovedRow = boardRowMovedData.row as JsonRecord; - assertCondition(boardRowMovedRow.section === "closed" && Array.isArray(boardRowMovedRow.cells) && boardRowMovedRow.cells[1] === "CLOSED", "board-row move should preserve row fields while updating GitHub status", boardRowMovedRow); - const boardRowMoveTargetSectionPlan = boardRowMovePlan.sectionPlan as JsonRecord; - assertCondition(boardRowMoveTargetSectionPlan.targetHeading === "## 看板(CLOSED)", "board-row move should point to the CLOSED section heading", boardRowMoveTargetSectionPlan); - - const badListField = await runCli(["gh", "issue", "list", "--repo", "pikasTech/unidesk", "--json", "number,body"], env); - assertCondition(badListField.status !== 0, "issue list unsupported --json field should fail", badListField.json ?? { stdout: badListField.stdout }); - const badListFieldData = failedDataOf(badListField.json ?? {}); - assertCondition(badListFieldData.degradedReason === "validation-failed", "issue list unsupported --json should be validation-failed", badListFieldData); - assertCondition(badListFieldData.runnerDisposition === "business-failed", "issue list unsupported --json should be business-failed", badListFieldData); - - const badState = await runCli(["gh", "issue", "list", "--repo", "pikasTech/unidesk", "--state", "triaged"], env); - assertCondition(badState.status !== 0, "issue list unsupported state should fail", badState.json ?? { stdout: badState.stdout }); - const badStateData = failedDataOf(badState.json ?? {}); - assertCondition(badStateData.degradedReason === "validation-failed", "issue list unsupported state should be validation-failed", badStateData); - assertCondition(badStateData.runnerDisposition === "business-failed", "issue list unsupported state should be business-failed", badStateData); - - const readBody = await runCli(["gh", "issue", "read", "20", "--repo", "pikasTech/unidesk", "--json", "body"], env); - assertCondition(readBody.status === 0, "issue read --json body should succeed", readBody.json ?? { stdout: readBody.stdout }); - const readBodyData = dataOf(readBody.json ?? {}); - const readIssue = readBodyData.issue as JsonRecord; - assertCondition(typeof readIssue.body === "string" && readIssue.body.includes("## 看板(OPEN)"), ".data.issue.body should remain readable", readBodyData); - const readBodyHint = readBodyData.codeQueueBoardHint as JsonRecord; - assertCondition(readBodyHint.detected === false && String(readBodyHint.warning ?? "").includes("governance board only"), "issue read #20 should remind callers that #20 is governance-only", readBodyHint); - const selectedJson = readBodyData.json as JsonRecord; - assertCondition(typeof selectedJson.body === "string" && selectedJson.body === readIssue.body, "selected json body should match issue body", readBodyData); - assertCondition(!("comments" in selectedJson), "--json body should not imply comments field", selectedJson); - - const viewBody = await runCli(["gh", "issue", "view", "20", "--repo", "pikasTech/unidesk", "--json", "body"], env); - assertCondition(viewBody.status === 0, "issue view should succeed as canonical read path", viewBody.json ?? { stdout: viewBody.stdout }); - const viewBodyData = dataOf(viewBody.json ?? {}); - const viewIssue = viewBodyData.issue as JsonRecord; - assertCondition(typeof viewIssue.body === "string" && viewIssue.body.includes("## 看板(OPEN)"), "issue view should keep .data.issue.body readable", viewBodyData); - const viewSelectedJson = viewBodyData.json as JsonRecord; - assertCondition(typeof viewSelectedJson.body === "string" && viewSelectedJson.body === readIssue.body, "issue view should preserve selected json body", viewBodyData); - - const issueUrlView = await runCli(["gh", "issue", "view", "https://github.com/pikasTech/HWLAB/issues/7", "--json", "body,title,state"], env); - assertCondition(issueUrlView.status === 0, "issue view should accept GitHub issue URL target", issueUrlView.json ?? { stdout: issueUrlView.stdout }); - const issueUrlViewData = dataOf(issueUrlView.json ?? {}); - assertCondition(issueUrlViewData.repo === "pikasTech/HWLAB", "issue URL target should derive repo", issueUrlViewData); - assertCondition((issueUrlViewData.issue as JsonRecord).number === 7, "issue URL target should derive issue number", issueUrlViewData); - const issueUrlDisclosure = issueUrlViewData.disclosure as JsonRecord; - assertCondition(issueUrlDisclosure.shorthand && (issueUrlDisclosure.shorthand as JsonRecord).source === "github-url", "issue URL target should be disclosed", issueUrlDisclosure); - - const issuePrUrlMismatch = await runCli(["gh", "issue", "view", "https://github.com/pikasTech/HWLAB/pull/7", "--json", "body"], env); - assertCondition(issuePrUrlMismatch.status !== 0, "issue view should reject PR URLs", issuePrUrlMismatch.json ?? { stdout: issuePrUrlMismatch.stdout }); - const issuePrUrlMismatchData = failedDataOf(issuePrUrlMismatch.json ?? {}); - assertCondition(failureMessageOf(issuePrUrlMismatchData).includes("GitHub pr URL"), "issue view PR URL mismatch should be explicit", issuePrUrlMismatchData); - - const issueNumberOption = await runCli(["gh", "issue", "view", "--repo", "pikasTech/HWLAB", "--number", "7", "--json", "body"], env); - assertCondition(issueNumberOption.status === 0, "issue view should accept --number compatibility alias", issueNumberOption.json ?? { stdout: issueNumberOption.stdout }); - const issueNumberOptionData = dataOf(issueNumberOption.json ?? {}); - assertCondition(issueNumberOptionData.repo === "pikasTech/HWLAB", "issue view --number should preserve explicit repo", issueNumberOptionData); - assertCondition((issueNumberOptionData.issue as JsonRecord).number === 7, "issue view --number should read the requested issue", issueNumberOptionData); - const issueNumberOptionHint = issueNumberOptionData.standardSyntaxHint as JsonRecord; - assertCondition(issueNumberOptionHint.compatibility === true && String(issueNumberOptionHint.standardCommand ?? "").includes("gh issue view 7 --repo pikasTech/HWLAB"), "issue view --number should return standard syntax hint", issueNumberOptionHint); - - const shorthandRaw = await runCli(["gh", "issue", "view", "pikasTech/HWLAB#7", "--raw"], env); - assertCondition(shorthandRaw.status === 0, "issue view should accept owner/repo#number shorthand with --raw", shorthandRaw.json ?? { stdout: shorthandRaw.stdout }); - const shorthandRawData = dataOf(shorthandRaw.json ?? {}); - assertCondition(shorthandRawData.repo === "pikasTech/HWLAB", "issue shorthand should derive repo from owner/repo#number", shorthandRawData); - const shorthandIssueData = shorthandRawData.issue as JsonRecord; - assertCondition(shorthandIssueData.number === 7 && String(shorthandIssueData.body ?? "").includes("shorthand body fixture"), "issue shorthand should read the requested issue", shorthandRawData); - const shorthandDisclosure = shorthandRawData.disclosure as JsonRecord; - assertCondition(shorthandDisclosure.raw === true && shorthandDisclosure.fullDisclosure === true, "--raw should mark explicit full disclosure", shorthandDisclosure); - const shorthandSelected = shorthandRawData.json as JsonRecord; - assertCondition(shorthandSelected.body === shorthandIssueData.body && Array.isArray(shorthandSelected.comments), "--raw should select the supported full issue read field set", shorthandRawData); - assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/HWLAB/issues/7"), "issue shorthand should call the derived repo REST path", mock.requests); - - const shorthandConflict = await runCli(["gh", "issue", "read", "pikasTech/HWLAB#7", "--repo", "pikasTech/unidesk", "--raw"], env); - assertCondition(shorthandConflict.status !== 0, "issue shorthand with conflicting --repo should fail", shorthandConflict.json ?? { stdout: shorthandConflict.stdout }); - const shorthandConflictData = failedDataOf(shorthandConflict.json ?? {}); - assertCondition(shorthandConflictData.degradedReason === "validation-failed", "conflicting --repo should be validation-failed", shorthandConflictData); - assertCondition(String(shorthandConflictData.message ?? "").includes("resolves to repo pikasTech/HWLAB"), "conflict message should name the derived repo", shorthandConflictData); - const issueConflictCommands = shorthandConflictData.supportedCommands as string[]; - assertCondition(Array.isArray(issueConflictCommands) && issueConflictCommands.some((command) => command === "bun scripts/cli.ts gh issue view 7 --repo pikasTech/HWLAB --json body,title,state,closed,closedAt,comments,commentCount,number,url,author,createdAt,updatedAt"), "conflict should include the exact supported issue view command", shorthandConflictData); - - const rawIssueList = await runCli(["gh", "issue", "list", "--raw"], env); - assertCondition(rawIssueList.status === 0, "issue list --raw should be a supported explicit list disclosure path", rawIssueList.json ?? { stdout: rawIssueList.stdout }); - const rawIssueListData = dataOf(rawIssueList.json ?? {}); - assertCondition(rawIssueListData.command === "issue list" && rawIssueListData.rawCount === 3, "issue list --raw should keep compact list semantics with raw pagination metadata", rawIssueListData); - - const readFields = await runCli(["gh", "issue", "read", "20", "--repo", "pikasTech/unidesk", "--json", "body,title,state,closed,closedAt,comments,commentCount"], env); - assertCondition(readFields.status === 0, "common --json field selection should succeed", readFields.json ?? { stdout: readFields.stdout }); - const readFieldsData = dataOf(readFields.json ?? {}); - const fieldsJson = readFieldsData.json as JsonRecord; - assertCondition(fieldsJson.title === "长期总看板", "selected json title should be exposed", fieldsJson); - assertCondition(fieldsJson.closed === false && fieldsJson.closedAt === null, "open issue read should expose lifecycle fields", fieldsJson); - assertCondition(Array.isArray(fieldsJson.comments) && fieldsJson.comments.length === 1, "selected json comments should be exposed", fieldsJson); - assertCondition(fieldsJson.commentCount === 1, "selected json commentCount should be exposed", fieldsJson); - - const unsupported = await runCli(["gh", "issue", "read", "20", "--repo", "pikasTech/unidesk", "--json", "body,unknown"], env); - assertCondition(unsupported.status !== 0, "unsupported --json field should fail", unsupported.json ?? { stdout: unsupported.stdout }); - const unsupportedData = failedDataOf(unsupported.json ?? {}); - assertCondition(unsupportedData.degradedReason === "validation-failed", "unsupported --json should be validation-failed", unsupportedData); - assertCondition(unsupportedData.runnerDisposition === "business-failed", "unsupported --json should be business-failed", unsupportedData); - - const nullFile = join(tmp, "null.md"); - writeFileSync(nullFile, "null\n", "utf8"); - const nullEdit = await runCli(["gh", "issue", "edit", "20", "--repo", "pikasTech/unidesk", "--body-file", nullFile, "--dry-run"], env); - assertCondition(nullEdit.status !== 0, "issue edit should reject literal null body", nullEdit.json ?? { stdout: nullEdit.stdout }); - const nullData = failedDataOf(nullEdit.json ?? {}); - assertCondition(nullData.degradedReason === "validation-failed", "null body should be validation-failed", nullData); - const nullGuard = nullData.guard as JsonRecord; - assertCondition(Array.isArray(nullGuard.failures) && nullGuard.failures.includes("literal-null-body"), "null guard should report literal-null-body", nullGuard); - - const missingHeadingFile = join(tmp, "missing-heading.md"); - writeFileSync(missingHeadingFile, "# Board\n\n## Closed\n\nThis is long enough to pass length only.\n", "utf8"); - const profileBlocked = await runCli(["gh", "issue", "edit", "20", "--repo", "pikasTech/unidesk", "--body-file", missingHeadingFile, "--dry-run"], env); - assertCondition(profileBlocked.status !== 0, "#20 missing heading should fail", profileBlocked.json ?? { stdout: profileBlocked.stdout }); - const profileData = failedDataOf(profileBlocked.json ?? {}); - const profileGuard = profileData.guard as JsonRecord; - assertCondition(Array.isArray(profileGuard.failures) && profileGuard.failures.includes("profile-heading-missing"), "#20 guard should report missing heading", profileGuard); - - const pollutedBoardFile = join(tmp, "polluted-board.md"); - writeFileSync(pollutedBoardFile, [ - "# Code Queue", - "", - "## 看板(OPEN)", - "", - "| Issue | GitHub 状态 | Branch | 验收状态 | 相关 Code Queue 任务 | 当前关注点 | 进度 |", - "| --- | --- | --- | --- | --- | --- | --- |", - "| #20 | OPEN | master | meta | governance | active | active |", - "", - "## 更新 2026-05-21 15:18 北京时间", - "", - "- 这类每日简报段落必须写到每日滚动简报 issue,而不是 #20。", - "", - ].join("\n"), "utf8"); - const pollutedBoard = await runCli(["gh", "issue", "update", "20", "--repo", "pikasTech/unidesk", "--mode", "replace", "--body-file", pollutedBoardFile, "--dry-run"], env); - assertCondition(pollutedBoard.status !== 0, "#20 body guard should reject commander brief update sections", pollutedBoard.json ?? { stdout: pollutedBoard.stdout }); - const pollutedBoardData = failedDataOf(pollutedBoard.json ?? {}); - const pollutedBoardGuard = pollutedBoardData.guard as JsonRecord; - assertCondition(Array.isArray(pollutedBoardGuard.failures) && pollutedBoardGuard.failures.includes("code-queue-board-contains-commander-brief-updates"), "#20 guard should report commander brief pollution", pollutedBoardGuard); - const pollutedBoardHint = pollutedBoardGuard.codeQueueBoardHint as JsonRecord; - assertCondition(pollutedBoardHint.detected === true && String(pollutedBoardHint.route ?? "").includes("daily rolling commander brief"), "#20 guard should hint to move updates to the daily brief issue", pollutedBoardHint); - - const hwlabProductBoardFile = join(tmp, "hwlab-product-board.md"); - writeFileSync(hwlabProductBoardFile, [ - "# Code Queue", - "", - "## 看板(OPEN)", - "", - "| Issue | GitHub 状态 | Branch | 验收状态 | 相关 Code Queue 任务 | 当前关注点 | 进度 |", - "| --- | --- | --- | --- | --- | --- | --- |", - "| [pikasTech/HWLAB#108](https://github.com/pikasTech/HWLAB/issues/108) HWLAB user feedback | OPEN | main | product | cq-hwlab | Cloud Workbench 用户反馈 | doing |", - "", - ].join("\n"), "utf8"); - const hwlabProductBoard = await runCli(["gh", "issue", "update", "20", "--repo", "pikasTech/unidesk", "--mode", "replace", "--body-file", hwlabProductBoardFile, "--dry-run"], env); - assertCondition(hwlabProductBoard.status === 0, "#20 body guard should warn, not reject, HWLAB product issue rows", hwlabProductBoard.json ?? { stdout: hwlabProductBoard.stdout }); - const hwlabProductBoardData = dataOf(hwlabProductBoard.json ?? {}); - const hwlabProductBoardGuard = hwlabProductBoardData.guard as JsonRecord; - assertCondition(hwlabProductBoardGuard.ok === true, "#20 body guard should keep replacement allowed when only HWLAB product routing is detected", hwlabProductBoardGuard); - assertCondition(Array.isArray(hwlabProductBoardGuard.warnings) && hwlabProductBoardGuard.warnings.includes("code-queue-board-contains-hwlab-product-work"), "#20 guard should warn about HWLAB product routing pollution", hwlabProductBoardGuard); - const hwlabProductHint = hwlabProductBoardGuard.codeQueueBoardHint as JsonRecord; - const hwlabProductRouting = hwlabProductHint.hwlabProductRouting as JsonRecord; - assertCondition(hwlabProductRouting.detected === true && String(hwlabProductRouting.route ?? "").includes("pikasTech/HWLAB"), "#20 guard should route HWLAB product issues to the HWLAB repo", hwlabProductRouting); - - const hwlabProductUpsert = await runCli([ - "gh", - "issue", - "board-row", - "upsert", - "108", - "--repo", - "pikasTech/unidesk", - "--board-issue", - "20", - "--section", - "open", - "--branch", - "main", - "--tasks", - "cq-hwlab", - "--summary", - "pikasTech/HWLAB#108 HWLAB user feedback", - "--focus", - "Cloud Workbench 用户反馈", - "--validation", - "product", - "--progress", - "doing", - "--dry-run", - ], env); - assertCondition(hwlabProductUpsert.status === 0, "#20 board-row upsert should warn, not reject, HWLAB product issue rows", hwlabProductUpsert.json ?? { stdout: hwlabProductUpsert.stdout }); - const hwlabProductUpsertData = dataOf(hwlabProductUpsert.json ?? {}); - const hwlabProductUpsertGuard = hwlabProductUpsertData.guard as JsonRecord; - assertCondition(Array.isArray(hwlabProductUpsertGuard.warnings) && hwlabProductUpsertGuard.warnings.includes("code-queue-board-contains-hwlab-product-work"), "board-row upsert guard should report HWLAB product routing pollution", hwlabProductUpsertGuard); - - const hwlabGovernanceGuard = await runCli([ - "gh", - "issue", - "board-row", - "upsert", - "109", - "--repo", - "pikasTech/unidesk", - "--board-issue", - "20", - "--section", - "open", - "--branch", - "master", - "--tasks", - "cq-guard", - "--summary", - "UniDesk CLI guard for HWLAB#108 routing", - "--focus", - "commander governance guard prevents HWLAB product misfile", - "--validation", - "dry-run guard", - "--progress", - "ready", - "--dry-run", - ], env); - assertCondition(hwlabGovernanceGuard.status === 0, "#20 board-row upsert should allow UniDesk governance rows that mention HWLAB as routing context", hwlabGovernanceGuard.json ?? { stdout: hwlabGovernanceGuard.stdout }); - const hwlabGovernanceGuardData = dataOf(hwlabGovernanceGuard.json ?? {}); - const hwlabGovernanceGuardSummary = hwlabGovernanceGuardData.guard as JsonRecord; - assertCondition(hwlabGovernanceGuardSummary.ok === true && !(hwlabGovernanceGuardSummary.warnings as unknown[]).includes("code-queue-board-contains-hwlab-product-work"), "governance rows mentioning HWLAB should not be classified as HWLAB product work", hwlabGovernanceGuardSummary); - - const commanderBriefBlocked = await runCli(["gh", "issue", "edit", "24", "--repo", "pikasTech/unidesk", "--body-file", missingHeadingFile, "--dry-run"], env); - assertCondition(commanderBriefBlocked.status !== 0, "#24 missing heading should fail", commanderBriefBlocked.json ?? { stdout: commanderBriefBlocked.stdout }); - const commanderBriefData = failedDataOf(commanderBriefBlocked.json ?? {}); - const commanderBriefGuard = commanderBriefData.guard as JsonRecord; - assertCondition(Array.isArray(commanderBriefGuard.failures) && commanderBriefGuard.failures.includes("profile-heading-missing"), "#24 guard should report missing heading", commanderBriefGuard); - - const briefWrongProfile = await runCli(["gh", "issue", "edit", "20", "--repo", "pikasTech/unidesk", "--body-file", missingHeadingFile, "--body-profile", "commander-brief", "--dry-run"], env); - assertCondition(briefWrongProfile.status !== 0, "wrong explicit body profile should fail", briefWrongProfile.json ?? { stdout: briefWrongProfile.stdout }); - const briefWrongData = failedDataOf(briefWrongProfile.json ?? {}); - const briefWrongGuard = briefWrongData.guard as JsonRecord; - assertCondition(Array.isArray(briefWrongGuard.failures) && briefWrongGuard.failures.includes("profile-issue-mismatch"), "explicit profile should check issue number", briefWrongGuard); - assertCondition(!briefWrongProfile.stdout.includes(env.GH_TOKEN) && !briefWrongProfile.stderr.includes(env.GH_TOKEN), "failed profile output must not print GH_TOKEN", { - stdout: briefWrongProfile.stdout, - stderr: briefWrongProfile.stderr, - }); - - const safeFile = join(tmp, "safe.md"); - writeFileSync(safeFile, "# Code Queue\n\n## 看板(OPEN)\n\n- multiline Markdown keeps `code` intact.\n- real newline follows.\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n", "utf8"); - const validBriefFile = join(tmp, "valid-commander-brief.md"); - writeFileSync(validBriefFile, [ - "# 2026-05-21 指挥简报(北京时间)", - "", - "## 常驻观察与长期建议", - "", - "- 保持滚动简报正文只通过 heredoc/stdin 更新。", - "", - "## 更新 2026-05-21 15:18 北京时间", - "", - "- 今日新增进展包含 `code` 和表格。", - "", - "| 项 | 状态 |", - "| --- | --- |", - "| CLI | guarded |", - "", - ].join("\n"), "utf8"); - - const legacyBriefDryRun = await runCli(["gh", "issue", "update", "24", "--repo", "pikasTech/unidesk", "--mode", "replace", "--body-file", validBriefFile, "--body-profile", "commander-brief", "--dry-run"], env); - assertCondition(legacyBriefDryRun.status === 0, "#24 explicit commander-brief profile should remain compatible", legacyBriefDryRun.json ?? { stdout: legacyBriefDryRun.stdout }); - const legacyBriefData = dataOf(legacyBriefDryRun.json ?? {}); - const legacyBriefGuard = legacyBriefData.guard as JsonRecord; - const legacyBriefProfile = legacyBriefGuard.profile as JsonRecord; - assertCondition(legacyBriefGuard.ok === true && legacyBriefProfile.issueMatchesProfile === true && legacyBriefProfile.issueMatchReason === "legacy-issue-number", "#24 commander-brief profile should pass by legacy issue number", legacyBriefGuard); - - const dailyBriefDryRun = await runCli(["gh", "issue", "update", "46", "--repo", "pikasTech/unidesk", "--mode", "replace", "--body-file", validBriefFile, "--body-profile", "commander-brief", "--dry-run"], env); - assertCondition(dailyBriefDryRun.status === 0, "daily commander brief issue should pass commander-brief dry-run guard", dailyBriefDryRun.json ?? { stdout: dailyBriefDryRun.stdout }); - const dailyBriefData = dataOf(dailyBriefDryRun.json ?? {}); - const dailyBriefGuard = dailyBriefData.guard as JsonRecord; - const dailyBriefProfile = dailyBriefGuard.profile as JsonRecord; - assertCondition(dailyBriefGuard.ok === true && dailyBriefProfile.issueMatchesProfile === true && dailyBriefProfile.issueMatchReason === "daily-title", "daily commander brief profile should match by title", dailyBriefGuard); - assertCondition(dailyBriefData.containsLiteralBackslashN === false && dailyBriefData.containsMarkdownTable === true, "daily brief dry-run should preserve markdown safety signals", dailyBriefData); - - const nonBriefDryRun = await runCli(["gh", "issue", "update", "47", "--repo", "pikasTech/unidesk", "--mode", "replace", "--body-file", validBriefFile, "--body-profile", "commander-brief", "--dry-run"], env); - assertCondition(nonBriefDryRun.status !== 0, "non-brief issue should fail commander-brief profile", nonBriefDryRun.json ?? { stdout: nonBriefDryRun.stdout }); - const nonBriefData = failedDataOf(nonBriefDryRun.json ?? {}); - const nonBriefGuard = nonBriefData.guard as JsonRecord; - assertCondition(Array.isArray(nonBriefGuard.failures) && nonBriefGuard.failures.includes("profile-issue-mismatch"), "non-brief issue should report profile-issue-mismatch", nonBriefGuard); - assertCondition(!nonBriefDryRun.stdout.includes(env.GH_TOKEN) && !nonBriefDryRun.stderr.includes(env.GH_TOKEN), "non-brief failure must not print GH_TOKEN", { - stdout: nonBriefDryRun.stdout, - stderr: nonBriefDryRun.stderr, - }); - - const requestCountBeforeDryRun = mock.requests.length; - const safeDryRun = await runCli(["gh", "issue", "edit", "20", "--repo", "pikasTech/unidesk", "--body-file", safeFile, "--dry-run"], env); - assertCondition(safeDryRun.status === 0, "safe issue edit dry-run should succeed", safeDryRun.json ?? { stdout: safeDryRun.stdout }); - const dryRunPatchCount = mock.requests.slice(requestCountBeforeDryRun).filter((request) => request.method === "PATCH").length; - assertCondition(dryRunPatchCount === 0, "dry-run must not PATCH GitHub", { requests: mock.requests.slice(requestCountBeforeDryRun) }); - const safeDryRunData = dataOf(safeDryRun.json ?? {}); - assertCondition(safeDryRunData.dryRun === true, "dry-run should set dryRun=true", safeDryRunData); - assertCondition(safeDryRunData.containsBackticks === true, "dry-run should preserve backtick signal", safeDryRunData); - assertCondition(safeDryRunData.containsLiteralBackslashN === false, "real newlines must not become literal backslash-n", safeDryRunData); - assertCondition(safeDryRunData.containsMarkdownTable === true, "dry-run should detect markdown table", safeDryRunData); - const bodyOnlySafety = safeDryRunData.bodyOnlySafety as JsonRecord; - const oldBody = bodyOnlySafety.oldBody as JsonRecord; - assertCondition(oldBody.fetched === true && Number(oldBody.bodyChars ?? 0) > 0, "dry-run should report old body length when token is available", bodyOnlySafety); - - const requestCountBeforePatch = mock.requests.length; - const staleEdit = await runCli(["gh", "issue", "edit", "20", "--repo", "pikasTech/unidesk", "--body-file", safeFile, "--expect-updated-at", "2026-05-20T00:59:00Z"], env); - assertCondition(staleEdit.status !== 0, "stale expect-updated-at should fail", staleEdit.json ?? { stdout: staleEdit.stdout }); - const stalePatchCount = mock.requests.slice(requestCountBeforePatch).filter((request) => request.method === "PATCH").length; - assertCondition(stalePatchCount === 0, "stale concurrency guard must not PATCH", { requests: mock.requests.slice(requestCountBeforePatch) }); - const staleData = failedDataOf(staleEdit.json ?? {}); - assertCondition(staleData.degradedReason === "validation-failed", "stale guard should be validation-failed", staleData); - - const appendFile = join(tmp, "append.md"); - writeFileSync(appendFile, "\n- appended `code`\n| c | d |\n| --- | --- |\n| 3 | 4 |\n", "utf8"); - const issueCreateRequestCountBeforeDryRun = mock.requests.length; - const issueCreateDryRun = await runCli(["gh", "issue", "create", "--repo", "pikasTech/unidesk", "--title", "body file dry-run", "--body-file", appendFile, "--label", "cli,infra", "--label", "ops", "--dry-run"], env); - assertCondition(issueCreateDryRun.status === 0, "issue create dry-run should succeed", issueCreateDryRun.json ?? { stdout: issueCreateDryRun.stdout }); - const issueCreateDryRunData = dataOf(issueCreateDryRun.json ?? {}); - const issueCreateBodySource = issueCreateDryRunData.bodySource as JsonRecord; - assertCondition(issueCreateDryRunData.planned === true && issueCreateBodySource.kind === "body-file" && issueCreateBodySource.path === appendFile, "issue create dry-run should expose body-file source", issueCreateDryRunData); - const issueCreateDryRunLabels = issueCreateDryRunData.labels as unknown[]; - assertCondition(Array.isArray(issueCreateDryRunLabels) && issueCreateDryRunLabels.join(",") === "cli,infra,ops", "issue create dry-run should parse repeated and comma labels", issueCreateDryRunData); - const issueCreateDryRunRequest = issueCreateDryRunData.request as JsonRecord; - const issueCreateDryRunRequestBody = issueCreateDryRunRequest.body as JsonRecord; - assertCondition(Array.isArray(issueCreateDryRunRequestBody.labels) && (issueCreateDryRunRequestBody.labels as unknown[]).join(",") === "cli,infra,ops", "issue create dry-run request plan should include labels", issueCreateDryRunData); - assertCondition(issueCreateDryRunData.request && typeof issueCreateDryRunData.request === "object", "issue create dry-run should expose request plan", issueCreateDryRunData); - const issueCreateDryRunWriteCount = mock.requests.slice(issueCreateRequestCountBeforeDryRun).filter((request) => request.method === "POST" && request.url === "/repos/pikasTech/unidesk/issues").length; - assertCondition(issueCreateDryRunWriteCount === 0, "issue create dry-run must not POST GitHub", { requests: mock.requests.slice(issueCreateRequestCountBeforeDryRun) }); - - const issueCreateRequestCountBeforeWrite = mock.requests.length; - const issueCreate = await runCli(["gh", "issue", "create", "--repo", "pikasTech/unidesk", "--title", "body file write", "--body-file", appendFile, "--label", "cli", "--label", "infra,ops"], env); - assertCondition(issueCreate.status === 0, "issue create with labels should succeed", issueCreate.json ?? { stdout: issueCreate.stdout }); - const issueCreateData = dataOf(issueCreate.json ?? {}); - assertCondition(issueCreateData.command === "issue create", "issue create should report command name", issueCreateData); - const issueCreateLabels = issueCreateData.labels as unknown[]; - assertCondition(Array.isArray(issueCreateLabels) && issueCreateLabels.join(",") === "cli,infra,ops", "issue create should report labels", issueCreateData); - const issueCreateRequest = mock.requests.slice(issueCreateRequestCountBeforeWrite).find((request) => request.method === "POST" && request.url === "/repos/pikasTech/unidesk/issues"); - assertCondition(issueCreateRequest !== undefined, "issue create should POST to GitHub REST issues endpoint", { requests: mock.requests.slice(issueCreateRequestCountBeforeWrite) }); - const issueCreatePayload = JSON.parse(issueCreateRequest?.body ?? "{}") as JsonRecord; - assertCondition(Array.isArray(issueCreatePayload.labels) && (issueCreatePayload.labels as unknown[]).join(",") === "cli,infra,ops", "issue create REST payload should include labels", issueCreatePayload); - - const issueCreateStdinBody = "# stdin issue create\n\n- preserves `code`\n"; - const issueCreateStdinRequestCountBefore = mock.requests.length; - const issueCreateStdin = await runCli(["gh", "issue", "create", "--repo", "pikasTech/unidesk", "--title", "stdin issue create", "--body-file", "-", "--dry-run"], env, issueCreateStdinBody); - assertCondition(issueCreateStdin.status === 0, "issue create dry-run should accept --body-file - stdin", issueCreateStdin.json ?? { stdout: issueCreateStdin.stdout }); - const issueCreateStdinData = dataOf(issueCreateStdin.json ?? {}); - const issueCreateStdinSource = issueCreateStdinData.bodySource as JsonRecord; - assertCondition(issueCreateStdinSource.kind === "stdin" && issueCreateStdinSource.path === "-", "issue create stdin dry-run should expose stdin source", issueCreateStdinData); - assertCondition(issueCreateStdinData.containsBackticks === true && issueCreateStdinData.containsLiteralBackslashN === false, "issue create stdin should preserve Markdown signals", issueCreateStdinData); - const issueCreateStdinWriteCount = mock.requests.slice(issueCreateStdinRequestCountBefore).filter((request) => request.method === "POST" && request.url === "/repos/pikasTech/unidesk/issues").length; - assertCondition(issueCreateStdinWriteCount === 0, "issue create stdin dry-run must not POST GitHub", { requests: mock.requests.slice(issueCreateStdinRequestCountBefore) }); - - const issueCreateBodyStdin = await runCli(["gh", "issue", "create", "--repo", "pikasTech/unidesk", "--title", "body-stdin issue create", "--body-stdin", "--dry-run"], env, issueCreateStdinBody); - assertCondition(issueCreateBodyStdin.status === 0, "issue create dry-run should accept --body-stdin", issueCreateBodyStdin.json ?? { stdout: issueCreateBodyStdin.stdout }); - const issueCreateBodyStdinData = dataOf(issueCreateBodyStdin.json ?? {}); - const issueCreateBodyStdinSource = issueCreateBodyStdinData.bodySource as JsonRecord; - assertCondition(issueCreateBodyStdinSource.kind === "stdin" && issueCreateBodyStdinSource.path === "-", "issue create --body-stdin should expose stdin source", issueCreateBodyStdinData); - - const issueCreateInline = await runCli(["gh", "issue", "create", "--repo", "pikasTech/unidesk", "--title", "inline rejected", "--body", "inline body", "--dry-run"], env); - assertCondition(issueCreateInline.status !== 0, "issue create inline --body should fail", issueCreateInline.json ?? { stdout: issueCreateInline.stdout }); - const issueCreateInlineData = failedDataOf(issueCreateInline.json ?? {}); - assertCondition(failureMessageOf(issueCreateInlineData).includes("does not support --body"), "issue create inline --body should point to body-stdin", issueCreateInlineData); - - const issueCreateMissingLabel = await runCli(["gh", "issue", "create", "--repo", "pikasTech/unidesk", "--title", "bad label", "--body-file", appendFile, "--label", "missing-label"], env); - assertCondition(issueCreateMissingLabel.status !== 0, "issue create missing label should fail structurally", issueCreateMissingLabel.json ?? { stdout: issueCreateMissingLabel.stdout }); - const missingLabelData = failedDataOf(issueCreateMissingLabel.json ?? {}); - assertCondition(missingLabelData.degradedReason === "validation-failed", "missing label should map to validation-failed", missingLabelData); - const missingLabelDetails = missingLabelData.details as JsonRecord; - const missingLabelNestedDetails = missingLabelDetails.details as JsonRecord; - assertCondition(Array.isArray(missingLabelNestedDetails.errors), "missing label error should preserve GitHub validation errors", missingLabelData); - - const appendDryRun = await runCli(["gh", "issue", "update", "20", "--repo", "pikasTech/unidesk", "--mode", "append", "--body-file", appendFile, "--dry-run"], env); - assertCondition(appendDryRun.status === 0, "issue update append dry-run should succeed", appendDryRun.json ?? { stdout: appendDryRun.stdout }); - const appendData = dataOf(appendDryRun.json ?? {}); - assertCondition(appendData.command === "issue update", "update command should be primary", appendData); - assertCondition(appendData.mode === "append", "append mode should be explicit", appendData); - assertCondition(appendData.containsBackticks === true && appendData.containsMarkdownTable === true, "append should preserve markdown signals", appendData); - assertCondition(appendData.containsLiteralBackslashN === false, "append should preserve real newlines", appendData); - - const replaceDryRun = await runCli(["gh", "issue", "update", "20", "--repo", "pikasTech/unidesk", "--mode", "replace", "--body-file", safeFile, "--dry-run"], env); - assertCondition(replaceDryRun.status === 0, "issue update replace dry-run should succeed", replaceDryRun.json ?? { stdout: replaceDryRun.stdout }); - const replaceData = dataOf(replaceDryRun.json ?? {}); - assertCondition(replaceData.command === "issue update" && replaceData.mode === "replace", "replace mode should be explicit", replaceData); - const replaceDisclosure = replaceData.disclosure as JsonRecord; - const replaceReadCommands = replaceData.readCommands as JsonRecord; - assertCondition(replaceDisclosure.bodyOmitted === true && replaceDisclosure.dryRunBoundedPreview === true, "issue update dry-run should disclose compact body policy", replaceDisclosure); - assertCondition(typeof replaceReadCommands.full === "string" && String(replaceReadCommands.full).includes("gh issue view 20"), "issue update dry-run should expose full body drill-down", replaceReadCommands); - const replaceWouldPatch = replaceData.wouldPatch as JsonRecord; - assertCondition(typeof replaceWouldPatch.bodySha === "string" && String(replaceWouldPatch.bodySha).length === 64, "issue update dry-run should include wouldPatch body sha", replaceWouldPatch); - assertCondition(Number(replaceWouldPatch.bodyChars ?? 0) === Number(replaceData.bodyChars ?? 0), "issue update dry-run wouldPatch should include final body chars", replaceWouldPatch); - - const replaceNumberDryRun = await runCli(["gh", "issue", "update", "--number", "20", "--repo", "pikasTech/unidesk", "--mode", "replace", "--body-file", safeFile, "--dry-run"], env); - assertCondition(replaceNumberDryRun.status === 0, "issue update should accept --number compatibility alias", replaceNumberDryRun.json ?? { stdout: replaceNumberDryRun.stdout }); - const replaceNumberData = dataOf(replaceNumberDryRun.json ?? {}); - const replaceNumberHint = replaceNumberData.standardSyntaxHint as JsonRecord; - assertCondition(String(replaceNumberHint.standardCommand ?? "").includes("gh issue update 20 --repo pikasTech/unidesk"), "issue update --number should return standard syntax hint", replaceNumberHint); - - const compactLongBody = Array.from({ length: 260 }, (_, index) => `compact-success-line-${String(index + 1).padStart(4, "0")} ${"x".repeat(80)}`).join("\n"); - const compactLongFile = join(tmp, "compact-long-body.md"); - writeFileSync(compactLongFile, compactLongBody, "utf8"); - const compactUpdateRequestCountBefore = mock.requests.length; - const compactUpdate = await runCli(["gh", "issue", "update", "7", "--repo", "pikasTech/HWLAB", "--mode", "replace", "--body-file", compactLongFile], env); - assertCondition(compactUpdate.status === 0, "issue update non-dry-run compact success should succeed", compactUpdate.json ?? { stdout: compactUpdate.stdout, stderr: compactUpdate.stderr }); - assertCondition(compactUpdate.stdout.length < 20_000, "issue update compact success stdout should stay bounded for long bodies", { bytes: compactUpdate.stdout.length }); - assertCondition(!compactUpdate.stdout.includes("compact-success-line-0260"), "default issue update success stdout must not echo the full long body tail", { tail: compactUpdate.stdout.slice(-1000) }); - const compactUpdateData = dataOf(compactUpdate.json ?? {}); - const compactIssue = compactUpdateData.issue as JsonRecord; - assertCondition(compactUpdateData.command === "issue update" && compactUpdateData.rest === true, "compact update should report REST success", compactUpdateData); - assertCondition(!("body" in compactIssue), "default issue update success should omit issue.body", compactIssue); - assertCondition(compactIssue.bodyOmitted === true && compactIssue.fullBodyIncluded === false, "compact issue summary should mark omitted full body", compactIssue); - assertCondition(Number(compactIssue.bodyChars ?? 0) === compactLongBody.length, "compact issue summary should include bodyChars", compactIssue); - assertCondition(typeof compactIssue.bodySha === "string" && String(compactIssue.bodySha).length === 64, "compact issue summary should include bodySha", compactIssue); - assertCondition(String(compactIssue.bodyPreview ?? "").includes("compact-success-line-0001") && !String(compactIssue.bodyPreview ?? "").includes("compact-success-line-0260"), "compact issue summary should include only bounded preview", compactIssue); - const compactConcurrency = compactUpdateData.concurrency as JsonRecord; - assertCondition(compactConcurrency.checked === true && typeof compactConcurrency.oldBodySha === "string" && compactConcurrency.expectBodySha === null, "compact update should automatically read old issue metadata before PATCH", compactConcurrency); - const compactGuard = compactUpdateData.guard as JsonRecord; - assertCondition(compactGuard.ok === true && typeof compactGuard.bodySha === "string", "compact update should keep guard/body sha summary", compactGuard); - const compactDisclosure = compactUpdateData.disclosure as JsonRecord; - assertCondition(compactDisclosure.bodyOmitted === true && compactDisclosure.fullBodyIncluded === false && compactDisclosure.defaultCompact === true, "compact update disclosure should be explicit", compactDisclosure); - const compactCommands = compactUpdateData.readCommands as JsonRecord; - assertCondition(String(compactCommands.body ?? "").includes("gh issue view 7 --repo pikasTech/HWLAB --json body"), "compact update should expose body view command", compactCommands); - assertCondition(String(compactCommands.full ?? "").includes("--full") && String(compactCommands.raw ?? "").includes("--raw"), "compact update should expose full/raw drill-down", compactCommands); - const compactUpdatePatchCount = mock.requests.slice(compactUpdateRequestCountBefore).filter((request) => request.method === "PATCH" && request.url === "/repos/pikasTech/HWLAB/issues/7").length; - assertCondition(compactUpdatePatchCount === 1, "compact update should PATCH GitHub exactly once", { requests: mock.requests.slice(compactUpdateRequestCountBefore) }); - - const stdinIssueBody = "# Code Queue\n\n## 看板(OPEN)\n\n- stdin issue body keeps `code`.\n\n| s | t |\n| --- | --- |\n| 5 | 6 |\n"; - const stdinUpdateRequestCountBefore = mock.requests.length; - const stdinUpdate = await runCli(["gh", "issue", "update", "20", "--repo", "pikasTech/unidesk", "--mode", "replace", "--body-file", "-"], env, stdinIssueBody); - assertCondition(stdinUpdate.status === 0, "issue update should accept --body-file - stdin", stdinUpdate.json ?? { stdout: stdinUpdate.stdout, stderr: stdinUpdate.stderr }); - const stdinUpdateData = dataOf(stdinUpdate.json ?? {}); - const stdinUpdateBodySource = stdinUpdateData.bodySource as JsonRecord; - assertCondition(stdinUpdateBodySource.kind === "stdin" && stdinUpdateBodySource.path === "-", "stdin issue update should report stdin bodySource", stdinUpdateData); - const stdinUpdateConcurrency = stdinUpdateData.concurrency as JsonRecord; - assertCondition(stdinUpdateConcurrency.checked === true && typeof stdinUpdateConcurrency.oldBodySha === "string", "stdin issue update should automatically read current issue metadata before PATCH", stdinUpdateConcurrency); - const stdinUpdatePatch = mock.requests.slice(stdinUpdateRequestCountBefore).find((request) => request.method === "PATCH" && request.url === "/repos/pikasTech/unidesk/issues/20"); - assertCondition(stdinUpdatePatch !== undefined, "stdin issue update should PATCH issue body", { requests: mock.requests.slice(stdinUpdateRequestCountBefore) }); - const stdinUpdatePayload = JSON.parse(stdinUpdatePatch?.body ?? "{}") as JsonRecord; - assertCondition(stdinUpdatePayload.body === stdinIssueBody, "stdin issue update should preserve exact stdin Markdown", stdinUpdatePayload); - - const explicitFullBody = "# compact full body\n\nThis body is intentionally short enough to avoid global dump while still proving explicit disclosure.\n"; - const explicitFullFile = join(tmp, "explicit-full-body.md"); - writeFileSync(explicitFullFile, explicitFullBody, "utf8"); - const explicitFullUpdate = await runCli(["gh", "issue", "update", "7", "--repo", "pikasTech/HWLAB", "--mode", "replace", "--body-file", explicitFullFile, "--full"], env); - assertCondition(explicitFullUpdate.status === 0, "issue update --full should succeed", explicitFullUpdate.json ?? { stdout: explicitFullUpdate.stdout, stderr: explicitFullUpdate.stderr }); - const explicitFullData = dataOf(explicitFullUpdate.json ?? {}); - const explicitFullIssue = explicitFullData.issue as JsonRecord; - assertCondition(typeof explicitFullIssue.body === "string" && explicitFullIssue.body === explicitFullBody, "issue update --full should explicitly include the full body", explicitFullIssue); - const explicitFullDisclosure = explicitFullData.disclosure as JsonRecord; - assertCondition(explicitFullDisclosure.fullBodyIncluded === true && explicitFullDisclosure.explicitFullDisclosure === true, "issue update --full disclosure should mark full body inclusion", explicitFullDisclosure); - - const commentCreate = await runCli(["gh", "issue", "comment", "create", "20", "--repo", "pikasTech/unidesk", "--body-file", appendFile], env); - assertCondition(commentCreate.status === 0, "issue comment create should succeed", commentCreate.json ?? { stdout: commentCreate.stdout }); - const commentCreateData = dataOf(commentCreate.json ?? {}); - assertCondition(commentCreateData.command === "issue comment create", "comment create should use CRUD command name", commentCreateData); - assertCondition(commentCreateData.source === "body-file" && typeof commentCreateData.bodySha === "string", "issue comment body-file write should expose low-noise source and bodySha", commentCreateData); - const commentCreateSummary = commentCreateData.comment as JsonRecord; - assertCondition(commentCreateSummary.bodyOmitted === true && !("body" in commentCreateSummary), "issue comment write should not echo full comment body by default", commentCreateSummary); - - const inlineBody = "短评:已完成 #76 CLI inline body dry-run"; - const inlineDryRunRequestCountBefore = mock.requests.length; - const inlineDryRun = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body", inlineBody, "--dry-run"], env); - assertCondition(inlineDryRun.status === 0, "issue comment inline body dry-run should succeed", inlineDryRun.json ?? { stdout: inlineDryRun.stdout }); - assertCondition(inlineDryRun.json?.command === "gh issue comment create 36 --repo pikasTech/unidesk --body --dry-run", "outer gh command should redact inline body", inlineDryRun.json ?? {}); - const inlineDryRunData = dataOf(inlineDryRun.json ?? {}); - assertCondition(inlineDryRunData.dryRun === true && inlineDryRunData.planned === true, "inline issue comment dry-run should be planned", inlineDryRunData); - assertCondition(inlineDryRunData.issueNumber === 36 && inlineDryRunData.source === "inline", "inline issue comment dry-run should preserve issue number and source", inlineDryRunData); - const inlineDryRunSource = inlineDryRunData.bodySource as JsonRecord; - assertCondition(inlineDryRunSource.kind === "inline" && inlineDryRunSource.maxInlineBodyChars === 1000, "inline issue comment dry-run should expose inline source policy", inlineDryRunSource); - assertCondition(Number(inlineDryRunData.bodyChars ?? 0) === inlineBody.length && typeof inlineDryRunData.bodySha === "string", "inline issue comment dry-run should expose bodyChars/bodySha", inlineDryRunData); - assertCondition(String(inlineDryRunData.bodyPreview ?? "") === inlineBody, "inline issue comment dry-run should provide bounded preview for short text", inlineDryRunData); - const inlineDryRunReadCommands = inlineDryRunData.readCommands as JsonRecord; - assertCondition(String(inlineDryRunReadCommands.comments ?? "").includes("gh issue view 36") && String(inlineDryRunReadCommands.comments ?? "").includes("--json comments"), "inline issue comment dry-run should expose comment view command", inlineDryRunReadCommands); - const inlineDryRunWriteCount = mock.requests.slice(inlineDryRunRequestCountBefore).filter((request) => request.method === "POST" && request.url.includes("/comments")).length; - assertCondition(inlineDryRunWriteCount === 0, "inline issue comment dry-run must not POST GitHub", { requests: mock.requests.slice(inlineDryRunRequestCountBefore) }); - - const inlineWriteRequestCountBefore = mock.requests.length; - const inlineWrite = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body", inlineBody], env); - assertCondition(inlineWrite.status === 0, "issue comment inline body write should succeed", inlineWrite.json ?? { stdout: inlineWrite.stdout }); - assertCondition(inlineWrite.json?.command === "gh issue comment create 36 --repo pikasTech/unidesk --body ", "outer gh command should redact inline body on write", inlineWrite.json ?? {}); - const inlineWriteData = dataOf(inlineWrite.json ?? {}); - assertCondition(inlineWriteData.command === "issue comment create" && inlineWriteData.source === "inline", "inline issue comment write should report source=inline", inlineWriteData); - assertCondition(Number(inlineWriteData.bodyChars ?? 0) === inlineBody.length && typeof inlineWriteData.bodySha === "string", "inline issue comment write should expose bounded body metadata", inlineWriteData); - const inlineWriteComment = inlineWriteData.comment as JsonRecord; - assertCondition(inlineWriteComment.bodyOmitted === true && inlineWriteComment.bodyPreview === inlineBody && !("body" in inlineWriteComment), "inline issue comment write should summarize without full body field", inlineWriteComment); - const inlinePost = mock.requests.slice(inlineWriteRequestCountBefore).find((request) => request.method === "POST" && request.url === "/repos/pikasTech/unidesk/issues/36/comments"); - assertCondition(inlinePost !== undefined, "inline issue comment write should POST comments REST endpoint", { requests: mock.requests.slice(inlineWriteRequestCountBefore) }); - const inlinePayload = JSON.parse(inlinePost?.body ?? "{}") as JsonRecord; - assertCondition(inlinePayload.body === inlineBody, "inline issue comment REST payload should preserve short text", inlinePayload); - - const commentUpdateBody = "修正:保留评论 ID 的原地编辑"; - const commentUpdateDryRunRequestCountBefore = mock.requests.length; - const commentUpdateDryRun = await runCli(["gh", "issue", "comment", "update", "9002", "--repo", "pikasTech/unidesk", "--body", commentUpdateBody, "--dry-run"], env); - assertCondition(commentUpdateDryRun.status === 0, "issue comment update dry-run should succeed", commentUpdateDryRun.json ?? { stdout: commentUpdateDryRun.stdout }); - assertCondition(commentUpdateDryRun.json?.command === "gh issue comment update 9002 --repo pikasTech/unidesk --body --dry-run", "outer gh command should redact issue comment update inline body", commentUpdateDryRun.json ?? {}); - const commentUpdateDryRunData = dataOf(commentUpdateDryRun.json ?? {}); - assertCondition(commentUpdateDryRunData.command === "issue comment update" && commentUpdateDryRunData.dryRun === true && commentUpdateDryRunData.commentId === 9002, "issue comment update dry-run should plan by commentId", commentUpdateDryRunData); - assertCondition(typeof commentUpdateDryRunData.bodySha === "string" && String(commentUpdateDryRunData.bodySha).length === 64 && Number(commentUpdateDryRunData.bodyChars ?? 0) === commentUpdateBody.length, "issue comment update dry-run should expose body metadata", commentUpdateDryRunData); - const commentUpdateRequest = commentUpdateDryRunData.request as JsonRecord; - assertCondition(commentUpdateRequest.method === "PATCH" && String(commentUpdateRequest.path ?? "").includes("/issues/comments/{comment_id}"), "issue comment update dry-run should plan PATCH comment endpoint", commentUpdateRequest); - const commentUpdateDryRunWriteCount = mock.requests.slice(commentUpdateDryRunRequestCountBefore).filter((request) => request.method === "PATCH" && request.url.includes("/issues/comments/")).length; - assertCondition(commentUpdateDryRunWriteCount === 0, "issue comment update dry-run must not PATCH GitHub", { requests: mock.requests.slice(commentUpdateDryRunRequestCountBefore) }); - - const commentEditStdinBody = "编辑别名:stdin 正文\n\n- 保留 `code`\n"; - const commentEditRequestCountBefore = mock.requests.length; - const commentEdit = await runCli(["gh", "issue", "comment", "edit", "--number", "9002", "--repo", "pikasTech/unidesk", "--body-stdin"], env, commentEditStdinBody); - assertCondition(commentEdit.status === 0, "issue comment edit should accept --number compatibility alias and stdin", commentEdit.json ?? { stdout: commentEdit.stdout }); - const commentEditData = dataOf(commentEdit.json ?? {}); - assertCondition(commentEditData.command === "issue comment edit" && commentEditData.commentId === 9002, "issue comment edit should report alias command and commentId", commentEditData); - const commentEditHint = commentEditData.standardSyntaxHint as JsonRecord; - assertCondition(String(commentEditHint.standardCommand ?? "").includes("gh issue comment edit 9002 --repo pikasTech/unidesk"), "issue comment edit --number should point to positional commentId syntax", commentEditHint); - const commentEditSummary = commentEditData.comment as JsonRecord; - assertCondition(commentEditSummary.id === 9002 && commentEditSummary.bodyOmitted === true && !("body" in commentEditSummary), "issue comment edit should preserve id and omit full body", commentEditSummary); - const commentEditPatch = mock.requests.slice(commentEditRequestCountBefore).find((request) => request.method === "PATCH" && request.url === "/repos/pikasTech/unidesk/issues/comments/9002"); - assertCondition(commentEditPatch !== undefined, "issue comment edit should PATCH issue comments endpoint", { requests: mock.requests.slice(commentEditRequestCountBefore) }); - const commentEditPayload = JSON.parse(commentEditPatch?.body ?? "{}") as JsonRecord; - assertCondition(commentEditPayload.body === commentEditStdinBody, "issue comment edit payload should preserve stdin Markdown", commentEditPayload); - - const closeComment = "收口:CLI close --comment contract"; - const closeDryRunRequestCountBefore = mock.requests.length; - const closeDryRun = await runCli(["gh", "issue", "close", "20", "--repo", "pikasTech/unidesk", "--comment", closeComment, "--dry-run"], env); - assertCondition(closeDryRun.status === 0, "issue close --comment dry-run should succeed", closeDryRun.json ?? { stdout: closeDryRun.stdout }); - assertCondition(closeDryRun.json?.command === "gh issue close 20 --repo pikasTech/unidesk --comment --dry-run", "outer gh command should redact lifecycle comment", closeDryRun.json ?? {}); - const closeDryRunData = dataOf(closeDryRun.json ?? {}); - assertCondition(closeDryRunData.command === "issue close" && closeDryRunData.dryRun === true, "issue close dry-run should report lifecycle command", closeDryRunData); - const closeDryRunComment = closeDryRunData.comment as JsonRecord; - assertCondition(closeDryRunComment.planned === true && closeDryRunComment.source === "inline", "issue close dry-run should plan inline lifecycle comment", closeDryRunComment); - assertCondition(String(closeDryRunComment.bodyPreview ?? "") === closeComment && typeof closeDryRunComment.bodySha === "string", "issue close dry-run should expose bounded comment metadata", closeDryRunComment); - const closeDryRunWriteCount = mock.requests.slice(closeDryRunRequestCountBefore).filter((request) => request.method === "POST" || request.method === "PATCH").length; - assertCondition(closeDryRunWriteCount === 0, "issue close --comment dry-run must not POST or PATCH", { requests: mock.requests.slice(closeDryRunRequestCountBefore) }); - - const closeWriteRequestCountBefore = mock.requests.length; - const closeWrite = await runCli(["gh", "issue", "close", "20", "--repo", "pikasTech/unidesk", "--comment", closeComment], env); - assertCondition(closeWrite.status === 0, "issue close --comment should succeed", closeWrite.json ?? { stdout: closeWrite.stdout }); - const closeWriteData = dataOf(closeWrite.json ?? {}); - assertCondition(closeWriteData.command === "issue close", "issue close write should report lifecycle command", closeWriteData); - const closeWriteComment = closeWriteData.comment as JsonRecord; - assertCondition(closeWriteComment.bodyOmitted === true && closeWriteComment.bodyPreview === closeComment, "issue close should summarize lifecycle comment without full body", closeWriteComment); - const closeWriteIssue = closeWriteData.issue as JsonRecord; - assertCondition(closeWriteIssue.state === "closed", "issue close should PATCH state=closed", closeWriteIssue); - const closeWriteRequests = mock.requests.slice(closeWriteRequestCountBefore).filter((request) => request.url === "/repos/pikasTech/unidesk/issues/20/comments" || request.url === "/repos/pikasTech/unidesk/issues/20"); - assertCondition(closeWriteRequests.length === 2 && closeWriteRequests[0]?.method === "POST" && closeWriteRequests[1]?.method === "PATCH", "issue close --comment should POST comment before PATCH state", closeWriteRequests); - const closeCommentPayload = JSON.parse(closeWriteRequests[0]?.body ?? "{}") as JsonRecord; - const closePatchPayload = JSON.parse(closeWriteRequests[1]?.body ?? "{}") as JsonRecord; - assertCondition(closeCommentPayload.body === closeComment && closePatchPayload.state === "closed", "issue close --comment payloads should preserve comment and state", { closeCommentPayload, closePatchPayload }); - - const reopenCommentStdinBody = "reopen heredoc comment\n\n- keeps `code`\n"; - const reopenStdinRequestCountBefore = mock.requests.length; - const reopenStdin = await runCli(["gh", "issue", "reopen", "20", "--repo", "pikasTech/unidesk", "--comment-stdin", "--dry-run"], env, reopenCommentStdinBody); - assertCondition(reopenStdin.status === 0, "issue reopen --comment-stdin dry-run should succeed", reopenStdin.json ?? { stdout: reopenStdin.stdout }); - const reopenStdinData = dataOf(reopenStdin.json ?? {}); - const reopenStdinComment = reopenStdinData.comment as JsonRecord; - const reopenStdinCommentSource = (reopenStdinComment.bodySource ?? {}) as JsonRecord; - assertCondition(reopenStdinCommentSource.kind === "stdin" && reopenStdinCommentSource.path === "-", "issue reopen --comment-stdin should expose stdin source", reopenStdinComment); - const reopenStdinWriteCount = mock.requests.slice(reopenStdinRequestCountBefore).filter((request) => request.method === "POST" || request.method === "PATCH").length; - assertCondition(reopenStdinWriteCount === 0, "issue reopen --comment-stdin dry-run must not write GitHub", { requests: mock.requests.slice(reopenStdinRequestCountBefore) }); - - const closeCommentWrongCommand = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--comment", closeComment, "--dry-run"], env); - assertCondition(closeCommentWrongCommand.status !== 0, "--comment outside issue close/reopen should fail structurally", closeCommentWrongCommand.json ?? { stdout: closeCommentWrongCommand.stdout }); - const closeCommentWrongData = failedDataOf(closeCommentWrongCommand.json ?? {}); - assertCondition(failureMessageOf(closeCommentWrongData).includes("only supported by gh issue close/reopen"), "wrong --comment usage should point to close/reopen", closeCommentWrongData); - - const missingCommentBody = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--dry-run"], env); - assertCondition(missingCommentBody.status !== 0, "issue comment create without body source should fail", missingCommentBody.json ?? { stdout: missingCommentBody.stdout }); - const missingCommentBodyData = failedDataOf(missingCommentBody.json ?? {}); - assertCondition(missingCommentBodyData.degradedReason === "validation-failed" && failureMessageOf(missingCommentBodyData).includes("requires --body-stdin"), "missing issue comment body should be structured validation failure", missingCommentBodyData); - - const mutualCommentBody = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body", "inline", "--body-file", appendFile, "--dry-run"], env); - assertCondition(mutualCommentBody.status !== 0, "issue comment create with body and body-file should fail", mutualCommentBody.json ?? { stdout: mutualCommentBody.stdout }); - const mutualCommentBodyData = failedDataOf(mutualCommentBody.json ?? {}); - assertCondition(mutualCommentBodyData.degradedReason === "validation-failed" && failureMessageOf(mutualCommentBodyData).includes("accepts only one body source"), "mutual issue comment body sources should be rejected", mutualCommentBodyData); - - const blankInlineComment = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body", " ", "--dry-run"], env); - assertCondition(blankInlineComment.status !== 0, "blank inline issue comment body should fail", blankInlineComment.json ?? { stdout: blankInlineComment.stdout }); - const blankInlineCommentData = failedDataOf(blankInlineComment.json ?? {}); - assertCondition(failureMessageOf(blankInlineCommentData).includes("must not be blank"), "blank inline issue comment body should name blank-body reason", blankInlineCommentData); - - const multilineInlineComment = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body", "line1\nline2", "--dry-run"], env); - assertCondition(multilineInlineComment.status !== 0, "multiline inline issue comment body should fail", multilineInlineComment.json ?? { stdout: multilineInlineComment.stdout }); - const multilineInlineCommentData = failedDataOf(multilineInlineComment.json ?? {}); - assertCondition(failureMessageOf(multilineInlineCommentData).includes("single-line text only"), "multiline inline issue comment body should point to body-stdin", multilineInlineCommentData); - - const pollutedInlineComment = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body", "literal \\n pollution", "--dry-run"], env); - assertCondition(pollutedInlineComment.status !== 0, "polluted inline issue comment body should fail", pollutedInlineComment.json ?? { stdout: pollutedInlineComment.stdout }); - const pollutedInlineCommentData = failedDataOf(pollutedInlineComment.json ?? {}); - assertCondition(failureMessageOf(pollutedInlineCommentData).includes("shell-pollution signals"), "polluted inline issue comment body should report shell pollution", pollutedInlineCommentData); - - const secretInlineComment = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body", "token=ghp_1234567890abcdef", "--dry-run"], env); - assertCondition(secretInlineComment.status !== 0, "secret-like inline issue comment body should fail", secretInlineComment.json ?? { stdout: secretInlineComment.stdout }); - assertCondition(!secretInlineComment.stdout.includes("ghp_1234567890abcdef") && !secretInlineComment.stderr.includes("ghp_1234567890abcdef"), "secret-like inline issue comment failure must not print token value", { - stdout: secretInlineComment.stdout, - stderr: secretInlineComment.stderr, - }); - - const stdinCommentBody = "stdin comment line 1\n\n- keeps `code`\n"; - const stdinComment = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body-file", "-", "--dry-run"], env, stdinCommentBody); - assertCondition(stdinComment.status === 0, "issue comment body stdin should be supported", stdinComment.json ?? { stdout: stdinComment.stdout }); - const stdinCommentData = dataOf(stdinComment.json ?? {}); - const stdinCommentSource = stdinCommentData.bodySource as JsonRecord; - assertCondition(stdinCommentSource.kind === "stdin" && stdinCommentSource.path === "-" && stdinCommentData.source === "stdin", "stdin issue comment dry-run should expose stdin source", stdinCommentData); - assertCondition(stdinCommentData.containsBackticks === true && stdinCommentData.containsLiteralBackslashN === false, "stdin issue comment should preserve Markdown signals", stdinCommentData); - - const bodyStdinComment = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--body-stdin", "--dry-run"], env, stdinCommentBody); - assertCondition(bodyStdinComment.status === 0, "issue comment --body-stdin should be supported", bodyStdinComment.json ?? { stdout: bodyStdinComment.stdout }); - const bodyStdinCommentData = dataOf(bodyStdinComment.json ?? {}); - const bodyStdinCommentSource = bodyStdinCommentData.bodySource as JsonRecord; - assertCondition(bodyStdinCommentSource.kind === "stdin" && bodyStdinCommentSource.path === "-" && bodyStdinCommentData.source === "stdin", "issue comment --body-stdin should expose stdin source", bodyStdinCommentData); - - const commentDeleteDryRun = await runCli(["gh", "issue", "comment", "delete", "9001", "--repo", "pikasTech/unidesk", "--dry-run"], env); - assertCondition(commentDeleteDryRun.status === 0, "issue comment delete dry-run should succeed", commentDeleteDryRun.json ?? { stdout: commentDeleteDryRun.stdout }); - const commentDeleteDryRunData = dataOf(commentDeleteDryRun.json ?? {}); - assertCondition(commentDeleteDryRunData.command === "issue comment delete" && commentDeleteDryRunData.planned === true, "comment delete dry-run should plan DELETE", commentDeleteDryRunData); - - const commentDeleteNumberDryRun = await runCli(["gh", "issue", "comment", "delete", "--number", "9001", "--repo", "pikasTech/unidesk", "--dry-run"], env); - assertCondition(commentDeleteNumberDryRun.status === 0, "issue comment delete should accept --number commentId compatibility alias", commentDeleteNumberDryRun.json ?? { stdout: commentDeleteNumberDryRun.stdout }); - const commentDeleteNumberData = dataOf(commentDeleteNumberDryRun.json ?? {}); - assertCondition(commentDeleteNumberData.commentId === 9001 && commentDeleteNumberData.standardSyntaxHint, "issue comment delete --number should return commentId and standard syntax hint", commentDeleteNumberData); - const commentDeleteNumberHint = commentDeleteNumberData.standardSyntaxHint as JsonRecord; - assertCondition(String(commentDeleteNumberHint.standardCommand ?? "").includes("gh issue comment delete 9001 --repo pikasTech/unidesk"), "issue comment delete --number should point to positional commentId syntax", commentDeleteNumberHint); - - const commentDelete = await runCli(["gh", "issue", "comment", "delete", "9001", "--repo", "pikasTech/unidesk"], env); - assertCondition(commentDelete.status === 0, "issue comment delete should succeed", commentDelete.json ?? { stdout: commentDelete.stdout }); - const commentDeleteData = dataOf(commentDelete.json ?? {}); - assertCondition(commentDeleteData.deleted === true, "comment delete should report deleted", commentDeleteData); - - const issueDelete = await runCli(["gh", "issue", "delete", "20", "--repo", "pikasTech/unidesk"], env); - assertCondition(issueDelete.status !== 0, "issue hard delete should be unsupported", issueDelete.json ?? { stdout: issueDelete.stdout }); - const issueDeleteData = failedDataOf(issueDelete.json ?? {}); - assertCondition(issueDeleteData.degradedReason === "unsupported-command", "issue delete should be unsupported-command", issueDeleteData); - - return { - ok: true, - checks: [ - "issue view --json body preserves .data.issue.body", - "issue read remains a compatibility alias", - "issue view/read accept GitHub URL and owner/repo#number targets and reject conflicting --repo", - "issue single numeric target commands accept --number compatibility with a standard syntax hint", - "issue view/read --raw is explicit full disclosure", - "issue list supports state/limit/json with stable selected fields", - "issue list positional owner/repo targets the requested repo and conflicting --repo fails", - "acceptance issue list command succeeds under mock GitHub", - "issue list default fields include labels and filter pull requests", - "large gh issue view/read output is dumped to a temp file with bounded stdout and head/tail metadata", - "issue scan-escape classifies pollution, explanatory mentions, and body risks", - "issue cleanup-plan remains dry-run with body/comment cleanup suggestions", - "issue board-audit returns read-only board structure, disables OPEN/CLOSED coverage validation, and keeps compatibility fields empty without writes", - "issue board-row list/get expose parsed #20 rows without writes", - "issue board-row upsert updates existing rows, adds missing rows, reports operation, preserves table trailers, rejects ambiguous rows, blocks stale body SHA writes, and stays dry-run without concurrency guards", - "issue board-row add/delete without guard stay on dry-run and do not PATCH", - "issue board-row update defaults to dry-run, reports old/new row, body SHA, guard result, and does not introduce literal backslash-n", - "issue board-row update rejects literal backslash-n cell values", - "issue board-row update escapes markdown table pipes and performs guarded PATCH with --expect-body-sha", - "issue board-row move is supported, defaults to dry-run, and can migrate OPEN rows into CLOSED", - "issue create dry-run parses repeated/comma labels, supports --body-stdin and compatible --body-file -, rejects inline --body, and exposes request plan", - "issue create sends labels through REST and preserves GitHub validation errors for missing labels", - "issue list unsupported fields and states fail structurally", - "issue read supports body,title,state,closed,closedAt,comments,commentCount selection", - "unknown/full disclosure option guidance remains actionable", - "unsupported --json fields fail structurally", - "issue edit --body-file rejects literal null", - "#20/#24 body profile guards reject missing headings or wrong profile", - "#20 body and board-row guards reject HWLAB product issue routing and point to pikasTech/HWLAB", - "#20 board-row guard allows UniDesk governance rows that mention HWLAB only as routing context", - "#24 commander-brief profile remains compatible", - "daily commander brief issues match commander-brief profile by title", - "non-brief issues fail commander-brief profile without printing token", - "dry-run reports old/new body safety and does not PATCH", - "multiline Markdown and backticks are not polluted", - "expect-updated-at stale write protection blocks PATCH", - "issue update replace/append modes preserve Markdown and support stdin with automatic current issue metadata checks", - "issue update non-dry-run success defaults to compact output without full issue.body and exposes bodySha plus drill-down commands", - "issue update --full explicitly includes full issue.body", - "issue close/reopen supports --comment-stdin dry-run without writes", - "issue comment create supports short inline --body dry-run and write with bounded output", - "issue comment create supports --body-stdin and compatible --body-file -, and still rejects missing, blank, multiline inline, polluted inline, secret-like inline, and mixed body sources", - "issue comment create/update/edit/delete follows CRUD shape", - "issue hard delete is structurally unsupported", - ], - }; - } finally { - clearInterval(heartbeat); - rmSync(tmp, { recursive: true, force: true }); - await mock.close(); - } -} - -if (import.meta.main) { - const result = await runGhCliIssueGuardContract(); - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); -} diff --git a/scripts/gh-cli-pr-contract-test.ts b/scripts/gh-cli-pr-contract-test.ts deleted file mode 100644 index 52e57c25..00000000 --- a/scripts/gh-cli-pr-contract-test.ts +++ /dev/null @@ -1,846 +0,0 @@ -import { spawn } from "node:child_process"; -import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; -import type { AddressInfo } from "node:net"; -import { writeFileSync, unlinkSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; - -type JsonRecord = Record; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function runBun(args: string[], env: Record = {}, stdin?: string): Promise<{ status: number | null; stdout: string; stderr: string; json: JsonRecord | null }> { - return new Promise((resolve, reject) => { - const child = spawn("bun", args, { - cwd: process.cwd(), - env: { ...process.env, ...env }, - stdio: ["pipe", "pipe", "pipe"], - }); - const stdoutChunks: Buffer[] = []; - const stderrChunks: Buffer[] = []; - child.stdout.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk))); - child.stderr.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk))); - child.on("error", reject); - child.on("close", (status) => { - const stdout = Buffer.concat(stdoutChunks).toString("utf8"); - let json: JsonRecord | null = null; - try { - json = JSON.parse(stdout) as JsonRecord; - } catch { - json = null; - } - resolve({ - status, - stdout, - stderr: Buffer.concat(stderrChunks).toString("utf8"), - json, - }); - }); - if (stdin !== undefined) child.stdin.end(stdin); - else child.stdin.end(); - }); -} - -function runCli(args: string[], env: Record = {}, stdin?: string): Promise<{ status: number | null; stdout: string; stderr: string; json: JsonRecord | null }> { - return runBun(["scripts/cli.ts", ...args], env, stdin); -} - -interface MockRequest { - method: string; - url: string; - body: string; -} - -function collectBody(req: IncomingMessage): Promise { - return new Promise((resolve) => { - const chunks: Buffer[] = []; - req.on("data", (chunk) => chunks.push(Buffer.from(chunk))); - req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); - }); -} - -function sendJson(res: ServerResponse, status: number, payload: unknown): void { - res.statusCode = status; - res.setHeader("content-type", "application/json"); - res.end(JSON.stringify(payload)); -} - -async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockRequest[]; close: () => Promise }> { - const requests: MockRequest[] = []; - const pullRequest = { - id: 4200, - number: 42, - title: "contract PR", - body: "PR body", - state: "open", - html_url: "https://github.com/pikasTech/unidesk/pull/42", - draft: false, - user: { login: "runner" }, - head: { ref: "feature/pr-contract", sha: "head-sha" }, - base: { ref: "master", sha: "base-sha" }, - closed_at: null, - merged: false, - merged_at: null, - merge_commit_sha: null, - created_at: "2026-05-20T04:00:00Z", - updated_at: "2026-05-20T05:00:00Z", - }; - const shorthandPullRequest = { - ...pullRequest, - id: 7000, - number: 7, - title: "generic shorthand PR fixture", - body: "PR shorthand body", - html_url: "https://github.com/pikasTech/HWLAB/pull/7", - head: { ref: "feature/hwlab-shorthand", sha: "hwlab-head-sha" }, - base: { ref: "master", sha: "hwlab-base-sha" }, - }; - const mergedPullRequest = { - ...pullRequest, - id: 4300, - number: 43, - title: "merged contract PR", - state: "closed", - html_url: "https://github.com/pikasTech/unidesk/pull/43", - closed_at: "2026-05-21T08:00:00Z", - merged: true, - merged_at: "2026-05-21T08:00:00Z", - merge_commit_sha: "merge-commit-sha", - updated_at: "2026-05-21T08:00:00Z", - }; - const unknownMetadataPullRequest = { - ...pullRequest, - id: 4400, - number: 44, - title: "unknown metadata PR", - html_url: "https://github.com/pikasTech/unidesk/pull/44", - head: { ref: "feature/pr-unknown", sha: "unknown-head-sha" }, - }; - const graphqlErrorPullRequest = { - ...pullRequest, - id: 4500, - number: 45, - title: "graphql error PR", - html_url: "https://github.com/pikasTech/unidesk/pull/45", - head: { ref: "feature/pr-graphql-error", sha: "graphql-error-head-sha" }, - }; - const server = createServer(async (req, res) => { - const body = await collectBody(req); - requests.push({ method: req.method ?? "", url: req.url ?? "", body }); - if (req.method === "GET" && req.url === "/rate_limit") { - sendJson(res, 200, { resources: { core: { limit: 5000, remaining: 4999 } } }); - return; - } - if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk") { - sendJson(res, 200, { id: 1, full_name: "pikasTech/unidesk", private: true, default_branch: "master", permissions: { pull: true, push: true } }); - return; - } - if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues?per_page=1&state=all") { - sendJson(res, 200, []); - return; - } - if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/pulls?state=all&per_page=4") { - sendJson(res, 200, [pullRequest]); - return; - } - if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/pulls?state=open&per_page=4") { - sendJson(res, 200, [pullRequest]); - return; - } - if (req.method === "GET" && req.url === "/repos/pikasTech/HWLAB/pulls?state=open&per_page=4") { - sendJson(res, 200, [shorthandPullRequest]); - return; - } - if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/pulls?state=closed&per_page=4") { - sendJson(res, 200, [mergedPullRequest]); - return; - } - if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/pulls/42") { - 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; - } - if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/pulls/43") { - sendJson(res, 200, mergedPullRequest); - return; - } - if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/pulls/44") { - sendJson(res, 200, unknownMetadataPullRequest); - return; - } - if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/pulls/45") { - sendJson(res, 200, graphqlErrorPullRequest); - return; - } - if (req.method === "POST" && req.url === "/graphql") { - const parsed = JSON.parse(body) as { variables?: { number?: unknown } }; - const number = Number(parsed.variables?.number ?? 0); - if (number === 44) { - sendJson(res, 200, { - data: { - repository: { - pullRequest: { - mergeable: "UNKNOWN", - mergeStateStatus: null, - headRefName: "feature/pr-unknown", - baseRefName: "master", - statusCheckRollup: null, - }, - }, - }, - }); - return; - } - if (number === 45) { - sendJson(res, 200, { - errors: [ - { type: "FORBIDDEN", message: "Resource not accessible by integration" }, - ], - }); - return; - } - sendJson(res, 200, { - data: { - repository: { - pullRequest: { - mergeable: "MERGEABLE", - mergeStateStatus: "CLEAN", - headRefName: "feature/pr-contract", - baseRefName: "master", - statusCheckRollup: { - state: "SUCCESS", - contexts: { - nodes: [ - { __typename: "CheckRun", name: "contract", status: "COMPLETED", conclusion: "SUCCESS" }, - { __typename: "StatusContext", context: "legacy-ci", state: "SUCCESS", targetUrl: "https://ci.example.test/42", description: "ok" }, - ], - }, - }, - }, - }, - }, - }); - return; - } - if (req.method === "PATCH" && req.url === "/repos/pikasTech/unidesk/pulls/42") { - const parsed = JSON.parse(body) as JsonRecord; - sendJson(res, 200, { ...pullRequest, title: String(parsed.title ?? pullRequest.title), body: String(parsed.body ?? pullRequest.body), state: String(parsed.state ?? pullRequest.state), updated_at: "2026-05-20T06:00:00Z" }); - return; - } - if (req.method === "POST" && req.url === "/repos/pikasTech/unidesk/issues/42/comments") { - const parsed = JSON.parse(body) as JsonRecord; - sendJson(res, 201, { id: 9101, body: String(parsed.body ?? ""), html_url: "https://github.com/pikasTech/unidesk/pull/42#issuecomment-9101", user: { login: "runner" }, created_at: "2026-05-20T06:10:00Z", updated_at: "2026-05-20T06:10:00Z" }); - return; - } - if (req.method === "PATCH" && req.url === "/repos/pikasTech/unidesk/issues/comments/9101") { - const parsed = JSON.parse(body) as JsonRecord; - sendJson(res, 200, { id: 9101, body: String(parsed.body ?? ""), html_url: "https://github.com/pikasTech/unidesk/pull/42#issuecomment-9101", user: { login: "runner" }, created_at: "2026-05-20T06:10:00Z", updated_at: "2026-05-20T06:12:00Z" }); - return; - } - if (req.method === "DELETE" && req.url === "/repos/pikasTech/unidesk/issues/comments/9101") { - res.statusCode = 204; - res.end(); - return; - } - sendJson(res, 404, { message: "not found" }); - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - const address = server.address(); - assertCondition(typeof address === "object" && address !== null, "mock server should expose address"); - const port = (address as AddressInfo).port; - assertCondition(typeof port === "number", "mock server should expose port"); - return { - baseUrl: `http://127.0.0.1:${port}`, - requests, - close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())), - }; -} - -async function startResetGitHub(): Promise<{ baseUrl: string; close: () => Promise }> { - const server = createServer((req) => { - req.socket.destroy(); - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - const address = server.address(); - assertCondition(typeof address === "object" && address !== null, "reset mock server should expose address"); - const port = (address as AddressInfo).port; - assertCondition(typeof port === "number", "reset mock server should expose port"); - return { - baseUrl: `http://127.0.0.1:${port}`, - close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())), - }; -} - -function dataOf(response: JsonRecord): JsonRecord { - assertCondition(response.ok === true, "CLI command should succeed", response); - assertCondition(typeof response.data === "object" && response.data !== null && !Array.isArray(response.data), "response data should be object", response); - return response.data as JsonRecord; -} - -function failedDataOf(response: JsonRecord): JsonRecord { - assertCondition(response.ok === false, "CLI command should fail", response); - assertCondition(typeof response.data === "object" && response.data !== null && !Array.isArray(response.data), "failure data should be object", response); - return response.data as JsonRecord; -} - -function failureMessageOf(data: JsonRecord): string { - return String((data.details as JsonRecord | undefined)?.message ?? data.message ?? ""); -} - -export async function runGhCliPrContract(): Promise { - const help = await runCli(["gh", "help"]); - assertCondition(help.status === 0, "gh help should succeed", help.json ?? { stdout: help.stdout }); - const helpData = dataOf(help.json ?? {}); - const usage = Array.isArray(helpData.usage) ? helpData.usage.map((value) => String(value)) : []; - const notes = Array.isArray(helpData.notes) ? helpData.notes.map((value) => String(value)) : []; - assertCondition(usage.some((line) => line.includes("gh pr list")), "gh help should list pr list", { usage }); - assertCondition(usage.some((line) => line.includes("gh pr view") && line.includes("number|url|owner/repo#number") && line.includes("--raw|--full")), "gh help should document standard pr view targets and raw/full disclosure", { usage }); - assertCondition(usage.some((line) => line.includes("gh pr read") && line.includes("compatibility alias for pr view")), "gh help should list pr read compatibility alias", { usage }); - assertCondition(usage.some((line) => line.includes("gh preflight")), "gh help should list top-level preflight alias", { usage }); - assertCondition(usage.some((line) => line.includes("gh pr preflight")), "gh help should list pr preflight", { usage }); - assertCondition(usage.some((line) => line.includes("gh pr create")), "gh help should list pr create", { usage }); - assertCondition(usage.some((line) => line.includes("gh pr edit")), "gh help should list pr edit", { usage }); - assertCondition(usage.some((line) => line.includes("gh pr comment")), "gh help should list pr comment", { usage }); - assertCondition(usage.some((line) => line.includes("gh pr list") && line.includes("--state open|closed|all")), "gh help should document pr list state filtering", { usage }); - assertCondition(usage.some((line) => line.includes("mergedAt") && line.includes("mergeCommit")), "gh help should document merged PR closeout fields", { usage }); - assertCondition(notes.some((line) => line.includes("PR view is the canonical")), "gh help should state pr view is canonical", { notes }); - assertCondition(notes.some((line) => line.includes("read remains") && line.includes("compatibility alias")), "gh help should state pr read is alias", { notes }); - assertCondition(notes.some((line) => line.includes("GitHub PR URLs") && line.includes("owner/repo#number shorthand")), "gh help should explain pr view/read URL and shorthand targets", { notes }); - assertCondition(notes.some((line) => line.includes("--number is accepted on single PR/comment numeric target commands") && line.includes("PR comment update/edit/delete treat --number as commentId")), "gh help should document --number compatibility hint", { notes }); - assertCondition(notes.some((line) => line.includes("--raw and --full are explicit full-disclosure aliases")), "gh help should explain raw/full read disclosure", { notes }); - assertCondition(notes.some((line) => line.includes("PR list defaults to --state all")), "gh help should document pr list default state", { notes }); - assertCondition(notes.some((line) => line.includes("stateDetail") && line.includes("mergedAt")), "gh help should describe closeout field normalization", { notes }); - assertCondition(notes.some((line) => line.includes("low-noise read-only closeout helper")), "gh help should document PR preflight closeout helper", { notes }); - assertCondition(notes.some((line) => line.includes("closeoutMetadata") && line.includes("UNKNOWN/null")), "gh help should document explicit closeout metadata unknowns", { notes }); - assertCondition(notes.some((line) => line.includes("PR list does not fetch mergeability")), "gh help should direct closeout metadata to pr view", { notes }); - - const mock = await startMockGitHub(); - const env = { - GH_TOKEN: "contract-token", - UNIDESK_GITHUB_API_URL: mock.baseUrl, - }; - try { - const list = await runCli(["gh", "pr", "list", "--repo", "pikasTech/unidesk", "--limit", "4"], env); - assertCondition(list.status === 0, "pr list should succeed through REST", list.json ?? { stdout: list.stdout }); - const listData = dataOf(list.json ?? {}); - assertCondition(listData.state === "all", "pr list should keep default state=all compatibility", listData); - const pullRequests = listData.pullRequests as JsonRecord[]; - assertCondition(Array.isArray(pullRequests) && pullRequests.length === 1, "pr list should return pullRequests", listData); - assertCondition(pullRequests[0]?.number === 42 && pullRequests[0]?.base && pullRequests[0]?.head, "pr list should expose PR summary", pullRequests[0]); - assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/unidesk/pulls?state=all&per_page=4"), "default pr list should query state=all", mock.requests); - - const listOpen = await runCli(["gh", "pr", "list", "--repo", "pikasTech/unidesk", "--state", "open", "--limit", "4", "--json", "number,title,state,url"], env); - assertCondition(listOpen.status === 0, "pr list should support --state open", listOpen.json ?? { stdout: listOpen.stdout }); - const listOpenData = dataOf(listOpen.json ?? {}); - assertCondition(listOpenData.state === "open", "pr list should preserve requested state", listOpenData); - const listOpenPrs = listOpenData.pullRequests as JsonRecord[]; - assertCondition(Array.isArray(listOpenPrs) && listOpenPrs[0]?.state === "open", "pr list --state open should return selected PR fields", listOpenData); - assertCondition(!("body" in listOpenPrs[0]), "pr list --json should keep progressive disclosure", listOpenPrs[0]); - assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/unidesk/pulls?state=open&per_page=4"), "pr list --state open should query REST state=open", mock.requests); - - const positionalRepoList = await runCli(["gh", "pr", "list", "pikasTech/HWLAB", "--state", "open", "--limit", "4", "--json", "number,title,state,url"], env); - assertCondition(positionalRepoList.status === 0, "pr list positional owner/repo should succeed", positionalRepoList.json ?? { stdout: positionalRepoList.stdout }); - const positionalRepoListData = dataOf(positionalRepoList.json ?? {}); - assertCondition(positionalRepoListData.repo === "pikasTech/HWLAB", "pr list positional repo should become the actual request repo", positionalRepoListData); - const positionalRepoPrs = positionalRepoListData.pullRequests as JsonRecord[]; - assertCondition(Array.isArray(positionalRepoPrs) && positionalRepoPrs[0]?.number === 7, "pr list positional repo should return HWLAB fixture PR", positionalRepoListData); - assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/HWLAB/pulls?state=open&per_page=4"), "pr list positional repo should query derived repo REST path", mock.requests); - - const positionalRepoConflict = await runCli(["gh", "pr", "list", "pikasTech/HWLAB", "--repo", "pikasTech/unidesk", "--state", "open"], env); - assertCondition(positionalRepoConflict.status !== 0, "pr list conflicting positional repo and --repo should fail", positionalRepoConflict.json ?? { stdout: positionalRepoConflict.stdout }); - const positionalRepoConflictData = failedDataOf(positionalRepoConflict.json ?? {}); - assertCondition(positionalRepoConflictData.degradedReason === "validation-failed", "pr list repo conflict should be validation-failed", positionalRepoConflictData); - assertCondition(String((positionalRepoConflictData.details as JsonRecord)?.message ?? "").includes("positional repo pikasTech/HWLAB"), "pr list repo conflict should name positional repo", positionalRepoConflictData); - - const listClosed = await runCli(["gh", "pr", "list", "--repo", "pikasTech/unidesk", "--state", "closed", "--limit", "4", "--json", "number,state,url"], env); - assertCondition(listClosed.status === 0, "pr list should support --state closed", listClosed.json ?? { stdout: listClosed.stdout }); - const listClosedData = dataOf(listClosed.json ?? {}); - assertCondition(listClosedData.state === "closed", "pr list should preserve requested closed state", listClosedData); - const listClosedPrs = listClosedData.pullRequests as JsonRecord[]; - assertCondition(Array.isArray(listClosedPrs) && listClosedPrs[0]?.number === 43 && listClosedPrs[0]?.state === "closed", "pr list --state closed should return closed PR summaries", listClosedData); - assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/unidesk/pulls?state=closed&per_page=4"), "pr list --state closed should query REST state=closed", mock.requests); - - const badState = await runCli(["gh", "pr", "list", "--repo", "pikasTech/unidesk", "--state", "merged"], env); - assertCondition(badState.status !== 0, "pr list unsupported state should fail", badState.json ?? { stdout: badState.stdout }); - const badStateData = failedDataOf(badState.json ?? {}); - assertCondition(badStateData.degradedReason === "validation-failed", "pr list unsupported state should be validation-failed", badStateData); - assertCondition(badStateData.runnerDisposition === "business-failed", "pr list unsupported state should be business-failed", badStateData); - - const badListCloseout = await runCli(["gh", "pr", "list", "--repo", "pikasTech/unidesk", "--json", "number,mergeable,statusCheckRollup"], env); - assertCondition(badListCloseout.status !== 0, "pr list closeout metadata fields should fail explicitly", badListCloseout.json ?? { stdout: badListCloseout.stdout }); - const badListCloseoutData = failedDataOf(badListCloseout.json ?? {}); - const badListCloseoutMessage = String((badListCloseoutData.details as JsonRecord)?.message ?? badListCloseoutData.message ?? ""); - assertCondition(badListCloseoutData.degradedReason === "validation-failed", "pr list closeout fields should be validation-failed", badListCloseoutData); - assertCondition(badListCloseoutMessage.includes("use gh pr view ") && badListCloseoutMessage.includes("statusCheckRollup"), "pr list closeout failure should point to pr view", badListCloseoutData); - - const read = await runCli(["gh", "pr", "read", "42", "--repo", "pikasTech/unidesk", "--json", "body,title,state,head,base"], env); - assertCondition(read.status === 0, "pr read should succeed through REST", read.json ?? { stdout: read.stdout }); - const readData = dataOf(read.json ?? {}); - const pullRequest = readData.pullRequest as JsonRecord; - assertCondition(pullRequest.number === 42 && pullRequest.url === "https://github.com/pikasTech/unidesk/pull/42", "pr read should expose PR details", readData); - const selected = readData.json as JsonRecord; - assertCondition(selected.body === "PR body" && selected.title === "contract PR", "pr read --json should select fields", readData); - - const readNumberAlias = await runCli(["gh", "pr", "read", "--repo", "pikasTech/HWLAB", "--number", "7", "--json", "body,title,state,head,base"], env); - assertCondition(readNumberAlias.status === 0, "pr read should accept --number compatibility alias", readNumberAlias.json ?? { stdout: readNumberAlias.stdout }); - const readNumberAliasData = dataOf(readNumberAlias.json ?? {}); - assertCondition(readNumberAliasData.repo === "pikasTech/HWLAB", "pr read --number should preserve explicit repo", readNumberAliasData); - const readNumberAliasPr = readNumberAliasData.pullRequest as JsonRecord; - assertCondition(readNumberAliasPr.number === 7 && readNumberAliasPr.url === "https://github.com/pikasTech/HWLAB/pull/7", "pr read --number should read the requested PR", readNumberAliasData); - const readNumberAliasDisclosure = readNumberAliasData.disclosure as JsonRecord; - assertCondition(String(readNumberAliasDisclosure.compatibilityHint ?? "").includes("standard gh syntax") && String(readNumberAliasDisclosure.standardCommand ?? "").includes("gh pr view 7 --repo pikasTech/HWLAB"), "pr read --number should return standard syntax hint", readNumberAliasDisclosure); - assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/HWLAB/pulls/7"), "pr read --number should call explicit repo REST path", mock.requests); - - const numberAliasUnsupported = await runCli(["gh", "pr", "list", "--repo", "pikasTech/unidesk", "--number", "7"], env); - assertCondition(numberAliasUnsupported.status !== 0, "--number should not be silently ignored outside standard view/read", numberAliasUnsupported.json ?? { stdout: numberAliasUnsupported.stdout }); - const numberAliasUnsupportedData = failedDataOf(numberAliasUnsupported.json ?? {}); - assertCondition(numberAliasUnsupportedData.degradedReason === "validation-failed", "unsupported --number should be validation-failed", numberAliasUnsupportedData); - - const openLifecycle = await runCli(["gh", "pr", "read", "42", "--repo", "pikasTech/unidesk", "--json", "state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,headRefName,baseRefName"], env); - assertCondition(openLifecycle.status === 0, "open pr lifecycle fields should succeed through REST", openLifecycle.json ?? { stdout: openLifecycle.stdout }); - const openLifecycleData = dataOf(openLifecycle.json ?? {}); - const openLifecycleJson = openLifecycleData.json as JsonRecord; - assertCondition(openLifecycleJson.state === "open" && openLifecycleJson.stateDetail === "open", "open pr should distinguish stateDetail=open", openLifecycleData); - assertCondition(openLifecycleJson.closed === false && openLifecycleJson.closedAt === null, "open pr should expose closed=false", openLifecycleData); - assertCondition(openLifecycleJson.merged === false && openLifecycleJson.mergedAt === null && openLifecycleJson.mergeCommit === null, "open pr should expose merged=false", openLifecycleData); - - const view = await runCli(["gh", "pr", "view", "42", "--repo", "pikasTech/unidesk", "--json", "body,title,state,head,base"], env); - assertCondition(view.status === 0, "pr view should succeed as canonical read path", view.json ?? { stdout: view.stdout }); - const viewData = dataOf(view.json ?? {}); - assertCondition((viewData.pullRequest as JsonRecord).number === 42, "pr view should expose PR details", viewData); - const viewSelected = viewData.json as JsonRecord; - assertCondition(viewSelected.body === "PR body" && viewSelected.title === "contract PR", "pr view should preserve selected fields", viewData); - - const prUrlView = await runCli(["gh", "pr", "view", "https://github.com/pikasTech/HWLAB/pull/7", "--json", "body,title,state,head,base"], env); - assertCondition(prUrlView.status === 0, "pr view should accept GitHub PR URL target", prUrlView.json ?? { stdout: prUrlView.stdout }); - const prUrlViewData = dataOf(prUrlView.json ?? {}); - assertCondition(prUrlViewData.repo === "pikasTech/HWLAB", "PR URL target should derive repo", prUrlViewData); - assertCondition((prUrlViewData.pullRequest as JsonRecord).number === 7, "PR URL target should derive PR number", prUrlViewData); - const prUrlDisclosure = prUrlViewData.disclosure as JsonRecord; - assertCondition(prUrlDisclosure.shorthand && (prUrlDisclosure.shorthand as JsonRecord).source === "github-url", "PR URL target should be disclosed", prUrlDisclosure); - - const prIssueUrlMismatch = await runCli(["gh", "pr", "view", "https://github.com/pikasTech/HWLAB/issues/7", "--json", "body"], env); - assertCondition(prIssueUrlMismatch.status !== 0, "pr view should reject issue URLs", prIssueUrlMismatch.json ?? { stdout: prIssueUrlMismatch.stdout }); - const prIssueUrlMismatchData = failedDataOf(prIssueUrlMismatch.json ?? {}); - assertCondition(failureMessageOf(prIssueUrlMismatchData).includes("GitHub issue URL"), "pr view issue URL mismatch should be explicit", prIssueUrlMismatchData); - - const shorthandRaw = await runCli(["gh", "pr", "view", "pikasTech/HWLAB#7", "--raw"], env); - assertCondition(shorthandRaw.status === 0, "pr view should accept owner/repo#number shorthand with --raw", shorthandRaw.json ?? { stdout: shorthandRaw.stdout }); - const shorthandRawData = dataOf(shorthandRaw.json ?? {}); - assertCondition(shorthandRawData.repo === "pikasTech/HWLAB", "pr shorthand should derive repo from owner/repo#number", shorthandRawData); - const shorthandPr = shorthandRawData.pullRequest as JsonRecord; - assertCondition(shorthandPr.number === 7 && shorthandPr.url === "https://github.com/pikasTech/HWLAB/pull/7", "pr shorthand should read the requested PR", shorthandRawData); - const shorthandDisclosure = shorthandRawData.disclosure as JsonRecord; - assertCondition(shorthandDisclosure.raw === true && shorthandDisclosure.fullDisclosure === true, "--raw should mark explicit full disclosure for PR read/view", shorthandDisclosure); - const shorthandJson = shorthandRawData.json as JsonRecord; - assertCondition(shorthandJson.body === "PR shorthand body" && shorthandJson.mergeStateStatus === "CLEAN", "--raw should include full PR read fields including closeout metadata", shorthandRawData); - assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/HWLAB/pulls/7"), "pr shorthand should call the derived repo REST path", mock.requests); - - const shorthandConflict = await runCli(["gh", "pr", "read", "pikasTech/HWLAB#7", "--repo", "pikasTech/unidesk", "--raw"], env); - assertCondition(shorthandConflict.status !== 0, "pr shorthand with conflicting --repo should fail", shorthandConflict.json ?? { stdout: shorthandConflict.stdout }); - const shorthandConflictData = failedDataOf(shorthandConflict.json ?? {}); - assertCondition(shorthandConflictData.degradedReason === "validation-failed", "pr conflicting --repo should be validation-failed", shorthandConflictData); - assertCondition(String(shorthandConflictData.message ?? "").includes("resolves to repo pikasTech/HWLAB"), "pr conflict message should name the derived repo", shorthandConflictData); - const prConflictCommands = shorthandConflictData.supportedCommands as string[]; - assertCondition(Array.isArray(prConflictCommands) && prConflictCommands.some((command) => command === "bun scripts/cli.ts gh pr view 7 --repo pikasTech/HWLAB --json body,title,state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,head,base,draft,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup"), "pr conflict should include exact supported view command", shorthandConflictData); - - const closeout = await runCli(["gh", "pr", "view", "42", "--repo", "pikasTech/unidesk", "--json", "mergeable,mergeStateStatus,statusCheckRollup,headRefName,baseRefName"], env); - assertCondition(closeout.status === 0, "pr view closeout metadata fields should not be rejected", closeout.json ?? { stdout: closeout.stdout }); - const closeoutData = dataOf(closeout.json ?? {}); - const closeoutJson = closeoutData.json as JsonRecord; - assertCondition(closeoutJson.mergeable === "MERGEABLE", "pr view should expose mergeable", closeoutData); - assertCondition(closeoutJson.mergeStateStatus === "CLEAN", "pr view should expose mergeStateStatus", closeoutData); - assertCondition(closeoutJson.headRefName === "feature/pr-contract" && closeoutJson.baseRefName === "master", "pr view should expose PR branch names", closeoutData); - const rollup = closeoutJson.statusCheckRollup as JsonRecord; - assertCondition(rollup.state === "SUCCESS", "pr view should expose statusCheckRollup", closeoutData); - const closeoutMetadata = closeoutData.closeoutMetadata as 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 === 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); - assertCondition(unknownCloseout.status === 0, "pr view unknown closeout metadata should remain a structured read success", unknownCloseout.json ?? { stdout: unknownCloseout.stdout }); - const unknownCloseoutData = dataOf(unknownCloseout.json ?? {}); - const unknownCloseoutJson = unknownCloseoutData.json as JsonRecord; - const unknownCloseoutMetadata = unknownCloseoutData.closeoutMetadata as JsonRecord; - const unknownFields = unknownCloseoutMetadata.missingOrUnknownFields as unknown[]; - assertCondition(unknownCloseoutJson.mergeable === "UNKNOWN" && unknownCloseoutJson.statusCheckRollup === null, "unknown closeout JSON should preserve GitHub values", unknownCloseoutData); - assertCondition(unknownCloseoutMetadata.ok === false, "unknown closeout metadata should be explicit", unknownCloseoutMetadata); - assertCondition(Array.isArray(unknownFields) && unknownFields.includes("mergeable") && unknownFields.includes("mergeStateStatus") && unknownFields.includes("statusCheckRollup"), "unknown closeout metadata should name missing/unknown fields", unknownCloseoutMetadata); - assertCondition(String(unknownCloseoutMetadata.advice ?? "").includes("missing or unknown"), "unknown closeout metadata should include operator advice", unknownCloseoutMetadata); - - const graphqlErrorCloseout = await runCli(["gh", "pr", "view", "45", "--repo", "pikasTech/unidesk", "--json", "headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup"], env); - assertCondition(graphqlErrorCloseout.status !== 0, "pr view GraphQL closeout failure should fail structurally", graphqlErrorCloseout.json ?? { stdout: graphqlErrorCloseout.stdout }); - const graphqlErrorData = failedDataOf(graphqlErrorCloseout.json ?? {}); - const graphqlErrorMetadata = graphqlErrorData.closeoutMetadata as JsonRecord; - assertCondition(graphqlErrorData.phase === "fetch-pr-closeout-metadata", "GraphQL closeout failure should report phase", graphqlErrorData); - assertCondition(graphqlErrorMetadata.ok === false && graphqlErrorMetadata.source === "github-graphql", "GraphQL closeout failure should include explicit metadata error", graphqlErrorData); - assertCondition(String(graphqlErrorMetadata.message ?? "").includes("Resource not accessible"), "GraphQL closeout failure should preserve sanitized error message", graphqlErrorMetadata); - - const requestsBeforeMergedRead = mock.requests.length; - const mergedRead = await runCli(["gh", "pr", "read", "43", "--repo", "pikasTech/unidesk", "--json", "state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit"], env); - assertCondition(mergedRead.status === 0, "merged pr closeout fields should succeed through REST", mergedRead.json ?? { stdout: mergedRead.stdout }); - const mergedReadData = dataOf(mergedRead.json ?? {}); - const mergedSummary = mergedReadData.pullRequest as JsonRecord; - assertCondition(mergedSummary.state === "closed" && mergedSummary.stateDetail === "merged", "pullRequest summary should distinguish merged from closed", mergedReadData); - const mergedReadJson = mergedReadData.json as JsonRecord; - assertCondition(mergedReadJson.state === "closed" && mergedReadJson.stateDetail === "merged", "merged pr json should distinguish merged from closed", mergedReadData); - assertCondition(mergedReadJson.closed === true && mergedReadJson.closedAt === "2026-05-21T08:00:00Z", "merged pr should expose closed fields", mergedReadData); - assertCondition(mergedReadJson.merged === true && mergedReadJson.mergedAt === "2026-05-21T08:00:00Z", "merged pr should expose merged fields", mergedReadData); - const mergeCommit = mergedReadJson.mergeCommit as JsonRecord; - assertCondition(mergeCommit.oid === "merge-commit-sha", "merged pr should expose merge commit oid", mergedReadData); - const mergedReadRequests = mock.requests.slice(requestsBeforeMergedRead); - assertCondition(!mergedReadRequests.some((request) => request.method === "POST" && request.url === "/graphql"), "REST closeout fields should not require GraphQL", mergedReadRequests); - - const unsupportedReadField = await runCli(["gh", "pr", "read", "42", "--repo", "pikasTech/unidesk", "--json", "mergedAt,mergeCommit,projectCards"], env); - assertCondition(unsupportedReadField.status !== 0, "unsupported pr read field should fail before network work", unsupportedReadField.json ?? { stdout: unsupportedReadField.stdout }); - const unsupportedReadFieldData = failedDataOf(unsupportedReadField.json ?? {}); - assertCondition(unsupportedReadFieldData.degradedReason === "validation-failed", "unsupported pr read field should be validation-failed", unsupportedReadFieldData); - const unsupportedReadDetails = unsupportedReadFieldData.details as JsonRecord; - const unsupportedReadMessage = String(unsupportedReadDetails.message ?? ""); - assertCondition(unsupportedReadMessage.includes("projectCards"), "unsupported pr read field message should name the bad field", unsupportedReadFieldData); - assertCondition(unsupportedReadMessage.includes("mergedAt") && unsupportedReadMessage.includes("mergeCommit"), "unsupported pr read field message should include supported closeout fields", unsupportedReadFieldData); - - const closeoutPreflight = await runCli(["gh", "pr", "preflight", "42", "--repo", "pikasTech/unidesk"], env); - assertCondition(closeoutPreflight.status === 0, "pr preflight should succeed through REST and GraphQL", closeoutPreflight.json ?? { stdout: closeoutPreflight.stdout }); - assertCondition(!closeoutPreflight.stdout.includes("contract-token"), "pr preflight must not print token values", { stdout: closeoutPreflight.stdout }); - const closeoutPreflightData = dataOf(closeoutPreflight.json ?? {}); - assertCondition(closeoutPreflightData.command === "pr preflight", "pr preflight should report command", closeoutPreflightData); - assertCondition(closeoutPreflightData.readOnly === true && closeoutPreflightData.writesRemote === false, "pr preflight must stay read-only", closeoutPreflightData); - assertCondition(!("raw" in closeoutPreflightData), "pr preflight default output should omit raw payloads", closeoutPreflightData); - const authCapability = closeoutPreflightData.authCapability as JsonRecord; - assertCondition(authCapability.ok === true && authCapability.tokenPresent === true && authCapability.tokenSource === "GH_TOKEN", "pr preflight should expose redacted auth capability", authCapability); - assertCondition(authCapability.valuesPrinted === false, "pr preflight should explicitly avoid secret values", authCapability); - const preflightPr = closeoutPreflightData.pullRequest as JsonRecord; - assertCondition(preflightPr.number === 42 && preflightPr.bodyOmitted === true, "pr preflight should return bounded PR metadata", preflightPr); - const mergeability = closeoutPreflightData.mergeability as JsonRecord; - assertCondition(mergeability.mergeable === "MERGEABLE" && mergeability.mergeStateStatus === "CLEAN", "pr preflight should expose mergeability", mergeability); - assertCondition(mergeability.readyForCommanderMerge === true && mergeability.conclusion === "ready", "pr preflight should summarize closeout readiness", mergeability); - const preflightStatus = closeoutPreflightData.statusChecks as JsonRecord; - const preflightCounts = preflightStatus.counts as 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 === 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 }); - const aliasPreflightData = dataOf(aliasPreflight.json ?? {}); - assertCondition(aliasPreflightData.command === "preflight", "top-level gh preflight should report alias command", aliasPreflightData); - assertCondition((aliasPreflightData.policy as JsonRecord).mergesPr === false, "top-level gh preflight alias must not merge", aliasPreflightData); - - const optionsFirstPreflight = await runCli(["gh", "pr", "preflight", "--repo", "pikasTech/unidesk", "42"], env); - assertCondition(optionsFirstPreflight.status === 0, "pr preflight should accept PR number after --repo", optionsFirstPreflight.json ?? { stdout: optionsFirstPreflight.stdout }); - const optionsFirstPreflightData = dataOf(optionsFirstPreflight.json ?? {}); - assertCondition(optionsFirstPreflightData.command === "pr preflight", "options-first pr preflight should report command", optionsFirstPreflightData); - assertCondition((optionsFirstPreflightData.pullRequest as JsonRecord).number === 42, "options-first pr preflight should read the requested PR", optionsFirstPreflightData); - - const fullPreflight = await runCli(["gh", "pr", "preflight", "42", "--repo", "pikasTech/unidesk", "--full"], env); - assertCondition(fullPreflight.status === 0, "pr preflight --full should succeed", fullPreflight.json ?? { stdout: fullPreflight.stdout }); - const fullPreflightData = dataOf(fullPreflight.json ?? {}); - const fullStatus = fullPreflightData.statusChecks as 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 mergeRequestCount = mock.requests.filter((request) => request.method === "PUT" && request.url === "/repos/pikasTech/unidesk/pulls/42/merge").length; - const alreadyMerged = await runCli(["gh", "pr", "merge", "43", "--repo", "pikasTech/unidesk", "--squash"], env); - assertCondition(alreadyMerged.status === 0, "pr merge should treat an already merged PR as idempotent success", alreadyMerged.json ?? { stdout: alreadyMerged.stdout }); - const alreadyMergedData = dataOf(alreadyMerged.json ?? {}); - assertCondition(alreadyMergedData.alreadyMerged === true, "already merged PR response should expose alreadyMerged=true", alreadyMergedData); - const alreadyMergedPullRequest = alreadyMergedData.pullRequest as JsonRecord | undefined; - assertCondition(alreadyMergedPullRequest?.merged === true, "already merged PR response should expose merged pullRequest", alreadyMergedData); - const mergeRequestCountAfterAlreadyMerged = mock.requests.filter((request) => request.method === "PUT" && request.url === "/repos/pikasTech/unidesk/pulls/42/merge").length; - assertCondition(mergeRequestCountAfterAlreadyMerged === mergeRequestCount, "already merged PR should not call REST merge endpoint again", mock.requests); - - const preflight = await runBun([ - "scripts/code-queue-pr-preflight-example.ts", - "--repo", - "pikasTech/unidesk", - "--base", - "master", - "--head", - "feature/pr-contract", - "--comment-pr", - "42", - ], env); - assertCondition(preflight.status === 0, "PR preflight example should succeed against mock GitHub", preflight.json ?? { stdout: preflight.stdout }); - assertCondition(preflight.json?.ok === true, "PR preflight example should report ok=true", preflight.json ?? {}); - assertCondition(!preflight.stdout.includes("contract-token"), "PR preflight example must not print token values", { stdout: preflight.stdout }); - assertCondition(typeof preflight.json?.checks === "object" && preflight.json.checks !== null && !Array.isArray(preflight.json.checks), "PR preflight should expose checks", preflight.json ?? {}); - const preflightChecks = preflight.json?.checks as JsonRecord; - const envToken = preflightChecks.envToken as JsonRecord; - assertCondition(envToken.present === true && envToken.source === "GH_TOKEN", "PR preflight should require env token source", envToken); - const authStatus = preflightChecks.githubAuthStatus as JsonRecord; - assertCondition(authStatus.ok === true, "PR preflight should prove GitHub REST egress and repo visibility", authStatus); - const preflightCreate = preflightChecks.prCreateDryRun as JsonRecord; - const preflightComment = preflightChecks.prCommentDryRun as JsonRecord; - assertCondition(preflightCreate.ok === true && preflightCreate.dryRun === true && preflightCreate.planned === true, "PR preflight create must stay dry-run", preflightCreate); - 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.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(); - } - - const resetMock = await startResetGitHub(); - try { - const transient = await runCli(["gh", "auth", "status", "--repo", "pikasTech/unidesk"], { - GH_TOKEN: "contract-token", - UNIDESK_GITHUB_API_URL: resetMock.baseUrl, - }); - assertCondition(transient.status !== 0, "GitHub DNS/API transient should fail structurally", transient.json ?? { stdout: transient.stdout }); - const transientData = failedDataOf(transient.json ?? {}); - assertCondition(transientData.degradedReason === "github-transient", "GitHub DNS/API transient should not be auth-missing or semantic failure", transientData); - assertCondition(transientData.runnerDisposition === "infra-blocked", "GitHub transient should remain infra-blocked", transientData); - const transientDetails = transientData.details as JsonRecord; - assertCondition(transientDetails.retryable === true, "GitHub transient should be retryable", transientDetails); - assertCondition(transientDetails.commanderAction === "retry-backoff-or-keep-running-if-heartbeat-fresh", "GitHub transient should expose bounded commander action", transientDetails); - assertCondition(!transient.stdout.includes("contract-token"), "GitHub transient output must not print token values", { stdout: transient.stdout }); - } finally { - await resetMock.close(); - } - - const title = "contract pr create"; - const bodyFile = join(tmpdir(), `unidesk-gh-pr-contract-${process.pid}.md`); - writeFileSync(bodyFile, "Line 1\n`code`\n| a | b |\n", "utf8"); - try { - const createDryRun = await runCli(["gh", "pr", "create", "--repo", "pikasTech/unidesk", "--title", title, "--body-file", bodyFile, "--base", "master", "--head", "feature/pr-contract", "--draft", "--dry-run"]); - assertCondition(createDryRun.status === 0, "pr create dry-run should succeed", createDryRun.json ?? { stdout: createDryRun.stdout }); - const createData = dataOf(createDryRun.json ?? {}); - assertCondition(createData.dryRun === true, "dry-run create must set dryRun=true", createData); - assertCondition(createData.planned === true, "dry-run create must set planned=true", createData); - assertCondition(createData.repo === "pikasTech/unidesk", "dry-run create should preserve repo", createData); - assertCondition(createData.base === "master", "dry-run create should preserve base", createData); - assertCondition(createData.head === "feature/pr-contract", "dry-run create should preserve head", createData); - assertCondition(createData.draft === true, "dry-run create should preserve draft", createData); - assertCondition(Number(createData.bodyChars ?? 0) > 0, "dry-run create should expose bodyChars", createData); - assertCondition(Array.isArray(createData.bodyPreviewLines), "dry-run create should expose bodyPreviewLines", createData); - assertCondition(String(createData.bodyPreview ?? "").includes("`code`"), "dry-run create should preserve backticks in preview", createData); - assertCondition(createData.request && typeof createData.request === "object", "dry-run create should include request plan", createData); - - const commentDryRun = await runCli(["gh", "pr", "comment", "42", "--repo", "pikasTech/unidesk", "--body-file", bodyFile, "--dry-run"]); - assertCondition(commentDryRun.status === 0, "pr comment dry-run should succeed", commentDryRun.json ?? { stdout: commentDryRun.stdout }); - const commentData = dataOf(commentDryRun.json ?? {}); - assertCondition(commentData.dryRun === true, "dry-run comment must set dryRun=true", commentData); - assertCondition(commentData.planned === true, "dry-run comment must set planned=true", commentData); - assertCondition(commentData.issueNumber === 42, "dry-run comment should preserve PR number", commentData); - assertCondition(Number(commentData.bodyChars ?? 0) > 0, "dry-run comment should expose bodyChars", commentData); - - const mock2 = await startMockGitHub(); - const env2 = { - GH_TOKEN: "contract-token", - UNIDESK_GITHUB_API_URL: mock2.baseUrl, - }; - try { - const beforeUpdateRequests = mock2.requests.length; - const updateReplace = await runCli(["gh", "pr", "update", "42", "--repo", "pikasTech/unidesk", "--mode", "replace", "--body-file", bodyFile, "--title", "updated"], env2); - assertCondition(updateReplace.status === 0, "pr update replace should succeed", updateReplace.json ?? { stdout: updateReplace.stdout }); - const updateReplaceData = dataOf(updateReplace.json ?? {}); - assertCondition(updateReplaceData.command === "pr update", "pr update replace should report command", updateReplaceData); - assertCondition(updateReplaceData.repo === "pikasTech/unidesk" && updateReplaceData.pr === 42 && updateReplaceData.number === 42, "pr update should return low-noise repo and PR number", updateReplaceData); - assertCondition(updateReplaceData.url === "https://github.com/pikasTech/unidesk/pull/42", "pr update should return PR URL", updateReplaceData); - assertCondition(JSON.stringify(updateReplaceData.changedFields) === JSON.stringify(["title", "body"]), "pr update should report changed fields only", updateReplaceData); - assertCondition(!("pullRequest" in updateReplaceData), "pr update should not echo full pullRequest/body by default", updateReplaceData); - assertCondition(updateReplaceData.rest === true && updateReplaceData.graphQl === false && updateReplaceData.projectsClassic === false, "pr update should be REST-only and avoid GraphQL projectCards", updateReplaceData); - const updatePatch = mock2.requests.slice(beforeUpdateRequests).find((request) => request.method === "PATCH" && request.url === "/repos/pikasTech/unidesk/pulls/42"); - assertCondition(updatePatch !== undefined, "pr update should PATCH the pulls REST endpoint", mock2.requests); - const updatePayload = JSON.parse(updatePatch?.body ?? "{}") as JsonRecord; - assertCondition(updatePayload.title === "updated" && updatePayload.body === "Line 1\n`code`\n| a | b |\n", "pr update should preserve body-file bytes in REST payload", updatePayload); - assertCondition(!mock2.requests.slice(beforeUpdateRequests).some((request) => request.method === "POST" && request.url === "/graphql"), "pr update should not call GraphQL", mock2.requests.slice(beforeUpdateRequests)); - - const updateAppend = await runCli(["gh", "pr", "update", "42", "--repo", "pikasTech/unidesk", "--mode", "append", "--body-file", bodyFile, "--dry-run"], env2); - assertCondition(updateAppend.status === 0, "pr update append dry-run should succeed", updateAppend.json ?? { stdout: updateAppend.stdout }); - const updateAppendData = dataOf(updateAppend.json ?? {}); - const appendBody = updateAppendData.body as JsonRecord; - const finalBody = appendBody.finalBody as JsonRecord; - assertCondition(appendBody.mode === "append", "pr append mode should be explicit", updateAppendData); - assertCondition(finalBody.containsBackticks === true && finalBody.containsMarkdownTable === true, "pr append should preserve markdown signals", updateAppendData); - - const updateNumberDryRun = await runCli(["gh", "pr", "update", "--number", "42", "--repo", "pikasTech/unidesk", "--mode", "replace", "--body-file", bodyFile, "--dry-run"], env2); - assertCondition(updateNumberDryRun.status === 0, "pr update should accept --number compatibility alias", updateNumberDryRun.json ?? { stdout: updateNumberDryRun.stdout }); - const updateNumberData = dataOf(updateNumberDryRun.json ?? {}); - const updateNumberHint = updateNumberData.standardSyntaxHint as JsonRecord; - assertCondition(String(updateNumberHint.standardCommand ?? "").includes("gh pr update 42 --repo pikasTech/unidesk"), "pr update --number should return standard syntax hint", updateNumberHint); - - const editStdinBody = "stdin line\n`stdin code`\n| c | d |\n"; - const beforeEditRequests = mock2.requests.length; - const editStdin = await runCli(["gh", "pr", "edit", "42", "--repo", "pikasTech/unidesk", "--title", "stdin title", "--body-stdin"], env2, editStdinBody); - assertCondition(editStdin.status === 0, "pr edit stdin should succeed", editStdin.json ?? { stdout: editStdin.stdout }); - const editStdinData = dataOf(editStdin.json ?? {}); - assertCondition(editStdinData.command === "pr edit" && editStdinData.pr === 42 && editStdinData.url === "https://github.com/pikasTech/unidesk/pull/42", "pr edit stdin should return low-noise summary", editStdinData); - assertCondition(JSON.stringify(editStdinData.changedFields) === JSON.stringify(["title", "body"]), "pr edit stdin should report changed fields", editStdinData); - assertCondition(!JSON.stringify(editStdinData).includes(editStdinBody), "pr edit stdin should not echo full body", editStdinData); - const editBody = editStdinData.body as JsonRecord; - const editBodySource = editBody.bodySource as JsonRecord; - assertCondition(editBodySource.kind === "stdin" && editBodySource.path === "-", "pr edit should mark stdin body source", editBodySource); - const editPatch = mock2.requests.slice(beforeEditRequests).find((request) => request.method === "PATCH" && request.url === "/repos/pikasTech/unidesk/pulls/42"); - assertCondition(editPatch !== undefined, "pr edit stdin should PATCH the pulls REST endpoint", mock2.requests); - const editPayload = JSON.parse(editPatch?.body ?? "{}") as JsonRecord; - assertCondition(editPayload.title === "stdin title" && editPayload.body === editStdinBody, "pr edit should send stdin body through REST JSON payload", editPayload); - assertCondition(!mock2.requests.slice(beforeEditRequests).some((request) => request.method === "POST" && request.url === "/graphql"), "pr edit should not call GraphQL", mock2.requests.slice(beforeEditRequests)); - - const titleOnly = await runCli(["gh", "pr", "edit", "42", "--repo", "pikasTech/unidesk", "--title", "title only", "--dry-run"], env2); - assertCondition(titleOnly.status === 0, "pr edit title-only dry-run should succeed", titleOnly.json ?? { stdout: titleOnly.stdout }); - const titleOnlyData = dataOf(titleOnly.json ?? {}); - assertCondition(JSON.stringify(titleOnlyData.changedFields) === JSON.stringify(["title"]), "title-only edit should report only title changed", titleOnlyData); - assertCondition(!("body" in titleOnlyData), "title-only edit should not include body details", titleOnlyData); - - const closePr = await runCli(["gh", "pr", "close", "42", "--repo", "pikasTech/unidesk"], env2); - assertCondition(closePr.status === 0, "pr close should succeed", closePr.json ?? { stdout: closePr.stdout }); - const closeData = dataOf(closePr.json ?? {}); - assertCondition(closeData.command === "pr close", "pr close command should be explicit", closeData); - - const reopenPr = await runCli(["gh", "pr", "reopen", "42", "--repo", "pikasTech/unidesk", "--dry-run"], env2); - assertCondition(reopenPr.status === 0, "pr reopen dry-run should succeed", reopenPr.json ?? { stdout: reopenPr.stdout }); - const reopenData = dataOf(reopenPr.json ?? {}); - assertCondition(reopenData.command === "pr reopen" && reopenData.dryRun === true, "pr reopen dry-run should be explicit", reopenData); - - const commentCreate = await runCli(["gh", "pr", "comment", "create", "42", "--repo", "pikasTech/unidesk", "--body-file", bodyFile], env2); - assertCondition(commentCreate.status === 0, "pr comment create should succeed", commentCreate.json ?? { stdout: commentCreate.stdout }); - const commentCreateData = dataOf(commentCreate.json ?? {}); - assertCondition(commentCreateData.command === "pr comment create", "pr comment create should use CRUD command name", commentCreateData); - - const prBodyStdinCommentBody = "PR heredoc comment line\n\n- keeps `code`\n"; - const prBodyStdinComment = await runCli(["gh", "pr", "comment", "create", "42", "--repo", "pikasTech/unidesk", "--body-stdin"], env2, prBodyStdinCommentBody); - assertCondition(prBodyStdinComment.status === 0, "pr comment create --body-stdin should succeed", prBodyStdinComment.json ?? { stdout: prBodyStdinComment.stdout }); - const prBodyStdinCommentData = dataOf(prBodyStdinComment.json ?? {}); - const prBodyStdinSource = prBodyStdinCommentData.bodySource as JsonRecord; - assertCondition(prBodyStdinSource.kind === "stdin" && prBodyStdinSource.path === "-", "pr comment create --body-stdin should expose stdin source", prBodyStdinCommentData); - const prBodyStdinRequest = mock2.requests.filter((request) => request.method === "POST" && request.url === "/repos/pikasTech/unidesk/issues/42/comments").at(-1); - const prBodyStdinPayload = JSON.parse(prBodyStdinRequest?.body ?? "{}") as JsonRecord; - assertCondition(prBodyStdinPayload.body === prBodyStdinCommentBody, "pr comment --body-stdin payload should preserve stdin markdown", prBodyStdinPayload); - - const prInlineCommentBody = "short PR inline comment remains supported"; - const prInlineComment = await runCli(["gh", "pr", "comment", "create", "42", "--repo", "pikasTech/unidesk", "--body", prInlineCommentBody], env2); - assertCondition(prInlineComment.status === 0, "pr comment create --body should remain supported", prInlineComment.json ?? { stdout: prInlineComment.stdout }); - assertCondition(prInlineComment.json?.command === "gh pr comment create 42 --repo pikasTech/unidesk --body ", "outer gh command should redact PR inline comment body", prInlineComment.json ?? {}); - const prInlineCommentData = dataOf(prInlineComment.json ?? {}); - assertCondition(prInlineCommentData.command === "pr comment create", "pr inline comment should use CRUD command name", prInlineCommentData); - const prInlineCommentRequest = mock2.requests.filter((request) => request.method === "POST" && request.url === "/repos/pikasTech/unidesk/issues/42/comments").at(-1); - assertCondition(prInlineCommentRequest !== undefined, "pr inline comment should POST to issue comments endpoint", mock2.requests); - const prInlinePayload = JSON.parse(prInlineCommentRequest?.body ?? "{}") as JsonRecord; - assertCondition(prInlinePayload.body === prInlineCommentBody, "pr inline comment payload should preserve --body text", prInlinePayload); - - const prCommentUpdateBody = "PR 评论原地修正"; - const prCommentUpdateDryRunRequestCountBefore = mock2.requests.length; - const prCommentUpdateDryRun = await runCli(["gh", "pr", "comment", "update", "9101", "--repo", "pikasTech/unidesk", "--body", prCommentUpdateBody, "--dry-run"], env2); - assertCondition(prCommentUpdateDryRun.status === 0, "pr comment update dry-run should succeed", prCommentUpdateDryRun.json ?? { stdout: prCommentUpdateDryRun.stdout }); - assertCondition(prCommentUpdateDryRun.json?.command === "gh pr comment update 9101 --repo pikasTech/unidesk --body --dry-run", "outer gh command should redact PR comment update inline body", prCommentUpdateDryRun.json ?? {}); - const prCommentUpdateDryRunData = dataOf(prCommentUpdateDryRun.json ?? {}); - assertCondition(prCommentUpdateDryRunData.command === "pr comment update" && prCommentUpdateDryRunData.commentId === 9101 && prCommentUpdateDryRunData.dryRun === true, "pr comment update dry-run should report commentId", prCommentUpdateDryRunData); - const prCommentUpdateRequest = prCommentUpdateDryRunData.request as JsonRecord; - assertCondition(prCommentUpdateRequest.method === "PATCH" && String(prCommentUpdateRequest.path ?? "").includes("/issues/comments/{comment_id}"), "pr comment update dry-run should plan PATCH comment endpoint", prCommentUpdateRequest); - const prCommentUpdateDryRunWriteCount = mock2.requests.slice(prCommentUpdateDryRunRequestCountBefore).filter((request) => request.method === "PATCH" && request.url.includes("/issues/comments/")).length; - assertCondition(prCommentUpdateDryRunWriteCount === 0, "pr comment update dry-run must not PATCH GitHub", { requests: mock2.requests.slice(prCommentUpdateDryRunRequestCountBefore) }); - - const prCommentEditBody = "PR edit 别名\n\n- 保留 `code`\n"; - const prCommentEditRequestCountBefore = mock2.requests.length; - const prCommentEdit = await runCli(["gh", "pr", "comment", "edit", "--number", "9101", "--repo", "pikasTech/unidesk", "--body-stdin"], env2, prCommentEditBody); - assertCondition(prCommentEdit.status === 0, "pr comment edit should accept --number compatibility alias and stdin", prCommentEdit.json ?? { stdout: prCommentEdit.stdout }); - const prCommentEditData = dataOf(prCommentEdit.json ?? {}); - assertCondition(prCommentEditData.command === "pr comment edit" && prCommentEditData.commentId === 9101, "pr comment edit should report alias command and commentId", prCommentEditData); - const prCommentEditHint = prCommentEditData.standardSyntaxHint as JsonRecord; - assertCondition(String(prCommentEditHint.standardCommand ?? "").includes("gh pr comment edit 9101 --repo pikasTech/unidesk"), "pr comment edit --number should point to positional commentId syntax", prCommentEditHint); - const prCommentEditSummary = prCommentEditData.comment as JsonRecord; - assertCondition(prCommentEditSummary.id === 9101 && prCommentEditSummary.bodyOmitted === true && !("body" in prCommentEditSummary), "pr comment edit should preserve id and omit full body", prCommentEditSummary); - const prCommentEditPatch = mock2.requests.slice(prCommentEditRequestCountBefore).find((request) => request.method === "PATCH" && request.url === "/repos/pikasTech/unidesk/issues/comments/9101"); - assertCondition(prCommentEditPatch !== undefined, "pr comment edit should PATCH issue comments endpoint", { requests: mock2.requests.slice(prCommentEditRequestCountBefore) }); - const prCommentEditPayload = JSON.parse(prCommentEditPatch?.body ?? "{}") as JsonRecord; - assertCondition(prCommentEditPayload.body === prCommentEditBody, "pr comment edit payload should preserve stdin Markdown", prCommentEditPayload); - - const commentDelete = await runCli(["gh", "pr", "comment", "delete", "9101", "--repo", "pikasTech/unidesk"], env2); - assertCondition(commentDelete.status === 0, "pr comment delete should succeed", commentDelete.json ?? { stdout: commentDelete.stdout }); - const commentDeleteData = dataOf(commentDelete.json ?? {}); - assertCondition(commentDeleteData.deleted === true, "pr comment delete should report deleted", commentDeleteData); - - const commentDeleteNumber = await runCli(["gh", "pr", "comment", "delete", "--number", "9101", "--repo", "pikasTech/unidesk", "--dry-run"], env2); - assertCondition(commentDeleteNumber.status === 0, "pr comment delete should accept --number commentId compatibility alias", commentDeleteNumber.json ?? { stdout: commentDeleteNumber.stdout }); - const commentDeleteNumberData = dataOf(commentDeleteNumber.json ?? {}); - const commentDeleteNumberHint = commentDeleteNumberData.standardSyntaxHint as JsonRecord; - assertCondition(commentDeleteNumberData.commentId === 9101 && String(commentDeleteNumberHint.standardCommand ?? "").includes("gh pr comment delete 9101 --repo pikasTech/unidesk"), "pr comment delete --number should point to positional commentId syntax", commentDeleteNumberData); - } finally { - await mock2.close(); - } - - 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; - assertCondition(deleteData?.degradedReason === "unsupported-command", "pr delete should be unsupported-command", deleteData ?? {}); - - const createMissingBody = await runCli(["gh", "pr", "create", "--repo", "pikasTech/unidesk", "--title", title, "--base", "master", "--head", "feature/pr-contract", "--dry-run"]); - assertCondition(createMissingBody.status !== 0, "pr create without body source should fail", createMissingBody.json ?? { stdout: createMissingBody.stdout }); - const createMissingBodyData = createMissingBody.json?.data as JsonRecord | undefined; - assertCondition(createMissingBodyData?.degradedReason === "validation-failed", "missing body source should be validation-failed", createMissingBodyData ?? {}); - assertCondition(createMissingBodyData?.runnerDisposition === "business-failed", "validation should classify as business-failed", createMissingBodyData ?? {}); - - const unknownOption = await runCli(["gh", "pr", "create", "--repo", "pikasTech/unidesk", "--title", title, "--body-file", bodyFile, "--base", "master", "--head", "feature/pr-contract", "--dry-run", "--bad-option"]); - assertCondition(unknownOption.status !== 0, "unknown gh option should fail", unknownOption.json ?? { stdout: unknownOption.stdout }); - const unknownOptionData = unknownOption.json?.data as JsonRecord | undefined; - assertCondition(unknownOptionData?.degradedReason === "unsupported-command", "unknown option should be unsupported-command", unknownOptionData ?? {}); - assertCondition(unknownOptionData?.runnerDisposition === "business-failed", "unknown option should classify as business-failed", unknownOptionData ?? {}); - } finally { - unlinkSync(bodyFile); - } - - return { - ok: true, - checks: [ - "gh help lists pr create/comment", - "pr list/read/view work through REST with token and no gh binary dependency", - "pr list positional owner/repo targets the requested repo and conflicting --repo fails", - "pr single numeric target commands accept --number compatibility with a standard syntax hint", - "pr view/read accept GitHub URL and owner/repo#number targets and reject conflicting --repo", - "pr view/read --raw is explicit full disclosure", - "pr list rejects closeout fields and points to pr view", - "pr read normalizes open and merged lifecycle fields from REST", - "GitHub DNS/API transients are retryable and distinct from auth or PR semantic failures", - "pr view closeout metadata fields are accepted and hydrated through GraphQL", - "pr view closeout metadata makes GraphQL errors and UNKNOWN/null explicit", - "pr read unsupported fields fail structurally with supported closeout fields listed", - "pr preflight exposes redacted auth plus compact merge/status closeout metadata", - "top-level gh preflight alias works for commander closeout", - "pr preflight accepts the PR number after options", - "pr preflight --full is the explicit status-context disclosure path", - "pr create dry-run exposes planned operation", - "pr comment dry-run preserves markdown text", - "pr update/edit use low-noise REST PATCH without GraphQL projectCards", - "pr edit supports --body-stdin without echoing full body", - "pr update append and close/reopen are available", - "pr comment create/update/edit/delete follows CRUD shape, --body-stdin, and --body remains supported", - "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", - ], - }; -} - -if (import.meta.main) { - const result = await runGhCliPrContract(); - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); -} diff --git a/scripts/gh-cli-pr-files-contract-test.ts b/scripts/gh-cli-pr-files-contract-test.ts deleted file mode 100644 index 43b7be84..00000000 --- a/scripts/gh-cli-pr-files-contract-test.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { spawn } from "node:child_process"; -import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; -import type { AddressInfo } from "node:net"; - -type JsonRecord = Record; - -interface MockRequest { - method: string; - url: string; - body: string; -} - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function runBun(args: string[], env: Record = {}): Promise<{ status: number | null; stdout: string; stderr: string; json: JsonRecord | null }> { - return new Promise((resolve, reject) => { - const child = spawn("bun", args, { - cwd: process.cwd(), - env: { ...process.env, ...env }, - }); - const stdoutChunks: Buffer[] = []; - const stderrChunks: Buffer[] = []; - child.stdout.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk))); - child.stderr.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk))); - child.on("error", reject); - child.on("close", (status) => { - const stdout = Buffer.concat(stdoutChunks).toString("utf8"); - let json: JsonRecord | null = null; - try { - json = JSON.parse(stdout) as JsonRecord; - } catch { - json = null; - } - resolve({ - status, - stdout, - stderr: Buffer.concat(stderrChunks).toString("utf8"), - json, - }); - }); - }); -} - -function runCli(args: string[], env: Record = {}): Promise<{ status: number | null; stdout: string; stderr: string; json: JsonRecord | null }> { - return runBun(["scripts/cli.ts", ...args], env); -} - -function collectBody(req: IncomingMessage): Promise { - return new Promise((resolve) => { - const chunks: Buffer[] = []; - req.on("data", (chunk) => chunks.push(Buffer.from(chunk))); - req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); - }); -} - -function sendJson(res: ServerResponse, status: number, payload: unknown): void { - res.statusCode = status; - res.setHeader("content-type", "application/json"); - res.end(JSON.stringify(payload)); -} - -function prFixture(): JsonRecord { - return { - id: 4200, - number: 42, - title: "CLI summary fixture", - body: "fixture body", - state: "open", - html_url: "https://github.example/pikasTech/unidesk/pull/42", - draft: false, - user: { login: "runner" }, - head: { ref: "feature/pr-files", sha: "abc123" }, - base: { ref: "master", sha: "def456" }, - additions: 12, - deletions: 3, - changed_files: 2, - commits: 1, - created_at: "2026-05-23T00:00:00Z", - updated_at: "2026-05-23T00:10:00Z", - }; -} - -function prFilesFixture(): JsonRecord[] { - return [ - { - sha: "aaa", - filename: "scripts/src/gh.ts", - status: "modified", - additions: 10, - deletions: 2, - changes: 12, - blob_url: "https://github.example/blob/scripts/src/gh.ts", - raw_url: "https://github.example/raw/scripts/src/gh.ts", - contents_url: "https://api.github.example/contents/scripts/src/gh.ts", - patch: "@@ raw diff must not be returned @@", - }, - { - sha: "bbb", - filename: "scripts/gh-cli-pr-files-contract-test.ts", - status: "added", - additions: 2, - deletions: 1, - changes: 3, - blob_url: "https://github.example/blob/scripts/gh-cli-pr-files-contract-test.ts", - raw_url: "https://github.example/raw/scripts/gh-cli-pr-files-contract-test.ts", - contents_url: "https://api.github.example/contents/scripts/gh-cli-pr-files-contract-test.ts", - patch: "@@ raw diff must not be returned either @@", - }, - ]; -} - -async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockRequest[]; close: () => Promise }> { - const requests: MockRequest[] = []; - const server = createServer(async (req, res) => { - const body = await collectBody(req); - requests.push({ method: req.method ?? "", url: req.url ?? "", body }); - const url = new URL(req.url ?? "/", "http://localhost"); - if (req.method === "GET" && url.pathname === "/repos/pikasTech/unidesk/pulls/42") { - sendJson(res, 200, prFixture()); - return; - } - if (req.method === "GET" && url.pathname === "/repos/pikasTech/unidesk/pulls/42/files") { - const perPage = Number(url.searchParams.get("per_page") ?? "30"); - const page = Number(url.searchParams.get("page") ?? "1"); - const offset = (page - 1) * perPage; - sendJson(res, 200, prFilesFixture().slice(offset, offset + perPage)); - return; - } - sendJson(res, 404, { message: `unexpected ${req.method} ${req.url}` }); - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - const address = server.address(); - assertCondition(typeof address === "object" && address !== null, "mock server should expose address"); - const port = (address as AddressInfo).port; - assertCondition(typeof port === "number", "mock server should expose port"); - return { - baseUrl: `http://127.0.0.1:${port}`, - requests, - close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())), - }; -} - -function dataOf(response: JsonRecord): JsonRecord { - assertCondition(response.ok === true, "CLI command should succeed", response); - assertCondition(typeof response.data === "object" && response.data !== null && !Array.isArray(response.data), "response data should be object", response); - return response.data as JsonRecord; -} - -function failedDataOf(response: JsonRecord): JsonRecord { - assertCondition(response.ok === false, "CLI command should fail", response); - assertCondition(typeof response.data === "object" && response.data !== null && !Array.isArray(response.data), "failure data should be object", response); - return response.data as JsonRecord; -} - -export async function runGhCliPrFilesContract(): Promise { - const help = await runCli(["gh", "help"]); - assertCondition(help.status === 0, "gh help should succeed", help.json ?? { stdout: help.stdout }); - const helpData = dataOf(help.json ?? {}); - const usage = Array.isArray(helpData.usage) ? helpData.usage.map((value) => String(value)) : []; - const notes = Array.isArray(helpData.notes) ? helpData.notes.map((value) => String(value)) : []; - assertCondition(usage.some((line) => line.includes("gh pr files ")), "help should document gh pr files", { usage }); - assertCondition(usage.some((line) => line.includes("gh pr diff --stat")), "help should document gh pr diff --stat", { usage }); - assertCondition(notes.some((line) => line.includes("PR files is the canonical compact changed-file/stat summary")), "help should document PR files disclosure boundary", { notes }); - - const mock = await startMockGitHub(); - const env = { - GH_TOKEN: "contract-token", - UNIDESK_GITHUB_API_URL: mock.baseUrl, - }; - const checks: string[] = ["gh help documents pr files and pr diff --stat"]; - try { - mock.requests.length = 0; - const files = await runCli(["gh", "pr", "files", "42", "--repo", "pikasTech/unidesk", "--limit", "1"], env); - assertCondition(files.status === 0, "gh pr files should succeed", files.json ?? { stdout: files.stdout, stderr: files.stderr }); - const filesData = dataOf(files.json ?? {}); - assertCondition(filesData.command === "pr files", "command name should be pr files", filesData); - assertCondition(filesData.rawDiffIncluded === false, "rawDiffIncluded must be false", filesData); - const summary = filesData.summary as JsonRecord; - assertCondition(summary.files === 2, "summary must include total changed files", summary); - assertCondition(summary.additions === 12, "summary additions should come from PR REST", summary); - assertCondition(summary.deletions === 3, "summary deletions should come from PR REST", summary); - assertCondition(summary.changes === 15, "summary changes should include additions plus deletions", summary); - assertCondition(filesData.filesReturned === 1, "limit 1 should return one file", filesData); - const fileRows = filesData.files as JsonRecord[]; - assertCondition(Array.isArray(fileRows) && fileRows.length === 1, "files list should be bounded", filesData); - assertCondition(fileRows[0]?.filename === "scripts/src/gh.ts", "first file filename mismatch", fileRows[0]); - assertCondition(fileRows[0]?.patch === undefined, "raw patch must not be emitted", fileRows[0]); - assertCondition(fileRows[0]?.raw_url === undefined, "raw_url field casing must not leak", fileRows[0]); - const truncation = filesData.truncation as JsonRecord; - assertCondition(truncation.truncated === true, "limit 1 should mark truncation", truncation); - assertCondition(truncation.totalFiles === 2, "truncation should expose totalFiles", truncation); - const next = filesData.next as JsonRecord; - assertCondition(String(next.command).includes("--limit 2"), "next command should request the bounded full file count", next); - assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/unidesk/pulls/42/files?per_page=1&page=1"), "files endpoint should use bounded per_page", mock.requests); - checks.push("gh pr files returns bounded REST file/stat JSON without raw patches"); - - mock.requests.length = 0; - const filesOptionsFirst = await runCli(["gh", "pr", "files", "--repo", "pikasTech/unidesk", "--limit", "1", "42"], env); - assertCondition(filesOptionsFirst.status === 0, "gh pr files should accept PR number after options", filesOptionsFirst.json ?? { stdout: filesOptionsFirst.stdout, stderr: filesOptionsFirst.stderr }); - const filesOptionsFirstData = dataOf(filesOptionsFirst.json ?? {}); - assertCondition(filesOptionsFirstData.command === "pr files" && filesOptionsFirstData.filesReturned === 1, "options-first pr files should return compact file summary", filesOptionsFirstData); - assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/unidesk/pulls/42/files?per_page=1&page=1"), "options-first pr files should query the requested PR number", mock.requests); - checks.push("gh pr files accepts the PR number after options"); - - mock.requests.length = 0; - const stat = await runCli(["gh", "pr", "diff", "42", "--stat", "--repo", "pikasTech/unidesk", "--limit", "2"], env); - assertCondition(stat.status === 0, "gh pr diff --stat should succeed", stat.json ?? { stdout: stat.stdout, stderr: stat.stderr }); - const statData = dataOf(stat.json ?? {}); - assertCondition(statData.command === "pr diff --stat", "diff --stat should report alias command name", statData); - assertCondition(statData.rawDiffIncluded === false, "diff --stat must not include raw diff", statData); - assertCondition(statData.filesReturned === 2, "diff --stat should return requested files", statData); - const statRows = statData.files as JsonRecord[]; - assertCondition(statRows.every((file) => file.patch === undefined), "diff --stat file rows must not include patch", statRows); - checks.push("gh pr diff --stat is a compact summary alias"); - - mock.requests.length = 0; - const statOptionsFirst = await runCli(["gh", "pr", "diff", "--repo", "pikasTech/unidesk", "--stat", "--limit", "2", "42"], env); - assertCondition(statOptionsFirst.status === 0, "gh pr diff --stat should accept PR number after options", statOptionsFirst.json ?? { stdout: statOptionsFirst.stdout, stderr: statOptionsFirst.stderr }); - const statOptionsFirstData = dataOf(statOptionsFirst.json ?? {}); - assertCondition(statOptionsFirstData.command === "pr diff --stat" && statOptionsFirstData.filesReturned === 2, "options-first pr diff --stat should return compact file summary", statOptionsFirstData); - assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/unidesk/pulls/42/files?per_page=2&page=1"), "options-first pr diff --stat should query the requested PR number", mock.requests); - checks.push("gh pr diff --stat accepts the PR number after options"); - - const rawDiff = await runCli(["gh", "pr", "diff", "42", "--repo", "pikasTech/unidesk"], env); - assertCondition(rawDiff.status !== 0, "gh pr diff without --stat should fail closed", rawDiff.json ?? { stdout: rawDiff.stdout, stderr: rawDiff.stderr }); - const rawData = failedDataOf(rawDiff.json ?? {}); - assertCondition(rawData.degradedReason === "unsupported-command", "raw diff should fail as unsupported-command", rawData); - assertCondition(rawData.rawDiffIncluded === false, "raw diff failure should state rawDiffIncluded=false", rawData); - checks.push("gh pr diff without --stat fails closed without raw diff output"); - - return { ok: true, checks }; - } finally { - await mock.close(); - } -} - -if (import.meta.main) { - runGhCliPrFilesContract() - .then((result) => console.log(JSON.stringify(result, null, 2))) - .catch((error) => { - console.error(error instanceof Error ? error.stack ?? error.message : String(error)); - process.exitCode = 1; - }); -} diff --git a/scripts/gh-commander-brief-contract-test.ts b/scripts/gh-commander-brief-contract-test.ts deleted file mode 100644 index f17cf632..00000000 --- a/scripts/gh-commander-brief-contract-test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { commanderBriefDiff } from "./src/gh"; - -type JsonRecord = Record; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -export function runGhCommanderBriefContract(): JsonRecord { - const oldBody = [ - "# 指挥简报", - "", - "## 常驻观察与长期建议", - "", - "- 保持队列监督。", - "", - "## 更新 2026-05-20 17:28 北京时间", - "", - "- 已完成初始观察。", - "", - ].join("\n"); - const newSection = [ - "## 更新 2026-05-20 18:05 北京时间", - "", - "- 新增进展包含 `code`。", - "", - "| 项 | 状态 |", - "| --- | --- |", - "| GitHub | ready |", - "", - "真实换行必须保留。", - ].join("\n"); - - const appended = commanderBriefDiff(oldBody, `${oldBody}${newSection}\n`); - assertCondition(appended.ok === true, "append-only update should be detected", appended); - assertCondition(appended.mode === "append-only", "append-only mode should be reported", appended); - assertCondition(appended.sectionCount === 1, "one appended section should be extracted", appended); - assertCondition(appended.message === newSection, "appended section text should be exact", { message: appended.message }); - assertCondition(appended.message.includes("\n| 项 | 状态 |"), "markdown table should keep real newline", { message: appended.message }); - assertCondition(appended.message.includes("`code`"), "backticks should be preserved", { message: appended.message }); - assertCondition(!appended.message.includes("\\n"), "real newlines must not become literal backslash-n", { message: appended.message }); - - const identical = commanderBriefDiff(oldBody, oldBody); - assertCondition(identical.ok === false, "identical body should skip", identical); - assertCondition(identical.mode === "identical", "identical mode should be reported", identical); - assertCondition(String(identical.skippedReason ?? "").length > 0, "identical skip reason should be present", identical); - - const headerOnly = commanderBriefDiff(oldBody, oldBody.replace("- 保持队列监督。", "- 保持队列监督,并记录阻塞。")); - assertCondition(headerOnly.ok === false, "header-only modification should skip", headerOnly); - assertCondition(headerOnly.mode === "heading-diff", "header-only modification should use heading diff mode", headerOnly); - - const reordered = commanderBriefDiff( - oldBody, - [ - "# 指挥简报", - "", - "## 常驻观察与长期建议", - "", - "- 保持队列监督。", - "", - newSection, - "", - "## 更新 2026-05-20 17:28 北京时间", - "", - "- 已完成初始观察。", - "", - ].join("\n"), - ); - assertCondition(reordered.ok === true, "non append-only new heading should be detected", reordered); - assertCondition(reordered.mode === "heading-diff", "non append-only new heading should use heading-diff", reordered); - assertCondition(reordered.message === newSection, "heading diff should return only new section", { message: reordered.message }); - - return { - ok: true, - checks: [ - "append-only commander brief section extraction", - "identical body skip", - "header-only long-term observation edit skip", - "non append-only heading diff extraction", - "backticks, markdown table, and real newlines preserved", - ], - }; -} - -if (import.meta.main) { - const result = runGhCommanderBriefContract(); - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); -} diff --git a/scripts/gh-route-contract-test.ts b/scripts/gh-route-contract-test.ts deleted file mode 100644 index 29ed9c94..00000000 --- a/scripts/gh-route-contract-test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { GhVirtualFileExecutor, applyPatchToGhBody, parseGhContentRoute } from "./src/gh-route"; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -const route = parseGhContentRoute("gh:/pikasTech/HWLAB/503/1"); -assertCondition(route.repo === "pikasTech/HWLAB", "route should preserve owner/repo"); -assertCondition(route.number === 503, "route should parse issue or PR number"); -assertCondition(route.floor === 1, "route should parse first-floor body"); - -const prListRoute = parseGhContentRoute("gh:/pikasTech/HWLAB/pr"); -assertCondition(prListRoute.area === "pr-list" && prListRoute.number === null, "PR directory route should list pull requests", prListRoute); - -const prBodyRoute = parseGhContentRoute("gh:/pikasTech/HWLAB/pr/503/1"); -assertCondition(prBodyRoute.area === "pr-item" && prBodyRoute.number === 503 && prBodyRoute.floor === 1, "PR body route should parse explicit first floor", prBodyRoute); - -const issueListRoute = parseGhContentRoute("gh:/pikasTech/HWLAB/issue"); -assertCondition(issueListRoute.area === "issue-list" && issueListRoute.number === null, "issue directory route should list issues", issueListRoute); - -const original = [ - "# Title", - "", - "- 上线 changelog:", - " - fix: old title only", - " - changed files: 2; +10 / -1; commits: 1", - "", -].join("\n"); - -const patchResult = await applyPatchToGhBody(original, [ - "*** Begin Patch", - "*** Update File: body.md", - "@@", - " - 上线 changelog:", - "- - fix: old title only", - "- - changed files: 2; +10 / -1; commits: 1", - "+ - 修复目标:说明真实用户可见问题。", - "+- 自动 diff 摘要:", - "+ - changed files: 2; +10 / -1; commits: 1", - " ", - "*** End Patch", -].join("\n")); -const patched = patchResult.body; - -assertCondition( - patched.includes("- 上线 changelog:\n - 修复目标:说明真实用户可见问题。\n- 自动 diff 摘要:"), - "patch-apply should update the virtual body with apply_patch v2 semantics", - patched, -); -assertCondition(!patched.includes("fix: old title only"), "patch-apply should remove old title-only changelog", patched); -assertCondition(patchResult.applyPatchOutput.includes("Success. Updated the following files:"), "gh route should run through the shared apply-patch v2 engine", patchResult.applyPatchOutput); - -const executor = new GhVirtualFileExecutor("hello\n"); -const stat = await executor.run(["sh", "-c", "ignored", "unidesk-apply-patch-v2", "stat", "body.md"]); -assertCondition(stat.exitCode === 0 && /^6 [0-9a-f]{64}\n$/u.test(stat.stdout), "virtual file executor should expose apply-patch v2 stat protocol", stat); - -console.log(JSON.stringify({ - ok: true, - checks: [ - "gh:/owner/repo/number/1 route parses to a first-floor GitHub body", - "gh:/owner/repo/pr and gh:/owner/repo/issue directory routes parse", - "gh:/owner/repo/pr/number/1 explicit PR body route parses", - "gh route patch-apply uses the shared apply-patch v2 engine", - "gh virtual file executor exposes the apply-patch v2 low-level file protocol", - ], -}, null, 2)); diff --git a/scripts/host-codex-commander-approval-notification-contract-test.ts b/scripts/host-codex-commander-approval-notification-contract-test.ts deleted file mode 100644 index 6e0aeebe..00000000 --- a/scripts/host-codex-commander-approval-notification-contract-test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { spawnSync } from "node:child_process"; -import { commanderApprovalProxyFailureSummary } from "../src/components/microservices/host-codex-commander/src/approval-notification"; - -type JsonRecord = Record; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function asRecord(value: unknown, label: string): JsonRecord { - assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, value); - return value as JsonRecord; -} - -function runCli(args: string[], expectStatus: number, extraEnv: Record = {}): JsonRecord { - const result = spawnSync(process.execPath, ["scripts/cli.ts", ...args], { - cwd: process.cwd(), - encoding: "utf8", - env: { - ...process.env, - ...extraEnv, - }, - maxBuffer: 4 * 1024 * 1024, - }); - assertCondition(result.status === expectStatus, `status mismatch for ${args.join(" ")}`, { - status: result.status, - stdout: result.stdout.slice(-2000), - stderr: result.stderr.slice(-2000), - }); - assertCondition(result.stdout.trim().length > 0, `command produced no stdout: ${args.join(" ")}`); - return asRecord(JSON.parse(result.stdout) as unknown, "cli envelope"); -} - -function dataOf(envelope: JsonRecord): JsonRecord { - return asRecord(envelope.data, "data"); -} - -function charLength(value: string): number { - return Array.from(value).length; -} - -function assertPlainApprovalMessage(message: string): void { - assertCondition(charLength(message) <= 200, "approval notification draft must be <=200 chars", { message, chars: charLength(message) }); - assertCondition(!/[`*_#[\]|]/u.test(message), "approval notification draft must not contain Markdown syntax", { message }); - assertCondition(!message.includes("\n"), "approval notification draft must be one paragraph", { message }); -} - -const approval = dataOf(runCli([ - "commander", - "approval", - "request", - "--action", - "code-queue-task-interrupt", - "--task-id", - "stale-active-118", - "--reason", - "stale-active 恢复需要 interrupt;token=ghp_1234567890abcdef;不要使用 `local` 路径", - "--dry-run", -], 0, { - PATH: "/usr/bin:/bin", -})); - -assertCondition(approval.ok === true, "approval dry-run must succeed without local powershell", approval); -assertCondition(approval.mutation === false, "approval dry-run must be non-mutating", approval); -assertCondition(!JSON.stringify(approval).includes("ghp_1234567890abcdef"), "approval dry-run must redact secret-like reason", approval); - -const claudeqq = asRecord(approval.claudeqq, "claudeqq"); -assertCondition(claudeqq.mutation === false, "ClaudeQQ dry-run must not mutate", claudeqq); -assertCondition(claudeqq.sendImplemented === false, "commander skeleton must not claim send implementation", claudeqq); -assertCondition(claudeqq.dryRunNoClaudeQqSend === true, "dry-run must explicitly report no ClaudeQQ send", claudeqq); - -const draft = asRecord(claudeqq.notificationDraft, "claudeqq.notificationDraft"); -assertCondition(draft.format === "plain-text", "approval draft must be plain text", draft); -assertCondition(draft.markdownAllowed === false, "approval draft must forbid Markdown", draft); -assertCondition(draft.containsMarkdownSyntax === false, "approval draft must report no Markdown syntax", draft); -assertPlainApprovalMessage(String(draft.message)); - -const path = asRecord(claudeqq.notificationPath, "claudeqq.notificationPath"); -assertCondition(path.error === "notification-path-unavailable", "dry-run must expose notification-path-unavailable blocker", path); -assertCondition(path.servicePath === "/api/microservices/claudeqq/proxy/api/push/text", "service path must be backend-core ClaudeQQ proxy", path); -assertCondition(String(path.backendCoreProxyCommand).includes("bun scripts/cli.ts microservice proxy claudeqq /api/push/text --method POST"), "backend-core proxy command must be returned", path); -assertCondition(!String(path.backendCoreProxyCommand).includes("powershell"), "backend-core proxy command must not use local powershell", path); -assertCondition(!String(path.backendCoreProxyCommand).includes(".agents/skills/claudeqq"), "backend-core proxy command must not use local skill path", path); -assertCondition(path.timeoutMs === 15000, "proxy path must disclose timeout", path); - -const failure = commanderApprovalProxyFailureSummary({ - ok: false, - status: 503, - error: "microservice proxy task failed", - stderrTail: "token=ghp_1234567890abcdef powershell.exe failed", -}); -assertCondition(failure.ok === false, "proxy failure summary must be failing", failure); -assertCondition(failure.error === "notification-proxy-failed", "proxy failure summary must be structured", failure); -assertCondition(failure.degradedReason === "microservice-proxy-failed", "proxy failure degraded reason must be microservice-proxy-failed", failure); -assertCondition(asRecord(failure.rollback, "rollback").issueUpdateRolledBack === false, "proxy failure must not imply issue rollback", failure); -assertCondition(!JSON.stringify(failure).includes("ghp_1234567890abcdef"), "proxy failure summary must redact secrets", failure); - -const tmp = mkdtempSync(join(tmpdir(), "unidesk-gh-brief-notify-")); -try { - const bodyPath = join(tmp, "brief.md"); - writeFileSync(bodyPath, [ - "# 指挥简报", - "", - "## 常驻观察与长期建议", - "", - "- 保持监督。", - "", - "## 更新 2026-05-23 10:00 北京时间", - "", - "- 新增高风险审批路径 dry-run 预览。", - "", - ].join("\n"), "utf8"); - const ghDryRun = dataOf(runCli([ - "gh", - "issue", - "edit", - "24", - "--body-file", - bodyPath, - "--notify-claudeqq-brief-diff", - "--dry-run", - ], 0, { - UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL: "http://127.0.0.1:9082", - })); - const notification = asRecord(ghDryRun.commanderBriefNotification, "commanderBriefNotification"); - const ghClaudeqq = asRecord(notification.claudeqq, "commanderBriefNotification.claudeqq"); - assertCondition(ghClaudeqq.wouldSend === false, "dry-run helper must not allow non-proxy ClaudeQQ path", ghClaudeqq); - assertCondition(ghClaudeqq.blockedReason === "notification-path-unavailable", "dry-run helper must structure non-proxy blocker", ghClaudeqq); - assertCondition(String(ghClaudeqq.recommendedCommand).includes("microservice proxy claudeqq"), "dry-run helper must recommend repo-owned proxy command", ghClaudeqq); -} finally { - rmSync(tmp, { recursive: true, force: true }); -} - -const hostDoc = readFileSync("docs/reference/host-codex-commander.md", "utf8"); -const supervisionDoc = readFileSync("docs/reference/code-queue-supervision.md", "utf8"); -for (const snippet of [ - "notification-path-unavailable", - "microservice proxy claudeqq /api/push/text", - "200 字以内中文纯文本", -]) { - assertCondition(hostDoc.includes(snippet), `host commander doc missing snippet: ${snippet}`); -} -for (const snippet of [ - "不超过 200 个中文字符", - "不使用 Markdown 语法", - "发送失败只记录到 #24 或对应 blocker issue", -]) { - assertCondition(supervisionDoc.includes(snippet), `code queue supervision doc missing snippet: ${snippet}`); -} - -process.stdout.write(`${JSON.stringify({ - ok: true, - checks: [ - "commander approval dry-run survives without local powershell or ClaudeQQ skill server", - "dry-run generates a <=200 char Chinese plain-text ClaudeQQ draft and does not send", - "dry-run exposes notification-path-unavailable plus backend-core microservice proxy command", - "microservice proxy failure summary is structured, redacted, and best-effort", - "legacy gh issue edit notify dry-run rejects non-proxy ClaudeQQ base URLs", - "reference docs state the high-risk ClaudeQQ approval notification path", - ], -}, null, 2)}\n`); diff --git a/scripts/host-codex-commander-contract-test.ts b/scripts/host-codex-commander-contract-test.ts deleted file mode 100644 index bb7d558e..00000000 --- a/scripts/host-codex-commander-contract-test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; - -type JsonRecord = Record; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function asRecord(value: unknown, label: string): JsonRecord { - assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, value); - return value as JsonRecord; -} - -function asStringArray(value: unknown, label: string): string[] { - assertCondition(Array.isArray(value) && value.every((item) => typeof item === "string"), `${label} must be a string array`, value); - return value as string[]; -} - -function runCli(args: string[], expectStatus: number): JsonRecord { - const result = spawnSync("bun", ["scripts/cli.ts", ...args], { - cwd: process.cwd(), - encoding: "utf8", - maxBuffer: 4 * 1024 * 1024, - }); - assertCondition(result.status === expectStatus, `status mismatch for ${args.join(" ")}`, { - status: result.status, - stdout: result.stdout.slice(-2000), - stderr: result.stderr.slice(-2000), - }); - return asRecord(JSON.parse(result.stdout) as unknown, "cli envelope"); -} - -function dataOf(envelope: JsonRecord): JsonRecord { - return asRecord(envelope.data, "data"); -} - -const contract = dataOf(runCli(["commander", "contract"], 0)); -assertCondition(contract.phase === "source-contract", "contract must identify source-contract phase", contract); -assertCondition(contract.serviceId === "host-codex-commander", "contract must expose service id", contract); -assertCondition(contract.daemonImplemented === false, "daemon must not be implemented in phase one", contract); -assertCondition(contract.liveOperationsImplemented === false, "live operations must not be implemented in phase one", contract); -const capabilities = asStringArray(contract.requiredCapabilities, "requiredCapabilities"); -for (const expected of [ - "host-codex-process-discovery", - "ssh-bridge-contract", - "pty-bridge-contract", - "stdio-bridge-contract", - "prompt-guidance-plan", - "trace-summary-plan", - "issue-20-board-read-write-entry", - "issue-46-brief-read-write-entry", - "pr-closeout-boundary-plan", - "claudeqq-high-risk-approval-entry", -]) { - assertCondition(capabilities.includes(expected), `missing required capability ${expected}`, capabilities); -} - -const safety = asRecord(contract.safetyBoundary, "safetyBoundary"); -assertCondition(safety.phaseOneMutationAllowed === false, "phase one must forbid mutation", safety); -const forbidden = asStringArray(safety.forbiddenWithoutExplicitUserApproval, "forbiddenWithoutExplicitUserApproval"); -assertCondition(forbidden.includes("code-queue-backend-restart"), "backend restart must require approval", forbidden); -assertCondition(forbidden.includes("code-queue-task-interrupt"), "task interrupt must require approval", forbidden); -assertCondition(forbidden.includes("code-queue-task-cancel"), "task cancel must require approval", forbidden); -const alwaysForbidden = asStringArray(safety.alwaysForbidden, "alwaysForbidden"); -assertCondition(alwaysForbidden.includes("print-token-values"), "contract must forbid token output", alwaysForbidden); - -const plan = dataOf(runCli(["commander", "plan", "--dry-run", "--session-id", "primary"], 0)); -assertCondition(plan.mutation === false, "plan must be non-mutating", plan); -assertCondition(asRecord(asRecord(plan.processDiscovery, "processDiscovery").startPlan, "startPlan").enabled === false, "start plan must be disabled", plan); -assertCondition(asRecord(plan.bridge, "bridge").mutation === false, "bridge plan must not open bridges", plan); -assertCondition(asRecord(plan.traceSummary, "traceSummary").mutation === false, "trace summary plan must be non-mutating", plan); -assertCondition(asRecord(plan.issueEntries, "issueEntries").mutation === false, "issue entry plan must be non-mutating", plan); -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 === 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)); -assertCondition(planWithoutDryRun.error === "dry-run-required", "plan must require dry-run", planWithoutDryRun); - -const approval = dataOf(runCli([ - "commander", - "approval", - "request", - "--action", - "code-queue-task-interrupt", - "--task-id", - "task-123", - "--reason", - "heartbeat expired", - "--dry-run", -], 0)); -assertCondition(approval.mutation === false, "approval request must be non-mutating", approval); -assertCondition(approval.requiresExplicitUserApproval === true, "approval request must require explicit user approval", approval); -const claudeqq = asRecord(approval.claudeqq, "claudeqq"); -assertCondition(claudeqq.mutation === false, "ClaudeQQ preview must not send", claudeqq); -assertCondition(claudeqq.sendImplemented === false, "ClaudeQQ send must not be implemented", claudeqq); - -const invalidApproval = dataOf(runCli(["commander", "approval", "request", "--action", "read-token-file", "--dry-run"], 1)); -assertCondition(invalidApproval.error === "validation-failed", "unsupported approval action must fail validation", invalidApproval); - -const secretReasonResult = spawnSync("bun", [ - "scripts/cli.ts", - "commander", - "approval", - "request", - "--action", - "code-queue-backend-restart", - "--reason", - "token=ghp_1234567890abcdef", - "--dry-run", -], { - cwd: process.cwd(), - encoding: "utf8", - maxBuffer: 4 * 1024 * 1024, -}); -assertCondition(secretReasonResult.status === 0, "secret-like approval reason command should still return successfully", { - stdout: secretReasonResult.stdout, - stderr: secretReasonResult.stderr, -}); -assertCondition(!secretReasonResult.stdout.includes("ghp_1234567890abcdef"), "secret-like approval reason must be redacted from stdout", { - stdout: secretReasonResult.stdout, -}); -assertCondition(secretReasonResult.stdout.includes(""), "redacted approval reason should disclose redaction marker", { - stdout: secretReasonResult.stdout, -}); - -const doc = readFileSync("docs/reference/host-codex-commander.md", "utf8"); -for (const snippet of [ - "本地 skeleton 阶段", - "/health", - "/api/commander/contract", - ".state/commander/", - "trace summary dry-run", - "approval draft preview", - "sendImplemented=false", -]) { - assertCondition(doc.includes(snippet), `reference doc missing snippet: ${snippet}`); -} - -process.stdout.write(`${JSON.stringify({ - ok: true, - checks: [ - "commander contract exposes host Codex service boundary and phase-one no-live-operation flags", - "dry-run plan covers process discovery, SSH/PTY/stdio bridge, prompt guidance, trace summary, #20/#46 and ClaudeQQ approval", - "non-dry-run plan is rejected", - "approval request is dry-run only and rejects unsupported high-risk actions", - "secret-like approval reasons are redacted from stdout", - "reference doc states backend restart, task interrupt/cancel, and token-output prohibitions", - ], -}, null, 2)}\n`); diff --git a/scripts/host-codex-commander-no-daemon-smoke-contract-test.ts b/scripts/host-codex-commander-no-daemon-smoke-contract-test.ts deleted file mode 100644 index 34c9e51b..00000000 --- a/scripts/host-codex-commander-no-daemon-smoke-contract-test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { createCommanderRequestHandler, type RuntimeConfig } from "../src/components/microservices/host-codex-commander/src/index"; -import { commanderHealth, summarizeCommanderTrace } from "../src/components/microservices/host-codex-commander/src/state"; - -type JsonRecord = Record; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function asRecord(value: unknown, label: string): JsonRecord { - assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, value); - return value as JsonRecord; -} - -function asRecordArray(value: unknown, label: string): JsonRecord[] { - assertCondition(Array.isArray(value) && value.every((item) => typeof item === "object" && item !== null && !Array.isArray(item)), `${label} must be object array`, value); - return value as JsonRecord[]; -} - -function asStringArray(value: unknown, label: string): string[] { - assertCondition(Array.isArray(value) && value.every((item) => typeof item === "string"), `${label} must be string array`, value); - return value as string[]; -} - -function runCli(args: string[], expectStatus: number): JsonRecord { - const result = spawnSync("bun", ["scripts/cli.ts", ...args], { - cwd: process.cwd(), - encoding: "utf8", - maxBuffer: 4 * 1024 * 1024, - }); - assertCondition(result.status === expectStatus, `status mismatch for ${args.join(" ")}`, { - status: result.status, - stdout: result.stdout.slice(-2000), - stderr: result.stderr.slice(-2000), - }); - assertCondition(result.stdout.trim().length > 0, `command produced no stdout: ${args.join(" ")}`); - return asRecord(JSON.parse(result.stdout) as unknown, "cli envelope"); -} - -function dataOf(envelope: JsonRecord): JsonRecord { - return asRecord(envelope.data, "data"); -} - -async function readJson(response: Response): Promise { - return asRecord(await response.json() as unknown, "response body"); -} - -const sessionId = `no-daemon-smoke-contract-${process.pid}`; -const liveSessionPath = join(process.cwd(), ".state", "commander", "sessions", `${sessionId}.json`); -assertCondition(!existsSync(liveSessionPath), "precondition failed: smoke session path should not already exist", liveSessionPath); - -const smoke = dataOf(runCli(["commander", "smoke", "--dry-run", "--session-id", sessionId], 0)); -assertCondition(smoke.ok === true, "smoke must succeed", smoke); -assertCondition(smoke.phase === "source-contract", "smoke must remain source-contract phase", smoke); -assertCondition(smoke.mode === "dry-run", "smoke must report dry-run mode", smoke); -assertCondition(smoke.mutation === false, "smoke must be non-mutating", smoke); - -const noDaemon = asRecord(smoke.noDaemonSmokeContract, "noDaemonSmokeContract"); -for (const flag of [ - "startsDaemon", - "startsPtyBridge", - "startsStdioBridge", - "opensSshBridge", - "sendsClaudeqq", - "restartsServices", - "interruptsTasks", - "cancelsTasks", - "deploys", - "runsFullCheckOrE2e", -]) { - assertCondition(noDaemon[flag] === false, `${flag} must be false`, noDaemon); -} -assertCondition(asStringArray(noDaemon.allowedCommands, "allowedCommands").includes("bun scripts/host-codex-commander-no-daemon-smoke-contract-test.ts"), "smoke should name this lightweight contract", noDaemon); - -const validationPlan = asRecordArray(smoke.validationPlan, "validationPlan"); -const surfaces = validationPlan.map((item) => item.surface); -for (const expected of [ - "health endpoint", - "state file", - "trace summary dry-run", - "approval draft preview", - "SSH bridge boundary", -]) { - assertCondition(surfaces.includes(expected), `missing validation surface ${expected}`, surfaces); -} -for (const item of validationPlan) { - assertCondition(asStringArray(item.expectedEvidence, "expectedEvidence").length > 0, "each validation item must define evidence", item); - assertCondition(asStringArray(item.noRuntimeSideEffects, "noRuntimeSideEffects").length > 0, "each validation item must define no-side-effect boundary", item); -} - -const smokeWithoutDryRun = dataOf(runCli(["commander", "smoke", "--session-id", sessionId], 1)); -assertCondition(smokeWithoutDryRun.error === "dry-run-required", "smoke must require --dry-run", smokeWithoutDryRun); -assertCondition(!existsSync(liveSessionPath), "smoke CLI must not write live commander state", liveSessionPath); - -const tmp = mkdtempSync(join(tmpdir(), "host-codex-commander-smoke-")); -try { - const runtime: RuntimeConfig = { - rootDir: tmp, - host: "127.0.0.1", - port: 4261, - logFile: join(tmp, "logs", "commander.jsonl"), - serviceId: "host-codex-commander", - stateRoot: tmp, - sessionId, - }; - const health = commanderHealth(runtime, "2026-05-21T00:00:00.000Z"); - assertCondition(health.ok === true && health.service === "host-codex-commander", "health helper must expose service metadata", health); - assertCondition(health.stateRoot === tmp, "health helper must use temp state root", health); - - const handler = createCommanderRequestHandler(runtime); - const healthBody = await readJson(await handler(new Request("http://localhost/health"))); - assertCondition(healthBody.ok === true, "short-lived handler health route must succeed without Bun.serve", healthBody); - - const trace = summarizeCommanderTrace({ - taskId: "task-smoke", - sessionId, - traceJsonl: [ - JSON.stringify({ seq: 1, kind: "message", status: "running", summary: "checking token=ghp_1234567890abcdef" }), - JSON.stringify({ seq: 2, kind: "event", status: "attention_required", text: "needs approval" }), - ].join("\n"), - taskSummary: "summary password=secret", - }); - assertCondition(trace.taskId === "task-smoke", "trace summary must preserve task id", trace); - assertCondition(trace.sessionId === sessionId, "trace summary must preserve session id", trace); - assertCondition(trace.lastSeq === 2, "trace summary must compute last seq", trace); - assertCondition(trace.status === "attention_required", "trace summary must derive attention_required status", trace); - assertCondition(trace.redactionsApplied >= 2, "trace summary must redact mock secrets", trace); -} finally { - rmSync(tmp, { recursive: true, force: true }); -} - -const approval = dataOf(runCli([ - "commander", - "approval", - "request", - "--action", - "code-queue-task-cancel", - "--reason", - "token=ghp_1234567890abcdef", - "--dry-run", -], 0)); -const claudeqq = asRecord(approval.claudeqq, "claudeqq"); -assertCondition(claudeqq.mutation === false, "approval preview must not mutate ClaudeQQ", claudeqq); -assertCondition(claudeqq.sendImplemented === false, "approval preview must not implement sending", claudeqq); -assertCondition(!JSON.stringify(approval).includes("ghp_1234567890abcdef"), "approval preview must redact secret-like reason", approval); - -const doc = readFileSync("docs/reference/host-codex-commander.md", "utf8"); -for (const snippet of [ - "commander smoke --dry-run", - "无 daemon smoke contract", - "health endpoint", - "SSH bridge boundary", -]) { - assertCondition(doc.includes(snippet), `reference doc missing snippet: ${snippet}`); -} - -process.stdout.write(`${JSON.stringify({ - ok: true, - checks: [ - "commander smoke --dry-run is non-mutating and dry-run required", - "no-daemon smoke contract forbids daemon, SSH/PTY/stdio bridge, ClaudeQQ send, restart, interrupt, cancel, deploy, and full e2e", - "health endpoint and trace summary are validated through short-lived source-level helpers", - "approval draft preview remains sendImplemented=false and redacted", - "reference doc describes the dev validation surfaces and no-daemon boundary", - ], -}, null, 2)}\n`); diff --git a/scripts/host-codex-commander-prompt-lint-contract-test.ts b/scripts/host-codex-commander-prompt-lint-contract-test.ts deleted file mode 100644 index c110ee4e..00000000 --- a/scripts/host-codex-commander-prompt-lint-contract-test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { lintCommanderPrompt } from "./src/commander-prompt-lint"; - -type JsonRecord = Record; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function asRecord(value: unknown, label: string): JsonRecord { - assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, value); - return value as JsonRecord; -} - -function asStringArray(value: unknown, label: string): string[] { - assertCondition(Array.isArray(value) && value.every((item) => typeof item === "string"), `${label} must be string array`, value); - return value as string[]; -} - -function runCli(args: string[], stdin?: string): { status: number | null; stdout: string; stderr: string; envelope: JsonRecord } { - const result = spawnSync("bun", ["scripts/cli.ts", ...args], { - cwd: process.cwd(), - input: stdin, - encoding: "utf8", - maxBuffer: 4 * 1024 * 1024, - }); - assertCondition(String(result.stdout || "").trim().length > 0, `command produced no stdout: ${args.join(" ")}`, { - status: result.status, - stderr: String(result.stderr || ""), - }); - return { - status: result.status, - stdout: String(result.stdout || ""), - stderr: String(result.stderr || ""), - envelope: asRecord(JSON.parse(String(result.stdout || "")) as unknown, "cli envelope"), - }; -} - -function dataOf(envelope: JsonRecord): JsonRecord { - return asRecord(envelope.data, "data"); -} - -function assertLegacyFrozenWrite(result: { status: number | null; stdout: string; stderr: string; envelope: JsonRecord }, command: string): void { - assertCondition(result.status !== 0 && result.envelope.ok === false, `${command} should be frozen`, result.envelope); - const data = dataOf(result.envelope); - assertCondition(data.frozen === true, `${command} frozen payload should expose frozen=true`, data); - assertCondition(data.mutation === false, `${command} frozen payload should be non-mutating`, data); - assertCondition(data.degradedReason === "legacy-code-queue-frozen", `${command} should use the legacy frozen reason`, data); - const replacement = asRecord(data.replacement, "replacement"); - assertCondition(String(replacement.queueSubmit || "").includes("agentrun queue submit"), `${command} should point to AgentRun queue submit`, replacement); -} - -const completePrompt = ` -UniDesk#20 #118 / commander prompt boundary lint - -You are a D601 Code Queue GPT-5.5 runner. Work in UniDesk only. - -PR and Git authorization: -- You may create and update a head branch and PR, rebase/update from origin/master, resolve conflicts, and self-merge/close the ordinary PR when checks pass and the task boundary is satisfied. - -Artifact authorization: -- You may use repo-owned CI/CD, publish, or equivalent controlled build paths to build/publish DEV images or artifacts, and must report commit, image tag, digest, artifact report, and validation evidence. - -Rollout boundary: -- DEV deploy apply, rollout, and live health verification are owned by the host commander unless this prompt explicitly contains ROLLOUT_OK. -- Without explicit ROLLOUT_OK, do not acquire the DEV CD lock, run deploy apply, run rollout restart, or compete with host commander live verification. - -Forbidden: -- No PROD mutation, no reading or printing secrets/tokens/credentials, no manual database/DB writes, and no destructive rollback. -`; - -const incompletePromptWithSecret = ` -UniDesk#20 #118 -Task: implement the lint. token=ghp_prompt_lint_contract_secret -Please make code changes and tests. -`; - -const lint = lintCommanderPrompt(completePrompt); -assertCondition(lint.ok === true, "complete GPT-5.5 PR prompt should pass lint", lint); -assertCondition(lint.missingClauses.length === 0, "complete prompt should have no missing clauses", lint); -assertCondition(lint.suggestedPatchSnippet === "", "passing prompt should not include patch snippet", lint); -assertCondition(lint.policy.advisoryOnly === true, "lint must be advisory only", lint); -assertCondition(lint.policy.changesCodexSubmitDefault === false, "lint must not change codex submit default", lint); -assertCondition(JSON.stringify(lint).includes("promptShape"), "lint should expose prompt shape metadata", lint); -assertCondition(!JSON.stringify(lint).includes("self-merge/close the ordinary PR"), "direct lint result must not echo full prompt", lint); - -const failingLint = lintCommanderPrompt(incompletePromptWithSecret); -assertCondition(failingLint.ok === false, "incomplete prompt should fail lint", failingLint); -assertCondition(failingLint.riskLevel === "high", "incomplete prompt should be high risk", failingLint); -for (const expected of [ - "pr-self-merge-rebase-authorization", - "artifact-build-publish-authorization", - "host-owned-dev-rollout", - "runner-rollout-forbidden-without-rollout-ok", - "prod-secret-db-rollback-boundary", -]) { - assertCondition(failingLint.missingClauses.includes(expected), `missing expected clause id ${expected}`, failingLint); -} -assertCondition(failingLint.suggestedPatchSnippet.includes("ROLLOUT_OK"), "snippet should mention ROLLOUT_OK", failingLint); -assertCondition(!JSON.stringify(failingLint).includes("ghp_prompt_lint_contract_secret"), "lint output must not echo secret-like prompt text", failingLint); - -const tmp = mkdtempSync(join(tmpdir(), "host-codex-commander-prompt-lint-")); -try { - const promptFile = join(tmp, "prompt.md"); - writeFileSync(promptFile, incompletePromptWithSecret, "utf8"); - const fileRun = runCli(["commander", "prompt-lint", "--kind", "gpt55-pr", "--prompt-file", promptFile]); - assertCondition(fileRun.status === 0, "commander prompt-lint is advisory and should exit 0 even when lint ok=false", fileRun); - assertCondition(fileRun.envelope.ok === true, "CLI envelope should remain ok for advisory lint result", fileRun.envelope); - const fileData = dataOf(fileRun.envelope); - assertCondition(fileData.ok === false, "lint data ok should reflect missing clauses", fileData); - assertCondition(asStringArray(fileData.missingClauses, "missingClauses").includes("host-owned-dev-rollout"), "file lint should report rollout clause", fileData); - assertCondition(String(fileData.suggestedPatchSnippet || "").includes("DEV deploy apply"), "file lint should include bounded patch snippet", fileData); - assertCondition(!fileRun.stdout.includes("ghp_prompt_lint_contract_secret"), "file lint stdout must not echo prompt secret", fileRun.stdout); - - const stdinRun = runCli(["commander", "prompt-lint", "--kind", "gpt55-pr", "--stdin"], completePrompt); - assertCondition(stdinRun.status === 0 && stdinRun.envelope.ok === true, "stdin lint should exit successfully", stdinRun.envelope); - const stdinData = dataOf(stdinRun.envelope); - assertCondition(stdinData.ok === true, "stdin lint data should pass for complete prompt", stdinData); - assertCondition(asStringArray(stdinData.missingClauses, "missingClauses").length === 0, "stdin lint should have no missing clauses", stdinData); - assertCondition(!stdinRun.stdout.includes("Artifact authorization:"), "stdin lint must not echo full prompt", stdinRun.stdout); -} finally { - rmSync(tmp, { recursive: true, force: true }); -} - -const submitDryRun = runCli(["codex", "submit", "--prompt-stdin", "--queue", "prompt-lint-contract", "--dry-run"], incompletePromptWithSecret); -assertLegacyFrozenWrite(submitDryRun, "codex submit"); -assertCondition(!submitDryRun.stdout.includes("ghp_prompt_lint_contract_secret"), "frozen submit must not echo prompt secret", submitDryRun.stdout); - -const helpRun = runCli(["commander", "--help"]); -assertCondition(helpRun.status === 0 && helpRun.envelope.ok === true, "commander help should succeed", helpRun.envelope); -const helpData = dataOf(helpRun.envelope); -assertCondition(asStringArray(helpData.usage, "help usage").some((line) => line.includes("commander prompt-lint")), "commander help should list prompt-lint", helpData); -assertCondition(asRecord(helpData.promptLint, "promptLint").gate === "advisory-only; not a business PR gate and not a Code Queue submit admission change", "help should document advisory-only gate", helpData); - -const doc = readFileSync("docs/reference/host-codex-commander.md", "utf8"); -for (const snippet of [ - "commander prompt-lint --kind gpt55-pr", - "missingClauses", - "suggestedPatchSnippet", - "不是业务 PR 门禁", -]) { - assertCondition(doc.includes(snippet), `reference doc missing snippet: ${snippet}`); -} - -process.stdout.write(`${JSON.stringify({ - ok: true, - checks: [ - "complete GPT-5.5 PR prompt passes commander prompt-lint", - "missing PR/artifact/DEV rollout/ROLLOUT_OK/PROD-secret-DB-rollback clauses are reported as high risk", - "prompt-lint supports --prompt-file and --stdin", - "prompt-lint output does not echo full prompt or secret-like prompt text", - "commander prompt-lint remains advisory while legacy codex submit stays frozen", - "commander help and host commander reference document the advisory lint entry", - ], -}, null, 2)}\n`); diff --git a/scripts/host-codex-commander-skeleton-contract-test.ts b/scripts/host-codex-commander-skeleton-contract-test.ts deleted file mode 100644 index 9083fb4c..00000000 --- a/scripts/host-codex-commander-skeleton-contract-test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { commanderContract } from "../src/components/microservices/host-codex-commander/src/contract"; -import { createCommanderRequestHandler, type RuntimeConfig } from "../src/components/microservices/host-codex-commander/src/index"; -import { - buildCommanderApprovalDraft, - commanderApprovalPreview, - commanderHealth, - commanderSessionPreview, - commanderStatePaths, - listCommanderSessions, - readCommanderApproval, - readCommanderSession, - summarizeCommanderTrace, - writeCommanderApproval, - writeCommanderSession, -} from "../src/components/microservices/host-codex-commander/src/state"; - -type JsonRecord = Record; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function asRecord(value: unknown, label: string): JsonRecord { - assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, value); - return value as JsonRecord; -} - -function dataOf(response: JsonRecord): JsonRecord { - return asRecord(response.body, "body"); -} - -async function readJson(response: Response): Promise { - const body = await response.json(); - return asRecord(body, "response body"); -} - -const tmp = mkdtempSync(join(tmpdir(), "host-codex-commander-")); -const runtime: RuntimeConfig = { - rootDir: tmp, - host: "127.0.0.1", - port: 4261, - logFile: join(tmp, "logs", "commander.jsonl"), - serviceId: "host-codex-commander", - stateRoot: tmp, - sessionId: "primary", -}; -const handler = createCommanderRequestHandler(runtime); - -try { - const contract = commanderContract(); - assertCondition(contract.ok === true, "contract must be ok", contract); - assertCondition(contract.serviceId === "host-codex-commander", "contract must expose service id", contract); - assertCondition(contract.daemonImplemented === false, "contract must remain skeleton only", contract); - assertCondition(contract.currentImplementation === "host-codex-commander-skeleton", "contract must identify skeleton implementation", contract); - - const session = writeCommanderSession(runtime, { - sessionId: "primary", - state: "running", - promptState: "planned", - approvalState: "draft", - pid: 321, - cwd: "/workspace/unidesk", - lastSeq: 7, - heartbeatAt: "2026-05-21T00:00:00.000Z", - updatedAt: "2026-05-21T00:00:00.000Z", - notes: ["token=ghp_abcdef1234567890", "trace-summary:running"], - }); - assertCondition(session.notes[0] === "", "session notes must be redacted", session); - assertCondition(readCommanderSession(runtime, "primary").state === "running", "session read must round-trip", readCommanderSession(runtime, "primary")); - assertCondition(listCommanderSessions(runtime).length >= 1, "session listing must include stored session", listCommanderSessions(runtime)); - const sessionPreview = asRecord(commanderSessionPreview(session), "session preview"); - const sessionPreviewNotes = Array.isArray(sessionPreview.notes) ? sessionPreview.notes.map((item) => String(item)) : []; - assertCondition(sessionPreviewNotes.includes(""), "session preview must redact notes", sessionPreview); - - const trace = summarizeCommanderTrace({ - taskId: "task-123", - sessionId: "primary", - traceJsonl: [ - JSON.stringify({ seq: 1, kind: "message", status: "running", summary: "prompt token=ghp_1234567890abcdef" }), - JSON.stringify({ seq: 4, kind: "command", status: "attention_required", command: "ask-for-approval", output: "reason=https://user:secret@example.com" }), - JSON.stringify({ seq: 8, kind: "event", status: "completed", text: "done" }), - ].join("\n"), - taskSummary: "task summary token=ghp_aaaaaaaaaaaaaaaa", - }); - assertCondition(trace.taskId === "task-123", "trace summary must preserve task id", trace); - assertCondition(trace.lastSeq === 8, "trace summary must preserve last seq", trace); - assertCondition(trace.status === "terminal", "trace summary should reach terminal status", trace); - assertCondition(trace.redactionsApplied >= 2, "trace summary must redact secrets", trace); - assertCondition(trace.taskSummaryPreview?.includes("") === true, "task summary preview must redact", trace); - assertCondition(trace.keyEvents.length > 0, "trace summary must include key events", trace); - - const approval = buildCommanderApprovalDraft({ - approvalId: "draft-1", - action: "code-queue-task-interrupt", - taskId: "task-123", - reason: "token=ghp_1234567890abcdef https://user:secret@example.com", - sessionId: "primary", - }); - assertCondition(approval.reason.includes(""), "approval reason must redact", approval); - assertCondition(approval.previewMarkdown.includes(""), "approval preview must redact", approval); - assertCondition(approval.previewJson["sendImplemented"] === false, "approval preview must not imply sending", approval.previewJson); - writeCommanderApproval(runtime, approval); - assertCondition(readCommanderApproval(runtime, "draft-1").reason.includes(""), "approval round-trip must preserve redaction", readCommanderApproval(runtime, "draft-1")); - - const health = commanderHealth(runtime, "2026-05-21T00:00:00.000Z"); - assertCondition(health.ok === true && health.service === "host-codex-commander", "health must expose service metadata", health); - assertCondition(health.stateRoot === tmp, "health must point at temp state root", health); - - const healthBody = await readJson(await handler(new Request("http://localhost/health"))); - assertCondition(healthBody.ok === true, "health route must succeed", healthBody); - - const contractBody = await readJson(await handler(new Request("http://localhost/api/commander/contract"))); - assertCondition(contractBody.serviceId === "host-codex-commander", "HTTP contract route must expose service id", contractBody); - - const sessionsBody = await readJson(await handler(new Request("http://localhost/api/commander/sessions"))); - assertCondition(Array.isArray(sessionsBody.sessions) && sessionsBody.sessions.length >= 1, "sessions route must list sessions", sessionsBody); - - const traceBody = await readJson(await handler(new Request(`http://localhost/api/commander/trace-summary?taskId=task-123&traceJsonl=${encodeURIComponent(JSON.stringify({ seq: 1, status: "running", summary: "hello token=ghp_1234567890abcdef" }))}`))); - assertCondition(traceBody.ok === true && Number(asRecord(traceBody.summary, "summary").redactionsApplied) >= 1, "trace route must redact and summarize", traceBody); - - const approvalBody = await readJson(await handler(new Request("http://localhost/api/commander/approvals", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ action: "code-queue-task-cancel", reason: "cookie=session=secret", taskId: "task-123" }), - }))); - assertCondition(approvalBody.ok === true, "approval route must succeed", approvalBody); - assertCondition(String(JSON.stringify(approvalBody)).includes(""), "approval route must redact sensitive text", approvalBody); - - const statePath = commanderStatePaths(runtime); - assertCondition(readFileSync(statePath.stateFile, "utf8").length > 0, "state file must be written", statePath); - assertCondition(readFileSync(statePath.approvalFile, "utf8").length > 0, "approval file must be written", statePath); - - process.stdout.write(`${JSON.stringify({ - ok: true, - checks: [ - "commander contract exposes skeleton contract boundaries", - "state files round-trip and redact secrets", - "trace summary aggregates mock jsonl input", - "approval draft preview stays preview-only and redacted", - "HTTP handler serves /health, /api/commander/contract, /api/commander/sessions, /api/commander/trace-summary, and /api/commander/approvals", - ], - }, null, 2)}\n`); -} finally { - rmSync(tmp, { recursive: true, force: true }); -} diff --git a/scripts/hwlab-cd-wrapper-contract-test.ts b/scripts/hwlab-cd-wrapper-contract-test.ts deleted file mode 100644 index 69c30be9..00000000 --- a/scripts/hwlab-cd-wrapper-contract-test.ts +++ /dev/null @@ -1,437 +0,0 @@ -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { buildHwlabCdRemoteCommandForTest } from "./src/hwlab-cd"; - -type JsonRecord = Record; - -function dataOf(payload: JsonRecord): JsonRecord { - return payload.data as JsonRecord; -} - -function runCli(args: string[], env: NodeJS.ProcessEnv = {}): JsonRecord { - const result = spawnSync("bun", ["scripts/cli.ts", ...args], { - cwd: process.cwd(), - env: { - ...process.env, - UNIDESK_HWLAB_CD_TRANSPORT: "local", - ...env, - }, - encoding: "utf8", - timeout: 25_000, - }); - assert.equal(result.stderr, "", `stderr should be empty: ${result.stderr}`); - assert.notEqual(result.stdout.trim(), "", "CLI must not produce empty output"); - const parsed = JSON.parse(result.stdout) as JsonRecord; - if (result.status !== 0) assert.equal(parsed.ok, false, `nonzero CLI should return ok=false: ${result.stdout}`); - return parsed; -} - -function writeJson(path: string, value: unknown): void { - writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); -} - -function makeFakeHwlabRepo(): string { - const root = join(tmpdir(), `unidesk-hwlab-cd-wrapper-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`); - mkdirSync(join(root, "scripts"), { recursive: true }); - mkdirSync(join(root, "deploy/k8s/base"), { recursive: true }); - mkdirSync(join(root, "reports/dev-gate"), { recursive: true }); - writeFileSync(join(root, "scripts/dev-cd-apply.mjs"), [ - "const kubeconfigIndex = process.argv.indexOf('--kubeconfig');", - "if (process.argv.includes('--apply') || process.argv.includes('--write-report') || process.argv.includes('--confirm-dev')) { throw new Error('mutation flag must not be used in wrapper tests'); }", - "process.stdout.write(JSON.stringify({", - " ok: true,", - " status: 'pass',", - " mode: process.argv.includes('--dry-run') ? 'dry-run' : 'status',", - " command: 'dev-cd-apply',", - " mutationAttempted: false,", - " prodTouched: false,", - " target: {", - " ref: 'origin/main',", - " promotionCommit: 'abc1234567890abcdef',", - " shortCommitId: 'abc1234',", - " promotionSource: 'deploy/deploy.json',", - " publishRequired: false,", - " headCommitId: 'abc1234567890abcdef',", - " headMatchesTarget: true,", - " desiredStateCheck: { status: 'pass', summary: { desiredCommitId: 'abc1234', targetConvergence: 'already_promoted' } },", - " artifactBoundary: { status: 'pass', desiredState: { deployCommitId: 'abc1234', catalogCommitId: 'abc1234', deployCommitMatches: true, catalogCommitMatches: true } },", - " namespace: 'hwlab-dev'", - " },", - " deployJson: { path: 'deploy/deploy.json', commitId: 'abc1234', matchesTarget: true },", - " artifactCatalog: { path: 'deploy/artifact-catalog.dev.json', commitId: 'abc1234', artifactState: 'published', ciPublished: true, registryVerified: true },", - " artifactReport: { path: 'reports/dev-gate/dev-artifacts.json', commitId: 'abc1234' },", - " lock: { status: 'absent' },", - " liveDelta: { status: 'not_run', reason: '--skip-live-verify' },", - " kubeconfig: kubeconfigIndex >= 0 ? process.argv[kubeconfigIndex + 1] : null", - "}, null, 2));", - ].join("\n")); - writeJson(join(root, "deploy/deploy.json"), { commitId: "abc1234", environment: "dev", namespace: "hwlab-dev", endpoint: "http://74.48.78.17:16667" }); - writeJson(join(root, "deploy/artifact-catalog.dev.json"), { commitId: "abc1234", artifactState: "published", publish: { ciPublished: true, registryVerified: true }, services: [{ id: "hwlab-cloud-api", digest: "sha256:" + "a".repeat(64) }] }); - writeJson(join(root, "reports/dev-gate/dev-artifacts.json"), { commitId: "abc1234", artifactPublish: { sourceCommitId: "abc1234", serviceCount: 1, ciPublished: true, registryVerified: true } }); - writeFileSync(join(root, "deploy/k8s/base/workloads.yaml"), "apiVersion: v1\nkind: List\nitems: []\n"); - spawnSync("git", ["init", "-b", "main"], { cwd: root, encoding: "utf8" }); - spawnSync("git", ["config", "user.email", "test@example.invalid"], { cwd: root, encoding: "utf8" }); - spawnSync("git", ["config", "user.name", "HWLAB CD Test"], { cwd: root, encoding: "utf8" }); - spawnSync("git", ["remote", "add", "origin", "git@github.com:pikasTech/HWLAB.git"], { cwd: root, encoding: "utf8" }); - spawnSync("git", ["add", "."], { cwd: root, encoding: "utf8" }); - spawnSync("git", ["commit", "-m", "fixture"], { cwd: root, encoding: "utf8" }); - spawnSync("git", ["update-ref", "refs/remotes/origin/main", "HEAD"], { cwd: root, encoding: "utf8" }); - writeFileSync(join(root, ".git", "FETCH_HEAD"), "fixture\n"); - return root; -} - -function makeFakeBin(mode: "native" | "desktop" | "stale-default" | "wrong-node" | "missing-secret" | "second-plane"): string { - const bin = join(tmpdir(), `unidesk-hwlab-cd-bin-${process.pid}-${Date.now()}-${mode}-${Math.random().toString(16).slice(2)}`); - mkdirSync(bin, { recursive: true }); - const deploymentJson = { - items: [ - { - metadata: { - name: "hwlab-cloud-api", - labels: { "app.kubernetes.io/name": "hwlab-cloud-api", "hwlab.pikastech.local/service-id": "hwlab-cloud-api" }, - annotations: { "deployment.kubernetes.io/revision": "7" }, - }, - spec: { - replicas: 1, - template: { spec: { containers: [{ name: "hwlab-cloud-api", image: "127.0.0.1:5000/hwlab/hwlab-cloud-api:abc1234", env: [{ name: "HWLAB_COMMIT_ID", value: "abc1234" }] }] } }, - }, - status: { availableReplicas: 1, updatedReplicas: 1, unavailableReplicas: 0, conditions: [{ type: "Available", status: "True" }, { type: "Progressing", status: "True", reason: "NewReplicaSetAvailable" }] }, - }, - { - metadata: { - name: "hwlab-cloud-web", - labels: { "app.kubernetes.io/name": "hwlab-cloud-web", "hwlab.pikastech.local/service-id": "hwlab-cloud-web" }, - annotations: { "deployment.kubernetes.io/revision": "8" }, - }, - spec: { - replicas: 1, - template: { spec: { containers: [{ name: "hwlab-cloud-web", image: "127.0.0.1:5000/hwlab/hwlab-cloud-web:abc1234", env: [{ name: "HWLAB_COMMIT_ID", value: "abc1234" }] }] } }, - }, - status: { availableReplicas: 1, updatedReplicas: 1, unavailableReplicas: 0, conditions: [{ type: "Available", status: "True" }, { type: "Progressing", status: "True" }] }, - }, - ], - }; - const podsJson = { - items: [ - { - metadata: { name: "hwlab-cloud-api-abc" }, - status: { containerStatuses: [{ name: "hwlab-cloud-api", image: "127.0.0.1:5000/hwlab/hwlab-cloud-api:abc1234", imageID: "docker-pullable://127.0.0.1:5000/hwlab/hwlab-cloud-api@sha256:" + "b".repeat(64), state: { running: {} } }] }, - }, - ], - }; - const jobsJson = { items: [{ metadata: { name: "hwlab-runtime-provision-old" }, status: { succeeded: 1, failed: 0, active: 0, completionTime: "2026-05-24T00:00:00Z" } }] }; - const explicitContext = mode === "desktop" ? "docker-desktop" : "default"; - const explicitServer = mode === "desktop" ? "https://127.0.0.1:11700" : "https://127.0.0.1:6443"; - const explicitNodes = mode === "desktop" ? "desktop-control-plane" : mode === "wrong-node" ? "d602" : "d601"; - const defaultContext = mode === "stale-default" ? "docker-desktop" : mode === "second-plane" ? "other-k3s" : explicitContext; - const defaultServer = mode === "stale-default" ? "https://127.0.0.1:11700" : mode === "second-plane" ? "https://10.0.0.2:6443" : explicitServer; - const defaultNodes = mode === "stale-default" ? "desktop-control-plane" : explicitNodes; - const defaultNamespaceOk = mode === "second-plane"; - writeFileSync(join(bin, "kubectl"), [ - "#!/usr/bin/env bash", - "set -euo pipefail", - "context=" + JSON.stringify(explicitContext), - "server=" + JSON.stringify(explicitServer), - "nodes=" + JSON.stringify(explicitNodes), - "if [[ \"${KUBECONFIG:-}\" == '' ]]; then", - " context=" + JSON.stringify(defaultContext), - " server=" + JSON.stringify(defaultServer), - " nodes=" + JSON.stringify(defaultNodes), - "fi", - "if [[ \"$*\" == 'config current-context' ]]; then printf '%s\\n' \"$context\"; exit 0; fi", - "if [[ \"$*\" == 'config view --minify -o jsonpath={.clusters[0].cluster.server}' ]]; then printf '%s' \"$server\"; exit 0; fi", - "if [[ \"$*\" == 'get nodes -o jsonpath={range .items[*]}{.metadata.name}{\"\\n\"}{end}' ]]; then printf '%s\\n' \"$nodes\"; exit 0; fi", - "if [[ \"$*\" == 'get namespace hwlab-dev -o name' ]]; then", - " if [[ \"${KUBECONFIG:-}\" == '' && " + JSON.stringify(defaultNamespaceOk ? "yes" : "no") + " == 'yes' ]]; then printf 'namespace/hwlab-dev\\n'; exit 0; fi", - " if [[ \"${KUBECONFIG:-}\" != '' ]]; then printf 'namespace/hwlab-dev\\n'; exit 0; fi", - " printf 'Error from server (NotFound): namespaces \"hwlab-dev\" not found\\n' >&2; exit 1", - "fi", - "if [[ \"$*\" =~ ^-n[[:space:]]+hwlab-dev[[:space:]]+get[[:space:]]+lease[[:space:]]+hwlab-dev-cd-lock[[:space:]]+-o[[:space:]]+json$ ]]; then printf 'Error from server (NotFound): leases.coordination.k8s.io \"hwlab-dev-cd-lock\" not found\\n' >&2; exit 1; fi", - "if [[ \"$*\" == '-n hwlab-dev get deployments -o json' ]]; then printf '%s\\n' " + JSON.stringify(JSON.stringify(deploymentJson)) + "; exit 0; fi", - "if [[ \"$*\" == '-n hwlab-dev get pods -o json' ]]; then printf '%s\\n' " + JSON.stringify(JSON.stringify(podsJson)) + "; exit 0; fi", - "if [[ \"$*\" == '-n hwlab-dev get jobs -o json' ]]; then printf '%s\\n' " + JSON.stringify(JSON.stringify(jobsJson)) + "; exit 0; fi", - "if [[ \"$*\" == '-n hwlab-dev get secret hwlab-code-agent-provider -o name' && " + JSON.stringify(mode) + " == 'missing-secret' ]]; then printf 'Error from server (NotFound): secrets \"hwlab-code-agent-provider\" not found\\n' >&2; exit 1; fi", - "if [[ \"$*\" =~ ^-n\\ hwlab-dev\\ get\\ secret\\ ([^[:space:]]+)\\ -o\\ name$ ]]; then printf 'secret/%s\\n' \"${BASH_REMATCH[1]}\"; exit 0; fi", - "if [[ \"$*\" == '-n hwlab-dev describe secret hwlab-cloud-api-dev-db' ]]; then printf 'Name: hwlab-cloud-api-dev-db\\nData\\n====\\ndatabase-url: 48 bytes\\n'; exit 0; fi", - "if [[ \"$*\" == '-n hwlab-dev describe secret hwlab-cloud-api-dev-db-admin' ]]; then printf 'Name: hwlab-cloud-api-dev-db-admin\\nData\\n====\\nadmin-url: 48 bytes\\n'; exit 0; fi", - "if [[ \"$*\" == '-n hwlab-dev describe secret hwlab-code-agent-provider' ]]; then printf 'Name: hwlab-code-agent-provider\\nData\\n====\\nopenai-api-key: 48 bytes\\n'; exit 0; fi", - "printf 'unexpected kubectl args: %s\\n' \"$*\" >&2; exit 99", - ].join("\n")); - spawnSync("chmod", ["+x", join(bin, "kubectl")]); - writeFileSync(join(bin, "curl"), [ - "#!/usr/bin/env bash", - "set -euo pipefail", - "url=\"${@: -1}\"", - "if [[ \"$*\" == *'http://127.0.0.1:5000/v2/'* ]]; then printf '{}\\n'; exit 0; fi", - "if [[ \"$url\" == 'http://74.48.78.17:16666/health/live' ]]; then printf '%s\\n' " + JSON.stringify(JSON.stringify({ - serviceId: "hwlab-cloud-web", - environment: "dev", - status: "ready", - ready: true, - commit: { id: "abc1234" }, - image: { reference: "127.0.0.1:5000/hwlab/hwlab-cloud-web:abc1234", tag: "abc1234" }, - blockerCodes: [], - })) + "; exit 0; fi", - "if [[ \"$url\" == 'http://74.48.78.17:16667/health/live' ]]; then printf '%s\\n' " + JSON.stringify(JSON.stringify({ - serviceId: "hwlab-cloud-api", - environment: "dev", - status: "ready", - ready: true, - commit: { id: "abc1234" }, - image: { reference: "127.0.0.1:5000/hwlab/hwlab-cloud-api:abc1234", tag: "abc1234" }, - db: { ready: true, connected: true, liveDbEvidence: true, runtimeReadiness: { status: "ready", ready: true, blocker: null } }, - runtime: { status: "ready", ready: true, durable: true, durableRequested: true, liveRuntimeEvidence: true, blocker: null, adapter: "postgres" }, - blockerCodes: [], - })) + "; exit 0; fi", - "printf 'unexpected curl args: %s\\n' \"$*\" >&2; exit 22", - ].join("\n")); - spawnSync("chmod", ["+x", join(bin, "curl")]); - return bin; -} - -function scopes(data: JsonRecord): string[] { - return ((data.blockers ?? []) as JsonRecord[]).map((blocker) => String(blocker.scope ?? "")); -} - -function withLocalTransport(args: string[]): string[] { - return [...args, "--transport", "local"]; -} - -const fakeRepo = makeFakeHwlabRepo(); -const nativeBin = makeFakeBin("native"); -const desktopBin = makeFakeBin("desktop"); -const staleDefaultBin = makeFakeBin("stale-default"); -const wrongNodeBin = makeFakeBin("wrong-node"); -const missingSecretBin = makeFakeBin("missing-secret"); -const secondPlaneBin = makeFakeBin("second-plane"); - -const help = runCli(["hwlab", "help"]); -assert.equal(help.ok, true); -assert.equal((help.data as JsonRecord).command, "hwlab cd"); -assert.equal(((help.data as JsonRecord).usage as string[]).includes("bun scripts/cli.ts hwlab cd audit --env dev"), true); - -const remoteCommand = buildHwlabCdRemoteCommandForTest(withLocalTransport(["cd", "apply", "--env", "dev", "--dry-run"])); -assert.equal(remoteCommand.includes("scripts/dev-cd-apply.mjs"), true); -assert.equal(remoteCommand.includes("/etc/rancher/k3s/k3s.yaml"), true); -assert.equal(remoteCommand.includes("kubectl rollout"), false); -assert.equal(remoteCommand.includes("kubectl apply"), false); -assert.equal(remoteCommand.includes("break-stale-lock"), false); - -const realApply = runCli(["hwlab", "cd", "apply", "--env", "dev", "--hwlab-repo", fakeRepo], { - PATH: `${nativeBin}:${process.env.PATH ?? ""}`, -}); -assert.equal(realApply.ok, false); -assert.equal(dataOf(realApply).error, "host-commander-only-real-apply"); -assert.equal((dataOf(realApply).safety as JsonRecord).rolloutExecuted, false); - -const runnerHistoryRepo = runCli(withLocalTransport([ - "hwlab", - "cd", - "status", - "--env", - "dev", - "--hwlab-repo", - "/home/ubuntu/hwlab", -]), { - PATH: `${nativeBin}:${process.env.PATH ?? ""}`, -}); -assert.equal(runnerHistoryRepo.ok, false); -assert.equal((dataOf(runnerHistoryRepo).repo as JsonRecord).rejected, true); - -const applyDryRun = runCli(withLocalTransport([ - "hwlab", - "cd", - "apply", - "--env", - "dev", - "--dry-run", - "--hwlab-repo", - fakeRepo, -]), { - PATH: `${nativeBin}:${process.env.PATH ?? ""}`, -}); -assert.equal(applyDryRun.ok, true); -const dryRunData = dataOf(applyDryRun); -assert.equal(dryRunData.dryRun, true); -assert.equal(dryRunData.mutation, false); -assert.equal((dryRunData.safety as JsonRecord).kubectlApplyExecuted, false); -assert.equal((dryRunData.safety as JsonRecord).rolloutExecuted, false); -assert.equal((dryRunData.safety as JsonRecord).liveVerifyExecuted, false); -assert.equal((dryRunData.safety as JsonRecord).cdLockMutated, false); -assert.equal((dryRunData.safety as JsonRecord).secretValuesPrinted, false); -assert.equal((dryRunData.remote as JsonRecord).providerId, "D601"); -assert.equal(((dryRunData.remote as JsonRecord).commandCalls as unknown[]).includes("scripts/dev-cd-apply.mjs"), true); -assert.equal((dryRunData.kubeconfig as JsonRecord).path, "/etc/rancher/k3s/k3s.yaml"); -assert.equal((dryRunData.nodeGuard as JsonRecord).requiredNodePresent, true); -assert.equal((dryRunData.worktreeGuard as JsonRecord).clean, true); -assert.equal((dryRunData.secretPreflight as JsonRecord).status, "pass"); -assert.equal((dryRunData.controlledDevCd as JsonRecord).controlledEntrypoint, "scripts/dev-cd-apply.mjs"); -assert.equal(((dryRunData.target as JsonRecord).promotionCommit), "abc1234567890abcdef"); -assert.equal(((dryRunData.promotion as JsonRecord).source), "deploy/deploy.json"); -assert.equal(JSON.stringify(dryRunData).includes("sk-secret"), false); - -const reportsBefore = existsSync(join(fakeRepo, "reports")) ? JSON.stringify(readdirSync(join(fakeRepo, "reports"), { recursive: true })) : ""; -const audit = runCli(withLocalTransport([ - "hwlab", - "cd", - "audit", - "--env", - "dev", - "--hwlab-repo", - fakeRepo, -]), { - PATH: `${nativeBin}:${process.env.PATH ?? ""}`, -}); -assert.equal(audit.ok, true); -const auditData = dataOf(audit); -const auditSummary = auditData.audit as JsonRecord; -assert.equal(auditSummary.status, "pass"); -assert.equal((auditSummary.namespace), "hwlab-dev"); -assert.equal(((auditSummary.nodeGuard as JsonRecord).nodeNames as unknown[]).includes("d601"), true); -assert.equal(((auditSummary.secrets as JsonRecord).valuesRead), false); -assert.equal(((auditSummary.secrets as JsonRecord).valuesPrinted), false); -assert.equal(JSON.stringify(auditSummary).includes("48 bytes"), false); -assert.equal(((auditSummary.registry as JsonRecord).status), "pass"); -assert.equal(((auditSummary.lease as JsonRecord).staleClassification), "not-held"); -assert.equal((((auditSummary.desiredState as JsonRecord).imageConvergence as JsonRecord).status), "pass"); -assert.equal(((auditSummary.workload as JsonRecord).status), "healthy"); -assert.equal(((auditSummary.publicHealth as JsonRecord).status), "pass"); -assert.equal(((auditSummary.durability as JsonRecord).status), "pass"); -assert.deepEqual((auditSummary.blockerTypes as unknown[]), []); -assert.equal(((auditSummary.safety as JsonRecord).reportsWritten), false); -assert.equal(((auditSummary.safety as JsonRecord).cdLockMutated), false); -assert.equal((auditData.remote as JsonRecord).providerId, "D601"); -assert.equal(JSON.stringify(auditData).length < 80_000, true); -assert.equal(JSON.stringify(auditData).includes("kubectl apply"), false); -assert.equal(JSON.stringify(auditData).includes("kubectl rollout"), false); -assert.equal(JSON.stringify(auditData).includes("--apply"), false); -assert.equal(JSON.stringify(auditData).includes("--write-report"), false); -assert.equal(JSON.stringify(auditData).includes("sk-secret"), false); -const reportsAfter = existsSync(join(fakeRepo, "reports")) ? JSON.stringify(readdirSync(join(fakeRepo, "reports"), { recursive: true })) : ""; -assert.equal(reportsAfter, reportsBefore); - -const status = runCli(withLocalTransport([ - "hwlab", - "cd", - "status", - "--env", - "dev", - "--hwlab-repo", - fakeRepo, -]), { - PATH: `${nativeBin}:${process.env.PATH ?? ""}`, -}); -assert.equal(status.ok, true); -const statusData = dataOf(status); -assert.equal((statusData.secretPreflight as JsonRecord).status, "skipped"); -assert.equal((statusData.lockState as JsonRecord).status, "absent"); -assert.ok(typeof statusData.reportDumpPath === "string"); - -const staleDefaultOk = runCli(withLocalTransport([ - "hwlab", - "cd", - "apply", - "--env", - "dev", - "--dry-run", - "--hwlab-repo", - fakeRepo, -]), { - PATH: `${staleDefaultBin}:${process.env.PATH ?? ""}`, - KUBECONFIG: "", -}); -assert.equal(staleDefaultOk.ok, true); -const staleDefaultGuard = dataOf(staleDefaultOk).nodeGuard as JsonRecord; -assert.equal(staleDefaultGuard.status, "pass"); -assert.equal(staleDefaultGuard.refusal, false); -assert.equal((staleDefaultGuard.defaultKubectlDiagnostic as JsonRecord).status, "stale-forbidden-default"); - -const desktopRefusal = runCli(withLocalTransport([ - "hwlab", - "cd", - "audit", - "--env", - "dev", - "--hwlab-repo", - fakeRepo, -]), { - PATH: `${desktopBin}:${process.env.PATH ?? ""}`, -}); -assert.equal(desktopRefusal.ok, false); -const desktopData = dataOf(desktopRefusal); -assert.equal(desktopData.status, "refused"); -assert.deepEqual((((desktopData.audit as JsonRecord).nodeGuard as JsonRecord).refusalSignals), ["docker-desktop", "desktop-control-plane", "127.0.0.1:11700"]); -assert.equal(((desktopData.blockerTypes as unknown[]).includes("docker-desktop-context-risk")), true); - -const wrongNodeBlocked = runCli(withLocalTransport([ - "hwlab", - "cd", - "apply", - "--env", - "dev", - "--dry-run", - "--hwlab-repo", - fakeRepo, -]), { - PATH: `${wrongNodeBin}:${process.env.PATH ?? ""}`, -}); -assert.equal(wrongNodeBlocked.ok, false); -const wrongNodeData = dataOf(wrongNodeBlocked); -assert.equal((wrongNodeData.nodeGuard as JsonRecord).requiredNodePresent, false); -assert.equal(scopes(wrongNodeData).includes("d601-native-k3s-guard"), true); - -const missingSecretBlocked = runCli(withLocalTransport([ - "hwlab", - "cd", - "apply", - "--env", - "dev", - "--dry-run", - "--hwlab-repo", - fakeRepo, -]), { - PATH: `${missingSecretBin}:${process.env.PATH ?? ""}`, -}); -assert.equal(missingSecretBlocked.ok, false); -const missingSecretData = dataOf(missingSecretBlocked); -assert.equal((missingSecretData.secretPreflight as JsonRecord).status, "blocked"); -assert.equal((missingSecretData.controlledDevCd as JsonRecord).status, "skipped"); -assert.equal(scopes(missingSecretData).includes("secretref:hwlab-code-agent-provider/openai-api-key"), true); -assert.equal(JSON.stringify(missingSecretData).includes("sk-secret"), false); - -const secondPlaneBlocked = runCli(withLocalTransport([ - "hwlab", - "cd", - "apply", - "--env", - "dev", - "--dry-run", - "--hwlab-repo", - fakeRepo, -]), { - PATH: `${secondPlaneBin}:${process.env.PATH ?? ""}`, -}); -assert.equal(secondPlaneBlocked.ok, false); -assert.equal(((dataOf(secondPlaneBlocked).nodeGuard as JsonRecord).defaultKubectlDiagnostic as JsonRecord).secondControlPlaneRisk, true); -assert.equal(scopes(dataOf(secondPlaneBlocked)).includes("second-hwlab-dev-control-plane"), true); - -writeFileSync(join(fakeRepo, "dirty.txt"), "dirty\n"); -const dirtyBlocked = runCli(withLocalTransport([ - "hwlab", - "cd", - "apply", - "--env", - "dev", - "--dry-run", - "--hwlab-repo", - fakeRepo, -]), { - PATH: `${nativeBin}:${process.env.PATH ?? ""}`, -}); -assert.equal(dirtyBlocked.ok, false); -assert.equal(scopes(dataOf(dirtyBlocked)).includes("hwlab-git-clean"), true); - -console.log(JSON.stringify({ ok: true, checked: "hwlab-cd-wrapper-contract" })); diff --git a/scripts/hwlab-g14-contract-test.ts b/scripts/hwlab-g14-contract-test.ts deleted file mode 100644 index ef188d46..00000000 --- a/scripts/hwlab-g14-contract-test.ts +++ /dev/null @@ -1,1149 +0,0 @@ -import { activeV02PipelineRuns, cleanupPipelineRunTargetCandidateFromTextForTest, g14ObservabilityQueryAssertion, gitMirrorFlushJobManifest, gitMirrorStatusSummary, gitMirrorSyncJobManifest, gitMirrorV02SyncRequirement, hwlabG14Help, hwlabG14MonitorStateFileName, parseGitMirrorStatusRefs, parseK8sCpuMillicores, parseK8sMemoryMiB, parsePipelineTaskRunMetrics, parseV02TriggerSnapshot, rolloutRecordBody, runtimeLaneGitMirrorSourceInSyncForTest, runtimeLanePipelineRunManifest, semanticChangelogBullets, summarizeV02CdStatus, v02CloseoutVerdict, v02CommitAlignment, v02ControlPlaneRefreshScriptHash, v02ControlPlaneRenderScript, v02ExistingPipelineRunReuseDecision, v02FalseGreenGuard, v02GitMirrorPreSyncWaitMs, v02LatestOnlyTargetValidation, v02PipelineServiceIds, v02PrAutomationCommentBody, v02ReusableGitMirrorPreSyncMarker, v02ReusableRefreshMarker, v02StatusHistoryPolicy, v02TaskRunPerformanceSummary } from "./src/hwlab-g14"; -import { hwlabNodeHelp, nodeSecretStatusFromTextForTest } from "./src/hwlab-node"; -import { hwlabRequiredNoProxyEntries, hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneIds, hwlabRuntimeLaneSpec } from "./src/hwlab-node-lanes"; -import { runCommand } from "./src/command"; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function record(value: unknown): Record { - return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; -} - -assertCondition( - hwlabG14MonitorStateFileName({ once: false, dryRun: false }) === "latest-monitor-job.json", - "long-running monitor should own latest-monitor-job.json", -); -assertCondition( - hwlabG14MonitorStateFileName({ once: true, dryRun: false }) === "latest-once-job.json", - "once jobs must not overwrite the long-running monitor pointer", -); -assertCondition( - hwlabG14MonitorStateFileName({ once: false, dryRun: true }) === "latest-dry-run-job.json", - "dry-run monitor jobs must not overwrite the live monitor pointer", -); -assertCondition( - hwlabG14MonitorStateFileName({ once: true, dryRun: true }) === "latest-once-dry-run-job.json", - "once dry-runs need a distinct pointer for low-noise diagnostics", -); -assertCondition( - hwlabG14MonitorStateFileName({ lane: "v02", once: false, dryRun: false }) === "latest-v02-monitor-job.json" - && hwlabG14MonitorStateFileName({ lane: "v02", once: true, dryRun: false }) === "latest-v02-once-job.json" - && hwlabG14MonitorStateFileName({ lane: "v02", once: false, dryRun: true }) === "latest-v02-dry-run-job.json" - && hwlabG14MonitorStateFileName({ lane: "v02", once: true, dryRun: true }) === "latest-v02-once-dry-run-job.json", - "v0.2 PR monitor state pointers must not overwrite the legacy G14 monitor pointers", -); -assertCondition( - hwlabG14MonitorStateFileName({ lane: "v03", once: false, dryRun: false }) === "latest-v03-monitor-job.json" - && hwlabG14MonitorStateFileName({ lane: "v03", once: true, dryRun: false }) === "latest-v03-once-job.json" - && hwlabG14MonitorStateFileName({ lane: "v03", once: false, dryRun: true }) === "latest-v03-dry-run-job.json" - && hwlabG14MonitorStateFileName({ lane: "v03", once: true, dryRun: true }) === "latest-v03-once-dry-run-job.json", - "v0.3 PR monitor state pointers must not overwrite v0.2 or legacy G14 monitor pointers", -); -const hwlabHelp = hwlabG14Help(); -const hwlabHelpUsage = Array.isArray(hwlabHelp.usage) ? hwlabHelp.usage.map((line: unknown) => String(line)) : []; -const hwlabHelpJson = JSON.stringify(hwlabHelp); -const hwlabNodeHelpJson = JSON.stringify(hwlabNodeHelp()); -assertCondition( - hwlabHelpUsage.some((line) => line.includes("control-plane status --lane v02 --pipeline-run")) - && hwlabHelpUsage.some((line) => line.includes("control-plane status --lane v02 --source-commit")) - && hwlabHelpUsage.some((line) => line.includes("control-plane status --lane v02 --history")) - && hwlabHelpUsage.some((line) => line.includes("control-plane closeout --lane v02 --pipeline-run")) - && hwlabHelpUsage.some((line) => line.includes("control-plane closeout --lane v02 --source-commit")) - && hwlabHelpUsage.some((line) => line.includes("control-plane cleanup-runs --lane v02 --pipeline-run")) - && hwlabHelpUsage.some((line) => line.includes("control-plane cleanup-runs --lane v02 --source-commit")), - "v0.2 control-plane help must expose targeted PipelineRun/source-commit status, closeout inspection, and cleanup", - hwlabHelpUsage, -); -assertCondition( - hwlabHelpUsage.some((line) => line.includes("hwlab g14 retirement status")) - && hwlabHelpUsage.some((line) => line.includes("hwlab g14 retirement plan")) - && hwlabHelpUsage.some((line) => line.includes("hwlab g14 retirement execute --confirm")) - && hwlabHelpJson.includes("legacy-g14-retirement.json") - && hwlabHelpJson.includes("legacy DEV/PROD retirement"), - "G14 help must expose the controlled legacy DEV/PROD retirement status, dry-run plan, confirmed execution path, and marker", - hwlabHelp, -); -assertCondition( - hwlabHelpUsage.every((line) => !line.includes("record-rollout")) - && hwlabHelpJson.includes("legacy base=G14 monitor is blocked by the retirement contract"), - "G14 help must not advertise retired legacy DEV rollout support paths", - hwlabHelp, -); -assertCondition( - hwlabHelpUsage.some((line) => line.includes("hwlab nodes control-plane status --node G14 --lane v03")) - && hwlabHelpUsage.some((line) => line.includes("hwlab nodes control-plane apply --node G14 --lane v03 --dry-run")) - && hwlabHelpUsage.some((line) => line.includes("hwlab nodes control-plane trigger-current --node G14 --lane v03 --dry-run")) - && hwlabNodeHelpJson.includes("hwlab nodes control-plane status --node G14 --lane v03") - && hwlabHelpJson.includes("config/hwlab-node-lanes.yaml"), - "v0.3 control-plane help must expose node-scoped runtime lane bootstrap status/apply/trigger entrypoints", - { hwlabHelpUsage, hwlabNodeHelp: hwlabNodeHelp() }, -); -const v03LaneSpec = hwlabRuntimeLaneSpec("v03"); -assertCondition( - JSON.stringify(hwlabRuntimeLaneIds()) === JSON.stringify(["v02", "v03"]) - && hwlabRuntimeLaneConfigPath() === "config/hwlab-node-lanes.yaml" - && v03LaneSpec.nodeId === "G14" - && v03LaneSpec.nodeRoute === "G14" - && v03LaneSpec.sourceBranch === "v0.3" - && v03LaneSpec.gitopsBranch === "v0.3-gitops" - && v03LaneSpec.workspace === "/root/hwlab-v03" - && v03LaneSpec.cicdRepo === "/root/hwlab-v03-cicd.git" - && v03LaneSpec.runtimeNamespace === "hwlab-v03" - && v03LaneSpec.runtimeRenderDir === "runtime-v03" - && v03LaneSpec.pipeline === "hwlab-v03-ci-image-publish" - && v03LaneSpec.publicWebUrl.endsWith(":20666") - && v03LaneSpec.publicApiUrl.endsWith(":20667") - && v03LaneSpec.networkProfileId === "node-ci-egress" - && v03LaneSpec.downloadProfileId === "node-default" - && v03LaneSpec.networkProfile.proxy.noProxy.includes("hyueapi.com") - && v03LaneSpec.networkProfile.proxy.noProxy.includes(".hyueapi.com") - && v03LaneSpec.networkProfile.dockerBuildProxy.noProxy.includes("hyueapi.com") - && v03LaneSpec.downloadProfile.git.proxyMode === "inherit" - && v03LaneSpec.downloadProfile.npm.registry === "https://registry.npmjs.org/" - && JSON.stringify(hwlabRequiredNoProxyEntries()) === JSON.stringify(["hyueapi.com", ".hyueapi.com"]), - "runtime lane spec must make v0.3 node, network, and download expansion config-driven instead of scattered literals", - v03LaneSpec, -); -assertCondition( - hwlabHelpJson.includes("config/hwlab-node-lanes.yaml") - && hwlabHelpJson.includes("node-ci-egress") - && hwlabHelpJson.includes("node-default") - && hwlabHelpJson.includes("hyueapi.com"), - "G14 HWLAB help must expose the runtime lane YAML and required network/download profile identifiers", - hwlabHelp, -); -const v03PipelineRunManifest = runtimeLanePipelineRunManifest(v03LaneSpec, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); -const v03PipelineRunAnnotations = record(record(v03PipelineRunManifest.metadata).annotations); -assertCondition( - v03PipelineRunAnnotations["hwlab.pikastech.local/node"] === "G14" - && v03PipelineRunAnnotations["hwlab.pikastech.local/network-profile"] === "node-ci-egress" - && v03PipelineRunAnnotations["hwlab.pikastech.local/download-profile"] === "node-default" - && String(v03PipelineRunAnnotations["hwlab.pikastech.local/no-proxy-required"]).includes("hyueapi.com"), - "runtime lane PipelineRun manifest must preserve node/network/download profile provenance", - v03PipelineRunAnnotations, -); -assertCondition( - runtimeLaneGitMirrorSourceInSyncForTest("v03", "1912c321606a63fbabb8ee019f2e9a3a16222f23", { - localV03: "1912c321606a63fbabb8ee019f2e9a3a16222f23", - githubV03: "1912c321606a63fbabb8ee019f2e9a3a16222f23", - localV03Gitops: "5ce7152d2f51fcab66754bb4a38052b2f0d8ca44", - githubV03Gitops: "02f808937075ed25e50bd6127a8aa8c651661d68", - pendingFlush: true, - }) === true - && runtimeLaneGitMirrorSourceInSyncForTest("v03", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", { localV03: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" }) === false, - "runtime lane trigger should be able to skip GitHub sync when the local mirror already has the exact source commit, even if gitops flush is pending", -); -assertCondition( - hwlabHelpUsage.some((line) => line.includes("monitor-prs --lane v02")) - && hwlabHelpUsage.some((line) => line.includes("monitor-prs --lane v02 --status")) - && hwlabHelpUsage.some((line) => line.includes("monitor-prs --lane v03")) - && hwlabHelpUsage.some((line) => line.includes("monitor-prs --lane v03 --status")) - && hwlabHelpJson.includes("v02-pr-comment-signatures.json") - && hwlabHelpJson.includes("v03-pr-comment-signatures.json") - && hwlabHelpJson.includes("latest-only"), - "runtime lane PR monitor help must expose v0.2/v0.3 auto CI/CD lanes, status query, latest-only CD, and dedupe comment state", - hwlabHelp, -); -assertCondition( - hwlabHelpUsage.some((line) => line.includes("git-mirror apply --lane v02 --confirm")) - && hwlabHelpUsage.some((line) => line.includes("hwlab nodes git-mirror apply --node G14 --lane v03 --confirm")), - "git mirror help must expose node-scoped v0.3 apply so v0.3 mirror config is not rendered through v0.2", - hwlabHelpUsage, -); -assertCondition( - hwlabHelpUsage.some((line) => line.includes("secret status --lane v02 --name hwlab-v02-openfga")) - && hwlabHelpUsage.some((line) => line.includes("secret ensure --lane v02 --name hwlab-v02-openfga --confirm")) - && hwlabHelpUsage.some((line) => line.includes("secret status --lane v02 --name hwlab-v02-master-server-admin-api-key")) - && hwlabHelpUsage.some((line) => line.includes("secret ensure --lane v02 --name hwlab-v02-master-server-admin-api-key --confirm")), - "v0.2 secret help must expose controlled OpenFGA and master-server admin API key SecretRef bootstrap paths", - hwlabHelpUsage, -); -assertCondition( - hwlabHelpUsage.some((line) => line.includes("hwlab nodes secret status --node G14 --lane v03 --name hwlab-v03-code-agent-provider")) - && hwlabHelpUsage.some((line) => line.includes("hwlab nodes secret ensure --node G14 --lane v03 --name hwlab-v03-code-agent-provider --confirm")) - && hwlabHelpUsage.some((line) => line.includes("hwlab nodes secret status --node G14 --lane v03 --name hwlab-cloud-api-v03-db")) - && hwlabHelpUsage.some((line) => line.includes("hwlab nodes secret cleanup-owned-postgres --node G14 --lane v03 --dry-run")) - && hwlabNodeHelpJson.includes("hwlab nodes secret status --node G14 --lane v03 --name hwlab-v03-code-agent-provider") - && hwlabNodeHelpJson.includes("hwlab nodes secret ensure --node G14 --lane v03 --name hwlab-v03-code-agent-provider --confirm") - && hwlabNodeHelpJson.includes("hwlab nodes secret status --node G14 --lane v03 --name hwlab-cloud-api-v03-db") - && hwlabNodeHelpJson.includes("hwlab nodes secret cleanup-owned-postgres --node G14 --lane v03 --dry-run") - && !hwlabNodeHelpJson.includes("hwlab nodes secret ensure --node G14 --lane v03 --name hwlab-cloud-api-v03-db --confirm") - && !hwlabNodeHelpJson.includes("hwlab nodes secret ensure --node G14 --lane v03 --name hwlab-v03-openfga --confirm"), - "v0.3 node-scoped secret help must expose provider bootstrap plus Cloud API platform DB status and old DB cleanup without old DB ensure paths", - { hwlabHelpUsage, hwlabNodeHelp: hwlabNodeHelp() }, -); -const cloudApiDbStatus = nodeSecretStatusFromTextForTest([ - "namespace\thwlab-v03", - "secret\thwlab-cloud-api-v03-db", - "key\tdatabase-url", - "preset\tcloud-api-db", - "action\tobserved", - "dryRun\ttrue", - "mutation\tfalse", - "platformDbMode\ttrue", - "afterExists\tyes", - "afterDatabaseUrlPresent\tyes", - "afterDatabaseUrlBytes\t172", - "legacyPostgresSecret\thwlab-v03-postgres", - "legacyPostgresSecretExists\tno", - "platformService\tg14-platform-postgres", - "platformServiceExists\tyes", - "platformEndpointsExists\tno", - "platformEndpointSlice\tg14-platform-postgres-host", - "platformEndpointSliceExists\tyes", - "dbName\thwlab_v03", - "dbUser\thwlab_v03_app", - "dbHost\tg14-platform-postgres.hwlab-v03.svc.cluster.local", - "dbHostMatchesPlatform\tyes", - "dbNameMatchesExpected\tyes", - "dbUserMatchesExpected\tyes", -].join("\n"), true, 0, ""); -assertCondition( - record(cloudApiDbStatus).ok === true - && record(cloudApiDbStatus).valuesRedacted === true - && record(cloudApiDbStatus).platformDbMode === true - && record(record(cloudApiDbStatus).after).exists === true - && record(record(record(cloudApiDbStatus).after).databaseUrl).valueBytes === 172 - && record(record(cloudApiDbStatus).legacyPostgresSecret).exists === false - && record(record(cloudApiDbStatus).platformService).name === "g14-platform-postgres" - && record(record(cloudApiDbStatus).platformService).endpointsExist === false - && record(record(cloudApiDbStatus).platformService).legacyEndpointsAbsent === true - && record(record(cloudApiDbStatus).platformService).endpointSlice === "g14-platform-postgres-host" - && record(record(cloudApiDbStatus).platformService).endpointSliceExists === true - && record(cloudApiDbStatus).dbUser === "hwlab_v03_app" - && record(cloudApiDbStatus).dbHost === "g14-platform-postgres.hwlab-v03.svc.cluster.local" - && !JSON.stringify(cloudApiDbStatus).includes("postgres://") - && !JSON.stringify(cloudApiDbStatus).includes("password"), - "cloud-api DB Secret status must be redacted while proving native platform DB SecretRef and bridge are present", - cloudApiDbStatus, -); -const openFgaPlatformStatus = nodeSecretStatusFromTextForTest([ - "namespace\thwlab-v03", - "secret\thwlab-v03-openfga", - "key\tdatastore-uri", - "preset\topenfga", - "action\tobserved", - "dryRun\ttrue", - "mutation\tfalse", - "platformDbMode\ttrue", - "afterExists\tyes", - "afterDatastoreUriPresent\tyes", - "afterDatastoreUriBytes\t176", - "afterAuthnPresent\tyes", - "afterAuthnBytes\t64", - "afterPostgresPasswordPresent\tyes", - "afterPostgresPasswordBytes\t48", - "legacyPostgresSecret\thwlab-v03-postgres", - "legacyPostgresSecretExists\tno", - "platformService\tg14-platform-postgres", - "platformServiceExists\tyes", - "platformEndpointsExists\tno", - "platformEndpointSlice\tg14-platform-postgres-host", - "platformEndpointSliceExists\tyes", - "dbName\topenfga_v03", - "dbUser\topenfga_v03_app", - "dbHost\tg14-platform-postgres.hwlab-v03.svc.cluster.local", - "dbHostMatchesPlatform\tyes", - "dbNameMatchesExpected\tyes", - "dbUserMatchesExpected\tyes", -].join("\n"), true, 0, ""); -assertCondition( - record(openFgaPlatformStatus).ok === true - && record(openFgaPlatformStatus).platformDbMode === true - && record(record(openFgaPlatformStatus).legacyPostgresSecret).exists === false - && record(record(openFgaPlatformStatus).platformService).endpointsExist === false - && record(record(openFgaPlatformStatus).platformService).legacyEndpointsAbsent === true - && record(record(openFgaPlatformStatus).platformService).endpointSlice === "g14-platform-postgres-host" - && record(record(openFgaPlatformStatus).platformService).endpointSliceExists === true - && record(openFgaPlatformStatus).dbName === "openfga_v03" - && record(openFgaPlatformStatus).dbUser === "openfga_v03_app" - && !JSON.stringify(openFgaPlatformStatus).includes("postgres://") - && !JSON.stringify(openFgaPlatformStatus).includes("password"), - "OpenFGA Secret status must validate native platform DB datastore-uri without requiring the old lane-local Postgres Secret", - openFgaPlatformStatus, -); -const removedCloudApiDbEnsure = runCommand(["bun", "scripts/cli.ts", "hwlab", "nodes", "secret", "ensure", "--node", "G14", "--lane", "v03", "--name", "hwlab-cloud-api-v03-db", "--dry-run"], process.cwd(), { timeoutMs: 30_000 }); -assertCondition( - removedCloudApiDbEnsure.exitCode !== 0 - && `${removedCloudApiDbEnsure.stdout}\n${removedCloudApiDbEnsure.stderr}`.includes("was removed after native platform DB migration"), - "v0.3 cloud-api DB ensure must reject the removed lane-local Postgres path", - removedCloudApiDbEnsure, -); -const removedOpenFgaEnsure = runCommand(["bun", "scripts/cli.ts", "hwlab", "nodes", "secret", "ensure", "--node", "G14", "--lane", "v03", "--name", "hwlab-v03-openfga", "--dry-run"], process.cwd(), { timeoutMs: 30_000 }); -assertCondition( - removedOpenFgaEnsure.exitCode !== 0 - && `${removedOpenFgaEnsure.stdout}\n${removedOpenFgaEnsure.stderr}`.includes("was removed after native platform DB migration"), - "v0.3 OpenFGA ensure must reject the removed lane-local Postgres path", - removedOpenFgaEnsure, -); -const codeAgentProviderStatus = nodeSecretStatusFromTextForTest([ - "namespace\thwlab-v03", - "secret\thwlab-v03-code-agent-provider", - "preset\tcode-agent-provider", - "sourceNamespace\thwlab-v02", - "sourceSecret\thwlab-v02-code-agent-provider", - "action\tcopied-from-source", - "dryRun\tfalse", - "mutation\ttrue", - "beforeExists\tno", - "beforeOpenaiPresent\tno", - "beforeOpenaiBytes\t0", - "beforeOpencodePresent\tno", - "beforeOpencodeBytes\t0", - "sourceExists\tyes", - "sourceOpenaiPresent\tyes", - "sourceOpenaiBytes\t48", - "sourceOpencodePresent\tyes", - "sourceOpencodeBytes\t64", - "afterExists\tyes", - "afterOpenaiPresent\tyes", - "afterOpenaiBytes\t48", - "afterOpencodePresent\tyes", - "afterOpencodeBytes\t64", - "applyExitCode\t0", -].join("\n"), true, 0, ""); -assertCondition( - record(codeAgentProviderStatus).ok === true - && record(codeAgentProviderStatus).valuesRedacted === true - && record(record(codeAgentProviderStatus).after).requiredAnyProviderKeyPresent === true - && JSON.stringify(codeAgentProviderStatus).includes("hwlab-v02-code-agent-provider") - && !JSON.stringify(codeAgentProviderStatus).includes("sk-") - && !JSON.stringify(codeAgentProviderStatus).includes("base64"), - "code-agent provider Secret status must be redacted while proving at least one runtime provider key is present", - codeAgentProviderStatus, -); -assertCondition( - hwlabHelpUsage.some((line) => line.includes("upstream-image status --name openfga --tag v1.17.0")) - && hwlabHelpUsage.some((line) => line.includes("upstream-image ensure --name openfga --tag v1.17.0 --confirm")), - "v0.2 help must expose the controlled OpenFGA upstream image mirroring path", - hwlabHelpUsage, -); -assertCondition( - hwlabHelpUsage.some((line) => line.includes("observability query --promql 'up{namespace=\"hwlab-v02\"}' --expect-count 5 --expect-value 1")) - && hwlabHelpUsage.some((line) => line.includes("observability targets --lane v02")) - && hwlabHelpUsage.some((line) => line.includes("observability boundary --lane v02")) - && hwlabHelpUsage.some((line) => line.includes("observability closeout --lane v02")), - "observability help must expose assertion, target, boundary, and closeout entrypoints", - hwlabHelpUsage, -); - -const observabilityAssertionPass = g14ObservabilityQueryAssertion({ - status: "success", - data: { - resultType: "vector", - result: [ - { metric: { job: "hwlab-cloud-api", namespace: "hwlab-v02", pod: "api-pod", hwlab_pikastech_local_service_id: "hwlab-cloud-api" }, value: [1, "1"] }, - { metric: { job: "hwlab-cloud-web", namespace: "hwlab-v02", pod: "web-pod", hwlab_pikastech_local_service_id: "hwlab-cloud-web" }, value: [1, "1.0"] }, - ], - }, -}, 2, "1"); -const observabilityAssertionFail = g14ObservabilityQueryAssertion({ - status: "success", - data: { - resultType: "vector", - result: [ - { metric: { job: "hwlab-cloud-api", namespace: "hwlab-v02", pod: "api-pod", hwlab_pikastech_local_service_id: "hwlab-cloud-api" }, value: [1, "0"] }, - ], - }, -}, 2, "1"); -assertCondition( - record(observabilityAssertionPass).ok === true - && record(observabilityAssertionPass).actualCount === 2 - && record(observabilityAssertionPass).valueOk === true, - "observability query assertion must pass matching vector count and numeric string values", - observabilityAssertionPass, -); -assertCondition( - record(observabilityAssertionFail).ok === false - && record(observabilityAssertionFail).actualCount === 1 - && Array.isArray(record(observabilityAssertionFail).badValues) - && Array.isArray(record(observabilityAssertionFail).missingSeries) - && record(observabilityAssertionFail).badValueCount === 1, - "observability query assertion must report bad values and missing series instead of requiring manual vector inspection", - observabilityAssertionFail, -); -const unsupportedObservabilityOption = runCommand(["bun", "scripts/cli.ts", "hwlab", "g14", "observability", "query", "--not-a-real-option"], process.cwd(), { timeoutMs: 30_000 }); -const unsupportedObservabilityJson = JSON.parse(unsupportedObservabilityOption.stdout) as unknown; -assertCondition( - unsupportedObservabilityOption.exitCode !== 0 - && record(unsupportedObservabilityJson).ok === false - && String(record(record(unsupportedObservabilityJson).error).message ?? "").includes("unsupported observability option"), - "observability CLI must fail visibly on unsupported options instead of silently ignoring friction-prone flags", - unsupportedObservabilityJson, -); -assertCondition( - parseK8sCpuMillicores("46095136n") !== null - && Math.abs((parseK8sCpuMillicores("46095136n") ?? 0) - 46.095136) < 0.000001 - && parseK8sCpuMillicores("47m") === 47 - && parseK8sCpuMillicores("1") === 1000, - "observability resource snapshot must convert metrics.k8s.io CPU quantities to millicores", -); -assertCondition( - parseK8sMemoryMiB("99860Ki") !== null - && Math.abs((parseK8sMemoryMiB("99860Ki") ?? 0) - 97.51953125) < 0.000001 - && parseK8sMemoryMiB("97Mi") === 97 - && parseK8sMemoryMiB("1048576") === 1, - "observability resource snapshot must convert metrics.k8s.io memory quantities to MiB", -); - -const v02CommentBody = v02PrAutomationCommentBody({ - pr: { - number: 848, - title: "迁移:v0.2 PR 合并后自动触发 CD", - url: "https://github.com/pikasTech/HWLAB/pull/848", - baseRefName: "v0.2", - headRefName: "fix/v02-auto-cd", - }, - phase: "cd-passed", - state: "cd-succeeded", - startedAt: "2026-06-04T00:00:00.000Z", - observedAt: "2026-06-04T00:07:30.000Z", - elapsedSeconds: 450, - preflight: { - conclusion: "ready", - readyForCommanderMerge: true, - mergeable: "MERGEABLE", - mergeStateStatus: "CLEAN", - blockers: [], - pending: [], - }, - sourceCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - pipelineRun: "hwlab-v02-ci-poll-aaaaaaaaaaaa", - cd: { - pipelineStatus: "True", - pipelineReason: "Succeeded", - targetValidationState: "passed", - argoSync: "Synced", - argoHealth: "Healthy", - webAssetsOk: true, - pendingFlush: false, - githubInSync: true, - rolloutServices: ["hwlab-cloud-api"], - buildServices: [], - reusedServices: ["hwlab-cloud-web"], - }, - flush: { ok: true }, -}); -assertCondition( - v02CommentBody.includes("elapsed: 7m30s") - && v02CommentBody.includes("conflict: `clear-or-unknown`") - && v02CommentBody.includes("hwlab-v02-ci-poll-aaaaaaaaaaaa") - && v02CommentBody.includes("pendingFlush=`false`") - && v02CommentBody.includes("targetValidation: `passed`"), - "v0.2 PR automation comments must include duration, conflict state, PipelineRun, target validation, and git mirror flush status", - v02CommentBody, -); - -const gitMirrorJob = gitMirrorSyncJobManifest("git-mirror-hwlab-sync-manual-test"); -assertCondition(gitMirrorJob.kind === "Job", "git mirror sync must be a manual Job, not a CronJob", gitMirrorJob); -assertCondition(record(gitMirrorJob.metadata).namespace === "devops-infra", "git mirror sync Job must target devops-infra", gitMirrorJob); -assertCondition(record(record(gitMirrorJob.metadata).labels)["hwlab.pikastech.local/trigger"] === "manual-cli", "git mirror sync Job must be labeled as manual CLI triggered", gitMirrorJob); -assertCondition(record(gitMirrorJob.spec).backoffLimit === 0, "git mirror sync Job should fail visibly instead of retrying in the background", gitMirrorJob); - -const gitMirrorFlushJob = gitMirrorFlushJobManifest("git-mirror-hwlab-flush-manual-test"); -assertCondition(gitMirrorFlushJob.kind === "Job", "git mirror flush must be a manual Job", gitMirrorFlushJob); -assertCondition(record(record(gitMirrorFlushJob.metadata).labels)["app.kubernetes.io/component"] === "flush-controller", "git mirror flush Job must be labeled separately from sync", gitMirrorFlushJob); -assertCondition(record(record(gitMirrorFlushJob.metadata).labels)["hwlab.pikastech.local/trigger"] === "manual-cli", "git mirror flush Job must be labeled as manual CLI triggered", gitMirrorFlushJob); -const flushTemplate = record(record(gitMirrorFlushJob.spec).template); -const flushPodSpec = record(flushTemplate.spec); -const flushContainer = record(Array.isArray(flushPodSpec.containers) ? flushPodSpec.containers[0] : null); -assertCondition(JSON.stringify(flushContainer.command) === JSON.stringify(["/script/flush.sh"]), "git mirror flush Job must run the flush script", gitMirrorFlushJob); - -const gitMirrorStatusRaw = [ - 'lastSync={"ok":true}', - "lastWrite=", - "lastFlush=", - 'refs={"refs":{"localV02":"0cf79ecc9d8a784b7712b0b3a58d5e39025ba0dc","githubV02":"0cf79ecc9d8a784b7712b0b3a58d5e39025ba0dc","localV03":"4444444444444444444444444444444444444444","githubV03":"4444444444444444444444444444444444444444","localV03Gitops":"5555555555555555555555555555555555555555","githubV03Gitops":"6666666666666666666666666666666666666666","localG14":"1111111111111111111111111111111111111111","githubG14":"1111111111111111111111111111111111111111","localGitops":"2222222222222222222222222222222222222222","githubGitops":"3333333333333333333333333333333333333333"},"pendingFlush":true}', -].join("\n"); -const parsedGitMirrorRefs = parseGitMirrorStatusRefs(gitMirrorStatusRaw); -assertCondition( - parsedGitMirrorRefs.refs.localV02 === "0cf79ecc9d8a784b7712b0b3a58d5e39025ba0dc", - "git mirror status parser must expose local v0.2 ref for trigger pre-sync", - parsedGitMirrorRefs, -); -assertCondition(parsedGitMirrorRefs.pendingFlush === true, "git mirror status parser must preserve pending flush signal", parsedGitMirrorRefs); -assertCondition( - parsedGitMirrorRefs.refs.githubV02 === "0cf79ecc9d8a784b7712b0b3a58d5e39025ba0dc", - "git mirror status parser must expose GitHub source branch staging ref", - parsedGitMirrorRefs, -); -assertCondition( - parsedGitMirrorRefs.refs.localV03 === "4444444444444444444444444444444444444444" - && parsedGitMirrorRefs.refs.githubV03Gitops === "6666666666666666666666666666666666666666", - "git mirror status parser must expose v0.3 source and GitOps refs for lane expansion visibility", - parsedGitMirrorRefs, -); -assertCondition( - gitMirrorV02SyncRequirement("0cf79ecc9d8a784b7712b0b3a58d5e39025ba0dc", gitMirrorStatusRaw).required === false, - "trigger-current must not sync mirror when local v0.2 already matches source commit", -); -assertCondition( - gitMirrorV02SyncRequirement("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", gitMirrorStatusRaw).required === true, - "trigger-current must sync mirror before creating PipelineRun when local v0.2 is stale", -); -const reusableGitMirrorPreSyncMarker = v02ReusableGitMirrorPreSyncMarker({ - ok: true, - sourceCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - syncedAt: "2026-06-04T16:00:10.000Z", - localV02: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - githubV02: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", -}, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Date.parse("2026-06-04T16:01:00.000Z"), Date.parse("2026-06-04T16:00:00.000Z")); -const preObservationGitMirrorMarker = v02ReusableGitMirrorPreSyncMarker({ - ok: true, - sourceCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - syncedAt: "2026-06-04T16:00:10.000Z", -}, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Date.parse("2026-06-04T16:01:00.000Z"), Date.parse("2026-06-04T16:00:20.000Z")); -const staleGitMirrorPreSyncMarker = v02ReusableGitMirrorPreSyncMarker({ - ok: true, - sourceCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - syncedAt: "2026-06-04T16:00:10.000Z", -}, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Date.parse("2026-06-04T16:03:00.000Z"), Date.parse("2026-06-04T16:00:00.000Z")); -assertCondition( - reusableGitMirrorPreSyncMarker !== null && preObservationGitMirrorMarker === null && staleGitMirrorPreSyncMarker === null, - "v0.2 git mirror pre-sync marker must only reuse fresh same-commit markers written after the stale status observation", - { reusableGitMirrorPreSyncMarker, preObservationGitMirrorMarker, staleGitMirrorPreSyncMarker }, -); -assertCondition( - v02GitMirrorPreSyncWaitMs(10) === 10_000 - && v02GitMirrorPreSyncWaitMs(180) === 120_000 - && v02GitMirrorPreSyncWaitMs(0) === 120_000, - "v0.2 git mirror pre-sync wait must not impose a hidden 30s minimum latency", - { tenSeconds: v02GitMirrorPreSyncWaitMs(10), capped: v02GitMirrorPreSyncWaitMs(180), default: v02GitMirrorPreSyncWaitMs(0) }, -); -const gitMirrorSummary = gitMirrorStatusSummary(gitMirrorStatusRaw); -assertCondition( - gitMirrorSummary.flushNeeded === true && gitMirrorSummary.flushCommand === "bun scripts/cli.ts hwlab g14 git-mirror flush --confirm", - "git mirror status summary must expose pending flush and the exact controlled flush command", - gitMirrorSummary, -); -assertCondition(gitMirrorSummary.githubInSync === false, "git mirror status summary must expose GitHub GitOps drift", gitMirrorSummary); -assertCondition(gitMirrorSummary.sourceInSync === true && gitMirrorSummary.gitopsInSync === false, "git mirror status must split source and gitops GitHub sync state", gitMirrorSummary); -assertCondition(gitMirrorSummary.v03SourceInSync === true && gitMirrorSummary.v03GitopsInSync === false, "git mirror status must split v0.3 source and gitops sync state", gitMirrorSummary); -const renderScript = v02ControlPlaneRenderScript("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); -const renderScriptHash = v02ControlPlaneRefreshScriptHash("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); -const sourceText = await Bun.file(new URL("./src/hwlab-g14.ts", import.meta.url)).text(); -const jobsSourceText = await Bun.file(new URL("./src/jobs.ts", import.meta.url)).text(); -assertCondition( - sourceText.includes("monitor-prs --lane must be g14 or") - && sourceText.includes("monitorRuntimeLaneCycle") - && sourceText.includes("reportRuntimeLaneAutomationIssue") - && sourceText.includes("runtimeLanePublicProbeScript") - && sourceText.includes("publicProbes") - && sourceText.includes("runtimeLaneRerunPipelineRunName") - && sourceText.includes("source-commit=$source_commit") - && sourceText.includes("same source commit is idempotent only after") - && sourceText.includes("creates or updates failure issues"), - "v0.3 monitor must accept runtime lane ids, use runtime lane control-plane/public probes, rerun incomplete CD, and create/update failure issues for blocked automation", -); -assertCondition( - renderScript.includes("git clone --shared --no-checkout \"$cicd_repo\" \"$worktree_dir\"") - && renderScript.includes("git -C \"$worktree_dir\" checkout --detach \"$source_commit\"") - && renderScript.includes("npm ci --ignore-scripts --no-audit --prefer-offline") - && renderScript.includes("bun install --frozen-lockfile --ignore-scripts") - && renderScript.includes("scripts/run-bun.mjs") - && renderScript.includes("--lane \"$render_lane\"") - && renderScript.includes("/tmp/hwlab-v02-control-plane-source-aaaaaaaaaaaa-"), - "v0.2 control-plane render must use an isolated temp clone with lockfile-based dependencies and the modular Bun renderer fallback", - renderScript, -); -assertCondition( - renderScript.includes("flock -w 120 9") && renderScript.includes("v0.2 CI/CD repo refresh failed status="), - "v0.2 CI/CD repo refresh must serialize only the shared bare repo fetch and expose lock/fetch failures", - renderScript, -); -const reusableRefreshMarker = v02ReusableRefreshMarker({ - ok: true, - sourceCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - refreshedAt: "2026-06-04T16:00:00.000Z", - renderScriptHash, - renderDir: "/tmp/hwlab-v02-control-plane-aaaaaaaaaaaa-contract", -}, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", renderScriptHash, Date.parse("2026-06-04T16:01:00.000Z")); -const staleRefreshMarker = v02ReusableRefreshMarker({ - ok: true, - sourceCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - refreshedAt: "2026-06-04T16:00:00.000Z", - renderScriptHash, -}, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", renderScriptHash, Date.parse("2026-06-04T16:10:01.000Z")); -const wrongScriptMarker = v02ReusableRefreshMarker({ - ok: true, - sourceCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - refreshedAt: "2026-06-04T16:00:00.000Z", - renderScriptHash: "old-script", -}, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", renderScriptHash, Date.parse("2026-06-04T16:01:00.000Z")); -assertCondition( - reusableRefreshMarker !== null && staleRefreshMarker === null && wrongScriptMarker === null, - "v0.2 control-plane refresh marker must only reuse recent markers for the same render script contract", - { reusableRefreshMarker, staleRefreshMarker, wrongScriptMarker }, -); -assertCondition( - renderScript.includes("cicd_repo='/root/hwlab-v02-cicd.git'") && !renderScript.includes("git -C /root/hwlab-v02") && !renderScript.includes("git checkout v0.2"), - "v0.2 control-plane render must not use the fixed workspace checkout or its clean status", - renderScript, -); -assertCondition( - sourceText.includes("function runG14K3sRemoteAsync") - && sourceText.includes("function applyRuntimeLaneControlPlaneFiles") - && sourceText.includes("function legacyRuntimeLaneArgoApplications") - && sourceText.includes("hwlab-g14-${spec.lane}") - && sourceText.includes('"delete",') - && sourceText.includes('"application",') - && sourceText.includes("label: `${spec.lane}-control-plane-apply`") - && sourceText.includes("remote async command timed out after") - && sourceText.includes("return runG14K3sRemoteAsync({"), - "runtime lane control-plane apply must use short start/poll remote-async semantics and clean legacy lane applications through the controlled entry", -); -const existingPipelineRunReuse = v02ExistingPipelineRunReuseDecision({ - sourceCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - before: { exists: true, status: "True" }, - latestSourceCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", -}); -const existingPipelineRunFailed = v02ExistingPipelineRunReuseDecision({ - sourceCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - before: { exists: true, status: "False" }, - latestSourceCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", -}); -const existingPipelineRunHeadAdvanced = v02ExistingPipelineRunReuseDecision({ - sourceCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - before: { exists: true, status: "True" }, - latestSourceCommit: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", -}); -const existingPipelineRunHeadUnresolved = v02ExistingPipelineRunReuseDecision({ - sourceCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - before: { exists: true, status: "True" }, - latestSourceCommit: null, -}); -const triggerSnapshot = parseV02TriggerSnapshot({ - ok: true, - command: ["test"], - exitCode: 0, - stdout: [ - "sourceCommit\taaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "pipelineRun\thwlab-v02-ci-poll-aaaaaaaaaaaa", - "pipelineRunExitCode\t0", - "status\tTrue", - "reason\tCompleted", - "message\tTasks Completed: 9", - "pipelineRunStderr\t", - ].join("\n"), - stderr: "", - parsed: null, -}, Date.parse("2026-06-04T16:00:00.000Z")); -assertCondition( - existingPipelineRunReuse.reusable === true - && existingPipelineRunReuse.alreadyUsable === true - && existingPipelineRunFailed.reusable === true - && existingPipelineRunFailed.alreadyUsable === false - && existingPipelineRunHeadAdvanced.reusable === false - && existingPipelineRunHeadAdvanced.reason === "source-head-advanced-before-existing-pipelinerun-reuse" - && existingPipelineRunHeadUnresolved.reusable === false - && existingPipelineRunHeadUnresolved.reason === "source-head-recheck-unresolved-before-existing-pipelinerun-reuse", - "trigger-current must recheck latest v0.2 head before reusing an existing PipelineRun", - { existingPipelineRunReuse, existingPipelineRunFailed, existingPipelineRunHeadAdvanced, existingPipelineRunHeadUnresolved }, -); -assertCondition( - triggerSnapshot.sourceCommit === "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - && triggerSnapshot.pipelineRun === "hwlab-v02-ci-poll-aaaaaaaaaaaa" - && record(triggerSnapshot.before).status === "True", - "trigger-current fast snapshot must parse source head and PipelineRun status in one G14:k3s route while latest head is checked locally", - triggerSnapshot, -); -assertCondition( - !v02PipelineServiceIds().includes("hwlab-cli") - && !v02PipelineServiceIds().includes("hwlab-agent-mgr") - && !v02PipelineServiceIds().includes("hwlab-agent-worker") - && !v02PipelineServiceIds().includes("hwlab-device-pod"), - "v0.2 PipelineRun service matrix must exclude short-connection cli, removed HWLAB-owned code-agent control-plane services, and retired device-pod service", - v02PipelineServiceIds(), -); -assertCondition( - sourceText.includes("curl -fsS --connect-timeout 2 --max-time 10") - && !sourceText.includes("curl -fsS --connect-timeout 2 --max-time 5"), - "v0.2 web asset probes must not use a 5s app.js timeout that creates false deployment failures", -); -assertCondition( - sourceText.includes("printf 'probeElapsedMs\\\\t%s\\\\n'") - && sourceText.includes("probeElapsedMs: numericField(fields.probeElapsedMs)"), - "v0.2 web asset probes must expose probeElapsedMs for timeout diagnostics", -); -assertCondition( - sourceText.includes("probe_start_s=$(date +%s") - && !sourceText.includes("date +%s%3N"), - "v0.2 web asset elapsed timing must avoid non-portable date +%s%3N in k3s probe shells", -); -assertCondition( - sourceText.includes("if (args.includes(\"--status\")) return monitorStatus(options);") - && sourceText.indexOf("if (args.includes(\"--status\")) return monitorStatus(options);") < sourceText.indexOf("const command = [\"bun\", \"scripts/cli.ts\", \"hwlab\", \"g14\", \"monitor-prs\""), - "monitor-prs --status must be a read-only query before async monitor startJob", -); -assertCondition( - sourceText.includes("protectedLatestByPrefix") - && sourceText.includes("protected-latest-pipelinerun"), - "control-plane cleanup-runs must protect the latest PipelineRun per lane by default", -); -assertCondition( - record(cleanupPipelineRunTargetCandidateFromTextForTest({ - targetPipelineRun: "hwlab-v02-ci-poll-d6b01b261f1b", - text: "hwlab-v02-ci-poll-d6b01b261f1b\t2026-06-08T16:15:31Z\tTrue\tCompleted", - nowMs: Date.parse("2026-06-08T16:51:31Z"), - minAgeMinutes: 60, - })).selectedReason === "below-min-age" - && record(cleanupPipelineRunTargetCandidateFromTextForTest({ - targetPipelineRun: "hwlab-v02-ci-poll-d6b01b261f1b", - text: "hwlab-v02-ci-poll-d6b01b261f1b\t2026-06-08T16:15:31Z\tUnknown\tRunning", - nowMs: Date.parse("2026-06-08T18:00:00Z"), - })).selectedReason === "target-pipelinerun-not-terminal" - && record(cleanupPipelineRunTargetCandidateFromTextForTest({ - targetPipelineRun: "hwlab-v02-ci-poll-d6b01b261f1b", - text: "", - commandOk: false, - exitCode: 1, - stderr: "Error from server (NotFound): pipelineruns.tekton.dev \"hwlab-v02-ci-poll-d6b01b261f1b\" not found", - })).reason === "target-pipelinerun-not-found" - && sourceText.includes("targetPipelineRun !== undefined") - && sourceText.includes("cleanupPipelineRunFieldsJsonPath(false)") - && !sourceText.includes("target-pipelinerun-not-found-or-not-terminal"), - "targeted cleanup-runs must query the requested PipelineRun directly and distinguish below-min-age, not-terminal, and not-found states", -); -assertCondition( - hwlabHelpUsage.some((line) => line.includes("hwlab nodes control-plane cleanup-runs --node G14 --lane v03 --pipeline-run")) - && hwlabHelpUsage.some((line) => line.includes("hwlab nodes control-plane cleanup-runs --node G14 --lane v03 --source-commit")) - && sourceText.includes("control-plane cleanup-runs requires --lane v02|v03|g14|all") - && sourceText.includes("printRuntimeLaneTriggerProgress") - && sourceText.includes("hwlab.runtime-lane.trigger.progress") - && sourceText.includes("hwlab nodes control-plane trigger-current --node") - && jobsSourceText.includes("hwlab-runtime-lane-trigger") - && jobsSourceText.includes("hwlab.runtime-lane.trigger.progress") - && jobsSourceText.includes("hwlab nodes control-plane status --node"), - "v0.3 runtime lane retry and trigger visibility must be represented by controlled cleanup/help/progress paths", -); -assertCondition( - sourceText.includes("remoteStoragePathEstimates") - && sourceText.includes("activeMountPods") - && sourceText.includes("estimatedReclaimBytes") - && sourceText.includes("selectedPersistentVolumes") - && sourceText.includes("storageHostPathFromClaim"), - "HWLAB CI workspace retention dry-run must expose owner-aware PVC/PV hostPath, active mount, and reclaim estimate visibility", -); - -const staleSuccessAlignment = v02CommitAlignment({ - expectedSourceHead: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - sourceHeads: { - cicdRepo: "/root/hwlab-v02-cicd.git", - cicdSourceHead: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - originHead: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - workspace: { path: "/root/hwlab-v02", head: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", originHead: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", dirty: true, dirtyCount: 1 }, - }, - gitMirrorSummary: { localV02: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", githubV02: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", sourceInSync: false, gitopsInSync: true }, - pipelineRun: { pipelineRun: "hwlab-v02-ci-poll-aaaaaaaaaaaa", status: null }, - recentPipelineRuns: { items: [{ name: "hwlab-v02-ci-poll-bbbbbbbbbbbb", sourceCommit: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", status: "True", reason: "Completed" }] }, - runtimeWorkloads: { items: [] }, - webAssets: { apiRevision: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" }, -}); -assertCondition( - staleSuccessAlignment.aligned === false - && staleSuccessAlignment.state === "stale-success" - && JSON.stringify(staleSuccessAlignment.staleReasons).includes("mirror-source-stale") - && JSON.stringify(staleSuccessAlignment.workspaceWarnings).includes("workspace-dirty-but-isolated-from-cicd"), - "v0.2 commit alignment must call out stale-success while keeping dirty workspace isolated from CI source selection", - staleSuccessAlignment, -); - -const latestOnlySuperseded = v02LatestOnlyTargetValidation({ - targetMode: "source-commit", - sourceCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - pipelineRun: { exists: true, status: "True", pipelineRun: "hwlab-v02-ci-poll-aaaaaaaaaaaa" }, - commitAlignment: { staleReasons: ["origin-head-mismatch"], originHead: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" }, - targetValidation: { - ok: false, - state: "failed", - failures: [{ reason: "runtime-service-source-mismatch", serviceId: "hwlab-cloud-api" }], - }, -}); -assertCondition( - latestOnlySuperseded.ok === true - && latestOnlySuperseded.state === "superseded" - && latestOnlySuperseded.latestOnlySuperseded === true - && Array.isArray(latestOnlySuperseded.failures) - && latestOnlySuperseded.failures.length === 0 - && JSON.stringify(latestOnlySuperseded.supersededFailures).includes("runtime-service-source-mismatch"), - "v0.2 latest-only target validation must close a succeeded stale source commit as superseded instead of failing runtime mismatch", - latestOnlySuperseded, -); - -const latestOnlyStalePassed = v02LatestOnlyTargetValidation({ - targetMode: "pipeline-run", - sourceCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - pipelineRun: { exists: true, status: "True", pipelineRun: "hwlab-v02-ci-poll-aaaaaaaaaaaa" }, - commitAlignment: { staleReasons: ["latest-pipelinerun-not-current"], latestPipelineSourceCommit: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" }, - targetValidation: { - ok: true, - state: "passed", - failures: [], - }, -}); -assertCondition( - latestOnlyStalePassed.ok === true - && latestOnlyStalePassed.state === "superseded" - && latestOnlyStalePassed.latestOnlySuperseded === true - && latestOnlyStalePassed.originalState === "passed" - && Array.isArray(latestOnlyStalePassed.failures) - && latestOnlyStalePassed.failures.length === 0, - "v0.2 latest-only target validation must mark stale successful historical runs as superseded instead of plain passed", - latestOnlyStalePassed, -); - -const supersededCloseoutStatus = { - command: "hwlab g14 control-plane status --lane v02 --source-commit aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - sourceCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - statusTarget: { mode: "source-commit", sourceCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", pipelineRun: "hwlab-v02-ci-poll-aaaaaaaaaaaa" }, - pipelineRun: { exists: true, pipelineRun: "hwlab-v02-ci-poll-aaaaaaaaaaaa", status: "True", reason: "Completed" }, - targetValidation: { ok: true, state: "superseded", latestOnlySuperseded: true, latestOnlyReasons: ["origin-head-mismatch"], failures: [] }, - sourceHeads: { - originHead: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - target: { - sourceCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - objectExists: true, - ancestorOfOriginHead: true, - ancestorExitCode: 0, - }, - }, - commitAlignment: { originHead: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", staleReasons: ["origin-head-mismatch"] }, - gitMirror: { - summary: { - pendingFlush: false, - githubInSync: true, - flushNeeded: false, - flushCommand: null, - lastFlush: { status: "flushed", at: "2026-06-04T00:00:00Z" }, - }, - }, - argo: { fields: { syncStatus: "Synced", health: "Healthy", syncRevision: "gitopsbbbb", targetRevision: "v0.2-gitops", path: "deploy/gitops/g14/runtime-v02" } }, - webAssets: { ok: true, summary: "19666/19667 React/Vite asset probes passed", apiRevision: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" }, - activePipelineRuns: [], -}; -const supersededCloseout = v02CloseoutVerdict(supersededCloseoutStatus); -assertCondition( - supersededCloseout.ok === true - && supersededCloseout.closeable === true - && supersededCloseout.state === "superseded" - && record(record(supersededCloseout.latestOnly).ancestorCheck).ancestorOfLatest === true - && String(supersededCloseout.issueCommentMarkdown).includes("ancestorOfLatest=`true`") - && String(record(supersededCloseout.recommendedNext).action) === "comment-and-close-issue", - "v0.2 closeout verdict must close historical successful runs only after ancestor, Argo, and git mirror checks pass", - supersededCloseout, -); - -const unresolvedAncestorCloseout = v02CloseoutVerdict({ - ...supersededCloseoutStatus, - sourceHeads: { originHead: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", target: { sourceCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", objectExists: true, ancestorOfOriginHead: null } }, -}); -assertCondition( - unresolvedAncestorCloseout.closeable === false - && unresolvedAncestorCloseout.state === "pending" - && JSON.stringify(unresolvedAncestorCloseout.pendingReasons).includes("ancestor-check-unresolved"), - "v0.2 closeout verdict must not close superseded targets when ancestor evidence is missing", - unresolvedAncestorCloseout, -); - -const pendingFlushCloseout = v02CloseoutVerdict({ - ...supersededCloseoutStatus, - targetValidation: { - ok: false, - state: "failed", - failures: [{ reason: "git-mirror-not-flushed", pendingFlush: true, githubInSync: false }], - }, - gitMirror: { - summary: { - pendingFlush: true, - githubInSync: false, - flushNeeded: true, - flushCommand: "bun scripts/cli.ts hwlab g14 git-mirror flush --confirm", - }, - }, -}); -assertCondition( - pendingFlushCloseout.closeable === false - && pendingFlushCloseout.state === "pending" - && String(record(pendingFlushCloseout.recommendedNext).action) === "flush-git-mirror" - && String(record(pendingFlushCloseout.recommendedNext).command).includes("git-mirror flush --confirm --wait"), - "v0.2 closeout verdict must turn pendingFlush into an explicit controlled flush command", - pendingFlushCloseout, -); - -const cdSummaryWithFields = summarizeV02CdStatus({ - pipelineRun: { pipelineRun: "hwlab-v02-ci-poll-aaaaaaaaaaaa", status: "True", reason: "Completed" }, - targetValidation: { state: "passed", ok: true, failures: [] }, - gitMirror: { summary: { pendingFlush: false, githubInSync: true } }, - argo: { fields: { syncStatus: "Synced", health: "Healthy" } }, - webAssets: { ok: true }, - planArtifacts: { buildServices: ["hwlab-cloud-api"], reusedServices: ["hwlab-cloud-web"], rolloutServices: ["hwlab-cloud-api"] }, -}); -assertCondition( - cdSummaryWithFields.argoSync === "Synced" - && cdSummaryWithFields.argoHealth === "Healthy", - "v0.2 CD summary must read Argo sync/health from status.argo.fields", - cdSummaryWithFields, -); -const activeRunSummary = activeV02PipelineRuns({ - activePipelineRuns: [ - { name: "hwlab-v02-ci-poll-aaaaaaaaaaaa", status: "Unknown" }, - { name: "hwlab-v02-ci-poll-bbbbbbbbbbbb", status: "True" }, - ], -}); -assertCondition( - activeRunSummary.length === 1 && activeRunSummary[0]?.name === "hwlab-v02-ci-poll-aaaaaaaaaaaa", - "v0.2 active PipelineRun helper must accept the status.activePipelineRuns array shape", - activeRunSummary, -); -const hiddenHistoryPolicy = v02StatusHistoryPolicy({ - activePipelineRuns: [], - history: { - included: false, - note: "recent PipelineRun history is hidden by default so old manifest/service-id failures do not pollute the current-target verdict; rerun with --history to inspect it", - command: "hwlab g14 control-plane status --lane v02 --history", - }, -}); -const explicitHistoryPolicy = v02StatusHistoryPolicy({ - activePipelineRuns: [], - recentPipelineRuns: { items: [{ name: "hwlab-v02-ci-poll-aaaaaaaaaaaa", status: "False" }] }, - history: { included: true }, -}); -assertCondition( - hiddenHistoryPolicy.included === false - && hiddenHistoryPolicy.recentPipelineRunsVisible === false - && hiddenHistoryPolicy.activePipelineRunsVisible === true - && String(hiddenHistoryPolicy.command).includes("--history") - && explicitHistoryPolicy.included === true - && explicitHistoryPolicy.recentPipelineRunsVisible === true, - "v0.2 status must hide historical PipelineRun lists by default and expose them only via --history", - { hiddenHistoryPolicy, explicitHistoryPolicy }, -); - -const falseGreenPassed = v02FalseGreenGuard({ - sourceCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - pipelineRun: { exists: true, status: "True" }, - taskRuns: { - items: [ - { name: "hwlab-v02-ci-poll-aaaaaaaaaaaa-build-hwlab-cloud-api", status: "True" }, - { name: "hwlab-v02-ci-poll-aaaaaaaaaaaa-build-hwlab-cloud-web", status: "True" }, - ], - }, - planArtifacts: { - ok: true, - buildServices: ["hwlab-cloud-api"], - reusedServices: ["hwlab-cloud-web"], - artifactProvenanceAudit: { - ok: true, - unsafeReuseServices: [], - }, - }, - runtimeWorkloads: { - ok: true, - items: [ - { serviceId: "hwlab-cloud-api", artifactSourceCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, - { serviceId: "hwlab-cloud-web", artifactSourceCommit: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" }, - ], - }, -}); -assertCondition( - falseGreenPassed.ok === true && falseGreenPassed.state === "passed", - "v0.2 false-green guard should pass when built runtime services and reuse provenance are proven", - falseGreenPassed, -); - -const falseGreenReuseMissingAudit = v02FalseGreenGuard({ - sourceCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - pipelineRun: { exists: true, status: "True" }, - taskRuns: { items: [{ name: "hwlab-v02-ci-poll-aaaaaaaaaaaa-build-hwlab-cloud-api", status: "True" }] }, - planArtifacts: { - ok: true, - buildServices: ["hwlab-cloud-api"], - reusedServices: ["hwlab-cloud-web"], - artifactProvenanceAudit: null, - }, - runtimeWorkloads: { ok: true, items: [{ serviceId: "hwlab-cloud-api", artifactSourceCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }] }, -}); -assertCondition( - falseGreenReuseMissingAudit.ok === true - && JSON.stringify(falseGreenReuseMissingAudit.provenanceWarnings).includes("artifact-provenance-audit-missing-for-reuse"), - "v0.2 false-green guard should not fail a completed rollout only because optional reuse provenance is missing", - falseGreenReuseMissingAudit, -); - -const falseGreenRuntimeMismatch = v02FalseGreenGuard({ - sourceCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - pipelineRun: { exists: true, status: "True" }, - taskRuns: { items: [{ name: "hwlab-v02-ci-poll-aaaaaaaaaaaa-build-hwlab-cloud-api", status: "True" }] }, - planArtifacts: { - ok: true, - buildServices: ["hwlab-cloud-api"], - reusedServices: [], - artifactProvenanceAudit: null, - }, - runtimeWorkloads: { ok: true, items: [{ serviceId: "hwlab-cloud-api", artifactSourceCommit: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" }] }, -}); -assertCondition( - falseGreenRuntimeMismatch.ok === false - && JSON.stringify(falseGreenRuntimeMismatch.failures).includes("artifact-source-commit-mismatch"), - "v0.2 false-green guard must fail when a built service still runs an old artifact", - falseGreenRuntimeMismatch, -); - -const slowBuildSummary = v02TaskRunPerformanceSummary([ - { - name: "hwlab-v02-ci-poll-f8a090b66616-build-hwlab-agent-worker", - status: "True", - reason: "Succeeded", - durationSeconds: 234, - }, - { - name: "hwlab-v02-ci-poll-f8a090b66616-build-hwlab-cloud-web", - status: "True", - reason: "Succeeded", - durationSeconds: 37, - }, -]); -const slowBuildItems = Array.isArray(record(slowBuildSummary).slowTaskRuns) ? record(slowBuildSummary).slowTaskRuns as Record[] : []; -assertCondition( - slowBuildSummary.ok === false - && record(record(slowBuildSummary).thresholds).buildTaskRunWarningSeconds === 120 - && slowBuildItems.length === 1 - && slowBuildItems[0]?.serviceId === "hwlab-agent-worker" - && slowBuildItems[0]?.severity === "critical", - "v0.2 status must warn on slow build TaskRuns like issue #659", - slowBuildSummary, -); - -const prBody = [ - "## 背景", - "", - "G14 DEV Cloud Workbench 中,内部 `message-trace-body` 的 `pre` 会在 trace 更新时被替换,导致用户滚到中间后被重置到顶部。", - "", - "## 修改", - "", - "- trace row patch 从 index/replace 改为 stable `data-trace-row-id` keyed reconciliation。", - "- 已存在 row 只移动和原地更新 header/body,不替换 `li/pre`。", - "- `message-trace-body` 内部滚动状态按 `traceUiKey + rowId` 记忆。", -].join("\n"); - -const semanticBullets = semanticChangelogBullets("fix: preserve inner trace scroll", prBody, { - keyFiles: [ - "modified web/hwlab-cloud-web/app.mjs", - "modified web/hwlab-cloud-web/scripts/trace-scroll.test.mjs", - ], -}); - -assertCondition( - semanticBullets.some((line) => line.includes("修复目标") && line.includes("滚到中间后被重置到顶部")), - "semantic changelog should explain the user-visible bug being fixed", - semanticBullets, -); -assertCondition( - semanticBullets.some((line) => line.includes("data-trace-row-id")), - "semantic changelog should include PR body change bullets instead of only file stats", - semanticBullets, -); - -const summaryBullets = semanticChangelogBullets("feat: preload gateway tran helper", [ - "## 摘要", - "- 新增 `/app/tools/hwlab-gateway-tran.mjs`,支持 `cmd`、`ps`、`upload`、`download`。", -].join("\n"), {}); - -assertCondition( - summaryBullets.some((line) => line.includes("hwlab-gateway-tran.mjs") && line.includes("upload")), - "semantic changelog should also extract Chinese summary sections", - summaryBullets, -); - -const rolloutBody = rolloutRecordBody({ - pr: { number: 506, title: "fix: preserve inner trace scroll", url: "https://github.com/pikasTech/HWLAB/pull/506" }, - prData: { json: { title: "fix: preserve inner trace scroll", body: prBody, url: "https://github.com/pikasTech/HWLAB/pull/506" } }, - fileSummary: { - summary: { files: 2, additions: 268, deletions: 22, commits: 1 }, - keyFiles: [ - "modified web/hwlab-cloud-web/app.mjs", - "modified web/hwlab-cloud-web/scripts/trace-scroll.test.mjs", - ], - }, - sourceCommit: "1a3fa9e6fbc987142463ecff2cbcef240a6278f2", - pipelineRun: "hwlab-g14-ci-poll-1a3fa9e6fbc9", - gitopsRevision: "21462b78ce4e7dba4ea374398f60db690e290147", - mergedAt: "2026-05-27T06:52:22Z", - pipelineSucceededAt: "2026-05-27T06:55:38Z", - finishedAt: "2026-05-27T06:55:47Z", - rollout: { pipelineText: "True\nSucceeded", argoText: "21462\nSynced\nHealthy\nSucceeded\n21462", healthOk: true }, - ciMetrics: parsePipelineTaskRunMetrics("hwlab-g14-ci-poll-test", [ - "taskrun-a\tbuild-hwlab-cloud-api\t2026-05-27T06:54:43Z\t2026-05-27T06:54:52Z\tservice-id=hwlab-cloud-api;status=reused;build-backend=reused-catalog;", - "taskrun-b\tbuild-hwlab-cloud-web\t2026-05-27T06:54:43Z\t2026-05-27T06:55:08Z\tservice-id=hwlab-cloud-web;status=published;build-backend=buildkit;", - ].join("\n")), -}); - -assertCondition( - rolloutBody.includes("- 上线 changelog(语义化):"), - "rollout record must label natural-language changelog separately", -); -assertCondition( - rolloutBody.includes("- 自动 diff 摘要:"), - "rollout record must keep automatic diff summary separately", -); -assertCondition( - rolloutBody.indexOf("- 上线 changelog(语义化):") < rolloutBody.indexOf("- 自动 diff 摘要:"), - "semantic changelog should appear before automatic diff summary", -); -assertCondition( - rolloutBody.includes("changed files: 2; +268 / -22; commits: 1"), - "automatic diff summary should preserve file/stat evidence", -); -assertCondition( - rolloutBody.includes("lazy build reused: 1/2"), - "rollout record should include lazy-build reused ratio", - rolloutBody, -); -assertCondition( - rolloutBody.includes("reused services: hwlab-cloud-api") && rolloutBody.includes("rebuild services: hwlab-cloud-web"), - "rollout record should list reused and rebuild services", - rolloutBody, -); -assertCondition( - rolloutBody.includes("hwlab-cloud-api: reused, 9s") && rolloutBody.includes("hwlab-cloud-web: published, 25s"), - "rollout record should include each service duration", - rolloutBody, -); - -console.log(JSON.stringify({ - ok: true, - checks: [ - "long-running monitor owns latest-monitor-job.json", - "once jobs do not overwrite long-running monitor state", - "dry-run jobs do not overwrite live monitor state", - "once dry-run jobs have a distinct diagnostic state file", - "v0.2 PR monitor state pointers do not overwrite legacy G14 monitor pointers", - "observability help exposes assertion, target, boundary, and closeout entrypoints", - "observability query assertions report count and terminal value pass/fail", - "observability CLI rejects unsupported options with visible JSON errors", - "observability resource snapshot converts metrics.k8s.io CPU quantities to millicores", - "observability resource snapshot converts metrics.k8s.io memory quantities to MiB", - "git mirror sync is a manual devops-infra Job, not a CronJob", - "git mirror flush is a manual devops-infra Job, not a CronJob", - "trigger-current can decide whether v0.2 git mirror pre-sync is required", - "v0.2 git mirror pre-sync marker only reuses fresh same-commit post-observation markers", - "git mirror status exposes source and gitops GitHub sync state plus controlled flush command", - "v0.2 control-plane status and closeout expose targeted PipelineRun/source-commit inspection", - "v0.2 control-plane render uses an isolated temp clone from a CI/CD dedicated bare repo", - "v0.2 control-plane refresh marker only reuses recent same-contract refreshes", - "trigger-current rechecks latest v0.2 head before reusing an existing PipelineRun", - "trigger-current fast snapshot parses source head and PipelineRun status in one G14:k3s route while latest head is checked locally", - "v0.2 status alignment reports stale-success without coupling CI to dirty workspace state", - "v0.2 closeout verdicts distinguish passed, superseded, pending flush, and unresolved ancestor states", - "v0.2 CD summary and active run helpers expose Argo fields plus concurrent PipelineRun visibility", - "v0.2 PipelineRun service matrix excludes hwlab-cli", - "v0.2 false-green guard checks build TaskRuns, runtime artifact source commits, and reuse provenance", - "v0.2 status warns on slow build TaskRuns", - "HWLAB CI workspace retention dry-run exposes PVC/PV hostPath and reclaim estimates", - "rollout brief includes natural-language changelog before automatic diff summary", - "semantic changelog extracts Chinese summary sections", - "rollout brief includes lazy-build reused/rebuild metrics and service durations", - ], -}, null, 2)); diff --git a/scripts/issue-60-cicd-drift-contract-test.ts b/scripts/issue-60-cicd-drift-contract-test.ts deleted file mode 100644 index 19a86cf8..00000000 --- a/scripts/issue-60-cicd-drift-contract-test.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { readFileSync } from "node:fs"; -import { rootPath } from "./src/config"; - -type JsonRecord = Record; -type Environment = "dev" | "prod"; - -const reviewedSourceBuildServices = new Set([ - "backend-core", - "frontend", - "baidu-netdisk", - "decision-center", - "project-manager", - "oa-event-flow", - "todo-note", - "code-queue-mgr", - "findjob", - "pipeline", - "met-nonlinear", - "k3sctl-adapter", - "mdtodo", - "claudeqq", - "code-queue", -]); - -const reviewedArtifactConsumers = new Set([ - "backend-core", - "frontend", - "baidu-netdisk", - "decision-center", - "project-manager", - "oa-event-flow", - "todo-note", - "code-queue-mgr", - "findjob", - "pipeline", - "met-nonlinear", - "k3sctl-adapter", - "mdtodo", - "claudeqq", - "code-queue", -]); - -const plannedDeployJsonFields = [ - "artifact.kind", - "artifact.repository", - "artifact.tag", - "artifact.digestRef", - "consumer.kind", - "consumer.dev.enabled", - "consumer.prod.enabled", - "consumer.supportLevel", - "consumer.targetRef", - "consumer.noRuntimeSourceBuild", - "runtime.containerPort", - "runtime.healthPath", - "runtime.memory.request", - "runtime.memory.limit", - "health.deployMetadataRequired", - "runtime.requiredSecretKeys", -]; - -const phaseTwoExecutorContractServices = new Set(["dev/decision-center", "dev/mdtodo", "dev/code-queue"]); - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function asRecord(value: unknown, label: string): JsonRecord { - assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, value); - return value as JsonRecord; -} - -function asArray(value: unknown, label: string): unknown[] { - assertCondition(Array.isArray(value), `${label} must be an array`, value); - return value as unknown[]; -} - -function stringField(value: unknown, label: string): string { - assertCondition(typeof value === "string" && value.length > 0, `${label} must be a non-empty string`, value); - return value as string; -} - -function loadJson(path: string): JsonRecord { - return asRecord(JSON.parse(readFileSync(rootPath(path), "utf8")) as unknown, path); -} - -function isFullSha(value: string): boolean { - return /^[0-9a-f]{40}$/u.test(value); -} - -function deployServicesByEnvironment(deploy: JsonRecord, environment: Environment): JsonRecord[] { - const environments = asRecord(deploy.environments, "deploy.json.environments"); - const env = asRecord(environments[environment], `deploy.json.environments.${environment}`); - return asArray(env.services, `deploy.json.environments.${environment}.services`).map((item, index) => asRecord(item, `${environment}.services[${index}]`)); -} - -function serviceKey(environment: Environment, serviceId: string): string { - return `${environment}/${serviceId}`; -} - -function artifactByService(ciCatalog: JsonRecord): Map { - const artifacts = asArray(ciCatalog.artifacts, "CI.json.artifacts").map((item, index) => asRecord(item, `CI.json.artifacts[${index}]`)); - return new Map(artifacts.map((artifact) => [stringField(artifact.serviceId, "CI.json artifact serviceId"), artifact])); -} - -function configByService(config: JsonRecord): Map { - const microservices = asArray(config.microservices, "config.json.microservices").map((item, index) => asRecord(item, `config.json.microservices[${index}]`)); - return new Map(microservices.map((service) => [stringField(service.id, "config service id"), service])); -} - -function assertDeployJsonReleaseIntent(deploy: JsonRecord): Array<{ environment: Environment; serviceId: string; repo: string; commitId: string }> { - assertCondition(deploy.schemaVersion === 2, "deploy.json must remain schemaVersion=2", deploy); - const seen = new Set(); - const services: Array<{ environment: Environment; serviceId: string; repo: string; commitId: string }> = []; - for (const environment of ["dev", "prod"] as const) { - for (const service of deployServicesByEnvironment(deploy, environment)) { - const serviceId = stringField(service.id, `${environment} service id`); - const key = serviceKey(environment, serviceId); - const keys = Object.keys(service).sort(); - const allowedKeys = phaseTwoExecutorContractServices.has(key) - ? ["artifact", "commitId", "consumer", "id", "repo", "runtime"] - : ["commitId", "id", "repo"]; - assertCondition(keys.join(",") === allowedKeys.join(","), "deploy.json service entries must stay in the reviewed phase-one/phase-two schema set", { environment, service, keys, allowedKeys }); - const repo = stringField(service.repo, `${environment}/${serviceId}.repo`); - const commitId = stringField(service.commitId, `${environment}/${serviceId}.commitId`).toLowerCase(); - assertCondition(isFullSha(commitId), "deploy.json phase-one commit pins must be full 40-character SHAs", { environment, serviceId, commitId }); - assertCondition(!seen.has(key), "deploy.json service must not be duplicated within an environment", { key }); - seen.add(key); - services.push({ environment, serviceId, repo, commitId }); - } - } - return services; -} - -function assertCiCatalogAlignment(services: Array<{ environment: Environment; serviceId: string; repo: string; commitId: string }>, ciCatalog: JsonRecord): void { - const artifacts = artifactByService(ciCatalog); - const defaults = asRecord(ciCatalog.defaults, "CI.json.defaults"); - assertCondition(defaults.mutableTagsAllowed === false, "CI.json must continue rejecting mutable tags", defaults); - assertCondition(defaults.tagTemplate === "{{sourceCommit}}", "CI.json tag template must remain commit-pinned", defaults); - - for (const service of services) { - const artifact = artifacts.get(service.serviceId); - assertCondition(artifact !== undefined, "every deploy.json service must have a CI catalog entry or a reviewed exception", service); - assertCondition(reviewedSourceBuildServices.has(service.serviceId), "deploy.json service must be in the reviewed source-build/drift set for phase one", service); - const kind = stringField(artifact?.kind, `${service.serviceId}.kind`); - const status = stringField(artifact?.status, `${service.serviceId}.status`); - assertCondition(kind === "source-build", "deploy.json source services must stay source-build in CI.json during phase one", { service, artifact }); - assertCondition(status === "supported", "deploy.json source services must stay supported in CI.json during phase one", { service, artifact }); - const source = asRecord(artifact?.source, `${service.serviceId}.source`); - const image = asRecord(artifact?.image, `${service.serviceId}.image`); - assertCondition(source.repo === service.repo, "deploy.json repo and CI.json source.repo must not drift", { service, source }); - assertCondition(typeof source.dockerfile === "string" && source.dockerfile.length > 0, "CI.json must keep the producer Dockerfile until deploy.json owns artifact identity", { service, source }); - assertCondition(image.repository === `unidesk/${service.serviceId}`, "CI image repository must remain service-id derived before deploy.json artifact.repository migration", { service, image }); - } -} - -function assertConfigCompatibility(services: Array<{ environment: Environment; serviceId: string; repo: string; commitId: string }>, config: JsonRecord): Array<{ environment: Environment; serviceId: string; deployCommitId: string; configCommitId: string }> { - const configs = configByService(config); - const commitMirrors: Array<{ environment: Environment; serviceId: string; deployCommitId: string; configCommitId: string }> = []; - for (const service of services) { - if (service.serviceId === "backend-core" || service.serviceId === "frontend") continue; - const configService = configs.get(service.serviceId); - assertCondition(configService !== undefined, "deploy.json service must exist in config.json until renderers replace config lookups", service); - const repository = asRecord(configService?.repository, `${service.serviceId}.repository`); - assertCondition(repository.url === service.repo, "deploy.json repo and config.json repository.url must not drift", { service, repository }); - assertCondition(typeof repository.dockerfile === "string" && repository.dockerfile.length > 0, "config.json keeps Dockerfile target metadata in phase one", { service, repository }); - assertCondition(typeof repository.composeService === "string" && repository.composeService.length > 0, "config.json keeps compose/k3s service name in phase one", { service, repository }); - assertCondition(typeof repository.containerName === "string" && repository.containerName.length > 0, "config.json keeps container name in phase one", { service, repository }); - const configCommitId = stringField(repository.commitId, `${service.serviceId}.repository.commitId`); - if (configCommitId === service.commitId) { - commitMirrors.push({ environment: service.environment, serviceId: service.serviceId, deployCommitId: service.commitId, configCommitId }); - } - } - return commitMirrors; -} - -function assertConsumerCoverage(services: Array<{ environment: Environment; serviceId: string; repo: string; commitId: string }>): void { - for (const service of services) { - assertCondition(reviewedArtifactConsumers.has(service.serviceId), "deploy.json service must be covered by the phase-one consumer drift set", service); - if (service.environment === "prod" && service.serviceId === "code-queue") { - continue; - } - if (service.serviceId === "k3sctl-adapter" || service.serviceId === "met-nonlinear") { - continue; - } - assertCondition( - service.serviceId !== "code-agent-sandbox", - "code-agent-sandbox must not become a deploy.json artifact consumer before the schema contract is reviewed", - service, - ); - } -} - -function assertUpstreamImageBoundary(ciCatalog: JsonRecord, config: JsonRecord): void { - const artifacts = artifactByService(ciCatalog); - const configs = configByService(config); - for (const serviceId of ["filebrowser", "filebrowser-d601"]) { - const artifact = asRecord(artifacts.get(serviceId), `CI.json ${serviceId}`); - const upstream = asRecord(artifact.upstream, `CI.json ${serviceId}.upstream`); - const configService = asRecord(configs.get(serviceId), `config.json ${serviceId}`); - const repository = asRecord(configService.repository, `config.json ${serviceId}.repository`); - const artifactSource = asRecord(repository.artifactSource, `config.json ${serviceId}.repository.artifactSource`); - assertCondition(artifact.kind === "upstream-image", "File Browser services must stay upstream-image catalog entries", artifact); - assertCondition(artifact.status === "blocked", "File Browser upstream-image services must stay blocked until mirror consumer exists", artifact); - assertCondition(upstream.imageRef === artifactSource.imageRef, "CI upstream imageRef and config artifactSource imageRef must not drift", { serviceId, upstream, artifactSource }); - assertCondition(artifactSource.ciDockerfileBuild === false, "File Browser must not be treated as a CI Dockerfile build", { serviceId, artifactSource }); - assertCondition(artifactSource.pullOnlyCd === true, "File Browser must remain pull-only CD", { serviceId, artifactSource }); - assertCondition(typeof upstream.digestRef === "string" && String(upstream.digestRef).includes("@sha256:"), "upstream image entry must keep a digest pin", { serviceId, upstream }); - } -} - -function assertDocsContract(): void { - const docs = readFileSync(rootPath("docs/reference/cicd-standardization.md"), "utf8"); - for (const phrase of [ - "Phase-One Deploy.json Consolidation Contract", - "dev/mdtodo", - "deploy-json-drift", - "bun scripts/issue-60-deploy-json-executor-preflight-contract-test.ts", - "Current duplicated configuration surfaces", - "Fields that stay outside `deploy.json` during phase one", - "The drift contract is:", - "bun scripts/issue-60-cicd-drift-contract-test.ts", - ]) { - assertCondition(docs.includes(phrase), "CI/CD standardization docs must include the issue #60 phase-one contract", { phrase }); - } - for (const field of plannedDeployJsonFields) { - assertCondition(docs.includes(field), "phase-one docs must name the planned deploy.json schema field", { field }); - } -} - -const deploy = loadJson("deploy.json"); -const ciCatalog = loadJson("CI.json"); -const config = loadJson("config.json"); -const services = assertDeployJsonReleaseIntent(deploy); -assertCiCatalogAlignment(services, ciCatalog); -const configCommitMirrors = assertConfigCompatibility(services, config); -assertConsumerCoverage(services); -assertUpstreamImageBoundary(ciCatalog, config); -assertDocsContract(); - -process.stdout.write(`${JSON.stringify({ - ok: true, - checks: [ - "deploy.json remains the phase-one release-intent source with full commit pins", - "deploy.json repo values match CI.json source-build producer repos", - "CI.json keeps commit-tag producer policy and service-id image repositories", - "config.json runtime topology remains compatibility metadata and does not own env-ref commits", - "File Browser upstream-image entries stay blocked from Dockerfile CI", - "docs/reference/cicd-standardization.md names the phase-one schema and drift contract", - ], - deployServicesChecked: services.length, - compatibilityCommitMirrors: configCommitMirrors, - plannedDeployJsonFields, -}, null, 2)}\n`); diff --git a/scripts/issue-60-deploy-json-executor-preflight-contract-test.ts b/scripts/issue-60-deploy-json-executor-preflight-contract-test.ts deleted file mode 100644 index 0e635afb..00000000 --- a/scripts/issue-60-deploy-json-executor-preflight-contract-test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { readFileSync } from "node:fs"; -import { - compareDeployJsonExecutorMirrors, - deployJsonDriftResult, - deployJsonSourceOfTruth, - encodeDeployJsonServiceContract, - k3sManifestExecutorMirror, - parseDeployJsonServiceContract, - type DeployJsonServiceContract, - type DeployJsonExecutorMirror, -} from "./src/deploy-json-contract"; -import { rootPath } from "./src/config"; -import { runArtifactRegistryCommand } from "./src/artifact-registry"; - -type JsonRecord = Record; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function asRecord(value: unknown, label: string): JsonRecord { - assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, value); - return value as JsonRecord; -} - -function asArray(value: unknown, label: string): unknown[] { - assertCondition(Array.isArray(value), `${label} must be an array`, value); - return value as unknown[]; -} - -interface ContractCase { - serviceId: string; - expectedRuntimeImage: string; - driftPort: number; -} - -function deployService(environment: "dev" | "prod", serviceId: string): DeployJsonServiceContract { - const deploy = asRecord(JSON.parse(readFileSync(rootPath("deploy.json"), "utf8")) as unknown, "deploy.json"); - const environments = asRecord(deploy.environments, "deploy.json.environments"); - const env = asRecord(environments[environment], `deploy.json.environments.${environment}`); - const services = asArray(env.services, `deploy.json.environments.${environment}.services`); - const raw = services.find((item) => asRecord(item, `${environment} service`).id === serviceId); - assertCondition(raw !== undefined, `deploy.json must contain ${environment}/${serviceId}`); - return parseDeployJsonServiceContract(raw, `deploy.json.environments.${environment}.services.${serviceId}`); -} - -const deploySource = readFileSync(rootPath("scripts/src/deploy.ts"), "utf8"); -assertCondition(deploySource.includes('"--deploy-json-service", encodeDeployJsonServiceContract(service)'), "deploy executor must pass deploy.json service contract into artifact-registry", {}); - -const cases: ContractCase[] = [ - { - serviceId: "mdtodo", - expectedRuntimeImage: "unidesk-mdtodo", - driftPort: 4268, - }, - { - serviceId: "decision-center", - expectedRuntimeImage: "unidesk-decision-center", - driftPort: 4278, - }, -]; - -const verifiedServices: string[] = []; -for (const item of cases) { - const service = deployService("dev", item.serviceId); - const artifact = asRecord(service.artifact, `${item.serviceId} artifact`); - const consumer = asRecord(service.consumer, `${item.serviceId} consumer`); - const targetContract = asRecord(consumer.target, `${item.serviceId} consumer target`); - const runtime = asRecord(service.runtime, `${item.serviceId} runtime`); - const memory = asRecord(runtime.memory, `${item.serviceId} runtime memory`); - const health = asRecord(runtime.health, `${item.serviceId} runtime health`); - const commit = String(service.commitId); - const sourceOfTruth = deployJsonSourceOfTruth(service, "dev"); - - const applyPlan = asRecord(await runArtifactRegistryCommand([ - "deploy-service", - "--env", - "dev", - "--service", - item.serviceId, - "--commit", - commit, - "--source-repo", - service.repo, - "--deploy-ref", - `origin/master:deploy.json#environments.dev.services.${item.serviceId}`, - "--deploy-json-service", - encodeDeployJsonServiceContract(service), - "--dry-run", - ]), `${item.serviceId} artifact-registry dry-run result`); - const applyTarget = asRecord(applyPlan.target, `${item.serviceId} artifact-registry target`); - const applyRegistry = asRecord(applyPlan.registry, `${item.serviceId} artifact-registry registry`); - const applyRuntime = asRecord(applyPlan.runtime, `${item.serviceId} artifact-registry runtime`); - const applyRuntimeMemory = asRecord(applyRuntime.memory, `${item.serviceId} artifact-registry runtime memory`); - const applyRuntimeHealth = asRecord(applyRuntime.health, `${item.serviceId} artifact-registry runtime health`); - const applyDriftCheck = asRecord(applyPlan.driftCheck, `${item.serviceId} artifact-registry driftCheck`); - assertCondition(applyPlan.sourceOfTruth !== undefined, `${item.serviceId} artifact-registry dry-run should expose deploy.json sourceOfTruth`, applyPlan); - assertCondition(JSON.stringify(applyPlan.sourceOfTruth) === JSON.stringify(sourceOfTruth), `${item.serviceId} artifact-registry sourceOfTruth must enumerate deploy.json fields`, applyPlan.sourceOfTruth); - assertCondition(applyDriftCheck.ok === true, `${item.serviceId} artifact-registry dry-run must report a passing drift preflight`, applyDriftCheck); - assertCondition(applyRegistry.repository === artifact.repository, `${item.serviceId} artifact-registry dry-run repository must come from deploy.json`, applyRegistry); - assertCondition(applyRegistry.tag === commit, `${item.serviceId} artifact-registry dry-run tag must be deploy.json commitId`, applyRegistry); - assertCondition(applyRegistry.imageRef === `127.0.0.1:5000/${artifact.repository}:${commit}`, `${item.serviceId} artifact-registry imageRef must be deploy.json artifact repository + commit`, applyRegistry); - assertCondition(applyTarget.namespace === targetContract.namespace, `${item.serviceId} artifact-registry dry-run namespace must come from deploy.json`, applyTarget); - assertCondition(applyTarget.deployment === targetContract.deployment, `${item.serviceId} artifact-registry dry-run deployment must come from deploy.json`, applyTarget); - assertCondition(applyTarget.service === targetContract.service, `${item.serviceId} artifact-registry dry-run service must come from deploy.json`, applyTarget); - assertCondition(asArray(applyTarget.deployments, `${item.serviceId} artifact-registry deployments`).some((deployment) => asRecord(deployment, "deployment").name === targetContract.deployment), `${item.serviceId} artifact-registry deployments must come from deploy.json target`, applyTarget); - assertCondition(applyTarget.stableImage === targetContract.stableImage, `${item.serviceId} artifact-registry stableImage must come from deploy.json`, applyTarget); - assertCondition(applyTarget.runtimeImage === `${item.expectedRuntimeImage}:${commit}`, `${item.serviceId} artifact-registry runtimeImage must derive from deploy.json stableImage + commit`, applyTarget); - assertCondition(applyTarget.manifestRepoPath === targetContract.manifestRepoPath, `${item.serviceId} artifact-registry manifest path must come from deploy.json`, applyTarget); - assertCondition(applyRuntime.sourceOfTruth === "deploy.json", `${item.serviceId} artifact-registry runtime source must be deploy.json`, applyRuntime); - assertCondition(applyRuntime.containerPort === runtime.containerPort, `${item.serviceId} artifact-registry runtime port must come from deploy.json`, applyRuntime); - assertCondition(applyRuntime.healthPath === runtime.healthPath, `${item.serviceId} artifact-registry runtime healthPath must come from deploy.json`, applyRuntime); - assertCondition(applyRuntimeMemory.request === memory.request && applyRuntimeMemory.limit === memory.limit, `${item.serviceId} artifact-registry memory must come from deploy.json`, applyRuntimeMemory); - assertCondition(applyRuntimeHealth.deployMetadataRequired === health.deployMetadataRequired, `${item.serviceId} artifact-registry deploy metadata requirement must come from deploy.json`, applyRuntimeHealth); - - const implicitPlan = asRecord(await runArtifactRegistryCommand([ - "deploy-service", - "--env", - "dev", - "--service", - item.serviceId, - "--commit", - commit, - "--dry-run", - ]), `${item.serviceId} implicit deploy.json dry-run result`); - assertCondition(asRecord(implicitPlan.registry, `${item.serviceId} implicit registry`).repository === artifact.repository, `${item.serviceId} implicit artifact-registry dry-run must read deploy.json from file`, implicitPlan); - assertCondition(asRecord(implicitPlan.driftCheck, `${item.serviceId} implicit driftCheck`).ok === true, `${item.serviceId} implicit artifact-registry dry-run must drift-check deploy.json`, implicitPlan); - - const manifestMirror = k3sManifestExecutorMirror(service); - assertCondition(manifestMirror !== null, `${item.serviceId} deploy.json contract should locate a k8s manifest mirror`); - const cleanDrifts = compareDeployJsonExecutorMirrors(service, "dev", [manifestMirror!]); - assertCondition(cleanDrifts.length === 0, `${item.serviceId} current k8s manifest mirror should match deploy.json contract`, cleanDrifts); - - const driftMirror: DeployJsonExecutorMirror = { - ...manifestMirror!, - runtime: { - ...manifestMirror!.runtime, - containerPort: item.driftPort, - memory: { - ...manifestMirror!.runtime?.memory, - limit: "384Mi", - }, - health: { - ...manifestMirror!.runtime?.health, - deployMetadataRequired: false, - }, - }, - }; - const drift = compareDeployJsonExecutorMirrors(service, "dev", [driftMirror]); - const driftResult = deployJsonDriftResult(service, "dev", drift); - const driftPayload = asRecord(driftResult.drift, `${item.serviceId} drift result payload`); - const driftItems = asArray(driftPayload.items, `${item.serviceId} drift result items`).map((entry) => asRecord(entry, "drift item")); - assertCondition(driftResult.ok === false, `${item.serviceId} drift result must be non-ok`, driftResult); - assertCondition(driftResult.error === "deploy-json-drift", `${item.serviceId} drift result should use structured deploy-json-drift error`, driftResult); - assertCondition(driftItems.some((entry) => entry.field === "runtime.containerPort" && entry.expected === runtime.containerPort && entry.actual === item.driftPort), `${item.serviceId} drift result should report port mismatch`, driftItems); - assertCondition(driftItems.some((entry) => entry.field === "runtime.memory.limit" && entry.expected === "512Mi" && entry.actual === "384Mi"), `${item.serviceId} drift result should report memory mismatch`, driftItems); - assertCondition(driftItems.some((entry) => entry.field === "runtime.health.deployMetadataRequired" && entry.expected === true && entry.actual === false), `${item.serviceId} drift result should report deploy metadata requirement mismatch`, driftItems); - verifiedServices.push(item.serviceId); -} - -process.stdout.write(`${JSON.stringify({ - ok: true, - checks: [ - "deploy executor passes the deploy.json service contract to artifact-registry dry-run", - "artifact-registry dry-run reads k3s user-service image, target, port, memory and deploy metadata fields from deploy.json", - "k8s manifest target/port/memory/deploy metadata are treated as derived mirrors and checked for drift", - "drift preflight returns structured deploy-json-drift with field-level expected/actual values", - ], - services: verifiedServices, -}, null, 2)}\n`); diff --git a/scripts/issue-9-mdtodo-health-metadata-contract-test.ts b/scripts/issue-9-mdtodo-health-metadata-contract-test.ts deleted file mode 100644 index 3edaf53a..00000000 --- a/scripts/issue-9-mdtodo-health-metadata-contract-test.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { spawn, spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { createServer } from "node:net"; -import { rootPath } from "./src/config"; - -type JsonRecord = Record; - -const mdtodoCommit = "595de3d320b73ec006794440b32db48b3ad14d2b"; -const mdtodoRepo = "https://github.com/pikasTech/unidesk"; -const mdtodoSourcePath = "src/components/microservices/mdtodo/src/index.ts"; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function asRecord(value: unknown, label: string): JsonRecord { - assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, value); - return value as JsonRecord; -} - -function manifestService(manifest: JsonRecord, environment: "dev" | "prod", serviceId: string): JsonRecord { - const environments = asRecord(manifest.environments, "deploy.json.environments"); - const env = asRecord(environments[environment], `deploy.json.environments.${environment}`); - const services = Array.isArray(env.services) ? env.services.map((item, index) => asRecord(item, `${environment}.services[${index}]`)) : []; - const service = services.find((item) => item.id === serviceId); - assertCondition(service !== undefined, `deploy.json ${environment} must include ${serviceId}`, env); - return service as JsonRecord; -} - -async function reservePort(): Promise { - const server = createServer(); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", resolve); - }); - const address = server.address(); - const port = typeof address === "object" && address !== null ? address.port : 0; - await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); - assertCondition(port > 0, "failed to reserve a local port", address); - return port; -} - -async function fetchJson(url: string): Promise { - const response = await fetch(url); - const text = await response.text(); - let body: unknown; - try { - body = JSON.parse(text) as unknown; - } catch { - body = { raw: text }; - } - assertCondition(response.ok, `request failed: ${url}`, { status: response.status, body }); - return asRecord(body, `response ${url}`); -} - -function assertDeployMetadata(body: JsonRecord, endpoint: "/health" | "/live"): void { - const deploy = asRecord(body.deploy, `${endpoint}.deploy`); - assertCondition(body.ok === true, `${endpoint} must be ok`, body); - assertCondition(body.service === "mdtodo", `${endpoint} service mismatch`, body); - assertCondition(deploy.serviceId === "mdtodo", `${endpoint} deploy service id mismatch`, deploy); - assertCondition(deploy.repo === mdtodoRepo, `${endpoint} deploy repo mismatch`, deploy); - assertCondition(deploy.commit === mdtodoCommit, `${endpoint} deploy commit mismatch`, deploy); - assertCondition(deploy.requestedCommit === mdtodoCommit, `${endpoint} requested commit mismatch`, deploy); -} - -function assertDesiredCommitIncludesHealthMetadata(): void { - const manifest = asRecord(JSON.parse(readFileSync(rootPath("deploy.json"), "utf8")) as unknown, "deploy.json"); - for (const environment of ["dev", "prod"] as const) { - const service = manifestService(manifest, environment, "mdtodo"); - assertCondition(service.repo === mdtodoRepo, `mdtodo ${environment} repo mismatch`, service); - assertCondition(service.commitId === mdtodoCommit, `mdtodo ${environment} desired commit must include health deploy metadata`, service); - } - - const desiredSource = spawnSync("git", ["show", `${mdtodoCommit}:${mdtodoSourcePath}`], { - cwd: rootPath(), - encoding: "utf8", - maxBuffer: 4 * 1024 * 1024, - }); - assertCondition(desiredSource.status === 0, "must be able to inspect the desired mdtodo source commit", { - status: desiredSource.status, - stderr: desiredSource.stderr.slice(-1000), - }); - assertCondition(desiredSource.stdout.includes("UNIDESK_DEPLOY_SERVICE_ID"), "desired mdtodo source must read deploy service id", {}); - assertCondition(desiredSource.stdout.includes("UNIDESK_DEPLOY_COMMIT"), "desired mdtodo source must read deploy commit", {}); - assertCondition(desiredSource.stdout.includes("UNIDESK_DEPLOY_REQUESTED_COMMIT"), "desired mdtodo source must read deploy requested commit", {}); - assertCondition(desiredSource.stdout.includes("deploy:"), "desired mdtodo source must expose deploy metadata in health/live payloads", {}); -} - -async function main(): Promise { - assertDesiredCommitIncludesHealthMetadata(); - - const port = await reservePort(); - const tempRoot = mkdtempSync(join(tmpdir(), "unidesk-mdtodo-health-")); - const workspace = join(tempRoot, "workspace"); - const logDir = join(tempRoot, "logs"); - mkdirSync(workspace, { recursive: true }); - mkdirSync(logDir, { recursive: true }); - writeFileSync(join(workspace, "issue-9-mdtodo.md"), "# Issue 9 MDTODO\n\n## R1 Health metadata proof\n\nLocal contract seed.\n", "utf8"); - - const stdout: string[] = []; - const stderr: string[] = []; - const child = spawn("bun", ["run", "src/index.ts"], { - cwd: rootPath("src", "components", "microservices", "mdtodo"), - env: { - ...process.env, - HOST: "127.0.0.1", - PORT: String(port), - MDTODO_ROOT_DIR: workspace, - LOG_FILE: join(logDir, "mdtodo.jsonl"), - UNIDESK_DEPLOY_SERVICE_ID: "mdtodo", - UNIDESK_DEPLOY_REPO: mdtodoRepo, - UNIDESK_DEPLOY_COMMIT: mdtodoCommit, - UNIDESK_DEPLOY_REQUESTED_COMMIT: mdtodoCommit, - }, - stdio: ["ignore", "pipe", "pipe"], - }); - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - child.stdout.on("data", (chunk) => stdout.push(String(chunk))); - child.stderr.on("data", (chunk) => stderr.push(String(chunk))); - - try { - let health: JsonRecord | null = null; - for (let attempt = 1; attempt <= 80; attempt += 1) { - if (child.exitCode !== null) break; - try { - health = await fetchJson(`http://127.0.0.1:${port}/health`); - break; - } catch { - await new Promise((resolve) => setTimeout(resolve, 100)); - } - } - assertCondition(health !== null, "mdtodo local health endpoint did not become ready", { - exitCode: child.exitCode, - stdout: stdout.join("").slice(-2000), - stderr: stderr.join("").slice(-2000), - }); - assertDeployMetadata(health as JsonRecord, "/health"); - assertCondition((health as JsonRecord).fileCount === 1, "/health should scan the seeded MDTODO file", health); - - const live = await fetchJson(`http://127.0.0.1:${port}/live`); - assertDeployMetadata(live, "/live"); - assertCondition(existsSync(join(logDir, "mdtodo.jsonl")) || stdout.join("").includes("service_started"), "local service should produce visible startup output", { - stdout: stdout.join("").slice(-1200), - stderr: stderr.join("").slice(-1200), - }); - } finally { - child.kill("SIGTERM"); - await new Promise((resolve) => setTimeout(resolve, 100)); - if (child.exitCode === null) child.kill("SIGKILL"); - rmSync(tempRoot, { recursive: true, force: true }); - } - - process.stdout.write(`${JSON.stringify({ - ok: true, - checks: [ - "deploy.json dev/prod mdtodo desired commit points at a health-metadata-capable source commit", - "desired mdtodo source commit reads UNIDESK_DEPLOY_* metadata", - "local /health exposes deploy.serviceId, repo, commit and requestedCommit", - "local /live exposes the same deploy metadata", - "local /health proves a readable seeded MDTODO markdown workspace", - ], - serviceId: "mdtodo", - desiredCommit: mdtodoCommit, - }, null, 2)}\n`); -} - -if (import.meta.main) { - await main(); -} diff --git a/scripts/issue-9-user-service-artifact-gap-contract-test.ts b/scripts/issue-9-user-service-artifact-gap-contract-test.ts deleted file mode 100644 index 339d15ab..00000000 --- a/scripts/issue-9-user-service-artifact-gap-contract-test.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { readFileSync } from "node:fs"; -import { rootPath } from "./src/config"; -import { runArtifactRegistryCommand } from "./src/artifact-registry"; - -type Environment = "dev" | "prod"; -type ServiceId = "mdtodo" | "claudeqq" | "todo-note"; -type JsonRecord = Record; - -type ServiceContract = { - serviceId: ServiceId; - desiredCommit: string; - runtimeCommit: string | null; - runtimeCommitSource: string; - artifactExists: boolean; - devStatus: string; - prodStatus: string; - blockedScopes: string[]; - recommendedAction: string; - sourceRepo: string; - dockerfile: string; - registryRepository: string; - consumerKind: "d601-k3s" | "compose"; -}; - -const contracts: ServiceContract[] = [ - { - serviceId: "mdtodo", - desiredCommit: "595de3d320b73ec006794440b32db48b3ad14d2b", - runtimeCommit: "75fb6757b2504ba86d61f2587fb34a9c9ed4019a", - runtimeCommitSource: "prod Deployment annotations; desired artifact target was advanced because 75fb6757 predates mdtodo /health.deploy metadata", - artifactExists: false, - devStatus: "missing-dev-service", - prodStatus: "healthy-prod-annotation-stale-after-health-metadata-repin", - blockedScopes: ["registry-artifact", "dev-service", "runtime-health-metadata-proof", "prod-runtime-commit-drift"], - recommendedAction: "Publish the desired artifact that includes mdtodo health deploy metadata, create/verify unidesk-dev/mdtodo-dev, then run focused dev smoke before deciding whether prod needs replacement.", - sourceRepo: "https://github.com/pikasTech/unidesk", - dockerfile: "src/components/microservices/mdtodo/Dockerfile", - registryRepository: "unidesk/mdtodo", - consumerKind: "d601-k3s", - }, - { - serviceId: "claudeqq", - desiredCommit: "203b1f46684c91340ecbbd8a74502bd55e4f2011", - runtimeCommit: "203b1f46684c91340ecbbd8a74502bd55e4f2011", - runtimeCommitSource: "prod /health deploy.commit and deploy.requestedCommit", - artifactExists: false, - devStatus: "missing-dev-service", - prodStatus: "healthy-prod-health-aligned-event-api-unverified", - blockedScopes: ["registry-artifact", "dev-service", "event-api-surface"], - recommendedAction: "Publish the desired artifact, create/verify unidesk-dev/claudeqq-dev, then resolve or document the event API paths before prod artifact replacement.", - sourceRepo: "https://gitee.com/lyon1998/agent_skills", - dockerfile: "claudeqq/Dockerfile", - registryRepository: "unidesk/claudeqq", - consumerKind: "d601-k3s", - }, - { - serviceId: "todo-note", - desiredCommit: "a14ce0eb855a685fa17b47adacd54623e72cd2ff", - runtimeCommit: null, - runtimeCommitSource: "prod health and container labels do not expose source commit", - artifactExists: false, - devStatus: "consumer-plan-only-no-live-dev", - prodStatus: "healthy-behavior-no-commit-proof", - blockedScopes: ["registry-artifact", "runtime-commit-proof", "health-deploy-metadata"], - recommendedAction: "Publish the desired artifact, then use the no-build Compose artifact consumer to recreate only todo-note and verify image labels plus health deploy metadata.", - sourceRepo: "https://gitee.com/Lyon1998/todo_note", - dockerfile: "Dockerfile", - registryRepository: "unidesk/todo-note", - consumerKind: "compose", - }, -]; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function asRecord(value: unknown, label: string): JsonRecord { - assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, value); - return value as JsonRecord; -} - -function asArray(value: unknown, label: string): unknown[] { - assertCondition(Array.isArray(value), `${label} must be an array`, value); - return value as unknown[]; -} - -function strings(value: unknown, label: string): string[] { - return asArray(value, label).map(String); -} - -function manifestService(manifest: JsonRecord, environment: Environment, serviceId: ServiceId): JsonRecord { - const environments = asRecord(manifest.environments, "deploy.json.environments"); - const env = asRecord(environments[environment], `deploy.json.environments.${environment}`); - const services = asArray(env.services, `deploy.json.environments.${environment}.services`); - const service = services.map((item, index) => asRecord(item, `${environment}.services[${index}]`)).find((item) => item.id === serviceId); - assertCondition(service !== undefined, `deploy.json ${environment} must include ${serviceId}`, env); - return service as JsonRecord; -} - -function artifactCatalogEntry(catalog: JsonRecord, serviceId: ServiceId): JsonRecord { - const artifacts = asArray(catalog.artifacts, "CI.json.artifacts").map((item, index) => asRecord(item, `CI.json.artifacts[${index}]`)); - const artifact = artifacts.find((item) => item.serviceId === serviceId); - assertCondition(artifact !== undefined, `CI.json must include ${serviceId}`, catalog); - return artifact as JsonRecord; -} - -function assertDeployJson(): void { - const manifest = asRecord(JSON.parse(readFileSync(rootPath("deploy.json"), "utf8")) as unknown, "deploy.json"); - assertCondition(manifest.schemaVersion === 2, "deploy.json must use schemaVersion=2", manifest); - for (const contract of contracts) { - for (const environment of ["dev", "prod"] as const) { - const service = manifestService(manifest, environment, contract.serviceId); - assertCondition(service.repo === contract.sourceRepo, `${contract.serviceId} ${environment} repo mismatch`, service); - assertCondition(service.commitId === contract.desiredCommit, `${contract.serviceId} ${environment} desired commit mismatch`, service); - } - } -} - -function assertCiCatalog(): void { - const catalog = asRecord(JSON.parse(readFileSync(rootPath("CI.json"), "utf8")) as unknown, "CI.json"); - for (const contract of contracts) { - const artifact = artifactCatalogEntry(catalog, contract.serviceId); - const source = asRecord(artifact.source, `${contract.serviceId} CI source`); - const image = asRecord(artifact.image, `${contract.serviceId} CI image`); - assertCondition(artifact.kind === "source-build", `${contract.serviceId} producer must be source-build`, artifact); - assertCondition(artifact.status === "supported", `${contract.serviceId} producer must be supported`, artifact); - assertCondition(artifact.producer === "ci publish-user-service", `${contract.serviceId} producer command mismatch`, artifact); - assertCondition(source.repo === contract.sourceRepo, `${contract.serviceId} source repo mismatch`, source); - assertCondition(source.dockerfile === contract.dockerfile, `${contract.serviceId} Dockerfile mismatch`, source); - assertCondition(image.repository === contract.registryRepository, `${contract.serviceId} registry repository mismatch`, image); - } -} - -function assertNormalizedStatus(): void { - for (const contract of contracts) { - assertCondition(/^[0-9a-f]{40}$/u.test(contract.desiredCommit), `${contract.serviceId} desiredCommit must be a full sha`, contract); - assertCondition(contract.runtimeCommit === null || /^[0-9a-f]{40}$/u.test(contract.runtimeCommit), `${contract.serviceId} runtimeCommit must be a full sha or null`, contract); - assertCondition(contract.artifactExists === false, `${contract.serviceId} desired artifact should remain recorded as missing`, contract); - assertCondition(contract.devStatus.length > 0, `${contract.serviceId} devStatus must be structured`, contract); - assertCondition(contract.prodStatus.length > 0, `${contract.serviceId} prodStatus must be structured`, contract); - assertCondition(contract.blockedScopes.length > 0, `${contract.serviceId} blockedScopes must not be empty`, contract); - assertCondition(contract.recommendedAction.length > 0, `${contract.serviceId} recommendedAction must not be empty`, contract); - } -} - -function assertCommonDryRun(plan: JsonRecord, contract: ServiceContract, environment: Environment): void { - const source = asRecord(plan.source, `${contract.serviceId} ${environment} source`); - const registry = asRecord(plan.registry, `${contract.serviceId} ${environment} registry`); - const build = asRecord(plan.build, `${contract.serviceId} ${environment} build`); - const labels = asRecord(plan.requiredLabels, `${contract.serviceId} ${environment} labels`); - const registryProbe = asRecord(plan.registryProbe, `${contract.serviceId} ${environment} registryProbe`); - - assertCondition(plan.ok === true && plan.supported === true, `${contract.serviceId} ${environment} dry-run must be supported`, plan); - assertCondition(plan.dryRun === true && plan.mutation === false, `${contract.serviceId} ${environment} dry-run must be non-mutating`, plan); - assertCondition(plan.environment === environment, `${contract.serviceId} ${environment} environment mismatch`, plan); - assertCondition(plan.providerId === "D601", `${contract.serviceId} ${environment} provider mismatch`, plan); - assertCondition(plan.serviceId === contract.serviceId, `${contract.serviceId} ${environment} service id mismatch`, plan); - assertCondition(plan.commit === contract.desiredCommit, `${contract.serviceId} ${environment} commit mismatch`, plan); - const deployRef = `deploy.json#environments.${environment}.services.${contract.serviceId}`; - const allowedDeployRefs = [deployRef, `origin/master:${deployRef}`]; - assertCondition(allowedDeployRefs.includes(String(plan.deployRef)), `${contract.serviceId} ${environment} deployRef mismatch`, plan); - assertCondition(plan.sourceImage === `127.0.0.1:5000/${contract.registryRepository}:${contract.desiredCommit}`, `${contract.serviceId} ${environment} source image mismatch`, plan); - assertCondition(source.repo === contract.sourceRepo, `${contract.serviceId} ${environment} source repo mismatch`, source); - assertCondition(source.commit === contract.desiredCommit, `${contract.serviceId} ${environment} source commit mismatch`, source); - assertCondition(source.dockerfile === contract.dockerfile, `${contract.serviceId} ${environment} source dockerfile mismatch`, source); - assertCondition(registry.repository === contract.registryRepository, `${contract.serviceId} ${environment} registry repository mismatch`, registry); - assertCondition(registry.imageRef === plan.sourceImage, `${contract.serviceId} ${environment} registry image mismatch`, registry); - assertCondition(registry.digest === null, `${contract.serviceId} ${environment} dry-run must not fake a digest`, registry); - assertCondition(String(registry.digestSource ?? "").includes("live apply must read this digest"), `${contract.serviceId} ${environment} digest source mismatch`, registry); - assertCondition(registryProbe.method === "HEAD", `${contract.serviceId} ${environment} registry probe must be HEAD`, registryProbe); - assertCondition(labels["unidesk.ai/service-id"] === contract.serviceId, `${contract.serviceId} ${environment} service label mismatch`, labels); - assertCondition(labels["unidesk.ai/source-repo"] === contract.sourceRepo, `${contract.serviceId} ${environment} source repo label mismatch`, labels); - assertCondition(labels["unidesk.ai/source-commit"] === contract.desiredCommit, `${contract.serviceId} ${environment} commit label mismatch`, labels); - assertCondition(labels["unidesk.ai/dockerfile"] === contract.dockerfile, `${contract.serviceId} ${environment} Dockerfile label mismatch`, labels); - assertCondition(build.willCompile === false, `${contract.serviceId} ${environment} CD must not compile`, build); - assertCondition(build.willRunCargoBuild === false, `${contract.serviceId} ${environment} CD must not run cargo build`, build); - assertCondition(build.willRunDockerBuild === false, `${contract.serviceId} ${environment} CD must not run docker build`, build); - assertCondition(build.willRunDockerComposeBuild === false, `${contract.serviceId} ${environment} CD must not run docker compose build`, build); - assertCondition(build.producerBoundary === "ci publish-user-service", `${contract.serviceId} ${environment} producer boundary mismatch`, build); - assertCondition(String(plan.boundary ?? "").includes("artifact-consumer only"), `${contract.serviceId} ${environment} boundary must be artifact-only`, plan); - assertCondition(String(plan.boundary ?? "").includes("never builds source"), `${contract.serviceId} ${environment} boundary must forbid source builds`, plan); -} - -function assertK3sDryRun(plan: JsonRecord, contract: ServiceContract, environment: Environment): void { - const target = asRecord(plan.target, `${contract.serviceId} ${environment} target`); - const validation = strings(plan.validation, `${contract.serviceId} ${environment} validation`); - const suffix = environment === "dev" ? "-dev" : ""; - assertCondition(target.kind === "d601-k3s", `${contract.serviceId} ${environment} target kind mismatch`, target); - assertCondition(target.namespace === (environment === "dev" ? "unidesk-dev" : "unidesk"), `${contract.serviceId} ${environment} namespace mismatch`, target); - assertCondition(target.deployment === `${contract.serviceId}${suffix}`, `${contract.serviceId} ${environment} deployment mismatch`, target); - assertCondition(target.service === `${contract.serviceId}${suffix}`, `${contract.serviceId} ${environment} service mismatch`, target); - assertCondition(String(target.deployCommandShape ?? "").includes("kubectl set image"), `${contract.serviceId} ${environment} command must be k3s image update`, target); - assertCondition(validation.some((line) => line.includes("Kubernetes API service proxy") || line.includes("service health")), `${contract.serviceId} ${environment} validation should include k3s service health`, validation); -} - -function assertComposeDryRun(plan: JsonRecord, contract: ServiceContract, environment: Environment): void { - const target = asRecord(plan.target, `${contract.serviceId} ${environment} target`); - const validation = strings(plan.validation, `${contract.serviceId} ${environment} validation`); - assertCondition(target.kind === "compose", `${contract.serviceId} ${environment} target kind mismatch`, target); - assertCondition(target.runtimeHost === "main-server", `${contract.serviceId} ${environment} runtime host mismatch`, target); - assertCondition(target.composeService === "todo-note", `${contract.serviceId} ${environment} compose service mismatch`, target); - assertCondition(target.containerName === "todo-note-backend", `${contract.serviceId} ${environment} container mismatch`, target); - assertCondition(target.deployCommandShape === "docker compose up -d --no-build --no-deps --force-recreate todo-note", `${contract.serviceId} ${environment} command shape mismatch`, target); - const runtimeProof = asRecord(plan.runtimeProof, `${contract.serviceId} ${environment} runtimeProof`); - const requiredEnvKeys = asArray(runtimeProof.requiredEnvKeys, `${contract.serviceId} ${environment} runtime proof env keys`).map(String); - assertCondition(runtimeProof.kind === "compose-container-runtime-metadata", `${contract.serviceId} ${environment} runtime proof kind mismatch`, runtimeProof); - assertCondition(runtimeProof.sourceDirectoryUsed === false, `${contract.serviceId} ${environment} runtime proof must not use source directory guesses`, runtimeProof); - assertCondition(requiredEnvKeys.includes("UNIDESK_DEPLOY_COMMIT"), `${contract.serviceId} ${environment} runtime proof must use generic commit env`, runtimeProof); - assertCondition(requiredEnvKeys.includes("UNIDESK_TODO_NOTE_DEPLOY_COMMIT"), `${contract.serviceId} ${environment} runtime proof must use service commit env`, runtimeProof); - assertCondition(validation.some((line) => line.includes("deploy.commit/deploy.requestedCommit") && line.includes("Compose container runtime metadata")), `${contract.serviceId} ${environment} validation must require synthetic health deploy metadata`, validation); -} - -async function assertDryRuns(): Promise { - for (const contract of contracts) { - for (const environment of ["dev", "prod"] as const) { - const plan = asRecord(await runArtifactRegistryCommand([ - "deploy-service", - "--env", - environment, - "--service", - contract.serviceId, - "--commit", - contract.desiredCommit, - "--dry-run", - ]), `${contract.serviceId} ${environment} artifact dry-run`); - assertCommonDryRun(plan, contract, environment); - if (contract.consumerKind === "d601-k3s") assertK3sDryRun(plan, contract, environment); - else assertComposeDryRun(plan, contract, environment); - } - } -} - -async function main(): Promise { - assertDeployJson(); - assertCiCatalog(); - assertNormalizedStatus(); - await assertDryRuns(); - - process.stdout.write(`${JSON.stringify({ - ok: true, - checks: [ - "deploy.json dev/prod desired commits match the issue #9 service matrix", - "CI.json keeps mdtodo, claudeqq and todo-note on supported ci publish-user-service source-build producers", - "normalized status records expose desiredCommit, runtimeCommit, artifactExists, devStatus, prodStatus, blockedScopes and recommendedAction", - "dev/prod artifact-registry dry-runs are non-mutating, commit-pinned and no-build", - "mdtodo and claudeqq dry-runs target D601 k3s consumers", - "todo-note dry-runs target the main-server Compose consumer with no-build/no-deps recreate shape", - ], - services: contracts.map((contract) => ({ - serviceId: contract.serviceId, - desiredCommit: contract.desiredCommit, - runtimeCommit: contract.runtimeCommit, - runtimeCommitSource: contract.runtimeCommitSource, - artifactExists: contract.artifactExists, - devStatus: contract.devStatus, - prodStatus: contract.prodStatus, - blockedScopes: contract.blockedScopes, - recommendedAction: contract.recommendedAction, - })), - }, null, 2)}\n`); -} - -if (import.meta.main) { - await main(); -} diff --git a/scripts/issue-9-user-service-deploy-apply-dry-run-contract-test.ts b/scripts/issue-9-user-service-deploy-apply-dry-run-contract-test.ts deleted file mode 100644 index cbc2077a..00000000 --- a/scripts/issue-9-user-service-deploy-apply-dry-run-contract-test.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { spawnSync } from "node:child_process"; - -type JsonRecord = Record; -type Environment = "dev" | "prod"; - -type ServiceCase = - | { - serviceId: "mdtodo"; - environment: "dev"; - commit: "595de3d320b73ec006794440b32db48b3ad14d2b"; - sourceRepo: "https://github.com/pikasTech/unidesk"; - dockerfile: "src/components/microservices/mdtodo/Dockerfile"; - targetKind: "d601-k3s"; - namespace: "unidesk-dev"; - deployment: "mdtodo-dev"; - service: "mdtodo-dev"; - runtimeImage: "unidesk-mdtodo:595de3d320b73ec006794440b32db48b3ad14d2b"; - expectedValidationSnippets: string[]; - rollbackType: "d601-k3s-previous-commit"; - } - | { - serviceId: "claudeqq"; - environment: "dev"; - commit: "203b1f46684c91340ecbbd8a74502bd55e4f2011"; - sourceRepo: "https://gitee.com/lyon1998/agent_skills"; - dockerfile: "claudeqq/Dockerfile"; - targetKind: "d601-k3s"; - namespace: "unidesk-dev"; - deployment: "claudeqq-dev"; - service: "claudeqq-dev"; - runtimeImage: "unidesk-claudeqq:203b1f46684c91340ecbbd8a74502bd55e4f2011"; - expectedValidationSnippets: string[]; - rollbackType: "d601-k3s-previous-commit"; - } - | { - serviceId: "todo-note"; - environment: "prod"; - commit: "a14ce0eb855a685fa17b47adacd54623e72cd2ff"; - sourceRepo: "https://gitee.com/Lyon1998/todo_note"; - dockerfile: "Dockerfile"; - targetKind: "compose"; - runtimeHost: "main-server"; - composeService: "todo-note"; - containerName: "todo-note-backend"; - runtimeImage: "todo-note:a14ce0eb855a685fa17b47adacd54623e72cd2ff"; - deployEnvPrefix: "UNIDESK_TODO_NOTE_DEPLOY"; - expectedValidationSnippets: string[]; - rollbackType: "compose-retag-recreate"; - }; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function asRecord(value: unknown, label: string): JsonRecord { - assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, value); - return value as JsonRecord; -} - -function asArray(value: unknown, label: string): unknown[] { - assertCondition(Array.isArray(value), `${label} must be an array`, value); - return value as unknown[]; -} - -function runDeployApplyDryRun(environment: Environment, serviceId: string): JsonRecord { - const result = spawnSync("bun", ["scripts/cli.ts", "deploy", "apply", "--env", environment, "--service", serviceId, "--dry-run"], { - cwd: process.cwd(), - encoding: "utf8", - maxBuffer: 8 * 1024 * 1024, - }); - assertCondition(result.status === 0, `deploy apply dry-run should exit 0 for ${environment}/${serviceId}`, { - status: result.status, - stdout: result.stdout.slice(-2000), - stderr: result.stderr.slice(-2000), - }); - const envelope = asRecord(JSON.parse(result.stdout) as unknown, "cli envelope"); - assertCondition(envelope.ok === true, `deploy apply dry-run envelope should be ok for ${environment}/${serviceId}`, envelope); - const data = asRecord(envelope.data, "deploy apply dry-run data"); - assertCondition(data.action === "apply", `deploy apply dry-run should report action=apply for ${environment}/${serviceId}`, data); - assertCondition(data.environment === environment, `deploy apply dry-run environment mismatch for ${environment}/${serviceId}`, data); - assertCondition(data.executor === "d601-registry-artifact-consumer", `deploy apply dry-run executor mismatch for ${environment}/${serviceId}`, data); - assertCondition(data.dryRun === true, `deploy apply dry-run should set dryRun=true for ${environment}/${serviceId}`, data); - const results = asArray(data.results, `deploy apply dry-run results for ${environment}/${serviceId}`); - assertCondition(results.length === 1, `deploy apply dry-run should return one service result for ${environment}/${serviceId}`, data); - return asRecord(results[0], `deploy apply dry-run service result for ${environment}/${serviceId}`); -} - -function assertK3sTarget(result: JsonRecord, item: Extract): void { - const target = asRecord(result.target, `${item.serviceId} target`); - const deployments = asArray(target.deployments, `${item.serviceId} deployments`).map((deployment, index) => asRecord(deployment, `${item.serviceId} deployment ${index}`)); - assertCondition(target.kind === "d601-k3s", `${item.serviceId} target kind mismatch`, target); - assertCondition(target.namespace === item.namespace, `${item.serviceId} namespace mismatch`, target); - assertCondition(target.deployment === item.deployment, `${item.serviceId} deployment mismatch`, target); - assertCondition(target.service === item.service, `${item.serviceId} service mismatch`, target); - assertCondition(target.runtimeImage === item.runtimeImage, `${item.serviceId} runtime image mismatch`, target); - assertCondition(target.stableImage === `unidesk-${item.serviceId}:dev`, `${item.serviceId} stable image mismatch`, target); - assertCondition(String(target.deployCommandShape ?? "").includes("kubectl set image"), `${item.serviceId} deploy command shape must be k3s`, target); - assertCondition(deployments.length === 1 && deployments[0]?.name === item.deployment, `${item.serviceId} deployment list mismatch`, deployments); - assertCondition(deployments[0]?.containerName === item.serviceId, `${item.serviceId} deployment container mismatch`, deployments); -} - -function assertComposeTarget(result: JsonRecord, item: Extract): void { - const target = asRecord(result.target, `${item.serviceId} target`); - assertCondition(target.kind === "compose", `${item.serviceId} target kind mismatch`, target); - assertCondition(target.runtimeHost === item.runtimeHost, `${item.serviceId} runtime host mismatch`, target); - assertCondition(target.composeService === item.composeService, `${item.serviceId} compose service mismatch`, target); - assertCondition(target.containerName === item.containerName, `${item.serviceId} container mismatch`, target); - assertCondition(target.targetImage === item.composeService, `${item.serviceId} target image mismatch`, target); - assertCondition(target.runtimeImage === item.runtimeImage, `${item.serviceId} runtime image mismatch`, target); - assertCondition(target.deployEnvPrefix === item.deployEnvPrefix, `${item.serviceId} deploy env prefix mismatch`, target); - assertCondition(target.deployCommandShape === "docker compose up -d --no-build --no-deps --force-recreate todo-note", `${item.serviceId} deploy command shape mismatch`, target); -} - -function assertCommonContract(result: JsonRecord, item: ServiceCase): void { - const source = asRecord(result.source, `${item.serviceId} source`); - const registry = asRecord(result.registry, `${item.serviceId} registry`); - const build = asRecord(result.build, `${item.serviceId} build`); - const labels = asRecord(result.requiredLabels, `${item.serviceId} requiredLabels`); - const registryProbe = asRecord(result.registryProbe, `${item.serviceId} registryProbe`); - const validation = asArray(result.validation, `${item.serviceId} validation`).map(String); - const liveApply = asRecord(result.liveApply, `${item.serviceId} liveApply`); - const rollback = asRecord(result.rollback, `${item.serviceId} rollback`); - - assertCondition(result.ok === true, `${item.serviceId} dry-run service result must be ok`, result); - assertCondition(result.supported === true, `${item.serviceId} dry-run service result must be supported`, result); - assertCondition(result.dryRun === true && result.mutation === false, `${item.serviceId} dry-run must be non-mutating`, result); - assertCondition(result.environment === item.environment, `${item.serviceId} environment mismatch`, result); - assertCondition(result.providerId === "D601", `${item.serviceId} provider mismatch`, result); - assertCondition(result.serviceId === item.serviceId, `${item.serviceId} service id mismatch`, result); - assertCondition(result.commit === item.commit, `${item.serviceId} commit mismatch`, result); - assertCondition(result.sourceRepo === item.sourceRepo, `${item.serviceId} source repo mismatch`, result); - assertCondition(result.deployRef === `origin/master:deploy.json#environments.${item.environment}.services.${item.serviceId}`, `${item.serviceId} deployRef mismatch`, result); - assertCondition(result.sourceImage === `127.0.0.1:5000/unidesk/${item.serviceId}:${item.commit}`, `${item.serviceId} source image mismatch`, result); - assertCondition(source.repo === item.sourceRepo, `${item.serviceId} source.repo mismatch`, source); - assertCondition(source.commit === item.commit, `${item.serviceId} source.commit mismatch`, source); - assertCondition(source.dockerfile === item.dockerfile, `${item.serviceId} source.dockerfile mismatch`, source); - assertCondition(registry.endpoint === "http://127.0.0.1:5000", `${item.serviceId} registry endpoint mismatch`, registry); - assertCondition(registry.repository === `unidesk/${item.serviceId}`, `${item.serviceId} registry repository mismatch`, registry); - assertCondition(registry.tag === item.commit, `${item.serviceId} registry tag mismatch`, registry); - assertCondition(registry.imageRef === `127.0.0.1:5000/unidesk/${item.serviceId}:${item.commit}`, `${item.serviceId} registry imageRef mismatch`, registry); - assertCondition(registry.digest === null, `${item.serviceId} dry-run must not fake a digest`, registry); - assertCondition(String(registry.digestSource ?? "").includes("live apply must read this digest"), `${item.serviceId} digest source mismatch`, registry); - assertCondition(build.willCompile === false, `${item.serviceId} dry-run must not compile`, build); - assertCondition(build.willRunCargoBuild === false, `${item.serviceId} dry-run must not run cargo build`, build); - assertCondition(build.willRunDockerBuild === false, `${item.serviceId} dry-run must not run docker build`, build); - assertCondition(build.willRunDockerComposeBuild === false, `${item.serviceId} dry-run must not run docker compose build`, build); - assertCondition(build.producerBoundary === "ci publish-user-service", `${item.serviceId} producer boundary mismatch`, build); - assertCondition(labels["unidesk.ai/service-id"] === item.serviceId, `${item.serviceId} service label mismatch`, labels); - assertCondition(labels["unidesk.ai/source-repo"] === item.sourceRepo, `${item.serviceId} source repo label mismatch`, labels); - assertCondition(labels["unidesk.ai/source-commit"] === item.commit, `${item.serviceId} commit label mismatch`, labels); - assertCondition(labels["unidesk.ai/dockerfile"] === item.dockerfile, `${item.serviceId} dockerfile label mismatch`, labels); - assertCondition(registryProbe.method === "HEAD", `${item.serviceId} registry probe must be HEAD`, registryProbe); - assertCondition(String(registryProbe.url ?? "").includes(`/v2/unidesk/${item.serviceId}/manifests/${item.commit}`), `${item.serviceId} registry probe url mismatch`, registryProbe); - assertCondition(String(result.boundary ?? "").includes("artifact-consumer only"), `${item.serviceId} boundary should say artifact-consumer only`, result); - assertCondition(String(result.boundary ?? "").includes("never builds source on the runtime target"), `${item.serviceId} boundary should forbid target builds`, result); - assertCondition(liveApply.allowed === true, `${item.serviceId} dry-run should remain live-apply eligible`, liveApply); - assertCondition(liveApply.reason === null, `${item.serviceId} dry-run liveApply reason should be null`, liveApply); - assertCondition(rollback.type === item.rollbackType, `${item.serviceId} rollback type mismatch`, rollback); - assertCondition(String(rollback.commandShape ?? "").includes(""), `${item.serviceId} rollback command should keep previous-full-sha placeholder`, rollback); - - for (const snippet of item.expectedValidationSnippets) { - assertCondition(validation.some((line) => line.includes(snippet)), `${item.serviceId} validation should include ${snippet}`, validation); - } - if (item.serviceId === "todo-note") { - const runtimeProof = asRecord(result.runtimeProof, "todo-note runtimeProof"); - const requiredEnvKeys = asArray(runtimeProof.requiredEnvKeys, "todo-note runtime proof env keys").map(String); - assertCondition(runtimeProof.kind === "compose-container-runtime-metadata", "todo-note runtime proof kind mismatch", runtimeProof); - assertCondition(runtimeProof.sourceDirectoryUsed === false, "todo-note runtime proof must not use source directory guesses", runtimeProof); - assertCondition(requiredEnvKeys.includes("UNIDESK_DEPLOY_REQUESTED_COMMIT"), "todo-note proof should use generic requested commit env", runtimeProof); - assertCondition(requiredEnvKeys.includes("UNIDESK_TODO_NOTE_DEPLOY_REQUESTED_COMMIT"), "todo-note proof should use service requested commit env", runtimeProof); - } -} - -const cases: ServiceCase[] = [ - { - serviceId: "mdtodo", - environment: "dev", - commit: "595de3d320b73ec006794440b32db48b3ad14d2b", - sourceRepo: "https://github.com/pikasTech/unidesk", - dockerfile: "src/components/microservices/mdtodo/Dockerfile", - targetKind: "d601-k3s", - namespace: "unidesk-dev", - deployment: "mdtodo-dev", - service: "mdtodo-dev", - runtimeImage: "unidesk-mdtodo:595de3d320b73ec006794440b32db48b3ad14d2b", - expectedValidationSnippets: [ - "D601 registry /v2 manifest exists", - "native k3s containerd has the commit image and stable runtime image tag", - "service health via Kubernetes API service proxy returns the same deploy.commit and deploy.requestedCommit", - ], - rollbackType: "d601-k3s-previous-commit", - }, - { - serviceId: "claudeqq", - environment: "dev", - commit: "203b1f46684c91340ecbbd8a74502bd55e4f2011", - sourceRepo: "https://gitee.com/lyon1998/agent_skills", - dockerfile: "claudeqq/Dockerfile", - targetKind: "d601-k3s", - namespace: "unidesk-dev", - deployment: "claudeqq-dev", - service: "claudeqq-dev", - runtimeImage: "unidesk-claudeqq:203b1f46684c91340ecbbd8a74502bd55e4f2011", - expectedValidationSnippets: [ - "D601 registry /v2 manifest exists", - "native k3s containerd has the commit image and stable runtime image tag", - "service health via Kubernetes API service proxy returns the same deploy.commit and deploy.requestedCommit", - ], - rollbackType: "d601-k3s-previous-commit", - }, - { - serviceId: "todo-note", - environment: "prod", - commit: "a14ce0eb855a685fa17b47adacd54623e72cd2ff", - sourceRepo: "https://gitee.com/Lyon1998/todo_note", - dockerfile: "Dockerfile", - targetKind: "compose", - runtimeHost: "main-server", - composeService: "todo-note", - containerName: "todo-note-backend", - runtimeImage: "todo-note:a14ce0eb855a685fa17b47adacd54623e72cd2ff", - deployEnvPrefix: "UNIDESK_TODO_NOTE_DEPLOY", - expectedValidationSnippets: [ - "D601 registry /v2 manifest exists for the commit tag", - "running Compose container image label matches the requested commit", - "todo-note runtime health proof synthesizes deploy.commit/deploy.requestedCommit from Compose container runtime metadata", - "not source directory guesses", - ], - rollbackType: "compose-retag-recreate", - }, -]; - -for (const item of cases) { - const result = runDeployApplyDryRun(item.environment, item.serviceId); - if (item.targetKind === "d601-k3s") assertK3sTarget(result, item); - else assertComposeTarget(result, item); - assertCommonContract(result, item); -} - -process.stdout.write(`${JSON.stringify({ - ok: true, - checks: [ - "deploy apply --env dev --service mdtodo --dry-run remains a non-mutating D601 k3s artifact consumer", - "deploy apply --env dev --service claudeqq --dry-run remains a non-mutating D601 k3s artifact consumer", - "deploy apply --env prod --service todo-note --dry-run remains a non-mutating main-server Compose artifact consumer", - "dry-run output keeps registry/source/build boundaries and live-apply eligibility explicit", - "dry-run output keeps rollback hints and health validation snippets intact", - ], - services: cases.map((service) => ({ - serviceId: service.serviceId, - environment: service.environment, - commit: service.commit, - targetKind: service.targetKind, - })), -}, null, 2)}\n`); diff --git a/scripts/microservice-health-output-contract-test.ts b/scripts/microservice-health-output-contract-test.ts deleted file mode 100644 index d54dbde0..00000000 --- a/scripts/microservice-health-output-contract-test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { summarizeMicroserviceHealthResponse } from "./src/microservices"; -import { summarizeRemoteMicroserviceResponse } from "./src/remote"; - -type JsonRecord = Record; - -function assertCondition(condition: unknown, message: string, detail: JsonRecord = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function asRecord(value: unknown): JsonRecord { - assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), "expected JSON object", { value }); - return value as JsonRecord; -} - -function manyIds(prefix: string, count: number): string[] { - return Array.from({ length: count }, (_, index) => `${prefix}-${String(index + 1).padStart(3, "0")}`); -} - -function longText(prefix: string, count: number): string { - return Array.from({ length: count }, (_, index) => `${prefix} diagnostic row ${index + 1} with queue detail and commander-noise payload`).join("\n"); -} - -function largeCodeQueueHealth(): JsonRecord { - return { - ok: true, - status: 200, - body: { - ok: true, - service: "code-queue", - instanceId: "D601-code-queue-scheduler", - role: "scheduler", - deploy: { - commit: "abcdef1234567890", - requestedCommit: "abcdef1234567890", - }, - databaseReady: true, - schedulerEnabled: true, - queue: { - total: 640, - counts: { - running: 3, - judging: 2, - queued: 41, - retry_wait: 4, - succeeded: 560, - failed: 30, - }, - queues: manyIds("queue", 90).map((id) => ({ - id, - name: id, - total: 12, - counts: { queued: 5, succeeded: 7 }, - diagnostics: longText(id, 18), - })), - queueCount: 90, - activeRunSlotCount: 5, - activeTaskIds: manyIds("active-task", 12), - activeTaskId: "active-task-001", - activeQueueIds: manyIds("active-queue", 12), - processingQueueIds: manyIds("processing-queue", 8), - processing: true, - orphanedActiveTaskCount: 0, - unreadTerminal: 7, - defaultQueueId: "default", - defaultModel: "gpt-5.5", - defaultReasoningEffort: "xhigh", - maxActiveQueues: 0, - schedulerHeartbeatStaleMs: 120000, - }, - executionDiagnostics: { - state: "split-brain", - degraded: true, - splitBrain: true, - splitBrainLive: true, - effectiveLiveness: "live", - recommendedAction: "continue-supervision", - executionStateSource: "postgres-control-plane", - controlPlane: "D601-code-queue-scheduler", - databaseActiveTaskCount: 12, - schedulerActiveRunSlotCount: 5, - activeHeartbeatCount: 12, - activeHeartbeatTaskIds: manyIds("active-heartbeat", 12), - heartbeatFreshTaskIds: manyIds("fresh-heartbeat", 12), - heartbeatExpiredTaskIds: [], - heartbeatMissingTaskIds: [], - staleRecoveryCandidateTaskIds: [], - heartbeatRiskTaskIds: [], - traceGapTaskIds: manyIds("trace-gap", 15), - lastSchedulerHeartbeatAt: "2026-05-22T00:00:12.000Z", - lastObservedAgentEventAt: "2026-05-22T00:00:11.000Z", - lastPersistedTraceAt: "2026-05-22T00:00:10.000Z", - reasons: Array.from({ length: 12 }, (_, index) => longText(`reason-${index + 1}`, 8)), - }, - modelProviderConfig: { - source: "fixture", - largeNoise: longText("model-provider", 200), - }, - }, - }; -} - -const fixture = largeCodeQueueHealth(); -const compact = asRecord(summarizeMicroserviceHealthResponse(fixture, ["health", "code-queue"], "code-queue")); -const raw = summarizeMicroserviceHealthResponse(fixture, ["health", "code-queue", "--raw"], "code-queue"); -const full = summarizeMicroserviceHealthResponse(fixture, ["health", "code-queue", "--full"], "code-queue"); -const remoteCompact = asRecord(summarizeRemoteMicroserviceResponse("health", "code-queue", fixture, ["microservice", "health", "code-queue"])); -const remoteRaw = summarizeRemoteMicroserviceResponse("health", "code-queue", fixture, ["microservice", "health", "code-queue", "--raw"]); -const unhealthyWrapper = asRecord(summarizeMicroserviceHealthResponse({ - ok: false, - status: 503, - body: { - ok: false, - status: "unhealthy", - serviceId: "code-queue", - reason: "health body ok=false", - upstream: { - status: 503, - contentType: "application/json", - body: fixture.body, - }, - }, -}, ["health", "code-queue"], "code-queue")); -const compactBody = JSON.stringify(compact); -const rawBody = JSON.stringify(fixture); -const queue = asRecord(compact.queue); -const heartbeat = asRecord(compact.heartbeat); -const liveness = asRecord(compact.liveness); -const outputPolicy = asRecord(compact.outputPolicy); -const unhealthyQueue = asRecord(unhealthyWrapper.queue); -const unhealthyLiveness = asRecord(unhealthyWrapper.liveness); - -assertCondition(compactBody.length < rawBody.length * 0.25, "default code-queue health output should be materially compact", { compactChars: compactBody.length, rawChars: rawBody.length }); -assertCondition(compactBody.length < 12000, "default code-queue health output should stay commander-safe", { compactChars: compactBody.length }); -assertCondition(!("body" in compact), "compact health must not retain the raw body", compact); -assertCondition(!("queues" in queue), "compact queue summary must not include full queue dumps", queue); -assertCondition(compact.ok === true && compact.status === 200 && compact.serviceId === "code-queue", "compact health keeps ok/status/service identity", compact); -assertCondition(queue.runningCount === 5 && queue.queueCount === 90, "compact queue keeps running count and queue count", queue); -assertCondition(heartbeat.heartbeatFreshTaskCount === 12 && heartbeat.heartbeatRiskTaskCount === 0, "compact heartbeat keeps freshness and risk counts", heartbeat); -assertCondition(liveness.effectiveLiveness === "live" && liveness.splitBrainLive === true, "compact liveness keeps split-brain live interpretation", liveness); -assertCondition(String(liveness.interpretation).includes("continue supervision"), "compact liveness includes commander interpretation", liveness); -assertCondition(typeof outputPolicy.rawCommand === "string" && String(outputPolicy.rawCommand).includes("--raw"), "compact output exposes raw drill-down command", outputPolicy); -assertCondition(raw === fixture && full === fixture, "--raw/--full must preserve full health access", { rawSame: raw === fixture, fullSame: full === fixture }); -assertCondition(!("body" in remoteCompact) && asRecord(remoteCompact.queue).runningCount === 5, "remote frontend health must reuse compact code-queue summary by default", remoteCompact); -assertCondition(remoteRaw === fixture, "remote frontend health --raw must preserve full diagnostics", { remoteRawSame: remoteRaw === fixture }); -assertCondition(unhealthyWrapper.ok === false && unhealthyWrapper.reason === "health body ok=false", "unhealthy wrapper keeps probe failure", unhealthyWrapper); -assertCondition(unhealthyQueue.runningCount === 5 && unhealthyLiveness.effectiveLiveness === "live", "unhealthy wrapper still surfaces upstream queue liveness", { unhealthyQueue, unhealthyLiveness }); - -console.log(JSON.stringify({ - ok: true, - checks: [ - "default code-queue health output is compact and omits raw body/queue dumps", - "critical ok/status/service/running/heartbeat/liveness fields remain visible", - "unhealthy backend health wrapper still surfaces upstream code-queue liveness", - "remote frontend microservice health uses the same compact/raw policy", - "--raw and --full return the original health response for diagnostics", - ], - compactChars: compactBody.length, - rawChars: rawBody.length, -}, null, 2)); diff --git a/scripts/platform-infra-sub2api-codex-local-config-contract-test.ts b/scripts/platform-infra-sub2api-codex-local-config-contract-test.ts deleted file mode 100644 index d872e973..00000000 --- a/scripts/platform-infra-sub2api-codex-local-config-contract-test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { renderCaddySiteBlock, renderCodexLocalConsumerToml } from "./src/platform-infra-sub2api-codex"; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -const baseOptions = { - providerName: "OpenAI", - baseUrl: "https://sub2api.74-48-78-17.nip.io/", - wireApi: "responses", - supportsWebSockets: true, - responsesWebSocketsV2: true, -}; - -const existing = [ - 'model_provider = "Legacy"', - "", - "[model_providers.OpenAI]", - 'name = "OpenAI"', - 'base_url = "https://old.example"', - 'wire_api = "responses"', - "requires_openai_auth = false", - "supports_websockets = false", - 'env_key = "OLD_OPENAI_API_KEY"', - 'extra_setting = "kept"', - "", - "[features]", - "goals = true", - "responses_websockets_v2 = false", - "", -].join("\n"); - -const updated = renderCodexLocalConsumerToml(existing, baseOptions); - -assertCondition(updated.includes('model_provider = "OpenAI"'), "model_provider must point at the configured provider", updated); -assertCondition(updated.includes('base_url = "https://sub2api.74-48-78-17.nip.io/"'), "provider base_url must use the fixed public Sub2API consumer endpoint", updated); -assertCondition(updated.includes("requires_openai_auth = true"), "provider must require OpenAI auth", updated); -assertCondition(updated.includes("supports_websockets = true"), "provider must enable WebSocket transport", updated); -assertCondition(updated.includes("responses_websockets_v2 = true"), "features must enable Responses WebSocket v2", updated); -assertCondition(updated.includes("goals = true"), "existing feature flags must be preserved", updated); -assertCondition(updated.includes('extra_setting = "kept"'), "unknown provider settings must be preserved", updated); -assertCondition(!updated.includes("env_key"), "stale provider env_key must be removed", updated); -assertCondition(updated.endsWith("\n"), "rendered TOML must end with newline", updated); - -const fresh = renderCodexLocalConsumerToml("", baseOptions); - -assertCondition(fresh.includes("[model_providers.OpenAI]"), "fresh TOML must create the provider section", fresh); -assertCondition(fresh.includes("[features]"), "fresh TOML must create the features section", fresh); -assertCondition(fresh.includes("supports_websockets = true"), "fresh TOML must enable WebSocket transport", fresh); -assertCondition(fresh.includes("responses_websockets_v2 = true"), "fresh TOML must enable Responses WebSocket v2", fresh); - -const disabled = renderCodexLocalConsumerToml(existing, { - ...baseOptions, - supportsWebSockets: false, - responsesWebSocketsV2: false, -}); - -assertCondition(disabled.includes("supports_websockets = false"), "disabled localCodex policy must render provider WebSocket transport off", disabled); -assertCondition(disabled.includes("responses_websockets_v2 = false"), "disabled localCodex policy must render Responses WebSocket v2 off", disabled); - -const caddyBlock = renderCaddySiteBlock("sub2api.example.test", "http://127.0.0.1:21880", 180); - -assertCondition(caddyBlock.includes("sub2api.example.test {"), "Caddy site block must use the configured domain", caddyBlock); -assertCondition(caddyBlock.includes("reverse_proxy 127.0.0.1:21880"), "Caddy site block must use the configured local upstream", caddyBlock); -assertCondition(caddyBlock.includes("response_header_timeout 180s"), "Caddy response header timeout must allow long Codex compact requests", caddyBlock); -assertCondition(!caddyBlock.includes("response_header_timeout 30s"), "Caddy site block must not retain the old 30s compact timeout", caddyBlock); - -console.log(JSON.stringify({ - ok: true, - checks: [ - "existing Codex TOML is upgraded to the Sub2API WSv2 consumer settings", - "fresh Codex TOML creates provider and feature sections with WSv2 enabled", - "disabled localCodex WebSocket policy renders both consumer flags off", - "Caddy site block uses the YAML-controlled long response-header timeout", - ], -})); diff --git a/scripts/platform-infra-sub2api-codex-routing-contract-test.ts b/scripts/platform-infra-sub2api-codex-routing-contract-test.ts deleted file mode 100644 index f06f4f37..00000000 --- a/scripts/platform-infra-sub2api-codex-routing-contract-test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { readFileSync } from "node:fs"; -import { rootPath } from "./src/config"; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -const configPath = rootPath("config", "platform-infra", "sub2api-codex-pool.yaml"); -const parsed = Bun.YAML.parse(readFileSync(configPath, "utf8")) as { - pool?: { - defaultAccountPriority?: number; - defaultAccountCapacity?: number; - defaultAccountLoadFactor?: number; - minOwnerConcurrency?: number; - defaultTempUnschedulable?: { - enabled?: boolean; - rules?: Array<{ statusCode?: number; keywords?: string[]; durationMinutes?: number; description?: string }>; - }; - }; - profiles?: { entries?: Array<{ profile?: string; accountName?: string; capacity?: number; loadFactor?: number; openaiResponsesWebSocketsV2Mode?: string | null }> }; - publicExposure?: { masterCaddy?: { responseHeaderTimeoutSeconds?: number } }; - localCodex?: { supportsWebSockets?: boolean; responsesWebSocketsV2?: boolean; responsesSmokeModel?: string }; -}; - -const entries = parsed.profiles?.entries ?? []; -const rules = parsed.pool?.defaultTempUnschedulable?.rules ?? []; -const defaultPriority = parsed.pool?.defaultAccountPriority ?? 0; -const defaultCapacity = parsed.pool?.defaultAccountCapacity ?? 0; -const defaultLoadFactor = parsed.pool?.defaultAccountLoadFactor ?? 0; -const desiredCapacity = entries.reduce((total, entry) => total + (entry.capacity ?? defaultCapacity), 0); -const explicitMinOwnerConcurrency = parsed.pool?.minOwnerConcurrency; -const resolvedMinOwnerConcurrency = explicitMinOwnerConcurrency ?? desiredCapacity; -const allowedWebSocketModes = new Set(["off", "ctx_pool", "passthrough"]); -const wsEnabledEntries = entries.filter((entry) => entry.openaiResponsesWebSocketsV2Mode && entry.openaiResponsesWebSocketsV2Mode !== "off"); -const localWsEnabled = parsed.localCodex?.supportsWebSockets === true || parsed.localCodex?.responsesWebSocketsV2 === true; - -assertCondition(entries.length > 0, "Codex pool must declare YAML-managed profile entries", parsed.profiles); -assertCondition(Number.isInteger(defaultPriority) && defaultPriority >= 0, "defaultAccountPriority must be a non-negative integer", parsed.pool); -assertCondition(Number.isInteger(defaultCapacity) && defaultCapacity > 0, "defaultAccountCapacity must be a positive integer", parsed.pool); -assertCondition(Number.isInteger(defaultLoadFactor) && defaultLoadFactor > 0, "defaultAccountLoadFactor must be a positive integer", parsed.pool); -assertCondition(entries.every((entry) => typeof entry.profile === "string" && entry.profile.length > 0), "profile entries must declare profile names", entries); -assertCondition(entries.every((entry) => typeof entry.accountName === "string" && entry.accountName.length > 0), "profile entries must declare account names", entries); -assertCondition(entries.every((entry) => entry.capacity === undefined || (Number.isInteger(entry.capacity) && entry.capacity > 0)), "profile capacity overrides must be positive integers when declared", entries); -assertCondition(entries.every((entry) => entry.loadFactor === undefined || (Number.isInteger(entry.loadFactor) && entry.loadFactor > 0)), "profile load factor overrides must be positive integers when declared", entries); -assertCondition( - Number.isInteger(parsed.publicExposure?.masterCaddy?.responseHeaderTimeoutSeconds) && (parsed.publicExposure?.masterCaddy?.responseHeaderTimeoutSeconds ?? 0) >= 180, - "Sub2API public Caddy response-header timeout must allow long Codex compact requests", - parsed.publicExposure?.masterCaddy, -); -assertCondition( - entries.every((entry) => entry.openaiResponsesWebSocketsV2Mode === undefined || entry.openaiResponsesWebSocketsV2Mode === null || allowedWebSocketModes.has(entry.openaiResponsesWebSocketsV2Mode)), - "profile WebSocket mode overrides must use supported values when declared", - entries, -); -assertCondition(parsed.localCodex?.supportsWebSockets === parsed.localCodex?.responsesWebSocketsV2, "local Codex WebSocket feature flags must be changed together", parsed.localCodex); -if (localWsEnabled) { - assertCondition(wsEnabledEntries.length > 0, "local Codex WebSocket transport must not be enabled without at least one YAML WSv2-capable account", { localCodex: parsed.localCodex, entries }); -} else { - assertCondition(wsEnabledEntries.length === 0, "local Codex WebSocket transport disabled means all account WSv2 capability declarations must be off or omitted", { localCodex: parsed.localCodex, wsEnabledEntries }); -} -assertCondition( - explicitMinOwnerConcurrency === undefined || (Number.isInteger(explicitMinOwnerConcurrency) && explicitMinOwnerConcurrency > 0), - "explicit pool owner concurrency override must be a positive integer when declared", - { minOwnerConcurrency: explicitMinOwnerConcurrency }, -); -assertCondition( - resolvedMinOwnerConcurrency >= desiredCapacity, - "pool owner concurrency must auto-resolve or be configured to cover the declared account capacity set", - { - minOwnerConcurrency: explicitMinOwnerConcurrency, - minOwnerConcurrencySource: explicitMinOwnerConcurrency === undefined ? "auto-capacity-sum" : "yaml", - resolvedMinOwnerConcurrency, - desiredCapacity, - }, -); -if (parsed.pool?.defaultTempUnschedulable?.enabled === true) { - assertCondition(rules.length > 0, "enabled temporary unschedulable policy must declare rules", parsed.pool?.defaultTempUnschedulable); - assertCondition(rules.every((rule) => Number.isInteger(rule.statusCode) && (rule.statusCode ?? 0) >= 100 && (rule.statusCode ?? 0) <= 599), "temporary unschedulable rules must declare valid HTTP status codes", rules); - assertCondition(rules.every((rule) => Array.isArray(rule.keywords) && rule.keywords.length > 0), "temporary unschedulable rules must declare non-empty keywords", rules); - assertCondition(rules.every((rule) => Number.isInteger(rule.durationMinutes) && (rule.durationMinutes ?? 0) > 0), "temporary unschedulable rules must declare positive cooldown durations", rules); - const gateway502Rule = rules.find((rule) => rule.statusCode === 502); - const gateway502Keywords = new Set((gateway502Rule?.keywords ?? []).map((keyword) => keyword.toLowerCase())); - assertCondition(gateway502Keywords.has("recovered upstream error"), "502 temporary-unschedulable rule must catch recovered upstream error wrappers", gateway502Rule); - for (const keyword of ["unknown error", "upstream request failed", "context deadline exceeded", "context canceled"]) { - assertCondition(gateway502Keywords.has(keyword), "502 temporary-unschedulable rule must catch compact gateway timeout wrappers", { keyword, gateway502Rule }); - } - const largeContext413Rule = rules.find((rule) => rule.statusCode === 413); - const largeContext413Keywords = new Set((largeContext413Rule?.keywords ?? []).map((keyword) => keyword.toLowerCase())); - for (const keyword of ["openai_error", "context length", "maximum context"]) { - assertCondition(largeContext413Keywords.has(keyword), "413 temporary-unschedulable rule must catch large-context upstream failures", { keyword, largeContext413Rule }); - } - const gateway504Rule = rules.find((rule) => rule.statusCode === 504); - const gateway504Keywords = new Set((gateway504Rule?.keywords ?? []).map((keyword) => keyword.toLowerCase())); - for (const keyword of ["gateway timeout", "unknown error", "context deadline exceeded"]) { - assertCondition(gateway504Keywords.has(keyword), "504 temporary-unschedulable rule must catch gateway timeout wrappers", { keyword, gateway504Rule }); - } - const cloudflare524Rule = rules.find((rule) => rule.statusCode === 524); - const cloudflare524Keywords = new Set((cloudflare524Rule?.keywords ?? []).map((keyword) => keyword.toLowerCase())); - for (const keyword of ["timeout", "a timeout occurred", "cloudflare", "upstream request failed", "unknown error", "context canceled", "recovered upstream error"]) { - assertCondition(cloudflare524Keywords.has(keyword), "524 temporary-unschedulable rule must catch Cloudflare timeout wrappers", { keyword, cloudflare524Rule }); - } - const accountState403Rule = rules.find((rule) => rule.statusCode === 403); - const clientError400Rule = rules.find((rule) => rule.statusCode === 400); - const quota429Rule = rules.find((rule) => rule.statusCode === 429); - const successBody200Rule = rules.find((rule) => rule.statusCode === 200); - const serviceUnavailable503Rule = rules.find((rule) => rule.statusCode === 503); - const accountState403Keywords = new Set((accountState403Rule?.keywords ?? []).map((keyword) => keyword.toLowerCase())); - const clientError400Keywords = new Set((clientError400Rule?.keywords ?? []).map((keyword) => keyword.toLowerCase())); - const quota429Keywords = new Set((quota429Rule?.keywords ?? []).map((keyword) => keyword.toLowerCase())); - const successBody200Keywords = new Set((successBody200Rule?.keywords ?? []).map((keyword) => keyword.toLowerCase())); - const serviceUnavailable503Keywords = new Set((serviceUnavailable503Rule?.keywords ?? []).map((keyword) => keyword.toLowerCase())); - const accountStatePhrases = ["weekly limit", "less than 10% of your weekly limit left", "run /status for a breakdown"]; - const successBodyPhrase = "less than 10% of your weekly limit left"; - for (const keyword of ["invalid_encrypted_content", "encrypted content", "could not be verified", "bad_response_status_code", "暂不支持", "可用模型"]) { - assertCondition(clientError400Keywords.has(keyword), "400 temporary-unschedulable rule must catch upstream Responses compatibility and model-routing failures", { keyword, clientError400Rule }); - } - for (const accountStatePhrase of accountStatePhrases) { - assertCondition(accountState403Keywords.has(accountStatePhrase), "403 temporary-unschedulable rule must catch Codex account-state phrases", { accountStatePhrase, accountState403Rule }); - assertCondition(quota429Keywords.has(accountStatePhrase), "429 temporary-unschedulable rule must catch Codex account-state phrases", { accountStatePhrase, quota429Rule }); - } - if (successBody200Rule !== undefined) { - assertCondition(successBody200Keywords.size === 1 && successBody200Keywords.has(successBodyPhrase), "200 temporary-unschedulable rule must use one stable success-body classifier phrase", successBody200Rule); - assertCondition(/reclassification/u.test(successBody200Rule.description ?? ""), "200 temporary-unschedulable rule must be documented as a runtime reclassification requirement", successBody200Rule); - } - for (const keyword of ["model_not_found", "no available channel for model"]) { - assertCondition(serviceUnavailable503Keywords.has(keyword), "503 temporary-unschedulable rule must catch upstream model-routing failures", { keyword, serviceUnavailable503Rule }); - } -} -assertCondition(typeof parsed.localCodex?.responsesSmokeModel === "string" && parsed.localCodex.responsesSmokeModel.length > 0, "localCodex.responsesSmokeModel must be declared for Responses smoke validation", parsed.localCodex); - -console.log(JSON.stringify({ - ok: true, - checks: [ - "routing config is schema-valid without profile-specific test gates", - "pool owner concurrency auto-resolves or covers the YAML account capacity set", - "profile load factor overrides are YAML-controlled positive integers", - "public Caddy response-header timeout is long enough for Codex compact", - "optional WebSocket mode overrides use supported values", - "local Codex WebSocket transport is consistent with YAML-declared WSv2-capable accounts", - "temporary unschedulable rules are structurally valid when enabled", - "upstream 400 Responses compatibility and model-routing failures are caught by the 400 cooldown rule", - "generic recovered upstream error wrappers are caught by cooldown rules", - "large-context upstream failures are caught by the 413 cooldown rule", - "gateway timeout wrappers are caught by the 504 cooldown rule", - "Cloudflare timeout wrappers are caught by the 524 cooldown rule", - "Codex weekly-limit prompts are caught by account-state and quota cooldown rules", - "upstream model-routing failures are caught by the 503 cooldown rule", - "Responses smoke model is YAML-declared", - ], -})); diff --git a/scripts/platform-infra-sub2api-codex-sentinel-contract-test.ts b/scripts/platform-infra-sub2api-codex-sentinel-contract-test.ts deleted file mode 100644 index 27e01ccd..00000000 --- a/scripts/platform-infra-sub2api-codex-sentinel-contract-test.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { readFileSync } from "node:fs"; -import { rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { rootPath } from "./src/config"; -import { codexPoolHelp, codexPoolSentinelProbeConfigFingerprint, defaultCodexTempUnschedulablePolicy } from "./src/platform-infra-sub2api-codex"; -import { - codexPoolSentinelRuntimeImage, - defaultCodexPoolSentinelConfig, - readCodexPoolSentinelConfig, - renderCodexPoolSentinelManifest, - sentinelContainerShellCommand, - sentinelRunnerPython, -} from "./src/platform-infra-sub2api-codex-sentinel"; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -const configPath = rootPath("config", "platform-infra", "sub2api-codex-pool.yaml"); -const codexPoolSourcePath = rootPath("scripts", "src", "platform-infra-sub2api-codex.ts"); -const sentinelDockerfilePath = rootPath("src", "components", "platform-infra", "sub2api", "sentinel.Dockerfile"); -const parsed = Bun.YAML.parse(readFileSync(configPath, "utf8")) as { - sentinel?: unknown; - pool?: { defaultTempUnschedulable?: { rules?: Array<{ statusCode?: unknown }> } }; -}; -const sentinel = readCodexPoolSentinelConfig(parsed.sentinel, defaultCodexPoolSentinelConfig(), configPath); -const sentinelRuntimeImage = codexPoolSentinelRuntimeImage(sentinel); -const codexPoolSource = readFileSync(codexPoolSourcePath, "utf8"); -const sentinelDockerfile = readFileSync(sentinelDockerfilePath, "utf8"); -const yamlTempUnschedulableRules = parsed.pool?.defaultTempUnschedulable?.rules ?? []; - -assertCondition(sentinel.monitor.enabled === true, "sentinel monitor must be enabled for marker-only guard rollout", sentinel); -assertCondition(sentinel.actions.enabled === true, "sentinel actions must be enabled so marker-only guard can freeze and recover accounts", sentinel); -assertCondition(!yamlTempUnschedulableRules.some((rule) => rule.statusCode === 200), "native Sub2API temp-unschedulable policy must not classify HTTP 200 bodies; marker-only sentinel owns 200 semantic failures", yamlTempUnschedulableRules); -assertCondition(!defaultCodexTempUnschedulablePolicy().rules.some((rule) => rule.statusCode === 200), "default temp-unschedulable policy must not reintroduce HTTP 200 body classifiers", defaultCodexTempUnschedulablePolicy()); -assertCondition(!("freezeOnMarkerMismatch" in sentinel.actions), "sentinel must not keep a marker-specific freeze branch; marker match is the only health standard", sentinel.actions); -assertCondition(!("freezeOnTransportError" in sentinel.actions), "sentinel must not keep a transport-specific freeze branch; non-marker results all use the same freeze state machine", sentinel.actions); -assertCondition(sentinel.endpoint === "responses", "v1 sentinel must target OpenAI Responses only", sentinel); -assertCondition(sentinel.model === "gpt-5.5", "v1 sentinel must use GPT-5.5", sentinel); -assertCondition(sentinel.probe.maxOutputTokens > 0 && sentinel.probe.maxOutputTokens <= 16, "sentinel local stream capture limit must be tightly capped", sentinel.probe); -assertCondition(!("maxResponseBytes" in sentinel.probe), "sentinel must not use hand-rolled response byte parsing for OpenAI model probes", sentinel.probe); -assertCondition(sentinel.probe.userAgent === "Go-http-client/1.1", "sentinel default User-Agent must match Sub2API net/http account test shape", sentinel.probe); -assertCondition(sentinel.sdk.openaiPythonVersion === "2.41.1", "sentinel must pin the OpenAI Python SDK version in YAML", sentinel.sdk); -assertCondition(!("concurrency" in sentinel.probe), "sentinel must not cap probe concurrency; all due accounts are probed concurrently", sentinel.probe); -assertCondition(!("maxAccountsPerRun" in sentinel.probe), "sentinel must not cap accounts per run; all due accounts are eligible", sentinel.probe); -assertCondition(sentinel.cadence.successInitialIntervalMinutes === 1, "success trust backoff must start at 1 minute", sentinel.cadence); -assertCondition(sentinel.cadence.successMaxIntervalMinutes === 20, "success trust backoff must cap at 20 minutes", sentinel.cadence); -assertCondition(sentinel.freeze.initialTtlMinutes === 1, "failure freeze backoff must start at 1 minute", sentinel.freeze); -assertCondition(sentinel.freeze.maxTtlMinutes === 10, "failure freeze backoff must cap at 10 minutes", sentinel.freeze); -assertCondition(!("budget" in sentinel), "sentinel must not use token budgets as a probe gate; usage is recorded only", sentinel); - -const probeFingerprint = codexPoolSentinelProbeConfigFingerprint({ - accountName: "unidesk-codex-example", - profile: "example", - baseUrl: "https://example.invalid/v1/", - apiKeyFingerprint: "key-a", - upstreamUserAgent: null, - openaiResponsesWebSocketsV2Mode: null, -}); -assertCondition( - probeFingerprint === codexPoolSentinelProbeConfigFingerprint({ - accountName: "unidesk-codex-example", - profile: "example", - baseUrl: "https://example.invalid/v1", - apiKeyFingerprint: "key-a", - upstreamUserAgent: null, - openaiResponsesWebSocketsV2Mode: null, - }), - "sentinel probe fingerprint must normalize base URL trailing slash", -); -assertCondition( - probeFingerprint !== codexPoolSentinelProbeConfigFingerprint({ - accountName: "unidesk-codex-example", - profile: "example", - baseUrl: "https://example.invalid/v1", - apiKeyFingerprint: "key-b", - upstreamUserAgent: null, - openaiResponsesWebSocketsV2Mode: null, - }), - "sentinel probe fingerprint must change when API key fingerprint changes", -); -assertCondition( - probeFingerprint !== codexPoolSentinelProbeConfigFingerprint({ - accountName: "unidesk-codex-example", - profile: "example", - baseUrl: "https://example.invalid/v1", - apiKeyFingerprint: "key-a", - upstreamUserAgent: "CodexCLI/0.1", - openaiResponsesWebSocketsV2Mode: "ctx_pool", - }), - "sentinel probe fingerprint must change when direct probe request-shape inputs change", -); - -const manifest = renderCodexPoolSentinelManifest(sentinel, [ - { - accountName: "unidesk-codex-example", - profile: "example", - baseUrl: "https://example.invalid/v1", - apiKey: "sk-test-secret", - upstreamUserAgent: null, - }, -], { - namespace: "platform-infra", - serviceName: "sub2api", - serviceDns: "sub2api.platform-infra.svc.cluster.local:8080", - appSecretName: "sub2api-secrets", -}); - -assertCondition(manifest.includes("kind: CronJob"), "sentinel manifest must render a CronJob", manifest.slice(0, 1000)); -assertCondition(manifest.includes("concurrencyPolicy: Forbid"), "sentinel CronJob must forbid overlapping runs", manifest); -assertCondition(manifest.includes("suspend: false"), "monitor.enabled=true must unsuspend the CronJob", manifest); -assertCondition(manifest.includes("kind: ServiceAccount") && manifest.includes("kind: Role") && manifest.includes("kind: RoleBinding"), "sentinel manifest must include minimal RBAC", manifest); -assertCondition(manifest.includes("sub2api-account-sentinel-state"), "sentinel manifest must reference the state ConfigMap", manifest); -assertCondition(manifest.includes("\"enabled\": true"), "sentinel manifest must preserve actions.enabled=true in config.json", manifest); -assertCondition(!manifest.includes("sk-test-secret"), "sentinel manifest must not expose upstream credentials as plaintext", manifest); -assertCondition(manifest.includes("profiles.json:"), "sentinel credentials Secret must include the profiles payload as Secret data", manifest); -assertCondition(manifest.includes("\"budgetMode\": \"record-only\""), "sentinel runner must expose record-only budget/accounting mode", manifest); -assertCondition(manifest.includes("max_workers=max(1, len(due))"), "sentinel runner must probe all due accounts concurrently", manifest); -assertCondition(manifest.includes(`image: ${sentinelRuntimeImage.runtimeImage}`), "sentinel manifest must use the reusable prebuilt runtime image", { image: sentinelRuntimeImage.runtimeImage, manifest }); -assertCondition(!manifest.includes("transport-failed-no-freeze"), "sentinel runner must not exempt transport failures from marker-based freezing", manifest); -const command = sentinelContainerShellCommand(sentinel); -assertCondition(command.includes("openai-python-version-mismatch"), "sentinel command must fail fast when the image SDK version does not match YAML", command); -assertCondition(!command.includes("pip install") && !command.includes("subprocess.check_call"), "sentinel command must not install Python packages at runtime", command); -assertCondition(sentinelDockerfile.includes("ARG OPENAI_PYTHON_VERSION=2.41.1"), "sentinel Dockerfile must make the OpenAI SDK version a build arg with the current default", sentinelDockerfile); -assertCondition(sentinelDockerfile.includes('"openai==${OPENAI_PYTHON_VERSION}"'), "sentinel Dockerfile must preinstall the pinned OpenAI SDK", sentinelDockerfile); - -const help = codexPoolHelp() as { usage?: unknown }; -assertCondition(Array.isArray(help.usage) && help.usage.some((item) => typeof item === "string" && item.includes("sentinel-probe --account")), "codex-pool help must expose manual sentinel-probe by account", help); -assertCondition(Array.isArray(help.usage) && help.usage.some((item) => typeof item === "string" && item.includes("sentinel-image build")), "codex-pool help must expose reusable sentinel image build", help); -assertCondition(Array.isArray(help.usage) && help.usage.some((item) => typeof item === "string" && item.includes("sentinel-report")), "codex-pool help must expose low-noise sentinel-report", help); -assertCondition(typeof (help as { output?: unknown }).output === "string" && String((help as { output?: unknown }).output).includes("ps-like text table"), "codex-pool help must document sentinel-report text table output", help); -const runner = sentinelRunnerPython(); -assertCondition(runner.includes("from openai import APIConnectionError, APIStatusError, APITimeoutError, OpenAI"), "sentinel runner must use the standard OpenAI Python SDK", runner); -assertCondition(runner.includes("client.responses.create(") && runner.includes("stream=True"), "sentinel runner must use the SDK Responses streaming create method", runner); -assertCondition(runner.includes("sub2api_style_input(prompt)") && runner.includes("sub2api_style_instructions()"), "sentinel runner must mirror Sub2API WebUI default account test request shape", runner); -assertCondition(runner.includes("extra_headers=headers"), "sentinel runner must pass configured User-Agent through SDK extra_headers", runner); -assertCondition(!runner.includes("store=False"), "sentinel runner must not add store=false to API-key account probes", runner); -assertCondition(!runner.includes("max_output_tokens="), "sentinel runner must not send max_output_tokens upstream for WebUI-compatible probes", runner); -assertCondition(!runner.includes("Originator") && !runner.includes("Session_ID") && !runner.includes("OpenAI-Beta"), "sentinel runner must not add Codex/compact headers to default account probes", runner); -assertCondition(!runner.includes("upstream_responses_url"), "sentinel runner must not hand-roll /v1/responses URLs for model probes", runner); -assertCondition(runner.includes("def error_details("), "sentinel runner must emit structured error diagnostics for failed probes", runner); -assertCondition(runner.includes('"openaiError": openai_error_fields(body)'), "sentinel diagnostics must expose OpenAI error type/code/message fields", runner); -assertCondition(runner.includes('"responseBodyHash": result.get("responseBodyHash")'), "sentinel state must keep response body hashes for diagnostics", runner); -assertCondition(runner.includes('"responseBodyPreview": item.get("responseBodyPreview")'), "sentinel CLI output must include bounded response body previews for diagnostics", runner); -assertCondition(runner.includes("SENTINEL_ACCOUNT_NAMES"), "sentinel runner must support forced account probes for CLI manual measurement", runner); -assertCondition(runner.includes('parsed.get("code") not in (None, 0)'), "sentinel admin client must treat Sub2API {code:0,message:success,data} envelopes as successful", runner); -assertCondition(runner.includes("page_size=20&platform=openai&type=apikey&search="), "sentinel admin client must query one target account instead of fetching all accounts into the 64KiB admin response cap", runner); -assertCondition(runner.includes('account_state["qualityGate"] = {**quality_gate, "pending": False'), "sentinel runner must clear pending YAML quality gates after marker-matched recovery", runner); - -assertCondition(codexPoolSource.includes("sentinelProbeConfigFingerprint: codexPoolSentinelProbeConfigFingerprint({"), "sync payload must include per-account sentinel probe fingerprints", codexPoolSourcePath); -assertCondition(codexPoolSource.includes("quality_gate_required = sentinel_quality_gate_enabled() and len(change_reasons) > 0"), "sync must require quality gate only for new or direct-probe-changed accounts", codexPoolSourcePath); -assertCondition(codexPoolSource.includes('ensure_account_schedulable(token, data["id"], profile["accountName"], not quality_gate_required and not keep_frozen)'), "sync must default-freeze changed/new accounts before they enter scheduling", codexPoolSourcePath); -assertCondition(codexPoolSource.includes('"sentinelProbeRequired": quality_gate_required'), "sync result must report whether each account needs sentinel quality gate", codexPoolSourcePath); -assertCondition(codexPoolSource.includes('"reason": "yaml-account-change-pending-sentinel-probe"'), "sync state must record YAML-change quality-gate quarantine reason", codexPoolSourcePath); -assertCondition(codexPoolSource.includes('account_state["nextProbeAfter"] = now'), "sync state must schedule changed/new account probes immediately", codexPoolSourcePath); -assertCondition(codexPoolSource.includes("planned_account_results = planned_sentinel_account_results(profiles, existing_accounts)"), "sync must compute changed/new accounts from pre-mutation runtime state", codexPoolSourcePath); -assertCondition(codexPoolSource.includes("sentinel_quality_prepare = ensure_sentinel_state_for_sync(planned_account_results, True)"), "sync must prepare changed/new account quality gate state before mutating accounts", codexPoolSourcePath); -assertCondition(codexPoolSource.indexOf("sentinel_quality_prepare = ensure_sentinel_state_for_sync(planned_account_results, True)") < codexPoolSource.indexOf("account_results, pruned_account_results = ensure_accounts("), "sync must prepare quality gate before account reconcile", codexPoolSourcePath); -assertCondition(codexPoolSource.includes("sentinel_quality = ensure_sentinel_state_for_sync(account_results)"), "sync response must surface sentinel quality-gate reconciliation", codexPoolSourcePath); -const reassertFunction = codexPoolSource.slice( - codexPoolSource.indexOf("def reassert_sentinel_freezes_after_sync("), - codexPoolSource.indexOf("def list_user_keys("), -); -assertCondition(!reassertFunction.includes("until_epoch"), "sync freeze reassert must not restore active quarantines just because TTL has expired", codexPoolSourcePath); -assertCondition(codexPoolSource.includes("protected_frozen_names = active_sentinel_quarantine_names()"), "sync must read active sentinel quarantines before reconciling accounts", codexPoolSourcePath); -assertCondition(codexPoolSource.includes("sentinelFreezeProtected"), "sync output must reveal accounts kept frozen by pre-existing sentinel quarantine", codexPoolSourcePath); -assertCondition(codexPoolSource.includes("def clamp_sentinel_freezes_for_config("), "sync must migrate active freeze state when YAML freeze max is lowered", codexPoolSourcePath); -assertCondition(codexPoolSource.includes('reason = "freeze-backoff-clamped-to-current-config"'), "sync quality gate output must report freeze backoff clamping", codexPoolSourcePath); -const stateUpdateFunction = codexPoolSource.slice( - codexPoolSource.indexOf("def update_sentinel_state_configmap("), - codexPoolSource.indexOf("def ensure_sentinel_state_for_sync("), -); -assertCondition(!stateUpdateFunction.includes('"patch"'), "sentinel state updates must not pass large state.json through kubectl patch argv", codexPoolSourcePath); -assertCondition(stateUpdateFunction.includes('"-f", "-"'), "sentinel state updates must stream the ConfigMap manifest through stdin", codexPoolSourcePath); - -const disabledMonitor = { - ...sentinel, - monitor: { enabled: false }, - actions: { ...sentinel.actions, enabled: false }, -}; -const suspendedManifest = renderCodexPoolSentinelManifest(disabledMonitor, [], { - namespace: "platform-infra", - serviceName: "sub2api", - serviceDns: "sub2api.platform-infra.svc.cluster.local:8080", - appSecretName: "sub2api-secrets", -}); -assertCondition(suspendedManifest.includes("suspend: true"), "monitor.enabled=false must suspend the CronJob", suspendedManifest); - -const pythonPath = join(tmpdir(), `sub2api-account-sentinel-${process.pid}.py`); -writeFileSync(pythonPath, sentinelRunnerPython(), "utf8"); -try { - const pyCompile = Bun.spawnSync(["python3", "-m", "py_compile", pythonPath], { - stdout: "pipe", - stderr: "pipe", - }); - assertCondition(pyCompile.exitCode === 0, "sentinel runner python must compile", { - exitCode: pyCompile.exitCode, - stdout: pyCompile.stdout.toString(), - stderr: pyCompile.stderr.toString(), - }); -} finally { - rmSync(pythonPath, { force: true }); -} - -console.log(JSON.stringify({ - ok: true, - checks: [ - "sentinel has independent monitor/actions YAML switches", - "marker-only guard actions are enabled", - "v1 scope is OpenAI Responses + GPT-5.5", - "probe local stream capture limit is tightly capped", - "probe uses the standard OpenAI Python SDK streaming Responses API", - "probe mirrors Sub2API WebUI default account test request shape", - "probe passes configured User-Agent through SDK extra_headers", - "OpenAI Python SDK version is YAML-pinned", - "OpenAI Python SDK is preinstalled in a reusable sentinel image", - "manual account probe CLI is exposed", - "probe concurrency is not artificially capped", - "marker match is the only health standard", - "budget is record-only and does not gate probes", - "success trust backoff is 1m to 20m", - "failure freeze backoff is 1m to 10m", - "YAML changed/new accounts default-freeze until marker-matched sentinel recovery", - "CronJob is k8s-native with Forbid concurrency and minimal RBAC", - "monitor switch controls CronJob suspend state", - "rendered Secret avoids plaintext upstream credentials", - "embedded Python runner compiles", - ], -})); diff --git a/scripts/platform-infra-sub2api-codex-temp-unsched-contract-test.ts b/scripts/platform-infra-sub2api-codex-temp-unsched-contract-test.ts deleted file mode 100644 index 57adee4f..00000000 --- a/scripts/platform-infra-sub2api-codex-temp-unsched-contract-test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { defaultCodexTempUnschedulablePolicy, renderSub2ApiTempUnschedulableCredentials } from "./src/platform-infra-sub2api-codex"; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -const policy = defaultCodexTempUnschedulablePolicy(); -const credentials = renderSub2ApiTempUnschedulableCredentials(policy) as { - temp_unschedulable_enabled?: boolean; - temp_unschedulable_rules?: Array<{ - error_code?: number; - keywords?: string[]; - duration_minutes?: number; - description?: string; - }>; - pool_mode?: unknown; -}; - -const rules = credentials.temp_unschedulable_rules ?? []; - -assertCondition(credentials.temp_unschedulable_enabled === true, "default policy must enable Sub2API temporary unschedulable mode", credentials); -assertCondition(Array.isArray(credentials.temp_unschedulable_rules), "Sub2API rules must be rendered as an array", credentials); -assertCondition(rules.length === policy.rules.length, "rendered rule count must match the UniDesk policy", { policy, credentials }); -assertCondition(rules.every((rule, index) => rule.error_code === policy.rules[index]?.statusCode), "rules must map statusCode to Sub2API error_code", { policy, credentials }); -assertCondition(rules.every((rule, index) => rule.duration_minutes === policy.rules[index]?.durationMinutes), "rules must map durationMinutes to Sub2API duration_minutes", { policy, credentials }); -assertCondition(rules.every((rule, index) => JSON.stringify(rule.keywords ?? []) === JSON.stringify(policy.rules[index]?.keywords ?? [])), "rules must preserve policy keywords", { policy, credentials }); -assertCondition(rules.every((rule, index) => rule.description === policy.rules[index]?.description), "rules must preserve policy descriptions", { policy, credentials }); -assertCondition(!("pool_mode" in credentials), "pool_mode must not be enabled because it retries the same account instead of cooling it down", credentials); -assertCondition(!("api_key" in credentials) && !("base_url" in credentials), "temporary-unschedulable rendering must not include secrets or endpoints", credentials); -const accountState403Rule = rules.find((rule) => rule.error_code === 403); -const clientError400Rule = rules.find((rule) => rule.error_code === 400); -const quota429Rule = rules.find((rule) => rule.error_code === 429); -const successBody200Rule = rules.find((rule) => rule.error_code === 200); -const gateway502Rule = rules.find((rule) => rule.error_code === 502); -const serviceUnavailable503Rule = rules.find((rule) => rule.error_code === 503); -const gatewayTimeout504Rule = rules.find((rule) => rule.error_code === 504); -const largeContext413Rule = rules.find((rule) => rule.error_code === 413); -const cloudflare524Rule = rules.find((rule) => rule.error_code === 524); -const accountStatePhrases = ["weekly limit", "less than 10% of your weekly limit left", "run /status for a breakdown"]; -const successBodyPhrase = "less than 10% of your weekly limit left"; -assertCondition(successBody200Rule?.keywords?.length === 1 && successBody200Rule.keywords.includes(successBodyPhrase), "200 rendered rule must use the single stable success-body account-state phrase", successBody200Rule); -for (const keyword of ["invalid_encrypted_content", "encrypted content", "could not be verified", "bad_response_status_code", "暂不支持", "可用模型"]) { - assertCondition(clientError400Rule?.keywords?.includes(keyword), "400 rendered rule must catch upstream Responses compatibility and model-routing failures", { keyword, clientError400Rule }); -} -for (const accountStatePhrase of accountStatePhrases) { - assertCondition(accountState403Rule?.keywords?.includes(accountStatePhrase), "403 rendered rule must preserve Codex account-state phrases", { accountStatePhrase, accountState403Rule }); - assertCondition(quota429Rule?.keywords?.includes(accountStatePhrase), "429 rendered rule must preserve Codex account-state phrases", { accountStatePhrase, quota429Rule }); -} -for (const keyword of ["model_not_found", "no available channel for model"]) { - assertCondition(serviceUnavailable503Rule?.keywords?.includes(keyword), "503 rendered rule must catch upstream model-routing failures", { keyword, serviceUnavailable503Rule }); -} -for (const keyword of ["openai_error", "context length", "maximum context"]) { - assertCondition(largeContext413Rule?.keywords?.includes(keyword), "413 rendered rule must catch large-context upstream failures", { keyword, largeContext413Rule }); -} -for (const keyword of ["unknown error", "upstream request failed", "context deadline exceeded", "context canceled"]) { - assertCondition(gateway502Rule?.keywords?.includes(keyword), "502 rendered rule must catch compact gateway timeout wrappers", { keyword, gateway502Rule }); -} -for (const keyword of ["gateway timeout", "unknown error", "context deadline exceeded"]) { - assertCondition(gatewayTimeout504Rule?.keywords?.includes(keyword), "504 rendered rule must preserve gateway-timeout cooldown keyword", { keyword, gatewayTimeout504Rule }); -} -for (const keyword of ["timeout", "a timeout occurred", "cloudflare", "upstream request failed", "unknown error", "context canceled", "recovered upstream error"]) { - assertCondition(cloudflare524Rule?.keywords?.includes(keyword), "524 rendered rule must catch Cloudflare timeout wrappers", { keyword, cloudflare524Rule }); -} - -const disabled = renderSub2ApiTempUnschedulableCredentials({ enabled: false, rules: policy.rules }) as { - temp_unschedulable_enabled?: boolean; - temp_unschedulable_rules?: unknown[]; -}; - -assertCondition(disabled.temp_unschedulable_enabled === false, "disabled policy must explicitly disable Sub2API temporary unschedulable mode", disabled); -assertCondition(Array.isArray(disabled.temp_unschedulable_rules) && disabled.temp_unschedulable_rules.length === 0, "disabled policy must not leave stale rules active", disabled); - -console.log(JSON.stringify({ - ok: true, - checks: [ - "temporary unschedulable policy renders to Sub2API credential field names", - "temporary unschedulable rendering follows the input policy without hard-coded policy gates", - "Codex account-state prompt uses one stable phrase, including the 200 success-body rule", - "upstream 400 Responses compatibility and model-routing failures render into the 400 cooldown rule", - "large-context upstream failures render into the 413 cooldown rule", - "upstream model-routing failures render into the 503 cooldown rule", - "gateway timeout wrappers render into the 504 cooldown rule", - "Cloudflare timeout wrappers render into the 524 cooldown rule", - "disabled policies clear runtime rules", - ], -})); diff --git a/scripts/platform-infra-sub2api-http-upstream-contract-test.ts b/scripts/platform-infra-sub2api-http-upstream-contract-test.ts deleted file mode 100644 index 09c4d044..00000000 --- a/scripts/platform-infra-sub2api-http-upstream-contract-test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { readFileSync } from "node:fs"; -import { rootPath } from "./src/config"; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -const sub2apiConfigPath = rootPath("config", "platform-infra", "sub2api.yaml"); -const codexPoolConfigPath = rootPath("config", "platform-infra", "sub2api-codex-pool.yaml"); -const manifestPath = rootPath("src", "components", "platform-infra", "sub2api", "sub2api.k8s.yaml"); - -const sub2api = Bun.YAML.parse(readFileSync(sub2apiConfigPath, "utf8")) as { - security?: { - urlAllowlist?: { - enabled?: boolean; - allowInsecureHttp?: boolean; - allowPrivateHosts?: boolean; - upstreamHosts?: string[]; - }; - }; -}; -const codexPool = Bun.YAML.parse(readFileSync(codexPoolConfigPath, "utf8")) as { - profiles?: { - entries?: Array<{ - accountName?: string; - }>; - }; -}; -const manifest = readFileSync(manifestPath, "utf8"); - -assertCondition((codexPool.profiles?.entries ?? []).length > 0, "Codex pool must have YAML-selected upstream accounts", codexPool.profiles); -assertCondition(sub2api.security?.urlAllowlist?.enabled === false, "Sub2API URL allowlist must be disabled for current HTTP upstream pool policy", sub2api.security); -assertCondition(sub2api.security?.urlAllowlist?.allowInsecureHttp === true, "Sub2API must allow http:// upstream base URLs for account tests and normal scheduling", { - security: sub2api.security, - accounts: (codexPool.profiles?.entries ?? []).map((entry) => entry.accountName), -}); -assertCondition(sub2api.security?.urlAllowlist?.allowPrivateHosts === false, "Sub2API must not allow private hosts for this public HTTP upstream exception", sub2api.security); -assertCondition(Array.isArray(sub2api.security?.urlAllowlist?.upstreamHosts), "Sub2API upstreamHosts must be YAML-controlled even when empty", sub2api.security); -assertCondition(manifest.includes('SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP: "__SUB2API_SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP__"'), "Sub2API manifest must render allowInsecureHttp from YAML", manifest); - -console.log(JSON.stringify({ - ok: true, - checks: [ - "Sub2API runtime URL policy explicitly allows http:// upstream base URLs", - "Sub2API manifest renders URL policy from YAML instead of hardcoding the old value", - ], - accounts: (codexPool.profiles?.entries ?? []).map((entry) => entry.accountName), -})); diff --git a/scripts/platform-infra-sub2api-network-policy-contract-test.ts b/scripts/platform-infra-sub2api-network-policy-contract-test.ts deleted file mode 100644 index 463ed775..00000000 --- a/scripts/platform-infra-sub2api-network-policy-contract-test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { readFileSync } from "node:fs"; -import { rootPath } from "./src/config"; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -const manifestPath = rootPath("src", "components", "platform-infra", "sub2api", "sub2api.k8s.yaml"); -const platformInfraSourcePath = rootPath("scripts", "src", "platform-infra.ts"); - -const manifest = readFileSync(manifestPath, "utf8"); -const platformInfraSource = readFileSync(platformInfraSourcePath, "utf8"); -const allowAllNetworkPolicy = manifest.split(/^---\s*$/mu).find((document) => /^\s*kind:\s*NetworkPolicy\s*$/mu.test(document) && /^\s*name:\s*allow-all\s*$/mu.test(document)); - -assertCondition(allowAllNetworkPolicy !== undefined, "Sub2API manifest must include NetworkPolicy/allow-all", manifest); -assertCondition(/^\s*namespace:\s*platform-infra\s*$/mu.test(allowAllNetworkPolicy ?? ""), "allow-all NetworkPolicy must live in platform-infra", allowAllNetworkPolicy); -assertCondition(/^\s*podSelector:\s*\{\}\s*$/mu.test(allowAllNetworkPolicy ?? ""), "allow-all NetworkPolicy must select all pods", allowAllNetworkPolicy); -assertCondition(/^\s*-\s*Ingress\s*$/mu.test(allowAllNetworkPolicy ?? "") && /^\s*-\s*Egress\s*$/mu.test(allowAllNetworkPolicy ?? ""), "allow-all NetworkPolicy must cover ingress and egress", allowAllNetworkPolicy); -assertCondition(/^\s*ingress:\s*\n\s*-\s*\{\}\s*$/mu.test(allowAllNetworkPolicy ?? ""), "allow-all NetworkPolicy must allow all ingress", allowAllNetworkPolicy); -assertCondition(/^\s*egress:\s*\n\s*-\s*\{\}\s*$/mu.test(allowAllNetworkPolicy ?? ""), "allow-all NetworkPolicy must allow all egress", allowAllNetworkPolicy); -assertCondition(platformInfraSource.includes("allow-all-network-policy"), "plan policy checks must require the allow-all NetworkPolicy", platformInfraSource); -assertCondition(platformInfraSource.includes("capture_json networkpolicies"), "status must report NetworkPolicy resources", platformInfraSource); -assertCondition(platformInfraSource.includes("network_policy[\"ok\"]"), "status ok must fail when NetworkPolicy/allow-all is missing or malformed", platformInfraSource); -assertCondition(platformInfraSource.includes("postgresCrossPodPgIsReady"), "validate must include a cross-pod PostgreSQL connectivity probe", platformInfraSource); -assertCondition(platformInfraSource.includes("redisCrossPodPing"), "validate must include a cross-pod Redis connectivity probe", platformInfraSource); -assertCondition(platformInfraSource.includes("kubectl -n ${namespace} run \"$pg_probe\""), "validate must run PostgreSQL probe from a temporary pod, not from the PostgreSQL pod itself", platformInfraSource); -assertCondition(platformInfraSource.includes("kubectl -n ${namespace} run \"$redis_probe\""), "validate must run Redis probe from a temporary pod, not from the Redis pod itself", platformInfraSource); - -console.log(JSON.stringify({ - ok: true, - checks: [ - "Sub2API manifest includes controlled NetworkPolicy/allow-all", - "plan blocks manifests that drop the required NetworkPolicy", - "status reports NetworkPolicy/allow-all shape", - "validate exercises cross-pod PostgreSQL and Redis traffic through temporary probe pods", - ], -})); diff --git a/scripts/playwright-cli-contract-test.ts b/scripts/playwright-cli-contract-test.ts deleted file mode 100644 index f9797267..00000000 --- a/scripts/playwright-cli-contract-test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; - -type JsonRecord = Record; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function runBun(args: string[]): { status: number | null; stdout: string; stderr: string; json: JsonRecord | null } { - const result = spawnSync("bun", args, { - cwd: process.cwd(), - encoding: "utf8", - }); - const stdout = String(result.stdout || ""); - let json: JsonRecord | null = null; - try { - json = JSON.parse(stdout) as JsonRecord; - } catch { - json = null; - } - return { - status: result.status, - stdout, - stderr: String(result.stderr || ""), - json, - }; -} - -function nestedRecord(value: unknown, path: string[]): JsonRecord { - let current: unknown = value; - for (const key of path) { - assertCondition(current !== null && typeof current === "object" && !Array.isArray(current), "expected object while traversing JSON", { path, key, current }); - current = (current as JsonRecord)[key]; - } - assertCondition(current !== null && typeof current === "object" && !Array.isArray(current), "expected nested object", { path, current }); - return current as JsonRecord; -} - -function stringArray(value: unknown): string[] { - return Array.isArray(value) ? value.map((item) => String(item)) : []; -} - -function assertOkJson(result: ReturnType, message: string): JsonRecord { - assertCondition(result.status === 0, message, { status: result.status, stdout: result.stdout, stderr: result.stderr }); - assertCondition(result.json?.ok === true, `${message}: expected ok JSON`, result.json ?? { stdout: result.stdout }); - return result.json as JsonRecord; -} - -function readTextIfExists(path: string): string | null { - return existsSync(path) ? readFileSync(path, "utf8") : null; -} - -function inspectExternalSkill(): JsonRecord { - const candidateRoots = [ - process.env.PLAYWRIGHT_SKILL_ROOT, - join(process.env.HOME || "", ".agents", "skills", "playwright"), - "/home/ubuntu/.agents/skills/playwright", - "/root/.agents/skills/playwright", - ].filter((value, index, array): value is string => typeof value === "string" && value.length > 0 && array.indexOf(value) === index); - const skillRoot = candidateRoots.find((candidate) => existsSync(join(candidate, "scripts", "playwright-cli.ts")) && existsSync(join(candidate, "SKILL.md"))) ?? candidateRoots[0] ?? "/home/ubuntu/.agents/skills/playwright"; - const scriptPath = join(skillRoot, "scripts", "playwright-cli.ts"); - const skillPath = join(skillRoot, "SKILL.md"); - const scriptText = readTextIfExists(scriptPath); - const skillText = readTextIfExists(skillPath); - if (scriptText === null || skillText === null) { - return { - checked: false, - reason: "external playwright skill files not found on this host", - skillRoot, - scriptPath, - skillPath, - searchedRoots: candidateRoots, - }; - } - const passthrough = /spawn\(['"]npx(?:\.cmd)?['"]/u.test(scriptText) || scriptText.includes("npx.cmd"); - const advertisesSession = skillText.includes("--session") || skillText.includes("session-list"); - const advertisesSnapshot = skillText.includes("snapshot"); - const implementsSession = scriptText.includes("--session") && scriptText.includes("sessionFile"); - const mismatch = passthrough && (advertisesSession || advertisesSnapshot) && !implementsSession; - return { - checked: true, - skillRoot, - passthrough, - advertisesSession, - advertisesSnapshot, - implementsSession, - mismatch, - repoOwnedResolution: "Use scripts/playwright-cli.ts for commander checks; update the external skill source/distribution separately.", - }; -} - -function runContract(): JsonRecord { - const help = assertOkJson(runBun(["scripts/playwright-cli.ts", "help"]), "playwright help should succeed"); - const helpData = nestedRecord(help.data, []); - const usage = stringArray(helpData.usage); - const behavior = stringArray(helpData.behavior); - assertCondition(usage.some((line) => line.includes("screenshot ")), "help should document screenshot flow", usage); - assertCondition(behavior.some((line) => line.includes("headless by default")), "help should document headless default", behavior); - assertCondition(behavior.some((line) => line.includes("no long-running browser daemon")), "help should state no daemon/session-ref behavior", behavior); - - const dryRun = assertOkJson(runBun([ - "scripts/playwright-cli.ts", - "--session=hwlab-dev", - "screenshot", - "http://127.0.0.1:16666/", - "/tmp/hwlab-dev.png", - "--selector", - "#root", - "--dry-run", - ]), "playwright screenshot dry-run should succeed"); - const dryRunData = nestedRecord(dryRun.data, []); - assertCondition(dryRunData.dryRun === true, "dry-run should be explicit", dryRunData); - assertCondition(dryRunData.headless === true, "headless should default true", dryRunData); - assertCondition(dryRunData.sessionId === "hwlab-dev", "--session should be parsed instead of passed through", dryRunData); - assertCondition(String(dryRunData.screenshotPath || "").endsWith("/tmp/hwlab-dev.png"), "screenshot path should be resolved", dryRunData); - - const headedPlan = assertOkJson(runBun([ - "scripts/playwright-cli.ts", - "open", - "https://example.com", - "--headed", - "--screenshot", - "/tmp/example-headed.png", - "--dry-run", - ]), "headed open dry-run should succeed"); - const headedData = nestedRecord(headedPlan.data, []); - assertCondition(headedData.headless === false, "--headed should opt out of headless", headedData); - assertCondition(String(headedData.screenshotPath || "").endsWith("/tmp/example-headed.png"), "open should accept --screenshot path", headedData); - - const unsupported = runBun(["scripts/playwright-cli.ts", "--session=hwlab-dev", "click", "e3"]); - assertCondition(unsupported.status !== 0, "unsupported interactive command should fail", unsupported); - assertCondition(unsupported.json?.ok === false, "unsupported command should return structured JSON", unsupported.json ?? { stdout: unsupported.stdout }); - const unsupportedData = nestedRecord(unsupported.json?.data, []); - assertCondition(unsupportedData.error === "unsupported-command", "unsupported error should be explicit", unsupportedData); - assertCondition(String(unsupportedData.reason || "").includes("short-run/headless"), "unsupported reason should explain no live refs", unsupportedData); - assertCondition(stringArray(unsupportedData.next).some((line) => line.includes("xvfb-run -a")), "unsupported next steps should include xvfb-run path", unsupportedData); - - const externalSkill = inspectExternalSkill(); - return { - ok: true, - checks: [ - "help documents headless short-run behavior", - "dry-run parses --session without upstream passthrough", - "unsupported interactive commands return compact actionable JSON", - "external skill source gap is observable when present", - ], - externalSkill, - }; -} - -const result = runContract(); -console.log(JSON.stringify(result, null, 2)); diff --git a/scripts/provider-runner-triage-contract-test.ts b/scripts/provider-runner-triage-contract-test.ts deleted file mode 100644 index 59666b14..00000000 --- a/scripts/provider-runner-triage-contract-test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { buildProviderTriageResult, providerTriageRecommendedCrossChecks, type ProviderTriageSignal } from "./src/provider-triage"; -import { codexTaskQuery } from "./src/code-queue"; -import { classifyRunnerError } from "../src/components/microservices/code-queue/src/runner-error-classifier"; - -type JsonRecord = Record; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function signal( - id: string, - scope: ProviderTriageSignal["scope"], - status: ProviderTriageSignal["status"], - independentPath = true, -): ProviderTriageSignal { - return { - id, - scope, - status, - independentPath, - observedAt: "2026-05-21T00:00:00.000Z", - summary: `${id}:${scope}:${status}`, - }; -} - -function assertScope(message: string, expectedScope: string, expectedDisposition: string): JsonRecord { - const classification = classifyRunnerError(message, "D601") as unknown as JsonRecord; - assertCondition(classification.scope === expectedScope, `runner error should classify as ${expectedScope}`, classification); - assertCondition(classification.disposition === expectedDisposition, `runner error disposition should be ${expectedDisposition}`, classification); - assertCondition(classification.globalBlocker === false, "single runner error classification must not be global blocker", classification); - assertCondition(classification.retryable === true, "single runner error classification should remain retryable", classification); - assertCondition(String(classification.recommendedTriageCommand ?? "").includes("provider triage D601"), "classification should include triage command", classification); - return classification; -} - -export function runProviderRunnerTriageContract(): JsonRecord { - assertScope("provider is not online: D601", "runner-local", "runner-local-observation-gap"); - assertScope("provider-gateway http tunnel failed waiting for request", "provider-gateway", "provider-degraded"); - assertScope("artifact registry /v2 manifest HEAD failed on 127.0.0.1:5000", "registry", "service-degraded"); - assertScope("kubectl get pods failed: k3s api unavailable", "k3s", "service-degraded"); - assertScope("code-queue scheduler active run heartbeat is stale", "scheduler", "retryable-transient"); - assertScope("unexpected runner process exited with code 1", "unknown", "retryable-transient"); - const rateLimit = assertScope("exceeded retry limit, last status: 429 Too Many Requests, request id: 21zqfw7apcg", "external-provider", "retryable-transient"); - assertCondition(rateLimit.externalProvider429 === true, "OpenAI/model 429 should be explicit externalProvider429 evidence", rateLimit); - assertCondition(rateLimit.failureKind === "external-provider-rate-limit", "429 should use a stable rate-limit failure kind", rateLimit); - assertCondition((rateLimit.backoffHint as JsonRecord | undefined)?.strategy === "exponential-jitter", "429 classification should expose jittered backoff hint", rateLimit); - - const singlePath = classifyRunnerError("provider is not online: D601", "D601"); - const result = buildProviderTriageResult("D601", [ - signal("observed-runner-error", singlePath.scope, "failed", false), - signal("backend-core-node", "provider-gateway", "ok"), - signal("host-ssh-probe", "ssh", "ok"), - signal("code-queue-health", "scheduler", "ok"), - ], "2026-05-21T00:00:00.000Z"); - - assertCondition(result.blockingDisposition === "runner-local-observation-gap", "single path provider offline must stay observation gap", result); - assertCondition(result.decision === "retryable-transient", "single path provider offline should be retryable transient", result); - assertCondition(result.retryable === true, "single path provider offline should be retryable", result); - assertCondition(result.contract.singlePathProviderOfflineIsGlobalBlocker === false, "triage contract should reject single-path global blocker", result); - assertCondition(result.recommendedCrossChecks.includes("trans D601 argv true"), "provider triage result must recommend argv Host SSH cross-check", result.recommendedCrossChecks); - - const crossChecks = providerTriageRecommendedCrossChecks("D601"); - assertCondition(crossChecks.includes("trans D601 argv true"), "provider triage recommendedCrossChecks must keep ssh argv true", crossChecks); - assertCondition(crossChecks.includes("bun scripts/cli.ts debug dispatch D601 host.ssh --wait-ms 15000"), "provider triage recommendedCrossChecks must keep host.ssh dispatch probe", crossChecks); - - const rateLimitTriage = buildProviderTriageResult("D601", [ - signal("observed-runner-429", "external-provider", "failed", false), - signal("code-queue-health", "scheduler", "ok"), - ], "2026-05-21T00:00:00.000Z"); - assertCondition(rateLimitTriage.blockingDisposition === "external-provider-backoff", "external provider 429 should stay in backoff disposition", rateLimitTriage); - assertCondition(rateLimitTriage.decision === "retryable-transient", "external provider 429 should remain retryable transient", rateLimitTriage); - assertCondition(rateLimitTriage.retryable === true, "external provider 429 should be retryable", rateLimitTriage); - - const cliSummary = codexTaskQuery("codex_runner_triage_fixture", ["--detail"], (path) => { - assertCondition(path.includes("/api/microservices/code-queue/proxy/api/tasks/codex_runner_triage_fixture/summary"), "task summary should use stable proxy path", { path }); - return { - ok: true, - upstream: { ok: true, status: 200 }, - body: { - ok: true, - summary: { - id: "codex_runner_triage_fixture", - status: "failed", - providerId: "D601", - attempts: [{ - index: 1, - mode: "initial", - terminalStatus: "failed", - runnerErrorClassification: singlePath, - stderrTail: "provider is not online: D601", - }], - }, - }, - }; - }) as JsonRecord; - const summary = cliSummary.summary as JsonRecord; - const attempts = summary.attempts as JsonRecord; - const attemptRecords = attempts.attemptRecords as JsonRecord[]; - const compactClassification = attemptRecords[0]?.runnerErrorClassification as JsonRecord | undefined; - assertCondition(compactClassification?.scope === "runner-local", "CLI compact task detail should preserve runnerErrorClassification", cliSummary); - assertCondition(compactClassification?.globalBlocker === false, "CLI compact classification should preserve non-global-blocker contract", cliSummary); - - return { - ok: true, - checks: [ - "runner error classifier separates runner-local/provider-gateway/registry/k3s/scheduler/unknown", - "each single runner error classification has globalBlocker=false", - "provider triage keeps single provider is not online as retryable-transient, not global-blocker", - "provider triage recommendedCrossChecks keeps host.ssh dispatch and ssh argv true probes", - "external OpenAI/model provider 429 is explicit retryable backoff evidence, not Code Queue infra outage", - "codex task --detail preserves runnerErrorClassification in compact attempt output", - ], - }; -} - -if (import.meta.main) { - process.stdout.write(`${JSON.stringify(runProviderRunnerTriageContract(), null, 2)}\n`); -} diff --git a/scripts/schedule-cli-contract-test.ts b/scripts/schedule-cli-contract-test.ts deleted file mode 100644 index ef1ed506..00000000 --- a/scripts/schedule-cli-contract-test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { scheduleRetryRunObservation, scheduleRunObservation, scheduleRunsScope } from "./src/schedules"; -import { backendCoreUnavailableDiagnostic } from "./src/microservices"; - -type JsonRecord = Record; - -function assertCondition(condition: unknown, message: string, detail: JsonRecord = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -export function runScheduleCliContract(): JsonRecord { - const global = scheduleRunsScope(["runs", "--limit", "50"]); - assertCondition(global.scheduleId === null, "global schedule runs must not treat --limit value as schedule id", global); - assertCondition(global.limit === 50, "global schedule runs limit should be preserved", global); - - const scoped = scheduleRunsScope(["runs", "unidesk-pgdata-baidu-daily", "--limit", "5"]); - assertCondition(scoped.scheduleId === "unidesk-pgdata-baidu-daily", "schedule-specific runs should preserve schedule id", scoped); - assertCondition(scoped.limit === 5, "schedule-specific runs limit should be preserved", scoped); - - let numericScheduleRejected = false; - try { - scheduleRunsScope(["runs", "50"]); - } catch (error) { - numericScheduleRejected = String((error as Error).message).includes("schedule runs --limit N"); - } - assertCondition(numericScheduleRejected, "numeric positional schedule id should point operators to global --limit syntax"); - - const timeoutObservation = scheduleRunObservation( - "unidesk-pgdata-baidu-daily", - { body: { run: { id: "schedrun_new" } } }, - { ok: false, timedOut: true, timeoutMs: 1 }, - ); - assertCondition(timeoutObservation.newRunId === "schedrun_new", "schedule run output must expose newRunId even when wait times out", timeoutObservation); - assertCondition(String(timeoutObservation.observeCommand).includes("schedule runs unidesk-pgdata-baidu-daily --limit 20"), "schedule run output must expose observeCommand", timeoutObservation); - - const retryObservation = scheduleRetryRunObservation("schedrun_failed", { - body: { - originalRunId: "schedrun_failed", - scheduleId: "unidesk-pgdata-baidu-daily", - newRunId: "schedrun_retry", - }, - }); - assertCondition(retryObservation.originalRunId === "schedrun_failed", "retry-run output must preserve originalRunId", retryObservation); - assertCondition(retryObservation.scheduleId === "unidesk-pgdata-baidu-daily", "retry-run output must expose scheduleId", retryObservation); - assertCondition(retryObservation.newRunId === "schedrun_retry", "retry-run output must expose newRunId", retryObservation); - assertCondition(String(retryObservation.observeCommand).includes("schedule runs unidesk-pgdata-baidu-daily --limit 20"), "retry-run output must expose observeCommand", retryObservation); - - const unavailable = backendCoreUnavailableDiagnostic({ - exitCode: 1, - stdoutTail: "", - stderrTail: "Error response from daemon: No such container: unidesk-backend-core\n", - relatedContainers: [ - { name: "unidesk-backend-core.verify-20260520T153456Z", image: "unidesk-backend-core:latest", status: "Exited (255)" }, - { name: "unidesk-database.verify-20260520T153456Z", image: "postgres:16-alpine", status: "Exited (255)" }, - ], - envPath: "/tmp/docker-compose.env", - baiduSecretPresence: { - envPath: "/tmp/docker-compose.env", - exists: true, - keys: { - UNIDESK_BAIDU_NETDISK_CLIENT_ID: { present: true, nonEmpty: false }, - UNIDESK_BAIDU_NETDISK_CLIENT_SECRET: { present: true, nonEmpty: false }, - UNIDESK_BAIDU_NETDISK_TOKEN_KEY: { present: true, nonEmpty: false }, - }, - }, - }); - assertCondition(unavailable.ok === false, "backend-core unavailable diagnostic must be a failed result", unavailable); - assertCondition(unavailable.failureKind === "target-stack-not-running", "backend-core unavailable diagnostic must classify target stack absence", unavailable); - assertCondition((unavailable.targetStack as JsonRecord).verifyOnlyObserved === true, "backend-core unavailable diagnostic must expose verify-only evidence", unavailable); - assertCondition(Array.isArray(unavailable.authorizationRequiredForRecovery), "backend-core unavailable diagnostic must list authorization-gated recovery actions", unavailable); - assertCondition(Array.isArray(unavailable.readOnlyCommands), "backend-core unavailable diagnostic must list read-only observation commands", unavailable); - - return { - ok: true, - checks: [ - "global schedule runs limit parsing", - "schedule-specific runs parsing", - "numeric positional guard", - "run wait timeout observation", - "retry-run observation", - "target stack unavailable diagnostic", - ], - }; -} - -if (import.meta.main) { - process.stdout.write(`${JSON.stringify(runScheduleCliContract(), null, 2)}\n`); -} diff --git a/scripts/server-cleanup-plan-contract-test.ts b/scripts/server-cleanup-plan-contract-test.ts deleted file mode 100644 index ff877815..00000000 --- a/scripts/server-cleanup-plan-contract-test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { buildDockerCleanupPlan, type DockerCleanupInventory } from "./src/server-cleanup"; - -type JsonRecord = Record; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -const observedAt = "2026-05-21T14:00:00.000Z"; - -const fixture: DockerCleanupInventory = { - observedAt, - images: [ - { - id: "sha256:1111111111111111111111111111111111111111111111111111111111111111", - repoTags: ["unidesk-backend-core:latest"], - repoDigests: [], - sizeBytes: 110 * 1024 * 1024, - createdAt: "2026-05-21T10:00:00.000Z", - labels: { "unidesk.ai/service-id": "backend-core", "unidesk.ai/source-commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, - }, - { - id: "sha256:2222222222222222222222222222222222222222222222222222222222222222", - repoTags: ["127.0.0.1:5000/unidesk/frontend:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], - repoDigests: [], - sizeBytes: 120 * 1024 * 1024, - createdAt: "2026-05-21T08:00:00.000Z", - labels: { "unidesk.ai/service-id": "frontend", "unidesk.ai/source-commit": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" }, - }, - { - id: "sha256:3333333333333333333333333333333333333333333333333333333333333333", - repoTags: ["old-test-image:local"], - repoDigests: ["old-test-image@sha256:3333333333333333333333333333333333333333333333333333333333333333"], - sizeBytes: 500 * 1024 * 1024, - createdAt: "2026-05-19T14:00:00.000Z", - labels: {}, - }, - { - id: "sha256:4444444444444444444444444444444444444444444444444444444444444444", - repoTags: [], - repoDigests: [], - sizeBytes: 1024 * 1024 * 1024, - createdAt: "2026-05-18T14:00:00.000Z", - labels: {}, - }, - ], - containers: [ - { - id: "container-running-backend-core", - name: "unidesk-backend-core", - imageRef: "unidesk-backend-core:latest", - imageId: "sha256:1111111111111111111111111111111111111111111111111111111111111111", - state: "running", - status: "running", - labels: {}, - }, - ], - desiredImageRefs: [ - { - ref: "127.0.0.1:5000/unidesk/frontend:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - source: "CI.json + deploy.json", - serviceId: "frontend", - reason: "current commit-pinned registry artifact", - }, - ], - desiredCommitsByService: { - "backend-core": ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], - frontend: ["bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], - }, - protectedStorage: [ - { - kind: "docker-volume", - ref: "unidesk_pgdata_10gb", - risk: "blocked", - reason: "database named volume", - }, - { - kind: "path", - ref: "/workspace/unidesk/.state/baidu-netdisk", - risk: "blocked", - reason: "Baidu Netdisk state", - }, - { - kind: "path", - ref: "/home/ubuntu/.unidesk/registry-storage", - risk: "blocked", - reason: "registry storage", - }, - ], - collection: { - dockerAvailable: true, - readOnlyCommands: [["docker", "image", "ls", "-q", "--no-trunc"]], - errors: [], - }, -}; - -export function runServerCleanupPlanContract(): JsonRecord { - const plan = buildDockerCleanupPlan(fixture, { minAgeHours: 24, limit: 20 }); - const candidateIds = plan.candidateStaleImages.map((image) => image.id); - const protectedIds = plan.protectedImages.map((image) => image.id); - - assertCondition(plan.ok === true, "plan should be ok", plan); - assertCondition(plan.dryRun === true && plan.mutation === false, "plan must be dry-run and non-mutating", plan.policy); - assertCondition(plan.policy.deletionExecuted === false, "plan must not execute deletion", plan.policy); - assertCondition(plan.policy.dockerPruneUsed === false, "plan must not use docker prune", plan.policy); - assertCondition(plan.policy.dockerVolumesTouched === false, "plan must not touch docker volumes", plan.policy); - assertCondition(plan.policy.databaseCleanupIncluded === false, "plan must not include database cleanup", plan.policy); - - assertCondition(candidateIds.includes("sha256:3333333333333333333333333333333333333333333333333333333333333333"), "tagged stale image should be a candidate", plan.candidateStaleImages); - assertCondition(candidateIds.includes("sha256:4444444444444444444444444444444444444444444444444444444444444444"), "dangling stale image should be a candidate", plan.candidateStaleImages); - assertCondition(protectedIds.includes("sha256:1111111111111111111111111111111111111111111111111111111111111111"), "running image should be protected", plan.protectedImages); - assertCondition(protectedIds.includes("sha256:2222222222222222222222222222222222222222222222222222222222222222"), "desired deploy image should be protected", plan.protectedImages); - - assertCondition(plan.candidateStaleImages.length === 2, "only stale non-protected images should be candidates", plan.candidateStaleImages); - assertCondition(plan.risk.medium === 1, "tagged stale candidate should be medium risk", plan.risk); - assertCondition(plan.risk.low === 1, "dangling stale candidate should be low risk", plan.risk); - assertCondition(plan.commandsToReview.length === 2, "commandsToReview should include candidate commands", plan.commandsToReview); - assertCondition(plan.commandsToReview.every((command) => command.requiresManualApproval === true), "commands must require manual approval", plan.commandsToReview); - const taggedCommand = plan.commandsToReview.find((command) => command.imageId === "sha256:3333333333333333333333333333333333333333333333333333333333333333"); - assertCondition(taggedCommand?.command.includes("old-test-image:local"), "tagged candidate command should include reviewed tag", plan.commandsToReview); - assertCondition(taggedCommand?.command.includes("old-test-image@sha256:3333333333333333333333333333333333333333333333333333333333333333"), "tagged candidate command should include reviewed digest", plan.commandsToReview); - assertCondition(plan.commandsToReview.some((command) => command.command.includes("sha256:4444444444444444444444444444444444444444444444444444444444444444")), "dangling candidate command should use image id", plan.commandsToReview); - assertCondition(!JSON.stringify(plan.commandsToReview).includes("docker image prune"), "plan must not recommend image prune", plan.commandsToReview); - assertCondition(!JSON.stringify(plan.commandsToReview).includes("docker system prune"), "plan must not recommend system prune", plan.commandsToReview); - assertCondition(plan.prohibitedCommands.includes("docker image prune"), "image prune should be explicitly prohibited", plan.prohibitedCommands); - assertCondition(plan.prohibitedCommands.includes("docker system prune"), "system prune should be explicitly prohibited", plan.prohibitedCommands); - assertCondition(plan.protectedStorage.some((item) => item.ref === "unidesk_pgdata_10gb"), "database volume must be protected", plan.protectedStorage); - assertCondition(plan.protectedStorage.some((item) => String(item.ref).includes("baidu-netdisk")), "Baidu Netdisk state must be protected", plan.protectedStorage); - assertCondition(plan.protectedStorage.some((item) => String(item.ref).includes("registry-storage")), "registry storage must be protected", plan.protectedStorage); - assertCondition(plan.estimatedReclaimBytes === (500 * 1024 * 1024) + (1024 * 1024 * 1024), "estimated reclaim should sum candidate image sizes", plan.estimates); - - return { - ok: true, - checks: [ - "dry-run non-mutating policy", - "active image protected", - "deploy/CI desired image protected", - "stale candidates emitted", - "risk levels emitted", - "manual commandsToReview emitted", - "database/registry/baidu storage protected", - "prune commands absent", - ], - }; -} - -if (import.meta.main) { - process.stdout.write(`${JSON.stringify(runServerCleanupPlanContract(), null, 2)}\n`); -} diff --git a/scripts/src/check.ts b/scripts/src/check.ts index b85e53ce..44eac86c 100644 --- a/scripts/src/check.ts +++ b/scripts/src/check.ts @@ -14,13 +14,6 @@ interface CheckItem { const syntaxFiles = [ "scripts/cli.ts", "scripts/playwright-cli.ts", - "scripts/playwright-cli-contract-test.ts", - "scripts/platform-infra-sub2api-codex-local-config-contract-test.ts", - "scripts/platform-infra-sub2api-codex-routing-contract-test.ts", - "scripts/platform-infra-sub2api-codex-temp-unsched-contract-test.ts", - "scripts/platform-infra-sub2api-http-upstream-contract-test.ts", - "scripts/check-command-progress-contract-test.ts", - "scripts/check-gh-contract-scope-contract-test.ts", "scripts/src/playwright-cli.ts", "scripts/src/check.ts", "scripts/src/artifact-registry.ts", @@ -37,32 +30,10 @@ const syntaxFiles = [ "scripts/src/e2e.ts", "scripts/src/help.ts", "scripts/src/commander.ts", - "scripts/src/commander-prompt-lint.ts", "scripts/src/recovery-guardrails.ts", "scripts/src/server-cleanup.ts", "scripts/src/remote.ts", - "scripts/host-codex-commander-contract-test.ts", - "scripts/host-codex-commander-no-daemon-smoke-contract-test.ts", - "scripts/host-codex-commander-prompt-lint-contract-test.ts", - "scripts/host-codex-commander-skeleton-contract-test.ts", - "scripts/auth-broker-contract-test.ts", - "scripts/code-queue-cli-disclosure-contract-test.ts", - "scripts/code-queue-prompt-lint-contract-test.ts", "scripts/code-queue-cli-steer-test.ts", - "scripts/code-queue-steer-confirmation-contract-test.ts", - "scripts/code-queue-cli-submit-prompt-contract-test.ts", - "scripts/code-queue-submit-execution-mode-contract-test.ts", - "scripts/code-queue-submit-summary-contract-test.ts", - "scripts/code-queue-cli-read-terminal-contract-test.ts", - "scripts/code-queue-gh-auth-redaction-contract-test.ts", - "scripts/d601-recovery-guardrails-contract-test.ts", - "scripts/hwlab-cd-wrapper-contract-test.ts", - "scripts/code-queue-queues-shape-contract-test.ts", - "scripts/microservice-health-output-contract-test.ts", - "scripts/code-queue-supervisor-disclosure-contract-test.ts", - "scripts/code-queue-commander-view-contract-test.ts", - "scripts/code-queue-postgres-rotation-contract-test.ts", - "scripts/ssh-argv-guidance-contract-test.ts", "src/components/frontend/src/index.ts", "src/components/frontend/src/app.tsx", "src/components/frontend/src/decision-center.tsx", @@ -88,7 +59,6 @@ export interface CheckOptions { compose: boolean; logs: boolean; recoveryGuardrails: boolean; - ghContracts: boolean; rust: boolean; scriptsTypecheckTimeoutMs: number; checkHeartbeatMs: number; @@ -105,7 +75,6 @@ const defaultCheckOptions: CheckOptions = { compose: false, logs: false, recoveryGuardrails: false, - ghContracts: false, rust: false, scriptsTypecheckTimeoutMs: defaultScriptsTypecheckTimeoutMs, checkHeartbeatMs: defaultCheckHeartbeatMs, @@ -124,18 +93,17 @@ export function checkHelp(): Record { ok: true, command: "check", usage: [ - "bun scripts/cli.ts check [--syntax-only|--full|--files|--scripts-typecheck|--scripts-typecheck-timeout-ms N|--check-heartbeat-ms N|--gh-contracts|--components|--compose|--logs|--recovery-guardrails|--rust]", + "bun scripts/cli.ts check [--syntax-only|--full|--files|--scripts-typecheck|--scripts-typecheck-timeout-ms N|--check-heartbeat-ms N|--components|--compose|--logs|--recovery-guardrails|--rust]", "bun scripts/cli.ts check recovery-guardrails", ], defaultMode: "syntax/config only; Rust is never compiled on the master server by default", options: [ { name: "--syntax-only|--basic", description: "Run only config validation, Bun version and TypeScript syntax transpile." }, - { name: "--full", description: "Enable all non-Rust checks, including explicit GitHub CLI contracts." }, + { name: "--full", description: "Enable all non-Rust checks." }, { name: "--files", description: "Verify required entrypoint files, including backend-core Cargo files." }, { name: "--scripts-typecheck", description: "Run scripts TypeScript typecheck through the observed checker." }, { name: "--scripts-typecheck-timeout-ms N", description: `Bound scripts TypeScript typecheck duration; default ${defaultScriptsTypecheckTimeoutMs}.` }, { name: "--check-heartbeat-ms N", description: `Emit unidesk.check.progress JSON lines for running command checks; default ${defaultCheckHeartbeatMs}.` }, - { name: "--gh-contracts", description: "Run slower GitHub CLI contract tests; intentionally separate from generic scripts typecheck." }, { name: "--components", description: "Run component TypeScript typecheck." }, { name: "--compose", description: "Render Docker Compose config." }, { name: "--logs", description: "Check unified log rotation policy." }, @@ -166,7 +134,6 @@ export function parseCheckOptions(args: string[]): CheckOptions { options.compose = true; options.logs = true; options.recoveryGuardrails = true; - options.ghContracts = true; } else if (arg === "--scripts-typecheck-timeout-ms") { options.scriptsTypecheckTimeoutMs = positiveIntegerOption(arg, args[index + 1], 600_000); index += 1; @@ -177,8 +144,6 @@ export function parseCheckOptions(args: string[]): CheckOptions { options.files = true; } else if (arg === "--scripts-typecheck") { options.scriptsTypecheck = true; - } else if (arg === "--gh-contracts") { - options.ghContracts = true; } else if (arg === "--components") { options.components = true; } else if (arg === "--compose") { @@ -430,60 +395,21 @@ export async function runChecks(config: UniDeskConfig, options: CheckOptions = d fileItem("src/components/microservices/host-codex-commander/src/redaction.ts"), fileItem("src/components/microservices/host-codex-commander/src/state.ts"), fileItem("src/components/microservices/code-queue-mgr/src/prompt-observation.ts"), - fileItem("scripts/src/commander-prompt-lint.ts"), fileItem("scripts/src/deploy.ts"), fileItem("scripts/code-queue-issue3-regression-test.ts"), fileItem("scripts/code-queue-liveness-diagnostics-test.ts"), fileItem("scripts/src/code-queue-liveness-fixtures.ts"), - fileItem("scripts/code-queue-trace-summary-contract-test.ts"), - fileItem("scripts/code-queue-pr-preflight-contract-test.ts"), - fileItem("scripts/code-queue-runner-skills-contract-test.ts"), - fileItem("scripts/code-queue-cli-disclosure-contract-test.ts"), - fileItem("scripts/code-queue-prompt-lint-contract-test.ts"), fileItem("scripts/code-queue-cli-steer-test.ts"), - fileItem("scripts/code-queue-steer-confirmation-contract-test.ts"), - fileItem("scripts/code-queue-cli-read-terminal-contract-test.ts"), - fileItem("scripts/code-queue-cli-submit-prompt-contract-test.ts"), - fileItem("scripts/code-queue-submit-summary-contract-test.ts"), - fileItem("scripts/code-queue-submit-routing-contract-test.ts"), - fileItem("scripts/code-queue-gh-auth-redaction-contract-test.ts"), - fileItem("scripts/code-queue-queues-shape-contract-test.ts"), - fileItem("scripts/code-queue-supervisor-disclosure-contract-test.ts"), - fileItem("scripts/code-queue-commander-view-contract-test.ts"), - fileItem("scripts/code-queue-postgres-rotation-contract-test.ts"), - fileItem("scripts/host-codex-commander-skeleton-contract-test.ts"), - fileItem("scripts/host-codex-commander-no-daemon-smoke-contract-test.ts"), - fileItem("scripts/host-codex-commander-prompt-lint-contract-test.ts"), - fileItem("scripts/provider-runner-triage-contract-test.ts"), - fileItem("scripts/ssh-argv-guidance-contract-test.ts"), fileItem("scripts/src/provider-triage.ts"), fileItem("src/components/microservices/code-queue/src/runner-error-classifier.ts"), fileItem("scripts/src/ci.ts"), fileItem("scripts/src/e2e.ts"), - fileItem("scripts/deploy-artifact-matrix-contract-test.ts"), - fileItem("scripts/artifact-registry-local-provider-contract-test.ts"), - fileItem("scripts/decision-center-diary-summary-contract-test.ts"), - fileItem("scripts/decision-center-desired-state-contract-test.ts"), fileItem("scripts/code-queue-prompt-observation-test.ts"), - fileItem("scripts/check-command-progress-contract-test.ts"), - fileItem("scripts/check-gh-contract-scope-contract-test.ts"), - fileItem("scripts/gh-cli-issue-guard-contract-test.ts"), - fileItem("scripts/gh-cli-pr-files-contract-test.ts"), - fileItem("scripts/gh-cli-pr-contract-test.ts"), - fileItem("scripts/playwright-cli-contract-test.ts"), - fileItem("scripts/platform-infra-sub2api-codex-local-config-contract-test.ts"), - fileItem("scripts/platform-infra-sub2api-codex-routing-contract-test.ts"), - fileItem("scripts/platform-infra-sub2api-codex-temp-unsched-contract-test.ts"), fileItem("scripts/code-queue-pr-preflight-example.ts"), - fileItem("scripts/schedule-cli-contract-test.ts"), - fileItem("scripts/server-cleanup-plan-contract-test.ts"), fileItem("scripts/src/artifact-registry.ts"), fileItem("scripts/src/server-cleanup.ts"), fileItem("scripts/src/recovery-guardrails.ts"), fileItem("scripts/src/auth-broker.ts"), - fileItem("scripts/auth-broker-contract-test.ts"), - fileItem("scripts/d601-recovery-guardrails-contract-test.ts"), - fileItem("scripts/hwlab-cd-wrapper-contract-test.ts"), fileItem("src/components/microservices/auth-broker/Cargo.toml"), fileItem("src/components/microservices/auth-broker/Dockerfile"), fileItem("src/components/microservices/auth-broker/src/main.rs"), @@ -498,113 +424,14 @@ export async function runChecks(config: UniDeskConfig, options: CheckOptions = d } if (options.scriptsTypecheck) { items.push(await commandItem("typescript:scripts", ["bun", "--bun", "tsc", "-p", "scripts/tsconfig.json", "--noEmit", "--pretty", "false"], options.scriptsTypecheckTimeoutMs, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("check:command-progress-contract", ["bun", "scripts/check-command-progress-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("code-queue:prompt-observation-contract", ["bun", "scripts/code-queue-prompt-observation-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("code-queue:issue3-diagnostics-and-image-preflight", ["bun", "scripts/code-queue-issue3-regression-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("code-queue:trace-summary-contract", ["bun", "scripts/code-queue-trace-summary-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("code-queue:pr-preflight-contract", ["bun", "scripts/code-queue-pr-preflight-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("code-queue:runner-skills-contract", ["bun", "scripts/code-queue-runner-skills-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("code-queue:cli-disclosure-contract", ["bun", "scripts/code-queue-cli-disclosure-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("code-queue:prompt-lint-contract", ["bun", "scripts/code-queue-prompt-lint-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("code-queue:cli-steer-contract", ["bun", "scripts/code-queue-cli-steer-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("code-queue:steer-confirmation-contract", ["bun", "scripts/code-queue-steer-confirmation-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("code-queue:resume-contract", ["bun", "scripts/code-queue-resume-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("code-queue:read-terminal-contract", ["bun", "scripts/code-queue-cli-read-terminal-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("code-queue:submit-prompt-contract", ["bun", "scripts/code-queue-cli-submit-prompt-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("code-queue:submit-execution-mode-contract", ["bun", "scripts/code-queue-submit-execution-mode-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("code-queue:submit-summary-contract", ["bun", "scripts/code-queue-submit-summary-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("code-queue:submit-routing-contract", ["bun", "scripts/code-queue-submit-routing-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("code-queue:gh-auth-redaction-contract", ["bun", "scripts/code-queue-gh-auth-redaction-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("code-queue:queues-shape-contract", ["bun", "scripts/code-queue-queues-shape-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("code-queue:supervisor-disclosure-contract", ["bun", "scripts/code-queue-supervisor-disclosure-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("code-queue:commander-view-contract", ["bun", "scripts/code-queue-commander-view-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("code-queue:postgres-rotation-contract", ["bun", "scripts/code-queue-postgres-rotation-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("host-codex-commander:skeleton-contract", ["bun", "scripts/host-codex-commander-skeleton-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("host-codex-commander:no-daemon-smoke-contract", ["bun", "scripts/host-codex-commander-no-daemon-smoke-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("host-codex-commander:prompt-lint-contract", ["bun", "scripts/host-codex-commander-prompt-lint-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("provider:runner-triage-contract", ["bun", "scripts/provider-runner-triage-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("ssh:argv-guidance-contract", ["bun", "scripts/ssh-argv-guidance-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("deploy:artifact-matrix-contract", ["bun", "scripts/deploy-artifact-matrix-contract-test.ts"], 90_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("artifact-registry:local-provider-contract", ["bun", "scripts/artifact-registry-local-provider-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("decision-center:diary-summary-contract", ["bun", "scripts/decision-center-diary-summary-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("decision-center:desired-state-contract", ["bun", "scripts/decision-center-desired-state-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("code-queue:active-run-heartbeat-visible", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:active-run-heartbeat-visible"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("code-queue:trace-gap-not-stale", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:trace-gap-not-stale"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("code-queue:stale-active-owner-expired", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:stale-active-owner-expired"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("code-queue:control-plane-split-brain-diagnostics", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:control-plane-split-brain-diagnostics"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("code-queue:oa-publisher-degraded-visible", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:oa-publisher-degraded-visible"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("baidu-netdisk:artifact-guard-contract", ["bun", "scripts/baidu-netdisk-artifact-guard-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("artifact-registry:direct-compose-dry-run-matrix", ["bun", "scripts/artifact-consumer-dry-run-matrix-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("schedule:cli-contract", ["bun", "scripts/schedule-cli-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("server:cleanup-plan-contract", ["bun", "scripts/server-cleanup-plan-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("check:gh-contract-scope-contract", ["bun", "scripts/check-gh-contract-scope-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("playwright:cli-wrapper-contract", ["bun", "scripts/playwright-cli-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("platform-infra:sub2api-codex-local-config-contract", ["bun", "scripts/platform-infra-sub2api-codex-local-config-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("platform-infra:sub2api-codex-routing-contract", ["bun", "scripts/platform-infra-sub2api-codex-routing-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("platform-infra:sub2api-codex-sentinel-contract", ["bun", "scripts/platform-infra-sub2api-codex-sentinel-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("platform-infra:sub2api-codex-temp-unsched-contract", ["bun", "scripts/platform-infra-sub2api-codex-temp-unsched-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("platform-infra:sub2api-http-upstream-contract", ["bun", "scripts/platform-infra-sub2api-http-upstream-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("auth-broker:p0-contract", ["bun", "scripts/auth-broker-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("d601:recovery-guardrails-contract", ["bun", "scripts/d601-recovery-guardrails-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("hwlab:cd-wrapper-contract", ["bun", "scripts/hwlab-cd-wrapper-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); } else { items.push(skippedItem("typescript:scripts", "scripts TypeScript typecheck is opt-in", "--scripts-typecheck or --full")); - items.push(skippedItem("check:command-progress-contract", "observed command progress contract is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("code-queue:prompt-observation-contract", "prompt observation contract is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("code-queue:issue3-diagnostics-and-image-preflight", "Code Queue issue #3 regression fixtures are opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("code-queue:trace-summary-contract", "Code Queue trace summary contract is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("code-queue:pr-preflight-contract", "Code Queue PR preflight contract is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("code-queue:runner-skills-contract", "Code Queue runner skill availability contract is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("code-queue:cli-disclosure-contract", "Code Queue CLI disclosure/noise contract is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("code-queue:prompt-lint-contract", "Code Queue prompt live-authorization lint contract is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("code-queue:cli-steer-contract", "Code Queue steer CLI contract is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("code-queue:steer-confirmation-contract", "Code Queue steer delivery confirmation contract is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("code-queue:resume-contract", "Code Queue resume CLI and delivery contract is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("code-queue:read-terminal-contract", "Code Queue terminal read contract is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("code-queue:submit-prompt-contract", "Code Queue submit prompt contract is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("code-queue:submit-execution-mode-contract", "Code Queue submit execution-mode contract is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("code-queue:submit-summary-contract", "Code Queue submit summary contract is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("code-queue:submit-routing-contract", "Code Queue submit routing contract is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("code-queue:gh-auth-redaction-contract", "Code Queue GitHub auth output redaction contract is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("code-queue:queues-shape-contract", "Code Queue queues shape contract is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("code-queue:supervisor-disclosure-contract", "Code Queue supervisor disclosure contract is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("code-queue:commander-view-contract", "Code Queue commander view contract is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("code-queue:postgres-rotation-contract", "Code Queue postgres rotation crash contract is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("host-codex-commander:skeleton-contract", "host Codex commander skeleton contract is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("host-codex-commander:no-daemon-smoke-contract", "host Codex commander no-daemon smoke contract is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("host-codex-commander:prompt-lint-contract", "host Codex commander prompt boundary lint contract is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("provider:runner-triage-contract", "Provider runner triage contract is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("ssh:argv-guidance-contract", "SSH argv guidance and failure hint contract is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("deploy:artifact-matrix-contract", "deploy artifact matrix contract is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("artifact-registry:local-provider-contract", "artifact registry local provider contract is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("decision-center:diary-summary-contract", "Decision Center diary summary contract is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("decision-center:desired-state-contract", "Decision Center desired-state drift contract is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("code-queue:liveness-diagnostics-fixtures", "Code Queue liveness diagnostics fixtures are opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("baidu-netdisk:artifact-guard-contract", "Baidu Netdisk artifact guard contract is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("artifact-registry:direct-compose-dry-run-matrix", "main-server direct artifact consumer dry-run matrix is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("schedule:cli-contract", "Schedule CLI contract is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("server:cleanup-plan-contract", "Server cleanup dry-run contract is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("check:gh-contract-scope-contract", "Check option scope contract is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("playwright:cli-wrapper-contract", "Playwright wrapper/headless/session contract is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("platform-infra:sub2api-codex-sentinel-contract", "Sub2API Codex account sentinel contract is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("auth-broker:p0-contract", "Auth Broker P0 skeleton and CLI adapter contract is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("d601:recovery-guardrails-contract", "D601 recovery guardrails fixture contract is opt-in with script checks", "--scripts-typecheck or --full")); - items.push(skippedItem("hwlab:cd-wrapper-contract", "HWLAB DEV CD wrapper contract is opt-in with script checks", "--scripts-typecheck or --full")); } if (options.logs) { items.push(unifiedLogRotationItem()); } else { items.push(skippedItem("logs:unified-hourly-rotation", "policy scan is opt-in", "--logs or --full")); } - if (options.ghContracts) { - items.push(await commandItem("gh:issue-guard-contract", ["bun", "scripts/gh-cli-issue-guard-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("gh:pr-files-contract", ["bun", "scripts/gh-cli-pr-files-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - items.push(await commandItem("gh:pr-contract", ["bun", "scripts/gh-cli-pr-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); - } else { - items.push(skippedItem("gh:issue-guard-contract", "GitHub issue CLI contract is opt-in because it can be slower than generic scripts typecheck", "--gh-contracts or --full")); - items.push(skippedItem("gh:pr-files-contract", "GitHub PR files/stat contract is opt-in because it can be slower than generic scripts typecheck", "--gh-contracts or --full")); - items.push(skippedItem("gh:pr-contract", "GitHub PR CLI contract is opt-in because it can be slower than generic scripts typecheck", "--gh-contracts or --full")); - } if (options.recoveryGuardrails) { const recovery = runRecoveryGuardrailsCheck(config); items.push({ diff --git a/scripts/src/code-queue.ts b/scripts/src/code-queue.ts index 2755a232..96dee795 100644 --- a/scripts/src/code-queue.ts +++ b/scripts/src/code-queue.ts @@ -154,71 +154,6 @@ interface CodexJudgeOptions { includePrompt: boolean; } -type LiveTestAuthorizationClass = "read-only" | "live-read" | "live-mutating"; -type PromptLintSeverity = "info" | "warning" | "block"; -type PromptLintDisposition = "ready" | "review" | "needs-authorization"; - -interface CodexPromptLintOptions { - prompt: string; -} - -interface PromptLintSignal { - id: string; - severity: PromptLintSeverity; - matched: boolean; - evidence: string[]; - message: string; -} - -interface PromptLiveAuthorizationLint { - ok: boolean; - dryRun: true; - mutation: false; - dispatchDisposition: PromptLintDisposition; - declaredClass: LiveTestAuthorizationClass | null; - effectiveClass: LiveTestAuthorizationClass; - requiredClass: LiveTestAuthorizationClass; - defaultedReadOnly: boolean; - liveMutationAuthorized: boolean; - promptShape: { - chars: number; - lines: number; - textEchoed: false; - }; - requiredPromptFields: { - devTestClass: { - present: boolean; - value: LiveTestAuthorizationClass | null; - allowedValues: LiveTestAuthorizationClass[]; - }; - allowedLiveMutation: { - present: boolean; - nonNone: boolean; - requiredWhen: "live-mutating"; - }; - forbiddenActions: { - present: boolean; - }; - closeoutFields: { - present: boolean; - }; - }; - signals: PromptLintSignal[]; - missingOrContradictory: string[]; - policy: { - defaultWhenUnclassified: "read-only"; - promptLintOnly: true; - accessesLiveService: false; - printsPromptText: false; - reference: string; - }; - commands: { - lintFile: string; - submitDryRun: string; - steerDryRun: string; - }; -} - interface CodexSubmitOptions { prompt: string; queueId: string | undefined; @@ -1397,196 +1332,6 @@ function routeSignal(id: string, severity: SubmitRouteSignalSeverity, evidence: return { id, severity, matched: evidence.length > 0, evidence, message }; } -function promptLintSignal(id: string, severity: PromptLintSeverity, evidence: string[], message: string): PromptLintSignal { - return { id, severity, matched: evidence.length > 0, evidence, message }; -} - -function declaredLiveTestClass(prompt: string): LiveTestAuthorizationClass | null { - const patterns: Array<[LiveTestAuthorizationClass, RegExp[]]> = [ - ["live-mutating", [ - /\bDEV\s+test\s+class\s*[::]\s*`?live-mutating`?/iu, - /\blive\s+test\s+class\s*[::]\s*`?live-mutating`?/iu, - /\btest\s+class\s*[::]\s*`?live-mutating`?/iu, - /\bDEV\s+测试(?:授权)?分级\s*[::]\s*`?live-mutating`?/iu, - ]], - ["live-read", [ - /\bDEV\s+test\s+class\s*[::]\s*`?live-read`?/iu, - /\blive\s+test\s+class\s*[::]\s*`?live-read`?/iu, - /\btest\s+class\s*[::]\s*`?live-read`?/iu, - /\bDEV\s+测试(?:授权)?分级\s*[::]\s*`?live-read`?/iu, - ]], - ["read-only", [ - /\bDEV\s+test\s+class\s*[::]\s*`?read-only`?/iu, - /\blive\s+test\s+class\s*[::]\s*`?read-only`?/iu, - /\btest\s+class\s*[::]\s*`?read-only`?/iu, - /\bDEV\s+测试(?:授权)?分级\s*[::]\s*`?read-only`?/iu, - ]], - ]; - for (const [value, valuePatterns] of patterns) { - if (valuePatterns.some((pattern) => pattern.test(prompt))) return value; - } - return null; -} - -function liveClassRank(value: LiveTestAuthorizationClass): number { - if (value === "read-only") return 0; - if (value === "live-read") return 1; - return 2; -} - -function hasPromptField(prompt: string, patterns: RegExp[]): boolean { - return patterns.some((pattern) => pattern.test(prompt)); -} - -function sanitizePromptLintEvidence(evidence: string[]): string[] { - return evidence.map((item) => item - .replace(/([?&](?:token|api[_-]?key|secret|password|credential)=)[^&\s]+/giu, "$1") - .replace(/((?:token|api[_-]?key|secret|password|credential)\s*[:=]\s*)[^\s,;]+/giu, "$1") - .replace(/(Bearer\s+)[A-Za-z0-9._~+/-]+=*/giu, "$1") - .slice(0, 160)); -} - -function buildPromptLiveAuthorizationLint(prompt: string): PromptLiveAuthorizationLint { - const declaredClass = declaredLiveTestClass(prompt); - const effectiveClass = declaredClass ?? "read-only"; - const allowedClasses: LiveTestAuthorizationClass[] = ["read-only", "live-read", "live-mutating"]; - const liveReadEvidence = sanitizePromptLintEvidence(regexEvidenceWithoutNegatedContext(prompt, [ - /\blive[- ]read\b/giu, - /\blive\s+(?:dev\s+)?(?:service|runtime|endpoint|health|status|logs?|metrics?)\b/giu, - /\bGET\s+\/(?:health|status|live|metrics|api\/diagnostics)\b/gu, - /\bkubectl\s+(?:get|describe|logs)\b/giu, - /\bmicroservice\s+(?:health|status|diagnostics)\b/giu, - /\bdiagnostics\b|\bstatus\b|\bmetrics\b|\blogs?\b/giu, - /只读(?:读取|观察|诊断|状态|日志)/gu, - /读取\s*(?:DEV|live|运行中|服务|日志|状态)/giu, - ])); - const liveMutationEvidence = sanitizePromptLintEvidence(regexEvidenceWithoutNegatedContext(prompt, [ - /\blive-mutating\b/giu, - /\bDEV\s+smoke\b|\blive\s+smoke\b|\bM3\s+smoke\b/giu, - /\bdeploy\s+apply\b|\brollout\s+restart\b|\bkubectl\s+(?:apply|delete|patch|rollout)\b/giu, - /\b(?:POST|PUT|PATCH|DELETE)\s+\/[A-Za-z0-9_./:-]*/gu, - /\bcodex\s+(?:submit|steer|interrupt|cancel)\b/giu, - /\btask\s+(?:submit|steer|retry|trigger)\b/giu, - /\btrigger\s+(?:schedule|job|task|operation|audit|evidence)\b/giu, - /\bschedule\s+(?:run|retry-run|delete)\b/giu, - /\b(?:create|write|post|put|patch)\b[^\n。]{0,40}\b(?:operation|audit|evidence)\b/giu, - /\b(?:operation|audit|evidence)\s+(?:id|record|write|create)\b/giu, - /\bDO\d+\b|\bDI\d+\b|\bres_boxsimu_\d+\b|\bhwlab-patch-panel\b/giu, - /触发|写入|部署|重启|重建|回滚|创建(?:任务|operation|audit|evidence)|硬件|虚拟硬件/gu, - ])); - const prodMutationEvidence = sanitizePromptLintEvidence(regexEvidenceWithoutNegatedContext(prompt, [ - /\bprod(?:uction)?\b[^\n。]*(?:deploy|restart|write|mutation|mutating|apply|rollout|delete|patch)\b/giu, - /\b(?:deploy|restart|write|mutation|mutating|apply|rollout|delete|patch)\b[^\n。]*\bprod(?:uction)?\b/giu, - /生产[^\n。]*(?:写入|部署|重启|变更|删除|回滚)/gu, - ])); - const requiredClass = liveMutationEvidence.length > 0 || prodMutationEvidence.length > 0 - ? "live-mutating" - : liveReadEvidence.length > 0 - ? "live-read" - : "read-only"; - const allowedLiveMutationPresent = hasPromptField(prompt, [ - /\ballowed\s+live\s+mutation\s*[::]/iu, - /允许的\s*live\s*mutation\s*[::]/iu, - /允许的(?:现场|实时|运行态)?(?:写入|变更|mutation)\s*[::]/iu, - ]); - const allowedLiveMutationNone = hasPromptField(prompt, [ - /\ballowed\s+live\s+mutation\s*[::]\s*(?:`?none`?|无|なし)(?:\s|$|[。.;,,])/iu, - /允许的\s*live\s*mutation\s*[::]\s*(?:`?none`?|无|なし)(?:\s|$|[。.;,,])/iu, - /允许的(?:现场|实时|运行态)?(?:写入|变更|mutation)\s*[::]\s*(?:`?none`?|无|なし)(?:\s|$|[。.;,,])/iu, - ]); - const forbiddenActionsPresent = hasPromptField(prompt, [ - /\bforbidden\s+actions?\s*[::]/iu, - /禁止动作\s*[::]/u, - /禁止\s*[::]/u, - ]); - const closeoutFieldsPresent = hasPromptField(prompt, [ - /\bcloseout\s+fields?\s*[::]/iu, - /\bfinal\s+response\b[^\n。]*(?:must|include|report)/iu, - /\b收口字段\s*[::]/u, - /\bfinal\s+response\b[^\n。]*报告/iu, - ]); - const effectiveInsufficient = liveClassRank(effectiveClass) < liveClassRank(requiredClass); - const liveMutationAuthorized = effectiveClass === "live-mutating" && allowedLiveMutationPresent && !allowedLiveMutationNone; - const contradictionEvidence = [ - ...(effectiveClass === "read-only" && liveMutationEvidence.length > 0 ? ["declares/read-only but prompt contains live mutation signals"] : []), - ...(effectiveClass === "live-read" && liveMutationEvidence.length > 0 ? ["declares/live-read but prompt contains live mutation signals"] : []), - ...(effectiveClass === "live-mutating" && allowedLiveMutationNone ? ["declares/live-mutating but allowed live mutation is none"] : []), - ...(prodMutationEvidence.length > 0 ? prodMutationEvidence.map((item) => `prod mutation signal: ${item}`) : []), - ]; - const missingOrContradictory = [ - ...(declaredClass === null ? ["missing DEV test class; defaulting to read-only"] : []), - ...(effectiveInsufficient ? [`effective class ${effectiveClass} is below required ${requiredClass}`] : []), - ...(requiredClass === "live-mutating" && !allowedLiveMutationPresent ? ["live-mutating prompt must include allowed live mutation"] : []), - ...(requiredClass === "live-mutating" && allowedLiveMutationNone ? ["live-mutating prompt cannot set allowed live mutation to none"] : []), - ...(!forbiddenActionsPresent ? ["missing forbidden actions"] : []), - ...(!closeoutFieldsPresent ? ["missing closeout fields"] : []), - ...contradictionEvidence, - ]; - const signals = [ - promptLintSignal("declared-dev-test-class", "info", declaredClass === null ? [] : [declaredClass], "Prompt explicitly declares DEV test class."), - promptLintSignal("live-read-signal", "warning", liveReadEvidence, "Prompt appears to read live DEV service state, logs, health, status, metrics, or Kubernetes objects."), - promptLintSignal("live-mutation-signal", "block", liveMutationEvidence, "Prompt appears to trigger runtime writes, deployment, task control, operation/audit/evidence creation, or HWLAB DO/DI activity."), - promptLintSignal("prod-mutation-signal", "block", prodMutationEvidence, "Prompt appears to mention production mutation; Code Queue runner prompts must not implicitly authorize this."), - promptLintSignal("allowed-live-mutation-field", requiredClass === "live-mutating" ? "block" : "info", allowedLiveMutationPresent && !allowedLiveMutationNone ? ["present"] : [], "live-mutating prompts must enumerate allowed live mutation commands and target state changes."), - promptLintSignal("forbidden-actions-field", "warning", forbiddenActionsPresent ? ["present"] : [], "Prompt should list forbidden high-risk actions."), - promptLintSignal("closeout-fields-field", "warning", closeoutFieldsPresent ? ["present"] : [], "Prompt should require final closeout fields for class, mutation, commands, targets, evidence, and residual risk."), - ]; - const ok = missingOrContradictory.length === 0; - const dispatchDisposition: PromptLintDisposition = ok - ? "ready" - : requiredClass === "live-mutating" || effectiveInsufficient || contradictionEvidence.length > 0 - ? "needs-authorization" - : "review"; - return { - ok, - dryRun: true, - mutation: false, - dispatchDisposition, - declaredClass, - effectiveClass, - requiredClass, - defaultedReadOnly: declaredClass === null, - liveMutationAuthorized, - promptShape: { - chars: prompt.length, - lines: prompt.split(/\r\n|\r|\n/u).length, - textEchoed: false, - }, - requiredPromptFields: { - devTestClass: { - present: declaredClass !== null, - value: declaredClass, - allowedValues: allowedClasses, - }, - allowedLiveMutation: { - present: allowedLiveMutationPresent, - nonNone: allowedLiveMutationPresent && !allowedLiveMutationNone, - requiredWhen: "live-mutating", - }, - forbiddenActions: { - present: forbiddenActionsPresent, - }, - closeoutFields: { - present: closeoutFieldsPresent, - }, - }, - signals, - missingOrContradictory, - policy: { - defaultWhenUnclassified: "read-only", - promptLintOnly: true, - accessesLiveService: false, - printsPromptText: false, - reference: "docs/reference/code-queue-supervision.md#dev-测试授权分级", - }, - commands: { - lintFile: "bun scripts/cli.ts codex prompt-lint --prompt-file ", - submitDryRun: "bun scripts/cli.ts codex submit --prompt-file --dry-run", - steerDryRun: "bun scripts/cli.ts codex steer --prompt-file --dry-run", - }, - }; -} - function submitPolicyContract(): SubmitRoutingRecommendation["policyContract"] { return { selectionPrinciples: [ @@ -1752,7 +1497,7 @@ function submitRoutingRecommendation(options: CodexSubmitOptions): SubmitRouting routeSignal("issue-auxiliary-source-guard", "info", issueAuxiliaryGuard, "Prompt explicitly says GitHub issue is auxiliary and not the only source."), routeSignal("cross-module-release", "warning", crossModule, "Mentions cross-module architecture, CI/CD rollout, release line, or rollback work."), routeSignal("medium-complexity-verifiable", "info", mediumComplexityEvidence, "Mentions bounded medium-complexity work such as frontend, local CLI/helper, user-service module, or contract guard changes."), - routeSignal("low-risk-verifiable", "info", lowRiskEvidence, "Mentions low-risk or verifiable work such as docs, read-only checks, dry-run, preflight, or contract tests."), + routeSignal("low-risk-verifiable", "info", lowRiskEvidence, "Mentions low-risk or verifiable work such as docs, read-only checks, dry-run, preflight, or syntax checks."), routeSignal("evidence-requested", "info", evidenceRequest, "Prompt asks for tests, validation, commit, or evidence."), routeSignal("self-contained-hints", "info", selfContainedHints, "Prompt includes explicit task sections that make it easier to verify without reading an issue."), ]; @@ -5611,16 +5356,6 @@ function parseResumeOptions(args: string[]): CodexResumeOptions { }; } -function parsePromptLintOptions(args: string[]): CodexPromptLintOptions { - assertKnownOptions(args, { - flags: ["--prompt-stdin", "--stdin"], - valueOptions: ["--prompt-file", "--file"], - }, "codex prompt-lint"); - return { - prompt: promptFromArgs(args, "codex prompt-lint", steerPromptValueOptions), - }; -} - function submitPayload(options: CodexSubmitOptions): Record { return { prompt: options.prompt, @@ -7978,28 +7713,17 @@ export function codexSubmitRoutingRecommendationForTest(prompt: string, model?: }); } -export function codexPromptLiveAuthorizationLintForTest(prompt: string): PromptLiveAuthorizationLint { - return buildPromptLiveAuthorizationLint(prompt); -} - export function codexSubmitModelRegistryForTest(models: string[] = sharedDefaultCodeModels): ReturnType { return submitModelRegistry(models); } -function codexPromptLintTask(args: string[]): unknown { - const options = parsePromptLintOptions(args); - return buildPromptLiveAuthorizationLint(options.prompt); -} - function codexSubmitTask(args: string[]): unknown { const options = parseSubmitOptions(args); const payload = submitPayload(options); - const promptLint = buildPromptLiveAuthorizationLint(options.prompt); if (options.dryRun) { return { ok: true, dryRun: true, - promptLint, executionMode: executionModeSummary(options.executionMode), runnerPermissions: dryRunRunnerPermissionsSummary(), routingRecommendation: submitRoutingRecommendation(options), @@ -8033,7 +7757,6 @@ function codexInterruptTask(taskId: string): unknown { function codexSteerTask(taskId: string, args: string[], fetcher: CodexResponseFetcher = coreInternalFetch): unknown { const options = parseSteerOptions(args); - const promptLint = buildPromptLiveAuthorizationLint(options.prompt); const steerId = options.steerId ?? createSteerId(taskId, options.prompt); const targetPath = `/api/tasks/${encodeURIComponent(taskId)}/steer`; const stableProxyPath = codeQueueProxyPath(targetPath); @@ -8064,7 +7787,6 @@ function codexSteerTask(taskId: string, args: string[], fetcher: CodexResponseFe return { ok: true, dryRun: true, - promptLint, request, commands: { run: `bun scripts/cli.ts codex steer ${taskId} --prompt-file --steer-id ${steerId}`, @@ -8401,9 +8123,6 @@ function codexResumeTask(taskId: string, args: string[], fetcher: CodexResponseF export async function runCodeQueueCommand(config: UniDeskConfig, args: string[]): Promise { const [action = "task", taskIdArg] = args; - if (action === "prompt-lint" || action === "lint-prompt") { - return codexPromptLintTask(args.slice(1)); - } if (action === "submit" || action === "enqueue") { return legacyCodeQueueFrozenMutation(`codex ${action}`); } @@ -8462,5 +8181,5 @@ export async function runCodeQueueCommand(config: UniDeskConfig, args: string[]) const taskId = requireTaskId(taskIdArg, `codex ${action}`); return codexSteerTraceConfirm(taskId, args.slice(2)); } - throw new Error("codex command must be one of: prompt-lint, submit, enqueue, task, summary, show, tasks, overview, unread, terminal-unread, output, judge, read, mark-read, dev-ready, health, skills-sync, execution-plane, exec-plane, runtime-plane, pr-preflight, runtime-preflight, queues, queue list, queue create, queue merge, move, steer, resume, steer-confirm, interrupt, cancel"); + throw new Error("codex command must be one of: submit, enqueue, task, summary, show, tasks, overview, unread, terminal-unread, output, judge, read, mark-read, dev-ready, health, skills-sync, execution-plane, exec-plane, runtime-plane, pr-preflight, runtime-preflight, queues, queue list, queue create, queue merge, move, steer, resume, steer-confirm, interrupt, cancel"); } diff --git a/scripts/src/commander-prompt-lint.ts b/scripts/src/commander-prompt-lint.ts deleted file mode 100644 index c2bad55b..00000000 --- a/scripts/src/commander-prompt-lint.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { readFileSync } from "node:fs"; - -export type CommanderPromptLintKind = "gpt55-pr"; -export type CommanderPromptLintRiskLevel = "low" | "medium" | "high"; - -interface ClauseCheck { - id: string; - label: string; - required: boolean; - matched: boolean; - severity: "medium" | "high"; - suggestion: string; -} - -export interface CommanderPromptLintResult { - ok: boolean; - kind: CommanderPromptLintKind; - missingClauses: string[]; - riskLevel: CommanderPromptLintRiskLevel; - suggestedPatchSnippet: string; - promptShape: { - chars: number; - lines: number; - textEchoed: false; - }; - policy: { - advisoryOnly: true; - mutatesScheduler: false; - changesCodexSubmitDefault: false; - printsPromptText: false; - supportedInputs: Array<"--stdin" | "--prompt-file">; - reference: string; - }; -} - -interface ParsedPromptLintArgs { - kind: CommanderPromptLintKind; - prompt: string; -} - -const gpt55PrSnippet = [ - "GPT-5.5 runner boundary:", - "- You may create/update the branch and PR, resolve conflicts, rebase/update from the target branch, and self-merge/close an ordinary PR when checks pass and the task boundary is satisfied.", - "- You may use repo-owned CI/CD, publish, or equivalent controlled build paths to build/publish DEV images or artifacts, and must report commit, image tag, digest, artifact report, and validation evidence.", - "- DEV deploy apply, rollout, and live health verification are owned by the host commander unless this prompt explicitly contains ROLLOUT_OK.", - "- Without explicit ROLLOUT_OK, do not acquire the DEV CD lock, run deploy apply, perform rollout/restart, or compete with host commander live verification.", - "- Forbidden: PROD mutation, reading or printing secrets, manual database writes, destructive rollback, Code Queue backend/scheduler restart, or interrupt/cancel running tasks unless explicitly authorized.", -].join("\n"); - -function hasFlag(args: string[], flag: string): boolean { - return args.includes(flag); -} - -function optionValue(args: string[], names: string[]): string | undefined { - for (const name of names) { - const index = args.indexOf(name); - if (index === -1) continue; - const value = args[index + 1]; - if (value === undefined || value.startsWith("--")) throw new Error(`${name} requires a value`); - return value; - } - return undefined; -} - -function assertKnownOptions(args: string[]): void { - const flags = new Set(["--stdin"]); - const valueOptions = new Set(["--kind", "--prompt-file", "--file"]); - for (let index = 0; index < args.length; index += 1) { - const arg = args[index] ?? ""; - if (!arg.startsWith("--")) throw new Error(`commander prompt-lint does not accept positional prompt text; use --stdin or --prompt-file`); - if (flags.has(arg)) continue; - if (valueOptions.has(arg)) { - index += 1; - if (index >= args.length) throw new Error(`${arg} requires a value`); - continue; - } - throw new Error(`unknown commander prompt-lint option: ${arg}`); - } -} - -function parseKind(raw: string | undefined): CommanderPromptLintKind { - const kind = raw ?? "gpt55-pr"; - if (kind !== "gpt55-pr") throw new Error(`unsupported commander prompt-lint kind: ${kind}`); - return kind; -} - -function readPromptFromArgs(args: string[]): string { - const promptFile = optionValue(args, ["--prompt-file", "--file"]); - const promptStdin = hasFlag(args, "--stdin"); - const sources = [promptFile !== undefined, promptStdin].filter(Boolean).length; - if (sources !== 1) throw new Error("commander prompt-lint requires exactly one prompt source: --prompt-file or --stdin"); - const prompt = promptFile !== undefined - ? (promptFile === "-" ? readFileSync(0, "utf8") : readFileSync(promptFile, "utf8")) - : readFileSync(0, "utf8"); - if (prompt.trim().length === 0) throw new Error("commander prompt-lint prompt must not be empty"); - return prompt; -} - -export function parseCommanderPromptLintArgs(args: string[]): ParsedPromptLintArgs { - assertKnownOptions(args); - return { - kind: parseKind(optionValue(args, ["--kind"])), - prompt: readPromptFromArgs(args), - }; -} - -function any(patterns: RegExp[], prompt: string): boolean { - return patterns.some((pattern) => pattern.test(prompt)); -} - -function rolloutOkPresent(prompt: string): boolean { - return /\bROLLOUT_OK\b/u.test(prompt); -} - -function gpt55PrClauseChecks(prompt: string): ClauseCheck[] { - const prAuthorization = any([ - /\bPR\b[^\n。]{0,120}(?:create|update|push|branch|merge|close|rebase|conflict)/iu, - /(?:创建|更新|push|提交|合并|关闭|rebase|解决冲突)[^\n。]{0,80}\bPR\b/iu, - ], prompt) && any([ - /self[- ]?(?:merge|close)|自行(?:合并|关闭|收口)|自合并|自收口/iu, - /merge\/close|合并\/关闭/iu, - ], prompt) && any([ - /\brebase\b|\bupdate\b|解决冲突|更新(?:目标|base|master|分支)/iu, - ], prompt); - - const artifactAuthorization = any([ - /\b(?:build|publish)\b[^\n。]{0,80}\b(?:artifact|image|DEV image|镜像|制品)\b/iu, - /\b(?:artifact|image|镜像|制品)\b[^\n。]{0,80}\b(?:build|publish|tag|digest|构建|发布)\b/iu, - /(?:构建|发布)[^\n。]{0,80}(?:镜像|制品|artifact|image)/iu, - ], prompt) && any([ - /\b(?:tag|digest|artifact report|image tag)\b/iu, - /(?:镜像\s*tag|digest|制品报告|artifact report)/iu, - ], prompt); - - const hostOwnsDevRollout = any([ - /DEV[^\n。]{0,120}(?:deploy apply|rollout|live health|live verification)[^\n。]{0,120}(?:host commander|commander|host|统一执行|统一处理|统一复验)/iu, - /(?:host commander|commander|host|指挥官)[^\n。]{0,120}DEV[^\n。]{0,120}(?:deploy apply|rollout|live health|live verification|发布|上线|复验)/iu, - /DEV[^\n。]{0,80}(?:发布|上线|rollout|复验)[^\n。]{0,80}(?:host|commander|指挥官|统一)/iu, - ], prompt); - - const forbidsRunnerRolloutUnlessOk = any([ - /unless[^\n。]{0,80}\bROLLOUT_OK\b/iu, - /未显式[^\n。]{0,60}\bROLLOUT_OK\b[^\n。]{0,80}(?:不要|不得|禁止|do not|don't|must not)/iu, - /\bROLLOUT_OK\b[^\n。]{0,80}(?:才|unless|only if|explicit)/iu, - ], prompt) && any([ - /(?:do not|don't|must not|禁止|不要|不得)[^\n。]{0,100}(?:DEV CD lock|deploy apply|rollout|live health|rollout restart|竞争|抢)/iu, - /(?:DEV CD lock|deploy apply|rollout|live health|rollout restart|竞争|抢)[^\n。]{0,100}(?:do not|don't|must not|禁止|不要|不得)/iu, - ], prompt); - - const prodForbidden = any([ - /(?:禁止|不要|不得|must not|do not|don't|\bno\b|forbid|forbidden)[^\n。]{0,80}(?:PROD|prod|production|生产)/iu, - /(?:PROD|prod|production|生产)[^\n。]{0,80}(?:禁止|不要|不得|must not|do not|don't|\bno\b|forbid|forbidden|not allowed)/iu, - ], prompt); - const secretForbidden = any([ - /(?:禁止|不要|不得|must not|do not|don't|\bno\b|forbid|forbidden)[^\n。]{0,80}(?:secret|token|credential|密钥|凭证)/iu, - /(?:secret|token|credential|密钥|凭证)[^\n。]{0,80}(?:禁止|不要|不得|must not|do not|don't|\bno\b|forbid|forbidden|not allowed|读取|打印)/iu, - ], prompt); - const dbForbidden = any([ - /(?:禁止|不要|不得|must not|do not|don't|\bno\b|forbid|forbidden)[^\n。]{0,80}(?:database|DB|数据库)/iu, - /(?:database|DB|数据库)[^\n。]{0,80}(?:manual|手工|patch|write|写入|禁止|不要|不得|must not|do not|don't|\bno\b)/iu, - ], prompt); - const rollbackForbidden = any([ - /(?:禁止|不要|不得|must not|do not|don't|\bno\b|forbid|forbidden)[^\n。]{0,80}(?:destructive rollback|破坏性回滚|回滚)/iu, - /(?:destructive rollback|破坏性回滚)[^\n。]{0,80}(?:禁止|不要|不得|must not|do not|don't|\bno\b|forbid|forbidden|not allowed)/iu, - ], prompt); - - return [ - { - id: "pr-self-merge-rebase-authorization", - label: "PR/自合并/rebase/update 授权文本", - required: true, - matched: prAuthorization, - severity: "high", - suggestion: "明确授权 runner 创建/更新 PR、rebase/update/解决冲突,并在普通 PR 满足任务边界和检查通过时自合并/关闭。", - }, - { - id: "artifact-build-publish-authorization", - label: "build/publish artifact 授权文本", - required: true, - matched: artifactAuthorization, - severity: "high", - suggestion: "明确授权使用 repo-owned CI/CD 或受控构建路径发布 DEV image/artifact,并要求回报 tag、digest 和 artifact report。", - }, - { - id: "host-owned-dev-rollout", - label: "DEV deploy apply/rollout/live verification 由 host 统一执行文本", - required: true, - matched: hostOwnsDevRollout, - severity: "high", - suggestion: "写明 DEV deploy apply、rollout 和 live health verification 默认由 host commander 统一执行。", - }, - { - id: "runner-rollout-forbidden-without-rollout-ok", - label: "未显式 ROLLOUT_OK 时禁止 runner rollout", - required: true, - matched: forbidsRunnerRolloutUnlessOk, - severity: "high", - suggestion: "写明未显式包含 ROLLOUT_OK 时,runner 不得抢 DEV CD lock、deploy apply、rollout 或 live verification。", - }, - { - id: "prod-secret-db-rollback-boundary", - label: "PROD/secret/DB/破坏性回滚边界", - required: true, - matched: prodForbidden && secretForbidden && dbForbidden && rollbackForbidden, - severity: "high", - suggestion: "同时禁止 PROD mutation、密钥读取/打印、数据库手工写入和破坏性回滚。", - }, - ]; -} - -export function lintCommanderPrompt(prompt: string, kind: CommanderPromptLintKind = "gpt55-pr"): CommanderPromptLintResult { - const clauses = gpt55PrClauseChecks(prompt); - const missingClauses = clauses.filter((clause) => clause.required && !clause.matched).map((clause) => clause.id); - const highMissing = clauses.some((clause) => clause.severity === "high" && !clause.matched); - const rolloutOk = rolloutOkPresent(prompt); - const riskLevel: CommanderPromptLintRiskLevel = missingClauses.length === 0 - ? rolloutOk ? "medium" : "low" - : highMissing ? "high" : "medium"; - - return { - ok: missingClauses.length === 0, - kind, - missingClauses, - riskLevel, - suggestedPatchSnippet: missingClauses.length === 0 ? "" : gpt55PrSnippet, - promptShape: { - chars: prompt.length, - lines: prompt.split(/\r\n|\r|\n/u).length, - textEchoed: false, - }, - policy: { - advisoryOnly: true, - mutatesScheduler: false, - changesCodexSubmitDefault: false, - printsPromptText: false, - supportedInputs: ["--stdin", "--prompt-file"], - reference: "docs/reference/host-codex-commander.md", - }, - }; -} - -export function runCommanderPromptLintCommand(args: string[]): CommanderPromptLintResult { - const options = parseCommanderPromptLintArgs(args); - return lintCommanderPrompt(options.prompt, options.kind); -} diff --git a/scripts/src/commander.ts b/scripts/src/commander.ts index 2276f4f7..fa54afe8 100644 --- a/scripts/src/commander.ts +++ b/scripts/src/commander.ts @@ -1,7 +1,6 @@ import { buildCommanderApprovalNotificationDraft, commanderApprovalNotificationPathUnavailable } from "../../src/components/microservices/host-codex-commander/src/approval-notification"; import { commanderContract as hostCommanderContract, commanderHighRiskActions as highRiskActions } from "../../src/components/microservices/host-codex-commander/src/contract"; import { redactText } from "../../src/components/microservices/host-codex-commander/src/redaction"; -import { runCommanderPromptLintCommand } from "./commander-prompt-lint"; const requiredDryRunMessage = "This host Codex commander skeleton only supports dry-run planning; live daemon/control operations are not implemented."; @@ -37,7 +36,6 @@ function commanderHelp(): Record { "bun scripts/cli.ts commander plan --dry-run [--session-id id]", "bun scripts/cli.ts commander smoke --dry-run [--session-id id]", "bun scripts/cli.ts commander approval request --action --dry-run [--reason text] [--task-id id]", - "bun scripts/cli.ts commander prompt-lint --kind gpt55-pr (--prompt-file |--stdin)", ], highRiskActions, reference: "docs/reference/host-codex-commander.md", @@ -264,7 +262,7 @@ function healthEndpointValidation(): Record { return { surface: "health endpoint", endpoint: "GET /health", - validationMethod: "invoke createCommanderRequestHandler with a temporary RuntimeConfig inside a short-lived contract test", + validationMethod: "invoke createCommanderRequestHandler with a temporary RuntimeConfig during dry-run/manual inspection", expectedEvidence: [ "body.ok=true", "body.service=host-codex-commander", @@ -283,7 +281,7 @@ function stateFileValidation(sessionId: string): Record { return { surface: "state file", storageRoot: ".state/commander/", - validationMethod: "write and read a session record only under a temporary directory owned by the contract test", + validationMethod: "write and read a session record only under a temporary directory during dry-run/manual inspection", files: [ `sessions/${sessionId}.json`, `events/${sessionId}.jsonl`, @@ -402,7 +400,6 @@ function commanderSmoke(args: string[]): Record { "bun scripts/cli.ts commander plan --dry-run", "bun scripts/cli.ts commander smoke --dry-run", "bun scripts/cli.ts commander approval request --action --dry-run", - "bun scripts/host-codex-commander-no-daemon-smoke-contract-test.ts", ], }, validationPlan: [ @@ -414,7 +411,7 @@ function commanderSmoke(args: string[]): Record { ], manualAuthorizationBeforeLiveRuntime: [ "operator explicitly names the exact live action and target session/task/service", - "current source-contract smoke and skeleton contract tests are green", + "current dry-run smoke plan and source inspection are acceptable", "risk review confirms no token output, no direct database patch, and no backend restart bypass", "ClaudeQQ approval draft is reviewed, any authorized send uses backend-core /api/microservices/claudeqq/proxy, and the reply is matched to an explicit approval id", "rollback and observation steps are written before enabling any daemon or bridge", @@ -497,7 +494,6 @@ export function runCommanderCommand(args: string[]): Record { if (sub === "plan") return commanderPlan(args.slice(1)); if (sub === "smoke") return commanderSmoke(args.slice(1)); if (sub === "approval" && second === "request") return commanderApprovalRequest(args.slice(2)); - if (sub === "prompt-lint") return runCommanderPromptLintCommand(args.slice(1)) as unknown as Record; return { ok: false, error: "unsupported-command", diff --git a/scripts/src/help.ts b/scripts/src/help.ts index bca92b1c..1742c643 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -54,7 +54,7 @@ export function rootHelp(): unknown { { 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 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: "commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run", description: "Host Codex commander skeleton contract, no-daemon smoke plan, and dry-run approval preview without live bridges or message sends." }, { command: "hwlab nodes control-plane|git-mirror|secret --node G14 --lane v03", description: "Manage HWLAB node/lane runtime prerequisites for v0.3+ with the node identity passed as data instead of a command family." }, { command: "hwlab g14 monitor-prs | hwlab g14 control-plane status|apply|trigger-current|runtime-migration|cleanup-runs|cleanup-released-pvs | hwlab g14 git-mirror status|apply|sync|flush | hwlab g14 tools-image status|build", description: "Start the legacy G14 PR monitor, run bounded v0.2 Tekton/Argo control-plane, manual PipelineRun trigger, runtime migration, CI workspace retention, manual devops-infra git mirror/relay maintenance, or fixed HWLAB CI tools image actions; long confirmed trigger/sync/flush actions return async jobs by default." }, { command: "agentrun get|describe|events|logs|result|ack|cancel|dispatch|create|apply|steer|send|control-plane|git-mirror", description: "Use AgentRun v0.1 resource primitives with low-noise human output by default; legacy bridge groups remain available for raw compatibility." }, @@ -64,7 +64,6 @@ export function rootHelp(): unknown { { command: "schedule list|get|runs|run|retry-run|delete", description: "Manage backend-core scheduled tasks and run history; schedule run 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." }, { command: "codex deploy [--provider-id D601] [--timeout-ms N]", description: "Disabled legacy Code Queue deploy path; use the dev-only artifact consumer instead." }, - { command: "codex prompt-lint [prompt|--prompt-file path|--prompt-stdin]", description: "Dry-run lint a runner prompt for DEV test class read-only/live-read/live-mutating authorization without echoing prompt text or touching live services." }, { command: "codex submit|steer|resume|queue create|queue merge|move", description: "Frozen legacy Code Queue write commands; use agentrun create/apply/steer/send for new commander work. Historical codex task/tasks/output/read/unread/queues remain available for archive troubleshooting." }, { command: "codex skills-sync --dry-run [--full]", description: "Inspect the controlled runner skills hostPath lifecycle contract without copying files, restarting services, reading secrets, or mutating live runner paths." }, { command: "codex execution-plane [--full|--raw]", description: "Read-only D601 native k3s Code Queue execution-plane inspection; compares formal deployments, deprecated Compose residuals, commit markers, pod digest, and mounted worktree HEAD." }, @@ -331,31 +330,22 @@ function gcHelp(): unknown { function commanderHelp(): unknown { return { - command: "commander contract|plan|smoke|approval|prompt-lint", + command: "commander contract|plan|smoke|approval", output: "json", usage: [ "bun scripts/cli.ts commander contract", "bun scripts/cli.ts commander plan --dry-run [--session-id id]", "bun scripts/cli.ts commander smoke --dry-run [--session-id id]", "bun scripts/cli.ts commander approval request --action --dry-run [--reason text] [--task-id id]", - "bun scripts/cli.ts commander prompt-lint --kind gpt55-pr (--prompt-file |--stdin)", ], - description: "Inspect the local host Codex commander skeleton contract, dry-run planner, no-daemon smoke validation plan, state helpers, trace summary aggregator, approval draft preview, and advisory GPT-5.5 PR prompt boundary lint.", + description: "Inspect the local host Codex commander skeleton contract, dry-run planner, no-daemon smoke validation plan, state helpers, trace summary aggregator, and approval draft preview.", boundary: [ "the current skeleton is local-only and never attaches to live bridges", "dry-run commands never open SSH, PTY, or stdio bridges", "high-risk actions only produce a <=200 char Chinese ClaudeQQ approval draft and notification-path-unavailable blocker", "authorized future sends must use backend-core /api/microservices/claudeqq/proxy, not local skill or powershell paths", - "prompt-lint is commander advisory output for AgentRun payload review; legacy codex submit is frozen", "token and secret values must never be printed", ], - promptLint: { - command: "bun scripts/cli.ts commander prompt-lint --kind gpt55-pr --prompt-file ", - stdin: "cat prompt.md | bun scripts/cli.ts commander prompt-lint --kind gpt55-pr --stdin", - outputFields: ["ok", "missingClauses", "riskLevel", "suggestedPatchSnippet"], - fullPromptEchoed: false, - gate: "advisory-only; not a business PR gate and not a Code Queue submit admission change", - }, reference: "docs/reference/host-codex-commander.md", }; } @@ -380,11 +370,10 @@ function scheduleHelp(): unknown { function codexHelp(): unknown { return { - command: "codex deploy|prompt-lint|submit|task|tasks|unread|output|read|dev-ready|skills-sync|execution-plane|pr-preflight|judge|steer|resume|interrupt|cancel|queues|queue|move", + command: "codex deploy|submit|task|tasks|unread|output|read|dev-ready|skills-sync|execution-plane|pr-preflight|judge|steer|resume|interrupt|cancel|queues|queue|move", output: "json", usage: [ "bun scripts/cli.ts codex deploy # disabled legacy deployment entry", - "bun scripts/cli.ts codex prompt-lint [prompt|--prompt-file path|--prompt-stdin]", "bun scripts/cli.ts agentrun get tasks --queue commander --limit 20", "bun scripts/cli.ts agentrun describe aipodspec/Artificer", "bun scripts/cli.ts agentrun create task --aipod Artificer --prompt-stdin", @@ -452,7 +441,6 @@ function codexHelp(): unknown { blockers: ["deployment-drift", "deprecated-compose-residual", "d601-k3s-guard-blocked"], }, examples: { - promptLint: "bun scripts/cli.ts codex prompt-lint --prompt-file /tmp/code-queue-prompt.md", agentRunCommander: "bun scripts/cli.ts agentrun get tasks --queue commander --limit 20", agentRunAipod: "bun scripts/cli.ts agentrun create task --aipod Artificer --prompt-stdin", agentRunSubmit: "bun scripts/cli.ts agentrun apply -f - --dry-run", @@ -475,13 +463,6 @@ function codexHelp(): unknown { redline: "data.supervisor.activeRunning.redline names the count field, routine target, burst redline, hard redline, and decisionReady flag.", limitSemantics: "filters.requestedLimit preserves the user input; filters.limit/effectiveLimit shows the capped query budget; section outputBudget/rowPage show returned-row caps.", }, - promptLiveAuthorization: { - classes: ["read-only", "live-read", "live-mutating"], - defaultWhenMissing: "read-only", - command: "bun scripts/cli.ts codex prompt-lint --prompt-file ", - embeddedIn: [], - reference: "docs/reference/code-queue-supervision.md#dev-测试授权分级", - }, description: "Operate legacy Code Queue as a read-only archive through bounded task/output/read/unread/queues views. New task dispatch, retry/resume, steer, queue mutation, move, and workdir mutation are frozen and replaced by AgentRun resource primitives via bun scripts/cli.ts agentrun get|describe|events|logs|result|ack|cancel|dispatch|create|apply|steer|send.", }; } diff --git a/scripts/ssh-argv-guidance-contract-test.ts b/scripts/ssh-argv-guidance-contract-test.ts deleted file mode 100644 index e1492888..00000000 --- a/scripts/ssh-argv-guidance-contract-test.ts +++ /dev/null @@ -1,1883 +0,0 @@ -import { PassThrough, Writable } from "node:stream"; -import { spawnSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { sshHelp } from "./src/help"; -import { runApplyPatchV2, type ApplyPatchV2TimingSummary, type ApplyPatchV2BulkReplacementWritePlan } from "./src/apply-patch-v2"; -import { providerTriageRecommendedCrossChecks } from "./src/provider-triage"; -import { extractRemoteCliOptions, remoteSshFrontendPlanForTest } from "./src/remote"; -import { emitError } from "./src/output"; -import { runSshFileTransferOperation, type SshFileTransferCommandBuilders, type SshRemoteCommandExecutor } from "./src/ssh-file-transfer"; -import { - formatSshFailureHint, - formatSshRuntimeTimeoutHint, - formatSshRuntimeTimingHint, - normalizeSshOperationArgs, - parseSshArgs, - parseSshInvocation, - remoteApplyPatchSource, - shellArgv, - sshFailureHint, - sshRouteSeparatorCompatibilityHint, - sshShellCompatibilityPrelude, - sshUserToolPathPrelude, - sshRuntimeTimeoutHint, - sshRuntimeTimeoutMs, - sshRuntimeTimingHint, -} from "./src/ssh"; - -type JsonRecord = Record; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function assertThrows(fn: () => unknown, pattern: RegExp, message: string): void { - try { - fn(); - } catch (error) { - const text = error instanceof Error ? error.message : String(error); - assertCondition(pattern.test(text), message, { error: text }); - return; - } - throw new Error(`${message}: expected throw`); -} - -function sha256Hex(value: string): string { - return createHash("sha256").update(Buffer.from(value, "utf8")).digest("hex"); -} - -function sha256BufferHex(value: Buffer): string { - return createHash("sha256").update(value).digest("hex"); -} - -function sshShellScriptPrelude(): string { - return `${sshUserToolPathPrelude}\n${sshShellCompatibilityPrelude}`; -} - -async function captureStdout(fn: () => Promise): Promise<{ exitCode: number; stdout: string; stderr: string }> { - const originalWrite = process.stdout.write; - const originalStderrWrite = process.stderr.write; - let stdout = ""; - let stderr = ""; - process.stdout.write = ((chunk: unknown, ...args: unknown[]) => { - stdout += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); - const callback = args.find((arg): arg is () => void => typeof arg === "function"); - if (callback) callback(); - return true; - }) as typeof process.stdout.write; - process.stderr.write = ((chunk: unknown, ...args: unknown[]) => { - stderr += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); - const callback = args.find((arg): arg is () => void => typeof arg === "function"); - if (callback) callback(); - return true; - }) as typeof process.stderr.write; - try { - const exitCode = await fn(); - return { exitCode, stdout, stderr }; - } finally { - process.stdout.write = originalWrite; - process.stderr.write = originalStderrWrite; - } -} - -function decodeWinEncodedCommand(remoteCommand: string | null | undefined): string { - const text = String(remoteCommand ?? ""); - const match = /'-EncodedCommand' '([^']+)'/u.exec(text); - assertCondition(match !== null, "win command must use PowerShell -EncodedCommand", remoteCommand); - return Buffer.from(match[1] ?? "", "base64").toString("utf16le"); -} - -function applyPatchFixture(args: string[], patch: string, files: Record): { status: number | null; stdout: string; stderr: string; files: Record } { - const root = mkdtempSync(path.join(os.tmpdir(), "unidesk-apply-patch-contract-")); - try { - const helperPath = path.join(root, "apply_patch"); - writeFileSync(helperPath, remoteApplyPatchSource, "utf8"); - for (const [relativePath, content] of Object.entries(files)) { - const target = path.join(root, relativePath); - writeFileSync(target, content, "utf8"); - } - const run = spawnSync("sh", [helperPath, ...args], { - cwd: root, - input: patch, - encoding: "utf8", - }); - const outputFiles: Record = {}; - for (const relativePath of Object.keys(files)) { - outputFiles[relativePath] = readFileSync(path.join(root, relativePath), "utf8"); - } - return { - status: run.status, - stdout: run.stdout, - stderr: run.stderr, - files: outputFiles, - }; - } finally { - rmSync(root, { recursive: true, force: true }); - } -} - -async function applyPatchV2FixtureAttempt(patch: string, files: Record, options: { stderrOutput?: boolean } = {}): Promise<{ stdout: string; stderr: string; exitCode: number | null; files: Record; commands: string[]; error: unknown | null }> { - const state = new Map(Object.entries(files)); - const pendingWrites = new Map(); - const commands: string[] = []; - const stdin = new PassThrough(); - stdin.end(patch); - let stdout = ""; - let stderr = ""; - const stdoutSink = new Writable({ - write(chunk, _encoding, callback) { - stdout += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); - callback(); - }, - }); - const stderrSink = new Writable({ - write(chunk, _encoding, callback) { - stderr += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); - callback(); - }, - }); - let error: unknown | null = null; - let exitCode: number | null = null; - try { - exitCode = await runApplyPatchV2({ - stdin, - stdout: stdoutSink, - ...(options.stderrOutput === true ? { stderr: stderrSink } : {}), - executor: { - async run(command, input) { - const operation = command[4] ?? ""; - const target = command[5] ?? ""; - commands.push([operation, ...command.slice(5)].join(" ")); - if (operation === "stat") { - if (!state.has(target)) return { exitCode: 1, stdout: "", stderr: `missing ${target}` }; - const content = state.get(target) ?? ""; - return { exitCode: 0, stdout: `${Buffer.byteLength(content, "utf8")} ${sha256Hex(content)}\n`, stderr: "" }; - } - if (operation === "read-b64-block") { - if (!state.has(target)) return { exitCode: 1, stdout: "", stderr: `missing ${target}` }; - const content = Buffer.from(state.get(target) ?? "", "utf8"); - const blockIndex = Number(command[6] ?? "-1"); - const blockSize = Number(command[7] ?? "-1"); - if (!Number.isSafeInteger(blockIndex) || !Number.isSafeInteger(blockSize) || blockIndex < 0 || blockSize <= 0) { - return { exitCode: 2, stdout: "", stderr: "bad read block args" }; - } - const start = blockIndex * blockSize; - return { exitCode: 0, stdout: content.subarray(start, start + blockSize).toString("base64"), stderr: "" }; - } - if (operation === "read-bulk-b64") { - const targets = command.slice(5); - const records: string[] = []; - for (const item of targets) { - if (!state.has(item)) return { exitCode: 1, stdout: "", stderr: `missing ${item}` }; - const content = Buffer.from(state.get(item) ?? "", "utf8"); - records.push([ - Buffer.from(item, "utf8").toString("base64"), - String(content.length), - sha256Hex(content), - content.toString("base64"), - ].join(" ")); - } - return { exitCode: 0, stdout: `UNIDESK_APPLY_PATCH_V2_BULK_READ ${targets.length}\n${records.join("\n")}\n`, stderr: "" }; - } - if (operation === "write-b64-argv") { - const expectedBytes = Number(command[6] ?? "-1"); - const expectedSha256 = command[7] ?? ""; - const content = Buffer.from(command.slice(8).join(""), "base64").toString("utf8"); - if (Buffer.byteLength(content, "utf8") !== expectedBytes || sha256Hex(content) !== expectedSha256) { - return { exitCode: 23, stdout: "", stderr: "mock integrity mismatch" }; - } - state.set(target, content); - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (operation === "write-b64-stdin") { - const expectedBytes = Number(command[6] ?? "-1"); - const expectedSha256 = command[7] ?? ""; - const content = Buffer.from(input ?? "", "base64").toString("utf8"); - if (Buffer.byteLength(content, "utf8") !== expectedBytes || sha256Hex(content) !== expectedSha256) { - return { exitCode: 23, stdout: "", stderr: "mock integrity mismatch" }; - } - state.set(target, content); - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (operation === "apply-replacements-bulk-stdin") { - const expectedCount = Number(command[5] ?? "-1"); - const records = (input ?? "").split(/\r?\n/u).filter((line) => line.trim().length > 0); - if (records.length !== expectedCount) return { exitCode: 23, stdout: "", stderr: "mock bulk replacement record count mismatch" }; - for (const record of records) { - const fields = record.split(/\s+/u); - if (fields.length !== 6) return { exitCode: 23, stdout: "", stderr: "mock bulk replacement malformed record" }; - const [pathB64, originalBytesText, originalSha256, finalBytesText, finalSha256, replacementsText] = fields; - const targetPath = Buffer.from(pathB64 ?? "", "base64").toString("utf8"); - const original = state.get(targetPath); - if (original === undefined) return { exitCode: 1, stdout: "", stderr: `missing ${targetPath}` }; - const originalBuffer = Buffer.from(original, "utf8"); - if (originalBuffer.length !== Number(originalBytesText) || sha256Hex(original) !== originalSha256) return { exitCode: 23, stdout: "", stderr: "mock bulk replacement original integrity mismatch" }; - const lines = original.split("\n"); - if (lines.at(-1) === "") lines.pop(); - for (const item of (replacementsText ?? "").split(";").filter(Boolean).reverse()) { - const [startText, oldLengthText, newB64] = item.split(",", 3); - const newLines = Buffer.from(newB64 ?? "", "base64").toString("utf8").split("\n"); - if (newLines.at(-1) === "") newLines.pop(); - lines.splice(Number(startText), Number(oldLengthText), ...newLines); - } - const updated = lines.length === 0 ? "" : `${lines.join("\n")}\n`; - if (Buffer.byteLength(updated, "utf8") !== Number(finalBytesText) || sha256Hex(updated) !== finalSha256) return { exitCode: 23, stdout: "", stderr: "mock bulk replacement final integrity mismatch" }; - state.set(targetPath, updated); - } - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (operation === "write-b64-begin") { - pendingWrites.set(`${target}\0${command[6] ?? ""}`, ""); - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (operation === "write-b64-append") { - const key = `${target}\0${command[6] ?? ""}`; - if (!pendingWrites.has(key)) return { exitCode: 2, stdout: "", stderr: "missing pending write" }; - pendingWrites.set(key, `${pendingWrites.get(key) ?? ""}${command[7] ?? ""}`); - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (operation === "write-b64-commit") { - const key = `${target}\0${command[6] ?? ""}`; - const expectedBytes = Number(command[7] ?? "-1"); - const expectedSha256 = command[8] ?? ""; - const content = Buffer.from(pendingWrites.get(key) ?? "", "base64").toString("utf8"); - if (Buffer.byteLength(content, "utf8") !== expectedBytes || sha256Hex(content) !== expectedSha256) { - return { exitCode: 23, stdout: "", stderr: "mock integrity mismatch" }; - } - state.set(target, content); - pendingWrites.delete(key); - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (operation === "delete") { - state.delete(target); - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (operation === "move") { - state.set(command[6] ?? "", state.get(target) ?? ""); - state.delete(target); - return { exitCode: 0, stdout: "", stderr: "" }; - } - return { exitCode: 2, stdout: "", stderr: "bad op" }; - }, - }, - }); - } catch (caught) { - error = caught; - } - return { stdout, stderr, exitCode, files: Object.fromEntries(state), commands, error }; -} - -function applyPatchTimingFromStderr(stderr: string): ApplyPatchV2TimingSummary { - const line = stderr.split(/\r?\n/u).find((item) => item.startsWith("UNIDESK_APPLY_PATCH_TIMING ")); - assertCondition(line !== undefined, "apply-patch stderr must include UNIDESK_APPLY_PATCH_TIMING", stderr); - return JSON.parse(line!.slice("UNIDESK_APPLY_PATCH_TIMING ".length)) as ApplyPatchV2TimingSummary; -} - -async function applyPatchV2ActualShellFixtureAttempt( - patch: string, - files: Record, - mutateInput?: (operation: string, input: string | undefined) => string | undefined, - mutateResult?: (operation: string, result: { exitCode: number; stdout: string; stderr: string }) => { exitCode: number; stdout: string; stderr: string }, -): Promise<{ stdout: string; files: Record; commands: string[]; error: unknown | null }> { - const root = mkdtempSync(path.join(os.tmpdir(), "unidesk-apply-patch-v2-shell-")); - const commands: string[] = []; - const stdin = new PassThrough(); - stdin.end(patch); - let stdout = ""; - const stdoutSink = new Writable({ - write(chunk, _encoding, callback) { - stdout += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); - callback(); - }, - }); - try { - for (const [relativePath, content] of Object.entries(files)) { - const target = path.join(root, relativePath); - mkdirSync(path.dirname(target), { recursive: true }); - writeFileSync(target, content, "utf8"); - } - let error: unknown | null = null; - try { - await runApplyPatchV2({ - stdin, - stdout: stdoutSink, - executor: { - async run(command, input) { - const operation = command[4] ?? ""; - commands.push([operation, ...command.slice(5)].join(" ")); - const run = spawnSync(command[0] ?? "sh", command.slice(1), { - cwd: root, - input: mutateInput ? mutateInput(operation, input) : input, - encoding: "utf8", - }); - const result = { - exitCode: run.status ?? 255, - stdout: run.stdout, - stderr: run.stderr, - }; - return mutateResult ? mutateResult(operation, result) : result; - }, - }, - }); - } catch (caught) { - error = caught; - } - const outputFiles: Record = {}; - for (const relativePath of Object.keys(files)) { - const target = path.join(root, relativePath); - outputFiles[relativePath] = existsSync(target) ? readFileSync(target, "utf8") : ""; - } - return { stdout, files: outputFiles, commands, error }; - } finally { - rmSync(root, { recursive: true, force: true }); - } -} - -async function applyPatchV2Fixture(patch: string, files: Record): Promise<{ stdout: string; files: Record; commands: string[] }> { - const result = await applyPatchV2FixtureAttempt(patch, files); - if (result.error !== null) throw result.error; - return { stdout: result.stdout, files: result.files, commands: result.commands }; -} - -async function applyPatchV2FsBulkFixtureAttempt(patch: string, files: Record): Promise<{ stdout: string; stderr: string; exitCode: number | null; files: Record; operations: string[]; error: unknown | null }> { - const state = new Map(Object.entries(files)); - const operations: string[] = []; - const stdin = new PassThrough(); - stdin.end(patch); - let stdout = ""; - let stderr = ""; - const stdoutSink = new Writable({ - write(chunk, _encoding, callback) { - stdout += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); - callback(); - }, - }); - const stderrSink = new Writable({ - write(chunk, _encoding, callback) { - stderr += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); - callback(); - }, - }); - let error: unknown | null = null; - let exitCode: number | null = null; - try { - exitCode = await runApplyPatchV2({ - stdin, - stdout: stdoutSink, - stderr: stderrSink, - executor: { - fs: { - async stat(filePath) { - operations.push(`stat ${filePath}`); - const content = state.get(filePath); - if (content === undefined) throw new Error(`missing ${filePath}`); - const buffer = Buffer.from(content, "utf8"); - return { bytes: buffer.length, sha256: sha256BufferHex(buffer) }; - }, - async readBlock(filePath, blockIndex, blockBytes) { - operations.push(`readBlock ${filePath}`); - const content = state.get(filePath); - if (content === undefined) throw new Error(`missing ${filePath}`); - const buffer = Buffer.from(content, "utf8"); - return buffer.subarray(blockIndex * blockBytes, (blockIndex + 1) * blockBytes); - }, - async writeFile(filePath, content) { - operations.push(`writeFile ${filePath}`); - state.set(filePath, content.toString("utf8")); - }, - async deleteFile(filePath) { - operations.push(`deleteFile ${filePath}`); - state.delete(filePath); - }, - async readFiles(paths) { - operations.push(`readFiles ${paths.join(",")}`); - const result = new Map(); - for (const filePath of paths) { - const content = state.get(filePath); - if (content === undefined) throw new Error(`missing ${filePath}`); - result.set(filePath, content); - } - return result; - }, - async applyReplacementsBulk(paths: Iterable, plans: Map) { - const targets = Array.from(paths); - operations.push(`applyReplacementsBulk ${targets.join(",")}`); - for (const filePath of targets) { - const plan = plans.get(filePath); - const original = state.get(filePath); - if (plan === undefined || original === undefined) throw new Error(`missing replacement plan ${filePath}`); - const originalBuffer = Buffer.from(original, "utf8"); - assertCondition(originalBuffer.length === plan.originalBytes && sha256BufferHex(originalBuffer) === plan.originalSha256, "fs bulk fixture original integrity mismatch", { filePath, plan }); - const lines = original.split("\n"); - if (lines.at(-1) === "") lines.pop(); - for (const [start, oldLength, newLines] of [...plan.replacements].reverse()) { - lines.splice(start, oldLength, ...newLines); - } - const updated = lines.length === 0 ? "" : `${lines.join("\n")}\n`; - const updatedBuffer = Buffer.from(updated, "utf8"); - assertCondition(updatedBuffer.length === plan.finalBytes && sha256BufferHex(updatedBuffer) === plan.finalSha256, "fs bulk fixture final integrity mismatch", { filePath, plan }); - state.set(filePath, updated); - } - }, - }, - }, - }); - } catch (caught) { - error = caught; - } - return { stdout, stderr, exitCode, files: Object.fromEntries(state), operations, error }; -} - -function fileTransferFixture(initial: Record = {}, options: { emptyReadOnce?: Record; shortReadOnce?: Record>; statStdoutPrefix?: string; statStdoutSuffix?: string } = {}): { - state: Map; - commands: Array<{ operation: string; stdin: boolean }>; - executor: SshRemoteCommandExecutor; - builders: SshFileTransferCommandBuilders; -} { - const state = new Map(Object.entries(initial)); - const pending = new Map(); - const emptyReadOnce = new Map(Object.entries(options.emptyReadOnce ?? {}).map(([target, blocks]) => [target, new Set(blocks)])); - const shortReadOnce = new Map(Object.entries(options.shortReadOnce ?? {}).map(([target, blocks]) => [target, new Map(Object.entries(blocks).map(([block, bytes]) => [Number(block), bytes]))])); - const commands: Array<{ operation: string; stdin: boolean }> = []; - const builders: SshFileTransferCommandBuilders = { - buildRouteCommand(route, command, options) { - return JSON.stringify({ route: route.raw, command, stdin: options?.stdin === true }); - }, - buildWindowsPowerShellCommand(script) { - return JSON.stringify({ route: "win", command: ["powershell", script], stdin: false }); - }, - }; - const executor: SshRemoteCommandExecutor = { - async runRemoteCommand(remoteCommand, input) { - const payload = JSON.parse(remoteCommand) as { command: string[]; stdin?: boolean }; - const command = payload.command; - const operation = command[4] ?? ""; - const target = command[5] ?? ""; - commands.push({ operation, stdin: payload.stdin === true }); - if (operation === "stat") { - const content = state.get(target); - if (content === undefined) return { exitCode: 1, stdout: "", stderr: "missing" }; - return { exitCode: 0, stdout: `${options.statStdoutPrefix ?? ""}${content.length} ${sha256BufferHex(content)}\n${options.statStdoutSuffix ?? ""}`, stderr: "" }; - } - if (operation === "read-b64-block") { - const content = state.get(target); - if (content === undefined) return { exitCode: 1, stdout: "", stderr: "missing" }; - const blockIndex = Number(command[6] ?? "-1"); - const blockSize = Number(command[7] ?? "-1"); - const start = blockIndex * blockSize; - const emptyBlocks = emptyReadOnce.get(target); - if (emptyBlocks?.has(blockIndex)) { - emptyBlocks.delete(blockIndex); - return { exitCode: 0, stdout: "", stderr: "" }; - } - const shortBlocks = shortReadOnce.get(target); - const shortBytes = shortBlocks?.get(blockIndex); - if (shortBytes !== undefined) { - shortBlocks?.delete(blockIndex); - return { exitCode: 0, stdout: content.subarray(start, start + Math.max(0, shortBytes)).toString("base64"), stderr: "" }; - } - return { exitCode: 0, stdout: content.subarray(start, start + blockSize).toString("base64"), stderr: "" }; - } - if (operation === "write-b64-argv" || operation === "write-b64-stdin") { - const expectedBytes = Number(command[6] ?? "-1"); - const expectedSha256 = command[7] ?? ""; - const encoded = operation === "write-b64-argv" ? command.slice(8).join("") : String(input ?? ""); - const content = Buffer.from(encoded, "base64"); - if (content.length !== expectedBytes || sha256BufferHex(content) !== expectedSha256) return { exitCode: 23, stdout: "", stderr: "integrity mismatch" }; - state.set(target, content); - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (operation === "write-b64-begin") { - pending.set(`${target}\0${command[6] ?? ""}`, ""); - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (operation === "write-b64-append-stdin") { - const key = `${target}\0${command[6] ?? ""}`; - if (!pending.has(key)) return { exitCode: 2, stdout: "", stderr: "missing pending write" }; - pending.set(key, `${pending.get(key) ?? ""}${String(input ?? "")}`); - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (operation === "write-b64-commit") { - const key = `${target}\0${command[6] ?? ""}`; - const expectedBytes = Number(command[7] ?? "-1"); - const expectedSha256 = command[8] ?? ""; - const content = Buffer.from(pending.get(key) ?? "", "base64"); - if (content.length !== expectedBytes || sha256BufferHex(content) !== expectedSha256) return { exitCode: 23, stdout: "", stderr: "integrity mismatch" }; - state.set(target, content); - pending.delete(key); - return { exitCode: 0, stdout: "", stderr: "" }; - } - return { exitCode: 2, stdout: "", stderr: `unsupported op ${operation}` }; - }, - }; - return { state, commands, executor, builders }; -} - -export async function runSshArgvGuidanceContract(): Promise { - const argv = parseSshArgs(["argv", "true"]); - assertCondition(argv.invocationKind === "argv", "argv subcommand must be classified as argv", argv); - assertCondition(argv.remoteCommand === "'true'", "argv command must shell-quote each token", argv); - assertCondition(argv.requiresStdin === false, "argv command must not require stdin", argv); - assertCondition(sshFailureHint("D601", argv, 255, "kex_exchange_identification: Connection closed by remote host") === null, "argv failures must not produce ssh-like friction hint", argv); - assertThrows( - () => parseSshInvocation("D601:/tmp", ["argv", "pwd && ls -la"]), - /one shell-like command string.*script --/u, - "argv must reject a single shell command string before the remote host treats it as an executable path", - ); - const argvExplicitShell = parseSshInvocation("D601:/tmp", ["argv", "sh", "-c", "pwd && ls -la"]); - assertCondition(argvExplicitShell.parsed.remoteCommand === "'sh' '-c' 'pwd && ls -la'", "argv must still allow explicit sh -c as multi-token direct argv", argvExplicitShell); - - const shortcut = parseSshArgs(["pwd"]); - assertCondition(shortcut.invocationKind === "argv", "safe command shortcuts must use argv quoting", shortcut); - assertCondition(shortcut.remoteCommand === "'pwd'", "safe command shortcut should be shell-quoted", shortcut); - - const hostWorkspace = parseSshInvocation("D601:/home/ubuntu/workspace/hwlab-dev", ["pwd"]); - assertCondition(hostWorkspace.route.plane === "host" && hostWorkspace.route.workspace === "/home/ubuntu/workspace/hwlab-dev", "host workspace route must parse provider:/absolute/path", hostWorkspace); - assertCondition(hostWorkspace.parsed.remoteCommand === "'pwd'", "host workspace route must leave operation argv independent from location", hostWorkspace); - - const hostWorkspaceLongForm = parseSshInvocation("D601:host:/home/ubuntu/workspace/hwlab-dev", ["argv", "git", "status", "--short"]); - assertCondition(hostWorkspaceLongForm.route.workspace === "/home/ubuntu/workspace/hwlab-dev", "host: workspace route must parse as the same location model", hostWorkspaceLongForm); - assertCondition(hostWorkspaceLongForm.parsed.remoteCommand === "'git' 'status' '--short'", "host workspace argv operation must stay argv-quoted", hostWorkspaceLongForm); - - const routeSeparatorArgs = normalizeSshOperationArgs(["--", "apply-patch"]); - assertCondition(JSON.stringify(routeSeparatorArgs) === JSON.stringify(["apply-patch"]), "route-level -- before an operation should be ignored for MiniMax compatibility", routeSeparatorArgs); - const routeSeparatorHint = sshRouteSeparatorCompatibilityHint(["--", "apply-patch"], routeSeparatorArgs); - assertCondition(routeSeparatorHint.includes("route-level -- is ignored") && routeSeparatorHint.includes("canonical form"), "route-level -- compatibility should emit a canonical hint", routeSeparatorHint); - const hostRouteSeparatorApplyPatch = parseSshInvocation("D601:/tmp", ["--", "apply-patch"]); - assertCondition(hostRouteSeparatorApplyPatch.parsed.requiresStdin === true && hostRouteSeparatorApplyPatch.parsed.remoteCommand === null, "host route-level -- apply-patch should still use local v2 apply-patch", hostRouteSeparatorApplyPatch); - const hostRouteSeparatorRg = parseSshInvocation("D601:/tmp", ["--", "rg", "-ne", "DEVICE_JOB_READ_ONLY_SUB_ACTIONS", "-ne", "ACCESS_SCHEMA_STATEMENTS", "internal/cloud/access-control.ts"]); - assertCondition( - hostRouteSeparatorRg.parsed.invocationKind === "argv" - && hostRouteSeparatorRg.parsed.remoteCommand === "'rg' '-ne' 'DEVICE_JOB_READ_ONLY_SUB_ACTIONS' '-ne' 'ACCESS_SCHEMA_STATEMENTS' 'internal/cloud/access-control.ts'", - "host route-level -- rg should preserve argv quoting instead of turning regex | into a remote shell pipe", - hostRouteSeparatorRg, - ); - - const winPs = parseSshInvocation("D601:win", ["ps"]); - assertCondition(winPs.route.plane === "win" && winPs.parsed.requiresStdin === true && winPs.parsed.invocationKind === "helper", "win ps without args must read PowerShell from stdin", winPs); - const winPsScript = decodeWinEncodedCommand(winPs.parsed.remoteCommand); - assertCondition( - winPsScript.includes("[Console]::In.ReadToEnd()") - && winPsScript.includes("unidesk-win-ps-") - && winPsScript.includes("$ErrorActionPreference = 'Stop'") - && winPsScript.includes("powershell.exe") - && winPsScript.includes("$global:LASTEXITCODE"), - "win ps stdin launcher must execute a UTF-8 temp .ps1 with fail-fast semantics", - { winPs, winPsScript }, - ); - - const winPsCwd = parseSshInvocation("D601:win/c/test", ["ps", "Write-Output", "'中文'"]); - assertCondition(winPsCwd.route.plane === "win" && winPsCwd.route.workspace === String.raw`C:\test` && winPsCwd.parsed.requiresStdin === false, "win ps inline route must map slash workspace and not require stdin", winPsCwd); - const winPsCwdScript = decodeWinEncodedCommand(winPsCwd.parsed.remoteCommand); - assertCondition( - winPsCwdScript.includes("Set-Location -LiteralPath ''C:\\test''") - && winPsCwdScript.includes("Write-Output ''中文''") - && !winPsCwdScript.includes("chcp 65001"), - "win ps inline launcher must use PowerShell cwd semantics rather than cmd.exe batch setup", - { winPsCwd, winPsCwdScript }, - ); - - assertThrows( - () => parseSshInvocation("D601:win", ["script", "Get-Location"]), - /unsupported ssh win operation: script.*win ps/u, - "win route must reject POSIX script operation and point callers to ps", - ); - - const winSkills = parseSshInvocation("D601:win", ["skills", "--scope", "all", "--limit", "20"]); - assertCondition(winSkills.route.plane === "win" && winSkills.parsed.invocationKind === "helper", "win skills route must be a Windows helper operation", winSkills); - const winSkillsScript = decodeWinEncodedCommand(winSkills.parsed.remoteCommand); - assertCondition(winSkillsScript.includes(".agents\\skills") && winSkillsScript.includes(".codex\\skills") && winSkillsScript.includes("$limit = 20") && winSkillsScript.includes("ConvertTo-Json"), "win skills must discover Windows user skill roots as JSON", { winSkills, winSkillsScript }); - - const hostUploadParse = parseSshInvocation("D601", ["upload", "/tmp/local.bin", "/tmp/remote.bin"]); - assertCondition(hostUploadParse.parsed.remoteCommand === null && hostUploadParse.parsed.invocationKind === "helper", "host upload must be a structured local operation, not an ssh-like command string", hostUploadParse); - const winDownloadParse = parseSshInvocation("D601:win", ["download", String.raw`F:\Work\hwlab\.tmp\tool.mjs`, "/tmp/tool.mjs"]); - assertCondition(winDownloadParse.route.plane === "win" && winDownloadParse.parsed.remoteCommand === null, "win download must be handled by the file-transfer operation module", winDownloadParse); - const podUploadParse = parseSshInvocation("D601:k3s:unidesk:code-queue/root/unidesk", ["upload", "/tmp/local.bin", "/root/unidesk/.tmp/remote.bin"]); - assertCondition(podUploadParse.route.plane === "k3s" && podUploadParse.parsed.remoteCommand === null, "pod upload must keep k3s route as location-only and defer transfer execution to the operation module", podUploadParse); - - const transferRoot = mkdtempSync(path.join(os.tmpdir(), "unidesk-transfer-contract-")); - try { - const localSource = path.join(transferRoot, "local-source.bin"); - const localDownload = path.join(transferRoot, "downloaded", "local-copy.bin"); - const payload = Buffer.from("hello 中文\n\0binary tail", "utf8"); - writeFileSync(localSource, payload); - const transfer = fileTransferFixture(); - const uploadResult = await captureStdout(() => runSshFileTransferOperation(hostUploadParse, ["upload", localSource, "/tmp/remote.bin"], transfer.executor, transfer.builders)); - const uploadJson = JSON.parse(uploadResult.stdout) as JsonRecord; - const uploadVerification = uploadJson.verification as JsonRecord; - const uploadMatch = uploadVerification.match as JsonRecord; - const uploadSource = uploadVerification.source as JsonRecord; - const uploadTarget = uploadVerification.target as JsonRecord; - assertCondition(uploadResult.exitCode === 0 && uploadJson.verified === true, "upload should report verified JSON success", uploadResult); - assertCondition( - uploadVerification.automatic === true - && uploadVerification.verified === true - && uploadMatch.bytes === true - && uploadMatch.sha256 === true - && uploadSource.side === "local" - && uploadTarget.side === "remote", - "upload should expose automatic endpoint verification so callers do not need manual sha256sum checks", - uploadJson, - ); - assertCondition(transfer.state.get("/tmp/remote.bin")?.equals(payload), "upload must preserve binary and UTF-8 bytes in the mock remote file", transfer.commands); - const downloadResult = await captureStdout(() => runSshFileTransferOperation(parseSshInvocation("D601", ["download", "/tmp/remote.bin", localDownload]), ["download", "/tmp/remote.bin", localDownload], transfer.executor, transfer.builders)); - const downloadJson = JSON.parse(downloadResult.stdout) as JsonRecord; - const downloadVerification = downloadJson.verification as JsonRecord; - const downloadMatch = downloadVerification.match as JsonRecord; - const downloadSource = downloadVerification.source as JsonRecord; - const downloadTarget = downloadVerification.target as JsonRecord; - assertCondition(downloadResult.exitCode === 0 && downloadJson.sha256 === sha256BufferHex(payload), "download should report the verified sha256", downloadResult); - assertCondition( - downloadVerification.automatic === true - && downloadVerification.verified === true - && downloadMatch.bytes === true - && downloadMatch.sha256 === true - && downloadSource.side === "remote" - && downloadTarget.side === "local", - "download should expose automatic endpoint verification so callers do not need manual sha256sum checks", - downloadJson, - ); - assertCondition(readFileSync(localDownload).equals(payload), "download must preserve binary and UTF-8 bytes locally", { commands: transfer.commands }); - assertCondition(transfer.commands.some((item) => item.operation === "stat") && transfer.commands.some((item) => item.operation === "read-b64-block"), "file transfer should use stat plus chunked verified reads", transfer.commands); - - const noisyStatDownload = path.join(transferRoot, "downloaded", "noisy-stat-copy.bin"); - const noisyStatTransfer = fileTransferFixture({ "/tmp/noisy-stat.bin": payload }, { - statStdoutPrefix: "provider note before stat\n", - statStdoutSuffix: "provider note after stat\n", - }); - const noisyStatResult = await captureStdout(() => runSshFileTransferOperation(parseSshInvocation("D601", ["download", "/tmp/noisy-stat.bin", noisyStatDownload]), ["download", "/tmp/noisy-stat.bin", noisyStatDownload], noisyStatTransfer.executor, noisyStatTransfer.builders)); - const noisyStatJson = JSON.parse(noisyStatResult.stdout) as JsonRecord; - assertCondition(noisyStatResult.exitCode === 0 && noisyStatJson.sha256 === sha256BufferHex(payload), "download stat parser should ignore unrelated stdout lines around the metadata record", noisyStatResult); - - let missingStatError: unknown = null; - const missingStatTransfer = fileTransferFixture(); - const missingStatDownload = path.join(transferRoot, "missing.bin"); - try { - await captureStdout(() => runSshFileTransferOperation(parseSshInvocation("D601", ["download", "/tmp/missing-stat.bin", missingStatDownload]), ["download", "/tmp/missing-stat.bin", missingStatDownload], missingStatTransfer.executor, missingStatTransfer.builders)); - } catch (error) { - missingStatError = error; - } - const missingDetails = (missingStatError as { details?: JsonRecord }).details ?? {}; - const missingStderr = missingDetails.stderr as JsonRecord | undefined; - assertCondition( - missingStatError instanceof Error - && missingStatError.name === "SshFileTransferError" - && missingDetails.operation === "stat" - && missingDetails.exitCode === 1 - && missingStderr?.tail === "missing", - "stat failures should preserve bounded diagnostic details on the thrown file-transfer error", - { name: (missingStatError as Error | null)?.name, details: missingDetails }, - ); - const emittedMissingStat = await captureStdout(async () => { - emitError("ssh D601 download", missingStatError); - return 0; - }); - const emittedMissingJson = JSON.parse(emittedMissingStat.stdout) as JsonRecord; - const emittedError = emittedMissingJson.error as JsonRecord; - const emittedDetails = emittedError.details as JsonRecord; - assertCondition(emittedDetails.operation === "stat" && emittedDetails.exitCode === 1 && (emittedDetails.stderr as JsonRecord).tail === "missing", "CLI JSON error should include bounded file-transfer details by default", emittedMissingJson); - - const retryDownload = path.join(transferRoot, "downloaded", "retry-copy.bin"); - const retryPayload = Buffer.from("0123456789abcdef".repeat(4096), "utf8"); - const retryTransfer = fileTransferFixture({ "/tmp/retry-remote.bin": retryPayload }, { emptyReadOnce: { "/tmp/retry-remote.bin": [1] } }); - const retryResult = await captureStdout(() => runSshFileTransferOperation(parseSshInvocation("D601", ["download", "--chunk-bytes", "1024", "/tmp/retry-remote.bin", retryDownload]), ["download", "--chunk-bytes", "1024", "/tmp/retry-remote.bin", retryDownload], retryTransfer.executor, retryTransfer.builders)); - const retryJson = JSON.parse(retryResult.stdout) as JsonRecord; - const retryReadBlocks = retryTransfer.commands.filter((item) => item.operation === "read-b64-block"); - assertCondition(retryResult.exitCode === 0 && retryJson.sha256 === sha256BufferHex(retryPayload), "download should retry a transient empty block and keep sha256 verification", retryResult); - assertCondition(retryReadBlocks.length === Number(retryJson.transfer && typeof retryJson.transfer === "object" ? (retryJson.transfer as JsonRecord).chunks : 0) + 1, "transient empty block should add exactly one repeated read without counting as a chunk", retryTransfer.commands); - assertCondition(retryResult.stderr.includes("unidesk.ssh.download.progress") && retryResult.stderr.includes("unidesk.ssh.download.empty-read-retry"), "download should emit bounded progress and retry events to stderr", retryResult.stderr); - assertCondition(readFileSync(retryDownload).equals(retryPayload), "retry download must preserve complete content after transient empty block", { commands: retryTransfer.commands }); - - const shortReadDownload = path.join(transferRoot, "downloaded", "short-read-copy.bin"); - const shortReadPayload = Buffer.from("fedcba9876543210".repeat(4096), "utf8"); - const shortReadTransfer = fileTransferFixture({ "/tmp/short-read-remote.bin": shortReadPayload }, { shortReadOnce: { "/tmp/short-read-remote.bin": { 2: 512 } } }); - const shortReadResult = await captureStdout(() => runSshFileTransferOperation(parseSshInvocation("D601", ["download", "--chunk-bytes", "1024", "/tmp/short-read-remote.bin", shortReadDownload]), ["download", "--chunk-bytes", "1024", "/tmp/short-read-remote.bin", shortReadDownload], shortReadTransfer.executor, shortReadTransfer.builders)); - const shortReadJson = JSON.parse(shortReadResult.stdout) as JsonRecord; - const shortReadBlocks = shortReadTransfer.commands.filter((item) => item.operation === "read-b64-block"); - assertCondition(shortReadResult.exitCode === 0 && shortReadJson.sha256 === sha256BufferHex(shortReadPayload), "download should retry a truncated block and keep sha256 verification", shortReadResult); - assertCondition(shortReadBlocks.length === Number(shortReadJson.transfer && typeof shortReadJson.transfer === "object" ? (shortReadJson.transfer as JsonRecord).chunks : 0) + 1, "short block should add exactly one repeated read without counting as a chunk", shortReadTransfer.commands); - assertCondition(shortReadResult.stderr.includes("unidesk.ssh.download.short-read-retry"), "download should emit short-read retry events to stderr", shortReadResult.stderr); - assertCondition(readFileSync(shortReadDownload).equals(shortReadPayload), "short-read retry download must preserve complete content", { commands: shortReadTransfer.commands }); - } finally { - rmSync(transferRoot, { recursive: true, force: true }); - } - - assertThrows( - () => parseSshInvocation("D601:win32", ["cmd", "ver"]), - /use D601:win/u, - "win32 route spelling must be rejected in favor of win", - ); - - const script = parseSshArgs(["script", "--shell", "bash", "--", "alpha beta"]); - assertCondition(script.invocationKind === "helper", "script stdin helper must be classified as helper", script); - assertCondition(script.remoteCommand === "'bash' '-s' '--' 'alpha beta'", "script helper must pass stdin to shell directly", script); - assertCondition(script.requiresStdin === true, "script helper must require stdin", script); - - const directScriptCommand = parseSshArgs(["script", "--", "sed", "-n", "1,2p", "file.txt"]); - assertCondition(directScriptCommand.invocationKind === "argv", "script -- command form must run as direct argv without stdin", directScriptCommand); - assertCondition(directScriptCommand.remoteCommand === "'sed' '-n' '1,2p' 'file.txt'", "script -- command form must preserve dash-prefixed command args", directScriptCommand); - assertCondition(directScriptCommand.requiresStdin === false, "script -- command form must not wait for stdin", directScriptCommand); - const routeSeparatorBeforeScript = parseSshInvocation("D601:/tmp", ["--", "script", "--", "sed", "-n", "1,2p", "file.txt"]); - assertCondition(routeSeparatorBeforeScript.parsed.remoteCommand === "'sed' '-n' '1,2p' 'file.txt'", "route-level -- compatibility must not remove the command-local script -- separator", routeSeparatorBeforeScript); - - const directScriptOneLiner = parseSshArgs(["script", "--", "cd /root/hwlab && git status --short --branch"]); - assertCondition(directScriptOneLiner.invocationKind === "helper", "script -- single-string command should run through a remote shell", directScriptOneLiner); - assertCondition(directScriptOneLiner.remoteCommand === shellArgv(["sh", "-c", `${sshShellScriptPrelude()}\ncd /root/hwlab && git status --short --branch`]), "script -- single-string command should match the intuitive remote shell one-liner form with the compatibility prelude", directScriptOneLiner); - assertCondition(directScriptOneLiner.requiresStdin === false, "script -- single-string command should not wait for stdin", directScriptOneLiner); - - const shellOneLiner = parseSshArgs(["shell", "sed -n '1,2p' a && sed -n '1,2p' b"]); - assertCondition(shellOneLiner.invocationKind === "helper", "shell one-liner must be a helper operation", shellOneLiner); - assertCondition(shellOneLiner.remoteCommand === shellArgv(["sh", "-c", `${sshShellScriptPrelude()}\nsed -n '1,2p' a && sed -n '1,2p' b`]), "shell one-liner must keep command operators inside the remote shell", shellOneLiner); - assertCondition(shellOneLiner.requiresStdin === false, "shell one-liner must not require stdin", shellOneLiner); - - for (const shell of ["sh", "bash"]) { - const fixture = spawnSync(shell, ["-c", `${sshShellCompatibilityPrelude}\nprintf "--- AGENTS ---\\n"\nprintf -- "%s\\n" ok`], { encoding: "utf8" }); - assertCondition(fixture.status === 0 && fixture.stdout === "--- AGENTS ---\nok\n", "script/shell compatibility prelude must make printf headings portable across sh and bash", { shell, status: fixture.status, stdout: fixture.stdout, stderr: fixture.stderr }); - } - - const k3sGuard = parseSshInvocation("D601:k3s", ["guard"]).parsed; - assertCondition(k3sGuard.invocationKind === "helper", "k3s guard must be classified as helper", k3sGuard); - assertCondition(k3sGuard.remoteCommand?.includes("KUBECONFIG") && k3sGuard.remoteCommand.includes("/etc/rancher/k3s/k3s.yaml"), "k3s guard must force native k3s kubeconfig", k3sGuard); - - const k3sExec = parseSshInvocation("D601:k3s", ["exec", "--namespace", "hwlab-dev", "--deployment", "hwlab-cloud-api", "--", "node", "-e", "console.log(process.version)"]).parsed; - assertCondition(k3sExec.invocationKind === "helper", "k3s exec must be classified as helper", k3sExec); - assertCondition(k3sExec.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-n' 'hwlab-dev' 'deployment/hwlab-cloud-api' '--' 'node' '-e' 'console.log(process.version)'", "k3s exec must assemble kubectl argv without nested shell quoting", k3sExec); - - const routeKubectl = parseSshInvocation("D601:k3s", ["kubectl", "get", "pods", "-n", "hwlab-dev"]); - assertCondition(routeKubectl.providerId === "D601", "route must preserve provider id", routeKubectl); - assertCondition(routeKubectl.route.plane === "k3s" && routeKubectl.route.entry === null, "route must keep kubectl as an operation, not as a route entry", routeKubectl); - assertCondition(routeKubectl.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'get' 'pods' '-n' 'hwlab-dev'", "D601:k3s kubectl must map to kubectl argv", routeKubectl); - - const routeK3sShell = parseSshInvocation("D601:k3s", ["shell", "kubectl get nodes && kubectl get pods -A"]); - assertCondition(routeK3sShell.parsed.remoteCommand === shellArgv(["env", "KUBECONFIG=/etc/rancher/k3s/k3s.yaml", "sh", "-c", `${sshShellScriptPrelude()}\nkubectl get nodes && kubectl get pods -A`]), "D601:k3s shell must run one-line shell logic on the k3s host with native kubeconfig", routeK3sShell); - - const g14Guard = parseSshInvocation("G14:k3s", []); - assertCondition(g14Guard.providerId === "G14" && g14Guard.route.plane === "k3s", "G14:k3s must parse as a native k3s route", g14Guard); - assertCondition(g14Guard.parsed.remoteCommand?.includes("UNIDESK_K3S_PROVIDER_ID") && g14Guard.parsed.remoteCommand.includes("ubuntu-rog-zephyrus-g14-ga401iv-ga401iv"), "G14:k3s guard must use the G14 node profile", g14Guard); - - assertThrows( - () => parseSshInvocation("G14", ["k3s", "kubectl", "get", "nodes"]), - /unsupported.*trans G14:k3s/u, - "k3s must be a route plane, not a post-provider shorthand", - ); - - const routeTarget = parseSshInvocation("D601:k3s:hwlab-dev:hwlab-cloud-api", ["node", "-e", "console.log(process.version)"]); - assertCondition(routeTarget.route.namespace === "hwlab-dev" && routeTarget.route.resource === "hwlab-cloud-api", "route target must parse namespace and workload", routeTarget); - assertCondition(routeTarget.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-n' 'hwlab-dev' 'deployment/hwlab-cloud-api' '--' 'node' '-e' 'console.log(process.version)'", "D601:k3s:: must default to deployment exec", routeTarget); - - const routeTargetArgv = parseSshInvocation("D601:k3s:hwlab-dev:hwlab-cloud-api", ["argv", "sh", "-c", "printf ok"]); - assertCondition(routeTargetArgv.parsed.invocationKind === "argv", "k3s target argv operation must stay explicit argv", routeTargetArgv); - assertCondition(routeTargetArgv.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-n' 'hwlab-dev' 'deployment/hwlab-cloud-api' '--' 'sh' '-c' 'printf ok'", "D601:k3s:: argv must exec the argv payload instead of treating argv as a pod command", routeTargetArgv); - assertThrows( - () => parseSshInvocation("D601:k3s:hwlab-dev:hwlab-cloud-api", ["argv", "pwd && ls -la"]), - /one shell-like command string.*script --/u, - "k3s workload argv must reject a single shell command string before kubectl exec treats it as an executable path", - ); - - const routeTargetShell = parseSshInvocation("D601:k3s:hwlab-dev:hwlab-cloud-api/app", ["shell", "pwd && ls"]); - assertCondition(routeTargetShell.parsed.remoteCommand === shellArgv(["env", "KUBECONFIG=/etc/rancher/k3s/k3s.yaml", "kubectl", "exec", "-n", "hwlab-dev", "deployment/hwlab-cloud-api", "--", "sh", "-c", 'cd "$1" || exit; shift; exec "$@"', "unidesk-cwd", "/app", "sh", "-c", `${sshShellScriptPrelude()}\npwd && ls`]), "D601:k3s::/ shell must run shell logic after cd inside the pod", routeTargetShell); - - const routeScript = parseSshInvocation("D601:k3s:hwlab-dev:hwlab-cloud-api", ["script", "--shell", "bash", "--", "arg"]); - assertCondition(routeScript.parsed.requiresStdin === true, "k3s script operation must stream local stdin", routeScript); - assertCondition(routeScript.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-i' '-n' 'hwlab-dev' 'deployment/hwlab-cloud-api' '--' 'bash' '-s' '--' 'arg'", "D601:k3s:: script must map stdin to shell -s", routeScript); - assertCondition(routeScript.parsed.stdinPrefix === `${sshShellScriptPrelude()}\n`, "k3s script stdin must inject the shell compatibility prelude before user script text", routeScript); - - const routeControlScript = parseSshInvocation("D601:k3s", ["script", "--shell", "bash", "--", "arg"]); - assertCondition(routeControlScript.parsed.requiresStdin === true, "k3s control-plane script operation must stream local stdin", routeControlScript); - assertCondition(routeControlScript.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'bash' '-s' '--' 'arg'", "D601:k3s script must inject native kubeconfig without manual export", routeControlScript); - assertCondition(routeControlScript.parsed.stdinPrefix === `${sshShellScriptPrelude()}\n`, "k3s control-plane script stdin must inject the shell compatibility prelude before user script text", routeControlScript); - - const routeControlScriptOneLiner = parseSshInvocation("D601:k3s", ["script", "--", "echo k3s-script-ok"]); - assertCondition(routeControlScriptOneLiner.parsed.requiresStdin === false, "k3s control-plane script -- one-liner must not wait for stdin", routeControlScriptOneLiner); - assertCondition(routeControlScriptOneLiner.parsed.remoteCommand === shellArgv(["env", "KUBECONFIG=/etc/rancher/k3s/k3s.yaml", "sh", "-c", `${sshShellScriptPrelude()}\necho k3s-script-ok`]), "k3s control-plane script -- one-liner must run through the native kubeconfig shell path", routeControlScriptOneLiner); - - const routePodScriptOneLiner = parseSshInvocation("D601:k3s:hwlab-dev:hwlab-cloud-api", ["script", "--", "echo pod-script-ok"]); - assertCondition(routePodScriptOneLiner.parsed.requiresStdin === false, "k3s workload script -- one-liner must not wait for stdin", routePodScriptOneLiner); - assertCondition(routePodScriptOneLiner.parsed.remoteCommand === shellArgv(["env", "KUBECONFIG=/etc/rancher/k3s/k3s.yaml", "kubectl", "exec", "-n", "hwlab-dev", "deployment/hwlab-cloud-api", "--", "sh", "-c", `${sshShellScriptPrelude()}\necho pod-script-ok`]), "k3s workload script -- one-liner must run as sh -c inside the workload", routePodScriptOneLiner); - - const topLevelScriptSeparator = extractRemoteCliOptions(["ssh", "D601:/tmp", "script", "--", "sed", "-n", "1,2p", "file.txt"]); - assertCondition(JSON.stringify(topLevelScriptSeparator.args) === JSON.stringify(["ssh", "D601:/tmp", "script", "--", "sed", "-n", "1,2p", "file.txt"]), "top-level remote option parser must preserve command-local -- after the command starts", topLevelScriptSeparator); - const topLevelScriptInvocation = parseSshInvocation(topLevelScriptSeparator.args[1] as string, topLevelScriptSeparator.args.slice(2)); - assertCondition(topLevelScriptInvocation.parsed.remoteCommand === "'sed' '-n' '1,2p' 'file.txt'", "script -- must allow argv such as sed -n to execute without being parsed as script options", topLevelScriptInvocation); - assertCondition(topLevelScriptInvocation.parsed.requiresStdin === false, "script -- direct command must not require stdin after top-level parsing", topLevelScriptInvocation); - - const remoteOptionSeparator = extractRemoteCliOptions(["--main-server-ip", "74.48.78.17", "--", "ssh", "D601:/tmp", "script", "--", "sed", "-n", "1,2p", "file.txt"]); - assertCondition(remoteOptionSeparator.host === "74.48.78.17", "global remote options before -- must still be parsed", remoteOptionSeparator); - assertCondition(JSON.stringify(remoteOptionSeparator.args) === JSON.stringify(["ssh", "D601:/tmp", "script", "--", "sed", "-n", "1,2p", "file.txt"]), "global -- must not strip nested command separators", remoteOptionSeparator); - - const routeApplyPatch = parseSshInvocation("D601:k3s:hwlab-dev:hwlab-cloud-api", ["apply-patch"]); - assertCondition(routeApplyPatch.parsed.requiresStdin === true && routeApplyPatch.parsed.remoteCommand === null, "k3s apply-patch operation must use the default v2 local engine", routeApplyPatch); - - const routeApplyPatchV1 = parseSshInvocation("D601:k3s:hwlab-dev:hwlab-cloud-api", ["apply-patch-v1"]); - assertCondition(routeApplyPatchV1.parsed.requiresStdin === true, "k3s apply-patch-v1 operation must stream local patch stdin", routeApplyPatchV1); - assertCondition(routeApplyPatchV1.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-i' '-n' 'hwlab-dev' 'deployment/hwlab-cloud-api' '--' 'sh' '-s' '--'", "D601:k3s:: apply-patch-v1 must enter pod with stdin", routeApplyPatchV1); - assertCondition(routeApplyPatchV1.parsed.stdinPrefix?.includes("apply_patch") && routeApplyPatchV1.parsed.stdinPrefix.includes("__UNIDESK_APPLY_PATCH_PAYLOAD__"), "k3s apply-patch-v1 operation must inject pod helper before patch stdin", routeApplyPatchV1); - assertCondition(routeApplyPatchV1.parsed.stdinPrefix?.includes("#!/bin/sh"), "k3s apply-patch-v1 operation must inject the same sh helper used by host apply-patch-v1", routeApplyPatchV1); - assertCondition(!routeApplyPatchV1.parsed.stdinPrefix?.includes("python3") && !routeApplyPatchV1.parsed.stdinPrefix?.includes("node "), "k3s apply-patch-v1 operation must use the sh-only pod helper", routeApplyPatchV1); - assertCondition(routeApplyPatchV1.parsed.stdinSuffix === "\n__UNIDESK_APPLY_PATCH_PAYLOAD__\n", "k3s apply-patch-v1 operation must terminate patch heredoc", routeApplyPatchV1); - - const hostApplyPatchLoose = parseSshArgs(["apply-patch-v1", "--allow-loose"]); - assertCondition(hostApplyPatchLoose.remoteCommand === "'apply_patch' '--allow-loose'", "host apply-patch-v1 must pass --allow-loose as an explicit helper argument", hostApplyPatchLoose); - assertCondition(hostApplyPatchLoose.requiredHelpers?.length === 1 && hostApplyPatchLoose.requiredHelpers.includes("apply_patch"), "host apply-patch-v1 must request only the apply_patch helper bootstrap", hostApplyPatchLoose); - assertCondition(remoteApplyPatchSource.includes("replace_once_with_perl") && remoteApplyPatchSource.includes("perl -0777"), "apply_patch helper must keep a fast path for large files", {}); - - const hostApplyPatchV2 = parseSshArgs(["apply-patch"]); - assertCondition(hostApplyPatchV2.requiresStdin === true && hostApplyPatchV2.requiredHelpers === undefined && hostApplyPatchV2.remoteCommand === null, "host apply-patch must be a local v2 engine operation, not a remote helper bootstrap", hostApplyPatchV2); - const hostApplyPatchV2Help = parseSshArgs(["apply-patch", "--help"]); - assertCondition(hostApplyPatchV2Help.requiresStdin === false && hostApplyPatchV2Help.remoteCommand === null, "host apply-patch --help must not wait for patch stdin", hostApplyPatchV2Help); - const podApplyPatchV2 = parseSshInvocation("D601:k3s:hwlab-dev:hwlab-cloud-api/app", ["apply-patch"]); - assertCondition(podApplyPatchV2.parsed.requiresStdin === true && podApplyPatchV2.parsed.remoteCommand === null, "pod apply-patch must be handled by the local v2 engine instead of injecting the legacy helper", podApplyPatchV2); - assertThrows(() => parseSshArgs(["v2"]), /remote patch entrypoints/u, "v2 must not remain as an independent patch subcommand"); - assertThrows(() => parseSshArgs(["patch"]), /remote patch entrypoints/u, "patch must not remain as a patch alias"); - assertThrows(() => parseSshArgs(["patch-v1"]), /remote patch entrypoints/u, "patch-v1 must not remain as a legacy patch alias"); - - const longChinesePatch = await applyPatchV2Fixture([ - "*** Begin Patch", - "*** Update File: story.md", - "@@", - "+这是一个很长很长的中文段落,用来证明远端 v2 不再依赖 shell hunk 拼接和手写长中文 search block。它只是通过本地行级 patch engine 计算新内容,然后把完整文件写回远端,所以中文、标点、长句都不应该影响 patch 解析和匹配。", - "*** End Patch", - "", - ].join("\n"), { - "story.md": "开头\n", - }); - assertCondition(longChinesePatch.files["story.md"]?.includes("很长很长的中文段落"), "v2 should accept pure insertion with long Chinese text", longChinesePatch); - assertCondition(longChinesePatch.stdout.includes("Success. Updated the following files:"), "v2 must print visible success output", longChinesePatch); - - const lowContextV1Baseline = applyPatchFixture([], [ - "*** Begin Patch", - "*** Update File: story.md", - "@@", - " 开头", - "+低上下文纯插入在 v1 会失败,但 v2 应该按 Codex 行级语义允许。", - "*** End Patch", - "", - ].join("\n"), { - "story.md": "开头\n结尾\n", - }); - assertCondition(lowContextV1Baseline.status !== 0 && lowContextV1Baseline.stderr.includes("insert-only without both leading and trailing context"), "v1 baseline should reject low-context pure insertion", lowContextV1Baseline); - const lowContextV2 = await applyPatchV2Fixture([ - "*** Begin Patch", - "*** Update File: story.md", - "@@", - " 开头", - "+低上下文纯插入在 v1 会失败,但 v2 应该按 Codex 行级语义允许。", - "*** End Patch", - "", - ].join("\n"), { - "story.md": "开头\n结尾\n", - }); - assertCondition(lowContextV2.files["story.md"]?.includes("v2 应该按 Codex 行级语义允许"), "v2 should fix v1 low-context insertion friction", lowContextV2); - assertCondition(lowContextV2.commands.some((command) => command.includes("write-b64-argv")), "v2 should use argv write path for small remote files to work inside k3s pod exec capture", lowContextV2); - - const unicodePunctuationV1Baseline = applyPatchFixture([], [ - "*** Begin Patch", - "*** Update File: notes.txt", - "@@", - "-alpha - beta", - "+alpha - gamma", - "*** End Patch", - "", - ].join("\n"), { - "notes.txt": "alpha – beta\n", - }); - assertCondition(unicodePunctuationV1Baseline.status !== 0, "v1 baseline should miss ASCII dash against typographic dash", unicodePunctuationV1Baseline); - const unicodePunctuationV2 = await applyPatchV2Fixture([ - "*** Begin Patch", - "*** Update File: notes.txt", - "@@", - "-alpha - beta", - "+alpha - gamma", - "*** End Patch", - "", - ].join("\n"), { - "notes.txt": "alpha – beta\n", - }); - assertCondition(unicodePunctuationV2.files["notes.txt"] === "alpha - gamma\n", "v2 should normalize common Unicode punctuation while matching expected lines", unicodePunctuationV2); - - const repeatedBlockWithContext = await applyPatchV2Fixture([ - "*** Begin Patch", - "*** Update File: repeated.txt", - "@@ section two", - "-marker", - "+patched", - "*** End Patch", - "", - ].join("\n"), { - "repeated.txt": "section one\nmarker\nsection two\nmarker\n", - }); - assertCondition(repeatedBlockWithContext.files["repeated.txt"] === "section one\nmarker\nsection two\npatched\n", "v2 should use @@ context to target repeated blocks", repeatedBlockWithContext); - - const longChineseReplace = await applyPatchV2Fixture([ - "*** Begin Patch", - "*** Update File: novel.md", - "@@", - "-林深在透明的舷窗前停下脚步,远处的群星像被压进黑色玻璃里的碎银,安静得让人怀疑整个宇宙都屏住了呼吸。", - "+林深在透明的舷窗前停下脚步,远处的群星像被压进黑色玻璃里的碎银,安静得让人怀疑整个宇宙正在等待他重新命名。", - "*** End Patch", - "", - ].join("\n"), { - "novel.md": "林深在透明的舷窗前停下脚步,远处的群星像被压进黑色玻璃里的碎银,安静得让人怀疑整个宇宙都屏住了呼吸。\n", - }); - assertCondition(longChineseReplace.files["novel.md"]?.includes("等待他重新命名"), "v2 should replace long Chinese lines without remote shell search blocks", longChineseReplace); - - const largeOriginal = `${"0123456789abcdef\n".repeat(4096)}`; - const largeV2 = await applyPatchV2Fixture([ - "*** Begin Patch", - "*** Update File: large.txt", - "@@", - "+large insert", - "*** End Patch", - "", - ].join("\n"), { - "large.txt": largeOriginal, - }); - assertCondition(largeV2.commands.some((command) => command.includes("write-b64-stdin")), "v2 should use stdin write path for large remote files to avoid E2BIG", largeV2.commands); - assertCondition(!largeV2.commands.some((command) => command.includes("write-b64-append")), "v2 should keep the single stdin write as the normal large-file fast path", largeV2.commands); - assertCondition(largeV2.commands.filter((command) => command.startsWith("read-b64-block")).length <= 2, "v2 large-file verified read should use coarse chunks, not many tiny SSH calls", largeV2.commands); - - const repeatedLargeLines = Array.from({ length: 1200 }, (_, index) => `repeat target ${String(index).padStart(4, "0")}`); - repeatedLargeLines[899] = "same marker"; - repeatedLargeLines[1099] = "same marker"; - const unifiedHeaderLineRangeV2 = await applyPatchV2FixtureAttempt([ - "*** Begin Patch", - "*** Update File: repeated-large.txt", - "@@ -1100,1 +1100,1 @@", - "-same marker", - "+same marker patched", - "*** End Patch", - "", - ].join("\n"), { - "repeated-large.txt": `${repeatedLargeLines.join("\n")}\n`, - }, { stderrOutput: true }); - assertCondition(unifiedHeaderLineRangeV2.exitCode === 0 && unifiedHeaderLineRangeV2.error === null, "v2 should accept unified-diff hunk headers with a hint", unifiedHeaderLineRangeV2); - const unifiedLines = unifiedHeaderLineRangeV2.files["repeated-large.txt"]?.split("\n") ?? []; - assertCondition(unifiedLines[899] === "same marker" && unifiedLines[1099] === "same marker patched", "v2 should use unified header old line number to avoid patching the first repeated match", { - line900: unifiedLines[899], - line1100: unifiedLines[1099], - stderr: unifiedHeaderLineRangeV2.stderr, - }); - assertCondition(unifiedHeaderLineRangeV2.stderr.includes("accepted unified-diff hunk header in repeated-large.txt"), "v2 unified header compatibility should emit a canonical syntax hint", unifiedHeaderLineRangeV2.stderr); - - const unifiedTiming = applyPatchTimingFromStderr(unifiedHeaderLineRangeV2.stderr); - assertCondition(unifiedTiming.code === "apply-patch-v2-timing" && unifiedTiming.status === "succeeded", "v2 apply-patch timing summary should identify successful runs", unifiedTiming); - assertCondition(unifiedTiming.patchBytes > 0 && unifiedTiming.fileCount === 1 && unifiedTiming.hunkCount === 1 && unifiedTiming.changedCount === 1, "v2 timing summary should expose patch/file/hunk/change counts", unifiedTiming); - assertCondition(unifiedTiming.remoteOperationCount === unifiedHeaderLineRangeV2.commands.length, "v2 timing summary should count remote operations", { unifiedTiming, commands: unifiedHeaderLineRangeV2.commands }); - assertCondition((unifiedTiming.remoteOperationCounts.stat ?? 0) >= 1 && (unifiedTiming.remoteOperationCounts["read-b64-block"] ?? 0) >= 1 && Object.keys(unifiedTiming.remoteOperationCounts).some((key) => key.startsWith("write-b64")), "v2 timing summary should classify stat/read/write operations", unifiedTiming); - assertCondition(unifiedHeaderLineRangeV2.stdout.startsWith("Success. Updated the following files:"), "v2 timing summary must not change Codex-compatible success stdout", unifiedHeaderLineRangeV2.stdout); - - const bulkPatchLines = ["*** Begin Patch"]; - const bulkFiles: Record = {}; - for (let fileIndex = 0; fileIndex < 4; fileIndex += 1) { - const fileName = `bulk-${fileIndex}.txt`; - bulkFiles[fileName] = Array.from({ length: 120 }, (_, lineIndex) => `file=${fileIndex} line=${lineIndex} value=alpha`).join("\n") + "\n"; - bulkPatchLines.push(`*** Update File: ${fileName}`); - bulkPatchLines.push("@@"); - bulkPatchLines.push(` file=${fileIndex} line=39 value=alpha`); - bulkPatchLines.push(`-file=${fileIndex} line=40 value=alpha`); - bulkPatchLines.push(`+file=${fileIndex} line=40 value=beta`); - bulkPatchLines.push(` file=${fileIndex} line=41 value=alpha`); - } - bulkPatchLines.push("*** End Patch", ""); - const bulkV2 = await applyPatchV2FixtureAttempt(bulkPatchLines.join("\n"), bulkFiles, { stderrOutput: true }); - assertCondition(bulkV2.exitCode === 0 && bulkV2.error === null, "v2 multi-file update patch should succeed through the bulk path", bulkV2); - assertCondition(bulkV2.commands.filter((command) => command.startsWith("read-bulk-b64")).length === 1 && bulkV2.commands.filter((command) => command.startsWith("apply-replacements-bulk-stdin")).length === 1, "v2 multi-file updates should collapse remote IO to one bulk read and one line-level bulk apply", bulkV2.commands); - assertCondition(!bulkV2.commands.some((command) => command.startsWith("stat") || command.startsWith("read-b64-block") || command.startsWith("write-b64-stdin")), "v2 bulk path should avoid per-file stat/read/write operations", bulkV2.commands); - for (let fileIndex = 0; fileIndex < 4; fileIndex += 1) { - assertCondition(bulkV2.files[`bulk-${fileIndex}.txt`]?.includes(`file=${fileIndex} line=40 value=beta`), "v2 bulk path should write all changed files", { fileIndex, files: bulkV2.files }); - } - const bulkTiming = applyPatchTimingFromStderr(bulkV2.stderr); - assertCondition(bulkTiming.remoteOperationCount === 2 && bulkTiming.remoteOperationCounts["read-bulk-b64"] === 1 && bulkTiming.remoteOperationCounts["apply-replacements-bulk-stdin"] === 1, "v2 timing summary should classify bulk read and line-level apply operations", bulkTiming); - - const fsBulkV2 = await applyPatchV2FsBulkFixtureAttempt(bulkPatchLines.join("\n"), bulkFiles); - assertCondition(fsBulkV2.exitCode === 0 && fsBulkV2.error === null, "v2 fs executor should support the same multi-file bulk update path", fsBulkV2); - assertCondition( - fsBulkV2.operations.length === 2 - && fsBulkV2.operations[0]?.startsWith("readFiles ") - && fsBulkV2.operations[1]?.startsWith("applyReplacementsBulk "), - "v2 fs multi-file update path should use fs bulk read and line-level apply operations", - fsBulkV2.operations, - ); - assertCondition(!fsBulkV2.operations.some((operation) => operation.startsWith("stat ") || operation.startsWith("readBlock ") || operation.startsWith("writeFile ")), "v2 fs bulk path should avoid per-file stat/read/write operations", fsBulkV2.operations); - for (let fileIndex = 0; fileIndex < 4; fileIndex += 1) { - assertCondition(fsBulkV2.files[`bulk-${fileIndex}.txt`]?.includes(`file=${fileIndex} line=40 value=beta`), "v2 fs bulk path should write all changed files", { fileIndex, files: fsBulkV2.files }); - } - const fsBulkTiming = applyPatchTimingFromStderr(fsBulkV2.stderr); - assertCondition(fsBulkTiming.remoteOperationCount === 2 && fsBulkTiming.remoteOperationCounts["fs.readFiles"] === 1 && fsBulkTiming.remoteOperationCounts["fs.applyReplacementsBulk"] === 1, "v2 timing summary should classify fs bulk read and line-level apply operations", fsBulkTiming); - - const unprefixedUpdateContextV2 = await applyPatchV2FixtureAttempt([ - "*** Begin Patch", - "*** Update File: internal/cloud/access-control.ts", - "@@", - " \"io.uart.jsonrpc\"", - "]);", - "+const DEVICE_JOB_READ_ONLY_SUB_ACTIONS = new Set([", - "+ \"status\",", - "+ \"output\"", - "+]);", - "const ACCESS_SCHEMA_STATEMENTS = Object.freeze([", - "*** End Patch", - "", - ].join("\n"), { - "internal/cloud/access-control.ts": [ - " \"io.uart.write\",", - " \"io.uart.jsonrpc\"", - "]);", - "const ACCESS_SCHEMA_STATEMENTS = Object.freeze([", - "]);", - "", - ].join("\n"), - }, { stderrOutput: true }); - assertCondition(unprefixedUpdateContextV2.exitCode === 0 && unprefixedUpdateContextV2.error === null, "v2 should accept MiniMax-style unprefixed Update File context lines", unprefixedUpdateContextV2); - assertCondition( - unprefixedUpdateContextV2.files["internal/cloud/access-control.ts"]?.includes("const DEVICE_JOB_READ_ONLY_SUB_ACTIONS = new Set(["), - "v2 should apply the update when MiniMax omits context prefixes for column-0 lines", - unprefixedUpdateContextV2, - ); - assertCondition( - unprefixedUpdateContextV2.stderr.includes("accepted unprefixed Update File context line in internal/cloud/access-control.ts") - && unprefixedUpdateContextV2.stderr.includes("one extra space in addition to source indentation"), - "v2 MiniMax-style Update File compatibility should emit canonical syntax hints", - unprefixedUpdateContextV2.stderr, - ); - - const unprefixedFirstUpdateContextV2 = await applyPatchV2FixtureAttempt([ - "*** Begin Patch", - "*** Update File: zero-column.ts", - "const MUTATING_INTENTS = new Set([", - "+ \"workspace.apply-patch\",", - "]);", - "*** End Patch", - "", - ].join("\n"), { - "zero-column.ts": "const MUTATING_INTENTS = new Set([\n]);\n", - }, { stderrOutput: true }); - assertCondition(unprefixedFirstUpdateContextV2.exitCode === 0 && unprefixedFirstUpdateContextV2.error === null, "v2 should accept an omitted first @@ plus unprefixed column-0 context", unprefixedFirstUpdateContextV2); - assertCondition(unprefixedFirstUpdateContextV2.files["zero-column.ts"] === "const MUTATING_INTENTS = new Set([\n \"workspace.apply-patch\",\n]);\n", "v2 omitted-@@ compatibility should preserve column-0 context correctly", unprefixedFirstUpdateContextV2); - - const manyLines = Array.from({ length: 6200 }, (_, index) => { - if (index === 4) return "HEAD old"; - if (index === 3099) return "MIDDLE old"; - if (index === 6194) return "TAIL old"; - return `ROW-${String(index + 1).padStart(5, "0")} keep`; - }); - const largeMultiMixedV2 = await applyPatchV2FixtureAttempt([ - "*** Begin Patch", - "*** Update File: big-multi.txt", - "@@ -5,1 +5,1 @@", - "-HEAD old", - "+HEAD new", - "@@ -3100,1 +3100,2 @@", - "-MIDDLE old", - "+MIDDLE new", - "+MIDDLE inserted", - "@@ -6195,1 +6196,1 @@", - "-TAIL old", - "+TAIL new", - "*** Add File: nested/compat-created.txt", - "@@", - "first", - "+ ", - "+last", - "*** Delete File: stale.txt", - "@@", - "-stale", - "*** End Patch", - "", - ].join("\n"), { - "big-multi.txt": `${manyLines.join("\n")}\n`, - "stale.txt": "stale\n", - }, { stderrOutput: true }); - assertCondition(largeMultiMixedV2.exitCode === 0 && largeMultiMixedV2.error === null, "v2 should apply large multi-hunk mixed compatibility patches", largeMultiMixedV2); - const bigMulti = largeMultiMixedV2.files["big-multi.txt"]?.split("\n") ?? []; - assertCondition( - bigMulti[4] === "HEAD new" - && bigMulti[3099] === "MIDDLE new" - && bigMulti[3100] === "MIDDLE inserted" - && bigMulti[6195] === "TAIL new" - && bigMulti[0] === "ROW-00001 keep" - && bigMulti[6200] === "ROW-06200 keep" - && bigMulti.length === 6202, - "v2 should preserve distant untouched lines while applying multiple large-file hunks", - { head: bigMulti.slice(0, 6), middle: bigMulti.slice(3098, 3102), tail: bigMulti.slice(6194, 6201) }, - ); - assertCondition(largeMultiMixedV2.files["nested/compat-created.txt"] === "first\n\nlast\n", "v2 mixed compatibility Add File should preserve intended blank line", largeMultiMixedV2); - assertCondition(largeMultiMixedV2.files["stale.txt"] === undefined, "v2 mixed compatibility Delete File should delete stale files", largeMultiMixedV2); - assertCondition( - largeMultiMixedV2.stderr.includes("accepted unified-diff hunk header in big-multi.txt") - && largeMultiMixedV2.stderr.includes("accepted MiniMax-style @@ inside Add File nested/compat-created.txt") - && largeMultiMixedV2.stderr.includes("ignored extra MiniMax-style hunk/body lines after Delete File stale.txt"), - "v2 mixed compatibility patch should emit hints for every non-canonical form", - largeMultiMixedV2.stderr, - ); - assertCondition(largeMultiMixedV2.commands.some((command) => command.startsWith("write-b64-stdin big-multi.txt")), "v2 large multi-hunk file should use stdin write path", largeMultiMixedV2.commands); - - const multiChunkTailV2 = await applyPatchV2ActualShellFixtureAttempt([ - "*** Begin Patch", - "*** Update File: two_chunks.txt", - "@@", - "-b", - "+B", - "@@", - "-d", - "+D", - "*** End Patch", - "", - ].join("\n"), { - "two_chunks.txt": "a\nb\nc\nd\ne\nf\n", - }); - assertCondition(multiChunkTailV2.error === null, "v2 should apply explicit multi-chunk patches through the real shell writer", multiChunkTailV2); - assertCondition(multiChunkTailV2.files["two_chunks.txt"] === "a\nB\nc\nD\ne\nf\n", "v2 must preserve untouched tail lines when applying multiple chunks", multiChunkTailV2); - - const largeTailV2 = await applyPatchV2ActualShellFixtureAttempt([ - "*** Begin Patch", - "*** Update File: large-tail.txt", - "@@ LINE-2048 tail-preserve", - "-LINE-2049 keep middle", - "+LINE-2049 patched middle", - "*** End Patch", - "", - ].join("\n"), { - "large-tail.txt": Array.from({ length: 5000 }, (_, index) => `LINE-${String(index).padStart(4, "0")} ${index === 2049 ? "keep middle" : "tail-preserve"}`).join("\n") + "\n", - }); - assertCondition(largeTailV2.error === null, "v2 should patch a large file through the real shell writer", largeTailV2); - assertCondition(largeTailV2.files["large-tail.txt"]?.includes("LINE-2049 patched middle"), "v2 large-file patch should update the target line", largeTailV2); - assertCondition(largeTailV2.files["large-tail.txt"]?.endsWith("LINE-4999 tail-preserve\n"), "v2 must preserve the untouched tail of large files", largeTailV2); - assertCondition(largeTailV2.commands.some((command) => command.startsWith("write-b64-stdin")), "v2 large-file real shell path should use stdin fast path before any fallback", largeTailV2.commands); - assertCondition(!largeTailV2.commands.some((command) => command.startsWith("write-b64-append")), "v2 large-file real shell path should not use slower chunk fallback unless stdin integrity fails", largeTailV2.commands); - - const truncatedLargeReadV2 = await applyPatchV2ActualShellFixtureAttempt([ - "*** Begin Patch", - "*** Update File: large-read.txt", - "@@", - "+this write must not happen after a truncated read", - "*** End Patch", - "", - ].join("\n"), { - "large-read.txt": largeOriginal, - }, undefined, (operation, result) => { - if (operation !== "read-b64-block" || result.exitCode !== 0) return result; - return { ...result, stdout: result.stdout.slice(0, Math.max(0, result.stdout.length - 32)) }; - }); - assertCondition(truncatedLargeReadV2.error !== null, "v2 should reject a truncated remote read before planning writes", truncatedLargeReadV2); - assertCondition(truncatedLargeReadV2.files["large-read.txt"] === largeOriginal, "v2 must keep the original file when bridge stdout truncates a read block", truncatedLargeReadV2); - assertCondition(!truncatedLargeReadV2.commands.some((command) => command.startsWith("write-b64")), "v2 must not write after read integrity fails", truncatedLargeReadV2.commands); - - const missingUpdateShellV2 = await applyPatchV2ActualShellFixtureAttempt([ - "*** Begin Patch", - "*** Update File: missing-dist.js", - "@@", - "-old", - "+new", - "*** End Patch", - "", - ].join("\n"), {}); - const missingUpdateShellError = missingUpdateShellV2.error instanceof Error ? missingUpdateShellV2.error : null; - assertCondition(missingUpdateShellError !== null, "v2 real shell stat should reject missing update targets", missingUpdateShellV2); - assertCondition( - missingUpdateShellError.message.includes("remote apply-patch v2 operation failed") - && JSON.stringify((missingUpdateShellError as { details?: unknown }).details ?? {}).includes("file not found: missing-dist.js"), - "v2 real shell stat must expose file-not-found instead of invalid metadata", - missingUpdateShellError, - ); - assertCondition(!missingUpdateShellV2.commands.some((command) => command.startsWith("read-b64") || command.startsWith("write-b64")), "missing update target must fail before remote read/write", missingUpdateShellV2.commands); - - const truncatedLargeWriteV2 = await applyPatchV2ActualShellFixtureAttempt([ - "*** Begin Patch", - "*** Update File: large.txt", - "@@", - "+large insert that forces a rewritten full-file payload", - "*** End Patch", - "", - ].join("\n"), { - "large.txt": largeOriginal, - }, (operation, input) => { - if (operation !== "write-b64-stdin" || input === undefined) return input; - return input.slice(0, Math.max(0, input.length - 32)); - }); - assertCondition(truncatedLargeWriteV2.error === null, "v2 should fall back to bounded argv chunks when the stdin write path is truncated", truncatedLargeWriteV2); - assertCondition(truncatedLargeWriteV2.files["large.txt"]?.includes("large insert that forces a rewritten full-file payload"), "v2 fallback write should still apply the patch", truncatedLargeWriteV2); - assertCondition(truncatedLargeWriteV2.files["large.txt"]?.startsWith(largeOriginal), "v2 fallback write must preserve the original large-file content before appending the inserted line", { - commands: truncatedLargeWriteV2.commands.map((command) => command.split(" ").slice(0, 2).join(" ")), - outputBytes: Buffer.byteLength(truncatedLargeWriteV2.files["large.txt"] ?? "", "utf8"), - }); - assertCondition(truncatedLargeWriteV2.commands.some((command) => command.startsWith("write-b64-stdin")), "v2 should attempt the stdin fast path first", truncatedLargeWriteV2.commands); - assertCondition(truncatedLargeWriteV2.commands.some((command) => command.startsWith("write-b64-commit")), "v2 should commit the chunked fallback after stdin integrity failure", truncatedLargeWriteV2.commands); - - const failedCompoundV2 = await applyPatchV2FixtureAttempt([ - "*** Begin Patch", - "*** Update File: first.txt", - "@@", - "-old first", - "+new first", - "*** Update File: second.txt", - "@@", - "-missing second", - "+new second", - "*** End Patch", - "", - ].join("\n"), { - "first.txt": "old first\n", - "second.txt": "old second\n", - }); - assertCondition(failedCompoundV2.error !== null, "v2 compound patch should fail when a later hunk does not match", failedCompoundV2); - assertCondition(failedCompoundV2.files["first.txt"] === "new first\n", "v2 should match Codex apply_patch by preserving earlier committed changes when a later hunk fails", failedCompoundV2); - assertCondition(failedCompoundV2.files["second.txt"] === "old second\n", "v2 must leave later failed files unchanged", failedCompoundV2); - assertCondition(failedCompoundV2.commands.some((command) => command.startsWith("write-b64") || command.startsWith("apply-replacements-bulk-stdin")), "v2 should commit preceding operations in patch order like Codex apply_patch", failedCompoundV2.commands); - assertCondition( - Array.isArray((failedCompoundV2.error as { details?: { partialChanges?: unknown } })?.details?.partialChanges) - && ((failedCompoundV2.error as { details?: { partialChanges?: string[] } }).details?.partialChanges ?? []).includes("M first.txt"), - "v2 failure should expose partialChanges for already committed operations", - failedCompoundV2.error, - ); - - const failedCompoundVisibleV2 = await applyPatchV2FixtureAttempt([ - "*** Begin Patch", - "*** Update File: first.txt", - "@@", - "-old first", - "+new first", - "*** Update File: second.txt", - "@@", - "-missing second", - "+new second", - "*** Update File: third.txt", - "@@", - "-old third", - "+new third", - "*** End Patch", - "", - ].join("\n"), { - "first.txt": "old first\n", - "second.txt": "old second\n", - "third.txt": "old third\n", - }, { stderrOutput: true }); - assertCondition(failedCompoundVisibleV2.exitCode === 1 && failedCompoundVisibleV2.error === null, "v2 CLI path should return non-zero instead of throwing when stderr is provided", failedCompoundVisibleV2); - assertCondition(failedCompoundVisibleV2.stdout === "", "v2 failed CLI path should keep Codex-style empty stdout", failedCompoundVisibleV2); - assertCondition( - failedCompoundVisibleV2.stderr.includes("failed to find expected lines") - && failedCompoundVisibleV2.stderr.includes("Applied before failure:") - && failedCompoundVisibleV2.stderr.includes("Expected lines for second.txt hunk 1:") - && failedCompoundVisibleV2.stderr.includes("\"missing second\"") - && failedCompoundVisibleV2.stderr.includes("M first.txt") - && failedCompoundVisibleV2.stderr.includes("Failed:") - && failedCompoundVisibleV2.stderr.includes("hunk 2 update second.txt") - && !failedCompoundVisibleV2.stderr.includes("third.txt"), - "v2 failed CLI path should print Codex-style stderr plus expected lines, applied/failed summary, and stop before later hunks", - failedCompoundVisibleV2.stderr, - ); - - const missingPlusLargeInsertVisibleV2 = await applyPatchV2FixtureAttempt([ - "*** Begin Patch", - "*** Update File: access-control.test.ts", - "@@", - " });", - "", - " test(\"cloud api accepts read-only workspace.build / debug.download sub-actions without --reason\", async () => {", - " const receivedJobs = [];", - " const executor = createServer(async (request, response) => {", - " const body = await requestJson(request);", - " receivedJobs.push({ url: request.url, body });", - " response.writeHead(200, { \"content-type\": \"application/json; charset=utf-8\" });", - " response.end(JSON.stringify({ accepted: true, status: \"completed\" }));", - " });", - " });", - "", - " test(\"cloud api routes device-pod probe GET requests through executor jobs\", async () => {", - "*** End Patch", - "", - ].join("\n"), { - "access-control.test.ts": [ - "test(\"cloud api bounds device-pod job output payloads\", async () => {", - "});", - "", - "test(\"cloud api routes device-pod probe GET requests through executor jobs\", async () => {", - "});", - "", - ].join("\n"), - }, { stderrOutput: true }); - assertCondition(missingPlusLargeInsertVisibleV2.exitCode === 1 && missingPlusLargeInsertVisibleV2.error === null, "v2 should still reject unsafe large insertion hunks whose added lines are missing + prefixes", missingPlusLargeInsertVisibleV2); - assertCondition( - missingPlusLargeInsertVisibleV2.stderr.includes("First expected line appears near target line(s): 2, 5") - && missingPlusLargeInsertVisibleV2.stderr.includes("Best partial context match: 2 expected line(s) matched") - && missingPlusLargeInsertVisibleV2.stderr.includes("large insertion whose new lines were written as context") - && missingPlusLargeInsertVisibleV2.stderr.includes("regenerate the patch instead of editing it with sed") - && missingPlusLargeInsertVisibleV2.stderr.includes("handled by apply-patch v2") - && missingPlusLargeInsertVisibleV2.stderr.includes("do not switch the same patch to apply-patch-v1"), - "v2 missing-plus failure should diagnose the MiniMax sed-regression pattern explicitly", - missingPlusLargeInsertVisibleV2.stderr, - ); - - const staleBlockReplacementVisibleV2 = await applyPatchV2FixtureAttempt([ - "*** Begin Patch", - "*** Update File: runner-trace.ts", - "@@", - " export async function waitForAgentResult(", - " initial: AgentChatResponse,", - " activityRef?: ActivityRefSource", - " ): Promise {", - " if (isTerminalStatus(initial.status)) {", - " return isTerminalStatus(initial.status) ? initial : null;", - " }", - "- return initial;", - "+ return pollAgentResult(initial, activityRef);", - " }", - "*** End Patch", - "", - ].join("\n"), { - "runner-trace.ts": [ - "export async function waitForAgentResult(", - " initial: AgentChatResponse,", - " activityRef?: ActivityRefSource", - "): Promise {", - " if (isTerminalStatus(initial.status)) return initial;", - " return pollAgentResult(initial);", - "}", - "", - ].join("\n"), - }, { stderrOutput: true }); - assertCondition(staleBlockReplacementVisibleV2.exitCode === 1 && staleBlockReplacementVisibleV2.error === null, "v2 should reject stale block-replacement hunks without falling back", staleBlockReplacementVisibleV2); - assertCondition( - staleBlockReplacementVisibleV2.stderr.includes("Best partial context match: 4 expected line(s) matched") - && staleBlockReplacementVisibleV2.stderr.includes("stale or oversized context for a block/function replacement") - && staleBlockReplacementVisibleV2.stderr.includes("Do not switch to download/upload") - && staleBlockReplacementVisibleV2.stderr.includes("remote Python/Perl/sed") - && staleBlockReplacementVisibleV2.stderr.includes("retry apply-patch with a smaller hunk") - && staleBlockReplacementVisibleV2.stderr.includes("split the edit into hunks around unique anchors"), - "v2 stale block replacement failure should steer MiniMax back to smaller apply-patch hunks instead of file transfer or script rewrites", - staleBlockReplacementVisibleV2.stderr, - ); - - const fragmentedEnvelopeFailureV2 = await applyPatchV2FixtureAttempt([ - "*** Begin Patch", - "*** Update File: fragment.txt", - "@@", - "-alpha", - "+ALPHA", - "*** End Patch", - "printf '%s\\n' '*** Begin Patch'", - "*** End Patch", - "", - ].join("\n"), { - "fragment.txt": "alpha\n", - }, { stderrOutput: true }); - assertCondition(fragmentedEnvelopeFailureV2.exitCode === 1 && fragmentedEnvelopeFailureV2.error === null, "v2 should reject non-patch shell fragments with a parser hint", fragmentedEnvelopeFailureV2); - assertCondition( - fragmentedEnvelopeFailureV2.stderr.includes("invalid hunk header") - && fragmentedEnvelopeFailureV2.stderr.includes("concatenated MiniMax/MXCX fragments") - && fragmentedEnvelopeFailureV2.stderr.includes("exactly one outer *** Begin Patch / *** End Patch envelope") - && fragmentedEnvelopeFailureV2.stderr.includes("v2 engine only") - && fragmentedEnvelopeFailureV2.stderr.includes("do not retry by switching to apply-patch-v1"), - "v2 parser failure should hint the MXCX printf/heredoc envelope fix without falling back to v1", - fragmentedEnvelopeFailureV2.stderr, - ); - - const nestedEnvelopeV2 = await applyPatchV2FixtureAttempt([ - "*** Begin Patch", - "*** Update File: nested-envelope.txt", - "@@", - " alpha", - "*** End Patch", - "*** Begin Patch", - "*** Update File: nested-envelope.txt", - "@@", - "-beta", - "+BETA", - "*** End Patch", - "", - ].join("\n"), { - "nested-envelope.txt": "alpha\nbeta\n", - }, { stderrOutput: true }); - assertCondition(nestedEnvelopeV2.exitCode === 0 && nestedEnvelopeV2.error === null, "v2 should accept MiniMax-style nested patch envelopes between hunks", nestedEnvelopeV2); - assertCondition(nestedEnvelopeV2.files["nested-envelope.txt"] === "alpha\nBETA\n", "v2 nested-envelope compatibility should still apply the later hunk", nestedEnvelopeV2); - assertCondition( - nestedEnvelopeV2.stderr.includes("ignored nested MiniMax-style End Patch marker") - && nestedEnvelopeV2.stderr.includes("ignored nested MiniMax-style Begin Patch marker"), - "v2 nested-envelope compatibility should emit canonical envelope hints", - nestedEnvelopeV2.stderr, - ); - - const addBeforeFailedUpdateV2 = await applyPatchV2FixtureAttempt([ - "*** Begin Patch", - "*** Add File: hwpod", - "+#!/bin/sh", - "+exec node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs \"$@\"", - "*** Update File: scripts/artifact-publish.mjs", - "@@", - "-missing artifact line", - "+patched artifact line", - "*** End Patch", - "", - ].join("\n"), { - "scripts/artifact-publish.mjs": "actual artifact line\n", - }); - assertCondition(addBeforeFailedUpdateV2.error !== null, "v2 should still fail when a later file hunk misses", addBeforeFailedUpdateV2); - assertCondition(addBeforeFailedUpdateV2.files.hwpod?.includes("device-pod-cli.mjs"), "v2 should preserve an earlier Add File from a large patch when a later hunk misses", addBeforeFailedUpdateV2); - assertCondition(addBeforeFailedUpdateV2.files["scripts/artifact-publish.mjs"] === "actual artifact line\n", "v2 must leave the failed later file unchanged", addBeforeFailedUpdateV2); - - const addFileWithHunkMarkerV2 = await applyPatchV2FixtureAttempt([ - "*** Begin Patch", - "*** Add File: bad-add.txt", - "@@", - "+content", - "*** End Patch", - "", - ].join("\n"), {}, { stderrOutput: true }); - assertCondition(addFileWithHunkMarkerV2.exitCode === 0 && addFileWithHunkMarkerV2.error === null, "v2 should accept MiniMax-style Add File @@ misuse with a hint", addFileWithHunkMarkerV2); - assertCondition(addFileWithHunkMarkerV2.stdout.includes("A bad-add.txt"), "v2 MiniMax-style Add File should still apply the new file", addFileWithHunkMarkerV2); - assertCondition( - addFileWithHunkMarkerV2.stderr.includes("accepted MiniMax-style @@ inside Add File bad-add.txt") - && addFileWithHunkMarkerV2.stderr.includes("Add File does not need @@"), - "v2 MiniMax-style Add File compatibility should still hint the canonical syntax", - addFileWithHunkMarkerV2.stderr, - ); - assertCondition(addFileWithHunkMarkerV2.files["bad-add.txt"] === "content\n", "v2 MiniMax-style Add File compatibility should preserve content", addFileWithHunkMarkerV2); - - const addFileBareLinesV2 = await applyPatchV2FixtureAttempt([ - "*** Begin Patch", - "*** Add File: loose-add.txt", - "first", - "", - "+ ", - "+third", - "*** End Patch", - "", - ].join("\n"), {}, { stderrOutput: true }); - assertCondition(addFileBareLinesV2.exitCode === 0 && addFileBareLinesV2.error === null, "v2 should accept MiniMax-style Add File bare content and blank lines with hints", addFileBareLinesV2); - assertCondition(addFileBareLinesV2.files["loose-add.txt"] === "first\n\n\nthird\n", "v2 MiniMax-style Add File loose lines should preserve intended content", addFileBareLinesV2); - assertCondition( - addFileBareLinesV2.stderr.includes("accepted unprefixed Add File content in loose-add.txt") - && addFileBareLinesV2.stderr.includes("accepted a bare blank line inside Add File loose-add.txt") - && addFileBareLinesV2.stderr.includes("accepted MiniMax-style whitespace-only Add File line in loose-add.txt"), - "v2 MiniMax-style Add File loose lines should emit canonical syntax hints", - addFileBareLinesV2.stderr, - ); - - const deleteFileWithHunkMarkerV2 = await applyPatchV2FixtureAttempt([ - "*** Begin Patch", - "*** Delete File: bad-delete.txt", - "@@", - "-content", - "*** End Patch", - "", - ].join("\n"), { "bad-delete.txt": "content\n" }, { stderrOutput: true }); - assertCondition(deleteFileWithHunkMarkerV2.exitCode === 0 && deleteFileWithHunkMarkerV2.error === null, "v2 should accept MiniMax-style Delete File @@ misuse with a hint", deleteFileWithHunkMarkerV2); - assertCondition(deleteFileWithHunkMarkerV2.stdout.includes("D bad-delete.txt"), "v2 MiniMax-style Delete File should still delete the file", deleteFileWithHunkMarkerV2); - assertCondition( - deleteFileWithHunkMarkerV2.stderr.includes("ignored extra MiniMax-style hunk/body lines after Delete File bad-delete.txt") - && deleteFileWithHunkMarkerV2.stderr.includes("Delete File only needs the header"), - "v2 MiniMax-style Delete File compatibility should still hint the canonical syntax", - deleteFileWithHunkMarkerV2.stderr, - ); - assertCondition(deleteFileWithHunkMarkerV2.files["bad-delete.txt"] === undefined, "v2 MiniMax-style Delete File compatibility should delete the file", deleteFileWithHunkMarkerV2); - - const sequentialCompoundV2 = await applyPatchV2Fixture([ - "*** Begin Patch", - "*** Update File: sequence.txt", - "@@", - "-alpha", - "+beta", - "*** Update File: sequence.txt", - "@@", - "-beta", - "+gamma", - "*** End Patch", - "", - ].join("\n"), { - "sequence.txt": "alpha\n", - }); - assertCondition(sequentialCompoundV2.files["sequence.txt"] === "gamma\n", "v2 should plan later hunks against earlier planned edits before remote writes", sequentialCompoundV2); - - const emptyPatchV2 = await applyPatchV2FixtureAttempt([ - "*** Begin Patch", - "*** End Patch", - "", - ].join("\n"), { - "empty.txt": "kept\n", - }); - assertCondition(emptyPatchV2.error !== null, "v2 should reject empty patches like Codex apply_patch", emptyPatchV2); - assertCondition(!emptyPatchV2.commands.some((command) => command.startsWith("write-b64") || command.startsWith("delete")), "empty v2 patches must not touch remote files", emptyPatchV2.commands); - - const environmentPreambleV2 = await applyPatchV2Fixture([ - "*** Begin Patch", - "*** Environment ID: remote", - "*** Update File: env.txt", - "@@", - "-before", - "+after", - "*** End Patch", - "", - ].join("\n"), { - "env.txt": "before\n", - }); - assertCondition(environmentPreambleV2.files["env.txt"] === "after\n", "v2 should accept Codex apply_patch environment_id preamble", environmentPreambleV2); - - const absolutePathV2 = await applyPatchV2Fixture([ - "*** Begin Patch", - "*** Update File: /tmp/absolute.txt", - "@@", - "-old", - "+new", - "*** End Patch", - "", - ].join("\n"), { - "/tmp/absolute.txt": "old\n", - }); - assertCondition(absolutePathV2.files["/tmp/absolute.txt"] === "new\n", "v2 should accept absolute paths like Codex apply_patch when the executor route supports them", absolutePathV2); - - const parentSegmentPathV2 = await applyPatchV2Fixture([ - "*** Begin Patch", - "*** Update File: ../outside.txt", - "@@", - "-old", - "+new", - "*** End Patch", - "", - ].join("\n"), { - "../outside.txt": "old\n", - }); - assertCondition(parentSegmentPathV2.files["../outside.txt"] === "new\n", "v2 should leave path containment policy to the executor like Codex apply_patch", parentSegmentPathV2); - - const missingDeleteV2 = await applyPatchV2FixtureAttempt([ - "*** Begin Patch", - "*** Delete File: missing.txt", - "*** End Patch", - "", - ].join("\n"), { - "keep.txt": "kept\n", - }); - assertCondition(missingDeleteV2.error !== null, "v2 should reject deleting a missing file like Codex apply_patch", missingDeleteV2); - assertCondition(missingDeleteV2.files["keep.txt"] === "kept\n", "missing delete must leave unrelated files unchanged", missingDeleteV2); - - const moveOverwriteV2 = await applyPatchV2Fixture([ - "*** Begin Patch", - "*** Update File: old-name.txt", - "*** Move to: new-name.txt", - "@@", - "-old content", - "+new content", - "*** End Patch", - "", - ].join("\n"), { - "old-name.txt": "old content\n", - "new-name.txt": "existing content\n", - }); - assertCondition(!Object.prototype.hasOwnProperty.call(moveOverwriteV2.files, "old-name.txt"), "v2 rename should remove the source file", moveOverwriteV2); - assertCondition(moveOverwriteV2.files["new-name.txt"] === "new content\n", "v2 rename should overwrite the destination like Codex apply_patch", moveOverwriteV2); - - const safePatch = applyPatchFixture([], [ - "*** Begin Patch", - "*** Update File: sample.txt", - "@@", - " function alpha() {", - " return 1;", - " }", - "+function inserted() {", - "+ return 3;", - "+}", - " function beta() {", - " return 2;", - " }", - "*** End Patch", - "", - ].join("\n"), { - "sample.txt": [ - "function alpha() {", - " return 1;", - "}", - "function beta() {", - " return 2;", - "}", - "", - ].join("\n"), - }); - assertCondition(safePatch.status === 0, "apply_patch sh helper should accept anchored insertions", safePatch); - assertCondition(safePatch.stderr.includes("apply_patch: hunk 1 matched sample.txt:1"), "apply_patch sh helper must report matched file:line", safePatch); - assertCondition(safePatch.files["sample.txt"]?.includes("function inserted()"), "apply_patch sh helper should apply anchored insertion", safePatch); - - const lowContextPatch = applyPatchFixture([], [ - "*** Begin Patch", - "*** Update File: sample.txt", - "@@", - " function alpha() {", - " return 1;", - " }", - "+function misplaced() {", - "+ return 9;", - "+}", - "*** End Patch", - "", - ].join("\n"), { - "sample.txt": [ - "function alpha() {", - " return 1;", - "}", - "function beta() {", - " return 2;", - "}", - "", - ].join("\n"), - }); - assertCondition(lowContextPatch.status !== 0, "apply_patch sh helper should reject insert-only hunks without trailing context", lowContextPatch); - assertCondition(lowContextPatch.stderr.includes("insert-only without both leading and trailing context"), "low-context rejection must explain the risk", lowContextPatch); - - const duplicateContextPatch = applyPatchFixture([], [ - "*** Begin Patch", - "*** Update File: sample.txt", - "@@", - " marker", - "+inserted", - " x", - "*** End Patch", - "", - ].join("\n"), { - "sample.txt": "marker\nx\nmarker\nx\n", - }); - assertCondition(duplicateContextPatch.status !== 0, "apply_patch sh helper should reject hunks whose context matches multiple locations", duplicateContextPatch); - assertCondition(duplicateContextPatch.stderr.includes("matched multiple locations"), "duplicate context rejection must explain the risk", duplicateContextPatch); - - const reviewedLoosePatch = applyPatchFixture(["--allow-loose"], [ - "*** Begin Patch", - "*** Update File: sample.txt", - "@@", - " marker", - "+reviewed", - " x", - "*** End Patch", - "", - ].join("\n"), { - "sample.txt": "marker\nx\nmarker\nx\n", - }); - assertCondition(reviewedLoosePatch.status === 0, "--allow-loose should allow an explicitly reviewed ambiguous hunk", reviewedLoosePatch); - assertCondition(reviewedLoosePatch.files["sample.txt"] === "marker\nreviewed\nx\nmarker\nx\n", "--allow-loose should still apply only one hunk", reviewedLoosePatch); - - assertThrows( - () => parseSshInvocation("D601:k3s:kubectl", ["get", "pods"]), - /route must locate a target only.*ssh D601:k3s kubectl/u, - "operation names must not be accepted as k3s route segments", - ); - assertThrows( - () => parseSshInvocation("D601:k3s:hwlab-ci:kubectl", ["get", "pods"]), - /route must locate a target only.*operation "kubectl" after the route/u, - "operation names must not be accepted as nested k3s route segments", - ); - assertThrows( - () => parseSshInvocation("D601:k3s:hwlab-dev:hwlab-cloud-api:logs", []), - /route must locate a target only.*operation "logs" after the route/u, - "operation names must not be accepted as k3s container route segments", - ); - assertThrows( - () => parseSshInvocation("D601:k3s:apply-patch:hwlab-dev:hwlab-cloud-api", []), - /route must locate a target only.*apply-patch/u, - "pod apply-patch must be an operation after the route", - ); - - const routePodTarget = parseSshInvocation("D601:k3s:hwlab-dev:pod:hwlab-cloud-api-abc:api", ["printenv", "HOSTNAME"]); - assertCondition(routePodTarget.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-n' 'hwlab-dev' 'pod/hwlab-cloud-api-abc' '-c' 'api' '--' 'printenv' 'HOSTNAME'", "pod route with container must preserve explicit pod kind", routePodTarget); - - const routePodWorkspace = parseSshInvocation("D601:k3s:hwlab-dev:pod:hwlab-cloud-api-abc/workspace/app:api", ["pwd"]); - assertCondition(routePodWorkspace.route.resource === "pod/hwlab-cloud-api-abc" && routePodWorkspace.route.container === "api" && routePodWorkspace.route.workspace === "/workspace/app", "pod route must support a workspace suffix after the pod id", routePodWorkspace); - assertCondition(routePodWorkspace.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-n' 'hwlab-dev' 'pod/hwlab-cloud-api-abc' '-c' 'api' '--' 'sh' '-c' 'cd \"$1\" || exit; shift; exec \"$@\"' 'unidesk-cwd' '/workspace/app' 'pwd'", "pod workspace route must run commands through a fixed cwd wrapper", routePodWorkspace); - - const legacyRoutePodTarget = parseSshInvocation("D601:k3s:hwlab-dev:pod/hwlab-cloud-api-abc:api", ["printenv", "HOSTNAME"]); - assertCondition(legacyRoutePodTarget.parsed.remoteCommand === routePodTarget.parsed.remoteCommand, "legacy pod/name route remains accepted for compatibility", legacyRoutePodTarget); - - const routeDeploymentWorkspaceScript = parseSshInvocation("D601:k3s:hwlab-dev:hwlab-cloud-api/app", ["script"]); - assertCondition(routeDeploymentWorkspaceScript.route.resource === "hwlab-cloud-api" && routeDeploymentWorkspaceScript.route.workspace === "/app", "deployment shorthand route must support workspace suffix", routeDeploymentWorkspaceScript); - assertCondition(routeDeploymentWorkspaceScript.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-i' '-n' 'hwlab-dev' 'deployment/hwlab-cloud-api' '--' 'sh' '-c' 'cd \"$1\" || exit; shift; exec \"$@\"' 'unidesk-cwd' '/app' 'sh' '-s' '--'", "pod workspace script must set cwd before shell -s consumes stdin", routeDeploymentWorkspaceScript); - - const routeApplyPatchWorkspace = parseSshInvocation("D601:k3s:hwlab-dev:hwlab-cloud-api/app", ["apply-patch"]); - assertCondition(routeApplyPatchWorkspace.parsed.requiresStdin === true, "pod workspace apply-patch must still stream patch stdin", routeApplyPatchWorkspace); - assertCondition(routeApplyPatchWorkspace.parsed.remoteCommand === null, "pod workspace apply-patch must use the default v2 local engine", routeApplyPatchWorkspace); - - const routeApplyPatchV1Workspace = parseSshInvocation("D601:k3s:hwlab-dev:hwlab-cloud-api/app", ["apply-patch-v1"]); - assertCondition(routeApplyPatchV1Workspace.parsed.requiresStdin === true, "pod workspace apply-patch-v1 must still stream patch stdin", routeApplyPatchV1Workspace); - assertCondition(routeApplyPatchV1Workspace.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-i' '-n' 'hwlab-dev' 'deployment/hwlab-cloud-api' '--' 'sh' '-c' 'cd \"$1\" || exit; shift; exec \"$@\"' 'unidesk-cwd' '/app' 'sh' '-s' '--'", "pod workspace apply-patch-v1 must set cwd before injecting the sh helper", routeApplyPatchV1Workspace); - - const routeExecStdin = parseSshInvocation("D601:k3s:unidesk:code-queue/root/unidesk", ["exec", "--stdin", "--", "tar", "-xf", "-", "-C", "/root/unidesk"]); - assertCondition(routeExecStdin.parsed.requiresStdin === true, "pod route exec --stdin must stream local stdin into kubectl exec", routeExecStdin); - assertCondition(routeExecStdin.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-n' 'unidesk' 'deployment/code-queue' '-i' '--' 'sh' '-c' 'cd \"$1\" || exit; shift; exec \"$@\"' 'unidesk-cwd' '/root/unidesk' 'tar' '-xf' '-' '-C' '/root/unidesk'", "pod route exec --stdin must keep exec flags before -- and command argv after --", routeExecStdin); - - const sshLike = parseSshArgs(["echo hello"]); - const hint = sshFailureHint("D601", sshLike, 255, "kex_exchange_identification: Connection closed by remote host"); - assertCondition(hint !== null, "ssh-like kex failure must produce a hint", sshLike); - assertCondition(hint?.try === "trans D601 script <<'SCRIPT'", "hint must provide canonical stdin script retry", hint); - assertCondition(hint?.triage.includes("provider triage D601"), "hint must provide provider triage command", hint); - const formatted = formatSshFailureHint(hint!); - assertCondition(formatted.startsWith("UNIDESK_SSH_HINT "), "formatted hint must have structured prefix", formatted); - assertCondition(!formatted.includes("echo hello"), "formatted hint must not echo the original remote command", formatted); - - const timingInfo = sshRuntimeTimingHint({ - invocation: parseSshInvocation("D601:/home/ubuntu/workspace/hwlab-dev", ["argv", "true"]), - transport: "backend-core-broker", - exitCode: 0, - startedAtMs: 1000, - finishedAtMs: 5200, - thresholdMs: 10_000, - }); - assertCondition(timingInfo.level === "info" && timingInfo.slow === false, "short ssh operation should stay below the timing warning threshold", timingInfo); - assertCondition(timingInfo.elapsedMs === 4200 && timingInfo.elapsedSeconds === 4.2, "timing hint must include elapsed ms and seconds", timingInfo); - assertCondition(formatSshRuntimeTimingHint(timingInfo) === "", "short ssh operation must not write routine timing noise to stderr", timingInfo); - const slowTiming = sshRuntimeTimingHint({ - invocation: parseSshInvocation("D601", ["apply-patch"]), - transport: "frontend-websocket", - exitCode: 0, - startedAtMs: 0, - finishedAtMs: 12_345, - thresholdMs: 10_000, - }); - assertCondition(slowTiming.level === "warning" && slowTiming.slow === true, "slow ssh operation should emit a warning timing hint", slowTiming); - assertCondition(slowTiming.message.includes("above the 10s warning threshold"), "slow timing warning must explain the threshold", slowTiming); - const formattedTiming = formatSshRuntimeTimingHint(slowTiming); - assertCondition(formattedTiming.startsWith("UNIDESK_SSH_TIMING "), "formatted timing hint must have structured prefix", formattedTiming); - assertCondition(formattedTiming.includes("\"exitCode\":0"), "slow successful ssh operation must still emit timing warning as a performance signal", formattedTiming); - assertCondition(!formattedTiming.includes("apply_patch"), "timing hint must not echo the original remote command", formattedTiming); - - const timeoutHint = sshFailureHint("D601", sshLike, 255, "unidesk ssh bridge timed out waiting for provider session"); - assertCondition(timeoutHint?.trigger === "timeout-or-kex", "provider session timeout must map to timeout-or-kex", timeoutHint); - assertCondition(sshRuntimeTimeoutMs({ UNIDESK_SSH_RUNTIME_TIMEOUT_MS: "120000" } as NodeJS.ProcessEnv) === 60_000, "ssh runtime timeout must cap at 60s", {}); - assertCondition(sshRuntimeTimeoutMs({ UNIDESK_TRAN_RUNTIME_TIMEOUT_MS: "2500" } as NodeJS.ProcessEnv) === 2500, "ssh runtime timeout must accept smaller explicit limits", {}); - const runtimeTimeout = sshRuntimeTimeoutHint({ - invocation: parseSshInvocation("G14:k3s", ["script"]), - transport: "backend-core-broker", - timeoutMs: 60_000, - }); - const formattedRuntimeTimeout = formatSshRuntimeTimeoutHint(runtimeTimeout); - assertCondition(formattedRuntimeTimeout.startsWith("UNIDESK_SSH_RUNTIME_TIMEOUT "), "runtime timeout hint must have structured prefix", formattedRuntimeTimeout); - assertCondition(formattedRuntimeTimeout.includes("short query plus poll semantics"), "runtime timeout hint must point to short polling", formattedRuntimeTimeout); - assertCondition(!formattedRuntimeTimeout.includes("kubectl"), "runtime timeout hint must not echo remote command text", formattedRuntimeTimeout); - - const help = sshHelp() as { notes?: unknown }; - const helpText = JSON.stringify(help); - const helpNotes = Array.isArray(help.notes) ? help.notes.map(String).join("\n") : ""; - assertCondition(helpText.includes("trans script [--shell sh|bash] [script-args...] <<'SCRIPT'"), "ssh help must recommend stdin script passthrough for shell scripts", helpText); - assertCondition(helpText.includes("trans shell [--shell sh|bash]") && helpText.includes("outer shell operators written outside trans"), "ssh help must document one-line shell passthrough and the local operator boundary", helpText); - assertCondition(helpText.includes("inherits provider proxy variables"), "ssh help must state default script inherits provider proxy env", helpText); - assertCondition(helpText.includes("not as a proxy workaround"), "ssh help must reserve --shell bash for bash syntax instead of proxy workarounds", helpText); - assertCondition(helpNotes.includes("portable printf headings") && helpNotes.includes('printf "--- section ---\\n"'), "ssh help must document script/shell printf heading compatibility", helpNotes); - assertCondition(helpText.includes("trans D601:/home/ubuntu/workspace/hwlab-dev git status --short --branch"), "ssh help must document host workspace routes", helpText); - assertCondition(helpText.includes("trans D601:win ps <<'PS'") && helpText.includes("trans D601:win/c/test ps <<'PS'"), "ssh help must document Windows PowerShell ps routes", helpText); - assertCondition(helpText.includes("Use `win`, not `win32`") && helpText.includes("win route is a Windows operation plane"), "ssh help must document win route naming and operation boundary", helpText); - assertCondition(helpText.includes("Do not use `script` for Windows PowerShell") && helpText.includes("trans :win ps <<'PS'"), "ssh help must direct Windows PowerShell users to ps rather than script", helpText); - assertCondition(helpText.includes("trans D601:k3s kubectl get pods -n hwlab-dev"), "ssh help must document k3s kubectl operation", helpText); - assertCondition(helpText.includes("trans G14:k3s kubectl get pipelineruns -n hwlab-ci"), "ssh help must document G14 k3s route operation", helpText); - assertCondition(helpText.includes("trans D601:k3s:hwlab-dev:hwlab-cloud-api/app pwd"), "ssh help must document k3s pod workspace route", helpText); - assertCondition(helpText.includes("trans D601:k3s script <<'SCRIPT'"), "ssh help must document k3s control-plane script operation", helpText); - assertCondition(helpText.includes("trans D601:k3s:hwlab-dev:hwlab-cloud-api/app apply-patch <<'PATCH'"), "ssh help must document k3s pod apply-patch operation", helpText); - assertCondition(helpText.includes("trans upload ") && helpText.includes("trans download "), "ssh help must document verified file transfer operations", helpText); - assertCondition(helpText.includes("trans D601:k3s:unidesk:code-queue/root/unidesk exec --stdin -- tar -xf - -C /root/unidesk"), "ssh help must document one-step stdin file streaming into pod exec", helpText); - assertCondition(helpText.includes("apply-patch-v1 [--allow-loose]") && helpText.includes("low-context update hunks"), "ssh help must document apply-patch-v1 loose-context guard", helpText); - assertCondition(helpText.includes("trans D601:k3s:hwlab-dev:hwlab-cloud-api script <<'SCRIPT'"), "ssh help must document k3s script operation", helpText); - assertCondition(helpText.includes("UNIDESK_SSH_HINT"), "ssh help must document structured failure hint", helpText); - assertCondition(helpText.includes("UNIDESK_SSH_RUNTIME_TIMEOUT") && helpText.includes("UNIDESK_TRAN_TIMEOUT_HINT") && helpText.includes("60s") && helpText.includes("submit-and-poll"), "ssh help must document top-level runtime timeout and short polling discipline", helpText); - assertCondition(helpText.includes("UNIDESK_SSH_TIMING") && helpText.includes("10s") && helpText.includes("slow successful calls are a distributed performance monitoring signal") && helpText.includes("Routine short calls do not emit timing noise"), "ssh help must document slow-only runtime timing hints", helpText); - assertCondition(helpText.includes("UNIDESK_APPLY_PATCH_TIMING") && helpText.includes("remoteOperationCounts") && helpText.includes("durationMs"), "ssh help must document apply-patch aggregate timing summary", helpText); - assertCondition(helpText.includes("must not add provider/plane directory locks") && helpText.includes("k8s/Tekton/Argo/Lease"), "ssh help must document tran's no-local-lock boundary", helpText); - - const crossChecks = providerTriageRecommendedCrossChecks("D601"); - assertCondition(crossChecks.includes("trans D601 argv true"), "provider triage cross-checks must keep argv true", crossChecks); - - const frontendRemoteK3sPlan = remoteSshFrontendPlanForTest("D601:k3s", ["kubectl", "get", "nodes", "-o", "name"]); - assertCondition(frontendRemoteK3sPlan.transport === "frontend-websocket", "remote frontend ssh must use the streaming websocket bridge", frontendRemoteK3sPlan); - assertCondition(frontendRemoteK3sPlan.providerId === "D601", "remote frontend ssh must dispatch route target to the provider id", frontendRemoteK3sPlan); - assertCondition(frontendRemoteK3sPlan.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'get' 'nodes' '-o' 'name'", "remote frontend ssh must preserve k3s route command construction", frontendRemoteK3sPlan); - assertCondition(!String(frontendRemoteK3sPlan.wrappedRemoteCommand ?? "").includes("UNIDESK_SSH_TOOL_DIR=/tmp/unidesk-ssh-tools"), "remote frontend ssh must not bootstrap helper tools for plain kubectl argv", frontendRemoteK3sPlan); - - const frontendRemoteHostPatchPlan = remoteSshFrontendPlanForTest("D601", ["apply-patch"]); - assertCondition(frontendRemoteHostPatchPlan.requiresStdin === true && frontendRemoteHostPatchPlan.remoteCommand === null && !String(frontendRemoteHostPatchPlan.wrappedRemoteCommand ?? "").includes("UNIDESK_SSH_TOOL_DIR"), "frontend apply-patch plan must stay a local v2 engine operation and not bootstrap legacy helpers", frontendRemoteHostPatchPlan); - const frontendRemoteHostPatchSeparatorPlan = remoteSshFrontendPlanForTest("D601", ["--", "apply-patch"]); - assertCondition(frontendRemoteHostPatchSeparatorPlan.requiresStdin === true && frontendRemoteHostPatchSeparatorPlan.remoteCommand === null, "frontend apply-patch plan should also accept route-level -- before apply-patch", frontendRemoteHostPatchSeparatorPlan); - - const frontendRemoteV1Plan = remoteSshFrontendPlanForTest("D601:/tmp", ["apply-patch-v1"]); - assertCondition(String(frontendRemoteV1Plan.wrappedRemoteCommand ?? "").includes("UNIDESK_SSH_TOOL_DIR=/tmp/unidesk-ssh-tools"), "frontend apply-patch-v1 must bootstrap the remote apply_patch helper", frontendRemoteV1Plan); - assertCondition(String(frontendRemoteV1Plan.wrappedRemoteCommand ?? "").includes("/apply_patch") && !String(frontendRemoteV1Plan.wrappedRemoteCommand ?? "").includes("/glob") && !String(frontendRemoteV1Plan.wrappedRemoteCommand ?? "").includes("/skill-discover"), "frontend apply-patch-v1 must not bootstrap unrelated helper tools", frontendRemoteV1Plan); - - const frontendRemotePodArgvPlan = remoteSshFrontendPlanForTest("G14:k3s:unidesk:code-queue", ["argv", "sh", "-c", "command -v tran"]); - assertCondition(frontendRemotePodArgvPlan.providerId === "G14", "remote frontend pod route must dispatch through G14 provider", frontendRemotePodArgvPlan); - assertCondition(frontendRemotePodArgvPlan.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-n' 'unidesk' 'deployment/code-queue' '--' 'sh' '-c' 'command -v tran'", "remote frontend pod argv route must be fully assembled before dispatch", frontendRemotePodArgvPlan); - - const frontendRemoteWorkspacePlan = remoteSshFrontendPlanForTest("D601:/home/ubuntu/workspace/hwlab-dev", ["git", "status", "--short"]); - assertCondition(frontendRemoteWorkspacePlan.payloadCwd === "/home/ubuntu/workspace/hwlab-dev", "remote frontend host workspace route must pass cwd to host.ssh payload", frontendRemoteWorkspacePlan); - assertCondition(frontendRemoteWorkspacePlan.remoteCommand === "'git' 'status' '--short'", "remote frontend host workspace route must keep command argv-quoted", frontendRemoteWorkspacePlan); - - const frontendRemoteWinPlan = remoteSshFrontendPlanForTest("D601:win/c/test", ["ps", "Get-Location"]); - assertCondition(frontendRemoteWinPlan.providerId === "D601" && frontendRemoteWinPlan.payloadCwd === "/mnt/c/Windows", "remote frontend win route must dispatch through provider host.ssh from a Windows-mounted cwd", frontendRemoteWinPlan); - const frontendRemoteWinScript = decodeWinEncodedCommand(String(frontendRemoteWinPlan.remoteCommand)); - assertCondition(frontendRemoteWinScript.includes("powershell.exe") && frontendRemoteWinScript.includes("Set-Location -LiteralPath ''C:\\test''") && frontendRemoteWinScript.includes("Get-Location"), "remote frontend win route must assemble Windows PowerShell cwd internally", { frontendRemoteWinPlan, frontendRemoteWinScript }); - - const tranScript = readFileSync(new URL("./tran", import.meta.url), "utf8"); - assertCondition(tranScript.includes("CODE_QUEUE_DEV_CONTAINER_MASTER_HOST") && tranScript.includes("--main-server-ip"), "tran wrapper must auto-select frontend transport inside Code Queue runner pods", tranScript); - assertCondition(tranScript.includes("UNIDESK_TRAN_LOCAL"), "tran wrapper must keep an explicit local override for diagnostics", tranScript); - assertCondition(!tranScript.includes("tran_lock_scope") && !tranScript.includes("UNIDESK_TRAN_LOCK_DIR") && !tranScript.includes("mkdir \"$lock_path\""), "tran wrapper must not add local provider/plane directory locks", tranScript); - - const remoteSource = readFileSync(new URL("./src/remote.ts", import.meta.url), "utf8"); - assertCondition(remoteSource.includes("UNIDESK_REMOTE_HTTP_CLIENT") && remoteSource.includes("isCodeQueueRunnerEnv(env) ? \"curl\" : \"fetch\""), "remote frontend transport must default to curl HTTP in Code Queue runner environments", remoteSource); - assertCondition(remoteSource.includes("frontendSshWebSocketUrl") && remoteSource.includes("runRemoteSshWebSocket"), "remote frontend ssh must go through the streaming websocket implementation", remoteSource); - assertCondition(remoteSource.includes("UNIDESK_SSH_CLIENT_TOKEN") && remoteSource.includes("authorization: `Bearer ${session.sshClientToken}`"), "remote frontend ssh must support scoped bearer-token clients without frontend admin login", remoteSource); - assertCondition(!remoteSource.includes("remote frontend transport does not stream stdin"), "remote frontend ssh must not reject stdin-backed helpers", remoteSource); - assertCondition(!remoteSource.includes("source: \"cli-remote-ssh\""), "remote frontend ssh must not use host.ssh dispatch task polling", remoteSource); - - const sshSource = readFileSync(new URL("./src/ssh.ts", import.meta.url), "utf8"); - const sshFileTransferSource = readFileSync(new URL("./src/ssh-file-transfer.ts", import.meta.url), "utf8"); - assertCondition(sshFileTransferSource.includes("runSshFileTransferOperation") && sshFileTransferSource.includes("write-b64-commit"), "file transfer operation implementation must live in the dedicated ssh-file-transfer module", {}); - assertCondition(sshFileTransferSource.includes("buildTransferVerification") && sshFileTransferSource.includes("automatic: true") && sshFileTransferSource.includes("match"), "file transfer JSON must expose automatic endpoint verification instead of relying on manual sha256sum checks", sshFileTransferSource); - assertCondition(!sshSource.includes("type SshFileTransferOperation") && !sshSource.includes("posixFileTransferScript"), "ssh.ts must not accumulate the full upload/download implementation", {}); - - const frontendSource = readFileSync(new URL("../src/components/frontend/src/index.ts", import.meta.url), "utf8"); - assertCondition(frontendSource.includes('url.pathname === "/ws/ssh"') && frontendSource.includes("proxySshWebSocket"), "frontend must expose an authenticated /ws/ssh proxy", frontendSource); - assertCondition(frontendSource.includes("coreSshWebSocketUrl") && frontendSource.includes('url.searchParams.set("token"'), "frontend /ws/ssh proxy must connect to backend-core ssh bridge with the provider token", frontendSource); - assertCondition(frontendSource.includes("PROVIDER_TOKEN_FILE") && frontendSource.includes("/run/secrets/unidesk_provider_token"), "frontend ssh proxy must support file-based provider token injection for runtime hotfix and secret mounts", frontendSource); - assertCondition(frontendSource.includes("UNIDESK_SSH_CLIENT_TOKEN") && frontendSource.includes("UNIDESK_SSH_CLIENT_ROUTE_ALLOWLIST"), "frontend ssh proxy must support scoped client-token configuration", frontendSource); - assertCondition(frontendSource.includes("route-not-allowed") && frontendSource.includes("sshRouteAllowed") && frontendSource.includes("ssh.open"), "frontend ssh proxy must reject disallowed scoped-client routes before opening a provider session", frontendSource); - - const composeSource = readFileSync(new URL("../docker-compose.yml", import.meta.url), "utf8"); - assertCondition(composeSource.includes('PROVIDER_TOKEN: "${UNIDESK_PROVIDER_TOKEN}"'), "frontend compose service must receive provider token for the ssh proxy", composeSource); - assertCondition(composeSource.includes('UNIDESK_SSH_CLIENT_TOKEN: "${UNIDESK_SSH_CLIENT_TOKEN:-}"') && composeSource.includes("UNIDESK_SSH_CLIENT_ROUTE_ALLOWLIST"), "frontend compose service must receive scoped ssh client token and route allowlist", composeSource); - const dockerCliSource = readFileSync(new URL("./src/docker.ts", import.meta.url), "utf8"); - assertCondition(dockerCliSource.includes('UNIDESK_SSH_CLIENT_TOKEN: runtimeSecret("UNIDESK_SSH_CLIENT_TOKEN")') && dockerCliSource.includes('UNIDESK_SSH_CLIENT_ROUTE_ALLOWLIST: runtimeSecret("UNIDESK_SSH_CLIENT_ROUTE_ALLOWLIST")'), "server rebuild must preserve scoped ssh client runtime env instead of dropping it from docker-compose.env", dockerCliSource); - - const devCoreManifest = readFileSync(new URL("../src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-core.k8s.yaml", import.meta.url), "utf8"); - assertCondition(devCoreManifest.includes("name: frontend-dev") && devCoreManifest.includes("name: PROVIDER_TOKEN"), "dev frontend manifest must receive provider token for the ssh proxy", devCoreManifest); - - const codeQueueDockerfile = readFileSync(new URL("../src/components/microservices/code-queue/Dockerfile", import.meta.url), "utf8"); - assertCondition(codeQueueDockerfile.includes("COPY scripts/tran /usr/local/bin/tran") && codeQueueDockerfile.includes("chmod 755 /usr/local/bin/tran"), "Code Queue runner image must install tran on PATH", codeQueueDockerfile); - - return { - ok: true, - checks: [ - "argv form is classified and quoted as the success path for non-interactive commands", - "stdin script form removes shell-command strings for host and k3s workload scripts", - "script -- single-string runs as a remote shell one-liner while multi-token form keeps dash-prefixed argv", - "script/shell helpers inject a portable printf prelude for common section headings", - "pod apply-patch operation uses the v2 local engine and apply-patch-v1 injects the legacy helper", - "pod exec --stdin streams arbitrary local stdin through workload routes without shell wrapping", - "upload/download file transfer operations use a dedicated module with automatic endpoint byte-count and sha256 verification JSON", - "apply-patch-v1 uses one sh helper for host and pod paths and rejects low-context hunks unless --allow-loose is explicit", - "legacy operation-in-route forms are rejected in any k3s route segment with canonical route-plus-operation guidance", - "post-provider k3s shorthand is rejected so location and operation stay separated", - "k3s route stays location-only while operations fix native kubeconfig and assemble kubectl exec as argv", - "win route supports Windows PowerShell ps heredoc with slash cwd syntax such as D601:win/c/test", - "win skills discovers the current Windows user's skill roots without hand-written cmd dir or PowerShell", - "top-level remote option parsing preserves command-local -- separators for script -- sed -n style commands", - "ssh-like timeout/kex failures emit one structured argv retry hint", - "ssh runtime emits structured timing for slow operations over 10 seconds, including successful slow calls", - "help text documents stdin script passthrough and UNIDESK_SSH_HINT", - "provider triage recommendedCrossChecks keeps trans D601 argv true", - "remote frontend ssh uses the same structured route parser for host, k3s and pod argv routes", - "ssh helper bootstrap is lazy so plain argv/script commands do not transfer helper sources", - "host apply-patch-v1 bootstraps only the apply_patch helper and uses a Perl fast path for large files", - "remote frontend ssh uses authenticated /ws/ssh streaming instead of host.ssh dispatch task polling", - "Code Queue runner image installs the tran wrapper and runner tran auto-selects remote frontend transport", - "tran does not add local provider/plane directory locks and leaves coordination to k8s/Tekton/Argo/Lease", - "Code Queue runner remote frontend HTTP uses curl by default for non-ssh API calls to avoid Bun response-body native crashes", - ], - }; -} - -if (import.meta.main) { - const result = await runSshArgvGuidanceContract(); - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); -} diff --git a/scripts/ssh-capture-bridge-contract-test.ts b/scripts/ssh-capture-bridge-contract-test.ts deleted file mode 100644 index c8a98ede..00000000 --- a/scripts/ssh-capture-bridge-contract-test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { sshCaptureBackendPlan } from "./src/ssh"; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -const baseConfig = { - network: { publicHost: "74.48.78.17" }, -} as Parameters[0]; - -const runnerPlan = sshCaptureBackendPlan(baseConfig, { - KUBERNETES_SERVICE_HOST: "10.43.0.1", -}); - -assertCondition( - runnerPlan.backend === "remote-frontend-websocket" && runnerPlan.remoteHost === "74.48.78.17" && runnerPlan.reason === "runner-environment", - "ssh capture should use frontend websocket in runner environments", - runnerPlan, -); - -const explicitHostPlan = sshCaptureBackendPlan(baseConfig, { - UNIDESK_MAIN_SERVER_IP: "http://74.48.78.17:18081/", - KUBERNETES_SERVICE_HOST: "10.43.0.1", -}); - -assertCondition( - explicitHostPlan.backend === "remote-frontend-websocket" && explicitHostPlan.remoteHost === "http://74.48.78.17:18081", - "ssh capture should prefer explicit main server host hints and trim trailing slash", - explicitHostPlan, -); - -const localOnlyPlan = sshCaptureBackendPlan({ network: { publicHost: "127.0.0.1" } } as Parameters[0], {}); - -assertCondition( - localOnlyPlan.backend === "local-backend-core-broker" && localOnlyPlan.remoteHost === null, - "ssh capture should keep local backend-core broker mode when no remote host is configured", - localOnlyPlan, -); - -console.log(JSON.stringify({ - ok: true, - checks: [ - "runner environments use remote frontend websocket capture", - "explicit main server host hints are preferred and normalized", - "local-only environments keep local backend-core broker mode", - ], -})); diff --git a/scripts/ssh-data-tcp-pool-contract-test.ts b/scripts/ssh-data-tcp-pool-contract-test.ts deleted file mode 100644 index 20e3f742..00000000 --- a/scripts/ssh-data-tcp-pool-contract-test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { readFileSync } from "node:fs"; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function compareSemver(left: unknown, right: string): number { - if (typeof left !== "string") return -1; - const a = left.split(".").map((part) => Number.parseInt(part, 10)); - const b = right.split(".").map((part) => Number.parseInt(part, 10)); - for (let i = 0; i < Math.max(a.length, b.length); i += 1) { - const delta = (Number.isFinite(a[i]) ? a[i] : 0) - (Number.isFinite(b[i]) ? b[i] : 0); - if (delta !== 0) return delta; - } - return 0; -} - -const rustBridge = readFileSync("src/components/backend-core/src/ssh_bridge.rs", "utf8"); -const rustData = readFileSync("src/components/backend-core/src/ssh_data_channel.rs", "utf8"); -const rustMain = readFileSync("src/components/backend-core/src/main.rs", "utf8"); -const rustState = readFileSync("src/components/backend-core/src/state.rs", "utf8"); -const rustProviderRegistry = readFileSync("src/components/backend-core/src/provider_registry.rs", "utf8"); -const provider = readFileSync("src/components/provider-gateway/src/index.ts", "utf8"); -const providerRegistryTs = readFileSync("src/components/backend-core/src/provider-registry.ts", "utf8"); -const providerPackage = JSON.parse(readFileSync("src/components/provider-gateway/package.json", "utf8")) as { version?: unknown }; -const shared = readFileSync("src/components/shared/src/index.ts", "utf8"); -const compose = readFileSync("docker-compose.yml", "utf8"); -const config = readFileSync("config.json", "utf8"); -const backendCoreDockerfile = readFileSync("src/components/backend-core/Dockerfile", "utf8"); - -assertCondition(rustBridge.includes('"host.ssh.tcp-pool"'), "backend-core ssh bridge must require host.ssh.tcp-pool"); -assertCondition(rustBridge.includes("provider-gateway-upgrade-required"), "old provider must fail with upgrade-required classification"); -assertCondition(rustBridge.includes("provider-data-pool-exhausted"), "tcp pool exhaustion must be visible"); -assertCondition(rustBridge.includes("remove_ssh_data_channel") && rustBridge.includes("ssh_data_channel_removed_after_control_error"), "control-fallback host_ssh_error must remove the claimed data channel"); -assertCondition(rustBridge.includes("provider-data-channel-missing"), "missing data channel must be classified for clients"); -assertCondition(!rustBridge.includes('"host_ssh_input"'), "ssh input must not fall back to provider control websocket"); -assertCondition(!rustBridge.includes('"host_ssh_eof"'), "ssh eof must not fall back to provider control websocket"); -assertCondition(!rustBridge.includes('"host_ssh_close"'), "ssh close must not fall back to provider control websocket"); -assertCondition(rustBridge.includes('json!({ "type": "input"') && rustBridge.includes('json!({ "type": "eof"'), "ssh bridge must send stdin/eof over data frames"); -assertCondition(!rustProviderRegistry.includes('"host_ssh_opened"') && !rustProviderRegistry.includes('"host_ssh_data"') && !rustProviderRegistry.includes('"host_ssh_exit"'), "rust provider registry must not accept old websocket ssh data messages"); - -assertCondition(rustData.includes("TcpListener") && rustData.includes("read_data_frame") && rustData.includes("write_data_frame"), "backend-core must expose raw TCP data frame listener"); -assertCondition(rustData.includes("pub async fn remove_ssh_data_channel") && rustData.includes("channels.remove(&key)") && rustData.includes(".max_by_key(|channel| channel.connected_at_millis)"), "backend-core must purge stale/dead data channels and prefer the freshest idle channel"); -assertCondition(rustData.includes("SSH_DATA_PROTOCOL") && rustData.includes("unidesk-host-ssh-tcp-pool-v1"), "tcp data protocol must be explicit"); -assertCondition(rustData.includes("base64::engine::general_purpose::STANDARD.encode(payload)"), "only client-facing websocket payload should remain base64 encoded"); -assertCondition(rustMain.includes("providerDataTcpUrl") && rustMain.includes("spawn_ssh_data_listener"), "backend-core startup must expose provider data listener visibility"); -assertCondition(rustState.includes("active_ssh_data_channels") && rustState.includes("data_channel_id"), "backend state must track data channels separately from provider control socket"); - -assertCondition(provider.includes("createConnection") && provider.includes("sshDataChannels"), "provider-gateway must use direct TCP sockets for ssh data pool"); -assertCondition(provider.includes("PROVIDER_DATA_POOL_SIZE") && provider.includes("providerGatewaySshDataPoolReady"), "provider-gateway must expose pool config and heartbeat labels"); -assertCondition(provider.includes('capabilities.push("host.ssh", "host.ssh.tcp-pool")'), "provider-gateway must declare tcp-pool capability only with host ssh"); -assertCondition(provider.includes("acquireSshDataChannel") && provider.includes("releaseSshDataChannel"), "provider-gateway must claim/release one data channel per ssh session"); -assertCondition(provider.includes("writeSshDataFrame(dataChannel") && provider.includes("sendSshDataSessionFrame"), "provider-gateway ssh output must use tcp data frames"); -assertCondition(!provider.includes('parsed.type === "host_ssh_input"') && !provider.includes('parsed.type === "host_ssh_close"'), "provider-gateway must not retain old websocket ssh input handlers"); -assertCondition(compareSemver(providerPackage.version, "0.2.29") >= 0, "provider-gateway tcp-pool contract must not regress below the deployed tcp-pool version", providerPackage); - -assertCondition(shared.includes('transport: "tcp-pool"') && shared.includes("dataChannelId: string"), "shared host_ssh_open contract must require tcp-pool fields"); -assertCondition(!shared.includes("CoreHostSshInputMessage") && !shared.includes("ProviderHostSshDataMessage"), "shared protocol must remove old websocket ssh data contracts"); -assertCondition(!providerRegistryTs.includes("host_ssh_data") && !providerRegistryTs.includes("host_ssh_exit"), "typescript backend registry must not forward old websocket ssh data messages"); -assertCondition(compose.includes("UNIDESK_PROVIDER_DATA_PORT") && compose.includes("PROVIDER_DATA_POOL_SIZE"), "compose must wire provider data port and pool size"); -assertCondition(config.includes('"providerData"') && config.includes('"port": 18084') && config.includes('"containerPort": 8082'), "config.json must declare providerData port pair"); -assertCondition( - backendCoreDockerfile.includes("ARG CARGO_BUILD_JOBS=1") - && backendCoreDockerfile.includes('ENV CARGO_BUILD_JOBS=${CARGO_BUILD_JOBS}') - && backendCoreDockerfile.includes('cargo build --release --locked --jobs "${CARGO_BUILD_JOBS}"'), - "backend-core main-server online build must keep cargo concurrency constrained", -); -assertCondition(compose.includes("UNIDESK_BACKEND_CORE_CARGO_BUILD_JOBS") && compose.includes("CARGO_BUILD_JOBS"), "compose must pass backend-core cargo build concurrency explicitly"); - -console.log(JSON.stringify({ ok: true, test: "ssh-data-tcp-pool-contract" })); diff --git a/scripts/todo-note-artifact-runtime-proof-contract-test.ts b/scripts/todo-note-artifact-runtime-proof-contract-test.ts deleted file mode 100644 index a7a0f708..00000000 --- a/scripts/todo-note-artifact-runtime-proof-contract-test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { readFileSync } from "node:fs"; -import { spawnSync } from "node:child_process"; -import { rootPath } from "./src/config"; -import { runArtifactRegistryCommand } from "./src/artifact-registry"; - -type JsonRecord = Record; - -const serviceId = "todo-note"; -const commit = "a14ce0eb855a685fa17b47adacd54623e72cd2ff"; -const sourceRepo = "https://gitee.com/Lyon1998/todo_note"; - -function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { - if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); -} - -function asRecord(value: unknown, label: string): JsonRecord { - assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, value); - return value as JsonRecord; -} - -function asArray(value: unknown, label: string): unknown[] { - assertCondition(Array.isArray(value), `${label} must be an array`, value); - return value as unknown[]; -} - -function runDeployApplyDryRun(): JsonRecord { - const result = spawnSync("bun", ["scripts/cli.ts", "deploy", "apply", "--env", "prod", "--service", serviceId, "--dry-run"], { - cwd: process.cwd(), - encoding: "utf8", - maxBuffer: 8 * 1024 * 1024, - }); - assertCondition(result.status === 0, "deploy apply dry-run should exit 0", { - status: result.status, - stdout: result.stdout.slice(-2000), - stderr: result.stderr.slice(-2000), - }); - const envelope = asRecord(JSON.parse(result.stdout) as unknown, "deploy apply envelope"); - assertCondition(envelope.ok === true, "deploy apply dry-run envelope should be ok", envelope); - const data = asRecord(envelope.data, "deploy apply data"); - const results = asArray(data.results, "deploy apply results"); - assertCondition(results.length === 1, "deploy apply dry-run should include one result", data); - return asRecord(results[0], "todo-note deploy apply result"); -} - -function assertTodoNoteComposeYaml(): void { - const compose = readFileSync(rootPath("docker-compose.yml"), "utf8"); - const todoNoteSection = compose.slice(compose.indexOf(" todo-note:"), compose.indexOf(" oa-event-flow:")); - assertCondition(todoNoteSection.includes("image: todo-note"), "todo-note Compose service must name a stable image", todoNoteSection); - for (const expected of [ - "unidesk.ai/deploy-service-id", - "unidesk.ai/deploy-ref", - "unidesk.ai/deploy-repo", - "unidesk.ai/deploy-commit", - "unidesk.ai/deploy-requested-commit", - "UNIDESK_DEPLOY_COMMIT", - "UNIDESK_DEPLOY_REQUESTED_COMMIT", - ]) { - assertCondition(todoNoteSection.includes(expected), `todo-note Compose section must include ${expected}`, todoNoteSection); - } -} - -function assertPlan(plan: JsonRecord, label: string): void { - const target = asRecord(plan.target, `${label} target`); - const source = asRecord(plan.source, `${label} source`); - const build = asRecord(plan.build, `${label} build`); - const labels = asRecord(plan.requiredLabels, `${label} requiredLabels`); - const validation = asArray(plan.validation, `${label} validation`).map(String); - const runtimeProof = asRecord(plan.runtimeProof, `${label} runtimeProof`); - const sources = asArray(runtimeProof.sources, `${label} runtimeProof.sources`).map(String); - const requiredEnvKeys = asArray(runtimeProof.requiredEnvKeys, `${label} runtimeProof.requiredEnvKeys`).map(String); - - assertCondition(plan.ok === true && plan.supported === true, `${label} plan must be supported`, plan); - assertCondition(plan.dryRun === true && plan.mutation === false, `${label} plan must be non-mutating`, plan); - assertCondition(plan.environment === "prod", `${label} environment mismatch`, plan); - assertCondition(plan.serviceId === serviceId, `${label} service id mismatch`, plan); - assertCondition(plan.commit === commit, `${label} commit mismatch`, plan); - assertCondition(plan.sourceRepo === sourceRepo, `${label} source repo mismatch`, plan); - assertCondition(source.repo === sourceRepo && source.commit === commit && source.dockerfile === "Dockerfile", `${label} source mismatch`, source); - assertCondition(build.willCompile === false, `${label} dry-run must not compile`, build); - assertCondition(build.willRunDockerBuild === false, `${label} dry-run must not build Docker images`, build); - assertCondition(build.willRunDockerComposeBuild === false, `${label} dry-run must not run docker compose build`, build); - assertCondition(target.kind === "compose", `${label} target kind mismatch`, target); - assertCondition(target.runtimeHost === "main-server", `${label} runtime host mismatch`, target); - assertCondition(target.composeService === serviceId, `${label} compose service mismatch`, target); - assertCondition(target.containerName === "todo-note-backend", `${label} container mismatch`, target); - assertCondition(target.targetImage === "todo-note", `${label} target image mismatch`, target); - assertCondition(target.runtimeImage === `todo-note:${commit}`, `${label} runtime image mismatch`, target); - assertCondition(target.deployCommandShape === "docker compose up -d --no-build --no-deps --force-recreate todo-note", `${label} command shape mismatch`, target); - assertCondition(labels["unidesk.ai/service-id"] === serviceId, `${label} service label mismatch`, labels); - assertCondition(labels["unidesk.ai/source-repo"] === sourceRepo, `${label} source repo label mismatch`, labels); - assertCondition(labels["unidesk.ai/source-commit"] === commit, `${label} source commit label mismatch`, labels); - assertCondition(labels["unidesk.ai/dockerfile"] === "Dockerfile", `${label} dockerfile label mismatch`, labels); - assertCondition(runtimeProof.kind === "compose-container-runtime-metadata", `${label} runtime proof kind mismatch`, runtimeProof); - assertCondition(runtimeProof.sourceDirectoryUsed === false, `${label} runtime proof must not use source directory guesses`, runtimeProof); - for (const expected of ["service-health", "container-env", "container-labels", "image-labels"]) { - assertCondition(sources.includes(expected), `${label} runtime proof sources should include ${expected}`, runtimeProof); - } - for (const expected of [ - "UNIDESK_DEPLOY_COMMIT", - "UNIDESK_DEPLOY_REQUESTED_COMMIT", - "UNIDESK_TODO_NOTE_DEPLOY_COMMIT", - "UNIDESK_TODO_NOTE_DEPLOY_REQUESTED_COMMIT", - ]) { - assertCondition(requiredEnvKeys.includes(expected), `${label} runtime proof env keys should include ${expected}`, runtimeProof); - } - assertCondition(validation.some((line) => line.includes("Compose container runtime metadata") && line.includes("not source directory guesses")), `${label} validation must name runtime metadata proof`, validation); - assertCondition(!JSON.stringify(plan).includes("/root/todo_note"), `${label} dry-run must not rely on the todo-note source directory`, plan); - assertCondition(!JSON.stringify(plan).includes("docker compose build"), `${label} dry-run must not mention docker compose build`, plan); -} - -async function main(): Promise { - assertTodoNoteComposeYaml(); - assertPlan(runDeployApplyDryRun(), "deploy apply"); - const artifactPlan = asRecord(await runArtifactRegistryCommand([ - "deploy-service", - "--env", - "prod", - "--service", - serviceId, - "--commit", - commit, - "--dry-run", - ]), "artifact-registry dry-run"); - assertPlan(artifactPlan, "artifact-registry"); - - process.stdout.write(`${JSON.stringify({ - ok: true, - checks: [ - "todo-note Compose service names the stable artifact image and deploy labels", - "deploy apply prod dry-run is a no-build/no-deps main-server Compose artifact consumer", - "artifact-registry prod dry-run requires source repo/source commit/Dockerfile image labels", - "runtime proof uses container env, container labels, image labels and health output", - "runtime proof does not infer commit from /root/todo_note or any source directory", - ], - }, null, 2)}\n`); -} - -if (import.meta.main) { - await main(); -}