feat: add decision center service

This commit is contained in:
Codex
2026-05-17 06:17:17 +00:00
parent 1cafe6da6a
commit d74439ecba
26 changed files with 1517 additions and 14 deletions
+4 -3
View File
@@ -33,6 +33,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文
- `bun scripts/cli.ts provider attach <providerId> [--master-server URL] [--up] [--force]`:在新增计算节点上生成两项配置的 provider-gateway 挂载包;默认只需要主 server URL(默认 `http://74.48.78.17/`)和唯一 Provider ID,生成的 Compose 固定 Docker socket、`pid: "host"``restart: always`、只读 `/workspace`、SSH 维护私钥挂载和 loopback egress proxy 端口,规则见 `docs/reference/provider-gateway.md`
- `bun scripts/cli.ts ssh <providerId> [ssh-like args...]`:通过 provider-gateway 的 Host SSH / WSL SSH 维护桥打开近似原生 ssh 的交互会话或远端命令,并在远端 PATH 注入 `apply_patch``glob``skill-discover``apply-patch``py``skills`、结构化 `find``glob``argv` 子命令用于避免远端补丁、Python stdin、skill 发现与常用只读命令的嵌套转义问题,使用规则见 `docs/reference/cli.md``docs/reference/provider-gateway.md`
- `bun scripts/cli.ts microservice list/status/health/proxy`:管理和验证挂载在主 server、计算节点 Docker 或 k3s 控制面上的用户服务,OA Event Flow/Todo Note/Baidu Netdisk on main-server、k3s Control/Code Queue/MDTODO/FindJob/Pipeline/MET Nonlinear on D601 的规则见 `docs/reference/microservices.md`
- `bun scripts/cli.ts decision upload/list/show/health`:通过 backend-core 用户服务代理上传会议记录/决议 Markdown、列出记录和查看详情;Decision Center 运行在 D601 k3s,规则见 `docs/reference/microservices.md`
- `bun scripts/cli.ts deploy check/plan/apply [--file deploy.json] [--service <id>]`:按根目录 `deploy.json` 的服务 repo 和 commit 期望状态校验或更新用户服务,目标侧自行 fetch、构建、部署和 live commit 验证;规则见 `docs/reference/deploy.md`
- `bun scripts/cli.ts ci install/status/run/logs`:在 D601 原生 k3s 上安装和运行 Tekton CI,只做每 commit 检查和 Code Queue 只读性能门禁,不部署 CD;规则见 `docs/reference/ci.md`
- `bun scripts/cli.ts codex deploy <commitId>`:Code Queue 兼容部署入口,会生成临时 desired manifest 并调用 `deploy apply --service code-queue` 的同一条 target-side build 与 live commit 验证路径;规则见 `docs/reference/codex-deploy.md`
@@ -46,8 +47,8 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文
## Runtime
- `bun`TypeScript 运行时固定使用 Bun,组件入口和 CLI 都直接运行 `.ts` 文件,约束见 `docs/reference/config.md`
- `docker-compose.yml`:主 server 统一编排 core、frontend、database、本机 provider gateway、Todo Note 后端、Baidu Netdisk 后端和 OA Event Flow 后端;Code QueueMDTODO 由 D601 k3s/k8s 控制面代管,并经 `k3sctl-adapter` 的 Kubernetes API service proxy 单一路径接入,服务拓扑见 `docs/reference/deployment.md`
- `src/components/frontend`:前端源码固定使用 TypeScript + React`app.tsx` 只做 shell/router,左侧主模块与顶部子标签统一编译为模块前缀路由:`/ops/<tab>/``/nodes/<tab>/``/tasks/<tab>/``/config/<tab>/`,只有用户服务使用 `/app/<tab>/` 深链接,运行总览包含通用性能面板,资源监控含曲线和进程资源排序表,Todo Note、FindJob、Pipeline、MET Nonlinear、Baidu Netdisk、Code Queue、MDTODO、OA Event Flow、k3s Control 等业务页必须拆到独立 TSX 模块,界面规则见 `docs/reference/frontend.md`
- `docker-compose.yml`:主 server 统一编排 core、frontend、database、本机 provider gateway、Todo Note 后端、Baidu Netdisk 后端和 OA Event Flow 后端;Code QueueMDTODO 和 Decision Center 由 D601 k3s/k8s 控制面代管,并经 `k3sctl-adapter` 的 Kubernetes API service proxy 单一路径接入,服务拓扑见 `docs/reference/deployment.md`
- `src/components/frontend`:前端源码固定使用 TypeScript + React`app.tsx` 只做 shell/router,左侧主模块与顶部子标签统一编译为模块前缀路由:`/ops/<tab>/``/nodes/<tab>/``/tasks/<tab>/``/config/<tab>/`,只有用户服务使用 `/app/<tab>/` 深链接,运行总览包含通用性能面板,资源监控含曲线和进程资源排序表,Todo Note、FindJob、Pipeline、MET Nonlinear、Baidu Netdisk、Code Queue、MDTODO、Decision Center、OA Event Flow、k3s Control 等业务页必须拆到独立 TSX 模块,界面规则见 `docs/reference/frontend.md`
- `backend-core / frontend performance`backend-core 暴露 `/api/performance`frontend 暴露同源 `/api/frontend-performance` 并在 `/ops/performance/` 汇总组件请求、失败请求、内部操作和慢操作,规则见 `docs/reference/observability.md`。backend-core 源码已拆分为 15 个模块,结构见 `docs/reference/repo-tree.md`
- `Unified OA event flow``oa-event-flow` 是独立主 server 用户服务,提供事件表、按 tag 订阅和 Trace/STEP 统计中心,Code Queue 与 Pipeline 都必须接入统一事件流;共享契约见 `docs/reference/oa-event-flow.md`Pipeline 专有控制流规则见 `docs/reference/pipeline-oa-event-flow.md`
- `src/components/provider-gateway`:当前主 server `74.48.78.17` 也作为 provider gateway 接入 UniDesk,外部节点通过 `ws://74.48.78.17:18082/ws/provider` 接入,必须以 `restart: always` 部署 always-enabled 远程升级、sleep-and-validate 回滚保护和 Host SSH / WSL SSH 透传并完成自测,部署与 Playwright 公网前端验证方法见 `docs/reference/provider-gateway.md`
@@ -59,7 +60,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文
- `docs/reference/arch.md`:UniDesk 分布式工作平台的长期架构约束。
- `docs/reference/repo-tree.md`:仓库结构目标与组件边界。
- `docs/reference/observability.md`:服务日志、任务活性、通用性能指标 API 和性能面板的可观测性规则。
- `docs/reference/microservices.md`:用户服务(兼容命名 `microservice`)的配置、代理、安全边界、unidesk-direct/k3sctl-managed 部署模式、Todo Note/Baidu Netdisk on main-server、k3s Control/Code Queue/MDTODO/FindJob/Pipeline/MET Nonlinear on D601 和验证规则。
- `docs/reference/microservices.md`:用户服务(兼容命名 `microservice`)的配置、代理、安全边界、unidesk-direct/k3sctl-managed 部署模式、Todo Note/Baidu Netdisk on main-server、k3s Control/Code Queue/MDTODO/Decision Center/FindJob/Pipeline/MET Nonlinear on D601 和验证规则。
- `docs/reference/windows-passthrough.md`WSL provider 通过 SSH 透传调用 Windows cmd/PowerShell、Keil、COM 串口和 Windows 侧 skill 的长期规则。
- `docs/reference/constar-d601.md`D601 上 ConStart/constar 固件工作区的 UniDesk SSH 入口、WSL skill wrapper、Keil 编译下载和串口/JSON-RPC 验证简要引导。
- `docs/reference/oa-event-flow.md`:统一 OA 事件流微服务、事件表、tag 订阅、Trace/STEP 统计中心和前端可见性规则。
+4
View File
@@ -111,6 +111,10 @@
阅读 `AGENTS.md``docs/reference/ci.md`,运行 `bun scripts/cli.ts ci install`,确认 Tekton Pipelines `v1.12.0`、Tekton Triggers `v0.34.0``unidesk-ci` Pipeline/Task/EventListener 已部署到 D601 原生 k3s;随后运行 `bun scripts/cli.ts ci run --revision <已push的commitId> --wait-ms 1200000`,确认 PipelineRun 只执行 clone/check/performance,不调用 `deploy apply``codex deploy`,并确认临时 `code-queue-ci-read` 使用主 PostgreSQL 只读查询 Code Queue 首屏、TraceView summary、TraceView steps 和 step detail 的性能指标。若失败,使用 `bun scripts/cli.ts ci logs <pipelineRun>` 查看 TaskRun 和 Pod 日志;交付说明必须记录性能预算是否通过。
## T23B D601 Decision Center User Service
阅读 `AGENTS.md``docs/reference/microservices.md`,运行 `bun scripts/cli.ts microservice list`,确认 `decision-center` 显示为 `providerId=D601``public=false``frontendOnly=true`、仓库 URL `https://github.com/pikasTech/unidesk`、k3s/k8s `k3s://unidesk/decision-center:4277` 逻辑服务映射、`deployment.mode=k3sctl-managed``runtime.orchestrator=k3sctl` 且无业务直连容器摘要;使用 `bun scripts/cli.ts deploy apply --service decision-center``deploy.json` 期望状态部署,确认 job 在 D601 target-side build、导入原生 k3s/containerd、apply `src/components/microservices/k3sctl-adapter/k3s/decision-center.k8s.yaml`、stamp deployment commit、rollout 并通过 UniDesk microservice proxy 验证 live commit。运行 `bun scripts/cli.ts microservice health decision-center`,确认 `service=decision-center``storage=postgres``schemaReady=true`;准备一份临时 Markdown 会议记录,运行 `bun scripts/cli.ts decision upload <markdown-file> --title <title> --type meeting --level G1 --status active --evidence <url>`,再运行 `bun scripts/cli.ts decision list``bun scripts/cli.ts decision show <id>`,确认 CLI 只通过 backend-core 用户服务代理访问,返回结构化 JSON 且能看到刚上传的记录。最后登录公网 frontend `http://74.48.78.17:18081/`,进入 `用户服务 / Decision Center`,确认页面显示 G0/G1 目标、P0/P1 Blocker、停放事项、最近会议/决议、筛选和全部记录表,刚上传的会议记录可见;页面不得提供聊天/LLM 会话窗口,默认不得裸 JSON,完整 JSON 只能通过 `查看原始JSON` 打开。
## T24 MET Nonlinear D601 GPU User Service
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md``scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:确认 D601 `~/met_nonlinear` 中存在 `docker-compose.unidesk.yml``docker/unidesk/Dockerfile.ml``unidesk/server/src/index.ts``docs/reference/unidesk_microservice.md`;运行 `bun scripts/cli.ts microservice list`,确认 `met-nonlinear` 显示为 `providerId=D601``public=false``frontendOnly=true``127.0.0.1:3288` 后端映射和 `met-nonlinear-ts` 容器摘要;运行 `bun scripts/cli.ts microservice health met-nonlinear``bun scripts/cli.ts microservice proxy met-nonlinear /api/queue``bun scripts/cli.ts microservice proxy met-nonlinear '/api/projects?root=projects&limit=500'``bun scripts/cli.ts microservice proxy met-nonlinear '/api/projects?root=ex_projects&limit=500'``bun scripts/cli.ts microservice proxy met-nonlinear '/api/projects/config?path=projects/<name>' --raw``bun scripts/cli.ts microservice proxy met-nonlinear /api/images`,确认链路通过 backend-core、D601 provider-gateway 和 D601 本机 TS 后端,项目详情包含 `config``progress``data``model``metrics` 字段;最后登录公网 frontend `http://74.48.78.17:18081/`,进入 `用户服务 / MET Nonlinear`,确认项目库按 `projects/``ex_projects/` 文件树层级展示且文件夹 Project 数与后端返回数量一致,点击项目行能看到结构化 `config.json``data/` 训练状态、模型参数量和指标;通过 UI 选择已有 source Project,设置训练轮数和最大并发,使用 `Fork Project` 创建新的 `projects/unidesk_forks/` Project,确认新 Project 被自动勾选但不会直接训练,再点击 `加入待启动队列``启动队列`;完整验收可用 UI 输入 `Fork 数量=10``训练轮数=200``最大并发=3`,但这个规模只能由输入框配置,不能作为硬编码按钮。确认最多按 UI 设置的并发数运行、目标 GPU 是 2080Ti、显存余量低于 20% 时自动限制并发、任务最终进入已完成或失败诊断标签且训练容器自动销毁。页面必须以 React 控件显示项目库、待启动/排队/训练中、已完成、失败诊断、GPU/镜像、训练进度、ETA、`epoch/h` 训练速度和历史记录;项目库、当前队列、已完成和失败列表中的项目必须可点击打开详情;默认没有裸 JSON,只有点击 `查看原始JSON` 才显示原始数据;前端不得再提供 `创建10个10轮任务` 这类硬编码测试按钮。
+56
View File
@@ -699,6 +699,62 @@
],
"activeNodeId": "D601"
}
},
{
"id": "decision-center",
"name": "Decision Center",
"providerId": "D601",
"description": "Decision Center 是由 D601 k3s 控制面代管的决策权威记录服务,用于沉淀会议记录、决议、目标、问题分级、停放事项和证据;参谋对话仍使用 Codex 原生会话。",
"repository": {
"url": "https://github.com/pikasTech/unidesk",
"commitId": "eb2660d3b777e01291506c418352ab6cfa4eca35",
"dockerfile": "src/components/microservices/decision-center/Dockerfile",
"composeFile": "src/components/microservices/k3sctl-adapter/k3s/decision-center.k3s.json",
"composeService": "decision-center",
"containerName": "k3s:decision-center"
},
"backend": {
"nodeBaseUrl": "k3s://decision-center",
"nodeBindHost": "k3s://unidesk/decision-center",
"nodePort": 4277,
"proxyMode": "k3sctl-adapter-http",
"frontendOnly": true,
"public": false,
"allowedMethods": [
"GET",
"HEAD",
"POST",
"PUT",
"DELETE"
],
"allowedPathPrefixes": [
"/health",
"/live",
"/logs",
"/api/"
],
"healthPath": "/health",
"timeoutMs": 30000
},
"development": {
"providerId": "D601",
"sshPassthrough": true,
"worktreePath": "/home/ubuntu/cq-deploy/src/components/microservices/decision-center"
},
"frontend": {
"route": "/apps/decision-center",
"integrated": true
},
"deployment": {
"mode": "k3sctl-managed",
"adapterServiceId": "k3sctl-adapter",
"k3sServiceId": "decision-center",
"namespace": "unidesk",
"expectedNodeIds": [
"D601"
],
"activeNodeId": "D601"
}
}
],
"paths": {
+5
View File
@@ -55,6 +55,11 @@
"id": "mdtodo",
"repo": "https://github.com/pikasTech/unidesk",
"commitId": "75fb6757b2504ba86d61f2587fb34a9c9ed4019a"
},
{
"id": "decision-center",
"repo": "https://github.com/pikasTech/unidesk",
"commitId": "eb2660d3b777e01291506c418352ab6cfa4eca35"
}
]
}
+3 -2
View File
@@ -19,6 +19,7 @@ UniDesk 的统一 CLI 入口是根目录 `scripts/cli.ts`,运行方式固定
- `ssh <providerId> py [script-args...] < script.py` 把本地 stdin 落到远端临时 `.py` 文件后再以 `python3 -u` 执行并自动清理,避免再手写 `'python3 -'`、heredoc 或多层引号;`script-args` 会按 argv 安全透传给远端脚本。
- `ssh <providerId> skills [--scope all|wsl|windows] [--limit N]` 发现目标节点上的 WSL/Linux skill 根目录;当 provider 是 WSL 时同一次调用还会扫描 Windows 用户目录下的 `.agents/skills``.codex/skills`
- `microservice list/status/health/proxy` 通过 backend-core 内网 API 管理挂载在计算节点 Docker 中的用户服务(底层命令名仍为 microservice);`health``proxy` 会走真实 backend-core -> provider-gateway -> 节点本机后端链路,`proxy` 对超大 body 默认输出有界预览,规则见 `docs/reference/microservices.md`
- `decision upload/list/show/health` 通过 backend-core 用户服务代理访问 D601 k3s Decision Center,用于上传会议记录/决议 Markdown、列出权威记录、查看详情和健康检查;它不得直连 D601 Service、NodePort 或 provider-gateway 业务 HTTP。
- `deploy check/plan/apply` 从根目录 `deploy.json` 读取服务 repo 与 commit 期望状态,join `config.json` 和现有 manifest 后使用 target-side build 单一路径校验或更新直管服务与 k3s 代管服务;规则见 `docs/reference/deploy.md`
- `codex deploy <commitId>` 是 Code Queue 兼容部署入口,会生成临时 desired manifest 并调用 `deploy apply --service code-queue` 的同一条 target-side build、k3s import、rollout 和 live commit 验证路径;详细规则见 `docs/reference/codex-deploy.md`
- `codex task <taskId>` 通过 Code Queue 私有代理按任务 ID 查询结构化执行摘要;默认只返回有界 prompt/response 预览、执行 Provider、工作目录、最后 assistant message、最近工具调用摘要、attempt、judge、错误、耗时和 trace 翻页提示,适合在新队列任务中引用历史 session 且避免噪声爆炸。
@@ -34,7 +35,7 @@ UniDesk 的统一 CLI 入口是根目录 `scripts/cli.ts`,运行方式固定
长时操作采用 Fire-and-Forget 模式:CLI 创建 `.state/jobs/{jobId}.json`,后台进程执行真实命令,并将 stdout、stderr 分别写入 `.state/jobs/{jobId}.stdout.log``.state/jobs/{jobId}.stderr.log`。调用者通过 `bun scripts/cli.ts job status <jobId>` 查询进度和尾部输出。
`server rebuild``server start``server stop` 一样必须通过返回的 job id 确认结果;不要把连续 `server rebuild` 命令理解成“前一个重建已完成”,因为两个命令只是在快速创建异步 job。重建 frontend 的标准流程是运行 `bun scripts/cli.ts server rebuild frontend`,随后轮询 `bun scripts/cli.ts job status <jobId>``succeeded`,再用 `server status``e2e run` 验证公网 frontend;重建 Todo Note 后端使用 `bun scripts/cli.ts server rebuild todo-note`,随后用 `microservice health todo-note``microservice proxy todo-note /api/instances` 验证;重建 Project Manager 后端使用 `bun scripts/cli.ts server rebuild project-manager`,随后用 `microservice health project-manager``microservice proxy project-manager /api/projects` 验证;重建 Baidu Netdisk 后端使用 `bun scripts/cli.ts server rebuild baidu-netdisk`,随后用 `microservice health baidu-netdisk``microservice proxy baidu-netdisk /api/transfers` 验证;重建 OA Event Flow 后端使用 `bun scripts/cli.ts server rebuild oa-event-flow`,随后用 `microservice health oa-event-flow``microservice proxy oa-event-flow /api/diagnostics` 验证。Code Queue 后端由 D601 k3s/k8s 控制面代管,必须使用 `bun scripts/cli.ts deploy apply --service code-queue` 兼容入口 `bun scripts/cli.ts codex deploy <commitId>` 部署已 push 的 remote commit;部署 job 自身必须通过真实 `/health` 和 k3s Deployment annotation 证明不是旧服务在充数,之后再用 `microservice health code-queue``microservice proxy code-queue /api/tasks/overview` 做人工复核。不得把 `docker rm` 手工兜底当成正式交付步骤。
`server rebuild``server start``server stop` 一样必须通过返回的 job id 确认结果;不要把连续 `server rebuild` 命令理解成“前一个重建已完成”,因为两个命令只是在快速创建异步 job。重建 frontend 的标准流程是运行 `bun scripts/cli.ts server rebuild frontend`,随后轮询 `bun scripts/cli.ts job status <jobId>``succeeded`,再用 `server status``e2e run` 验证公网 frontend;重建 Todo Note 后端使用 `bun scripts/cli.ts server rebuild todo-note`,随后用 `microservice health todo-note``microservice proxy todo-note /api/instances` 验证;重建 Project Manager 后端使用 `bun scripts/cli.ts server rebuild project-manager`,随后用 `microservice health project-manager``microservice proxy project-manager /api/projects` 验证;重建 Baidu Netdisk 后端使用 `bun scripts/cli.ts server rebuild baidu-netdisk`,随后用 `microservice health baidu-netdisk``microservice proxy baidu-netdisk /api/transfers` 验证;重建 OA Event Flow 后端使用 `bun scripts/cli.ts server rebuild oa-event-flow`,随后用 `microservice health oa-event-flow``microservice proxy oa-event-flow /api/diagnostics` 验证。Code Queue 和 Decision Center 后端由 D601 k3s/k8s 控制面代管,必须使用 `bun scripts/cli.ts deploy apply --service code-queue``bun scripts/cli.ts deploy apply --service decision-center` 或 Code Queue 兼容入口 `bun scripts/cli.ts codex deploy <commitId>` 部署已 push 的 remote commit;部署 job 自身必须通过真实 `/health` 和 k3s Deployment annotation 证明不是旧服务在充数,之后再用 `microservice health <service>` 和对应私有代理 API 做人工复核。不得把 `docker rm` 手工兜底当成正式交付步骤。
新部署入口优先使用 `deploy apply`。旧的 `server rebuild``codex deploy` 只保留为兼容入口,后续实现应收敛到同一个 reconciler:从 remote commit 导出源码,在目标节点一次性代理构建镜像,部署后用 live commit 校验证明不是旧服务。
@@ -112,7 +113,7 @@ bun scripts/cli.ts ssh D601 glob --root /home/ubuntu/pikapython --pattern '**/*-
`--main-server-ip` 是一个全局前缀,必须放在需要透传的命令同一次调用中,例如 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug health`。默认传输是公网 frontend:本地 CLI 读取本仓库 `config.json` 中的 frontend 登录账号密码,登录 `http://<ip>:<frontendPort>/` 获取 HttpOnly session cookie,然后通过 frontend 的 `/api/*` 同源代理访问 backend-core 内网 API;因此计算节点只需要能访问公网 frontend,不需要主 server SSH key,也不需要打开 backend-core REST API 或 PostgreSQL 端口。
默认 frontend 传输支持 `debug health``debug dispatch``debug task``microservice list/status/health/proxy``codex task <taskId>``codex output <taskId>``codex judge <taskId> --attempt N``ssh <PROVIDER_ID> <remote-command>`。其中 `ssh` 的 remote frontend 传输使用 `host.ssh` dispatch 执行有界远端命令,适合 `ssh D601 hostname``ssh D601 skills` 这类自测;交互式登录 shell 仍应在主 server 本机 CLI 使用,或显式切换到旧 SSH 传输后在主 server 上执行。frontend 远程透传不会流式转发本地 stdin,因此 `ssh py < script.py``ssh apply-patch < patch.diff` 这类 stdin-backed helper 必须在主 server 本机运行,或显式切换到 `--main-server-transport ssh`。若确实需要旧行为,可使用 `--main-server-key <key>``--main-server-transport ssh`,这时 CLI 会通过 SSH 登录主 server 的 `--main-server-root` 目录执行同一个 `bun scripts/cli.ts <command>`
默认 frontend 传输支持 `debug health``debug dispatch``debug task``microservice list/status/health/proxy``decision upload/list/show/health``codex task <taskId>``codex output <taskId>``codex judge <taskId> --attempt N``ssh <PROVIDER_ID> <remote-command>`。其中 `ssh` 的 remote frontend 传输使用 `host.ssh` dispatch 执行有界远端命令,适合 `ssh D601 hostname``ssh D601 skills` 这类自测;交互式登录 shell 仍应在主 server 本机 CLI 使用,或显式切换到旧 SSH 传输后在主 server 上执行。frontend 远程透传不会流式转发本地 stdin,因此 `ssh py < script.py``ssh apply-patch < patch.diff` 这类 stdin-backed helper 必须在主 server 本机运行,或显式切换到 `--main-server-transport ssh`。若确实需要旧行为,可使用 `--main-server-key <key>``--main-server-transport ssh`,这时 CLI 会通过 SSH 登录主 server 的 `--main-server-root` 目录执行同一个 `bun scripts/cli.ts <command>`
计算节点可以用该入口测试自身的远程升级闭环,而不需要在计算节点公开 core REST API 或 database。标准顺序是:先运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug health` 确认主 server 看到当前 Provider 在线,且该 Provider labels 中 `unideskCapabilities` 包含 `host.ssh``hostSshConfigured=true``hostSshKeyPresent=true`;再运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch <PROVIDER_ID> provider.upgrade --mode schedule --wait-ms 15000` 触发真实 `provider.upgrade`;随后再次运行 `debug health` 确认节点重新上线;最后运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch <PROVIDER_ID> host.ssh --wait-ms 15000``bun scripts/cli.ts --main-server-ip 74.48.78.17 ssh <PROVIDER_ID> hostname` 验证 SSH 透传能力。provider-gateway 新部署或升级后没有完成这组 remote CLI 自测,不能视为交付完成。
+2
View File
@@ -73,6 +73,8 @@ The reconciler selects the executor from `config.json`:
Existing service-specific commands such as Code Queue deploy should converge onto this reconciler path instead of keeping a parallel implementation.
Decision Center is a standard `k3sctl-managed` service in this model. `deploy apply --service decision-center` must build `src/components/microservices/decision-center/Dockerfile` on D601, import `unidesk-decision-center:d601` into native k3s containerd, apply `src/components/microservices/k3sctl-adapter/k3s/decision-center.k8s.yaml`, stamp the Deployment, and verify health through `/api/microservices/decision-center/health`. It must not add a main-server Compose service, NodePort, hostPort, or provider-gateway direct HTTP backend for Decision Center.
## CI Separation
Continuous integration is intentionally separate from this deploy reconciler. D601 k3s hosts Tekton CI resources described in `docs/reference/ci.md`, but those PipelineRuns only clone, check and run read-only performance gates. They must not call `deploy apply`, `codex deploy`, `kubectl rollout restart` for production services, or mutate `deploy.json`.
+13
View File
@@ -193,6 +193,18 @@ Baidu Netdisk 在 UniDesk 语境中按纯后端服务管理:不得暴露百度
- 代理/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` 或可解释错误。
- UniDesk 前端:`用户服务 / ClaudeQQ` React 页面负责展示 D601、仓库引用、私有 k3s 后端映射、NapCat 登录二维码、NapCat HTTP/WS 状态、事件缓存、订阅表、订阅创建表单、消息推送表单、主用户私聊账号 `645275593`、最近 QQ 事件和已发送记录;完整原始 JSON 只能通过显式 `查看原始JSON` 打开。浏览器只能通过 UniDesk frontend 同源代理访问 ClaudeQQ,不得直接访问 D601 `3290/3000/3001/6099`,也不得 iframe ClaudeQQ 旧 WebUI。
### Decision Center k3s-Managed
当前 Decision Center 作为 `id=decision-center``k3sctl-managed` 用户服务登记在 `config.json`,用于沉淀 Codex/人工会议后的会议记录、决议、目标、问题分级、停放事项和证据。它只负责权威记录和展示,不承载通用聊天、LLM 会话窗口或自动参谋对话。
- Orchestrator`deployment.mode=k3sctl-managed``deployment.adapterServiceId=k3sctl-adapter``deployment.k3sServiceId=decision-center``backend.proxyMode=k3sctl-adapter-http``backend.nodeBaseUrl=k3s://decision-center`;正式链路只能是 `frontend/CLI -> backend-core -> k3sctl-adapter -> Kubernetes API service proxy -> Kubernetes Service decision-center:4277`
- 部署引用:后端源码位于 UniDesk 仓库 `src/components/microservices/decision-center`Dockerfile 为 `src/components/microservices/decision-center/Dockerfile`k3s manifest 为 `src/components/microservices/k3sctl-adapter/k3s/decision-center.k3s.json`Kubernetes 运行清单为 `src/components/microservices/k3sctl-adapter/k3s/decision-center.k8s.yaml`,镜像名固定为 `unidesk-decision-center:d601`。主 server `docker-compose.yml` 不得加入该服务,也不得公开 `4277`
- 状态权威:Decision Center 必须写入主 PostgreSQL,当前表为 `decision_center_records`;不得使用浏览器 `localStorage`、IndexedDB、容器 writable layer 或本地 JSON 文件作为会议、决议、目标或问题状态权威。D601 Pod 通过集群内 `d601-tcp-egress-gateway.unidesk.svc.cluster.local:15432` 访问主 PostgreSQL。
- 数据模型:第一版记录类型为 `meeting|decision|goal|blocker|debt|experiment`,等级为 `G0|G1|G2|G3|P0|P1|P2|P3|none`,状态为 `active|blocked|parked|done`,字段包含 `title`、Markdown `summary/body``linkedGoalId``tags``evidenceLinks``sourceSession``taskId``commitId``createdAt``updatedAt`
- API:只允许 `/health``/live``/logs``/api/` 前缀;允许 `GET``HEAD``POST``PUT``DELETE`。业务 API 包含 `GET /api/records``POST /api/records``GET|PUT|DELETE /api/records/:id``POST /api/meetings/import`,错误必须返回结构化 JSON,便于 CLI 与 frontend 诊断。
- CLI`bun scripts/cli.ts decision upload <markdown-file>``decision list``decision show <id>``decision health` 只能通过 backend-core 用户服务代理访问 Decision Center,不得直连 D601 Service、NodePort 或 provider-gateway `microservice.http`
- UniDesk 前端:`用户服务 / Decision Center` React 页面只展示高密度记录、筛选、当前 G0/G1 目标、P0/P1 blocker、停放事项和最近会议/决议;默认不得展示裸 JSON,完整原始数据只能通过 `查看原始JSON` 打开。
### MDTODO k3s-Managed
当前 MDTODO 作为 `id=mdtodo``k3sctl-managed` 用户服务登记在 `config.json`,用于把 D601 Windows 工作区 `F:\Work\vscode-mdtodo` 从 VS Code 扩展形态拆成 UniDesk 可代理的后端服务:
@@ -212,6 +224,7 @@ Baidu Netdisk 在 UniDesk 语境中按纯后端服务管理:不得暴露百度
- `pipeline`Pipeline v2 控制与观测服务,UniDesk frontend 渲染组件矩阵、React Flow 控制图、epoch 甘特图、运行材料索引和 node 精细控制面板。
- `met-nonlinear`MET Nonlinear 训练编排服务,UniDesk frontend 渲染 GPU/镜像、训练队列、Project config 预览、训练进度、ETA 和历史记录。
- `claudeqq`ClaudeQQ 纯后端 QQ 消息网关,UniDesk frontend 渲染 NapCat 连接、事件订阅、消息推送、最近 QQ 事件和发送记录。
- `decision-center`Decision Center 决策权威记录服务,D601 k3s 代管,状态写入主 PostgreSQLUniDesk frontend 渲染记录筛选、目标、blocker、停放事项和会议/决议。
### D601 Docker/k3s Restart Recovery
+2
View File
@@ -75,6 +75,7 @@
- src/met-nonlinear.tsx (MET Nonlinear D601 training orchestration React page; do not fold back into `app.tsx`)
- src/code-queue.tsx (Code Queue user-service React page; do not fold back into `app.tsx`)
- src/oa-event-flow.tsx (Unified OA event flow and Trace/STEP stats React page; do not fold back into `app.tsx`)
- src/decision-center.tsx (Decision Center records dashboard; do not fold back into `app.tsx`)
- src/k3sctl.tsx (k3s Control Plane React page backed only by `k3sctl-adapter`; do not fold back into `app.tsx`)
- public/ (HTML/CSS static assets for the compact industrial console; no handwritten app JS)
- provider-gateway/ (Compute node Provider Gateway container)
@@ -89,6 +90,7 @@
- microservices/ (UniDesk-owned user services and compatibility examples)
- code-queue/ (Codex/OpenCode queue backend; k3s-managed when exposed through UniDesk)
- oa-event-flow/ (Unified OA event ledger, tag stream, and Trace/STEP stats center)
- decision-center/ (Decision records backend; k3s-managed on D601 and PostgreSQL-backed)
- k3sctl-adapter/ (D601 k3s control-plane adapter and managed service manifests)
- k3s/ci/ (Tekton CI install marker, Pipeline/Task, and in-cluster Trigger manifests)
- example-service/
+9
View File
@@ -9,6 +9,7 @@ import { runSsh } from "./src/ssh";
import { extractRemoteCliOptions, runRemoteCli } from "./src/remote";
import { runMicroserviceCommand } from "./src/microservices";
import { runCodeQueueCommand } from "./src/code-queue";
import { runDecisionCenterCommand } from "./src/decision-center";
import { runCodeQueueDeployCompatCommand, runDeployCommand } from "./src/deploy";
import { runProviderCommand } from "./src/provider-attach";
import { runScheduleCommand } from "./src/schedules";
@@ -45,6 +46,9 @@ function help(): unknown {
{ command: "microservice status <id>", description: "Show one user service config, repository reference, backend mapping, and runtime status." },
{ command: "microservice health <id>", description: "Probe one user service through backend-core -> provider-gateway HTTP proxy." },
{ command: "microservice proxy <id> <path> [--method GET|POST|PUT|PATCH|DELETE] [--raw] [--max-body-bytes N]", description: "Access a private user-service backend path through the same frontend-only proxy used by WebUI; large bodies are summarized unless --raw is set." },
{ command: "decision upload <markdown-file> [--title text] [--type meeting|decision] [--level G0|G1|G2|G3|P0|P1|P2|P3|none] [--status active|blocked|parked|done] [--linked-goal-id id] [--evidence url]", description: "Upload a meeting note or decision record through backend-core -> decision-center user-service proxy." },
{ command: "decision list [--type ...] [--status ...] [--level ...] [--linked-goal-id id] [--limit N]", description: "List Decision Center records through the user-service proxy." },
{ command: "decision show <id>", description: "Show one Decision Center record." },
{ command: "deploy check|plan|apply [--file deploy.json] [--service id] [--dry-run] [--force]", description: "Reconcile services from a repo+commit manifest using target-side build and live commit verification." },
{ command: "schedule list|get|runs|run|delete", description: "Manage backend-core scheduled tasks and run history; schedule run <id> supports --wait-ms N." },
{ 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." },
@@ -184,6 +188,11 @@ async function main(): Promise<void> {
return;
}
if (top === "decision" || top === "decision-center") {
emitJson(commandName, await runDecisionCenterCommand(config, args.slice(1)));
return;
}
if (top === "deploy") {
const result = await runDeployCommand(config, args.slice(1));
const ok = (result as { ok?: unknown }).ok !== false;
+2
View File
@@ -39,6 +39,7 @@ function unifiedLogRotationItem(): CheckItem {
"src/components/microservices/project-manager/src/index.ts",
"src/components/microservices/baidu-netdisk/src/index.ts",
"src/components/microservices/oa-event-flow/src/index.ts",
"src/components/microservices/decision-center/src/index.ts",
];
const offenders = serviceFiles.flatMap((path) => {
const text = readFileSync(rootPath(path), "utf8");
@@ -70,6 +71,7 @@ export function runChecks(config: UniDeskConfig): { ok: boolean; items: CheckIte
fileItem("src/components/microservices/oa-event-flow/src/index.ts"),
fileItem("src/components/microservices/k3sctl-adapter/src/index.ts"),
fileItem("src/components/microservices/mdtodo/src/index.ts"),
fileItem("src/components/microservices/decision-center/src/index.ts"),
fileItem("scripts/src/deploy.ts"),
fileItem("scripts/src/e2e.ts"),
unifiedLogRotationItem(),
+208
View File
@@ -0,0 +1,208 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { type UniDeskConfig, repoRoot } from "./config";
import { coreInternalFetch } from "./microservices";
type DecisionRecordType = "meeting" | "decision" | "goal" | "blocker" | "debt" | "experiment";
type DecisionRecordLevel = "G0" | "G1" | "G2" | "G3" | "P0" | "P1" | "P2" | "P3" | "none";
type DecisionRecordStatus = "active" | "blocked" | "parked" | "done";
const serviceId = "decision-center";
const typeValues = new Set<DecisionRecordType>(["meeting", "decision", "goal", "blocker", "debt", "experiment"]);
const levelValues = new Set<DecisionRecordLevel>(["G0", "G1", "G2", "G3", "P0", "P1", "P2", "P3", "none"]);
const statusValues = new Set<DecisionRecordStatus>(["active", "blocked", "parked", "done"]);
function optionValue(args: string[], names: string[]): string | undefined {
for (const name of names) {
const index = args.indexOf(name);
if (index === -1) continue;
const raw = args[index + 1];
if (raw === undefined || raw.length === 0 || raw.startsWith("--")) throw new Error(`${name} requires a non-empty value`);
return raw;
}
return undefined;
}
function optionValues(args: string[], names: string[]): string[] {
const values: string[] = [];
for (let index = 0; index < args.length; index += 1) {
if (!names.includes(args[index] ?? "")) continue;
const raw = args[index + 1];
if (raw === undefined || raw.length === 0 || raw.startsWith("--")) throw new Error(`${args[index]} requires a non-empty value`);
values.push(raw);
index += 1;
}
return values;
}
function positionalArgs(args: string[]): string[] {
const positions: string[] = [];
for (let index = 0; index < args.length; index += 1) {
const value = args[index] ?? "";
if (value.startsWith("--")) {
index += 1;
continue;
}
positions.push(value);
}
return positions;
}
function parseType(raw: string | undefined, fallback: DecisionRecordType): DecisionRecordType {
const value = raw || fallback;
if (!typeValues.has(value as DecisionRecordType)) throw new Error(`--type must be one of: ${Array.from(typeValues).join(", ")}`);
return value as DecisionRecordType;
}
function parseLevel(raw: string | undefined, fallback: DecisionRecordLevel): DecisionRecordLevel {
const value = raw || fallback;
if (!levelValues.has(value as DecisionRecordLevel)) throw new Error(`--level must be one of: ${Array.from(levelValues).join(", ")}`);
return value as DecisionRecordLevel;
}
function parseStatus(raw: string | undefined, fallback: DecisionRecordStatus): DecisionRecordStatus {
const value = raw || fallback;
if (!statusValues.has(value as DecisionRecordStatus)) throw new Error(`--status must be one of: ${Array.from(statusValues).join(", ")}`);
return value as DecisionRecordStatus;
}
function splitList(values: string[]): string[] {
return [...new Set(values.flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean))];
}
function readMarkdownFile(path: string): { absolutePath: string; markdown: string } {
const absolutePath = resolve(repoRoot, path);
const markdown = readFileSync(absolutePath, "utf8");
if (markdown.trim().length === 0) throw new Error(`markdown file is empty: ${absolutePath}`);
if (markdown.length > 1_000_000) throw new Error(`markdown file is too large: ${absolutePath}`);
return { absolutePath, markdown };
}
function decisionProxy(path: string, init?: { method?: string; body?: unknown }): unknown {
return coreInternalFetch(`/api/microservices/${encodeURIComponent(serviceId)}/proxy${path}`, init);
}
async function decisionProxyAsync(
fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>,
path: string,
init?: { method?: string; body?: unknown },
): Promise<unknown> {
return await fetcher(`/api/microservices/${encodeURIComponent(serviceId)}/proxy${path}`, init);
}
function unwrapProxyResponse(response: unknown): unknown {
const record = typeof response === "object" && response !== null && !Array.isArray(response) ? response as Record<string, unknown> : {};
if (record.ok !== true) return response;
const body = record.body;
return { upstream: { ok: record.ok, status: record.status }, body };
}
function uploadMeeting(args: string[]): unknown {
const file = positionalArgs(args)[0];
if (!file) throw new Error("decision upload requires markdown file");
const { absolutePath, markdown } = readMarkdownFile(file);
const type = parseType(optionValue(args, ["--type"]), "meeting");
const payload = {
markdown,
title: optionValue(args, ["--title"]),
type,
level: parseLevel(optionValue(args, ["--level"]), "none"),
status: parseStatus(optionValue(args, ["--status"]), "active"),
linkedGoalId: optionValue(args, ["--linked-goal-id", "--linkedGoalId"]),
tags: splitList(optionValues(args, ["--tag", "--tags"])),
evidenceLinks: splitList(optionValues(args, ["--evidence", "--evidence-link", "--evidenceLinks"])),
sourceSession: optionValue(args, ["--source-session", "--sourceSession"]),
taskId: optionValue(args, ["--task-id", "--taskId"]),
commitId: optionValue(args, ["--commit-id", "--commitId"]),
};
const endpoint = type === "meeting" ? "/api/meetings/import" : "/api/records";
const body = type === "meeting" ? payload : { ...payload, body: markdown };
return { file: absolutePath, result: unwrapProxyResponse(decisionProxy(endpoint, { method: "POST", body })) };
}
async function uploadMeetingAsync(args: string[], fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>): Promise<unknown> {
const file = positionalArgs(args)[0];
if (!file) throw new Error("decision upload requires markdown file");
const { absolutePath, markdown } = readMarkdownFile(file);
const type = parseType(optionValue(args, ["--type"]), "meeting");
const payload = {
markdown,
title: optionValue(args, ["--title"]),
type,
level: parseLevel(optionValue(args, ["--level"]), "none"),
status: parseStatus(optionValue(args, ["--status"]), "active"),
linkedGoalId: optionValue(args, ["--linked-goal-id", "--linkedGoalId"]),
tags: splitList(optionValues(args, ["--tag", "--tags"])),
evidenceLinks: splitList(optionValues(args, ["--evidence", "--evidence-link", "--evidenceLinks"])),
sourceSession: optionValue(args, ["--source-session", "--sourceSession"]),
taskId: optionValue(args, ["--task-id", "--taskId"]),
commitId: optionValue(args, ["--commit-id", "--commitId"]),
};
const endpoint = type === "meeting" ? "/api/meetings/import" : "/api/records";
const body = type === "meeting" ? payload : { ...payload, body: markdown };
return { file: absolutePath, result: unwrapProxyResponse(await decisionProxyAsync(fetcher, endpoint, { method: "POST", body })) };
}
function listRecords(args: string[]): unknown {
const params = new URLSearchParams();
const type = optionValue(args, ["--type"]);
const status = optionValue(args, ["--status"]);
const level = optionValue(args, ["--level"]);
const linkedGoalId = optionValue(args, ["--linked-goal-id", "--linkedGoalId"]);
const limit = optionValue(args, ["--limit"]);
if (type !== undefined) params.set("type", parseType(type, "meeting"));
if (status !== undefined) params.set("status", parseStatus(status, "active"));
if (level !== undefined) params.set("level", parseLevel(level, "none"));
if (linkedGoalId !== undefined) params.set("linkedGoalId", linkedGoalId);
if (limit !== undefined) params.set("limit", limit);
const query = params.toString();
return unwrapProxyResponse(decisionProxy(`/api/records${query ? `?${query}` : ""}`));
}
async function listRecordsAsync(args: string[], fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>): Promise<unknown> {
const params = new URLSearchParams();
const type = optionValue(args, ["--type"]);
const status = optionValue(args, ["--status"]);
const level = optionValue(args, ["--level"]);
const linkedGoalId = optionValue(args, ["--linked-goal-id", "--linkedGoalId"]);
const limit = optionValue(args, ["--limit"]);
if (type !== undefined) params.set("type", parseType(type, "meeting"));
if (status !== undefined) params.set("status", parseStatus(status, "active"));
if (level !== undefined) params.set("level", parseLevel(level, "none"));
if (linkedGoalId !== undefined) params.set("linkedGoalId", linkedGoalId);
if (limit !== undefined) params.set("limit", limit);
const query = params.toString();
return unwrapProxyResponse(await decisionProxyAsync(fetcher, `/api/records${query ? `?${query}` : ""}`));
}
function showRecord(id: string | undefined): unknown {
if (!id) throw new Error("decision show requires record id");
return unwrapProxyResponse(decisionProxy(`/api/records/${encodeURIComponent(id)}`));
}
async function showRecordAsync(id: string | undefined, fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>): Promise<unknown> {
if (!id) throw new Error("decision show requires record id");
return unwrapProxyResponse(await decisionProxyAsync(fetcher, `/api/records/${encodeURIComponent(id)}`));
}
export async function runDecisionCenterCommand(_config: UniDeskConfig, args: string[]): Promise<unknown> {
const [action = "list", id] = args;
if (action === "upload") return uploadMeeting(args.slice(1));
if (action === "list") return listRecords(args.slice(1));
if (action === "show") return showRecord(id);
if (action === "health") return unwrapProxyResponse(coreInternalFetch(`/api/microservices/${encodeURIComponent(serviceId)}/health`));
throw new Error("decision command must be one of: upload, list, show, health");
}
export async function runDecisionCenterCommandAsync(
_config: UniDeskConfig,
args: string[],
fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>,
): Promise<unknown> {
const [action = "list", id] = args;
if (action === "upload") return uploadMeetingAsync(args.slice(1), fetcher);
if (action === "list") return listRecordsAsync(args.slice(1), fetcher);
if (action === "show") return showRecordAsync(id, fetcher);
if (action === "health") return unwrapProxyResponse(await fetcher(`/api/microservices/${encodeURIComponent(serviceId)}/health`));
throw new Error("decision command must be one of: upload, list, show, health");
}
+121 -3
View File
@@ -39,6 +39,7 @@ const NETWORK_CHECK_NAMES = [
"network:todo-note-public-blocked",
"network:code-queue-public-blocked",
"network:oa-event-flow-public-blocked",
"network:decision-center-public-blocked",
"network:filebrowser-public-blocked",
] as const;
@@ -61,6 +62,7 @@ const SERVICE_CHECK_NAMES = [
"microservice:catalog-todo-note",
"microservice:catalog-oa-event-flow",
"microservice:catalog-code-queue",
"microservice:catalog-decision-center",
"microservice:k3sctl-adapter-status",
"microservice:k3sctl-control-plane",
"microservice:catalog-filebrowser",
@@ -92,6 +94,9 @@ const SERVICE_CHECK_NAMES = [
"microservice:code-queue-status",
"microservice:code-queue-health",
"microservice:code-queue-tasks",
"microservice:decision-center-status",
"microservice:decision-center-health",
"microservice:decision-center-records",
"microservice:oa-event-flow-status",
"microservice:oa-event-flow-health",
"microservice:oa-event-flow-diagnostics",
@@ -129,6 +134,7 @@ const FRONTEND_CHECK_NAMES = [
"frontend:todo-note-integrated-visible",
"frontend:findjob-integrated-visible",
"frontend:oa-event-flow-visible",
"frontend:decision-center-visible",
"frontend:code-queue-integrated-visible",
"frontend:code-queue-enqueue-await-smoke",
"frontend:code-queue-summary-mobile-wrap",
@@ -321,6 +327,7 @@ const LAYOUT_OVERFLOW_PAGE_TEST_IDS: Record<string, string> = {
"/app/met-nonlinear/": "met-nonlinear-page",
"/app/claudeqq/": "claudeqq-page",
"/app/oa-event-flow/": "oa-event-flow-page",
"/app/decision-center/": "decision-center-page",
"/app/code-queue/": "code-queue-page",
};
@@ -1025,6 +1032,7 @@ async function exposureChecks(config: UniDeskConfig, urls: PublicUrls, checks: E
const codeQueuePublic = await fetchProbe(`http://${config.network.publicHost}:14222/health`, 2500);
const oaEventFlowPublic = await fetchProbe(`http://${config.network.publicHost}:4255/health`, 2500);
const oaEventFlowRestriction = dockerUserPortRestriction(4255, allowedSourceCidrs);
const decisionCenterPublic = await fetchProbe(`http://${config.network.publicHost}:4277/health`, 2500);
const filebrowserPublic = await fetchProbe(`http://${config.network.publicHost}:4251/health`, 2500);
addSelectedCheck(checks, options, "network:only-frontend-provider-ports", !portsText.includes(`:${config.network.core.port}->`) && !portsText.includes(":14222->"), portSummary);
addSelectedCheck(checks, options, "network:core-public-blocked", (corePublic as { reachable?: boolean }).reachable === false, corePublic);
@@ -1035,6 +1043,7 @@ async function exposureChecks(config: UniDeskConfig, urls: PublicUrls, checks: E
addSelectedCheck(checks, options, "network:todo-note-public-blocked", (todoNotePublic as { reachable?: boolean }).reachable === false, todoNotePublic);
addSelectedCheck(checks, options, "network:code-queue-public-blocked", (codeQueuePublic as { reachable?: boolean }).reachable === false, codeQueuePublic);
addSelectedCheck(checks, options, "network:oa-event-flow-public-blocked", publicProbeBlockedOrRestricted(oaEventFlowPublic, oaEventFlowRestriction), { publicProbe: oaEventFlowPublic, restriction: oaEventFlowRestriction });
addSelectedCheck(checks, options, "network:decision-center-public-blocked", (decisionCenterPublic as { reachable?: boolean }).reachable === false, decisionCenterPublic);
addSelectedCheck(checks, options, "network:filebrowser-public-blocked", (filebrowserPublic as { reachable?: boolean }).reachable === false, filebrowserPublic);
}
@@ -1077,6 +1086,9 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2
const codeQueueStatus = dockerCoreJson("/api/microservices/code-queue/status");
const codeQueueHealth = dockerCoreJson("/api/microservices/code-queue/health");
const codeQueueTasks = dockerCoreJson("/api/microservices/code-queue/proxy/api/tasks/overview?limit=5&transcriptLimit=1&compact=1&afterSeq=0&preferId=");
const decisionCenterStatus = dockerCoreJson("/api/microservices/decision-center/status");
const decisionCenterHealth = dockerCoreJson("/api/microservices/decision-center/health");
const decisionCenterRecords = dockerCoreJson("/api/microservices/decision-center/proxy/api/records?limit=20");
const filebrowserHealth = dockerCoreJson("/api/microservices/filebrowser/health");
const filebrowserWebui = dockerCoreJson("/api/microservices/filebrowser/proxy/");
const filebrowserD601Health = dockerCoreJson("/api/microservices/filebrowser-d601/health");
@@ -1127,6 +1139,7 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2
const todoNote = microserviceList.find((service) => service.id === "todo-note");
const oaEventFlow = microserviceList.find((service) => service.id === "oa-event-flow");
const codeQueue = microserviceList.find((service) => service.id === "code-queue");
const decisionCenter = microserviceList.find((service) => service.id === "decision-center");
const filebrowser = microserviceList.find((service) => service.id === "filebrowser");
const filebrowserD601 = microserviceList.find((service) => service.id === "filebrowser-d601");
const findjobSummaryBody = (findjobSummary as { body?: { totalJobs?: number; prioritizedJobs?: number } }).body;
@@ -1150,6 +1163,8 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2
const oaEventFlowStatsBody = (oaEventFlowStats as { body?: { ok?: boolean; stats?: unknown[]; returned?: number } }).body;
const codeQueueHealthBody = (codeQueueHealth as { body?: { ok?: boolean; egressProxy?: { connected?: boolean }; queue?: { defaultModel?: string; judgeConfigured?: boolean; modelReasoningEfforts?: Record<string, string> } } }).body;
const codeQueueTasksBody = (codeQueueTasks as { body?: { ok?: boolean; queue?: { defaultModel?: string; modelReasoningEfforts?: Record<string, string> }; tasks?: unknown[] } }).body;
const decisionCenterHealthBody = (decisionCenterHealth as { body?: { ok?: boolean; service?: string; storage?: string; schemaReady?: boolean; recordCount?: number; deploy?: { commit?: string } } }).body;
const decisionCenterRecordsBody = (decisionCenterRecords as { body?: { ok?: boolean; records?: unknown[]; returned?: number } }).body;
const k3sctlControlPlaneBody = (k3sctlControlPlane as { body?: {
ok?: boolean;
clusterId?: string;
@@ -1169,6 +1184,7 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2
} }).body;
const k3sctlCodeQueueService = k3sctlControlPlaneBody?.services?.find((service) => service.id === "code-queue");
const k3sctlClaudeqqService = k3sctlControlPlaneBody?.services?.find((service) => service.id === "claudeqq");
const k3sctlDecisionCenterService = k3sctlControlPlaneBody?.services?.find((service) => service.id === "decision-center");
const filebrowserHealthBody = (filebrowserHealth as { body?: { status?: string } }).body;
const filebrowserD601HealthBody = (filebrowserD601Health as { body?: { status?: string } }).body;
const filebrowserWebuiText = String((filebrowserWebui as { body?: { text?: string } }).body?.text || "");
@@ -1216,6 +1232,15 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2
&& codeQueue.runtime?.orchestrator === "k3sctl"
&& codeQueue.runtime?.container === null,
{ microservices });
addSelectedCheck(checks, options, "microservice:catalog-decision-center",
(microservices as { ok?: boolean }).ok === true
&& decisionCenter?.providerId === "D601"
&& decisionCenter.backend?.public === false
&& decisionCenter.backend?.proxyMode === "k3sctl-adapter-http"
&& decisionCenter.deployment?.mode === "k3sctl-managed"
&& decisionCenter.runtime?.orchestrator === "k3sctl"
&& decisionCenter.runtime?.container === null,
{ microservices });
addSelectedCheck(checks, options, "microservice:k3sctl-adapter-status",
(k3sctlStatus as { ok?: boolean; body?: { microservice?: { id?: string; providerId?: string } } }).ok === true
&& (k3sctlStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.id === "k3sctl-adapter"
@@ -1238,6 +1263,11 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2
&& k3sctlClaudeqqService?.servingHealthy === true
&& k3sctlClaudeqqService?.active?.id === "D601"
&& k3sctlClaudeqqService?.active?.healthy === true
&& k3sctlDecisionCenterService?.status === "healthy"
&& k3sctlDecisionCenterService?.topologyComplete === true
&& k3sctlDecisionCenterService?.servingHealthy === true
&& k3sctlDecisionCenterService?.active?.id === "D601"
&& k3sctlDecisionCenterService?.active?.healthy === true
&& (k3sctlCodeQueueService?.presentNodeIds ?? []).includes("D601")
&& (k3sctlCodeQueueService?.missingNodeIds ?? []).length === 0
&& (k3sctlCodeQueueService?.instances ?? []).some((instance) => instance.id === "D601" && instance.healthy === true),
@@ -1248,6 +1278,7 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2
kubeApiProxy: k3sctlControlPlaneBody?.kubeApiProxy,
service: k3sctlCodeQueueService,
claudeqq: k3sctlClaudeqqService,
decisionCenter: k3sctlDecisionCenterService,
});
addSelectedCheck(checks, options, "microservice:catalog-filebrowser", (microservices as { ok?: boolean }).ok === true
&& filebrowser?.providerId === "D518"
@@ -1319,6 +1350,9 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2
addSelectedCheck(checks, options, "microservice:code-queue-status", (codeQueueStatus as { ok?: boolean }).ok === true && (codeQueueStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === "D601", codeQueueStatus);
addSelectedCheck(checks, options, "microservice:code-queue-health", (codeQueueHealth as { ok?: boolean }).ok === true && codeQueueHealthBody?.ok === true && codeQueueHealthBody.egressProxy?.connected === true && codeQueueHealthBody.queue?.defaultModel === "gpt-5.5" && codeQueueHealthBody.queue?.modelReasoningEfforts?.["gpt-5.5"] === "xhigh", codeQueueHealth);
addSelectedCheck(checks, options, "microservice:code-queue-tasks", (codeQueueTasks as { ok?: boolean }).ok === true && codeQueueTasksBody?.ok === true && Array.isArray(codeQueueTasksBody.tasks) && codeQueueTasksBody.queue?.defaultModel === "gpt-5.5" && codeQueueTasksBody.queue?.modelReasoningEfforts?.["gpt-5.5"] === "xhigh", codeQueueTasks);
addSelectedCheck(checks, options, "microservice:decision-center-status", (decisionCenterStatus as { ok?: boolean }).ok === true && (decisionCenterStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === "D601", decisionCenterStatus);
addSelectedCheck(checks, options, "microservice:decision-center-health", (decisionCenterHealth as { ok?: boolean }).ok === true && decisionCenterHealthBody?.ok === true && decisionCenterHealthBody.service === "decision-center" && decisionCenterHealthBody.storage === "postgres" && decisionCenterHealthBody.schemaReady === true, decisionCenterHealth);
addSelectedCheck(checks, options, "microservice:decision-center-records", (decisionCenterRecords as { ok?: boolean }).ok === true && decisionCenterRecordsBody?.ok === true && Array.isArray(decisionCenterRecordsBody.records), decisionCenterRecords);
const upgradeDispatch = dockerCoreJson("/api/dispatch", {
method: "POST",
body: { providerId: config.providerGateway.id, command: "provider.upgrade", payload: { source: "cli-e2e", mode: "plan" } },
@@ -1417,6 +1451,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
const needTodoNote = wants("frontend:todo-note-integrated-visible");
const needFindJob = wants("frontend:findjob-integrated-visible");
const needOaEventFlow = wants("frontend:oa-event-flow-visible");
const needDecisionCenter = wants("frontend:decision-center-visible");
const needCodeQueue = wantsAny([
"frontend:code-queue-integrated-visible",
"frontend:code-queue-enqueue-await-smoke",
@@ -1502,6 +1537,10 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
let findjobText = "";
let oaEventFlowText = "";
let oaEventFlowMetrics: any = { pageVisible: false, eventTableVisible: false, statsVisible: false, tagFilterValue: "", rawButtonCount: 0 };
let decisionCenterText = "";
let decisionCenterE2eRecord: any = null;
let decisionCenterDeleteResult: any = null;
let decisionCenterMetrics: any = { pageVisible: false, tableVisible: false, rawButtonCount: 0, rawJsonBlocks: 0, chatInputCount: 0, bodyContainsRecord: false };
let codeQueueText = "";
let codeQueueOutputText = "";
let codeQueueTaskCount = 0;
@@ -1717,9 +1756,9 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
}
}
if (needMicroserviceCatalog || needTodoNote || needFindJob || needOaEventFlow || needCodeQueue || needClaudeqq || needRouteDeepLink || needPipeline || needMetNonlinear) {
if (needMicroserviceCatalog || needTodoNote || needFindJob || needOaEventFlow || needDecisionCenter || needCodeQueue || needClaudeqq || needRouteDeepLink || needPipeline || needMetNonlinear) {
await page.getByRole("button", { name: /用户服务/ }).click();
if (needMicroserviceCatalog || needTodoNote || needFindJob || needOaEventFlow || needCodeQueue || needClaudeqq || needRouteDeepLink || needPipeline || needMetNonlinear) {
if (needMicroserviceCatalog || needTodoNote || needFindJob || needOaEventFlow || needDecisionCenter || needCodeQueue || needClaudeqq || needRouteDeepLink || needPipeline || needMetNonlinear) {
await page.waitForSelector('[data-testid="microservice-catalog-page"]', { timeout: 10000 });
}
if (needMicroserviceCatalog) {
@@ -1730,6 +1769,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
await page.waitForSelector('[data-testid="microservice-row-todo-note"]', { timeout: 10000 });
await page.waitForSelector('[data-testid="microservice-row-oa-event-flow"]', { timeout: 10000 });
await page.waitForSelector('[data-testid="microservice-row-code-queue"]', { timeout: 10000 });
await page.waitForSelector('[data-testid="microservice-row-decision-center"]', { timeout: 10000 });
microserviceCatalogText = await page.locator('[data-testid="microservice-catalog-page"]').innerText({ timeout: 5000 });
}
if (needTodoNote) {
@@ -1806,6 +1846,65 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
};
});
}
if (needDecisionCenter) {
const decisionCenterE2eTitle = `E2E Decision Center ${Date.now()}`;
decisionCenterE2eRecord = await page.evaluate(async (title) => {
const response = await fetch("/api/microservices/decision-center/proxy/api/records", {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/json" },
body: JSON.stringify({
type: "meeting",
level: "G1",
status: "active",
title,
body: "E2E seeded meeting record for Decision Center frontend validation.",
tags: ["e2e", "decision-center"],
evidenceLinks: ["https://example.com/unidesk/decision-center-e2e"],
}),
});
return { ok: response.ok, status: response.status, body: await response.json().catch(() => null) };
}, decisionCenterE2eTitle);
await page.getByRole("button", { name: /Decision Center/ }).click();
await page.waitForSelector('[data-testid="decision-center-page"]', { timeout: 10000 });
await page.waitForSelector('[data-testid="decision-center-filters"]', { timeout: 30000 });
await page.waitForSelector('[data-testid="decision-center-record-table"]', { timeout: 30000 });
await page.waitForFunction((title) => {
const text = document.body.innerText;
return text.includes("Decision Center")
&& text.includes("G0/G1 目标")
&& text.includes("P0/P1 Blocker")
&& text.includes("停放事项")
&& text.includes("最近会议/决议")
&& text.includes("查看原始JSON")
&& text.includes(String(title));
}, decisionCenterE2eTitle, { timeout: 30000 });
decisionCenterText = await page.locator('[data-testid="decision-center-page"]').innerText({ timeout: 5000 });
decisionCenterMetrics = await page.evaluate(() => {
const root = document.querySelector('[data-testid="decision-center-page"]') as HTMLElement | null;
const table = document.querySelector('[data-testid="decision-center-record-table"]') as HTMLElement | null;
return {
pageVisible: Boolean(root),
tableVisible: Boolean(table),
rawButtonCount: root?.querySelectorAll('[data-testid^="raw-decision-center"], .ghost-btn').length ?? 0,
rawJsonBlocks: root?.querySelectorAll("pre.raw-json, [data-testid='raw-json']").length ?? 0,
chatInputCount: root?.querySelectorAll("textarea, [contenteditable='true']").length ?? 0,
recordCardCount: root?.querySelectorAll('[data-testid^="decision-record-"]').length ?? 0,
tableRows: table?.querySelectorAll("tbody tr").length ?? 0,
textPreview: root?.innerText.slice(0, 1000) || "",
};
});
const decisionCenterRecordId = String(decisionCenterE2eRecord?.body?.record?.id || "");
if (decisionCenterRecordId) {
decisionCenterDeleteResult = await page.evaluate(async (id) => {
const response = await fetch(`/api/microservices/decision-center/proxy/api/records/${encodeURIComponent(String(id))}`, {
method: "DELETE",
credentials: "same-origin",
});
return { ok: response.ok, status: response.status, body: await response.json().catch(() => null) };
}, decisionCenterRecordId);
}
}
if (needCodeQueue) {
await page.getByLabel("用户服务 子功能").getByRole("button", { name: "Code Queue" }).click();
await page.waitForSelector('[data-testid="code-queue-page"]', { timeout: 10000 });
@@ -2787,6 +2886,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
const microserviceCatalogTextLower = microserviceCatalogText.toLowerCase();
const todoNoteTextLower = todoNoteText.toLowerCase();
const findjobTextLower = findjobText.toLowerCase();
const decisionCenterTextLower = decisionCenterText.toLowerCase();
const codeQueueTextLower = codeQueueText.toLowerCase();
const claudeqqTextLower = claudeqqText.toLowerCase();
const pipelineTextLower = pipelineText.toLowerCase();
@@ -2821,7 +2921,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
addSelectedCheck(checks, options, "frontend:gateway-duration-subsecond-visible", gatewayHasSubsecondDuration && !gatewayHasRoundedZeroDuration, { gatewayHasSubsecondDuration, gatewayHasRoundedZeroDuration, gatewayTextPreview: gatewayText.slice(0, 900) });
addSelectedCheck(checks, options, "frontend:provider-operation-availability-visible", sshAvailabilityTexts.length >= 1 && upgradeAvailabilityTexts.length >= 1 && sshAvailabilityTexts.every((text) => text.includes("SSH 透传")) && upgradeAvailabilityTexts.every((text) => text.includes("远程更新")) && upgradeAvailabilityTexts.some((text) => text.includes("always-enabled")), { sshAvailabilityTexts, upgradeAvailabilityTexts });
addSelectedCheck(checks, options, "frontend:overview-pgdata-visible", bodyText.includes("PGDATA") && bodyText.includes(config.database.volume), { bodyPreview: bodyText.slice(0, 800) });
addSelectedCheck(checks, options, "frontend:microservice-catalog-visible", microserviceCatalogTextLower.includes("findjob") && microserviceCatalogTextLower.includes("pipeline") && microserviceCatalogTextLower.includes("todo note") && microserviceCatalogTextLower.includes("met nonlinear") && microserviceCatalogTextLower.includes("claudeqq") && microserviceCatalogTextLower.includes("oa event flow") && microserviceCatalogTextLower.includes("code queue") && microserviceCatalogText.includes("D601") && microserviceCatalogText.includes(config.providerGateway.id) && microserviceCatalogTextLower.includes("private") && microserviceCatalogText.includes("https://gitee.com/Lyon1998/findjob") && microserviceCatalogText.includes("https://github.com/pikasTech/pipeline") && microserviceCatalogText.includes("https://github.com/pikasTech/met_nonlinear") && microserviceCatalogText.includes("https://gitee.com/lyon1998/agent_skills") && microserviceCatalogText.includes("https://gitee.com/Lyon1998/todo_note") && microserviceCatalogText.includes("https://github.com/pikasTech/unidesk"), { microserviceCatalogPreview: microserviceCatalogText.slice(0, 2000) });
addSelectedCheck(checks, options, "frontend:microservice-catalog-visible", microserviceCatalogTextLower.includes("findjob") && microserviceCatalogTextLower.includes("pipeline") && microserviceCatalogTextLower.includes("todo note") && microserviceCatalogTextLower.includes("met nonlinear") && microserviceCatalogTextLower.includes("claudeqq") && microserviceCatalogTextLower.includes("oa event flow") && microserviceCatalogTextLower.includes("code queue") && microserviceCatalogTextLower.includes("decision center") && microserviceCatalogText.includes("D601") && microserviceCatalogText.includes(config.providerGateway.id) && microserviceCatalogTextLower.includes("private") && microserviceCatalogText.includes("https://gitee.com/Lyon1998/findjob") && microserviceCatalogText.includes("https://github.com/pikasTech/pipeline") && microserviceCatalogText.includes("https://github.com/pikasTech/met_nonlinear") && microserviceCatalogText.includes("https://gitee.com/lyon1998/agent_skills") && microserviceCatalogText.includes("https://gitee.com/Lyon1998/todo_note") && microserviceCatalogText.includes("https://github.com/pikasTech/unidesk"), { microserviceCatalogPreview: microserviceCatalogText.slice(0, 2000) });
addSelectedCheck(checks, options, "frontend:todo-note-integrated-visible", todoNoteTextLower.includes("todo note 工作台") && todoNoteText.includes("CONSTAR") && todoNoteText.includes("大论文") && todoNoteText.includes("UI E2E smoke task") && todoNoteText.includes("撤销") && todoNoteText.includes("重做") && todoNoteText.includes("全部展开") && todoNoteText.includes("仅 UniDesk frontend 代理访问"), { todoNoteTextPreview: todoNoteText.slice(0, 1400) });
addSelectedCheck(checks, options, "frontend:findjob-integrated-visible", findjobTextLower.includes("findjob 工作台".toLowerCase()) && findjobText.includes("岗位总量") && findjobText.includes("D601") && findjobText.includes("近期岗位") && findjobText.includes("仅 UniDesk frontend 代理访问") && /岗位总量\s+\d+/.test(findjobText) && /health\s+ok/i.test(findjobText) && /[1-9]\d*\/[1-9]\d*\s+preview/i.test(findjobText), { findjobTextPreview: findjobText.slice(0, 1200) });
addSelectedCheck(checks, options, "frontend:oa-event-flow-visible",
@@ -2837,6 +2937,24 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
&& Number(oaEventFlowMetrics.rawButtonCount || 0) >= 2
&& !oaEventFlowText.includes("{\n"),
{ oaEventFlowMetrics, oaEventFlowTextPreview: oaEventFlowText.slice(0, 1400) });
addSelectedCheck(checks, options, "frontend:decision-center-visible",
decisionCenterTextLower.includes("decision center")
&& decisionCenterText.includes("G0/G1 目标")
&& decisionCenterText.includes("P0/P1 Blocker")
&& decisionCenterText.includes("停放事项")
&& decisionCenterText.includes("最近会议/决议")
&& decisionCenterText.includes("全部记录")
&& decisionCenterText.includes("PostgreSQL")
&& decisionCenterText.includes("查看原始JSON")
&& decisionCenterMetrics.pageVisible === true
&& decisionCenterMetrics.tableVisible === true
&& decisionCenterE2eRecord?.ok === true
&& decisionCenterDeleteResult?.ok === true
&& Number(decisionCenterMetrics.rawButtonCount || 0) >= 1
&& Number(decisionCenterMetrics.rawJsonBlocks || 0) === 0
&& Number(decisionCenterMetrics.chatInputCount || 0) === 0
&& !decisionCenterText.includes("{\n"),
{ decisionCenterMetrics, decisionCenterE2eRecord, decisionCenterDeleteResult, decisionCenterTextPreview: decisionCenterText.slice(0, 1400) });
addSelectedCheck(checks, options, "frontend:code-queue-integrated-visible", codeQueueTextLower.includes("code queue") && codeQueueText.includes("gpt-5.4-mini") && codeQueueText.includes("gpt-5.4") && codeQueueText.includes("gpt-5.5") && codeQueueText.includes("提交任务") && codeQueueText.includes("执行 Provider") && codeQueueText.includes("入队份数") && codeQueueText.includes("追加 prompt") && codeQueueText.includes("打断") && codeQueueTextLower.includes("查看 queue") && codeQueueText.includes("创建 queue") && codeQueueText.includes("合并 queue") && codeQueueOptions.some((text) => text.includes("All queues")) && codeQueueTracePlacement.firstChildIsTrace === true && codeQueueTracePlacement.noPageTopStatus === true && codeQueueTracePlacement.filterInsideTracePanel === true && codeQueueTracePlacement.taskSearchVisible === true && codeQueueTracePlacement.traceStatusVisible === true && codeQueueTracePlacement.markAllReadVisible === true && codeQueueGlobalStatus.activeMicroserviceVisible === true && codeQueueSidebarUpdateMetrics.hasRecentUpdateLabel === true && codeQueueHtmlGuard.rootAttrMissing === true && codeQueueHtmlGuard.sourceAttrMissing === true && codeQueueHtmlGuard.sourceNoBasePrompt === true && codeQueueSubmitQueueControl.tagName === "select" && codeQueueSubmitQueueControl.createButtonVisible === true && codeQueueSubmitQueueControl.mergeButtonVisible === true && codeQueueSubmitQueueControl.mergeSourceInlineMissing === true && codeQueueSubmitQueueControl.mergeDialogMissingBeforeClick === true && (codeQueueSubmitQueueControl.mergeButtonDisabled === true || (codeQueueSubmitQueueControl.mergeDialogVisible === true && codeQueueSubmitQueueControl.mergeDialogSelectVisible === true && Number(codeQueueSubmitQueueControl.mergeDialogSourceOptionCount || 0) > 1 && codeQueueSubmitQueueControl.mergeDialogSelectInsideSubmitForm !== true && codeQueueSubmitQueueControl.mergeDialogUsesCommonComponent === true && codeQueueSubmitQueueControl.mergeDialogDeleteNoteVisible === true)) && codeQueueSubmitQueueControl.oldInputMissing === true && codeQueueSubmitQueueControl.providerValue === "D601" && codeQueueSubmitQueueControl.cwdValue === "/workspace" && Array.isArray(codeQueueSubmitQueueControl.providerOptions) && codeQueueSubmitQueueControl.providerOptions.some((item: any) => item.value === "D601" && String(item.text || "").includes("/workspace")) && codeQueueSubmitQueueControl.maxAttemptsMax === "99" && codeQueueSubmitQueueControl.maxAttemptsValue === "99" && codeQueueSubmitQueueControl.moveQueueVisible === true && codeQueuePromptDefaultEmpty === true && codeQueueSubmitGuard.batchRowVisible === true && codeQueueSubmitGuard.checkboxVisible === true && codeQueueSubmitGuard.disabledBeforeConfirm === true && codeQueueSubmitGuard.enabledAfterConfirm === true && codeQueueSubmitGuard.waitElementMissingBeforeSubmit === true && codeQueueScrollbarMetrics.transcriptThin === true && codeQueueScrollbarMetrics.toolHorizontalHidden === true && (codeQueueSwitchMetrics.optionCount <= 1 || codeQueueSwitchMetrics.switched === true) && codeQueueTextLower.includes("attempts") && codeQueueText.includes("仅 UniDesk frontend 代理访问") && (codeQueueTaskCount === 0 || codeQueueOutputText.includes("Submitted prompt")), { codeQueueTaskCount, codeQueueOptions, codeQueueSwitchMetrics, codeQueueSubmitQueueControl, codeQueueSubmitGuard, codeQueueScrollbarMetrics, codeQueuePromptDefaultEmpty, codeQueueTracePlacement, codeQueueGlobalStatus, codeQueueSidebarUpdateMetrics, codeQueueHtmlGuard, codeQueueOutputPreview: codeQueueOutputText.slice(0, 900), codeQueueTextPreview: codeQueueText.slice(0, 1400) });
addSelectedCheck(checks, options, "frontend:code-queue-enqueue-await-smoke",
codeQueueEnqueueAwaitSmoke.checked === true
+15 -1
View File
@@ -5,6 +5,7 @@ import { summarizeMicroserviceProxyResponse } from "./microservices";
import { parseNetworkPerfOptions, runNetworkPerf } from "./network-perf";
import { isSshSkillDiscoveryArgs, parseSshArgs } from "./ssh";
import { codexJudgeQueryAsync, codexOutputQueryAsync, codexTaskQueryAsync } from "./code-queue";
import { runDecisionCenterCommandAsync } from "./decision-center";
export interface RemoteCliOptions {
host: string | null;
@@ -558,7 +559,7 @@ async function runRemoteCliOverFrontend(options: RemoteCliOptions, config: UniDe
emitRemoteJson(name, {
transport: "frontend",
baseUrl: session.baseUrl,
commands: ["debug health", "debug dispatch", "debug task", "ssh <providerId> <command>", "ssh <providerId> skills", "microservice list", "microservice status <id>", "microservice health <id>", "microservice proxy <id> <path>", "codex task <taskId>", "codex judge <taskId> --attempt N", "network perf"],
commands: ["debug health", "debug dispatch", "debug task", "ssh <providerId> <command>", "ssh <providerId> skills", "microservice list", "microservice status <id>", "microservice health <id>", "microservice proxy <id> <path>", "decision upload <markdown-file>", "decision list", "decision show <id>", "codex task <taskId>", "codex judge <taskId> --attempt N", "network perf"],
});
return 0;
}
@@ -578,6 +579,19 @@ async function runRemoteCliOverFrontend(options: RemoteCliOptions, config: UniDe
emitRemoteJson(name, await remoteMicroservice(session, args));
return 0;
}
if (top === "decision" || top === "decision-center") {
const fetcher = (path: string, init?: { method?: string; body?: unknown }): Promise<FetchJsonResult> => {
const requestInit = init === undefined
? undefined
: {
method: init.method,
body: init.body === undefined ? undefined : JSON.stringify(init.body),
};
return frontendJson(session, path, requestInit, 30_000);
};
emitRemoteJson(name, await runDecisionCenterCommandAsync(config, args.slice(1), fetcher));
return 0;
}
if (top === "codex") {
emitRemoteJson(name, await remoteCodeQueue(session, args));
return 0;
+115 -2
View File
@@ -1422,7 +1422,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
.result-card dd { margin: 0; }
.result-grid { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
.microservice-page, .findjob-page, .pipeline-page, .met-page, .code-queue-page, .baidu-netdisk-page, .filebrowser-page, .oa-event-flow-page {
.microservice-page, .findjob-page, .pipeline-page, .met-page, .code-queue-page, .baidu-netdisk-page, .filebrowser-page, .oa-event-flow-page, .decision-center-page {
display: grid;
gap: 10px;
}
@@ -6617,8 +6617,121 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
overflow-wrap: anywhere;
}
.decision-hero {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(260px, 0.36fr);
gap: 10px;
align-items: stretch;
}
.decision-filter-bar {
display: grid;
grid-template-columns: repeat(4, minmax(160px, 1fr));
gap: 10px;
}
.decision-filter-bar label {
display: grid;
gap: 5px;
color: var(--muted);
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.decision-default-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
align-items: start;
}
.decision-card-list {
display: grid;
gap: 8px;
}
.decision-record-card {
display: grid;
gap: 8px;
min-width: 0;
padding: 10px;
border: 1px solid var(--line-soft);
background: rgba(0,0,0,0.16);
}
.decision-record-card.compact {
padding: 9px;
}
.decision-record-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: start;
}
.decision-record-head strong {
display: block;
min-width: 0;
margin-top: 4px;
overflow-wrap: anywhere;
font-size: 14px;
}
.decision-record-meta,
.decision-record-foot,
.decision-tags,
.decision-evidence {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.decision-record-meta span:first-child {
color: var(--accent);
font-size: 10px;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.decision-summary {
margin: 0;
color: var(--muted);
line-height: 1.45;
overflow-wrap: anywhere;
}
.decision-markdown {
max-height: 280px;
overflow: auto;
padding-right: 4px;
}
.decision-record-foot {
color: var(--muted);
font-size: 11px;
}
.decision-record-foot code {
white-space: normal;
overflow-wrap: anywhere;
}
.decision-tags span {
padding: 2px 6px;
border: 1px solid rgba(78, 183, 168, 0.34);
color: var(--accent-2);
background: rgba(78, 183, 168, 0.08);
font-size: 11px;
}
.decision-evidence a {
color: var(--text);
text-decoration: none;
overflow-wrap: anywhere;
}
.decision-table th,
.decision-table td {
vertical-align: top;
}
.decision-table td strong,
.decision-table td code {
display: block;
min-width: 0;
overflow-wrap: anywhere;
}
@media (max-width: 1100px) {
.mdtodo-layout {
.mdtodo-layout,
.decision-hero,
.decision-filter-bar,
.decision-default-grid {
grid-template-columns: 1fr;
}
}
+3
View File
@@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client";
import { BaiduNetdiskPage } from "./baidu-netdisk";
import { ClaudeQqPage } from "./claudeqq";
import { CodeQueuePage } from "./code-queue";
import { DecisionCenterPage } from "./decision-center";
import { FileBrowserPage } from "./filebrowser";
import { FindJobPage } from "./findjob";
import { MetNonlinearPage } from "./met-nonlinear";
@@ -1652,6 +1653,7 @@ function MicroserviceCatalogPage({ microservices, onRaw, onNavigate }: AnyRecord
service.id === "k3sctl-adapter" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "k3sctl"), "data-testid": "open-k3sctl-button" }, "打开") : null,
service.id === "code-queue" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "code-queue"), "data-testid": "open-code-queue-button" }, "打开") : null,
service.id === "mdtodo" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "mdtodo"), "data-testid": "open-mdtodo-button" }, "打开") : null,
service.id === "decision-center" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "decision-center"), "data-testid": "open-decision-center-button" }, "打开") : null,
service.id === "project-manager" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "project-manager"), "data-testid": "open-project-manager-button" }, "打开") : null,
h(RawButton, { title: `用户服务 ${service.id}`, data: service, onOpen: onRaw }),
),
@@ -2160,6 +2162,7 @@ function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw, onNa
if (activeModule === "apps" && activeTab === "k3sctl") return h(K3sCtlPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl, onNavigate });
if (activeModule === "apps" && activeTab === "code-queue") return h(CodeQueuePage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl, initialTasksData: initialCodeQueueOverview });
if (activeModule === "apps" && activeTab === "mdtodo") return h(MdtodoPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "apps" && activeTab === "decision-center") return h(DecisionCenterPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "apps" && activeTab === "project-manager") return h(ProjectManagerPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "config" && activeTab === "topology") return h(TopologyPage, { data });
if (activeModule === "config" && activeTab === "auth") return h(AuthPage, { session });
@@ -0,0 +1,258 @@
import React from "react";
import { fmtClock, fmtDate } from "./time";
import { LoadingTitle } from "./loading-indicator";
import { MarkdownBody } from "./markdown";
import { errorMessage, requestJson } from "./unidesk-error";
import { UniDeskErrorBanner } from "./unidesk-error-banner";
type AnyRecord = Record<string, any>;
const h = React.createElement;
const { useEffect } = React;
const useState: any = React.useState;
const recordTypes = ["all", "meeting", "decision", "goal", "blocker", "debt", "experiment"];
const recordLevels = ["all", "G0", "G1", "G2", "G3", "P0", "P1", "P2", "P3", "none"];
const recordStatuses = ["all", "active", "blocked", "parked", "done"];
function StatusBadge({ status, children }: AnyRecord) {
const normalized = String(status || "unknown").toLowerCase();
return h("span", { className: `status-badge ${normalized}` }, children || status || "unknown");
}
function MetricCard({ label, value, hint, tone }: AnyRecord) {
return h("article", { className: `metric-card ${tone || ""}` },
h("div", { className: "metric-label" }, label),
h("div", { className: "metric-value" }, value),
h("div", { className: "metric-hint" }, hint),
);
}
function Panel({ title, eyebrow, actions, children, className, loading }: AnyRecord) {
return h("section", { className: `panel ${className || ""}` },
h("div", { className: "panel-head" },
h("div", null,
eyebrow ? h("p", { className: "panel-eyebrow" }, eyebrow) : null,
h(LoadingTitle, { title, loading }),
),
actions ? h("div", { className: "panel-actions" }, actions) : null,
),
h("div", { className: "panel-body" }, children),
);
}
function RawButton({ title, data, onOpen, testId }: AnyRecord) {
return h("button", {
type: "button",
className: "ghost-btn",
"data-testid": testId,
onClick: () => onOpen(title, data),
}, "查看原始JSON");
}
function EmptyState({ title, text }: AnyRecord) {
return h("div", { className: "empty-state" }, h("strong", null, title), h("span", null, text));
}
function microserviceRuntime(service: any): AnyRecord {
return service?.runtime && typeof service.runtime === "object" && !Array.isArray(service.runtime) ? service.runtime : {};
}
function microserviceBackend(service: any): AnyRecord {
return service?.backend && typeof service.backend === "object" && !Array.isArray(service.backend) ? service.backend : {};
}
function microserviceRepository(service: any): AnyRecord {
return service?.repository && typeof service.repository === "object" && !Array.isArray(service.repository) ? service.repository : {};
}
function decisionApi(apiBaseUrl: string, path: string): string {
return `${apiBaseUrl}/microservices/decision-center/proxy${path}`;
}
function levelTone(level: string): string {
if (level === "G0" || level === "G1") return "online";
if (level === "P0" || level === "P1") return "failed";
if (level === "none") return "unknown";
return "warn";
}
function statusTone(status: string): string {
if (status === "done") return "online";
if (status === "blocked") return "failed";
if (status === "parked") return "warn";
return "unknown";
}
function fmtRecordTime(value: any): string {
return fmtDate(value) || "--";
}
function shortText(value: any, max = 220): string {
const text = String(value || "").replace(/\s+/gu, " ").trim();
return text.length > max ? `${text.slice(0, max - 1)}...` : text;
}
function RecordCard({ record, onRaw, compact }: AnyRecord) {
const tags = Array.isArray(record.tags) ? record.tags : [];
const evidence = Array.isArray(record.evidenceLinks) ? record.evidenceLinks : [];
return h("article", { className: `decision-record-card ${compact ? "compact" : ""}`, "data-testid": `decision-record-${String(record.id || "").replace(/[^A-Za-z0-9_-]+/g, "-")}` },
h("div", { className: "decision-record-head" },
h("div", null,
h("div", { className: "decision-record-meta" },
h("span", null, record.type || "--"),
h(StatusBadge, { status: levelTone(record.level) }, record.level || "none"),
h(StatusBadge, { status: statusTone(record.status) }, record.status || "--"),
),
h("strong", null, record.title || "--"),
),
h(RawButton, { title: `Decision ${record.id}`, data: record, onOpen: onRaw }),
),
compact
? h("p", { className: "decision-summary" }, shortText(record.summary || record.body))
: h(MarkdownBody, { markdown: record.body || record.summary || "", className: "decision-markdown" }),
h("div", { className: "decision-record-foot" },
record.linkedGoalId ? h("code", null, `goal:${record.linkedGoalId}`) : null,
record.taskId ? h("code", null, `task:${record.taskId}`) : null,
record.commitId ? h("code", null, record.commitId.slice(0, 12)) : null,
h("span", null, fmtRecordTime(record.updatedAt)),
),
tags.length > 0 ? h("div", { className: "decision-tags" }, tags.slice(0, 8).map((tag: string) => h("span", { key: tag }, tag))) : null,
evidence.length > 0 ? h("div", { className: "decision-evidence" }, evidence.slice(0, 4).map((link: string) => h("a", { key: link, href: link, target: "_blank", rel: "noreferrer" }, shortText(link, 58)))) : null,
);
}
function RecordTable({ records, onRaw }: AnyRecord) {
if (!records.length) return h(EmptyState, { title: "暂无记录", text: "通过 CLI 上传会议记录或决议后会显示在这里。" });
return h("div", { className: "table-wrap" },
h("table", { className: "decision-table", "data-testid": "decision-center-record-table" },
h("thead", null, h("tr", null,
h("th", null, "等级"),
h("th", null, "状态"),
h("th", null, "类型"),
h("th", null, "标题"),
h("th", null, "摘要"),
h("th", null, "证据"),
h("th", null, "更新"),
h("th", null, "操作"),
)),
h("tbody", null, records.map((record: any) => h("tr", { key: record.id },
h("td", null, h(StatusBadge, { status: levelTone(record.level) }, record.level || "none")),
h("td", null, h(StatusBadge, { status: statusTone(record.status) }, record.status || "--")),
h("td", null, record.type || "--"),
h("td", null, h("strong", null, record.title || "--"), record.linkedGoalId ? h("code", null, record.linkedGoalId) : null),
h("td", null, shortText(record.summary || record.body, 180)),
h("td", null, Array.isArray(record.evidenceLinks) ? record.evidenceLinks.length : 0),
h("td", null, fmtRecordTime(record.updatedAt)),
h("td", null, h(RawButton, { title: `Decision ${record.id}`, data: record, onOpen: onRaw })),
))),
),
);
}
function selectOptions(values: string[]): any[] {
return values.map((value) => h("option", { key: value, value }, value));
}
function filteredQuery(filters: AnyRecord): string {
const params = new URLSearchParams();
if (filters.type !== "all") params.set("type", filters.type);
if (filters.status !== "all") params.set("status", filters.status);
if (filters.level !== "all") params.set("level", filters.level);
if (filters.linkedGoalId.trim()) params.set("linkedGoalId", filters.linkedGoalId.trim());
params.set("limit", "240");
return params.toString();
}
export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyRecord) {
const service = microservices.find((item: any) => item.id === "decision-center") || null;
const [state, setState] = useState({ loading: false, error: "", health: null, records: [], refreshedAt: null });
const [filters, setFilters] = useState({ type: "all", status: "all", level: "all", linkedGoalId: "" });
async function load(): Promise<void> {
if (!service) return;
setState((prev: any) => ({ ...prev, loading: true, error: "" }));
try {
const query = filteredQuery(filters);
const [health, records] = await Promise.all([
requestJson(`${apiBaseUrl}/microservices/decision-center/health`),
requestJson(decisionApi(apiBaseUrl, `/api/records?${query}`)),
]);
setState({ loading: false, error: "", health, records: Array.isArray(records.records) ? records.records : [], refreshedAt: new Date() });
} catch (err) {
setState((prev: any) => ({ ...prev, loading: false, error: errorMessage(err, "Decision Center 加载失败") }));
}
}
useEffect(() => {
load();
}, [service?.id, service?.runtime?.providerStatus]);
useEffect(() => {
const timer = setTimeout(() => void load(), 120);
return () => clearTimeout(timer);
}, [filters.type, filters.status, filters.level, filters.linkedGoalId]);
if (!service) return h(EmptyState, { title: "Decision Center 未登记", text: "请在 config.json 的 microservices 中登记用户服务 id=decision-center" });
const runtime = microserviceRuntime(service);
const repository = microserviceRepository(service);
const backend = microserviceBackend(service);
const records = Array.isArray(state.records) ? state.records : [];
const goals = records.filter((record: any) => record.type === "goal" && ["G0", "G1"].includes(record.level) && record.status !== "done").slice(0, 8);
const blockers = records.filter((record: any) => record.type === "blocker" && ["P0", "P1"].includes(record.level) && record.status !== "done").slice(0, 8);
const parked = records.filter((record: any) => record.status === "parked").slice(0, 8);
const recentMeetings = records.filter((record: any) => record.type === "meeting" || record.type === "decision").slice(0, 12);
return h("div", { className: "decision-center-page", "data-testid": "decision-center-page" },
h(Panel, { title: "Decision Center", eyebrow: "Authority Records", loading: state.loading, actions: h("div", { className: "inline-actions" },
h("button", { type: "button", className: "ghost-btn", onClick: () => void load(), disabled: state.loading }, state.loading ? "刷新中" : "刷新"),
h(RawButton, { title: "Decision Center Health", data: state.health, onOpen: onRaw, testId: "raw-decision-center-health" }),
) },
h("div", { className: "decision-hero" },
h("div", { className: "metric-grid" },
h(MetricCard, { label: "记录数", value: records.length, hint: `PostgreSQL / ${state.health?.storage || "postgres"}`, tone: "ok" }),
h(MetricCard, { label: "G0/G1 目标", value: goals.length, hint: "active authority goals", tone: "ok" }),
h(MetricCard, { label: "P0/P1 Blocker", value: blockers.length, hint: "requires decision", tone: blockers.length > 0 ? "warn" : "ok" }),
h(MetricCard, { label: "Parked", value: parked.length, hint: "停放事项", tone: parked.length > 0 ? "warn" : "ok" }),
),
h("div", { className: "microservice-ref-card" },
h("span", null, "Runtime"),
h("strong", null, runtime.orchestrator || service.deployment?.mode || "k3sctl"),
h("code", null, `${service.providerId} / ${backend.nodeBindHost || "--"}:${backend.nodePort || "--"}`),
h("code", null, repository.commitId || "--"),
),
),
h(UniDeskErrorBanner, { error: state.error, title: "Decision Center 请求失败" }),
),
h(Panel, { title: "筛选", eyebrow: "Type / Status / Level" },
h("div", { className: "decision-filter-bar", "data-testid": "decision-center-filters" },
h("label", null, "类型", h("select", { value: filters.type, onChange: (event: any) => setFilters((prev: any) => ({ ...prev, type: event.target.value })) }, selectOptions(recordTypes))),
h("label", null, "状态", h("select", { value: filters.status, onChange: (event: any) => setFilters((prev: any) => ({ ...prev, status: event.target.value })) }, selectOptions(recordStatuses))),
h("label", null, "等级", h("select", { value: filters.level, onChange: (event: any) => setFilters((prev: any) => ({ ...prev, level: event.target.value })) }, selectOptions(recordLevels))),
h("label", null, "Linked Goal", h("input", { value: filters.linkedGoalId, onChange: (event: any) => setFilters((prev: any) => ({ ...prev, linkedGoalId: event.target.value })), placeholder: "goal id" })),
),
),
h("div", { className: "decision-default-grid" },
h(Panel, { title: "G0/G1 目标", eyebrow: `${goals.length} Goals` },
goals.length === 0 ? h(EmptyState, { title: "暂无当前目标", text: "目标记录使用 type=goal 且 level=G0/G1。" }) :
h("div", { className: "decision-card-list" }, goals.map((record: any) => h(RecordCard, { key: record.id, record, onRaw, compact: true }))),
),
h(Panel, { title: "P0/P1 Blocker", eyebrow: `${blockers.length} Blockers` },
blockers.length === 0 ? h(EmptyState, { title: "暂无高优先级阻塞", text: "阻塞记录使用 type=blocker 且 level=P0/P1。" }) :
h("div", { className: "decision-card-list" }, blockers.map((record: any) => h(RecordCard, { key: record.id, record, onRaw, compact: true }))),
),
h(Panel, { title: "停放事项", eyebrow: `${parked.length} Parked` },
parked.length === 0 ? h(EmptyState, { title: "暂无停放事项", text: "status=parked 的记录会集中展示。" }) :
h("div", { className: "decision-card-list" }, parked.map((record: any) => h(RecordCard, { key: record.id, record, onRaw, compact: true }))),
),
h(Panel, { title: "最近会议/决议", eyebrow: `${recentMeetings.length} Recent` },
recentMeetings.length === 0 ? h(EmptyState, { title: "暂无会议或决议", text: "使用 CLI 上传 Markdown 会议记录后会显示。" }) :
h("div", { className: "decision-card-list" }, recentMeetings.map((record: any) => h(RecordCard, { key: record.id, record, onRaw, compact: true }))),
),
),
h(Panel, { title: "全部记录", eyebrow: `${records.length} Records`, actions: state.refreshedAt ? h("span", { className: "muted" }, `刷新 ${fmtClock(state.refreshedAt)}`) : null },
h(RecordTable, { records, onRaw }),
),
);
}
@@ -73,6 +73,7 @@ export const MODULES: UniDeskModuleDefinition[] = [
{ id: "k3sctl", label: "k3s Control" },
{ id: "code-queue", label: "Code Queue" },
{ id: "mdtodo", label: "MDTODO" },
{ id: "decision-center", label: "Decision Center" },
{ id: "project-manager", label: "Project Manager" },
] },
{ id: "config", label: "系统配置", code: "CFG", tabs: [
@@ -0,0 +1,11 @@
FROM oven/bun:1-alpine
WORKDIR /app/src/components/microservices/decision-center
COPY src/components/microservices/decision-center/package.json ./package.json
RUN bun install --production
COPY src/components/microservices/decision-center/tsconfig.json ./tsconfig.json
COPY src/components/shared /app/src/components/shared
COPY src/components/microservices/decision-center/src ./src
EXPOSE 4277
CMD ["bun", "run", "src/index.ts"]
@@ -0,0 +1,12 @@
{
"name": "@unidesk/decision-center",
"private": true,
"type": "module",
"scripts": {
"start": "bun run src/index.ts",
"check": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"postgres": "latest"
}
}
@@ -0,0 +1,510 @@
import { randomUUID } from "node:crypto";
import postgres from "postgres";
import { createHourlyJsonlWriter, logRetentionBytesForService } from "../../../shared/src/rotating-jsonl";
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
type JsonRecord = Record<string, JsonValue>;
type DecisionRecordType = "meeting" | "decision" | "goal" | "blocker" | "debt" | "experiment";
type DecisionRecordLevel = "G0" | "G1" | "G2" | "G3" | "P0" | "P1" | "P2" | "P3" | "none";
type DecisionRecordStatus = "active" | "blocked" | "parked" | "done";
interface RuntimeConfig {
host: string;
port: number;
databaseUrl: string;
logFile: string;
databasePoolMax: number;
}
interface DecisionRecordRow {
id: string;
type: DecisionRecordType;
level: DecisionRecordLevel;
status: DecisionRecordStatus;
title: string;
body: string;
linked_goal_id: string | null;
tags: JsonValue;
evidence_links: JsonValue;
source_session: string;
task_id: string;
commit_id: string;
created_at: Date | string;
updated_at: Date | string;
}
interface DecisionRecord extends JsonRecord {
id: string;
type: DecisionRecordType;
level: DecisionRecordLevel;
status: DecisionRecordStatus;
title: string;
summary: string;
body: string;
linkedGoalId: string | null;
tags: string[];
evidenceLinks: string[];
sourceSession: string;
taskId: string;
commitId: string;
createdAt: string;
updatedAt: string;
}
class HttpError extends Error {
readonly status: number;
readonly detail: JsonRecord;
constructor(status: number, message: string, detail: JsonRecord = {}) {
super(message);
this.name = "HttpError";
this.status = status;
this.detail = detail;
}
}
const recordTypes = new Set<DecisionRecordType>(["meeting", "decision", "goal", "blocker", "debt", "experiment"]);
const recordLevels = new Set<DecisionRecordLevel>(["G0", "G1", "G2", "G3", "P0", "P1", "P2", "P3", "none"]);
const recordStatuses = new Set<DecisionRecordStatus>(["active", "blocked", "parked", "done"]);
const serviceStartedAt = new Date().toISOString();
const recentLogs: JsonRecord[] = [];
let schemaReady = false;
let schemaLastError: JsonRecord | null = null;
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.length === 0) return fallback;
const value = Number(raw);
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
}
function configFromEnv(): RuntimeConfig {
const databaseUrl = process.env.DATABASE_URL || "";
if (!databaseUrl) throw new Error("DATABASE_URL is required");
return {
host: envString("HOST", "0.0.0.0"),
port: envNumber("PORT", 4277),
databaseUrl,
logFile: envString("LOG_FILE", "/var/log/unidesk/decision-center.jsonl"),
databasePoolMax: Math.max(1, Math.min(8, envNumber("DATABASE_POOL_MAX", 2))),
};
}
const config = configFromEnv();
const sql = postgres(config.databaseUrl, {
max: config.databasePoolMax,
idle_timeout: 20,
connect_timeout: 10,
connection: { application_name: "unidesk-decision-center" },
});
const logWriter = config.logFile
? createHourlyJsonlWriter({
baseLogFile: config.logFile,
service: "decision-center",
maxBytes: logRetentionBytesForService("decision-center"),
})
: null;
logWriter?.prune();
function log(level: "info" | "warn" | "error", event: string, detail: JsonRecord = {}): void {
const record: JsonRecord = { at: new Date().toISOString(), service: "decision-center", level, event, ...detail };
recentLogs.push(record);
while (recentLogs.length > 300) recentLogs.shift();
try {
logWriter?.appendJson(record, new Date(String(record.at)));
} catch {
// Logging must not break decision writes.
}
const line = JSON.stringify(record);
const writer = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
writer(line);
}
function jsonResponse(body: JsonValue, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json; charset=utf-8" },
});
}
function errorToJson(error: unknown): JsonRecord {
if (error instanceof HttpError) return { name: error.name, message: error.message, status: error.status, detail: error.detail };
if (error instanceof Error) return { name: error.name, message: error.message, stack: error.stack || "" };
return { message: String(error) };
}
function errorResponse(error: unknown): Response {
const status = error instanceof HttpError ? error.status : 500;
const body = error instanceof HttpError
? { ok: false, error: error.message, ...error.detail }
: { ok: false, error: error instanceof Error ? error.message : String(error) };
log(status >= 500 ? "error" : "warn", "request_failed", { status, error: errorToJson(error) });
return jsonResponse(body, status);
}
function iso(value: Date | string | null | undefined): string {
if (value === null || value === undefined) return "";
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? String(value) : date.toISOString();
}
function asRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function asString(value: unknown): string {
if (value === null || value === undefined) return "";
if (typeof value === "string") return value.trim();
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value).trim();
return "";
}
function asStringArray(value: unknown, field: string): string[] {
if (value === null || value === undefined || value === "") return [];
if (typeof value === "string") {
return value.split(",").map((item) => item.trim()).filter(Boolean).slice(0, 50);
}
if (!Array.isArray(value)) throw new HttpError(400, `${field} must be an array of strings`);
const items = value.map((item) => asString(item)).filter(Boolean);
if (items.length > 50) throw new HttpError(400, `${field} must contain at most 50 items`);
return [...new Set(items)];
}
function summaryFromBody(body: string): string {
return body
.split("\n")
.map((line) => line.replace(/^#{1,6}\s+/u, "").trim())
.filter(Boolean)
.join(" ")
.replace(/\s+/gu, " ")
.slice(0, 280);
}
function recordFromRow(row: DecisionRecordRow): DecisionRecord {
const body = row.body || "";
return {
id: row.id,
type: row.type,
level: row.level,
status: row.status,
title: row.title,
summary: summaryFromBody(body),
body,
linkedGoalId: row.linked_goal_id,
tags: Array.isArray(row.tags) ? row.tags.map(String) : [],
evidenceLinks: Array.isArray(row.evidence_links) ? row.evidence_links.map(String) : [],
sourceSession: row.source_session,
taskId: row.task_id,
commitId: row.commit_id,
createdAt: iso(row.created_at),
updatedAt: iso(row.updated_at),
};
}
function parseRecordType(value: unknown, fallback: DecisionRecordType): DecisionRecordType {
const raw = asString(value) || fallback;
if (!recordTypes.has(raw as DecisionRecordType)) throw new HttpError(400, "unsupported record type", { value: raw, allowed: [...recordTypes] });
return raw as DecisionRecordType;
}
function parseLevel(value: unknown, fallback: DecisionRecordLevel): DecisionRecordLevel {
const raw = asString(value) || fallback;
if (!recordLevels.has(raw as DecisionRecordLevel)) throw new HttpError(400, "unsupported record level", { value: raw, allowed: [...recordLevels] });
return raw as DecisionRecordLevel;
}
function parseStatus(value: unknown, fallback: DecisionRecordStatus): DecisionRecordStatus {
const raw = asString(value) || fallback;
if (!recordStatuses.has(raw as DecisionRecordStatus)) throw new HttpError(400, "unsupported record status", { value: raw, allowed: [...recordStatuses] });
return raw as DecisionRecordStatus;
}
function titleFromMarkdown(markdown: string, fallback: string): string {
const heading = markdown.split("\n").map((line) => line.trim()).find((line) => /^#{1,3}\s+\S/u.test(line));
if (heading !== undefined) return heading.replace(/^#{1,3}\s+/u, "").trim().slice(0, 220);
const firstLine = markdown.split("\n").map((line) => line.trim()).find(Boolean);
return (firstLine || fallback).slice(0, 220);
}
async function readJsonBody(req: Request): Promise<Record<string, unknown>> {
const text = await req.text();
if (text.length > 1_000_000) throw new HttpError(413, "request body is too large", { maxBytes: 1_000_000 });
if (!text.trim()) return {};
try {
const parsed = JSON.parse(text) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new Error("JSON body must be an object");
}
return parsed as Record<string, unknown>;
} catch (error) {
throw new HttpError(400, "invalid JSON body", { detail: error instanceof Error ? error.message : String(error) });
}
}
async function ensureSchema(): Promise<void> {
await sql`
CREATE TABLE IF NOT EXISTS decision_center_records (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
level TEXT NOT NULL DEFAULT 'none',
status TEXT NOT NULL DEFAULT 'active',
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
linked_goal_id TEXT,
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
evidence_links JSONB NOT NULL DEFAULT '[]'::jsonb,
source_session TEXT NOT NULL DEFAULT '',
task_id TEXT NOT NULL DEFAULT '',
commit_id TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT decision_center_records_type_check CHECK (type IN ('meeting', 'decision', 'goal', 'blocker', 'debt', 'experiment')),
CONSTRAINT decision_center_records_level_check CHECK (level IN ('G0', 'G1', 'G2', 'G3', 'P0', 'P1', 'P2', 'P3', 'none')),
CONSTRAINT decision_center_records_status_check CHECK (status IN ('active', 'blocked', 'parked', 'done'))
)
`;
await sql`CREATE INDEX IF NOT EXISTS idx_decision_center_records_type_status_level ON decision_center_records(type, status, level)`;
await sql`CREATE INDEX IF NOT EXISTS idx_decision_center_records_linked_goal ON decision_center_records(linked_goal_id)`;
await sql`CREATE INDEX IF NOT EXISTS idx_decision_center_records_updated ON decision_center_records(updated_at DESC)`;
}
async function waitForSchema(): Promise<void> {
for (let attempt = 1; attempt <= 30; attempt += 1) {
try {
await ensureSchema();
schemaReady = true;
schemaLastError = null;
log("info", "schema_ready", { attempt });
return;
} catch (error) {
schemaReady = false;
schemaLastError = errorToJson(error);
log("warn", "schema_wait", { attempt, error: schemaLastError });
await Bun.sleep(Math.min(1000 + attempt * 250, 5000));
}
}
throw new Error(`Decision Center schema initialization failed: ${JSON.stringify(schemaLastError)}`);
}
function deployInfo(): JsonRecord {
return {
serviceId: envString("UNIDESK_DEPLOY_SERVICE_ID", "decision-center"),
repo: envString("UNIDESK_DEPLOY_REPO", ""),
commit: envString("UNIDESK_DEPLOY_COMMIT", ""),
requestedCommit: envString("UNIDESK_DEPLOY_REQUESTED_COMMIT", ""),
};
}
async function health(): Promise<JsonRecord> {
let dbOk = false;
let recordCount = 0;
let dbError: JsonValue = null;
try {
const rows = await sql<{ count: string | number }[]>`SELECT count(*) AS count FROM decision_center_records`;
dbOk = true;
recordCount = Number(rows[0]?.count ?? 0);
} catch (error) {
dbError = errorToJson(error);
}
return {
ok: schemaReady && dbOk,
service: "decision-center",
status: schemaReady && dbOk ? "ready" : "not-ready",
startedAt: serviceStartedAt,
storage: "postgres",
schemaReady,
recordCount,
database: { ok: dbOk, error: dbError },
deploy: deployInfo(),
};
}
async function createRecord(input: Record<string, unknown>): Promise<DecisionRecord> {
const body = asString(input.body ?? input.summary ?? input.markdown);
const title = asString(input.title) || titleFromMarkdown(body, "Untitled decision record");
if (!title) throw new HttpError(400, "title is required");
if (title.length > 240) throw new HttpError(400, "title must be at most 240 characters");
if (body.length > 300_000) throw new HttpError(400, "body must be at most 300000 characters");
const id = asString(input.id) || `dc_${randomUUID()}`;
const rows = await sql<DecisionRecordRow[]>`
INSERT INTO decision_center_records (
id, type, level, status, title, body, linked_goal_id, tags, evidence_links, source_session, task_id, commit_id
) VALUES (
${id},
${parseRecordType(input.type, "meeting")},
${parseLevel(input.level, "none")},
${parseStatus(input.status, "active")},
${title},
${body},
${asString(input.linkedGoalId) || null},
${sql.json(asStringArray(input.tags, "tags"))},
${sql.json(asStringArray(input.evidenceLinks ?? input.evidence, "evidenceLinks"))},
${asString(input.sourceSession)},
${asString(input.taskId)},
${asString(input.commitId)}
)
RETURNING *
`;
log("info", "record_created", { id: rows[0]?.id ?? id, type: rows[0]?.type ?? "", level: rows[0]?.level ?? "" });
return recordFromRow(rows[0]!);
}
async function updateRecord(id: string, input: Record<string, unknown>): Promise<DecisionRecord> {
const existing = await getRecord(id);
const rows = await sql<DecisionRecordRow[]>`
UPDATE decision_center_records
SET
type = ${"type" in input ? parseRecordType(input.type, existing.type) : existing.type},
level = ${"level" in input ? parseLevel(input.level, existing.level) : existing.level},
status = ${"status" in input ? parseStatus(input.status, existing.status) : existing.status},
title = ${"title" in input ? asString(input.title) : existing.title},
body = ${"body" in input || "summary" in input || "markdown" in input ? asString(input.body ?? input.summary ?? input.markdown) : existing.body},
linked_goal_id = ${"linkedGoalId" in input ? asString(input.linkedGoalId) || null : existing.linkedGoalId},
tags = ${"tags" in input ? sql.json(asStringArray(input.tags, "tags")) : sql.json(existing.tags)},
evidence_links = ${"evidenceLinks" in input || "evidence" in input ? sql.json(asStringArray(input.evidenceLinks ?? input.evidence, "evidenceLinks")) : sql.json(existing.evidenceLinks)},
source_session = ${"sourceSession" in input ? asString(input.sourceSession) : existing.sourceSession},
task_id = ${"taskId" in input ? asString(input.taskId) : existing.taskId},
commit_id = ${"commitId" in input ? asString(input.commitId) : existing.commitId},
updated_at = now()
WHERE id = ${id}
RETURNING *
`;
return recordFromRow(rows[0]!);
}
async function getRecord(id: string): Promise<DecisionRecord> {
const rows = await sql<DecisionRecordRow[]>`SELECT * FROM decision_center_records WHERE id = ${id}`;
if (rows.length === 0) throw new HttpError(404, "decision record not found", { id });
return recordFromRow(rows[0]!);
}
async function listRecords(url: URL): Promise<DecisionRecord[]> {
const type = asString(url.searchParams.get("type"));
const status = asString(url.searchParams.get("status"));
const level = asString(url.searchParams.get("level"));
const linkedGoalId = asString(url.searchParams.get("linkedGoalId"));
const limit = Math.max(1, Math.min(500, Number(url.searchParams.get("limit") || 200) || 200));
if (type && !recordTypes.has(type as DecisionRecordType)) throw new HttpError(400, "unsupported type filter", { type });
if (status && !recordStatuses.has(status as DecisionRecordStatus)) throw new HttpError(400, "unsupported status filter", { status });
if (level && !recordLevels.has(level as DecisionRecordLevel)) throw new HttpError(400, "unsupported level filter", { level });
const rows = await sql<DecisionRecordRow[]>`
SELECT *
FROM decision_center_records
WHERE (${type || null}::text IS NULL OR type = ${type || null})
AND (${status || null}::text IS NULL OR status = ${status || null})
AND (${level || null}::text IS NULL OR level = ${level || null})
AND (${linkedGoalId || null}::text IS NULL OR linked_goal_id = ${linkedGoalId || null})
ORDER BY
CASE level
WHEN 'G0' THEN 0
WHEN 'P0' THEN 1
WHEN 'G1' THEN 2
WHEN 'P1' THEN 3
WHEN 'G2' THEN 4
WHEN 'P2' THEN 5
WHEN 'G3' THEN 6
WHEN 'P3' THEN 7
ELSE 8
END ASC,
updated_at DESC
LIMIT ${limit}
`;
return rows.map(recordFromRow);
}
function normalizeDecisionDrafts(value: unknown): Array<Record<string, unknown>> {
if (value === undefined || value === null) return [];
if (!Array.isArray(value)) throw new HttpError(400, "decisions must be an array");
return value.map((item, index) => {
const record = asRecord(item);
if (Object.keys(record).length === 0) throw new HttpError(400, "decision item must be an object", { index });
return record;
});
}
async function importMeeting(input: Record<string, unknown>): Promise<JsonRecord> {
const markdown = asString(input.markdown ?? input.body ?? input.summary);
if (!markdown) throw new HttpError(400, "markdown is required");
const base: Record<string, unknown> = {
type: "meeting",
level: parseLevel(input.level, "none"),
status: parseStatus(input.status, "active"),
title: asString(input.title) || titleFromMarkdown(markdown, "Imported meeting"),
body: markdown,
linkedGoalId: asString(input.linkedGoalId) || null,
tags: asStringArray(input.tags, "tags"),
evidenceLinks: asStringArray(input.evidenceLinks ?? input.evidence, "evidenceLinks"),
sourceSession: asString(input.sourceSession),
taskId: asString(input.taskId),
commitId: asString(input.commitId),
};
const meeting = await createRecord(base);
const decisionInputs = normalizeDecisionDrafts(input.decisions);
const decisions: DecisionRecord[] = [];
for (const decision of decisionInputs) {
decisions.push(await createRecord({
...base,
...decision,
type: "decision",
linkedGoalId: asString(decision.linkedGoalId) || meeting.linkedGoalId,
body: asString(decision.body ?? decision.summary ?? decision.markdown) || asString(decision.title),
}));
}
return { ok: true, meeting, decisions, createdCount: 1 + decisions.length };
}
async function deleteRecord(id: string): Promise<JsonRecord> {
const rows = await sql<DecisionRecordRow[]>`DELETE FROM decision_center_records WHERE id = ${id} RETURNING *`;
if (rows.length === 0) throw new HttpError(404, "decision record not found", { id });
log("info", "record_deleted", { id });
return { ok: true, deleted: recordFromRow(rows[0]!) };
}
async function route(req: Request): Promise<Response> {
const url = new URL(req.url);
const method = req.method.toUpperCase();
if (url.pathname === "/live") {
return jsonResponse({ ok: true, service: "decision-center", status: "alive", startedAt: serviceStartedAt, deploy: deployInfo() });
}
if (url.pathname === "/health") {
const body = await health();
return jsonResponse(body, body.ok === true ? 200 : 503);
}
if (url.pathname === "/logs" && method === "GET") return jsonResponse({ ok: true, logs: recentLogs.slice(-200) });
if (url.pathname === "/api/records" && method === "GET") return jsonResponse({ ok: true, records: await listRecords(url) });
if (url.pathname === "/api/records" && method === "POST") return jsonResponse({ ok: true, record: await createRecord(await readJsonBody(req)) }, 201);
if (url.pathname === "/api/meetings/import" && method === "POST") return jsonResponse(await importMeeting(await readJsonBody(req)), 201);
const recordMatch = url.pathname.match(/^\/api\/records\/([^/]+)$/u);
if (recordMatch !== null) {
const id = decodeURIComponent(recordMatch[1] ?? "");
if (!id) throw new HttpError(400, "record id is required");
if (method === "GET") return jsonResponse({ ok: true, record: await getRecord(id) });
if (method === "PUT") return jsonResponse({ ok: true, record: await updateRecord(id, await readJsonBody(req)) });
if (method === "DELETE") return jsonResponse(await deleteRecord(id));
throw new HttpError(405, "record route supports GET, PUT, DELETE", { method });
}
throw new HttpError(404, "route not found", { path: url.pathname });
}
Bun.serve({
hostname: config.host,
port: config.port,
async fetch(req) {
try {
return await route(req);
} catch (error) {
return errorResponse(error);
}
},
});
void waitForSchema().catch((error) => {
log("error", "schema_init_failed", { error: errorToJson(error) });
});
log("info", "service_started", { host: config.host, port: config.port, storage: "postgres" });
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"composite": true,
"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"],
"references": [{ "path": "../../shared" }]
}
@@ -39,7 +39,8 @@ services:
K3SCTL_NATIVE_SERVICE_TUNNEL_CONNECT_TIMEOUT_MS: "${K3SCTL_NATIVE_SERVICE_TUNNEL_CONNECT_TIMEOUT_MS:-3000}"
K3SCTL_NATIVE_SERVICE_URL_CODE_QUEUE: "${K3SCTL_NATIVE_SERVICE_URL_CODE_QUEUE:-}"
K3SCTL_NATIVE_SERVICE_URL_MDTODO: "${K3SCTL_NATIVE_SERVICE_URL_MDTODO:-}"
K3SCTL_MANIFEST_PATHS: "${K3SCTL_MANIFEST_PATHS:-k3s/code-queue.k3s.json,k3s/mdtodo.k3s.json,k3s/claudeqq.k3s.json}"
K3SCTL_NATIVE_SERVICE_URL_DECISION_CENTER: "${K3SCTL_NATIVE_SERVICE_URL_DECISION_CENTER:-}"
K3SCTL_MANIFEST_PATHS: "${K3SCTL_MANIFEST_PATHS:-k3s/code-queue.k3s.json,k3s/mdtodo.k3s.json,k3s/claudeqq.k3s.json,k3s/decision-center.k3s.json}"
K3SCTL_SERVICES_JSON: "${K3SCTL_SERVICES_JSON:-[]}"
UNIDESK_LOG_RETENTION_BYTES: "${UNIDESK_LOG_RETENTION_BYTES:-512MiB}"
volumes:
@@ -0,0 +1,37 @@
{
"apiVersion": "unidesk.ai/k3s/v1",
"kind": "ManagedKubernetesService",
"metadata": {
"name": "decision-center",
"namespace": "unidesk"
},
"spec": {
"adapterServiceId": "k3sctl-adapter",
"controlPlane": {
"type": "kubernetes",
"cluster": "unidesk-k3s",
"context": "unidesk-k3s"
},
"route": {
"kind": "kubernetes-service",
"serviceName": "decision-center",
"servicePort": 4277
},
"activeInstanceId": "D601",
"singleWriter": true,
"expectedNodeIds": [
"D601"
],
"instances": [
{
"id": "D601",
"nodeId": "D601",
"role": "primary",
"baseUrl": "kubernetes://unidesk/services/decision-center:4277",
"healthPath": "/health",
"healthMode": "service-proxy"
}
],
"requireAllInstancesHealthy": true
}
}
@@ -0,0 +1,102 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: decision-center
namespace: unidesk
labels:
app.kubernetes.io/name: decision-center
app.kubernetes.io/part-of: unidesk
unidesk.ai/deployment-mode: k3sctl-managed
unidesk.ai/instance-id: D601
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: decision-center
unidesk.ai/instance-id: D601
template:
metadata:
labels:
app.kubernetes.io/name: decision-center
app.kubernetes.io/part-of: unidesk
unidesk.ai/deployment-mode: k3sctl-managed
unidesk.ai/instance-id: D601
unidesk.ai/node-id: D601
spec:
nodeSelector:
unidesk.ai/node-id: D601
terminationGracePeriodSeconds: 15
containers:
- name: decision-center
image: unidesk-decision-center:d601
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 4277
env:
- name: HOST
value: "0.0.0.0"
- name: PORT
value: "4277"
- name: DATABASE_URL
value: "postgres://unidesk:unidesk_dev_password@d601-tcp-egress-gateway.unidesk.svc.cluster.local:15432/unidesk"
- name: DATABASE_POOL_MAX
value: "2"
- name: LOG_FILE
value: "/var/log/unidesk/decision-center.jsonl"
- name: UNIDESK_LOG_RETENTION_BYTES
value: "512MiB"
volumeMounts:
- name: logs
mountPath: /var/log/unidesk
readinessProbe:
httpGet:
path: /health
port: http
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 18
livenessProbe:
httpGet:
path: /live
port: http
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 6
startupProbe:
httpGet:
path: /live
port: http
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 30
resources:
requests:
cpu: 50m
memory: 96Mi
limits:
memory: 512Mi
volumes:
- name: logs
hostPath:
path: /home/ubuntu/cq-deploy/.state/decision-center/logs
type: DirectoryOrCreate
---
apiVersion: v1
kind: Service
metadata:
name: decision-center
namespace: unidesk
labels:
app.kubernetes.io/name: decision-center
app.kubernetes.io/part-of: unidesk
unidesk.ai/deployment-mode: k3sctl-managed
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: decision-center
unidesk.ai/instance-id: D601
ports:
- name: http
port: 4277
targetPort: http
@@ -274,7 +274,7 @@ function mergeServices(services: ManagedService[]): ManagedService[] {
}
function readConfig(): RuntimeConfig {
const paths = manifestPaths(envString("K3SCTL_MANIFEST_PATHS", "k3s/code-queue.k3s.json,k3s/mdtodo.k3s.json,k3s/claudeqq.k3s.json"));
const paths = manifestPaths(envString("K3SCTL_MANIFEST_PATHS", "k3s/code-queue.k3s.json,k3s/mdtodo.k3s.json,k3s/claudeqq.k3s.json,k3s/decision-center.k3s.json"));
const inlineServices = parseServices(envString("K3SCTL_SERVICES_JSON", "[]"));
const manifestServices = readManifestServices(paths);
return {
+3 -1
View File
@@ -9,6 +9,8 @@
{ "path": "components/microservices/k3sctl-adapter" },
{ "path": "components/microservices/mdtodo" },
{ "path": "components/microservices/project-manager" },
{ "path": "components/microservices/baidu-netdisk" }
{ "path": "components/microservices/baidu-netdisk" },
{ "path": "components/microservices/oa-event-flow" },
{ "path": "components/microservices/decision-center" }
]
}