From 4d9eed65132f158b2323b128aabed5613cb15627 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 17 May 2026 06:49:42 +0000 Subject: [PATCH] feat: add decision center service and cli --- AGENTS.md | 9 +- TEST.md | 4 + config.json | 56 ++ deploy.json | 5 + docker-compose.yml | 4 + docs/reference/cli.md | 6 +- docs/reference/deploy.md | 3 + docs/reference/microservices.md | 13 + docs/reference/repo-tree.md | 2 + scripts/cli.ts | 9 + scripts/src/check.ts | 2 + scripts/src/code-queue.ts | 45 +- scripts/src/decision-center.ts | 208 +++++++ scripts/src/deploy.ts | 169 +++++- scripts/src/docker.ts | 4 + scripts/src/e2e.ts | 124 ++++- scripts/src/remote.ts | 16 +- src/components/frontend/public/style.css | 117 +++- src/components/frontend/src/app.tsx | 3 + .../frontend/src/decision-center.tsx | 258 +++++++++ src/components/frontend/src/index.ts | 14 +- src/components/frontend/src/navigation.ts | 1 + .../microservices/decision-center/Dockerfile | 11 + .../microservices/decision-center/bun.lock | 30 ++ .../decision-center/package.json | 17 + .../decision-center/src/index.ts | 510 ++++++++++++++++++ .../decision-center/tsconfig.json | 18 + .../k3sctl-adapter/docker-compose.d601.yml | 3 +- .../k3s/decision-center.k3s.json | 37 ++ .../k3s/decision-center.k8s.yaml | 102 ++++ .../microservices/k3sctl-adapter/src/index.ts | 2 +- src/tsconfig.base.json | 4 +- 32 files changed, 1758 insertions(+), 48 deletions(-) create mode 100644 scripts/src/decision-center.ts create mode 100644 src/components/frontend/src/decision-center.tsx create mode 100644 src/components/microservices/decision-center/Dockerfile create mode 100644 src/components/microservices/decision-center/bun.lock create mode 100644 src/components/microservices/decision-center/package.json create mode 100644 src/components/microservices/decision-center/src/index.ts create mode 100644 src/components/microservices/decision-center/tsconfig.json create mode 100644 src/components/microservices/k3sctl-adapter/k3s/decision-center.k3s.json create mode 100644 src/components/microservices/k3sctl-adapter/k3s/decision-center.k8s.yaml diff --git a/AGENTS.md b/AGENTS.md index 0ec03799..7173b515 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,8 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `bun scripts/cli.ts server rebuild `:以 build-first、Compose lock、no-deps force-recreate 和 post-up validation 的异步 job 重建主 server Compose 内单个服务;Code Queue 部署在 D601,规则见 `docs/reference/deployment.md`。 - `bun scripts/cli.ts provider attach [--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 [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 控制面上的用户服务,`proxy` 支持受控 JSON body,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 microservice list/status/health/proxy`:管理和验证挂载在主 server、计算节点 Docker 或 k3s 控制面上的用户服务,`proxy` 支持受控 JSON body,OA Event Flow/Todo Note/Baidu Netdisk on main-server、k3s Control/Code Queue/MDTODO/Decision Center/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 ]`:按根目录 `deploy.json` 的服务 repo 和 commit 期望状态校验或更新用户服务,目标侧自行 fetch、构建、部署和 live commit 验证;规则见 `docs/reference/deploy.md`。 - `bun scripts/cli.ts codex deploy `:Code Queue 兼容部署入口,会生成临时 desired manifest 并调用 `deploy apply --service code-queue` 的同一条 target-side build 与 live commit 验证路径;规则见 `docs/reference/codex-deploy.md`。 - `bun scripts/cli.ts codex submit [prompt] [--prompt-file path|--prompt-stdin] [--queue ]`:通过 backend-core 私有代理提交 Code Queue 任务,`--dry-run` 可只检查请求体不入队,规则见 `docs/reference/cli.md`。 @@ -47,8 +48,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 Queue 和 MDTODO 由 D601 k3s/k8s 控制面代管,并经 `k3sctl-adapter` 的 Kubernetes API service proxy 单一路径接入,服务拓扑见 `docs/reference/deployment.md`。 -- `src/components/frontend`:前端源码固定使用 TypeScript + React,`app.tsx` 只做 shell/router,左侧主模块与顶部子标签统一编译为模块前缀路由:`/ops//`、`/nodes//`、`/tasks//`、`/config//`,只有用户服务使用 `/app//` 深链接,运行总览包含通用性能面板,资源监控含曲线和进程资源排序表,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 Queue、MDTODO 和 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//`、`/nodes//`、`/tasks//`、`/config//`,只有用户服务使用 `/app//` 深链接,运行总览包含通用性能面板,资源监控含曲线和进程资源排序表,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`。 @@ -60,7 +61,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 统计中心和前端可见性规则。 diff --git a/TEST.md b/TEST.md index 16368630..36c787fa 100644 --- a/TEST.md +++ b/TEST.md @@ -107,6 +107,10 @@ 随后登录公网 frontend `http://74.48.78.17:18081/`,进入 `用户服务 / Code Queue`,确认页面显示默认模型 `gpt-5.5`、默认执行 Provider `D601`、默认工作目录 `/workspace`、模型下拉菜单包含 `gpt-5.4-mini`/`gpt-5.4`/`gpt-5.5`、入队份数、队列指标、任务 ID、复制任务 ID、引用按钮、任务耗时、引用任务 ID、清空输入、创建成功提示、任务提交表单、Trace 输出、attempt 表、MiniMax/fallback judge 状态、追加 prompt、打断和重试控件;通过页面提交一个小任务,确认任务进入 queued/running/succeeded 或可解释的 failed 状态,并且输出区能看到运行中的 Codex 消息。批量验收时设置 `入队份数=5` 或用 `---` 分隔 5 段 prompt,一次性入队 5 条任务,确认 5 条任务按顺序运行并全部进入 succeeded 或可解释的非成功终态,不能只运行第一条后停止;其中任一任务被 judge 判定 `fail` 时只能把当前任务标为 failed,后续 queued 任务仍必须继续推进。测试异常中断时可以提交长任务后点击 `打断`,确认任务变为 canceled 或被 judge 标记为非成功终态;自动重试只应在服务端/传输异常、任务正常结束但 execution record 显示未完成、或 judge 判定 retry 时发生;retry 必须复用已有 Codex thread 并 append 继续执行 prompt,只有当前任务 complete 后才推进队列中的下一个任务。MiniMax judge 必须能处理 Markdown fence/夹杂文本等 JSON 去噪;若去噪后仍失败,必须把解析错误和上一轮去噪前原始回答反馈给 MiniMax 修复后重试,日志中应出现 `judge_json_parse_retry`,且 repair 成功时仍以 `source=minimax` 返回。Codex provider key 只能通过 `OPENAI_API_KEY`、`CRS_OAI_KEY` 这类运行时环境透传,MiniMax API key 只能通过 D601 env-file 运行时环境传入,禁止写入 `config.json`、Dockerfile、源码或测试文档。 +## 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 --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轮任务` 这类硬编码测试按钮。 diff --git a/config.json b/config.json index 0bd2ba2d..d749542c 100644 --- a/config.json +++ b/config.json @@ -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": { diff --git a/deploy.json b/deploy.json index 9d301142..0ad1f77d 100644 --- a/deploy.json +++ b/deploy.json @@ -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" } ] } diff --git a/docker-compose.yml b/docker-compose.yml index ba0d12fc..58abc13b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -220,6 +220,10 @@ services: AUTH_PASSWORD: "${UNIDESK_AUTH_PASSWORD}" SESSION_SECRET: "${UNIDESK_SESSION_SECRET}" SESSION_TTL_SECONDS: "${UNIDESK_SESSION_TTL_SECONDS}" + UNIDESK_DEPLOY_SERVICE_ID: "${UNIDESK_FRONTEND_DEPLOY_SERVICE_ID:-frontend}" + UNIDESK_DEPLOY_REPO: "${UNIDESK_FRONTEND_DEPLOY_REPO:-}" + UNIDESK_DEPLOY_COMMIT: "${UNIDESK_FRONTEND_DEPLOY_COMMIT:-}" + UNIDESK_DEPLOY_REQUESTED_COMMIT: "${UNIDESK_FRONTEND_DEPLOY_REQUESTED_COMMIT:-}" LOG_FILE: "/var/log/unidesk/${UNIDESK_LOG_DAY}/${UNIDESK_LOG_PREFIX}_frontend.jsonl" UNIDESK_LOG_RETENTION_BYTES: "${UNIDESK_LOG_RETENTION_BYTES:-1GiB}" volumes: diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 2aa0ace8..0f9e9173 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -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 或 k3s 控制面中的用户服务(底层命令名仍为 microservice);`health` 和 `proxy` 会走真实 backend-core -> provider-gateway 或 k3sctl-adapter -> 节点服务链路,`proxy` 支持受控 JSON 请求体并对超大响应 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 submit [prompt] [--prompt-file path|--prompt-stdin] [--queue queueId] [--provider-id id] [--cwd path] [--model model] [--reasoning-effort effort] [--execution-mode mode] [--max-attempts N] [--reference-task-id id] [--dry-run]` 通过 backend-core 私有代理向 Code Queue 提交任务;prompt 必须且只能来自位置参数、文件或 stdin 之一,`--dry-run` 只返回结构化请求与 prompt 预览,不实际入队。 @@ -28,6 +29,7 @@ UniDesk 的统一 CLI 入口是根目录 `scripts/cli.ts`,运行方式固定 - `codex judge <taskId> --attempt N [--dry-run] [--include-prompt]` 通过 Code Queue 私有代理按指定 attempt 单步复现 judge;后端会从 PostgreSQL task JSON 与 output 归档重建该 attempt 在真实队列 worker 中的 `QueueTask`/`CodexRunResult`,再调用同一套 judge prompt builder 和 MiniMax 请求路径。默认会真实调用 MiniMax,`--dry-run` 只返回 prompt/payload 大小、attempt 窗口和重建来源诊断,`--include-prompt` 仅用于本地深度排查。 - `codex interrupt|cancel <taskId>` 通过 Code Queue 私有代理请求中断;running/judging 任务会请求当前 agent run 停止,queued/retry_wait 任务会直接转为 canceled,返回有界 task 摘要和后续查询命令。 - Code Queue 多队列 lane 由 `codex` 命令命名空间管理:`queues` 列表、`queue create <queueId>` 创建、`queue merge <sourceQueueId> --into <targetQueueId>` 合并、`move <taskId> --queue <queueId>` 迁移;同一个 queue 内部串行执行,不同 queue 之间并行执行。合并会移动任务归属并自动删除源 queue 记录,只保留合并后的目标 queue;合并后的目标 queue 按任务原 `queueEnteredAt`/`createdAt` 时间顺序串行。迁移 queued/retry_wait 任务后会立即调度目标 queue。 +- 所有 `codex` 查询和管理命令必须走与 WebUI 相同的 backend-core 私有代理路径 `/api/microservices/code-queue/proxy/...`;CLI 不得为了提交、移动、中断、取消或队列管理直接调用 D601 内部 Service、数据库、pod curl 或 k3sctl scheduler 子服务。若该路径失败,应先修复 CLI/backend/provider tunnel 链路,而不是绕过控制面。 - `job list` 与 `job status` 查询 `.state/jobs/` 文件系统状态,是异步命令的可观测入口。 - `debug health`、`debug dispatch` 与 `debug task` 走真实内部 core、WebSocket、数据库、provider、系统指标、Docker 状态和 Host SSH 维护桥流程,只用于开发调试,不写入 `TEST.md` 的正式验收步骤。 - `e2e run [--only pattern[,pattern...]] [--skip pattern[,pattern...]]` 使用 publicHost 派生的公开 frontend/provider ingress URL,并通过 Docker 内网验证 core API、PostgreSQL、provider self-connection、系统指标曲线、Docker 状态快照、provider.upgrade 预检和 Playwright 前端页面,是交付前的自动化 E2E 门禁;CLI 默认输出 check 状态摘要,完整诊断写入 `resultPath`,日常迭代应优先用 `--only` / `--skip` 跑最小必要集合。 @@ -36,7 +38,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 校验证明不是旧服务。 @@ -114,7 +116,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 自测,不能视为交付完成。 diff --git a/docs/reference/deploy.md b/docs/reference/deploy.md index a1d24695..8b7387e8 100644 --- a/docs/reference/deploy.md +++ b/docs/reference/deploy.md @@ -73,6 +73,9 @@ 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. + + ## Version Stamping And Verification Every successful deployment must stamp the source version in the runtime: diff --git a/docs/reference/microservices.md b/docs/reference/microservices.md index 51bf3a6c..138f11fb 100644 --- a/docs/reference/microservices.md +++ b/docs/reference/microservices.md @@ -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 代管,状态写入主 PostgreSQL,UniDesk frontend 渲染记录筛选、目标、blocker、停放事项和会议/决议。 ### D601 Docker/k3s Restart Recovery diff --git a/docs/reference/repo-tree.md b/docs/reference/repo-tree.md index 13a877a5..97ffcf5f 100644 --- a/docs/reference/repo-tree.md +++ b/docs/reference/repo-tree.md @@ -73,6 +73,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) @@ -87,5 +88,6 @@ - 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) - example-service/ diff --git a/scripts/cli.ts b/scripts/cli.ts index cee254ee..8bb7e673 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -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"; @@ -44,6 +45,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] [--body-json JSON|--body-file path|--body-stdin] [--raw] [--max-body-bytes N]", description: "Access a private user-service backend path through the same frontend-only proxy used by WebUI; JSON request bodies are supported for controlled write/debug endpoints." }, + { 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; diff --git a/scripts/src/check.ts b/scripts/src/check.ts index e25ee7d4..57c463e5 100644 --- a/scripts/src/check.ts +++ b/scripts/src/check.ts @@ -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(), diff --git a/scripts/src/code-queue.ts b/scripts/src/code-queue.ts index b9588eba..5ed4d8b1 100644 --- a/scripts/src/code-queue.ts +++ b/scripts/src/code-queue.ts @@ -51,6 +51,13 @@ type CodexRequestInit = { method?: string; body?: unknown }; type CodexResponseFetcher = (path: string, init?: CodexRequestInit) => unknown; type AsyncCodexResponseFetcher = (path: string, init?: CodexRequestInit) => Promise<unknown>; +const codeQueueProxyPrefix = "/api/microservices/code-queue/proxy"; + +function codeQueueProxyPath(path: string): string { + if (!path.startsWith("/")) throw new Error("Code Queue proxy path must start with /"); + return `${codeQueueProxyPrefix}${path}`; +} + function requireTaskId(value: string | undefined, command: string): string { if (value === undefined || value.trim().length === 0) throw new Error(`${command} requires task id`); return value.trim(); @@ -105,7 +112,11 @@ function upstreamError(response: unknown): string { if (record === null) return String(response); const body = asRecord(record.body); const bodyError = body?.error; - if (typeof bodyError === "string") return bodyError; + if (typeof bodyError === "string") { + const requestId = typeof body?.requestId === "string" ? ` requestId=${body.requestId}` : ""; + const providerId = typeof body?.providerId === "string" ? ` providerId=${body.providerId}` : ""; + return `${bodyError}${providerId}${requestId}`; + } const status = typeof record.status === "number" ? `HTTP ${record.status}` : "upstream request failed"; return `${status}: ${JSON.stringify(response).slice(0, 1200)}`; } @@ -488,7 +499,7 @@ function queryString(params: Record<string, string | number | boolean | null | u } function codexTaskSummary(taskId: string, options: CodexTaskOptions, fetcher: CodexResponseFetcher): unknown { - const summaryPath = `/api/microservices/code-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/summary${queryString({ toolLimit: options.toolLimit })}`; + const summaryPath = codeQueueProxyPath(`/api/tasks/${encodeURIComponent(taskId)}/summary${queryString({ toolLimit: options.toolLimit })}`); const summaryResponse = unwrapCodexResponse(fetcher(summaryPath)); const summary = summaryResponse.body.summary; const result: Record<string, unknown> = { @@ -501,8 +512,8 @@ function codexTaskSummary(taskId: string, options: CodexTaskOptions, fetcher: Co if (options.traceMode === "tail") traceParams.tail = 1; if (options.traceMode === "after") traceParams.afterSeq = options.afterSeq; if (options.traceMode === "before") traceParams.beforeSeq = options.beforeSeq; - const traceSummaryResponse = unwrapCodexResponse(fetcher(`/api/microservices/code-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/trace-summary`)); - const traceResponse = unwrapCodexResponse(fetcher(`/api/microservices/code-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/trace-steps${queryString(traceParams)}`)); + const traceSummaryResponse = unwrapCodexResponse(fetcher(codeQueueProxyPath(`/api/tasks/${encodeURIComponent(taskId)}/trace-summary`))); + const traceResponse = unwrapCodexResponse(fetcher(codeQueueProxyPath(`/api/tasks/${encodeURIComponent(taskId)}/trace-steps${queryString(traceParams)}`))); result.trace = compactTracePage(traceResponse.body, taskId, options.traceLimit, traceSummaryResponse.body, options); } return result; @@ -548,7 +559,7 @@ function codexTaskOutput(taskId: string, options: CodexOutputOptions, fetcher: C if (options.mode === "tail") params.tail = 1; if (options.mode === "after") params.afterSeq = options.afterSeq; if (options.mode === "before") params.beforeSeq = options.beforeSeq; - const response = unwrapCodexResponse(fetcher(`/api/microservices/code-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/output${queryString(params)}`)); + const response = unwrapCodexResponse(fetcher(codeQueueProxyPath(`/api/tasks/${encodeURIComponent(taskId)}/output${queryString(params)}`))); return { upstream: response.upstream, outputPage: compactOutputPage(response.body, taskId, options.limit) }; } @@ -558,7 +569,7 @@ function codexTaskJudge(taskId: string, options: CodexJudgeOptions, fetcher: Cod dryRun: options.dryRun ? 1 : undefined, includePrompt: options.includePrompt ? 1 : undefined, }); - const response = unwrapCodexResponse(fetcher(`/api/microservices/code-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/judge${params}`, { method: "POST" })); + const response = unwrapCodexResponse(fetcher(codeQueueProxyPath(`/api/tasks/${encodeURIComponent(taskId)}/judge${params}`), { method: "POST" })); return { upstream: response.upstream, judgeReplay: response.body }; } @@ -575,7 +586,7 @@ export function codexJudgeQuery(taskId: string, optionArgs: string[], fetcher: C } async function codexTaskSummaryAsync(taskId: string, options: CodexTaskOptions, fetcher: AsyncCodexResponseFetcher): Promise<unknown> { - const summaryPath = `/api/microservices/code-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/summary${queryString({ toolLimit: options.toolLimit })}`; + const summaryPath = codeQueueProxyPath(`/api/tasks/${encodeURIComponent(taskId)}/summary${queryString({ toolLimit: options.toolLimit })}`); const summaryResponse = unwrapCodexResponse(await fetcher(summaryPath)); const summary = summaryResponse.body.summary; const result: Record<string, unknown> = { @@ -588,8 +599,8 @@ async function codexTaskSummaryAsync(taskId: string, options: CodexTaskOptions, if (options.traceMode === "tail") traceParams.tail = 1; if (options.traceMode === "after") traceParams.afterSeq = options.afterSeq; if (options.traceMode === "before") traceParams.beforeSeq = options.beforeSeq; - const traceSummaryResponse = unwrapCodexResponse(await fetcher(`/api/microservices/code-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/trace-summary`)); - const traceResponse = unwrapCodexResponse(await fetcher(`/api/microservices/code-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/trace-steps${queryString(traceParams)}`)); + const traceSummaryResponse = unwrapCodexResponse(await fetcher(codeQueueProxyPath(`/api/tasks/${encodeURIComponent(taskId)}/trace-summary`))); + const traceResponse = unwrapCodexResponse(await fetcher(codeQueueProxyPath(`/api/tasks/${encodeURIComponent(taskId)}/trace-steps${queryString(traceParams)}`))); result.trace = compactTracePage(traceResponse.body, taskId, options.traceLimit, traceSummaryResponse.body, options); } return result; @@ -604,7 +615,7 @@ async function codexTaskOutputAsync(taskId: string, options: CodexOutputOptions, if (options.mode === "tail") params.tail = 1; if (options.mode === "after") params.afterSeq = options.afterSeq; if (options.mode === "before") params.beforeSeq = options.beforeSeq; - const response = unwrapCodexResponse(await fetcher(`/api/microservices/code-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/output${queryString(params)}`)); + const response = unwrapCodexResponse(await fetcher(codeQueueProxyPath(`/api/tasks/${encodeURIComponent(taskId)}/output${queryString(params)}`))); return { upstream: response.upstream, outputPage: compactOutputPage(response.body, taskId, options.limit) }; } @@ -614,7 +625,7 @@ async function codexTaskJudgeAsync(taskId: string, options: CodexJudgeOptions, f dryRun: options.dryRun ? 1 : undefined, includePrompt: options.includePrompt ? 1 : undefined, }); - const response = unwrapCodexResponse(await fetcher(`/api/microservices/code-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/judge${params}`, { method: "POST" })); + const response = unwrapCodexResponse(await fetcher(codeQueueProxyPath(`/api/tasks/${encodeURIComponent(taskId)}/judge${params}`), { method: "POST" })); return { upstream: response.upstream, judgeReplay: response.body }; } @@ -700,19 +711,19 @@ function requireMergeTargetQueueId(args: string[], command: string): string { } function codeQueues(): unknown { - return unwrapCodexResponse(coreInternalFetch("/api/microservices/code-queue/proxy/api/queues")); + return unwrapCodexResponse(coreInternalFetch(codeQueueProxyPath("/api/queues"))); } function codexCreateQueue(queueId: string): unknown { - return unwrapCodexResponse(coreInternalFetch("/api/microservices/code-queue/proxy/api/queues", { method: "POST", body: { queueId } })); + return unwrapCodexResponse(coreInternalFetch(codeQueueProxyPath("/api/queues"), { method: "POST", body: { queueId } })); } function codexMergeQueue(sourceQueueId: string, targetQueueId: string): unknown { - return unwrapCodexResponse(coreInternalFetch(`/api/microservices/code-queue/proxy/api/queues/${encodeURIComponent(targetQueueId)}/merge`, { method: "POST", body: { sourceQueueId } })); + return unwrapCodexResponse(coreInternalFetch(codeQueueProxyPath(`/api/queues/${encodeURIComponent(targetQueueId)}/merge`), { method: "POST", body: { sourceQueueId } })); } function codexMoveTask(taskId: string, queueId: string): unknown { - return unwrapCodexResponse(coreInternalFetch(`/api/microservices/code-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/move`, { method: "POST", body: { queueId } })); + return unwrapCodexResponse(coreInternalFetch(codeQueueProxyPath(`/api/tasks/${encodeURIComponent(taskId)}/move`), { method: "POST", body: { queueId } })); } function promptFromSubmitArgs(args: string[]): string { @@ -845,7 +856,7 @@ function codexSubmitTask(args: string[]): unknown { }, }; } - const response = unwrapCodexResponse(coreInternalFetch("/api/microservices/code-queue/proxy/api/tasks", { method: "POST", body: payload })); + const response = unwrapCodexResponse(coreInternalFetch(codeQueueProxyPath("/api/tasks"), { method: "POST", body: payload })); return { upstream: response.upstream, tasks: asArray(response.body.tasks).map(compactTaskMutationResponse), @@ -854,7 +865,7 @@ function codexSubmitTask(args: string[]): unknown { } function codexInterruptTask(taskId: string): unknown { - const response = unwrapCodexResponse(coreInternalFetch(`/api/microservices/k3sctl-adapter/proxy/api/services/code-queue-scheduler/proxy/api/tasks/${encodeURIComponent(taskId)}/interrupt`, { method: "POST" })); + const response = unwrapCodexResponse(coreInternalFetch(codeQueueProxyPath(`/api/tasks/${encodeURIComponent(taskId)}/interrupt`), { method: "POST" })); return { upstream: response.upstream, task: compactTaskMutationResponse(response.body.task), diff --git a/scripts/src/decision-center.ts b/scripts/src/decision-center.ts new file mode 100644 index 00000000..74767cdf --- /dev/null +++ b/scripts/src/decision-center.ts @@ -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"); +} diff --git a/scripts/src/deploy.ts b/scripts/src/deploy.ts index 27227058..bafc692e 100644 --- a/scripts/src/deploy.ts +++ b/scripts/src/deploy.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; import { runCommand } from "./command"; import { type UniDeskConfig, type UniDeskMicroserviceConfig, repoRoot, rootPath } from "./config"; import { ensureGithubSshIdentityForProvider } from "./deploy-ssh-identity"; @@ -116,7 +117,7 @@ function deployHelp(action: string | undefined = undefined): Record<string, unkn apply: "Start an async target-side reconcile job unless --run-now is explicitly present.", }, options: [ - { name: "--file <path>", default: defaultDeployFile, description: "Desired-state manifest path relative to the repo root." }, + { name: "--file <path>", default: defaultDeployFile, description: "Desired-state manifest path relative to the repo root. JSON and ESM JS manifests are supported, for example deploy.json or develop.js." }, { name: "--service <id>", description: "Limit reconcile to one service from the manifest." }, { name: "--dry-run", description: "Prepare and validate without mutating the target service." }, { name: "--force", description: "Redeploy even when the live commit appears up to date." }, @@ -360,10 +361,7 @@ function positionalArgs(args: string[]): string[] { return result; } -function readDeployManifest(file: string): DeployManifest { - const path = resolve(repoRoot, file); - if (!existsSync(path)) throw new Error(`deploy manifest not found: ${path}`); - const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown; +function parseDeployManifest(parsed: unknown): DeployManifest { const record = asRecord(parsed); if (record === null) throw new Error("deploy manifest must be an object"); if (record.schemaVersion !== 1) throw new Error("deploy manifest schemaVersion must be 1"); @@ -385,6 +383,67 @@ function readDeployManifest(file: string): DeployManifest { return { schemaVersion: 1, services }; } +async function readDeployManifest(file: string): Promise<DeployManifest> { + const path = resolve(repoRoot, file); + if (!existsSync(path)) throw new Error(`deploy manifest not found: ${path}`); + if (/\.(?:mjs|js|cjs)$/iu.test(path)) { + const moduleValue = await import(`${pathToFileURL(path).href}?unideskDeployManifest=${Date.now()}`); + const moduleRecord = asRecord(moduleValue); + const parsed = moduleRecord?.default ?? moduleRecord?.manifest; + if (parsed === undefined) throw new Error(`deploy JS manifest must export default or named manifest: ${path}`); + return parseDeployManifest(parsed); + } + return parseDeployManifest(JSON.parse(readFileSync(path, "utf8")) as unknown); +} + +function frontendCoreDeployService(config: UniDeskConfig): UniDeskMicroserviceConfig { + return { + id: "frontend", + name: "UniDesk Frontend", + providerId: "main-server", + description: "UniDesk core frontend entrypoint deployed by the main-server direct Compose executor.", + repository: { + url: unideskRepoUrl, + commitId: "local", + dockerfile: "src/components/frontend/Dockerfile", + composeFile: config.docker.composeFile, + composeService: "frontend", + containerName: "unidesk-frontend", + }, + backend: { + nodeBaseUrl: `http://127.0.0.1:${config.network.frontend.port}`, + nodeBindHost: "127.0.0.1", + nodePort: config.network.frontend.port, + proxyMode: "core-direct", + frontendOnly: false, + public: true, + allowedMethods: ["GET", "HEAD"], + allowedPathPrefixes: ["/"], + healthPath: "/health", + timeoutMs: 8000, + }, + deployment: { mode: "unidesk-direct" }, + development: { + providerId: "main-server", + sshPassthrough: false, + worktreePath: repoRoot, + }, + frontend: { + route: "/", + integrated: true, + }, + }; +} + +function coreDeployService(config: UniDeskConfig, id: string): UniDeskMicroserviceConfig | undefined { + if (id === "frontend") return frontendCoreDeployService(config); + return undefined; +} + +function isCoreDeployService(service: UniDeskMicroserviceConfig): boolean { + return service.id === "frontend" && service.providerId === "main-server" && service.backend.proxyMode === "core-direct"; +} + function selectServices(config: UniDeskConfig, manifest: DeployManifest, serviceId: string | null): Array<{ desired: DeployManifestService; config: UniDeskMicroserviceConfig; @@ -393,8 +452,8 @@ function selectServices(config: UniDeskConfig, manifest: DeployManifest, service const selected = serviceId === null ? manifest.services : manifest.services.filter((service) => service.id === serviceId); if (serviceId !== null && selected.length === 0) throw new Error(`deploy manifest does not contain service: ${serviceId}`); return selected.map((desired) => { - const service = configById.get(desired.id); - if (service === undefined) throw new Error(`deploy manifest service ${desired.id} is not present in config.json microservices`); + const service = configById.get(desired.id) ?? coreDeployService(config, desired.id); + if (service === undefined) throw new Error(`deploy manifest service ${desired.id} is not present in config.json microservices or supported core deploy services`); return { desired, config: service }; }); } @@ -424,7 +483,7 @@ function targetExportDir(service: UniDeskMicroserviceConfig, runId: string): str function targetWorkDir(service: UniDeskMicroserviceConfig): string { if (service.deployment.mode === "k3sctl-managed") return k3sDeployDir; - if (targetIsMain(service) && service.repository.url === unideskRepoUrl) { + if (targetIsMain(service) && isUnideskRepo(service.repository.url)) { return rootPath(".state", "deploy", "work", safeId(service.id)); } return service.development.worktreePath; @@ -475,12 +534,12 @@ function directComposeEnvFile(service: UniDeskMicroserviceConfig): string { } function directBuildContextOverride(service: UniDeskMicroserviceConfig): string { - if (targetIsMain(service) && service.repository.url === unideskRepoUrl) return targetWorkDir(service); + if (targetIsMain(service) && isUnideskRepo(service.repository.url)) return targetWorkDir(service); return ""; } function directDockerfileOverride(service: UniDeskMicroserviceConfig): string { - if (targetIsMain(service) && service.repository.url === unideskRepoUrl) return service.repository.dockerfile; + if (targetIsMain(service) && isUnideskRepo(service.repository.url)) return service.repository.dockerfile; return ""; } @@ -587,7 +646,7 @@ function buildCachePrelude(dockerfileVariable: string): string[] { } function prepareSourceScript(service: UniDeskMicroserviceConfig, desired: DeployManifestService, exportDir: string): string { - if (targetIsMain(service) && desired.repo === unideskRepoUrl) { + if (targetIsMain(service) && isUnideskRepo(desired.repo)) { return [ "set -euo pipefail", `repo=${shellQuote(repoRoot)}`, @@ -848,10 +907,63 @@ function imageLabelVerifyScript(service: UniDeskMicroserviceConfig, expectedComm ].join("\n"); } -function composeDeployScript(service: UniDeskMicroserviceConfig): string { +function directComposeDeployEnv(service: UniDeskMicroserviceConfig, desired: DeployManifestService, resolvedCommit: string): Record<string, string> { + if (service.id !== "frontend") return {}; + return { + UNIDESK_FRONTEND_DEPLOY_SERVICE_ID: service.id, + UNIDESK_FRONTEND_DEPLOY_REPO: desired.repo, + UNIDESK_FRONTEND_DEPLOY_COMMIT: resolvedCommit, + UNIDESK_FRONTEND_DEPLOY_REQUESTED_COMMIT: desired.commitId, + }; +} + +function composeEnvStampLines(service: UniDeskMicroserviceConfig, desired: DeployManifestService, resolvedCommit: string): string[] { + const deployEnv = directComposeDeployEnv(service, desired, resolvedCommit); + const entries = Object.entries(deployEnv); + if (entries.length === 0) return []; + const encoded = Buffer.from(JSON.stringify(deployEnv), "utf8").toString("base64"); + return [ + `deploy_env_b64=${shellQuote(encoded)}`, + "if [ -n \"$compose_env_file\" ]; then", + " python3 - \"$compose_env_file\" \"$deploy_env_b64\" <<'PY'", + "import base64, json, re, sys", + "path = sys.argv[1]", + "updates = json.loads(base64.b64decode(sys.argv[2]).decode('utf-8'))", + "def env_value(value):", + " text = str(value)", + " return text if re.match(r'^[A-Za-z0-9_./:@-]+$', text) else json.dumps(text)", + "try:", + " lines = open(path, encoding='utf-8').read().splitlines()", + "except FileNotFoundError:", + " lines = []", + "seen = set()", + "out = []", + "for line in lines:", + " if '=' not in line or line.startswith('#'):", + " out.append(line)", + " continue", + " key = line.split('=', 1)[0]", + " if key in updates:", + " out.append(f'{key}={env_value(updates[key])}')", + " seen.add(key)", + " else:", + " out.append(line)", + "for key, value in updates.items():", + " if key not in seen:", + " out.append(f'{key}={env_value(value)}')", + "with open(path, 'w', encoding='utf-8') as handle:", + " handle.write('\\n'.join(out) + '\\n')", + "PY", + " echo compose_env_deploy_stamp=updated", + "fi", + ]; +} + +function composeDeployScript(service: UniDeskMicroserviceConfig, desired: DeployManifestService, resolvedCommit: string): string { return [ "set -euo pipefail", directComposeResolveScript(service), + ...composeEnvStampLines(service, desired, resolvedCommit), "docker compose \"${compose_env_args[@]}\" -f \"$compose_file\" -p \"$project\" up -d --no-build --no-deps --force-recreate \"$compose_service\"", "ready=0", "for attempt in $(seq 1 90); do", @@ -1146,6 +1258,33 @@ function healthSummary(response: unknown): Record<string, unknown> { }; } +async function directHttpJson(url: string, timeoutMs: number): Promise<unknown> { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { signal: controller.signal }); + const text = await response.text(); + let body: unknown = null; + try { + body = text.length > 0 ? JSON.parse(text) : null; + } catch { + body = { text }; + } + return { ok: response.ok, status: response.status, body }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) }; + } finally { + clearTimeout(timer); + } +} + +async function serviceHealth(config: UniDeskConfig, service: UniDeskMicroserviceConfig): Promise<unknown> { + if (isCoreDeployService(service)) { + return await directHttpJson(`${service.backend.nodeBaseUrl}${service.backend.healthPath}`, service.backend.timeoutMs); + } + return coreInternalFetch(`/api/microservices/${encodeURIComponent(service.id)}/health`); +} + function commitMatches(actual: string | null, desired: string): boolean { if (actual === null || actual.length === 0) return false; const normalized = actual.toLowerCase(); @@ -1397,7 +1536,7 @@ async function readK8sCommit(config: UniDeskConfig, service: UniDeskMicroservice async function readRuntimeState(config: UniDeskConfig, service: UniDeskMicroserviceConfig, desired: DeployManifestService): Promise<ServiceRuntimeState> { const reason = unsupportedReason(service); - const health = coreInternalFetch(`/api/microservices/${encodeURIComponent(service.id)}/health`); + const health = await serviceHealth(config, service); const healthBody = coreBody(health); const healthCommit = healthDeployCommit(healthBody); const healthRecord = asRecord(health); @@ -1553,7 +1692,7 @@ async function applyOneService(config: UniDeskConfig, service: UniDeskMicroservi if (!pushStep(steps, cleanup)) return { ok: false, serviceId: service.id, startedAt, finishedAt: nowIso(), resolvedCommit, before, steps }; } } else { - const deploy = await step(config, service, "compose-up", composeDeployScript(service), targetIsMain(service) ? repoRoot : targetWorkDir(service), 180_000, !targetIsMain(service)); + const deploy = await step(config, service, "compose-up", composeDeployScript(service, desired, resolvedCommit), targetIsMain(service) ? repoRoot : targetWorkDir(service), 180_000, !targetIsMain(service)); if (!pushStep(steps, deploy)) return { ok: false, serviceId: service.id, startedAt, finishedAt: nowIso(), resolvedCommit, before, steps }; const imageVerify = await step(config, service, "image-label-verify", imageLabelVerifyScript(service, resolvedCommit), targetIsMain(service) ? repoRoot : targetWorkDir(service), 60_000, false); if (!pushStep(steps, imageVerify)) return { ok: false, serviceId: service.id, startedAt, finishedAt: nowIso(), resolvedCommit, before, steps }; @@ -1638,7 +1777,7 @@ export async function runDeployCommand(config: UniDeskConfig, args: string[]): P if (!["check", "plan", "apply"].includes(actionRaw)) throw new Error("deploy command must be one of: check, plan, apply"); const action = actionRaw as DeployAction; const options = parseOptions(args.slice(1)); - const manifest = resolveManifestCommits(readDeployManifest(options.file), options.serviceId); + const manifest = resolveManifestCommits(await readDeployManifest(options.file), options.serviceId); if (action === "check" || action === "plan") return await checkOrPlan(config, manifest, options, action); if (!options.runNow) return applyJob(config, args, options); return await runApplyNow(config, manifest, options); diff --git a/scripts/src/docker.ts b/scripts/src/docker.ts index 29631cf0..4c71ac48 100644 --- a/scripts/src/docker.ts +++ b/scripts/src/docker.ts @@ -125,6 +125,10 @@ export function writeComposeEnv(config: UniDeskConfig, freshLogPrefix: boolean): UNIDESK_LOG_DAY: logDay, UNIDESK_LOG_PREFIX: logPrefix, UNIDESK_LOG_RETENTION_BYTES: runtimeSecret("UNIDESK_LOG_RETENTION_BYTES") || "1GiB", + UNIDESK_FRONTEND_DEPLOY_SERVICE_ID: runtimeSecret("UNIDESK_FRONTEND_DEPLOY_SERVICE_ID") || "frontend", + UNIDESK_FRONTEND_DEPLOY_REPO: runtimeSecret("UNIDESK_FRONTEND_DEPLOY_REPO"), + UNIDESK_FRONTEND_DEPLOY_COMMIT: runtimeSecret("UNIDESK_FRONTEND_DEPLOY_COMMIT"), + UNIDESK_FRONTEND_DEPLOY_REQUESTED_COMMIT: runtimeSecret("UNIDESK_FRONTEND_DEPLOY_REQUESTED_COMMIT"), UNIDESK_HOST_ROOT_SSH_DIR: process.env.UNIDESK_HOST_ROOT_SSH_DIR || "/root/.ssh", UNIDESK_HOST_SSH_KEY_DIR: config.sshForwarding.keyDir, UNIDESK_HOST_SSH_HOST: config.sshForwarding.host, diff --git a/scripts/src/e2e.ts b/scripts/src/e2e.ts index 628bb160..2156547e 100644 --- a/scripts/src/e2e.ts +++ b/scripts/src/e2e.ts @@ -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 diff --git a/scripts/src/remote.ts b/scripts/src/remote.ts index 55843035..bf3f508e 100644 --- a/scripts/src/remote.ts +++ b/scripts/src/remote.ts @@ -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; diff --git a/src/components/frontend/public/style.css b/src/components/frontend/public/style.css index 63564555..706f905f 100644 --- a/src/components/frontend/public/style.css +++ b/src/components/frontend/public/style.css @@ -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; } } diff --git a/src/components/frontend/src/app.tsx b/src/components/frontend/src/app.tsx index 4bece919..522ff926 100644 --- a/src/components/frontend/src/app.tsx +++ b/src/components/frontend/src/app.tsx @@ -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 }); diff --git a/src/components/frontend/src/decision-center.tsx b/src/components/frontend/src/decision-center.tsx new file mode 100644 index 00000000..53d141a2 --- /dev/null +++ b/src/components/frontend/src/decision-center.tsx @@ -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 }), + ), + ); +} diff --git a/src/components/frontend/src/index.ts b/src/components/frontend/src/index.ts index a7e234a6..82a528a4 100644 --- a/src/components/frontend/src/index.ts +++ b/src/components/frontend/src/index.ts @@ -14,6 +14,12 @@ interface RuntimeConfig { sessionSecret: string; sessionTtlSeconds: number; logFile: string; + deploy: { + serviceId: string; + repo: string; + commit: string; + requestedCommit: string; + }; } interface SessionPayload { @@ -143,6 +149,12 @@ function readConfig(): RuntimeConfig { sessionSecret: requiredEnv("SESSION_SECRET"), sessionTtlSeconds: readNumberEnv("SESSION_TTL_SECONDS"), logFile: requiredEnv("LOG_FILE"), + deploy: { + serviceId: process.env.UNIDESK_DEPLOY_SERVICE_ID || "frontend", + repo: process.env.UNIDESK_DEPLOY_REPO || "", + commit: process.env.UNIDESK_DEPLOY_COMMIT || "", + requestedCommit: process.env.UNIDESK_DEPLOY_REQUESTED_COMMIT || "", + }, }; } @@ -711,7 +723,7 @@ async function handleRequest(req: Request): Promise<Response> { logger("debug", "request", { path: url.pathname }); try { if (url.pathname === "/health") { - return jsonResponse({ ok: true, service: "unidesk-frontend", frontendPublicUrl: config.frontendPublicUrl }); + return jsonResponse({ ok: true, service: "unidesk-frontend", frontendPublicUrl: config.frontendPublicUrl, deploy: config.deploy }); } if (url.pathname === "/login" && req.method === "POST") return login(req); if (url.pathname === "/logout" && req.method === "POST") return logout(); diff --git a/src/components/frontend/src/navigation.ts b/src/components/frontend/src/navigation.ts index bbc4bb5d..28110df6 100644 --- a/src/components/frontend/src/navigation.ts +++ b/src/components/frontend/src/navigation.ts @@ -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: [ diff --git a/src/components/microservices/decision-center/Dockerfile b/src/components/microservices/decision-center/Dockerfile new file mode 100644 index 00000000..c993727c --- /dev/null +++ b/src/components/microservices/decision-center/Dockerfile @@ -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"] diff --git a/src/components/microservices/decision-center/bun.lock b/src/components/microservices/decision-center/bun.lock new file mode 100644 index 00000000..ff1c1ae4 --- /dev/null +++ b/src/components/microservices/decision-center/bun.lock @@ -0,0 +1,30 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@unidesk/decision-center", + "dependencies": { + "postgres": "latest", + }, + "devDependencies": { + "@types/bun": "latest", + "@types/node": "latest", + "typescript": "latest", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="], + + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + } +} diff --git a/src/components/microservices/decision-center/package.json b/src/components/microservices/decision-center/package.json new file mode 100644 index 00000000..bf9bac89 --- /dev/null +++ b/src/components/microservices/decision-center/package.json @@ -0,0 +1,17 @@ +{ + "name": "@unidesk/decision-center", + "private": true, + "type": "module", + "scripts": { + "start": "bun run src/index.ts", + "check": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "postgres": "latest" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/node": "latest", + "typescript": "latest" + } +} diff --git a/src/components/microservices/decision-center/src/index.ts b/src/components/microservices/decision-center/src/index.ts new file mode 100644 index 00000000..b222d5da --- /dev/null +++ b/src/components/microservices/decision-center/src/index.ts @@ -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" }); diff --git a/src/components/microservices/decision-center/tsconfig.json b/src/components/microservices/decision-center/tsconfig.json new file mode 100644 index 00000000..5d5f23f2 --- /dev/null +++ b/src/components/microservices/decision-center/tsconfig.json @@ -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" }] +} diff --git a/src/components/microservices/k3sctl-adapter/docker-compose.d601.yml b/src/components/microservices/k3sctl-adapter/docker-compose.d601.yml index 55bfa260..dad1708e 100644 --- a/src/components/microservices/k3sctl-adapter/docker-compose.d601.yml +++ b/src/components/microservices/k3sctl-adapter/docker-compose.d601.yml @@ -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: diff --git a/src/components/microservices/k3sctl-adapter/k3s/decision-center.k3s.json b/src/components/microservices/k3sctl-adapter/k3s/decision-center.k3s.json new file mode 100644 index 00000000..81903e0b --- /dev/null +++ b/src/components/microservices/k3sctl-adapter/k3s/decision-center.k3s.json @@ -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 + } +} diff --git a/src/components/microservices/k3sctl-adapter/k3s/decision-center.k8s.yaml b/src/components/microservices/k3sctl-adapter/k3s/decision-center.k8s.yaml new file mode 100644 index 00000000..5f48b35c --- /dev/null +++ b/src/components/microservices/k3sctl-adapter/k3s/decision-center.k8s.yaml @@ -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 diff --git a/src/components/microservices/k3sctl-adapter/src/index.ts b/src/components/microservices/k3sctl-adapter/src/index.ts index 18ae1c2b..22e50554 100644 --- a/src/components/microservices/k3sctl-adapter/src/index.ts +++ b/src/components/microservices/k3sctl-adapter/src/index.ts @@ -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 { diff --git a/src/tsconfig.base.json b/src/tsconfig.base.json index f0b8b3e0..65125db2 100644 --- a/src/tsconfig.base.json +++ b/src/tsconfig.base.json @@ -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" } ] }