feat: add mdtodo v3s microservice
This commit is contained in:
@@ -27,7 +27,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文
|
||||
- `bun scripts/cli.ts server rebuild <backend-core|frontend|provider-gateway|todo-note|project-manager|baidu-netdisk|oa-event-flow>`:以 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 <providerId> [--master-server URL] [--up] [--force]`:在新增计算节点上生成两项配置的 provider-gateway 挂载包;默认只需要主 server URL(默认 `http://74.48.78.17/`)和唯一 Provider ID,生成的 Compose 固定 Docker socket、`pid: "host"`、`restart: always`、只读 `/workspace`、SSH 维护私钥挂载和 loopback egress proxy 端口,规则见 `docs/reference/provider-gateway.md`。
|
||||
- `bun scripts/cli.ts ssh <providerId> [ssh-like args...]`:通过 provider-gateway 的 Host SSH / WSL SSH 维护桥打开近似原生 ssh 的交互会话或远端命令,并在远端 PATH 注入 `apply_patch`、`glob` 与 `skill-discover`;`apply-patch`、`py`、`skills`、结构化 `find`、`glob` 和 `argv` 子命令用于避免远端补丁、Python stdin、skill 发现与常用只读命令的嵌套转义问题,使用规则见 `docs/reference/cli.md` 和 `docs/reference/provider-gateway.md`。
|
||||
- `bun scripts/cli.ts microservice list/status/health/proxy`:管理和验证挂载在主 server、计算节点 Docker 或 v3s 控制面上的用户服务,OA Event Flow/Todo Note/Baidu Netdisk on main-server、V3S Control/Code Queue/FindJob/Pipeline/MET Nonlinear on D601 的规则见 `docs/reference/microservices.md`。
|
||||
- `bun scripts/cli.ts microservice list/status/health/proxy`:管理和验证挂载在主 server、计算节点 Docker 或 v3s 控制面上的用户服务,OA Event Flow/Todo Note/Baidu Netdisk on main-server、V3S Control/Code Queue/MDTODO/FindJob/Pipeline/MET Nonlinear on D601 的规则见 `docs/reference/microservices.md`。
|
||||
- `bun scripts/cli.ts codex task <taskId>`:按 Code Queue 任务 ID 查询初始 prompt、最后 assistant message、工具调用摘要、attempt/judge/error 和耗时,便于新任务引用历史 session。
|
||||
- `bun scripts/cli.ts codex judge <taskId> --attempt <n> [--dry-run]`:按指定 task/attempt 用与队列 worker 相同的上下文构建和 MiniMax judge 调用路径单步复现完成判定;`--dry-run` 只输出 prompt/payload 诊断。
|
||||
- `bun scripts/cli.ts server stop`:以异步 job 停止固定 Compose 项目中的全部 UniDesk 服务,停止后用 `server status` 复核。
|
||||
@@ -38,8 +38,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 由 D601 v3s/k8s 控制面代管,并经 `v3sctl-adapter` 的 Kubernetes API service proxy 单一路径接入,服务拓扑见 `docs/reference/deployment.md`。
|
||||
- `src/components/frontend`:前端源码固定使用 TypeScript + React,`app.tsx` 只做 shell/router,左侧主模块与顶部子标签统一编译为模块前缀路由:`/ops/<tab>/`、`/nodes/<tab>/`、`/tasks/<tab>/`、`/config/<tab>/`,只有用户服务使用 `/app/<tab>/` 深链接,运行总览包含通用性能面板,资源监控含曲线和进程资源排序表,Todo Note、FindJob、Pipeline、MET Nonlinear、Baidu Netdisk、Code Queue、OA Event Flow、V3S 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 由 D601 v3s/k8s 控制面代管,并经 `v3sctl-adapter` 的 Kubernetes API service proxy 单一路径接入,服务拓扑见 `docs/reference/deployment.md`。
|
||||
- `src/components/frontend`:前端源码固定使用 TypeScript + React,`app.tsx` 只做 shell/router,左侧主模块与顶部子标签统一编译为模块前缀路由:`/ops/<tab>/`、`/nodes/<tab>/`、`/tasks/<tab>/`、`/config/<tab>/`,只有用户服务使用 `/app/<tab>/` 深链接,运行总览包含通用性能面板,资源监控含曲线和进程资源排序表,Todo Note、FindJob、Pipeline、MET Nonlinear、Baidu Netdisk、Code Queue、MDTODO、OA Event Flow、V3S 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`。
|
||||
@@ -51,7 +51,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/v3sctl-managed 部署模式、Todo Note/Baidu Netdisk on main-server、V3S Control/Code Queue/FindJob/Pipeline/MET Nonlinear on D601 和验证规则。
|
||||
- `docs/reference/microservices.md`:用户服务(兼容命名 `microservice`)的配置、代理、安全边界、unidesk-direct/v3sctl-managed 部署模式、Todo Note/Baidu Netdisk on main-server、V3S Control/Code Queue/MDTODO/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 统计中心和前端可见性规则。
|
||||
|
||||
+57
@@ -636,6 +636,63 @@
|
||||
],
|
||||
"activeNodeId": "D601"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "mdtodo",
|
||||
"name": "MDTODO",
|
||||
"providerId": "D601",
|
||||
"description": "MDTODO 是由 D601 k3s/v3s 控制面代管的 Markdown TODO 后端,读取原 F:\\Work\\vscode-mdtodo 工作区,UniDesk frontend 负责统一任务树、编辑和状态展示。",
|
||||
"repository": {
|
||||
"url": "https://github.com/pikasTech/unidesk",
|
||||
"commitId": "local",
|
||||
"dockerfile": "src/components/microservices/mdtodo/Dockerfile",
|
||||
"composeFile": "src/components/microservices/v3sctl-adapter/v3s/mdtodo.v3s.json",
|
||||
"composeService": "mdtodo",
|
||||
"containerName": "v3s:mdtodo"
|
||||
},
|
||||
"backend": {
|
||||
"nodeBaseUrl": "v3s://mdtodo",
|
||||
"nodeBindHost": "v3s://unidesk/mdtodo",
|
||||
"nodePort": 4267,
|
||||
"proxyMode": "v3sctl-adapter-http",
|
||||
"frontendOnly": true,
|
||||
"public": false,
|
||||
"allowedMethods": [
|
||||
"GET",
|
||||
"HEAD",
|
||||
"POST",
|
||||
"PUT",
|
||||
"PATCH",
|
||||
"DELETE"
|
||||
],
|
||||
"allowedPathPrefixes": [
|
||||
"/health",
|
||||
"/live",
|
||||
"/logs",
|
||||
"/api/"
|
||||
],
|
||||
"healthPath": "/health",
|
||||
"timeoutMs": 30000
|
||||
},
|
||||
"development": {
|
||||
"providerId": "D601",
|
||||
"sshPassthrough": true,
|
||||
"worktreePath": "/home/ubuntu/cq-deploy/src/components/microservices/mdtodo"
|
||||
},
|
||||
"frontend": {
|
||||
"route": "/apps/mdtodo",
|
||||
"integrated": true
|
||||
},
|
||||
"deployment": {
|
||||
"mode": "v3sctl-managed",
|
||||
"adapterServiceId": "v3sctl-adapter",
|
||||
"v3sServiceId": "mdtodo",
|
||||
"namespace": "unidesk",
|
||||
"expectedNodeIds": [
|
||||
"D601"
|
||||
],
|
||||
"activeNodeId": "D601"
|
||||
}
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
|
||||
@@ -97,6 +97,7 @@ frontend shell 必须把左侧主模块与顶部子标签编译为统一的 URL
|
||||
- `OA Event Flow` 子标签必须把主 server `oa-event-flow-backend` 后端渲染为 UniDesk React 控件,包括服务健康、事件表、tag 过滤、SSE live 状态、Trace/STEP stats 表、Code Queue/Pipeline 标签入口和显式原始 JSON 按钮;默认页面不得裸铺完整事件 JSON,事件表只展示结构化摘要,完整 envelope/payload 只能通过 `查看原始JSON` 打开。
|
||||
- `V3S Control` 子标签必须把 D601 `v3sctl-adapter` 控制面渲染为 UniDesk React 控件,包括 control plane 状态、manifest 列表、D601/D518 节点实例、active instance、single-writer/no-fallback 路径、Kubernetes API service proxy 状态、kubectl/v3s snapshot 摘要和显式原始 JSON 按钮;页面只能通过 `/api/microservices/v3sctl-adapter/proxy/api/control-plane` 取数,不得直接访问 provider-gateway、NodePort、业务容器端口或裸 v3s/kubectl API。
|
||||
- `Code Queue` 子标签必须把 D601 v3s/k8s `code-queue` Service 渲染为 UniDesk React 控件,前端 API 基址只能是 `/api/microservices/code-queue/proxy`,不能继续使用旧 `/api/code-queue-direct` 别名;页面包括多 queue lane、queue 内串行、queue 间并行、queue 合并(点击“合并 queue”后必须用公共 `UniDeskDialog` 打开独立小窗口,用下拉菜单选择源 queue;不得把源 queue 选择控件塞进正常提交任务的 Queue 选择区;合并后自动删除源 queue,只保留合并后的目标 queue,目标 queue 按原 queueEnteredAt/createdAt 时间顺序串行)、任务 ID/复制任务 ID、引用按钮、任务耗时、任务提交/批量提交、引用任务 ID、创建成功提示、清空输入、模型下拉、执行 Provider 下拉、执行模式下拉(默认容器/本机或 `windows-native`)、显式入队份数、默认模型 `gpt-5.5`、MiniMax judge 状态、Codex CLI-like 输出流、attempt 终态、运行中追加 prompt、打断、手动重试和显式原始 JSON 按钮;`windows-native` 模式必须在任务 JSON、卡片和 Trace 头部显示,并要求非本机 WSL Provider 与 `/mnt/<drive>` 工作目录;Codex CLI-like 输出流必须始终保留任务的初始 `Submitted prompt` 和运行中 `Steer prompt`;整个 agent loop 消息流统一命名为专有名词 `Trace`,`Trace` 包含 assistant message、user prompt、system event 和 tool call,但非错误 system event 默认只保留在原始输出/数据库中,不在 TraceView 展示;Code Queue 与 Pipeline/OpenCode messages 必须共用 `src/components/frontend/src/trace.tsx` 的 Trace 公共组件、统一 Trace item 接口和 codex/opencode port 适配层;连续 read/edit/run 工具调用只是在 Trace 内折叠为可展开工具调用组,汇总格式至少包含 `xx read, xx edit, xx run`,并展示读取文件、编辑文件、运行命令和耗时摘要;最近 3 个工具调用保持展开,工具调用内容不得自动换行且必须在工具调用块内部横向滚动,工具调用组展开后不得再增加额外左侧缩进;message 与 prompt 必须自动换行,普通 message 不显示左侧项目符号缩进且永不折叠;Trace 首屏可以是摘要预览,但终态任务被选中后必须自动在后台加载完整 Trace,手动“加载完整 Trace”也必须从 Code Queue output archive 分页补齐早期 trace,不得把 preview 的 `hasMore=false` 当成完整历史;即使热状态为控制体积裁剪了早期 raw output,也要从结构化 `basePrompt/displayPrompt/promptHistory` 和 archive 合成完整用户输入与 agent trace,并且初始 prompt 默认显示注入前 prompt 而不是引用注入全文;当初始 prompt 含引用注入时,引用内容必须默认折叠,并只在 Trace 的初始消息中提供可展开的“最终传入 Codex 的真实完整 prompt”,不得再渲染独立 Prompt 全量卡片;多轮引用注入必须按上游/最早上下文在前、直接引用在后的顺序排列,每一轮必须有明确 `Reference Round N/M` 分割线和时间范围,不能用固定 6 轮截断引用链;点击队列引用按钮必须自动把该任务 ID 写入提交表单的引用输入框,引用任务 ID 创建新任务时必须自动注入 `bun scripts/cli.ts codex task <taskId>` 的提示;连续执行同一 prompt 应通过入队份数一次性生成多条任务,避免快速连点造成操作员误判。
|
||||
- `MDTODO` 子标签必须把 D601 k3s/v3s `mdtodo` Service 渲染为 UniDesk React 控件,前端 API 基址只能是 `/api/microservices/mdtodo/proxy`;页面包括 TODO Markdown 文件列表、任务树、状态徽标、标题与正文编辑、新增根任务/子任务、删除任务、执行命令生成、hostPath 健康摘要和显式原始 JSON 按钮,不得 iframe 原 VS Code webview、公开 VSIX 旧前端或把完整 Markdown/JSON 默认铺在页面上。
|
||||
- `Code Queue` 前端改进必须在同一任务内重建并上线公网 frontend,不能只修改源码或本地 bundle;重建 frontend 是无状态 WebUI 替换,不会导致 Code Queue 长期任务失败。已结束未读任务只能在 task card 边角显示类似未读消息的 `codex-unread-badge` 圆点和“标为已读”操作,不得把整张卡片改成红色/琥珀色失败态边框、背景或胶囊标签;状态栏的“结束未读”提示也不得使用失败态红色。
|
||||
- `Code Queue` 前端必须把 PostgreSQL-backed backend API 作为 task、queue、readAt/未读状态和 attempt 状态的唯一数据来源;不得用 `localStorage`、`sessionStorage` 或 IndexedDB 持久化这些业务状态,也不得在后端标记已读失败时伪造本地成功。前端允许保留 React 内存态、请求 in-flight guard 和本轮页面缓存,但刷新页面或切换设备后的状态必须完全由后端 PostgreSQL 数据恢复。
|
||||
- `Code Queue` 前后端通信必须采用渐进式披露:首屏只请求 queue/task 轻量摘要、必要的 selected preview 和小体积统计,不得默认拉取完整 transcript、raw output、原始 JSON 或全部历史任务;加载下一页或搜索分页时必须显式传递 `selected=0`、`includeActive=0`、`stats=0` 等价开关,避免每一页重复请求 selected/active/stats;点击/选中 task 后再按需加载 summary、prompt part、trace step、raw output 或完整 Trace。`read`/`mark all read` 应调用专用 mutation 并用后端返回的 patch 更新当前内存态,不能为了隐藏未读圆点而强制刷新完整 overview;请求仍需遵守 PostgreSQL 权威源,失败时不得本地伪造已读。Code Queue 性能问题应优先通过缩小 API 响应、分页/cursor、去重 in-flight 请求、短 TTL 且 mutation 失效的页面缓存和后端 SQL 聚合解决,避免以重写渲染层或把大 JSON 藏在 DOM/React state 中规避慢请求。
|
||||
|
||||
@@ -179,6 +179,17 @@ Baidu Netdisk 在 UniDesk 语境中按纯后端服务管理:不得暴露百度
|
||||
- 代理路径:只允许 `/health`、`/logs` 和 `/api/` 前缀;允许方法为 `GET`、`HEAD`、`POST`、`DELETE`、`PATCH`。Code Queue 只在 Compose 内网暴露 `4222/tcp`,不得映射或开放到公网。
|
||||
- UniDesk 前端:`用户服务 / Code Queue` React 页面负责展示队列卡片、任务 ID、复制任务 ID、引用按钮、任务耗时、默认模型、模型下拉、执行 Provider 下拉、执行模式下拉、Provider/模式对应默认工作目录、显式入队份数、引用任务 ID、清空输入、创建成功提示、MiniMax judge 状态、Codex CLI-like 输出流、attempt 终态、追加 prompt、打断和手动重试控件;选择 `windows-native` 时应优先切到支持 Windows 原生 Codex 的非主 server Provider,并把工作目录提示切到 `/mnt/<drive>` 默认路径;整个 agent loop 消息流统一命名为专有名词 `Trace`,`Trace` 包含 assistant message、user prompt、system event 和 tool call;Code Queue 与 Pipeline/OpenCode messages 必须共用 `src/components/frontend/src/trace.tsx` 的 Trace 公共组件、统一 Trace item 接口和 codex/opencode port 适配层;连续 read/edit/run 工具调用只是在 Trace 内折叠为可展开工具调用组,汇总格式至少包含 `xx read, xx edit, xx run`,并展示读取文件、编辑文件、运行命令和耗时摘要;最近 3 个工具调用保持展开,工具调用内容不得自动换行且必须在工具调用块内部横向滚动,工具调用组展开后不得再增加额外左侧缩进;message 与 prompt 必须自动换行,普通 message 不显示左侧项目符号缩进且永不折叠;点击队列卡片引用按钮必须自动把该任务 ID 写入提交表单的引用任务 ID 输入框;引用任务 ID 创建新任务时必须自动注入 `bun scripts/cli.ts codex task <taskId>` 的提示,让 Codex 读取初始 prompt、最后消息和工具摘要后继续;连续执行同一 prompt 应使用 `入队份数` 一次性生成多条队列任务,而不是依赖快速连点按钮;左侧 queue/session 卡片的 `QUEUED` 状态必须显示原因,例如 `QUEUED(PREV TASK)`、`QUEUED(MEM LIMIT)`、`QUEUED(ACTIVE LIMIT)`;原始任务 JSON 只能通过显式 `查看原始JSON` 打开。
|
||||
|
||||
### MDTODO V3S-Managed
|
||||
|
||||
当前 MDTODO 作为 `id=mdtodo` 的 `v3sctl-managed` 用户服务登记在 `config.json`,用于把 D601 Windows 工作区 `F:\Work\vscode-mdtodo` 从 VS Code 扩展形态拆成 UniDesk 可代理的后端服务:
|
||||
|
||||
- Orchestrator:`deployment.mode=v3sctl-managed`,`deployment.adapterServiceId=v3sctl-adapter`,`deployment.v3sServiceId=mdtodo`,`backend.proxyMode=v3sctl-adapter-http`,`backend.nodeBaseUrl=v3s://mdtodo`;正式链路只能是 `frontend -> backend-core -> v3sctl-adapter -> Kubernetes API service proxy -> Kubernetes Service mdtodo:4267`。
|
||||
- 代码与部署引用:后端源码位于 UniDesk 仓库 `src/components/microservices/mdtodo`,Dockerfile 为 `src/components/microservices/mdtodo/Dockerfile`;v3s manifest 为 `src/components/microservices/v3sctl-adapter/v3s/mdtodo.v3s.json`,Kubernetes 运行清单为 `src/components/microservices/v3sctl-adapter/v3s/mdtodo.k8s.yaml`,镜像名固定为 `unidesk-mdtodo:d601`。
|
||||
- 持久化边界:D601 的 `F:\Work\vscode-mdtodo` 先同步到 k3s 可见的 WSL hostPath `/home/ubuntu/cq-deploy/.state/mdtodo-workspace`,Pod 将该目录挂载为 `/workspace`,后端直接读写 Markdown TODO 文件;`.state/mdtodo/logs` 只保存 JSONL 日志,不作为任务权威状态。该服务不得把原 VS Code webview 前端或 VSIX 构建产物作为浏览器入口。
|
||||
- API:`GET /health`、`GET /live`、`GET /logs`;`GET /api/files`;`GET /api/tasks?file=...`;`GET|PATCH|DELETE /api/tasks/{id}`;`POST /api/tasks`;`GET|PUT /api/content`;`POST /api/execute-command`。`/health` 必须证明 hostPath 可读并至少能扫描到 TODO Markdown 文件。
|
||||
- 代理路径:只允许 `/health`、`/live`、`/logs` 和 `/api/` 前缀;允许方法为 `GET`、`HEAD`、`POST`、`PUT`、`PATCH` 和 `DELETE`。业务请求不得退化为 provider-gateway 直连、NodePort 或 D601 本机端口。
|
||||
- UniDesk 前端:`用户服务 / MDTODO` React 页面负责展示文件列表、任务树、任务状态、标题/正文编辑、新增/删除任务、执行命令生成和显式原始 JSON 按钮;默认页面不得裸铺完整 Markdown 或 JSON。
|
||||
|
||||
## D601 User Services
|
||||
|
||||
当前 `D601` 同时承载以下 UniDesk 用户服务:
|
||||
|
||||
@@ -35,6 +35,7 @@ function unifiedLogRotationItem(): CheckItem {
|
||||
"src/components/provider-gateway/src/index.ts",
|
||||
"src/components/microservices/code-queue/src/index.ts",
|
||||
"src/components/microservices/v3sctl-adapter/src/index.ts",
|
||||
"src/components/microservices/mdtodo/src/index.ts",
|
||||
"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",
|
||||
@@ -68,6 +69,7 @@ export function runChecks(config: UniDeskConfig): { ok: boolean; items: CheckIte
|
||||
fileItem("src/components/provider-gateway/src/index.ts"),
|
||||
fileItem("src/components/microservices/oa-event-flow/src/index.ts"),
|
||||
fileItem("src/components/microservices/v3sctl-adapter/src/index.ts"),
|
||||
fileItem("src/components/microservices/mdtodo/src/index.ts"),
|
||||
fileItem("scripts/src/e2e.ts"),
|
||||
unifiedLogRotationItem(),
|
||||
commandItem("bun:version", ["bun", "--version"]),
|
||||
|
||||
@@ -101,6 +101,13 @@ async function route(req: Request, server: Server<WsData>): Promise<Response | u
|
||||
|
||||
async function providerRoute(req: Request, server: Server<WsData>): Promise<Response | undefined> {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname === "/" || url.pathname === "/health") {
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
service: "unidesk-provider-ingress",
|
||||
activeSocketCount: ctx.activeProviders.size,
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/ws/provider") {
|
||||
const token = url.searchParams.get("token") ?? req.headers.get("x-provider-token");
|
||||
if (token !== config().providerToken) return jsonResponse({ ok: false, error: "invalid provider token" }, 401);
|
||||
|
||||
@@ -6490,3 +6490,113 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.mdtodo-page {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.mdtodo-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 0.7fr) minmax(320px, 1.25fr) minmax(320px, 1fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
.mdtodo-file-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.mdtodo-file-item,
|
||||
.mdtodo-task-row {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line-soft);
|
||||
background: rgba(0,0,0,0.16);
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
}
|
||||
.mdtodo-file-item {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 10px;
|
||||
}
|
||||
.mdtodo-file-item strong,
|
||||
.mdtodo-file-item span,
|
||||
.mdtodo-file-item code {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.mdtodo-file-item span {
|
||||
color: var(--muted);
|
||||
}
|
||||
.mdtodo-file-item.active,
|
||||
.mdtodo-task-row.active {
|
||||
border-color: rgba(215,161,58,0.75);
|
||||
background: rgba(215,161,58,0.08);
|
||||
}
|
||||
.mdtodo-tree,
|
||||
.mdtodo-tree ol {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.mdtodo-tree ol {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.mdtodo-task-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 34px;
|
||||
padding: 7px 8px 7px calc(8px + (var(--task-depth, 0) * 16px));
|
||||
}
|
||||
.mdtodo-task-row code {
|
||||
color: var(--accent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mdtodo-task-title {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.mdtodo-link-count {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.mdtodo-editor {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.mdtodo-editor label {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.mdtodo-editor textarea {
|
||||
min-height: 260px;
|
||||
resize: vertical;
|
||||
}
|
||||
.mdtodo-command {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line-soft);
|
||||
background: rgba(0,0,0,0.22);
|
||||
}
|
||||
.mdtodo-command span {
|
||||
color: var(--muted);
|
||||
}
|
||||
.mdtodo-command code {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.mdtodo-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { CodeQueuePage } from "./code-queue";
|
||||
import { FileBrowserPage } from "./filebrowser";
|
||||
import { FindJobPage } from "./findjob";
|
||||
import { MetNonlinearPage } from "./met-nonlinear";
|
||||
import { MdtodoPage } from "./mdtodo";
|
||||
import { canonicalizeKnownRoute, createRouteRegistry, DEFAULT_ACTIVE_TABS, MODULES, pathForTarget, resolveRouteTarget } from "./navigation";
|
||||
import { OaEventFlowPage } from "./oa-event-flow";
|
||||
import { PipelinePage } from "./pipeline";
|
||||
@@ -1650,6 +1651,7 @@ function MicroserviceCatalogPage({ microservices, onRaw, onNavigate }: AnyRecord
|
||||
service.id === "oa-event-flow" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "oa-event-flow"), "data-testid": "open-oa-event-flow-button" }, "打开") : null,
|
||||
service.id === "v3sctl-adapter" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "v3sctl"), "data-testid": "open-v3sctl-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 === "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 }),
|
||||
),
|
||||
@@ -2157,6 +2159,7 @@ function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw, onNa
|
||||
if (activeModule === "apps" && activeTab === "oa-event-flow") return h(OaEventFlowPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
|
||||
if (activeModule === "apps" && activeTab === "v3sctl") return h(V3sCtlPage, { 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 === "project-manager") return h(ProjectManagerPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
|
||||
if (activeModule === "config" && activeTab === "topology") return h(TopologyPage, { data });
|
||||
if (activeModule === "config" && activeTab === "auth") return h(AuthPage, { session });
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import React from "react";
|
||||
import { fmtClock, fmtDate } from "./time";
|
||||
import { LoadingTitle } from "./loading-indicator";
|
||||
import { errorMessage, requestJson } from "./unidesk-error";
|
||||
import { UniDeskErrorBanner } from "./unidesk-error-banner";
|
||||
|
||||
type AnyRecord = Record<string, any>;
|
||||
|
||||
const h = React.createElement;
|
||||
const { useEffect, useMemo } = React;
|
||||
const useState: any = React.useState;
|
||||
|
||||
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 mdtodoApi(apiBaseUrl: string, path: string): string {
|
||||
return `${apiBaseUrl}/microservices/mdtodo/proxy${path}`;
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
if (status === "completed") return "已完成";
|
||||
if (status === "in_progress") return "进行中";
|
||||
return "待处理";
|
||||
}
|
||||
|
||||
function statusTone(status: string): string {
|
||||
if (status === "completed") return "online";
|
||||
if (status === "in_progress") return "warn";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function flattenTasks(tasks: any[]): any[] {
|
||||
const rows: any[] = [];
|
||||
const visit = (items: any[]) => {
|
||||
for (const task of items) {
|
||||
rows.push(task);
|
||||
if (Array.isArray(task.children)) visit(task.children);
|
||||
}
|
||||
};
|
||||
visit(Array.isArray(tasks) ? tasks : []);
|
||||
return rows;
|
||||
}
|
||||
|
||||
function TaskTree({ tasks, selectedId, onSelect }: AnyRecord) {
|
||||
if (!tasks.length) return h(EmptyState, { title: "暂无任务", text: "当前文件没有 R 编号任务。" });
|
||||
const renderTask = (task: AnyRecord): React.ReactElement => h("li", { key: task.id },
|
||||
h("button", {
|
||||
type: "button",
|
||||
className: `mdtodo-task-row ${selectedId === task.id ? "active" : ""}`,
|
||||
style: { "--task-depth": Math.min(Number(task.depth || 0), 6) },
|
||||
onClick: () => onSelect(task.id),
|
||||
"data-testid": `mdtodo-task-${String(task.id).replace(/[^A-Za-z0-9_-]+/g, "-")}`,
|
||||
},
|
||||
h(StatusBadge, { status: statusTone(task.status) }, statusLabel(task.status)),
|
||||
h("code", null, task.id),
|
||||
h("span", { className: "mdtodo-task-title" }, task.title || "--"),
|
||||
h("span", { className: "mdtodo-link-count" }, `${task.linkExists ?? 0}/${task.linkCount ?? 0}`),
|
||||
),
|
||||
Array.isArray(task.children) && task.children.length > 0 ? h("ol", null, task.children.map(renderTask)) : null,
|
||||
);
|
||||
return h("ol", { className: "mdtodo-tree" }, tasks.map(renderTask));
|
||||
}
|
||||
|
||||
export function MdtodoPage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyRecord) {
|
||||
const service = microservices.find((item: any) => item.id === "mdtodo") || null;
|
||||
const [state, setState] = useState({ loading: false, saving: false, error: "", notice: "", health: null, files: [], todo: null, selectedFile: "", selectedTaskId: "", refreshedAt: null, command: null });
|
||||
const [draftTitle, setDraftTitle] = useState("");
|
||||
const [draftRaw, setDraftRaw] = useState("");
|
||||
const [newTaskTitle, setNewTaskTitle] = useState("");
|
||||
|
||||
const tasks = Array.isArray(state.todo?.tasks) ? state.todo.tasks : [];
|
||||
const flatTasks = useMemo(() => flattenTasks(tasks), [state.todo]);
|
||||
const selectedTask = flatTasks.find((task: any) => task.id === state.selectedTaskId) || flatTasks[0] || null;
|
||||
const stats = state.todo?.stats || {};
|
||||
|
||||
async function load(filePath = state.selectedFile): Promise<void> {
|
||||
if (!service) return;
|
||||
setState((prev: any) => ({ ...prev, loading: true, error: "", notice: "" }));
|
||||
try {
|
||||
const [health, fileList] = await Promise.all([
|
||||
requestJson(`${apiBaseUrl}/microservices/mdtodo/health`),
|
||||
requestJson(mdtodoApi(apiBaseUrl, "/api/files")),
|
||||
]);
|
||||
const files = Array.isArray(fileList.files) ? fileList.files : [];
|
||||
const selectedFile = filePath || files[0]?.path || "";
|
||||
const todo = selectedFile ? await requestJson(mdtodoApi(apiBaseUrl, `/api/tasks?file=${encodeURIComponent(selectedFile)}`)) : null;
|
||||
const nextTasks = Array.isArray(todo?.tasks) ? flattenTasks(todo.tasks) : [];
|
||||
const nextSelectedTaskId = nextTasks.some((task: any) => task.id === state.selectedTaskId)
|
||||
? state.selectedTaskId
|
||||
: nextTasks[0]?.id || "";
|
||||
setState({ loading: false, saving: false, error: "", notice: "", health, files, todo, selectedFile, selectedTaskId: nextSelectedTaskId, refreshedAt: new Date(), command: state.command });
|
||||
} catch (err) {
|
||||
setState((prev: any) => ({ ...prev, loading: false, error: errorMessage(err, "MDTODO 加载失败") }));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [service?.id, service?.runtime?.providerStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
setDraftTitle(selectedTask?.title || "");
|
||||
setDraftRaw(selectedTask?.rawContent || "");
|
||||
}, [selectedTask?.id, state.selectedFile]);
|
||||
|
||||
if (!service) return h(EmptyState, { title: "MDTODO 未登记", text: "请在 config.json 的 microservices 中登记用户服务 id=mdtodo" });
|
||||
|
||||
const runtime = microserviceRuntime(service);
|
||||
const repository = microserviceRepository(service);
|
||||
const backend = microserviceBackend(service);
|
||||
|
||||
async function selectFile(path: string): Promise<void> {
|
||||
setState((prev: any) => ({ ...prev, selectedFile: path, selectedTaskId: "", command: null }));
|
||||
await load(path);
|
||||
}
|
||||
|
||||
async function patchSelected(patch: AnyRecord): Promise<void> {
|
||||
if (!selectedTask || !state.selectedFile) return;
|
||||
setState((prev: any) => ({ ...prev, saving: true, error: "", notice: "" }));
|
||||
try {
|
||||
const result = await requestJson(mdtodoApi(apiBaseUrl, `/api/tasks/${encodeURIComponent(selectedTask.id)}`), {
|
||||
method: "PATCH",
|
||||
body: { file: state.selectedFile, ...patch },
|
||||
});
|
||||
setState((prev: any) => ({ ...prev, saving: false, todo: result.file, notice: result.result?.message || "任务已更新" }));
|
||||
} catch (err) {
|
||||
setState((prev: any) => ({ ...prev, saving: false, error: errorMessage(err, "任务更新失败") }));
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSelected(): Promise<void> {
|
||||
await patchSelected({ title: draftTitle, rawContent: draftRaw });
|
||||
}
|
||||
|
||||
async function addTask(parentId?: string): Promise<void> {
|
||||
if (!state.selectedFile) return;
|
||||
setState((prev: any) => ({ ...prev, saving: true, error: "", notice: "" }));
|
||||
try {
|
||||
const result = await requestJson(mdtodoApi(apiBaseUrl, "/api/tasks"), {
|
||||
method: "POST",
|
||||
body: { file: state.selectedFile, parentId, title: newTaskTitle || "新任务" },
|
||||
});
|
||||
setNewTaskTitle("");
|
||||
setState((prev: any) => ({ ...prev, saving: false, todo: result.file, selectedTaskId: result.result?.taskId || prev.selectedTaskId, notice: result.result?.message || "任务已创建" }));
|
||||
await load(state.selectedFile);
|
||||
} catch (err) {
|
||||
setState((prev: any) => ({ ...prev, saving: false, error: errorMessage(err, "创建任务失败") }));
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSelected(): Promise<void> {
|
||||
if (!selectedTask || !state.selectedFile) return;
|
||||
if (!window.confirm(`删除 ${selectedTask.id} 及其子任务?`)) return;
|
||||
setState((prev: any) => ({ ...prev, saving: true, error: "", notice: "" }));
|
||||
try {
|
||||
const result = await requestJson(mdtodoApi(apiBaseUrl, `/api/tasks/${encodeURIComponent(selectedTask.id)}?file=${encodeURIComponent(state.selectedFile)}`), { method: "DELETE" });
|
||||
const nextTasks = flattenTasks(result.file?.tasks || []);
|
||||
setState((prev: any) => ({ ...prev, saving: false, todo: result.file, selectedTaskId: nextTasks[0]?.id || "", notice: result.result?.message || "任务已删除" }));
|
||||
} catch (err) {
|
||||
setState((prev: any) => ({ ...prev, saving: false, error: errorMessage(err, "删除任务失败") }));
|
||||
}
|
||||
}
|
||||
|
||||
async function generateCommand(): Promise<void> {
|
||||
if (!selectedTask || !state.selectedFile) return;
|
||||
setState((prev: any) => ({ ...prev, saving: true, error: "", command: null }));
|
||||
try {
|
||||
const command = await requestJson(mdtodoApi(apiBaseUrl, "/api/execute-command"), {
|
||||
method: "POST",
|
||||
body: { file: state.selectedFile, taskId: selectedTask.id, mode: "codex" },
|
||||
});
|
||||
setState((prev: any) => ({ ...prev, saving: false, command, notice: "执行命令已生成" }));
|
||||
} catch (err) {
|
||||
setState((prev: any) => ({ ...prev, saving: false, error: errorMessage(err, "执行命令生成失败") }));
|
||||
}
|
||||
}
|
||||
|
||||
return h("div", { className: "mdtodo-page", "data-testid": "mdtodo-page" },
|
||||
h(Panel, {
|
||||
title: "MDTODO 工作台",
|
||||
eyebrow: "D601 k3s managed service",
|
||||
loading: state.loading,
|
||||
actions: h("div", { className: "panel-actions" },
|
||||
h("button", { type: "button", className: "ghost-btn", onClick: () => void load(), disabled: state.loading, "data-testid": "mdtodo-refresh-button" }, state.loading ? "刷新中" : "刷新"),
|
||||
h(RawButton, { title: "MDTODO 用户服务", data: service, onOpen: onRaw, testId: "raw-mdtodo-service" }),
|
||||
),
|
||||
},
|
||||
h("div", { className: "findjob-hero" },
|
||||
h("div", null,
|
||||
h("div", { className: "node-version-line" },
|
||||
h(StatusBadge, { status: runtime.providerStatus === "online" ? "online" : "warn" }, runtime.providerStatus || "unknown"),
|
||||
h("span", null, service.providerId),
|
||||
h("span", null, backend.proxyMode || "--"),
|
||||
),
|
||||
h("p", { className: "muted paragraph" }, service.description),
|
||||
),
|
||||
h("div", { className: "microservice-ref-card" },
|
||||
h("span", null, "Repo"),
|
||||
h("strong", null, repository.url || "--"),
|
||||
h("code", null, repository.composeFile || "--"),
|
||||
),
|
||||
h("div", { className: "microservice-ref-card" },
|
||||
h("span", null, "Workspace"),
|
||||
h("strong", null, state.health?.rootDir || "/workspace"),
|
||||
h("code", null, `${backend.nodeBindHost || "--"}:${backend.nodePort || "--"}`),
|
||||
),
|
||||
),
|
||||
h(UniDeskErrorBanner, { error: state.error, wide: true }),
|
||||
state.notice ? h("div", { className: "notice-line" }, state.notice) : null,
|
||||
),
|
||||
h("div", { className: "metric-grid" },
|
||||
h(MetricCard, { label: "TODO 文件", value: state.files.length, hint: state.health?.rootExists ? "hostPath ready" : "hostPath missing", tone: state.health?.ok ? "ok" : "warn" }),
|
||||
h(MetricCard, { label: "任务总数", value: stats.total ?? "--", hint: state.selectedFile || "--" }),
|
||||
h(MetricCard, { label: "已完成", value: stats.completed ?? "--", hint: "completed", tone: "ok" }),
|
||||
h(MetricCard, { label: "进行中", value: stats.inProgress ?? "--", hint: "in progress", tone: "warn" }),
|
||||
h(MetricCard, { label: "待处理", value: stats.pending ?? "--", hint: state.refreshedAt ? fmtClock(state.refreshedAt) : "pending" }),
|
||||
),
|
||||
h("div", { className: "mdtodo-layout" },
|
||||
h(Panel, { title: "文件", eyebrow: `${state.files.length} Markdown`, loading: state.loading, className: "mdtodo-file-panel" },
|
||||
state.files.length === 0 ? h(EmptyState, { title: "暂无 TODO 文件", text: "等待后端扫描 MDTODO 工作区。" }) :
|
||||
h("div", { className: "mdtodo-file-list" }, state.files.map((file: any) => h("button", {
|
||||
key: file.path,
|
||||
type: "button",
|
||||
className: `mdtodo-file-item ${state.selectedFile === file.path ? "active" : ""}`,
|
||||
onClick: () => void selectFile(file.path),
|
||||
},
|
||||
h("strong", null, file.name),
|
||||
h("span", null, file.directory || "."),
|
||||
h("code", null, `${file.stats?.total ?? "--"} tasks / ${fmtDate(file.mtime)}`),
|
||||
))),
|
||||
),
|
||||
h(Panel, {
|
||||
title: "任务树",
|
||||
eyebrow: state.selectedFile || "Tasks",
|
||||
loading: state.loading,
|
||||
actions: h("div", { className: "panel-actions" },
|
||||
h("input", { value: newTaskTitle, onChange: (event: any) => setNewTaskTitle(event.target.value), placeholder: "新任务标题", "data-testid": "mdtodo-new-title" }),
|
||||
h("button", { type: "button", className: "ghost-btn", onClick: () => void addTask(), disabled: state.saving || !state.selectedFile }, "新增"),
|
||||
selectedTask ? h("button", { type: "button", className: "ghost-btn", onClick: () => void addTask(selectedTask.id), disabled: state.saving }, "新增子任务") : null,
|
||||
state.todo ? h(RawButton, { title: "MDTODO 当前文件", data: state.todo, onOpen: onRaw, testId: "raw-mdtodo-file" }) : null,
|
||||
),
|
||||
},
|
||||
h(TaskTree, { tasks, selectedId: selectedTask?.id || "", onSelect: (id: string) => setState((prev: any) => ({ ...prev, selectedTaskId: id, command: null })) }),
|
||||
),
|
||||
h(Panel, {
|
||||
title: selectedTask ? `${selectedTask.id} 详情` : "任务详情",
|
||||
eyebrow: selectedTask ? statusLabel(selectedTask.status) : "Detail",
|
||||
loading: state.saving,
|
||||
className: "mdtodo-detail-panel",
|
||||
actions: selectedTask ? h("div", { className: "panel-actions" },
|
||||
h("button", { type: "button", className: "ghost-btn", onClick: () => void patchSelected({ status: "pending" }), disabled: state.saving }, "待处理"),
|
||||
h("button", { type: "button", className: "ghost-btn", onClick: () => void patchSelected({ status: "in_progress" }), disabled: state.saving }, "进行中"),
|
||||
h("button", { type: "button", className: "primary-btn", onClick: () => void patchSelected({ status: "completed" }), disabled: state.saving }, "完成"),
|
||||
) : null,
|
||||
},
|
||||
!selectedTask ? h(EmptyState, { title: "未选中任务", text: "请选择一个任务。" }) :
|
||||
h("div", { className: "mdtodo-editor" },
|
||||
h("label", null, "标题", h("input", { value: draftTitle, onChange: (event: any) => setDraftTitle(event.target.value), "data-testid": "mdtodo-title-input" })),
|
||||
h("label", null, "正文", h("textarea", { value: draftRaw, onChange: (event: any) => setDraftRaw(event.target.value), rows: 12, "data-testid": "mdtodo-raw-input" })),
|
||||
h("div", { className: "docker-meta compact" },
|
||||
h("span", null, `line ${Number(selectedTask.lineNumber ?? 0) + 1}`),
|
||||
h("span", null, `depth ${selectedTask.depth ?? 0}`),
|
||||
h("span", null, `links ${selectedTask.linkExists ?? 0}/${selectedTask.linkCount ?? 0}`),
|
||||
),
|
||||
h("div", { className: "inline-actions" },
|
||||
h("button", { type: "button", className: "primary-btn", onClick: saveSelected, disabled: state.saving }, state.saving ? "保存中" : "保存"),
|
||||
h("button", { type: "button", className: "ghost-btn", onClick: generateCommand, disabled: state.saving }, "生成执行命令"),
|
||||
h("button", { type: "button", className: "danger-btn", onClick: deleteSelected, disabled: state.saving }, "删除"),
|
||||
h(RawButton, { title: `MDTODO ${selectedTask.id}`, data: selectedTask, onOpen: onRaw, testId: "raw-mdtodo-task" }),
|
||||
),
|
||||
state.command ? h("div", { className: "mdtodo-command" },
|
||||
h("span", null, state.command.prompt || "--"),
|
||||
h("code", null, state.command.command || "--"),
|
||||
) : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -72,6 +72,7 @@ export const MODULES: UniDeskModuleDefinition[] = [
|
||||
{ id: "oa-event-flow", label: "OA Event Flow" },
|
||||
{ id: "v3sctl", label: "V3S Control" },
|
||||
{ id: "code-queue", label: "Code Queue" },
|
||||
{ id: "mdtodo", label: "MDTODO" },
|
||||
{ id: "project-manager", label: "Project Manager" },
|
||||
] },
|
||||
{ id: "config", label: "系统配置", code: "CFG", tabs: [
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
ARG MDTODO_BASE_IMAGE=oven/bun:1-debian
|
||||
FROM ${MDTODO_BASE_IMAGE}
|
||||
|
||||
WORKDIR /app/src/components/microservices/mdtodo
|
||||
COPY src/components/microservices/mdtodo/package.json ./package.json
|
||||
RUN bun install --production
|
||||
COPY src/components/shared /app/src/components/shared
|
||||
COPY src/components/microservices/mdtodo/tsconfig.json ./tsconfig.json
|
||||
COPY src/components/microservices/mdtodo/src ./src
|
||||
|
||||
EXPOSE 4267
|
||||
CMD ["bun", "run", "src/index.ts"]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@unidesk/mdtodo",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "bun run src/index.ts",
|
||||
"check": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/node": "latest",
|
||||
"typescript": "latest"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,671 @@
|
||||
import { createHourlyJsonlWriter, logRetentionBytesForService } from "../../../shared/src/rotating-jsonl";
|
||||
import {
|
||||
constants,
|
||||
accessSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
renameSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
||||
|
||||
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
||||
type JsonRecord = Record<string, JsonValue>;
|
||||
type TodoStatus = "pending" | "in_progress" | "completed";
|
||||
|
||||
interface TodoLink {
|
||||
label: string;
|
||||
target: string;
|
||||
exists: boolean | null;
|
||||
}
|
||||
|
||||
interface TodoTask {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
rawContent: string;
|
||||
status: TodoStatus;
|
||||
completed: boolean;
|
||||
processing: boolean;
|
||||
children: TodoTask[];
|
||||
lineNumber: number;
|
||||
level: number;
|
||||
depth: number;
|
||||
parentId: string | null;
|
||||
filePath: string;
|
||||
linkCount: number;
|
||||
linkExists: number;
|
||||
links: TodoLink[];
|
||||
}
|
||||
|
||||
interface TextBlock {
|
||||
id: string;
|
||||
content: string;
|
||||
rawContent: string;
|
||||
lineNumber: number;
|
||||
}
|
||||
|
||||
interface TodoStats {
|
||||
total: number;
|
||||
completed: number;
|
||||
inProgress: number;
|
||||
pending: number;
|
||||
maxDepth: number;
|
||||
}
|
||||
|
||||
interface TodoFileSummary {
|
||||
path: string;
|
||||
name: string;
|
||||
directory: string;
|
||||
sizeBytes: number;
|
||||
mtime: string;
|
||||
stats: TodoStats | null;
|
||||
parseError: string | null;
|
||||
}
|
||||
|
||||
interface ParsedTodoFile {
|
||||
file: TodoFileSummary;
|
||||
tasks: TodoTask[];
|
||||
textBlocks: TextBlock[];
|
||||
stats: TodoStats;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
interface TaskMutationResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
taskId?: string;
|
||||
}
|
||||
|
||||
const startedAt = new Date().toISOString();
|
||||
const host = envString("HOST", "0.0.0.0");
|
||||
const port = envNumber("PORT", 4267);
|
||||
const workspaceRoot = resolve(envString("MDTODO_ROOT_DIR", "/workspace"));
|
||||
const rootRealPath = resolveRealWorkspaceRoot(workspaceRoot);
|
||||
const maxFiles = envNumber("MDTODO_MAX_FILES", 250);
|
||||
const recentLogs: JsonRecord[] = [];
|
||||
const logFile = envString("LOG_FILE", envString("MDTODO_LOG_FILE", "/var/log/unidesk/mdtodo.jsonl"));
|
||||
const logWriter = logFile
|
||||
? createHourlyJsonlWriter({
|
||||
baseLogFile: logFile,
|
||||
service: "mdtodo",
|
||||
maxBytes: logRetentionBytesForService("mdtodo"),
|
||||
})
|
||||
: null;
|
||||
logWriter?.prune();
|
||||
|
||||
function envString(name: string, fallback: string): string {
|
||||
const value = process.env[name];
|
||||
return value === undefined || value.length === 0 ? fallback : value;
|
||||
}
|
||||
|
||||
function envNumber(name: string, fallback: number): number {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw.trim().length === 0) return fallback;
|
||||
const value = Number(raw);
|
||||
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
|
||||
}
|
||||
|
||||
function resolveRealWorkspaceRoot(root: string): string {
|
||||
try {
|
||||
return realpathSync(root);
|
||||
} catch {
|
||||
return root;
|
||||
}
|
||||
}
|
||||
|
||||
function log(level: "debug" | "info" | "warn" | "error", event: string, detail: JsonRecord = {}): void {
|
||||
const record: JsonRecord = { at: new Date().toISOString(), service: "mdtodo", level, event, ...detail };
|
||||
recentLogs.push(record);
|
||||
while (recentLogs.length > 500) recentLogs.shift();
|
||||
try {
|
||||
logWriter?.appendJson(record, new Date(String(record.at)));
|
||||
} catch {
|
||||
// Logging must not break task edits.
|
||||
}
|
||||
const line = JSON.stringify(record);
|
||||
const writer = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
|
||||
writer(line);
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200, headers: Record<string, string> = {}): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json; charset=utf-8", ...headers },
|
||||
});
|
||||
}
|
||||
|
||||
function textResponse(body: string, status = 200, headers: Record<string, string> = {}): Response {
|
||||
return new Response(body, {
|
||||
status,
|
||||
headers: { "content-type": "text/plain; charset=utf-8", ...headers },
|
||||
});
|
||||
}
|
||||
|
||||
function errorToJson(error: unknown): JsonRecord {
|
||||
if (error instanceof Error) return { name: error.name, message: error.message, stack: error.stack ?? "" };
|
||||
return { message: String(error) };
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
||||
}
|
||||
|
||||
function toSlashPath(value: string): string {
|
||||
return value.replace(/\\/gu, "/");
|
||||
}
|
||||
|
||||
function normalizeRelativePath(value: unknown): string {
|
||||
const raw = String(value ?? "").trim().replace(/\\/gu, "/").replace(/^\/+/u, "");
|
||||
if (!raw) throw new Error("file path is required");
|
||||
if (raw.split("/").some((segment) => segment === "..")) throw new Error("file path must stay inside MDTODO workspace");
|
||||
if (extname(raw).toLowerCase() !== ".md") throw new Error("file path must point to a Markdown file");
|
||||
return raw;
|
||||
}
|
||||
|
||||
function resolveWorkspaceFile(value: unknown): { relativePath: string; absolutePath: string } {
|
||||
const relativePath = normalizeRelativePath(value);
|
||||
const absolutePath = resolve(workspaceRoot, relativePath);
|
||||
const parent = dirname(absolutePath);
|
||||
const realParent = existsSync(parent) ? realpathSync(parent) : realpathSync(workspaceRoot);
|
||||
const candidateReal = existsSync(absolutePath) ? realpathSync(absolutePath) : absolutePath;
|
||||
const realRelativeParent = relative(rootRealPath, realParent);
|
||||
const realRelativeCandidate = relative(rootRealPath, candidateReal);
|
||||
if (realRelativeParent.startsWith("..") || realRelativeParent === "" && realParent !== rootRealPath && rootRealPath !== realParent) {
|
||||
throw new Error("file path parent escapes MDTODO workspace");
|
||||
}
|
||||
if (realRelativeCandidate.startsWith("..")) throw new Error("file path escapes MDTODO workspace");
|
||||
return { relativePath, absolutePath };
|
||||
}
|
||||
|
||||
function isTaskHeading(line: string): boolean {
|
||||
return /^(#{2,6})\s+R\d+(?:\.\d+)*(?=[\s\]]|$)/iu.test(line.trim());
|
||||
}
|
||||
|
||||
function matchTaskHeading(line: string): { hashes: string; id: string; tail: string; level: number } | null {
|
||||
const match = line.trim().match(/^(#{2,6})\s+(R\d+(?:\.\d+)*)(?=[\s\]]|$)(.*)$/iu);
|
||||
if (match === null) return null;
|
||||
const hashes = match[1] ?? "##";
|
||||
const id = match[2] ?? "";
|
||||
return { hashes, id, tail: match[3] ?? "", level: hashes.length };
|
||||
}
|
||||
|
||||
function taskDepth(taskId: string): number {
|
||||
const matches = taskId.match(/\./gu);
|
||||
return matches === null ? 0 : matches.length;
|
||||
}
|
||||
|
||||
function parentTaskId(taskId: string): string | null {
|
||||
const index = taskId.lastIndexOf(".");
|
||||
return index < 0 ? null : taskId.slice(0, index);
|
||||
}
|
||||
|
||||
function normalizeStatus(value: unknown): TodoStatus {
|
||||
const raw = String(value ?? "").trim().toLowerCase();
|
||||
if (raw === "completed" || raw === "done" || raw === "finished") return "completed";
|
||||
if (raw === "in_progress" || raw === "processing" || raw === "running" || raw === "start") return "in_progress";
|
||||
if (raw === "pending" || raw === "todo" || raw === "not_started" || raw === "") return "pending";
|
||||
throw new Error(`unsupported task status: ${String(value)}`);
|
||||
}
|
||||
|
||||
function statusFromLine(line: string): TodoStatus {
|
||||
if (/\[(completed|finished)\]/iu.test(line)) return "completed";
|
||||
if (/\[(in_progress|processing)\]/iu.test(line)) return "in_progress";
|
||||
return "pending";
|
||||
}
|
||||
|
||||
function statusMarker(status: TodoStatus): string {
|
||||
if (status === "completed") return "[completed]";
|
||||
if (status === "in_progress") return "[in_progress]";
|
||||
return "";
|
||||
}
|
||||
|
||||
function stripStatusMarkers(value: string): string {
|
||||
return value
|
||||
.replace(/\s*\[(completed|in_progress|processing|finished)\]/giu, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function taskTitleFromLine(line: string, taskId: string): string {
|
||||
const withoutLinks = line.trim().replace(/^#{2,6}\s+/u, "");
|
||||
const withoutId = withoutLinks.replace(new RegExp(`^${escapeRegex(taskId)}(?=[\\s\\]]|$)`, "iu"), "");
|
||||
const title = stripStatusMarkers(withoutId);
|
||||
return title.length > 0 ? title : "";
|
||||
}
|
||||
|
||||
function extractLinks(content: string, baseFilePath: string): TodoLink[] {
|
||||
const links: TodoLink[] = [];
|
||||
const regex = /\[([^\]]*)\]\(([^)]+)\)/gu;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
const label = match[1] ?? "";
|
||||
const target = String(match[2] ?? "").trim();
|
||||
let exists: boolean | null = null;
|
||||
if (target && !target.startsWith("#") && !target.startsWith("mailto:") && !/^https?:\/\//iu.test(target)) {
|
||||
try {
|
||||
const decoded = decodeURIComponent(target.startsWith("file://") ? target.slice(7) : target);
|
||||
const absolute = decoded.startsWith("/") ? decoded : resolve(dirname(baseFilePath), decoded);
|
||||
exists = existsSync(absolute);
|
||||
} catch {
|
||||
exists = false;
|
||||
}
|
||||
}
|
||||
links.push({ label, target, exists });
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
function parseTodoContent(content: string, relativePath: string, absolutePath: string): ParsedTodoFile {
|
||||
const normalizedContent = content.replace(/\[Processing\]/gu, "[in_progress]").replace(/\[Finished\]/gu, "[completed]");
|
||||
const lines = normalizedContent.split(/\r?\n/u);
|
||||
const taskLineIndexes: number[] = [];
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
if (isTaskHeading(lines[index] ?? "")) taskLineIndexes.push(index);
|
||||
}
|
||||
|
||||
const rootTasks: TodoTask[] = [];
|
||||
const stack: { task: TodoTask; id: string; depth: number }[] = [];
|
||||
for (let tokenIndex = 0; tokenIndex < taskLineIndexes.length; tokenIndex += 1) {
|
||||
const lineNumber = taskLineIndexes[tokenIndex] ?? 0;
|
||||
const heading = matchTaskHeading(lines[lineNumber] ?? "");
|
||||
if (heading === null) continue;
|
||||
const nextTaskLine = taskLineIndexes[tokenIndex + 1] ?? lines.length;
|
||||
const rawContent = lines.slice(lineNumber + 1, nextTaskLine).join("\n").trim();
|
||||
const headingAndContent = `${lines[lineNumber] ?? ""}\n${rawContent}`;
|
||||
const links = extractLinks(headingAndContent, absolutePath);
|
||||
const status = statusFromLine(lines[lineNumber] ?? "");
|
||||
const task: TodoTask = {
|
||||
id: heading.id,
|
||||
title: taskTitleFromLine(lines[lineNumber] ?? "", heading.id),
|
||||
description: rawContent || taskTitleFromLine(lines[lineNumber] ?? "", heading.id),
|
||||
rawContent,
|
||||
status,
|
||||
completed: status === "completed",
|
||||
processing: status === "in_progress",
|
||||
children: [],
|
||||
lineNumber,
|
||||
level: heading.level,
|
||||
depth: taskDepth(heading.id),
|
||||
parentId: parentTaskId(heading.id),
|
||||
filePath: relativePath,
|
||||
linkCount: links.length,
|
||||
linkExists: links.filter((link) => link.exists === true).length,
|
||||
links,
|
||||
};
|
||||
|
||||
while (stack.length > 0 && stack[stack.length - 1]!.depth >= task.depth) stack.pop();
|
||||
const parent = stack[stack.length - 1];
|
||||
if (parent !== undefined && task.id.startsWith(`${parent.id}.`)) {
|
||||
parent.task.children.push(task);
|
||||
} else {
|
||||
rootTasks.push(task);
|
||||
}
|
||||
stack.push({ task, id: task.id, depth: task.depth });
|
||||
}
|
||||
|
||||
const textBlocks = parseTextBlocks(lines, taskLineIndexes);
|
||||
const stats = countStats(rootTasks);
|
||||
const file = fileSummary(relativePath, absolutePath, stats, null);
|
||||
return { file, tasks: rootTasks, textBlocks, stats };
|
||||
}
|
||||
|
||||
function parseTextBlocks(lines: string[], taskLineIndexes: number[]): TextBlock[] {
|
||||
const firstTaskLine = taskLineIndexes[0] ?? lines.length;
|
||||
const prefix = lines.slice(0, firstTaskLine).join("\n");
|
||||
const trimmed = prefix.trim();
|
||||
if (!trimmed) return [];
|
||||
return [{
|
||||
id: "text-0",
|
||||
content: trimmed,
|
||||
rawContent: prefix,
|
||||
lineNumber: 0,
|
||||
}];
|
||||
}
|
||||
|
||||
function countStats(tasks: TodoTask[]): TodoStats {
|
||||
const stats: TodoStats = { total: 0, completed: 0, inProgress: 0, pending: 0, maxDepth: 0 };
|
||||
const visit = (items: TodoTask[]): void => {
|
||||
for (const task of items) {
|
||||
stats.total += 1;
|
||||
stats.maxDepth = Math.max(stats.maxDepth, task.depth);
|
||||
if (task.completed) stats.completed += 1;
|
||||
else if (task.processing) stats.inProgress += 1;
|
||||
else stats.pending += 1;
|
||||
visit(task.children);
|
||||
}
|
||||
};
|
||||
visit(tasks);
|
||||
return stats;
|
||||
}
|
||||
|
||||
function fileSummary(relativePath: string, absolutePath: string, stats: TodoStats | null, parseError: string | null): TodoFileSummary {
|
||||
const info = statSync(absolutePath);
|
||||
return {
|
||||
path: relativePath,
|
||||
name: basename(relativePath),
|
||||
directory: toSlashPath(dirname(relativePath)) === "." ? "" : toSlashPath(dirname(relativePath)),
|
||||
sizeBytes: info.size,
|
||||
mtime: info.mtime.toISOString(),
|
||||
stats,
|
||||
parseError,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldSkipDirectory(name: string): boolean {
|
||||
return [".git", "node_modules", "out", "dist", "resources", ".vscode", ".claude", ".playwright-mcp"].includes(name);
|
||||
}
|
||||
|
||||
function isTodoMarkdownFile(name: string): boolean {
|
||||
return extname(name).toLowerCase() === ".md" && /(?:^|[-_])(?:mdtodo|todo)|(?:mdtodo|todo)(?:[-_]|\.|$)/iu.test(name);
|
||||
}
|
||||
|
||||
function listTodoFiles(): TodoFileSummary[] {
|
||||
if (!existsSync(workspaceRoot)) return [];
|
||||
const results: TodoFileSummary[] = [];
|
||||
const walk = (dir: string): void => {
|
||||
if (results.length >= maxFiles) return;
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (results.length >= maxFiles) break;
|
||||
const absolute = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (!shouldSkipDirectory(entry.name)) walk(absolute);
|
||||
} else if (entry.isFile() && isTodoMarkdownFile(entry.name)) {
|
||||
const relativePath = toSlashPath(relative(workspaceRoot, absolute));
|
||||
try {
|
||||
const parsed = parseTodoContent(readFileSync(absolute, "utf8"), relativePath, absolute);
|
||||
results.push(parsed.file);
|
||||
} catch (error) {
|
||||
results.push(fileSummary(relativePath, absolute, null, errorMessage(error)));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(workspaceRoot);
|
||||
return results.sort((left, right) => left.path.localeCompare(right.path, "en"));
|
||||
}
|
||||
|
||||
function loadTodoFile(filePath: unknown, includeContent = false): ParsedTodoFile {
|
||||
const resolved = resolveWorkspaceFile(filePath);
|
||||
if (!existsSync(resolved.absolutePath)) throw new Error(`file not found: ${resolved.relativePath}`);
|
||||
const content = readFileSync(resolved.absolutePath, "utf8");
|
||||
const parsed = parseTodoContent(content, resolved.relativePath, resolved.absolutePath);
|
||||
return includeContent ? { ...parsed, content } : parsed;
|
||||
}
|
||||
|
||||
function flattenTasks(tasks: TodoTask[]): TodoTask[] {
|
||||
const result: TodoTask[] = [];
|
||||
const visit = (items: TodoTask[]): void => {
|
||||
for (const task of items) {
|
||||
result.push(task);
|
||||
visit(task.children);
|
||||
}
|
||||
};
|
||||
visit(tasks);
|
||||
return result;
|
||||
}
|
||||
|
||||
function findTaskInParsed(parsed: ParsedTodoFile, taskId: string): TodoTask | null {
|
||||
return flattenTasks(parsed.tasks).find((task) => task.id.toLowerCase() === taskId.toLowerCase()) ?? null;
|
||||
}
|
||||
|
||||
function findTaskLine(lines: string[], taskId: string): number {
|
||||
const regex = new RegExp(`^#{2,6}\\s+${escapeRegex(taskId)}(?=[\\s\\]]|$)`, "iu");
|
||||
return lines.findIndex((line) => regex.test(line.trim()));
|
||||
}
|
||||
|
||||
function nextTaskLine(lines: string[], startLine: number): number {
|
||||
for (let index = startLine + 1; index < lines.length; index += 1) {
|
||||
if (isTaskHeading(lines[index] ?? "")) return index;
|
||||
}
|
||||
return lines.length;
|
||||
}
|
||||
|
||||
function subtreeEndLine(lines: string[], startLine: number, taskLevel: number): number {
|
||||
for (let index = startLine + 1; index < lines.length; index += 1) {
|
||||
const heading = matchTaskHeading(lines[index] ?? "");
|
||||
if (heading !== null && heading.level <= taskLevel) return index;
|
||||
}
|
||||
return lines.length;
|
||||
}
|
||||
|
||||
function writeFileAtomic(filePath: string, content: string): void {
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
const temp = join(dirname(filePath), `.${basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
|
||||
writeFileSync(temp, content, "utf8");
|
||||
renameSync(temp, filePath);
|
||||
}
|
||||
|
||||
function mutateFile(filePath: unknown, updater: (lines: string[], relativePath: string) => TaskMutationResult): { result: TaskMutationResult; parsed: ParsedTodoFile } {
|
||||
const resolved = resolveWorkspaceFile(filePath);
|
||||
if (!existsSync(resolved.absolutePath)) throw new Error(`file not found: ${resolved.relativePath}`);
|
||||
const content = readFileSync(resolved.absolutePath, "utf8");
|
||||
const lines = content.split(/\r?\n/u);
|
||||
const result = updater(lines, resolved.relativePath);
|
||||
if (!result.success) return { result, parsed: loadTodoFile(resolved.relativePath) };
|
||||
writeFileAtomic(resolved.absolutePath, lines.join("\n"));
|
||||
log("info", "file_mutated", { file: resolved.relativePath, taskId: result.taskId ?? "", message: result.message });
|
||||
return { result, parsed: loadTodoFile(resolved.relativePath) };
|
||||
}
|
||||
|
||||
function allTaskIds(tasks: TodoTask[]): string[] {
|
||||
return flattenTasks(tasks).map((task) => task.id);
|
||||
}
|
||||
|
||||
function generateTaskId(parsed: ParsedTodoFile, parentId: string | null): string {
|
||||
const ids = allTaskIds(parsed.tasks);
|
||||
if (parentId) {
|
||||
const prefix = `${parentId}.`;
|
||||
const maxChild = ids
|
||||
.filter((id) => id.startsWith(prefix))
|
||||
.map((id) => id.match(new RegExp(`^${escapeRegex(prefix)}(\\d+)$`, "iu"))?.[1] ?? "")
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isFinite(value) && value > 0)
|
||||
.reduce((max, value) => Math.max(max, value), 0);
|
||||
return `${parentId}.${maxChild + 1}`;
|
||||
}
|
||||
const maxRoot = ids
|
||||
.map((id) => id.match(/^R(\d+)$/iu)?.[1] ?? "")
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isFinite(value) && value > 0)
|
||||
.reduce((max, value) => Math.max(max, value), 0);
|
||||
return `R${maxRoot + 1}`;
|
||||
}
|
||||
|
||||
function headingLevelForTaskId(taskId: string): number {
|
||||
return Math.min(6, 2 + taskDepth(taskId));
|
||||
}
|
||||
|
||||
function taskTemplate(taskId: string, title: string, rawContent: string): string {
|
||||
const heading = "#".repeat(headingLevelForTaskId(taskId));
|
||||
const safeTitle = stripStatusMarkers(title).replace(/\s+/gu, " ").trim() || "新任务";
|
||||
const body = rawContent.trim();
|
||||
return body ? `${heading} ${taskId} ${safeTitle}\n\n${body}` : `${heading} ${taskId} ${safeTitle}\n`;
|
||||
}
|
||||
|
||||
function addTask(filePath: unknown, body: Record<string, unknown>): { result: TaskMutationResult; parsed: ParsedTodoFile } {
|
||||
const parentId = typeof body.parentId === "string" && body.parentId.trim() ? body.parentId.trim() : null;
|
||||
const title = typeof body.title === "string" ? body.title : "新任务";
|
||||
const rawContent = typeof body.rawContent === "string" ? body.rawContent : "";
|
||||
const current = loadTodoFile(filePath);
|
||||
if (parentId !== null && findTaskInParsed(current, parentId) === null) {
|
||||
return { result: { success: false, message: `parent task not found: ${parentId}` }, parsed: current };
|
||||
}
|
||||
const newId = generateTaskId(current, parentId);
|
||||
return mutateFile(filePath, (lines) => {
|
||||
const content = taskTemplate(newId, title, rawContent);
|
||||
if (parentId === null) {
|
||||
const insertAt = lines.length > 0 && lines[lines.length - 1] === "" ? lines.length - 1 : lines.length;
|
||||
lines.splice(insertAt, 0, "", content);
|
||||
} else {
|
||||
const parentLine = findTaskLine(lines, parentId);
|
||||
if (parentLine < 0) return { success: false, message: `parent task not found: ${parentId}` };
|
||||
const heading = matchTaskHeading(lines[parentLine] ?? "");
|
||||
const insertAt = subtreeEndLine(lines, parentLine, heading?.level ?? headingLevelForTaskId(parentId));
|
||||
lines.splice(insertAt, 0, "", content);
|
||||
}
|
||||
return { success: true, message: `task ${newId} created`, taskId: newId };
|
||||
});
|
||||
}
|
||||
|
||||
function deleteTask(filePath: unknown, taskId: string): { result: TaskMutationResult; parsed: ParsedTodoFile } {
|
||||
return mutateFile(filePath, (lines) => {
|
||||
const start = findTaskLine(lines, taskId);
|
||||
if (start < 0) return { success: false, message: `task not found: ${taskId}` };
|
||||
const heading = matchTaskHeading(lines[start] ?? "");
|
||||
const end = subtreeEndLine(lines, start, heading?.level ?? headingLevelForTaskId(taskId));
|
||||
lines.splice(start, end - start);
|
||||
return { success: true, message: `task ${taskId} deleted`, taskId };
|
||||
});
|
||||
}
|
||||
|
||||
function patchTask(filePath: unknown, taskId: string, body: Record<string, unknown>): { result: TaskMutationResult; parsed: ParsedTodoFile } {
|
||||
return mutateFile(filePath, (lines) => {
|
||||
const lineIndex = findTaskLine(lines, taskId);
|
||||
if (lineIndex < 0) return { success: false, message: `task not found: ${taskId}` };
|
||||
if (body.title !== undefined) {
|
||||
const heading = matchTaskHeading(lines[lineIndex] ?? "");
|
||||
const nextStatus = body.status === undefined ? statusFromLine(lines[lineIndex] ?? "") : normalizeStatus(body.status);
|
||||
const marker = statusMarker(nextStatus);
|
||||
const title = stripStatusMarkers(String(body.title ?? "")).replace(/\s+/gu, " ").trim();
|
||||
lines[lineIndex] = `${heading?.hashes ?? "#".repeat(headingLevelForTaskId(taskId))} ${taskId}${title ? ` ${title}` : ""}${marker ? ` ${marker}` : ""}`;
|
||||
} else if (body.status !== undefined) {
|
||||
const nextStatus = normalizeStatus(body.status);
|
||||
const marker = statusMarker(nextStatus);
|
||||
const base = stripStatusMarkers(lines[lineIndex] ?? "");
|
||||
lines[lineIndex] = marker ? `${base} ${marker}` : base;
|
||||
}
|
||||
if (body.rawContent !== undefined) {
|
||||
const contentStart = lineIndex + 1;
|
||||
const contentEnd = nextTaskLine(lines, lineIndex);
|
||||
const rawContent = String(body.rawContent ?? "").replace(/\r\n/gu, "\n");
|
||||
const replacement = rawContent.trim().length > 0 ? ["", ...rawContent.split("\n")] : [];
|
||||
lines.splice(contentStart, contentEnd - contentStart, ...replacement);
|
||||
}
|
||||
return { success: true, message: `task ${taskId} updated`, taskId };
|
||||
});
|
||||
}
|
||||
|
||||
function updateContent(filePath: unknown, content: unknown): ParsedTodoFile {
|
||||
const resolved = resolveWorkspaceFile(filePath);
|
||||
if (typeof content !== "string") throw new Error("content must be a string");
|
||||
writeFileAtomic(resolved.absolutePath, content);
|
||||
log("info", "file_content_replaced", { file: resolved.relativePath, bytes: content.length });
|
||||
return loadTodoFile(resolved.relativePath, true);
|
||||
}
|
||||
|
||||
function buildExecutionCommand(body: Record<string, unknown>): JsonRecord {
|
||||
const file = normalizeRelativePath(body.file);
|
||||
const taskId = String(body.taskId ?? "").trim();
|
||||
if (!taskId) throw new Error("taskId is required");
|
||||
const mode = String(body.mode ?? "codex").trim().toLowerCase();
|
||||
const model = typeof body.model === "string" && body.model.trim() ? body.model.trim() : "";
|
||||
const prompt = `execute "${file} 中的 ${taskId} 任务"`;
|
||||
const command = mode === "opencode"
|
||||
? `opencode${model ? ` --model ${model}` : ""} --prompt ${JSON.stringify(`${file} 中的 ${taskId} 任务`)}`
|
||||
: `codex ${JSON.stringify(prompt)}`;
|
||||
return { ok: true, file, taskId, mode, model, prompt, command };
|
||||
}
|
||||
|
||||
function healthSnapshot(): { body: JsonRecord; status: number } {
|
||||
const rootExists = existsSync(workspaceRoot);
|
||||
let writable = false;
|
||||
try {
|
||||
if (rootExists) accessSync(workspaceRoot, constants.R_OK | constants.W_OK);
|
||||
writable = rootExists;
|
||||
} catch {
|
||||
writable = false;
|
||||
}
|
||||
const files = rootExists ? listTodoFiles() : [];
|
||||
const healthy = rootExists && files.length > 0;
|
||||
return {
|
||||
status: healthy ? 200 : 503,
|
||||
body: {
|
||||
ok: healthy,
|
||||
service: "mdtodo",
|
||||
startedAt,
|
||||
rootDir: workspaceRoot,
|
||||
rootExists,
|
||||
writable,
|
||||
fileCount: files.length,
|
||||
parsedFileCount: files.filter((file) => file.parseError === null).length,
|
||||
storage: {
|
||||
primary: "hostPath-markdown",
|
||||
rootDir: workspaceRoot,
|
||||
},
|
||||
files: files.slice(0, 20) as unknown as JsonValue,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function readJsonBody(req: Request): Promise<Record<string, unknown>> {
|
||||
const text = await req.text();
|
||||
if (!text.trim()) return {};
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("request body must be a JSON object");
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function route(req: Request): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
if (req.method === "OPTIONS") return jsonResponse({ ok: true });
|
||||
try {
|
||||
if (url.pathname === "/" || url.pathname === "/health") {
|
||||
const health = healthSnapshot();
|
||||
return jsonResponse(health.body, health.status);
|
||||
}
|
||||
if (url.pathname === "/live") return jsonResponse({ ok: true, service: "mdtodo", startedAt });
|
||||
if (url.pathname === "/logs" && req.method === "GET") return jsonResponse({ ok: true, logs: recentLogs.slice(-100) });
|
||||
if (url.pathname === "/api/files" && req.method === "GET") return jsonResponse({ ok: true, rootDir: workspaceRoot, files: listTodoFiles() });
|
||||
if (url.pathname === "/api/content" && req.method === "GET") {
|
||||
const parsed = loadTodoFile(url.searchParams.get("file"), true);
|
||||
return textResponse(parsed.content ?? "");
|
||||
}
|
||||
if (url.pathname === "/api/content" && req.method === "PUT") {
|
||||
const body = await readJsonBody(req);
|
||||
return jsonResponse({ ok: true, file: updateContent(body.file, body.content) });
|
||||
}
|
||||
if (url.pathname === "/api/tasks" && req.method === "GET") {
|
||||
return jsonResponse({ ok: true, ...loadTodoFile(url.searchParams.get("file"), url.searchParams.get("includeContent") === "1") });
|
||||
}
|
||||
if (url.pathname === "/api/tasks" && req.method === "POST") {
|
||||
const body = await readJsonBody(req);
|
||||
const result = addTask(body.file, body);
|
||||
return jsonResponse({ ok: result.result.success, result: result.result, file: result.parsed }, result.result.success ? 200 : 404);
|
||||
}
|
||||
const taskMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)$/u);
|
||||
if (taskMatch !== null && req.method === "GET") {
|
||||
const parsed = loadTodoFile(url.searchParams.get("file"));
|
||||
const task = findTaskInParsed(parsed, decodeURIComponent(taskMatch[1] ?? ""));
|
||||
if (task === null) return jsonResponse({ ok: false, error: "task not found" }, 404);
|
||||
return jsonResponse({ ok: true, task, file: parsed.file });
|
||||
}
|
||||
if (taskMatch !== null && req.method === "PATCH") {
|
||||
const body = await readJsonBody(req);
|
||||
const result = patchTask(body.file, decodeURIComponent(taskMatch[1] ?? ""), body);
|
||||
return jsonResponse({ ok: result.result.success, result: result.result, file: result.parsed }, result.result.success ? 200 : 404);
|
||||
}
|
||||
if (taskMatch !== null && req.method === "DELETE") {
|
||||
const result = deleteTask(url.searchParams.get("file"), decodeURIComponent(taskMatch[1] ?? ""));
|
||||
return jsonResponse({ ok: result.result.success, result: result.result, file: result.parsed }, result.result.success ? 200 : 404);
|
||||
}
|
||||
if (url.pathname === "/api/execute-command" && req.method === "POST") {
|
||||
return jsonResponse(buildExecutionCommand(await readJsonBody(req)));
|
||||
}
|
||||
return jsonResponse({ ok: false, error: "not found" }, 404);
|
||||
} catch (error) {
|
||||
log("error", "request_failed", { path: url.pathname, method: req.method, error: errorToJson(error) });
|
||||
return jsonResponse({ ok: false, error: errorMessage(error) }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
Bun.serve({ hostname: host, port, idleTimeout: 120, fetch: route });
|
||||
log("info", "service_started", { port, rootDir: workspaceRoot, rootExists: existsSync(workspaceRoot) });
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -23,7 +23,7 @@ services:
|
||||
V3SCTL_KUBE_API_PROXY_ENABLED: "${V3SCTL_KUBE_API_PROXY_ENABLED:-true}"
|
||||
V3SCTL_KUBECONFIG_PATH: "/var/lib/unidesk/v8s/kubeconfig"
|
||||
V3SCTL_KUBE_API_CONNECT_HOST: "${V3SCTL_KUBE_API_CONNECT_HOST:-host.docker.internal}"
|
||||
V3SCTL_MANIFEST_PATHS: "${V3SCTL_MANIFEST_PATHS:-v3s/code-queue.v3s.json}"
|
||||
V3SCTL_MANIFEST_PATHS: "${V3SCTL_MANIFEST_PATHS:-v3s/code-queue.v3s.json,v3s/mdtodo.v3s.json}"
|
||||
V3SCTL_SERVICES_JSON: "${V3SCTL_SERVICES_JSON:-[]}"
|
||||
UNIDESK_LOG_RETENTION_BYTES: "${UNIDESK_LOG_RETENTION_BYTES:-512MiB}"
|
||||
volumes:
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: mdtodo
|
||||
namespace: unidesk
|
||||
labels:
|
||||
app.kubernetes.io/name: mdtodo
|
||||
app.kubernetes.io/part-of: unidesk
|
||||
unidesk.ai/deployment-mode: v3sctl-managed
|
||||
unidesk.ai/instance-id: D601
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: mdtodo
|
||||
unidesk.ai/instance-id: D601
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: mdtodo
|
||||
app.kubernetes.io/part-of: unidesk
|
||||
unidesk.ai/deployment-mode: v3sctl-managed
|
||||
unidesk.ai/instance-id: D601
|
||||
unidesk.ai/node-id: D601
|
||||
spec:
|
||||
nodeSelector:
|
||||
unidesk.ai/node-id: D601
|
||||
terminationGracePeriodSeconds: 15
|
||||
containers:
|
||||
- name: mdtodo
|
||||
image: unidesk-mdtodo:d601
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 4267
|
||||
env:
|
||||
- name: HOST
|
||||
value: "0.0.0.0"
|
||||
- name: PORT
|
||||
value: "4267"
|
||||
- name: MDTODO_ROOT_DIR
|
||||
value: "/workspace"
|
||||
- name: LOG_FILE
|
||||
value: "/var/log/unidesk/mdtodo.jsonl"
|
||||
- name: UNIDESK_LOG_RETENTION_BYTES
|
||||
value: "512MiB"
|
||||
volumeMounts:
|
||||
- name: workspace
|
||||
mountPath: /workspace
|
||||
- name: logs
|
||||
mountPath: /var/log/unidesk
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 12
|
||||
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: workspace
|
||||
hostPath:
|
||||
path: /home/ubuntu/cq-deploy/.state/mdtodo-workspace
|
||||
type: Directory
|
||||
- name: logs
|
||||
hostPath:
|
||||
path: /home/ubuntu/cq-deploy/.state/mdtodo/logs
|
||||
type: DirectoryOrCreate
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: mdtodo
|
||||
namespace: unidesk
|
||||
labels:
|
||||
app.kubernetes.io/name: mdtodo
|
||||
app.kubernetes.io/part-of: unidesk
|
||||
unidesk.ai/deployment-mode: v3sctl-managed
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: mdtodo
|
||||
unidesk.ai/instance-id: D601
|
||||
ports:
|
||||
- name: http
|
||||
port: 4267
|
||||
targetPort: http
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"apiVersion": "unidesk.ai/v3s/v1",
|
||||
"kind": "ManagedKubernetesService",
|
||||
"metadata": {
|
||||
"name": "mdtodo",
|
||||
"namespace": "unidesk"
|
||||
},
|
||||
"spec": {
|
||||
"adapterServiceId": "v3sctl-adapter",
|
||||
"controlPlane": {
|
||||
"type": "kubernetes",
|
||||
"cluster": "unidesk-v8s",
|
||||
"context": "unidesk-v8s"
|
||||
},
|
||||
"route": {
|
||||
"kind": "kubernetes-service",
|
||||
"serviceName": "mdtodo",
|
||||
"servicePort": 4267
|
||||
},
|
||||
"activeInstanceId": "D601",
|
||||
"singleWriter": true,
|
||||
"expectedNodeIds": [
|
||||
"D601"
|
||||
],
|
||||
"instances": [
|
||||
{
|
||||
"id": "D601",
|
||||
"nodeId": "D601",
|
||||
"role": "primary",
|
||||
"baseUrl": "kubernetes://unidesk/services/mdtodo:4267",
|
||||
"healthPath": "/health",
|
||||
"healthMode": "service-proxy"
|
||||
}
|
||||
],
|
||||
"requireAllInstancesHealthy": true
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
{ "path": "components/frontend" },
|
||||
{ "path": "components/microservices/code-queue" },
|
||||
{ "path": "components/microservices/v3sctl-adapter" },
|
||||
{ "path": "components/microservices/mdtodo" },
|
||||
{ "path": "components/microservices/project-manager" },
|
||||
{ "path": "components/microservices/baidu-netdisk" }
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user