diff --git a/AGENTS.md b/AGENTS.md index 606f086d..6a8a5486 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `bun scripts/cli.ts dev-env validate [--manifest path] [--kubectl-dry-run]` / `dev-env prewarm-images`:离线校验 D601 `unidesk-dev` 生产隔离护栏和 dev workload manifests,或把开发底座基础镜像预热到 D601 原生 k3s containerd,规则见 `docs/reference/deploy.md` 与 `docs/reference/microservices.md`。 - `bun scripts/cli.ts artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service`:管理 D601 host-managed CNCF Distribution registry,并通过短生命周期 relay 或 D601 pull/import 做 commit-pinned pull-only artifact CD;`deploy-backend-core` 是 deprecated 兼容名,`findjob`/`pipeline` 支持 D601 direct dev/prod,`met-nonlinear` 和 `k3sctl-adapter` 只给受限计划路径,`code-queue` 只支持 dev,规则见 `docs/reference/artifact-registry.md`。 - `bun scripts/cli.ts gh auth status|issue ...|pr list|view|create|comment` / `bun scripts/code-queue-pr-preflight-example.ts`:通过 REST 执行安全 GitHub issue 读写、脱敏 auth/status 诊断、body-file Markdown 写入、当日滚动简报时间线 ClaudeQQ 通知、escape 扫描、只读 cleanup-plan 和 #20 board-audit、PR 创建/评论 dry-run 与 runner PR preflight;`gh pr merge` 当前仍结构化拒绝,规则见 `docs/reference/cli.md` 和 `docs/reference/code-queue-supervision.md`。 -- `bun scripts/cli.ts commander contract|plan --dry-run|approval request --dry-run`:查看 host Codex 指挥官直管微服务第一阶段 source/contract、bridge/state/trace/审批边界和 ClaudeQQ 高风险请示草案;当前只返回 dry-run 计划,不启动守护进程、不打开 SSH/PTY/stdio、不发送消息,规则见 `docs/reference/host-codex-commander.md`。 +- `bun scripts/cli.ts commander contract|plan --dry-run|approval request --dry-run`:查看 host Codex 指挥官直管微服务 skeleton 的 source/contract、.state/commander/ 状态模型、trace summary 聚合和 ClaudeQQ 高风险请示草案;当前只返回 dry-run 计划,不接 live bridge、不接管人工指挥官,不发送消息,规则见 `docs/reference/host-codex-commander.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 submit [prompt] [--prompt-file path|--prompt-stdin] [--queue ]` / `codex pr-preflight [--remote]`:前者通过 backend-core 私有代理提交 Code Queue 任务,`--dry-run` 会给出 MiniMax/GPT/人工路由建议但不改写 payload;后者只读检查 D601 scheduler/runner 的 GitHub token、egress 和 PR 能力,PR 型派单前必须使用,规则见 `docs/reference/cli.md` 和 `docs/reference/code-queue-supervision.md`。 @@ -87,7 +87,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `docs/reference/cicd-standardization.md`:`CI.json` catalog、CI producer summary、blocked/upstream-image 服务、File Browser 上游镜像例外、legacy CI/CD 路径分类和 CD consumer 分工。 - `docs/reference/release-governance.md`:`release/v1` 稳定维护线、`master` 集成线、CI/CD server 版本固定、master CLI 兼容和 feature flag 治理规则;决策记录见 GitHub issue #6。 - `docs/reference/artifact-registry.md`:D601 host-managed CNCF Distribution registry、loopback-only 边界和 backend-core artifact CD 目标流程。 -- `docs/reference/host-codex-commander.md`:host Codex 指挥官直管微服务第一阶段 source/contract、CLI dry-run、状态模型、SSH/PTY/stdio bridge、#20/#46 入口和 ClaudeQQ 高风险审批边界。 +- `docs/reference/host-codex-commander.md`:host Codex 指挥官 skeleton 的 source/contract、CLI dry-run、状态模型、SSH/PTY/stdio bridge 预留边界、#20/#46 入口和 ClaudeQQ 高风险审批边界。 - `docs/reference/user-service-delivery.md`:用户服务默认交付流程、CI 镜像构建与 registry、Baidu Netdisk 主 server 直管微服务样板、dev 自动测试、prod 拉镜像部署和 Decision Center 产品化需求管理规则。 - `docs/reference/dev-environment.md`:D601 `unidesk-dev` persistent dev 环境、18083 dev frontend proxy、`deploy apply --env dev` 服务范围和 Rust backend-core 只在 D601 编译的边界。 - `docs/reference/ci.md`:D601 k3s Tekton CI、只读主数据库性能门禁和 CLI 入口规则。 diff --git a/TEST.md b/TEST.md index 29107932..14d1abe1 100644 --- a/TEST.md +++ b/TEST.md @@ -143,6 +143,6 @@ 阅读 `AGENTS.md` 和 `docs/reference/cli.md`,然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts gh help`,确认 help 中包含 `gh issue create --title --body-file <file> [--label label[,label...]]...`、`gh issue scan-escape`、`gh issue cleanup-plan`、`gh issue board-row list` 和 `gh issue board-row update`,notes 中明确推荐 `--body-file`、quoted heredoc、只读 cleanup-plan、board-row update 默认 dry-run 和 `--expect-body-sha`/`--expect-updated-at` 并发保护。运行 `bun scripts/gh-cli-issue-guard-contract-test.ts`,确认 mock GitHub 覆盖污染命中、说明性 `\n` 命中不误报、短 body/null body guard、body-file dry-run 写入路径、`issue create --label cli,infra --label ops --dry-run` labels 解析和 request plan、真实 create REST payload labels、missing label 的结构化 `validation-failed`、comment-id/body-id 定位和 cleanupSuggestions、board-row list/get 复用 #20 表格解析、board-row update 给出 old/new row、body SHA、guard 结果、表格管道转义、默认 dry-run 不写入、带 `--expect-body-sha` 时只对 mock server PATCH、以及 board-row move 结构化 unsupported。对真实仓库只允许运行 `bun scripts/cli.ts gh issue scan-escape --repo pikasTech/unidesk --limit <N> --dry-run`、`bun scripts/cli.ts gh issue cleanup-plan --repo pikasTech/unidesk --limit <N>`、`bun scripts/cli.ts gh issue board-row list --repo pikasTech/unidesk --board-issue 20 --state open --dry-run` 或 `bun scripts/cli.ts gh issue board-row get <issueNumber> --repo pikasTech/unidesk --board-issue 20` 这类只读命令;不得运行真实历史评论清理、不得真实改写 #20/#24 正文,除非另有明确人工指令并先审阅 dry-run 输出和 body SHA。 -## T28 Host Codex Commander Contract +## T28 Host Codex Commander Skeleton Contract 阅读 `AGENTS.md` 和 `docs/reference/host-codex-commander.md`,然后用 cli 手动测试以下内容:运行 `bun scripts/host-codex-commander-contract-test.ts`,确认输出 `ok=true`;运行 `bun scripts/cli.ts commander contract`,确认返回 `phase=source-contract`、`serviceId=host-codex-commander`、`daemonImplemented=false`、`liveOperationsImplemented=false`,且 required capabilities 包含 host Codex 进程发现/启动计划、SSH/PTY/stdio bridge、prompt guidance、trace summary、#20/#46 入口和 ClaudeQQ 高风险审批入口;运行 `bun scripts/cli.ts commander plan --dry-run --session-id primary`,确认所有 top-level plan 均为 `mutation=false`,start plan `enabled=false`,不会打开 SSH/PTY/stdio、不会注入 prompt、不会发送 ClaudeQQ;运行 `bun scripts/cli.ts commander plan`,确认非 dry-run 返回非零状态和 `error=dry-run-required`;运行 `bun scripts/cli.ts commander approval request --action code-queue-task-interrupt --task-id <taskId> --reason '<reason>' --dry-run`,确认只生成 ClaudeQQ 审批草案且 `claudeqq.mutation=false`、`sendImplemented=false`;运行 `bun scripts/cli.ts commander approval request --action read-token-file --dry-run`,确认返回 `validation-failed`。本测试不得部署、不得重启 Code Queue backend、不得 cancel/interrupt 运行任务、不得读取或输出 token 明文。 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index a777c6ff..68e73d13 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -29,7 +29,7 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI - `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` 时额外执行 `kubectl apply --dry-run=client --validate=false -f <manifest>`,仍不 apply 资源。 - `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|approval request --dry-run` 是 host Codex 指挥官直管微服务第一阶段 contract 入口。当前只返回 `phase=source-contract`、service/API/state/bridge/prompt/trace/#20/#46/ClaudeQQ 审批边界和 dry-run 计划,不启动 daemon、不打开 SSH/PTY/stdio、不注入 prompt、不发送 ClaudeQQ。`plan` 与 `approval request` 必须带 `--dry-run`;缺少时返回 `error=dry-run-required`。长期规则见 `docs/reference/host-codex-commander.md`。 +- `commander contract|plan --dry-run|approval request --dry-run` 是 host Codex 指挥官直管微服务 skeleton 入口。当前命令返回 `phase=source-contract`、service/API/state/bridge/prompt/trace/#20/#46/ClaudeQQ 审批边界、.state/commander/ 状态模型和 dry-run 计划,服务骨架只提供本地 `/health`、`/api/commander/contract`、状态读写、trace summary 聚合和 approval draft preview,不接 live bridge、不注入 prompt、不发送 ClaudeQQ。`plan` 与 `approval request` 必须带 `--dry-run`;缺少时返回 `error=dry-run-required`。长期规则见 `docs/reference/host-codex-commander.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`、`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 应优先用该字段分流。 - `gh issue list [--state open|closed|all] [--limit N] [--repo owner/name] [--json number,title,state,url,updatedAt,createdAt,author,labels]` 通过 GitHub REST 列出 issue,默认 `state=open`、`limit=30`,输出稳定 JSON 且不依赖系统 `gh` binary。`--limit` 会映射到 GitHub `per_page` 并限制返回数量,避免一次拉爆上下文;未知 state 或未知 `--json` 字段必须结构化失败并带 `runnerDisposition=business-failed`。GitHub issues API 可能混入 PR,CLI 会从 `.data.issues` 中过滤 pull request。 - `gh issue read <number> [--repo owner/name] [--json body,title,state,comments]` 通过 GitHub REST 读取 issue title/body/state/url 和 comments,默认输出 JSON;`view` 只保留为兼容别名。兼容旧脚本的 `--json body` 和 `--json body,title,state,comments` 字段选择,且正文仍稳定暴露在 `.data.issue.body`,避免调用方因为 JSON 路径变化把空值当成正文。字段白名单是 `body,title,state,comments,number,url,author,createdAt,updatedAt`,未知字段必须结构化失败并带 `runnerDisposition=business-failed`。`gh issue create --title <title> --body-file <file> [--label label[,label...]]... [--dry-run]`、`gh issue update <number> --mode replace|append --body-file <file> [--title ...] [--dry-run]`、`gh issue comment create <number> --body-file <file> [--dry-run]`、`gh issue comment delete <commentId> [--dry-run]`、`gh issue close|reopen <number> [--dry-run]` 都走 REST,不依赖 `gh` binary。`--label` 仅用于 `issue create`,支持重复传入和逗号分隔;`--dry-run` 会展示解析后的 labels 与 request plan,正式创建时把 labels 放入 GitHub REST create-issue payload,GitHub 返回不存在 label 等 422 校验失败时 CLI 结构化返回 `validation-failed`,不静默成功。`gh issue delete <number>` 是结构化 `unsupported-command`,因为 GitHub REST 不支持 issue 硬删除;生命周期删除语义请使用 `close`。 diff --git a/docs/reference/host-codex-commander.md b/docs/reference/host-codex-commander.md index 971aa98f..837ff129 100644 --- a/docs/reference/host-codex-commander.md +++ b/docs/reference/host-codex-commander.md @@ -1,42 +1,15 @@ -# Host Codex Commander Contract +# Host Codex Commander Skeleton -本文定义第一阶段的 host Codex 指挥官正规化设计。当前阶段只建立 source/contract、CLI dry-run stub、状态模型和安全边界;不部署、不重启、不上线 production,也不实现会实际执行后台动作的守护进程。 +本文定义 host Codex 指挥官的本地 skeleton 阶段。当前只提供 `/health`、`/api/commander/contract`、`.state/commander/` 状态读写、trace summary dry-run 聚合和 approval draft preview;不接 live bridge,不发送 ClaudeQQ,不接管人工指挥官。 -## 目标边界 +## 边界 -host Codex 指挥官是独立用户服务/基础设施:在 master server host 上保留一个常驻 Codex 指挥会话,由未来的直管微服务负责保活、prompt 注入、trace 采集和高风险动作请示。它不替代 Code Queue runner,也不让 Code Queue 自己上线自己。 +- `host-codex-commander` 是独立的本地 skeleton,不是运行中的 live daemon。 +- 只允许本地文件状态、trace 摘要和审批草案预览。 +- 所有输出必须 redaction token/secret/URL credential。 +- 不得重启或接管 Code Queue backend,不得 cancel/interrupt 运行任务,不得打印 token 明文。 -服务边界固定为三层: - -- host Codex 进程:运行在 master server host 的常驻 Codex 指挥会话,只负责监督、派单、审阅和恢复计划。 -- 直管控制微服务:运行在 host 侧或能直接接入 host TTY/stdio 的受控位置,负责发现/启动计划、SSH/PTY/stdio bridge、事件持久化、prompt plan、trace summary 和审批状态。 -- UniDesk/Code Queue/ClaudeQQ:Code Queue 继续作为任务执行面,GitHub issue 继续作为长期记录,ClaudeQQ 作为高风险动作请示入口。 - -第一阶段只允许这些产物: - -- `docs/reference/host-codex-commander.md` 长期 contract; -- `bun scripts/cli.ts commander contract` 和 `commander plan --dry-run` 这类只读/dry-run 输出; -- contract test 证明 CLI 不执行 live operation; -- 后续任务拆分。 - -## 非目标 - -当前阶段明确不做: - -- 不启动、停止或重启任何常驻 commander daemon; -- 不打开真实 PTY、stdio 或 SSH bridge; -- 不向 host Codex 注入真实 prompt; -- 不发送 ClaudeQQ 消息; -- 不部署、不重启、不上线 prod; -- 不直接重启 Code Queue backend,不重建 Code Queue backend 容器,不重启 Code Queue 执行面; -- 不 cancel 或 interrupt 运行中的 Code Queue task; -- 不读取、打印或持久化 token 明文。 - -这些动作即使未来实现,也必须先通过本文的审批和安全边界。 - -## CLI Contract - -统一入口: +## CLI ```bash bun scripts/cli.ts commander contract @@ -44,188 +17,46 @@ bun scripts/cli.ts commander plan --dry-run [--session-id primary] bun scripts/cli.ts commander approval request --action <action> --dry-run [--reason text] [--task-id id] ``` -所有命令默认输出 JSON,失败也必须有结构化 stdout 和非零退出码。`plan` 和 `approval request` 在第一阶段必须要求 `--dry-run`;没有 `--dry-run` 时必须返回 `ok=false`、`error=dry-run-required`,不能降级成真实执行。 +`plan` 与 `approval request` 必须显式使用 `--dry-run`,缺失时返回 `error=dry-run-required`。 -`commander contract` 必须暴露: +## HTTP -- `phase=source-contract`; -- `serviceId=host-codex-commander`; -- `daemonImplemented=false`; -- `liveOperationsImplemented=false`; -- 需要的 host Codex 发现/启动计划、SSH/PTY/stdio bridge、prompt guidance、trace summary、#20/#46 issue 入口、ClaudeQQ 审批入口; -- `safetyBoundary`。 +| Method | Path | 说明 | +| --- | --- | --- | +| GET | `/health` | 返回 service id、启动时间、state root 和日志文件 | +| GET | `/api/commander/contract` | 返回机器可读 contract | +| GET | `/api/commander/sessions` | 读取本地 session 摘要 | +| POST | `/api/commander/state` | 写入本地 session state | +| GET | `/api/commander/trace-summary` | 对 mock trace JSONL 做 dry-run 摘要 | +| POST | `/api/commander/trace-summary` | 读取 mock trace JSONL 并更新本地 session 状态 | +| POST | `/api/commander/approvals` | 生成 approval draft preview 并落盘 | -`commander plan --dry-run` 必须输出: +## State -- process discovery signal 列表; -- host Codex start command shape,但 `enabled=false`; -- SSH/PTY/stdio bridge 设计和 guardrail; -- prompt guidance pipeline; -- trace summary sources 和 summary shape; -- #20/#46 read/write 入口; -- ClaudeQQ high-risk approval command shape; -- `mutation=false`。 +状态根目录固定为 `.state/commander/`,至少包含: -`commander approval request --dry-run` 只生成审批草案。允许的 `--action` 固定为: +- `sessions/<sessionId>.json` +- `events/<sessionId>.jsonl` +- `approvals/<approvalId>.json` +- `logs/commander.jsonl` -- `code-queue-backend-restart` -- `code-queue-backend-rebuild` -- `code-queue-execution-plane-restart` -- `code-queue-task-interrupt` -- `code-queue-task-cancel` -- `prod-runtime-mutation` +session 状态只保留 `unknown`、`discovered`、`planned`、`starting`、`running`、`attention_required`、`stopping`、`stopped`、`degraded`。 -输出中 `claudeqq.mutation=false`、`sendImplemented=false`。真实发送和审批消费属于后续阶段。 +## Trace summary -## Future API Contract +trace summary 输入 mock Code Queue trace JSONL 和可选 task summary,输出: -后续微服务 API 以 REST 为主,所有写入口默认异步、有 request id、有事件序列、有 redaction 结果: +- `taskId` +- `sessionId` +- `lastSeq` +- `status` +- `keyEvents` +- `openQuestions` +- `recommendedNextActions` +- `redactionsApplied` -| Method | Path | 用途 | 第一阶段状态 | -| --- | --- | --- | --- | -| GET | `/health` | 服务健康、版本、日志路径、state root | contract only | -| GET | `/api/commander/contract` | 返回本文对应机器可读 contract | contract only | -| GET | `/api/commander/sessions` | 列出 host Codex session 摘要 | contract only | -| POST | `/api/commander/sessions/:sessionId/plan-start` | 生成发现/启动计划 | contract only | -| POST | `/api/commander/sessions/:sessionId/prompt-plan` | 生成 prompt 注入计划 | contract only | -| GET | `/api/commander/trace-summary` | 读取有界 trace summary | contract only | -| POST | `/api/commander/issues/:issueNumber/write-plan` | 生成 #20/#46 写入计划 | contract only | -| POST | `/api/commander/approvals` | 创建 ClaudeQQ 高风险请示草案 | contract only | +输出只做摘要,不返回 live transcript。 -后续实现不得绕过现有 CLI/服务边界。GitHub issue 写入仍使用 `bun scripts/cli.ts gh issue ... --body-file`、dry-run-first 和并发 guard;Code Queue 读写仍优先使用 `codex task/tasks/steer/read` 等正式入口。 +## Approval draft -## State Model - -状态根目录规划为 `.state/commander/`。后续服务至少拆成这些文件或等价表: - -- `sessions/<sessionId>.json`:host Codex session 摘要、pid/cwd 指纹、bridge 状态、lastSeq、heartbeat; -- `events/<sessionId>.jsonl`:prompt、trace、approval、bridge lifecycle 事件; -- `approvals/<approvalId>.json`:ClaudeQQ 请示草案、状态、授权绑定动作、过期时间; -- `locks/<name>.lock.d/`:启动、prompt injection、issue write、approval consume 的互斥锁; -- `redactions/<eventId>.json`:脱敏摘要,不保存明文 secret。 - -session 状态: - -| State | 含义 | -| --- | --- | -| `unknown` | 没有足够信号判断 host Codex 是否存在 | -| `discovered` | 通过 state/process/bridge heartbeat 找到候选会话 | -| `planned` | 只生成启动或接管计划,尚未执行 | -| `starting` | 后续 live executor 正在启动或 attach | -| `running` | heartbeat 和 trace 新鲜 | -| `attention_required` | 需要人工判断或审批 | -| `stopping` | 后续 live executor 正在退出 | -| `stopped` | 会话已终止 | -| `degraded` | bridge、trace 或状态持久化部分失败 | - -prompt 状态:`draft`、`planned`、`queued_for_injection`、`injected`、`rejected`、`failed`。 - -approval 状态:`draft`、`requested`、`approved`、`rejected`、`expired`、`consumed`。审批只能绑定一个具体 action、taskId 或 target,不得作为泛授权复用。 - -## Bridge Contract - -SSH bridge 只复用现有 UniDesk Host SSH / WSL SSH 维护桥。它用于 provider 只读诊断、受控维护命令和未来已审批恢复动作;不得作为 provider-gateway 自重建通道,也不得绕过 provider.upgrade 调度规则。 - -PTY bridge 用于 host Codex 交互会话保活和窗口化 transcript 采集。后续实现必须: - -- 有 heartbeat; -- stdout/stderr 分流或标注; -- 按 seq 写入事件; -- 默认有 byte/line 上限; -- prompt 注入前先落 plan 和 redaction summary; -- 注入失败必须可见,不得静默重试。 - -stdio bridge 用于非交互 Codex 或 helper subprocess。它必须有 argv 记录、cwd、env key allowlist、exit code、timeout 和 bounded output。env 只能记录 key 存在性或来源,不能记录值。 - -## Prompt Guidance - -prompt 注入前必须按顺序执行: - -1. 分类意图和风险; -2. 汇总当前 #20/#46/task/queue 的有界上下文; -3. 运行 forbidden-action guard; -4. 对 prompt 和上下文做 secret-like redaction; -5. 持久化 prompt plan; -6. 后续 live executor 仅在 policy pass 后注入。 - -遇到 Code Queue backend 重启/重建、执行面重启、task interrupt/cancel、prod mutation、token 访问、破坏性 Git 操作时,必须转入 ClaudeQQ 审批草案,而不是注入执行 prompt。 - -## Trace Summary - -trace summary 不是 raw transcript dump。默认输出应包含: - -- `taskId`、`sessionId`、`lastSeq`; -- 当前状态和 freshness; -- 最近关键事件; -- open questions; -- recommended next actions; -- redactionsApplied; -- drill-down 命令。 - -原始 transcript 必须分页读取,默认不在 summary 中展开。summary 可以引用 Code Queue `codex task --trace`、`codex output`、host Codex event JSONL 和 approval event,但不能把任一路径失败升级为全局故障,仍需遵守 `docs/reference/code-queue-supervision.md` 的多信号裁决规则。 - -## Issue Entrypoints - -#20 是 Code Queue 总看板入口。读取优先: - -```bash -bun scripts/cli.ts gh issue board-audit --board-issue 20 --dry-run -bun scripts/cli.ts gh issue board-row list --board-issue 20 -``` - -写入必须先 dry-run,再带 body SHA 或 updatedAt: - -```bash -bun scripts/cli.ts gh issue board-row update <issueNumber> --board-issue 20 --field progress --value <text> --expect-body-sha <sha> -``` - -#46 是每日指挥简报入口。读取: - -```bash -bun scripts/cli.ts gh issue read 46 --json body,title,state,updatedAt -``` - -写入: - -```bash -bun scripts/cli.ts gh issue update 46 --body-profile commander-brief --body-file <file> --expect-updated-at <ts> -``` - -所有 Markdown 正文必须来自 `--body-file`,禁止把正文拼入 shell 参数。任何 issue 写入都不能自动触发 ClaudeQQ,除非该动作本身是高风险请示或用户通知策略明确要求。 - -## Safety Boundary - -第一阶段所有 commander CLI 输出都必须是 `mutation=false`。 - -未来 live executor 在没有用户明确同意前也不得执行: - -- 重启或重建 Code Queue backend; -- 重启 Code Queue 执行面; -- interrupt 或 cancel 运行任务; -- 修改 production runtime; -- 读取或输出 token 明文; -- 直写 PostgreSQL 修补任务状态; -- 破坏性 Git 操作; -- 绕过 GitHub issue body-file 和并发 guard。 - -高风险动作流程固定为: - -1. 生成 ClaudeQQ 请示草案,说明原因、影响范围、拟执行动作和可回滚性; -2. 发送给配置的主用户私聊入口; -3. 等待明确批准; -4. 将批准绑定到唯一 action 和 target; -5. 执行前再次校验审批未过期且未被消费; -6. 执行后写入 #46 或对应 issue 的结果摘要。 - -ClaudeQQ 不可达时,不得把请求视为已批准;只能记录通知失败和继续只读诊断。 - -## Next Stage Tasks - -可派单的下一阶段任务: - -- 实现 `src/components/microservices/host-codex-commander` 服务骨架,提供 `/health` 和 `/api/commander/contract`,仍不启动 live bridge。 -- 增加 `.state/commander/` 文件状态读写模块和 redaction 单元测试。 -- 实现只读 host Codex process discovery,输出候选 pid/cwd/age,不 attach、不 kill。 -- 实现 trace summary 聚合器,读取 mock event JSONL 和 Code Queue bounded trace。 -- 实现 ClaudeQQ approval draft service,不发送消息,只落审批草案和 preview。 -- 设计第二阶段 live PTY/stdio bridge 的权限、日志、锁和超时测试。 +高风险动作只生成 approval draft JSON / Markdown preview。preview 必须显示 redaction 结果,并明确 `sendImplemented=false`。 diff --git a/scripts/host-codex-commander-contract-test.ts b/scripts/host-codex-commander-contract-test.ts index 1d7760a9..6b92cbb4 100644 --- a/scripts/host-codex-commander-contract-test.ts +++ b/scripts/host-codex-commander-contract-test.ts @@ -124,13 +124,13 @@ assertCondition(secretReasonResult.stdout.includes("<redacted>"), "redacted appr const doc = readFileSync("docs/reference/host-codex-commander.md", "utf8"); for (const snippet of [ - "不直接重启 Code Queue backend", - "不 cancel 或 interrupt 运行中的 Code Queue task", - "不读取、打印或持久化 token 明文", - "SSH/PTY/stdio", - "#20", - "#46", - "ClaudeQQ", + "本地 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}`); } diff --git a/scripts/host-codex-commander-skeleton-contract-test.ts b/scripts/host-codex-commander-skeleton-contract-test.ts new file mode 100644 index 00000000..5adc8041 --- /dev/null +++ b/scripts/host-codex-commander-skeleton-contract-test.ts @@ -0,0 +1,146 @@ +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<string, unknown>; + +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<JsonRecord> { + 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] === "<redacted>", "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)); + assertCondition(commanderSessionPreview(session).notes.includes("<redacted>"), "session preview must redact notes", commanderSessionPreview(session)); + + 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("<redacted>") === 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("<redacted>"), "approval reason must redact", approval); + assertCondition(approval.previewMarkdown.includes("<redacted>"), "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("<redacted>"), "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 && 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("<redacted>"), "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/src/check.ts b/scripts/src/check.ts index fd41e47a..2ef9635b 100644 --- a/scripts/src/check.ts +++ b/scripts/src/check.ts @@ -22,7 +22,10 @@ const syntaxFiles = [ "scripts/src/docker.ts", "scripts/src/e2e.ts", "scripts/src/help.ts", + "scripts/src/commander.ts", "scripts/src/remote.ts", + "scripts/host-codex-commander-contract-test.ts", + "scripts/host-codex-commander-skeleton-contract-test.ts", "src/components/frontend/src/index.ts", "src/components/frontend/src/app.tsx", "src/components/frontend/src/decision-center.tsx", @@ -34,6 +37,10 @@ const syntaxFiles = [ "src/components/microservices/decision-center/src/index.ts", "src/components/microservices/code-queue-mgr/src/index.ts", "src/components/microservices/code-agent-sandbox/src/index.ts", + "src/components/microservices/host-codex-commander/src/index.ts", + "src/components/microservices/host-codex-commander/src/contract.ts", + "src/components/microservices/host-codex-commander/src/redaction.ts", + "src/components/microservices/host-codex-commander/src/state.ts", ]; export interface CheckOptions { @@ -167,6 +174,7 @@ function unifiedLogRotationItem(): CheckItem { "src/components/microservices/oa-event-flow/src/index.ts", "src/components/microservices/decision-center/src/index.ts", "src/components/microservices/code-agent-sandbox/src/index.ts", + "src/components/microservices/host-codex-commander/src/index.ts", ]; const offenders = serviceFiles.flatMap((path) => { const text = readFileSync(rootPath(path), "utf8"); @@ -275,6 +283,13 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default fileItem("src/components/microservices/decision-center/src/index.ts"), fileItem("src/components/microservices/code-queue-mgr/src/index.ts"), fileItem("src/components/microservices/code-agent-sandbox/src/index.ts"), + fileItem("src/components/microservices/host-codex-commander/package.json"), + fileItem("src/components/microservices/host-codex-commander/tsconfig.json"), + fileItem("src/components/microservices/host-codex-commander/Dockerfile"), + fileItem("src/components/microservices/host-codex-commander/src/index.ts"), + fileItem("src/components/microservices/host-codex-commander/src/contract.ts"), + 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/deploy.ts"), fileItem("scripts/code-queue-issue3-regression-test.ts"), @@ -283,6 +298,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default fileItem("scripts/code-queue-trace-summary-contract-test.ts"), fileItem("scripts/code-queue-pr-preflight-contract-test.ts"), fileItem("scripts/code-queue-submit-routing-contract-test.ts"), + fileItem("scripts/host-codex-commander-skeleton-contract-test.ts"), fileItem("scripts/provider-runner-triage-contract-test.ts"), fileItem("scripts/src/provider-triage.ts"), fileItem("src/components/microservices/code-queue/src/runner-error-classifier.ts"), @@ -309,6 +325,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default items.push(commandItem("code-queue:trace-summary-contract", ["bun", "scripts/code-queue-trace-summary-contract-test.ts"], 30_000)); items.push(commandItem("code-queue:pr-preflight-contract", ["bun", "scripts/code-queue-pr-preflight-contract-test.ts"], 30_000)); items.push(commandItem("code-queue:submit-routing-contract", ["bun", "scripts/code-queue-submit-routing-contract-test.ts"], 30_000)); + items.push(commandItem("host-codex-commander:skeleton-contract", ["bun", "scripts/host-codex-commander-skeleton-contract-test.ts"], 30_000)); items.push(commandItem("provider:runner-triage-contract", ["bun", "scripts/provider-runner-triage-contract-test.ts"], 30_000)); items.push(commandItem("deploy:artifact-matrix-contract", ["bun", "scripts/deploy-artifact-matrix-contract-test.ts"], 30_000)); items.push(commandItem("decision-center:desired-state-contract", ["bun", "scripts/decision-center-desired-state-contract-test.ts"], 30_000)); @@ -329,6 +346,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default 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:submit-routing-contract", "Code Queue submit routing 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("provider:runner-triage-contract", "Provider runner triage 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("decision-center:desired-state-contract", "Decision Center desired-state drift contract is opt-in with script checks", "--scripts-typecheck or --full")); diff --git a/scripts/src/commander.ts b/scripts/src/commander.ts index 13672a75..c88fdb47 100644 --- a/scripts/src/commander.ts +++ b/scripts/src/commander.ts @@ -1,13 +1,6 @@ -const requiredDryRunMessage = "This first-phase commander contract only supports dry-run planning; live daemon/control operations are not implemented."; +import { commanderContract as hostCommanderContract, commanderHighRiskActions as highRiskActions } from "../../src/components/microservices/host-codex-commander/src/contract"; -const highRiskActions = [ - "code-queue-backend-restart", - "code-queue-backend-rebuild", - "code-queue-execution-plane-restart", - "code-queue-task-interrupt", - "code-queue-task-cancel", - "prod-runtime-mutation", -] as const; +const requiredDryRunMessage = "This host Codex commander skeleton only supports dry-run planning; live daemon/control operations are not implemented."; type HighRiskAction = typeof highRiskActions[number]; @@ -37,6 +30,7 @@ function redactText(value: string): { text: string; redactionsApplied: number } /\b(?:sk|ghp|github_pat|xoxb|xoxp|AKIA)[A-Za-z0-9_=-]{8,}\b/g, /\b(?:token|secret|password|passwd|authorization|cookie|api[_-]?key)\s*[:=]\s*[^,\s]+/gi, /\bBearer\s+[A-Za-z0-9._~+/-]+=*\b/gi, + /https?:\/\/[^/\s]+:[^@\s]+@[^/\s]+/gi, ]; let text = value; for (const pattern of patterns) { @@ -52,7 +46,7 @@ function commanderHelp(): Record<string, unknown> { return { command: "commander", output: "json", - description: "First-phase source/contract stub for the host Codex commander control microservice; no daemon or live control action is implemented.", + description: "Local skeleton contract for the host Codex commander control microservice; no daemon or live control action is implemented.", usage: [ "bun scripts/cli.ts commander contract", "bun scripts/cli.ts commander plan --dry-run [--session-id id]", @@ -64,51 +58,7 @@ function commanderHelp(): Record<string, unknown> { } export function commanderContract(): Record<string, unknown> { - return { - ok: true, - phase: "source-contract", - serviceId: "host-codex-commander", - currentImplementation: "cli-contract-stub-only", - daemonImplemented: false, - liveOperationsImplemented: false, - purpose: "Keep a host Codex commander session observable and controllable through a future direct-managed microservice without replacing Code Queue runners.", - ownershipBoundary: { - hostCodexProcess: "Long-lived Codex process on the master server host.", - controlMicroservice: "Future direct-managed bridge that records state, mediates PTY/stdio/SSH streams, injects prompts, and summarizes traces.", - codeQueue: "Remains the task execution plane; the commander only supervises through existing safe CLI/API contracts.", - claudeqq: "Approval and user-notification path for high-risk actions.", - }, - requiredCapabilities: [ - "host-codex-process-discovery", - "host-codex-start-plan", - "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", - "claudeqq-high-risk-approval-entry", - ], - apiContract: { - health: "GET /health", - contract: "GET /api/commander/contract", - sessions: "GET /api/commander/sessions", - sessionPlan: "POST /api/commander/sessions/:sessionId/plan-start", - promptPlan: "POST /api/commander/sessions/:sessionId/prompt-plan", - traceSummary: "GET /api/commander/trace-summary?taskId=<taskId>", - issueWritePlan: "POST /api/commander/issues/:issueNumber/write-plan", - approvalRequest: "POST /api/commander/approvals", - }, - stateModel: { - sessionStates: ["unknown", "discovered", "planned", "starting", "running", "attention_required", "stopping", "stopped", "degraded"], - promptStates: ["draft", "planned", "queued_for_injection", "injected", "rejected", "failed"], - approvalStates: ["draft", "requested", "approved", "rejected", "expired", "consumed"], - storageRoot: ".state/commander/", - redactionPolicy: "Never persist or print token, secret, password, key, cookie, or authorization values in cleartext.", - }, - safetyBoundary: safetyBoundary(), - }; + return hostCommanderContract(); } function safetyBoundary(): Record<string, unknown> { diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 3aa0f5a1..a6298d0a 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -44,7 +44,7 @@ export function rootHelp(): unknown { { command: "dev-env validate|prewarm-images", description: "Validate D601 unidesk-dev guardrails or prewarm dev foundation images into native k3s containerd through a bounded async job." }, { command: "artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service", description: "Manage the D601 host-managed CNCF Distribution registry and run pull-only artifact CD for supported services, including D601 direct, k3s-managed, and code-queue dev-only consumers." }, { command: "gh 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, hard delete unsupported, and merge blocked." }, - { command: "commander contract|plan --dry-run|approval request --dry-run", description: "First-phase host Codex commander source/contract design stub; returns boundaries and approval plans without starting daemons or executing live control actions." }, + { command: "commander contract|plan --dry-run|approval request --dry-run", description: "Host Codex commander skeleton contract and dry-run preview; exposes local health, state, trace summary, and approval draft helpers without live bridges or message sends." }, { command: "code-agent-sandbox", description: "Independent Code Agent Sandbox service skeleton for adapter, mode, and credential-boundary diagnostics." }, { command: "schedule list|get|runs|run|retry-run|delete", description: "Manage backend-core scheduled tasks and run history; schedule run <id> supports --wait-ms N and retry-run reuses the failed run's schedule." }, { command: "schedule upsert-pgdata-backup [--time HH:MM] [--remote-base /SERVER_DATA/UNIDESK_PG_DATA]", description: "Create or update the daily PGDATA physical backup task that uploads monthly rotated archives to Baidu Netdisk." }, @@ -195,9 +195,9 @@ function commanderHelp(): unknown { "bun scripts/cli.ts commander plan --dry-run [--session-id id]", "bun scripts/cli.ts commander approval request --action <action> --dry-run [--reason text] [--task-id id]", ], - description: "Inspect the first-phase source/contract design for the future host Codex commander microservice.", + description: "Inspect the local host Codex commander skeleton contract, dry-run planner, state helpers, trace summary aggregator, and approval draft preview.", boundary: [ - "phase one is contract-only and never starts a daemon", + "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 ClaudeQQ approval draft", "token and secret values must never be printed", diff --git a/src/components/microservices/host-codex-commander/Dockerfile b/src/components/microservices/host-codex-commander/Dockerfile new file mode 100644 index 00000000..7bdfd8d3 --- /dev/null +++ b/src/components/microservices/host-codex-commander/Dockerfile @@ -0,0 +1,11 @@ +FROM oven/bun:1-alpine + +WORKDIR /app/src/components/microservices/host-codex-commander +COPY src/components/microservices/host-codex-commander/package.json ./package.json +RUN bun install --production +COPY src/components/microservices/host-codex-commander/tsconfig.json ./tsconfig.json +COPY src/components/shared /app/src/components/shared +COPY src/components/microservices/host-codex-commander/src ./src + +EXPOSE 4261 +CMD ["bun", "run", "src/index.ts"] diff --git a/src/components/microservices/host-codex-commander/package.json b/src/components/microservices/host-codex-commander/package.json new file mode 100644 index 00000000..558cf2f1 --- /dev/null +++ b/src/components/microservices/host-codex-commander/package.json @@ -0,0 +1,15 @@ +{ + "name": "@unidesk/host-codex-commander", + "private": true, + "type": "module", + "scripts": { + "start": "bun run src/index.ts", + "check": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": {}, + "devDependencies": { + "@types/bun": "latest", + "@types/node": "latest", + "typescript": "latest" + } +} diff --git a/src/components/microservices/host-codex-commander/src/contract.ts b/src/components/microservices/host-codex-commander/src/contract.ts new file mode 100644 index 00000000..a2b3a3ec --- /dev/null +++ b/src/components/microservices/host-codex-commander/src/contract.ts @@ -0,0 +1,141 @@ +export const commanderServiceId = "host-codex-commander"; + +export const commanderPhase = "source-contract"; + +export const commanderHighRiskActions = [ + "code-queue-backend-restart", + "code-queue-backend-rebuild", + "code-queue-execution-plane-restart", + "code-queue-task-interrupt", + "code-queue-task-cancel", + "prod-runtime-mutation", +] as const; + +export type CommanderHighRiskAction = typeof commanderHighRiskActions[number]; + +export type CommanderSessionState = + | "unknown" + | "discovered" + | "planned" + | "starting" + | "running" + | "attention_required" + | "stopping" + | "stopped" + | "degraded"; + +export type CommanderPromptState = + | "draft" + | "planned" + | "queued_for_injection" + | "injected" + | "rejected" + | "failed"; + +export type CommanderApprovalState = + | "draft" + | "requested" + | "approved" + | "rejected" + | "expired" + | "consumed"; + +export interface CommanderContract { + ok: true; + phase: typeof commanderPhase; + serviceId: typeof commanderServiceId; + currentImplementation: "host-codex-commander-skeleton"; + daemonImplemented: false; + liveOperationsImplemented: false; + purpose: string; + ownershipBoundary: { + hostCodexProcess: string; + controlMicroservice: string; + codeQueue: string; + claudeqq: string; + }; + requiredCapabilities: string[]; + apiContract: { + health: string; + contract: string; + sessions: string; + sessionPlan: string; + promptPlan: string; + traceSummary: string; + issueWritePlan: string; + approvalRequest: string; + }; + stateModel: { + sessionStates: CommanderSessionState[]; + promptStates: CommanderPromptState[]; + approvalStates: CommanderApprovalState[]; + storageRoot: ".state/commander/"; + redactionPolicy: string; + }; + safetyBoundary: { + phaseOneMutationAllowed: false; + forbiddenWithoutExplicitUserApproval: CommanderHighRiskAction[]; + alwaysForbidden: string[]; + confirmationPolicy: string; + }; +} + +export function commanderContract(): CommanderContract { + return { + ok: true, + phase: commanderPhase, + serviceId: commanderServiceId, + currentImplementation: "host-codex-commander-skeleton", + daemonImplemented: false, + liveOperationsImplemented: false, + purpose: "Keep host Codex supervision observable through a local-only skeleton that can persist state, summarize traces, and draft approvals without touching live bridges.", + ownershipBoundary: { + hostCodexProcess: "Long-lived Codex process on the master server host.", + controlMicroservice: "Local skeleton for health, state, trace summary, and approval drafting with no live bridge or executor.", + codeQueue: "Execution plane remains separate and is never restarted or attached by this skeleton.", + claudeqq: "Approval draft destination only; no messages are sent from this stage.", + }, + requiredCapabilities: [ + "host-codex-process-discovery", + "host-codex-start-plan", + "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", + "claudeqq-high-risk-approval-entry", + ], + apiContract: { + health: "GET /health", + contract: "GET /api/commander/contract", + sessions: "GET /api/commander/sessions", + sessionPlan: "POST /api/commander/sessions/:sessionId/plan-start", + promptPlan: "POST /api/commander/sessions/:sessionId/prompt-plan", + traceSummary: "GET /api/commander/trace-summary", + issueWritePlan: "POST /api/commander/issues/:issueNumber/write-plan", + approvalRequest: "POST /api/commander/approvals", + }, + stateModel: { + sessionStates: ["unknown", "discovered", "planned", "starting", "running", "attention_required", "stopping", "stopped", "degraded"], + promptStates: ["draft", "planned", "queued_for_injection", "injected", "rejected", "failed"], + approvalStates: ["draft", "requested", "approved", "rejected", "expired", "consumed"], + storageRoot: ".state/commander/", + redactionPolicy: "Never persist or print token, secret, password, key, cookie, authorization, or credential URLs in cleartext.", + }, + safetyBoundary: { + phaseOneMutationAllowed: false, + forbiddenWithoutExplicitUserApproval: commanderHighRiskActions, + alwaysForbidden: [ + "print-token-values", + "read-token-files-for-display", + "direct-database-state-patch", + "bypass-code-queue-backend-confirmation-policy", + "replace-code-queue-runner", + "deploy-or-restart-production-runtime-from-this-contract-stub", + ], + confirmationPolicy: "High-risk actions must draft a ClaudeQQ request, wait for explicit user approval, bind approval to one exact action, and record the decision before any future live executor may proceed.", + }, + }; +} diff --git a/src/components/microservices/host-codex-commander/src/index.ts b/src/components/microservices/host-codex-commander/src/index.ts new file mode 100644 index 00000000..b14c31ff --- /dev/null +++ b/src/components/microservices/host-codex-commander/src/index.ts @@ -0,0 +1,283 @@ +import { existsSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import { createHourlyJsonlWriter, logRetentionBytesForService } from "../../../shared/src/rotating-jsonl"; +import { commanderContract } from "./contract"; +import { commanderHighRiskActions } from "./contract"; +import { + appendCommanderTraceEvent, + buildCommanderApprovalDraft, + commanderApprovalPreview, + commanderHealth, + commanderSessionPreview, + commanderStatePaths, + listCommanderSessions, + normalizeCommanderApprovalState, + normalizeCommanderPromptState, + normalizeCommanderSessionState, + readCommanderApproval, + readCommanderSession, + writeCommanderApproval, + writeCommanderSession, + summarizeCommanderTrace, + type CommanderStorageConfig, + type CommanderApprovalDraftRecord, + type CommanderSessionRecord, +} from "./state"; + +export interface RuntimeConfig extends CommanderStorageConfig { + host: string; + port: number; + logFile: string; + serviceId: string; + stateRoot: string; + sessionId: string; +} + +type JsonRecord = Record<string, unknown>; + +const startedAt = new Date().toISOString(); +const recentLogs: JsonRecord[] = []; +const isCheckMode = process.execArgv.includes("--check"); + +function envString(name: string, fallback: string): string { + const value = process.env[name]; + return value === undefined || value.length === 0 ? fallback : value; +} + +function envNumber(name: string, fallback: number): number { + const raw = process.env[name]; + if (raw === undefined || raw.trim().length === 0) return fallback; + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback; +} + +function buildConfig(): RuntimeConfig { + const stateRoot = envString("COMMANDER_STATE_ROOT", "/var/lib/unidesk/commander"); + const logFile = envString("LOG_FILE", "/var/log/unidesk/commander.jsonl"); + return { + rootDir: stateRoot, + host: envString("HOST", "0.0.0.0"), + port: envNumber("PORT", 4261), + logFile, + serviceId: "host-codex-commander", + stateRoot, + sessionId: envString("COMMANDER_SESSION_ID", "primary"), + }; +} + +let config = buildConfig(); +let logWriter: ReturnType<typeof createHourlyJsonlWriter> | null = null; + +export function setCommanderRuntimeConfig(next: RuntimeConfig): void { + config = next; + logWriter = null; +} + +function getLogWriter(): ReturnType<typeof createHourlyJsonlWriter> { + if (logWriter === null) { + mkdirSync(dirname(config.logFile), { recursive: true }); + mkdirSync(config.stateRoot, { recursive: true }); + logWriter = createHourlyJsonlWriter({ + baseLogFile: config.logFile, + service: "host-codex-commander", + maxBytes: logRetentionBytesForService("host-codex-commander"), + }); + logWriter.prune(); + } + return logWriter; +} + +function log(event: string, detail: JsonRecord = {}): void { + const record: JsonRecord = { at: new Date().toISOString(), service: config.serviceId, event, ...detail }; + recentLogs.push(record); + while (recentLogs.length > 200) recentLogs.shift(); + try { + getLogWriter().appendJson(record, new Date(String(record.at))); + } catch { + // Logging must never block the skeleton service. + } + console.log(JSON.stringify(record)); +} + +function jsonResponse(body: unknown, status = 200, headers: Record<string, string> = {}): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json; charset=utf-8", ...headers }, + }); +} + +function errorToJson(error: unknown): JsonRecord { + if (error instanceof Error) return { name: error.name, message: error.message, stack: error.stack ?? "" }; + return { message: String(error) }; +} + +function errorResponse(error: unknown, status = 500): Response { + log("request_failed", { status, error: errorToJson(error) }); + return jsonResponse({ ok: false, error: error instanceof Error ? error.message : String(error) }, status); +} + +function readJsonBody(body: Request): Promise<unknown> { + return body.json(); +} + +function routePath(url: URL): string { + return url.pathname.replace(/\/+$/u, "") || "/"; +} + +function commanderInfo(): JsonRecord { + const paths = commanderStatePaths(config); + const session = readCommanderSession(config, config.sessionId); + return { + ok: true, + service: config.serviceId, + serviceId: config.serviceId, + status: "healthy", + startedAt, + config: { + host: config.host, + port: config.port, + stateRoot: config.stateRoot, + logFile: config.logFile, + sessionId: config.sessionId, + }, + state: commanderSessionPreview(session), + files: { + stateFile: paths.stateFile, + approvalFile: paths.approvalFile, + traceFile: paths.traceFile, + logFile: paths.logFile, + stateRootExists: existsSync(config.stateRoot), + }, + }; +} + +export function createCommanderRequestHandler(runtimeConfig: RuntimeConfig): (req: Request) => Promise<Response> | Response { + return async (req: Request) => { + const previousConfig = config; + config = runtimeConfig; + try { + return await Promise.resolve(handleCommanderRequest(req)); + } finally { + config = previousConfig; + } + }; +} + +function normalizeSessionUpdate(input: unknown): CommanderSessionRecord { + const body = typeof input === "object" && input !== null && !Array.isArray(input) ? input as Record<string, unknown> : {}; + const current = readCommanderSession(config, typeof body.sessionId === "string" && body.sessionId.length > 0 ? body.sessionId : config.sessionId); + current.state = typeof body.state === "string" ? normalizeCommanderSessionState(body.state) : current.state; + current.promptState = typeof body.promptState === "string" ? normalizeCommanderPromptState(body.promptState) : current.promptState; + current.approvalState = typeof body.approvalState === "string" ? normalizeCommanderApprovalState(body.approvalState) : current.approvalState; + current.pid = typeof body.pid === "number" && Number.isFinite(body.pid) ? Math.floor(body.pid) : current.pid; + current.cwd = typeof body.cwd === "string" ? body.cwd : current.cwd; + current.lastSeq = typeof body.lastSeq === "number" && Number.isFinite(body.lastSeq) ? Math.max(0, Math.floor(body.lastSeq)) : current.lastSeq; + current.heartbeatAt = typeof body.heartbeatAt === "string" ? body.heartbeatAt : current.heartbeatAt; + current.updatedAt = new Date().toISOString(); + if (Array.isArray(body.notes)) { + current.notes = body.notes.filter((item): item is string => typeof item === "string"); + } + return writeCommanderSession(config, current); +} + +function updateSessionFromTrace(input: unknown): JsonRecord { + const body = typeof input === "object" && input !== null && !Array.isArray(input) ? input as Record<string, unknown> : {}; + const sessionId = typeof body.sessionId === "string" && body.sessionId.length > 0 ? body.sessionId : config.sessionId; + const traceJsonl = typeof body.traceJsonl === "string" ? body.traceJsonl : ""; + const taskSummary = typeof body.taskSummary === "string" ? body.taskSummary : null; + const taskId = typeof body.taskId === "string" ? body.taskId : null; + const summary = summarizeCommanderTrace({ taskId, sessionId, traceJsonl, taskSummary }); + appendCommanderTraceEvent(config, sessionId, { + type: "trace-summary-dry-run", + taskId, + summary, + }); + const nextState = summary.status === "terminal" + ? "stopped" + : summary.status === "attention_required" + ? "attention_required" + : summary.status === "blocked" + ? "degraded" + : "running"; + const session = writeCommanderSession(config, { + ...readCommanderSession(config, sessionId), + sessionId, + state: nextState, + promptState: summary.status === "attention_required" ? "planned" : "draft", + approvalState: summary.status === "attention_required" ? "requested" : "draft", + lastSeq: summary.lastSeq, + heartbeatAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + notes: [...readCommanderSession(config, sessionId).notes, `trace-summary:${summary.status}`].slice(-20), + pid: readCommanderSession(config, sessionId).pid, + cwd: readCommanderSession(config, sessionId).cwd, + }); + return { ok: true, summary, session: commanderSessionPreview(session) }; +} + +function writeApprovalDraft(input: unknown): JsonRecord { + const body = typeof input === "object" && input !== null && !Array.isArray(input) ? input as Record<string, unknown> : {}; + const action = typeof body.action === "string" ? body.action : "unknown"; + if (!commanderHighRiskActions.includes(action as typeof commanderHighRiskActions[number])) { + return { ok: false, error: "validation-failed", message: `unsupported high-risk action: ${action}`, highRiskActions: commanderHighRiskActions }; + } + const taskId = typeof body.taskId === "string" ? body.taskId : null; + const reason = typeof body.reason === "string" ? body.reason : ""; + const approvalId = typeof body.approvalId === "string" && body.approvalId.length > 0 ? body.approvalId : "draft"; + const draft = buildCommanderApprovalDraft({ + approvalId, + action, + taskId, + reason, + sessionId: config.sessionId, + }); + const stored = writeCommanderApproval(config, draft); + return { ok: true, approval: commanderApprovalPreview(stored), previewMarkdown: stored.previewMarkdown, previewJson: stored.previewJson }; +} + +export function handleCommanderRequest(req: Request): Promise<Response> | Response { + const url = new URL(req.url); + const path = routePath(url); + if (req.method === "GET" && (path === "/" || path === "/health")) return jsonResponse(commanderHealth(config, startedAt)); + if (req.method === "GET" && path === "/logs") return jsonResponse({ ok: true, logs: recentLogs.slice(-100), logFile: config.logFile, stateRoot: config.stateRoot, startedAt }); + if (req.method === "GET" && path === "/api/commander/contract") return jsonResponse(commanderContract()); + if (req.method === "GET" && path === "/api/commander/sessions") return jsonResponse({ ok: true, sessions: listCommanderSessions(config).map(commanderSessionPreview) }); + if (req.method === "GET" && path === "/api/commander/state") return jsonResponse({ ok: true, session: commanderSessionPreview(readCommanderSession(config, config.sessionId)), approval: commanderApprovalPreview(readCommanderApproval(config, "draft")) }); + if (req.method === "POST" && path === "/api/commander/state") { + return readJsonBody(req).then((body) => jsonResponse({ ok: true, session: commanderSessionPreview(normalizeSessionUpdate(body)) })); + } + if (req.method === "GET" && path === "/api/commander/trace-summary") { + const traceJsonl = url.searchParams.get("traceJsonl") ?? ""; + const taskSummary = url.searchParams.get("taskSummary"); + const taskId = url.searchParams.get("taskId"); + return jsonResponse({ ok: true, summary: summarizeCommanderTrace({ taskId, sessionId: config.sessionId, traceJsonl, taskSummary }) }); + } + if (req.method === "POST" && path === "/api/commander/trace-summary") { + return readJsonBody(req).then((body) => jsonResponse(updateSessionFromTrace(body))); + } + if (req.method === "POST" && path === "/api/commander/approvals") { + return readJsonBody(req).then((body) => { + const result = writeApprovalDraft(body); + return jsonResponse(result, result.ok === false ? 400 : 200); + }); + } + return jsonResponse({ + ok: false, + service: config.serviceId, + status: "not_found", + available: ["/health", "/logs", "/api/commander/contract", "/api/commander/sessions", "/api/commander/state", "/api/commander/trace-summary", "/api/commander/approvals"], + }, 404); +} + +if (import.meta.main && !isCheckMode) { + const server = Bun.serve({ + hostname: config.host, + port: config.port, + idleTimeout: 120, + fetch(req) { + return Promise.resolve().then(() => handleCommanderRequest(req)).catch((error) => errorResponse(error)); + }, + }); + + log("service_started", { startedAt, service: config.serviceId, stateRoot: config.stateRoot, logFile: config.logFile, port: server.port }); +} diff --git a/src/components/microservices/host-codex-commander/src/redaction.ts b/src/components/microservices/host-codex-commander/src/redaction.ts new file mode 100644 index 00000000..59f54025 --- /dev/null +++ b/src/components/microservices/host-codex-commander/src/redaction.ts @@ -0,0 +1,35 @@ +const secretPatterns = [ + /\b(?:sk|ghp|github_pat|xoxb|xoxp|AKIA)[A-Za-z0-9_=-]{8,}\b/gu, + /\b(?:token|secret|password|passwd|authorization|cookie|api[_-]?key)\s*[:=]\s*[^,\s]+/giu, + /\bBearer\s+[A-Za-z0-9._~+/-]+=*\b/giu, + /https?:\/\/[^/\s]+:[^@\s]+@[^/\s]+/giu, +] as const; + +export interface RedactionResult { + text: string; + redactionsApplied: number; +} + +export function redactText(value: string): RedactionResult { + let redactionsApplied = 0; + let text = value; + for (const pattern of secretPatterns) { + text = text.replace(pattern, () => { + redactionsApplied += 1; + return "<redacted>"; + }); + } + return { text, redactionsApplied }; +} + +export function redactJsonValue(value: unknown): unknown { + if (typeof value === "string") return redactText(value).text; + if (Array.isArray(value)) return value.map((item) => redactJsonValue(item)); + if (typeof value !== "object" || value === null) return value; + const record = value as Record<string, unknown>; + const result: Record<string, unknown> = {}; + for (const [key, item] of Object.entries(record)) { + result[key] = redactJsonValue(item); + } + return result; +} diff --git a/src/components/microservices/host-codex-commander/src/state.ts b/src/components/microservices/host-codex-commander/src/state.ts new file mode 100644 index 00000000..2610b415 --- /dev/null +++ b/src/components/microservices/host-codex-commander/src/state.ts @@ -0,0 +1,462 @@ +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import type { CommanderApprovalState, CommanderPromptState, CommanderSessionState } from "./contract"; +import { redactJsonValue, redactText } from "./redaction"; + +export interface CommanderStorageConfig { + rootDir: string; +} + +export interface CommanderSessionRecord { + sessionId: string; + state: CommanderSessionState; + promptState: CommanderPromptState; + approvalState: CommanderApprovalState; + pid: number | null; + cwd: string | null; + lastSeq: number; + heartbeatAt: string | null; + updatedAt: string; + notes: string[]; +} + +export interface CommanderApprovalDraftRecord { + id: string; + action: string; + taskId: string | null; + reason: string; + status: CommanderApprovalState; + previewMarkdown: string; + previewJson: Record<string, unknown>; + redactionsApplied: number; + createdAt: string; + updatedAt: string; +} + +export interface CommanderApprovalDraftInput { + approvalId?: string; + action: string; + taskId?: string | null; + reason: string; + sessionId?: string | null; +} + +export interface CommanderTraceInput { + taskId: string | null; + sessionId: string; + traceJsonl: string; + taskSummary?: string | null; +} + +export interface CommanderTraceSummary { + taskId: string | null; + sessionId: string; + lastSeq: number; + status: "running" | "attention_required" | "blocked" | "terminal" | "unknown"; + keyEvents: string[]; + openQuestions: string[]; + recommendedNextActions: string[]; + redactionsApplied: number; + sourceCount: number; + taskSummaryPreview: string | null; + traceLineCount: number; +} + +export interface CommanderHealth { + ok: true; + service: "host-codex-commander"; + status: "healthy"; + startedAt: string; + stateRoot: string; + stateRootExists: boolean; + currentStateFile: string; + currentApprovalFile: string; + currentTraceFile: string; + currentLogFile: string; +} + +interface PersistedSessionFile { + sessionId: string; + state: CommanderSessionState; + promptState: CommanderPromptState; + approvalState: CommanderApprovalState; + pid: number | null; + cwd: string | null; + lastSeq: number; + heartbeatAt: string | null; + updatedAt: string; + notes: string[]; +} + +interface PersistedApprovalFile { + id: string; + action: string; + taskId: string | null; + reason: string; + status: CommanderApprovalState; + previewMarkdown: string; + previewJson: Record<string, unknown>; + redactionsApplied: number; + createdAt: string; + updatedAt: string; +} + +const defaultSessionId = "primary"; + +function ensureDir(path: string): void { + mkdirSync(path, { recursive: true }); +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function safeJsonParse(text: string): Record<string, unknown> | null { + try { + const parsed = JSON.parse(text) as unknown; + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null; + return parsed as Record<string, unknown>; + } catch { + return null; + } +} + +function clampText(value: string, maxChars = 1200): string { + const compact = value.replace(/\s+/gu, " ").trim(); + return compact.length <= maxChars ? compact : `${compact.slice(0, Math.max(0, maxChars - 12))}…<truncated>`; +} + +function stateFilePath(rootDir: string, sessionId = defaultSessionId): string { + return join(rootDir, "sessions", `${sessionId}.json`); +} + +function traceFilePath(rootDir: string, sessionId = defaultSessionId): string { + return join(rootDir, "events", `${sessionId}.jsonl`); +} + +function approvalFilePath(rootDir: string, approvalId = "draft"): string { + return join(rootDir, "approvals", `${approvalId}.json`); +} + +function logFilePath(rootDir: string): string { + return join(rootDir, "logs", "commander.jsonl"); +} + +export function commanderStatePaths(config: CommanderStorageConfig, sessionId = defaultSessionId, approvalId = "draft"): { + stateFile: string; + approvalFile: string; + traceFile: string; + logFile: string; +} { + return { + stateFile: stateFilePath(config.rootDir, sessionId), + approvalFile: approvalFilePath(config.rootDir, approvalId), + traceFile: traceFilePath(config.rootDir, sessionId), + logFile: logFilePath(config.rootDir), + }; +} + +export function readCommanderSession(config: CommanderStorageConfig, sessionId = defaultSessionId): CommanderSessionRecord { + const path = stateFilePath(config.rootDir, sessionId); + if (!existsSync(path)) { + return { + sessionId, + state: "unknown", + promptState: "draft", + approvalState: "draft", + pid: null, + cwd: null, + lastSeq: 0, + heartbeatAt: null, + updatedAt: nowIso(), + notes: [], + }; + } + const parsed = safeJsonParse(readFileSync(path, "utf8")); + if (parsed === null) throw new Error(`invalid commander session file: ${path}`); + return normalizeSessionRecord(parsed, sessionId); +} + +export function writeCommanderSession(config: CommanderStorageConfig, session: CommanderSessionRecord): CommanderSessionRecord { + const path = stateFilePath(config.rootDir, session.sessionId); + ensureDir(dirname(path)); + const normalized = normalizeSessionRecord(session, session.sessionId); + writeFileSync(path, `${JSON.stringify(normalized, null, 2)}\n`, "utf8"); + return normalized; +} + +export function readCommanderApproval(config: CommanderStorageConfig, approvalId = "draft"): CommanderApprovalDraftRecord { + const path = approvalFilePath(config.rootDir, approvalId); + if (!existsSync(path)) { + return { + id: approvalId, + action: "unknown", + taskId: null, + reason: "", + status: "draft", + previewMarkdown: "", + previewJson: { ok: true, status: "draft" }, + redactionsApplied: 0, + createdAt: nowIso(), + updatedAt: nowIso(), + }; + } + const parsed = safeJsonParse(readFileSync(path, "utf8")); + if (parsed === null) throw new Error(`invalid commander approval file: ${path}`); + return normalizeApprovalRecord(parsed, approvalId); +} + +export function writeCommanderApproval(config: CommanderStorageConfig, approval: CommanderApprovalDraftRecord): CommanderApprovalDraftRecord { + const path = approvalFilePath(config.rootDir, approval.id); + ensureDir(dirname(path)); + const normalized = normalizeApprovalRecord(approval, approval.id); + writeFileSync(path, `${JSON.stringify(normalized, null, 2)}\n`, "utf8"); + return normalized; +} + +export function buildCommanderApprovalDraft(input: CommanderApprovalDraftInput): CommanderApprovalDraftRecord { + const approvalId = input.approvalId ?? "draft"; + const reason = redactText(input.reason); + const action = String(input.action || "unknown"); + const taskId = input.taskId ?? null; + const previewMarkdown = [ + "# Commander approval draft", + "", + `- approvalId: ${approvalId}`, + `- action: ${action}`, + `- taskId: ${taskId ?? "null"}`, + `- status: draft`, + `- redactionsApplied: ${reason.redactionsApplied}`, + input.sessionId ? `- sessionId: ${input.sessionId}` : "- sessionId: null", + "", + "## Reason", + "", + reason.text || "(empty)", + "", + "## Boundary", + "", + "- ClaudeQQ send is not implemented in this skeleton.", + "- Approval is preview-only until explicit user approval is recorded.", + ].join("\n"); + return { + id: approvalId, + action, + taskId, + reason: reason.text, + status: "draft", + previewMarkdown, + previewJson: redactJsonValue({ + ok: true, + service: "host-codex-commander", + approvalId, + action, + taskId, + reason: reason.text, + redactionsApplied: reason.redactionsApplied, + requiresExplicitUserApproval: true, + sendImplemented: false, + claudeqq: { + mutation: false, + target: "preview-only", + messageTemplate: `Approval required for ${action}. Reason: ${reason.text}.`, + }, + blockedUntilApproved: [action], + }) as Record<string, unknown>, + redactionsApplied: reason.redactionsApplied, + createdAt: nowIso(), + updatedAt: nowIso(), + }; +} + +export function commanderSessionPreview(session: CommanderSessionRecord): Record<string, unknown> { + return redactJsonValue({ + sessionId: session.sessionId, + state: session.state, + promptState: session.promptState, + approvalState: session.approvalState, + pid: session.pid, + cwd: session.cwd, + lastSeq: session.lastSeq, + heartbeatAt: session.heartbeatAt, + updatedAt: session.updatedAt, + notes: session.notes, + }) as Record<string, unknown>; +} + +export function commanderApprovalPreview(approval: CommanderApprovalDraftRecord): Record<string, unknown> { + return redactJsonValue({ + id: approval.id, + action: approval.action, + taskId: approval.taskId, + reason: approval.reason, + status: approval.status, + redactionsApplied: approval.redactionsApplied, + createdAt: approval.createdAt, + updatedAt: approval.updatedAt, + }) as Record<string, unknown>; +} + +export function listCommanderSessions(config: CommanderStorageConfig): CommanderSessionRecord[] { + const dir = join(config.rootDir, "sessions"); + if (!existsSync(dir)) return [readCommanderSession(config)]; + const sessions = readdirSync(dir) + .filter((name) => name.endsWith(".json")) + .map((name) => name.slice(0, -".json".length)) + .map((sessionId) => { + try { + return readCommanderSession(config, sessionId); + } catch { + return null; + } + }) + .filter((item): item is CommanderSessionRecord => item !== null); + if (sessions.length === 0) sessions.push(readCommanderSession(config)); + return sessions.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); +} + +export function appendCommanderTraceEvent(config: CommanderStorageConfig, sessionId: string, event: Record<string, unknown>): string { + const path = traceFilePath(config.rootDir, sessionId); + ensureDir(dirname(path)); + const redactedEvent = redactJsonValue({ ...event, at: nowIso(), sessionId }); + writeFileSync(path, `${JSON.stringify(redactedEvent)}\n`, { flag: "a" }); + return path; +} + +export function summarizeCommanderTrace(input: CommanderTraceInput): CommanderTraceSummary { + const lines = input.traceJsonl.split(/\r?\n/gu).map((line) => line.trim()).filter(Boolean); + const keyEvents: string[] = []; + const openQuestions: string[] = []; + const recommendedNextActions: string[] = []; + let lastSeq = 0; + let status: CommanderTraceSummary["status"] = "unknown"; + let redactionsApplied = 0; + for (const line of lines) { + const parsed = safeJsonParse(line); + if (parsed === null) continue; + const seq = Number(parsed.seq ?? parsed.sequence ?? parsed.eventSequence ?? 0); + if (Number.isFinite(seq)) lastSeq = Math.max(lastSeq, Math.floor(seq)); + const rawText = String(parsed.summary ?? parsed.text ?? parsed.message ?? parsed.command ?? parsed.output ?? ""); + const redacted = redactText(rawText); + redactionsApplied += redacted.redactionsApplied; + const compact = clampText(redacted.text, 180); + const kind = String(parsed.kind ?? parsed.channel ?? parsed.type ?? "event"); + const statusText = String(parsed.status ?? "").toLowerCase(); + if (statusText.includes("failed") || statusText.includes("error")) status = "blocked"; + else if (statusText.includes("completed") || statusText.includes("succeeded")) status = "terminal"; + else if (statusText.includes("blocked") || statusText.includes("attention")) status = "attention_required"; + else if (status === "unknown") status = "running"; + if (compact.length > 0 && keyEvents.length < 8) keyEvents.push(`${kind}: ${compact}`); + } + const taskSummary = input.taskSummary ? redactText(input.taskSummary) : null; + if (taskSummary !== null) redactionsApplied += taskSummary.redactionsApplied; + const summaryPreview = taskSummary === null ? null : clampText(taskSummary.text, 300); + if (status === "unknown" && lines.length > 0) status = "running"; + if (status === "running") { + openQuestions.push("Confirm whether current host Codex session still needs direct control."); + recommendedNextActions.push("Review the last session events and keep the state file current."); + } else if (status === "attention_required") { + openQuestions.push("Identify the action that needs approval or operator intervention."); + recommendedNextActions.push("Draft approval text before any live command path is considered."); + } else if (status === "blocked") { + openQuestions.push("Identify the blocking error or missing prerequisite."); + recommendedNextActions.push("Capture the failure summary and keep the action non-live."); + } else if (status === "terminal") { + recommendedNextActions.push("Persist the terminal summary and mark the session read."); + } else { + recommendedNextActions.push("Collect more trace lines before making a state claim."); + } + return { + taskId: input.taskId, + sessionId: input.sessionId, + lastSeq, + status, + keyEvents, + openQuestions, + recommendedNextActions, + redactionsApplied, + sourceCount: lines.length, + taskSummaryPreview: summaryPreview, + traceLineCount: lines.length, + }; +} + +export function commanderHealth(config: CommanderStorageConfig, startedAt: string): CommanderHealth { + const paths = commanderStatePaths(config); + return { + ok: true, + service: "host-codex-commander", + status: "healthy", + startedAt, + stateRoot: config.rootDir, + stateRootExists: existsSync(config.rootDir), + currentStateFile: paths.stateFile, + currentApprovalFile: paths.approvalFile, + currentTraceFile: paths.traceFile, + currentLogFile: paths.logFile, + }; +} + +function normalizeSessionRecord(record: Record<string, unknown>, sessionId: string): CommanderSessionRecord { + const notes = Array.isArray(record.notes) ? record.notes.filter((item): item is string => typeof item === "string").map((item) => redactText(item).text) : []; + return { + sessionId: String(record.sessionId ?? sessionId), + state: normalizeSessionState(String(record.state ?? "unknown")), + promptState: normalizePromptState(String(record.promptState ?? "draft")), + approvalState: normalizeApprovalState(String(record.approvalState ?? "draft")), + pid: typeof record.pid === "number" && Number.isFinite(record.pid) ? Math.floor(record.pid) : null, + cwd: typeof record.cwd === "string" && record.cwd.length > 0 ? record.cwd : null, + lastSeq: Number.isFinite(Number(record.lastSeq ?? 0)) ? Math.max(0, Math.floor(Number(record.lastSeq ?? 0))) : 0, + heartbeatAt: typeof record.heartbeatAt === "string" && record.heartbeatAt.length > 0 ? record.heartbeatAt : null, + updatedAt: typeof record.updatedAt === "string" && record.updatedAt.length > 0 ? record.updatedAt : nowIso(), + notes, + }; +} + +function normalizeApprovalRecord(record: Record<string, unknown>, approvalId: string): CommanderApprovalDraftRecord { + const previewMarkdown = typeof record.previewMarkdown === "string" ? redactText(record.previewMarkdown).text : ""; + const previewJson = typeof record.previewJson === "object" && record.previewJson !== null && !Array.isArray(record.previewJson) + ? redactJsonValue(record.previewJson) as Record<string, unknown> + : { ok: true, status: "draft" }; + return { + id: String(record.id ?? approvalId), + action: typeof record.action === "string" ? record.action : "unknown", + taskId: typeof record.taskId === "string" ? record.taskId : null, + reason: typeof record.reason === "string" ? redactText(record.reason).text : "", + status: normalizeApprovalState(String(record.status ?? "draft")), + previewMarkdown, + previewJson, + redactionsApplied: Number.isFinite(Number(record.redactionsApplied ?? 0)) ? Math.max(0, Math.floor(Number(record.redactionsApplied ?? 0))) : 0, + createdAt: typeof record.createdAt === "string" && record.createdAt.length > 0 ? record.createdAt : nowIso(), + updatedAt: typeof record.updatedAt === "string" && record.updatedAt.length > 0 ? record.updatedAt : nowIso(), + }; +} + +function normalizeSessionState(value: string): CommanderSessionState { + return ["unknown", "discovered", "planned", "starting", "running", "attention_required", "stopping", "stopped", "degraded"].includes(value) + ? value as CommanderSessionState + : "unknown"; +} + +function normalizePromptState(value: string): CommanderPromptState { + return ["draft", "planned", "queued_for_injection", "injected", "rejected", "failed"].includes(value) + ? value as CommanderPromptState + : "draft"; +} + +function normalizeApprovalState(value: string): CommanderApprovalState { + return ["draft", "requested", "approved", "rejected", "expired", "consumed"].includes(value) + ? value as CommanderApprovalState + : "draft"; +} + +export function normalizeCommanderSessionState(value: string): CommanderSessionState { + return normalizeSessionState(value); +} + +export function normalizeCommanderPromptState(value: string): CommanderPromptState { + return normalizePromptState(value); +} + +export function normalizeCommanderApprovalState(value: string): CommanderApprovalState { + return normalizeApprovalState(value); +} diff --git a/src/components/microservices/host-codex-commander/tsconfig.json b/src/components/microservices/host-codex-commander/tsconfig.json new file mode 100644 index 00000000..e7d9d39a --- /dev/null +++ b/src/components/microservices/host-codex-commander/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "composite": true, + "rootDir": "../../", + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["bun", "node"], + "strict": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "../../shared/src/**/*.ts"] +}