From 79166574e8a5733e0eeb777ea7adcc89cb23c7d1 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 7 May 2026 18:11:43 +0000 Subject: [PATCH] feat: integrate codex queue and pipeline oa flow - add Codex Queue microservice/frontend integration and related deployment docs - document 100% Pipeline OA event-flow requirements and E2E gates - harden Pipeline frontend Gantt/timeline E2E assertions and rendering --- AGENTS.md | 10 +- TEST.md | 14 +- config.json | 48 +- docker-compose.yml | 41 + docs/reference/arch.md | 1 + docs/reference/cli.md | 4 +- docs/reference/config.md | 2 +- docs/reference/deployment.md | 5 +- docs/reference/e2e.md | 12 +- docs/reference/frontend.md | 28 +- docs/reference/microservices.md | 35 +- docs/reference/pipeline-oa-event-flow.md | 69 + docs/reference/provider-gateway.md | 2 +- scripts/cli.ts | 4 +- scripts/src/docker.ts | 5 +- scripts/src/e2e.ts | 341 ++++- src/components/frontend/public/style.css | 366 ++++- src/components/frontend/src/app.tsx | 3 + src/components/frontend/src/codex-queue.tsx | 435 ++++++ src/components/frontend/src/navigation.ts | 1 + src/components/frontend/src/pipeline.tsx | 685 +++++++++- .../microservices/codex-queue/Dockerfile | 15 + .../microservices/codex-queue/package.json | 9 + .../microservices/codex-queue/src/index.ts | 1176 +++++++++++++++++ .../microservices/codex-queue/tsconfig.json | 17 + src/components/provider-gateway/package.json | 2 +- src/components/provider-gateway/src/index.ts | 2 +- 27 files changed, 3209 insertions(+), 123 deletions(-) create mode 100644 docs/reference/pipeline-oa-event-flow.md create mode 100644 src/components/frontend/src/codex-queue.tsx create mode 100644 src/components/microservices/codex-queue/Dockerfile create mode 100644 src/components/microservices/codex-queue/package.json create mode 100644 src/components/microservices/codex-queue/src/index.ts create mode 100644 src/components/microservices/codex-queue/tsconfig.json diff --git a/AGENTS.md b/AGENTS.md index 92b823e4..0bcf08d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,9 +18,9 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `bun scripts/cli.ts server start`:以异步 job 启动 database、backend-core、frontend、provider-gateway 和主 server microservice,部署规则见 `docs/reference/deployment.md`。 - `bun scripts/cli.ts server status`:查询固定端口、容器状态、健康检查和访问 URL,判定标准见 `docs/reference/deployment.md`。 - `bun scripts/cli.ts server logs`:分页返回文件日志与 Docker 日志尾部,日志规则见 `docs/reference/observability.md`。 -- `bun scripts/cli.ts server rebuild `:以 build-first、label-scoped replace 的异步 job 重建单个服务,避免 Docker Compose v1 recreate 问题,规则见 `docs/reference/deployment.md`。 +- `bun scripts/cli.ts server rebuild `:以 build-first、label-scoped replace 的异步 job 重建单个服务,避免 Docker Compose v1 recreate 问题,规则见 `docs/reference/deployment.md`。 - `bun scripts/cli.ts ssh [ssh-like args...]`:通过 provider-gateway 的 Host SSH / WSL SSH 维护桥打开近似原生 ssh 的交互会话或远端命令,并在远端 PATH 注入 `apply_patch` 与 `glob`;`apply-patch`、`py`、结构化 `find`、`glob` 和 `argv` 子命令用于避免远端补丁、Python stdin 执行与常用只读命令的嵌套转义问题,使用规则见 `docs/reference/cli.md` 和 `docs/reference/provider-gateway.md`。 -- `bun scripts/cli.ts microservice list/status/health/proxy`:管理和验证挂载在主 server 或计算节点 Docker 中的业务 microservice,Todo Note on main-server 与 FindJob/Pipeline/MET Nonlinear on D601 的规则见 `docs/reference/microservices.md`。 +- `bun scripts/cli.ts microservice list/status/health/proxy`:管理和验证挂载在主 server 或计算节点 Docker 中的业务 microservice,Codex Queue/Todo Note on main-server 与 FindJob/Pipeline/MET Nonlinear on D601 的规则见 `docs/reference/microservices.md`。 - `bun scripts/cli.ts server stop`:以异步 job 停止固定 Compose 项目中的全部 UniDesk 服务,停止后用 `server status` 复核。 - `bun scripts/cli.ts job list` / `bun scripts/cli.ts job status latest`:查询 `.state/jobs/` 中的异步任务状态,job 机制见 `docs/reference/cli.md`。 - `bun scripts/cli.ts debug health` / `bun scripts/cli.ts debug dispatch` / `bun scripts/cli.ts debug task`:通过 Docker 内网 core、真实 HTTP、WebSocket、系统指标、Docker 状态和 Host SSH 维护桥流程调试健康检查、任务下发与任务结果,调试规则见 `docs/reference/cli.md`。 @@ -29,8 +29,9 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 ## Runtime - `bun`:TypeScript 运行时固定使用 Bun,组件入口和 CLI 都直接运行 `.ts` 文件,约束见 `docs/reference/config.md`。 -- `docker-compose.yml`:主 server 统一编排 core、frontend、database、本机 provider gateway 和 Todo Note 后端,且只公开 frontend/provider ingress,服务拓扑见 `docs/reference/deployment.md`。 -- `src/components/frontend`:前端源码固定使用 TypeScript + React,`app.tsx` 只做 shell/router,左侧主模块与顶部子标签统一编译为模块前缀路由:`/ops//`、`/nodes//`、`/tasks//`、`/config//`,只有微服务使用 `/app//` 深链接,资源监控含曲线和进程资源排序表,Todo Note、FindJob、Pipeline、MET Nonlinear 等业务页必须拆到独立 TSX 模块,界面规则见 `docs/reference/frontend.md`。 +- `docker-compose.yml`:主 server 统一编排 core、frontend、database、本机 provider gateway、Todo Note 后端和 Codex Queue 后端,且只公开 frontend/provider ingress,服务拓扑见 `docs/reference/deployment.md`。 +- `src/components/frontend`:前端源码固定使用 TypeScript + React,`app.tsx` 只做 shell/router,左侧主模块与顶部子标签统一编译为模块前缀路由:`/ops//`、`/nodes//`、`/tasks//`、`/config//`,只有微服务使用 `/app//` 深链接,资源监控含曲线和进程资源排序表,Todo Note、FindJob、Pipeline、MET Nonlinear、Codex Queue 等业务页必须拆到独立 TSX 模块,界面规则见 `docs/reference/frontend.md`。 +- `Pipeline OA event flow`:Pipeline 控制、事件取证和甘特图渲染必须最终 100% 由 OA 事件流驱动,交付态不得保留点对点控制、旧审核事件、旧 batch 推进、runner 私有 JSONL 或 frontend/CLI 直写状态,权威规则见 `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`。 - `microservices`:主 server 本地开发边界固定为只开发 UniDesk frontend;非 UniDesk 核心业务后端、Dockerfile、GPU/训练调试必须在目标计算节点通过 SSH 透传完成,Todo Note 这类明确写入主 server 的例外需单独登记,规则见 `docs/reference/microservices.md`。 - `docs/reference/e2e.md`:交付前必须执行的自测门禁、Playwright 登录、资源监控进程排序、JSON 展示断言和数据库命名卷持久化要求。 @@ -40,4 +41,5 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `docs/reference/arch.md`:UniDesk 分布式工作平台的长期架构约束。 - `docs/reference/repo-tree.md`:仓库结构目标与组件边界。 - `docs/reference/microservices.md`:计算节点 microservice 的配置、代理、安全边界、Todo Note on main-server、FindJob/Pipeline/MET Nonlinear on D601 和验证规则。 +- `docs/reference/pipeline-oa-event-flow.md`:Pipeline/OA 事件流、审核/无审核流转、单步调试、甘特图渲染和最终去残留规则。 - `reference`:兼容旧路径的符号链接,指向 `docs/reference/`。 diff --git a/TEST.md b/TEST.md index 169215a8..5dff0efa 100644 --- a/TEST.md +++ b/TEST.md @@ -34,7 +34,7 @@ ## T8 Playwright 公网前端 E2E -阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:确认 `config.json` 的 `network.publicHost` 是主 server 公网地址,运行 `bun scripts/cli.ts e2e run`,要求 JSON 中 `network:only-frontend-provider-ports`、`network:core-public-blocked`、`network:database-public-blocked`、`network:findjob-public-blocked`、`network:met-nonlinear-public-blocked`、`network:todo-note-public-blocked`、`core:internal-overview`、`core:pgdata-usage`、`provider:self-node-online`、`provider:gateway-version-label`、`provider:system-status`、`provider:docker-status`、`provider:upgrade-plan`、`provider-ingress:public-health`、`microservice:catalog-findjob`、`microservice:catalog-pipeline`、`microservice:catalog-met-nonlinear`、`microservice:catalog-todo-note`、`microservice:todo-note-health`、`microservice:todo-note-migrated-data`、`microservice:todo-note-write-path`、`microservice:findjob-health`、`microservice:findjob-summary`、`microservice:findjob-jobs-preview`、`microservice:pipeline-status`、`microservice:pipeline-health`、`microservice:pipeline-snapshot`、`microservice:met-nonlinear-status`、`microservice:met-nonlinear-health`、`microservice:met-nonlinear-queue`、`microservice:met-nonlinear-projects`、`microservice:met-nonlinear-image`、`database:named-volume-write`、`database:todo-note-pg-storage`、`frontend:login-provider-visible`、`frontend:public-provider-info-visible`、`frontend:sidebar-collapse`、`frontend:overview-pgdata-visible`、`frontend:no-naked-json-before-click`、`frontend:system-monitor-visible`、`frontend:upgrade-plan-dispatch`、`frontend:docker-status-visible`、`frontend:gateway-version-records-visible`、`frontend:gateway-duration-subsecond-visible`、`frontend:provider-operation-availability-visible`、`frontend:microservice-catalog-visible`、`frontend:todo-note-integrated-visible`、`frontend:findjob-integrated-visible`、`frontend:pipeline-integrated-visible`、`frontend:pipeline-react-flow-visible`、`frontend:pipeline-step-timeline-visible`、`frontend:met-nonlinear-integrated-visible`、`frontend:met-nonlinear-project-tree-detail`、`frontend:met-nonlinear-queue-detail-speed` 全部 passed;打开输出的 screenshotPath,确认 Playwright 访问的是公网 frontend,页面上能看到 `main-server`、`Main Server Provider`、`D601`、`FindJob`、`Pipeline`、`MET Nonlinear`、`SSH 透传`、`远程更新` 和结构化控件。 +阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:确认 `config.json` 的 `network.publicHost` 是主 server 公网地址,运行 `bun scripts/cli.ts e2e run`,要求 JSON 中 `network:only-frontend-provider-ports`、`network:core-public-blocked`、`network:database-public-blocked`、`network:findjob-public-blocked`、`network:met-nonlinear-public-blocked`、`network:todo-note-public-blocked`、`network:codex-queue-public-blocked`、`core:internal-overview`、`core:pgdata-usage`、`provider:self-node-online`、`provider:gateway-version-label`、`provider:system-status`、`provider:docker-status`、`provider:upgrade-plan`、`provider-ingress:public-health`、`microservice:catalog-findjob`、`microservice:catalog-pipeline`、`microservice:catalog-met-nonlinear`、`microservice:catalog-todo-note`、`microservice:catalog-codex-queue`、`microservice:todo-note-health`、`microservice:todo-note-migrated-data`、`microservice:todo-note-write-path`、`microservice:codex-queue-status`、`microservice:codex-queue-health`、`microservice:codex-queue-tasks`、`microservice:findjob-health`、`microservice:findjob-summary`、`microservice:findjob-jobs-preview`、`microservice:pipeline-status`、`microservice:pipeline-health`、`microservice:pipeline-snapshot`、`microservice:pipeline-oa-event-flow`、`microservice:met-nonlinear-status`、`microservice:met-nonlinear-health`、`microservice:met-nonlinear-queue`、`microservice:met-nonlinear-projects`、`microservice:met-nonlinear-image`、`database:named-volume-write`、`database:todo-note-pg-storage`、`frontend:login-provider-visible`、`frontend:public-provider-info-visible`、`frontend:sidebar-collapse`、`frontend:overview-pgdata-visible`、`frontend:no-naked-json-before-click`、`frontend:system-monitor-visible`、`frontend:upgrade-plan-dispatch`、`frontend:docker-status-visible`、`frontend:gateway-version-records-visible`、`frontend:gateway-duration-subsecond-visible`、`frontend:provider-operation-availability-visible`、`frontend:microservice-catalog-visible`、`frontend:todo-note-integrated-visible`、`frontend:findjob-integrated-visible`、`frontend:codex-queue-integrated-visible`、`frontend:pipeline-integrated-visible`、`frontend:pipeline-react-flow-visible`、`frontend:pipeline-step-timeline-visible`、`frontend:pipeline-oa-event-flow-visible`、`frontend:met-nonlinear-integrated-visible`、`frontend:met-nonlinear-project-tree-detail`、`frontend:met-nonlinear-queue-detail-speed` 全部 passed;打开输出的 screenshotPath,确认 Playwright 访问的是公网 frontend,页面上能看到 `main-server`、`Main Server Provider`、`D601`、`FindJob`、`Pipeline`、`MET Nonlinear`、`Codex Queue`、`SSH 透传`、`远程更新` 和结构化控件。 ## T9 Database 命名卷持久化 @@ -50,7 +50,7 @@ ## T12 前端 TypeScript + React 源码约束 -阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `find src/components/frontend -type f \\( -name '*.js' -o -name '*.jsx' \\) -print`,确认没有手写 frontend JS/JSX 源码;确认 `src/components/frontend/src/app.tsx` 只承担 shell/router,Todo Note、FindJob、Pipeline、MET Nonlinear 分别在 `src/components/frontend/src/todo-note.tsx`、`src/components/frontend/src/findjob.tsx`、`src/components/frontend/src/pipeline.tsx`、`src/components/frontend/src/met-nonlinear.tsx` 中维护;运行 `bun scripts/cli.ts check`,确认这些 TSX 模块全部纳入 TypeScript 检查,且浏览器请求 `/app.js` 由 frontend Bun server 从 TSX imports 转译生成。 +阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `find src/components/frontend -type f \\( -name '*.js' -o -name '*.jsx' \\) -print`,确认没有手写 frontend JS/JSX 源码;确认 `src/components/frontend/src/app.tsx` 只承担 shell/router,Todo Note、FindJob、Pipeline、MET Nonlinear、Codex Queue 分别在 `src/components/frontend/src/todo-note.tsx`、`src/components/frontend/src/findjob.tsx`、`src/components/frontend/src/pipeline.tsx`、`src/components/frontend/src/met-nonlinear.tsx`、`src/components/frontend/src/codex-queue.tsx` 中维护;运行 `bun scripts/cli.ts check`,确认这些 TSX 模块全部纳入 TypeScript 检查,且浏览器请求 `/app.js` 由 frontend Bun server 从 TSX imports 转译生成。 ## T13 资源节点任务管理器曲线 @@ -70,7 +70,7 @@ ## T17 Provider Gateway Host SSH / WSL SSH 维护桥 -阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:确认目标 provider-gateway 已只读挂载维护私钥目录到 `/run/host-ssh`,目标宿主或 WSL 的 sshd 已启动且 `authorized_keys` 包含对应公钥;运行 `bun scripts/cli.ts debug dispatch main-server host.ssh --wait-ms 15000`,再运行 `bun scripts/cli.ts debug task latest`,确认任务通过真实 WebSocket 下发、状态为 `succeeded`、result 中 `probeLine` 包含 `UNIDESK_SSH_TEST`、`exitCode` 为 0、`hostSshKeyPresent` 为 true。随后运行 `bun scripts/cli.ts ssh main-server hostname`,确认输出是远端 hostname 且进程 exit code 为 0;再用 `printf 'pwd\nexit\n' | bun scripts/cli.ts ssh main-server` 验证无命令参数时能进入并退出远端登录 shell。对 D518 这类无公网 SSH 的 WSL 节点,使用同一命令替换 Provider ID 为 `D518`,必要时先用 debug dispatch 加 `--cwd /home/ubuntu` 覆盖远端工作目录,只能通过 provider-gateway 自连维护桥验证,不得把主 server 直连节点公网 22 端口作为通过标准;在计算节点本机还必须用 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch host.ssh --wait-ms 15000` 和 `bun scripts/cli.ts --main-server-ip 74.48.78.17 ssh hostname` 自测 remote CLI 透传,命令不得要求 `--main-server-key`。 +阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:确认目标 provider-gateway 已只读挂载维护私钥目录到 `/run/host-ssh`,目标宿主或 WSL 的 sshd 已启动且 `authorized_keys` 包含对应公钥;运行 `bun scripts/cli.ts debug dispatch main-server host.ssh --wait-ms 15000`,再运行 `bun scripts/cli.ts debug task latest`,确认任务通过真实 WebSocket 下发、状态为 `succeeded`、result 中 `probeLine` 包含 `UNIDESK_SSH_TEST`、`exitCode` 为 0、`hostSshKeyPresent` 为 true。随后运行 `bun scripts/cli.ts ssh main-server hostname`,确认输出是远端 hostname 且进程 exit code 为 0;运行 `bun scripts/cli.ts ssh main-server find /home/ubuntu --max-depth 2 --type d --icontains unidesk --limit 5 --sort`,确认结构化 `find` 能返回远端目录且不需要手写括号或嵌套引号;运行 `bun scripts/cli.ts ssh main-server glob --root /home/ubuntu --icontains unidesk --type d --limit 5 --sort`,确认远端注入 `glob` 能返回目录;运行 `bun scripts/cli.ts ssh main-server rg -n UniDesk /home/ubuntu/unidesk/AGENTS.md`,确认 `rg` 直接子命令可用;再用 `printf 'pwd\nexit\n' | bun scripts/cli.ts ssh main-server` 验证无命令参数时能进入并退出远端登录 shell。对 D518 这类无公网 SSH 的 WSL 节点,使用同一命令替换 Provider ID 为 `D518`,必要时先用 debug dispatch 加 `--cwd /home/ubuntu` 覆盖远端工作目录,只能通过 provider-gateway 自连维护桥验证,不得把主 server 直连节点公网 22 端口作为通过标准;在计算节点本机还必须用 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch host.ssh --wait-ms 15000` 和 `bun scripts/cli.ts --main-server-ip 74.48.78.17 ssh hostname` 自测 remote CLI 透传,命令不得要求 `--main-server-key`。 ## T18 Provider Gateway 版本与远程更新记录 @@ -86,13 +86,17 @@ ## T21 D601 Pipeline Microservice -阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts microservice list`,确认 `pipeline` 显示为 `providerId=D601`、`public=false`、`frontendOnly=true`、仓库 URL `https://github.com/pikasTech/pipeline`、commit id、`127.0.0.1:18082` 后端映射、`allowedMethods` 包含 `GET/HEAD/POST` 和 `pipeline-v2-webui` 容器摘要;运行 `bun scripts/cli.ts microservice health pipeline` 和 `bun scripts/cli.ts microservice proxy pipeline '/api/snapshot?__unideskArrayLimit=registry.components:8,runs:3'`,确认链路通过 backend-core、D601 provider-gateway 和 D601 本机 Pipeline 后端,snapshot 返回 `ok=true`、组件 registry、Pipeline run 预览,并且 run/procedure 摘要包含甘特图需要的 `startedAt`、`finishedAt` 或 `durationMs`;运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 microservice health pipeline`,确认非主 server 也能通过公网 frontend remote CLI 验证同一链路且不需要 `--main-server-key`。随后运行 `bun scripts/cli.ts e2e run`,确认 microservice 和 frontend Pipeline 检查全部 passed;再登录公网 frontend `http://74.48.78.17:18081/`,进入 `微服务 / 服务目录` 和 `微服务 / Pipeline`,确认页面以 React 控件显示 D601、仓库引用、私有后端映射、Pipeline 组件矩阵、React Flow 控制图框图、epoch 列表、epoch 甘特图和运行材料索引,点击控制图中的 node 后会打开 node 精细控制面板,能通过“抓取过程”读取 node 执行过程,并显示 append prompt、guide 和 redo/restart 操作入口;甘特图必须按纵向时间轴绘制 node 工作竖条,滚动到某个时间窗口时自动隐藏该窗口内无工作的 node 列;点击甘特图执行线后必须显示 `OpenCode Step Timeline`,按角色、模型、tokens、正文摘要、思考块和工具调用分区展示 step,风格参考 `agent-sessions` 的 `MessageList.tsx`;默认没有裸 JSON、JSONL 或逐行日志,只有点击 `查看原始JSON` 才显示原始数据。Pipeline 业务代码开发和调试必须用 `bun scripts/cli.ts ssh D601 ...` 进入 D601 的 `/home/ubuntu/pipeline`,不得把 pipeline 全量代码复制进 UniDesk 仓库,也不得占用主 server 部署调试服务。 +阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts microservice list`,确认 `pipeline` 显示为 `providerId=D601`、`public=false`、`frontendOnly=true`、仓库 URL `https://github.com/pikasTech/pipeline`、commit id、`127.0.0.1:18082` 后端映射、`allowedMethods` 包含 `GET/HEAD/POST` 和 `pipeline-v2-control` 容器摘要;运行 `bun scripts/cli.ts microservice health pipeline`、`bun scripts/cli.ts microservice proxy pipeline '/api/snapshot?__unideskArrayLimit=registry.components:8,runs:3'` 和 `bun scripts/cli.ts microservice proxy pipeline /api/oa-event-flow/diagnostics`,确认链路通过 backend-core、D601 provider-gateway 和 D601 本机 Pipeline 后端,snapshot 返回 `ok=true`、组件 registry、Pipeline run 预览,diagnostics 返回 `mode=oa-event-flow-100`、禁止残留为 0、有无审核与 monitor 审核证据、node 完成事件不携带流程策略字段,且运行代码中不存在 `control-prompts.jsonl`、`monitor-prompts.jsonl`、`monitor-control`、`control-events.jsonl`、monitor stop 文件、`PIPELINE_*_APPEND_FILE`、本地 JSONL append/read helper 或 monitor `/pipeline-state` 挂载等旧文件传输残留;运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 microservice health pipeline`,确认非主 server 也能通过公网 frontend remote CLI 验证同一链路且不需要 `--main-server-key`。随后运行 `bun scripts/cli.ts e2e run --only microservice:pipeline,frontend:pipeline`,确认定向 Pipeline 验收通过;若没有正在运行且已产生 `node-long-running-observation` 的候选 run,E2E 会先通过 D601 SSH 透传启动 `monitor-management-behavior-test` 作为真实长任务观察种子,再由公网 frontend Playwright 验证甘特图连线和实时 running 条。再运行 `bun scripts/cli.ts e2e run`,确认 `microservice:pipeline-oa-event-flow`、`frontend:pipeline-oa-event-flow-visible` 和其他 microservice/frontend Pipeline 检查全部 passed;再登录公网 frontend `http://74.48.78.17:18081/`,进入 `微服务 / 服务目录` 和 `微服务 / Pipeline`,确认页面以 React 控件显示 D601、仓库引用、私有后端映射、Pipeline 组件矩阵、React Flow 控制图框图、评分器 score 卡片、结构化 `OA 事件流` 诊断面板、epoch 列表、epoch 甘特图和运行材料索引,点击控制图中的 node 后会打开 node 精细控制面板,能通过“抓取过程”读取 node 执行过程,并显示 append prompt、guide 和 redo/restart 操作入口;甘特图必须按纵向时间轴绘制 node 工作竖条,滚动到某个时间窗口时自动隐藏该窗口内无工作的 node 列;点击甘特图执行线后必须显示 `OpenCode Step Timeline`,按角色、模型、tokens、正文摘要、思考块和工具调用分区展示 step,风格参考 `agent-sessions` 的 `MessageList.tsx`;Pipeline OA 事件流验收必须证明有审核和无审核流程都由 OA 从 `node-finished` 事实、config policy、monitor 控制事件和 runner control result 串起,不能保留独立审核请求事件、旧批次推进、runner 私有 JSONL 取证或 frontend/CLI 直写 `.state`;默认没有裸 JSON、JSONL 或逐行日志,只有点击 `查看原始JSON` 才显示原始数据。Pipeline 业务代码开发和调试必须用 `bun scripts/cli.ts ssh D601 ...` 进入 D601 的 `/home/ubuntu/pipeline`,不得把 pipeline 全量代码复制进 UniDesk 仓库,也不得占用主 server 部署调试服务。 ## T22 Main Server Todo Note Microservice 阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:确认 D518 `/mnt/d/work/todo_note` 已复制到主 server `/root/todo_note`,运行 `bun scripts/cli.ts microservice list`,确认 `todo-note` 显示为 `providerId=main-server`、`public=false`、`frontendOnly=true`、仓库 URL `https://gitee.com/Lyon1998/todo_note`、`todo-note:4211` 后端映射和 `todo-note-backend` 容器摘要;运行 `bun scripts/cli.ts microservice health todo-note`,确认返回 `storage=postgres`;运行 `bun scripts/cli.ts microservice proxy todo-note /api/instances --max-body-bytes 8000`,确认能看到 `CONSTAR`、`大论文`、`找工作`、`小论文`、`事务` 五个迁移清单,总任务数不低于 100。随后通过 backend-core 或 `bun scripts/cli.ts e2e run` 执行临时清单 create/add/toggle/undo/delete 写入循环,确认 Todo Note 写入真实经过 backend-core、main-server provider-gateway、`todo-note-backend` 和主 PostgreSQL,且删除前必须按唯一临时清单名称重新选中临时清单,不能误删迁移清单。最后登录公网 frontend `http://74.48.78.17:18081/`,进入 `微服务 / Todo Note`,确认清单、树形任务、筛选、提醒、移动、撤销/重做、字号控制都以 React 控件展示,默认没有裸 JSON,只有点击 `查看原始JSON` 才显示原始数据。 -## T23 MET Nonlinear D601 GPU Microservice +## T23 Main Server Codex Queue Microservice + +阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts microservice list`,确认 `codex-queue` 显示为 `providerId=main-server`、`public=false`、`frontendOnly=true`、仓库 URL `https://github.com/pikasTech/unidesk`、`codex-queue:4222` 后端映射和 `codex-queue-backend` 容器摘要;运行 `bun scripts/cli.ts server rebuild codex-queue` 并用 `bun scripts/cli.ts job status latest` 等待成功,再运行 `bun scripts/cli.ts microservice health codex-queue` 和 `bun scripts/cli.ts microservice proxy codex-queue /api/tasks`,确认链路通过 backend-core、main-server provider-gateway 和 Codex Queue 后端。随后登录公网 frontend `http://74.48.78.17:18081/`,进入 `微服务 / Codex Queue`,确认页面显示默认模型 `gpt-5.4-mini`、队列指标、任务提交表单、Codex CLI-like 输出、attempt 表、MiniMax/fallback judge 状态、追加 prompt、打断和重试控件;通过页面提交一个小任务,确认任务进入 queued/running/succeeded 或可解释的 failed 状态,并且输出区能看到运行中的 Codex 消息。测试异常中断时可以提交长任务后点击 `打断`,确认任务变为 canceled 或被 judge 标记为非成功终态;自动重试只应在服务端/传输异常、任务正常结束但 execution record 显示未完成、或 judge 判定 retry 时发生;retry 必须复用已有 Codex thread 并 append 继续执行 prompt,只有当前任务 complete 后才推进队列中的下一个任务。Codex provider key 只能通过 `OPENAI_API_KEY`、`CRS_OAI_KEY` 这类运行时环境透传,MiniMax API key 只能通过 `UNIDESK_CODEX_QUEUE_MINIMAX_API_KEY` 这类运行时环境传入,禁止写入 `config.json`、Dockerfile、源码或测试文档。 + +## T24 MET Nonlinear D601 GPU Microservice 阅读 `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/' --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 5bf7b24b..44b84edd 100644 --- a/config.json +++ b/config.json @@ -114,8 +114,8 @@ "commitId": "87811a8d43edf216a4f4d8efa55bbb96bad8df14", "dockerfile": "Dockerfile", "composeFile": "docker-compose.yml", - "composeService": "pipeline-webui", - "containerName": "pipeline-v2-webui" + "composeService": "pipeline-control", + "containerName": "pipeline-v2-control" }, "backend": { "nodeBaseUrl": "http://host.docker.internal:18082", @@ -230,6 +230,50 @@ "route": "/apps/todo-note", "integrated": true } + }, + { + "id": "codex-queue", + "name": "Codex Queue", + "providerId": "main-server", + "description": "Codex Queue 是主 server 承载的 Codex app-server 编排微服务,用于串行任务队列、运行中输出、追加 prompt、打断会话、异常中断判定和自动重试。", + "repository": { + "url": "https://github.com/pikasTech/unidesk", + "commitId": "2aaf0447a62c336f3a488d77516edbf05ff1d742", + "dockerfile": "src/components/microservices/codex-queue/Dockerfile", + "composeFile": "docker-compose.yml", + "composeService": "codex-queue", + "containerName": "codex-queue-backend" + }, + "backend": { + "nodeBaseUrl": "http://codex-queue:4222", + "nodeBindHost": "codex-queue", + "nodePort": 4222, + "proxyMode": "provider-gateway-http", + "frontendOnly": true, + "public": false, + "allowedMethods": [ + "GET", + "HEAD", + "POST", + "DELETE" + ], + "allowedPathPrefixes": [ + "/health", + "/logs", + "/api/" + ], + "healthPath": "/health", + "timeoutMs": 30000 + }, + "development": { + "providerId": "main-server", + "sshPassthrough": true, + "worktreePath": "/root/unidesk" + }, + "frontend": { + "route": "/apps/codex-queue", + "integrated": true + } } ], "paths": { diff --git a/docker-compose.yml b/docker-compose.yml index 06629de1..28597701 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -91,6 +91,47 @@ services: timeout: 3s retries: 20 + codex-queue: + build: + context: . + dockerfile: src/components/microservices/codex-queue/Dockerfile + container_name: codex-queue-backend + restart: unless-stopped + depends_on: + - backend-core + expose: + - "4222" + environment: + HOST: "0.0.0.0" + PORT: "4222" + CODEX_QUEUE_STATE_PATH: "/var/lib/unidesk/codex-queue/state.json" + CODEX_QUEUE_WORKDIR: "/workspace" + CODEX_QUEUE_CODEX_HOME: "/var/lib/unidesk/codex-queue/codex-home" + CODEX_QUEUE_SOURCE_CODEX_CONFIG: "/root/.codex/config.toml" + CODEX_QUEUE_DEFAULT_MODEL: "gpt-5.4-mini" + CODEX_QUEUE_SANDBOX: "danger-full-access" + CODEX_QUEUE_APPROVAL_POLICY: "never" + CODEX_QUEUE_MAX_ATTEMPTS: "3" + OPENAI_API_KEY: "${OPENAI_API_KEY:-}" + CRS_OAI_KEY: "${CRS_OAI_KEY:-}" + MINIMAX_API_KEY: "${UNIDESK_CODEX_QUEUE_MINIMAX_API_KEY:-}" + MINIMAX_API_BASE: "${UNIDESK_CODEX_QUEUE_MINIMAX_API_BASE:-https://api.minimax.io/v1}" + MINIMAX_MODEL: "${UNIDESK_CODEX_QUEUE_MINIMAX_MODEL:-MiniMax-M2.7}" + LOG_FILE: "/var/log/unidesk/${UNIDESK_LOG_PREFIX}_codex-queue.jsonl" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - .:/workspace + - /root/.codex/config.toml:/root/.codex/config.toml:ro + - ${UNIDESK_LOG_DIR}:/var/log/unidesk + - ./.state/codex-queue:/var/lib/unidesk/codex-queue + extra_hosts: + - "host.docker.internal:host-gateway" + healthcheck: + test: ["CMD", "bun", "-e", "fetch('http://127.0.0.1:4222/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 5s + timeout: 3s + retries: 20 + frontend: build: context: . diff --git a/docs/reference/arch.md b/docs/reference/arch.md index ab705b2a..e197861d 100644 --- a/docs/reference/arch.md +++ b/docs/reference/arch.md @@ -59,6 +59,7 @@ - Task commands are delivered over WebSocket and never contain large file content - All state changes are reported to the main server in real time by Provider Gateway - The main server writes state updates to PostgreSQL, completing the unified closed loop + - Pipeline workflow control follows the OA event-flow model: OA is the only control bus, factual node events remain policy-neutral, and runner/monitor/frontend/CLI actions are represented as OA events; detailed constraints live in `docs/reference/pipeline-oa-event-flow.md` - Critical Task Deployment Principles - Single-point components such as the database, core scheduler logic, and API gateway are deployed on the main server - The high-availability environment of the main server ensures the critical scheduling path never breaks diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 3b73a466..76dd9e16 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -12,7 +12,7 @@ UniDesk 的统一 CLI 入口是根目录 `scripts/cli.ts`,运行方式固定 - `server stop` 创建异步 job,在后台停止固定 Compose project 中的全部 UniDesk 服务。 - `server status` 查询公开端口、内部端口、Compose 容器、core/frontend/provider/database 健康检查和访问 URL。 - `server logs` 返回 `logs/` 文件日志和 Docker 容器日志的尾部,默认限制输出大小,避免日志爆炸。 -- `server rebuild ` 创建异步 job,先构建目标服务镜像,构建成功后只按 Compose project/service label 移除该服务旧容器,再用 `--no-deps` 启动目标服务;该命令用于替代手工删除容器的兜底流程,其中 `todo-note` 只重建主 server 承载的 Todo Note 后端,不会重建或删除 database 命名卷。 +- `server rebuild ` 创建异步 job,先构建目标服务镜像,构建成功后只按 Compose project/service label 移除该服务旧容器,再用 `--no-deps` 启动目标服务;该命令用于替代手工删除容器的兜底流程,其中 `todo-note` 和 `codex-queue` 只重建主 server 承载的对应后端,不会重建或删除 database 命名卷。 - `ssh [ssh-like args...]` 通过 backend-core 内网 WebSocket broker 和 provider-gateway 的 Host SSH / WSL SSH 维护桥连接目标节点;无后续参数时进入远端登录 shell,有后续参数时按 ssh 远端命令体验执行并返回远端 exit code。 - `ssh apply-patch [tool args...] < patch.diff` 直接调用远端注入的 `apply_patch` 工具,并把本地 stdin 中的标准 `*** Begin Patch` / `*** End Patch` patch 流透传给目标节点。 - `ssh py [script-args...] < script.py` 把本地 stdin 落到远端临时 `.py` 文件后再以 `python3 -u` 执行并自动清理,避免再手写 `'python3 -'`、heredoc 或多层引号;`script-args` 会按 argv 安全透传给远端脚本。 @@ -25,7 +25,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 ` 查询进度和尾部输出。 -`server rebuild` 与 `server start`、`server stop` 一样必须通过 job 状态确认结果。重建 frontend 的标准流程是运行 `bun scripts/cli.ts server rebuild frontend`,随后轮询 `bun scripts/cli.ts job status latest` 到 `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` 验证。不得把 `docker rm` 手工兜底当成正式交付步骤。 +`server rebuild` 与 `server start`、`server stop` 一样必须通过 job 状态确认结果。重建 frontend 的标准流程是运行 `bun scripts/cli.ts server rebuild frontend`,随后轮询 `bun scripts/cli.ts job status latest` 到 `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` 验证;重建 Codex Queue 后端使用 `bun scripts/cli.ts server rebuild codex-queue`,随后用 `microservice health codex-queue` 和 `microservice proxy codex-queue /api/tasks` 验证。不得把 `docker rm` 手工兜底当成正式交付步骤。 ## Output Contract diff --git a/docs/reference/config.md b/docs/reference/config.md index 0fb2ac21..eb21827d 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -26,7 +26,7 @@ TypeScript 运行时固定为 Bun。根目录 CLI、backend-core、frontend 和 `microservices` 定义挂载在计算节点或主 server Docker 中的非核心业务后端。该数组只保存业务仓库 URL、commit id、业务仓库自身 Dockerfile/docker-compose 引用、provider 映射、节点后端端口和 UniDesk frontend 集成入口;不得把业务全量代码复制进 UniDesk。`backend.public` 必须为 `false`,`backend.frontendOnly` 必须为 `true`,`backend.allowedPathPrefixes` 必须限制到业务 API 前缀,`backend.allowedMethods` 必须显式列出允许代理的 HTTP 方法;浏览器只能通过 frontend 同源代理访问这些后端。详细规则见 `docs/reference/microservices.md`。 -主 server 承载的 Todo Note microservice 使用 `providerId=main-server`、`nodeBaseUrl=http://todo-note:4211` 和 `allowedMethods=["GET","HEAD","POST","DELETE"]`,数据库使用主 PostgreSQL;D601 的 FindJob 只允许 `GET/HEAD` 展示型读取路径;D601 的 Pipeline 允许 `GET/HEAD/POST`,其中 `POST` 只用于 Pipeline 后端 `/api/node-control/...` 的 append prompt、guide 和 redo/restart 等受控 node 操作。 +主 server 承载的 Todo Note microservice 使用 `providerId=main-server`、`nodeBaseUrl=http://todo-note:4211` 和 `allowedMethods=["GET","HEAD","POST","DELETE"]`,数据库使用主 PostgreSQL;Codex Queue 使用 `providerId=main-server`、`nodeBaseUrl=http://codex-queue:4222` 和 `allowedPathPrefixes=["/health","/logs","/api/"]`,只通过 frontend/backend/provider-gateway 私有代理访问 Compose 内网服务名;D601 的 FindJob 只允许 `GET/HEAD` 展示型读取路径;D601 的 Pipeline 允许 `GET/HEAD/POST`,其中 `POST` 只用于 Pipeline 后端 `/api/node-control/...` 的 append prompt、guide 和 redo/restart 等受控 node 操作。 ## Compose Env Generation diff --git a/docs/reference/deployment.md b/docs/reference/deployment.md index 964dbd82..df24a099 100644 --- a/docs/reference/deployment.md +++ b/docs/reference/deployment.md @@ -9,6 +9,7 @@ - `frontend` 是唯一公开 Web 控制台,提供登录、从 TSX 转译出的 React 应用资产和到 backend-core 的同源代理。 - `provider-gateway` 是当前主 server 的本机计算节点代理,通过 WebSocket 主动连到 provider ingress,挂载 `/var/run/docker.sock` 作为自动任务执行主路径,使用 `pid: "host"` 读取节点级进程资源,并周期性上报系统资源指标、进程占用与 Docker daemon 状态;维护用 Host SSH / WSL SSH 私钥目录只读挂载到 `/run/host-ssh`,不得作为自动任务调度主路径。 - `todo-note` 是主 server 承载的 Todo Note 纯后端 microservice,容器名 `todo-note-backend`,只在 Compose 内网暴露 `4211/tcp`,使用主 PostgreSQL 存储迁移后的 Todo Note 数据。 +- `codex-queue` 是主 server 承载的 Codex app-server 队列 microservice,容器名 `codex-queue-backend`,仅在 Compose 内网暴露 `4222/tcp` 给本机 provider-gateway 私有代理访问,浏览器只能通过 UniDesk frontend 同源代理查看运行输出、追加 prompt、打断和重试。 ## Public Exposure Boundary @@ -28,7 +29,7 @@ Compose v2 安装后仍然必须遵守 UniDesk 的服务控制入口:全栈生 ## Single Service Rebuild -前端、backend-core、本机 provider-gateway 或主 server 承载的 Todo Note microservice 需要重建时,统一使用 `bun scripts/cli.ts server rebuild `,其中 `` 只能是 `backend-core`、`frontend`、`provider-gateway` 或 `todo-note`。该命令先执行目标服务镜像构建,只有构建成功后才移除旧容器,避免构建失败导致运行中的服务被提前停掉。 +前端、backend-core、本机 provider-gateway 或主 server 承载的 Todo Note/Codex Queue microservice 需要重建时,统一使用 `bun scripts/cli.ts server rebuild `,其中 `` 只能是 `backend-core`、`frontend`、`provider-gateway`、`todo-note` 或 `codex-queue`。该命令先执行目标服务镜像构建,只有构建成功后才移除旧容器,避免构建失败导致运行中的服务被提前停掉。 单服务重建必须按 Docker Compose label 精确选择旧容器:`com.docker.compose.project` 等于 `config.json` 中的固定 project name,`com.docker.compose.service` 等于目标服务名。删除范围不得扩大到其他 Compose project、database 容器、named volume 或未匹配 label 的容器;随后必须通过 CLI 解析出的 Compose 命令执行 `up -d --no-deps ` 启动目标服务,避免因为重建 frontend 而连带重启 database 或 backend-core。 @@ -36,7 +37,7 @@ Compose v2 安装后仍然必须遵守 UniDesk 的服务控制入口:全栈生 ## Health Criteria -服务跑通的最低标准是:backend-core 内网 `/health` 返回 ok,frontend 公网 `/health` 返回 ok,provider ingress 公网 `/health` 返回 ok,database 在容器内 `pg_isready` 可用,Todo Note 后端 `/api/health` 返回 `storage=postgres`,`/api/nodes` 中出现 `main-server` provider 且状态为 `online`,`/api/nodes/system-status` 中出现 `main-server` 的 CPU/内存/硬盘采样,`/api/nodes/docker-status` 中出现 `main-server` 的 Docker 快照。交付前还必须运行 `bun scripts/cli.ts e2e run`,并以 `docs/reference/e2e.md` 的门禁作为最终判定。 +服务跑通的最低标准是:backend-core 内网 `/health` 返回 ok,frontend 公网 `/health` 返回 ok,provider ingress 公网 `/health` 返回 ok,database 在容器内 `pg_isready` 可用,Todo Note 后端 `/api/health` 返回 `storage=postgres`,Codex Queue `/health` 返回队列摘要和默认模型,`/api/nodes` 中出现 `main-server` provider 且状态为 `online`,`/api/nodes/system-status` 中出现 `main-server` 的 CPU/内存/硬盘采样,`/api/nodes/docker-status` 中出现 `main-server` 的 Docker 快照。交付前还必须运行 `bun scripts/cli.ts e2e run`,并以 `docs/reference/e2e.md` 的门禁作为最终判定。 ## Database Volume diff --git a/docs/reference/e2e.md b/docs/reference/e2e.md index d87d5bb2..aa6c4de2 100644 --- a/docs/reference/e2e.md +++ b/docs/reference/e2e.md @@ -31,14 +31,18 @@ Typical targeted commands: - `bun scripts/cli.ts e2e run --only frontend --skip frontend:todo-note-integrated-visible,frontend:findjob-integrated-visible` - `bun scripts/cli.ts e2e run --only network,provider-ingress` -- Public exposure: Docker port summary must show only frontend and provider ingress host mappings; public core、public database and known private microservice ports such as FindJob `3254`, MET Nonlinear `3288` and Todo Note `4211` probes must fail. +- Public exposure: Docker port summary must show only frontend and provider ingress host mappings; public core、public database and known private microservice ports such as FindJob `3254`, MET Nonlinear `3288`, Todo Note `4211` and Codex Queue host port `14222` probes must fail. - Core API: `docker exec unidesk-backend-core` calls internal `GET /api/overview`, which must report `dbReady: true`, `pgdata.volumeName=unidesk_pgdata_10gb`, a positive PostgreSQL database byte count, and at least one online node. - Provider self-connection: internal `GET /api/nodes` must contain `main-server` with `status: online`, `labels.providerGatewayVersion` equal to `src/components/provider-gateway/package.json` and `labels.providerGatewayUpgradePolicy: "always-enabled"`; internal `GET /api/nodes/system-status` must contain CPU/memory/disk samples plus a non-empty process resource list sorted by memory by default; internal `GET /api/nodes/docker-status` must contain a Docker snapshot for `main-server`; public provider ingress `/health` must return ok. - Provider remote control: internal `/api/dispatch` must successfully complete a real `provider.upgrade` task in `mode: "plan"` so the upgrade path is validated without recreating the running gateway during E2E. -- Microservices: internal `/api/microservices` must include `todo-note` on `main-server` plus `findjob`, `pipeline` and `met-nonlinear` on `D601` with `public=false`; `/api/microservices/todo-note/health` must report `storage=postgres`, `/api/microservices/todo-note/proxy/api/instances` must expose the migrated Todo Note lists, and a temporary Todo Note list create/add/toggle/undo/delete cycle must succeed through the real provider-gateway proxy; `/api/microservices/findjob/health` and `/api/microservices/findjob/proxy/api/summary` must succeed through the real provider-gateway proxy; `/api/microservices/findjob/proxy/api/jobs?__unideskArrayLimit=jobs:5` must return a bounded preview with `_unidesk.arrayLimits` metadata; `/api/microservices/pipeline/health` and `/api/microservices/pipeline/proxy/api/snapshot?__unideskArrayLimit=registry.components:8,runs:3` must return Pipeline health, registry and run previews; `/api/microservices/met-nonlinear/health`, `/api/microservices/met-nonlinear/proxy/api/queue`, `/api/microservices/met-nonlinear/proxy/api/projects?root=projects&limit=500`, `/api/microservices/met-nonlinear/proxy/api/projects?root=ex_projects&limit=500`, `/api/microservices/met-nonlinear/proxy/api/projects/config?path=` and `/api/microservices/met-nonlinear/proxy/api/images` must return the D601 TS backend health, queue/GPU policy, full project tree inputs, structured project detail and ready `met-nonlinear-ml:tf26` image status. +- Microservices: internal `/api/microservices` must include `todo-note` and `codex-queue` on `main-server` plus `findjob`, `pipeline` and `met-nonlinear` on `D601` with `public=false`; `/api/microservices/todo-note/health` must report `storage=postgres`, `/api/microservices/todo-note/proxy/api/instances` must expose the migrated Todo Note lists, and a temporary Todo Note list create/add/toggle/undo/delete cycle must succeed through the real provider-gateway proxy; `/api/microservices/codex-queue/health` must return a Codex Queue summary with default model `gpt-5.4-mini`, and `/api/microservices/codex-queue/proxy/api/tasks` must return queue state through the same proxy; `/api/microservices/findjob/health` and `/api/microservices/findjob/proxy/api/summary` must succeed through the real provider-gateway proxy; `/api/microservices/findjob/proxy/api/jobs?__unideskArrayLimit=jobs:5` must return a bounded preview with `_unidesk.arrayLimits` metadata; `/api/microservices/pipeline/health`, `/api/microservices/pipeline/proxy/api/snapshot?__unideskArrayLimit=registry.components:8,runs:3` and `/api/microservices/pipeline/proxy/api/oa-event-flow/diagnostics` must return Pipeline health, registry/run previews and OA event-flow evidence; `/api/microservices/met-nonlinear/health`, `/api/microservices/met-nonlinear/proxy/api/queue`, `/api/microservices/met-nonlinear/proxy/api/projects?root=projects&limit=500`, `/api/microservices/met-nonlinear/proxy/api/projects?root=ex_projects&limit=500`, `/api/microservices/met-nonlinear/proxy/api/projects/config?path=` and `/api/microservices/met-nonlinear/proxy/api/images` must return the D601 TS backend health, queue/GPU policy, full project tree inputs, structured project detail and ready `met-nonlinear-ml:tf26` image status. - Database: the command writes an `unidesk_e2e_markers` row through `docker exec unidesk-database psql`, confirms provider state is stored in PostgreSQL, and checks Todo Note rows exist in `todo_note_instances` using the same named volume. -- Frontend: Playwright must open the public frontend URL derived from `network.publicHost`, not localhost or a Docker-internal URL; it logs in with the configured account, waits for `核心在线`, asserts that `main-server` and `Main Server Provider` are visible, verifies desktop sidebar collapse and `PGDATA` overview metric, clicks `查看原始JSON` to verify Provider data from the frontend, confirms no raw JSON is visible before that click, opens task history to verify duration and failure diagnostics, opens resource nodes `资源监控` to verify CPU/Memory/Disk curves, the structured process resource table, default memory-desc sorting, sortable CPU column and provider upgrade precheck dispatch, opens `Docker 状态`, switches to `main-server`, and verifies the Docker Desktop-style container view including the database named volume `unidesk_pgdata_10gb`, opens `网关版本` and verifies the provider-gateway version, SSH 透传可用性、远程更新可用性 plus structured remote update records for `provider.upgrade`, then opens `微服务 / 服务目录`、`微服务 / Todo Note`、`微服务 / FindJob`、`微服务 / Pipeline` and `微服务 / MET Nonlinear` to verify 主 server Todo Note、D601、仓库引用、私有后端映射、Todo Note 迁移清单和树形任务、FindJob 指标和岗位预览、Pipeline 组件矩阵、React Flow 控制图、epoch 甘特图和 OpenCode Step Timeline、MET Nonlinear 项目库/Fork/待启动队列/当前队列/已完成/失败诊断/GPU/镜像都通过 React 控件展示。Playwright 还必须验证至少一个深链接直达路由,例如公网 `http://:/app/pipeline/` 能直接落到 Pipeline 页面,随后切到 `资源节点 / Docker 状态` 时地址栏更新为 `/nodes/docker/`,并且浏览器 history 返回链路仍能回到 `/app/pipeline/`;同时 `态势总览` 这类非微服务页面应落在自己的模块前缀下,例如 `/ops/status/`。Task history and provider upgrade records must not display a real sub-second duration as `0s`; MET Nonlinear running rows must show an ETA derived from backend progress or from `startedAt` plus epoch progress, and queue/completed rows must show training speed as `epoch/h`. +- Pipeline OA event flow: `microservice:pipeline-oa-event-flow` must prove both no-audit and monitor-audit runs are driven by OA events end to end. The event stream must show `node-finished` as a neutral fact with `pipeline:{pipelineId}` and `epoch:{runId}` tags, OA policy as the source of downstream/audit decisions, monitor decisions as OA control events, and runner control-result evidence. E2E must fail if delivery still depends on a legacy detail audit policy flag as policy authority, independent legacy audit-request points, a legacy batch completion gate, direct monitor-to-runner calls, or frontend/CLI writes to Pipeline `.state`. +- The same Pipeline OA diagnostics must fail on legacy file-transport residuals. Procedure containers, monitor sessions, UI/Gantt DTO builders and CLI fetches must consume prompt/control/stop/display evidence only from the OA event ledger and normalized HTTP read APIs; `control-prompts.jsonl`, `monitor-prompts.jsonl`, `monitor-control`, `control-events.jsonl`, monitor stop files, `.state/pipeline-runs/{runId}/control/commands/`, `PIPELINE_*_APPEND_FILE`, local JSONL append/read helpers, and monitor `/pipeline-state` mounts are forbidden in runtime source. +- Pipeline live Gantt setup: when `frontend:pipeline-gantt-observation-live-running` is selected, E2E first looks for a current Pipeline run that already contains both a `node-long-running-observation` marker and a still-running execution interval. If no such candidate exists, the E2E setup starts the D601 `monitor-management-behavior-test` pipeline through `bun scripts/cli.ts ssh D601 ...` and polls the private backend proxy until the observation candidate exists; the acceptance assertion itself still opens the public frontend with Playwright and verifies the rendered arrows, absence of observation source pseudo-points, target arrow inset, and live flashing running bar through React DOM controls. +- Frontend: Playwright must open the public frontend URL derived from `network.publicHost`, not localhost or a Docker-internal URL; it logs in with the configured account, waits for `核心在线`, asserts that `main-server` and `Main Server Provider` are visible, verifies desktop sidebar collapse and `PGDATA` overview metric, clicks `查看原始JSON` to verify Provider data from the frontend, confirms no raw JSON is visible before that click, opens task history to verify duration and failure diagnostics, opens resource nodes `资源监控` to verify CPU/Memory/Disk curves, the structured process resource table, default memory-desc sorting, sortable CPU column and provider upgrade precheck dispatch, opens `Docker 状态`, switches to `main-server`, and verifies the Docker Desktop-style container view including the database named volume `unidesk_pgdata_10gb`, opens `网关版本` and verifies the provider-gateway version, SSH 透传可用性、远程更新可用性 plus structured remote update records for `provider.upgrade`, then opens `微服务 / 服务目录`、`微服务 / Todo Note`、`微服务 / Codex Queue`、`微服务 / FindJob`、`微服务 / Pipeline` and `微服务 / MET Nonlinear` to verify 主 server Todo Note/Codex Queue、D601、仓库引用、私有后端映射、Todo Note 迁移清单和树形任务、Codex Queue 队列/模型/输出/追加 prompt/打断控件、FindJob 指标和岗位预览、Pipeline 组件矩阵、结构化 OA 事件流诊断面板、React Flow 控制图、epoch 甘特图、甘特图渲染图导出、monitor 首列排序、长任务观察连线、无观察来源伪点、running node 实时闪动执行条和 OpenCode Step Timeline、MET Nonlinear 项目库/Fork/待启动队列/当前队列/已完成/失败诊断/GPU/镜像都通过 React 控件展示。Playwright 还必须验证至少一个深链接直达路由,例如公网 `http://:/app/pipeline/` 能直接落到 Pipeline 页面,随后切到 `资源节点 / Docker 状态` 时地址栏更新为 `/nodes/docker/`,并且浏览器 history 返回链路仍能回到 `/app/pipeline/`;同时 `态势总览` 这类非微服务页面应落在自己的模块前缀下,例如 `/ops/status/`。Task history and provider upgrade records must not display a real sub-second duration as `0s`; MET Nonlinear running rows must show an ETA derived from backend progress or from `startedAt` plus epoch progress, and queue/completed rows must show training speed as `epoch/h`. - Frontend dense-layout regression gate: whenever a frontend change touches Pipeline 右侧边栏、step timeline、详情抽屉或其他高信息密度面板, Playwright acceptance must inspect both `总高度` and `横向滚动条`. For Pipeline specifically, the OpenCode Step Timeline session head must carry shared agent/model/session facts, each step summary must keep time in the header rather than a left narrow column, and Playwright must fail if the timeline or the collapsed step summary introduces an internal horizontal scrollbar or if the collapsed summary height grows beyond a bounded compact threshold. +- OpenCode Step Timeline must also prove that idle gaps between adjacent steps remain visually blank. Playwright should fail if `.pipeline-opencode-flow::before` or any equivalent continuous connector draws across the whole timeline, because an interval such as previous `completedAt` to next `createdAt` is not execution activity. - Microservice frontend assertions must wait for real backend data, not only the page skeleton. For Todo Note this means the page must show the migrated lists `CONSTAR`、`大论文`、`找工作`、`小论文`、`事务`, support creating a temporary list and task through the frontend, and delete that temporary list afterwards. The temporary list must be selected again by its unique generated name before deletion so E2E never deletes a migrated source list by accident. For FindJob this means the page must show a numeric `岗位总量`, `HEALTH OK`, and a non-empty `PREVIEW` count such as `40/1463 PREVIEW`; for Pipeline this means the page must show `Pipeline v2 工作台`, `Health OK`, a numeric component count, a non-empty React Flow control graph, `控制图`, `Epoch 甘特图`, and after clicking a Gantt execution line it must show `OpenCode Step Timeline` with role/model/tokens/text/reasoning/tool sections; for MET Nonlinear this means the page must show `MET Nonlinear 训练编排`, `Health OK`, `Fork Project`, `加入待启动队列`, `启动队列`, `当前队列`, 最大并发设置、task queue and GPU/image panels, and must not show the removed hard-coded `创建10个10轮任务` frontend entry. The MET Nonlinear project library must render `projects/` and `ex_projects/` as a true path tree with folder Project counts; clicking a project row must open a structured detail panel containing `config.json`, `data/ 训练状态`, `模型参数`, `指标` and a parameter count such as `Total Params`; clicking a completed/current/failed job row must open a structured job detail and both the row and detail must show `epoch/h`. Full MET Nonlinear acceptance is driven by public frontend controls: choose a visible source Project, set batch size, epochs and max concurrency in inputs, fork into `projects/unidesk_forks/`, stage the selected forks, start the queue, and verify completed rows plus automatic `metnl-train-*` container removal; loading placeholders like `--` or empty states are not sufficient for E2E success. ## Frontend JSON Rule @@ -49,7 +53,7 @@ Remote update records in the frontend are covered by the same rule: `provider.up Provider operation availability is also covered by the structured rendering rule. `host.ssh` availability must be displayed as badges or equivalent controls derived from capabilities and `hostSsh*` labels, and remote update availability must be displayed from `provider.upgrade` capability plus the `always-enabled` policy; these fields must not require opening raw Provider JSON. -Microservice pages are covered by the same rule. `Todo Note` must show lists, task tree, filters, reminder input, movement controls, undo/redo and metrics as controls; `FindJob` must show metrics, jobs and drafts as cards/tables; `Pipeline` must show component classes, React Flow graph nodes/edges, run cards, Gantt execution lines and OpenCode step timelines as controls; `MET Nonlinear` must show queue rows, GPU/image cards, a real path tree for the project library, structured project/job detail panels, project config preview, `data/` training state, model parameter count, metrics, progress bars, ETA, `epoch/h` speed and history diagnostics as controls; the full microservice config, summary, snapshot, jobs preview, drafts and run JSON can only appear after an explicit `查看原始JSON` click. +Microservice pages are covered by the same rule. `Todo Note` must show lists, task tree, filters, reminder input, movement controls, undo/redo and metrics as controls; `Codex Queue` must show queue cards, live transcript, model/cwd/max attempt inputs, judge decision, attempt table, append prompt, interrupt and retry controls; `FindJob` must show metrics, jobs and drafts as cards/tables; `Pipeline` must show component classes, React Flow graph nodes/edges, run cards, Gantt execution lines and OpenCode step timelines as controls; `MET Nonlinear` must show queue rows, GPU/image cards, a real path tree for the project library, structured project/job detail panels, project config preview, `data/` training state, model parameter count, metrics, progress bars, ETA, `epoch/h` speed and history diagnostics as controls; the full microservice config, summary, snapshot, jobs preview, drafts and run JSON can only appear after an explicit `查看原始JSON` click. ## Public Boundary Rule diff --git a/docs/reference/frontend.md b/docs/reference/frontend.md index 968b497b..8ee4fb59 100644 --- a/docs/reference/frontend.md +++ b/docs/reference/frontend.md @@ -6,11 +6,11 @@ UniDesk 前端是 React 组件化工业控制台,不追求展示型大屏效 frontend 应用源码必须使用 TypeScript + React,禁止在 `src/components/frontend` 中维护手写 `.js` / `.jsx` 应用源码。浏览器请求的 `/app.js` 只能由 frontend Bun server 从 `src/components/frontend/src/app.tsx` 及其 TSX imports 转译生成;`public/` 目录只保存 HTML/CSS 等静态资产,不提交手写 `app.js`。 -`src/components/frontend/src/app.tsx` 只承担应用 shell、登录、全局数据加载、主模块/子标签路由和通用控制台页面。业务 microservice 前端必须模块化到独立 TSX 文件,禁止继续把所有业务页面堆进 `app.tsx`。当前长期固定入口为:`todo-note.tsx` 承载 Todo Note 工作台,`findjob.tsx` 承载 FindJob 工作台,`pipeline.tsx` 承载 Pipeline 工作台,`met-nonlinear.tsx` 承载 MET Nonlinear 训练编排工作台;新增业务 microservice 也必须按同样规则新增独立页面模块,并由 `app.tsx` 只做导入和路由分发。 +`src/components/frontend/src/app.tsx` 只承担应用 shell、登录、全局数据加载、主模块/子标签路由和通用控制台页面。业务 microservice 前端必须模块化到独立 TSX 文件,禁止继续把所有业务页面堆进 `app.tsx`。当前长期固定入口为:`todo-note.tsx` 承载 Todo Note 工作台,`findjob.tsx` 承载 FindJob 工作台,`pipeline.tsx` 承载 Pipeline 工作台,`met-nonlinear.tsx` 承载 MET Nonlinear 训练编排工作台,`codex-queue.tsx` 承载 Codex Queue 控制台;新增业务 microservice 也必须按同样规则新增独立页面模块,并由 `app.tsx` 只做导入和路由分发。 ## Layout -左侧边栏只切换主模块:运行总览、资源节点、任务调度、微服务、系统配置。顶部标签只切换当前主模块内的子功能;例如资源节点下的节点清单、资源标签、心跳状态只属于资源节点,微服务下的服务目录、Todo Note、FindJob、Pipeline、MET Nonlinear 只属于微服务,和运行总览、任务调度、系统配置没有重复或共享语义。桌面端左侧边栏必须支持收起,只保留模块 code 和展开按钮,以便最大化主面板空间;移动端左侧边栏会转为顶部横向主模块条,但高度必须在不同主模块之间保持一致,并保持窄条、单行、不换行;主内容区无论内容多少都必须从顶部向下排列,空状态也不得上下居中制造大块留白。 +左侧边栏只切换主模块:运行总览、资源节点、任务调度、微服务、系统配置。顶部标签只切换当前主模块内的子功能;例如资源节点下的节点清单、资源标签、心跳状态只属于资源节点,微服务下的服务目录、Todo Note、FindJob、Pipeline、MET Nonlinear、Codex Queue 只属于微服务,和运行总览、任务调度、系统配置没有重复或共享语义。桌面端左侧边栏必须支持收起,只保留模块 code 和展开按钮,以便最大化主面板空间;移动端左侧边栏会转为顶部横向主模块条,但高度必须在不同主模块之间保持一致,并保持窄条、单行、不换行;主内容区无论内容多少都必须从顶部向下排列,空状态也不得上下居中制造大块留白。 ## Route Model @@ -58,13 +58,18 @@ frontend shell 必须把左侧主模块与顶部子标签编译为统一的 URL - `服务目录` 必须显示 service id、Provider、仓库 URL、commit id、业务 Dockerfile/docker-compose 引用、节点后端私有映射、SSH 透传开发入口和运行态容器摘要。 - `Todo Note` 子标签必须把主 server `todo-note-backend` 后端渲染为 UniDesk React 控件,包括迁移清单、树形任务、筛选、提醒、拖放/移动、撤销/重做、字号控制和显式原始 JSON 按钮。 - `FindJob` 子标签必须把 D601 findjob 后端渲染为 UniDesk React 控件,包括岗位指标、岗位预览、草稿报告和显式原始 JSON 按钮。 + - `Codex Queue` 子标签必须把主 server `codex-queue-backend` 后端渲染为 UniDesk React 控件,包括串行队列、任务提交/批量提交、默认模型 `gpt-5.4-mini`、MiniMax judge 状态、Codex CLI-like 输出流、attempt 终态、运行中追加 prompt、打断、手动重试和显式原始 JSON 按钮。 - 业务 microservice 页面不得 iframe 业务旧前端、Todo Note 原 Vite 前端或 Pipeline 自身 WebUI,不得把 microservice 后端端口暴露为浏览器直连 URL,也不得把业务 API 的 JSON 裸铺在页面上。 - `Pipeline` 子标签是 D601 `/home/ubuntu/pipeline` 的 UniDesk host UI。 + - Pipeline 仓库自带 WebUI 前端已经废弃;UniDesk frontend 是唯一用户可见的 Pipeline UI。 + - Pipeline microservice 只提供 backend/control API,UniDesk 通过 `/api/microservices/pipeline/proxy/...` 拉取 snapshot、Gantt DTO、node detail 和控制接口。 + - Pipeline 控制与观测的最终权威是 100% OA 事件流;分阶段迁移不得在交付态保留点对点控制、旧审核事件或旧 batch 推进逻辑,完整规则见 `docs/reference/pipeline-oa-event-flow.md`。 - 基础视图必须包含组件矩阵、React Flow 控制图框图、epoch 列表、运行材料索引、epoch 甘特图和 node 精细控制面板。 + - Pipeline scorer 结果必须结构化展示为 `x/N`、通过率、scorer 状态、item pass/fail badge 和 raw inspector 入口;主界面不得裸展示 scorer JSON。 - 用户点击控制图中的 node 后,必须通过同源 microservice 代理抓取该 node 执行过程,并支持向运行中 node 追加 prompt、给下次尝试下发 guide、对已完成 node 排队 modify、提交 monitor 审核 approve,以及排队 restart/redo。 - append-prompt、guide、modify、redo/restart 统称为 node 的管理行为;approve 是 monitor 审核决策;fetch/status 只属于观察行为。 - UniDesk 只负责同源代理、结构化展示和人工控制入口,不直接写 Pipeline `.state` 文件。 - - node 精细控制面板的 append、guide、modify、approve、redo/restart 必须调用 Pipeline HTTP node-control 后端,并把 WebUI 发起者记录为结构化 `sourceKind=webui` 事件。 + - node 精细控制面板的 append、guide、modify、approve、redo/restart 必须调用 Pipeline 后端 OA control API;`node-control` 可作为 HTTP 路由名保留,但内部必须写入 OA 控制事件,并把 UniDesk frontend 发起者记录为结构化事件;历史兼容字段可继续使用 `sourceKind=webui` 表示前端来源。 - `Pipeline` epoch 和甘特图规则。 - 一个 epoch 定义为同一个 pipeline 从入口到终态完整执行一遍。 - 同一 `pipelineId` 下的多个 run 必须作为多个 epoch 展示,并允许操作员切换当前 epoch;控制图、node 控制面板、运行材料索引和甘特图都必须跟随当前 epoch。 @@ -73,6 +78,7 @@ frontend shell 必须把左侧主模块与顶部子标签编译为统一的 URL - epoch 甘特图的纵轴是时间,左侧第一列展示时间刻度,后续每列对应一个 node;node 在 `startedAt` 到 `finishedAt` 或当前时间之间处于工作态时绘制竖向工作条,空闲时间留空。 - 甘特图 node 列顺序必须遵循 pipeline 拓扑,从上游到下游依次向右展开;不得把下游 node 排在上游左侧。 - 甘特图必须提供时间尺度滑块,用同一份时间数据调整每分钟像素密度:全局尺度压缩纵向高度以查看完整 epoch,细节尺度拉长纵向高度以查看短时间内的 prompt 点、控制点和执行线。 + - 甘特图必须提供当前 epoch 的渲染图导出按钮;导出图应使用当前可见布局,执行区间先绘制,控制/观察箭头覆盖在执行区间之上,真实事件点覆盖在最上层,避免箭头被执行条遮住。 - 精确数学图形的坐标权威规则。 - 凡是精确时序图、甘特图或其他包含严格数学映射关系的渲染,坐标权威必须在后端完成。 - Pipeline `GET /api/node-control/runs/{runId}?view=gantt&scale=0..100` 应返回可直接展示的 `layout.chartHeight`、时间刻度 `ticks[].y`、执行区间 `startY/endY/y1/y2/height`、事件点 `y/timeAxisY`、控制箭头 `sourceY/targetY/y1/y2` 和对齐诊断。 @@ -83,17 +89,25 @@ frontend shell 必须把左侧主模块与顶部子标签编译为统一的 URL - 甘特图上的执行线、prompt 点、控制点和 monitor 虚线箭头必须通过同源 `node-control` HTTP 读取接口驱动。 - run 级图形数据使用 `GET /api/node-control/runs/{runId}?view=gantt&scale=0..100`。 - node 级 OpenCode 明细来自用户选择后的 `GET /api/node-control/runs/{runId}/nodes/{nodeId}`,避免跨 UniDesk provider HTTP 代理时一次性拉取全 run 的大体积 step 数组。 + - 这些 monitor/control 事件的权威来源是 Pipeline OA 事件流;Pipeline 后端会为每条事件自动附加当前 `epoch:{runId}` 与 `pipeline:{pipelineId}` scope tag,并在 monitor 订阅时自动追加同一组 scope 限制,避免不同 epoch 之间串流。UniDesk 只消费 Pipeline 暴露的结构化读接口,不直接依赖旧的 monitor append 文件语义;OA 事件流的完整约束见 `docs/reference/pipeline-oa-event-flow.md`。 - prompt 点来源于 attempt 级 `controlEventRecords` 中的 prompt-delivered 事件,必要时回退到 `controlPromptRecords` / `monitorPromptRecords`。 + - Monitor prompt 点必须将 `node-finished`、`node-long-running-observation` 作为一等结构化事件展示;`node-finished` 是中性完成事实,不得携带是否审核的权威策略字段。是否需要审核由 OA backend 根据当前 epoch 的 Pipeline config/topology 判定;审核开启时同一个 node completion 在 UI 中只保留一条完成/等待审核点,不得再生成独立 `legacy node audit request event` 或 `legacy monitor audit request event` 点。 - 控制点和 monitor/人工控制箭头来源于 run 级 `controlEvents`,必要时回退到 `controlCommands`。 - - 控制事件必须依赖后端记录的 `sourceKind`、`sourceNodeId`、`targetNodeId`、`commandId`、`eventId`、`resetNodeIds` 和 `interruptedProcedureRunIds` 等结构化字段识别 monitor、WebUI、CLI 等发起者,不能通过解析 monitor 自己的 step 文本来反推。 + - 控制事件必须依赖后端记录的 `sourceKind`、`sourceNodeId`、`targetNodeId`、`commandId`、`eventId`、`resetNodeIds` 和 `interruptedProcedureRunIds` 等结构化字段识别 monitor、UniDesk frontend、CLI 等发起者,不能通过解析 monitor 自己的 step 文本来反推。 - `Pipeline` monitor 审核与详情展示。 - - Pipeline 后端可通过 `control.monitoring.audit.enabled=true` 开启 monitor 审核门禁。 - - 当前 pipeline 存在 monitor node 时,node 成功后必须等待后端记录的 `approve` 审核通过,或由 monitor 发起 `modify` 增量修改管理行为、`redo/restart` 重做管理行为。 - - 前端应把审核/控制事件渲染为结构化点和虚线箭头。 + - Pipeline 页面必须提供结构化 `OA 事件流` 诊断面板,通过 `/api/microservices/pipeline/proxy/api/oa-event-flow/diagnostics` 展示 100% 事件流模式、禁止残留数、no-audit 证据、monitor 审核证据、控制 queued/applied 计数和近期 run 证据;原始 diagnostics 只能通过 `查看原始JSON` 打开。 + - Pipeline 后端可通过 `control.monitoring.audit.enabled=true` 开启 monitor 审核门禁,但是否审核必须由 OA backend 加载 config snapshot 后判断,不能写入 `node-finished` 事实事件。 + - 当前 pipeline 存在 monitor node 时,node 成功后必须先进入 OA 事件流;OA 判定需要审核后才向 monitor 推送 prompt,随后必须等待 monitor 通过 OA 控制事件发起 `approve`、`modify`、`redo/restart` 或等价管理行为。 + - 长时间处于 `running` 的 node 必须触发 `node-long-running-observation` 观察事件,默认节奏为 2、5、10、20 分钟,之后每 20 分钟继续发送一次;前端需要把这类观察事件和普通 `node-finished` 清晰区分。 + - 前端应把审核/控制事件渲染为结构化点和虚线箭头;有 monitor 的 pipeline 中,monitor node 必须固定在甘特图第 1 个 node 列,其余 node 再按上游到下游顺序从左到右排列。 + - `node-long-running-observation` 必须显示从被观察 node 指向 monitor node 的观察连线,不能只在 monitor 列留下孤立事件点;观察来源本身不是一个真实行为,不能在被观察 node 上额外绘制“观察来源”点,点只代表真实收到 prompt、发出管理行为、发出审核结果等事件。 + - 甘特图箭头的末端必须在目标点外回缩约一个箭头长度,箭头尖不能插入 prompt 点、控制点或 monitor 观察点内部,避免把“连线”和“真实行为点”混在一起。 + - 仍处于 `running` 的 node 必须显示从实际开始时间延伸到当前时间的实时执行条,并用明确的闪动/扫描效果标识“仍在执行”;不得把 running node 渲染成只有起始点或 1s 极短条线。 - 点击甘特图中的执行线、prompt 点或控制点后,右侧边栏必须展示结构化事件字段、匹配的 procedure/attempt、以及对应 OpenCode step 的摘要与展开详情,而不是在主界面直接铺 raw JSON、JSONL、worker log 或 control event 文本。 - OpenCode step 展示必须参考 `~/.agents/skills/agent-sessions/scripts/webui/src/components/MessageList.tsx` 的信息组织方式:按消息流时间线展示角色、模型、tokens、创建/完成时间,正文摘要直接可读,思考和工具调用分区折叠,工具调用要显示工具名、状态、输入字段、输出摘要和元数据。 - 右侧边栏中的 OpenCode Step Timeline 必须把公共 session 信息(agent、model、session id)聚合到 session 头部,不得在每个 step 重复;单个 step 的默认摘要只保留时间、消息、工具调用三类信息,统计信息和 tag 必须折叠到展开层。 - 右侧边栏排版必须优先保护横向可读宽度:时间放在 step 顶部 header,而不是单独占用左侧窄列;默认摘要不得引入右侧边栏内部横向滚动条,也不得因为窄列挤压把 step 高度拉得过高。 + - OpenCode Step Timeline 不能使用跨越所有 step 的连续装饰线;相邻 step 之间若存在真实时间空闲区间,例如上一个 step `completedAt` 到下一个 step `createdAt`,该区间必须视觉留白,不能被误渲染为持续执行条线。 - 调整任何高信息密度右侧边栏布局时,都必须把 `总高度` 与 `横向滚动条` 作为显式验收指标,用 Playwright 打开真实页面验证,而不是只看静态代码或本地想象。 - 运行材料只能作为结构化索引行展示计数、状态、时间和来源摘要,完整 JSON、JSONL 或 log tail 只能通过显式 `查看原始JSON` 按钮打开。 - `Pipeline` 渲染与算法验证。 diff --git a/docs/reference/microservices.md b/docs/reference/microservices.md index 1532cf11..bbbe206e 100644 --- a/docs/reference/microservices.md +++ b/docs/reference/microservices.md @@ -7,7 +7,7 @@ UniDesk microservice 是挂载到主 server 控制面的非核心业务后端。 - microservice 后端端口默认只绑定计算节点本机地址,例如 `127.0.0.1:`,不得直接暴露公网。 - 浏览器只访问 UniDesk frontend;frontend 通过同源 `/api/microservices/*` 代理到 backend-core,backend-core 再通过目标 provider-gateway 的 `microservice.http` 能力访问计算节点本机后端。 - backend-core REST API、database 和计算节点 microservice 后端都不得新增公网端口;公网入口仍只有 frontend 和 provider ingress。 -- `microservice.http` 只允许 provider-gateway 访问 `http://127.0.0.1`、`http://localhost`、`http://host.docker.internal` 这类节点本地地址;主 server 内置 microservice 可使用同一 Compose 网络内的显式服务名,例如 `todo-note:4211`。backend-core 还必须用 `allowedPathPrefixes` 和 `allowedMethods` 同时限制可代理路径和 HTTP 方法。 +- `microservice.http` 只允许 provider-gateway 访问 `http://127.0.0.1`、`http://localhost`、`http://host.docker.internal` 这类节点本地地址;主 server 内置 microservice 可使用同一 Compose 网络内的显式服务名,例如 `todo-note:4211` 或 `codex-queue:4222`。backend-core 还必须用 `allowedPathPrefixes` 和 `allowedMethods` 同时限制可代理路径和 HTTP 方法。 ## Config Contract @@ -28,7 +28,7 @@ UniDesk microservice 是挂载到主 server 控制面的非核心业务后端。 ## Main Server Microservices -主 server 只承载对统一入口或状态迁移有明确必要的 microservice。该类服务仍遵守不暴露公网端口、前端统一 React 控件化展示、状态写入主 PostgreSQL 的规则。 +主 server 只承载对统一入口、状态迁移或控制面自动化有明确必要的 microservice。该类服务仍遵守不暴露公网端口、前端统一 React 控件化展示的规则;业务持久状态优先写入主 PostgreSQL,控制队列这类运行态可使用 `.state/` 文件并必须提供 `/logs` 与结构化状态端点。 ### Todo Note On Main Server @@ -48,6 +48,24 @@ Todo Note 首次迁移或源 JSON 修复时,在主 server 通过 Docker 内网 Todo Note 数据迁移后必须验证:`microservice proxy todo-note /api/instances` 至少能看到 `CONSTAR`、`大论文`、`找工作`、`小论文`、`事务` 五个迁移清单,总任务数不低于源数据的 100 条;再通过代理创建临时清单、添加任务、切换完成、撤销并删除临时清单,证明写入路径走 PostgreSQL 且不会污染长期数据。 +### Codex Queue On Main Server + +当前 Codex Queue 作为 `id=codex-queue` 的 microservice 登记在 `config.json`: + +- Provider:`main-server`,由本机 provider-gateway 通过 `microservice.http` 访问同一 Compose 网络内的 `http://codex-queue:4222`。 +- 代码引用:`https://github.com/pikasTech/unidesk` 与配置中的 `repository.commitId`;服务源码位于 `src/components/microservices/codex-queue`,属于 UniDesk 自有控制面组件。 +- 部署引用:UniDesk 根仓库 `docker-compose.yml` 中的 `codex-queue` service,Dockerfile 为 `src/components/microservices/codex-queue/Dockerfile`,容器名为 `codex-queue-backend`。 +- Codex 认证:容器只从主 server 的 `/root/.codex/config.toml` 同步 Codex provider 配置到 `.state/codex-queue/codex-home`,并通过运行时环境透传 `OPENAI_API_KEY`、`CRS_OAI_KEY` 等 provider 所需变量;新增 provider 的 `env_key` 时必须增加同类运行时透传,禁止把 Codex 或 MiniMax 密钥写入仓库文件。 +- Codex 控制:服务内部启动 `codex app-server --listen stdio://`,用 JSON-RPC 调用 `thread/start`、`turn/start`、`turn/steer` 和 `turn/interrupt`,并监听 `turn/completed`、assistant delta、reasoning delta、command output delta、file diff delta 等通知生成前端可轮询的 transcript。 +- 队列语义:`POST /api/tasks` 或 `/api/tasks/batch` 入队,服务始终只运行一个 Codex turn;当前任务真正终止后才推进下一个任务。`GET /api/tasks` 与 `GET /api/tasks/{id}` 返回队列、attempt、judge 和输出;`POST /api/tasks/{id}/steer` 向运行中 turn 推入 prompt;`POST /api/tasks/{id}/interrupt` 或 `DELETE /api/tasks/{id}` 打断/取消;`POST /api/tasks/{id}/retry` 手动重试。 +- 完成判定:app-server `turn/completed` 的 `turn.status=completed|interrupted|failed` 只代表 Codex turn 已结束;即使 `completed` 也必须把原始任务、assistant 最终回复、command/file-change 事件、stderr tail 和 recent events 组成 execution record 交给 judge 判断是否真的完成。配置了 `UNIDESK_CODEX_QUEUE_MINIMAX_API_KEY` 时使用 MiniMax `MiniMax-M2.7` 判定 `complete|retry|fail`,否则使用 fallback 规则。 +- Retry/推进语义:`retry` 不是新开一个独立任务或完全新 session;只要已有 `codexThreadId`,服务必须 `thread/resume` 原 thread 并 append 一个继续执行 prompt。只有 judge 判定 `complete` 后,队列 worker 才把当前任务标为成功并推进下一个 queued/retry_wait 任务。 +- Judge 探针:`GET|POST /api/judge/probe` 使用同一套 judge 逻辑跑内置 synthetic execution records,覆盖正常完成、正常结束但只给计划、传输中断和用户打断四类样本,返回 `hits`、`total`、`hitRate`、每例 `expected` 与 `decision`;该接口不得回显 MiniMax API key。 +- 模型选择:默认 Codex 模型是 `gpt-5.4-mini`,每个入队任务可覆盖 `model`、`cwd`、`reasoningEffort` 和 `maxAttempts`。 +- 状态与日志:队列状态保存在 `.state/codex-queue/state.json` 对应的容器挂载路径,日志写入 UniDesk `logs/{YYYYMMDD}/..._codex-queue.jsonl`,`/logs` 端点返回最近结构化日志。 +- 代理路径:只允许 `/health`、`/logs` 和 `/api/` 前缀;允许方法为 `GET`、`HEAD`、`POST`、`DELETE`。Codex Queue 只在 Compose 内网暴露 `4222/tcp`,不得映射或开放到公网。 +- UniDesk 前端:`微服务 / Codex Queue` React 页面负责展示队列卡片、默认模型、MiniMax judge 状态、Codex CLI-like 输出流、attempt 终态、追加 prompt、打断和手动重试控件;原始任务 JSON 只能通过显式 `查看原始JSON` 打开。 + ## D601 Microservices 当前 `D601` 同时承载以下 UniDesk microservice: @@ -77,12 +95,12 @@ FindJob 在 UniDesk 语境中按纯后端服务管理:默认页面不得 ifram - Provider:`D601`。 - 开发工作树:`/home/ubuntu/pipeline`,开发和调试必须通过 UniDesk SSH 透传进入 D601。 - 代码引用:`https://github.com/pikasTech/pipeline` 与配置中的 `repository.commitId`。 -- 部署引用:业务仓库自身 `Dockerfile`、`docker-compose.yml`、`composeService=pipeline-webui`、`containerName=pipeline-v2-webui`。 +- 部署引用:业务仓库自身 `Dockerfile`、`docker-compose.yml`、`composeService=pipeline-control`、`containerName=pipeline-v2-control`。 - 节点后端:D601 上 `127.0.0.1:18082`,provider-gateway 容器内通过 `http://host.docker.internal:18082` 访问。 -- 代理路径:只允许 `/health` 和 `/api/` 前缀;允许方法为 `GET`、`HEAD`、`POST`,其中 `POST` 仅用于 `/api/node-control/...` 这类 node 控制动作;Pipeline 自身 WebUI 静态页面即使仍由 `pipeline-webui` 提供,也不作为 UniDesk microservice 入口使用。 +- 代理路径:只允许 `/health` 和 `/api/` 前缀;允许方法为 `GET`、`HEAD`、`POST`,其中 `POST` 仅用于 `/api/node-control/...` 这类 node 控制动作;Pipeline 自身 WebUI 前端已废弃,UniDesk 只访问 Pipeline control backend。 - UniDesk 前端:`微服务 / Pipeline` React 页面负责展示 health、组件数量、React Flow pipeline 控制图框图、epoch 列表、epoch 甘特图、OA/procedure 结构化摘要、运行材料索引、点击 node 后的执行过程抓取、append prompt、guide 和 redo/restart 控件,以及显式原始 JSON 按钮。 -Pipeline 在 UniDesk 语境中按控制与观测后端服务管理:默认页面不得 iframe 或跳转到 Pipeline 自身 WebUI,也不得直接暴露 D601 的 `18082` 到公网。UniDesk frontend 只能通过 `/api/microservices/pipeline/health`、`/api/microservices/pipeline/proxy/api/snapshot?...` 和 `/api/microservices/pipeline/proxy/api/node-control/...` 访问 Pipeline 后端;超大 snapshot 必须使用 `__unideskArrayLimit=registry.components:,runs:` 做展示级裁剪。node 控制入口必须走 Pipeline 后端 HTTP API,前端不得直接写 `.state`、runner prompt 文件或命令队列。 +Pipeline 在 UniDesk 语境中按控制与观测后端服务管理:默认页面不得 iframe 或跳转到 Pipeline 自身 WebUI,也不得直接暴露 D601 的 `18082` 到公网。UniDesk frontend 只能通过 `/api/microservices/pipeline/health`、`/api/microservices/pipeline/proxy/api/snapshot?...` 和 `/api/microservices/pipeline/proxy/api/node-control/...` 访问 Pipeline control backend;超大 snapshot 必须使用 `__unideskArrayLimit=registry.components:,runs:` 做展示级裁剪。node 控制入口必须走 Pipeline 后端 OA control API,前端不得直接写 `.state`、runner prompt 文件或命令队列;scorer 结果必须在 UniDesk Pipeline UI 中以结构化 score 卡片展示。Pipeline 控制与观测的最终态必须 100% 由 OA 事件流驱动,不得保留点对点控制、旧审核事件或旧 batch 推进逻辑,权威规则见 `docs/reference/pipeline-oa-event-flow.md`。 Pipeline 的一个 epoch 是同一个 pipeline 从入口到终态完整执行一遍,UniDesk 前端把同一 `pipelineId` 下的多个 run 作为多个 epoch 管理。甘特图必须从 Pipeline snapshot 中的 `startedAt`、`finishedAt`、`durationMs` 和 procedure run 摘要生成,不得按某个 pipeline 实例或 node 名称硬编码布局。甘特图纵轴按时间从上到下排列,左侧固定时间列,后续每列对应一个 node;当前可见时间窗口内没有工作区间的 node 列应自动隐藏。默认页面不得展示裸 JSON、JSONL、worker log 或 control event 行;运行材料和 node 过程只能作为一组一行的结构化索引展示,完整原始内容只能在操作员点击 `查看原始JSON` 后显示。 @@ -116,13 +134,14 @@ MET Nonlinear 验收必须通过公网 UniDesk frontend 的交互式 UI 完成 - `bun scripts/cli.ts microservice proxy met-nonlinear /api/queue` 与 `bun scripts/cli.ts microservice proxy met-nonlinear /api/images`:读取 MET Nonlinear 队列、GPU 策略和训练镜像状态,适合人工验证,不用于公开业务端口。 - `bun scripts/cli.ts microservice proxy met-nonlinear '/api/projects?root=projects&limit=500'` 与 `bun scripts/cli.ts microservice proxy met-nonlinear '/api/projects/config?path=projects/' --raw`:验证项目库文件树输入和结构化项目详情;详情应包含 config、progress、data、model、metrics 字段,供前端渲染训练状态、模型参数量和指标。 - `bun scripts/cli.ts microservice health todo-note` 与 `bun scripts/cli.ts microservice proxy todo-note /api/instances`:验证主 server Todo Note 后端、PostgreSQL 存储和本机 provider-gateway 私有代理链路。 +- `bun scripts/cli.ts microservice health codex-queue` 与 `bun scripts/cli.ts microservice proxy codex-queue /api/tasks`:验证主 server Codex Queue 后端、队列状态文件和本机 provider-gateway 私有代理链路;写入、追加 prompt 和打断由 frontend 同源代理或直接 HTTP API 发起。 - `bun scripts/cli.ts --main-server-ip 74.48.78.17 microservice health findjob`:在计算节点或其他非主 server 主机上通过公网 frontend remote CLI 进行同一验证,不需要主 server SSH key。 `debug dispatch D601 microservice.http --payload-json ...` 仅用于开发调试 provider-gateway 代理能力;正式验收和用户入口应优先使用 `microservice` 命令与 frontend 页面。 ## Frontend Rules -microservice 前端必须整合到 `src/components/frontend/src/` 下的 TypeScript + React 模块中。`app.tsx` 只做 shell/router 和导入分发,业务页面必须拆成独立 TSX,例如 `todo-note.tsx`、`findjob.tsx`、`pipeline.tsx`。默认展示必须是业务控件:指标卡、状态徽标、表格、草稿卡片、运行卡片、树形任务、表单控件、结构化材料索引、链接和字段摘要;只有操作员点击 `查看原始JSON` 时才允许打开原始 JSON 弹窗。日志、JSONL 和大块 JSON 不得在主界面按行展示,避免把裸数据伪装成 UI。 +microservice 前端必须整合到 `src/components/frontend/src/` 下的 TypeScript + React 模块中。`app.tsx` 只做 shell/router 和导入分发,业务页面必须拆成独立 TSX,例如 `todo-note.tsx`、`findjob.tsx`、`pipeline.tsx`、`met-nonlinear.tsx`、`codex-queue.tsx`。默认展示必须是业务控件:指标卡、状态徽标、表格、草稿卡片、运行卡片、树形任务、表单控件、结构化材料索引、链接和字段摘要;只有操作员点击 `查看原始JSON` 时才允许打开原始 JSON 弹窗。日志、JSONL 和大块 JSON 不得在主界面按行展示,避免把裸数据伪装成 UI。 对于超大业务 JSON,backend-core 可把 `__unideskArrayLimit=:` 作为 frontend-only 代理参数传给 provider-gateway,由 provider-gateway 在返回前裁剪指定 JSON 数组并写入 `_unidesk.arrayLimits` 元数据。该参数只用于控制 UniDesk 展示预览,不能替代业务后端自身分页 API 的长期设计。CLI 的 `microservice proxy` 还会对超过默认阈值的 body 做二次有界预览,防止人工验证时输出爆炸;只有显式 `--raw` 才允许倾倒完整 body。 @@ -131,12 +150,14 @@ microservice 前端必须整合到 `src/components/frontend/src/` 下的 TypeScr microservice 交付必须同时通过后端、CLI 和公网 frontend 验证: - 在主 server 运行 `bun scripts/cli.ts microservice list`,确认 `findjob` 的 `providerId=D601`、`public=false`、`frontendOnly=true`、仓库 URL、commit id、`127.0.0.1:3254` 映射和 `findjob-server` 容器摘要可见。 -- 在主 server 运行 `bun scripts/cli.ts microservice list`,确认 `pipeline` 的 `providerId=D601`、`public=false`、`frontendOnly=true`、仓库 URL、commit id、`127.0.0.1:18082` 映射和 `pipeline-v2-webui` 容器摘要可见。 +- 在主 server 运行 `bun scripts/cli.ts microservice list`,确认 `pipeline` 的 `providerId=D601`、`public=false`、`frontendOnly=true`、仓库 URL、commit id、`127.0.0.1:18082` 映射和 `pipeline-v2-control` 容器摘要可见。 - 在主 server 运行 `bun scripts/cli.ts microservice list`,确认 `met-nonlinear` 的 `providerId=D601`、`public=false`、`frontendOnly=true`、仓库 URL、commit id、`127.0.0.1:3288` 映射和 `met-nonlinear-ts` 容器摘要可见。 +- 在主 server 运行 `bun scripts/cli.ts microservice list`,确认 `codex-queue` 的 `providerId=main-server`、`public=false`、`frontendOnly=true`、UniDesk 仓库 URL、`codex-queue:4222` 映射和 `codex-queue-backend` 容器摘要可见。 - 运行 `bun scripts/cli.ts microservice health findjob` 与 `bun scripts/cli.ts microservice proxy findjob /api/summary`,确认真实链路经过 backend-core、WebSocket、D601 provider-gateway 和 D601 本机 FindJob 后端。 - 运行 `bun scripts/cli.ts microservice health pipeline` 与 `bun scripts/cli.ts microservice proxy pipeline '/api/snapshot?__unideskArrayLimit=registry.components:8,runs:3'`,确认真实链路经过 backend-core、WebSocket、D601 provider-gateway 和 D601 本机 Pipeline 后端,且 run/procedure 摘要包含甘特图所需时间字段。 - 运行 `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=20'` 和 `bun scripts/cli.ts microservice proxy met-nonlinear /api/images`,确认真实链路经过 backend-core、WebSocket、D601 provider-gateway 和 D601 本机 MET Nonlinear TS 后端。 - 运行 `bun scripts/cli.ts microservice health todo-note` 与 `bun scripts/cli.ts microservice proxy todo-note /api/instances`,确认真实链路经过 backend-core、WebSocket、main-server provider-gateway 和主 server `todo-note-backend` 后端;输出中必须包含五个迁移清单和 PostgreSQL 存储健康状态。 +- 运行 `bun scripts/cli.ts microservice health codex-queue` 与 `bun scripts/cli.ts microservice proxy codex-queue /api/tasks`,确认真实链路经过 backend-core、WebSocket、main-server provider-gateway 和主 server `codex-queue-backend` 后端;再通过 frontend 提交一个 `gpt-5.4-mini` 小任务,确认队列串行推进、输出实时更新、结束后有 judge 判定,且运行中可追加 prompt 或打断。 - 在 D601 上用 `bun scripts/cli.ts ssh D601 ...` 调试业务仓库和容器,确认 `curl http://127.0.0.1:3254/api/health` 可用;不要把调试服务部署到主 server。 - 在 D601 上用 `bun scripts/cli.ts ssh D601 ...` 调试业务仓库和容器,确认 `curl http://127.0.0.1:18082/health` 和 `curl http://127.0.0.1:18082/api/snapshot` 可用;不要把 Pipeline 调试服务部署到主 server。 - 在 D601 上用 `bun scripts/cli.ts ssh D601 ...` 调试 `~/met_nonlinear`,确认 `curl http://127.0.0.1:3288/health` 可用;最终验收必须回到公网 UniDesk frontend,通过项目库选择、Fork、加入待启动队列和启动队列完成,不要把 MET Nonlinear 后端、Docker build 或训练任务部署到主 server。 diff --git a/docs/reference/pipeline-oa-event-flow.md b/docs/reference/pipeline-oa-event-flow.md new file mode 100644 index 00000000..c0f5a122 --- /dev/null +++ b/docs/reference/pipeline-oa-event-flow.md @@ -0,0 +1,69 @@ +# Pipeline OA Event Flow + +Pipeline 的最终控制模型必须是 100% OA 事件流驱动。开发过程可以分阶段迁移,但交付态不能保留中间态、双写语义、点对点控制通道或依赖旧事件的永久兜底;任何阶段性兼容只能作为局部迁移手段,必须在同一重构闭环内被删除并由 E2E 证明已退出。 + +## Core Principles + +- OA backend 是 Pipeline runtime 的唯一控制总线;node、monitor、runner、UniDesk frontend 和 CLI 都只能通过 OA 事件表达事实、订阅事实或发出控制意图。 +- Node 之间不得直接启动、暂停、重试或通知彼此;monitor 不得绕过 OA 直接控制 runner;frontend 不得直接写 Pipeline `.state`、prompt 文件、命令队列或 worker 状态文件。 +- 事实事件只描述已经发生的事实,策略判断由 OA 加载 Pipeline config/topology 后完成;`node-finished` 不得携带 `legacy audit policy flag`、`nextNodeIds`、`shouldStartDownstream` 这类控制决策字段。 +- 控制行为必须由 OA 控制/管理事件表达,例如 start downstream、pause、resume、single-step、append prompt、guide、modify、approve、redo/restart、skip、cancel 和 retry。 +- UI、CLI、E2E 和调试工具都应消费 OA 归一化读接口或通过 OA 控制 API 写入事件,不得解析 monitor 文本、worker log、runner 私有文件或旧 JSONL 文件来反推控制语义。 +- 旧的本地文件传输通道必须完全退出控制路径:不得创建、轮询或依赖 `control-prompts.jsonl`、`monitor-prompts.jsonl`、`monitor-control`、`control-events.jsonl`、monitor stop 文件、`.state/pipeline-runs/{runId}/control/commands/` 或给 monitor 容器挂载可读 `/pipeline-state` 来完成 prompt、stop、guide、approve、redo/restart、append 或 UI/Gantt 事件取证。 + +## Event Identity And Tags + +- 每条事件必须有稳定 `eventId`、`type`、`createdAt`、`sourceKind`、`sourceId`、`correlationId` 和可选 `causationId`,用于排序、幂等、回放和追踪控制来源。 +- Pipeline 后端必须自动附加 scope tag:`pipeline:{pipelineId}`、`epoch:{runId}`,node 相关事件还必须带 `node:{nodeId}`;attempt/procedure 相关事件应带 `attempt:{attemptId}` 或 `procedure:{procedureRunId}`。 +- Monitor 在 config 中只声明要订阅的业务 tag 或事件类型,OA backend 在订阅时自动追加当前 `pipeline:{pipelineId}` 与 `epoch:{runId}` 限制,避免不同 pipeline 或 epoch 串流。 +- 一次 epoch 应绑定 config snapshot 或 config version;OA 的审核、拓扑推进、并发和单步策略都以该快照为准,避免运行中配置漂移导致控制解释不一致。 +- 控制事件必须有幂等键,重复投递同一个 start、approve、redo/restart 或 append prompt 时,OA 应识别为同一意图而不是重复启动或重复写入。 + +## Fact Events + +- `node-started`、`node-finished`、`node-failed`、`node-cancelled`、`node-long-running-observation` 等事实事件用于记录运行状态,不承担流程推进决策。 +- `node-finished` 是中性完成事实,包含 node id、procedure run id、status、timestamps、outputs summary 和诊断摘要;它不描述是否需要审核,也不直接声明下游节点。 +- 长任务观察事件只在 node 未进入终态时触发,默认节奏为 2、5、10、20 分钟,之后每 20 分钟循环;固定 30 秒 monitor 周期不得作为正式机制保留。 +- `legacy batch completion gate` 只能作为派生统计或兼容只读事件,不能作为流程推进门禁;下游推进由每个 `node-finished` 加 OA config/topology 判断完成。 +- 如果 node 完成后需要 monitor 审核,UI 中仍只应保留这一条 `node-finished` 作为完成/等待审核的事实点,不再创建独立的 `legacy node audit request event` 或 `legacy monitor audit request event` 事实点。 + +## No-Audit Flow + +1. Runner 观察到 node 进入终态,并把中性 `node-finished` 事实推入 OA backend。 +2. OA backend 记录事件,加载该 epoch 的 config snapshot 和 topology。 +3. OA 判定该 node 不需要 monitor 审核,且下游依赖已满足。 +4. OA 产生下游 `start-node` 控制事件。 +5. Runner 只消费 OA 控制事件来启动下游 node,并把启动结果再写回 OA 事件流。 + +## Audit Flow + +1. Runner 把中性 `node-finished` 事实推入 OA backend。 +2. OA backend 记录事件,并按 config snapshot 判定该 node completion 需要 monitor 审核。 +3. Monitor 通过 OA 订阅收到同一 scope 下的 `node-finished` 事实和 prompt;是否需要审核来自 OA policy,不来自 `node-finished.detail`。 +4. Monitor 通过 OA skill 或等价 OA 控制 API 发出 `approve`、`modify`、`redo/restart`、`reject` 等管理事件。 +5. OA backend 记录 monitor 决策,按 config/topology 产生 start downstream、reset downstream、restart target 或 stop/pause 等后续控制事件。 +6. Runner 只消费 OA 控制事件执行后续动作,执行结果继续回写 OA 事件流。 + +## Single-Step And Debug Flow + +- 单步运行、人工放行、暂停、恢复、重试、跳过和取消都必须建模为 OA 控制事件;不得通过调试 HTTP 端点直接修改 runner 内存状态。 +- CLI 和 UniDesk frontend 的 node 精细控制面板只能调用 Pipeline 后端的 OA 控制 API;后端可保留 `node-control` 路由名,但路由内部必须写入 OA 事件并由 OA dispatcher 执行。 +- Debug 工具可以读取事件流、config snapshot 和归一化状态机结果,但不得维护另一套流程推进逻辑。 + +## Rendering Contract + +- UniDesk Pipeline UI 只消费 Pipeline 后端根据 OA 事件流生成的 snapshot、Gantt DTO、node detail 和 control history,不直接消费旧 monitor append 文件语义。 +- 甘特图中的执行条、prompt 点、monitor 决策点、长任务观察箭头和人工控制箭头都必须来自 OA 事件或 OA 派生 DTO。 +- 有 monitor 的 pipeline 中,monitor node 固定在甘特图第 1 列,其余 node 再按拓扑上游到下游向右排列。 +- `node-long-running-observation` 必须画从被观察 node 到 monitor node 的观察连线;被观察 node 上不得额外绘制“观察来源”点,因为点只代表真实行为。 +- Running node 必须显示从实际开始时间延伸到当前时间的实时闪动执行条;相邻 OpenCode step 之间的真实空闲区间必须留白,不能被连续装饰线误渲染为执行。 +- 审核开启时不得同时渲染 `node-finished` 和独立 audit-request 点;node completion 是唯一完成/等待审核点,monitor 的 approve/modify/redo/restart 是后续独立控制点。 + +## Migration And Completion Gates + +- 分阶段重构只允许按“schema 文档与测试门禁 -> OA 事件写入 -> OA dispatcher 接管控制 -> UI/CLI 改为 OA 读写 -> 删除旧事件和旧控制路径”的方向前进,不得把双写/双读作为最终设计。 +- 完成态必须删除或禁用以下残留:`legacy detail audit policy flag` 作为权威策略字段、`legacy node audit request event` / `legacy monitor audit request event` 独立审核请求事件、`legacy batch completion gate` 控制推进、monitor 直接调用 runner、node 直接启动下游、frontend/CLI 直接写 `.state` 文件。 +- 完成态还必须删除文件传输残留:`PIPELINE_MONITOR_APPEND_FILE`、`PIPELINE_MONITOR_STOP_FILE`、`PIPELINE_MONITOR_CONTROL_DIR`、`PIPELINE_NODE_PROMPT_APPEND_FILE`、`control-events.jsonl`、`runControlEventFile`、`appendJsonLine`、`readJsonLineRecords`、`readPromptAppendFile`、`parseCommandFile`、`parseMonitorCommandFile`、`readMonitorCommands`、`fallbackMonitorInterventions`、`syntheticMonitorStopPrompt` 以及 monitor 的 `/pipeline-state` 挂载都不得出现在运行代码中。 +- E2E 必须覆盖有审核与无审核两条链路:无审核时由 OA 从 `node-finished` 推进下游;有审核时由 monitor 经 OA 控制事件推进下游;事件都必须带 `pipeline:{pipelineId}` 与 `epoch:{runId}` tag。 +- Pipeline 甘特图 E2E 必须证明没有重复审核点、长任务观察有连线且无来源伪点、running node 有实时条、OpenCode step 空闲区间留白、控制箭头来自 OA 控制事件。 +- 代码检查应加入防回归搜索或等价单元测试,防止重新引入 `legacy audit policy flag` 权威字段、旧 audit-request 事件、`legacy batch completion gate` 推进逻辑或非 OA 控制写路径。 diff --git a/docs/reference/provider-gateway.md b/docs/reference/provider-gateway.md index 59a0531c..de62fa20 100644 --- a/docs/reference/provider-gateway.md +++ b/docs/reference/provider-gateway.md @@ -80,7 +80,7 @@ provider ingress 是唯一允许公网暴露的 provider 连接接口,当前 ## Microservice HTTP Proxy -`microservice.http` 是 provider-gateway 给 UniDesk microservice 使用的私有后端访问能力。backend-core 通过真实 WebSocket dispatch 下发目标 service id、节点本机 `targetBaseUrl`、path、query、method、request body、timeout 和可选 JSON 数组裁剪参数;provider-gateway 支持 `GET`、`HEAD`、`POST`、`PUT`、`PATCH`、`DELETE`,但最终允许方法必须由每个 microservice 的 `backend.allowedMethods` 显式配置。provider-gateway 只允许访问 `http://127.0.0.1`、`http://localhost`、`http://host.docker.internal` 这些节点本地地址;主 server 内置 Todo Note 后端可使用 Compose 服务名 `http://todo-note:4211`。该能力不打开 provider-gateway 入站端口,也不替代业务仓库自身 Dockerfile/docker-compose。 +`microservice.http` 是 provider-gateway 给 UniDesk microservice 使用的私有后端访问能力。backend-core 通过真实 WebSocket dispatch 下发目标 service id、节点本机 `targetBaseUrl`、path、query、method、request body、timeout 和可选 JSON 数组裁剪参数;provider-gateway 支持 `GET`、`HEAD`、`POST`、`PUT`、`PATCH`、`DELETE`,但最终允许方法必须由每个 microservice 的 `backend.allowedMethods` 显式配置。provider-gateway 只允许访问 `http://127.0.0.1`、`http://localhost`、`http://host.docker.internal` 这些节点本地地址;主 server 内置 Todo Note 与 Codex Queue 后端可分别使用 Compose 服务名 `http://todo-note:4211` 与 `http://codex-queue:4222`。该能力不打开 provider-gateway 入站端口,也不替代业务仓库自身 Dockerfile/docker-compose。 超大 JSON 响应可以使用 `jsonArrayLimits` 在 provider-gateway 返回前裁剪指定数组,并在响应体中写入 `_unidesk.arrayLimits` 元数据,便于 UniDesk frontend 预览列表而不展示裸 JSON。长期应优先推动业务后端提供分页 API;裁剪只是 UniDesk 集成层的展示保护。 diff --git a/scripts/cli.ts b/scripts/cli.ts index 06bd368b..f8b3ad20 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -26,7 +26,7 @@ function help(): unknown { { command: "server stop", description: "Fire-and-forget docker-compose down for the fixed UniDesk stack." }, { command: "server status", description: "Show fixed ports, containers, service health, and public URLs." }, { command: "server logs [--tail-bytes N]", description: "Return bounded tails from file logs and docker logs." }, - { command: "server rebuild ", description: "Build first, then label-replace one service without Docker Compose v1 recreate fallback." }, + { command: "server rebuild ", description: "Build first, then label-replace one service without Docker Compose v1 recreate fallback." }, { command: "ssh [ssh-like args...]", description: "Open a Host SSH / WSL SSH maintenance session through the provider-gateway bridge with built-in remote helper tools in PATH." }, { command: "ssh apply-patch [tool args...] < patch.diff", description: "Invoke the injected remote apply_patch helper directly over SSH passthrough and stream the patch from local stdin." }, { command: "ssh py [script-args...] < script.py", description: "Run remote Python from local stdin through SSH passthrough without nested shell quoting; extra args become script argv." }, @@ -154,7 +154,7 @@ async function main(): Promise { } if (sub === "rebuild") { if (!isRebuildableService(third)) { - throw new Error("server rebuild requires one of: backend-core, frontend, provider-gateway, todo-note"); + throw new Error("server rebuild requires one of: backend-core, frontend, provider-gateway, todo-note, codex-queue"); } emitJson(commandName, rebuildService(config, third)); return; diff --git a/scripts/src/docker.ts b/scripts/src/docker.ts index 0e5ec042..7c353ae2 100644 --- a/scripts/src/docker.ts +++ b/scripts/src/docker.ts @@ -18,7 +18,7 @@ export interface ContainerStatus { ports: string; } -const rebuildableServices = ["backend-core", "frontend", "provider-gateway", "todo-note"] as const; +const rebuildableServices = ["backend-core", "frontend", "provider-gateway", "todo-note", "codex-queue"] as const; export type RebuildableService = typeof rebuildableServices[number]; export function isRebuildableService(value: string | undefined): value is RebuildableService { @@ -270,6 +270,7 @@ export async function stackStatus(config: UniDeskConfig): Promise { internalPorts: [ { name: "backend-core", containerPort: config.network.core.containerPort, hostPort: null }, { name: "database", containerPort: config.network.database.containerPort, hostPort: null }, + { name: "codex-queue", containerPort: 4222, hostPort: null }, ], containers: dockerContainers(config), health: { @@ -307,7 +308,7 @@ export function stackLogs(config: UniDeskConfig, tailBytes: number): unknown { const currentFiles = allFiles.filter((path) => basename(path).startsWith(runtimeEnv.logPrefix)); const selectedFiles = (currentFiles.length > 0 ? currentFiles : allFiles.slice(-12)).slice(-12); const files = selectedFiles.map((path) => ({ path, name: basename(path), tail: tailFile(path, tailBytes) })); - const containerNames = ["unidesk-database", "unidesk-backend-core", "unidesk-frontend", "unidesk-provider-gateway-main", "todo-note-backend"]; + const containerNames = ["unidesk-database", "unidesk-backend-core", "unidesk-frontend", "unidesk-provider-gateway-main", "todo-note-backend", "codex-queue-backend"]; const docker = containerNames.map((name) => { const result = runCommand(["docker", "logs", "--tail", "40", name], repoRoot); return { name, exitCode: result.exitCode, stdoutTail: result.stdout.slice(-tailBytes), stderrTail: result.stderr.slice(-tailBytes) }; diff --git a/scripts/src/e2e.ts b/scripts/src/e2e.ts index 6aa1e6c2..70ee99e2 100644 --- a/scripts/src/e2e.ts +++ b/scripts/src/e2e.ts @@ -35,6 +35,7 @@ const NETWORK_CHECK_NAMES = [ "network:findjob-public-blocked", "network:met-nonlinear-public-blocked", "network:todo-note-public-blocked", + "network:codex-queue-public-blocked", ] as const; const SERVICE_CHECK_NAMES = [ @@ -51,6 +52,7 @@ const SERVICE_CHECK_NAMES = [ "microservice:catalog-pipeline", "microservice:catalog-met-nonlinear", "microservice:catalog-todo-note", + "microservice:catalog-codex-queue", "microservice:findjob-status", "microservice:findjob-health", "microservice:findjob-summary", @@ -58,6 +60,7 @@ const SERVICE_CHECK_NAMES = [ "microservice:pipeline-status", "microservice:pipeline-health", "microservice:pipeline-snapshot", + "microservice:pipeline-oa-event-flow", "microservice:met-nonlinear-status", "microservice:met-nonlinear-health", "microservice:met-nonlinear-queue", @@ -67,6 +70,9 @@ const SERVICE_CHECK_NAMES = [ "microservice:todo-note-health", "microservice:todo-note-migrated-data", "microservice:todo-note-write-path", + "microservice:codex-queue-status", + "microservice:codex-queue-health", + "microservice:codex-queue-tasks", ] as const; const DATABASE_CHECK_NAMES = [ @@ -96,11 +102,15 @@ const FRONTEND_CHECK_NAMES = [ "frontend:microservice-catalog-visible", "frontend:todo-note-integrated-visible", "frontend:findjob-integrated-visible", + "frontend:codex-queue-integrated-visible", "frontend:url-route-deeplink", "frontend:pipeline-integrated-visible", "frontend:pipeline-react-flow-visible", "frontend:pipeline-gantt-defaults", + "frontend:pipeline-gantt-export", + "frontend:pipeline-gantt-observation-live-running", "frontend:pipeline-step-timeline-visible", + "frontend:pipeline-oa-event-flow-visible", "frontend:met-nonlinear-integrated-visible", "frontend:met-nonlinear-project-tree-detail", "frontend:met-nonlinear-queue-detail-speed", @@ -157,17 +167,18 @@ function pipelineSnapshotNodeIds(value: any): string[] { function pipelineSnapshotEdges(pipeline: any): Array<{ source: string; target: string; edgeType: string }> { const config = pipelineSnapshotConfigObject(pipeline); const rawEdges = Array.isArray(config.edges) ? config.edges : Array.isArray(pipeline?.edges) ? pipeline.edges : []; - return rawEdges.map((edge: any) => ({ + const edges: Array<{ source: string; target: string; edgeType: string }> = rawEdges.map((edge: any) => ({ source: String(edge?.from || edge?.source || ""), target: String(edge?.to || edge?.target || ""), edgeType: String(edge?.edgeType || ""), - })).filter((edge) => edge.source && edge.target); + })); + return edges.filter((edge) => edge.source.length > 0 && edge.target.length > 0); } function pipelineSnapshotNodes(pipeline: any): string[] { const config = pipelineSnapshotConfigObject(pipeline); const rawNodes = Array.isArray(config.nodes) ? config.nodes : Array.isArray(pipeline?.nodes) ? pipeline.nodes : []; - const nodeIds = new Set(rawNodes.map((node: any) => String(node?.id || node?.nodeId || "")).filter(Boolean)); + const nodeIds = new Set(rawNodes.map((node: any) => String(node?.id || node?.nodeId || "")).filter((nodeId: string) => nodeId.length > 0)); const rawBatches = Array.isArray(config.topologicalBatches) ? config.topologicalBatches : Array.isArray(pipeline?.topologicalBatches) ? pipeline.topologicalBatches : []; for (const batch of rawBatches) pipelineSnapshotNodeIds(batch).forEach((nodeId) => nodeIds.add(nodeId)); for (const edge of pipelineSnapshotEdges(pipeline)) { @@ -177,11 +188,33 @@ function pipelineSnapshotNodes(pipeline: any): string[] { return Array.from(nodeIds); } +function pipelineSnapshotMonitorNodeIds(pipeline: any): string[] { + const config = pipelineSnapshotConfigObject(pipeline); + const rawNodes = Array.isArray(config.nodes) ? config.nodes : Array.isArray(pipeline?.nodes) ? pipeline.nodes : []; + return rawNodes.map((node: any) => { + const nodeId = String(node?.id || node?.nodeId || ""); + const kind = String(node?.kind || "").toLowerCase(); + const monitor = node?.instanceInputs?.monitor; + const componentRef = node?.componentRef || {}; + const componentId = String(componentRef?.id || componentRef?.componentClass || ""); + const isMonitor = kind === "procedure" + && (node?.instanceInputs?.monitorMode === true || monitor?.enabled === true || componentId.toLowerCase().includes("monitor")); + return isMonitor ? nodeId : ""; + }).filter(Boolean); +} + +function pipelineOrderWithLeadingMonitors(nodeIds: string[], monitorNodeIds: string[]): string[] { + const monitorSet = new Set(monitorNodeIds); + const leading = monitorNodeIds.filter((nodeId) => nodeIds.includes(nodeId)); + return leading.length === 0 ? nodeIds : [...leading, ...nodeIds.filter((nodeId) => !monitorSet.has(nodeId))]; +} + function pipelineSnapshotNodeOrder(pipeline: any): string[] { const config = pipelineSnapshotConfigObject(pipeline); const rawBatches = Array.isArray(config.topologicalBatches) ? config.topologicalBatches : Array.isArray(pipeline?.topologicalBatches) ? pipeline.topologicalBatches : []; const explicit = rawBatches.map((batch: any) => pipelineSnapshotNodeIds(batch)).filter((batch: string[]) => batch.length > 0); - if (explicit.length > 0) return explicit.flatMap((batch: string[]) => batch); + const monitorNodeIds = pipelineSnapshotMonitorNodeIds(pipeline); + if (explicit.length > 0) return pipelineOrderWithLeadingMonitors(explicit.flatMap((batch: string[]) => batch), monitorNodeIds); const nodeIds = pipelineSnapshotNodes(pipeline); const idSet = new Set(nodeIds); @@ -206,7 +239,7 @@ function pipelineSnapshotNodeOrder(pipeline: any): string[] { } nodeIds.forEach((nodeId) => { if (!levels.has(nodeId)) levels.set(nodeId, 0); }); const maxLevel = Math.max(0, ...Array.from(levels.values())); - return Array.from({ length: maxLevel + 1 }, (_item, level) => nodeIds.filter((nodeId) => levels.get(nodeId) === level)).flatMap((batch) => batch); + return pipelineOrderWithLeadingMonitors(Array.from({ length: maxLevel + 1 }, (_item, level) => nodeIds.filter((nodeId) => levels.get(nodeId) === level)).flatMap((batch) => batch), monitorNodeIds); } function escapedPatternRegex(value: string): string { @@ -363,6 +396,78 @@ function dockerCoreJson(path: string, init?: { method?: string; body?: unknown } } } +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +function pipelineDetailHasLiveObservation(detail: unknown): boolean { + const body = (detail as { body?: { gantt?: { markers?: unknown[]; intervals?: unknown[] } } }).body; + const markers = Array.isArray(body?.gantt?.markers) ? body.gantt.markers : []; + const intervals = Array.isArray(body?.gantt?.intervals) ? body.gantt.intervals : []; + const hasObservation = markers.some((marker: any) => + String(marker?.promptEvent || marker?.event || "").toLowerCase() === "node-long-running-observation" + && String(marker?.sourceNodeId || "").length > 0 + && String(marker?.nodeId || "").length > 0 + && String(marker?.sourceNodeId || "") !== String(marker?.nodeId || "")); + const hasRunning = intervals.some((interval: any) => String(interval?.status || "").toLowerCase() === "running"); + return hasObservation && hasRunning; +} + +function findPipelineLiveObservationCandidate(): { ok: boolean; candidate: { pipelineId: string; runId: string } | null; latest?: unknown } { + const snapshot = dockerCoreJson("/api/microservices/pipeline/proxy/api/snapshot?__unideskArrayLimit=registry.components:2,runs:30"); + const runs = (snapshot as { body?: { runs?: Array<{ runId?: string; pipelineId?: string }> } }).body?.runs ?? []; + let latest: unknown = snapshot; + for (const run of runs) { + const runId = String(run?.runId || ""); + if (!runId) continue; + const detail = dockerCoreJson(`/api/microservices/pipeline/proxy/api/node-control/runs/${encodeURIComponent(runId)}?tail=180&view=gantt&scale=77&_=${Date.now()}`); + latest = detail; + if (pipelineDetailHasLiveObservation(detail)) { + return { ok: true, candidate: { pipelineId: String(run?.pipelineId || ""), runId }, latest: detail }; + } + } + return { ok: false, candidate: null, latest }; +} + +function startPipelineLiveObservationRun(): { ok: boolean; runId: string; command: string[]; exitCode: number | null; stdout: string; stderr: string } { + const runId = `e2e_observe_live_${Date.now()}`; + const task = "UniDesk E2E live Gantt verification: keep a real node running long enough to receive OA long-running observation events."; + const startCommand = [ + "npx tsx scripts/pipeline-cli.ts pipeline start", + "--id monitor-management-behavior-test", + `--run-id ${shellQuote(runId)}`, + "--timeout-ms 1200000", + `--task ${shellQuote(task)}`, + ].join(" "); + const remoteCommand = `cd /home/ubuntu/pipeline && ${startCommand}`; + const command = ["bun", "scripts/cli.ts", "ssh", "D601", remoteCommand]; + const result = runCommand(command, repoRoot); + return { + ok: result.exitCode === 0, + runId, + command, + exitCode: result.exitCode, + stdout: result.stdout.slice(-4000), + stderr: result.stderr.slice(-4000), + }; +} + +async function ensurePipelineLiveObservationCandidate(): Promise { + const existing = findPipelineLiveObservationCandidate(); + if (existing.candidate) return { ...existing, ok: true, reused: true }; + const started = startPipelineLiveObservationRun(); + if (!started.ok) return { ok: false, stage: "start", started, existing }; + const startedAt = Date.now(); + let latest: unknown = null; + while (Date.now() - startedAt < 240_000) { + const candidate = findPipelineLiveObservationCandidate(); + latest = candidate.latest; + if (candidate.candidate) return { ...candidate, ok: true, reused: false, started }; + await Bun.sleep(10_000); + } + return { ok: false, stage: "wait-live-observation", timeoutMs: 240_000, started, latest }; +} + async function waitForTaskStatus(taskId: string, expected: string, timeoutMs = 10_000): Promise { const started = Date.now(); let latest: unknown = null; @@ -455,12 +560,14 @@ async function exposureChecks(config: UniDeskConfig, urls: PublicUrls, checks: E const findjobPublic = await fetchProbe(`http://${config.network.publicHost}:3254/api/health`, 2500); const metNonlinearPublic = await fetchProbe(`http://${config.network.publicHost}:3288/health`, 2500); const todoNotePublic = await fetchProbe(`http://${config.network.publicHost}:4211/api/health`, 2500); - addSelectedCheck(checks, options, "network:only-frontend-provider-ports", !portsText.includes(`:${config.network.core.port}->`) && !portsText.includes(`:${config.network.database.port}->`), portSummary); + const codexQueuePublic = await fetchProbe(`http://${config.network.publicHost}:14222/health`, 2500); + addSelectedCheck(checks, options, "network:only-frontend-provider-ports", !portsText.includes(`:${config.network.core.port}->`) && !portsText.includes(`:${config.network.database.port}->`) && !portsText.includes(":14222->"), portSummary); addSelectedCheck(checks, options, "network:core-public-blocked", (corePublic as { reachable?: boolean }).reachable === false, corePublic); addSelectedCheck(checks, options, "network:database-public-blocked", (databasePublic as { reachable?: boolean }).reachable === false, databasePublic); addSelectedCheck(checks, options, "network:findjob-public-blocked", (findjobPublic as { reachable?: boolean }).reachable === false, findjobPublic); addSelectedCheck(checks, options, "network:met-nonlinear-public-blocked", (metNonlinearPublic as { reachable?: boolean }).reachable === false, metNonlinearPublic); addSelectedCheck(checks, options, "network:todo-note-public-blocked", (todoNotePublic as { reachable?: boolean }).reachable === false, todoNotePublic); + addSelectedCheck(checks, options, "network:codex-queue-public-blocked", (codexQueuePublic as { reachable?: boolean }).reachable === false, codexQueuePublic); } async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[], options: E2ERunOptions): Promise { @@ -476,6 +583,7 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2 const pipelineStatus = dockerCoreJson("/api/microservices/pipeline/status"); const pipelineHealth = dockerCoreJson("/api/microservices/pipeline/health"); const pipelineSnapshot = dockerCoreJson("/api/microservices/pipeline/proxy/api/snapshot?__unideskArrayLimit=registry.components:8,runs:3"); + const pipelineOaEventFlow = dockerCoreJson("/api/microservices/pipeline/proxy/api/oa-event-flow/diagnostics"); const metNonlinearStatus = dockerCoreJson("/api/microservices/met-nonlinear/status"); const metNonlinearHealth = dockerCoreJson("/api/microservices/met-nonlinear/health"); const metNonlinearQueue = dockerCoreJson("/api/microservices/met-nonlinear/proxy/api/queue?__unideskArrayLimit=jobs:10"); @@ -484,6 +592,9 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2 const todoNoteStatus = dockerCoreJson("/api/microservices/todo-note/status"); const todoNoteHealth = dockerCoreJson("/api/microservices/todo-note/health"); const todoNoteInstances = dockerCoreJson("/api/microservices/todo-note/proxy/api/instances"); + const codexQueueStatus = dockerCoreJson("/api/microservices/codex-queue/status"); + const codexQueueHealth = dockerCoreJson("/api/microservices/codex-queue/health"); + const codexQueueTasks = dockerCoreJson("/api/microservices/codex-queue/proxy/api/tasks?limit=5"); const todoE2eName = `E2E Todo ${Date.now()}`; const todoNoteCreate = dockerCoreJson("/api/microservices/todo-note/proxy/api/instances", { method: "POST", body: { name: todoE2eName } }); const todoCreatedId = (todoNoteCreate as { body?: { id?: string } }).body?.id ?? ""; @@ -524,15 +635,19 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2 const pipeline = microserviceList.find((service) => service.id === "pipeline"); const metNonlinear = microserviceList.find((service) => service.id === "met-nonlinear"); const todoNote = microserviceList.find((service) => service.id === "todo-note"); + const codexQueue = microserviceList.find((service) => service.id === "codex-queue"); const findjobSummaryBody = (findjobSummary as { body?: { totalJobs?: number; prioritizedJobs?: number } }).body; const findjobJobs = (findjobJobsPreview as { body?: { jobs?: unknown[]; _unidesk?: { arrayLimits?: { jobs?: { returnedLength?: number; originalLength?: number } } } } }).body; const todoNoteRows = (todoNoteInstances as { body?: { instances?: Array<{ id?: string; name?: string; todoCount?: number; completedCount?: number }> } }).body?.instances ?? []; const todoNoteNames = todoNoteRows.map((row) => row.name); const pipelineSnapshotBody = (pipelineSnapshot as { body?: { ok?: boolean; registry?: { ok?: boolean; components?: unknown[] }; pipelines?: unknown[]; runs?: unknown[]; _unidesk?: { arrayLimits?: { "registry.components"?: { returnedLength?: number; originalLength?: number }; runs?: { returnedLength?: number; originalLength?: number } } } } }).body; + const pipelineOaBody = (pipelineOaEventFlow as { body?: { ok?: boolean; mode?: string; forbiddenResiduals?: unknown[]; runs?: Array<{ runId?: string; nodeFinishedCount?: number; nodeFinishedWithPolicyCount?: number; monitorAuditNodeFinishedCount?: number; noAuditPolicyCount?: number; controlQueuedCount?: number; controlAppliedCount?: number }>; hasNeutralNodeFinishedEvidence?: boolean; hasNoAuditPolicyEvidence?: boolean; hasAuditPolicyEvidence?: boolean } }).body; const metNonlinearHealthBody = (metNonlinearHealth as { body?: { ok?: boolean; targetGpu?: { name?: string; freeRatio?: number } | null; image?: { present?: boolean; image?: string } } }).body; const metNonlinearQueueBody = (metNonlinearQueue as { body?: { ok?: boolean; queue?: { counts?: Record; maxConcurrency?: number; targetGpuName?: string }; jobs?: unknown[] } }).body; const metNonlinearProjectsBody = (metNonlinearProjects as { body?: { ok?: boolean; projects?: unknown[] } }).body; const metNonlinearImagesBody = (metNonlinearImages as { body?: { ok?: boolean; mlImage?: { present?: boolean; image?: string } } }).body; + const codexQueueHealthBody = (codexQueueHealth as { body?: { ok?: boolean; queue?: { defaultModel?: string; judgeConfigured?: boolean } } }).body; + const codexQueueTasksBody = (codexQueueTasks as { body?: { ok?: boolean; queue?: { defaultModel?: string }; tasks?: unknown[] } }).body; const firstPipelineRun = Array.isArray(pipelineSnapshotBody?.runs) ? pipelineSnapshotBody.runs[0] as { runId?: string; pipelineId?: string; status?: string; updatedAt?: string } | undefined : undefined; @@ -555,9 +670,10 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2 }, }; addSelectedCheck(checks, options, "microservice:catalog-findjob", (microservices as { ok?: boolean }).ok === true && findjob?.providerId === "D601" && findjob.backend?.public === false, { microservices }); - addSelectedCheck(checks, options, "microservice:catalog-pipeline", (microservices as { ok?: boolean }).ok === true && pipeline?.providerId === "D601" && pipeline.backend?.public === false && pipeline.runtime?.container?.name === "pipeline-v2-webui", { microservices }); + addSelectedCheck(checks, options, "microservice:catalog-pipeline", (microservices as { ok?: boolean }).ok === true && pipeline?.providerId === "D601" && pipeline.backend?.public === false && pipeline.runtime?.container?.name === "pipeline-v2-control", { microservices }); addSelectedCheck(checks, options, "microservice:catalog-met-nonlinear", (microservices as { ok?: boolean }).ok === true && metNonlinear?.providerId === "D601" && metNonlinear.backend?.public === false && metNonlinear.runtime?.container?.name === "met-nonlinear-ts", { microservices }); addSelectedCheck(checks, options, "microservice:catalog-todo-note", (microservices as { ok?: boolean }).ok === true && todoNote?.providerId === config.providerGateway.id && todoNote.backend?.public === false && todoNote.runtime?.container?.name === "todo-note-backend", { microservices }); + addSelectedCheck(checks, options, "microservice:catalog-codex-queue", (microservices as { ok?: boolean }).ok === true && codexQueue?.providerId === config.providerGateway.id && codexQueue.backend?.public === false && codexQueue.runtime?.container?.name === "codex-queue-backend", { microservices }); addSelectedCheck(checks, options, "microservice:findjob-status", (findjobStatus as { ok?: boolean }).ok === true && (findjobStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === "D601", findjobStatus); addSelectedCheck(checks, options, "microservice:findjob-health", (findjobHealth as { ok?: boolean; body?: { ok?: boolean } }).ok === true && (findjobHealth as { body?: { ok?: boolean } }).body?.ok === true, findjobHealth); addSelectedCheck(checks, options, "microservice:findjob-summary", (findjobSummary as { ok?: boolean }).ok === true && Number.isFinite(findjobSummaryBody?.totalJobs) && Number.isFinite(findjobSummaryBody?.prioritizedJobs), findjobSummary); @@ -565,6 +681,27 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2 addSelectedCheck(checks, options, "microservice:pipeline-status", (pipelineStatus as { ok?: boolean }).ok === true && (pipelineStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === "D601", pipelineStatus); addSelectedCheck(checks, options, "microservice:pipeline-health", (pipelineHealth as { ok?: boolean; body?: { ok?: boolean; service?: string } }).ok === true && (pipelineHealth as { body?: { ok?: boolean } }).body?.ok === true, pipelineHealth); addSelectedCheck(checks, options, "microservice:pipeline-snapshot", (pipelineSnapshot as { ok?: boolean }).ok === true && pipelineSnapshotBody?.ok === true && pipelineSnapshotBody.registry?.ok === true && Array.isArray(pipelineSnapshotBody.registry.components) && pipelineSnapshotBody.registry.components.length > 0 && Array.isArray(pipelineSnapshotBody.pipelines) && pipelineSnapshotBody.pipelines.length > 0 && Array.isArray(pipelineSnapshotBody.runs) && pipelineSnapshotBody.runs.length > 0 && (pipelineSnapshotBody._unidesk?.arrayLimits?.["registry.components"]?.returnedLength ?? 999) <= 8 && (pipelineSnapshotBody._unidesk?.arrayLimits?.runs?.returnedLength ?? 999) <= 3, pipelineSnapshotDetail); + addSelectedCheck(checks, options, "microservice:pipeline-oa-event-flow", + (pipelineOaEventFlow as { ok?: boolean }).ok === true + && pipelineOaBody?.ok === true + && pipelineOaBody.mode === "oa-event-flow-100" + && Array.isArray(pipelineOaBody.forbiddenResiduals) + && pipelineOaBody.forbiddenResiduals.length === 0 + && pipelineOaBody.hasNeutralNodeFinishedEvidence === true + && pipelineOaBody.hasNoAuditPolicyEvidence === true + && pipelineOaBody.hasAuditPolicyEvidence === true + && (pipelineOaBody.runs ?? []).some((run) => Number(run.noAuditPolicyCount || 0) > 0) + && (pipelineOaBody.runs ?? []).some((run) => Number(run.monitorAuditNodeFinishedCount || 0) > 0 && Number(run.controlQueuedCount || 0) > 0 && Number(run.controlAppliedCount || 0) > 0) + && (pipelineOaBody.runs ?? []).every((run) => Number(run.nodeFinishedWithPolicyCount || 0) === 0), + { + ok: (pipelineOaEventFlow as { ok?: boolean }).ok, + mode: pipelineOaBody?.mode, + forbiddenResiduals: pipelineOaBody?.forbiddenResiduals, + hasNeutralNodeFinishedEvidence: pipelineOaBody?.hasNeutralNodeFinishedEvidence, + hasNoAuditPolicyEvidence: pipelineOaBody?.hasNoAuditPolicyEvidence, + hasAuditPolicyEvidence: pipelineOaBody?.hasAuditPolicyEvidence, + runs: (pipelineOaBody?.runs ?? []).slice(0, 8), + }); addSelectedCheck(checks, options, "microservice:met-nonlinear-status", (metNonlinearStatus as { ok?: boolean }).ok === true && (metNonlinearStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === "D601", metNonlinearStatus); addSelectedCheck(checks, options, "microservice:met-nonlinear-health", (metNonlinearHealth as { ok?: boolean }).ok === true && metNonlinearHealthBody?.ok === true, metNonlinearHealth); addSelectedCheck(checks, options, "microservice:met-nonlinear-queue", (metNonlinearQueue as { ok?: boolean }).ok === true && metNonlinearQueueBody?.ok === true && typeof metNonlinearQueueBody.queue?.counts === "object" && metNonlinearQueueBody.queue?.targetGpuName === "2080 Ti", metNonlinearQueue); @@ -574,6 +711,9 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2 addSelectedCheck(checks, options, "microservice:todo-note-health", (todoNoteHealth as { ok?: boolean; body?: { ok?: boolean; storage?: string } }).ok === true && (todoNoteHealth as { body?: { ok?: boolean; storage?: string } }).body?.ok === true && (todoNoteHealth as { body?: { storage?: string } }).body?.storage === "postgres", todoNoteHealth); addSelectedCheck(checks, options, "microservice:todo-note-migrated-data", (todoNoteInstances as { ok?: boolean }).ok === true && todoNoteRows.length >= 5 && ["CONSTAR", "大论文", "找工作", "小论文", "事务"].every((name) => todoNoteNames.includes(name)) && todoNoteRows.reduce((sum, row) => sum + Number(row.todoCount ?? 0), 0) >= 100, { todoNoteInstances }); addSelectedCheck(checks, options, "microservice:todo-note-write-path", (todoNoteCreate as { ok?: boolean }).ok === true && (todoNoteAdd as { ok?: boolean }).ok === true && (todoNoteToggle as { ok?: boolean }).ok === true && (todoNoteUndo as { ok?: boolean }).ok === true && (todoNoteDelete as { ok?: boolean }).ok === true, { todoNoteCreate, todoNoteAdd, todoNoteToggle, todoNoteUndo, todoNoteDelete }); + addSelectedCheck(checks, options, "microservice:codex-queue-status", (codexQueueStatus as { ok?: boolean }).ok === true && (codexQueueStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === config.providerGateway.id, codexQueueStatus); + addSelectedCheck(checks, options, "microservice:codex-queue-health", (codexQueueHealth as { ok?: boolean }).ok === true && codexQueueHealthBody?.ok === true && codexQueueHealthBody.queue?.defaultModel === "gpt-5.4-mini", codexQueueHealth); + addSelectedCheck(checks, options, "microservice:codex-queue-tasks", (codexQueueTasks as { ok?: boolean }).ok === true && codexQueueTasksBody?.ok === true && Array.isArray(codexQueueTasksBody.tasks) && codexQueueTasksBody.queue?.defaultModel === "gpt-5.4-mini", codexQueueTasks); const upgradeDispatch = dockerCoreJson("/api/dispatch", { method: "POST", body: { providerId: config.providerGateway.id, command: "provider.upgrade", payload: { source: "cli-e2e", mode: "plan" } }, @@ -670,12 +810,16 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 const needMicroserviceCatalog = wants("frontend:microservice-catalog-visible"); const needTodoNote = wants("frontend:todo-note-integrated-visible"); const needFindJob = wants("frontend:findjob-integrated-visible"); + const needCodexQueue = wants("frontend:codex-queue-integrated-visible"); const needRouteDeepLink = wants("frontend:url-route-deeplink"); const needPipeline = wantsAny([ "frontend:pipeline-integrated-visible", "frontend:pipeline-react-flow-visible", "frontend:pipeline-gantt-defaults", + "frontend:pipeline-gantt-export", + "frontend:pipeline-gantt-observation-live-running", "frontend:pipeline-step-timeline-visible", + "frontend:pipeline-oa-event-flow-visible", ]); const needMetNonlinear = wantsAny([ "frontend:met-nonlinear-integrated-visible", @@ -734,6 +878,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 let microserviceCatalogText = ""; let todoNoteText = ""; let findjobText = ""; + let codexQueueText = ""; let routeDeepLinkText = ""; let routeInitialPath = ""; let routeDockerPath = ""; @@ -742,16 +887,21 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 let routeOverviewPath = ""; let routeOverviewText = ""; let pipelineText = ""; + let pipelineOaPanelText = ""; let pipelineFlowNodeCount = 0; let pipelineFlowEdgeCount = 0; + let pipelineSelectedId = ""; let pipelineGanttScaleLabel = ""; let pipelineGanttAutoHideIdleChecked = true; let pipelineGanttHeaderNodeOrder: string[] = []; + let pipelineGanttExportInfo: any = { downloaded: false, suggestedFilename: "", savePath: "", bytes: 0 }; let pipelineSnapshotForFrontend: any = null; + let pipelineObservationSetup: any = null; + let pipelineObservationGanttMetrics: any = { candidate: null, observationArrowCount: 0, observationSourceMarkerCount: 0, observationArrowTargetInsetsPx: [], liveRunningBarCount: 0, liveRunningHeights: [], runningAnimationNames: [], arrowPairs: [], hasLiveSweep: false }; let pipelineStepTimelineText = ""; let pipelineSessionHeadText = ""; let firstPipelineStepSummaryText = ""; - let pipelineTimelineMetrics = { clientWidth: 0, scrollWidth: 0, clientHeight: 0, scrollHeight: 0, hasHorizontalScroll: false }; + let pipelineTimelineMetrics = { clientWidth: 0, scrollWidth: 0, clientHeight: 0, scrollHeight: 0, hasHorizontalScroll: false, flowConnectorVisible: false, maxStepIdleGapMs: 0, idleGapCount: 0 }; let firstPipelineStepSummaryMetrics = { clientWidth: 0, scrollWidth: 0, clientHeight: 0, scrollHeight: 0, hasHorizontalScroll: false }; let firstPipelineStepExpandedText = ""; let metNonlinearInitialText = ""; @@ -769,6 +919,10 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 await page.waitForTimeout(80); } + if (wants("frontend:pipeline-gantt-observation-live-running")) { + pipelineObservationSetup = await ensurePipelineLiveObservationCandidate(); + } + if (needMobileRail || needMobileContent) { await page.setViewportSize({ width: 390, height: 860 }); if (needMobileRail) { @@ -885,9 +1039,9 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 } } - if (needMicroserviceCatalog || needTodoNote || needFindJob || needRouteDeepLink || needPipeline || needMetNonlinear) { + if (needMicroserviceCatalog || needTodoNote || needFindJob || needCodexQueue || needRouteDeepLink || needPipeline || needMetNonlinear) { await page.getByRole("button", { name: /微服务/ }).click(); - if (needMicroserviceCatalog || needTodoNote || needFindJob || needRouteDeepLink || needPipeline || needMetNonlinear) { + if (needMicroserviceCatalog || needTodoNote || needFindJob || needCodexQueue || needRouteDeepLink || needPipeline || needMetNonlinear) { await page.waitForSelector('[data-testid="microservice-catalog-page"]', { timeout: 10000 }); } if (needMicroserviceCatalog) { @@ -895,6 +1049,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 await page.waitForSelector('[data-testid="microservice-row-pipeline"]', { timeout: 10000 }); await page.waitForSelector('[data-testid="microservice-row-met-nonlinear"]', { timeout: 10000 }); await page.waitForSelector('[data-testid="microservice-row-todo-note"]', { timeout: 10000 }); + await page.waitForSelector('[data-testid="microservice-row-codex-queue"]', { timeout: 10000 }); microserviceCatalogText = await page.locator('[data-testid="microservice-catalog-page"]').innerText({ timeout: 5000 }); } if (needTodoNote) { @@ -950,6 +1105,22 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 }, undefined, { timeout: 30000 }); findjobText = await page.locator('[data-testid="findjob-page"]').innerText({ timeout: 5000 }); } + if (needCodexQueue) { + await page.getByRole("button", { name: /Codex Queue/ }).click(); + await page.waitForSelector('[data-testid="codex-queue-page"]', { timeout: 10000 }); + await page.waitForFunction(() => { + const text = document.body.innerText; + const lower = text.toLowerCase(); + return lower.includes("codex queue 控制台") + && text.includes("gpt-5.4-mini") + && text.includes("仅 UniDesk frontend 代理访问") + && text.includes("提交任务") + && text.includes("追加 prompt") + && text.includes("打断") + && lower.includes("attempts"); + }, undefined, { timeout: 30000 }); + codexQueueText = await page.locator('[data-testid="codex-queue-page"]').innerText({ timeout: 5000 }); + } if (needRouteDeepLink) { await page.goto(`${urls.frontendUrl}/app/pipeline/`, { waitUntil: "domcontentloaded", timeout: 15000 }); await page.waitForSelector('[data-testid="app-shell"]', { timeout: 10000 }); @@ -990,16 +1161,49 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 && /运行记录\s+[1-9]\d*/.test(text); }, undefined, { timeout: 30000 }); pipelineFlowNodeCount = await page.locator('[data-testid="pipeline-react-flow"] .react-flow__node').count(); + await page.waitForFunction(() => document.querySelectorAll('[data-testid="pipeline-react-flow"] .react-flow__edge').length > 0, undefined, { timeout: 10000 }).catch(() => undefined); pipelineFlowEdgeCount = await page.locator('[data-testid="pipeline-react-flow"] .react-flow__edge').count(); + pipelineSelectedId = await page.locator('[data-testid="pipeline-select"]').evaluate((element) => (element as HTMLSelectElement).value); + await page.waitForFunction(() => { + const panel = document.querySelector('[data-testid="pipeline-oa-event-flow-panel"]') as HTMLElement | null; + const text = panel?.innerText || ""; + const lower = text.toLowerCase(); + return lower.includes("oa flow") + && text.includes("100%") + && lower.includes("no-audit") + && lower.includes("monitor 审核") + && text.includes("禁止残留") + && text.includes("policy-in-detail 0"); + }, undefined, { timeout: 30000 }); pipelineText = await page.locator('[data-testid="pipeline-page"]').innerText({ timeout: 5000 }); + pipelineOaPanelText = await page.locator('[data-testid="pipeline-oa-event-flow-panel"]').innerText({ timeout: 5000 }); pipelineGanttScaleLabel = await page.locator('[data-testid="pipeline-gantt-scale-label"]').innerText({ timeout: 5000 }); pipelineGanttAutoHideIdleChecked = await page.locator('[data-testid="pipeline-gantt-auto-hide-idle"]').isChecked(); + await page.waitForFunction(() => document.querySelectorAll('[data-testid="pipeline-gantt-head-node"]').length > 0, undefined, { timeout: 15000 }); pipelineGanttHeaderNodeOrder = await page.locator('[data-testid="pipeline-gantt-head-node"]').evaluateAll((elements) => elements.map((element) => element.getAttribute("data-node-id") || "").filter(Boolean)); pipelineSnapshotForFrontend = await page.evaluate(async () => { const response = await fetch("/api/microservices/pipeline/proxy/api/snapshot?__unideskArrayLimit=registry.components:8,runs:3", { credentials: "same-origin" }); return response.ok ? response.json() : null; }); + if (wants("frontend:pipeline-gantt-export")) { + const exportButton = page.getByTestId("pipeline-export-gantt"); + await exportButton.waitFor({ state: "visible", timeout: 10000 }); + await page.waitForFunction(() => { + const button = document.querySelector('[data-testid="pipeline-export-gantt"]') as HTMLButtonElement | null; + return Boolean(button && !button.disabled); + }, undefined, { timeout: 10000 }); + const [download] = await Promise.all([ + page.waitForEvent("download", { timeout: 15000 }), + exportButton.click(), + ]); + const suggestedFilename = download.suggestedFilename(); + const safeFilename = suggestedFilename.replace(/[^a-zA-Z0-9_.-]+/g, "-") || "pipeline-gantt.png"; + const savePath = join(e2eDir, `${Date.now()}_${safeFilename}`); + await download.saveAs(savePath); + const bytes = readFileSync(savePath).byteLength; + pipelineGanttExportInfo = { downloaded: true, suggestedFilename, savePath, bytes }; + } if (wants("frontend:pipeline-step-timeline-visible")) { const firstGanttLine = page.locator('[data-testid="pipeline-epoch-gantt"] [data-testid="pipeline-gantt-line"]').first(); await firstGanttLine.scrollIntoViewIfNeeded({ timeout: 10000 }); @@ -1012,12 +1216,24 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 firstPipelineStepSummaryText = await firstPipelineStepSummary.innerText({ timeout: 5000 }); pipelineTimelineMetrics = await page.locator('[data-testid="pipeline-step-timeline"]').evaluate((element) => { const target = element as HTMLElement; + const flow = target.querySelector(".pipeline-opencode-flow") as HTMLElement | null; + const flowBefore = flow ? window.getComputedStyle(flow, "::before") : null; + const steps = Array.from(target.querySelectorAll('[data-testid="pipeline-opencode-step"]')) as HTMLElement[]; + const gaps = steps.slice(1).map((step, index) => { + const previous = steps[index]; + const previousEndMs = Number(previous.dataset.stepEndMs || "0"); + const nextStartMs = Number(step.dataset.stepStartMs || "0"); + return Number.isFinite(previousEndMs) && Number.isFinite(nextStartMs) && previousEndMs > 0 && nextStartMs > 0 ? nextStartMs - previousEndMs : 0; + }).filter((gap) => gap > 0); return { clientWidth: target.clientWidth, scrollWidth: target.scrollWidth, clientHeight: target.clientHeight, scrollHeight: target.scrollHeight, hasHorizontalScroll: target.scrollWidth > target.clientWidth + 1, + flowConnectorVisible: Boolean(flowBefore && flowBefore.display !== "none" && flowBefore.content !== "none" && flowBefore.content !== "normal" && Number.parseFloat(flowBefore.width || "0") > 0), + maxStepIdleGapMs: gaps.length > 0 ? Math.max(...gaps) : 0, + idleGapCount: gaps.length, }; }); firstPipelineStepSummaryMetrics = await firstPipelineStepSummary.evaluate((element) => { @@ -1034,6 +1250,72 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 await firstPipelineStep.locator('[data-testid="pipeline-opencode-step-body"]').waitFor({ state: "visible", timeout: 10000 }); firstPipelineStepExpandedText = await firstPipelineStep.innerText({ timeout: 5000 }); } + if (wants("frontend:pipeline-gantt-observation-live-running")) { + const candidate = await page.evaluate(async () => { + const snapshotResponse = await fetch("/api/microservices/pipeline/proxy/api/snapshot?__unideskArrayLimit=registry.components:2,runs:30", { credentials: "same-origin" }); + if (!snapshotResponse.ok) return null; + const snapshot = await snapshotResponse.json(); + const runs = Array.isArray(snapshot?.runs) ? snapshot.runs : []; + for (const run of runs) { + const runId = String(run?.runId || ""); + if (!runId) continue; + const detailResponse = await fetch(`/api/microservices/pipeline/proxy/api/node-control/runs/${encodeURIComponent(runId)}?tail=160&view=gantt&scale=77&_=${Date.now()}`, { credentials: "same-origin" }); + if (!detailResponse.ok) continue; + const detail = await detailResponse.json(); + const markers = Array.isArray(detail?.gantt?.markers) ? detail.gantt.markers : []; + const intervals = Array.isArray(detail?.gantt?.intervals) ? detail.gantt.intervals : []; + const hasObservation = markers.some((marker: any) => + String(marker?.promptEvent || marker?.event || "").toLowerCase() === "node-long-running-observation" + && String(marker?.sourceNodeId || "") + && String(marker?.nodeId || "") + && String(marker?.sourceNodeId || "") !== String(marker?.nodeId || "")); + const hasRunning = intervals.some((interval: any) => String(interval?.status || "").toLowerCase() === "running"); + if (hasObservation && hasRunning) return { pipelineId: String(run?.pipelineId || detail?.request?.pipelineId || ""), runId }; + } + return null; + }); + if (candidate?.pipelineId && candidate?.runId) { + await page.locator('[data-testid="pipeline-select"]').selectOption(candidate.pipelineId); + await page.waitForFunction((runId) => Array.from(document.querySelectorAll('[data-testid="pipeline-run-select"] option')).some((option) => (option as HTMLOptionElement).value === runId), candidate.runId, { timeout: 15000 }); + await page.locator('[data-testid="pipeline-run-select"]').selectOption(candidate.runId); + await page.waitForFunction((runId) => document.querySelector('[data-testid="pipeline-epoch-gantt"]')?.getAttribute("data-run-id") === runId, candidate.runId, { timeout: 15000 }); + await page.waitForFunction(() => document.querySelectorAll('[data-testid="pipeline-gantt-observation-arrow"]').length > 0, undefined, { timeout: 15000 }); + await page.waitForFunction(() => document.querySelectorAll('[data-testid="pipeline-gantt-line"][data-status="running"][data-live="true"]').length > 0, undefined, { timeout: 15000 }); + await page.waitForTimeout(1200); + pipelineObservationGanttMetrics = await page.locator('[data-testid="pipeline-epoch-gantt"]').evaluate((element, selectedCandidate) => { + const root = element as HTMLElement; + const arrows = Array.from(root.querySelectorAll('[data-testid="pipeline-gantt-observation-arrow"]')); + const liveBars = Array.from(root.querySelectorAll('[data-testid="pipeline-gantt-line"][data-status="running"][data-live="true"]')) as HTMLElement[]; + const svg = root.querySelector(".pipeline-gantt-arrow-layer") as SVGSVGElement | null; + const svgRect = svg?.getBoundingClientRect(); + const markerElements = Array.from(root.querySelectorAll("[data-marker-id]")) as HTMLElement[]; + const markerById = new Map(markerElements.map((marker) => [marker.getAttribute("data-marker-id") || "", marker])); + return { + candidate: selectedCandidate, + observationArrowCount: arrows.length, + observationSourceMarkerCount: root.querySelectorAll('[data-marker-id^="observation-source:"]').length, + arrowPairs: arrows.map((arrow) => `${arrow.getAttribute("data-source-node-id") || ""}->${arrow.getAttribute("data-target-node-id") || ""}`), + observationArrowTargetInsetsPx: arrows.map((arrow) => { + const path = arrow as SVGPathElement; + const targetMarker = markerById.get(path.getAttribute("data-target-marker-id") || ""); + if (!svgRect || !targetMarker || typeof path.getTotalLength !== "function") return null; + const end = path.getPointAtLength(path.getTotalLength()); + const markerRect = targetMarker.getBoundingClientRect(); + const markerCenterX = markerRect.left + markerRect.width / 2 - svgRect.left; + const markerCenterY = markerRect.top + markerRect.height / 2 - svgRect.top; + return Math.round(Math.hypot(markerCenterX - end.x, markerCenterY - end.y)); + }).filter((value) => typeof value === "number"), + liveRunningBarCount: liveBars.length, + liveRunningHeights: liveBars.map((bar) => Math.round(bar.getBoundingClientRect().height)), + runningAnimationNames: liveBars.map((bar) => window.getComputedStyle(bar).animationName), + hasLiveSweep: liveBars.some((bar) => window.getComputedStyle(bar, "::after").animationName.includes("ganttLiveSweep")), + }; + }, candidate); + pipelineObservationGanttMetrics = { ...pipelineObservationGanttMetrics, setup: pipelineObservationSetup }; + } else { + pipelineObservationGanttMetrics = { ...pipelineObservationGanttMetrics, candidate, setup: pipelineObservationSetup }; + } + } } if (needMetNonlinear) { await page.getByRole("button", { name: /MET Nonlinear/ }).click(); @@ -1095,8 +1377,11 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 const microserviceCatalogTextLower = microserviceCatalogText.toLowerCase(); const todoNoteTextLower = todoNoteText.toLowerCase(); const findjobTextLower = findjobText.toLowerCase(); + const codexQueueTextLower = codexQueueText.toLowerCase(); const pipelineTextLower = pipelineText.toLowerCase(); - const activePipeline = Array.isArray(pipelineSnapshotForFrontend?.pipelines) ? pipelineSnapshotForFrontend.pipelines[0] : null; + const activePipeline = Array.isArray(pipelineSnapshotForFrontend?.pipelines) + ? pipelineSnapshotForFrontend.pipelines.find((pipeline: any) => String(pipeline?.id || "") === pipelineSelectedId) || pipelineSnapshotForFrontend.pipelines[0] + : null; const expectedGanttNodeOrder = activePipeline ? pipelineSnapshotNodeOrder(activePipeline) : []; const headerIndexByNodeId = new Map(pipelineGanttHeaderNodeOrder.map((nodeId, index) => [nodeId, index])); const downstreamViolations = activePipeline @@ -1112,7 +1397,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 addSelectedCheck(checks, options, "frontend:sidebar-collapse", railWidthBefore >= 160 && railWidthCollapsed <= 70, { railWidthBefore, railWidthCollapsed }); addSelectedCheck(checks, options, "frontend:mobile-nav-fixed-height", mobileRailHeights.length > 0 && mobileRailMax - mobileRailMin <= 1 && mobileRailMax <= 44, { mobileRailHeights }); addSelectedCheck(checks, options, "frontend:mobile-content-top-aligned", mobileContentMetrics.pageTop <= 190 && mobileContentMetrics.emptyTextOffset <= 14, { mobileContentMetrics }); - addSelectedCheck(checks, options, "frontend:pending-task-drilldown", pendingTaskText.includes("待处理任务") && (pendingTaskText.includes("当前无待处理任务") || (pendingTaskText.includes("Provider") && pendingTaskText.includes("已等待"))), { pendingTaskPreview: pendingTaskText.slice(0, 600) }); + addSelectedCheck(checks, options, "frontend:pending-task-drilldown", pendingTaskText.includes("待处理任务") && (pendingTaskText.includes("当前无待处理任务") || (pendingTaskText.toLowerCase().includes("provider") && pendingTaskText.includes("已等待"))), { pendingTaskPreview: pendingTaskText.slice(0, 600) }); addSelectedCheck(checks, options, "frontend:task-history-diagnostics", taskHistoryText.includes("任务耗时") && taskHistoryText.includes("诊断信息") && taskHistoryText.includes("失败原因") && taskHistoryText.includes("e2e forced failure for diagnostics"), { taskHistoryPreview: taskHistoryText.slice(0, 900) }); addSelectedCheck(checks, options, "frontend:no-naked-json-before-click", rawBlocksBefore === 0 && !nakedJsonText, { rawBlocksBefore, nakedJsonText }); addSelectedCheck(checks, options, "frontend:raw-json-explicit-button", rawText.includes('"providerId"') && rawText.includes(config.providerGateway.id), { rawTextPreview: rawText.slice(0, 400) }); @@ -1124,12 +1409,22 @@ 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") && 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/todo_note"), { microserviceCatalogPreview: microserviceCatalogText.slice(0, 1600) }); + addSelectedCheck(checks, options, "frontend:microservice-catalog-visible", microserviceCatalogTextLower.includes("findjob") && microserviceCatalogTextLower.includes("pipeline") && microserviceCatalogTextLower.includes("todo note") && microserviceCatalogTextLower.includes("met nonlinear") && microserviceCatalogTextLower.includes("codex 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/todo_note") && microserviceCatalogText.includes("https://github.com/pikasTech/unidesk"), { microserviceCatalogPreview: microserviceCatalogText.slice(0, 1800) }); 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:codex-queue-integrated-visible", codexQueueTextLower.includes("codex queue 控制台".toLowerCase()) && codexQueueText.includes("gpt-5.4-mini") && codexQueueText.includes("提交任务") && codexQueueText.includes("追加 prompt") && codexQueueText.includes("打断") && codexQueueTextLower.includes("attempts") && codexQueueText.includes("仅 UniDesk frontend 代理访问"), { codexQueueTextPreview: codexQueueText.slice(0, 1400) }); addSelectedCheck(checks, options, "frontend:url-route-deeplink", routeInitialPath === "/app/pipeline/" && routeDockerPath === "/nodes/docker/" && routeBackPath === "/app/pipeline/" && routeOverviewPath === "/ops/status/" && routeDeepLinkText.toLowerCase().includes("pipeline v2 工作台".toLowerCase()) && routeOverviewText.includes("核心指标"), { routeInitialPath, routeDockerPath, routeBackIntermediatePath, routeBackPath, routeOverviewPath, routeDeepLinkPreview: routeDeepLinkText.slice(0, 1200), routeOverviewPreview: routeOverviewText.slice(0, 800) }); - addSelectedCheck(checks, options, "frontend:pipeline-integrated-visible", pipelineTextLower.includes("pipeline v2 工作台".toLowerCase()) && pipelineText.includes("D601") && pipelineText.includes("控制图") && /epoch\s+甘特图/i.test(pipelineText) && pipelineText.includes("运行材料索引") && pipelineText.includes("仅 UniDesk frontend 代理访问") && /Health\s+OK/i.test(pipelineText) && /组件\s+\d+/.test(pipelineText) && /运行记录\s+[1-9]\d*/.test(pipelineText), { pipelineTextPreview: pipelineText.slice(0, 1200) }); + addSelectedCheck(checks, options, "frontend:pipeline-integrated-visible", pipelineTextLower.includes("pipeline v2 工作台".toLowerCase()) && pipelineText.includes("D601") && pipelineText.includes("控制图") && pipelineText.includes("评分器") && /epoch\s+甘特图/i.test(pipelineText) && pipelineText.includes("运行材料索引") && pipelineText.includes("仅 UniDesk frontend 代理访问") && /Health\s+OK/i.test(pipelineText) && /组件\s+\d+/.test(pipelineText) && /运行记录\s+[1-9]\d*/.test(pipelineText), { pipelineTextPreview: pipelineText.slice(0, 1200) }); addSelectedCheck(checks, options, "frontend:pipeline-react-flow-visible", pipelineFlowNodeCount > 0 && pipelineFlowEdgeCount > 0, { pipelineFlowNodeCount, pipelineFlowEdgeCount }); + addSelectedCheck(checks, options, "frontend:pipeline-oa-event-flow-visible", + pipelineOaPanelText.toLowerCase().includes("oa flow") + && pipelineOaPanelText.includes("100%") + && pipelineOaPanelText.toLowerCase().includes("no-audit") + && pipelineOaPanelText.toLowerCase().includes("monitor 审核") + && pipelineOaPanelText.includes("禁止残留") + && pipelineOaPanelText.includes("policy-in-detail 0") + && !pipelineOaPanelText.includes("{"), + { pipelineOaPanelPreview: pipelineOaPanelText.slice(0, 1400) }); addSelectedCheck(checks, options, "frontend:pipeline-gantt-defaults", pipelineGanttScaleLabel.includes("100 px/min") && pipelineGanttAutoHideIdleChecked === false && pipelineGanttHeaderNodeOrder.length > 0 && downstreamViolations.length === 0, { pipelineGanttScaleLabel, pipelineGanttAutoHideIdleChecked, @@ -1137,17 +1432,33 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 expectedGanttNodeOrder, downstreamViolations, }); + addSelectedCheck(checks, options, "frontend:pipeline-gantt-export", + pipelineGanttExportInfo.downloaded === true + && Number(pipelineGanttExportInfo.bytes || 0) > 2048 + && /\.(png|svg)$/i.test(String(pipelineGanttExportInfo.suggestedFilename || "")), + { pipelineGanttExportInfo }); + addSelectedCheck(checks, options, "frontend:pipeline-gantt-observation-live-running", + Boolean(pipelineObservationGanttMetrics?.candidate) + && Number(pipelineObservationGanttMetrics?.observationArrowCount || 0) > 0 + && Number(pipelineObservationGanttMetrics?.observationSourceMarkerCount || 0) === 0 + && (pipelineObservationGanttMetrics?.observationArrowTargetInsetsPx || []).some((value: number) => value >= 8) + && Number(pipelineObservationGanttMetrics?.liveRunningBarCount || 0) > 0 + && (pipelineObservationGanttMetrics?.liveRunningHeights || []).some((height: number) => height >= 24) + && (pipelineObservationGanttMetrics?.runningAnimationNames || []).some((name: string) => String(name || "").includes("ganttPulse")) + && pipelineObservationGanttMetrics?.hasLiveSweep === true, + { pipelineObservationGanttMetrics }); addSelectedCheck(checks, options, "frontend:pipeline-step-timeline-visible", pipelineStepTimelineText.includes("OpenCode Step Timeline") && pipelineStepTimelineText.includes("时间") && pipelineStepTimelineText.includes("工具调用") - && !pipelineStepTimelineText.includes("{\n") + && !firstPipelineStepSummaryText.includes("{\n") && pipelineSessionHeadText.includes("agent") && pipelineSessionHeadText.toLowerCase().includes("model") && !firstPipelineStepSummaryText.toLowerCase().includes("tokens") && !firstPipelineStepSummaryText.includes("MiniMax-M2.7") && !firstPipelineStepSummaryText.includes("\nbuild\n") && !pipelineTimelineMetrics.hasHorizontalScroll + && !pipelineTimelineMetrics.flowConnectorVisible && !firstPipelineStepSummaryMetrics.hasHorizontalScroll && firstPipelineStepSummaryMetrics.clientHeight <= 190 && firstPipelineStepExpandedText.toLowerCase().includes("tokens") diff --git a/src/components/frontend/public/style.css b/src/components/frontend/public/style.css index d156ed51..872e4e73 100644 --- a/src/components/frontend/public/style.css +++ b/src/components/frontend/public/style.css @@ -1110,7 +1110,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 { +.microservice-page, .findjob-page, .pipeline-page, .met-page, .codex-queue-page { display: grid; gap: 10px; } @@ -1159,7 +1159,6 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } gap: 10px; align-items: start; } -.pipeline-grid .panel:nth-child(3), .pipeline-grid .panel:nth-child(5) { grid-column: 1 / -1; } .pipeline-grid .pipeline-wide-panel { grid-column: 1 / -1; } .met-grid { display: grid; @@ -1205,6 +1204,173 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } color: var(--accent-2); font-size: 12px; } +.codex-queue-hero { + display: grid; + grid-template-columns: minmax(300px, 1.35fr) minmax(260px, 0.8fr) minmax(220px, 0.65fr); + gap: 8px; + align-items: stretch; +} +.codex-queue-metrics { + display: grid; + grid-template-columns: repeat(5, minmax(140px, 1fr)); + gap: 8px; +} +.codex-queue-layout { + display: grid; + grid-template-columns: minmax(320px, 0.52fr) minmax(680px, 1.58fr); + gap: 10px; + align-items: start; +} +.codex-left-rail, +.codex-main-stage, +.codex-detail-grid, +.codex-task-form, +.codex-steer-form { + display: grid; + gap: 10px; +} +.codex-form-grid { + display: grid; + grid-template-columns: minmax(120px, 0.8fr) minmax(180px, 1fr) 92px; + gap: 8px; +} +.codex-task-list { + display: grid; + gap: 7px; + max-height: calc(100vh - 460px); + min-height: 180px; + overflow: auto; + align-content: start; +} +.codex-task-card { + display: grid; + gap: 6px; + width: 100%; + padding: 9px; + border: 1px solid var(--line-soft); + color: var(--muted); + background: + linear-gradient(135deg, rgba(78, 183, 168, 0.08), transparent 38%), + rgba(10, 16, 21, 0.74); + text-align: left; +} +.codex-task-card:hover, +.codex-task-card.selected { + color: var(--text); + border-color: var(--accent-2); + background: + linear-gradient(135deg, rgba(78, 183, 168, 0.16), transparent 42%), + rgba(12, 24, 28, 0.9); +} +.codex-task-card-head, +.codex-task-meta, +.codex-judge-line { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.codex-task-card strong { + overflow-wrap: anywhere; + color: var(--text); +} +.codex-task-meta, +.codex-judge-line { + color: var(--muted); + font-size: 11px; +} +.codex-output-panel .panel-body { + padding: 0; +} +.codex-transcript { + min-height: 520px; + max-height: calc(100vh - 300px); + overflow: auto; + padding: 12px; + background: + linear-gradient(rgba(78, 183, 168, 0.03) 1px, transparent 1px), + linear-gradient(90deg, rgba(78, 183, 168, 0.026) 1px, transparent 1px), + radial-gradient(circle at top right, rgba(215, 161, 58, 0.10), transparent 34%), + #060a0d; + background-size: 24px 24px, 24px 24px, auto, auto; +} +.codex-output-empty { + padding: 24px; + border: 1px dashed var(--line); + color: var(--muted); + background: rgba(255,255,255,0.025); +} +.codex-output-line { + display: grid; + grid-template-columns: 130px minmax(0, 1fr); + gap: 10px; + padding: 8px 0; + border-bottom: 1px solid rgba(255,255,255,0.045); +} +.codex-output-meta { + display: grid; + align-content: start; + gap: 4px; + color: var(--muted); + font-size: 10px; +} +.codex-output-meta code { + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.codex-output-channel { + width: max-content; + padding: 2px 6px; + border: 1px solid rgba(78, 183, 168, 0.42); + color: var(--accent-2); + background: rgba(78, 183, 168, 0.08); + letter-spacing: 0.08em; +} +.codex-output-line.user .codex-output-channel { color: var(--accent); border-color: rgba(215, 161, 58, 0.48); background: rgba(215, 161, 58, 0.08); } +.codex-output-line.error .codex-output-channel { color: var(--danger); border-color: rgba(207, 106, 84, 0.52); background: rgba(207, 106, 84, 0.08); } +.codex-output-line.command .codex-output-channel { color: #8fc7ee; border-color: rgba(105, 174, 232, 0.46); background: rgba(105, 174, 232, 0.08); } +.codex-output-line.diff .codex-output-channel { color: #b6da89; border-color: rgba(182, 218, 137, 0.42); background: rgba(182, 218, 137, 0.07); } +.codex-output-line pre { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + color: #d9e8e7; + font-size: 12px; + line-height: 1.48; +} +.codex-output-line.reasoning pre { color: #9fb5b8; font-style: italic; } +.codex-detail-grid { + grid-template-columns: minmax(320px, 1fr) minmax(320px, 1fr); +} +.codex-judge-card { + display: grid; + gap: 8px; + padding: 10px; + border: 1px solid var(--line-soft); + background: var(--panel-3); +} +.codex-judge-card p { + margin: 0; + color: var(--muted); +} +.codex-attempt-table { + max-height: 260px; +} +.inline-check { + display: inline-flex; + gap: 5px; + align-items: center; + color: var(--muted); +} +.inline-check input { + width: auto; +} +.mono-text { + font-family: "SFMono-Regular", "Consolas", monospace; + color: var(--muted); +} .met-control-strip, .met-tabs { display: flex; flex-wrap: wrap; @@ -1797,9 +1963,15 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } opacity: 0.82; } .pipeline-gantt-arrow.monitor, -.pipeline-gantt-arrow.guide { +.pipeline-gantt-arrow.guide, +.pipeline-gantt-arrow.observe { stroke: var(--accent-2); } +.pipeline-gantt-arrow.observation { + opacity: 0.92; + stroke-width: 1.9; + stroke-dasharray: 3 4; +} .pipeline-gantt-arrow.webui { stroke: #69aee8; } @@ -1859,7 +2031,20 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } color: #ffe3db; } .pipeline-gantt-bar.running { - animation: ganttPulse 1.8s ease-in-out infinite; + border-color: rgba(105, 174, 232, 0.95); + background: + linear-gradient(180deg, rgba(105, 174, 232, 0.95), rgba(35, 94, 133, 0.86)), + #07131d; + box-shadow: 0 0 0 1px rgba(0,0,0,0.32), 0 0 18px rgba(105, 174, 232, 0.38); + animation: ganttPulse 1.25s ease-in-out infinite; +} +.pipeline-gantt-bar.running.live::after { + content: ""; + position: absolute; + inset: -18px 0; + background: linear-gradient(180deg, transparent, rgba(255,255,255,0.82), transparent); + opacity: 0.7; + animation: ganttLiveSweep 1.4s linear infinite; } .pipeline-gantt-bar.selected { width: 10px; @@ -2054,6 +2239,46 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } max-width: 100%; overflow-wrap: anywhere; } +.pipeline-oa-panel { + display: grid; + gap: 10px; +} +.pipeline-oa-guarantees { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 7px; +} +.pipeline-oa-guarantee { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 7px; + align-items: center; + min-width: 0; + padding: 7px; + border: 1px solid var(--line-soft); + background: rgba(255,255,255,0.026); +} +.pipeline-oa-guarantee.ok { + border-color: rgba(78, 183, 168, 0.22); +} +.pipeline-oa-guarantee.warn { + border-color: rgba(215, 161, 58, 0.28); +} +.pipeline-oa-guarantee strong, +.pipeline-oa-guarantee span { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.pipeline-oa-guarantee strong { + color: var(--text); +} +.pipeline-oa-guarantee span { + color: var(--muted); + font-size: 11px; +} .pipeline-attempt-card { display: grid; gap: 8px; @@ -2150,12 +2375,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } } .pipeline-opencode-flow::before { content: ""; - position: absolute; - left: 3px; - top: 6px; - bottom: 8px; - width: 1px; - background: linear-gradient(180deg, rgba(78, 183, 168, 0.56), rgba(215, 161, 58, 0.34), rgba(105, 174, 232, 0.18)); + display: none; } .pipeline-opencode-step { position: relative; @@ -2607,8 +2827,12 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } } } @keyframes ganttPulse { - 0%, 100% { box-shadow: 0 0 0 1px rgba(0,0,0,0.32), 0 0 12px rgba(215, 161, 58, 0.28); } - 50% { box-shadow: 0 0 0 1px rgba(215, 161, 58, 0.52), 0 0 20px rgba(215, 161, 58, 0.48); } + 0%, 100% { box-shadow: 0 0 0 1px rgba(0,0,0,0.32), 0 0 12px rgba(105, 174, 232, 0.28); } + 50% { box-shadow: 0 0 0 1px rgba(105, 174, 232, 0.62), 0 0 24px rgba(105, 174, 232, 0.58); } +} +@keyframes ganttLiveSweep { + 0% { transform: translateY(-48px); } + 100% { transform: translateY(48px); } } .pipeline-node-control { min-width: 0; @@ -2773,6 +2997,114 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } color: var(--muted); font-size: 12px; } +.pipeline-score-board { + display: grid; + gap: 8px; +} +.pipeline-score-card, .pipeline-score-empty { + display: grid; + gap: 8px; + min-width: 0; + padding: 10px; + border: 1px solid var(--line-soft); + background: + linear-gradient(135deg, rgba(78, 183, 168, 0.08), transparent 36%), + var(--panel-3); +} +.pipeline-score-empty strong { + color: var(--accent); +} +.pipeline-score-empty span { + color: var(--muted); +} +.pipeline-score-head { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: flex-start; +} +.pipeline-score-head div { + display: grid; + gap: 2px; + min-width: 0; +} +.pipeline-score-head span { + overflow: hidden; + color: var(--muted); + font-size: 10px; + letter-spacing: 0.12em; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; +} +.pipeline-score-head strong { + color: var(--accent); + font-size: 30px; + line-height: 1; +} +.pipeline-score-meter { + height: 7px; + overflow: hidden; + border: 1px solid rgba(255,255,255,0.08); + background: rgba(0,0,0,0.24); +} +.pipeline-score-meter span { + display: block; + height: 100%; + min-width: 2px; + max-width: 100%; + background: linear-gradient(90deg, var(--accent-2), var(--accent)); +} +.pipeline-score-card.failed .pipeline-score-meter span { background: linear-gradient(90deg, var(--danger), var(--accent)); } +.pipeline-score-card.running .pipeline-score-meter span { background: linear-gradient(90deg, var(--accent), var(--accent-2)); } +.pipeline-score-facts, .pipeline-score-items { + display: flex; + flex-wrap: wrap; + gap: 5px; +} +.pipeline-score-facts span, .pipeline-score-badge, .pipeline-score-item { + padding: 3px 7px; + border: 1px solid var(--line-soft); + background: rgba(255,255,255,0.03); + color: var(--muted); + font-size: 11px; +} +.pipeline-score-badge { + text-transform: uppercase; +} +.pipeline-score-badge.succeeded, .pipeline-score-item.passed { + border-color: rgba(78, 183, 168, 0.55); + color: var(--accent-2); +} +.pipeline-score-badge.failed, .pipeline-score-item.failed { + border-color: rgba(207, 106, 84, 0.58); + color: var(--danger); +} +.pipeline-score-badge.running { + border-color: rgba(215, 161, 58, 0.58); + color: var(--accent); +} +.pipeline-score-item { + display: grid; + gap: 1px; + min-width: 62px; +} +.pipeline-score-item b { + color: var(--text); + font-size: 12px; +} +.pipeline-score-item small { + color: currentColor; + font-size: 9px; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.pipeline-score-error { + margin: 0; + color: var(--danger); + font-size: 12px; + line-height: 1.45; +} .component-stratum span { color: var(--muted); font-size: 10px; @@ -2915,9 +3247,10 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } @media (max-width: 1120px) { .metric-grid, .policy-grid, .security-board, .docker-metrics, .monitor-chart-grid, .monitor-summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .pipeline-oa-guarantees { grid-template-columns: repeat(2, minmax(0, 1fr)); } .dispatch-form { grid-template-columns: 1fr 1fr; } .dispatch-actions { align-items: center; } - .page-grid, .docker-layout, .monitor-layout, .findjob-grid, .findjob-hero, .pipeline-grid, .pipeline-hero, .met-grid, .met-form-grid { grid-template-columns: 1fr; } + .page-grid, .docker-layout, .monitor-layout, .findjob-grid, .findjob-hero, .pipeline-grid, .pipeline-hero, .met-grid, .met-form-grid, .codex-queue-layout, .codex-queue-hero, .codex-detail-grid { grid-template-columns: 1fr; } .pipeline-control-shell { grid-template-columns: 1fr; } .pipeline-node-control { max-height: none; min-height: 0; } .findjob-grid .panel:nth-child(3), .pipeline-grid .panel:nth-child(3), .pipeline-grid .panel:nth-child(5), .met-grid .panel:nth-child(3), .met-grid .panel:nth-child(5), .met-detail-panel { grid-column: 1; } @@ -2932,6 +3265,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } .pipeline-control-evidence-grid, .pipeline-evidence-row, .pipeline-gantt-detail-layout, + .pipeline-oa-guarantees, .pipeline-kv-grid, .pipeline-field-list { grid-template-columns: 1fr; @@ -3034,8 +3368,10 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } padding: 4px 9px; white-space: nowrap; } - .metric-grid, .policy-grid, .security-board, .dispatch-form, .docker-metrics, .monitor-chart-grid, .monitor-summary-grid, .gateway-record-grid, .met-detail-kv { grid-template-columns: 1fr; } - .compact-row, .heartbeat-row, .log-row, .endpoint-list article, .volume-route, .findjob-hero, .pipeline-hero { grid-template-columns: 1fr; align-items: start; } + .metric-grid, .policy-grid, .security-board, .dispatch-form, .docker-metrics, .monitor-chart-grid, .monitor-summary-grid, .gateway-record-grid, .met-detail-kv, .codex-queue-metrics, .codex-form-grid { grid-template-columns: 1fr; } + .compact-row, .heartbeat-row, .log-row, .endpoint-list article, .volume-route, .findjob-hero, .pipeline-hero, .codex-queue-hero { grid-template-columns: 1fr; align-items: start; } + .codex-output-line { grid-template-columns: 1fr; } + .codex-transcript { min-height: 360px; } .process-resource-head { align-items: stretch; flex-direction: column; diff --git a/src/components/frontend/src/app.tsx b/src/components/frontend/src/app.tsx index c7301dbe..875131eb 100644 --- a/src/components/frontend/src/app.tsx +++ b/src/components/frontend/src/app.tsx @@ -1,5 +1,6 @@ import React from "react"; import { createRoot } from "react-dom/client"; +import { CodexQueuePage } from "./codex-queue"; import { FindJobPage } from "./findjob"; import { MetNonlinearPage } from "./met-nonlinear"; import { canonicalizeKnownRoute, createRouteRegistry, DEFAULT_ACTIVE_TABS, MODULES, pathForTarget, resolveRouteTarget } from "./navigation"; @@ -1221,6 +1222,7 @@ function MicroserviceCatalogPage({ microservices, onRaw, onNavigate }: AnyRecord service.id === "pipeline" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "pipeline"), "data-testid": "open-pipeline-button" }, "打开") : null, service.id === "todo-note" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "todo-note"), "data-testid": "open-todo-note-button" }, "打开") : null, service.id === "met-nonlinear" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "met-nonlinear"), "data-testid": "open-met-nonlinear-button" }, "打开") : null, + service.id === "codex-queue" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "codex-queue"), "data-testid": "open-codex-queue-button" }, "打开") : null, h(RawButton, { title: `Microservice ${service.id}`, data: service, onOpen: onRaw }), ), ), @@ -1481,6 +1483,7 @@ function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw, onNa if (activeModule === "apps" && activeTab === "findjob") return h(FindJobPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl }); if (activeModule === "apps" && activeTab === "pipeline") return h(PipelinePage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl }); if (activeModule === "apps" && activeTab === "met-nonlinear") return h(MetNonlinearPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl }); + if (activeModule === "apps" && activeTab === "codex-queue") return h(CodexQueuePage, { 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 }); if (activeModule === "config" && activeTab === "security") return h(SecurityPage); diff --git a/src/components/frontend/src/codex-queue.tsx b/src/components/frontend/src/codex-queue.tsx new file mode 100644 index 00000000..343a1201 --- /dev/null +++ b/src/components/frontend/src/codex-queue.tsx @@ -0,0 +1,435 @@ +import React from "react"; + +type AnyRecord = Record; + +const h = React.createElement; +const { useEffect, useMemo, useRef } = React; +const useState: any = React.useState; + +function errorMessage(error: unknown, fallback = "操作失败"): string { + return error instanceof Error ? error.message : String(error || fallback); +} + +function fmtDate(value: any): string { + if (!value) return "--"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "--"; + return date.toLocaleString("zh-CN", { hour12: false }); +} + +function fmtClock(value: Date): string { + return value.toLocaleTimeString("zh-CN", { hour12: false }); +} + +function shortText(value: any, max = 180): string { + const text = String(value || "").replace(/\s+/gu, " ").trim(); + return text.length > max ? `${text.slice(0, max - 1)}…` : text; +} + +async function requestJson(path: string, options: AnyRecord = {}): Promise { + const headers = new Headers(options.headers || {}); + const body = options.body && typeof options.body !== "string" ? JSON.stringify(options.body) : options.body; + if (body && !headers.has("content-type")) headers.set("content-type", "application/json"); + const response = await fetch(path, { credentials: "same-origin", ...options, body, headers }); + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = { text }; + } + if (!response.ok || payload?.ok === false) { + const message = payload?.error?.message || payload?.error || `HTTP ${response.status}`; + const error = new Error(message); + (error as Error & { status?: number }).status = response.status; + throw error; + } + return payload; +} + +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 }: AnyRecord) { + return h("section", { className: `panel ${className || ""}` }, + h("div", { className: "panel-head" }, + h("div", null, + eyebrow ? h("p", { className: "panel-eyebrow" }, eyebrow) : null, + h("h2", null, title), + ), + 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 codexApi(apiBaseUrl: string, path: string): string { + return `${apiBaseUrl}/microservices/codex-queue/proxy${path}`; +} + +function taskRows(data: any): any[] { + return Array.isArray(data?.tasks) ? data.tasks : []; +} + +function taskOutput(task: any): any[] { + return Array.isArray(task?.output) ? task.output : []; +} + +function taskAttempts(task: any): any[] { + return Array.isArray(task?.attempts) ? task.attempts : []; +} + +function queueCounts(queue: any): AnyRecord { + return queue?.counts && typeof queue.counts === "object" && !Array.isArray(queue.counts) ? queue.counts : {}; +} + +function splitPromptTasks(prompt: string): string[] { + return prompt + .split(/^\s*---+\s*$/gmu) + .map((part) => part.trim()) + .filter(Boolean); +} + +function channelLabel(channel: string): string { + const labels: Record = { + system: "SYS", + user: "YOU", + assistant: "GPT", + reasoning: "THINK", + command: "CMD", + diff: "DIFF", + tool: "TOOL", + error: "ERR", + }; + return labels[channel] || channel.toUpperCase(); +} + +function taskIsActive(task: any): boolean { + return ["running", "judging", "retry_wait"].includes(String(task?.status || "")); +} + +function countValue(counts: AnyRecord, key: string): string { + const value = Number(counts[key] ?? 0); + return Number.isFinite(value) ? String(value) : "0"; +} + +function TaskCard({ task, selected, onSelect }: AnyRecord) { + const judge = task?.lastJudge || {}; + return h("button", { + type: "button", + className: `codex-task-card ${selected ? "selected" : ""}`, + onClick: onSelect, + "data-testid": `codex-task-${task?.id || "unknown"}`, + }, + h("div", { className: "codex-task-card-head" }, + h(StatusBadge, { status: task?.status }, task?.status || "unknown"), + h("span", { className: "mono-text" }, `${task?.currentAttempt || 0}/${task?.maxAttempts || 0}`), + ), + h("strong", null, shortText(task?.prompt, 120) || "空任务"), + h("div", { className: "codex-task-meta" }, + h("span", null, task?.model || "--"), + h("span", null, fmtDate(task?.updatedAt)), + ), + judge?.decision ? h("div", { className: "codex-judge-line" }, `judge=${judge.decision} ${Math.round(Number(judge.confidence || 0) * 100)}%`) : null, + ); +} + +function Transcript({ task, autoScroll }: AnyRecord) { + const ref = useRef(null); + const output = taskOutput(task); + useEffect(() => { + if (autoScroll && ref.current) ref.current.scrollTop = ref.current.scrollHeight; + }, [autoScroll, output.length, task?.id]); + if (!task) return h(EmptyState, { title: "未选择任务", text: "从左侧队列选择任务,或提交新 Codex 任务。" }); + return h("div", { className: "codex-transcript", ref, "data-testid": "codex-output" }, + output.length === 0 ? h("div", { className: "codex-output-empty" }, "等待 Codex 输出...") : output.map((item: any) => h("article", { key: `${item.seq}-${item.channel}`, className: `codex-output-line ${item.channel || "system"}` }, + h("div", { className: "codex-output-meta" }, + h("span", { className: "codex-output-channel" }, channelLabel(String(item.channel || "system"))), + h("span", null, fmtDate(item.at)), + item.method ? h("code", null, item.method) : null, + ), + h("pre", null, String(item.text || "")), + )), + ); +} + +function AttemptTable({ task }: AnyRecord) { + const attempts = taskAttempts(task).slice().reverse(); + if (attempts.length === 0) return h(EmptyState, { title: "尚无 attempt", text: "任务开始运行后,这里会记录 Codex 终态、传输中断和 stderr tail。" }); + return h("div", { className: "table-wrap codex-attempt-table" }, + h("table", null, + 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("tbody", null, attempts.map((attempt: any) => h("tr", { key: `${attempt.index}-${attempt.startedAt}` }, + h("td", null, attempt.index), + h("td", null, attempt.mode), + h("td", null, h(StatusBadge, { status: attempt.terminalStatus || "unknown" }, attempt.terminalStatus || "unknown")), + h("td", null, attempt.transportClosedBeforeTerminal ? h(StatusBadge, { status: "failed" }, "closed-before-terminal") : h(StatusBadge, { status: "succeeded" }, "normal")), + h("td", null, `code=${attempt.appServerExitCode ?? "--"} signal=${attempt.appServerSignal ?? "--"}`), + h("td", null, fmtDate(attempt.finishedAt)), + ))), + ), + ); +} + +export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyRecord) { + const service = microservices.find((item: any) => item.id === "codex-queue") || null; + const [health, setHealth] = useState(null); + const [tasksData, setTasksData] = useState(null); + const [selectedId, setSelectedId] = useState(""); + const [selectedTask, setSelectedTask] = useState(null); + const [prompt, setPrompt] = useState("请在 UniDesk 工作区中完成一个很小的验证任务:读取 package.json 并总结项目名称,不要修改文件。"); + const [model, setModel] = useState("gpt-5.4-mini"); + const [cwd, setCwd] = useState("/workspace"); + const [maxAttempts, setMaxAttempts] = useState(3); + const [steerPrompt, setSteerPrompt] = useState(""); + const [autoScroll, setAutoScroll] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [refreshedAt, setRefreshedAt] = useState(null); + + const tasks = taskRows(tasksData); + const queue = tasksData?.queue || health?.body?.queue || health?.queue || {}; + const counts = queueCounts(queue); + const activeTaskId = queue?.activeTaskId || tasks.find((task: any) => taskIsActive(task))?.id || ""; + const runtime = service ? microserviceRuntime(service) : {}; + const repository = service ? microserviceRepository(service) : {}; + const backend = service ? microserviceBackend(service) : {}; + const promptParts = useMemo(() => splitPromptTasks(prompt), [prompt]); + const selectedCanSteer = selectedTask?.id && selectedTask?.activeTurnId && String(selectedTask?.status) === "running"; + const selectedCanInterrupt = selectedTask?.id && !["succeeded", "failed", "canceled"].includes(String(selectedTask?.status || "")); + const selectedCanRetry = selectedTask?.id && ["succeeded", "failed", "canceled"].includes(String(selectedTask?.status || "")); + + async function load(preferId = selectedId): Promise { + if (!service) return; + const [healthResult, tasksResult] = await Promise.all([ + requestJson(`${apiBaseUrl}/microservices/codex-queue/health`), + requestJson(codexApi(apiBaseUrl, "/api/tasks?limit=80")), + ]); + setHealth(healthResult); + setTasksData(tasksResult); + const rows = taskRows(tasksResult); + const nextId = preferId && rows.some((task: any) => task.id === preferId) + ? preferId + : (tasksResult?.queue?.activeTaskId || rows.find((task: any) => taskIsActive(task))?.id || rows[0]?.id || ""); + setSelectedId(nextId); + if (nextId) { + const detail = await requestJson(codexApi(apiBaseUrl, `/api/tasks/${encodeURIComponent(nextId)}`)); + setSelectedTask(detail?.task || null); + } else { + setSelectedTask(null); + } + setRefreshedAt(new Date()); + } + + async function guarded(action: () => Promise, message: string): Promise { + setBusy(true); + setError(""); + try { + await action(); + } catch (err) { + setError(errorMessage(err, message)); + } finally { + setBusy(false); + } + } + + async function enqueue(event: any): Promise { + event.preventDefault(); + await guarded(async () => { + if (promptParts.length === 0) throw new Error("prompt 不能为空"); + const body = promptParts.length === 1 + ? { prompt: promptParts[0], model, cwd, maxAttempts: Number(maxAttempts) } + : { tasks: promptParts.map((text) => ({ prompt: text, model, cwd, maxAttempts: Number(maxAttempts) })) }; + const result = await requestJson(codexApi(apiBaseUrl, promptParts.length === 1 ? "/api/tasks" : "/api/tasks/batch"), { method: "POST", body }); + const firstId = result?.tasks?.[0]?.id || ""; + await load(firstId); + }, "Codex 任务入队失败"); + } + + async function steer(event: any): Promise { + event.preventDefault(); + if (!selectedTask?.id) return; + await guarded(async () => { + await requestJson(codexApi(apiBaseUrl, `/api/tasks/${encodeURIComponent(selectedTask.id)}/steer`), { method: "POST", body: { prompt: steerPrompt } }); + setSteerPrompt(""); + await load(selectedTask.id); + }, "追加 prompt 失败"); + } + + async function interrupt(): Promise { + if (!selectedTask?.id) return; + await guarded(async () => { + await requestJson(codexApi(apiBaseUrl, `/api/tasks/${encodeURIComponent(selectedTask.id)}/interrupt`), { method: "POST", body: {} }); + await load(selectedTask.id); + }, "打断 Codex session 失败"); + } + + async function retry(): Promise { + if (!selectedTask?.id) return; + await guarded(async () => { + await requestJson(codexApi(apiBaseUrl, `/api/tasks/${encodeURIComponent(selectedTask.id)}/retry`), { method: "POST", body: {} }); + await load(selectedTask.id); + }, "重新入队失败"); + } + + useEffect(() => { + void guarded(() => load(), "Codex Queue 加载失败"); + }, [service?.id, service?.runtime?.providerStatus]); + + useEffect(() => { + if (!service) return undefined; + const timer = window.setInterval(() => { + void load(selectedId).catch((err) => setError(errorMessage(err, "Codex Queue 轮询失败"))); + }, 1500); + return () => window.clearInterval(timer); + }, [service?.id, selectedId]); + + if (!service) return h(EmptyState, { title: "Codex Queue 未登记", text: "请在 config.json 的 microservices 中登记 id=codex-queue" }); + + return h("div", { className: "codex-queue-page", "data-testid": "codex-queue-page" }, + h(Panel, { + title: "Codex Queue 控制台", + eyebrow: "App-Server Task Deck", + actions: h("div", { className: "panel-actions" }, + h("button", { type: "button", className: "ghost-btn", onClick: () => void guarded(() => load(selectedId), "刷新失败"), disabled: busy, "data-testid": "codex-refresh-button" }, busy ? "同步中" : "刷新"), + h(RawButton, { title: "Codex Queue Microservice", data: service, onOpen: onRaw, testId: "raw-codex-queue-service" }), + ), + }, + h("div", { className: "codex-queue-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.public ? "公网暴露" : "仅 UniDesk frontend 代理访问"), + h("span", null, queue?.judgeConfigured ? `MiniMax ${queue?.minimaxModel || "M2.7"}` : "Fallback judge"), + ), + h("p", { className: "muted paragraph" }, service.description), + ), + h("div", { className: "microservice-ref-card" }, + h("span", null, "Codex"), + h("strong", null, queue?.defaultModel || "gpt-5.4-mini"), + h("code", null, "codex app-server --listen stdio://"), + ), + h("div", { className: "microservice-ref-card" }, + h("span", null, "Backend"), + h("strong", null, `${backend.nodeBindHost || "--"}:${backend.nodePort || "--"}`), + h("code", null, repository.containerName || "codex-queue-backend"), + ), + ), + error ? h("div", { className: "form-error wide" }, error) : null, + ), + h("div", { className: "codex-queue-metrics" }, + h(MetricCard, { label: "排队", value: countValue(counts, "queued"), hint: "waiting turns" }), + h(MetricCard, { label: "运行", value: countValue(counts, "running"), hint: activeTaskId ? `active ${String(activeTaskId).slice(0, 16)}` : "idle", tone: activeTaskId ? "warn" : "ok" }), + h(MetricCard, { label: "成功", value: countValue(counts, "succeeded"), hint: "completed tasks", tone: "ok" }), + h(MetricCard, { label: "异常/取消", value: String(Number(counts.failed || 0) + Number(counts.canceled || 0)), hint: "terminal non-success", tone: Number(counts.failed || 0) > 0 ? "fail" : "" }), + h(MetricCard, { label: "最近刷新", value: refreshedAt ? fmtClock(refreshedAt) : "--", hint: "1.5s polling" }), + ), + h("div", { className: "codex-queue-layout" }, + h("div", { className: "codex-left-rail" }, + h(Panel, { title: "提交任务", eyebrow: promptParts.length > 1 ? `${promptParts.length} tasks` : "Single or Batch", className: "codex-compose-panel" }, + h("form", { className: "codex-task-form", onSubmit: enqueue, "data-testid": "codex-queue-task-form" }, + h("label", null, "Prompt / 多任务用单独一行 --- 分隔", + h("textarea", { value: prompt, rows: 8, onChange: (event: any) => setPrompt(event.target.value), placeholder: "写入 Codex 任务;多个任务之间用 --- 分隔。" }), + ), + h("div", { className: "codex-form-grid" }, + h("label", null, "模型", h("input", { value: model, onChange: (event: any) => setModel(event.target.value), placeholder: "gpt-5.4-mini" })), + h("label", null, "工作目录", h("input", { value: cwd, onChange: (event: any) => setCwd(event.target.value), placeholder: "/workspace" })), + h("label", null, "最大尝试", h("input", { type: "number", min: 1, max: 10, value: maxAttempts, onChange: (event: any) => setMaxAttempts(Number(event.target.value)) })), + ), + h("button", { type: "submit", className: "primary-btn", disabled: busy || promptParts.length === 0 }, promptParts.length > 1 ? `批量入队 ${promptParts.length} 个任务` : "入队并运行"), + ), + ), + h(Panel, { title: "队列", eyebrow: `${tasks.length} visible` }, + h("div", { className: "codex-task-list" }, + tasks.length === 0 ? h(EmptyState, { title: "队列为空", text: "提交一个任务后,Codex 会串行执行并保存输出。" }) : tasks.map((task: any) => h(TaskCard, { + key: task.id, + task, + selected: selectedId === task.id, + onSelect: () => { + setSelectedId(task.id); + void load(task.id); + }, + })), + ), + ), + ), + h("div", { className: "codex-main-stage" }, + h(Panel, { + title: selectedTask ? `Session ${String(selectedTask.id).slice(0, 22)}` : "Session 输出", + eyebrow: selectedTask ? `${selectedTask.status} / ${selectedTask.model}` : "Codex CLI-like stream", + actions: h("div", { className: "panel-actions" }, + h("label", { className: "inline-check" }, h("input", { type: "checkbox", checked: autoScroll, onChange: (event: any) => setAutoScroll(Boolean(event.target.checked)) }), "自动滚动"), + h("button", { type: "button", className: "ghost-btn", disabled: !selectedCanInterrupt || busy, onClick: () => void interrupt(), "data-testid": "codex-interrupt-button" }, "打断"), + h("button", { type: "button", className: "ghost-btn", disabled: !selectedCanRetry || busy, onClick: () => void retry() }, "重试"), + selectedTask ? h(RawButton, { title: "Codex Task", data: selectedTask, onOpen: onRaw, testId: "raw-codex-task" }) : null, + ), + className: "codex-output-panel", + }, + h(Transcript, { task: selectedTask, autoScroll }), + ), + h("div", { className: "codex-detail-grid" }, + h(Panel, { title: "运行控制", eyebrow: selectedCanSteer ? "Active turn steer" : "Steer when running" }, + h("form", { className: "codex-steer-form", onSubmit: steer }, + h("label", null, "追加 prompt", + h("textarea", { value: steerPrompt, rows: 4, onChange: (event: any) => setSteerPrompt(event.target.value), placeholder: "给正在运行的 Codex session 推入新的指令或纠偏。", disabled: !selectedCanSteer }), + ), + h("button", { type: "submit", className: "primary-btn", disabled: !selectedCanSteer || busy || steerPrompt.trim().length === 0, "data-testid": "codex-steer-button" }, "推入运行中 session"), + ), + ), + h(Panel, { title: "完成判定", eyebrow: selectedTask?.lastJudge ? selectedTask.lastJudge.source : "judge" }, + selectedTask?.lastJudge ? h("div", { className: "codex-judge-card" }, + h(StatusBadge, { status: selectedTask.lastJudge.decision }, selectedTask.lastJudge.decision), + h("strong", null, `${Math.round(Number(selectedTask.lastJudge.confidence || 0) * 100)}% confidence`), + h("p", null, selectedTask.lastJudge.reason || "--"), + selectedTask.lastJudge.continuePrompt ? h("code", null, shortText(selectedTask.lastJudge.continuePrompt, 220)) : null, + ) : h(EmptyState, { title: "尚未判定", text: "Codex turn 结束后会由 MiniMax M2.7 或 fallback judge 判定 complete/retry/fail;retry 会在已有 thread 追加继续执行 prompt。" }), + ), + ), + h(Panel, { title: "Attempts", eyebrow: "terminal vs interruption" }, h(AttemptTable, { task: selectedTask })), + ), + ), + ); +} diff --git a/src/components/frontend/src/navigation.ts b/src/components/frontend/src/navigation.ts index 88f9a086..d26295ac 100644 --- a/src/components/frontend/src/navigation.ts +++ b/src/components/frontend/src/navigation.ts @@ -64,6 +64,7 @@ export const MODULES: UniDeskModuleDefinition[] = [ { id: "findjob", label: "FindJob" }, { id: "pipeline", label: "Pipeline" }, { id: "met-nonlinear", label: "MET Nonlinear" }, + { id: "codex-queue", label: "Codex Queue" }, ] }, { id: "config", label: "系统配置", code: "CFG", tabs: [ { id: "topology", label: "连接拓扑" }, diff --git a/src/components/frontend/src/pipeline.tsx b/src/components/frontend/src/pipeline.tsx index 73799667..543008ba 100644 --- a/src/components/frontend/src/pipeline.tsx +++ b/src/components/frontend/src/pipeline.tsx @@ -30,6 +30,7 @@ const pipelineSnapshotRunLimit = 10; const pipelineGanttTimeAxisWidth = 96; const pipelineGanttNodeColumnWidth = 72; const pipelineGanttHeaderHeight = 64; +const pipelineGanttArrowTipInsetPx = 12; function pipelinePercent(value: any, fallback: number): number { const number = Number.parseFloat(String(value || "")); @@ -250,6 +251,11 @@ function terminalStatus(status: any): boolean { return ["succeeded", "failed", "skipped", "cancelled", "canceled", "completed"].includes(String(status || "").toLowerCase()); } +function runningStatus(status: any): boolean { + const value = statusValue(status).toLowerCase(); + return ["running", "active", "in-progress", "in_progress"].includes(value); +} + function statusCounts(items: any[], key = "status"): AnyRecord { return items.reduce((counts: AnyRecord, item: any) => { const status = String(item?.[key] || "unknown").toLowerCase(); @@ -877,6 +883,8 @@ function PipelineOpenCodeStep({ step, matched = false }: AnyRecord) { : []; const roleTone = stepRoleTone(step?.role); const statusTone = stepStatusTone(step); + const stepStartMs = timeMs(step?.createdAt) ?? timeMs(step?.completedAt); + const stepEndMs = timeMs(step?.completedAt) ?? stepStartMs; const toolNames = uniqueStrings([...asArray(step?.tools), ...tools.map((part: any) => part?.tool)]).slice(0, 5); const facts = [ step?.finish ? `finish ${step.finish}` : "", @@ -887,7 +895,13 @@ function PipelineOpenCodeStep({ step, matched = false }: AnyRecord) { ].filter(Boolean); const messageRows = pipelineMessageCardRows(step, textParts, reasoningParts); - return h("details", { className: `pipeline-opencode-step ${roleTone} ${statusTone} ${matched ? "matched" : ""}`, open: matched ? true : undefined, "data-testid": "pipeline-opencode-step" }, + return h("details", { + className: `pipeline-opencode-step ${roleTone} ${statusTone} ${matched ? "matched" : ""}`, + open: matched ? true : undefined, + "data-testid": "pipeline-opencode-step", + "data-step-start-ms": stepStartMs !== null ? String(stepStartMs) : "", + "data-step-end-ms": stepEndMs !== null ? String(stepEndMs) : "", + }, h("summary", { className: "pipeline-opencode-step-summary", "data-testid": "pipeline-opencode-step-summary" }, h(PipelineStepTimeSummary, { step, role: roleTone, matched }), h(PipelineStepMessageCard, { rows: messageRows, compact: true, role: roleTone, matched }), @@ -962,7 +976,6 @@ function controlActionValue(record: any): string { function controlActionLabel(record: any): string { switch (controlActionValue(record)) { - case "audit-request": return "待审核"; case "guide": return "引导"; case "modify": return "修改"; case "approve": return "审核通过"; @@ -978,9 +991,9 @@ function eventLabel(record: any): string { case "append-prompt-delivered": return "追加 prompt"; case "append-prompt-queued": return "追加 prompt 已排队"; case "monitor-prompt-delivered": return "Monitor prompt"; - case "monitor-audit-requested": return "待审核"; - case "monitor-audit-approved": return "审核通过"; - case "monitor-audit-intervened": return "审核被打断"; + case "node-long-running-observation": return "长任务观察"; + case "node-finished": return "节点完成"; + case "oa-policy-downstream-evaluated": return "OA 下游策略"; case "control-command-queued": return `${controlActionLabel(record)} 已发起`; case "control-command-applied": return `${controlActionLabel(record)} 已生效`; case "control-command-ignored": return `${controlActionLabel(record)} 已忽略`; @@ -1162,7 +1175,7 @@ function PipelineProcedureAttemptList({ procedure, matchedStepKey = "", matchedA { label: "updated", value: fmtDate(messages.updatedAt) }, { label: "sessions", value: sessionIds.join(", ") || "--" }, ] }), - steps.length === 0 ? h("p", { className: "muted paragraph" }, "当前 attempt 尚未返回 OpenCode step 摘要;请确认 D601 pipeline-webui 已重建并重新抓取。") : + steps.length === 0 ? h("p", { className: "muted paragraph" }, "当前 attempt 尚未返回 OpenCode step 摘要;请确认 D601 pipeline-control 已重建并重新抓取。") : h("section", { className: "pipeline-opencode-timeline", "data-testid": "pipeline-step-timeline" }, h("div", { className: "pipeline-opencode-timeline-head" }, h("div", null, @@ -1472,6 +1485,116 @@ function pipelineStatusCounts(runs: any[]): AnyRecord { }, {}); } +function pipelineRunScorers(run: any): AnyRecord[] { + if (Array.isArray(run?.scorers)) return run.scorers.filter(isRecord); + if (Array.isArray(run?.summary?.scorers)) return run.summary.scorers.filter(isRecord); + if (Array.isArray(run?.artifact?.summary?.scorers)) return run.artifact.summary.scorers.filter(isRecord); + return []; +} + +function pipelineDetailedRun(details: any): AnyRecord | null { + if (isRecord(details?.run)) return details.run; + if (isRecord(details?.runSummary)) return details.runSummary; + return null; +} + +function mergePipelineRunSummary(base: any, detail: any): AnyRecord | null { + if (!isRecord(base) && !isRecord(detail)) return null; + if (!isRecord(base)) return detail; + if (!isRecord(detail)) return base; + return { + ...base, + ...detail, + request: isRecord(base.request) || isRecord(detail.request) ? { ...(isRecord(base.request) ? base.request : {}), ...(isRecord(detail.request) ? detail.request : {}) } : detail.request ?? base.request, + artifact: isRecord(base.artifact) || isRecord(detail.artifact) ? { ...(isRecord(base.artifact) ? base.artifact : {}), ...(isRecord(detail.artifact) ? detail.artifact : {}) } : detail.artifact ?? base.artifact, + summary: isRecord(base.summary) || isRecord(detail.summary) ? { ...(isRecord(base.summary) ? base.summary : {}), ...(isRecord(detail.summary) ? detail.summary : {}) } : detail.summary ?? base.summary, + }; +} + +function pipelineScoreSummary(run: any): AnyRecord { + const scorers = pipelineRunScorers(run); + const primary = scorers.find((scorer) => isRecord(scorer?.score)) || scorers[0] || null; + const score = isRecord(primary?.score) ? primary.score : {}; + const passed = Number(score.passed); + const total = Number(score.total); + const ratio = Number(score.ratio); + const computedRatio = Number.isFinite(ratio) + ? ratio + : Number.isFinite(passed) && Number.isFinite(total) && total > 0 ? passed / total : null; + const percent = computedRatio === null ? null : Math.round(Math.max(0, Math.min(100, computedRatio <= 1 ? computedRatio * 100 : computedRatio))); + const text = String(score.text || (Number.isFinite(passed) && Number.isFinite(total) ? `${passed}/${total}` : "")); + return { + scorer: primary, + scorers, + score, + passed: Number.isFinite(passed) ? passed : null, + total: Number.isFinite(total) ? total : null, + percent, + text, + }; +} + +function pipelineScoreText(run: any): string { + const summary = pipelineScoreSummary(run); + return summary.text || (summary.scorers.length > 0 ? String(summary.scorer?.status || "pending") : "--"); +} + +function pipelineScoreTone(run: any): string { + const summary = pipelineScoreSummary(run); + if (summary.total > 0 && summary.passed === summary.total) return "succeeded"; + if (summary.total > 0 && summary.passed > 0) return "running"; + if (summary.scorers.length > 0) return "failed"; + return "pending"; +} + +function pipelineScorerItems(scorer: any): AnyRecord[] { + return Array.isArray(scorer?.items) ? scorer.items.filter(isRecord) : []; +} + +function PipelineScoreBadge({ run }: AnyRecord) { + const text = pipelineScoreText(run); + return h("span", { className: `pipeline-score-badge ${pipelineScoreTone(run)}` }, `score ${text}`); +} + +function PipelineScoreBoard({ run, onRaw }: AnyRecord) { + const summary = pipelineScoreSummary(run); + const scorers = summary.scorers; + if (!run) return h(EmptyState, { title: "暂无评分", text: "选择一个 epoch 后会显示 scorer 结果。" }); + if (scorers.length === 0) return h("div", { className: "pipeline-score-empty" }, + h("strong", null, "评分器等待中"), + h("span", null, "DAG 完成后,Pipeline control backend 会把 scorer summary 追加到 run artifact,并通过 UniDesk 显示。"), + ); + return h("div", { className: "pipeline-score-board", "data-testid": "pipeline-score-board" }, + scorers.map((scorer: AnyRecord, index: number) => { + const localSummary = pipelineScoreSummary({ scorers: [scorer] }); + const items = pipelineScorerItems(scorer); + const percent = localSummary.percent ?? 0; + return h("article", { key: `${scorer.scorerId || scorer.component || index}`, className: `pipeline-score-card ${pipelineScoreTone({ scorers: [scorer] })}` }, + h("div", { className: "pipeline-score-head" }, + h("div", null, + h("span", null, scorer.scorerId || scorer.component || "scorer"), + h("strong", null, localSummary.text || scorer.status || "--"), + ), + h(StatusBadge, { status: scorer.status || "unknown" }, scorer.status || "unknown"), + ), + h("div", { className: "pipeline-score-meter", "aria-label": `score ${percent}%` }, h("span", { style: { width: `${percent}%` } })), + h("div", { className: "pipeline-score-facts" }, + h("span", null, `${percent}%`), + h("span", null, scorer.component || "--"), + h("span", null, scorer.applicationCheckoutRef || "--"), + ), + items.length > 0 ? h("div", { className: "pipeline-score-items" }, items.map((item: AnyRecord) => h("span", { + key: `${item.id || item.filter}`, + className: `pipeline-score-item ${String(item.status || "").toLowerCase()}`, + title: `${item.filter || "--"} / ran=${item.ran ?? "?"}`, + }, h("b", null, item.id || "--"), h("small", null, item.status || "--")))) : h("p", { className: "muted paragraph" }, "当前 scorer 尚未返回 item 级结果。"), + scorer.error ? h("p", { className: "pipeline-score-error" }, previewText(scorer.error, 360)) : null, + h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: `Scorer ${scorer.scorerId || index}`, data: scorer, onOpen: onRaw, testId: "raw-pipeline-score" })), + ); + }), + ); +} + function pipelineComponentClassCounts(components: any[]): Array<{ name: string; count: number }> { const counts = components.reduce((memo: AnyRecord, component: any) => { const name = String(component?.componentClass || "unknown"); @@ -1496,7 +1619,23 @@ function pipelineNodeIsMonitor(node: any, component?: any): boolean { if (String(node?.kind || "").toLowerCase() !== "procedure") return false; const monitorInputs = pipelineMonitorInputs(node); if (node?.instanceInputs?.monitorMode === true || monitorInputs.enabled === true) return true; - return String(component?.id || component?.config?.id || "").toLowerCase().includes("monitor"); + const componentRef = pipelineComponentRef(node?.componentRef); + return String(component?.id || component?.config?.id || componentRef || "").toLowerCase().includes("monitor"); +} + +function pipelineMonitorNodeIds(pipelineNodes: AnyRecord[]): string[] { + return pipelineNodes + .filter((node: any) => pipelineNodeIsMonitor(node)) + .map((node: any) => String(node?.id || "")) + .filter(Boolean); +} + +function pipelineOrderWithLeadingMonitors(nodeIds: string[], monitorNodeIds: string[]): string[] { + if (monitorNodeIds.length === 0) return nodeIds; + const monitorSet = new Set(monitorNodeIds); + const leading = monitorNodeIds.filter((nodeId) => nodeIds.includes(nodeId)); + if (leading.length === 0) return nodeIds; + return [...leading, ...nodeIds.filter((nodeId) => !monitorSet.has(nodeId))]; } function pipelineColumnsWithLeadingMonitors(columns: string[][], monitorNodeIds: string[]): string[][] { @@ -1582,7 +1721,7 @@ function pipelineGraphNodeOrder(pipeline: any, pipelineNodes: AnyRecord[], pipel ordered.push(nodeId); seen.add(nodeId); } - return ordered; + return pipelineOrderWithLeadingMonitors(ordered, pipelineMonitorNodeIds(pipelineNodes)); } function pipelineFlowEdgeKey(edge: AnyRecord): string { @@ -1843,6 +1982,10 @@ function exportMarkerId(color: string): string { return `arrow-${color.replace(/[^a-zA-Z0-9_-]+/g, "")}`; } +function safeExportFileTitle(title: string, fallback = "pipeline"): string { + return String(title || fallback).replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-|-$/g, "") || fallback; +} + function targetPortPosition(node: Node, handle: string): { x: number; y: number; position: Position } { const x = node.position.x; const y = node.position.y; @@ -1911,6 +2054,171 @@ function pipelineGraphSvg(flow: { nodes: Node[]; edges: Edge[] }, title: string) return { svg, width, height }; } +function pipelineGanttExportStatusColor(status: any): string { + const value = String(status || "").toLowerCase(); + if (value === "succeeded" || value === "completed") return "#4eb7a8"; + if (value === "failed") return "#cf6a54"; + if (runningStatus(value)) return "#69aee8"; + return "#d7a13a"; +} + +function pipelineGanttExportMarkerColor(marker: AnyRecord): string { + const kind = String(marker?.kind || ""); + const tone = String(marker?.tone || marker?.status || "").toLowerCase(); + if (kind === "prompt" && tone === "initial") return "#d7a13a"; + if (kind === "prompt" && tone === "monitor") return "#69aee8"; + if (kind === "prompt") return "#4eb7a8"; + if (tone === "modify") return "#e0b95a"; + if (tone === "approve" || tone === "guide" || tone === "monitor") return "#4eb7a8"; + if (tone === "restart" || tone === "redo") return "#d7a13a"; + if (tone === "ignored") return "#81939f"; + if (tone === "webui") return "#69aee8"; + if (tone === "cli") return "#d7a13a"; + return "#a7bac5"; +} + +function pipelineGanttExportArrowColor(arrow: AnyRecord): string { + const sourceKind = String(arrow?.sourceKind || "").toLowerCase(); + const action = String(arrow?.action || "").toLowerCase(); + const status = String(arrow?.status || "").toLowerCase(); + if (action === "observe" || status === "observation" || sourceKind === "monitor") return "#4eb7a8"; + if (sourceKind === "webui") return "#69aee8"; + if (sourceKind === "cli") return "#d7a13a"; + if (status.includes("ignored")) return "#81939f"; + return "#8aa0ad"; +} + +function pipelineGanttExportMarkerSvg(marker: AnyRecord, x: number, y: number): string { + const color = pipelineGanttExportMarkerColor(marker); + const kind = String(marker?.kind || ""); + if (kind === "control-source") { + return ``; + } + if (kind === "control-target") { + const fill = String(marker?.tone || "").toLowerCase() === "approve" ? "rgba(78,183,168,0.22)" : "#081118"; + return ``; + } + const radius = kind === "prompt" ? 4.5 : 5.5; + return ``; +} + +function pipelineGanttSvg(input: AnyRecord): { svg: string; width: number; height: number } { + const nodeIds = asArray(input.visibleNodeIds).map((nodeId) => String(nodeId || "")).filter(Boolean); + const intervals = asArray(input.intervals).filter(isRecord); + const markers = asArray(input.markers).filter(isRecord); + const arrows = asArray(input.arrows).filter(isRecord); + const ticks = asArray(input.ticks).filter(isRecord); + const bounds = isRecord(input.bounds) ? input.bounds : {}; + const backendLayout = isRecord(input.backendLayout) ? input.backendLayout : null; + const chartHeight = Math.max(240, Math.round(Number(input.chartHeight || 360))); + const exportColumnWidth = Math.max(pipelineGanttNodeColumnWidth, 108); + const timeAxisWidth = 128; + const padding = 24; + const titleHeight = 58; + const headerHeight = 56; + const boardWidth = timeAxisWidth + Math.max(1, nodeIds.length) * exportColumnWidth; + const width = Math.max(760, boardWidth + padding * 2); + const height = titleHeight + headerHeight + chartHeight + padding; + const boardX = padding; + const boardY = titleHeight; + const chartY = boardY + headerHeight; + const nodeX = (index: number) => boardX + timeAxisWidth + index * exportColumnWidth; + const nodeCenterX = (index: number) => nodeX(index) + exportColumnWidth / 2; + const meta = asArray(input.meta).map((item) => String(item || "")).filter(Boolean).slice(0, 4).join(" · "); + const markerById = new Map(markers.map((marker) => [String(marker.id || ""), marker])); + const markerColors = Array.from(new Set(["#4eb7a8", "#69aee8", "#d7a13a", "#cf6a54", "#8aa0ad", ...arrows.map(pipelineGanttExportArrowColor)])); + const defs = markerColors.map((color) => + ``, + ).join(""); + const tickSvg = ticks.map((tick) => { + const y = chartY + pipelineGanttTickY(tick, bounds, chartHeight, backendLayout); + return ` + + ${escapeSvg(fmtDate(tick.ms))} + +${escapeSvg(fmtDurationMs(Number(tick.offsetMs ?? Number(tick.ms) - Number(bounds.startMs))))} + `; + }).join("\n"); + const headerSvg = [ + ``, + `TIME`, + ...nodeIds.map((nodeId, index) => { + const x = nodeX(index); + const label = nodeId.length > 18 ? `${nodeId.slice(0, 16)}…` : nodeId; + return ` + + ${escapeSvg(label)} + node ${index + 1} + `; + }), + ].join("\n"); + const columnSvg = nodeIds.map((_nodeId, index) => { + const x = nodeX(index); + return ``; + }).join("\n"); + const intervalSvg = intervals.map((interval) => { + const index = nodeIds.indexOf(String(interval.nodeId || "")); + if (index < 0) return ""; + const top = chartY + pipelineGanttIntervalTop(interval, bounds, chartHeight, backendLayout); + const barHeight = Math.max(2, pipelineGanttIntervalHeight(interval, bounds, chartHeight, backendLayout)); + const color = pipelineGanttExportStatusColor(interval.status); + const x = nodeCenterX(index) - 3.5; + const liveOverlay = interval.live ? `` : ""; + const label = barHeight >= 28 + ? `${escapeSvg(String(interval.status || "working"))} + ${escapeSvg(fmtDurationMs(interval.durationMs))}` + : ""; + return ` + + ${liveOverlay} + ${label} + `; + }).join("\n"); + const markerSvg = markers.map((marker) => { + const index = nodeIds.indexOf(String(marker.nodeId || "")); + if (index < 0) return ""; + const y = chartY + pipelineGanttMarkerY(marker, bounds, chartHeight, backendLayout); + return pipelineGanttExportMarkerSvg(marker, nodeCenterX(index), y); + }).join("\n"); + const arrowSvg = arrows.map((arrow) => { + const targetMarker = markerById.get(String(arrow.targetMarkerId || "")); + if (!targetMarker) return ""; + const sourceMarker = markerById.get(String(arrow.sourceMarkerId || "")); + const sourceNodeId = String(sourceMarker?.nodeId || arrow.sourceNodeId || ""); + const targetNodeId = String(targetMarker.nodeId || arrow.targetNodeId || ""); + const sourceIndex = nodeIds.indexOf(sourceNodeId); + const targetIndex = nodeIds.indexOf(targetNodeId); + if (sourceIndex < 0 || targetIndex < 0) return ""; + const sourceX = nodeCenterX(sourceIndex) - boardX - timeAxisWidth; + const targetX = nodeCenterX(targetIndex) - boardX - timeAxisWidth; + const sourceY = pipelineGanttNumber(arrow.sourceY ?? arrow.y1) + ?? (sourceMarker ? pipelineGanttMarkerY(sourceMarker, bounds, chartHeight, backendLayout) : pipelineGanttMarkerY(targetMarker, bounds, chartHeight, backendLayout)); + const targetY = pipelineGanttNumber(arrow.targetY ?? arrow.y2) ?? pipelineGanttMarkerY(targetMarker, bounds, chartHeight, backendLayout); + const color = pipelineGanttExportArrowColor(arrow); + const dash = String(arrow.action || "").toLowerCase() === "observe" ? "3 4" : "6 5"; + const path = escapeSvg(pipelineControlArrowPath(sourceX, sourceY, targetX, targetY)); + return ` + `; + }).join("\n"); + const emptySvg = nodeIds.length === 0 ? `No visible Gantt nodes` : ""; + const svg = ` + ${defs} + + + + ${escapeSvg(input.title || "Pipeline Epoch Gantt")} + ${escapeSvg(meta)} + ${headerSvg} + + ${columnSvg} + ${tickSvg} + ${intervalSvg} + ${arrowSvg} + ${markerSvg} + ${emptySvg} + `; + return { svg, width, height }; +} + function downloadBlob(blob: Blob, filename: string): void { const url = URL.createObjectURL(blob); const link = document.createElement("a"); @@ -1921,7 +2229,7 @@ function downloadBlob(blob: Blob, filename: string): void { } async function exportPipelineGraph(flow: { nodes: Node[]; edges: Edge[] }, title: string): Promise { - const safeTitle = String(title || "pipeline").replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-|-$/g, "") || "pipeline"; + const safeTitle = safeExportFileTitle(title, "pipeline"); const { svg, width, height } = pipelineGraphSvg(flow, title); const svgBlob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" }); const url = URL.createObjectURL(svgBlob); @@ -1948,6 +2256,34 @@ async function exportPipelineGraph(flow: { nodes: Node[]; edges: Edge[] }, title } } +async function exportPipelineGantt(input: AnyRecord): Promise { + const safeTitle = safeExportFileTitle(String(input?.title || "pipeline-gantt"), "pipeline-gantt"); + const { svg, width, height } = pipelineGanttSvg(input); + const svgBlob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" }); + const url = URL.createObjectURL(svgBlob); + try { + const image = new Image(); + await new Promise((resolve, reject) => { + image.onload = () => resolve(); + image.onerror = () => reject(new Error("gantt svg image load failed")); + image.src = url; + }); + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("canvas unavailable"); + ctx.drawImage(image, 0, 0); + const pngBlob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png")); + if (!pngBlob) throw new Error("gantt png export failed"); + downloadBlob(pngBlob, `${safeTitle}.png`); + } catch { + downloadBlob(svgBlob, `${safeTitle}.svg`); + } finally { + URL.revokeObjectURL(url); + } +} + async function exportPipelineGraphs(items: Array<{ flow: { nodes: Node[]; edges: Edge[] }; title: string }>): Promise { for (const item of items) { if (item.flow.nodes.length === 0) continue; @@ -2021,7 +2357,7 @@ function procedureEndIso(procedure: any, run: any): string { ); } -function pipelineRunIntervals(run: any, pipelineNodes: AnyRecord[]): AnyRecord[] { +function pipelineRunIntervals(run: any, pipelineNodes: AnyRecord[], nowMs = Date.now()): AnyRecord[] { const runId = String(run?.runId || ""); const knownNodeIds = new Set(pipelineNodes.map((node: any) => String(node?.id || "")).filter(Boolean)); return asArray(run?.procedureRuns).flatMap((procedure: any) => { @@ -2032,7 +2368,7 @@ function pipelineRunIntervals(run: any, pipelineNodes: AnyRecord[]): AnyRecord[] const startMs = timeMs(startIso); if (startMs === null) return []; const explicitEndIso = procedureEndIso(procedure, run); - const endMs = timeMs(explicitEndIso) ?? (terminalStatus(status) ? (timeMs(procedure?.updatedAt) ?? startMs + 1000) : Date.now()); + const endMs = timeMs(explicitEndIso) ?? (terminalStatus(status) ? (timeMs(procedure?.updatedAt) ?? startMs + 1000) : nowMs); const safeEndMs = Math.max(startMs + 1000, endMs); return [{ nodeId, @@ -2089,6 +2425,11 @@ function pipelineGanttScaleConfig(value: number): AnyRecord { return { value: clampPipelineGanttScale(normalized * 100), pxPerMinute, label }; } +function pipelineDisplayPxPerMinute(value: any): number { + const rounded = Math.round(Number(value)); + return Math.abs(rounded - pipelineDefaultGanttPxPerMinute) <= 1 ? pipelineDefaultGanttPxPerMinute : rounded; +} + function pipelineGanttHeight(bounds: AnyRecord, scaleValue = pipelineDefaultGanttScale): number { const minutes = Math.max(1, Number(bounds.durationMs || 0) / 60_000); const scale = pipelineGanttScaleConfig(scaleValue); @@ -2123,6 +2464,69 @@ function pipelineGanttNumber(value: any): number | null { return Number.isFinite(number) ? number : null; } +function pipelineGanttIntervalIsRunning(interval: AnyRecord): boolean { + return runningStatus(interval?.status) && !terminalStatus(interval?.status); +} + +function pipelineGanttMsToY(ms: number, layout: AnyRecord | null): number | null { + if (!layout) return null; + const startMs = pipelineGanttNumber(layout?.startMs); + const endMs = pipelineGanttNumber(layout?.endMs); + const chartHeight = pipelineGanttNumber(layout?.chartHeight); + if (startMs === null || endMs === null || chartHeight === null) return null; + const durationMs = Math.max(1, endMs - startMs); + return Math.max(0, ((ms - startMs) / durationMs) * chartHeight); +} + +function pipelineGanttLiveEndMs(interval: AnyRecord, nowMs: number): number { + const startMs = pipelineGanttNumber(interval?.rawStartMs ?? interval?.startMs) ?? pipelineGanttNumber(interval?.startMs) ?? nowMs; + const currentEndMs = pipelineGanttNumber(interval?.endMs) ?? startMs + 1000; + if (!pipelineGanttIntervalIsRunning(interval)) return Math.max(startMs + 1000, currentEndMs); + return Math.max(startMs + 1000, currentEndMs, nowMs); +} + +function pipelineGanttLiveBackendLayout(layout: AnyRecord | null, intervals: AnyRecord[], nowMs: number): AnyRecord | null { + if (!layout) return null; + const startMs = pipelineGanttNumber(layout?.startMs); + const endMs = pipelineGanttNumber(layout?.endMs); + const chartHeight = pipelineGanttNumber(layout?.chartHeight); + if (startMs === null || endMs === null || chartHeight === null) return layout; + const liveEndMs = intervals.reduce((maxMs, interval) => Math.max(maxMs, pipelineGanttLiveEndMs(interval, nowMs)), endMs); + if (liveEndMs <= endMs) return layout; + const pxPerMs = chartHeight / Math.max(1, endMs - startMs); + return { + ...layout, + endMs: liveEndMs, + durationMs: Math.max(1, liveEndMs - startMs), + chartHeight: Math.max(chartHeight, Math.round((liveEndMs - startMs) * pxPerMs)), + liveExtended: true, + }; +} + +function pipelineGanttNormalizeLiveInterval(interval: AnyRecord, layout: AnyRecord | null, nowMs: number): AnyRecord { + if (!pipelineGanttIntervalIsRunning(interval)) return interval; + const startMs = pipelineGanttNumber(interval?.rawStartMs ?? interval?.startMs) ?? pipelineGanttNumber(interval?.startMs) ?? nowMs; + const endMs = pipelineGanttLiveEndMs(interval, nowMs); + const y1 = pipelineGanttMsToY(startMs, layout); + const y2 = pipelineGanttMsToY(endMs, layout); + const safeY1 = pipelineGanttNumber(y1 ?? interval?.y1 ?? interval?.startY) ?? 0; + const safeY2 = pipelineGanttNumber(y2 ?? interval?.y2 ?? interval?.endY) ?? safeY1 + 10; + const height = Math.max(24, safeY2 - safeY1); + return { + ...interval, + live: true, + startMs, + endMs, + durationMs: Math.max(1000, endMs - startMs), + finishedAt: isoFromMs(endMs), + y1: safeY1, + y2: safeY2, + startY: safeY1, + endY: safeY2, + height, + }; +} + function pipelineGanttFallbackY(ms: number, bounds: AnyRecord, chartHeight: number): number { return (markerPercent(ms, bounds) / 100) * chartHeight; } @@ -2205,30 +2609,72 @@ function pipelineGanttNormalizeBackendMarker(marker: AnyRecord): AnyRecord { }; } +function pipelineObservedPromptSourceNodeId(marker: AnyRecord): string { + const promptEvent = String(marker?.promptEvent || marker?.raw?.promptEvent || marker?.event || "").toLowerCase(); + if (!["node-long-running-observation", "node-finished"].includes(promptEvent)) return ""; + const sourceNodeId = String(marker?.sourceNodeId || marker?.raw?.sourceNodeId || marker?.raw?.detail?.nodeId || ""); + const monitorNodeId = String(marker?.nodeId || marker?.targetNodeId || ""); + return sourceNodeId && sourceNodeId !== monitorNodeId ? sourceNodeId : ""; +} + +function pipelineGanttAddObservationArrows(markers: AnyRecord[], arrows: AnyRecord[]): AnyRecord { + const arrowKeys = new Set(arrows.map((arrow) => [ + String(arrow.sourceNodeId || ""), + String(arrow.targetNodeId || ""), + String(arrow.targetMarkerId || ""), + String(arrow.action || ""), + ].join(":"))); + const nextArrows = [...arrows]; + for (const marker of markers) { + const observedNodeId = pipelineObservedPromptSourceNodeId(marker); + const monitorNodeId = String(marker?.nodeId || ""); + const targetMarkerId = String(marker?.id || ""); + if (!observedNodeId || !monitorNodeId || !targetMarkerId) continue; + const arrowKey = [observedNodeId, monitorNodeId, targetMarkerId, "observe"].join(":"); + if (arrowKeys.has(arrowKey)) continue; + arrowKeys.add(arrowKey); + nextArrows.push({ + id: `observation-arrow:${targetMarkerId}:${observedNodeId}:${monitorNodeId}`, + commandId: String(marker?.commandId || marker?.eventId || targetMarkerId), + sourceNodeId: observedNodeId, + targetNodeId: monitorNodeId, + sourceMarkerId: "", + targetMarkerId, + sourceKind: "monitor", + action: "observe", + status: "observation", + }); + } + return { markers, arrows: nextArrows }; +} + function pipelineBackendGanttSignals(details: any): AnyRecord { const gantt = isRecord(details?.gantt) ? details.gantt : {}; - return { - markers: asArray(gantt.markers).filter(isRecord).map(pipelineGanttNormalizeBackendMarker), - arrows: asArray(gantt.arrows).filter(isRecord), - }; + const rawMarkers = asArray(gantt.markers).filter(isRecord).map(pipelineGanttNormalizeBackendMarker); + const arrows = asArray(gantt.arrows).filter(isRecord); + return pipelineGanttAddObservationArrows(rawMarkers, arrows); } function pipelinePromptMarkerTone(record: any, fallbackKind = ""): string { const kind = eventKind(record) || fallbackKind; const promptEvent = String(record?.promptEvent || ""); if (kind === "initial-prompt-delivered") return "initial"; - if (promptEvent === "node-audit-required" || promptEvent.startsWith("monitor-")) return "monitor"; + if (promptEvent === "node-finished" || promptEvent === "node-long-running-observation" || promptEvent.startsWith("monitor-")) return "monitor"; if (kind === "monitor-prompt-delivered" || String(record?.sourceKind || "").toLowerCase() === "monitor" || fallbackKind === "monitor-prompt-queued") return "monitor"; return "append"; } +function pipelineRecordTags(record: any): string[] { + return asArray(record?.tags || record?.raw?.tags).map((tag) => String(tag || "")).filter(Boolean); +} + function pipelinePromptMarkerLabel(record: any, fallbackKind = ""): string { const kind = eventKind(record) || fallbackKind; const promptEvent = String(record?.promptEvent || ""); if (kind === "initial-prompt-delivered") return "初始 prompt"; - if (promptEvent === "node-audit-required") return "审核请求"; - if (promptEvent === "monitor-interval") return "Monitor interval"; - if (promptEvent === "batch-finished") return "批次完成"; + if (promptEvent === "node-long-running-observation") return "长任务观察"; + if (promptEvent === "node-finished") return pipelineRecordTags(record).includes("monitor.audit") ? "节点完成 / OA 审核" : "节点完成"; + if (promptEvent === "monitor-interval") return "旧版轮询"; if (promptEvent === "monitor-start") return "Monitor start"; if (promptEvent === "monitor-stop") return "Monitor stop"; if (kind === "monitor-prompt-delivered" || fallbackKind === "monitor-prompt-queued") return "Monitor prompt"; @@ -2316,10 +2762,14 @@ function controlTargetMarkerPlacement(intervals: AnyRecord[], nodeId: string, ev } function pipelineControlArrowPath(sourceX: number, sourceY: number, targetX: number, targetY: number): string { - const deltaX = targetX - sourceX; + const distance = Math.hypot(targetX - sourceX, targetY - sourceY); + const inset = distance > pipelineGanttArrowTipInsetPx ? pipelineGanttArrowTipInsetPx : 0; + const endX = inset > 0 ? targetX - ((targetX - sourceX) / distance) * inset : targetX; + const endY = inset > 0 ? targetY - ((targetY - sourceY) / distance) * inset : targetY; + const deltaX = endX - sourceX; const bend = Math.max(16, Math.min(42, Math.abs(deltaX) * 0.45 + 12)); const sign = deltaX === 0 ? 1 : Math.sign(deltaX); - return `M ${sourceX},${sourceY} C ${sourceX + sign * bend},${sourceY} ${targetX - sign * bend},${targetY} ${targetX},${targetY}`; + return `M ${sourceX},${sourceY} C ${sourceX + sign * bend},${sourceY} ${endX - sign * bend},${endY} ${endX},${endY}`; } function pipelineRunGanttSignals(details: any, activeRun: any): AnyRecord { @@ -2517,7 +2967,7 @@ function pipelineRunGanttSignals(details: any, activeRun: any): AnyRecord { Number(left.ms) - Number(right.ms) || String(left.nodeId).localeCompare(String(right.nodeId)) || String(left.id).localeCompare(String(right.id))); - return { markers, arrows: controlArrows, sourceMarkerByCommand }; + return { ...pipelineGanttAddObservationArrows(markers, controlArrows), sourceMarkerByCommand }; } function PipelineNodeExecutionIndex({ details, selectedNodeId, selectedNodeRuntime, control, onRaw }: AnyRecord) { @@ -2678,20 +3128,85 @@ function PipelineRunMaterialIndex({ activeRun, onRaw }: AnyRecord) { ); } +function PipelineOaEventFlowPanel({ diagnostics, onRaw }: AnyRecord) { + const runs = asArray(diagnostics?.runs).filter(isRecord); + const forbiddenResiduals = asArray(diagnostics?.forbiddenResiduals); + const guarantees = isRecord(diagnostics?.guarantees) ? diagnostics.guarantees : {}; + const evidenceOk = diagnostics?.hasNeutralNodeFinishedEvidence === true + && diagnostics?.hasNoAuditPolicyEvidence === true + && diagnostics?.hasAuditPolicyEvidence === true; + const ok = diagnostics?.ok === true && evidenceOk && forbiddenResiduals.length === 0; + const latestRun = runs[0] || null; + const guaranteeItems = [ + { label: "中性完成事实", ok: guarantees.neutralNodeFinished === true, hint: "node-finished 不携带流程策略" }, + { label: "Config 策略判定", ok: guarantees.auditPolicyFromConfig === true, hint: "OA backend 读取当前 epoch 配置" }, + { label: "控制命令来自 OA", ok: guarantees.runnerConsumesControlCommandsFromOaEvents === true, hint: "runner 只消费 OA control.command" }, + { label: "无独立审核事件", ok: guarantees.noIndependentAuditRequestEvent === true, hint: "审核由 node-finished + policy 派生" }, + { label: "无批次门禁", ok: guarantees.noBatchFinishedControlGate === true, hint: "下游启动由每个 node 完成驱动" }, + ]; + return h("div", { className: "pipeline-oa-panel", "data-testid": "pipeline-oa-event-flow-panel" }, + h("div", { className: "metric-grid compact" }, + h(MetricCard, { label: "OA Flow", value: ok ? "100%" : "--", hint: String(diagnostics?.mode || "waiting diagnostics"), tone: ok ? "ok" : "warn" }), + h(MetricCard, { label: "禁止残留", value: forbiddenResiduals.length, hint: forbiddenResiduals.length === 0 ? "source scan clean" : "needs cleanup", tone: forbiddenResiduals.length === 0 ? "ok" : "warn" }), + h(MetricCard, { label: "No-audit", value: diagnostics?.hasNoAuditPolicyEvidence ? "OK" : "--", hint: "OA 下游策略证据", tone: diagnostics?.hasNoAuditPolicyEvidence ? "ok" : "warn" }), + h(MetricCard, { label: "Monitor 审核", value: diagnostics?.hasAuditPolicyEvidence ? "OK" : "--", hint: "OA 控制事件闭环", tone: diagnostics?.hasAuditPolicyEvidence ? "ok" : "warn" }), + ), + h("div", { className: "pipeline-oa-guarantees" }, + guaranteeItems.map((item) => h("article", { key: item.label, className: `pipeline-oa-guarantee ${item.ok ? "ok" : "warn"}` }, + h(StatusBadge, { status: item.ok ? "online" : "warn" }, item.ok ? "OK" : "MISS"), + h("div", null, h("strong", null, item.label), h("span", null, item.hint)), + )), + ), + h("div", { className: "pipeline-evidence-list compact" }, + runs.slice(0, 6).map((run: AnyRecord) => h(EvidenceIndexRow, { + key: run.runId, + title: String(run.runId || "--"), + subtitle: [ + Number(run.monitorAuditNodeFinishedCount || 0) > 0 ? "monitor audit" : "", + Number(run.noAuditPolicyCount || 0) > 0 ? "no-audit policy" : "", + ].filter(Boolean).join(" / ") || "event evidence", + facts: [ + `events ${run.eventCount || 0}`, + `node-finished ${run.nodeFinishedCount || 0}`, + `policy-in-detail ${run.nodeFinishedWithPolicyCount || 0}`, + `queued ${run.controlQueuedCount || 0}`, + `applied ${run.controlAppliedCount || 0}`, + ], + data: run, + onRaw, + testId: `raw-pipeline-oa-run-${String(run.runId || "run").replace(/[^a-zA-Z0-9_.-]+/g, "-")}`, + })), + ), + latestRun ? h("p", { className: "muted paragraph" }, `最新证据 ${latestRun.runId}: ${latestRun.nodeFinishedCount || 0} 个 node-finished,${latestRun.controlAppliedCount || 0} 个控制结果。`) : h(EmptyState, { title: "暂无 OA 事件流证据", text: "等待 Pipeline backend 暴露 diagnostics。" }), + diagnostics ? h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "Pipeline OA Event Flow Diagnostics", data: diagnostics, onOpen: onRaw, testId: "raw-pipeline-oa-event-flow" })) : null, + ); +} + function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes, pipelineEdges, runDetails, nodeDetails, ganttScale = pipelineDefaultGanttScale, onGanttScaleChange, onRunChange, onIntervalSelect, onMarkerSelect, selection, onRaw }: AnyRecord) { const [autoHideIdle, setAutoHideIdle] = useState(pipelineDefaultGanttAutoHideIdle); const [visibleRange, setVisibleRange] = useState({ startY: 0, endY: 0, startMs: 0, endMs: 0 }); + const [liveNowMs, setLiveNowMs] = useState(Date.now()); const viewportRef = useRef(null); const activeRunId = String(activeRun?.runId || ""); const timeScale = clampPipelineGanttScale(ganttScale ?? pipelineDefaultGanttScale); - const fallbackIntervals = pipelineRunIntervals(activeRun, pipelineNodes); + const fallbackIntervals = pipelineRunIntervals(activeRun, pipelineNodes, liveNowMs); const runDetailPayload = String(runDetails?.runId || "") === activeRunId ? runDetails?.details : null; const backendGantt = isRecord(runDetailPayload?.gantt) ? runDetailPayload.gantt : null; - const backendLayout = pipelineGanttBackendLayout(backendGantt); - const hasBackendLayout = Boolean(backendLayout); - const intervals = hasBackendLayout + const baseBackendLayout = pipelineGanttBackendLayout(backendGantt); + const hasBackendLayout = Boolean(baseBackendLayout); + const rawIntervals = hasBackendLayout ? asArray(backendGantt?.intervals).filter(isRecord).map((interval: AnyRecord) => ({ ...interval, runId: activeRunId })) : fallbackIntervals; + const hasLiveRunning = rawIntervals.some(pipelineGanttIntervalIsRunning); + useEffect(() => { + if (!activeRunId || !hasLiveRunning) return undefined; + const timer = window.setInterval(() => setLiveNowMs(Date.now()), 1000); + return () => window.clearInterval(timer); + }, [activeRunId, hasLiveRunning]); + const backendLayout = hasBackendLayout ? pipelineGanttLiveBackendLayout(baseBackendLayout, rawIntervals, liveNowMs) : baseBackendLayout; + const intervals = hasBackendLayout + ? rawIntervals.map((interval: AnyRecord) => pipelineGanttNormalizeLiveInterval(interval, backendLayout, liveNowMs)) + : rawIntervals; const bounds = hasBackendLayout ? { startMs: Number(backendLayout?.startMs), endMs: Number(backendLayout?.endMs), @@ -2777,14 +3292,37 @@ function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes, const visibleMarkerIds = new Set(allMarkers.filter((marker: AnyRecord) => visibleNodeIds.includes(String(marker.nodeId || "")) && markerIsVisible(marker)).map((marker: AnyRecord) => String(marker.id))); const markerById = new Map(allMarkers.map((marker: AnyRecord) => [String(marker.id), marker])); - const visibleArrows = asArray(ganttSignals.arrows).filter((arrow: AnyRecord) => - visibleMarkerIds.has(String(arrow.sourceMarkerId || "")) && visibleMarkerIds.has(String(arrow.targetMarkerId || ""))); + const visibleArrows = asArray(ganttSignals.arrows).filter((arrow: AnyRecord) => { + const targetVisible = visibleMarkerIds.has(String(arrow.targetMarkerId || "")); + if (!targetVisible) return false; + if (String(arrow.action || "") === "observe") return visibleNodeIds.includes(String(arrow.sourceNodeId || "")); + return visibleMarkerIds.has(String(arrow.sourceMarkerId || "")); + }); const boardMinWidth = pipelineGanttTimeAxisWidth + Math.max(1, visibleNodeIds.length) * pipelineGanttNodeColumnWidth; const setScaleFromSlider = (event: any) => { const nextScale = clampPipelineGanttScale(event.target.value); if (typeof onGanttScaleChange === "function") onGanttScaleChange(nextScale); window.setTimeout(updateVisibleRange, 0); }; + const exportCurrentGantt = () => exportPipelineGantt({ + title: `${activePipeline?.id || "pipeline"}-${activeRunId || "epoch"}-gantt`, + meta: [ + `run ${activeRunId || "--"}`, + `${fmtDate(bounds.startMs)} -> ${fmtDate(bounds.endMs)}`, + `duration ${fmtDurationMs(bounds.durationMs)}`, + `${timeScaleConfig.label} / ${pipelineDisplayPxPerMinute(timeScaleConfig.pxPerMinute)} px/min`, + `${visibleNodeIds.length}/${allNodeIds.length} nodes`, + `${allMarkers.length} markers`, + ], + visibleNodeIds, + intervals, + markers: allMarkers.filter((marker: AnyRecord) => visibleNodeIds.includes(String(marker.nodeId || ""))), + arrows: visibleArrows, + ticks, + bounds, + chartHeight, + backendLayout, + }); const diagnostics = isRecord(backendGantt?.diagnostics) ? backendGantt.diagnostics : null; return h(Panel, { title: "Epoch 甘特图", @@ -2812,7 +3350,7 @@ function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes, h("label", { className: "pipeline-gantt-scale" }, h("span", null, h("b", null, "时间尺度"), - h("em", { "data-testid": "pipeline-gantt-scale-label" }, `${timeScaleConfig.label} · ${Math.round(Number(timeScaleConfig.pxPerMinute))} px/min`), + h("em", { "data-testid": "pipeline-gantt-scale-label" }, `${timeScaleConfig.label} · ${pipelineDisplayPxPerMinute(timeScaleConfig.pxPerMinute)} px/min`), ), h("input", { type: "range", @@ -2826,6 +3364,13 @@ function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes, }), h("small", null, h("span", null, "全局"), h("span", null, "细节")), ), + activeRun ? h("button", { + type: "button", + className: "ghost-btn", + onClick: exportCurrentGantt, + disabled: visibleNodeIds.length === 0, + "data-testid": "pipeline-export-gantt", + }, "导出甘特图") : null, activeRun ? h(RawButton, { title: `Pipeline Epoch ${activeRun.runId}`, data: activeRun, onOpen: onRaw, testId: "raw-pipeline-epoch-gantt" }) : null, ), }, @@ -2837,14 +3382,14 @@ function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes, h("div", { className: "pipeline-gantt-meta" }, h("span", null, `time ${fmtDate(bounds.startMs)} -> ${fmtDate(bounds.endMs)}`), h("span", null, `duration ${fmtDurationMs(bounds.durationMs)}`), - h("span", null, `scale ${timeScaleConfig.label} / ${Math.round(Number(timeScaleConfig.pxPerMinute))} px/min`), + h("span", null, `scale ${timeScaleConfig.label} / ${pipelineDisplayPxPerMinute(timeScaleConfig.pxPerMinute)} px/min`), h("span", null, `layout ${hasBackendLayout ? "backend-y" : "fallback"}`), diagnostics ? h("span", null, `align ${diagnostics.timeAxisAlignmentOk === false ? "check" : "ok"}`) : null, h("span", null, `visible ${visibleNodeIds.length}/${allNodeIds.length} nodes`), runDetailPayload ? h("span", null, `markers ${allMarkers.length}`) : null, autoHideIdle && hiddenCount > 0 ? h("span", null, `hidden idle ${hiddenCount}`) : null, ), - h("div", { className: "pipeline-gantt-viewport", ref: viewportRef, "data-testid": "pipeline-epoch-gantt", "data-pipeline-id": activePipeline?.id || "" }, + h("div", { className: "pipeline-gantt-viewport", ref: viewportRef, "data-testid": "pipeline-epoch-gantt", "data-pipeline-id": activePipeline?.id || "", "data-run-id": activeRunId }, h("div", { className: "pipeline-gantt-board", style: { gridTemplateColumns, minWidth: `${boardMinWidth}px` } }, h("div", { className: "pipeline-gantt-head time" }, "Time"), visibleNodeIds.length === 0 ? h("div", { className: "pipeline-gantt-head empty" }, "当前时间窗无工作节点") : @@ -2885,21 +3430,28 @@ function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes, ), ), visibleArrows.map((arrow: AnyRecord) => { - const sourceMarker = markerById.get(String(arrow.sourceMarkerId || "")); const targetMarker = markerById.get(String(arrow.targetMarkerId || "")); - if (!sourceMarker || !targetMarker) return null; - const sourceIndex = visibleNodeIds.indexOf(String(sourceMarker.nodeId || "")); + if (!targetMarker) return null; + const sourceMarker = markerById.get(String(arrow.sourceMarkerId || "")); + const sourceNodeId = String(sourceMarker?.nodeId || arrow.sourceNodeId || ""); + const sourceIndex = visibleNodeIds.indexOf(sourceNodeId); const targetIndex = visibleNodeIds.indexOf(String(targetMarker.nodeId || "")); if (sourceIndex < 0 || targetIndex < 0) return null; const sourceX = sourceIndex * pipelineGanttNodeColumnWidth + pipelineGanttNodeColumnWidth / 2; const targetX = targetIndex * pipelineGanttNodeColumnWidth + pipelineGanttNodeColumnWidth / 2; - const sourceY = pipelineGanttNumber(arrow.sourceY ?? arrow.y1) ?? pipelineGanttMarkerY(sourceMarker, bounds, chartHeight, backendLayout); + const sourceY = pipelineGanttNumber(arrow.sourceY ?? arrow.y1) + ?? (sourceMarker ? pipelineGanttMarkerY(sourceMarker, bounds, chartHeight, backendLayout) : pipelineGanttMarkerY(targetMarker, bounds, chartHeight, backendLayout)); const targetY = pipelineGanttNumber(arrow.targetY ?? arrow.y2) ?? pipelineGanttMarkerY(targetMarker, bounds, chartHeight, backendLayout); return h("path", { key: arrow.id, className: `pipeline-gantt-arrow ${String(arrow.sourceKind || "").toLowerCase()} ${String(arrow.status || "").toLowerCase()} ${String(arrow.action || "").toLowerCase()}`, d: pipelineControlArrowPath(sourceX, sourceY, targetX, targetY), markerEnd: "url(#pipeline-gantt-arrowhead)", + "data-testid": String(arrow.action || "") === "observe" ? "pipeline-gantt-observation-arrow" : "pipeline-gantt-arrow", + "data-source-node-id": String(arrow.sourceNodeId || ""), + "data-target-node-id": String(arrow.targetNodeId || ""), + "data-target-marker-id": String(arrow.targetMarkerId || ""), + "data-action": String(arrow.action || ""), }); }), ) : null, @@ -2915,11 +3467,15 @@ function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes, return h("button", { key: intervalKey, type: "button", - className: `pipeline-gantt-bar ${interval.status} ${selectedIntervalKey === intervalKey ? "selected" : ""}`, + className: `pipeline-gantt-bar ${interval.status} ${interval.live ? "live" : ""} ${selectedIntervalKey === intervalKey ? "selected" : ""}`, style: { top: `${top}px`, height: `${height}px` }, title: `${nodeId} ${interval.status} ${fmtDate(interval.startedAt || interval.startMs)} -> ${fmtDate(interval.finishedAt || interval.endMs)}`, onClick: () => onIntervalSelect(interval), "data-testid": "pipeline-gantt-line", + "data-node-id": nodeId, + "data-procedure-run-id": String(interval.procedureRunId || ""), + "data-status": String(interval.status || ""), + "data-live": interval.live ? "true" : "false", }, h("strong", null, interval.status || "working"), h("span", null, fmtDurationMs(interval.durationMs)), @@ -2933,6 +3489,7 @@ function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes, title: `${marker.label || "event"} / ${fmtDate(marker.timestampIso || marker.timestamp || marker.ms)}`, onClick: () => onMarkerSelect(marker), "data-testid": marker.kind === "prompt" ? "pipeline-gantt-prompt-marker" : "pipeline-gantt-control-marker", + "data-marker-id": String(marker.id || ""), })), ); }), @@ -3098,7 +3655,7 @@ function PipelineNodeControlPanel({ activeRun, pipelineRuns, selectedRunId, onRu export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyRecord) { const service = microservices.find((item: any) => item.id === "pipeline") || null; - const [state, setState] = useState({ loading: false, error: "", health: null, snapshot: null, refreshedAt: null }); + const [state, setState] = useState({ loading: false, error: "", health: null, snapshot: null, oaDiagnostics: null, refreshedAt: null }); const [selectedPipelineId, setSelectedPipelineId] = useState(""); const [selectedRunId, setSelectedRunId] = useState(""); const [selectedNodeId, setSelectedNodeId] = useState(""); @@ -3121,10 +3678,14 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR if (!silent) setState((prev: any) => ({ ...prev, loading: true, error: "" })); try { const snapshotQuery = `__unideskArrayLimit=registry.components:80,runs:${pipelineSnapshotRunLimit}&_=${Date.now()}`; - const snapshot = await requestJson(`${apiBaseUrl}/microservices/pipeline/proxy/api/snapshot?${snapshotQuery}`, { cache: "no-store" }); + const [snapshot, oaDiagnostics] = await Promise.all([ + requestJson(`${apiBaseUrl}/microservices/pipeline/proxy/api/snapshot?${snapshotQuery}`, { cache: "no-store" }), + requestJson(`${apiBaseUrl}/microservices/pipeline/proxy/api/oa-event-flow/diagnostics?_=${Date.now()}`, { cache: "no-store" }) + .catch((error: unknown) => ({ ok: false, error: errorMessage(error, "OA event flow diagnostics failed") })), + ]); if (requestId !== loadRequestRef.current) return; - const health = { ok: snapshot?.ok !== false, service: "pipeline-v2-webui snapshot" }; - setState({ loading: false, error: "", health, snapshot, refreshedAt: new Date() }); + const health = { ok: snapshot?.ok !== false, service: "pipeline-v2-control snapshot" }; + setState({ loading: false, error: "", health, snapshot, oaDiagnostics, refreshedAt: new Date() }); } catch (err) { if (requestId !== loadRequestRef.current) return; setState((prev: any) => ({ ...prev, loading: false, error: errorMessage(err, "Pipeline 加载失败") })); @@ -3146,14 +3707,19 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR const repository = microserviceRepository(service); const backend = microserviceBackend(service); const snapshot = state.snapshot || {}; + const oaDiagnostics = state.oaDiagnostics || null; const { components, pipelines, runs } = pipelineSnapshotArrays(snapshot); - const activePipeline = pipelines.find((pipeline: any) => String(pipeline.id || "") === selectedPipelineId) || pipelines[0] || {}; + const latestPipelineId = String(runs[0]?.pipelineId || ""); + const defaultPipeline = (latestPipelineId ? pipelines.find((pipeline: any) => String(pipeline.id || "") === latestPipelineId) : null) || pipelines[0] || {}; + const activePipeline = pipelines.find((pipeline: any) => String(pipeline.id || "") === selectedPipelineId) || defaultPipeline; const activePipelineId = String(activePipeline.id || ""); const pipelineNodes = pipelineConfigNodes(activePipeline); const pipelineEdges = pipelineConfigEdges(activePipeline); const latestRun = pipelineLatestRun(runs, activePipelineId); const pipelineRuns = pipelineEpochRuns(runs, activePipelineId); - const activeRun = pipelineRuns.find((run: any) => String(run?.runId || "") === selectedRunId) || latestRun; + const activeRunBase = pipelineRuns.find((run: any) => String(run?.runId || "") === selectedRunId) || latestRun; + const activeRunDetail = String(runDetails.runId || "") === String(activeRunBase?.runId || "") ? pipelineDetailedRun(runDetails.details) : null; + const activeRun = mergePipelineRunSummary(activeRunBase, activeRunDetail); const activeRunId = String(activeRun?.runId || ""); const selectedNodeConfig = pipelineNodes.find((node: any) => String(node?.id || "") === selectedNodeId) || null; const selectedNodeRuntime = selectedNodeId ? pipelineRunNodeRecord(activeRun, selectedNodeId) : null; @@ -3210,9 +3776,12 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR fetchedAt: prev.runId === runId ? prev.fetchedAt : null, })); try { - const details = await requestJson(`${pipelineProxyPath(apiBaseUrl, `/api/node-control/runs/${encodeURIComponent(runId)}?tail=160&view=gantt&scale=${scale}`)}&_=${Date.now()}`, { cache: "no-store" }); + const [details, runSummary] = await Promise.all([ + requestJson(`${pipelineProxyPath(apiBaseUrl, `/api/node-control/runs/${encodeURIComponent(runId)}?tail=160&view=gantt&scale=${scale}`)}&_=${Date.now()}`, { cache: "no-store" }), + requestJson(`${pipelineProxyPath(apiBaseUrl, `/api/runs/${encodeURIComponent(runId)}`)}?_=${Date.now()}`, { cache: "no-store" }).catch((error: unknown) => ({ ok: false, runSummaryError: errorMessage(error, "抓取评分失败") })), + ]); if (requestId !== runDetailsRequestRef.current) return; - setRunDetails({ runId, scale, loading: false, error: "", details, fetchedAt: new Date() }); + setRunDetails({ runId, scale, loading: false, error: "", details: { ...details, run: isRecord(runSummary?.run) ? runSummary.run : undefined, runSummaryError: runSummary?.runSummaryError }, fetchedAt: new Date() }); } catch (err) { if (requestId !== runDetailsRequestRef.current) return; setRunDetails((prev: AnyRecord) => ({ @@ -3371,9 +3940,16 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR h(MetricCard, { label: "运行记录", value: runCount, hint: `${statusCounts.succeeded || 0} succeeded / ${statusCounts.running || 0} running` }), h(MetricCard, { label: "OA 记录", value: Array.isArray(latestRun?.submissions) ? latestRun.submissions.length : 0, hint: latestRun?.runId || "latest run" }), h(MetricCard, { label: "Procedure", value: Array.isArray(latestRun?.procedureRuns) ? latestRun.procedureRuns.length : 0, hint: latestRun?.status || "no run" }), + h(MetricCard, { label: "Score", value: pipelineScoreText(activeRun), hint: activeRun?.runId || "selected epoch", tone: pipelineScoreTone(activeRun) }), ), h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "Pipeline Snapshot", data: snapshot, onOpen: onRaw, testId: "raw-pipeline-snapshot" })), ), + h(Panel, { title: "评分器", eyebrow: activeRun?.runId || "selected epoch" }, + h(PipelineScoreBoard, { run: activeRun, onRaw }), + ), + h(Panel, { title: "OA 事件流", eyebrow: "100% event-driven diagnostics", className: "pipeline-wide-panel" }, + h(PipelineOaEventFlowPanel, { diagnostics: oaDiagnostics, onRaw }), + ), h(Panel, { title: "组件矩阵", eyebrow: `${componentClasses.length} classes` }, componentClasses.length === 0 ? h(EmptyState, { title: "暂无组件", text: "等待 D601 pipeline backend 返回 registry.components" }) : h("div", { className: "component-strata" }, componentClasses.map((item) => h("article", { key: item.name, className: "component-stratum" }, @@ -3481,6 +4057,7 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR h("span", null, `${pipelines.length} pipelines`), h("span", null, `source config+components(${components.length})`), h("span", null, `run ${activeRun?.runId || "--"}`), + h("span", null, `score ${pipelineScoreText(activeRun)}`), h("span", null, selectedNodeId ? `selected ${selectedNodeId}` : "click node to control"), ), ), @@ -3507,7 +4084,9 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR }), h(Panel, { title: "Epoch 列表", eyebrow: `${pipelineRuns.length}/${runCount} preview` }, pipelineRuns.length === 0 ? h(EmptyState, { title: "暂无运行记录", text: "当前 pipeline 在 .state/pipeline-runs 中还没有 epoch。" }) : - h("div", { className: "pipeline-run-list" }, pipelineRuns.map((run: any) => h("article", { + h("div", { className: "pipeline-run-list" }, pipelineRuns.map((run: any) => { + const cardRun = String(run?.runId || "") === activeRunId ? activeRun : run; + return h("article", { key: run.runId, className: `pipeline-run-card ${String(run.runId || "") === activeRunId ? "active" : ""}`, role: "button", @@ -3525,14 +4104,16 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR }, h("div", { className: "node-card-head" }, h("strong", null, pipelineEpochLabel(pipelineRuns, run)), h(StatusBadge, { status: run.status }, run.status || "--")), h("div", { className: "docker-meta compact" }, - h("span", null, run.pipelineId || "--"), - h("span", null, `nodes ${Array.isArray(run.nodes) ? run.nodes.length : 0}`), - h("span", null, `oa ${Array.isArray(run.submissions) ? run.submissions.length : 0}`), - h("span", null, `procedures ${Array.isArray(run.procedureRuns) ? run.procedureRuns.length : 0}`), + h("span", null, cardRun?.pipelineId || "--"), + h("span", null, `nodes ${Array.isArray(cardRun?.nodes) ? cardRun.nodes.length : 0}`), + h("span", null, `oa ${Array.isArray(cardRun?.submissions) ? cardRun.submissions.length : 0}`), + h("span", null, `procedures ${Array.isArray(cardRun?.procedureRuns) ? cardRun.procedureRuns.length : 0}`), + h(PipelineScoreBadge, { run: cardRun }), ), - h("p", { className: "muted paragraph" }, summarizeValue(run.task)), - h("span", { className: "pipeline-run-time" }, fmtDate(run.updatedAt)), - ))), + h("p", { className: "muted paragraph" }, summarizeValue(cardRun?.task)), + h("span", { className: "pipeline-run-time" }, fmtDate(cardRun?.updatedAt)), + ); + })), ), h(Panel, { title: "运行材料索引", eyebrow: activeRun?.runId || "selected epoch", className: "pipeline-wide-panel" }, h(PipelineRunMaterialIndex, { activeRun, onRaw }), diff --git a/src/components/microservices/codex-queue/Dockerfile b/src/components/microservices/codex-queue/Dockerfile new file mode 100644 index 00000000..cbccede5 --- /dev/null +++ b/src/components/microservices/codex-queue/Dockerfile @@ -0,0 +1,15 @@ +FROM oven/bun:1-debian + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl git bash ripgrep procps python3 make g++ bubblewrap docker.io npm \ + && npm install -g @openai/codex@0.128.0 \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY src/components/microservices/codex-queue/package.json ./package.json +COPY src/components/microservices/codex-queue/tsconfig.json ./tsconfig.json +COPY src/components/microservices/codex-queue/src ./src + +EXPOSE 4222 +CMD ["bun", "run", "src/index.ts"] diff --git a/src/components/microservices/codex-queue/package.json b/src/components/microservices/codex-queue/package.json new file mode 100644 index 00000000..b98b389e --- /dev/null +++ b/src/components/microservices/codex-queue/package.json @@ -0,0 +1,9 @@ +{ + "name": "@unidesk/codex-queue", + "private": true, + "type": "module", + "scripts": { + "start": "bun run src/index.ts", + "check": "tsc -p tsconfig.json --noEmit" + } +} diff --git a/src/components/microservices/codex-queue/src/index.ts b/src/components/microservices/codex-queue/src/index.ts new file mode 100644 index 00000000..84696d94 --- /dev/null +++ b/src/components/microservices/codex-queue/src/index.ts @@ -0,0 +1,1176 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import * as readline from "node:readline"; + +type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; +type TaskStatus = "queued" | "running" | "judging" | "retry_wait" | "succeeded" | "failed" | "canceled"; +type RunMode = "initial" | "retry"; +type JudgeDecision = "complete" | "retry" | "fail"; +type OutputChannel = "system" | "user" | "assistant" | "reasoning" | "command" | "diff" | "tool" | "error"; +type TerminalStatus = "completed" | "interrupted" | "failed" | null; + +interface RuntimeConfig { + host: string; + port: number; + statePath: string; + logFile: string; + defaultWorkdir: string; + codexHome: string; + sourceCodexConfig: string; + defaultModel: string; + defaultReasoningEffort: string | null; + sandbox: "read-only" | "workspace-write" | "danger-full-access"; + approvalPolicy: "untrusted" | "on-failure" | "on-request" | "never"; + defaultMaxAttempts: number; + minimaxApiKey: string; + minimaxApiBase: string; + minimaxModel: string; + judgeTimeoutMs: number; +} + +interface QueueTaskRequest { + prompt: string; + cwd?: string; + model?: string; + reasoningEffort?: string; + maxAttempts?: number; +} + +interface LiveOutput { + seq: number; + at: string; + channel: OutputChannel; + text: string; + method?: string; + itemId?: string; +} + +interface CodexEventSummary { + at: string; + method: string; + itemType?: string; + status?: string; + message?: string; + textPreview?: string; +} + +interface AttemptSummary { + index: number; + mode: RunMode; + startedAt: string; + finishedAt: string; + terminalStatus: TerminalStatus; + transportClosedBeforeTerminal: boolean; + appServerExitCode: number | null; + appServerSignal: string | null; + error: string | null; + finalResponsePreview: string; + stderrTail: string; +} + +interface JudgeResult { + decision: JudgeDecision; + confidence: number; + reason: string; + continuePrompt?: string; + source: "minimax" | "fallback"; + raw?: JsonValue; +} + +interface QueueTask { + id: string; + prompt: string; + cwd: string; + model: string; + reasoningEffort: string | null; + maxAttempts: number; + status: TaskStatus; + createdAt: string; + updatedAt: string; + startedAt: string | null; + finishedAt: string | null; + currentAttempt: number; + currentMode: RunMode | null; + codexThreadId: string | null; + activeTurnId: string | null; + finalResponse: string; + lastError: string | null; + lastJudge: JudgeResult | null; + output: LiveOutput[]; + events: CodexEventSummary[]; + attempts: AttemptSummary[]; + cancelRequested: boolean; + nextPrompt: string | null; + nextMode: RunMode | null; +} + +interface PersistedState { + version: 1; + updatedAt: string; + nextSeq: number; + tasks: QueueTask[]; +} + +interface AppServerExit { + code: number | null; + signal: string | null; + stderrTail: string; +} + +interface CodexRunResult { + threadId: string | null; + turnId: string | null; + finalResponse: string; + terminalStatus: TerminalStatus; + terminalError: string | null; + transportClosedBeforeTerminal: boolean; + appServerExit: AppServerExit; + events: CodexEventSummary[]; +} + +interface JudgeProbeCase { + id: string; + prompt: string; + finalResponse: string; + expected: JudgeDecision; + terminalStatus: TerminalStatus; + transportClosedBeforeTerminal?: boolean; + terminalError?: string | null; + stderrTail?: string; + outputs?: Array<{ channel: OutputChannel; text: string; method?: string }>; + events?: CodexEventSummary[]; +} + +interface ActiveRun { + taskId: string; + app: AppServerClient; + threadId: string; + turnId: string | null; +} + +const recentLogs: JsonValue[] = []; +const serviceStartedAt = new Date().toISOString(); +const config = readConfig(); +const logger = createLogger("codex-queue", config.logFile); +const state = readState(config.statePath); +let processing = false; +let activeRun: ActiveRun | null = null; + +function envString(name: string, fallback: string): string { + const value = process.env[name]; + return value === undefined || value.length === 0 ? fallback : value; +} + +function envNullableString(name: string): string | null { + const value = process.env[name]; + return value === undefined || value.length === 0 ? null : 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 sandboxValue(raw: string): RuntimeConfig["sandbox"] { + if (raw === "read-only" || raw === "workspace-write" || raw === "danger-full-access") return raw; + return "danger-full-access"; +} + +function approvalValue(raw: string): RuntimeConfig["approvalPolicy"] { + if (raw === "untrusted" || raw === "on-failure" || raw === "on-request" || raw === "never") return raw; + return "never"; +} + +function readConfig(): RuntimeConfig { + return { + host: envString("HOST", "0.0.0.0"), + port: envNumber("PORT", 4222), + statePath: envString("CODEX_QUEUE_STATE_PATH", "/var/lib/unidesk/codex-queue/state.json"), + logFile: envString("LOG_FILE", "/var/log/unidesk/codex-queue.jsonl"), + defaultWorkdir: envString("CODEX_QUEUE_WORKDIR", "/workspace"), + codexHome: envString("CODEX_QUEUE_CODEX_HOME", "/var/lib/unidesk/codex-queue/codex-home"), + sourceCodexConfig: envString("CODEX_QUEUE_SOURCE_CODEX_CONFIG", "/root/.codex/config.toml"), + defaultModel: envString("CODEX_QUEUE_DEFAULT_MODEL", "gpt-5.4-mini"), + defaultReasoningEffort: envNullableString("CODEX_QUEUE_REASONING_EFFORT"), + sandbox: sandboxValue(envString("CODEX_QUEUE_SANDBOX", "danger-full-access")), + approvalPolicy: approvalValue(envString("CODEX_QUEUE_APPROVAL_POLICY", "never")), + defaultMaxAttempts: Math.max(1, Math.min(10, envNumber("CODEX_QUEUE_MAX_ATTEMPTS", 3))), + minimaxApiKey: envString("MINIMAX_API_KEY", ""), + minimaxApiBase: envString("MINIMAX_API_BASE", "https://api.minimax.io/v1").replace(/\/+$/u, ""), + minimaxModel: envString("MINIMAX_MODEL", "MiniMax-M2.7"), + judgeTimeoutMs: envNumber("MINIMAX_JUDGE_TIMEOUT_MS", 30_000), + }; +} + +function createLogger(service: string, logFile: string) { + mkdirSync(dirname(logFile), { recursive: true }); + return (level: "debug" | "info" | "warn" | "error", message: string, data?: JsonValue): void => { + const entry = data === undefined + ? { ts: new Date().toISOString(), service, level, message } + : { ts: new Date().toISOString(), service, level, message, data }; + recentLogs.push(entry as JsonValue); + while (recentLogs.length > 500) recentLogs.shift(); + const line = `${JSON.stringify(entry)}\n`; + try { + appendFileSync(logFile, line, "utf8"); + } catch (error) { + console.error(JSON.stringify({ ts: new Date().toISOString(), service, level: "error", message: "log_write_failed", error: String(error) })); + } + const consoleMethod = level === "error" ? console.error : level === "warn" ? console.warn : console.log; + consoleMethod(line.trimEnd()); + }; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function emptyState(): PersistedState { + return { version: 1, updatedAt: nowIso(), nextSeq: 1, tasks: [] }; +} + +function normalizeTask(task: QueueTask): QueueTask { + task.output ??= []; + task.events ??= []; + task.attempts ??= []; + task.activeTurnId ??= null; + task.model ||= config.defaultModel; + task.cwd ||= config.defaultWorkdir; + return task; +} + +function readState(path: string): PersistedState { + mkdirSync(dirname(path), { recursive: true }); + if (!existsSync(path)) return emptyState(); + try { + const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown; + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return emptyState(); + const record = parsed as Partial; + const tasks = Array.isArray(record.tasks) ? (record.tasks as QueueTask[]).map(normalizeTask) : []; + return { version: 1, updatedAt: String(record.updatedAt ?? nowIso()), nextSeq: Number(record.nextSeq ?? 1), tasks }; + } catch { + return emptyState(); + } +} + +function persistState(): void { + state.updatedAt = nowIso(); + mkdirSync(dirname(config.statePath), { recursive: true }); + const tmp = `${config.statePath}.tmp`; + writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + renameSync(tmp, config.statePath); +} + +function prepareCodexHome(): void { + mkdirSync(config.codexHome, { recursive: true }); + if (existsSync(config.sourceCodexConfig)) { + copyFileSync(config.sourceCodexConfig, resolve(config.codexHome, "config.toml")); + } else { + logger("warn", "codex_config_source_missing", { sourceCodexConfig: config.sourceCodexConfig }); + } +} + +function safePreview(value: string, max = 900): string { + const compact = value.replace(/\s+/gu, " ").trim(); + return compact.length > max ? `${compact.slice(0, max)}...` : compact; +} + +function makeTaskId(): string { + return `codex_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`; +} + +function normalizeRequest(value: unknown): QueueTaskRequest { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("request body must be an object"); + const record = value as Record; + if (typeof record.prompt !== "string" || record.prompt.trim().length === 0) throw new Error("prompt is required"); + const request: QueueTaskRequest = { prompt: record.prompt }; + if (typeof record.cwd === "string" && record.cwd.length > 0) request.cwd = record.cwd; + if (typeof record.model === "string" && record.model.length > 0) request.model = record.model; + if (typeof record.reasoningEffort === "string" && record.reasoningEffort.length > 0) request.reasoningEffort = record.reasoningEffort; + if (typeof record.maxAttempts === "number" && Number.isInteger(record.maxAttempts) && record.maxAttempts > 0) request.maxAttempts = Math.min(10, record.maxAttempts); + return request; +} + +function createTask(request: QueueTaskRequest): QueueTask { + const at = nowIso(); + return { + id: makeTaskId(), + prompt: request.prompt, + cwd: resolve(request.cwd ?? config.defaultWorkdir), + model: request.model ?? config.defaultModel, + reasoningEffort: request.reasoningEffort ?? config.defaultReasoningEffort, + maxAttempts: request.maxAttempts ?? config.defaultMaxAttempts, + status: "queued", + createdAt: at, + updatedAt: at, + startedAt: null, + finishedAt: null, + currentAttempt: 0, + currentMode: null, + codexThreadId: null, + activeTurnId: null, + finalResponse: "", + lastError: null, + lastJudge: null, + output: [], + events: [], + attempts: [], + cancelRequested: false, + nextPrompt: null, + nextMode: null, + }; +} + +function appendOutput(task: QueueTask, channel: OutputChannel, text: string, method?: string, itemId?: string, append = false): void { + if (text.length === 0) return; + const last = task.output[task.output.length - 1]; + if (append && last !== undefined && last.channel === channel && last.itemId === itemId && last.method === method && last.text.length < 24_000) { + last.text += text; + last.at = nowIso(); + } else { + task.output.push({ seq: state.nextSeq++, at: nowIso(), channel, text, method, itemId }); + } + if (task.output.length > 1200) task.output.splice(0, task.output.length - 1200); + task.updatedAt = nowIso(); + persistState(); +} + +function addEvent(task: QueueTask, event: CodexEventSummary): void { + task.events.push(event); + if (task.events.length > 400) task.events.splice(0, task.events.length - 400); +} + +function taskForResponse(task: QueueTask, full = false): JsonValue { + return { + ...task, + prompt: full ? task.prompt : safePreview(task.prompt, 2000), + finalResponse: full ? task.finalResponse : safePreview(task.finalResponse, 5000), + output: full ? task.output : task.output.slice(-240), + events: full ? task.events : task.events.slice(-120), + } as unknown as JsonValue; +} + +function queueSummary(): JsonValue { + const counts = state.tasks.reduce>((memo, task) => { + memo[task.status] = (memo[task.status] ?? 0) + 1; + return memo; + }, {}); + const activeTaskId = activeRun?.taskId ?? state.tasks.find((task) => task.status === "running" || task.status === "judging")?.id ?? null; + return { + total: state.tasks.length, + activeTaskId, + processing, + counts, + judgeConfigured: config.minimaxApiKey.length > 0, + minimaxModel: config.minimaxModel, + defaultModel: config.defaultModel, + defaultWorkdir: config.defaultWorkdir, + }; +} + +function textInput(text: string): JsonValue[] { + return [{ type: "text", text, text_elements: [] }]; +} + +function extractRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; +} + +function extractString(value: unknown, key: string): string | null { + const record = extractRecord(value); + const field = record?.[key]; + return typeof field === "string" ? field : null; +} + +class AppServerClient { + private child: ChildProcessWithoutNullStreams; + private nextId = 1; + private pending = new Map void; reject: (error: Error) => void }>(); + private stderrChunks: Buffer[] = []; + private closed = false; + private exitInfo: AppServerExit | null = null; + private closeResolve!: (value: AppServerExit) => void; + readonly closedPromise: Promise; + + constructor(private readonly task: QueueTask, private readonly onNotification: (message: Record) => void) { + this.closedPromise = new Promise((resolveClosed) => { this.closeResolve = resolveClosed; }); + this.child = spawn("codex", ["app-server", "--listen", "stdio://"], { + cwd: task.cwd, + env: { ...process.env, CODEX_HOME: config.codexHome, CODEX_INTERNAL_ORIGINATOR_OVERRIDE: "unidesk_codex_queue" }, + stdio: "pipe", + }); + this.child.stderr.on("data", (chunk: Buffer) => { + this.stderrChunks.push(chunk); + while (Buffer.concat(this.stderrChunks).length > 96_000) this.stderrChunks.shift(); + }); + const rl = readline.createInterface({ input: this.child.stdout, crlfDelay: Infinity }); + void this.readLines(rl); + this.child.on("close", (code, signal) => this.handleClose(code, signal)); + this.child.on("error", (error) => this.handleClose(127, error.message)); + } + + async initialize(): Promise { + await this.request("initialize", { + clientInfo: { name: "unidesk_codex_queue", title: "UniDesk Codex Queue", version: "0.1.0" }, + capabilities: { experimentalApi: true }, + }); + this.notify("initialized", {}); + } + + async startOrResumeThread(): Promise { + if (this.task.codexThreadId !== null) { + const response = await this.request("thread/resume", { + threadId: this.task.codexThreadId, + model: this.task.model, + cwd: this.task.cwd, + approvalPolicy: config.approvalPolicy, + sandbox: config.sandbox, + }); + const threadId = extractString(extractRecord(response)?.thread, "id"); + if (threadId === null) throw new Error("thread/resume response did not include thread.id"); + return threadId; + } + const response = await this.request("thread/start", { + model: this.task.model, + cwd: this.task.cwd, + approvalPolicy: config.approvalPolicy, + sandbox: config.sandbox, + serviceName: "unidesk-codex-queue", + }); + const threadId = extractString(extractRecord(response)?.thread, "id"); + if (threadId === null) throw new Error("thread/start response did not include thread.id"); + return threadId; + } + + async startTurn(threadId: string, prompt: string): Promise { + const params: Record = { + threadId, + input: textInput(prompt), + cwd: this.task.cwd, + approvalPolicy: config.approvalPolicy, + model: this.task.model, + }; + if (this.task.reasoningEffort !== null) params.effort = this.task.reasoningEffort; + const response = await this.request("turn/start", params); + const turnId = extractString(extractRecord(response)?.turn, "id"); + if (turnId === null) throw new Error("turn/start response did not include turn.id"); + return turnId; + } + + async steer(threadId: string, turnId: string, prompt: string): Promise { + await this.request("turn/steer", { threadId, expectedTurnId: turnId, input: textInput(prompt) }); + } + + async interrupt(threadId: string, turnId: string): Promise { + await this.request("turn/interrupt", { threadId, turnId }); + } + + stop(): void { + if (this.closed) return; + this.child.kill("SIGTERM"); + setTimeout(() => { + if (!this.closed) this.child.kill("SIGKILL"); + }, 1500).unref?.(); + } + + private request(method: string, params: unknown): Promise { + if (this.closed) return Promise.reject(new Error("app-server is already closed")); + const id = this.nextId++; + const message = { method, id, params }; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.write(message); + }); + } + + private notify(method: string, params: unknown): void { + this.write({ method, params }); + } + + private write(message: unknown): void { + this.child.stdin.write(`${JSON.stringify(message)}\n`); + } + + private async readLines(rl: readline.Interface): Promise { + try { + for await (const line of rl) { + const trimmed = String(line).trim(); + if (trimmed.length === 0) continue; + const message = JSON.parse(trimmed) as Record; + this.handleMessage(message); + } + } catch (error) { + appendOutput(this.task, "error", `app-server stream error: ${error instanceof Error ? error.message : String(error)}\n`, "app-server"); + } + } + + private handleMessage(message: Record): void { + const id = typeof message.id === "number" ? message.id : null; + const method = typeof message.method === "string" ? message.method : null; + if (id !== null && method === null) { + const pending = this.pending.get(id); + if (pending === undefined) return; + this.pending.delete(id); + if ("error" in message) { + pending.reject(new Error(JSON.stringify(message.error))); + } else { + pending.resolve(message.result); + } + return; + } + if (id !== null && method !== null) { + this.handleServerRequest(id, method); + return; + } + if (method !== null) this.onNotification(message); + } + + private handleServerRequest(id: number, method: string): void { + if (method === "item/commandExecution/requestApproval") { + this.write({ id, result: { decision: "decline" } }); + return; + } + if (method === "item/fileChange/requestApproval") { + this.write({ id, result: { decision: "decline" } }); + return; + } + this.write({ id, error: { code: -32601, message: `Unsupported client-side request: ${method}` } }); + } + + private handleClose(code: number | null, signal: string | null): void { + if (this.closed) return; + this.closed = true; + this.exitInfo = { code, signal, stderrTail: Buffer.concat(this.stderrChunks).toString("utf8").slice(-8000) }; + for (const pending of this.pending.values()) pending.reject(new Error(`app-server closed with code=${code} signal=${signal}`)); + this.pending.clear(); + this.closeResolve(this.exitInfo); + } +} + +function eventSummary(message: Record): CodexEventSummary { + const params = extractRecord(message.params); + const item = extractRecord(params?.item); + const turn = extractRecord(params?.turn); + const error = extractRecord(turn?.error) ?? extractRecord(params?.error); + return { + at: nowIso(), + method: typeof message.method === "string" ? message.method : "unknown", + itemType: typeof item?.type === "string" ? item.type : undefined, + status: typeof item?.status === "string" ? item.status : typeof turn?.status === "string" ? turn.status : undefined, + message: typeof error?.message === "string" ? safePreview(error.message, 600) : undefined, + textPreview: typeof item?.text === "string" ? safePreview(item.text, 800) : undefined, + }; +} + +function handleNotification(task: QueueTask, message: Record, terminal: (status: TerminalStatus, error: string | null) => void): void { + const method = typeof message.method === "string" ? message.method : "unknown"; + const params = extractRecord(message.params); + addEvent(task, eventSummary(message)); + if (method === "thread/started") { + const threadId = extractString(extractRecord(params?.thread), "id"); + if (threadId !== null) task.codexThreadId = threadId; + appendOutput(task, "system", `thread started ${threadId ?? "unknown"}\n`, method); + return; + } + if (method === "turn/started") { + const turnId = extractString(extractRecord(params?.turn), "id"); + task.activeTurnId = turnId; + appendOutput(task, "system", `turn started ${turnId ?? "unknown"}\n`, method); + return; + } + if (method === "item/agentMessage/delta") { + appendOutput(task, "assistant", String(params?.delta ?? ""), method, extractString(params, "itemId") ?? undefined, true); + return; + } + if (method === "item/reasoning/summaryTextDelta" || method === "item/reasoning/textDelta") { + appendOutput(task, "reasoning", String(params?.delta ?? ""), method, extractString(params, "itemId") ?? undefined, true); + return; + } + if (method === "item/commandExecution/outputDelta") { + appendOutput(task, "command", String(params?.delta ?? ""), method, extractString(params, "itemId") ?? undefined, true); + return; + } + if (method === "item/fileChange/outputDelta") { + appendOutput(task, "diff", String(params?.delta ?? ""), method, extractString(params, "itemId") ?? undefined, true); + return; + } + if (method === "item/started" || method === "item/completed") { + const item = extractRecord(params?.item); + const type = String(item?.type ?? "item"); + if (type === "agentMessage" && typeof item?.text === "string") task.finalResponse = item.text; + if (type === "commandExecution") appendOutput(task, "command", `${method}: ${String(item?.command ?? "command")} status=${String(item?.status ?? "unknown")}\n`, method, extractString(item, "id") ?? undefined); + if (type === "fileChange") appendOutput(task, "diff", `${method}: file changes status=${String(item?.status ?? "unknown")}\n`, method, extractString(item, "id") ?? undefined); + if (type === "mcpToolCall" || type === "webSearch" || type === "dynamicToolCall") appendOutput(task, "tool", `${method}: ${type}\n`, method, extractString(item, "id") ?? undefined); + return; + } + if (method === "error") { + const error = extractRecord(params?.error); + appendOutput(task, "error", `${String(error?.message ?? "Codex error")}\n`, method); + return; + } + if (method === "turn/completed") { + const turn = extractRecord(params?.turn); + const status = terminalStatus(String(turn?.status ?? "failed")); + const error = extractRecord(turn?.error); + task.activeTurnId = null; + appendOutput(task, status === "completed" ? "system" : "error", `turn completed status=${status ?? "unknown"}\n`, method); + terminal(status, typeof error?.message === "string" ? error.message : null); + } +} + +function terminalStatus(value: string): TerminalStatus { + if (value === "completed" || value === "interrupted" || value === "failed") return value; + return null; +} + +async function runCodexTurn(task: QueueTask, prompt: string): Promise { + const events: CodexEventSummary[] = []; + let terminalSeen = false; + let terminalResult: { status: TerminalStatus; error: string | null } = { status: null, error: null }; + let terminalResolve!: () => void; + const terminalPromise = new Promise((resolveTerminal) => { terminalResolve = resolveTerminal; }); + const app = new AppServerClient(task, (message) => { + const before = task.events.length; + handleNotification(task, message, (status, error) => { + terminalSeen = true; + terminalResult = { status, error }; + terminalResolve(); + }); + events.push(...task.events.slice(before)); + }); + + try { + await app.initialize(); + const threadId = await app.startOrResumeThread(); + task.codexThreadId = threadId; + activeRun = { taskId: task.id, app, threadId, turnId: null }; + const turnId = await app.startTurn(threadId, prompt); + task.activeTurnId = turnId; + activeRun.turnId = turnId; + persistState(); + const race = await Promise.race([terminalPromise.then(() => "terminal" as const), app.closedPromise.then(() => "closed" as const)]); + const closedBeforeTerminal = race === "closed" && !terminalSeen; + if (terminalSeen) app.stop(); + const exit = await app.closedPromise; + return { + threadId, + turnId, + finalResponse: task.finalResponse, + terminalStatus: terminalResult.status, + terminalError: terminalResult.error, + transportClosedBeforeTerminal: closedBeforeTerminal, + appServerExit: exit, + events, + }; + } finally { + if (activeRun?.app === app) activeRun = null; + app.stop(); + } +} + +function fallbackJudge(result: CodexRunResult): JudgeResult { + if (result.transportClosedBeforeTerminal || result.terminalStatus === null) { + return { decision: "retry", confidence: 0.75, reason: "Codex app-server closed before turn/completed.", continuePrompt: retryInstruction, source: "fallback" }; + } + if (result.terminalStatus === "failed") { + return { decision: "retry", confidence: 0.7, reason: result.terminalError ?? "Codex turn failed.", continuePrompt: retryInstruction, source: "fallback" }; + } + if (result.terminalStatus === "interrupted") { + return { decision: "fail", confidence: 0.8, reason: "Codex turn was interrupted by user request.", source: "fallback" }; + } + return { decision: "complete", confidence: 0.65, reason: "Codex emitted turn/completed with completed status and no MiniMax judge is configured.", source: "fallback" }; +} + +const retryInstruction = "上一次执行疑似因服务端或传输问题中断。请检查当前工作区状态,继续完成原始任务;避免重复已经完成的修改。"; + +function judgePrompt(task: QueueTask, result: CodexRunResult): string { + const latestAttempt = task.attempts[task.attempts.length - 1] ?? null; + return JSON.stringify({ + instruction: "Classify whether a Codex coding task is truly complete, should retry by appending a continuation prompt to the existing Codex thread, or should fail as non-retryable. Return JSON only. Important: a normal Codex turn/completed status is only a transport/session terminal event; it is not proof the user's task was actually finished. Inspect the transcript, final response, command/file-change events, stderr, and original task before deciding.", + schema: { decision: "complete|retry|fail", confidence: "0..1", reason: "short string", continuePrompt: "required when decision=retry unless no useful prompt is possible" }, + originalTask: task.prompt, + attempt: task.currentAttempt, + maxAttempts: task.maxAttempts, + executionRecord: { + terminalStatus: result.terminalStatus, + terminalError: result.terminalError, + transportClosedBeforeTerminal: result.transportClosedBeforeTerminal, + appServerExitCode: result.appServerExit.code, + appServerSignal: result.appServerExit.signal, + stderrTail: safePreview(result.appServerExit.stderrTail, 2000), + finalResponse: safePreview(task.finalResponse, 6000), + latestAttempt, + recentOutput: task.output.slice(-80).map((item) => ({ channel: item.channel, text: safePreview(item.text, 500), method: item.method })), + recentEvents: task.events.slice(-60), + }, + policy: { + complete: "Use only when the transcript/final answer shows the current task is actually done; the queue worker will then advance to the next queued task.", + retry: "Use when the current task is incomplete, Codex only made a plan, skipped requested edits/commands, produced partial work, needs another turn, or hit a transient network/server/disconnected/transport/internal error. Retry must resume the existing thread when a thread id exists and append continuePrompt.", + fail: "Use when interrupted by user, blocked on missing user input, or failed for deterministic non-retryable reasons.", + }, + }); +} + +function parseJudgeJson(text: string): Record { + try { + return JSON.parse(text) as Record; + } catch { + const start = text.indexOf("{"); + const end = text.lastIndexOf("}"); + if (start >= 0 && end > start) return JSON.parse(text.slice(start, end + 1)) as Record; + throw new Error("MiniMax judge did not return JSON"); + } +} + +function normalizedDecision(value: unknown): JudgeDecision { + if (value === "complete" || value === "retry" || value === "fail") return value; + if (value === "continue") return "retry"; + return "retry"; +} + +async function judgeTask(task: QueueTask, result: CodexRunResult): Promise { + if (config.minimaxApiKey.length === 0) return fallbackJudge(result); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), config.judgeTimeoutMs); + try { + const response = await fetch(`${config.minimaxApiBase}/chat/completions`, { + method: "POST", + headers: { authorization: `Bearer ${config.minimaxApiKey}`, "content-type": "application/json" }, + body: JSON.stringify({ + model: config.minimaxModel, + temperature: 0, + messages: [ + { role: "system", content: "You are a strict task-state classifier. Return compact JSON only." }, + { role: "user", content: judgePrompt(task, result) }, + ], + }), + signal: controller.signal, + }); + const rawText = await response.text(); + if (!response.ok) throw new Error(`MiniMax HTTP ${response.status}: ${safePreview(rawText, 1000)}`); + const payload = JSON.parse(rawText) as Record; + const first = Array.isArray(payload.choices) ? extractRecord(payload.choices[0]) : null; + const content = extractString(first?.message, "content") ?? rawText; + const parsed = parseJudgeJson(content); + const confidenceRaw = Number(parsed.confidence ?? 0.5); + return { + decision: normalizedDecision(parsed.decision), + confidence: Number.isFinite(confidenceRaw) ? Math.max(0, Math.min(1, confidenceRaw)) : 0.5, + reason: typeof parsed.reason === "string" ? parsed.reason : "MiniMax judge returned a decision.", + continuePrompt: typeof parsed.continuePrompt === "string" && parsed.continuePrompt.trim().length > 0 ? parsed.continuePrompt : undefined, + source: "minimax", + raw: parsed as JsonValue, + }; + } catch (error) { + logger("warn", "judge_failed_fallback", { taskId: task.id, error: error instanceof Error ? error.message : String(error) }); + return fallbackJudge(result); + } finally { + clearTimeout(timer); + } +} + +const defaultJudgeProbeCases: JudgeProbeCase[] = [ + { + id: "completed_exact_response", + prompt: "Reply exactly: codex-queue-judge-complete.", + finalResponse: "codex-queue-judge-complete.", + expected: "complete", + terminalStatus: "completed", + outputs: [ + { channel: "user", text: "Reply exactly: codex-queue-judge-complete.\n", method: "enqueue" }, + { channel: "assistant", text: "codex-queue-judge-complete.", method: "item/agentMessage/delta" }, + { channel: "system", text: "turn completed status=completed\n", method: "turn/completed" }, + ], + events: [{ at: nowIso(), method: "turn/completed", status: "completed" }], + }, + { + id: "completed_but_plan_only", + prompt: "Create /workspace/tmp/judge_probe.txt containing exactly judge-probe-ok, then summarize the file path.", + finalResponse: "I can do that. Plan: create the file under /workspace/tmp and then summarize the path.", + expected: "retry", + terminalStatus: "completed", + outputs: [ + { channel: "user", text: "Create /workspace/tmp/judge_probe.txt containing exactly judge-probe-ok, then summarize the file path.\n", method: "enqueue" }, + { channel: "assistant", text: "I can do that. Plan: create the file under /workspace/tmp and then summarize the path.", method: "item/agentMessage/delta" }, + { channel: "system", text: "turn completed status=completed\n", method: "turn/completed" }, + ], + events: [{ at: nowIso(), method: "turn/completed", status: "completed" }], + }, + { + id: "transport_closed_before_terminal", + prompt: "Refactor the queue worker and run the focused tests.", + finalResponse: "", + expected: "retry", + terminalStatus: null, + transportClosedBeforeTerminal: true, + stderrTail: "stream disconnected before completion: upstream overloaded; app-server closed before turn/completed", + outputs: [ + { channel: "user", text: "Refactor the queue worker and run the focused tests.\n", method: "enqueue" }, + { channel: "system", text: "attempt 1/3 mode=initial model=gpt-5.4-mini\n", method: "queue" }, + ], + events: [{ at: nowIso(), method: "thread/status/changed", status: "inProgress" }], + }, + { + id: "user_interrupted", + prompt: "Run a long shell command, then produce a report.", + finalResponse: "", + expected: "fail", + terminalStatus: "interrupted", + outputs: [ + { channel: "user", text: "Run a long shell command, then produce a report.\n", method: "enqueue" }, + { channel: "system", text: "interrupt requested\n", method: "turn/interrupt" }, + { channel: "error", text: "turn completed status=interrupted\n", method: "turn/completed" }, + ], + events: [{ at: nowIso(), method: "turn/completed", status: "interrupted" }], + }, +]; + +function outputForProbe(item: { channel: OutputChannel; text: string; method?: string }, index: number): LiveOutput { + return { + seq: index + 1, + at: nowIso(), + channel: item.channel, + text: item.text, + method: item.method, + }; +} + +function taskForJudgeProbe(probe: JudgeProbeCase): QueueTask { + const at = nowIso(); + return { + id: `judge_probe_${probe.id}`, + prompt: probe.prompt, + cwd: config.defaultWorkdir, + model: config.defaultModel, + reasoningEffort: config.defaultReasoningEffort, + maxAttempts: 3, + status: "judging", + createdAt: at, + updatedAt: at, + startedAt: at, + finishedAt: null, + currentAttempt: 1, + currentMode: "initial", + codexThreadId: "judge-probe-thread", + activeTurnId: null, + finalResponse: probe.finalResponse, + lastError: null, + lastJudge: null, + output: (probe.outputs ?? []).map(outputForProbe), + events: probe.events ?? [], + attempts: [], + cancelRequested: false, + nextPrompt: null, + nextMode: null, + }; +} + +function resultForJudgeProbe(probe: JudgeProbeCase, task: QueueTask): CodexRunResult { + return { + threadId: "judge-probe-thread", + turnId: "judge-probe-turn", + finalResponse: probe.finalResponse, + terminalStatus: probe.terminalStatus, + terminalError: probe.terminalError ?? null, + transportClosedBeforeTerminal: probe.transportClosedBeforeTerminal ?? false, + appServerExit: { + code: probe.transportClosedBeforeTerminal ? 1 : 0, + signal: null, + stderrTail: probe.stderrTail ?? "", + }, + events: task.events, + }; +} + +async function runJudgeProbe(): Promise { + const results = []; + for (const probe of defaultJudgeProbeCases) { + const task = taskForJudgeProbe(probe); + const result = resultForJudgeProbe(probe, task); + const startedAt = nowIso(); + const finishedAt = nowIso(); + task.attempts.push(attemptFromResult(task, "initial", startedAt, finishedAt, result)); + const judge = await judgeTask(task, result); + results.push({ + id: probe.id, + expected: probe.expected, + decision: judge.decision, + hit: judge.decision === probe.expected, + confidence: judge.confidence, + source: judge.source, + reason: judge.reason, + continuePrompt: judge.continuePrompt ?? null, + }); + } + const hits = results.filter((result) => result.hit).length; + const hitRate = results.length === 0 ? 0 : hits / results.length; + logger("info", "judge_probe_completed", { configured: config.minimaxApiKey.length > 0, model: config.minimaxModel, hits, total: results.length, hitRate }); + return jsonResponse({ + ok: true, + configured: config.minimaxApiKey.length > 0, + model: config.minimaxModel, + hits, + total: results.length, + hitRate, + results, + }); +} + +function attemptFromResult(task: QueueTask, mode: RunMode, startedAt: string, finishedAt: string, result: CodexRunResult): AttemptSummary { + return { + index: task.currentAttempt, + mode, + startedAt, + finishedAt, + terminalStatus: result.terminalStatus, + transportClosedBeforeTerminal: result.transportClosedBeforeTerminal, + appServerExitCode: result.appServerExit.code, + appServerSignal: result.appServerExit.signal, + error: result.terminalError, + finalResponsePreview: safePreview(result.finalResponse, 3000), + stderrTail: safePreview(result.appServerExit.stderrTail, 3000), + }; +} + +function retryPrompt(task: QueueTask, judge: JudgeResult): string { + if (judge.continuePrompt !== undefined && judge.continuePrompt.trim().length > 0) return judge.continuePrompt; + return [retryInstruction, "原始任务:", task.prompt].join("\n\n"); +} + +async function runTask(task: QueueTask): Promise { + logger("info", "task_run_start", { taskId: task.id, maxAttempts: task.maxAttempts, model: task.model, promptPreview: safePreview(task.prompt, 240) }); + task.startedAt ??= nowIso(); + task.lastError = null; + while (task.attempts.length < task.maxAttempts && !task.cancelRequested) { + const mode = task.nextMode ?? (task.attempts.length === 0 ? "initial" : "retry"); + const prompt = task.nextPrompt ?? task.prompt; + const startedAt = nowIso(); + task.currentAttempt = task.attempts.length + 1; + task.currentMode = mode; + task.status = "running"; + task.updatedAt = startedAt; + appendOutput(task, "system", `attempt ${task.currentAttempt}/${task.maxAttempts} mode=${mode} model=${task.model}\n`, "queue"); + + const result = await runCodexTurn(task, prompt); + const finishedAt = nowIso(); + task.finalResponse = result.finalResponse || task.finalResponse; + task.attempts.push(attemptFromResult(task, mode, startedAt, finishedAt, result)); + task.status = "judging"; + task.updatedAt = nowIso(); + persistState(); + + if (task.cancelRequested) break; + const judge = await judgeTask(task, result); + task.lastJudge = judge; + appendOutput(task, judge.decision === "complete" ? "system" : judge.decision === "fail" ? "error" : "system", `judge=${judge.decision} confidence=${judge.confidence.toFixed(2)} source=${judge.source}: ${judge.reason}\n`, "judge"); + logger("info", "task_judged", { taskId: task.id, attempt: task.currentAttempt, decision: judge.decision, confidence: judge.confidence, source: judge.source, reason: safePreview(judge.reason, 500) }); + + if (judge.decision === "complete") { + task.status = "succeeded"; + task.finishedAt = nowIso(); + task.activeTurnId = null; + task.nextPrompt = null; + task.nextMode = null; + persistState(); + logger("info", "task_succeeded", { taskId: task.id, attempts: task.attempts.length }); + return; + } + if (judge.decision === "fail") { + task.status = "failed"; + task.finishedAt = nowIso(); + task.activeTurnId = null; + task.lastError = judge.reason; + persistState(); + logger("warn", "task_failed_by_judge", { taskId: task.id, reason: safePreview(judge.reason, 500) }); + return; + } + task.status = "retry_wait"; + task.nextPrompt = retryPrompt(task, judge); + task.nextMode = "retry"; + task.updatedAt = nowIso(); + persistState(); + } + + if (task.cancelRequested) { + task.status = "canceled"; + task.finishedAt = nowIso(); + task.lastError = "Task canceled by request."; + } else { + task.status = "failed"; + task.finishedAt = nowIso(); + task.lastError = `Max attempts reached (${task.maxAttempts}).`; + } + task.activeTurnId = null; + persistState(); + logger(task.status === "canceled" ? "warn" : "error", "task_terminal", { taskId: task.id, status: task.status, attempts: task.attempts.length, error: task.lastError ?? "" }); +} + +async function processQueue(): Promise { + if (processing) return; + processing = true; + try { + while (true) { + const task = state.tasks.find((item) => item.status === "queued" || item.status === "retry_wait") ?? null; + if (task === null) break; + await runTask(task); + } + } finally { + processing = false; + persistState(); + } +} + +function scheduleQueue(): void { + void processQueue().catch((error) => { + logger("error", "queue_loop_failed", { error: error instanceof Error ? error.stack ?? error.message : String(error) }); + processing = false; + activeRun = null; + }); +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body, null, 2), { + status, + headers: { + "content-type": "application/json; charset=utf-8", + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET,HEAD,POST,DELETE,OPTIONS", + "access-control-allow-headers": "content-type", + }, + }); +} + +async function readJson(req: Request): Promise { + const text = await req.text(); + if (text.trim().length === 0) return {}; + return JSON.parse(text) as unknown; +} + +function findTask(id: string): QueueTask | null { + return state.tasks.find((task) => task.id === id) ?? null; +} + +function parseLimit(url: URL): number { + const value = Number(url.searchParams.get("limit") ?? 100); + return Number.isInteger(value) && value > 0 ? Math.min(500, value) : 100; +} + +async function createTasks(req: Request): Promise { + const body = await readJson(req); + const records = typeof body === "object" && body !== null && !Array.isArray(body) && Array.isArray((body as Record).tasks) + ? (body as Record).tasks as unknown[] + : [body]; + const tasks = records.map((record) => createTask(normalizeRequest(record))); + for (const task of tasks) appendOutput(task, "user", `${task.prompt}\n`, "enqueue"); + state.tasks.push(...tasks); + persistState(); + logger("info", "tasks_enqueued", { count: tasks.length, ids: tasks.map((task) => task.id) }); + scheduleQueue(); + return jsonResponse({ ok: true, tasks: tasks.map((task) => taskForResponse(task)), queue: queueSummary() }, 202); +} + +async function steerTask(task: QueueTask, req: Request): Promise { + const body = await readJson(req); + const prompt = typeof (body as Record).prompt === "string" ? String((body as Record).prompt) : ""; + if (prompt.trim().length === 0) return jsonResponse({ ok: false, error: "prompt is required" }, 400); + if (activeRun === null || activeRun.taskId !== task.id || activeRun.turnId === null) { + return jsonResponse({ ok: false, error: "task does not have an active steerable turn", task: taskForResponse(task) }, 409); + } + appendOutput(task, "user", `\n[steer] ${prompt}\n`, "turn/steer"); + await activeRun.app.steer(activeRun.threadId, activeRun.turnId, prompt); + return jsonResponse({ ok: true, task: taskForResponse(task), queue: queueSummary() }); +} + +async function interruptTask(task: QueueTask): Promise { + if (task.status === "succeeded" || task.status === "failed" || task.status === "canceled") { + return jsonResponse({ ok: false, error: `task is already terminal: ${task.status}`, task: taskForResponse(task) }, 409); + } + task.cancelRequested = true; + task.updatedAt = nowIso(); + appendOutput(task, "system", "interrupt requested\n", "turn/interrupt"); + if (activeRun !== null && activeRun.taskId === task.id) { + if (activeRun.turnId !== null) { + await activeRun.app.interrupt(activeRun.threadId, activeRun.turnId).catch((error) => { + appendOutput(task, "error", `interrupt request failed: ${error instanceof Error ? error.message : String(error)}\n`, "turn/interrupt"); + }); + } else { + activeRun.app.stop(); + } + } + if (task.status === "queued" || task.status === "retry_wait") { + task.status = "canceled"; + task.finishedAt = nowIso(); + } + persistState(); + return jsonResponse({ ok: true, task: taskForResponse(task), queue: queueSummary() }); +} + +function manualRetry(task: QueueTask): Response { + if (task.status !== "failed" && task.status !== "canceled" && task.status !== "succeeded") { + return jsonResponse({ ok: false, error: `task is not terminal: ${task.status}`, task: taskForResponse(task) }, 409); + } + task.status = "queued"; + task.finishedAt = null; + task.cancelRequested = false; + task.lastError = null; + task.maxAttempts = Math.max(task.maxAttempts, task.attempts.length + 1); + task.nextMode = "retry"; + task.nextPrompt = retryPrompt(task, { decision: "retry", confidence: 1, reason: "Manual retry", source: "fallback" }); + task.updatedAt = nowIso(); + appendOutput(task, "system", "manual retry queued\n", "manual-retry"); + persistState(); + scheduleQueue(); + return jsonResponse({ ok: true, task: taskForResponse(task), queue: queueSummary() }, 202); +} + +async function route(req: Request): Promise { + const url = new URL(req.url); + if (req.method === "OPTIONS") return jsonResponse({ ok: true }); + try { + if (url.pathname === "/" || url.pathname === "/health") return jsonResponse({ ok: true, service: "codex-queue", queue: queueSummary(), startedAt: serviceStartedAt }); + if (url.pathname === "/logs") return jsonResponse({ ok: true, logs: recentLogs.slice(-parseLimit(url)) }); + if (url.pathname === "/api/judge/probe" && (req.method === "GET" || req.method === "POST")) return runJudgeProbe(); + if (url.pathname === "/api/tasks" && req.method === "GET") { + const status = url.searchParams.get("status"); + const tasks = state.tasks.filter((task) => status === null || task.status === status).slice(-parseLimit(url)).reverse().map((task) => taskForResponse(task)); + return jsonResponse({ ok: true, queue: queueSummary(), tasks }); + } + if ((url.pathname === "/api/tasks" || url.pathname === "/api/tasks/batch") && req.method === "POST") return createTasks(req); + const match = url.pathname.match(/^\/api\/tasks\/([^/]+)(?:\/(retry|steer|interrupt))?$/u); + if (match !== null) { + const task = findTask(decodeURIComponent(match[1] ?? "")); + if (task === null) return jsonResponse({ ok: false, error: "task not found" }, 404); + const action = match[2]; + if (action === "retry" && req.method === "POST") return manualRetry(task); + if (action === "steer" && req.method === "POST") return steerTask(task, req); + if (action === "interrupt" && req.method === "POST") return interruptTask(task); + if (action !== undefined) return jsonResponse({ ok: false, error: "not found" }, 404); + if (req.method === "GET") return jsonResponse({ ok: true, task: taskForResponse(task, true) }); + if (req.method === "DELETE") return interruptTask(task); + return jsonResponse({ ok: false, error: "method not allowed" }, 405); + } + return jsonResponse({ ok: false, error: "not found", path: url.pathname }, 404); + } catch (error) { + logger("error", "request_failed", { path: url.pathname, error: error instanceof Error ? error.stack ?? error.message : String(error) }); + return jsonResponse({ ok: false, error: error instanceof Error ? error.message : String(error) }, 500); + } +} + +prepareCodexHome(); +Bun.serve({ hostname: config.host, port: config.port, fetch: route }); +logger("info", "service_started", { port: config.port, statePath: config.statePath, workdir: config.defaultWorkdir, defaultModel: config.defaultModel, judgeConfigured: config.minimaxApiKey.length > 0 }); +for (const task of state.tasks) { + if (task.status === "running" || task.status === "judging") { + task.status = "retry_wait"; + task.activeTurnId = null; + task.lastError = "Service restarted while task was active."; + task.nextMode = "retry"; + task.nextPrompt = retryPrompt(task, { decision: "retry", confidence: 1, reason: "Service restart", source: "fallback" }); + appendOutput(task, "system", "service restarted; task queued for retry\n", "startup"); + } +} +persistState(); +scheduleQueue(); diff --git a/src/components/microservices/codex-queue/tsconfig.json b/src/components/microservices/codex-queue/tsconfig.json new file mode 100644 index 00000000..f62969e7 --- /dev/null +++ b/src/components/microservices/codex-queue/tsconfig.json @@ -0,0 +1,17 @@ +{ + "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"] +} diff --git a/src/components/provider-gateway/package.json b/src/components/provider-gateway/package.json index c52e1e08..959e712b 100644 --- a/src/components/provider-gateway/package.json +++ b/src/components/provider-gateway/package.json @@ -1,6 +1,6 @@ { "name": "@unidesk/provider-gateway", - "version": "0.2.8", + "version": "0.2.9", "private": true, "type": "module", "scripts": { diff --git a/src/components/provider-gateway/src/index.ts b/src/components/provider-gateway/src/index.ts index 2fcc9cf3..96e32566 100644 --- a/src/components/provider-gateway/src/index.ts +++ b/src/components/provider-gateway/src/index.ts @@ -1350,7 +1350,7 @@ function assertAllowedMicroserviceBase(rawBaseUrl: string): URL { const baseUrl = new URL(rawBaseUrl); if (baseUrl.protocol !== "http:") throw new Error(`microservice backend only supports http URLs, got ${baseUrl.protocol}`); const host = baseUrl.hostname.toLowerCase(); - const allowedHosts = new Set(["127.0.0.1", "localhost", "host.docker.internal", "todo-note"]); + const allowedHosts = new Set(["127.0.0.1", "localhost", "host.docker.internal", "todo-note", "codex-queue"]); if (!allowedHosts.has(host)) throw new Error(`microservice backend host is not allowed: ${baseUrl.hostname}`); return baseUrl; }