feat: add host commander skeleton
This commit is contained in:
@@ -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 <commitId>`:旧 Code Queue 兼容部署入口已禁用,原因是它会绕过受控部署边界直连 D601 部署 Code Queue;规则见 `docs/reference/codex-deploy.md`。
|
||||
- `bun scripts/cli.ts codex submit [prompt] [--prompt-file path|--prompt-stdin] [--queue <id>]` / `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 入口规则。
|
||||
|
||||
@@ -143,6 +143,6 @@
|
||||
|
||||
阅读 `AGENTS.md` 和 `docs/reference/cli.md`,然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts gh help`,确认 help 中包含 `gh issue create --title <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 明文。
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
@@ -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> {
|
||||
|
||||
+3
-3
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user