fix: adapt claudeqq k3s deploy and notifications

This commit is contained in:
Codex
2026-05-16 19:03:56 +00:00
parent cd2aebfe75
commit 550a12b0a9
7 changed files with 212 additions and 10 deletions
+1 -1
View File
@@ -113,4 +113,4 @@
## T25 ClaudeQQ D601 QQ Gateway User Service
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md``scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:确认 D601 `/home/ubuntu/.agents/skills/claudeqq` 中存在 `Dockerfile``docker-compose.unidesk.yml``napcat/config/onebot11.json``scripts/src/server_ts/src/server/event_bus.ts`;运行 `bun scripts/cli.ts microservice list`,确认 `claudeqq` 显示为 `providerId=D601``public=false``frontendOnly=true`、仓库 URL `https://gitee.com/lyon1998/agent_skills`、k3s/k8s `k3s://unidesk/claudeqq:3290` 逻辑服务映射、`deployment.mode=k3sctl-managed``runtime.orchestrator=k3sctl` 且无业务直连容器摘要;使用 `bun scripts/cli.ts deploy apply --service claudeqq``deploy.json` 期望状态部署,确认 job 能在 D601 target-side fetch/export 外部 repo、构建 `unidesk-claudeqq:d601`、导入原生 k3s/containerd、apply `src/components/microservices/k3sctl-adapter/k3s/claudeqq.k8s.yaml`、stamp deployment commit、rollout 并通过 UniDesk microservice proxy 验证 live commit。运行 `bun scripts/cli.ts microservice health claudeqq``bun scripts/cli.ts microservice proxy claudeqq /api/napcat/login``bun scripts/cli.ts microservice proxy claudeqq /api/events/recent``bun scripts/cli.ts microservice proxy claudeqq /api/events/subscriptions`,确认链路通过 backend-core、k3sctl-adapter、Kubernetes API service proxy 和 D601 ClaudeQQ Servicehealth 返回 `service=claudeqq``pureBackend=true``napcat.containerized=true`、NapCat HTTP/WS 状态、登录状态/二维码、`/api/push/text``/api/events/subscriptions` 端点;通过 `POST /api/events/subscriptions` 创建临时 webhook 订阅再删除,确认 main server 和其他用户服务可订阅 QQ 消息事件;通过 `POST /api/push/text` 发送消息时若 NapCat 未登录必须返回可解释错误,NapCat 在线时必须返回消息 ID,人工推送验收只允许发给主用户私聊账号 `645275593`。最后登录公网 frontend `http://74.48.78.17:18081/`,进入 `用户服务 / ClaudeQQ`,确认页面以 React 控件显示 D601、仓库引用、私有后端映射、NapCat 容器登录二维码、NapCat 状态、QQ 事件订阅、消息推送、主用户私聊账号 `645275593`、最近 QQ 事件和已发送记录,默认没有裸 JSON,只有点击 `查看原始JSON` 才显示原始数据;不得把 D601 `3290/3000/3001/6099` 暴露到公网,也不得 iframe ClaudeQQ 旧 WebUI。
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md``scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:确认 D601 `/home/ubuntu/.agents/skills/claudeqq` 中存在 `napcat/config/onebot11.json`,确认 UniDesk 仓库中存在 `src/components/microservices/claudeqq/Dockerfile``src/components/microservices/claudeqq/adapter.js`;运行 `bun scripts/cli.ts microservice list`,确认 `claudeqq` 显示为 `providerId=D601``public=false``frontendOnly=true`、仓库 URL `https://gitee.com/lyon1998/agent_skills`、k3s/k8s `k3s://unidesk/claudeqq:3290` 逻辑服务映射、`deployment.mode=k3sctl-managed``runtime.orchestrator=k3sctl` 且无业务直连容器摘要;使用 `bun scripts/cli.ts deploy apply --service claudeqq``deploy.json` 期望状态部署,确认 job 能在 D601 target-side fetch/export 外部 repo 或验证过的 provider worktree、注入 UniDesk 管理的 ClaudeQQ Dockerfile/API adapter、构建 `unidesk-claudeqq:d601`、导入原生 k3s/containerd、apply `src/components/microservices/k3sctl-adapter/k3s/claudeqq.k8s.yaml`、stamp deployment commit、rollout 并通过 UniDesk microservice proxy 验证 live commit。运行 `bun scripts/cli.ts microservice health claudeqq``bun scripts/cli.ts microservice proxy claudeqq /api/napcat/login``bun scripts/cli.ts microservice proxy claudeqq /api/events/recent``bun scripts/cli.ts microservice proxy claudeqq /api/events/subscriptions`,确认链路通过 backend-core、k3sctl-adapter、Kubernetes API service proxy 和 D601 ClaudeQQ Servicehealth 返回 `service=claudeqq``pureBackend=true``napcat.containerized=true`、NapCat HTTP/WS 状态、登录状态/二维码、`/api/push/text``/api/events/subscriptions` 端点;通过 `POST /api/events/subscriptions` 创建临时 webhook 订阅再删除,确认 main server 和其他用户服务可订阅 QQ 消息事件;通过 `POST /api/push/text` 发送消息时若 NapCat 未登录必须返回可解释错误,NapCat 在线时必须返回消息 ID,人工推送验收只允许发给主用户私聊账号 `645275593`。最后登录公网 frontend `http://74.48.78.17:18081/`,进入 `用户服务 / ClaudeQQ`,确认页面以 React 控件显示 D601、仓库引用、私有后端映射、NapCat 容器登录二维码、NapCat 状态、QQ 事件订阅、消息推送、主用户私聊账号 `645275593`、最近 QQ 事件和已发送记录,默认没有裸 JSON,只有点击 `查看原始JSON` 才显示原始数据;不得把 D601 `3290/3000/3001/6099` 暴露到公网,也不得 iframe ClaudeQQ 旧 WebUI。
+3 -3
View File
@@ -180,14 +180,14 @@ Baidu Netdisk 在 UniDesk 语境中按纯后端服务管理:不得暴露百度
- Judge 探针与复现:`GET|POST /api/judge/probe` 使用同一套 judge 逻辑跑内置 synthetic execution records,覆盖正常完成、正常结束但只给计划、未上线/未部署的服务或 WebUI 改动、传输中断和用户打断等样本,返回 `hits``total``hitRate`、每例 `expected``decision`;该接口不得回显 MiniMax API key。真实任务排障必须优先使用 `codex judge <taskId> --attempt N``/api/tasks/{id}/judge?attempt=N`,响应要包含 attempt 窗口、promptChars/payloadBytes、stored judge 对比、MiniMax 失败阶段和是否因历史 per-attempt events 缺失而降级为 retained events 重建。
- 模型选择:默认 Codex 模型是 `gpt-5.5`,内置模型队列包含 `gpt-5.5``gpt-5.4-mini``gpt-5.4``gpt-5.5` 的默认 reasoning effort 必须是 `xhigh`,可通过 `CODE_QUEUE_MODEL_REASONING_EFFORTS` 追加或覆盖模型级默认值;每个入队任务可通过前端模型下拉菜单或 API 覆盖 `model``cwd``reasoningEffort``maxAttempts``maxAttempts` 上限为 `99`。Judge 判定 `retry` 或非用户取消类 `fail` 时必须继续已有 `codexThreadId`,不能新建 session;重试间隔使用指数退避,从 `1s` 开始,最大 `10min`。MiniMax 不可用而进入 fallback/non-LLM 判定时,当前 attempt 的 429、Too Many Requests、exceeded retry limit、overloaded、stream disconnected 等服务/限流错误应判定为 `retry`,不能当作完成;MiniMax 可用时,这些内容只能作为当前 attempt 的 factual evidence 提供给 MiniMax,不能通过硬编码覆盖 MiniMax 结果。
- 状态与日志:`D601` 默认工作目录为容器内 `/workspace`,该路径映射 D601 WSL host 的 `/home/ubuntu`;容器内 `/home/ubuntu` 也必须映射同一个 hostPath,保证从 `/workspace` 看到的绝对 symlink 可以继续解析到真实文件。服务自身仓库路径 `/root/unidesk``/app` 单独映射 D601 WSL host 的 `/home/ubuntu/cq-deploy`。其他 Provider 的任务默认工作目录为 `/home/ubuntu`,任务 JSON、列表、Trace 摘要和 CLI 查询都必须显示 `providerId``executionMode` 与最终 `cwd``executionMode=default` 在 D601 本机 Code Queue 容器中运行 Codex/OpenCode`executionMode=windows-native` 只允许非本机 WSL Provider、Codex 模型和 `/mnt/<drive>` 工作目录,Code Queue 仍会启动远程执行容器,但容器只运行 stdio relay,经 WSL bridge 调用 Windows 宿主原生 `codex app-server --listen stdio://`。Code Queue 的任务、queue、`readAt`/未读状态、attempt、judge、`promptHistory`、active session 元数据、控制状态和 ClaudeQQ 通知 outbox 一律以主 PostgreSQL 为权威,分别写入 `unidesk_code_queue_tasks``unidesk_code_queue_queues``unidesk_code_queue_notifications``DATABASE_URL` 是必需配置,服务不得在 PostgreSQL 缺失或不可用时进入文件存储模式。`.state/code-queue/state.json` 不再作为任务或 queue 状态存储,不得重新引入本地 JSON fallback;服务启动必须以 PostgreSQL 为唯一来源恢复队列,并把 running/judging 任务恢复为 retry_wait。D601 资源比主 server 宽裕,但 Code Queue 仍必须把“内存是稀缺资源”作为核心设计约束:历史任务列表、详情、统计和只读 Trace 查询优先从 PostgreSQL 直读,进程内只保留当前 running/judging、queued、retry_wait 等调度必需热任务;需要短期热缓存时必须有严格上限、可裁剪、可从 PostgreSQL 和 append-only 输出归档重建。WebUI 不得用 browser `localStorage``sessionStorage` 或 IndexedDB 持久化 task/queue/readAt/unread 等业务状态;浏览器只能保留临时 UI 内存缓存,刷新后必须重新从后端读取 PostgreSQL 权威数据。Codex CLI-like output/Trace 的完整记录可以使用 append-only 文件作为日志型归档,但任务状态、未读状态和列表摘要不得依赖这些文件作为权威来源;`/api/tasks/<id>/transcript``/api/tasks/<id>/output` 必须能分页重建完整历史,不得因为热状态裁剪而丢失早期 trace。WebUI 必须支持多 queue 查看、显式创建 queue、提交时下拉选择 queue、提交时下拉选择执行 Provider 和执行模式,并支持把已创建且非 active 的任务移动到其他 queuequeue 内串行,queue 间默认并行且不互相排队;`CODE_QUEUE_MAX_ACTIVE_QUEUES` 仅作为显式配置的全局 active slot 上限,`0` 表示不按 queue 数量限流,内存不足时由 cgroup memory pressure 阻止新 run 并在任务响应中暴露 `QUEUED(MEM LIMIT)`。Code Queue 镜像必须内置 Playwright Chromium 浏览器与系统依赖,并使用 `bun --smol` 运行后端,保证队列任务能直接执行公网 frontend Playwright 回归且主进程内存可控。日志写入 D601 `.state/code-queue/logs` 挂载的 UniDesk JSONL 日志;Codex app-server 上游产生的 `logs_*.sqlite` 只能作为短暂缓冲,必须由 Code Queue 周期性导出为 JSONL,避免重新形成大 SQLite 文件;`/logs` 端点返回最近结构化日志。`/health``queue.storage.primary` 必须恒为 `postgres`,并通过 `queue.storage.postgresReady``queue.devReady``/api/dev-ready` 暴露 PostgreSQL 可用性、develop-ready 自检、必需工具、Docker socket、`docker compose`、默认工作目录、Codex config 状态和 `/root/.ssh` 共享 SSH key 状态;D601 本机默认执行必须能通过 `/root/.ssh` 复用 WSL host GitHub SSH key 执行 Git over SSH,跨 Provider SSH 或 `windows-native` 任务同样需要 `queue.devReady.ssh.ready=true`。Codex CLI-like 输出可能很大,服务必须节流状态持久化,禁止对每个 output delta 同步重写完整 state 导致 `/health` 和控制 API 卡死;容器 healthcheck 必须使用带超时的 HTTP 探针,不能留下堆积的无超时探针进程.
- ClaudeQQ 通知:Code Queue 在 D601 k3s 上通过 `CODE_QUEUE_NOTIFY_CLAUDEQQ_BASE_URL=http://claudeqq.unidesk.svc.cluster.local:3290` 调用 k3s 代管 ClaudeQQ 后端 `POST /api/push/text`,在每个任务进入 `succeeded``failed``canceled` 终态后向配置目标发送最终 response,并附带 task id、queue、状态、模型、attempt、当前 running/queued/retry_wait 数和任务总耗时;当所有 queue 进入 `0 running / 0 queued` 空闲态时,必须单独发送一次空闲提醒。通知由 `CODE_QUEUE_NOTIFY_CLAUDEQQ_ENABLED` 控制,目标由 `CODE_QUEUE_NOTIFY_CLAUDEQQ_TARGET_TYPE=private|group``CODE_QUEUE_NOTIFY_CLAUDEQQ_USER_ID``CODE_QUEUE_NOTIFY_CLAUDEQQ_GROUP_ID` 配置,默认私聊 `645275593`;代理基址、最终 response 最大字符数、单次超时和发送尝试次数分别由 `CODE_QUEUE_NOTIFY_CLAUDEQQ_BASE_URL``CODE_QUEUE_NOTIFY_CLAUDEQQ_MAX_RESPONSE_CHARS``CODE_QUEUE_NOTIFY_CLAUDEQQ_TIMEOUT_MS``CODE_QUEUE_NOTIFY_CLAUDEQQ_SEND_ATTEMPTS` 配置。任务终态和队列空闲通知必须先写入 PostgreSQL outbox 表 `unidesk_code_queue_notifications` 再异步发送;不得使用 `.state/code-queue/claudeqq-notifications.json``CODE_QUEUE_NOTIFY_CLAUDEQQ_OUTBOX_PATH` 或任何本地 JSON 作为通知权威存储。发送失败、NapCat 离线、代理 502 或容器重启时不能丢通知,必须按 `CODE_QUEUE_NOTIFY_CLAUDEQQ_RETRY_INTERVAL_MS` 指数退避重试并跨进程/容器重启保留。`/health``queue.notifications.claudeqq` 必须暴露非敏感配置、目标配置状态和 PostgreSQL outbox 统计;`GET /api/notifications/claudeqq` 返回 outbox 明细,`POST /api/notifications/claudeqq/drain` 手动触发发送,`POST /api/notifications/claudeqq/backfill` 可按 `since` 补入某次故障窗口内已终态任务,确保 QQ/NapCat 超时或离线不会让任务完成通知永久丢失。
- ClaudeQQ 通知:Code Queue 在 D601 k3s 上通过 `CODE_QUEUE_NOTIFY_CLAUDEQQ_BASE_URL=http://claudeqq.unidesk.svc.cluster.local:3290` 调用 k3s 代管 ClaudeQQ 后端 `POST /api/push/text`并在旧 ClaudeQQ 只暴露 `/api/send/text` 时按同一目标自动兼容该接口;在每个任务进入 `succeeded``failed``canceled` 终态后向配置目标发送最终 response,并附带 task id、queue、状态、模型、attempt、当前 running/queued/retry_wait 数和任务总耗时;当所有 queue 进入 `0 running / 0 queued` 空闲态时,必须单独发送一次空闲提醒。通知由 `CODE_QUEUE_NOTIFY_CLAUDEQQ_ENABLED` 控制,目标由 `CODE_QUEUE_NOTIFY_CLAUDEQQ_TARGET_TYPE=private|group``CODE_QUEUE_NOTIFY_CLAUDEQQ_USER_ID``CODE_QUEUE_NOTIFY_CLAUDEQQ_GROUP_ID` 配置,默认私聊 `645275593`;代理基址、最终 response 最大字符数、单次超时和发送尝试次数分别由 `CODE_QUEUE_NOTIFY_CLAUDEQQ_BASE_URL``CODE_QUEUE_NOTIFY_CLAUDEQQ_MAX_RESPONSE_CHARS``CODE_QUEUE_NOTIFY_CLAUDEQQ_TIMEOUT_MS``CODE_QUEUE_NOTIFY_CLAUDEQQ_SEND_ATTEMPTS` 配置。任务终态和队列空闲通知必须先写入 PostgreSQL outbox 表 `unidesk_code_queue_notifications` 再异步发送;不得使用 `.state/code-queue/claudeqq-notifications.json``CODE_QUEUE_NOTIFY_CLAUDEQQ_OUTBOX_PATH` 或任何本地 JSON 作为通知权威存储。发送失败、NapCat 离线、代理 502 或容器重启时不能丢通知,必须按 `CODE_QUEUE_NOTIFY_CLAUDEQQ_RETRY_INTERVAL_MS` 指数退避重试并跨进程/容器重启保留。`/health``queue.notifications.claudeqq` 必须暴露非敏感配置、目标配置状态和 PostgreSQL outbox 统计;`GET /api/notifications/claudeqq` 返回 outbox 明细,`POST /api/notifications/claudeqq/drain` 手动触发发送,`POST /api/notifications/claudeqq/backfill` 可按 `since` 补入某次故障窗口内已终态任务,确保 QQ/NapCat 超时或离线不会让任务完成通知永久丢失。
### ClaudeQQ k3s-Managed
当前 ClaudeQQ 作为 `id=claudeqq``k3sctl-managed` 用户服务登记在 `config.json`,业务实例由 D601 k3s 控制面代管:
- Orchestrator`deployment.mode=k3sctl-managed``deployment.adapterServiceId=k3sctl-adapter``deployment.k3sServiceId=claudeqq``backend.proxyMode=k3sctl-adapter-http``backend.nodeBaseUrl=k3s://claudeqq`backend-core 对 ClaudeQQ 的正式链路只能是 `frontend -> backend-core -> k3sctl-adapter -> Kubernetes API service proxy -> Kubernetes Service claudeqq:3290`
- 部署引用:ClaudeQQ 源码来自 `https://gitee.com/lyon1998/agent_skills``claudeqq` 子目录镜像构建使用该目录内 `Dockerfile`Kubernetes 运行清单为 `src/components/microservices/k3sctl-adapter/k3s/claudeqq.k8s.yaml``config.json` 对外记录 k3s manifest `src/components/microservices/k3sctl-adapter/k3s/claudeqq.k3s.json`。旧 `docker-compose.unidesk.yml` 只作为迁移/本地诊断参考,不是正式运行入口。
- 部署引用:ClaudeQQ 业务源码来自 `https://gitee.com/lyon1998/agent_skills``claudeqq` 子目录镜像构建 Dockerfile 和 `0.0.0.0:3290` API 适配器由 UniDesk 仓库 `src/components/microservices/claudeqq/` 作为 k3s 部署资产注入,不能依赖 D601 未提交工作树文件。Kubernetes 运行清单为 `src/components/microservices/k3sctl-adapter/k3s/claudeqq.k8s.yaml``config.json` 对外记录 k3s manifest `src/components/microservices/k3sctl-adapter/k3s/claudeqq.k3s.json`。旧 `docker-compose.unidesk.yml` 只作为迁移/本地诊断参考,不是正式运行入口。
- 运行对象:ClaudeQQ Pod 在 D601 上以同 Pod 双容器运行 `claudeqq``napcat`,仅通过 ClusterIP Service 暴露 `claudeqq:3290` 给 UniDesk 代理和 Code Queue 通知链路;NapCat 的 `3000/3001/6099` 只在 Pod 内可达,不得作为 NodePort、hostPort 或 provider-gateway 业务直连目标。
- 持久化路径:NapCat 登录态保存在 D601 hostPath `/home/ubuntu/.agents/skills/claudeqq/napcat/qq`,NapCat 配置和二维码缓存分别保存在 `napcat/config``napcat/cache`;ClaudeQQ 后端挂载同一业务目录中的 `config.json``bot_workspace``logs``.state` 和只读 `napcat`。这些路径不得改成匿名 volume,避免重建 Pod 后 QQ 登录态和事件/订阅状态丢失。
- 代理/API:只允许 `/health``/logs``/api/` 前缀;允许方法为 `GET``HEAD``POST``DELETE``POST /api/push/text` 接受 `userId``groupId``message`,由 ClaudeQQ 通过同 Pod NapCat HTTP API 发送 QQ 消息;NapCat 不可用时必须快速返回 `status=napcat_offline` 或可解释错误。
@@ -279,7 +279,7 @@ ClaudeQQ 的业务源码和持久化数据仍在 D601,但正式运行由 k3s
- Provider`D601`
- 开发工作树:`/home/ubuntu/.agents/skills/claudeqq`,后端、Dockerfile、订阅分发和 NapCat 连接调试必须通过 UniDesk SSH 透传在 D601 完成;主 server 本地只允许开发 UniDesk frontend、代理登记和 k3s manifest。
- 代码引用:`https://gitee.com/lyon1998/agent_skills` 与配置中的 `repository.commitId`,实际服务目录为仓库内 `claudeqq/`
- 部署引用:标准 desired state 为根目录 `deploy.json` 中的 `claudeqq` 服务;运行清单为 UniDesk 仓库内 `src/components/microservices/k3sctl-adapter/k3s/claudeqq.k3s.json``claudeqq.k8s.yaml`,镜像名固定 `unidesk-claudeqq:d601`。业务仓库内 `docker-compose.unidesk.yml` 只作为迁移/本地诊断参考,不是正式部署入口。
- 部署引用:标准 desired state 为根目录 `deploy.json` 中的 `claudeqq` 服务;运行清单为 UniDesk 仓库内 `src/components/microservices/k3sctl-adapter/k3s/claudeqq.k3s.json``claudeqq.k8s.yaml`,镜像名固定 `unidesk-claudeqq:d601`;构建 Dockerfile 与 API 适配器由 `src/components/microservices/claudeqq/` 注入。业务仓库内 `docker-compose.unidesk.yml` 只作为迁移/本地诊断参考,不是正式部署入口。
- 运行配置:ClaudeQQ Pod 同时包含 `napcat``claudeqq` 两个容器;`claudeqq` 固定 `CLAUDEQQ_AUTO_REPLY=false``CLAUDEQQ_NAPCAT_HTTP_HOST=127.0.0.1``CLAUDEQQ_NAPCAT_WS_HOST=127.0.0.1``CLAUDEQQ_ONLINE_NOTICE_USER_ID=645275593``CLAUDEQQ_LOGIN_MONITOR_INTERVAL_MS`。NapCat 的 `3000/3001/6099` 仅 Pod 内访问。
- 节点后端:UniDesk 逻辑后端为 `k3s://unidesk/claudeqq:3290`,由 backend-core 经过 k3sctl-adapter 和 Kubernetes API service proxy 访问 Kubernetes Service `claudeqq:3290`;不得再通过 provider-gateway 业务代理或 D601 本机端口作为正式链路。
- NapCat 登录 API`GET /api/napcat/login``GET /api/napcat/status` 返回容器化状态、HTTP/WS 连通性、登录状态和二维码 data URL;`GET /api/napcat/qrcode` 只返回二维码。二维码来源为共享挂载中的 `/napcat/cache/qrcode.png`,由 ClaudeQQ 后端转为 JSON data URL 后经 UniDesk 同源代理给前端展示。`/health` 的 healthy 条件必须包含 `ready=true`、NapCat HTTP connected、NapCat WebSocket connected 和 `loginState=logged_in`;仅有二维码、NapCat 容器 running 或后端进程 running 不能算健康。
+27 -1
View File
@@ -627,7 +627,7 @@ function prepareSourceScript(service: UniDeskMicroserviceConfig, desired: Deploy
" tail -80 \"$source_log\" || true",
" if [ -n \"$fallback_worktree\" ] && git -C \"$fallback_worktree\" rev-parse --is-inside-work-tree >/dev/null 2>&1 && git -C \"$fallback_worktree\" rev-parse --verify \"$commit^{commit}\" > /tmp/unidesk-deploy-resolved-commit 2>/dev/null; then",
" resolved=$(cat /tmp/unidesk-deploy-resolved-commit)",
" source_repo=\"$fallback_worktree\"",
" source_repo=$(git -C \"$fallback_worktree\" rev-parse --show-toplevel)",
" source_mode=\"provider-worktree\"",
" printf 'target_source_fallback_worktree=%s\\n' \"$fallback_worktree\"",
" else",
@@ -647,6 +647,7 @@ function syncSourceScript(service: UniDeskMicroserviceConfig, exportDir: string)
const workDir = sourceWorkDir(service);
const buildContext = sourceBuildContext(service);
const dockerfilePath = sourceDockerfilePath(service);
const overlayCommands = service.id === "claudeqq" ? claudeqqDeployAssetOverlayCommands() : [];
return [
"set -euo pipefail",
`export_dir=${shellQuote(exportDir)}`,
@@ -664,11 +665,36 @@ function syncSourceScript(service: UniDeskMicroserviceConfig, exportDir: string)
"\"$export_dir/\"",
"\"$work_dir/\"",
].join(" "),
...overlayCommands,
"test -f \"$build_context/$dockerfile_path\"",
"printf 'synced deploy worktree to %s\\nbuild_context=%s\\n' \"$work_dir\" \"$build_context\"",
].join("\n");
}
function claudeqqDeployAssetOverlayCommands(): string[] {
const assets = [
{
relativePath: "$build_context/$dockerfile_path",
sourcePath: rootPath("src/components/microservices/claudeqq/Dockerfile"),
label: "Dockerfile",
},
{
relativePath: "$build_context/unidesk-adapter.cjs",
sourcePath: rootPath("src/components/microservices/claudeqq/adapter.js"),
label: "unidesk-adapter.cjs",
},
];
return assets.flatMap((asset) => {
if (!existsSync(asset.sourcePath)) throw new Error(`claudeqq deploy asset missing: ${asset.sourcePath}`);
const encoded = Buffer.from(readFileSync(asset.sourcePath, "utf8"), "utf8").toString("base64");
return [
`mkdir -p "$(dirname "${asset.relativePath}")"`,
`printf %s ${shellQuote(encoded)} | base64 -d > "${asset.relativePath}"`,
`printf 'synced_claudeqq_deploy_asset=%s\\n' ${shellQuote(asset.label)}`,
];
});
}
function syncK8sControlManifestsScript(service: UniDeskMicroserviceConfig): string {
if (service.deployment.mode !== "k3sctl-managed" || isUnideskRepo(service.repository.url)) return "";
const manifests = [service.repository.composeFile, k8sManifestPath(service)];
@@ -0,0 +1,26 @@
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY scripts/src/server_ts/package*.json ./scripts/src/server_ts/
WORKDIR /app/scripts/src/server_ts
RUN npm ci
WORKDIR /app
COPY . .
WORKDIR /app/scripts/src/server_ts
RUN npm run build
FROM node:22-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build /app/config.json ./config.json
COPY --from=build /app/scripts/src/server_ts/package*.json ./scripts/src/server_ts/
COPY --from=build /app/scripts/src/server_ts/dist ./scripts/src/server_ts/dist
COPY --from=build /app/unidesk-adapter.cjs ./scripts/src/server_ts/unidesk-adapter.cjs
WORKDIR /app/scripts/src/server_ts
RUN npm ci --omit=dev && npm cache clean --force
CMD ["node", "dist/index.js", "--max-restarts", "0"]
@@ -0,0 +1,130 @@
const http = require("node:http");
const { spawn } = require("node:child_process");
const { URL } = require("node:url");
const listenHost = process.env.CLAUDEQQ_ADAPTER_HOST || "0.0.0.0";
const listenPort = Number.parseInt(process.env.CLAUDEQQ_ADAPTER_PORT || "3290", 10);
const upstreamHost = process.env.CLAUDEQQ_UPSTREAM_HOST || "127.0.0.1";
const upstreamPort = Number.parseInt(process.env.CLAUDEQQ_UPSTREAM_PORT || "9082", 10);
const deployCommit = process.env.UNIDESK_DEPLOY_COMMIT || "";
const deployRequestedCommit = process.env.UNIDESK_DEPLOY_REQUESTED_COMMIT || deployCommit;
const deployRepo = process.env.UNIDESK_DEPLOY_REPO || "";
const serviceId = process.env.UNIDESK_DEPLOY_SERVICE_ID || "claudeqq";
const startedAt = new Date().toISOString();
const child = spawn("node", ["dist/index.js", "--max-restarts", "0"], {
cwd: process.env.CLAUDEQQ_SERVER_CWD || process.cwd(),
env: process.env,
stdio: "inherit",
});
child.on("exit", (code, signal) => {
console.error(JSON.stringify({ ts: new Date().toISOString(), service: serviceId, event: "upstream_exited", code, signal }));
process.exit(code === null ? 1 : code);
});
function json(res, status, body) {
const text = JSON.stringify(body);
res.writeHead(status, {
"content-type": "application/json; charset=utf-8",
"content-length": Buffer.byteLength(text),
});
res.end(text);
}
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
req.on("end", () => resolve(Buffer.concat(chunks)));
req.on("error", reject);
});
}
function normalizePushPayload(raw) {
const body = JSON.parse(raw.length > 0 ? raw.toString("utf8") : "{}");
const message = typeof body.message === "string" ? body.message : "";
if (message.length === 0) throw new Error("message is required");
if (body.targetType === "group" || body.groupId !== undefined) {
const groupId = typeof body.groupId === "string" ? body.groupId : String(body.groupId || "");
if (groupId.length === 0) throw new Error("groupId is required");
return { groupId, message };
}
const userId = typeof body.userId === "string" ? body.userId : String(body.userId || "");
if (userId.length === 0) throw new Error("userId is required");
return { userId, message };
}
function upstreamFetch(path, method = "GET") {
return new Promise((resolve) => {
const req = http.request({ host: upstreamHost, port: upstreamPort, path, method, timeout: 2000 }, (res) => {
const chunks = [];
res.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
res.on("end", () => resolve({ ok: (res.statusCode || 500) < 500, status: res.statusCode || 0, body: Buffer.concat(chunks).toString("utf8") }));
});
req.on("timeout", () => req.destroy(new Error("timeout")));
req.on("error", (error) => resolve({ ok: false, status: 0, body: String(error?.message || error) }));
req.end();
});
}
async function proxy(req, res, path, bodyOverride) {
const incomingBody = bodyOverride === undefined ? await readBody(req) : Buffer.from(JSON.stringify(bodyOverride));
const headers = { ...req.headers };
delete headers.host;
headers["content-length"] = String(incomingBody.length);
if (bodyOverride !== undefined) headers["content-type"] = "application/json";
const options = {
host: upstreamHost,
port: upstreamPort,
method: req.method || "GET",
path,
headers,
};
const upstream = http.request(options, (upstreamRes) => {
res.writeHead(upstreamRes.statusCode || 502, upstreamRes.headers);
upstreamRes.pipe(res);
});
upstream.on("error", (error) => {
json(res, 502, { ok: false, success: false, service: serviceId, error: String(error?.message || error) });
});
upstream.end(incomingBody);
}
const server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
if (url.pathname === "/health") {
const upstreamHealth = await upstreamFetch("/health");
json(res, upstreamHealth.ok ? 200 : 503, {
ok: upstreamHealth.ok,
service: serviceId,
pureBackend: true,
napcat: { containerized: true },
adapter: "unidesk-claudeqq-adapter",
startedAt,
upstream: { url: `http://${upstreamHost}:${upstreamPort}`, ok: upstreamHealth.ok, status: upstreamHealth.status },
deploy: {
repo: deployRepo,
commit: deployCommit,
requestedCommit: deployRequestedCommit,
},
});
return;
}
if (url.pathname === "/api/push/text" && req.method === "POST") {
const body = normalizePushPayload(await readBody(req));
await proxy({ ...req, method: "POST" }, res, "/api/send/text", body);
return;
}
await proxy(req, res, `${url.pathname}${url.search}`, undefined);
} catch (error) {
json(res, 400, { ok: false, success: false, service: serviceId, error: String(error?.message || error) });
}
});
server.listen(listenPort, listenHost, () => {
console.log(JSON.stringify({ ts: new Date().toISOString(), service: serviceId, event: "adapter_listening", listenHost, listenPort, upstreamHost, upstreamPort }));
});
@@ -221,6 +221,12 @@ function notificationTargetLabel(): string {
: `private:${ctx().config.notifyClaudeQqUserId || "-"}`;
}
function claudeQqSendPayload(message: string): Record<string, string> {
return ctx().config.notifyClaudeQqTargetType === "group"
? { groupId: ctx().config.notifyClaudeQqGroupId, message }
: { userId: ctx().config.notifyClaudeQqUserId, message };
}
function truncateNotificationText(value: string, maxChars: number): string {
if (value.length <= maxChars) return value;
return `${value.slice(0, maxChars)}\n\n...[Code Queue notification truncated: ${value.length - maxChars} chars omitted; use CLI/WebUI for the full trace]`;
@@ -393,20 +399,30 @@ async function drainClaudeQqNotificationOutbox(trigger = "manual"): Promise<Reco
async function postClaudeQqText(kind: string, message: string): Promise<void> {
if (!notificationTargetConfigured()) return;
const url = `${ctx().config.notifyClaudeQqBaseUrl}/api/push/text`;
const primaryUrl = `${ctx().config.notifyClaudeQqBaseUrl}/api/push/text`;
const legacyUrl = `${ctx().config.notifyClaudeQqBaseUrl}/api/send/text`;
let lastError: unknown = null;
for (let attempt = 1; attempt <= ctx().config.notifyClaudeQqSendAttempts; attempt += 1) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ctx().config.notifyClaudeQqTimeoutMs);
let responseText = "";
try {
const response = await fetch(url, {
let response = await fetch(primaryUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(claudeQqTargetPayload(message)),
signal: controller.signal,
});
responseText = await response.text();
if (response.status === 404 || response.status === 405) {
response = await fetch(legacyUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(claudeQqSendPayload(message)),
signal: controller.signal,
});
responseText = await response.text();
}
if (!response.ok) throw new Error(`ClaudeQQ proxy returned HTTP ${response.status}: ${ctx().safePreview(responseText, 500)}`);
try {
const parsed = JSON.parse(responseText) as Record<string, unknown>;
@@ -75,9 +75,7 @@ spec:
workingDir: /app/scripts/src/server_ts
command:
- node
- dist/index.js
- --max-restarts
- "0"
- unidesk-adapter.cjs
ports:
- name: http
containerPort: 3290
@@ -86,6 +84,12 @@ spec:
value: "0.0.0.0"
- name: CLAUDEQQ_PORT
value: "3290"
- name: CLAUDEQQ_ADAPTER_PORT
value: "3290"
- name: CLAUDEQQ_UPSTREAM_HOST
value: "127.0.0.1"
- name: CLAUDEQQ_UPSTREAM_PORT
value: "9082"
- name: CLAUDEQQ_WORKSPACE_DIR
value: "/bot_workspace"
- name: CLAUDEQQ_AUTO_REPLY