From 5a198baf77dc481b51b8fd80f77e7e4b488d4436 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 11 May 2026 07:39:37 +0000 Subject: [PATCH] chore: checkpoint before performance tuning --- AGENTS.md | 21 +- TEST.md | 32 +- config.json | 93 +- docker-compose.yml | 50 +- docs/reference/arch.md | 2 +- docs/reference/cli.md | 28 +- docs/reference/config.md | 6 +- docs/reference/constar-d601.md | 94 + docs/reference/deployment.md | 17 +- docs/reference/e2e.md | 16 +- docs/reference/frontend.md | 51 +- docs/reference/microservices.md | 113 +- docs/reference/observability.md | 6 + docs/reference/pipeline-oa-event-flow.md | 28 + docs/reference/provider-gateway.md | 12 +- docs/reference/repo-tree.md | 10 +- docs/reference/windows-passthrough.md | 177 + scripts/cli.ts | 24 +- scripts/src/codex-queue-perf.ts | 221 + scripts/src/codex-queue.ts | 449 ++ scripts/src/docker.ts | 112 +- scripts/src/e2e.ts | 896 +++- scripts/src/jobs.ts | 55 +- scripts/src/microservices.ts | 2 +- scripts/src/remote.ts | 121 +- scripts/src/ssh.ts | 266 +- src/components/backend-core/src/index.ts | 858 +++- src/components/frontend/package.json | 1 + src/components/frontend/public/app.js | 84 + src/components/frontend/public/style.css | 2046 ++++++--- src/components/frontend/scripts/build.ts | 29 + src/components/frontend/src/app.tsx | 486 ++- src/components/frontend/src/claudeqq.tsx | 402 ++ .../frontend/src/codex-queue-entry.tsx | 86 + src/components/frontend/src/codex-queue.tsx | 2166 +++++++++- src/components/frontend/src/findjob.tsx | 34 +- src/components/frontend/src/index.ts | 407 +- src/components/frontend/src/met-nonlinear.tsx | 95 +- src/components/frontend/src/navigation.ts | 5 +- src/components/frontend/src/pipeline.tsx | 1138 +++-- .../frontend/src/project-manager.tsx | 316 ++ src/components/frontend/src/todo-note.tsx | 34 +- src/components/frontend/src/top-status.tsx | 23 + src/components/frontend/src/trace.tsx | 768 ++++ .../frontend/src/unidesk-error-banner.tsx | 34 + src/components/frontend/src/unidesk-error.ts | 316 ++ .../microservices/codex-queue/Dockerfile | 6 +- .../microservices/codex-queue/package.json | 3 + .../microservices/codex-queue/src/index.ts | 3645 ++++++++++++++++- .../microservices/project-manager/Dockerfile | 10 + .../microservices/project-manager/bun.lock | 34 + .../project-manager/package.json | 13 + .../project-manager/src/index.ts | 612 +++ .../project-manager/tsconfig.json | 17 + src/components/provider-gateway/package.json | 2 +- src/components/provider-gateway/src/index.ts | 158 +- ...tor([data-testid=codex-execution-summary]" | 0 57 files changed, 14768 insertions(+), 1962 deletions(-) create mode 100644 docs/reference/constar-d601.md create mode 100644 docs/reference/windows-passthrough.md create mode 100644 scripts/src/codex-queue-perf.ts create mode 100644 scripts/src/codex-queue.ts create mode 100644 src/components/frontend/public/app.js create mode 100644 src/components/frontend/scripts/build.ts create mode 100644 src/components/frontend/src/claudeqq.tsx create mode 100644 src/components/frontend/src/codex-queue-entry.tsx create mode 100644 src/components/frontend/src/project-manager.tsx create mode 100644 src/components/frontend/src/top-status.tsx create mode 100644 src/components/frontend/src/trace.tsx create mode 100644 src/components/frontend/src/unidesk-error-banner.tsx create mode 100644 src/components/frontend/src/unidesk-error.ts create mode 100644 src/components/microservices/project-manager/Dockerfile create mode 100644 src/components/microservices/project-manager/bun.lock create mode 100644 src/components/microservices/project-manager/package.json create mode 100644 src/components/microservices/project-manager/src/index.ts create mode 100644 src/components/microservices/project-manager/tsconfig.json create mode 100644 "summary).scrollIntoViewIfNeeded();\n await page.locator([data-testid=codex-execution-summary]" diff --git a/AGENTS.md b/AGENTS.md index 0bcf08d0..2f7678e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,15 +12,16 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 ## CLI - `bun scripts/cli.ts help`:输出所有可用命令的 JSON 索引,详细规范见 `docs/reference/cli.md`。 -- `bun scripts/cli.ts --main-server-ip `:默认通过公网 frontend 登录态远程执行调试与节点自测命令,不要求主 server SSH key,详细规范见 `docs/reference/cli.md`。 +- `bun scripts/cli.ts --main-server-ip `:默认通过公网 frontend 登录态远程执行调试、用户服务(底层命令名 `microservice`)、`codex task ` 与节点自测命令,不要求主 server SSH key,详细规范见 `docs/reference/cli.md`。 - `bun scripts/cli.ts config show`:校验并展示根目录 `config.json`,配置来源规则见 `docs/reference/config.md`。 - `bun scripts/cli.ts check`:运行配置、TypeScript、文件存在性和 Docker Compose 配置检查,测试入口见 `TEST.md`。 -- `bun scripts/cli.ts server start`:以异步 job 启动 database、backend-core、frontend、provider-gateway 和主 server microservice,部署规则见 `docs/reference/deployment.md`。 +- `bun scripts/cli.ts server start`:以异步 job 启动 database、backend-core、frontend、provider-gateway 和主 server 用户服务,部署规则见 `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 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,Codex Queue/Todo Note on main-server 与 FindJob/Pipeline/MET Nonlinear on D601 的规则见 `docs/reference/microservices.md`。 +- `bun scripts/cli.ts server rebuild `:以 build-first、Compose lock、no-deps force-recreate 和 post-up validation 的异步 job 重建单个服务,规则见 `docs/reference/deployment.md`。 +- `bun scripts/cli.ts ssh [ssh-like args...]`:通过 provider-gateway 的 Host SSH / WSL SSH 维护桥打开近似原生 ssh 的交互会话或远端命令,并在远端 PATH 注入 `apply_patch`、`glob` 与 `skill-discover`;`apply-patch`、`py`、`skills`、结构化 `find`、`glob` 和 `argv` 子命令用于避免远端补丁、Python stdin、skill 发现与常用只读命令的嵌套转义问题,使用规则见 `docs/reference/cli.md` 和 `docs/reference/provider-gateway.md`。 +- `bun scripts/cli.ts microservice list/status/health/proxy`:管理和验证挂载在主 server 或计算节点 Docker 中的用户服务,Codex Queue/Todo Note on main-server 与 FindJob/Pipeline/MET Nonlinear on D601 的规则见 `docs/reference/microservices.md`。 +- `bun scripts/cli.ts codex task `:按 Codex Queue 任务 ID 查询初始 prompt、最后 assistant message、工具调用摘要、attempt/judge/error 和耗时,便于新任务引用历史 session。 - `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`。 @@ -30,16 +31,20 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `bun`:TypeScript 运行时固定使用 Bun,组件入口和 CLI 都直接运行 `.ts` 文件,约束见 `docs/reference/config.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`。 +- `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`。 +- `backend-core / frontend performance`:backend-core 暴露 `/api/performance`,frontend 暴露同源 `/api/frontend-performance` 并在 `/ops/performance/` 汇总组件请求、失败请求、内部操作和慢操作,规则见 `docs/reference/observability.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`。 +- `microservices`:用户服务配置命名仍保留 `microservices`;用户服务指挂载在 UniDesk 核心服务上的用户业务能力,缺少这些服务时核心仍可运行。主 server 本地开发边界固定为只开发 UniDesk frontend;非 UniDesk 核心业务后端、Dockerfile、GPU/训练调试必须在目标计算节点通过 SSH 透传完成,Todo Note 这类明确写入主 server 的例外需单独登记,规则见 `docs/reference/microservices.md`。 - `docs/reference/e2e.md`:交付前必须执行的自测门禁、Playwright 登录、资源监控进程排序、JSON 展示断言和数据库命名卷持久化要求。 ## Architecture Docs - `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/observability.md`:服务日志、任务活性、通用性能指标 API 和性能面板的可观测性规则。 +- `docs/reference/microservices.md`:用户服务(兼容命名 `microservice`)的配置、代理、安全边界、Todo Note on main-server、FindJob/Pipeline/MET Nonlinear on D601 和验证规则。 +- `docs/reference/windows-passthrough.md`:WSL provider 通过 SSH 透传调用 Windows cmd/PowerShell、Keil、COM 串口和 Windows 侧 skill 的长期规则。 +- `docs/reference/constar-d601.md`:D601 上 ConStart/constar 固件工作区的 UniDesk SSH 入口、WSL skill wrapper、Keil 编译下载和串口/JSON-RPC 验证简要引导。 - `docs/reference/pipeline-oa-event-flow.md`:Pipeline/OA 事件流、审核/无审核流转、单步调试、甘特图渲染和最终去残留规则。 - `reference`:兼容旧路径的符号链接,指向 `docs/reference/`。 diff --git a/TEST.md b/TEST.md index 2e825420..c0e4cd5e 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`、`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 透传`、`远程更新` 和结构化控件。 +阅读 `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`、`core:performance-api`、`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:performance-panel-visible`、`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,12 +50,16 @@ ## 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、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 转译生成。 +阅读 `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、ClaudeQQ、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/claudeqq.tsx`、`src/components/frontend/src/codex-queue.tsx` 中维护;运行 `bun scripts/cli.ts check`,确认这些 TSX 模块全部纳入 TypeScript 检查,且浏览器请求 `/app.js` 由 frontend Bun server 从 TSX imports 转译生成。 ## T13 资源节点任务管理器曲线 阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts e2e run`,确认 `provider:system-status`、`provider:process-resource-status`、`frontend:system-monitor-visible` 和 `frontend:process-resource-sorting` passed;再用浏览器登录 frontend,进入左侧 `资源节点` 和顶部 `资源监控` 子标签,确认可以像 Windows 任务管理器一样看到 CPU、Memory、Disk 当前用量和历史曲线,Memory 明确显示为不含 Linux page cache / buffer 的实际内存占用;确认 `进程资源占用` 表默认按内存 RSS 降序,能点击 CPU、PID、用户、磁盘 I/O 等表头切换排序,且只通过 `查看原始JSON` 查看完整进程快照;最后确认能执行 `Provider Gateway 升级` 的 `预检升级`。 +## T13A 通用性能面板 + +阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts e2e run --only core:performance-api,frontend:performance-panel-visible`,确认 backend-core `/api/performance` 返回组件请求统计、内部操作统计、PGDATA 和 Codex Queue PostgreSQL 存储摘要;再用浏览器登录 frontend,进入左侧 `运行总览` 和顶部 `性能面板` 子标签,确认页面能看到 `Bwebui` MB 趋势图、组件汇总、最近失败请求、内部操作汇总和最近慢操作,且没有失败请求时明确显示“最近没有失败请求”;完整性能 JSON 只能通过 `查看原始JSON` 打开。 + ## T14 Provider Gateway 远程升级 阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:如果本次变更修改了 `src/components/provider-gateway` 代码或行为,先确认 `src/components/provider-gateway/package.json` 的 `version` 已递增;运行 `bun scripts/cli.ts debug dispatch main-server provider.upgrade`,随后查看任务历史或 `bun scripts/cli.ts debug health`,确认 `provider.upgrade` 通过真实 WebSocket 下发并以 `mode: plan` 成功返回升级计划且计划中包含 `providerId`、`providerName`、`providerGatewayVersion`、`targetProviderGatewayVersion`、`policy: "always-enabled"`、`--no-deps`、`--force-recreate`、`oldGatewaySleepMs`、`promoteOnlyAfterCandidateValidation`、`candidateRestartPolicyAfterPromotion: "always"`、`candidateUsesOldContainerEnvironment` 和 `candidateUsesHostPidNamespace`;对明确要升级或重建 `provider-gateway` 容器的计算节点,必须再运行 `bun scripts/cli.ts debug dispatch provider.upgrade --mode schedule --wait-ms 15000`,确认任务成功、result 包含 updater 容器信息、候选 gateway 验证后节点重新上线,`providerGatewayVersion` 已上报目标新版本,且最终 provider-gateway 容器 Docker restart policy 是 `always` 并使用宿主 PID namespace。在非主 server 的计算节点上,必须使用 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch provider.upgrade --mode schedule --wait-ms 15000` 做同一验证,证明该节点能通过公网 frontend remote CLI 自测自动升级,且不需要指定 `--main-server-key`。正式执行计算节点 `provider-gateway` 重建/升级只能通过前端 `资源监控` 的 `执行升级` 或等价的 `provider.upgrade mode=schedule` 显式调度完成,不能通过 `bun scripts/cli.ts ssh ` 或 Host SSH 维护桥同步执行自重建命令,也不能通过 `PROVIDER_UPGRADE_ENABLED` 或等价开关禁用远程升级。 @@ -80,23 +84,27 @@ 阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts server rebuild frontend`,确认命令立即返回 `server_rebuild` job id;随后运行 `bun scripts/cli.ts job status latest` 直到状态为 `succeeded`,stdout 中必须能看到先 build、再按 `frontend` 服务容器 label 移除、最后 `--no-deps frontend` 启动的过程。重建后运行 `bun scripts/cli.ts server status` 和 `bun scripts/cli.ts e2e run`,确认公网 frontend 恢复健康、Playwright 登录通过、database 命名卷未被删除;正式验收不得要求人工执行 `docker rm` 作为兜底。 -## T20 D601 FindJob Microservice +## T20 D601 FindJob User Service -阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts microservice list`,确认 `findjob` 显示为 `providerId=D601`、`public=false`、`frontendOnly=true`、仓库 URL、commit id、`127.0.0.1:3254` 后端映射和 `findjob-server` 容器摘要;运行 `bun scripts/cli.ts microservice health findjob` 和 `bun scripts/cli.ts microservice proxy findjob /api/summary`,确认链路通过 backend-core、D601 provider-gateway 和 D601 本机 FindJob 后端;运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 microservice health findjob`,确认非主 server 也能通过公网 frontend remote CLI 验证同一链路且不需要 `--main-server-key`。随后运行 `bun scripts/cli.ts e2e run`,确认 microservice 和 frontend FindJob 检查全部 passed;再登录公网 frontend `http://74.48.78.17:18081/`,进入 `微服务 / 服务目录` 和 `微服务 / FindJob`,确认页面以 React 控件显示 D601、仓库引用、私有后端映射、FindJob 指标、岗位预览和草稿报告,默认没有裸 JSON,只有点击 `查看原始JSON` 才显示原始数据。FindJob 业务代码开发和调试必须用 `bun scripts/cli.ts ssh D601 ...` 进入 D601 的 `/home/ubuntu/findjob`,不得把 findjob 全量代码复制进 UniDesk 仓库,也不得占用主 server 部署调试服务。 +阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts microservice list`,确认 `findjob` 显示为 `providerId=D601`、`public=false`、`frontendOnly=true`、仓库 URL、commit id、`127.0.0.1:3254` 后端映射和 `findjob-server` 容器摘要;运行 `bun scripts/cli.ts microservice health findjob` 和 `bun scripts/cli.ts microservice proxy findjob /api/summary`,确认链路通过 backend-core、D601 provider-gateway 和 D601 本机 FindJob 后端;运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 microservice health findjob`,确认非主 server 也能通过公网 frontend remote CLI 验证同一链路且不需要 `--main-server-key`。随后运行 `bun scripts/cli.ts e2e run`,确认用户服务和 frontend FindJob 检查全部 passed;再登录公网 frontend `http://74.48.78.17:18081/`,进入 `用户服务 / 服务目录` 和 `用户服务 / FindJob`,确认页面以 React 控件显示 D601、仓库引用、私有后端映射、FindJob 指标、岗位预览和草稿报告,默认没有裸 JSON,只有点击 `查看原始JSON` 才显示原始数据。FindJob 业务代码开发和调试必须用 `bun scripts/cli.ts ssh D601 ...` 进入 D601 的 `/home/ubuntu/findjob`,不得把 findjob 全量代码复制进 UniDesk 仓库,也不得占用主 server 部署调试服务。 -## T21 D601 Pipeline Microservice +## T21 D601 Pipeline User Service -阅读 `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 部署调试服务。 +阅读 `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` 和其他用户服务/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 Trace`,Trace 正文必须复用 Codex Queue 的公共 Trace 样式和 opencode port,废弃旧 Pipeline step/message/tool 卡片风格;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 +## T22 Main Server Todo Note User Service -阅读 `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` 才显示原始数据。 +阅读 `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 Main Server Codex Queue Microservice +## T23 Main Server Codex Queue User Service -阅读 `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/dev-ready --raw` 和 `bun scripts/cli.ts microservice proxy codex-queue /api/tasks`,确认链路通过 backend-core、main-server provider-gateway 和 Codex Queue 后端,且 `queue.devReady.ok=true`、`devReady.missingTools=[]`、`docker.versionOk=true`、`docker.composeOk=true`,必需工具包含 `docker`、`docker-compose`、`jq`、`ssh`、`rsync`、`pip3` 和 `unzip`;提交会产生较多命令输出的小任务后,`/health` 和 `/api/tasks` 仍必须在常规 CLI 超时内返回,容器内不得堆积无超时 healthcheck 进程。随后登录公网 frontend `http://74.48.78.17:18081/`,进入 `微服务 / Codex Queue`,确认页面显示默认模型 `gpt-5.4-mini`、默认工作目录 `/root/unidesk`、模型下拉菜单包含 `gpt-5.4-mini`/`gpt-5.4`/`gpt-5.5`、入队份数、队列指标、任务提交表单、Codex CLI-like 输出、attempt 表、MiniMax/fallback judge 状态、追加 prompt、打断和重试控件;通过页面提交一个小任务,确认任务进入 queued/running/succeeded 或可解释的 failed 状态,并且输出区能看到运行中的 Codex 消息。批量验收时设置 `入队份数=5` 或用 `---` 分隔 5 段 prompt,一次性入队 5 条任务,确认 5 条任务按顺序运行并全部进入 succeeded 或可解释的非成功终态,不能只运行第一条后停止。测试异常中断时可以提交长任务后点击 `打断`,确认任务变为 canceled 或被 judge 标记为非成功终态;自动重试只应在服务端/传输异常、任务正常结束但 execution record 显示未完成、或 judge 判定 retry 时发生;retry 必须复用已有 Codex thread 并 append 继续执行 prompt,只有当前任务 complete 后才推进队列中的下一个任务。MiniMax judge 必须能处理 Markdown fence/夹杂文本等 JSON 去噪;若去噪后仍失败,必须把解析错误和上一轮去噪前原始回答反馈给 MiniMax 修复后重试,日志中应出现 `judge_json_parse_retry`,且 repair 成功时仍以 `source=minimax` 返回。Codex provider key 只能通过 `OPENAI_API_KEY`、`CRS_OAI_KEY` 这类运行时环境透传,MiniMax API key 只能通过 `UNIDESK_CODEX_QUEUE_MINIMAX_API_KEY` 这类运行时环境传入,禁止写入 `config.json`、Dockerfile、源码或测试文档。 +阅读 `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 ` 等待该 job 成功且输出 post-up validation,再运行 `bun scripts/cli.ts microservice health codex-queue`、`bun scripts/cli.ts microservice proxy codex-queue /api/dev-ready --raw`、`bun scripts/cli.ts microservice proxy codex-queue /api/tasks` 和 `bun scripts/cli.ts codex task <已有taskId>`,确认链路通过 backend-core、main-server provider-gateway 和 Codex Queue 后端,且 task id 查询返回初始 prompt、最后 assistant message、工具调用摘要、attempt/judge/error 和耗时,且 `queue.devReady.ok=true`、`devReady.missingTools=[]`、`docker.versionOk=true`、`docker.composeOk=true`,必需工具包含 `docker`、`docker-compose`、`jq`、`ssh`、`rsync`、`pip3` 和 `unzip`;提交会产生较多命令输出的小任务后,`/health` 和 `/api/tasks` 仍必须在常规 CLI 超时内返回,容器内不得堆积无超时 healthcheck 进程。随后登录公网 frontend `http://74.48.78.17:18081/`,进入 `用户服务 / Codex Queue`,确认页面显示默认模型 `gpt-5.5`、默认工作目录 `/root/unidesk`、模型下拉菜单包含 `gpt-5.4-mini`/`gpt-5.4`/`gpt-5.5`、入队份数、队列指标、任务 ID、复制任务 ID、引用按钮、任务耗时、引用任务 ID、清空输入、创建成功提示、任务提交表单、Codex CLI-like 输出、attempt 表、MiniMax/fallback judge 状态、追加 prompt、打断和重试控件;通过页面提交一个小任务,确认任务进入 queued/running/succeeded 或可解释的 failed 状态,并且输出区能看到运行中的 Codex 消息。批量验收时设置 `入队份数=5` 或用 `---` 分隔 5 段 prompt,一次性入队 5 条任务,确认 5 条任务按顺序运行并全部进入 succeeded 或可解释的非成功终态,不能只运行第一条后停止;其中任一任务被 judge 判定 `fail` 时只能把当前任务标为 failed,后续 queued 任务仍必须继续推进。测试异常中断时可以提交长任务后点击 `打断`,确认任务变为 canceled 或被 judge 标记为非成功终态;自动重试只应在服务端/传输异常、任务正常结束但 execution record 显示未完成、或 judge 判定 retry 时发生;retry 必须复用已有 Codex thread 并 append 继续执行 prompt,只有当前任务 complete 后才推进队列中的下一个任务。MiniMax judge 必须能处理 Markdown fence/夹杂文本等 JSON 去噪;若去噪后仍失败,必须把解析错误和上一轮去噪前原始回答反馈给 MiniMax 修复后重试,日志中应出现 `judge_json_parse_retry`,且 repair 成功时仍以 `source=minimax` 返回。Codex provider key 只能通过 `OPENAI_API_KEY`、`CRS_OAI_KEY` 这类运行时环境透传,MiniMax API key 只能通过 `UNIDESK_CODEX_QUEUE_MINIMAX_API_KEY` 这类运行时环境传入,禁止写入 `config.json`、Dockerfile、源码或测试文档。 -## T24 MET Nonlinear D601 GPU Microservice +## T24 MET Nonlinear D601 GPU User Service -阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:确认 D601 `~/met_nonlinear` 中存在 `docker-compose.unidesk.yml`、`docker/unidesk/Dockerfile.ml`、`unidesk/server/src/index.ts` 和 `docs/reference/unidesk_microservice.md`;运行 `bun scripts/cli.ts microservice list`,确认 `met-nonlinear` 显示为 `providerId=D601`、`public=false`、`frontendOnly=true`、`127.0.0.1:3288` 后端映射和 `met-nonlinear-ts` 容器摘要;运行 `bun scripts/cli.ts microservice health met-nonlinear`、`bun scripts/cli.ts microservice proxy met-nonlinear /api/queue`、`bun scripts/cli.ts microservice proxy met-nonlinear '/api/projects?root=projects&limit=500'`、`bun scripts/cli.ts microservice proxy met-nonlinear '/api/projects?root=ex_projects&limit=500'`、`bun scripts/cli.ts microservice proxy met-nonlinear '/api/projects/config?path=projects/' --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轮任务` 这类硬编码测试按钮。 +阅读 `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轮任务` 这类硬编码测试按钮。 + +## T25 ClaudeQQ D601 QQ Gateway User Service + +阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:确认 D601 `/home/ubuntu/.agents/skills/claudeqq` 中存在 `Dockerfile`、`docker-compose.unidesk.yml`、`napcat/config/onebot11.json` 和 `scripts/src/server_ts/src/server/event_bus.ts`;运行 `bun scripts/cli.ts microservice list`,确认 `claudeqq` 显示为 `providerId=D601`、`public=false`、`frontendOnly=true`、仓库 URL `https://gitee.com/lyon1998/agent_skills`、`127.0.0.1:3290` 后端映射和 `claudeqq-backend` 容器摘要,并在 D601 上确认 `claudeqq-napcat` 容器运行且只绑定 `127.0.0.1:3000/3001/6099`;运行 `bun scripts/cli.ts microservice health claudeqq`、`bun scripts/cli.ts microservice proxy claudeqq /api/napcat/login`、`bun scripts/cli.ts microservice proxy claudeqq /api/events/recent` 和 `bun scripts/cli.ts microservice proxy claudeqq /api/events/subscriptions`,确认链路通过 backend-core、D601 provider-gateway 和 D601 本机 ClaudeQQ 后端,health 返回 `service=claudeqq`、`pureBackend=true`、`napcat.containerized=true`、NapCat HTTP/WS 状态、登录状态/二维码、`/api/push/text` 与 `/api/events/subscriptions` 端点;通过 `POST /api/events/subscriptions` 创建临时 webhook 订阅再删除,确认 main server 和其他用户服务可订阅 QQ 消息事件;通过 `POST /api/push/text` 发送消息时若 NapCat 未登录必须返回可解释错误,NapCat 在线时必须返回消息 ID,人工推送验收只允许发给主用户私聊账号 `645275593`。最后登录公网 frontend `http://74.48.78.17:18081/`,进入 `用户服务 / ClaudeQQ`,确认页面以 React 控件显示 D601、仓库引用、私有后端映射、NapCat 容器登录二维码、NapCat 状态、QQ 事件订阅、消息推送、主用户私聊账号 `645275593`、最近 QQ 事件和已发送记录,默认没有裸 JSON,只有点击 `查看原始JSON` 才显示原始数据;不得把 D601 `3290/3000/3001/6099` 暴露到公网,也不得 iframe ClaudeQQ 旧 WebUI。 diff --git a/config.json b/config.json index 8f6f3ba6..e5a48692 100644 --- a/config.json +++ b/config.json @@ -150,7 +150,7 @@ "id": "met-nonlinear", "name": "MET Nonlinear", "providerId": "D601", - "description": "MET Nonlinear 训练编排微服务,TS 后端部署在 D601 Docker 中,按需拉起 TensorFlow 2.6 GPU 训练容器并由 UniDesk frontend 展示队列、进度和历史记录。", + "description": "MET Nonlinear 训练编排用户服务,TS 后端部署在 D601 Docker 中,按需拉起 TensorFlow 2.6 GPU 训练容器并由 UniDesk frontend 展示队列、进度和历史记录。", "repository": { "url": "https://github.com/pikasTech/met_nonlinear", "commitId": "9fcdfc0b505e52cc88cf51b196543dc055da2334", @@ -189,6 +189,50 @@ "integrated": true } }, + { + "id": "claudeqq", + "name": "ClaudeQQ", + "providerId": "D601", + "description": "ClaudeQQ 纯后端 QQ 消息网关,容器化部署在 D601 Docker 中,为 UniDesk、主 server 和其他用户服务提供 QQ 消息事件订阅与消息推送 API。", + "repository": { + "url": "https://gitee.com/lyon1998/agent_skills", + "commitId": "203b1f46684c91340ecbbd8a74502bd55e4f2011", + "dockerfile": "claudeqq/Dockerfile", + "composeFile": "claudeqq/docker-compose.unidesk.yml", + "composeService": "claudeqq", + "containerName": "claudeqq-backend" + }, + "backend": { + "nodeBaseUrl": "http://host.docker.internal:3290", + "nodeBindHost": "127.0.0.1", + "nodePort": 3290, + "proxyMode": "provider-gateway-http", + "frontendOnly": true, + "public": false, + "allowedMethods": [ + "GET", + "HEAD", + "POST", + "DELETE" + ], + "allowedPathPrefixes": [ + "/health", + "/logs", + "/api/" + ], + "healthPath": "/health", + "timeoutMs": 30000 + }, + "development": { + "providerId": "D601", + "sshPassthrough": true, + "worktreePath": "/home/ubuntu/.agents/skills/claudeqq" + }, + "frontend": { + "route": "/apps/claudeqq", + "integrated": true + } + }, { "id": "todo-note", "name": "Todo Note", @@ -231,11 +275,56 @@ "integrated": true } }, + { + "id": "project-manager", + "name": "Project Manager", + "providerId": "main-server", + "description": "项目管理用户服务,部署在主 server Docker 中,使用 UniDesk PostgreSQL 保存合作项目清单,支持项目增删改查、Excel 导入和 Excel 导出。", + "repository": { + "url": "https://github.com/pikasTech/unidesk", + "commitId": "a278de032d5cdb91010466ac1e2183c79026550d", + "dockerfile": "src/components/microservices/project-manager/Dockerfile", + "composeFile": "docker-compose.yml", + "composeService": "project-manager", + "containerName": "project-manager-backend" + }, + "backend": { + "nodeBaseUrl": "http://project-manager:4233", + "nodeBindHost": "project-manager", + "nodePort": 4233, + "proxyMode": "provider-gateway-http", + "frontendOnly": true, + "public": false, + "allowedMethods": [ + "GET", + "HEAD", + "POST", + "PUT", + "DELETE" + ], + "allowedPathPrefixes": [ + "/health", + "/logs", + "/api/" + ], + "healthPath": "/health", + "timeoutMs": 30000 + }, + "development": { + "providerId": "main-server", + "sshPassthrough": true, + "worktreePath": "/root/unidesk" + }, + "frontend": { + "route": "/apps/project-manager", + "integrated": true + } + }, { "id": "codex-queue", "name": "Codex Queue", "providerId": "main-server", - "description": "Codex Queue 是主 server 承载的 Codex app-server 编排微服务,用于串行任务队列、运行中输出、追加 prompt、打断会话、异常中断判定和自动重试。", + "description": "Codex Queue 是主 server 承载的 Codex app-server 编排用户服务,用于串行任务队列、运行中输出、追加 prompt、打断会话、异常中断判定和自动重试。", "repository": { "url": "https://github.com/pikasTech/unidesk", "commitId": "2aaf0447a62c336f3a488d77516edbf05ff1d742", diff --git a/docker-compose.yml b/docker-compose.yml index d0b0fef3..837a4d0a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -83,6 +83,15 @@ services: TODO_NOTE_LOGS_DIR: "logs" LOG_FILE: "/var/log/unidesk/${UNIDESK_LOG_PREFIX}_todo-note.jsonl" TODO_NOTE_LOG_PATH: "/var/log/unidesk/${UNIDESK_LOG_PREFIX}_todo-note.jsonl" + TODO_NOTE_REMINDER_CLAUDEQQ_ENABLED: "${UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_ENABLED:-true}" + TODO_NOTE_REMINDER_CLAUDEQQ_BASE_URL: "${UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_BASE_URL:-http://backend-core:8080/api/microservices/claudeqq/proxy}" + TODO_NOTE_REMINDER_CLAUDEQQ_TARGET_TYPE: "${UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_TARGET_TYPE:-private}" + TODO_NOTE_REMINDER_CLAUDEQQ_USER_ID: "${UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_USER_ID:-645275593}" + TODO_NOTE_REMINDER_CLAUDEQQ_GROUP_ID: "${UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_GROUP_ID:-}" + TODO_NOTE_REMINDER_LEAD_MINUTES: "${UNIDESK_TODO_NOTE_REMINDER_LEAD_MINUTES:-10}" + TODO_NOTE_REMINDER_SCAN_INTERVAL_MS: "${UNIDESK_TODO_NOTE_REMINDER_SCAN_INTERVAL_MS:-30000}" + TODO_NOTE_REMINDER_CLAUDEQQ_TIMEOUT_MS: "${UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_TIMEOUT_MS:-15000}" + TODO_NOTE_REMINDER_CLAUDEQQ_SEND_ATTEMPTS: "${UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_SEND_ATTEMPTS:-3}" volumes: - ${UNIDESK_LOG_DIR}:/var/log/unidesk healthcheck: @@ -98,21 +107,32 @@ services: container_name: codex-queue-backend restart: unless-stopped depends_on: + - database - backend-core expose: - "4222" environment: HOST: "0.0.0.0" PORT: "4222" + DATABASE_URL: "postgres://${UNIDESK_DATABASE_USER}:${UNIDESK_DATABASE_PASSWORD}@database:5432/${UNIDESK_DATABASE_NAME}" CODEX_QUEUE_STATE_PATH: "/var/lib/unidesk/codex-queue/state.json" CODEX_QUEUE_WORKDIR: "/root/unidesk" 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_MODELS: "gpt-5.4-mini,gpt-5.4,gpt-5.5" + CODEX_QUEUE_DEFAULT_MODEL: "gpt-5.5" + CODEX_QUEUE_MODELS: "gpt-5.5,gpt-5.4-mini,gpt-5.4" + CODEX_QUEUE_MODEL_REASONING_EFFORTS: "gpt-5.5=xhigh" CODEX_QUEUE_SANDBOX: "danger-full-access" CODEX_QUEUE_APPROVAL_POLICY: "never" - CODEX_QUEUE_MAX_ATTEMPTS: "3" + CODEX_QUEUE_MAX_ATTEMPTS: "99" + CODEX_QUEUE_NOTIFY_CLAUDEQQ_ENABLED: "${UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_ENABLED:-true}" + CODEX_QUEUE_NOTIFY_CLAUDEQQ_BASE_URL: "${UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_BASE_URL:-http://backend-core:8080/api/microservices/claudeqq/proxy}" + CODEX_QUEUE_NOTIFY_CLAUDEQQ_TARGET_TYPE: "${UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_TARGET_TYPE:-private}" + CODEX_QUEUE_NOTIFY_CLAUDEQQ_USER_ID: "${UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_USER_ID:-645275593}" + CODEX_QUEUE_NOTIFY_CLAUDEQQ_GROUP_ID: "${UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_GROUP_ID:-}" + CODEX_QUEUE_NOTIFY_CLAUDEQQ_MAX_RESPONSE_CHARS: "${UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_MAX_RESPONSE_CHARS:-12000}" + CODEX_QUEUE_NOTIFY_CLAUDEQQ_TIMEOUT_MS: "${UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_TIMEOUT_MS:-15000}" + CODEX_QUEUE_NOTIFY_CLAUDEQQ_SEND_ATTEMPTS: "${UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_SEND_ATTEMPTS:-3}" OPENAI_API_KEY: "${OPENAI_API_KEY:-}" CRS_OAI_KEY: "${CRS_OAI_KEY:-}" MINIMAX_API_KEY: "${UNIDESK_CODEX_QUEUE_MINIMAX_API_KEY:-}" @@ -137,6 +157,30 @@ services: timeout: 3s retries: 20 + project-manager: + image: project-manager + build: + context: . + dockerfile: src/components/microservices/project-manager/Dockerfile + container_name: project-manager-backend + restart: unless-stopped + depends_on: + - database + expose: + - "4233" + environment: + HOST: "0.0.0.0" + PORT: "4233" + DATABASE_URL: "postgres://${UNIDESK_DATABASE_USER}:${UNIDESK_DATABASE_PASSWORD}@database:5432/${UNIDESK_DATABASE_NAME}" + LOG_FILE: "/var/log/unidesk/${UNIDESK_LOG_PREFIX}_project-manager.jsonl" + volumes: + - ${UNIDESK_LOG_DIR}:/var/log/unidesk + healthcheck: + test: ["CMD", "bun", "-e", "fetch('http://127.0.0.1:4233/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 e197861d..813275db 100644 --- a/docs/reference/arch.md +++ b/docs/reference/arch.md @@ -22,7 +22,7 @@ - This design allows verification of the full distributed dispatching flow on a single main server - Main Server Components - UniDesk Stateless Services - - Run all business microservices as Docker containers + - Run all user services as Docker containers; these user-facing services are mounted onto the UniDesk core and the core can still run without them - Includes frontend gateway, task scheduler, project management, provider ingress, and other stateless modules - Instances can scale horizontally; failure recovery requires no state synchronization - Only the frontend gateway and provider ingress are public; core REST APIs and PostgreSQL remain on the Docker internal network diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 76dd9e16..56f91bdb 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -12,11 +12,16 @@ 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` 和 `codex-queue` 只重建主 server 承载的对应后端,不会重建或删除 database 命名卷。 +- `server rebuild ` 创建异步 job,先构建目标服务镜像,随后在 `.state/locks/server-compose.lock` 串行保护下用 `--no-deps --force-recreate` 替换目标 service 并等待容器 `healthy/running`;该命令用于替代手工删除容器的兜底流程,其中 `todo-note`、`codex-queue` 和 `project-manager` 只重建主 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 安全透传给远端脚本。 -- `microservice list/status/health/proxy` 通过 backend-core 内网 API 管理挂载在计算节点 Docker 中的 microservice;`health` 和 `proxy` 会走真实 backend-core -> provider-gateway -> 节点本机后端链路,`proxy` 对超大 body 默认输出有界预览,规则见 `docs/reference/microservices.md`。 +- `ssh skills [--scope all|wsl|windows] [--limit N]` 发现目标节点上的 WSL/Linux skill 根目录;当 provider 是 WSL 时同一次调用还会扫描 Windows 用户目录下的 `.agents/skills` 与 `.codex/skills`。 +- `microservice list/status/health/proxy` 通过 backend-core 内网 API 管理挂载在计算节点 Docker 中的用户服务(底层命令名仍为 microservice);`health` 和 `proxy` 会走真实 backend-core -> provider-gateway -> 节点本机后端链路,`proxy` 对超大 body 默认输出有界预览,规则见 `docs/reference/microservices.md`。 +- `codex task ` 通过 Codex Queue 私有代理按任务 ID 查询结构化执行摘要;默认只返回有界 prompt/response 预览、最后 assistant message、最近工具调用摘要、attempt、judge、错误、耗时和 trace 翻页提示,适合在新队列任务中引用历史 session 且避免噪声爆炸。 +- `codex task --trace --tail|--from-start|--after-seq N|--before-seq N --limit N` 按页拉取 Codex Queue 的逻辑 trace;响应会返回 `nextAfterSeq`、`previousBeforeSeq`、`hasMore`、`hasBefore` 和下一页/上一页命令,默认 `--trace` 取最新一页,需要完整 prompt/最后 response 时加 `--full`。 +- `codex output --tail|--from-start|--after-seq N|--before-seq N --limit N [--full-text]` 按原始 output seq 分页读取底层记录;当 trace 行提示 `commandOmittedLines`、`bodyOmittedLines` 或 `rawSeqs` 时,用该命令按 seq 补取完整信息,默认仍有单条文本预览上限,显式 `--full-text` 才返回该页全文。 +- `codex queues`、`codex queue create `、`codex move --queue ` 管理 Codex Queue 的多队列 lane;同一个 queue 内部串行执行,不同 queue 之间并行执行,迁移 queued/retry_wait 任务后会立即调度目标 queue。 - `job list` 与 `job status` 查询 `.state/jobs/` 文件系统状态,是异步命令的可观测入口。 - `debug health`、`debug dispatch` 与 `debug task` 走真实内部 core、WebSocket、数据库、provider、系统指标、Docker 状态和 Host SSH 维护桥流程,只用于开发调试,不写入 `TEST.md` 的正式验收步骤。 - `e2e run [--only pattern[,pattern...]] [--skip pattern[,pattern...]]` 使用 publicHost 派生的公开 frontend/provider ingress URL,并通过 Docker 内网验证 core API、PostgreSQL、provider self-connection、系统指标曲线、Docker 状态快照、provider.upgrade 预检和 Playwright 前端页面,是交付前的自动化 E2E 门禁;CLI 默认输出 check 状态摘要,完整诊断写入 `resultPath`,日常迭代应优先用 `--only` / `--skip` 跑最小必要集合。 @@ -25,17 +30,17 @@ 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` 验证;重建 Codex Queue 后端使用 `bun scripts/cli.ts server rebuild codex-queue`,随后用 `microservice health codex-queue` 和 `microservice proxy codex-queue /api/tasks` 验证。不得把 `docker rm` 手工兜底当成正式交付步骤。 +`server rebuild` 与 `server start`、`server stop` 一样必须通过返回的 job id 确认结果;不要把 `server rebuild codex-queue && server rebuild frontend` 理解成“前一个重建已完成”,因为两个命令只是在快速创建异步 job。重建 frontend 的标准流程是运行 `bun scripts/cli.ts server rebuild frontend`,随后轮询 `bun scripts/cli.ts job status ` 到 `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` 验证;重建 Project Manager 后端使用 `bun scripts/cli.ts server rebuild project-manager`,随后用 `microservice health project-manager` 和 `microservice proxy project-manager /api/projects` 验证。不得把 `docker rm` 手工兜底当成正式交付步骤。 ## Output Contract 每条命令的最外层 JSON 包含 `ok`、`command` 和 `data` 或 `error`。失败时 CLI 设置非零退出码,但仍然输出 JSON 错误对象;错误对象应包含 `name`、`message` 和可用的 `stack`。 -`microservice proxy` 是面向人工验证的私有后端读取入口。正式写入型 microservice 操作由 frontend 同源代理或 E2E 直接调用 backend-core 完成,并由 config 中的 `allowedMethods` 限制;CLI `proxy` 默认仍作为 GET/HEAD 读取验证入口。为了避免 Pipeline snapshot 这类超大业务 JSON 造成 CLI 输出爆炸,响应 body 超过默认阈值时会返回 `bodyOmitted=true`、`bodyPreview`、`bodyBytes` 和 `rawHint`;需要完整 body 时显式添加 `--raw`,或用 `--max-body-bytes ` 调整预览阈值。正式 frontend 展示仍应优先使用业务控件和 `__unideskArrayLimit` 这类展示级裁剪参数,而不是默认倾倒完整 JSON。 +`microservice proxy` 是面向人工验证的私有后端读取入口。正式写入型用户服务操作由 frontend 同源代理或 E2E 直接调用 backend-core 完成,并由 config 中的 `allowedMethods` 限制;CLI `proxy` 默认仍作为 GET/HEAD 读取验证入口。为了避免 Pipeline snapshot 这类超大业务 JSON 造成 CLI 输出爆炸,响应 body 超过默认阈值时会返回 `bodyOmitted=true`、`bodyPreview`、`bodyBytes` 和 `rawHint`;需要完整 body 时显式添加 `--raw`,或用 `--max-body-bytes ` 调整预览阈值。正式 frontend 展示仍应优先使用业务控件和 `__unideskArrayLimit` 这类展示级裁剪参数,而不是默认倾倒完整 JSON。 ## Debug Contract -`debug` 子命令必须复用真实模块与真实端点,禁止维护平行实现。`debug health` 会摘要展示 `/api/nodes/system-status` 和 `/api/nodes/docker-status`,避免输出完整快照造成信息爆炸。`debug dispatch` 会在 backend-core 容器内调用内部 `/api/dispatch`,core 再通过 WebSocket 将 `docker.ps`、`provider.upgrade`、`host.ssh`、`microservice.http` 或 `echo` 任务下发给 provider gateway,因此它可以验证核心调度闭环,同时不需要公开 core REST API。`provider.upgrade` 默认使用 `mode: "plan"` 预检;需要验证一键升级时必须显式加 `--mode schedule`,并通过 `--wait-ms` 或 `debug task` 确认任务进入 `succeeded`、result 中包含 updater 容器信息和 `policy: "always-enabled"`。`host.ssh` 默认使用 `mode: "probe"` 做短超时维护桥自检;需要执行明确命令时使用 `--ssh-command` 进入 `mode: "exec"`,并配合 `--wait-ms` 和 `debug task` 查看 stdout、stderr、exitCode 与 probeLine。`microservice.http` 只用于开发调试 provider-gateway 私有 HTTP 代理,正式用户入口应使用 `microservice` CLI 或 frontend 页面。 +`debug` 子命令必须复用真实模块与真实端点,禁止维护平行实现。`debug health` 会摘要展示 `/api/nodes/system-status` 和 `/api/nodes/docker-status`,避免输出完整快照造成信息爆炸。`debug dispatch` 会在 backend-core 容器内调用内部 `/api/dispatch`,core 再通过 WebSocket 将 `docker.ps`、`provider.upgrade`、`host.ssh`、`microservice.http` 或 `echo` 任务下发给 provider gateway,因此它可以验证核心调度闭环,同时不需要公开 core REST API。`provider.upgrade` 默认使用 `mode: "plan"` 预检;需要验证一键升级时必须显式加 `--mode schedule`,并通过 `--wait-ms` 或 `debug task` 确认任务进入 `succeeded`、result 中包含 updater 容器信息和 `policy: "always-enabled"`。`host.ssh` 默认使用 `mode: "probe"` 做短超时维护桥自检;需要执行明确命令时使用 `--ssh-command` 进入 `mode: "exec"`,并配合 `--wait-ms` 和 `debug task` 查看 stdout、stderr、exitCode 与 probeLine。`microservice.http` 只用于开发调试 provider-gateway 私有 HTTP 代理,正式用户入口应使用 `microservice` CLI 或 frontend 的用户服务页面。 ## SSH Command @@ -47,7 +52,7 @@ core 只允许声明了 `host.ssh` capability 的 provider 使用 `ssh` 透传 本地 broker 默认等待 provider SSH 会话打开 60000ms,以便在目标节点同时有较多 microservice.http 任务时仍能建立维护会话;需要诊断慢连接时可用 `UNIDESK_SSH_OPEN_TIMEOUT_MS=` 临时调大,但最小有效值固定为 15000ms,避免把真实离线误判为长时间阻塞。 -`ssh ` 会在远端会话启动时注入 `/tmp/unidesk-ssh-tools/apply_patch` 和 `/tmp/unidesk-ssh-tools/glob`,并把该目录加入远端 `PATH`。`apply_patch` 接受标准 `*** Begin Patch` / `*** End Patch` patch 格式,便于通过 SSH 透传编辑远端仓库文件;`glob` 在远端用 Python 执行路径匹配,避免依赖 shell glob 展开。目标节点需要具备 `python3` 和 `base64`。注入工具只写 `/tmp/unidesk-ssh-tools`,不修改目标仓库,交互式 shell 和远端命令都可以直接调用这些工具。 +`ssh ` 会在远端会话启动时注入 `/tmp/unidesk-ssh-tools/apply_patch`、`/tmp/unidesk-ssh-tools/glob` 和 `/tmp/unidesk-ssh-tools/skill-discover`,并把该目录加入远端 `PATH`。`apply_patch` 接受标准 `*** Begin Patch` / `*** End Patch` patch 格式,便于通过 SSH 透传编辑远端仓库文件;`glob` 在远端用 Python 执行路径匹配,避免依赖 shell glob 展开;`skill-discover` 用于列出远端 Linux/WSL 与 Windows skill。目标节点需要具备 `python3` 和 `base64`。注入工具只写 `/tmp/unidesk-ssh-tools`,不修改目标仓库,交互式 shell 和远端命令都可以直接调用这些工具。 如果只是远端打小补丁,不需要再手写 `ssh D601 'apply_patch' < patch.diff` 这种命令拼接;正式入口是 `bun scripts/cli.ts ssh D601 apply-patch < patch.diff`。`apply-patch` 与 `patch` 等价,附加参数会原样透传给远端 `apply_patch`,例如 `bun scripts/cli.ts ssh D601 apply-patch --help`。标准单命令用法如下,不需要先创建本地 patch 临时文件: @@ -70,6 +75,15 @@ printf 'import sys\nprint(sys.argv)\n' | bun scripts/cli.ts ssh D601 py foo '--b `ssh py` 的附加参数是脚本参数,不是 Python 解释器参数;如需 `-m`、`-X` 或多条 shell 命令,仍使用原始远端命令入口。为了保证 CLI 输出及时可见,helper 固定采用“临时文件 + `python3 -u`”模式;provider 命令模式不分配 TTY,因此脚本内容不应被远端回显。 +`ssh skills` 是远端 skill 发现入口,也可写作 `ssh skill discover`。输出固定为 JSON,包含 `node`、`roots`、`counts` 和 `skills`:`roots` 会显示每个候选 skill 根目录是否存在、扫描到多少 skill 以及错误;`skills` 会给出 `scope`、`name`、`description`、`path`、`skillMd` 和可转换时的 `windowsPath`。默认扫描远端用户的 `~/.agents/skills`、`~/.codex/skills`、可访问的 `/root/.agents/skills`、`/root/.codex/skills`;如果目标是 WSL,还会扫描 `/mnt/c/Users/*/.agents/skills` 与 `/mnt/c/Users/*/.codex/skills`,从而一次性看清 WSL 和 Windows 两套 skill。常用参数是 `--scope wsl`、`--scope windows`、`--limit N`、`--max-depth N`、`--root ` 和 `--windows-root `;不要用宽泛的 Linux `find /mnt/*` 扫 Windows 盘,优先用这个结构化入口避免卡在 Windows 挂载层。 + +```bash +bun scripts/cli.ts ssh D601 skills --limit 80 +bun scripts/cli.ts ssh D601 skills --scope windows --limit 40 +``` + +Windows 工具链透传的 wrapper、路径转换、是否修改 skill、是否额外安装依赖等长期规则见 `docs/reference/windows-passthrough.md`;`ssh skills` 本身只负责发现,不会修改远端 skill。 + `ssh find` 是常用远端搜索的结构化入口,避免在 Host SSH / WSL SSH 透传里手写 `find \( ... \)`、`*`、管道和多层引号。它会把路径、谓词和 pattern 作为 argv 安全拼接,并支持重复 `--name`、`--iname`、`--path` 或 `--ipath`,重复 pattern 默认按 OR 组合。稳定参数包括 `--max-depth`/`-maxdepth`、`--min-depth`/`-mindepth`、`--type`/`-type`、`--contains`、`--icontains`、`--name`/`-name`、`--iname`/`-iname`、`--path`/`-path`、`--ipath`/`-ipath`、`--mtime`/`-mtime`、`--mmin`/`-mmin`、`--size`/`-size`、`--sort` 和 `--limit N`。典型用法: ```bash @@ -90,7 +104,7 @@ bun scripts/cli.ts ssh D601 glob --root /home/ubuntu/pikapython --pattern '**/*- `--main-server-ip` 是一个全局前缀,必须放在需要透传的命令同一次调用中,例如 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug health`。默认传输是公网 frontend:本地 CLI 读取本仓库 `config.json` 中的 frontend 登录账号密码,登录 `http://:/` 获取 HttpOnly session cookie,然后通过 frontend 的 `/api/*` 同源代理访问 backend-core 内网 API;因此计算节点只需要能访问公网 frontend,不需要主 server SSH key,也不需要打开 backend-core REST API 或 PostgreSQL 端口。 -默认 frontend 传输支持 `debug health`、`debug dispatch`、`debug task`、`microservice list/status/health/proxy` 和 `ssh `。其中 `ssh` 的 remote frontend 传输使用 `host.ssh` dispatch 执行有界远端命令,适合 `ssh D601 hostname` 这类自测;交互式登录 shell 仍应在主 server 本机 CLI 使用,或显式切换到旧 SSH 传输后在主 server 上执行。frontend 远程透传不会流式转发本地 stdin,因此 `ssh py < script.py`、`ssh apply-patch < patch.diff` 这类 stdin-backed helper 必须在主 server 本机运行,或显式切换到 `--main-server-transport ssh`。若确实需要旧行为,可使用 `--main-server-key ` 或 `--main-server-transport ssh`,这时 CLI 会通过 SSH 登录主 server 的 `--main-server-root` 目录执行同一个 `bun scripts/cli.ts `。 +默认 frontend 传输支持 `debug health`、`debug dispatch`、`debug task`、`microservice list/status/health/proxy`、`codex task ` 和 `ssh `。其中 `ssh` 的 remote frontend 传输使用 `host.ssh` dispatch 执行有界远端命令,适合 `ssh D601 hostname` 和 `ssh D601 skills` 这类自测;交互式登录 shell 仍应在主 server 本机 CLI 使用,或显式切换到旧 SSH 传输后在主 server 上执行。frontend 远程透传不会流式转发本地 stdin,因此 `ssh py < script.py`、`ssh apply-patch < patch.diff` 这类 stdin-backed helper 必须在主 server 本机运行,或显式切换到 `--main-server-transport ssh`。若确实需要旧行为,可使用 `--main-server-key ` 或 `--main-server-transport ssh`,这时 CLI 会通过 SSH 登录主 server 的 `--main-server-root` 目录执行同一个 `bun scripts/cli.ts `。 计算节点可以用该入口测试自身的远程升级闭环,而不需要在计算节点公开 core REST API 或 database。标准顺序是:先运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug health` 确认主 server 看到当前 Provider 在线,且该 Provider labels 中 `unideskCapabilities` 包含 `host.ssh`、`hostSshConfigured=true`、`hostSshKeyPresent=true`;再运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch provider.upgrade --mode schedule --wait-ms 15000` 触发真实 `provider.upgrade`;随后再次运行 `debug health` 确认节点重新上线;最后运行 `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` 验证 SSH 透传能力。provider-gateway 新部署或升级后没有完成这组 remote CLI 自测,不能视为交付完成。 diff --git a/docs/reference/config.md b/docs/reference/config.md index eb21827d..00588e62 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -22,11 +22,11 @@ TypeScript 运行时固定为 Bun。根目录 CLI、backend-core、frontend 和 `sshForwarding` 定义 provider-gateway 维护专用 Host SSH / WSL SSH 桥的显式配置。CLI 会把 `sshForwarding.keyDir` 写入 `.state/docker-compose.env` 的 `UNIDESK_HOST_SSH_KEY_DIR`,Compose 将该目录只读挂载到 provider-gateway 的 `/run/host-ssh`,并把 `sshForwarding.host`、`sshForwarding.port`、`sshForwarding.user` 映射为 `HOST_SSH_HOST`、`HOST_SSH_PORT`、`HOST_SSH_USER`。目录中必须存在 `id_ed25519` 私钥且权限收紧,provider-gateway 才会把 `hostSshKeyPresent` 上报为 true,并允许 `host.ssh` 维护探测;该桥只用于故障诊断和 WSL 维护,不替代 Docker socket 调度。 -## Microservices +## User Services -`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`。 +`microservices` 定义挂载在计算节点或主 server Docker 中的非核心用户服务。用户服务是挂在 UniDesk 核心服务上的用户业务能力,缺少这些服务时 UniDesk 核心仍必须能运行。该数组只保存业务仓库 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;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 操作。 +主 server 承载的 Todo Note 用户服务使用 `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/constar-d601.md b/docs/reference/constar-d601.md new file mode 100644 index 00000000..d2ed7c9a --- /dev/null +++ b/docs/reference/constar-d601.md @@ -0,0 +1,94 @@ +# ConStart / constar on D601 + +本文记录 D601 Provider 上 ConStart 固件工作区通过 UniDesk 维护桥进入、用 WSL wrapper 调用 Windows skill,以及完成编译、下载和运行态验证的稳定入口。项目内部细节仍以 ConStart 仓库自己的 `docs/reference/` 为权威来源。 + +## 入口 + +- UniDesk 侧从主 server 仓库执行:`bun scripts/cli.ts ssh D601 -- ''`。 +- 当前真实工作区路径是 D601 WSL 内的 `/mnt/f/Work/ConStart`。用户口头说的 `/mnt/f/work/constar` 可视为简称;实际执行前要先解析到真实大小写路径。 +- 远端工作优先使用 UniDesk SSH helper:`ssh D601 skills`、`ssh D601 find`、`ssh D601 glob`、`ssh D601 py`、`ssh D601 apply-patch`,避免多层 shell/Windows 路径转义问题。 +- 不要宽泛扫描 `/mnt/*`;Windows 挂载层可能很慢且有权限噪声。应把查找范围限制到 `/mnt/f/Work/ConStart` 或已知子目录。 + +## D601 skill 栈 + +D601 WSL 用户的 `~/.local/bin` 已提供项目级 wrapper: + +- `keil`:经 `win-py` 调 Windows 侧 Keil skill,Keil MDK、pyOCD、USB probe 驱动和 Keil pack 都在 Windows 侧解析。 +- `serial-monitor`:经 `win-npm` 调 Windows 侧 Serial Monitor skill,因此能访问 Windows `COMx` 串口。 +- `board-comm`:直接用 WSL 侧 Board Comm skill 执行 JSON-RPC over TCP。 +- `win-cmd`、`win-powershell`、`win-py`、`win-npm`、`win-skill-path`:节点 bootstrap wrapper,通用规则见 `docs/reference/windows-passthrough.md`。 + +基础自检: + +```bash +bun scripts/cli.ts ssh D601 skills --limit 80 +bun scripts/cli.ts ssh D601 -- 'export PATH="$HOME/.local/bin:$PATH"; command -v win-py win-npm keil serial-monitor board-comm' +bun scripts/cli.ts ssh D601 -- 'export PATH="$HOME/.local/bin:$PATH"; keil status; serial-monitor ports; board-comm debug build-jrpctcp-request get api' +``` + +## ConStart 项目地图 + +以下命令默认在 `/mnt/f/Work/ConStart` 中执行,并先把 `~/.local/bin` 放进 `PATH`。 + +| Project | Keil project | Target(s) | 常见 probe / runtime | +| --- | --- | --- | --- | +| `71-FREQ` | `projects/71-00075-11/FirmWare/MDK-ARM/FREQ_Controller_FW.uvprojx` | `FREQ_Controller_FW`, `BOOTLOADER` | STM32H723,通常 probe `3FD750C63E342E24`,APP JSON-RPC `192.168.0.154:8000`,bootloader TCP `192.168.0.154:9001`,串口 `921600` | +| `41-MASTER` | `projects/41-00426-20-Controller-Code/Master_Controller/SDK/GD32470Z/Projects/26_ENET/Projects/FreeRTOS_tcpudp/MDK-ARM/Project.uvprojx` | `MASTER`, `SLAVE`, `DEVELOP` | GD32F470,通常 probe `123456789ABCDEF`,JSON-RPC `192.168.0.151:8000`,串口 `115200` | +| `71-ACMOD` | `projects/41-00426-20-Controller-Code/Slave_AC_Module/code/MDK-ARM/STM32H723ZGT6.uvprojx` | `STM32H723ZGT6` | STM32H723,必须显式选择 H7 probe,JSON-RPC `192.168.0.152:8000`,串口 `115200` | + +probe 和 COM 口是现场硬件状态,不是永久事实。下载前必须先跑 `keil list-devices -p `;H7 目标还要跑 `keil detect -u -p -t `。41/GD32 的 pyOCD `detect` 预期会因为 pyOCD 不支持该 target 而失败;41 下载应走 Keil UV4 工程链,并通过串口或 `board-comm` 验证。 + +## 编译与下载模式 + +所有命令都显式传 `project`、`target` 和 `probe`,不要依赖 Keil 当前 target 或隐式 probe 选择。 +`program` / `flash` 会改写真实目标板;只摸清流程或做文档核查时,先停在 `project targets`、`list-devices`、`detect` 和 `build`,确认目标板、probe、串口和期望固件后再执行下载命令。 + +```bash +# 查看 target 与 probe 绑定。 +keil project targets -p projects/71-00075-11/FirmWare/MDK-ARM/FREQ_Controller_FW.uvprojx +keil list-devices -p projects/71-00075-11/FirmWare/MDK-ARM/FREQ_Controller_FW.uvprojx +keil detect -u 3FD750C63E342E24 -p projects/71-00075-11/FirmWare/MDK-ARM/FREQ_Controller_FW.uvprojx -t FREQ_Controller_FW + +# 只编译。 +keil build --wait -p projects/71-00075-11/FirmWare/MDK-ARM/FREQ_Controller_FW.uvprojx -t FREQ_Controller_FW + +# 编译后下载,或下载已编译 target。 +keil flash --wait -p projects/71-00075-11/FirmWare/MDK-ARM/FREQ_Controller_FW.uvprojx -t FREQ_Controller_FW -m daplink --program-backend pyocd -u 3FD750C63E342E24 +keil program --wait -p projects/71-00075-11/FirmWare/MDK-ARM/FREQ_Controller_FW.uvprojx -t FREQ_Controller_FW -m daplink --program-backend pyocd -u 3FD750C63E342E24 + +# 不重烧,只复位运行。 +keil reset-run --wait -p projects/71-00075-11/FirmWare/MDK-ARM/FREQ_Controller_FW.uvprojx -t FREQ_Controller_FW -u 3FD750C63E342E24 +``` + +41/GD32 优先使用 Keil UV4 工程 backend,不要把 pyOCD generic 失败当成固件失败: + +```bash +keil build --wait -p projects/41-00426-20-Controller-Code/Master_Controller/SDK/GD32470Z/Projects/26_ENET/Projects/FreeRTOS_tcpudp/MDK-ARM/Project.uvprojx -t MASTER +keil program --wait -p projects/41-00426-20-Controller-Code/Master_Controller/SDK/GD32470Z/Projects/26_ENET/Projects/FreeRTOS_tcpudp/MDK-ARM/Project.uvprojx -t MASTER -m daplink --program-backend keil -u 123456789ABCDEF +``` + +## 运行态验证 + +`program`、`flash` 或 `reset-run` 前先打开串口抓取,启动日志确认网络和 JSON-RPC ready 后再用 `board-comm` 主动请求。 + +```bash +serial-monitor ports +serial-monitor monitor start -p COM5 -b 115200 +serial-monitor fetch --session-only --no-dedup -l 100 + +board-comm jrpctcp --host 192.168.0.154 --port 8000 get api +board-comm jrpctcp --host 192.168.0.154 --port 8000 get system/status +``` + +编译或下载成功只证明工具阶段完成。固件任务交付证据应同时包含 Keil 结果、新鲜串口启动证据,以及固件暴露 JSON-RPC 时与目标身份匹配的 `board-comm` 响应。 + +## ConStart 内部文档 + +项目特有规则继续回到 ConStart 仓库: + +- `AGENTS.md`:项目索引和术语约定。 +- `docs/reference/workspace/keil_skill_usage.md`:Keil skill 使用和工程文件维护约束。 +- `docs/reference/workspace/serial_monitor_skill_usage.md`:串口证据和会话抓取规则。 +- `docs/reference/workspace/board_comm_skill_usage.md`:JSON-RPC 主动通信规则。 +- `docs/reference/workspace/wsl_development_on_d601.md`:D601 WSL 开发注意事项。 +- `docs/reference/projects/71-00075-11/entry_and_build.md` 与 `docs/reference/projects/71-00075-11/TEST.md`:`71-FREQ` target、产物和验收入口。 diff --git a/docs/reference/deployment.md b/docs/reference/deployment.md index df24a099..021706ff 100644 --- a/docs/reference/deployment.md +++ b/docs/reference/deployment.md @@ -8,14 +8,15 @@ - `backend-core` 是无状态核心服务,提供 Docker 内网 REST API、provider ingress WebSocket、任务调度入口和数据库访问层。 - `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、打断和重试。 +- `todo-note` 是主 server 承载的 Todo Note 纯后端用户服务,容器名 `todo-note-backend`,只在 Compose 内网暴露 `4211/tcp`,使用主 PostgreSQL 存储迁移后的 Todo Note 数据。 +- `codex-queue` 是主 server 承载的 Codex app-server 队列用户服务,容器名 `codex-queue-backend`,仅在 Compose 内网暴露 `4222/tcp` 给本机 provider-gateway 私有代理访问,任务状态优先写入主 PostgreSQL 并保留 `.state/codex-queue/state.json` fallback 快照,浏览器只能通过 UniDesk frontend 同源代理查看运行输出、追加 prompt、打断和重试。 +- `project-manager` 是主 server 承载的项目管理用户服务,容器名 `project-manager-backend`,仅在 Compose 内网暴露 `4233/tcp`,项目清单写入主 PostgreSQL,浏览器只能通过 UniDesk frontend 同源代理执行增删改查、Excel 导入和 Excel 导出。 ## Public Exposure Boundary Docker Compose 只能向公网暴露两个接口:frontend host port 和 provider ingress host port。backend-core REST API 和 PostgreSQL database 必须只在 Docker 内部网络中可达,不允许映射到宿主机公网端口;浏览器访问 core API 必须通过 frontend 的同源代理完成。 -计算节点上的 microservice 后端也遵守同一边界:业务容器端口只绑定节点本机地址,并由 provider-gateway 主动连出 WebSocket 后接受 backend-core 的 `microservice.http` 调度访问。主 server 不为 microservice 新增公网反向代理端口;最终用户只通过 UniDesk frontend 的 React 页面访问结构化业务控件。 +计算节点上的用户服务后端也遵守同一边界:业务容器端口只绑定节点本机地址,并由 provider-gateway 主动连出 WebSocket 后接受 backend-core 的 `microservice.http` 调度访问。主 server 不为用户服务新增公网反向代理端口;最终用户只通过 UniDesk frontend 的 React 页面访问结构化业务控件。 ## Docker Compose Runtime @@ -25,19 +26,19 @@ Compose v2 安装后仍然必须遵守 UniDesk 的服务控制入口:全栈生 ## Start And Stop -`bun scripts/cli.ts server start` 与 `bun scripts/cli.ts server stop` 都是异步 job。启动 job 会先清理固定 Compose project 的旧容器,再重新构建并启动,避免主 server 上残留旧容器或旧镜像配置。启动和停止流程禁止删除 Docker named volume。 +`bun scripts/cli.ts server start` 与 `bun scripts/cli.ts server stop` 都是异步 job。启动 job 只执行固定 Compose project 的 `up -d --build --remove-orphans`,不得先 `down`,避免在 provider-gateway 旧容器或网络冲突时把 `codex-queue-backend` 等长任务容器先删掉又启动失败;停止 job 才允许执行 `down --remove-orphans`。启动和停止流程都禁止删除 Docker named volume。所有会改变主 server Compose 状态的 job 必须通过 `.state/locks/server-compose.lock` 串行化;`server rebuild codex-queue && server rebuild frontend` 这类连续命令只代表连续创建异步 job,不能代表第一个 job 已结束,实际容器变更仍必须由 Compose lock 串行执行。 ## Single Service Rebuild -前端、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`。该命令先执行目标服务镜像构建,只有构建成功后才移除旧容器,避免构建失败导致运行中的服务被提前停掉。 +前端、backend-core、本机 provider-gateway 或主 server 承载的 Todo Note/Codex Queue/Project Manager 用户服务需要重建时,统一使用 `bun scripts/cli.ts server rebuild `,其中 `` 只能是 `backend-core`、`frontend`、`provider-gateway`、`todo-note`、`codex-queue` 或 `project-manager`。该命令先执行目标服务镜像构建,构建成功后才通过 `up -d --no-deps --force-recreate ` 替换目标容器,避免构建失败导致运行中的服务被提前停掉。 -单服务重建必须按 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。 +单服务重建必须由 CLI 解析出的 Compose 命令执行,且只能影响目标 service,不得连带重启 database、backend-core 或其他未指定服务。重建后 job 必须按 Docker Compose label 校验目标容器:`com.docker.compose.project` 等于 `config.json` 中的固定 project name,`com.docker.compose.service` 等于目标服务名,并等待容器进入 `healthy`;没有 healthcheck 的服务至少要进入 `running`。如果验证失败,job 必须失败并输出目标容器状态,禁止把“无输出”或“只完成 build”当作成功。 -当前主 server 只安装 Docker Compose v1 时,直接执行 `docker-compose up -d --no-deps --build frontend` 可能走 recreate 路径并触发 `ContainerConfig` 兼容问题。正式流程不得依赖人工 `docker rm` 兜底,而应由 `server rebuild frontend` 固化为可观测 job:build-first、label-scoped remove、no-deps up、保留 named volume。 +正式流程不得依赖人工 `docker rm` 兜底;手工删除旧容器后若 job、Docker client 或 daemon 在 `up` 前中断,会直接造成 `direct microservice proxy failed`。`server rebuild ` 必须是可观测 job:build-first、Compose lock、no-deps force-recreate、post-up validation、保留 named volume。Codex Queue 等长任务服务即使被重建也必须依赖服务自身 restart-recovery 恢复任务,不能用“避免重建”掩盖恢复缺陷。 ## Health Criteria -服务跑通的最低标准是: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` 的门禁作为最终判定。 +服务跑通的最低标准是:backend-core 内网 `/health` 返回 ok,frontend 公网 `/health` 返回 ok,provider ingress 公网 `/health` 返回 ok,database 在容器内 `pg_isready` 可用,Todo Note 后端 `/api/health` 返回 `storage=postgres`,Codex Queue `/health` 返回队列摘要、默认模型和 `queue.storage`,Project Manager `/health` 返回 `storage.primary=postgres` 和项目数量,backend-core `/api/performance` 返回性能指标,`/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 aa6c4de2..c1ab4428 100644 --- a/docs/reference/e2e.md +++ b/docs/reference/e2e.md @@ -31,19 +31,19 @@ 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`, 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. +- Public exposure: Docker port summary must show only frontend and provider ingress host mappings; public core、public database and known private user-service 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; internal `GET /api/performance` must report component request statistics, internal operation statistics, PGDATA usage and Codex Queue PostgreSQL storage metadata. - 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` 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. +- User services: 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.5`, 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. - 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: 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, opens `运行总览 / 性能面板` to verify `Bwebui`、组件汇总、最近失败请求、内部操作汇总和最近慢操作, 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 队列/模型/输出/初始 `Submitted prompt`/终态任务自动加载完整 Trace/追加 prompt/打断控件、FindJob 指标和岗位预览、Pipeline 组件矩阵、MiniMax 限额卡片、结构化 OA 事件流诊断面板、React Flow 控制图、epoch 甘特图、甘特图渲染图导出、monitor 首列排序、长任务观察连线、无观察来源伪点、running node 实时闪动执行条和 OpenCode Trace、MET Nonlinear 项目库/Fork/待启动队列/当前队列/已完成/失败诊断/GPU/镜像都通过 React 控件展示。Playwright 还必须验证深链接直达路由,例如公网 `http://:/app/pipeline/` 能直接落到 Pipeline 页面,随后切到 `资源节点 / Docker 状态` 时地址栏更新为 `/nodes/docker/`,并且浏览器 history 返回链路仍能回到 `/app/pipeline/`;还必须直开 `/app/codex-queue/` 验证页面存在 `app-shell`、左侧主模块边栏、顶部状态栏、顶部子标签和 `codex-queue-page`,防止用户服务 deep link 退化成缺 shell 的 standalone 页面;同时 `态势总览` 这类非用户服务页面应落在自己的模块前缀下,例如 `/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 右侧边栏、Trace timeline、详情抽屉、甘特图坐标或其他高信息密度面板, Playwright acceptance must inspect both `总高度` and `横向滚动条`. For Pipeline specifically, the OpenCode Trace session head must carry shared agent/model/session facts and the Trace body must use the same Codex Queue `TraceView` styling; Playwright must fail if old `.pipeline-opencode-step`, `.pipeline-opencode-flow`, `.pipeline-step-message-card` or `.pipeline-opencode-part` user-visible styles reappear, if the Trace container introduces an internal horizontal scrollbar, or if `frontend:pipeline-gantt-frontend-y-accuracy` fails to prove the frontend `frontend-y` layout maps ticks, markers and execution bars from timestamps to y coordinates within tolerance. +- OpenCode Trace must use Codex Queue Trace styling and must not render the deprecated Pipeline continuous step connector; Playwright should fail if `.pipeline-opencode-flow`, `.pipeline-opencode-step` or any equivalent continuous connector/card returns to the user-visible Trace. +- User service 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 Trace` rendered by the shared Codex Queue-style Trace component with messages and tool-call groups; 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 @@ -53,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; `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. +User service 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 Trace 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 user-service 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 7bbb89f6..000751d6 100644 --- a/docs/reference/frontend.md +++ b/docs/reference/frontend.md @@ -6,28 +6,34 @@ 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 训练编排工作台,`codex-queue.tsx` 承载 Codex Queue 控制台;新增业务 microservice 也必须按同样规则新增独立页面模块,并由 `app.tsx` 只做导入和路由分发。 +`src/components/frontend/src/app.tsx` 只承担应用 shell、登录、全局数据加载、主模块/子标签路由和通用控制台页面。用户服务前端必须模块化到独立 TSX 文件,禁止继续把所有业务页面堆进 `app.tsx`。当前长期固定入口为:`todo-note.tsx` 承载 Todo Note 工作台,`findjob.tsx` 承载 FindJob 工作台,`pipeline.tsx` 承载 Pipeline 工作台,`met-nonlinear.tsx` 承载 MET Nonlinear 训练编排工作台,`claudeqq.tsx` 承载 ClaudeQQ QQ 消息网关工作台,`codex-queue.tsx` 承载 Codex Queue 控制台;新增用户服务也必须按同样规则新增独立页面模块,并由 `app.tsx` 只做导入和路由分发。 ## Layout -左侧边栏只切换主模块:运行总览、资源节点、任务调度、微服务、系统配置。顶部标签只切换当前主模块内的子功能;例如资源节点下的节点清单、资源标签、心跳状态只属于资源节点,微服务下的服务目录、Todo Note、FindJob、Pipeline、MET Nonlinear、Codex Queue 只属于微服务,和运行总览、任务调度、系统配置没有重复或共享语义。桌面端左侧边栏必须支持收起,只保留模块 code 和展开按钮,以便最大化主面板空间;移动端左侧边栏会转为顶部横向主模块条,但高度必须在不同主模块之间保持一致,并保持窄条、单行、不换行;主内容区无论内容多少都必须从顶部向下排列,空状态也不得上下居中制造大块留白。 +左侧边栏只切换主模块:运行总览、资源节点、任务调度、用户服务、系统配置。顶部标签只切换当前主模块内的子功能;例如资源节点下的节点清单、资源标签、心跳状态只属于资源节点,用户服务下的服务目录、Todo Note、FindJob、Pipeline、MET Nonlinear、Codex Queue 只属于用户服务,和运行总览、任务调度、系统配置没有重复或共享语义。桌面端左侧边栏必须支持收起,只保留模块 code 和展开按钮,以便最大化主面板空间;移动端左侧边栏会转为顶部横向主模块条,但高度必须在不同主模块之间保持一致,并保持窄条、单行、不换行;主内容区无论内容多少都必须从顶部向下排列,空状态也不得上下居中制造大块留白。 ## Route Model frontend shell 必须把左侧主模块与顶部子标签编译为统一的 URL 路由,而不是在 router 中手工为每个页面逐个挂路径。长期规则如下: - 导航权威数据只有一份:主模块、子标签、显示文案、route segment、默认子标签都从同一个 TypeScript 导航定义派生,左侧边栏、顶部标签、浏览器地址栏解析和前进/后退都复用同一套 registry。 -- Canonical route 必须按主模块前缀分组:运行总览使用 `/ops//`,资源节点使用 `/nodes//`,任务调度使用 `/tasks//`,系统配置使用 `/config//`;只有微服务主模块使用 `/app//`。例如 `态势总览` 固定为 `/ops/status/`,`Docker 状态` 固定为 `/nodes/docker/`,`Pipeline` 固定为 `/app/pipeline/`。 +- Canonical route 必须按主模块前缀分组:运行总览使用 `/ops//`,资源节点使用 `/nodes//`,任务调度使用 `/tasks//`,系统配置使用 `/config//`;只有用户服务主模块使用 `/app//`。例如 `态势总览` 固定为 `/ops/status/`,`Docker 状态` 固定为 `/nodes/docker/`,`Pipeline` 固定为 `/app/pipeline/`。 - 当 future 需要新增主模块时,通用机制必须允许为该模块声明自己的顶层前缀,而不是继续把所有页面都塞进 `/app/*`。 - 主模块根路径如 `/ops/`、`/nodes/`、`/tasks/`、`/app/`、`/config/` 只作为默认子标签或最近活动子标签的入口别名,实际当前页面仍应落到某个具体子标签;浏览器地址栏不能停留在“只有主模块,没有具体页面”的模糊状态。 - route segment 生成顺序固定为:显式 `routeSegment` > ASCII-safe `id` > 由 label 派生的 Unicode-safe slug > 稳定 hash fallback。这样新增 Unicode 标签时默认仍可得到稳定路径,而不要求每个标签单独写一段路由代码。 - 浏览器直开、刷新、`history.back()` / `history.forward()`、点击总览 drilldown 卡片、点击左侧边栏、点击顶部标签都必须走同一个路由状态机;不得出现“页面内容切换了,但 URL 没变”或“URL 变了,但 shell 仍停在旧 tab”的分裂状态。 -- frontend Bun server 必须把这些模块前缀下的深链接路由作为 SPA 入口返回同一个 `index.html`;实现上允许统一把非静态资源路径都回到同一个 shell,但判定标准是公网直开 `/ops/status/`、`/nodes/docker/`、`/app/pipeline/` 等深链接时都不得 404。 +- frontend Bun server 必须把这些模块前缀下的深链接路由作为 SPA 入口返回同一个 `index.html` 和同一个 UniDesk shell;实现上允许统一把非静态资源路径都回到同一个 shell,但判定标准是公网直开 `/ops/status/`、`/nodes/docker/`、`/app/pipeline/`、`/app/codex-queue/` 等深链接时都不得 404,且必须和从主页导航进入时一样显示左侧主模块边栏、顶部状态栏、顶部子标签和完整业务页面。禁止为某个用户服务 deep link 返回缺少 UniDesk shell 的独立/standalone bundle;新增应用服务、普通页面和性能优化入口也必须满足“直开 URL 与站内导航同壳同页”的一致性要求。 ## Overview Task Drilldown `态势总览` 中的 `待处理任务` 指标必须可点击进入任务调度的 `待处理任务` 子标签,展示具体 queued、dispatched、running 任务的状态、Provider、已等待时间、payload 摘要和显式 `查看原始JSON` 操作。总览不得只给出无法追溯的数字;当后台把超时未终态任务转为 failed 后,待处理指标应回落,历史记录仍可在任务历史和执行结果中查看。核心指标还必须展示 `PGDATA`,显示 PostgreSQL 当前数据库用量、命名卷 `unidesk_pgdata_10gb` 和配置容量,便于从总览判断数据库状态。 +## Performance Panel + +`运行总览 / 性能面板` 固定路由为 `/ops/performance/`,用于汇总 UniDesk 控制面的通用性能指标。页面必须参考服务端性能看板形态,包含 `Bwebui` 内存/Bundle 趋势图、组件汇总、最近失败请求、内部操作汇总和最近慢操作。组件汇总至少展示组件名、请求数、失败数、失败率、平均延迟和 P95;最近失败请求必须在没有失败时明确显示“最近没有失败请求”,不能留空;内部操作汇总和慢操作必须展示服务、操作名、耗时、结果和细节。 + +性能面板的数据来自两个同源端点:frontend 自身的 `/api/frontend-performance` 记录 webui 静态资源、登录/session 和 API 代理请求,backend-core 的 `/api/performance` 记录 core API、用户服务代理、provider ingress、数据库与内部操作。页面只展示聚合表和趋势图,完整原始指标只能通过 `查看原始JSON` 打开。Bwebui 曲线优先使用浏览器 `performance.memory.usedJSHeapSize`,不可用时回退到 frontend bundle size 或 frontend 进程 heap,用 MB 作为纵轴口径。 + ## Task History Diagnostics `任务调度 / 任务历史` 必须把任务生命周期渲染为可诊断表格,不得只显示更新时间和原始 payload 摘要。每行至少展示状态、任务命令和 id、Provider、任务耗时、载荷摘要、诊断信息、更新时间和显式 `查看原始JSON` 操作;终态任务的耗时按 `updatedAt - createdAt` 计算,待处理任务按当前时间减 `createdAt` 计算。耗时必须保留毫秒到秒的精度,小于 1 秒的任务显示小数秒或 `<0.01s`,不得把真实的亚秒级任务四舍五入或向下取整成 `0s`。失败任务必须在默认视图中提取 `result.error`、`result.message`、`result.stderr`、`result.reason` 或等价字段作为失败原因,并将 exit code、timeout、previous status 等关键诊断字段渲染为控件;完整 result 只能通过 `查看原始JSON` 展开。 @@ -52,21 +58,24 @@ frontend shell 必须把左侧主模块与顶部子标签编译为统一的 URL `资源监控` 子标签中的升级控制区通过 backend-core `/api/dispatch` 下发 `provider.upgrade` 任务。默认 `预检升级` 只生成升级计划并回传任务结果;`执行升级` 才允许调度节点本地 updater 容器执行 Compose 重建。前端只展示结构化任务状态、task id、摘要和当前节点的远程更新记录,完整升级计划必须通过 `查看原始JSON` 显式查看。 -## Microservice Frontend +## User Service Frontend -- `微服务` 主模块用于展示挂载在计算节点或主 server Docker 中的业务后端。 +- `用户服务` 主模块用于展示挂载在计算节点或主 server Docker 中的业务后端。 - `服务目录` 必须显示 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 按钮;连续执行同一 prompt 应通过入队份数一次性生成多条任务,避免快速连点造成操作员误判。 - - 业务 microservice 页面不得 iframe 业务旧前端、Todo Note 原 Vite 前端或 Pipeline 自身 WebUI,不得把 microservice 后端端口暴露为浏览器直连 URL,也不得把业务 API 的 JSON 裸铺在页面上。 + - `ClaudeQQ` 子标签必须把 D601 ClaudeQQ 后端渲染为 UniDesk React 控件,包括 NapCat 容器登录二维码、NapCat HTTP/WS 状态、事件缓存、QQ 事件订阅表、订阅创建表单、消息推送表单、主用户私聊账号 `645275593` 标记、最近 QQ 事件、已发送记录和显式原始 JSON 按钮。 + - `Codex Queue` 子标签必须把主 server `codex-queue-backend` 后端渲染为 UniDesk React 控件,包括多 queue lane、queue 内串行、queue 间并行、任务 ID/复制任务 ID、引用按钮、任务耗时、任务提交/批量提交、引用任务 ID、创建成功提示、清空输入、模型下拉、显式入队份数、默认模型 `gpt-5.5`、MiniMax judge 状态、Codex CLI-like 输出流、attempt 终态、运行中追加 prompt、打断、手动重试和显式原始 JSON 按钮;Codex CLI-like 输出流必须始终保留任务的初始 `Submitted prompt` 和运行中 `Steer prompt`;整个 agent loop 消息流统一命名为专有名词 `Trace`,`Trace` 包含 assistant message、user prompt、system event 和 tool call;Codex Queue 与 Pipeline/OpenCode messages 必须共用 `src/components/frontend/src/trace.tsx` 的 Trace 公共组件、统一 Trace item 接口和 codex/opencode port 适配层;连续 read/edit/run 工具调用只是在 Trace 内折叠为可展开工具调用组,汇总格式至少包含 `xx read, xx edit, xx run`,并展示读取文件、编辑文件、运行命令和耗时摘要;最近 3 个工具调用保持展开,工具调用内容不得自动换行且必须在工具调用块内部横向滚动,工具调用组展开后不得再增加额外左侧缩进;message 与 prompt 必须自动换行,普通 message 不显示左侧项目符号缩进且永不折叠;Trace 首屏可以是摘要预览,但终态任务被选中后必须自动在后台加载完整 Trace,手动“加载完整 Trace”也必须从 Codex Queue output archive 分页补齐早期 trace,不得把 preview 的 `hasMore=false` 当成完整历史;即使热状态为控制体积裁剪了早期 raw output,也要从结构化 `basePrompt/displayPrompt/promptHistory` 和 archive 合成完整用户输入与 agent trace,并且初始 prompt 默认显示注入前 prompt 而不是引用注入全文;当初始 prompt 含引用注入时,引用内容必须默认折叠,但必须在初始消息和 Prompt 全量面板提供可展开的“最终传入 Codex 的真实完整 prompt”;多轮引用注入必须按上游/最早上下文在前、直接引用在后的顺序排列,每一轮必须有明确 `Reference Round N/M` 分割线和时间范围,不能用固定 6 轮截断引用链;点击队列引用按钮必须自动把该任务 ID 写入提交表单的引用输入框,引用任务 ID 创建新任务时必须自动注入 `bun scripts/cli.ts codex task ` 的提示;连续执行同一 prompt 应通过入队份数一次性生成多条任务,避免快速连点造成操作员误判。 + - `Codex Queue` 的 queue/session 左侧边栏必须采用顶部对齐和内容高度优先布局:列表、分组和 task card 都不得用居中、space-between、stretch 或隐式等高网格去拉满侧栏高度;item 少时允许下半部分留空,不能把单个 item 拉高来铺满。提交任务时必须立即锁定 prompt、引用 ID、queue、模型、工作目录、最大尝试和入队份数等输入控件,显示等待状态,并用前端 in-flight guard 阻止重复点击造成重复入队;当解析到多个待入队任务时必须显式要求用户勾选批量确认,防止 `---` 分隔或入队份数误操作导致错误传入多个任务。Trace 面板的主滚动条使用全站细窄现代滚动条;工具调用块内部的横向滚动必须可滚动但隐藏横向滚动条,避免移动端阅读被滚动条占用。公共 `TraceView` 的自动滚动必须采用 follow-tail 语义:只有当前滚动位置在底部附近时才跟随新增输出;用户手动向上滚动后立即暂停自动滚动,异步刷新不得把视图拉回底部,直到用户再次滚动到最底部才恢复自动跟随。 + - 用户服务页面不得 iframe 业务旧前端、Todo Note 原 Vite 前端或 Pipeline 自身 WebUI,不得把用户服务后端端口暴露为浏览器直连 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 用户服务只提供 backend/control API,UniDesk 通过 `/api/microservices/pipeline/proxy/...` 拉取 snapshot、Gantt DTO、node detail 和控制接口。 + - Pipeline 页面必须通过同源用户服务代理展示 `model/minimax-m27` 的 MiniMax 限额,包括当前窗口总量/已用/剩余、重置时间和查询状态;主界面不得展示 API key,完整 quota JSON 只能通过显式 `查看原始JSON` 打开。 - Pipeline 控制与观测的最终权威是 100% OA 事件流;分阶段迁移不得在交付态保留点对点控制、旧审核事件或旧 batch 推进逻辑,完整规则见 `docs/reference/pipeline-oa-event-flow.md`。 - - 基础视图必须包含组件矩阵、React Flow 控制图框图、epoch 列表、运行材料索引、epoch 甘特图和 node 精细控制面板。 + - 基础视图必须包含组件矩阵、React Flow 控制图框图、epoch 列表、运行材料索引、epoch 甘特图和 node 精细控制面板。首屏信息顺序必须把核心操作前置:`控制图` 是 Pipeline hero 之后的第一个业务面板,`Epoch 甘特图` 紧随其后;观测指标、评分器、MiniMax 限额、OA 事件流和组件矩阵只能排在这两个核心面板之后,移动端也必须保持同一 DOM 顺序。控制图和甘特图的右侧详情栏默认收起,主图占满可用宽度;点击控制图 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。 + - 用户点击控制图中的 node 后,必须通过同源用户服务代理抓取该 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 后端 OA control API;`node-control` 可作为 HTTP 路由名保留,但内部必须写入 OA 控制事件,并把 UniDesk frontend 发起者记录为结构化事件;历史兼容字段可继续使用 `sourceKind=webui` 表示前端来源。 @@ -80,14 +89,14 @@ frontend shell 必须把左侧主模块与顶部子标签编译为统一的 URL - 甘特图必须提供时间尺度滑块,用同一份时间数据调整每分钟像素密度:全局尺度压缩纵向高度以查看完整 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` 和对齐诊断。 - - UniDesk 前端在该 DTO 存在时不得独立把时间戳重新换算为 y 轴坐标,只能按后端坐标纯展示。 - - 时间尺度滑块变化必须重新请求后端布局。 - - 甘特图必须根据当前可见的后端 y 区间自动隐藏该窗口内没有任何工作区间或事件点的 node 列,避免宽图把无关空闲 node 挤在屏幕中。 + - Pipeline epoch 甘特图的 y 坐标权威在 UniDesk 前端完成,后端只提供 run、procedure、prompt/control 事件和时间戳事实,避免跨 provider HTTP 代理在后端反复计算布局。 + - 前端必须用同一线性公式把 `startMs/endMs/tick.ms/marker.ms` 映射到 `layout.source=frontend-y` 的 `chartHeight`:`y = clamp((ms - startMs) / (endMs - startMs), 0..1) * chartHeight`;执行区间 `top` 使用 `startMs`,自然高度使用 `endMs-startMs` 对应 y 差,短区间可设最小可点击高度但不得改变记录的 `data-y1/data-y2`。 + - 时间尺度滑块只改变前端每分钟像素密度和 `chartHeight`,不得因为缩放重新请求后端 Gantt 布局;run 级过程数据使用轻量 timeline 视图刷新。 + - 甘特图必须根据当前可见的前端 y 区间自动隐藏该窗口内没有任何工作区间或事件点的 node 列,避免宽图把无关空闲 node 挤在屏幕中。 + - E2E 必须验证前端 DOM 暴露的 `data-start-ms/data-end-ms/data-chart-height/data-y*` 与公式计算结果一致,并确认布局来源为 `frontend-y`。 - `Pipeline` 甘特图事件来源。 - 甘特图上的执行线、prompt 点、控制点和 monitor 虚线箭头必须通过同源 `node-control` HTTP 读取接口驱动。 - - run 级图形数据使用 `GET /api/node-control/runs/{runId}?view=gantt&scale=0..100`。 + - run 级图形事实数据使用 `GET /api/node-control/runs/{runId}?view=timeline&tail=N`;该接口不得要求返回后端 y 坐标。 - 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`。 @@ -103,11 +112,11 @@ frontend shell 必须把左侧主模块与顶部子标签编译为统一的 URL - `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 必须折叠到展开层。 + - 点击甘特图中的执行线、prompt 点或控制点后,右侧边栏必须从默认收起状态展开,并展示结构化事件字段、匹配的 procedure/attempt、以及对应 OpenCode step 的摘要与展开详情,而不是在主界面直接铺 raw JSON、JSONL、worker log 或 control event 文本。 + - OpenCode step/message 展示必须进入公共 `TraceView`,视觉与交互以 `Codex Queue` 的 Trace 为唯一标准;Pipeline 原有 `pipeline-opencode-step`、`pipeline-step-message-card`、`pipeline-opencode-part` 等 step/message/tool 卡片风格已废弃,不得继续作为用户可见 Trace。 + - 右侧边栏中的 OpenCode Trace 必须把公共 session 信息(agent、model、session id)聚合到 Trace 头部,不得在每个 step 重复;Trace 正文必须由 `src/components/frontend/src/trace.tsx` 的 opencode port 转换后统一渲染,工具调用折叠、摘要、横向滚动、message 去缩进规则与 Codex Queue 完全一致。 - 右侧边栏排版必须优先保护横向可读宽度:时间放在 step 顶部 header,而不是单独占用左侧窄列;默认摘要不得引入右侧边栏内部横向滚动条,也不得因为窄列挤压把 step 高度拉得过高。 - - OpenCode Step Timeline 不能使用跨越所有 step 的连续装饰线;相邻 step 之间若存在真实时间空闲区间,例如上一个 step `completedAt` 到下一个 step `createdAt`,该区间必须视觉留白,不能被误渲染为持续执行条线。 + - OpenCode Trace 不能使用 Pipeline 旧连续 step 装饰线或旧 step 卡片;相邻 step 之间若存在真实时间空闲区间,不得被任何连续连接线误渲染为持续执行。 - 调整任何高信息密度右侧边栏布局时,都必须把 `总高度` 与 `横向滚动条` 作为显式验收指标,用 Playwright 打开真实页面验证,而不是只看静态代码或本地想象。 - 运行材料只能作为结构化索引行展示计数、状态、时间和来源摘要,完整 JSON、JSONL 或 log tail 只能通过显式 `查看原始JSON` 按钮打开。 - `Pipeline` 渲染与算法验证。 diff --git a/docs/reference/microservices.md b/docs/reference/microservices.md index 9559612b..2f85f8ed 100644 --- a/docs/reference/microservices.md +++ b/docs/reference/microservices.md @@ -1,17 +1,19 @@ -# UniDesk Microservices Reference +# UniDesk User Services Reference -UniDesk microservice 是挂载到主 server 控制面的非核心业务后端。业务容器运行在计算节点 Docker 中,主 server 只保存仓库引用、commit id、Dockerfile/docker-compose 引用、provider 映射和前端集成配置,不把业务仓库整体复制进 UniDesk。 +UniDesk 用户服务是挂载到 UniDesk 核心服务上的、面向用户使用的非核心业务服务;底层配置、API、CLI 和 E2E check 名称仍保留 `microservice` 兼容命名。UniDesk 核心服务(frontend、backend-core、database、provider-gateway、主 server 控制入口)不得依赖某个用户服务存在;缺少部分或全部用户服务时,核心仍必须能启动、运行和完成基础运维。 + +用户服务容器可运行在计算节点 Docker 或主 server Docker 中,主 server 只保存仓库引用、commit id、Dockerfile/docker-compose 引用、provider 映射和前端集成配置,不把业务仓库整体复制进 UniDesk。 ## Boundary -- microservice 后端端口默认只绑定计算节点本机地址,例如 `127.0.0.1:`,不得直接暴露公网。 +- 用户服务后端端口默认只绑定计算节点本机地址,例如 `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` 或 `codex-queue:4222`。backend-core 还必须用 `allowedPathPrefixes` 和 `allowedMethods` 同时限制可代理路径和 HTTP 方法。 +- backend-core REST API、database 和计算节点用户服务后端都不得新增公网端口;公网入口仍只有 frontend 和 provider ingress。 +- `microservice.http` 只允许 provider-gateway 访问 `http://127.0.0.1`、`http://localhost`、`http://host.docker.internal` 这类节点本地地址;主 server 内置用户服务可使用同一 Compose 网络内的显式服务名,例如 `todo-note:4211` 或 `codex-queue:4222`。backend-core 还必须用 `allowedPathPrefixes` 和 `allowedMethods` 同时限制可代理路径和 HTTP 方法。 ## Config Contract -`config.json` 的 `microservices` 是 microservice 的唯一登记来源。每个条目必须包含: +`config.json` 的 `microservices` 是用户服务的唯一登记来源。每个条目必须包含: - `id`、`name`、`providerId` 和 `description`,用于 CLI、backend-core 和 frontend 统一识别。 - `repository.url` 与 `repository.commitId`,用于记录业务代码的外部权威来源;UniDesk 不 vendoring 业务全量代码。 @@ -22,17 +24,17 @@ UniDesk microservice 是挂载到主 server 控制面的非核心业务后端。 ## Compute-Node Development Convention -主 server 本地开发边界固定为只开发 UniDesk frontend 与必要的 UniDesk 配置/代理登记;非 UniDesk 核心功能的后端、Dockerfile、GPU/训练容器、业务数据迁移和业务调试不得默认占用主 server 有限主机资源。涉及 findjob、pipeline、MET Nonlinear 这类业务功能时,应通过 `bun scripts/cli.ts ssh ...` 或 remote CLI SSH 透传进入计算节点,在计算节点本地业务仓库中开发、构建和调试;开发完成后,只把业务服务以 microservice 形式登记到 UniDesk。 +主 server 本地开发边界固定为只开发 UniDesk frontend 与必要的 UniDesk 配置/代理登记;非 UniDesk 核心功能的后端、Dockerfile、GPU/训练容器、业务数据迁移和业务调试不得默认占用主 server 有限主机资源。涉及 findjob、pipeline、MET Nonlinear 这类业务功能时,应通过 `bun scripts/cli.ts ssh ...` 或 remote CLI SSH 透传进入计算节点,在计算节点本地业务仓库中开发、构建和调试;开发完成后,只把业务服务以用户服务形式登记到 UniDesk。 业务仓库由业务系统自己维护,包括源码、Dockerfile、docker-compose、配置模板和业务测试。UniDesk 只引用业务仓库 URL、commit id、Dockerfile/docker-compose 路径和运行容器名;不得把业务全量代码复制到 `src/components/microservices/` 形成双维护。`src/components/microservices/` 只能放通用示例或 UniDesk 自有示例,不作为业务仓库镜像。 -## Main Server Microservices +## Main Server User Services -主 server 只承载对统一入口、状态迁移或控制面自动化有明确必要的 microservice。该类服务仍遵守不暴露公网端口、前端统一 React 控件化展示的规则;业务持久状态优先写入主 PostgreSQL,控制队列这类运行态可使用 `.state/` 文件并必须提供 `/logs` 与结构化状态端点。 +主 server 只承载对统一入口、状态迁移或控制面自动化有明确必要的用户服务。该类服务仍遵守不暴露公网端口、前端统一 React 控件化展示的规则;业务持久状态优先写入主 PostgreSQL,控制队列这类运行态可使用 `.state/` 文件并必须提供 `/logs` 与结构化状态端点。 ### Todo Note On Main Server -当前 Todo Note 作为 `id=todo-note` 的 microservice 登记在 `config.json`: +当前 Todo Note 作为 `id=todo-note` 的用户服务登记在 `config.json`: - 来源工作树:D518 的 `/mnt/d/work/todo_note`;主 server 工作树固定放在 `/root/todo_note`,用于 Docker build 和后端维护。 - Provider:`main-server`,由本机 provider-gateway 通过 `microservice.http` 访问同一 Compose 网络内的 `http://todo-note:4211`。 @@ -40,7 +42,7 @@ UniDesk microservice 是挂载到主 server 控制面的非核心业务后端。 - 部署引用:`/root/todo_note/Dockerfile` 构建纯后端镜像,Compose service 为 `todo-note`,容器名为 `todo-note-backend`。 - 数据库:Todo Note 不再使用 JSON 文件作为运行时权威存储;必须把 D518 `data/registry.json` 和 `data/instances/*.todo.json`、`*.history.jsonl` 迁移到主 server PostgreSQL 的 `todo_note_instances` 和 `todo_note_history` 表。 - 代理路径:只允许 `/api/` 前缀;允许方法为 `GET`、`HEAD`、`POST`、`DELETE`,用于保持 Todo Note 原有清单创建/删除、任务增删改、提醒、展开/收起、移动、撤销/重做等功能。 -- UniDesk 前端:`微服务 / Todo Note` React 页面负责展示清单列表、树形任务、筛选、提醒、拖放/上移下移、撤销/重做、字号控制和显式原始 JSON 按钮。 +- UniDesk 前端:`用户服务 / Todo Note` React 页面负责展示清单列表、树形任务、筛选、提醒、拖放/上移下移、撤销/重做、字号控制和显式原始 JSON 按钮。 Todo Note 在 UniDesk 语境中按纯后端服务管理:不得继续公开 Todo Note 自身 Vite/Web 前端,也不得把 `4211` 映射为公网端口。浏览器只能通过 UniDesk frontend 的 `/api/microservices/todo-note/...` 同源代理访问 Todo Note 后端。 @@ -48,50 +50,69 @@ Todo Note 首次迁移或源 JSON 修复时,在主 server 通过 Docker 内网 Todo Note 数据迁移后必须验证:`microservice proxy todo-note /api/instances` 至少能看到 `CONSTAR`、`大论文`、`找工作`、`小论文`、`事务` 五个迁移清单,总任务数不低于源数据的 100 条;再通过代理创建临时清单、添加任务、切换完成、撤销并删除临时清单,证明写入路径走 PostgreSQL 且不会污染长期数据。 +### Project Manager On Main Server + +当前 Project Manager 作为 `id=project-manager` 的用户服务登记在 `config.json`: + +- Provider:`main-server`,由本机 provider-gateway/直接内网代理访问同一 Compose 网络内的 `http://project-manager:4233`。 +- 代码引用:`https://github.com/pikasTech/unidesk` 与配置中的 `repository.commitId`;服务源码位于 `src/components/microservices/project-manager`,属于 UniDesk 自有主 server 用户服务。 +- 部署引用:UniDesk 根仓库 `docker-compose.yml` 中的 `project-manager` service,Dockerfile 为 `src/components/microservices/project-manager/Dockerfile`,容器名为 `project-manager-backend`。 +- 数据库:项目清单写入主 PostgreSQL 表 `project_manager_projects`;服务启动时自动创建/补齐 schema,不依赖仅首次生效的 database init SQL。 +- 初始数据来源:D601 Windows 文件 `C:\Users\liang\xwechat_files\wxid_01rxm0yxjksk12_345f\msg\file\2026-05\合作项目列表_I_20260309.xlsx`,通过 UniDesk SSH 透传读取到主 server 后,用 `/api/import/excel` 导入。当前 Excel 表头为 `序号`、`合同号`、`项目名称`、`当前状况`、`待完成`、`付款情况`、`其它`。 +- API:`GET /health`;`GET|POST /api/projects`;`GET|PUT|DELETE /api/projects/{id}`;`POST /api/import/excel`;`POST /api/import/projects`;`GET /api/projects/export.xlsx`。 +- 代理路径:只允许 `/health`、`/logs` 和 `/api/` 前缀;允许方法为 `GET`、`HEAD`、`POST`、`PUT`、`DELETE`。 +- UniDesk 前端:`用户服务 / Project Manager` React 页面负责展示主 server 仓库引用、私有后端映射、项目指标、项目表格、筛选搜索、编辑表单、Excel 导入和 Excel 导出;完整原始 JSON 只能通过显式 `查看原始JSON` 打开。 + +Project Manager 在 UniDesk 语境中按纯后端服务管理:不得将 `4233` 映射为公网端口。浏览器只能通过 UniDesk frontend 的 `/api/microservices/project-manager/health` 和 `/api/microservices/project-manager/proxy/...` 同源代理访问项目管理后端。 + ### Codex Queue On Main Server -当前 Codex Queue 作为 `id=codex-queue` 的 microservice 登记在 `config.json`: +当前 Codex Queue 作为 `id=codex-queue` 的用户服务登记在 `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 Queue 开发容器必须只读挂载 host 的 root SSH 目录到 `/root/.ssh`(默认 `${UNIDESK_HOST_ROOT_SSH_DIR:-/root/.ssh}`),让容器内 `git push`、`ssh -T git@github.com` 与 host 使用同一套 GitHub SSH key/known_hosts;不得把私钥复制进镜像或仓库。 +- Codex 认证:容器只从主 server 的 `/root/.codex/config.toml` 同步 Codex provider 配置到 `.state/codex-queue/codex-home`,并通过运行时环境透传 `OPENAI_API_KEY`、`CRS_OAI_KEY` 等 provider 所需变量;这些 provider 环境变量必须由 `writeComposeEnv` 写入 `.state/docker-compose.env` 并由 Compose 注入,确保 `server rebuild codex-queue` 的外部 Docker job runner、自重建和容器重启后不会丢失认证。新增 provider 的 `env_key` 时必须增加同类运行时透传和 Compose env 持久化,禁止把 Codex 或 MiniMax 密钥写入仓库文件。Codex Queue 开发容器必须只读挂载 host 的 root SSH 目录到 `/root/.ssh`(默认 `${UNIDESK_HOST_ROOT_SSH_DIR:-/root/.ssh}`),让容器内 `git push`、`ssh -T git@github.com` 与 host 使用同一套 GitHub SSH key/known_hosts;不得把私钥复制进镜像或仓库。 - Develop-ready 镜像:Codex Queue 镜像必须在启动前预装 UniDesk/Pipeline 调试所需工具,至少包含 `codex`、`bun`、`node`、`npm`/`npx`、`git`、`rg`、`curl`、`python3`/`pip3`、`docker`、`docker compose`、`docker-compose`、`jq`、`ssh`、`rsync`、`make`、`gcc`/`g++`、`tar`、`gzip` 和 `unzip`;不得依赖 Codex 任务运行时再 `apt-get install` 这些基础环境。 - 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` 手动重试。队列 worker 必须隔离单个 task 的异常,不能因为某个 app-server 或 judge 异常让后续 queued 任务停止;当存在 queued/retry_wait 且 worker 空闲时,watchdog 必须自动重新调度。 +- 用户输入持久化:任务初始 prompt 以 `basePrompt/displayPrompt` 作为结构化来源,运行中追加的 `turn/steer` prompt 必须写入 `promptHistory`;transcript 构建时从这些结构化字段合成 `Submitted prompt` 和 `Steer prompt`,不能只依赖有 600 条上限的 raw output,否则长任务输出增长后会丢失关键人工指令。 +- 队列语义:`POST /api/tasks` 或 `/api/tasks/batch` 入队,服务始终只运行一个 Codex turn;当前任务真正终止后才推进下一个任务。`GET /api/tasks` 与 `GET /api/tasks/{id}` 返回队列、attempt、judge 和输出;`GET /api/tasks/{id}/summary` 返回按任务 ID 查询的结构化摘要,包括初始 prompt、最后 assistant message、工具调用摘要、attempt、judge、错误和耗时;CLI 入口是 `bun scripts/cli.ts codex task `。`POST /api/tasks/{id}/steer` 向运行中 turn 推入 prompt;`POST /api/tasks/{id}/interrupt` 或 `DELETE /api/tasks/{id}` 打断/取消;`POST /api/tasks/{id}/retry` 手动重试。队列 worker 必须隔离单个 task 的异常,不能因为某个 app-server、judge 异常或 judge 判定 `fail` 让后续 queued 任务停止;`fail` 只把当前任务标为 failed,随后必须继续扫描并推进下一个 queued/retry_wait 任务。当存在 queued/retry_wait 且 worker 空闲时,watchdog 必须自动重新调度。 +- 稳定性与重启恢复:Codex Queue 的第一目标是长期稳定可用;部署修复或运维排障时不得因为担心容器重启会打断任务而拒绝重启、重建或替换 `codex-queue-backend`。容器重启、服务进程重启和镜像替换后,队列、`promptHistory`、running/judging/retry_wait 任务和 active session 元数据必须从 PostgreSQL 与 fallback 快照恢复,并在已有 `codexThreadId` 可用时用 `thread/resume` 和 continuation prompt 无缝继续当前任务;如果原 app-server turn 已丢失,也必须把当前任务恢复到可 retry/continue 的状态,不能错误推进下一个任务或永久卡住。主 server 侧重建必须走 `server rebuild codex-queue`,该 job 受 `.state/locks/server-compose.lock` 串行化约束,并且必须在 build 后执行 no-deps force-recreate 与 post-up health validation;禁止在 job 中先手工 `docker rm` 再依赖后续命令补救,因为中断窗口会让容器消失并触发 frontend `direct microservice proxy failed`。重启后出现 active task 丢失、手动 steer/interrupt 记录丢失、running 任务卡死、误判完成、跳过当前任务、容器消失或阻塞队列,均属于 Codex Queue 的 P0 核心缺陷,必须先修复并补充 restart-recovery 验收,不能把“避免重启”作为交付策略。 - 完成判定: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 规则。MiniMax 返回必须先做 JSON 去噪,支持去除 Markdown fence、`json` 标签和从夹杂文本中提取平衡 JSON object;如果去噪后仍无法解析,服务必须把解析错误和上一轮去噪前原始回答反馈给 MiniMax 做 JSON repair 重试,重试次数由 `UNIDESK_CODEX_QUEUE_MINIMAX_JUDGE_REPAIR_ATTEMPTS` 控制,默认 `2`,耗尽后才进入 fallback,并在 fallback 原因中保留 MiniMax 失败信息。 - 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`,内置模型队列包含 `gpt-5.4-mini`、`gpt-5.4`、`gpt-5.5`;每个入队任务可通过前端模型下拉菜单或 API 覆盖 `model`、`cwd`、`reasoningEffort` 和 `maxAttempts`。 -- 状态与日志:默认工作目录为容器内 `/root/unidesk`,该路径映射主 server 的 `~/unidesk`;同时保留 `/workspace` 映射以兼容历史任务。队列状态保存在 `.state/codex-queue/state.json` 对应的容器挂载路径,日志写入 UniDesk `logs/{YYYYMMDD}/..._codex-queue.jsonl`,`/logs` 端点返回最近结构化日志。`/health` 的 `queue.devReady` 和 `/api/dev-ready` 必须暴露 develop-ready 自检,包括必需工具、Docker socket、`docker compose`、默认工作目录、Codex config 状态和 `/root/.ssh` 共享 SSH key 状态。Codex CLI-like 输出可能很大,服务必须对输出条数设上限并节流状态持久化,禁止对每个 output delta 同步重写完整 state 导致 `/health` 和控制 API 卡死;容器 healthcheck 必须使用带超时的 HTTP 探针,不能留下堆积的无超时探针进程。 +- 模型选择:默认 Codex 模型是 `gpt-5.5`,内置模型队列包含 `gpt-5.5`、`gpt-5.4-mini`、`gpt-5.4`;`gpt-5.5` 的默认 reasoning effort 必须是 `xhigh`,可通过 `CODEX_QUEUE_MODEL_REASONING_EFFORTS` 追加或覆盖模型级默认值;每个入队任务可通过前端模型下拉菜单或 API 覆盖 `model`、`cwd`、`reasoningEffort` 和 `maxAttempts`,`maxAttempts` 上限为 `99`。Judge 判定 `retry` 或非用户取消类 `fail` 时必须继续已有 `codexThreadId`,不能新建 session;重试间隔使用指数退避,从 `1s` 开始,最大 `10min`。429、Too Many Requests、exceeded retry limit、overloaded、stream disconnected 等服务/限流错误一律判定为 `retry`,不能当作完成。 +- 状态与日志:默认工作目录为容器内 `/root/unidesk`,该路径映射主 server 的 `~/unidesk`;同时保留 `/workspace` 映射以兼容历史任务。队列任务的权威持久化优先写入主 PostgreSQL 表 `unidesk_codex_queue_tasks`,包含状态索引字段和 task 热状态;`.state/codex-queue/state.json` 仅作为本地恢复快照和 PostgreSQL 不可用时的 fallback,服务启动时必须合并 PG 与文件快照并把 running/judging 任务恢复为 retry_wait。Codex CLI-like output/Trace 的完整记录必须同步写入 `.state/codex-queue/output-archive/*.jsonl`,`/api/tasks//transcript` 与 `/api/tasks//output` 必须从 archive 分页重建完整历史,不得因为热状态裁剪而丢失早期 trace;热 task JSON 只保留可配置窗口(默认 600 条 output、400 条 event)以保证 `/health`、`/api/tasks` 和 PostgreSQL flush 不被长任务拖死。WebUI 必须支持多 queue 查看、显式创建 queue、提交时下拉选择 queue,并支持把已创建且非 active 的任务移动到其他 queue;queue 内串行,queue 间并行。Codex Queue 镜像必须内置 Playwright Chromium 浏览器与系统依赖,保证队列任务能直接执行公网 frontend Playwright 回归,不得只在宿主机临时安装。日志写入 UniDesk `logs/{YYYYMMDD}/..._codex-queue.jsonl`,`/logs` 端点返回最近结构化日志。`/health` 的 `queue.storage`、`queue.devReady` 和 `/api/dev-ready` 必须暴露 PostgreSQL 是否已配置/可用、develop-ready 自检、必需工具、Docker socket、`docker compose`、默认工作目录、Codex config 状态和 `/root/.ssh` 共享 SSH key 状态。Codex CLI-like 输出可能很大,服务必须节流状态持久化,禁止对每个 output delta 同步重写完整 state 导致 `/health` 和控制 API 卡死;容器 healthcheck 必须使用带超时的 HTTP 探针,不能留下堆积的无超时探针进程。 +- ClaudeQQ 通知:Codex Queue 可通过 backend-core 的 `claudeqq` 用户服务代理调用 `POST /api/push/text`,在每个任务进入 `succeeded`、`failed` 或 `canceled` 终态后向配置目标发送最终 response,并附带 task id、queue、状态、模型、attempt、当前 running/queued/retry_wait 数和任务总耗时;当所有 queue 进入 `0 running / 0 queued` 空闲态时,必须单独发送一次空闲提醒。通知由 `CODEX_QUEUE_NOTIFY_CLAUDEQQ_ENABLED` 控制,目标由 `CODEX_QUEUE_NOTIFY_CLAUDEQQ_TARGET_TYPE=private|group`、`CODEX_QUEUE_NOTIFY_CLAUDEQQ_USER_ID`、`CODEX_QUEUE_NOTIFY_CLAUDEQQ_GROUP_ID` 配置,默认私聊 `645275593`;代理基址、最终 response 最大字符数、单次超时和发送尝试次数分别由 `CODEX_QUEUE_NOTIFY_CLAUDEQQ_BASE_URL`、`CODEX_QUEUE_NOTIFY_CLAUDEQQ_MAX_RESPONSE_CHARS`、`CODEX_QUEUE_NOTIFY_CLAUDEQQ_TIMEOUT_MS` 和 `CODEX_QUEUE_NOTIFY_CLAUDEQQ_SEND_ATTEMPTS` 配置。通知必须异步发送,失败或重试只能写 warn 日志,不能阻塞队列继续推进;`/health` 的 `queue.notifications.claudeqq` 必须暴露非敏感配置与是否已配置目标。 - 代理路径:只允许 `/health`、`/logs` 和 `/api/` 前缀;允许方法为 `GET`、`HEAD`、`POST`、`DELETE`。Codex Queue 只在 Compose 内网暴露 `4222/tcp`,不得映射或开放到公网。 -- UniDesk 前端:`微服务 / Codex Queue` React 页面负责展示队列卡片、默认模型、模型下拉、显式入队份数、MiniMax judge 状态、Codex CLI-like 输出流、attempt 终态、追加 prompt、打断和手动重试控件;连续执行同一 prompt 应使用 `入队份数` 一次性生成多条队列任务,而不是依赖快速连点按钮;原始任务 JSON 只能通过显式 `查看原始JSON` 打开。 +- UniDesk 前端:`用户服务 / Codex Queue` React 页面负责展示队列卡片、任务 ID、复制任务 ID、引用按钮、任务耗时、默认模型、模型下拉、显式入队份数、引用任务 ID、清空输入、创建成功提示、MiniMax judge 状态、Codex CLI-like 输出流、attempt 终态、追加 prompt、打断和手动重试控件;整个 agent loop 消息流统一命名为专有名词 `Trace`,`Trace` 包含 assistant message、user prompt、system event 和 tool call;Codex Queue 与 Pipeline/OpenCode messages 必须共用 `src/components/frontend/src/trace.tsx` 的 Trace 公共组件、统一 Trace item 接口和 codex/opencode port 适配层;连续 read/edit/run 工具调用只是在 Trace 内折叠为可展开工具调用组,汇总格式至少包含 `xx read, xx edit, xx run`,并展示读取文件、编辑文件、运行命令和耗时摘要;最近 3 个工具调用保持展开,工具调用内容不得自动换行且必须在工具调用块内部横向滚动,工具调用组展开后不得再增加额外左侧缩进;message 与 prompt 必须自动换行,普通 message 不显示左侧项目符号缩进且永不折叠;点击队列卡片引用按钮必须自动把该任务 ID 写入提交表单的引用任务 ID 输入框;引用任务 ID 创建新任务时必须自动注入 `bun scripts/cli.ts codex task ` 的提示,让 Codex 读取初始 prompt、最后消息和工具摘要后继续;连续执行同一 prompt 应使用 `入队份数` 一次性生成多条队列任务,而不是依赖快速连点按钮;原始任务 JSON 只能通过显式 `查看原始JSON` 打开。 -## D601 Microservices +## D601 User Services -当前 `D601` 同时承载以下 UniDesk microservice: +当前 `D601` 同时承载以下 UniDesk 用户服务: - `findjob`:FindJob 纯后端服务,UniDesk frontend 渲染岗位指标、岗位预览和草稿报告。 - `pipeline`:Pipeline v2 控制与观测服务,UniDesk frontend 渲染组件矩阵、React Flow 控制图、epoch 甘特图、运行材料索引和 node 精细控制面板。 - `met-nonlinear`:MET Nonlinear 训练编排服务,UniDesk frontend 渲染 GPU/镜像、训练队列、Project config 预览、训练进度、ETA 和历史记录。 +- `claudeqq`:ClaudeQQ 纯后端 QQ 消息网关,UniDesk frontend 渲染 NapCat 连接、事件订阅、消息推送、最近 QQ 事件和发送记录。 ### FindJob On D601 -当前 FindJob 作为 `id=findjob` 的 microservice 登记在 `config.json`: +当前 FindJob 作为 `id=findjob` 的用户服务登记在 `config.json`: - Provider:`D601`。 - 开发工作树:`/home/ubuntu/findjob`,开发和调试必须通过 UniDesk SSH 透传进入 D601。 - 代码引用:`https://gitee.com/Lyon1998/findjob` 与配置中的 `repository.commitId`。 - 部署引用:业务仓库自身 `Dockerfile`、`docker-compose.yml`、`composeService=server`、`containerName=findjob-server`。 - 节点后端:D601 上 `127.0.0.1:3254`,provider-gateway 容器内通过 `http://host.docker.internal:3254` 访问。 -- 代理路径:只允许 `/api/` 前缀;`/` 上的业务旧前端即使仍存在,也不作为 UniDesk microservice 入口使用。 -- UniDesk 前端:`微服务 / FindJob` React 页面负责展示指标、岗位预览、草稿报告和原始 JSON 显式查看按钮。 +- 代理路径:只允许 `/api/` 前缀;`/` 上的业务旧前端即使仍存在,也不作为 UniDesk 用户服务入口使用。 +- UniDesk 前端:`用户服务 / FindJob` React 页面负责展示指标、岗位预览、草稿报告和原始 JSON 显式查看按钮。 FindJob 在 UniDesk 语境中按纯后端服务管理:默认页面不得 iframe 或跳转到 findjob 自身前端,也不得直接暴露 D601 的 `3254` 到公网。UniDesk frontend 只能通过 `/api/microservices/findjob/health` 和 `/api/microservices/findjob/proxy/api/...` 访问 FindJob 后端。 ### Pipeline On D601 -当前 Pipeline v2 作为 `id=pipeline` 的 microservice 登记在 `config.json`: +当前 Pipeline v2 作为 `id=pipeline` 的用户服务登记在 `config.json`: - Provider:`D601`。 - 开发工作树:`/home/ubuntu/pipeline`,开发和调试必须通过 UniDesk SSH 透传进入 D601。 @@ -99,7 +120,7 @@ FindJob 在 UniDesk 语境中按纯后端服务管理:默认页面不得 ifram - 部署引用:业务仓库自身 `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 前端已废弃,UniDesk 只访问 Pipeline control backend。 -- UniDesk 前端:`微服务 / Pipeline` React 页面负责展示 health、组件数量、React Flow pipeline 控制图框图、epoch 列表、epoch 甘特图、OA/procedure 结构化摘要、运行材料索引、点击 node 后的执行过程抓取、append prompt、guide 和 redo/restart 控件,以及显式原始 JSON 按钮。 +- 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 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`。 @@ -107,7 +128,7 @@ Pipeline 的一个 epoch 是同一个 pipeline 从入口到终态完整执行一 ### MET Nonlinear On D601 -当前 MET Nonlinear 作为 `id=met-nonlinear` 的 microservice 登记在 `config.json`: +当前 MET Nonlinear 作为 `id=met-nonlinear` 的用户服务登记在 `config.json`: - Provider:`D601`。 - 开发工作树:`/home/ubuntu/met_nonlinear`,后端、Dockerfile、训练队列和训练容器都必须通过 UniDesk SSH 透传在 D601 开发调试;主 server 本地只允许开发 UniDesk frontend 与代理登记。 @@ -116,16 +137,34 @@ Pipeline 的一个 epoch 是同一个 pipeline 从入口到终态完整执行一 - 部署引用:业务仓库内 `docker-compose.unidesk.yml`、`docker/unidesk/Dockerfile.server`、`docker/unidesk/Dockerfile.ml`、`composeService=met-nonlinear-ts`、`containerName=met-nonlinear-ts`。 - 节点后端:D601 上 `127.0.0.1:3288`,provider-gateway 容器内通过 `http://host.docker.internal:3288` 访问。 - 代理路径:只允许 `/health` 和 `/api/` 前缀;允许 `GET`、`HEAD`、`POST`、`PUT`,用于读取队列/历史、从已有 Project fork 新 Project、保存队列设置、加入待启动队列和启动队列。 -- UniDesk 前端:`微服务 / MET Nonlinear` React 页面采用类似下载器的工作台交互,负责从项目库选择已有 Project、fork 新 Project、加入待启动队列、启动队列、调整最大并发、分标签展示当前队列/已完成/失败诊断/GPU 与镜像,并展示训练进度、ETA、训练速度 `epoch/h`、历史训练记录和显式原始 JSON 按钮。项目库必须按 `projects/`、`ex_projects/` 的真实目录层级渲染文件树,文件夹计数等于子树 Project 数;项目库和任务列表行都必须可点击打开结构化详情,详情以控件展示 `config.json` 与 `data/` 中的训练状态、模型参数量、模型层和指标,不默认展示裸 JSON。 +- UniDesk 前端:`用户服务 / MET Nonlinear` React 页面采用类似下载器的工作台交互,负责从项目库选择已有 Project、fork 新 Project、加入待启动队列、启动队列、调整最大并发、分标签展示当前队列/已完成/失败诊断/GPU 与镜像,并展示训练进度、ETA、训练速度 `epoch/h`、历史训练记录和显式原始 JSON 按钮。项目库必须按 `projects/`、`ex_projects/` 的真实目录层级渲染文件树,文件夹计数等于子树 Project 数;项目库和任务列表行都必须可点击打开结构化详情,详情以控件展示 `config.json` 与 `data/` 中的训练状态、模型参数量、模型层和指标,不默认展示裸 JSON。 MET Nonlinear 的长期服务边界写在业务仓库 `~/met_nonlinear/docs/reference/unidesk_microservice.md`:`met-nonlinear-ts` 是长驻 Bun TypeScript 编排后端,`met-nonlinear-ml:tf26` 是按需训练镜像,每个训练任务用一个 `docker run --rm` 容器执行 `python cli.py -t `,训练完成后容器自动销毁。训练镜像 Dockerfile 必须使用中国大陆可达的软件源;当前固定使用 Huawei Cloud mirror 的 `nvidia/cuda:11.2.2-cudnn8-runtime-ubuntu20.04`、Aliyun apt mirror、Tsinghua PyPI mirror、Ubuntu Python 3.8 和 `tensorflow==2.6.0`,避免官方 TensorFlow 2.6 GPU 镜像 Python 3.6 与业务源码类型注解不兼容。 MET Nonlinear 验收必须通过公网 UniDesk frontend 的交互式 UI 完成:选择已有 source Project,设置训练轮数和最大并发,使用 `Fork Project` 创建新的 `projects/unidesk_forks/` Project,确认新 Project 只是被选中而不会直接训练,再加入待启动队列并点击 `启动队列`。验收时必须确认项目库的 `projects/` 与 `ex_projects/` 按文件树层级展开、文件夹 Project 计数与后端返回数量一致;点击项目行后详情显示 `config.json`、`data/` 训练状态、模型参数量和指标;待启动、排队中、训练中、已完成和失败诊断分标签可见;训练队列和已完成行显示 `epoch/h` 训练速度且可点击打开任务详情。最大并发必须按 UI 设置生效,运行中行显示训练进度和 ETA,目标 GPU 为 2080Ti,2080Ti 显存余量低于 20% 时自动限制并发,并确认训练容器结束后不残留。批量规模由 UI 输入框决定,完整验收可以通过输入 `Fork 数量=10`、`训练轮数=200`、`最大并发=3` 执行,但不得把该规模做成专用硬编码按钮。CLI `/api/queue/server-test` 仅保留为后端兼容入口,不作为 frontend 操作入口。 +### ClaudeQQ On D601 + +当前 ClaudeQQ 作为 `id=claudeqq` 的用户服务登记在 `config.json`: + +- Provider:`D601`。 +- 开发工作树:`/home/ubuntu/.agents/skills/claudeqq`,后端、Dockerfile、订阅分发和 NapCat 连接调试必须通过 UniDesk SSH 透传在 D601 完成;主 server 本地只允许开发 UniDesk frontend 与代理登记。 +- 代码引用:`https://gitee.com/lyon1998/agent_skills` 与配置中的 `repository.commitId`,实际服务目录为仓库内 `claudeqq/`。 +- 部署引用:业务目录内 `Dockerfile` 与 `docker-compose.unidesk.yml`,Compose service 为 `claudeqq` 与 `napcat`,容器名分别为 `claudeqq-backend` 与 `claudeqq-napcat`。 +- 节点后端:D601 上 `127.0.0.1:3290`,provider-gateway 容器内通过 `http://host.docker.internal:3290` 访问。 +- 代理路径:只允许 `/health`、`/logs` 和 `/api/` 前缀;允许方法为 `GET`、`HEAD`、`POST`、`DELETE`。 +- 服务模式:ClaudeQQ 在 UniDesk 中按纯后端运行,默认 `CLAUDEQQ_AUTO_REPLY=false`,只负责 NapCat HTTP/WS 连接、QQ 事件入站记录、HTTP webhook 订阅投递和 `/api/push/text` 消息推送,不把 ClaudeQQ 自身旧 WebUI 作为用户入口。NapCat 必须随同 `docker-compose.unidesk.yml` 容器化部署,D601 只绑定 `127.0.0.1:3000`、`127.0.0.1:3001` 和 `127.0.0.1:6099`,ClaudeQQ 容器通过 Compose 内网 `napcat:3000/3001` 访问。 +- NapCat 登录 API:`GET /api/napcat/login` 和 `GET /api/napcat/status` 返回容器化状态、HTTP/WS 连通性、登录状态和二维码 data URL;`GET /api/napcat/qrcode` 只返回二维码。二维码来源为共享挂载中的 `/napcat/cache/qrcode.png`,由 ClaudeQQ 后端转为 JSON data URL 后经 UniDesk 同源代理给前端展示。 +- 订阅 API:`GET /api/events/recent` 返回最近 QQ 事件,`GET|POST /api/events/subscriptions` 管理 webhook 订阅,`DELETE /api/events/subscriptions/{id}` 删除订阅;订阅回调使用 HTTP POST JSON,并在配置 secret 时携带 `x-claudeqq-signature` HMAC-SHA256。 +- 推送 API:`POST /api/push/text` 接受 `userId` 或 `groupId` 与 `message`,由 ClaudeQQ 通过 NapCat HTTP API 发送 QQ 消息;NapCat 不可用时必须快速返回 `status=napcat_offline` 和具体连接错误;当前人工推送验收只允许发给主用户私聊账号 `645275593`,其他用户服务和 main server 应通过 UniDesk 用户服务代理调用,不得直连 D601 公网端口。 +- UniDesk 前端:`用户服务 / ClaudeQQ` React 页面负责展示 D601 仓库引用、私有后端映射、NapCat 容器登录二维码、NapCat HTTP/WS 状态、事件缓存、订阅表、订阅创建表单、消息推送表单、主用户私聊账号 `645275593` 标记、最近 QQ 事件和已发送记录;完整原始 JSON 只能通过显式 `查看原始JSON` 打开。 + +ClaudeQQ 在 UniDesk 语境中按消息网关后端服务管理:不得直接暴露 D601 的 `3290`、`3000`、`3001` 或 `6099` 到公网,不得 iframe ClaudeQQ 旧 WebUI。浏览器只能通过 UniDesk frontend 的 `/api/microservices/claudeqq/health` 和 `/api/microservices/claudeqq/proxy/...` 同源代理访问。 + ## CLI -- `bun scripts/cli.ts microservice list`:列出全部 microservice、provider 映射、仓库引用、后端映射和运行态容器摘要。 -- `bun scripts/cli.ts microservice status findjob`:查看单个 microservice 的配置与运行态。 +- `bun scripts/cli.ts microservice list`:列出全部用户服务、provider 映射、仓库引用、后端映射和运行态容器摘要。 +- `bun scripts/cli.ts microservice status findjob`:查看单个用户服务的配置与运行态。 - `bun scripts/cli.ts microservice health findjob`:通过 backend-core -> provider-gateway -> D601 本机后端链路探测 FindJob `/api/health`。 - `bun scripts/cli.ts microservice proxy findjob /api/summary`:通过同一私有代理读取业务 API,适合人工验证,不用于公开业务端口。 - `bun scripts/cli.ts microservice health pipeline`:通过 backend-core -> provider-gateway -> D601 本机后端链路探测 Pipeline `/health`。 @@ -134,33 +173,37 @@ MET Nonlinear 验收必须通过公网 UniDesk frontend 的交互式 UI 完成 - `bun scripts/cli.ts microservice health met-nonlinear`:通过 backend-core -> provider-gateway -> D601 本机 TS 编排后端链路探测 MET Nonlinear `/health`。 - `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 claudeqq`、`bun scripts/cli.ts microservice proxy claudeqq /api/napcat/login`、`bun scripts/cli.ts microservice proxy claudeqq /api/events/recent` 和 `bun scripts/cli.ts microservice proxy claudeqq /api/events/subscriptions`:验证 ClaudeQQ 后端、NapCat 容器登录、事件订阅和私有代理链路;消息推送使用 `POST /api/push/text`,不得开放 D601 `3290/3000/3001/6099` 公网端口。 - `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 microservice health codex-queue` 与 `bun scripts/cli.ts microservice proxy codex-queue /api/tasks`:验证主 server Codex Queue 后端、PostgreSQL 优先持久化、文件 fallback 快照和本机 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 页面。 +`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`、`met-nonlinear.tsx`、`codex-queue.tsx`。默认展示必须是业务控件:指标卡、状态徽标、表格、草稿卡片、运行卡片、树形任务、表单控件、结构化材料索引、链接和字段摘要;只有操作员点击 `查看原始JSON` 时才允许打开原始 JSON 弹窗。日志、JSONL 和大块 JSON 不得在主界面按行展示,避免把裸数据伪装成 UI。 +用户服务前端必须整合到 `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。 ## Verification -microservice 交付必须同时通过后端、CLI 和公网 frontend 验证: +用户服务交付必须同时通过后端、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-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`,确认 `claudeqq` 的 `providerId=D601`、`public=false`、`frontendOnly=true`、仓库 URL、commit id、`127.0.0.1:3290` 映射和 `claudeqq-backend` 容器摘要可见。 - 在主 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 claudeqq`、`bun scripts/cli.ts microservice proxy claudeqq /api/napcat/login`、`bun scripts/cli.ts microservice proxy claudeqq /api/events/recent` 和 `bun scripts/cli.ts microservice proxy claudeqq /api/events/subscriptions`,确认真实链路经过 backend-core、WebSocket、D601 provider-gateway 和 D601 本机 ClaudeQQ 后端;在 D601 上 `curl http://127.0.0.1:3290/health` 应显示 `service=claudeqq`、`pureBackend=true`、`napcat.containerized=true`、NapCat HTTP/WS 状态、二维码状态和订阅计数。 - 运行 `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 或打断。批量验收必须通过公网 frontend 设置 `入队份数=5` 或使用多段 prompt 分隔,一次性入队 5 条任务,并确认 5 条任务按顺序进入 running/judging/succeeded,而不是只运行第一条。 +- 运行 `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` 后端,并且 `/health` 的 `queue.storage.primary` 为 `postgres` 或在 PG 不可用时显式显示 fallback 原因;再通过公网 frontend 提交一个 `gpt-5.5` 小任务,确认队列串行推进、输出实时更新、结束后有 judge 判定,且运行中可追加 prompt 或打断。Codex Queue 的重启恢复必须作为验收项:运行中任务存在时重启或重建 `codex-queue-backend` 后,任务必须从持久化状态恢复到可继续执行状态,不能丢失 active task、`promptHistory` 或后续 queued 任务。批量验收必须通过公网 frontend 设置 `入队份数=5` 或使用多段 prompt 分隔,一次性入队 5 条任务,并确认 5 条任务按顺序进入 running/judging/succeeded,而不是只运行第一条。 - 在 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。 -- 运行 `bun scripts/cli.ts e2e run`,确认 microservice 相关检查 passed,并确认 Playwright 访问的是公网 `http://74.48.78.17:18081/`。 -- 登录公网 frontend,进入 `微服务 / 服务目录`、`微服务 / Todo Note`、`微服务 / FindJob`、`微服务 / Pipeline` 和 `微服务 / MET Nonlinear`,确认能看到主 server 与 D601 provider、仓库引用、后端私有映射、Todo Note 迁移清单与树形任务、FindJob 指标和岗位预览、Pipeline 组件矩阵、React Flow 控制图、epoch 列表、epoch 甘特图和运行材料索引、MET Nonlinear 队列/GPU/镜像/Project config/训练历史;Todo Note 页面必须能创建临时清单、添加任务并删除临时清单,删除前必须按唯一临时清单名称重新选中对应行,禁止用未确认的当前 active 清单执行删除,FindJob 页面必须显示真实数字指标、`HEALTH OK` 和非空岗位预览,Pipeline 页面必须显示 `Pipeline v2 工作台`、`Health OK`、组件数、epoch 甘特图和结构化运行材料索引,MET Nonlinear 页面必须显示 `Health OK`、`Fork Project`、`启动队列`、`当前队列`、最大并发设置和 GPU/镜像面板,不能只停留在 loading 骨架;页面默认不得出现裸 JSON、JSONL 或逐行日志。 +- 在 D601 上用 `bun scripts/cli.ts ssh D601 ...` 调试 `~/.agents/skills/claudeqq`,确认 `docker compose -f docker-compose.unidesk.yml up -d --build claudeqq` 后 `claudeqq-backend` 与 `claudeqq-napcat` 都运行,`curl http://127.0.0.1:3290/health` 和 `curl http://127.0.0.1:3290/api/napcat/login` 可用;不要把 ClaudeQQ 后端或 NapCat 调试服务部署到主 server。 +- 运行 `bun scripts/cli.ts e2e run`,确认用户服务相关检查 passed,并确认 Playwright 访问的是公网 `http://74.48.78.17:18081/`。 +- 登录公网 frontend,进入 `用户服务 / 服务目录`、`用户服务 / Todo Note`、`用户服务 / FindJob`、`用户服务 / Pipeline`、`用户服务 / MET Nonlinear` 和 `用户服务 / ClaudeQQ`,确认能看到主 server 与 D601 provider、仓库引用、后端私有映射、Todo Note 迁移清单与树形任务、FindJob 指标和岗位预览、Pipeline 组件矩阵、React Flow 控制图、epoch 列表、epoch 甘特图和运行材料索引、MET Nonlinear 队列/GPU/镜像/Project config/训练历史、ClaudeQQ NapCat 容器登录二维码/NapCat 状态/事件订阅/消息推送/最近 QQ 事件;Todo Note 页面必须能创建临时清单、添加任务并删除临时清单,删除前必须按唯一临时清单名称重新选中对应行,禁止用未确认的当前 active 清单执行删除,FindJob 页面必须显示真实数字指标、`HEALTH OK` 和非空岗位预览,Pipeline 页面必须显示 `Pipeline v2 工作台`、`Health OK`、组件数、epoch 甘特图和结构化运行材料索引,MET Nonlinear 页面必须显示 `Health OK`、`Fork Project`、`启动队列`、`当前队列`、最大并发设置和 GPU/镜像面板,ClaudeQQ 页面必须显示 `Health OK`、`NapCat 容器登录`、`QQ 事件订阅`、`消息推送`、`事件缓存` 和私有代理说明,不能只停留在 loading 骨架;页面默认不得出现裸 JSON、JSONL 或逐行日志。 diff --git a/docs/reference/observability.md b/docs/reference/observability.md index 41ab7853..3033680e 100644 --- a/docs/reference/observability.md +++ b/docs/reference/observability.md @@ -17,3 +17,9 @@ UniDesk 的可观测性优先级高于静默成功。CLI、服务日志、Docker ## Task Liveness backend-core 必须把 queued、dispatched、running 视为待处理任务,并通过 `TASK_PENDING_TIMEOUT_MS` 对长时间没有 provider 终态回报的任务做超时处理。超时任务转为 failed,result 中保留 timeout、previousStatus 和 previousResult 摘要,避免 `态势总览` 的待处理数量长期卡住且无法解释。 + +## Performance Metrics + +backend-core 必须提供 `/api/performance`,返回滚动窗口内的 HTTP 组件请求统计、最近失败请求、内部操作统计、最近慢操作、进程内存、PGDATA 用量和 Codex Queue PostgreSQL 存储摘要。组件统计必须包含请求数、失败数、失败率、平均延迟和 P95,内部操作统计必须包含服务名、操作名、次数、平均延迟和 P95;失败和慢操作记录必须保留时间、状态、耗时、路径或细节,避免只给汇总数字而无法定位。 + +frontend Bun server 必须提供同源 `/api/frontend-performance`,记录 webui 静态资源、登录/session、API 代理和 frontend->core 代理操作耗时。浏览器中的 `运行总览 / 性能面板` 必须把 frontend 与 backend-core 指标合并展示为 Bwebui 曲线、组件汇总、最近失败请求、内部操作汇总和最近慢操作;完整性能 JSON 只能通过显式 `查看原始JSON` 打开。 diff --git a/docs/reference/pipeline-oa-event-flow.md b/docs/reference/pipeline-oa-event-flow.md index c0f5a122..27a24a98 100644 --- a/docs/reference/pipeline-oa-event-flow.md +++ b/docs/reference/pipeline-oa-event-flow.md @@ -8,9 +8,37 @@ Pipeline 的最终控制模型必须是 100% 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。 +- 如果 procedure 或 monitor 结束后缺少预期 OA 提交,runner 必须把它视为可恢复的 OA contract failure:在重试预算内通过 OA 事件写入明确反馈并启动 fresh attempt,不能直接把一次缺失提交升级成最终失败;预算耗尽后才 fail closed。 - 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 事件取证。 +## Benchmark Prompt And Skill Boundary + +- Pipeline benchmark 的入口 `task` prompt 必须保持需求导向:描述要恢复的用户可见行为、输入输出和验收目标,不得为了某个 PikaBench 或其他切片在任务 prompt 中写入内部流程控制、精确补丁路线、已知失败符号、采样 item id、隐藏 oracle 细节或上一轮失败诊断。 +- Dispatch/repair procedure prompt 只能声明角色、作用域、OA 提交、分支规范、通用验证习惯和安全边界;不得在失败后通过追加“定向解题 prompt”把单个 benchmark 的答案固化进 procedure config。 +- 历史运行中沉淀出的可复用经验必须进入版本化 skill 或机械 quality gate:skill 承载领域经验和源码形态经验,gate 只给出可判定的机器证据。若经验不能泛化为 skill/gate,应视为本轮人工调参,不得计入组件贡献。 +- 消融实验必须保留干净边界:同一个前门需求 prompt、同一个拓扑和同一个 scorer 下,注入经验型 skill 的 variant 可以使用该 skill;`no-skill` variant 必须能真正移除这部分经验。如果无 skill 仍因 prompt 中残留定向答案而达到满分,该消融结果无效。 +- 对 PikaBench-4/PikaBench-8 这类 Pipeline benchmark,若满分依赖把 `list.pop`、`max/min` 等具体修复路线直接写入任务/dispatch/repair prompt,应先把这些路线改写为泛化的 PikaPython benchmark skill 经验,再重跑 baseline 与 no-skill 对照。 + +## Benchmark Train/Valid Isolation + +- 任何用于迭代优化 Pipeline、skill、gate 或 monitor 的 benchmark 切片,都必须在优化前固定一个不重合的 valid holdout;只看 train 满分不能证明泛化。 +- PikaBench-4 是当前优先的短循环抗过拟合 benchmark;pipeline config 必须显式声明 `train-banch` 与 `valid-banch`(可保留兼容 alias),分别绑定 `pikabench-4` train scorer/prompt 与 `pikabench-4-valid` scorer/prompt,避免把单一 bench 当作泛化证据。PikaBench-8 只能在 4-item train/valid 先稳定出分后用于更慢的确认。 +- Train 与 valid 必须使用同一个 pipeline id、同一拓扑、同一模型、同一重试预算、同一 monitor 策略、同一 merge gate 和同一 per-node quality gate;只允许前门任务 prompt 与最终 scorer/benchmark oracle 不同。 +- Train 运行可以完整取证:trace、OA record、node log、gate evidence、scorer failed case 和 artifact 都可以用于分析和改进通用机制。 +- Valid 运行在优化期间只能读最终聚合 score,例如 `4/4` 或 `8/8`;不得查看 valid trace、OA record、node log、gate artifact、scorer stdout、failed case id、hidden filter、intermediate file 或生成报告。 +- 机制贡献或泛化结论必须以 train 和 valid 同时达到目标分作为验收;若 train 达标而 valid 未达标,结论只能写作过拟合或未通过验证,不能继续用 valid 细节调参。 + +## Benchmark Execution And Model Quota + +- Benchmark execution depends on develop-ready environment readiness. Large optional dependency payloads used by the benchmark build, such as PikaPython `port/linux/package/lvgl/lvgl`, must be pre-cloned or pre-installed by the referenced environment repo Dockerfile in a cacheable layer. Procedure nodes, evidence gates, and scorers must not clone those dependencies ad hoc during the run, because transient network failures then become false benchmark failures and repeated clones defeat Docker build cache. +- If a benchmark stalls or returns a low score because an environment dependency such as LVGL could not be cloned, classify it as infrastructure failure first. The preferred fix is to update the environment repo so the dependency is already present in the develop-ready image and its Dockerfile layer can be reused by subsequent train/valid/evidence builds; shrinking the benchmark denominator is only a diagnostic shortcut after the environment contract is fixed. +- MiniMax quota 是 benchmark 调度资源,不是跳过 Pipeline 实跑的理由。MiniMax 当前按约 5 小时窗口刷新;在窗口内不用完的额度会过期浪费,因此有明确实验问题、修复验证或消融对照时,应在额度可用时启动对应 Pipeline,而不是因担心“额度用完”而空等。 +- 成本意识体现在缩小实验切片、减少无效重复、记录 quota/耗时/attempt 和优先跑最小可判定对照;不得把成本意识解释为不跑 baseline、修复后不重跑或不跑关键消融。 +- 如果运行中发现 MiniMax quota 已耗尽,应立即转入离线工作:整理已有 scorer/artifact/OA/gate/monitor 证据,定位基础设施与组件边界问题,准备下一轮最小 run plan。等 quota 刷新后必须继续从该 plan 恢复实跑验证。 +- 如果 MiniMax quota 在运行中的 node 内耗尽,quota sleep/retry 的墙钟时间不得计入该 node 的 active timeout;否则一次 quota 暂停会被错误放大为 pipeline node 超时,阻塞 score 产出。 +- 消融或 benchmark 报告必须区分“quota 暂停”和“实验终止”:因额度耗尽暂停时,结论只能基于已有结果明确标注为临时分析,不能把未跑对照写成已验证结论。 + ## Event Identity And Tags - 每条事件必须有稳定 `eventId`、`type`、`createdAt`、`sourceKind`、`sourceId`、`correlationId` 和可选 `causationId`,用于排序、幂等、回放和追踪控制来源。 diff --git a/docs/reference/provider-gateway.md b/docs/reference/provider-gateway.md index de62fa20..dc91a457 100644 --- a/docs/reference/provider-gateway.md +++ b/docs/reference/provider-gateway.md @@ -8,7 +8,7 @@ Provider Gateway 是计算节点侧容器。它只主动连出到主 server 暴 ## Upgrade Safety Gate -计算节点 `provider-gateway` 容器的重建和升级权威路径是 `provider.upgrade` 的 `mode: "schedule"`,或 frontend 中等价的显式升级调度。该路径由在线 provider 通过本地 Docker socket 启动 detached updater 容器,让升级动作脱离当前 WebSocket 与 SSH 透传会话的生命周期;重建目标只能是 `provider-gateway` service,并且必须带 `--no-deps` 与 `--force-recreate`,不得牵连 database、backend-core、frontend 或业务 microservice,也不得因为镜像 tag 未变而 no-op。 +计算节点 `provider-gateway` 容器的重建和升级权威路径是 `provider.upgrade` 的 `mode: "schedule"`,或 frontend 中等价的显式升级调度。该路径由在线 provider 通过本地 Docker socket 启动 detached updater 容器,让升级动作脱离当前 WebSocket 与 SSH 透传会话的生命周期;重建目标只能是 `provider-gateway` service,并且必须带 `--no-deps` 与 `--force-recreate`,不得牵连 database、backend-core、frontend 或业务用户服务,也不得因为镜像 tag 未变而 no-op。 远程升级必须采用 sleep-and-validate 回滚保护:旧 gateway 在成功调度 updater 后关闭当前 WebSocket 并进入最长 5 分钟的助眠期;updater 先构建新镜像,再用旧容器的环境变量、挂载、网络和 `extra_hosts` 拉起候选 gateway;候选 gateway 必须在日志中出现 `connect_open` 和 register ack 成功,才允许把候选容器 restart policy 改为 `always`、删除旧 gateway、并把候选容器改名为原容器名。候选验证失败时 updater 必须删除候选容器并退出失败,旧 gateway 到达助眠上限后自动重连主 server,形成自动回滚。backend-core 必须在同一 Provider ID 被新 WebSocket 替换后忽略旧 WebSocket 的 close 事件,避免候选已上线后又被旧连接关闭标记为 offline。 @@ -66,7 +66,7 @@ WSL 节点还应补充一次真实调度验证:向该 `PROVIDER_ID` 下发 `do SSH 透传自测是 provider-gateway 部署验收的一部分。目标 Provider 在线后,先确认 frontend 节点清单或 `debug health` 中该节点 labels 显示 `hostSshConfigured=true`、`hostSshKeyPresent=true` 且能力包含 `host.ssh`;再运行 `bun scripts/cli.ts debug dispatch host.ssh --wait-ms 15000`,任务必须 `succeeded`,result 中 `probeLine` 必须包含 `UNIDESK_SSH_TEST` 且 `exitCode=0`;最后运行 `bun scripts/cli.ts ssh hostname`,输出必须是目标宿主或 WSL 的 hostname,进程退出码必须为 0。任何 provider 在线但不声明 `host.ssh` 的状态都只能算未完成部署。 -如果该节点承载 microservice,还必须声明 `microservice.http` capability,并通过 `bun scripts/cli.ts microservice health ` 或 remote CLI 等价命令验证 backend-core 能经 provider-gateway 访问节点本机后端。microservice 后端端口不得映射到公网;provider-gateway 只允许代理节点本地 HTTP 地址或主 server 显式 Compose 服务名,业务 API 路径和 HTTP 方法还要受 backend-core `allowedPathPrefixes` 与 `allowedMethods` 限制。 +如果该节点承载用户服务,还必须声明 `microservice.http` capability,并通过 `bun scripts/cli.ts microservice health ` 或 remote CLI 等价命令验证 backend-core 能经 provider-gateway 访问节点本机后端。用户服务后端端口不得映射到公网;provider-gateway 只允许代理节点本地 HTTP 地址或主 server 显式 Compose 服务名,业务 API 路径和 HTTP 方法还要受 backend-core `allowedPathPrefixes` 与 `allowedMethods` 限制。 自动化验证必须使用 Playwright 访问公网 frontend,而不是在容器内直接调 core API 代替浏览器验收。标准命令是 `bun scripts/cli.ts e2e run`;该命令会让 Playwright 打开公网 `http://74.48.78.17:18081/`、登录、抓取页面中的 Provider 信息和 `查看原始JSON` 内容,并检查 Provider 自接入、资源指标、Docker 状态和 `provider.upgrade` 预检。外部新增节点的人工验收应复用同一套前端路径:先确认 Provider 信息出现在节点清单,再确认资源监控和 Docker 状态页面有该节点的数据,最后通过任务调度向该 Provider 下发 `echo`、`docker.ps` 或维护专用 `host.ssh` probe,并在任务历史中查看耗时、状态、stdout/stderr 摘要和失败原因。 @@ -78,9 +78,9 @@ provider ingress 是唯一允许公网暴露的 provider 连接接口,当前 自动任务执行只允许走本地 Docker socket。Compose 将 `/var/run/docker.sock` 挂入 provider-gateway,provider 标签会报告 `dockerSocketPresent`,`docker.ps` 调试任务会通过该 socket 查询宿主 Docker 容器。 -## Microservice HTTP Proxy +## User Service 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 与 Codex Queue 后端可分别使用 Compose 服务名 `http://todo-note:4211` 与 `http://codex-queue:4222`。该能力不打开 provider-gateway 入站端口,也不替代业务仓库自身 Dockerfile/docker-compose。 +`microservice.http` 是 provider-gateway 给 UniDesk 用户服务使用的私有后端访问能力。backend-core 通过真实 WebSocket dispatch 下发目标 service id、节点本机 `targetBaseUrl`、path、query、method、request body、timeout 和可选 JSON 数组裁剪参数;provider-gateway 支持 `GET`、`HEAD`、`POST`、`PUT`、`PATCH`、`DELETE`,但最终允许方法必须由每个用户服务的 `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 集成层的展示保护。 @@ -126,8 +126,10 @@ backend-core 可以通过真实 WebSocket 调度向在线 provider 下发 `provi WSL 计算节点使用 Docker Desktop daemon 时,provider-gateway 容器通常应连接 `host.docker.internal:22`,目标是当前 WSL 发行版里的 sshd,而不是给节点开放公网 SSH。节点侧必须确认 sshd 监听 `22`、目标用户可用维护公钥免密登录、`authorized_keys` 与挂载到 `/run/host-ssh/id_ed25519` 的私钥匹配;如果容器内直连 `host.docker.internal` 都失败,先修复 WSL sshd、Docker Desktop host gateway 或密钥权限,再排查 UniDesk WebSocket 透传。 +WSL provider 需要调用 Windows-only 工具链时,优先在 WSL 用户的 `~/.local/bin` 放轻量 shim,而不是维护一套完全分叉的 Windows skill。稳定形态是:`win-powershell` 负责 Windows 发现和诊断,`win-cmd` 通过临时 `.cmd` 文件执行命令以规避 UNC 当前目录和带空格路径的 cmd 引号问题,`win-py` 将 `/mnt//...` 参数转换为 Windows 路径后调用 `py.exe`,`win-npm` 通过 `npm.cmd --prefix ` 运行需要访问 COM 口的 TypeScript skill。Keil 这类依赖 Windows GUI/驱动/注册表的 skill 应由 WSL wrapper 调用 Windows 侧 skill 脚本;board-comm 这类纯 TCP JSON-RPC skill 可以直接用 WSL Python 运行;serial-monitor 需要访问 Windows COM 口时应走 Windows Node/npm。不要用宽泛的 `find /mnt/*` 扫 Windows 盘,skill 位置先用 `bun scripts/cli.ts ssh skills`,项目位置优先用 PowerShell 或结构化 `glob`/`find` 缩小范围。Windows 透传 wrapper 属于节点 bootstrap,不等于修改 WSL skill 业务代码;完整规则见 `docs/reference/windows-passthrough.md`。 + 维护桥通过真实 WebSocket dispatch 暴露为 `host.ssh` 命令。默认 payload 使用 `mode: "probe"`,远端只执行一个短命令并返回 `UNIDESK_SSH_TEST user=... host=... bridge=host.ssh cwd=...`;需要人工诊断时可以显式使用 `mode: "exec"` 与 `command` 字段执行有界命令。所有 `host.ssh` 执行都必须有超时,stdout/stderr 在 task result 中截断展示;自动升级和普通任务仍必须使用 Docker socket 与 `provider.upgrade`,不得把 WSL SSH 维护桥当成调度通道。 -面向人的终端入口是 `bun scripts/cli.ts ssh [ssh-like args...]`。无后续参数时打开远端登录 shell,有后续参数时执行远端命令并返回远端 exit code;该入口走 backend-core 内网 `/ws/ssh` broker 和 provider 既有 WebSocket,不新增公网 core 端口。传统 ssh 传输参数由 provider-gateway 环境变量统一控制,CLI 只负责把 Provider ID 后的远端命令和终端 stdin/stdout/stderr 透传过去。 +面向人的终端入口是 `bun scripts/cli.ts ssh [ssh-like args...]`。无后续参数时打开远端登录 shell,有后续参数时执行远端命令并返回远端 exit code;该入口走 backend-core 内网 `/ws/ssh` broker 和 provider 既有 WebSocket,不新增公网 core 端口。传统 ssh 传输参数由 provider-gateway 环境变量统一控制,CLI 只负责把 Provider ID 后的远端命令和终端 stdin/stdout/stderr 透传过去。WSL 节点需要同时看清 Linux/WSL 与 Windows 两套 skill 时,使用 `bun scripts/cli.ts ssh skills`,该命令只通过已建立的维护桥读取 `SKILL.md` 元数据,不要求 provider-gateway 新增业务 API。 验证 WSL SSH 桥时,先在目标 WSL 中启动 sshd 并确保维护公钥写入目标用户的 `authorized_keys`,再确认目标 provider 注册 labels 中 `unideskCapabilities` 包含 `host.ssh`。运行 `bun scripts/cli.ts debug dispatch host.ssh --wait-ms 15000` 后,结果应在 `debug task latest` 或前端任务历史中显示 `status: succeeded`、`probeLine` 含 `UNIDESK_SSH_TEST`、`exitCode: 0`,并且目标节点 labels 中 `hostSshKeyPresent` 为 true;随后运行 `bun scripts/cli.ts ssh hostname` 验证近似原生 ssh 的远端命令体验。在计算节点本机自测时,使用 remote CLI 透传同一组命令:`bun scripts/cli.ts --main-server-ip 74.48.78.17 debug health`、`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 走公网 frontend 登录态,不需要主 server SSH key。健康检查必须能看到该 Provider 在线、`hostSshConfigured=true`、`hostSshKeyPresent=true`、`hostSshTarget` 正确、`unideskCapabilities` 包含 `host.ssh`,probe 必须返回 `UNIDESK_SSH_TEST`,`ssh hostname` 必须输出目标 WSL/宿主 hostname 且 exit code 为 0。如果 D518 这类 WSL 节点没有公网 SSH 入口,也必须通过这个 provider-gateway 自连维护桥完成验证,而不是要求主 server 直接连节点公网 22 端口;旧版 provider 未声明 `host.ssh` 时必须先升级 provider-gateway,否则 core 会拒绝 SSH 透传。 diff --git a/docs/reference/repo-tree.md b/docs/reference/repo-tree.md index 33a2d81d..f1c366b0 100644 --- a/docs/reference/repo-tree.md +++ b/docs/reference/repo-tree.md @@ -2,7 +2,7 @@ - AGENTS.md (Top-level agent index and `scripts/cli.ts` usage guide) - TEST.md (Manual CLI test plan following cli-spec expectations) - config.json (Single source of truth for ports, tokens, runtime, paths, and provider identity) - - docker-compose.yml (Main server orchestration for database, backend-core, frontend, provider-gateway, and managed main-server microservices such as Todo Note) + - docker-compose.yml (Main server orchestration for database, backend-core, frontend, provider-gateway, and managed main-server user services such as Todo Note) - package.json / bun.lock (Root Bun tooling for CLI checks) - .gitignore - reference -> docs/reference (Compatibility symlink for older references) @@ -53,9 +53,9 @@ - Dockerfile - src/index.ts (Bun static server, login/session handling, and same-origin internal API proxy) - src/app.tsx (TypeScript + React browser app shell, login, global data loading, and route dispatcher; `/app.js` is generated by Bun at runtime) - - src/todo-note.tsx (Todo Note microservice React page; do not fold back into `app.tsx`) - - src/findjob.tsx (FindJob microservice React page; do not fold back into `app.tsx`) - - src/pipeline.tsx (Pipeline microservice React page and React Flow control graph; do not fold back into `app.tsx`) + - src/todo-note.tsx (Todo Note user-service React page; do not fold back into `app.tsx`) + - src/findjob.tsx (FindJob user-service React page; do not fold back into `app.tsx`) + - src/pipeline.tsx (Pipeline user-service React page and React Flow control graph; do not fold back into `app.tsx`) - src/met-nonlinear.tsx (MET Nonlinear D601 training orchestration React page; do not fold back into `app.tsx`) - public/ (HTML/CSS static assets for the compact industrial console; no handwritten app JS) - provider-gateway/ (Compute node Provider Gateway container) @@ -67,5 +67,5 @@ - database/ (PostgreSQL initialization and configuration) - config/postgresql.conf - init/001_unidesk_init.sql - - microservices/ (Reserved for future stateless microservices) + - microservices/ (Compatibility path reserved for future stateless user services) - example-service/ diff --git a/docs/reference/windows-passthrough.md b/docs/reference/windows-passthrough.md new file mode 100644 index 00000000..65196a46 --- /dev/null +++ b/docs/reference/windows-passthrough.md @@ -0,0 +1,177 @@ +# Windows Passthrough Reference + +Windows 透传用于让 WSL provider 通过 UniDesk 的 Host SSH / WSL SSH 维护桥调用 Windows 侧工具链、驱动和 skill,同时尽量保持一套可维护的 skill 入口。它的目标不是把 Windows 当成新的 provider,而是让 `ssh ` 进入 WSL 后,可以稳定触达 Windows `cmd.exe`、PowerShell、Python、Node/npm、Keil、COM 串口和 USB 调试器。 + +## Architecture + +标准链路是:本机或计算节点 CLI 调用 `bun scripts/cli.ts ssh ...`,main server 的 backend-core 通过 provider-gateway 的既有 WebSocket 把终端流量转给目标 provider,provider-gateway 再用只读挂载的维护私钥连到目标 WSL sshd。进入 WSL 后,Windows-only 工具由 WSL 用户目录下的轻量 wrapper 调用 Windows 可执行文件。 + +这条链路分三层维护: + +- `provider-gateway` 只负责 Host SSH / WSL SSH 维护桥,不直接理解 Keil、串口或业务 skill。 +- WSL wrapper 负责路径转换、当前目录转换、Windows 进程启动、UTF-8 输出和 cmd/PowerShell 差异。 +- skill wrapper 负责把用户仍然熟悉的 `keil`、`serial-monitor`、`board-comm` 命令映射到正确运行侧,避免维护两套互相分叉的 skill。 + +## Skill Discovery + +先用 UniDesk SSH 透传内置的 skill 发现入口确认目标节点上 WSL 与 Windows 两侧 skill 的实际位置: + +```bash +bun scripts/cli.ts ssh skills --limit 80 +bun scripts/cli.ts ssh skills --scope windows --limit 40 +bun scripts/cli.ts --main-server-ip ssh skills --scope wsl --limit 20 +``` + +`ssh skills` 输出 JSON,包含 `roots` 和 `skills`。WSL provider 会同时扫描 WSL/Linux 的 `~/.agents/skills`、`~/.codex/skills`,以及 Windows 用户目录下的 `/mnt/c/Users/*/.agents/skills`、`/mnt/c/Users/*/.codex/skills`。不要用宽泛的 `find /mnt/*` 搜 Windows 盘;Windows 挂载层可能因权限、索引或设备状态导致长时间阻塞。 + +## Wrapper Contract + +WSL 用户的 `~/.local/bin` 是推荐 wrapper 放置位置,并应在 SSH 登录 shell 的 `PATH` 中优先于系统路径。wrapper 名称和职责固定如下: + +- `win-powershell `:用于 Windows 发现、WMI/CIM、注册表、设备和 JSON 输出;应设置 UTF-8 输出,并用 `Set-Location -LiteralPath` 进入 Windows 当前目录。 +- `win-cmd `:用于 `.cmd`、`.bat` 或必须走 `cmd.exe` 的 legacy 工具;应先写入 Windows 临时 `.cmd` 文件再执行,避免 UNC 当前目录警告和带空格路径引号错误。 +- `win-argpath `:把 `/mnt//...` 或存在的 WSL 路径转换为 Windows 路径,其余参数原样返回。 +- `win-py `:调用 Windows `py.exe -3`,并对路径参数做 `/mnt/` 到 `X:\...` 的转换。 +- `win-node `:调用 Windows `node.exe`,适合必须运行在 Windows 设备上下文中的 Node 脚本。 +- `win-npm `:调用 Windows `npm.cmd`;涉及 COM 口、USB 设备或 Windows-only npm 依赖时优先使用它。 +- `win-skill-path `:在 Windows `.agents/skills` 与 `.codex/skills` 中定位 skill 根目录,供上层 skill wrapper 复用。 + +`win-cmd` 不应直接从 WSL 默认 UNC cwd 运行 `cmd.exe /C `。稳定做法是选择一个 Windows 可写临时目录,例如 `C:\Temp`,生成 `.cmd` 文件,在脚本中 `chcp 65001`、`cd /d ""`,再执行用户命令。这样可同时处理 `C:\Program Files` 这类带空格路径和 Windows 工具不支持 UNC 当前目录的问题。 + +## Path And CWD Rules + +Windows 可执行文件只能稳定访问 Windows 盘符路径。WSL wrapper 应遵循这些规则: + +- 当前目录在 `/mnt//...` 下时,自动转换为 `:\...` 作为 Windows cwd。 +- 当前目录不在 `/mnt/` 下时,默认使用 `C:\Windows`,或由调用者显式设置 `WIN_CWD`。 +- 参数中存在的 `/mnt//...` 路径应转换为 Windows 路径;普通参数、IP、端口、JSON 字符串不应被转换。 +- 需要 Windows 工具直接读写的工程仓库应放在 Windows 盘符挂载路径下;WSL 原生 `/home/...` 更适合作为 UniDesk、脚本和临时 Linux 工作区。 +- 从 WSL 到 Windows 的搜索优先使用 `win-powershell` 或 `ssh skills`,再用结构化 `glob`/`find` 缩小到已知目录。 + +## Skill Compatibility Strategy + +不要为 Windows 和 WSL 分别维护两套业务逻辑分叉的 skill。推荐策略是保留一个用户可见命令入口,在 wrapper 中选择正确运行侧: + +- `keil`:依赖 Windows Keil、CMSIS-DAP/ST-Link/J-Link 驱动、注册表和工程路径,WSL 入口应调用 Windows 侧 `keil` skill 的 `keil-cli.py`,通常经 `win-py` 执行。 +- `serial-monitor`:访问 Windows COM 口时必须运行在 Windows Node/npm 上,WSL 入口应通过 `win-npm --prefix run cli -- ...` 调用。 +- `board-comm`:当前 JSON-RPC over TCP 不依赖 Windows 驱动,可直接用 WSL Python 运行 WSL 侧 skill;只有未来新增 serial transport 时才需要再评估是否切到 Windows 侧。 + +工程级 wrapper 可以保留 `*-wsl` 别名,例如 `keil-wsl`、`serial-monitor-wsl`、`board-comm-wsl`,但用户文档中的正式入口仍应优先写 `keil`、`serial-monitor` 和 `board-comm`,避免让任务 prompt 绑定到某台机器的一次性脚本名。 + +## Skill Modification And Bootstrap Status + +Windows 透传机制不要求修改 WSL skill 的业务代码。标准交付边界应区分三类内容: + +- UniDesk 内置能力:`ssh skills`、`apply-patch`、`glob`、`py` 等 helper 由 UniDesk CLI 在 SSH 会话启动时注入 `/tmp/unidesk-ssh-tools`,属于开箱即用能力;它们不修改远端 skill 目录。 +- 节点 bootstrap:`win-cmd`、`win-powershell`、`win-py`、`win-npm`、`win-skill-path` 以及 `keil`、`serial-monitor`、`board-comm` 的 WSL wrapper 属于节点运行环境 bootstrap,通常放在目标 WSL 用户的 `~/.local/bin`;这不是 skill 本身开箱即用的一部分。 +- skill 代码:除非明确要把 wrapper 能力合入某个 skill,否则不得为了某台节点直接改 `~/.agents/skills/` 或 `~/.codex/skills/` 的业务代码;如果确实修改了 skill CLI 或 `SKILL.md`,必须按该 skill 的测试和文档规则独立验收。 + +因此,判断一个节点是否“开箱即用”时要分别检查:UniDesk CLI 是否支持 `ssh skills`,目标 WSL 是否已有 wrapper bootstrap,Windows 侧是否已有 Keil/Node/npm/Python/驱动,目标 skill 是否已有依赖。缺少 wrapper 或 Windows 工具链时,应报告为节点环境未 bootstrap 完成,而不是把它误判成 skill 逻辑失败。 + +当前推荐不在 wrapper 中自动安装依赖。`serial-monitor` 所需 Windows `node_modules`、Keil pack、pyOCD、USB probe 驱动、COM 驱动等都应由节点 bootstrap 或人工安装完成;wrapper 只负责调用和给出明确错误。这样可以避免 agent 在硬件节点上静默安装版本不明的依赖,导致后续验收不可复现。 + +## Dependency Bootstrap + +Windows 透传依赖分为四类:UniDesk 侧能力、WSL 侧基础环境、Windows 侧工具链、skill 本地依赖。它们应由节点 bootstrap 或人工维护显式安装,不应由业务任务中的 wrapper 静默安装。 + +### UniDesk And WSL Prerequisites + +WSL provider 至少需要: + +- WSL sshd 已启动,provider-gateway 容器可用维护私钥免密登录目标 WSL 用户。 +- 目标 WSL 用户 `PATH` 包含 `~/.local/bin`,用于放置 `win-*` 与 skill wrapper。 +- WSL 内有 `python3`、`base64`、`bash`、`wslpath`;UniDesk SSH 注入的 `py`、`apply-patch`、`glob` 和 `skill-discover` 依赖这些基础命令。 +- WSL 可访问 Windows 盘符挂载,例如 `/mnt/c`、`/mnt/d`、`/mnt/f`。 +- 如果 provider-gateway 跑在 Docker Desktop daemon 上,容器到 WSL sshd 的 `host.docker.internal:22` 链路必须可用。 + +WSL wrapper bootstrap 至少应安装或生成这些脚本: + +```bash +mkdir -p ~/.local/bin +# win-cmd, win-powershell, win-argpath, win-py, win-node, win-npm, win-skill-path +# keil, serial-monitor, board-comm wrappers +chmod +x ~/.local/bin/win-* ~/.local/bin/keil ~/.local/bin/serial-monitor ~/.local/bin/board-comm +``` + +这些 wrapper 属于节点环境,不应提交到 UniDesk 仓库,除非后续专门新增一个受版本管理的 provider bootstrap 脚本。 + +### Windows Toolchain Prerequisites + +Windows 侧至少需要按目标硬件场景安装: + +- Python Launcher:`py.exe` 可用,且 `py -3` 指向 Python 3。 +- Node.js/npm:`node.exe` 和 `npm.cmd` 可用;涉及 COM 口的 TypeScript skill 应运行在 Windows Node 上。 +- Keil MDK:`UV4.exe` 可用,Keil Pack 已安装到工程需要的 MCU 系列。 +- pyOCD:Windows Python 环境中可用 `python -m pyocd`,用于 CMSIS-DAP 探头检测和 pyOCD backend 下载。 +- USB 调试器驱动:CMSIS-DAP、DAPLink、ST-Link 或 J-Link 对应驱动可被 Windows 识别。 +- 串口驱动:目标板 UART bridge 在 Windows 设备管理器中显示为 `COMx`。 +- PowerShell:用于 WMI/CIM 设备发现、路径诊断和 Windows 侧环境检查。 + +推荐的只读检查命令: + +```bash +win-powershell "Get-Command py.exe,node.exe,npm.cmd -ErrorAction SilentlyContinue | Select-Object Name,Source | ConvertTo-Json" +win-powershell "Get-CimInstance Win32_SerialPort | Select-Object DeviceID,Name,Description,PNPDeviceID | ConvertTo-Json -Depth 3" +win-cmd "where py && where node && where npm" +``` + +Keil 与 pyOCD 检查应通过 skill 入口完成: + +```bash +keil status +keil list-devices -p +``` + +### Skill Dependencies + +各 skill 的依赖安装边界如下: + +- `keil`:Windows 侧 `~/.agents/skills/keil` 应已存在 `keil-cli.py`、`config.json` 与所需 Python 依赖;Windows Python 中必须能导入 pyOCD。Keil pack 和 probe 驱动属于 Windows 工具链依赖,不属于 WSL wrapper 自动安装范围。 +- `serial-monitor`:Windows 侧 `~/.agents/skills/serial-monitor` 需要已执行过 `npm install`,生成对应 Windows 平台的 `node_modules`。从 WSL 调用时应使用 `win-npm --prefix run cli -- ...`,不要复用 WSL 的 `node_modules` 去访问 Windows COM 口。 +- `board-comm`:JSON-RPC over TCP 可直接使用 WSL 侧 `~/.agents/skills/board-comm` 与 WSL Python;如果 `check` 因缺少 pyright 失败,但 `jrpctcp` 正常,不应阻塞硬件通信验证。若未来增加 serial transport,再按运行侧补充 Windows 依赖。 + +依赖安装必须显式、可复现,并优先在对应 skill 的 `SKILL.md` 或节点 bootstrap 文档中记录。业务任务中如果发现依赖缺失,应输出缺失项、建议安装命令和验证命令,而不是直接静默安装。 + +### Verification Matrix + +节点完成依赖 bootstrap 后,至少运行以下验证: + +```bash +bun scripts/cli.ts ssh skills --limit 20 +bun scripts/cli.ts ssh -- 'export PATH="$HOME/.local/bin:$PATH"; command -v win-cmd win-powershell win-py win-npm keil serial-monitor board-comm' +bun scripts/cli.ts ssh -- 'export PATH="$HOME/.local/bin:$PATH"; win-py -V; win-npm -v' +bun scripts/cli.ts ssh -- 'export PATH="$HOME/.local/bin:$PATH"; keil status' +bun scripts/cli.ts ssh -- 'export PATH="$HOME/.local/bin:$PATH"; serial-monitor ports' +bun scripts/cli.ts ssh -- 'export PATH="$HOME/.local/bin:$PATH"; board-comm debug build-jrpctcp-request get api' +``` + +硬件项目级验收还应覆盖真实 `build`、`program`、串口抓取和 `board-comm jrpctcp get api`。如果只有依赖检查通过但没有真实硬件闭环,不能宣称该工程已完成下载和通信验收。 + +## Hardware Workflow + +Keil/串口/board-comm 的通用顺序如下: + +1. `ssh skills` 确认 WSL 与 Windows skill 位置。 +2. `win-powershell` 或 `serial-monitor ports` 确认可见 COM 口。 +3. `keil status` 和 `keil list-devices -p ` 确认 Keil、pyOCD 和 USB probe。 +4. `keil build --wait -p -t ` 构建。 +5. `serial-monitor server start --force` 和 `serial-monitor monitor start -p -b ` 先打开串口。 +6. `keil program --wait ... -u ` 下载并运行。 +7. `serial-monitor fetch --session-only --no-dedup -l ` 抓取串口证据。 +8. `board-comm jrpctcp --host --port get api` 验证主动通信面。 + +多 probe 同时在线时,`keil program`、`keil detect` 必须显式传 `-u `。若 Keil UV4 backend 报缺少 flash/download metadata,可优先用 pyOCD backend 完成下载;是否补齐 UV4 工程下载配置应作为工程维护问题处理,而不是 SSH/Windows 透传问题。 + +## Remote Frontend Limits + +`--main-server-ip ... ssh ` 走 frontend 登录态和 `host.ssh` dispatch,适合短命令和 skill discovery。它不流式转发 stdin,也受 provider-gateway 的 host.ssh command length 限制;`apply-patch`、`py < stdin`、超长 inline 脚本和需要完整终端流的操作应在 main server 本机 CLI 上执行,或显式走旧 SSH transport。 + +`ssh skills` 在 remote frontend 模式下使用短 inline 发现脚本以避开命令长度限制;如果输出被任务历史截断,应改在 main server 本机 CLI 执行同一命令以获得完整 stdout。 + +## Safety Rules + +- Windows 透传只用于节点诊断、硬件工具链和人工维护,不得作为 provider-gateway 自重建通道;provider-gateway 升级仍必须走 `provider.upgrade mode=schedule`。 +- 不要把 Windows 透传 wrapper 写入业务仓库根目录;它们属于节点运行环境,应放在 WSL 用户目录或节点私有 bootstrap 脚本中。 +- 不要在 wrapper 中静默安装依赖;缺少 `py.exe`、Node/npm、Keil、COM 驱动或 probe 驱动时,应输出明确错误并让节点 bootstrap 修复。 +- 不要把 Windows 用户目录、串口日志、Keil 日志、SSH key 或 provider token 提交到 UniDesk 仓库。 +- 串口抓取和烧录会影响真实硬件状态;执行前应确认目标 project、target、probe UID、COM 口和 baud rate。 diff --git a/scripts/cli.ts b/scripts/cli.ts index f8b3ad20..f22af123 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -8,6 +8,7 @@ import { runChecks } from "./src/check"; import { runSsh } from "./src/ssh"; import { extractRemoteCliOptions, runRemoteCli } from "./src/remote"; import { runMicroserviceCommand } from "./src/microservices"; +import { runCodexQueueCommand } from "./src/codex-queue"; const remoteOptions = extractRemoteCliOptions(process.argv.slice(2)); const args = remoteOptions.args; @@ -22,21 +23,25 @@ function help(): unknown { { command: "--main-server-ip ", description: "Run selected commands through the public frontend API; use --main-server-key only for legacy SSH transport." }, { command: "config show", description: "Validate and print config.json as the single source of truth." }, { command: "check", description: "Run config, TypeScript, file presence, and docker-compose config checks." }, - { command: "server start", description: "Fire-and-forget build/start for database, backend-core, frontend, provider gateway, and managed main-server microservices." }, + { command: "server start", description: "Fire-and-forget build/start for database, backend-core, frontend, provider gateway, and managed main-server user services." }, { 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 serialize, force-recreate, and validate one Compose service." }, { 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." }, + { command: "ssh skills [--scope all|wsl|windows] [--limit N]", description: "Discover WSL/Linux and, for WSL providers, Windows skill directories in one SSH passthrough call." }, { command: "ssh find [--max-depth N] [--type d|f|l] [--contains TEXT] [--iname PATTERN] [--limit N] [--sort]", description: "Run a structured remote find command without nested shell quoting or parentheses." }, { command: "ssh glob [--root DIR] [--pattern PATTERN] [--contains TEXT] [--type any|f|d] [--limit N] [--sort]", description: "Run remote glob matching through the injected helper without shell glob expansion." }, { command: "ssh argv [args...]", description: "Run a remote command with each argv token shell-quoted by UniDesk before SSH passthrough." }, - { command: "microservice list", description: "List UniDesk-managed microservices and their provider/runtime mapping." }, - { command: "microservice status ", description: "Show one microservice config, repository reference, backend mapping, and runtime status." }, - { command: "microservice health ", description: "Probe one microservice through backend-core -> provider-gateway HTTP proxy." }, - { command: "microservice proxy [--raw] [--max-body-bytes N]", description: "GET a private microservice backend path through the same frontend-only proxy used by WebUI; large bodies are summarized unless --raw is set." }, + { command: "microservice list", description: "List UniDesk-managed user services and their provider/runtime mapping." }, + { command: "microservice status ", description: "Show one user service config, repository reference, backend mapping, and runtime status." }, + { command: "microservice health ", description: "Probe one user service through backend-core -> provider-gateway HTTP proxy." }, + { command: "microservice proxy [--raw] [--max-body-bytes N]", description: "GET a private user-service backend path through the same frontend-only proxy used by WebUI; large bodies are summarized unless --raw is set." }, + { command: "codex task [--trace --tail|--from-start|--after-seq N|--before-seq N --limit N] [--full]", description: "Fetch a compact Codex Queue task summary; trace rows are opt-in and paged with next/previous commands to avoid output explosion." }, + { command: "codex output [--tail|--from-start|--after-seq N|--before-seq N --limit N] [--full-text]", description: "Fetch paged raw Codex Queue output records by seq when a trace row has omitted command/output text." }, + { command: "codex queues | codex queue create | codex move --queue ", description: "List/create Codex Queue lanes and move a queued task so each queue runs serially while queues run in parallel." }, { command: "job list", description: "List async jobs from .state/jobs." }, { command: "job status [--tail-bytes N]", description: "Show job state with bounded stdout/stderr tails." }, { command: "debug health", description: "Probe internal core, nodes, system/Docker status, frontend, provider ingress, and public boundary." }, @@ -154,7 +159,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, codex-queue"); + throw new Error("server rebuild requires one of: backend-core, frontend, provider-gateway, todo-note, codex-queue, project-manager"); } emitJson(commandName, rebuildService(config, third)); return; @@ -166,6 +171,11 @@ async function main(): Promise { return; } + if (top === "codex") { + emitJson(commandName, await runCodexQueueCommand(config, args.slice(1))); + return; + } + if (top === "job") { if (sub === "list") { emitJson(commandName, { jobs: listJobs() }); diff --git a/scripts/src/codex-queue-perf.ts b/scripts/src/codex-queue-perf.ts new file mode 100644 index 00000000..0c98c199 --- /dev/null +++ b/scripts/src/codex-queue-perf.ts @@ -0,0 +1,221 @@ +import { chromium, type BrowserContext, type Request } from "playwright"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { readConfig } from "./config"; + +interface CodexQueuePerfOptions { + url: string; + username: string; + password: string; + timeoutMs: number; + targetMs: number; + headless: boolean; + json: boolean; +} + +interface ApiTiming { + url: string; + method: string; + status: number; + durationMs: number; +} + +function argValue(args: string[], name: string): string | null { + const index = args.indexOf(name); + if (index === -1) return null; + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new Error(`${name} requires a value`); + return value; +} + +function numberArg(args: string[], name: string, fallback: number): number { + const raw = argValue(args, name); + if (raw === null) return fallback; + const value = Number(raw); + if (!Number.isFinite(value) || value <= 0) throw new Error(`${name} must be a positive number`); + return Math.floor(value); +} + +function normalizeBaseUrl(value: string): string { + return value.replace(/\/+$/u, ""); +} + +function readOptions(): CodexQueuePerfOptions { + const config = readConfig(); + const args = process.argv.slice(2); + return { + url: normalizeBaseUrl(argValue(args, "--url") ?? `http://${config.network.publicHost}:${config.network.frontend.port}`), + username: argValue(args, "--username") ?? config.auth.username, + password: argValue(args, "--password") ?? config.auth.password, + timeoutMs: numberArg(args, "--timeout-ms", 90_000), + targetMs: numberArg(args, "--target-ms", 1_000), + headless: !args.includes("--headed"), + json: args.includes("--json"), + }; +} + +async function authenticateSession(context: BrowserContext, options: CodexQueuePerfOptions): Promise { + const response = await fetch(`${options.url}/login`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ username: options.username, password: options.password }), + signal: AbortSignal.timeout(options.timeoutMs), + }); + if (!response.ok) { + throw new Error(`login failed: HTTP ${response.status} ${await response.text()}`); + } + const setCookie = response.headers.get("set-cookie"); + const cookiePair = setCookie?.split(";", 1)[0] ?? ""; + const separator = cookiePair.indexOf("="); + if (separator <= 0) throw new Error("login response did not set a session cookie"); + await context.addCookies([{ + name: cookiePair.slice(0, separator), + value: cookiePair.slice(separator + 1), + url: options.url, + }]); +} + +function requestPath(request: Request): string { + try { + const url = new URL(request.url()); + return `${url.pathname}${url.search}`; + } catch { + return request.url(); + } +} + +function parseNumberAttr(value: string | null): number | null { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +async function runCodexQueuePerf(options: CodexQueuePerfOptions): Promise> { + const browsersPath = process.env.PLAYWRIGHT_BROWSERS_PATH || ".state/playwright-browsers"; + const fullChromePath = resolve(browsersPath, "chromium-1217/chrome-linux64/chrome"); + const launchArgs = [ + "--no-sandbox", + "--disable-setuid-sandbox", + "--disable-dev-shm-usage", + ]; + const launchOptions: Parameters[0] = { headless: options.headless, args: launchArgs }; + if (existsSync(fullChromePath)) { + launchOptions.executablePath = fullChromePath; + } + const browser = await chromium.launch(launchOptions); + const apiStartedAt = new Map(); + const apiTimings: ApiTiming[] = []; + const consoleErrors: string[] = []; + const targetUrl = `${options.url}/app/codex-queue/`; + const measuredAt = new Date().toISOString(); + + try { + const context = await browser.newContext(); + await authenticateSession(context, options); + const page = await context.newPage(); + await page.route("**/favicon.ico", (route) => route.abort()); + page.on("console", (message) => { + if (message.type() === "error") consoleErrors.push(message.text().slice(0, 500)); + }); + page.on("request", (request) => { + const path = requestPath(request); + if (path.startsWith("/api/") || path === "/app.js") apiStartedAt.set(request, performance.now()); + }); + page.on("response", (response) => { + const request = response.request(); + const started = apiStartedAt.get(request); + if (started === undefined) return; + apiStartedAt.delete(request); + apiTimings.push({ + url: requestPath(request), + method: request.method(), + status: response.status(), + durationMs: Math.round((performance.now() - started) * 10) / 10, + }); + }); + + const startedAt = performance.now(); + await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: options.timeoutMs }); + const domContentLoadedMs = Math.round((performance.now() - startedAt) * 10) / 10; + await page.waitForSelector('[data-testid="codex-queue-page"][data-load-state="complete"]', { timeout: options.timeoutMs }); + const completeAt = performance.now(); + const dom = await page.evaluate(() => { + const pageElement = document.querySelector('[data-testid="codex-queue-page"]') as HTMLElement | null; + const output = document.querySelector('[data-testid="codex-output"]') as HTMLElement | null; + const tasks = document.querySelectorAll('[data-testid^="codex-task-codex_"]'); + return { + loadState: pageElement?.dataset.loadState ?? "", + componentLoadMs: pageElement?.dataset.loadTotalMs ?? "", + queueMs: pageElement?.dataset.loadQueueMs ?? "", + detailMs: pageElement?.dataset.loadDetailMs ?? "", + transcriptRows: pageElement?.dataset.loadTranscriptRows ?? "", + taskId: pageElement?.dataset.loadTaskId ?? "", + partial: pageElement?.dataset.loadPartial === "true", + visibleTaskCount: tasks.length, + outputChars: output?.textContent?.length ?? 0, + }; + }); + let networkIdleReached = true; + try { + await page.waitForLoadState("networkidle", { timeout: 5_000 }); + } catch { + networkIdleReached = false; + } + const networkIdleMs = Math.round((performance.now() - startedAt) * 10) / 10; + const playwrightObservedMs = Math.round((completeAt - startedAt) * 10) / 10; + const browserLoadMs = parseNumberAttr(dom.componentLoadMs); + const totalLoadMs = browserLoadMs ?? playwrightObservedMs; + + const slowestApi = apiTimings.slice().sort((left, right) => right.durationMs - left.durationMs).slice(0, 8); + const result = { + ok: true, + measuredAt, + url: targetUrl, + targetMs: options.targetMs, + withinTarget: totalLoadMs <= options.targetMs, + status: totalLoadMs <= options.targetMs ? "passed" : "slow", + wallMs: totalLoadMs, + totalLoadMs, + domContentLoadedMs, + appCompleteMs: playwrightObservedMs, + playwrightObservedMs, + networkIdleMs, + networkIdleReached, + componentLoadMs: browserLoadMs, + queueMs: parseNumberAttr(dom.queueMs), + detailMs: parseNumberAttr(dom.detailMs), + transcriptRows: parseNumberAttr(dom.transcriptRows), + partial: dom.partial, + selectedTaskId: dom.taskId || null, + visibleTaskCount: dom.visibleTaskCount, + outputChars: dom.outputChars, + apiRequestCount: apiTimings.length, + slowestApi, + consoleErrors, + }; + await context.close(); + return result; + } catch (error) { + return { + ok: false, + measuredAt, + url: targetUrl, + targetMs: options.targetMs, + status: "failed", + error: error instanceof Error ? error.message : String(error), + apiRequestCount: apiTimings.length, + slowestApi: apiTimings.slice().sort((left, right) => right.durationMs - left.durationMs).slice(0, 8), + consoleErrors, + }; + } finally { + await browser.close(); + } +} + +const options = readOptions(); +const result = await runCodexQueuePerf(options); +if (options.json) { + console.log(JSON.stringify(result)); +} else { + console.log(JSON.stringify(result, null, 2)); +} +if (result.ok !== true) process.exitCode = 1; diff --git a/scripts/src/codex-queue.ts b/scripts/src/codex-queue.ts new file mode 100644 index 00000000..189b60ed --- /dev/null +++ b/scripts/src/codex-queue.ts @@ -0,0 +1,449 @@ +import { type UniDeskConfig } from "./config"; +import { coreInternalFetch } from "./microservices"; + +const defaultToolLimit = 8; +const defaultTraceLimit = 80; +const maxTraceLimit = 500; +const defaultOutputLimit = 20; +const defaultTextPreviewChars = 12_000; + +interface CodexTaskOptions { + trace: boolean; + traceLimit: number; + traceMode: "tail" | "after" | "before"; + afterSeq: number; + beforeSeq: number | null; + toolLimit: number; + full: boolean; + rawSummary: boolean; +} + +interface CodexOutputOptions { + limit: number; + mode: "tail" | "after" | "before"; + afterSeq: number; + beforeSeq: number | null; + fullText: boolean; + maxTextChars: number; +} + +type CodexResponseFetcher = (path: string) => unknown; +type AsyncCodexResponseFetcher = (path: string) => Promise; + +function requireTaskId(value: string | undefined, command: string): string { + if (value === undefined || value.trim().length === 0) throw new Error(`${command} requires task id`); + return value.trim(); +} + +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; +} + +function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function asNumber(value: unknown, fallback = 0): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function upstreamError(response: unknown): string { + const record = asRecord(response); + if (record === null) return String(response); + const body = asRecord(record.body); + const bodyError = body?.error; + if (typeof bodyError === "string") return bodyError; + const status = typeof record.status === "number" ? `HTTP ${record.status}` : "upstream request failed"; + return `${status}: ${JSON.stringify(response).slice(0, 1200)}`; +} + +function unwrapCodexResponse(response: unknown): { upstream: { ok: unknown; status: unknown }; body: Record } { + const record = asRecord(response); + if (record?.ok !== true) throw new Error(upstreamError(response)); + const body = asRecord(record.body); + if (body?.ok !== true) throw new Error(upstreamError(response)); + return { upstream: { ok: record.ok, status: record.status }, body }; +} + +function positiveIntegerOption(args: string[], names: string[], defaultValue: number, maxValue = Number.MAX_SAFE_INTEGER): number { + for (const name of names) { + const index = args.indexOf(name); + if (index === -1) continue; + const raw = args[index + 1]; + const value = Number(raw); + if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`); + return Math.min(value, maxValue); + } + return defaultValue; +} + +function nonNegativeNumberOption(args: string[], names: string[], defaultValue: number): number { + for (const name of names) { + const index = args.indexOf(name); + if (index === -1) continue; + const raw = args[index + 1]; + const value = Number(raw); + if (!Number.isFinite(value) || value < 0) throw new Error(`${name} must be a non-negative number`); + return value; + } + return defaultValue; +} + +function nullablePositiveNumberOption(args: string[], names: string[]): number | null { + for (const name of names) { + const index = args.indexOf(name); + if (index === -1) continue; + const raw = args[index + 1]; + const value = Number(raw); + if (!Number.isFinite(value) || value <= 0) throw new Error(`${name} must be a positive number`); + return value; + } + return null; +} + +function hasFlag(args: string[], name: string): boolean { + return args.includes(name); +} + +function textView(text: string, full: boolean, maxChars: number): Record { + const truncated = !full && text.length > maxChars; + return { + text: truncated ? text.slice(0, maxChars) : text, + chars: text.length, + truncated, + omittedChars: truncated ? text.length - maxChars : 0, + }; +} + +function compactText(text: unknown, full: boolean, maxChars: number): Record { + return textView(asString(text), full, maxChars); +} + +function compactLastAssistant(value: unknown, full: boolean): Record { + const record = asRecord(value) ?? {}; + return { + at: record.at ?? null, + seq: record.seq ?? null, + source: record.source ?? "none", + ...textView(asString(record.text), full, 4000), + }; +} + +function compactToolSummary(value: unknown, full: boolean): Record { + const record = asRecord(value) ?? {}; + const items = asArray(record.items).map((item) => { + const line = asRecord(item) ?? {}; + return { + seq: line.seq ?? null, + at: line.at ?? null, + kind: line.kind ?? null, + title: line.title ?? "", + status: line.status ?? null, + commandPreview: compactText(line.commandPreview, full, 1200), + commandOmittedLines: line.commandOmittedLines ?? 0, + outputPreview: compactText(line.outputPreview, full, 800), + outputOmittedLines: line.outputOmittedLines ?? 0, + rawSeqs: line.rawSeqs ?? [], + }; + }); + return { + count: record.count ?? 0, + returned: record.returned ?? items.length, + limit: record.limit ?? items.length, + truncated: record.truncated ?? false, + items, + }; +} + +function compactSummary(summary: unknown, options: CodexTaskOptions, taskId: string): Record { + const record = asRecord(summary) ?? {}; + const transcriptCount = asNumber(record.transcriptCount, 0); + const transcriptMaxSeq = transcriptCount > 0 ? record.transcriptMaxSeq ?? null : null; + const initialPrompt = asString(record.initialPrompt ?? record.prompt); + return { + id: record.id ?? taskId, + queueId: record.queueId ?? null, + status: record.status ?? null, + model: record.model ?? null, + reasoningEffort: record.reasoningEffort ?? null, + cwd: record.cwd ?? null, + attempts: { + currentAttempt: record.currentAttempt ?? null, + maxAttempts: record.maxAttempts ?? null, + currentMode: record.currentMode ?? null, + judgeFailCount: record.judgeFailCount ?? null, + judgeFailRetryLimit: record.judgeFailRetryLimit ?? null, + attemptRecords: record.attempts ?? [], + }, + thread: { + codexThreadId: record.codexThreadId ?? null, + activeTurnId: record.activeTurnId ?? null, + cancelRequested: record.cancelRequested ?? null, + }, + timing: record.timing ?? null, + createdAt: record.createdAt ?? null, + startedAt: record.startedAt ?? null, + updatedAt: record.updatedAt ?? null, + finishedAt: record.finishedAt ?? null, + initialPrompt: textView(initialPrompt, options.full, 3000), + basePrompt: textView(asString(record.basePrompt), options.full, 2000), + referenceTaskIds: record.referenceTaskIds ?? [], + referenceInjection: record.referenceInjection ?? null, + lastAssistantMessage: compactLastAssistant(record.lastAssistantMessage, options.full), + lastJudge: record.lastJudge ?? null, + lastError: record.lastError ?? null, + toolSummary: compactToolSummary(record.toolSummary, options.full), + counts: { + transcript: record.transcriptCount ?? null, + output: record.outputCount ?? null, + events: record.eventCount ?? null, + }, + traceDisclosure: { + included: options.trace, + total: record.transcriptCount ?? null, + maxSeq: transcriptMaxSeq, + defaultPage: `bun scripts/cli.ts codex task ${taskId} --trace --limit ${defaultTraceLimit}`, + firstPage: `bun scripts/cli.ts codex task ${taskId} --trace --from-start --limit ${defaultTraceLimit}`, + nextPageTemplate: `bun scripts/cli.ts codex task ${taskId} --trace --after-seq --limit ${defaultTraceLimit}`, + previousPageTemplate: `bun scripts/cli.ts codex task ${taskId} --trace --before-seq --limit ${defaultTraceLimit}`, + rawOutputTemplate: `bun scripts/cli.ts codex output ${taskId} --after-seq --limit ${defaultOutputLimit}`, + fullTextSummary: `bun scripts/cli.ts codex task ${taskId} --full --tool-limit ${Math.max(options.toolLimit, defaultToolLimit)}`, + }, + }; +} + +function compactTracePage(body: Record, taskId: string, limit: number): Record { + const transcript = asArray(body.transcript); + const nextAfterSeq = body.nextAfterSeq ?? null; + const previousBeforeSeq = body.previousBeforeSeq ?? null; + const omittedInPage = transcript.some((item) => { + const line = asRecord(item) ?? {}; + return asNumber(line.bodyOmittedLines, 0) > 0 || asNumber(line.commandOmittedLines, 0) > 0; + }); + return { + taskId: body.taskId ?? taskId, + queueId: body.queueId ?? null, + status: body.status ?? null, + updatedAt: body.updatedAt ?? null, + mode: body.mode ?? null, + limit, + returned: transcript.length, + total: body.total ?? null, + maxSeq: body.maxSeq ?? null, + afterSeq: body.afterSeq ?? null, + nextAfterSeq, + beforeSeq: body.beforeSeq ?? null, + previousBeforeSeq, + hasMore: body.hasMore ?? false, + hasBefore: body.hasBefore ?? false, + transcript, + commands: { + next: body.hasMore === true && nextAfterSeq !== null ? `bun scripts/cli.ts codex task ${taskId} --trace --after-seq ${nextAfterSeq} --limit ${limit}` : null, + previous: body.hasBefore === true && previousBeforeSeq !== null ? `bun scripts/cli.ts codex task ${taskId} --trace --before-seq ${previousBeforeSeq} --limit ${limit}` : null, + tail: `bun scripts/cli.ts codex task ${taskId} --trace --tail --limit ${limit}`, + first: `bun scripts/cli.ts codex task ${taskId} --trace --from-start --limit ${limit}`, + rawOutput: omittedInPage ? `Use rawSeqs on each transcript line, e.g. bun scripts/cli.ts codex output ${taskId} --after-seq --limit ${defaultOutputLimit}` : null, + }, + }; +} + +function parseTaskOptions(args: string[]): CodexTaskOptions { + const beforeSeq = nullablePositiveNumberOption(args, ["--before-seq", "--beforeSeq"]); + const afterSeq = nonNegativeNumberOption(args, ["--after-seq", "--afterSeq"], 0); + const fromStart = hasFlag(args, "--from-start") || hasFlag(args, "--first"); + const trace = hasFlag(args, "--trace") || beforeSeq !== null || args.some((arg) => arg === "--after-seq" || arg === "--afterSeq") || fromStart || hasFlag(args, "--tail"); + const traceMode = beforeSeq !== null ? "before" : fromStart || args.some((arg) => arg === "--after-seq" || arg === "--afterSeq") ? "after" : "tail"; + return { + trace, + traceLimit: positiveIntegerOption(args, ["--trace-limit", "--limit"], defaultTraceLimit, maxTraceLimit), + traceMode, + afterSeq, + beforeSeq, + toolLimit: positiveIntegerOption(args, ["--tool-limit"], defaultToolLimit, 500), + full: hasFlag(args, "--full") || hasFlag(args, "--raw-summary"), + rawSummary: hasFlag(args, "--raw-summary"), + }; +} + +function parseOutputOptions(args: string[]): CodexOutputOptions { + const beforeSeq = nullablePositiveNumberOption(args, ["--before-seq", "--beforeSeq"]); + const afterSeq = nonNegativeNumberOption(args, ["--after-seq", "--afterSeq"], 0); + const fromStart = hasFlag(args, "--from-start") || hasFlag(args, "--first"); + const mode = beforeSeq !== null ? "before" : fromStart || args.some((arg) => arg === "--after-seq" || arg === "--afterSeq") ? "after" : "tail"; + return { + limit: positiveIntegerOption(args, ["--limit"], defaultOutputLimit, maxTraceLimit), + mode, + afterSeq, + beforeSeq, + fullText: hasFlag(args, "--full-text") || hasFlag(args, "--raw"), + maxTextChars: positiveIntegerOption(args, ["--max-text-chars"], defaultTextPreviewChars, 500_000), + }; +} + +function queryString(params: Record): string { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null) search.set(key, String(value)); + } + const text = search.toString(); + return text.length > 0 ? `?${text}` : ""; +} + +function codexTaskSummary(taskId: string, options: CodexTaskOptions, fetcher: CodexResponseFetcher): unknown { + const summaryPath = `/api/microservices/codex-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/summary${queryString({ toolLimit: options.toolLimit })}`; + const summaryResponse = unwrapCodexResponse(fetcher(summaryPath)); + const summary = summaryResponse.body.summary; + const result: Record = { + upstream: summaryResponse.upstream, + summary: compactSummary(summary, options, taskId), + }; + if (options.rawSummary) result.rawSummary = summary; + if (options.trace) { + const traceParams: Record = { limit: options.traceLimit }; + if (options.traceMode === "tail") traceParams.tail = 1; + if (options.traceMode === "after") traceParams.afterSeq = options.afterSeq; + if (options.traceMode === "before") traceParams.beforeSeq = options.beforeSeq; + const traceResponse = unwrapCodexResponse(fetcher(`/api/microservices/codex-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/transcript${queryString(traceParams)}`)); + result.trace = compactTracePage(traceResponse.body, taskId, options.traceLimit); + } + return result; +} + +function compactOutputPage(body: Record, taskId: string, limit: number): Record { + const output = asArray(body.output); + const nextAfterSeq = body.nextAfterSeq ?? null; + const previousBeforeSeq = body.previousBeforeSeq ?? null; + return { + taskId: body.taskId ?? taskId, + queueId: body.queueId ?? null, + status: body.status ?? null, + updatedAt: body.updatedAt ?? null, + mode: body.mode ?? null, + limit, + returned: output.length, + total: body.total ?? null, + maxSeq: body.maxSeq ?? null, + afterSeq: body.afterSeq ?? null, + nextAfterSeq, + beforeSeq: body.beforeSeq ?? null, + previousBeforeSeq, + hasMore: body.hasMore ?? false, + hasBefore: body.hasBefore ?? false, + output, + commands: { + next: body.hasMore === true && nextAfterSeq !== null ? `bun scripts/cli.ts codex output ${taskId} --after-seq ${nextAfterSeq} --limit ${limit}` : null, + previous: body.hasBefore === true && previousBeforeSeq !== null ? `bun scripts/cli.ts codex output ${taskId} --before-seq ${previousBeforeSeq} --limit ${limit}` : null, + tail: `bun scripts/cli.ts codex output ${taskId} --tail --limit ${limit}`, + first: `bun scripts/cli.ts codex output ${taskId} --from-start --limit ${limit}`, + fullText: `bun scripts/cli.ts codex output ${taskId} --after-seq --limit ${limit} --full-text`, + }, + }; +} + +function codexTaskOutput(taskId: string, options: CodexOutputOptions, fetcher: CodexResponseFetcher): unknown { + const params: Record = { + limit: options.limit, + fullText: options.fullText ? 1 : 0, + maxTextChars: options.maxTextChars, + }; + if (options.mode === "tail") params.tail = 1; + if (options.mode === "after") params.afterSeq = options.afterSeq; + if (options.mode === "before") params.beforeSeq = options.beforeSeq; + const response = unwrapCodexResponse(fetcher(`/api/microservices/codex-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/output${queryString(params)}`)); + return { upstream: response.upstream, outputPage: compactOutputPage(response.body, taskId, options.limit) }; +} + +export function codexTaskQuery(taskId: string, optionArgs: string[], fetcher: CodexResponseFetcher = coreInternalFetch): unknown { + return codexTaskSummary(taskId, parseTaskOptions(optionArgs), fetcher); +} + +export function codexOutputQuery(taskId: string, optionArgs: string[], fetcher: CodexResponseFetcher = coreInternalFetch): unknown { + return codexTaskOutput(taskId, parseOutputOptions(optionArgs), fetcher); +} + +async function codexTaskSummaryAsync(taskId: string, options: CodexTaskOptions, fetcher: AsyncCodexResponseFetcher): Promise { + const summaryPath = `/api/microservices/codex-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/summary${queryString({ toolLimit: options.toolLimit })}`; + const summaryResponse = unwrapCodexResponse(await fetcher(summaryPath)); + const summary = summaryResponse.body.summary; + const result: Record = { + upstream: summaryResponse.upstream, + summary: compactSummary(summary, options, taskId), + }; + if (options.rawSummary) result.rawSummary = summary; + if (options.trace) { + const traceParams: Record = { limit: options.traceLimit }; + if (options.traceMode === "tail") traceParams.tail = 1; + if (options.traceMode === "after") traceParams.afterSeq = options.afterSeq; + if (options.traceMode === "before") traceParams.beforeSeq = options.beforeSeq; + const traceResponse = unwrapCodexResponse(await fetcher(`/api/microservices/codex-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/transcript${queryString(traceParams)}`)); + result.trace = compactTracePage(traceResponse.body, taskId, options.traceLimit); + } + return result; +} + +async function codexTaskOutputAsync(taskId: string, options: CodexOutputOptions, fetcher: AsyncCodexResponseFetcher): Promise { + const params: Record = { + limit: options.limit, + fullText: options.fullText ? 1 : 0, + maxTextChars: options.maxTextChars, + }; + if (options.mode === "tail") params.tail = 1; + if (options.mode === "after") params.afterSeq = options.afterSeq; + if (options.mode === "before") params.beforeSeq = options.beforeSeq; + const response = unwrapCodexResponse(await fetcher(`/api/microservices/codex-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/output${queryString(params)}`)); + return { upstream: response.upstream, outputPage: compactOutputPage(response.body, taskId, options.limit) }; +} + +export async function codexTaskQueryAsync(taskId: string, optionArgs: string[], fetcher: AsyncCodexResponseFetcher): Promise { + return codexTaskSummaryAsync(taskId, parseTaskOptions(optionArgs), fetcher); +} + +export async function codexOutputQueryAsync(taskId: string, optionArgs: string[], fetcher: AsyncCodexResponseFetcher): Promise { + return codexTaskOutputAsync(taskId, parseOutputOptions(optionArgs), fetcher); +} + +function requireQueueId(args: string[], command: string): string { + const index = args.indexOf("--queue"); + const raw = index === -1 ? args[0] : args[index + 1]; + if (raw === undefined || raw.trim().length === 0) throw new Error(`${command} requires queue id, for example --queue default`); + return raw.trim(); +} + +function codexQueues(): unknown { + return unwrapCodexResponse(coreInternalFetch("/api/microservices/codex-queue/proxy/api/queues")); +} + +function codexCreateQueue(queueId: string): unknown { + return unwrapCodexResponse(coreInternalFetch("/api/microservices/codex-queue/proxy/api/queues", { method: "POST", body: { queueId } })); +} + +function codexMoveTask(taskId: string, queueId: string): unknown { + return unwrapCodexResponse(coreInternalFetch(`/api/microservices/codex-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/move`, { method: "POST", body: { queueId } })); +} + +export async function runCodexQueueCommand(_config: UniDeskConfig, args: string[]): Promise { + const [action = "task", taskIdArg] = args; + if (action === "task" || action === "summary" || action === "show") { + const taskId = requireTaskId(taskIdArg, `codex ${action}`); + return codexTaskQuery(taskId, args.slice(2)); + } + if (action === "output") { + const taskId = requireTaskId(taskIdArg, "codex output"); + return codexOutputQuery(taskId, args.slice(2)); + } + if (action === "queues") return codexQueues(); + if (action === "queue") { + const sub = taskIdArg ?? "list"; + if (sub === "list") return codexQueues(); + if (sub === "create") return codexCreateQueue(requireQueueId(args.slice(2), "codex queue create")); + } + if (action === "move") { + const taskId = requireTaskId(taskIdArg, "codex move"); + return codexMoveTask(taskId, requireQueueId(args.slice(2), "codex move")); + } + throw new Error("codex command must be one of: task, summary, show, output, queues, queue list, queue create, move"); +} diff --git a/scripts/src/docker.ts b/scripts/src/docker.ts index 81f4db6b..7b4d1cd1 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", "codex-queue"] as const; +const rebuildableServices = ["backend-core", "frontend", "provider-gateway", "todo-note", "codex-queue", "project-manager"] as const; export type RebuildableService = typeof rebuildableServices[number]; export function isRebuildableService(value: string | undefined): value is RebuildableService { @@ -112,10 +112,29 @@ export function writeComposeEnv(config: UniDeskConfig, freshLogPrefix: boolean): UNIDESK_HOST_SSH_HOST: config.sshForwarding.host, UNIDESK_HOST_SSH_PORT: String(config.sshForwarding.port), UNIDESK_HOST_SSH_USER: config.sshForwarding.user, + UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_ENABLED: runtimeSecret("UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_ENABLED") || "true", + UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_BASE_URL: runtimeSecret("UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_BASE_URL") || "http://backend-core:8080/api/microservices/claudeqq/proxy", + UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_TARGET_TYPE: runtimeSecret("UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_TARGET_TYPE") || "private", + UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_USER_ID: runtimeSecret("UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_USER_ID") || "645275593", + UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_GROUP_ID: runtimeSecret("UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_GROUP_ID"), + UNIDESK_TODO_NOTE_REMINDER_LEAD_MINUTES: runtimeSecret("UNIDESK_TODO_NOTE_REMINDER_LEAD_MINUTES") || "10", + UNIDESK_TODO_NOTE_REMINDER_SCAN_INTERVAL_MS: runtimeSecret("UNIDESK_TODO_NOTE_REMINDER_SCAN_INTERVAL_MS") || "30000", + UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_TIMEOUT_MS: runtimeSecret("UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_TIMEOUT_MS") || "15000", + UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_SEND_ATTEMPTS: runtimeSecret("UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_SEND_ATTEMPTS") || "3", UNIDESK_CODEX_QUEUE_MINIMAX_API_KEY: runtimeSecret("UNIDESK_CODEX_QUEUE_MINIMAX_API_KEY"), UNIDESK_CODEX_QUEUE_MINIMAX_MODEL: runtimeSecret("UNIDESK_CODEX_QUEUE_MINIMAX_MODEL") || "MiniMax-M2.7", UNIDESK_CODEX_QUEUE_MINIMAX_API_BASE: runtimeSecret("UNIDESK_CODEX_QUEUE_MINIMAX_API_BASE") || "https://api.minimaxi.com/v1", UNIDESK_CODEX_QUEUE_MINIMAX_JUDGE_TIMEOUT_MS: runtimeSecret("UNIDESK_CODEX_QUEUE_MINIMAX_JUDGE_TIMEOUT_MS") || "60000", + UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_ENABLED: runtimeSecret("UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_ENABLED") || "true", + UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_BASE_URL: runtimeSecret("UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_BASE_URL") || "http://backend-core:8080/api/microservices/claudeqq/proxy", + UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_TARGET_TYPE: runtimeSecret("UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_TARGET_TYPE") || "private", + UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_USER_ID: runtimeSecret("UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_USER_ID") || "645275593", + UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_GROUP_ID: runtimeSecret("UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_GROUP_ID"), + UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_MAX_RESPONSE_CHARS: runtimeSecret("UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_MAX_RESPONSE_CHARS") || "12000", + UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_TIMEOUT_MS: runtimeSecret("UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_TIMEOUT_MS") || "15000", + UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_SEND_ATTEMPTS: runtimeSecret("UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_SEND_ATTEMPTS") || "3", + OPENAI_API_KEY: runtimeSecret("OPENAI_API_KEY"), + CRS_OAI_KEY: runtimeSecret("CRS_OAI_KEY"), }; writeFileSync(envFile, Object.entries(lines).map(([key, value]) => `${key}=${envValue(value)}`).join("\n") + "\n", "utf8"); return { envFile, logDir, logPrefix }; @@ -136,17 +155,17 @@ export function startStack(config: UniDeskConfig): unknown { if (occupiedPorts.length > 0 && containers.length === 0) { throw new Error(`Fixed UniDesk port is occupied before start: ${occupiedPorts.map((p) => `${p.name}:${p.port}`).join(", ")}`); } - const downCommand = [...compose, "down", "--remove-orphans"]; - const upCommand = [...compose, "up", "-d", "--build"]; - const command = ["bash", "-lc", `set -euo pipefail; ${shellJoin(downCommand)}; ${shellJoin(upCommand)}`]; - const job = startJob("server_start", command, "Build and start UniDesk database, core, frontend, provider gateway, and managed microservice containers"); + const upCommand = [...compose, "up", "-d", "--build", "--remove-orphans"]; + const command = ["bash", "-lc", composeLockedScript(`set -euo pipefail; ${shellJoin(upCommand)}`)]; + const job = startJob("server_start", command, "Build and start UniDesk services without tearing down running queue containers"); return { job, runtimeEnv, command, ports: fixedPorts(config) }; } export function stopStack(config: UniDeskConfig): unknown { const runtimeEnv = writeComposeEnv(config, false); const compose = resolveComposeCommand(config, runtimeEnv.envFile); - const command = [...compose, "down", "--remove-orphans"]; + const downCommand = [...compose, "down", "--remove-orphans"]; + const command = ["bash", "-lc", composeLockedScript(`set -euo pipefail; ${shellJoin(downCommand)}`)]; const job = startJob("server_stop", command, "Stop all UniDesk Docker services managed by the fixed compose project"); return { job, runtimeEnv, command, portsBeforeStop: fixedPorts(config) }; } @@ -158,7 +177,7 @@ export function rebuildService(config: UniDeskConfig, service: RebuildableServic const listServiceContainersCommand = [ "docker", "ps", - "-aq", + "-q", "--filter", `label=com.docker.compose.project=${config.docker.projectName}`, "--filter", @@ -166,29 +185,65 @@ export function rebuildService(config: UniDeskConfig, service: RebuildableServic "--filter", "label=com.docker.compose.oneoff=False", ]; - const upCommand = [...compose, "up", "-d", "--no-deps", service]; + const upCommand = [...compose, "up", "-d", "--no-deps", "--force-recreate", service]; + const restoreCommand = [...compose, "up", "-d", "--no-deps", service]; + const listAllServiceContainersCommand = [...listServiceContainersCommand]; + listAllServiceContainersCommand[2] = "-a"; + const lockPath = composeLockPath(); + const watchdogLog = rootPath(".state", "jobs", "compose-rebuild-watchdog.log"); + const watchdogInnerScript = [ + "set -euo pipefail", + "sleep 20", + `cid=$(${shellJoin(listServiceContainersCommand)} || true)`, + `if [ -z "$cid" ]; then echo "$(date -Is) compose_rebuild_watchdog_restore service=${service}" >> ${shellQuote(watchdogLog)}; ${shellJoin(restoreCommand)} >> ${shellQuote(watchdogLog)} 2>&1 || true; fi`, + ].join("\n"); + const watchdogScript = `set -euo pipefail; ${shellJoin(["flock", "-w", "300", lockPath, "bash", "-lc", watchdogInnerScript])} || true`; + const validateScript = [ + "ready=0", + "for attempt in $(seq 1 60); do", + `cid=$(${shellJoin(listServiceContainersCommand)} || true)`, + "if [ -n \"$cid\" ]; then", + "health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' $cid 2>/dev/null | head -1 || true)", + `echo "service_container_probe service=${service} attempt=$attempt cid=$cid health=$health"`, + "if [ \"$health\" = \"healthy\" ] || [ \"$health\" = \"running\" ]; then ready=1; break; fi", + "else", + `echo "service_container_probe service=${service} attempt=$attempt cid=missing"`, + "fi", + "sleep 1", + "done", + "if [ \"$ready\" != \"1\" ]; then", + `echo "service_container_not_ready service=${service}" >&2`, + `${shellJoin(listAllServiceContainersCommand)} --format '{{.ID}} {{.Names}} {{.Status}}' >&2 || true`, + "exit 1", + "fi", + ].join("\n"); const script = [ "set -euo pipefail", - `echo ${shellJoin(["rebuild_service", service, "build_first_then_replace_container"])}`, + `echo ${shellJoin(["rebuild_service", service, "build_first_then_force_recreate_with_validation"])}`, shellJoin(buildCommand), - `ids=$(${shellJoin(listServiceContainersCommand)})`, - `if [ -n "$ids" ]; then echo "remove_existing_compose_service_containers service=${service} ids=$ids"; docker rm -f $ids; else echo "no_existing_compose_service_containers service=${service}"; fi`, + `nohup bash -lc ${shellQuote(watchdogScript)} >/dev/null 2>&1 &`, shellJoin(upCommand), - ].join("; "); - const command = ["bash", "-lc", script]; - const job = startJob("server_rebuild", command, `Rebuild and replace UniDesk ${service} without Docker Compose v1 recreate`); + validateScript, + ].join("\n"); + const command = ["bash", "-lc", composeLockedScript(script)]; + const jobRunner = service === "codex-queue" ? { runner: "docker" as const, dockerImage: "unidesk-codex-queue:latest" } : {}; + const job = startJob("server_rebuild", command, `Rebuild and validate UniDesk ${service} with serialized Docker Compose mutation`, jobRunner); return { job, runtimeEnv, service, command, strategy: { - buildBeforeRemove: true, - removeScope: { + buildBeforeReplace: true, + replaceScope: { projectLabel: config.docker.projectName, serviceLabel: service, }, noDeps: true, + forceRecreate: true, + composeMutationLock: rootPath(".state", "locks", "server-compose.lock"), + jobRunner: service === "codex-queue" ? "docker" : "local", + postUpValidation: true, namedVolumesPreserved: true, }, }; @@ -202,7 +257,27 @@ function fixedPorts(config: UniDeskConfig): Array<{ name: string; port: number; } function shellJoin(args: string[]): string { - return args.map((arg) => `'${arg.replace(/'/g, `'\\''`)}'`).join(" "); + return args.map(shellQuote).join(" "); +} + +function shellQuote(arg: string): string { + return `'${arg.replace(/'/g, `'\\''`)}'`; +} + +function composeLockedScript(innerScript: string): string { + const lockPath = composeLockPath(); + return [ + "set -euo pipefail", + `mkdir -p ${shellQuote(rootPath(".state", "locks"))}`, + `echo ${shellJoin(["compose_lock_wait", lockPath])}`, + shellJoin(["flock", lockPath, "bash", "-lc", innerScript]), + ].join("; "); +} + +function composeLockPath(): string { + const lockDir = rootPath(".state", "locks"); + mkdirSync(lockDir, { recursive: true }); + return join(lockDir, "server-compose.lock"); } function isPortListening(port: number): boolean { @@ -278,6 +353,7 @@ export async function stackStatus(config: UniDeskConfig): Promise { { 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 }, + { name: "project-manager", containerPort: 4233, hostPort: null }, ], containers: dockerContainers(config), health: { @@ -315,7 +391,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", "codex-queue-backend"]; + const containerNames = ["unidesk-database", "unidesk-backend-core", "unidesk-frontend", "unidesk-provider-gateway-main", "todo-note-backend", "codex-queue-backend", "project-manager-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 77b18ee7..4f162ccf 100644 --- a/scripts/src/e2e.ts +++ b/scripts/src/e2e.ts @@ -1,7 +1,8 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { connect } from "node:net"; import { join } from "node:path"; -import { chromium } from "playwright"; +import { chromium, type Page } from "playwright"; +import { createRouteRegistry, MODULES } from "../../src/components/frontend/src/navigation"; import { runCommand } from "./command"; import { type UniDeskConfig, repoRoot, rootPath } from "./config"; import { boundedJsonDetail } from "./preview"; @@ -34,6 +35,7 @@ const NETWORK_CHECK_NAMES = [ "network:database-public-blocked", "network:findjob-public-blocked", "network:met-nonlinear-public-blocked", + "network:claudeqq-public-blocked", "network:todo-note-public-blocked", "network:codex-queue-public-blocked", ] as const; @@ -41,6 +43,7 @@ const NETWORK_CHECK_NAMES = [ const SERVICE_CHECK_NAMES = [ "core:internal-overview", "core:pgdata-usage", + "core:performance-api", "provider:self-node-online", "provider:gateway-version-label", "provider:system-status", @@ -51,6 +54,7 @@ const SERVICE_CHECK_NAMES = [ "microservice:catalog-findjob", "microservice:catalog-pipeline", "microservice:catalog-met-nonlinear", + "microservice:catalog-claudeqq", "microservice:catalog-todo-note", "microservice:catalog-codex-queue", "microservice:findjob-status", @@ -66,6 +70,11 @@ const SERVICE_CHECK_NAMES = [ "microservice:met-nonlinear-queue", "microservice:met-nonlinear-projects", "microservice:met-nonlinear-image", + "microservice:claudeqq-status", + "microservice:claudeqq-health", + "microservice:claudeqq-napcat-login", + "microservice:claudeqq-events", + "microservice:claudeqq-subscriptions", "microservice:todo-note-status", "microservice:todo-note-health", "microservice:todo-note-migrated-data", @@ -91,6 +100,7 @@ const FRONTEND_CHECK_NAMES = [ "frontend:task-history-diagnostics", "frontend:no-naked-json-before-click", "frontend:raw-json-explicit-button", + "frontend:performance-panel-visible", "frontend:system-monitor-visible", "frontend:process-resource-sorting", "frontend:upgrade-plan-dispatch", @@ -103,17 +113,26 @@ const FRONTEND_CHECK_NAMES = [ "frontend:todo-note-integrated-visible", "frontend:findjob-integrated-visible", "frontend:codex-queue-integrated-visible", + "frontend:codex-queue-initial-prompt-full-expand", + "frontend:codex-queue-trace-full-load", + "frontend:codex-queue-judge-wrap", + "frontend:claudeqq-integrated-visible", "frontend:url-route-deeplink", "frontend:pipeline-integrated-visible", "frontend:pipeline-react-flow-visible", + "frontend:pipeline-sidebars-collapsible", "frontend:pipeline-gantt-defaults", + "frontend:pipeline-gantt-frontend-y-accuracy", "frontend:pipeline-gantt-export", "frontend:pipeline-gantt-observation-live-running", "frontend:pipeline-step-timeline-visible", "frontend:pipeline-oa-event-flow-visible", + "frontend:pipeline-minimax-quota-visible", "frontend:met-nonlinear-integrated-visible", "frontend:met-nonlinear-project-tree-detail", "frontend:met-nonlinear-queue-detail-speed", + "frontend:layout-overflow-desktop", + "frontend:layout-overflow-mobile", "frontend:no-console-errors", ] as const; @@ -242,6 +261,105 @@ function pipelineSnapshotNodeOrder(pipeline: any): string[] { return pipelineOrderWithLeadingMonitors(Array.from({ length: maxLevel + 1 }, (_item, level) => nodeIds.filter((nodeId) => levels.get(nodeId) === level)).flatMap((batch) => batch), monitorNodeIds); } +type OverflowProbe = { + viewport: string; + label: string; + path: string; + testId: string; + ok: boolean; + documentOverflowX: number; + offenders: Array<{ + tag: string; + className: string; + testId: string; + text: string; + left: number; + right: number; + width: number; + parentClassName: string; + }>; +}; + +const LAYOUT_OVERFLOW_PAGE_TEST_IDS: Record = { + "/ops/status/": "overview-page", + "/ops/performance/": "performance-page", + "/nodes/monitor/": "node-monitor-page", + "/nodes/docker/": "docker-status-page", + "/nodes/gateway/": "gateway-version-page", + "/tasks/pending/": "pending-task-page", + "/tasks/history/": "task-history-page", + "/app/catalog/": "microservice-catalog-page", + "/app/todo-note/": "todo-note-page", + "/app/findjob/": "findjob-page", + "/app/pipeline/": "pipeline-page", + "/app/met-nonlinear/": "met-nonlinear-page", + "/app/claudeqq/": "claudeqq-page", + "/app/codex-queue/": "codex-queue-page", +}; + +function layoutOverflowTargets(): Array<{ label: string; path: string; testId: string }> { + const registry = createRouteRegistry(MODULES); + return registry.modules.flatMap((module) => + module.tabs.map((tab) => ({ + label: `${module.id}:${tab.id}`, + path: tab.canonicalPath, + testId: LAYOUT_OVERFLOW_PAGE_TEST_IDS[tab.canonicalPath] || "app-shell", + }))); +} + +async function collectLayoutOverflow(page: Page, viewport: { width: number; height: number }, viewportName: string): Promise { + const targets = layoutOverflowTargets(); + const origin = new URL(page.url()).origin; + const results: OverflowProbe[] = []; + await page.setViewportSize(viewport); + for (const target of targets) { + await page.goto(`${origin}${target.path}`, { waitUntil: "domcontentloaded", timeout: 15000 }); + await page.waitForSelector(`[data-testid="${target.testId}"]`, { timeout: 15000 }).catch(() => undefined); + await page.waitForTimeout(160); + const result = await page.evaluate(({ viewportName: currentViewport, label, path, testId }) => { + const viewportWidth = document.documentElement.clientWidth; + const documentOverflowX = Math.max(0, document.documentElement.scrollWidth - viewportWidth, document.body.scrollWidth - viewportWidth); + const allowed = (element: Element): boolean => { + const html = element as HTMLElement; + if (html.closest(".raw-dialog, .raw-json, .performance-table-wrap, .process-table-wrap, .docker-table-wrap, .table-wrap, .met-project-table, .pipeline-flow-frame, .react-flow, .codex-transcript, .codex-task-list, .todo-tree-scroll, .tabs, .rail")) return true; + const style = getComputedStyle(html); + return ["auto", "scroll"].includes(style.overflowX); + }; + const offenders = Array.from(document.body.querySelectorAll("*")).flatMap((element) => { + const html = element as HTMLElement; + if (!(html instanceof HTMLElement) || allowed(html)) return []; + const rect = html.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return []; + const excessLeft = rect.left < -2; + const excessRight = rect.right > viewportWidth + 2; + if (!excessLeft && !excessRight) return []; + const parent = html.parentElement; + return [{ + tag: html.tagName.toLowerCase(), + className: String(html.className || "").slice(0, 160), + testId: html.getAttribute("data-testid") || "", + text: (html.innerText || html.textContent || "").replace(/\s+/g, " ").trim().slice(0, 160), + left: Math.round(rect.left), + right: Math.round(rect.right), + width: Math.round(rect.width), + parentClassName: String(parent?.className || "").slice(0, 160), + }]; + }).slice(0, 20); + return { + viewport: currentViewport, + label, + path, + testId, + ok: documentOverflowX <= 2 && offenders.length === 0, + documentOverflowX: Math.round(documentOverflowX), + offenders, + }; + }, { viewportName, label: target.label, path: target.path, testId: target.testId }); + results.push(result); + } + return results; +} + function escapedPatternRegex(value: string): string { return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); } @@ -401,15 +519,12 @@ function shellQuote(value: string): string { } 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"); + const body = (detail as { body?: { procedureRuns?: unknown[]; controlEvents?: unknown[] } }).body; + const text = JSON.stringify(body ?? {}); + const procedureRuns = Array.isArray(body?.procedureRuns) ? body.procedureRuns : []; + const hasObservation = text.includes("node-long-running-observation"); + const hasRunning = procedureRuns.some((procedure: any) => + String(procedure?.status?.status || procedure?.artifact?.status || procedure?.status || "").toLowerCase() === "running"); return hasObservation && hasRunning; } @@ -420,7 +535,7 @@ function findPipelineLiveObservationCandidate(): { ok: boolean; candidate: { pip 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()}`); + const detail = dockerCoreJson(`/api/microservices/pipeline/proxy/api/node-control/runs/${encodeURIComponent(runId)}?tail=180&view=timeline&_=${Date.now()}`); latest = detail; if (pipelineDetailHasLiveObservation(detail)) { return { ok: true, candidate: { pipelineId: String(run?.pipelineId || ""), runId }, latest: detail }; @@ -559,6 +674,7 @@ async function exposureChecks(config: UniDeskConfig, urls: PublicUrls, checks: E const databasePublic = await tcpProbe(urls.blockedDatabaseHost, urls.blockedDatabasePort); const findjobPublic = await fetchProbe(`http://${config.network.publicHost}:3254/api/health`, 2500); const metNonlinearPublic = await fetchProbe(`http://${config.network.publicHost}:3288/health`, 2500); + const claudeqqPublic = await fetchProbe(`http://${config.network.publicHost}:3290/health`, 2500); const todoNotePublic = await fetchProbe(`http://${config.network.publicHost}:4211/api/health`, 2500); 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); @@ -566,12 +682,14 @@ async function exposureChecks(config: UniDeskConfig, urls: PublicUrls, checks: E 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:claudeqq-public-blocked", (claudeqqPublic as { reachable?: boolean }).reachable === false, claudeqqPublic); 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 { const coreOverview = dockerCoreJson("/api/overview"); + const corePerformance = dockerCoreJson("/api/performance"); const coreNodes = dockerCoreJson("/api/nodes"); const systemStatus = dockerCoreJson("/api/nodes/system-status?limit=24"); const dockerStatus = dockerCoreJson("/api/nodes/docker-status"); @@ -589,6 +707,11 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2 const metNonlinearQueue = dockerCoreJson("/api/microservices/met-nonlinear/proxy/api/queue?__unideskArrayLimit=jobs:10"); const metNonlinearProjects = dockerCoreJson("/api/microservices/met-nonlinear/proxy/api/projects?root=projects&limit=20&__unideskArrayLimit=projects:20"); const metNonlinearImages = dockerCoreJson("/api/microservices/met-nonlinear/proxy/api/images"); + const claudeqqStatus = dockerCoreJson("/api/microservices/claudeqq/status"); + const claudeqqHealth = dockerCoreJson("/api/microservices/claudeqq/health"); + const claudeqqNapcatLogin = dockerCoreJson("/api/microservices/claudeqq/proxy/api/napcat/login"); + const claudeqqEvents = dockerCoreJson("/api/microservices/claudeqq/proxy/api/events/recent?limit=5"); + const claudeqqSubscriptions = dockerCoreJson("/api/microservices/claudeqq/proxy/api/events/subscriptions"); 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"); @@ -614,6 +737,7 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2 : { ok: false, error: "missing created todo note id" }; const providerIngress = await fetchProbe(urls.providerIngressHealthUrl); const overviewBody = (coreOverview as { body?: { ok?: boolean; dbReady?: boolean; onlineNodeCount?: number; pgdata?: { volumeName?: string; databaseBytes?: number } } }).body; + const corePerformanceBody = (corePerformance as { body?: { ok?: boolean; requests?: { componentSummary?: unknown[] }; operations?: { summary?: unknown[] }; database?: { pgdata?: { volumeName?: string }; codexQueueStorage?: { table?: string } } } }).body; const nodeList = (coreNodes as { body?: { nodes?: Array<{ providerId?: string; status?: string; labels?: Record }> } }).body?.nodes ?? []; const mainNode = nodeList.find((node) => node.providerId === config.providerGateway.id); const expectedGatewayVersion = providerGatewayPackageVersion(); @@ -625,6 +749,7 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2 const mainDocker = dockerStatuses.find((item) => item.providerId === config.providerGateway.id); addSelectedCheck(checks, options, "core:internal-overview", (coreOverview as { ok?: boolean }).ok === true && overviewBody?.ok === true && overviewBody.dbReady === true && (overviewBody.onlineNodeCount ?? 0) >= 1, coreOverview); addSelectedCheck(checks, options, "core:pgdata-usage", (coreOverview as { ok?: boolean }).ok === true && overviewBody?.pgdata?.volumeName === config.database.volume && Number(overviewBody.pgdata.databaseBytes ?? 0) > 0, coreOverview); + addSelectedCheck(checks, options, "core:performance-api", (corePerformance as { ok?: boolean }).ok === true && corePerformanceBody?.ok === true && Array.isArray(corePerformanceBody.requests?.componentSummary) && Array.isArray(corePerformanceBody.operations?.summary) && corePerformanceBody.database?.pgdata?.volumeName === config.database.volume && corePerformanceBody.database?.codexQueueStorage?.table === "unidesk_codex_queue_tasks", corePerformance); addSelectedCheck(checks, options, "provider:self-node-online", nodeList.some((node) => node.providerId === config.providerGateway.id && node.status === "online"), coreNodes); addSelectedCheck(checks, options, "provider:gateway-version-label", mainNode?.labels?.providerGatewayVersion === expectedGatewayVersion && mainNode?.labels?.providerGatewayUpgradePolicy === "always-enabled", { providerId: config.providerGateway.id, expectedGatewayVersion, labels: mainNode?.labels ?? null }); addSelectedCheck(checks, options, "provider:system-status", (systemStatus as { ok?: boolean }).ok === true && mainSystem?.current !== undefined && Number.isFinite(mainSystem.current.cpu?.percent) && Number.isFinite(mainSystem.current.memory?.percent) && mainSystem.current.memory?.mode === "actual_without_cache" && Number.isFinite(mainSystem.current.memory?.cacheBytes) && Number.isFinite(mainSystem.current.disk?.percent) && (mainSystem.history?.length ?? 0) > 0, systemStatusCheckDetail(systemStatus, config.providerGateway.id)); @@ -634,6 +759,7 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2 const findjob = microserviceList.find((service) => service.id === "findjob"); const pipeline = microserviceList.find((service) => service.id === "pipeline"); const metNonlinear = microserviceList.find((service) => service.id === "met-nonlinear"); + const claudeqq = microserviceList.find((service) => service.id === "claudeqq"); 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; @@ -646,8 +772,12 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2 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 claudeqqHealthBody = (claudeqqHealth as { body?: { ok?: boolean; service?: string; endpoints?: string[]; subscriptions?: { count?: number; enabled?: number }; napcat?: { containerized?: boolean; qrcode?: { available?: boolean; dataUrl?: string } } } }).body; + const claudeqqNapcatLoginBody = (claudeqqNapcatLogin as { body?: { ok?: boolean; napcat?: { containerized?: boolean; loginState?: string; qrcode?: { available?: boolean; dataUrl?: string } }; login?: { ready?: boolean; state?: string } } }).body; + const claudeqqEventsBody = (claudeqqEvents as { body?: { ok?: boolean; events?: unknown[]; count?: number } }).body; + const claudeqqSubscriptionsBody = (claudeqqSubscriptions as { body?: { ok?: boolean; subscriptions?: unknown[]; count?: number } }).body; + const codexQueueHealthBody = (codexQueueHealth as { body?: { ok?: boolean; queue?: { defaultModel?: string; judgeConfigured?: boolean; modelReasoningEfforts?: Record } } }).body; + const codexQueueTasksBody = (codexQueueTasks as { body?: { ok?: boolean; queue?: { defaultModel?: string; modelReasoningEfforts?: Record }; tasks?: unknown[] } }).body; const firstPipelineRun = Array.isArray(pipelineSnapshotBody?.runs) ? pipelineSnapshotBody.runs[0] as { runId?: string; pipelineId?: string; status?: string; updatedAt?: string } | undefined : undefined; @@ -672,6 +802,7 @@ 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-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-claudeqq", (microservices as { ok?: boolean }).ok === true && claudeqq?.providerId === "D601" && claudeqq.backend?.public === false && claudeqq.runtime?.container?.name === "claudeqq-backend", { 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); @@ -707,13 +838,18 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2 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); addSelectedCheck(checks, options, "microservice:met-nonlinear-projects", (metNonlinearProjects as { ok?: boolean }).ok === true && metNonlinearProjectsBody?.ok === true && Array.isArray(metNonlinearProjectsBody.projects) && metNonlinearProjectsBody.projects.length > 0, metNonlinearProjects); addSelectedCheck(checks, options, "microservice:met-nonlinear-image", (metNonlinearImages as { ok?: boolean }).ok === true && metNonlinearImagesBody?.ok === true && metNonlinearImagesBody.mlImage?.present === true && metNonlinearImagesBody.mlImage?.image === "met-nonlinear-ml:tf26", metNonlinearImages); + addSelectedCheck(checks, options, "microservice:claudeqq-status", (claudeqqStatus as { ok?: boolean }).ok === true && (claudeqqStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === "D601", claudeqqStatus); + addSelectedCheck(checks, options, "microservice:claudeqq-health", (claudeqqHealth as { ok?: boolean }).ok === true && claudeqqHealthBody?.ok === true && claudeqqHealthBody.service === "claudeqq" && (claudeqqHealthBody.endpoints ?? []).includes("/api/push/text") && (claudeqqHealthBody.endpoints ?? []).includes("/api/napcat/login"), claudeqqHealth); + addSelectedCheck(checks, options, "microservice:claudeqq-napcat-login", (claudeqqNapcatLogin as { ok?: boolean }).ok === true && claudeqqNapcatLoginBody?.ok === true && claudeqqNapcatLoginBody.napcat?.containerized === true && typeof claudeqqNapcatLoginBody.login?.state === "string" && (claudeqqNapcatLoginBody.login?.ready === true || (claudeqqNapcatLoginBody.napcat?.qrcode?.available === true && String(claudeqqNapcatLoginBody.napcat.qrcode.dataUrl || "").startsWith("data:image/"))), claudeqqNapcatLogin); + addSelectedCheck(checks, options, "microservice:claudeqq-events", (claudeqqEvents as { ok?: boolean }).ok === true && claudeqqEventsBody?.ok === true && Array.isArray(claudeqqEventsBody.events), claudeqqEvents); + addSelectedCheck(checks, options, "microservice:claudeqq-subscriptions", (claudeqqSubscriptions as { ok?: boolean }).ok === true && claudeqqSubscriptionsBody?.ok === true && Array.isArray(claudeqqSubscriptionsBody.subscriptions), claudeqqSubscriptions); addSelectedCheck(checks, options, "microservice:todo-note-status", (todoNoteStatus as { ok?: boolean }).ok === true && (todoNoteStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === config.providerGateway.id, todoNoteStatus); 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); + addSelectedCheck(checks, options, "microservice:codex-queue-health", (codexQueueHealth as { ok?: boolean }).ok === true && codexQueueHealthBody?.ok === true && codexQueueHealthBody.queue?.defaultModel === "gpt-5.5" && codexQueueHealthBody.queue?.modelReasoningEfforts?.["gpt-5.5"] === "xhigh", 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.5" && codexQueueTasksBody.queue?.modelReasoningEfforts?.["gpt-5.5"] === "xhigh", codexQueueTasks); const upgradeDispatch = dockerCoreJson("/api/dispatch", { method: "POST", body: { providerId: config.providerGateway.id, command: "provider.upgrade", payload: { source: "cli-e2e", mode: "plan" } }, @@ -801,6 +937,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 "frontend:process-resource-sorting", "frontend:upgrade-plan-dispatch", ]); + const needPerformancePanel = wants("frontend:performance-panel-visible"); const needDocker = wants("frontend:docker-status-visible"); const needGatewayVersion = wantsAny([ "frontend:gateway-version-records-visible", @@ -810,22 +947,33 @@ 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 needCodexQueue = wantsAny([ + "frontend:codex-queue-integrated-visible", + "frontend:codex-queue-initial-prompt-full-expand", + "frontend:codex-queue-trace-full-load", + "frontend:codex-queue-judge-wrap", + ]); + const needClaudeqq = wants("frontend:claudeqq-integrated-visible"); const needRouteDeepLink = wants("frontend:url-route-deeplink"); const needPipeline = wantsAny([ "frontend:pipeline-integrated-visible", "frontend:pipeline-react-flow-visible", + "frontend:pipeline-sidebars-collapsible", "frontend:pipeline-gantt-defaults", + "frontend:pipeline-gantt-frontend-y-accuracy", "frontend:pipeline-gantt-export", "frontend:pipeline-gantt-observation-live-running", "frontend:pipeline-step-timeline-visible", "frontend:pipeline-oa-event-flow-visible", + "frontend:pipeline-minimax-quota-visible", ]); const needMetNonlinear = wantsAny([ "frontend:met-nonlinear-integrated-visible", "frontend:met-nonlinear-project-tree-detail", "frontend:met-nonlinear-queue-detail-speed", ]); + const needLayoutOverflowDesktop = wants("frontend:layout-overflow-desktop"); + const needLayoutOverflowMobile = wants("frontend:layout-overflow-mobile"); const browser = await chromium.launch({ headless: true }); try { const page = await browser.newPage({ viewport: { width: 1440, height: 920 } }); @@ -859,6 +1007,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 let pendingTaskText = ""; let taskHistoryText = ""; let rawText = ""; + let performanceText = ""; let monitorText = ""; let processTableText = ""; let processMemoryValues: number[] = []; @@ -879,6 +1028,20 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 let todoNoteText = ""; let findjobText = ""; let codexQueueText = ""; + let codexQueueOutputText = ""; + let codexQueueTaskCount = 0; + let codexQueueOptions: string[] = []; + let codexQueueSwitchMetrics: any = { optionCount: 0, switched: false }; + let codexQueueSubmitQueueControl: any = { tagName: "", createButtonVisible: false, oldInputMissing: false }; + let codexQueueTracePlacement: any = { firstChildIsTrace: false, noPageTopStatus: false, filterInsideTracePanel: false, traceStatusVisible: false, markAllReadVisible: false }; + let codexQueueGlobalStatus: any = { activeMicroserviceVisible: false }; + let codexQueuePromptDefaultEmpty = false; + let codexQueueSubmitGuard: any = { batchRowVisible: false, disabledBeforeConfirm: false, enabledAfterConfirm: false, waitElementMissingBeforeSubmit: false }; + let codexQueueScrollbarMetrics: any = { transcriptThin: false, toolHorizontalHidden: true }; + let codexInitialPromptFullMetrics: any = { candidateFound: false }; + let codexTraceFullMetrics: any = { candidateFound: false }; + let codexJudgeWrapMetrics: any = { checked: false }; + let claudeqqText = ""; let routeDeepLinkText = ""; let routeInitialPath = ""; let routeDockerPath = ""; @@ -886,14 +1049,32 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 let routeBackPath = ""; let routeOverviewPath = ""; let routeOverviewText = ""; + let routeCodexPath = ""; + let routeCodexShellMetrics: any = { appShell: false, standalone: false, railText: "", topbar: false, tabsText: "", codexPage: false }; let pipelineText = ""; let pipelineOaPanelText = ""; + let pipelineMinimaxQuotaText = ""; + let pipelinePriorityOrder: any = { desktop: [], mobile: [] }; + let pipelineSidebarMetrics: any = { + nodeDefaultOpen: "", + nodeAfterClickOpen: "", + nodeAfterCollapseOpen: "", + nodeToggleEnabledAfterCollapse: false, + ganttDefaultOpen: "", + ganttLineCount: 0, + ganttAfterClickOpen: "", + ganttAfterCollapseOpen: "", + ganttToggleEnabledAfterCollapse: false, + mobileNodeCollapsed: "", + mobileGanttCollapsed: "", + }; let pipelineFlowNodeCount = 0; let pipelineFlowEdgeCount = 0; let pipelineSelectedId = ""; let pipelineGanttScaleLabel = ""; let pipelineGanttAutoHideIdleChecked = true; let pipelineGanttHeaderNodeOrder: string[] = []; + let pipelineGanttFrontendYMetrics: any = { layoutSource: "", checked: 0, maxDelta: null, violations: [] }; let pipelineGanttExportInfo: any = { downloaded: false, suggestedFilename: "", savePath: "", bytes: 0 }; let pipelineSnapshotForFrontend: any = null; let pipelineObservationSetup: any = null; @@ -901,7 +1082,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 let pipelineStepTimelineText = ""; let pipelineSessionHeadText = ""; let firstPipelineStepSummaryText = ""; - let pipelineTimelineMetrics = { clientWidth: 0, scrollWidth: 0, clientHeight: 0, scrollHeight: 0, hasHorizontalScroll: false, flowConnectorVisible: false, maxStepIdleGapMs: 0, idleGapCount: 0 }; + let pipelineTimelineMetrics = { clientWidth: 0, scrollWidth: 0, clientHeight: 0, scrollHeight: 0, hasHorizontalScroll: false, flowConnectorVisible: false, maxStepIdleGapMs: 0, idleGapCount: 0, oldPipelineStepStyleCount: 0, emptyAttemptDetailCount: 0, selectedNodeId: "", selectedProcedureRunId: "" }; let firstPipelineStepSummaryMetrics = { clientWidth: 0, scrollWidth: 0, clientHeight: 0, scrollHeight: 0, hasHorizontalScroll: false }; let firstPipelineStepExpandedText = ""; let metNonlinearInitialText = ""; @@ -909,6 +1090,8 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 let metProjectDetailText = ""; let metCompletedText = ""; let metJobDetailText = ""; + let layoutOverflowDesktop: OverflowProbe[] = []; + let layoutOverflowMobile: OverflowProbe[] = []; if (needSidebar) { railWidthBefore = await page.locator(".rail").evaluate((element) => Math.round(element.getBoundingClientRect().width)); @@ -926,7 +1109,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 if (needMobileRail || needMobileContent) { await page.setViewportSize({ width: 390, height: 860 }); if (needMobileRail) { - for (const moduleLabel of ["运行总览", "资源节点", "任务调度", "微服务", "系统配置"]) { + for (const moduleLabel of ["运行总览", "资源节点", "任务调度", "用户服务", "系统配置"]) { await page.getByRole("button", { name: new RegExp(moduleLabel) }).click(); await page.waitForTimeout(80); const height = await page.locator(".rail").evaluate((element) => Math.round(element.getBoundingClientRect().height)); @@ -953,7 +1136,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 await page.setViewportSize({ width: 1440, height: 920 }); } - if (needOverviewBody || needRawProviderJson || needPendingTask || needTaskHistory) { + if (needOverviewBody || needRawProviderJson || needPendingTask || needTaskHistory || needPerformancePanel) { await page.getByRole("button", { name: /运行总览/ }).click(); await page.getByRole("button", { name: /态势总览/ }).click(); await page.waitForSelector('[data-testid="overview-page"]', { timeout: 5000 }); @@ -980,6 +1163,21 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 rawText = await page.locator('[data-testid="raw-json"]').innerText({ timeout: 5000 }); await page.getByRole("button", { name: "关闭" }).click(); } + if (needPerformancePanel) { + await page.getByRole("button", { name: /性能面板/ }).click(); + await page.waitForSelector('[data-testid="performance-page"]', { timeout: 10000 }); + await page.waitForSelector('[data-testid="performance-memory-chart"]', { timeout: 10000 }); + await page.waitForFunction(() => { + const text = document.body.innerText; + return text.includes("性能面板") + && text.includes("组件汇总") + && text.includes("最近失败请求") + && text.includes("内部操作汇总") + && text.includes("最近慢操作") + && text.includes("Bwebui"); + }, undefined, { timeout: 15000 }); + performanceText = await page.locator('[data-testid="performance-page"]').innerText({ timeout: 5000 }); + } } if (needNodeMonitor || needDocker || needGatewayVersion) { @@ -1039,15 +1237,16 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 } } - if (needMicroserviceCatalog || needTodoNote || needFindJob || needCodexQueue || needRouteDeepLink || needPipeline || needMetNonlinear) { - await page.getByRole("button", { name: /微服务/ }).click(); - if (needMicroserviceCatalog || needTodoNote || needFindJob || needCodexQueue || needRouteDeepLink || needPipeline || needMetNonlinear) { + if (needMicroserviceCatalog || needTodoNote || needFindJob || needCodexQueue || needClaudeqq || needRouteDeepLink || needPipeline || needMetNonlinear) { + await page.getByRole("button", { name: /用户服务/ }).click(); + if (needMicroserviceCatalog || needTodoNote || needFindJob || needCodexQueue || needClaudeqq || needRouteDeepLink || needPipeline || needMetNonlinear) { await page.waitForSelector('[data-testid="microservice-catalog-page"]', { timeout: 10000 }); } if (needMicroserviceCatalog) { await page.waitForSelector('[data-testid="microservice-row-findjob"]', { timeout: 10000 }); 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-claudeqq"]', { 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 }); @@ -1111,7 +1310,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 await page.waitForFunction(() => { const text = document.body.innerText; const lower = text.toLowerCase(); - return lower.includes("codex queue 控制台") + return lower.includes("codex queue") && text.includes("gpt-5.4-mini") && text.includes("gpt-5.4") && text.includes("gpt-5.5") @@ -1122,7 +1321,342 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 && text.includes("打断") && lower.includes("attempts"); }, undefined, { timeout: 30000 }); - codexQueueText = await page.locator('[data-testid="codex-queue-page"]').innerText({ timeout: 5000 }); + await page.waitForSelector('[data-testid="codex-queue-filter-select"]', { timeout: 10000 }); + codexQueueTracePlacement = await page.evaluate(() => ({ + firstChildIsTrace: Boolean(document.querySelector('[data-testid="codex-queue-page"] > .codex-session-stage:first-child .codex-output-panel')), + noPageTopStatus: document.querySelector('[data-testid="codex-queue-page"] > [data-testid="codex-top-status"]') === null, + filterInsideTracePanel: Boolean(document.querySelector('.codex-output-panel [data-testid="codex-queue-filter-select"]')), + traceStatusVisible: /排队\s*\d+.*运行\s*\d+.*结束未读\s*\d+/s.test(document.querySelector('[data-testid="codex-trace-status-summary"]')?.textContent || ""), + markAllReadVisible: Boolean(document.querySelector('.codex-output-panel [data-testid="codex-mark-all-read-button"]')), + })); + codexQueueGlobalStatus = await page.evaluate(() => { + const text = document.querySelector('[data-testid="active-microservice-status"]')?.textContent || ""; + return { activeMicroserviceVisible: /Codex Queue.*在线|codex queue.*在线/i.test(text), text }; + }); + await page.waitForSelector('[data-testid="codex-queue-id-select"]', { timeout: 10000 }); + await page.waitForSelector('[data-testid="codex-create-queue-button"]', { timeout: 10000 }); + codexQueueSubmitQueueControl = await page.evaluate(() => { + const select = document.querySelector('[data-testid="codex-queue-id-select"]') as HTMLSelectElement | null; + const button = document.querySelector('[data-testid="codex-create-queue-button"]') as HTMLButtonElement | null; + const prompt = document.querySelector('[data-testid="codex-queue-task-form"] textarea') as HTMLTextAreaElement | null; + const maxAttempts = document.querySelector('[data-testid="codex-max-attempts-input"]') as HTMLInputElement | null; + const moveSelect = document.querySelector('[data-testid="codex-task-queue-move-select"]') as HTMLSelectElement | null; + const moveButton = document.querySelector('[data-testid="codex-task-queue-move-button"]') as HTMLButtonElement | null; + return { + tagName: select?.tagName.toLowerCase() || "", + optionCount: select?.options.length ?? 0, + createButtonVisible: Boolean(button && button.offsetParent !== null), + oldInputMissing: document.querySelector('[data-testid="codex-queue-id-input"]') === null, + promptDefaultEmpty: (prompt?.value || "") === "", + maxAttemptsMax: maxAttempts?.max || "", + maxAttemptsValue: maxAttempts?.value || "", + moveQueueVisible: Boolean(moveSelect && moveSelect.offsetParent !== null && moveButton && moveButton.offsetParent !== null), + }; + }); + codexQueuePromptDefaultEmpty = Boolean(codexQueueSubmitQueueControl.promptDefaultEmpty); + await page.locator('[data-testid="codex-queue-task-form"] textarea').fill("e2e batch guard one\n---\ne2e batch guard two"); + await page.waitForSelector('[data-testid="codex-batch-confirm-row"]', { timeout: 5000 }); + codexQueueSubmitGuard = await page.evaluate(() => { + const row = document.querySelector('[data-testid="codex-batch-confirm-row"]') as HTMLElement | null; + const checkbox = document.querySelector('[data-testid="codex-batch-confirm-checkbox"]') as HTMLInputElement | null; + const button = document.querySelector('[data-testid="codex-enqueue-button"]') as HTMLButtonElement | null; + const wait = document.querySelector('[data-testid="codex-submit-wait"]') as HTMLElement | null; + return { + batchRowVisible: Boolean(row && row.offsetParent !== null), + checkboxVisible: Boolean(checkbox && checkbox.offsetParent !== null), + disabledBeforeConfirm: Boolean(button?.disabled), + buttonTextBeforeConfirm: button?.textContent || "", + waitElementMissingBeforeSubmit: wait === null, + }; + }); + await page.locator('[data-testid="codex-batch-confirm-checkbox"]').check(); + await page.waitForFunction(() => { + const button = document.querySelector('[data-testid="codex-enqueue-button"]') as HTMLButtonElement | null; + return button !== null && !button.disabled; + }, undefined, { timeout: 5000 }); + codexQueueSubmitGuard = { + ...codexQueueSubmitGuard, + ...(await page.evaluate(() => { + const button = document.querySelector('[data-testid="codex-enqueue-button"]') as HTMLButtonElement | null; + return { + enabledAfterConfirm: Boolean(button && !button.disabled), + buttonTextAfterConfirm: button?.textContent || "", + }; + })), + }; + await page.locator('[data-testid="codex-clear-input-button"]').click(); + codexQueueOptions = await page.locator('[data-testid="codex-queue-filter-select"] option').evaluateAll((options) => options.map((option) => (option as HTMLOptionElement).textContent || "")); + codexQueueSwitchMetrics = await page.locator('[data-testid="codex-queue-filter-select"] option').evaluateAll((options) => ({ + optionCount: options.length, + queueValues: options.map((option) => (option as HTMLOptionElement).value).filter((value) => value !== "__all__"), + switched: false, + })); + await page.waitForSelector('[data-testid="codex-output"]', { timeout: 10000 }); + await page.waitForFunction(() => { + const taskCount = document.querySelectorAll('[data-testid^="codex-task-codex_"]').length; + const output = document.querySelector('[data-testid="codex-output"]')?.textContent || ""; + return taskCount === 0 || output.includes("Submitted prompt"); + }, undefined, { timeout: 15000 }); + codexQueueScrollbarMetrics = await page.evaluate(() => { + const transcript = document.querySelector('.codex-transcript') as HTMLElement | null; + const toolBlock = document.querySelector('.codex-transcript-item.ran .codex-transcript-command, .codex-transcript-item.ran .codex-transcript-body, .codex-transcript-item.explored .codex-transcript-command, .codex-transcript-item.explored .codex-transcript-body, .codex-transcript-item.edited .codex-transcript-command, .codex-transcript-item.edited .codex-transcript-body') as HTMLElement | null; + const transcriptStyle = transcript ? getComputedStyle(transcript) : null; + const toolStyle = toolBlock ? getComputedStyle(toolBlock) : null; + return { + transcriptThin: transcriptStyle?.scrollbarWidth === "thin", + transcriptScrollbarWidth: transcriptStyle?.scrollbarWidth || "", + toolFound: toolBlock !== null, + toolHorizontalHidden: toolBlock === null || toolStyle?.scrollbarWidth === "none", + toolScrollbarWidth: toolStyle?.scrollbarWidth || "", + toolOverflowX: toolStyle?.overflowX || "", + }; + }); + if (wants("frontend:codex-queue-judge-wrap")) { + codexJudgeWrapMetrics = await page.evaluate(() => { + const measure = (element: HTMLElement | null): any => { + if (element === null) return { found: false }; + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + const horizontalOverflowPx = Math.max(0, element.scrollWidth - element.clientWidth); + return { + found: true, + tag: element.tagName.toLowerCase(), + className: String(element.className || ""), + clientWidth: Math.round(element.clientWidth), + scrollWidth: Math.round(element.scrollWidth), + offsetWidth: Math.round(element.offsetWidth), + rectWidth: Math.round(rect.width), + horizontalOverflowPx: Math.round(horizontalOverflowPx), + noHorizontalOverflow: horizontalOverflowPx <= 1, + whiteSpace: style.whiteSpace, + overflowWrap: style.overflowWrap, + wordBreak: style.wordBreak, + overflowX: style.overflowX, + }; + }; + const longReason = `judge-regression-${"x".repeat(420)}`; + const longContinuePrompt = `continue-${"y".repeat(420)}\nnext-line-${"z".repeat(420)}`; + const probe = document.createElement("section"); + probe.className = "codex-progressive-card codex-progressive-judge"; + probe.setAttribute("data-testid", "codex-judge-wrap-probe"); + probe.style.cssText = "position: fixed; left: 16px; top: 16px; width: 320px; max-width: 320px; opacity: 0; pointer-events: none; z-index: -1;"; + const card = document.createElement("div"); + card.className = "codex-judge-card"; + card.setAttribute("data-testid", "codex-judge-wrap-probe-card"); + const badge = document.createElement("span"); + badge.className = "status-badge retry"; + badge.textContent = "retry"; + const confidence = document.createElement("strong"); + confidence.textContent = "92% confidence"; + const reason = document.createElement("p"); + reason.setAttribute("data-testid", "codex-judge-wrap-probe-reason"); + reason.textContent = longReason; + const continuePrompt = document.createElement("pre"); + continuePrompt.setAttribute("data-testid", "codex-judge-wrap-probe-continue"); + continuePrompt.textContent = longContinuePrompt; + card.append(badge, confidence, reason, continuePrompt); + probe.append(card); + document.body.append(probe); + const probeMetrics = { + card: measure(card), + reason: measure(reason), + continuePrompt: measure(continuePrompt), + }; + probe.remove(); + const actualCards = Array.from(document.querySelectorAll('[data-testid$="-judge-card"], [data-testid="codex-task-judge-card"], .codex-progressive-judge .codex-judge-card')) + .filter((element): element is HTMLElement => element instanceof HTMLElement) + .slice(0, 8) + .map((element) => measure(element)); + const noActualOverflow = actualCards.every((item: any) => item.noHorizontalOverflow === true); + const reasonWraps = probeMetrics.reason.overflowWrap === "anywhere" || probeMetrics.reason.wordBreak === "break-word"; + const continueWraps = probeMetrics.continuePrompt.whiteSpace === "pre-wrap" && (probeMetrics.continuePrompt.overflowWrap === "anywhere" || probeMetrics.continuePrompt.wordBreak === "break-word"); + return { + checked: true, + tokenLength: longReason.length, + probeWidth: 320, + probe: probeMetrics, + actualCardCount: actualCards.length, + actualCards, + reasonWraps, + continueWraps, + noActualOverflow, + ok: probeMetrics.card.noHorizontalOverflow === true + && probeMetrics.reason.noHorizontalOverflow === true + && probeMetrics.continuePrompt.noHorizontalOverflow === true + && reasonWraps + && continueWraps + && noActualOverflow, + }; + }); + } + codexQueueTaskCount = await page.locator('[data-testid^="codex-task-codex_"]').count(); + if (wants("frontend:codex-queue-initial-prompt-full-expand")) { + codexInitialPromptFullMetrics = await page.evaluate(async () => { + const tasksResponse = await fetch("/api/microservices/codex-queue/proxy/api/tasks?limit=300&lite=1&devReady=0", { credentials: "same-origin" }); + const tasksPayload = await tasksResponse.json().catch(() => null); + const tasks = Array.isArray(tasksPayload?.tasks) ? tasksPayload.tasks : []; + const candidate = tasks.find((task: any) => Array.isArray(task?.referenceTaskIds) && task.referenceTaskIds.length > 0); + if (!candidate?.id) return { candidateFound: false }; + const metaResponse = await fetch(`/api/microservices/codex-queue/proxy/api/tasks/${encodeURIComponent(String(candidate.id))}?meta=1`, { credentials: "same-origin" }); + const metaPayload = await metaResponse.json().catch(() => null); + const fullPrompt = String(metaPayload?.task?.prompt || ""); + const displayPrompt = String(metaPayload?.task?.displayPrompt || ""); + return { + candidateFound: true, + taskId: String(candidate.id), + promptChars: fullPrompt.length, + displayPromptChars: displayPrompt.length, + hasResolvedReference: fullPrompt.includes("# Codex Queue 已解析引用上下文"), + hasCurrentTaskMarker: fullPrompt.includes("# 本次任务"), + hasReferenceTaskId: /codex_\\d+_[A-Za-z0-9_-]+/u.test(fullPrompt), + }; + }); + if (codexInitialPromptFullMetrics.candidateFound) { + const refTaskCard = page.getByTestId(`codex-task-${codexInitialPromptFullMetrics.taskId}`); + await refTaskCard.scrollIntoViewIfNeeded({ timeout: 10000 }); + await refTaskCard.click(); + await page.waitForSelector('[data-testid="codex-initial-prompt-full"]', { timeout: 15000 }); + codexInitialPromptFullMetrics.initialDefaultOpen = await page.getByTestId("codex-initial-prompt-full").evaluate((element) => (element as HTMLDetailsElement).open); + await page.locator('[data-testid="codex-initial-prompt-full"] summary').click(); + await page.waitForFunction(() => (document.querySelector('[data-testid="codex-initial-prompt-full"]') as HTMLDetailsElement | null)?.open === true, undefined, { timeout: 5000 }); + const initialFullText = await page.getByTestId("codex-initial-prompt-full-text").innerText({ timeout: 5000 }); + codexInitialPromptFullMetrics.initialExpanded = await page.getByTestId("codex-initial-prompt-full").evaluate((element) => (element as HTMLDetailsElement).open); + codexInitialPromptFullMetrics.initialFullHasReference = initialFullText.includes("# Codex Queue 已解析引用上下文") || initialFullText.includes("引用 Codex Queue"); + codexInitialPromptFullMetrics.initialFullHasCurrentTask = initialFullText.includes("# 本次任务") || initialFullText.includes("本次任务:"); + codexInitialPromptFullMetrics.initialFullChars = initialFullText.length; + await page.waitForSelector('[data-testid="codex-final-prompt-full"]', { timeout: 10000 }); + codexInitialPromptFullMetrics.panelDefaultOpen = await page.getByTestId("codex-final-prompt-full").evaluate((element) => (element as HTMLDetailsElement).open); + await page.locator('[data-testid="codex-final-prompt-full"] summary').click(); + await page.waitForFunction(() => (document.querySelector('[data-testid="codex-final-prompt-full"]') as HTMLDetailsElement | null)?.open === true, undefined, { timeout: 5000 }); + const panelFullText = await page.getByTestId("codex-task-final-prompt-full").innerText({ timeout: 5000 }); + codexInitialPromptFullMetrics.panelExpanded = await page.getByTestId("codex-final-prompt-full").evaluate((element) => (element as HTMLDetailsElement).open); + codexInitialPromptFullMetrics.panelFullMatchesInitial = panelFullText === initialFullText; + codexInitialPromptFullMetrics.panelFullHasReference = panelFullText.includes("# Codex Queue 已解析引用上下文") || panelFullText.includes("引用 Codex Queue"); + codexInitialPromptFullMetrics.panelFullHasCurrentTask = panelFullText.includes("# 本次任务") || panelFullText.includes("本次任务:"); + codexInitialPromptFullMetrics.panelFullChars = panelFullText.length; + } + } + if (wants("frontend:codex-queue-trace-full-load")) { + codexTraceFullMetrics = await page.evaluate(async () => { + const tasksResponse = await fetch("/api/codex-queue-direct/api/tasks?limit=300&lite=1&devReady=0", { credentials: "same-origin" }); + const tasksPayload = await tasksResponse.json().catch(() => null); + const tasks = Array.isArray(tasksPayload?.tasks) ? tasksPayload.tasks : []; + const terminal = new Set(["succeeded", "failed", "canceled"]); + for (const task of tasks) { + const taskId = String(task?.id || ""); + if (!taskId || !terminal.has(String(task?.status || "")) || Number(task?.outputCount || 0) < 20) continue; + const transcriptResponse = await fetch(`/api/codex-queue-direct/api/tasks/${encodeURIComponent(taskId)}/transcript?afterSeq=0&limit=120&fullText=1`, { credentials: "same-origin" }); + const transcriptPayload = await transcriptResponse.json().catch(() => null); + const transcript = Array.isArray(transcriptPayload?.transcript) ? transcriptPayload.transcript : []; + const toolCount = transcript.filter((line: any) => ["ran", "explored", "edited"].includes(String(line?.kind || ""))).length; + if (toolCount >= 8) { + return { + candidateFound: true, + taskId, + apiTotal: Number(transcriptPayload?.total || transcript.length), + apiToolCount: toolCount, + apiHasInitialPrompt: transcript.some((line: any) => String(line?.title || "") === "Submitted prompt"), + }; + } + } + return { candidateFound: false, taskCount: tasks.length }; + }); + if (codexTraceFullMetrics.candidateFound) { + for (let index = 0; index < 8; index += 1) { + if (await page.getByTestId(`codex-task-${codexTraceFullMetrics.taskId}`).count()) break; + const moreButton = page.getByTestId("codex-load-more-tasks-button"); + if (!(await moreButton.count())) break; + await moreButton.scrollIntoViewIfNeeded().catch(() => undefined); + await moreButton.click(); + await page.waitForTimeout(500); + } + const traceTaskCard = page.getByTestId(`codex-task-${codexTraceFullMetrics.taskId}`); + await traceTaskCard.scrollIntoViewIfNeeded({ timeout: 10000 }); + await traceTaskCard.click(); + await page.waitForFunction(() => { + const pageElement = document.querySelector('[data-testid="codex-queue-page"]') as HTMLElement | null; + const output = document.querySelector('[data-testid="codex-output"]') as HTMLElement | null; + const toolCount = output?.querySelectorAll('.codex-transcript-item.ran, .codex-transcript-item.explored, .codex-transcript-item.edited').length ?? 0; + return pageElement?.getAttribute("data-load-state") === "complete" && toolCount >= 8; + }, undefined, { timeout: 30000 }); + codexTraceFullMetrics = { + ...codexTraceFullMetrics, + ...(await page.evaluate(() => { + const pageElement = document.querySelector('[data-testid="codex-queue-page"]') as HTMLElement | null; + const output = document.querySelector('[data-testid="codex-output"]') as HTMLElement | null; + const text = output?.textContent || ""; + return { + uiItemCount: output?.querySelectorAll('.codex-transcript-item').length ?? 0, + uiToolCount: output?.querySelectorAll('.codex-transcript-item.ran, .codex-transcript-item.explored, .codex-transcript-item.edited').length ?? 0, + uiHasInitialPrompt: text.includes("Submitted prompt"), + uiHasToolTrace: /Ran|Explored|Edited|Tool calls/.test(text), + loadState: pageElement?.getAttribute("data-load-state") || "", + loadPartial: pageElement?.getAttribute("data-load-partial") || "", + loadRows: Number(pageElement?.getAttribute("data-load-transcript-rows") || 0), + loadButtonVisible: document.querySelector('[data-testid="codex-load-full-trace-button"]') !== null, + }; + })), + }; + } + } + codexQueueOutputText = await page.locator('[data-testid="codex-output"]').innerText({ timeout: 5000 }); + codexQueueText = await page.locator('[data-testid="codex-queue-page"]').innerText({ timeout: 5000 }); + codexQueueOptions = await page.locator('[data-testid="codex-queue-filter-select"] option').evaluateAll((options) => options.map((option) => (option as HTMLOptionElement).textContent || "")); + codexQueueSubmitQueueControl = await page.evaluate(() => { + const select = document.querySelector('[data-testid="codex-queue-id-select"]') as HTMLSelectElement | null; + const button = document.querySelector('[data-testid="codex-create-queue-button"]') as HTMLButtonElement | null; + const prompt = document.querySelector('[data-testid="codex-queue-task-form"] textarea') as HTMLTextAreaElement | null; + const maxAttempts = document.querySelector('[data-testid="codex-max-attempts-input"]') as HTMLInputElement | null; + const moveSelect = document.querySelector('[data-testid="codex-task-queue-move-select"]') as HTMLSelectElement | null; + const moveButton = document.querySelector('[data-testid="codex-task-queue-move-button"]') as HTMLButtonElement | null; + return { + tagName: select?.tagName.toLowerCase() || "", + optionCount: select?.options.length ?? 0, + createButtonVisible: Boolean(button && button.offsetParent !== null), + oldInputMissing: document.querySelector('[data-testid="codex-queue-id-input"]') === null, + promptDefaultEmpty: (prompt?.value || "") === "", + maxAttemptsMax: maxAttempts?.max || "", + maxAttemptsValue: maxAttempts?.value || "", + moveQueueVisible: Boolean(moveSelect && moveSelect.offsetParent !== null && moveButton && moveButton.offsetParent !== null), + }; + }); + codexQueuePromptDefaultEmpty = Boolean(codexQueueSubmitQueueControl.promptDefaultEmpty); + codexQueueSwitchMetrics = await page.locator('[data-testid="codex-queue-filter-select"] option').evaluateAll((options) => ({ + optionCount: options.length, + queueValues: options.map((option) => (option as HTMLOptionElement).value).filter((value) => value !== "__all__"), + switched: false, + })); + if (codexQueueSwitchMetrics.queueValues.length > 0) { + const targetQueueId = String(codexQueueSwitchMetrics.queueValues.find((value: string) => value !== "default") || codexQueueSwitchMetrics.queueValues[0]); + await page.getByTestId("codex-queue-filter-select").selectOption(targetQueueId); + await page.waitForFunction((queueId) => { + const text = document.body.innerText; + return text.includes(`view=${queueId}`) || text.includes(`${queueId} ·`); + }, targetQueueId, { timeout: 10000 }); + await page.waitForFunction((queueId) => { + const cards = Array.from(document.querySelectorAll('[data-testid^="codex-task-codex_"]')).map((node) => (node as HTMLElement).innerText); + return cards.length === 0 || cards.every((text) => text.includes(`queue=${queueId}`)); + }, targetQueueId, { timeout: 15000 }); + codexQueueSwitchMetrics = { ...codexQueueSwitchMetrics, targetQueueId, switched: true }; + await page.getByTestId("codex-queue-filter-select").selectOption("__all__"); + } + } + if (needClaudeqq) { + await page.getByRole("button", { name: /ClaudeQQ/ }).click(); + await page.waitForSelector('[data-testid="claudeqq-page"]', { timeout: 10000 }); + await page.waitForFunction(() => { + const text = document.body.innerText; + const lower = text.toLowerCase(); + return lower.includes("claudeqq 工作台") + && text.includes("D601") + && text.includes("QQ 事件订阅") + && text.includes("消息推送") + && lower.includes("napcat 容器登录") + && text.includes("事件缓存") + && text.includes("仅 UniDesk frontend 代理访问"); + }, undefined, { timeout: 30000 }); + await page.waitForFunction(() => document.body.innerText.includes("最近 QQ 事件"), undefined, { timeout: 10000 }); + claudeqqText = await page.locator('[data-testid="claudeqq-page"]').innerText({ timeout: 5000 }); } if (needRouteDeepLink) { await page.goto(`${urls.frontendUrl}/app/pipeline/`, { waitUntil: "domcontentloaded", timeout: 15000 }); @@ -1145,11 +1679,24 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 await page.waitForSelector('[data-testid="overview-page"]', { timeout: 10000 }); routeOverviewPath = new URL(page.url()).pathname; routeOverviewText = await page.locator('[data-testid="overview-page"]').innerText({ timeout: 5000 }); - await page.getByRole("button", { name: /微服务/ }).click(); + await page.goto(`${urls.frontendUrl}/app/codex-queue/`, { waitUntil: "domcontentloaded", timeout: 15000 }); + await page.waitForSelector('[data-testid="app-shell"]', { timeout: 10000 }); + await page.waitForSelector('[data-testid="codex-queue-page"]', { timeout: 15000 }); + routeCodexPath = new URL(page.url()).pathname; + routeCodexShellMetrics = await page.evaluate(() => ({ + appShell: Boolean(document.querySelector('[data-testid="app-shell"]')), + standalone: Boolean(document.querySelector('[data-testid="codex-queue-standalone"]')), + railText: document.querySelector(".rail")?.textContent || "", + topbar: Boolean(document.querySelector(".topbar")), + tabsText: document.querySelector(".tabs")?.textContent || "", + codexPage: Boolean(document.querySelector('[data-testid="codex-queue-page"]')), + })); + await page.locator('.rail button[title="用户服务"]').click(); + await page.getByRole("button", { name: /^服务目录$/ }).click(); await page.waitForSelector('[data-testid="microservice-catalog-page"]', { timeout: 10000 }); } if (needPipeline) { - await page.getByRole("button", { name: /Pipeline/ }).click(); + await page.getByRole("button", { name: "Pipeline", exact: true }).click(); await page.waitForSelector('[data-testid="pipeline-page"]', { timeout: 10000 }); await page.waitForSelector('[data-testid="pipeline-react-flow"] .react-flow__node', { timeout: 30000 }); await page.waitForFunction(() => { @@ -1180,11 +1727,132 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 }, 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 }); + pipelineMinimaxQuotaText = await page.locator('[data-testid="pipeline-minimax-quota-panel"]').innerText({ timeout: 5000 }); + const pipelinePanelOrder = async () => page.locator('[data-testid="pipeline-page"] .pipeline-grid > .panel .panel-head h2').evaluateAll((elements) => elements.map((element) => (element as HTMLElement).innerText.trim()).filter(Boolean)); + pipelinePriorityOrder.desktop = await pipelinePanelOrder(); + await page.setViewportSize({ width: 390, height: 860 }); + await page.waitForTimeout(120); + pipelinePriorityOrder.mobile = await pipelinePanelOrder(); + await page.setViewportSize({ width: 1440, height: 920 }); + await page.waitForTimeout(120); 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)); + pipelineSidebarMetrics.nodeDefaultOpen = await page.getByTestId("pipeline-control-shell").getAttribute("data-sidebar-open") || ""; + const firstFlowNode = page.locator('[data-testid="pipeline-react-flow"] .react-flow__node').first(); + await firstFlowNode.scrollIntoViewIfNeeded({ timeout: 10000 }); + await firstFlowNode.click({ force: true }); + const firstNodeClickOpened = await page.waitForFunction(() => document.querySelector('[data-testid="pipeline-control-shell"]')?.getAttribute("data-sidebar-open") === "true", undefined, { timeout: 2500 }) + .then(() => true) + .catch(() => false); + if (!firstNodeClickOpened) { + const box = await firstFlowNode.boundingBox(); + if (box) await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); + } + await page.waitForFunction(() => document.querySelector('[data-testid="pipeline-control-shell"]')?.getAttribute("data-sidebar-open") === "true", undefined, { timeout: 15000 }); + await page.waitForSelector('[data-testid="pipeline-node-control"]', { timeout: 5000 }); + pipelineSidebarMetrics.nodeAfterClickOpen = await page.getByTestId("pipeline-control-shell").getAttribute("data-sidebar-open") || ""; + await page.getByTestId("pipeline-node-sidebar-collapse").click(); + await page.waitForFunction(() => document.querySelector('[data-testid="pipeline-control-shell"]')?.getAttribute("data-sidebar-open") === "false", undefined, { timeout: 10000 }); + pipelineSidebarMetrics.nodeAfterCollapseOpen = await page.getByTestId("pipeline-control-shell").getAttribute("data-sidebar-open") || ""; + pipelineSidebarMetrics.nodeToggleEnabledAfterCollapse = !(await page.getByTestId("pipeline-node-sidebar-toggle").isDisabled()); + pipelineSidebarMetrics.ganttDefaultOpen = await page.getByTestId("pipeline-gantt-detail-layout").getAttribute("data-sidebar-open") || ""; + const sidebarProbeGanttLines = page.locator('[data-testid="pipeline-epoch-gantt"] [data-testid="pipeline-gantt-line"]'); + pipelineSidebarMetrics.ganttLineCount = await sidebarProbeGanttLines.count(); + if (pipelineSidebarMetrics.ganttLineCount > 0) { + const firstSidebarProbeGanttLine = sidebarProbeGanttLines.first(); + await firstSidebarProbeGanttLine.scrollIntoViewIfNeeded({ timeout: 10000 }); + await firstSidebarProbeGanttLine.click({ force: true }); + const firstGanttClickOpened = await page.waitForFunction(() => document.querySelector('[data-testid="pipeline-gantt-detail-layout"]')?.getAttribute("data-sidebar-open") === "true", undefined, { timeout: 2500 }) + .then(() => true) + .catch(() => false); + if (!firstGanttClickOpened) { + const box = await firstSidebarProbeGanttLine.boundingBox(); + if (box) await page.mouse.click(box.x + box.width / 2, box.y + Math.max(2, Math.min(box.height / 2, 12))); + } + await page.waitForFunction(() => document.querySelector('[data-testid="pipeline-gantt-detail-layout"]')?.getAttribute("data-sidebar-open") === "true", undefined, { timeout: 15000 }); + await page.waitForSelector('[data-testid="pipeline-gantt-detail-panel"]', { timeout: 5000 }); + pipelineSidebarMetrics.ganttAfterClickOpen = await page.getByTestId("pipeline-gantt-detail-layout").getAttribute("data-sidebar-open") || ""; + await page.getByTestId("pipeline-gantt-sidebar-collapse").click(); + await page.waitForFunction(() => document.querySelector('[data-testid="pipeline-gantt-detail-layout"]')?.getAttribute("data-sidebar-open") === "false", undefined, { timeout: 10000 }); + pipelineSidebarMetrics.ganttAfterCollapseOpen = await page.getByTestId("pipeline-gantt-detail-layout").getAttribute("data-sidebar-open") || ""; + pipelineSidebarMetrics.ganttToggleEnabledAfterCollapse = !(await page.getByTestId("pipeline-gantt-sidebar-toggle").isDisabled()); + } + await page.setViewportSize({ width: 390, height: 860 }); + await page.waitForTimeout(120); + pipelineSidebarMetrics.mobileNodeCollapsed = await page.getByTestId("pipeline-control-shell").getAttribute("data-sidebar-open") || ""; + pipelineSidebarMetrics.mobileGanttCollapsed = await page.getByTestId("pipeline-gantt-detail-layout").getAttribute("data-sidebar-open") || ""; + await page.setViewportSize({ width: 1440, height: 920 }); + await page.waitForTimeout(120); + if (wants("frontend:pipeline-gantt-frontend-y-accuracy")) { + pipelineGanttFrontendYMetrics = await page.locator('[data-testid="pipeline-epoch-gantt"]').evaluate((element) => { + const root = element as HTMLElement; + const startMs = Number(root.dataset.startMs || "0"); + const endMs = Number(root.dataset.endMs || "0"); + const chartHeight = Number(root.dataset.chartHeight || "0"); + const duration = Math.max(1, endMs - startMs); + const expectedY = (ms: number): number => Math.max(0, Math.min(1, (ms - startMs) / duration)) * chartHeight; + const violations: any[] = []; + let checked = 0; + let maxDelta = 0; + const addCheck = (kind: string, id: string, actual: number, expected: number, tolerance = 1.25): void => { + if (!Number.isFinite(actual) || !Number.isFinite(expected)) { + violations.push({ kind, id, actual, expected, reason: "non-finite" }); + return; + } + checked += 1; + const delta = Math.abs(actual - expected); + maxDelta = Math.max(maxDelta, delta); + if (delta > tolerance) violations.push({ kind, id, actual: Math.round(actual * 1000) / 1000, expected: Math.round(expected * 1000) / 1000, delta: Math.round(delta * 1000) / 1000 }); + }; + const parseTop = (node: HTMLElement): number => Number.parseFloat(node.style.top || window.getComputedStyle(node).top || "NaN"); + const parseHeight = (node: HTMLElement): number => Number.parseFloat(node.style.height || window.getComputedStyle(node).height || "NaN"); + for (const tick of Array.from(root.querySelectorAll('[data-testid="pipeline-gantt-tick"]')) as HTMLElement[]) { + const ms = Number(tick.dataset.ms || "NaN"); + const y = Number(tick.dataset.y || parseTop(tick)); + const top = parseTop(tick); + addCheck("tick-data-y", tick.dataset.ms || "", y, expectedY(ms)); + addCheck("tick-style-top", tick.dataset.ms || "", top, expectedY(ms)); + } + for (const line of Array.from(root.querySelectorAll('[data-testid="pipeline-gantt-line"]')) as HTMLElement[]) { + const id = line.dataset.procedureRunId || line.dataset.nodeId || ""; + const start = Number(line.dataset.startMs || "NaN"); + const end = Number(line.dataset.endMs || "NaN"); + const y1 = Number(line.dataset.y1 || "NaN"); + const y2 = Number(line.dataset.y2 || "NaN"); + const top = parseTop(line); + const height = parseHeight(line); + const expectedStartY = expectedY(start); + const expectedEndY = expectedY(end); + const expectedHeight = Math.max(line.dataset.live === "true" ? 24 : 10, expectedEndY - expectedStartY); + addCheck("line-y1", id, y1, expectedStartY); + addCheck("line-y2", id, y2, expectedEndY); + addCheck("line-style-top", id, top, expectedStartY); + addCheck("line-style-height", id, height, expectedHeight, 1.5); + } + for (const marker of Array.from(root.querySelectorAll('[data-marker-id]')) as HTMLElement[]) { + const ms = Number(marker.dataset.ms || "NaN"); + const y = Number(marker.dataset.y || parseTop(marker)); + const top = parseTop(marker); + addCheck("marker-data-y", marker.dataset.markerId || "", y, expectedY(ms)); + addCheck("marker-style-top", marker.dataset.markerId || "", top, expectedY(ms)); + } + return { + layoutSource: root.dataset.layoutSource || "", + startMs, + endMs, + chartHeight, + checked, + maxDelta: Math.round(maxDelta * 1000) / 1000, + violations: violations.slice(0, 12), + tickCount: root.querySelectorAll('[data-testid="pipeline-gantt-tick"]').length, + lineCount: root.querySelectorAll('[data-testid="pipeline-gantt-line"]').length, + markerCount: root.querySelectorAll('[data-marker-id]').length, + }; + }); + } 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; @@ -1208,38 +1876,44 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 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(); + const regressionGanttLine = page.locator('[data-testid="pipeline-epoch-gantt"] [data-testid="pipeline-gantt-line"][data-node-id="repair-1"]').first(); + const firstGanttLine = (await regressionGanttLine.count()) > 0 + ? regressionGanttLine + : page.locator('[data-testid="pipeline-epoch-gantt"] [data-testid="pipeline-gantt-line"]').first(); await firstGanttLine.scrollIntoViewIfNeeded({ timeout: 10000 }); + const selectedGanttAttrs = await firstGanttLine.evaluate((element) => { + const target = element as HTMLElement; + return { + selectedNodeId: target.dataset.nodeId || "", + selectedProcedureRunId: target.dataset.procedureRunId || "", + }; + }); await firstGanttLine.click({ force: true }); - await page.waitForSelector('[data-testid="pipeline-step-timeline"] [data-testid="pipeline-opencode-step"]', { timeout: 30000 }); + await page.waitForSelector('[data-testid="pipeline-step-timeline"] [data-testid="pipeline-opencode-step-trace"]', { timeout: 30000 }); pipelineStepTimelineText = await page.locator('[data-testid="pipeline-step-timeline"]').innerText({ timeout: 5000 }); pipelineSessionHeadText = await page.locator('[data-testid="pipeline-step-timeline-session"]').innerText({ timeout: 5000 }); - const firstPipelineStep = page.locator('[data-testid="pipeline-opencode-step"]').first(); - const firstPipelineStepSummary = firstPipelineStep.locator('[data-testid="pipeline-opencode-step-summary"]'); - firstPipelineStepSummaryText = await firstPipelineStepSummary.innerText({ timeout: 5000 }); - pipelineTimelineMetrics = await page.locator('[data-testid="pipeline-step-timeline"]').evaluate((element) => { + const pipelineTrace = page.locator('[data-testid="pipeline-opencode-step-trace"]').first(); + const pipelineTraceHead = page.locator('[data-testid="pipeline-step-timeline"] .pipeline-trace-head').first(); + firstPipelineStepSummaryText = await pipelineTraceHead.innerText({ timeout: 5000 }); + pipelineTimelineMetrics = await page.locator('[data-testid="pipeline-step-timeline"]').evaluate((element, selected) => { 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); + const oldStepCards = Array.from(target.querySelectorAll('[data-testid="pipeline-opencode-step"], .pipeline-opencode-flow, .pipeline-opencode-step')) as HTMLElement[]; 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, + flowConnectorVisible: false, + maxStepIdleGapMs: 0, + idleGapCount: 0, + oldPipelineStepStyleCount: oldStepCards.length, + emptyAttemptDetailCount: target.innerText.includes("暂无 attempt 详情") ? 1 : 0, + selectedNodeId: selected.selectedNodeId, + selectedProcedureRunId: selected.selectedProcedureRunId, }; - }); - firstPipelineStepSummaryMetrics = await firstPipelineStepSummary.evaluate((element) => { + }, selectedGanttAttrs); + firstPipelineStepSummaryMetrics = await pipelineTrace.evaluate((element) => { const target = element as HTMLElement; return { clientWidth: target.clientWidth, @@ -1249,9 +1923,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 hasHorizontalScroll: target.scrollWidth > target.clientWidth + 1, }; }); - await firstPipelineStepSummary.click({ force: true }); - await firstPipelineStep.locator('[data-testid="pipeline-opencode-step-body"]').waitFor({ state: "visible", timeout: 10000 }); - firstPipelineStepExpandedText = await firstPipelineStep.innerText({ timeout: 5000 }); + firstPipelineStepExpandedText = await pipelineTrace.innerText({ timeout: 5000 }); } if (wants("frontend:pipeline-gantt-observation-live-running")) { const candidate = await page.evaluate(async () => { @@ -1262,17 +1934,12 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 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" }); + const detailResponse = await fetch(`/api/microservices/pipeline/proxy/api/node-control/runs/${encodeURIComponent(runId)}?tail=160&view=timeline&_=${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"); + const hasObservation = JSON.stringify(detail).includes("node-long-running-observation"); + const hasRunning = (Array.isArray(detail?.procedureRuns) ? detail.procedureRuns : []).some((procedure: any) => + String(procedure?.status?.status || procedure?.artifact?.status || procedure?.status || "").toLowerCase() === "running"); if (hasObservation && hasRunning) return { pipelineId: String(run?.pipelineId || detail?.request?.pipelineId || ""), runId }; } return null; @@ -1376,11 +2043,20 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 } } + if (needLayoutOverflowDesktop) { + layoutOverflowDesktop = await collectLayoutOverflow(page, { width: 1440, height: 920 }, "desktop"); + } + if (needLayoutOverflowMobile) { + layoutOverflowMobile = await collectLayoutOverflow(page, { width: 390, height: 860 }, "mobile"); + await page.setViewportSize({ width: 1440, height: 920 }); + } + await page.screenshot({ path: screenshotPath, fullPage: true }); const microserviceCatalogTextLower = microserviceCatalogText.toLowerCase(); const todoNoteTextLower = todoNoteText.toLowerCase(); const findjobTextLower = findjobText.toLowerCase(); const codexQueueTextLower = codexQueueText.toLowerCase(); + const claudeqqTextLower = claudeqqText.toLowerCase(); const pipelineTextLower = pipelineText.toLowerCase(); const activePipeline = Array.isArray(pipelineSnapshotForFrontend?.pipelines) ? pipelineSnapshotForFrontend.pipelines.find((pipeline: any) => String(pipeline?.id || "") === pipelineSelectedId) || pipelineSnapshotForFrontend.pipelines[0] @@ -1404,6 +2080,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 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) }); + addSelectedCheck(checks, options, "frontend:performance-panel-visible", performanceText.includes("性能面板") && performanceText.includes("Bwebui") && performanceText.includes("组件汇总") && performanceText.includes("最近失败请求") && performanceText.includes("内部操作汇总") && performanceText.includes("最近慢操作"), { performanceTextPreview: performanceText.slice(0, 1200) }); addSelectedCheck(checks, options, "frontend:system-monitor-visible", monitorText.includes("任务管理器视图") && monitorText.includes("CPU") && monitorText.includes("Memory") && monitorText.includes("Disk") && monitorText.includes("不含缓存") && monitorText.includes("进程资源占用"), { monitorTextPreview: monitorText.slice(0, 1000) }); addSelectedCheck(checks, options, "frontend:process-resource-sorting", processTableText.includes("进程") && processTableText.includes("PID") && processTableText.includes("CPU") && processTableText.includes("内存") && processTableText.includes("磁盘 I/O") && processMemorySortAria === "descending" && processDefaultMemoryDescending && processCpuSortAria === "descending" && processCpuDescending, { processMemorySortAria, processCpuSortAria, processMemoryValues: processMemoryValues.slice(0, 12), processCpuValues: processCpuValues.slice(0, 12), processTablePreview: processTableText.slice(0, 1000) }); addSelectedCheck(checks, options, "frontend:upgrade-plan-dispatch", upgradeControlText.includes("预检升级 已下发") && upgradeControlText.includes("指定 Provider") && upgradeControlText.includes(`v${providerGatewayPackageVersion()}`), { providerId: config.providerGateway.id, upgradeControlPreview: upgradeControlText.slice(0, 500) }); @@ -1412,13 +2089,81 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 addSelectedCheck(checks, options, "frontend:gateway-duration-subsecond-visible", gatewayHasSubsecondDuration && !gatewayHasRoundedZeroDuration, { gatewayHasSubsecondDuration, gatewayHasRoundedZeroDuration, gatewayTextPreview: gatewayText.slice(0, 900) }); addSelectedCheck(checks, options, "frontend:provider-operation-availability-visible", sshAvailabilityTexts.length >= 1 && upgradeAvailabilityTexts.length >= 1 && sshAvailabilityTexts.every((text) => text.includes("SSH 透传")) && upgradeAvailabilityTexts.every((text) => text.includes("远程更新")) && upgradeAvailabilityTexts.some((text) => text.includes("always-enabled")), { sshAvailabilityTexts, upgradeAvailabilityTexts }); addSelectedCheck(checks, options, "frontend:overview-pgdata-visible", bodyText.includes("PGDATA") && bodyText.includes(config.database.volume), { bodyPreview: bodyText.slice(0, 800) }); - addSelectedCheck(checks, options, "frontend:microservice-catalog-visible", microserviceCatalogTextLower.includes("findjob") && microserviceCatalogTextLower.includes("pipeline") && microserviceCatalogTextLower.includes("todo note") && microserviceCatalogTextLower.includes("met nonlinear") && microserviceCatalogTextLower.includes("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:microservice-catalog-visible", microserviceCatalogTextLower.includes("findjob") && microserviceCatalogTextLower.includes("pipeline") && microserviceCatalogTextLower.includes("todo note") && microserviceCatalogTextLower.includes("met nonlinear") && microserviceCatalogTextLower.includes("claudeqq") && 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/agent_skills") && microserviceCatalogText.includes("https://gitee.com/Lyon1998/todo_note") && microserviceCatalogText.includes("https://github.com/pikasTech/unidesk"), { microserviceCatalogPreview: microserviceCatalogText.slice(0, 2000) }); addSelectedCheck(checks, options, "frontend:todo-note-integrated-visible", todoNoteTextLower.includes("todo note 工作台") && todoNoteText.includes("CONSTAR") && todoNoteText.includes("大论文") && todoNoteText.includes("UI E2E smoke task") && todoNoteText.includes("撤销") && todoNoteText.includes("重做") && todoNoteText.includes("全部展开") && todoNoteText.includes("仅 UniDesk frontend 代理访问"), { todoNoteTextPreview: todoNoteText.slice(0, 1400) }); - addSelectedCheck(checks, options, "frontend:findjob-integrated-visible", findjobTextLower.includes("findjob 工作台".toLowerCase()) && findjobText.includes("岗位总量") && findjobText.includes("D601") && findjobText.includes("近期岗位") && findjobText.includes("仅 UniDesk frontend 代理访问") && /岗位总量\s+\d+/.test(findjobText) && /health\s+ok/i.test(findjobText) && /[1-9]\d*\/[1-9]\d*\s+preview/i.test(findjobText), { findjobTextPreview: findjobText.slice(0, 1200) }); - addSelectedCheck(checks, options, "frontend:codex-queue-integrated-visible", codexQueueTextLower.includes("codex queue 控制台".toLowerCase()) && codexQueueText.includes("gpt-5.4-mini") && codexQueueText.includes("gpt-5.4") && codexQueueText.includes("gpt-5.5") && codexQueueText.includes("提交任务") && 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("控制图") && 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: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") && codexQueueText.includes("gpt-5.4-mini") && codexQueueText.includes("gpt-5.4") && codexQueueText.includes("gpt-5.5") && codexQueueText.includes("提交任务") && codexQueueText.includes("入队份数") && codexQueueText.includes("追加 prompt") && codexQueueText.includes("打断") && codexQueueTextLower.includes("查看 queue") && codexQueueText.includes("创建 queue") && codexQueueOptions.some((text) => text.includes("All queues")) && codexQueueTracePlacement.firstChildIsTrace === true && codexQueueTracePlacement.noPageTopStatus === true && codexQueueTracePlacement.filterInsideTracePanel === true && codexQueueTracePlacement.traceStatusVisible === true && codexQueueTracePlacement.markAllReadVisible === true && codexQueueGlobalStatus.activeMicroserviceVisible === true && codexQueueSubmitQueueControl.tagName === "select" && codexQueueSubmitQueueControl.createButtonVisible === true && codexQueueSubmitQueueControl.oldInputMissing === true && codexQueueSubmitQueueControl.maxAttemptsMax === "99" && codexQueueSubmitQueueControl.maxAttemptsValue === "99" && codexQueueSubmitQueueControl.moveQueueVisible === true && codexQueuePromptDefaultEmpty === true && codexQueueSubmitGuard.batchRowVisible === true && codexQueueSubmitGuard.checkboxVisible === true && codexQueueSubmitGuard.disabledBeforeConfirm === true && codexQueueSubmitGuard.enabledAfterConfirm === true && codexQueueSubmitGuard.waitElementMissingBeforeSubmit === true && codexQueueScrollbarMetrics.transcriptThin === true && codexQueueScrollbarMetrics.toolHorizontalHidden === true && (codexQueueSwitchMetrics.optionCount <= 1 || codexQueueSwitchMetrics.switched === true) && codexQueueTextLower.includes("attempts") && codexQueueText.includes("仅 UniDesk frontend 代理访问") && (codexQueueTaskCount === 0 || codexQueueOutputText.includes("Submitted prompt")), { codexQueueTaskCount, codexQueueOptions, codexQueueSwitchMetrics, codexQueueSubmitQueueControl, codexQueueSubmitGuard, codexQueueScrollbarMetrics, codexQueuePromptDefaultEmpty, codexQueueTracePlacement, codexQueueGlobalStatus, codexQueueOutputPreview: codexQueueOutputText.slice(0, 900), codexQueueTextPreview: codexQueueText.slice(0, 1400) }); + addSelectedCheck(checks, options, "frontend:codex-queue-initial-prompt-full-expand", + codexInitialPromptFullMetrics.candidateFound === false + || ( + codexInitialPromptFullMetrics.promptChars > codexInitialPromptFullMetrics.displayPromptChars + && codexInitialPromptFullMetrics.initialDefaultOpen === false + && codexInitialPromptFullMetrics.initialExpanded === true + && codexInitialPromptFullMetrics.initialFullHasReference === true + && codexInitialPromptFullMetrics.initialFullHasCurrentTask === true + && codexInitialPromptFullMetrics.panelDefaultOpen === false + && codexInitialPromptFullMetrics.panelExpanded === true + && codexInitialPromptFullMetrics.panelFullMatchesInitial === true + && codexInitialPromptFullMetrics.panelFullHasReference === true + && codexInitialPromptFullMetrics.panelFullHasCurrentTask === true + ), + { codexInitialPromptFullMetrics }); + addSelectedCheck(checks, options, "frontend:codex-queue-trace-full-load", + codexTraceFullMetrics.candidateFound === false + || ( + codexTraceFullMetrics.apiTotal >= 20 + && codexTraceFullMetrics.apiToolCount >= 8 + && codexTraceFullMetrics.apiHasInitialPrompt === true + && codexTraceFullMetrics.uiItemCount >= 10 + && codexTraceFullMetrics.uiToolCount >= 8 + && codexTraceFullMetrics.uiHasInitialPrompt === true + && codexTraceFullMetrics.uiHasToolTrace === true + && codexTraceFullMetrics.loadState === "complete" + && codexTraceFullMetrics.loadPartial !== "true" + ), + { codexTraceFullMetrics }); + addSelectedCheck(checks, options, "frontend:codex-queue-judge-wrap", + codexJudgeWrapMetrics.checked === true && codexJudgeWrapMetrics.ok === true, + { codexJudgeWrapMetrics }); + addSelectedCheck(checks, options, "frontend:claudeqq-integrated-visible", claudeqqTextLower.includes("claudeqq 工作台") && claudeqqText.includes("D601") && claudeqqText.includes("QQ 事件订阅") && claudeqqText.includes("消息推送") && claudeqqText.includes("事件缓存") && claudeqqText.includes("主用户私聊账号") && claudeqqText.includes("645275593") && claudeqqTextLower.includes("napcat 容器登录") && (claudeqqText.includes("二维码") || claudeqqText.includes("QR SOURCE") || claudeqqText.includes("QR Source") || claudeqqText.includes("已登录")) && claudeqqText.includes("仅 UniDesk frontend 代理访问") && !claudeqqText.includes("{\n"), { claudeqqTextPreview: claudeqqText.slice(0, 1400) }); + addSelectedCheck(checks, options, "frontend:url-route-deeplink", routeInitialPath === "/app/pipeline/" && routeDockerPath === "/nodes/docker/" && routeBackPath === "/app/pipeline/" && routeOverviewPath === "/ops/status/" && routeCodexPath === "/app/codex-queue/" && routeDeepLinkText.toLowerCase().includes("pipeline v2 工作台".toLowerCase()) && routeOverviewText.includes("核心指标") && routeCodexShellMetrics.appShell === true && routeCodexShellMetrics.standalone === false && routeCodexShellMetrics.topbar === true && routeCodexShellMetrics.codexPage === true && String(routeCodexShellMetrics.railText || "").includes("用户服务") && String(routeCodexShellMetrics.tabsText || "").includes("Codex Queue"), { routeInitialPath, routeDockerPath, routeBackIntermediatePath, routeBackPath, routeOverviewPath, routeCodexPath, routeCodexShellMetrics, 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("控制图") + && 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) + && pipelinePriorityOrder.desktop[0] === "控制图" + && /epoch\s+甘特图/i.test(String(pipelinePriorityOrder.desktop[1] || "")) + && pipelinePriorityOrder.mobile[0] === "控制图" + && /epoch\s+甘特图/i.test(String(pipelinePriorityOrder.mobile[1] || "")), + { pipelinePriorityOrder, pipelineTextPreview: pipelineText.slice(0, 1200) }); addSelectedCheck(checks, options, "frontend:pipeline-react-flow-visible", pipelineFlowNodeCount > 0 && pipelineFlowEdgeCount > 0, { pipelineFlowNodeCount, pipelineFlowEdgeCount }); + addSelectedCheck(checks, options, "frontend:pipeline-sidebars-collapsible", + pipelineSidebarMetrics.nodeDefaultOpen === "false" + && pipelineSidebarMetrics.nodeAfterClickOpen === "true" + && pipelineSidebarMetrics.nodeAfterCollapseOpen === "false" + && pipelineSidebarMetrics.nodeToggleEnabledAfterCollapse === true + && pipelineSidebarMetrics.ganttDefaultOpen === "false" + && Number(pipelineSidebarMetrics.ganttLineCount || 0) > 0 + && pipelineSidebarMetrics.ganttAfterClickOpen === "true" + && pipelineSidebarMetrics.ganttAfterCollapseOpen === "false" + && pipelineSidebarMetrics.ganttToggleEnabledAfterCollapse === true + && pipelineSidebarMetrics.mobileNodeCollapsed === "false" + && pipelineSidebarMetrics.mobileGanttCollapsed === "false", + { pipelineSidebarMetrics }); + addSelectedCheck(checks, options, "frontend:pipeline-minimax-quota-visible", + pipelineMinimaxQuotaText.includes("MiniMax") + && pipelineMinimaxQuotaText.includes("当前窗口") + && pipelineMinimaxQuotaText.includes("剩余额度") + && pipelineMinimaxQuotaText.includes("重置时间") + && !pipelineMinimaxQuotaText.includes("{"), + { pipelineMinimaxQuotaPreview: pipelineMinimaxQuotaText.slice(0, 1000) }); addSelectedCheck(checks, options, "frontend:pipeline-oa-event-flow-visible", pipelineOaPanelText.toLowerCase().includes("oa flow") && pipelineOaPanelText.includes("100%") @@ -1435,6 +2180,12 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 expectedGanttNodeOrder, downstreamViolations, }); + addSelectedCheck(checks, options, "frontend:pipeline-gantt-frontend-y-accuracy", + pipelineGanttFrontendYMetrics.layoutSource === "frontend-y" + && Number(pipelineGanttFrontendYMetrics.checked || 0) > 0 + && Number(pipelineGanttFrontendYMetrics.maxDelta || 0) <= 1.25 + && (pipelineGanttFrontendYMetrics.violations || []).length === 0, + { pipelineGanttFrontendYMetrics }); addSelectedCheck(checks, options, "frontend:pipeline-gantt-export", pipelineGanttExportInfo.downloaded === true && Number(pipelineGanttExportInfo.bytes || 0) > 2048 @@ -1451,21 +2202,24 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 && pipelineObservationGanttMetrics?.hasLiveSweep === true, { pipelineObservationGanttMetrics }); addSelectedCheck(checks, options, "frontend:pipeline-step-timeline-visible", - pipelineStepTimelineText.includes("OpenCode Step Timeline") - && pipelineStepTimelineText.includes("时间") - && pipelineStepTimelineText.includes("工具调用") + pipelineStepTimelineText.includes("OpenCode Trace") + && pipelineStepTimelineText.includes("Codex Queue") + && pipelineStepTimelineText.toLowerCase().includes("tools") + && pipelineStepTimelineText.includes("Trace") && !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 + && Number(pipelineTimelineMetrics.oldPipelineStepStyleCount || 0) === 0 + && Number(pipelineTimelineMetrics.emptyAttemptDetailCount || 0) === 0 && !firstPipelineStepSummaryMetrics.hasHorizontalScroll - && firstPipelineStepSummaryMetrics.clientHeight <= 190 - && firstPipelineStepExpandedText.toLowerCase().includes("tokens") - && firstPipelineStepExpandedText.includes("用户输入"), + && firstPipelineStepSummaryMetrics.clientHeight <= 900 + && (firstPipelineStepExpandedText.includes("Message") + || firstPipelineStepExpandedText.includes("Tool calls") + || firstPipelineStepExpandedText.includes("Ran")), { pipelineStepTimelinePreview: pipelineStepTimelineText.slice(0, 1600), pipelineSessionHeadText, @@ -1477,6 +2231,8 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 addSelectedCheck(checks, options, "frontend:met-nonlinear-integrated-visible", metNonlinearTextLower.includes("met nonlinear 训练编排") && metNonlinearInitialText.includes("D601") && metNonlinearInitialText.includes("当前队列") && metNonlinearInitialText.includes("GPU/镜像") && metNonlinearInitialText.includes("Fork Project") && metNonlinearInitialText.includes("加入待启动队列") && metNonlinearInitialText.includes("启动队列") && !metNonlinearInitialText.includes("创建10个10轮任务") && metNonlinearInitialText.includes("仅 UniDesk frontend 代理访问") && /Health\s+OK/i.test(metNonlinearInitialText), { metNonlinearTextPreview: metNonlinearInitialText.slice(0, 1400) }); addSelectedCheck(checks, options, "frontend:met-nonlinear-project-tree-detail", metProjectTreeText.includes("projects") && metProjectTreeText.includes("ex_projects") && metProjectDetailText.toLowerCase().includes("project 详情") && metProjectDetailText.toLowerCase().includes("config.json") && metProjectDetailText.toLowerCase().includes("data/ 训练状态") && metProjectDetailText.includes("模型参数") && metProjectDetailText.includes("指标") && metProjectDetailText.toLowerCase().includes("total params") && !metProjectDetailText.includes('{\n'), { metProjectTreePreview: metProjectTreeText.slice(0, 1200), metProjectDetailPreview: metProjectDetailText.slice(0, 1400) }); addSelectedCheck(checks, options, "frontend:met-nonlinear-queue-detail-speed", metCompletedText.includes("速度") && metCompletedText.toLowerCase().includes("epoch/h") && metJobDetailText.includes("训练任务详情") && metJobDetailText.includes("训练速度") && metJobDetailText.toLowerCase().includes("epoch/h") && metJobDetailText.toLowerCase().includes("config.json") && metJobDetailText.toLowerCase().includes("data/ 训练状态"), { metCompletedPreview: metCompletedText.slice(0, 1200), metJobDetailPreview: metJobDetailText.slice(0, 1400) }); + addSelectedCheck(checks, options, "frontend:layout-overflow-desktop", layoutOverflowDesktop.length > 0 && layoutOverflowDesktop.every((item) => item.ok), { probes: layoutOverflowDesktop }); + addSelectedCheck(checks, options, "frontend:layout-overflow-mobile", layoutOverflowMobile.length > 0 && layoutOverflowMobile.every((item) => item.ok), { probes: layoutOverflowMobile }); addSelectedCheck(checks, options, "frontend:no-console-errors", consoleErrors.length === 0, { consoleErrors }); return { screenshotPath, bodyText, consoleErrors }; } finally { diff --git a/scripts/src/jobs.ts b/scripts/src/jobs.ts index 8e8e13e3..fb7acd7e 100644 --- a/scripts/src/jobs.ts +++ b/scripts/src/jobs.ts @@ -1,4 +1,4 @@ -import { spawn } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { repoRoot, rootPath } from "./config"; @@ -12,6 +12,9 @@ export interface JobRecord { status: JobStatus; command: string[]; cwd: string; + runner: "local" | "docker"; + runnerPid?: number | null; + runnerContainer?: string | null; createdAt: string; startedAt: string | null; finishedAt: string | null; @@ -21,6 +24,11 @@ export interface JobRecord { note: string; } +export interface StartJobOptions { + runner?: "local" | "docker"; + dockerImage?: string; +} + function jobsDir(): string { const dir = rootPath(".state", "jobs"); mkdirSync(dir, { recursive: true }); @@ -48,16 +56,20 @@ export function listJobs(): JobRecord[] { .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); } -export function startJob(name: string, command: string[], note: string): JobRecord { +export function startJob(name: string, command: string[], note: string, options: StartJobOptions = {}): JobRecord { const id = `${name}_${new Date().toISOString().replace(/[-:.TZ]/g, "")}_${Math.random().toString(16).slice(2, 8)}`; const stdoutFile = rootPath(".state", "jobs", `${id}.stdout.log`); const stderrFile = rootPath(".state", "jobs", `${id}.stderr.log`); + const runner = options.runner ?? "local"; const job: JobRecord = { id, name, status: "queued", command, cwd: repoRoot, + runner, + runnerPid: null, + runnerContainer: null, createdAt: new Date().toISOString(), startedAt: null, finishedAt: null, @@ -67,12 +79,50 @@ export function startJob(name: string, command: string[], note: string): JobReco note, }; writeJob(job); + if (runner === "docker") { + const containerName = `unidesk-job-runner-${id}`.replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 120); + job.runnerContainer = containerName; + writeJob(job); + const dockerArgs = [ + "run", + "-d", + "--rm", + "--name", + containerName, + "-v", + "/var/run/docker.sock:/var/run/docker.sock", + "-v", + `${repoRoot}:${repoRoot}`, + "-w", + repoRoot, + "--entrypoint", + "bun", + options.dockerImage ?? "unidesk-codex-queue:latest", + rootPath("scripts", "cli.ts"), + "internal", + "run-job", + id, + ]; + const result = spawnSync("docker", dockerArgs, { cwd: repoRoot, encoding: "utf8" }); + if (result.status !== 0) { + job.status = "failed"; + job.startedAt = new Date().toISOString(); + job.finishedAt = new Date().toISOString(); + job.exitCode = result.status ?? 127; + writeFileSync(stderrFile, result.stderr || result.error?.message || "failed to start docker job runner\n", "utf8"); + writeFileSync(stdoutFile, result.stdout || "", "utf8"); + writeJob(job); + } + return job; + } const child = spawn(process.execPath, [rootPath("scripts", "cli.ts"), "internal", "run-job", id], { cwd: repoRoot, detached: true, stdio: "ignore", env: process.env, }); + job.runnerPid = child.pid ?? null; + writeJob(job); child.unref(); return job; } @@ -80,6 +130,7 @@ export function startJob(name: string, command: string[], note: string): JobReco export async function runJob(id: string): Promise { const job = readJob(id); job.status = "running"; + job.runnerPid = process.pid; job.startedAt = new Date().toISOString(); writeJob(job); const exitCode = await runCommandToFiles(job.command, job.cwd, job.stdoutFile, job.stderrFile); diff --git a/scripts/src/microservices.ts b/scripts/src/microservices.ts index ef266e58..b2cabe83 100644 --- a/scripts/src/microservices.ts +++ b/scripts/src/microservices.ts @@ -2,7 +2,7 @@ import { runCommand } from "./command"; import { type UniDeskConfig, repoRoot } from "./config"; import { jsonByteLength, previewJson } from "./preview"; -function coreInternalFetch(path: string, init?: { method?: string; body?: unknown }): unknown { +export function coreInternalFetch(path: string, init?: { method?: string; body?: unknown }): unknown { if (!path.startsWith("/")) throw new Error("core internal path must start with /"); const code = ` const res = await fetch(${JSON.stringify(`http://127.0.0.1:8080${path}`)}, ${JSON.stringify({ diff --git a/scripts/src/remote.ts b/scripts/src/remote.ts index a7ce9cb5..677e7900 100644 --- a/scripts/src/remote.ts +++ b/scripts/src/remote.ts @@ -2,7 +2,8 @@ import { spawn } from "node:child_process"; import { type UniDeskConfig } from "./config"; import { type DebugDispatchCommand, isDebugDispatchCommand } from "./debug"; import { summarizeMicroserviceProxyResponse } from "./microservices"; -import { parseSshArgs } from "./ssh"; +import { isSshSkillDiscoveryArgs, parseSshArgs } from "./ssh"; +import { codexOutputQueryAsync, codexTaskQueryAsync } from "./codex-queue"; export interface RemoteCliOptions { host: string | null; @@ -236,6 +237,95 @@ function jsonOption(args: string[], name: string): Record | und return parsed as Record; } +const compactSkillDiscoverPython = String.raw`import os,json,socket,platform,getpass +from pathlib import Path as P +S=os.getenv('S','all');L=int(os.getenv('L','0'));D=int(os.getenv('D','4'));skip={'node_modules','.git','.state','logs','references','__pycache__'} +def isw(): + try:r=P('/proc/sys/kernel/osrelease').read_text(errors='ignore').lower() + except Exception:r='' + return 'microsoft' in r or 'wsl' in r or 'WSL_INTEROP' in os.environ +def wp(p): + s=str(p) + return s[5].upper()+':\\'+s[7:].replace('/','\\') if s.startswith('/mnt/') and len(s)>6 and s[5].isalpha() and s[6]=='/' else None +def md(f): + n=f.parent.name;d='' + try:ls=f.read_text(errors='replace')[:8192].splitlines() + except Exception:ls=[] + if ls and ls[0].strip()=='---': + for l in ls[1:]: + if l.strip()=='---':break + if ':' in l: + k,v=l.split(':',1);k=k.strip().lower();v=v.strip().strip('"\' ') + if k=='name' and v:n=v + if k=='description' and v:d=v + if not d: + for l in ls: + x=l.strip() + if x and not x.startswith('---') and not x.startswith('#'): + d=x;break + return n,d +def scan(sc,root): + rec={'scope':sc,'path':str(root),'windowsPath':wp(root),'exists':False,'skillCount':0,'error':None};out=[] + try:rec['exists']=root.exists() + except Exception as e:rec['error']=str(e) + if not rec['exists']:return rec,out + try: + for f in root.rglob('SKILL.md'): + rel=f.relative_to(root).parts[:-1] + if not rel or len(rel)>D or any(x in skip for x in rel):continue + n,d=md(f);out.append({'scope':sc,'name':n,'description':d,'path':str(f.parent),'skillMd':str(f),'windowsPath':wp(f.parent),'root':str(root)}) + except Exception as e:rec['error']=str(e) + rec['skillCount']=len(out);return rec,out +roots=[];h=P.home() +if S!='windows':roots += [('wsl',h/'.agents/skills'),('wsl',h/'.codex/skills'),('wsl',P('/root/.agents/skills')),('wsl',P('/root/.codex/skills'))] +if S!='wsl' and isw(): + try:users=list(P('/mnt/c/Users').iterdir()) + except Exception:users=[] + for u in users: + if u.is_dir() and u.name.lower() not in {'all users','default','default user','public'}:roots += [('windows',u/'.agents/skills'),('windows',u/'.codex/skills')] +seen=set();rr=[];ss=[] +for sc,r in roots: + k=(sc,str(r)) + if k in seen:continue + seen.add(k);a,b=scan(sc,r);rr.append(a);ss+=b +ss.sort(key=lambda x:(0 if x['scope']=='wsl' else 1,x['name'].lower(),x['path']));tb=len(ss) +if L>0:ss=ss[:L] +c={'total':len(ss),'totalBeforeLimit':tb,'wsl':sum(1 for x in ss if x['scope']=='wsl'),'windows':sum(1 for x in ss if x['scope']=='windows')} +print(json.dumps({'ok':True,'command':'unidesk ssh skills','node':{'hostname':socket.gethostname(),'user':getpass.getuser(),'home':str(P.home()),'platform':platform.platform(),'isWsl':isw()},'counts':c,'roots':rr,'skills':ss},ensure_ascii=False,indent=2))`; + +function remoteFrontendSkillDiscoverCommand(args: string[]): string { + let scope = "all"; + let limit = 0; + let maxDepth = 4; + const start = args[0] === "skill" ? 2 : 1; + for (let index = start; index < args.length; index += 1) { + const arg = args[index] ?? ""; + const next = args[index + 1]; + if (arg === "--scope") { + if (next !== "all" && next !== "wsl" && next !== "windows") throw new Error("ssh skills --scope must be one of: all, wsl, windows"); + scope = next; + index += 1; + continue; + } + if (arg === "--limit") { + const value = Number(next); + if (!Number.isInteger(value) || value < 0) throw new Error("ssh skills --limit must be >= 0"); + limit = value; + index += 1; + continue; + } + if (arg === "--max-depth") { + const value = Number(next); + if (!Number.isInteger(value) || value <= 0) throw new Error("ssh skills --max-depth must be positive"); + maxDepth = value; + index += 1; + continue; + } + throw new Error(`remote frontend ssh skills does not support option: ${arg}`); + } + return `S=${shellQuote(scope)} L=${shellQuote(String(limit))} D=${shellQuote(String(maxDepth))} python3 -c ${shellQuote(compactSkillDiscoverPython)}`; +} + function dispatchPayload(args: string[], command: DebugDispatchCommand): Record { const explicit = jsonOption(args, "--payload-json") ?? {}; if (command === "provider.upgrade") { @@ -378,6 +468,22 @@ async function remoteMicroservice(session: FrontendSession, args: string[]): Pro throw new Error("remote microservice command must be: microservice list | status | health | proxy "); } +async function remoteCodexQueue(session: FrontendSession, args: string[]): Promise { + const action = args[1] ?? "task"; + if (action !== "task" && action !== "summary" && action !== "show" && action !== "output") { + throw new Error("remote codex command must be: codex task or codex output "); + } + const taskId = args[2]; + if (taskId === undefined || taskId.length === 0) throw new Error(`codex ${action} requires task id`); + const fetcher = (path: string): Promise => frontendJson(session, path, undefined, 24_000); + return { + transport: "frontend", + result: action === "output" + ? await codexOutputQueryAsync(taskId, args.slice(3), fetcher) + : await codexTaskQueryAsync(taskId, args.slice(3), fetcher), + }; +} + async function runRemoteSshOverFrontend(session: FrontendSession, providerId: string | undefined, args: string[]): Promise { if (!providerId) throw new Error("remote ssh requires provider id, for example: bun scripts/cli.ts --main-server-ip 74.48.78.17 ssh D601 hostname"); const parsed = parseSshArgs(args); @@ -389,12 +495,17 @@ async function runRemoteSshOverFrontend(session: FrontendSession, providerId: st process.stderr.write("remote frontend transport supports ssh remote commands only; pass a command such as: ssh D601 hostname\n"); return 255; } + if (args[0] === "glob") { + process.stderr.write("remote frontend transport does not support the ssh glob helper because host.ssh exec has a short command-length limit; run it on the main server CLI instead\n"); + return 255; + } + const remoteCommand = isSshSkillDiscoveryArgs(args) ? remoteFrontendSkillDiscoverCommand(args) : parsed.remoteCommand; const dispatch = await frontendJson(session, "/api/dispatch", { method: "POST", body: JSON.stringify({ providerId, command: "host.ssh", - payload: { source: "cli-remote-ssh", mode: "exec", command: parsed.remoteCommand, timeoutMs: 15000 }, + payload: { source: "cli-remote-ssh", mode: "exec", command: remoteCommand, timeoutMs: isSshSkillDiscoveryArgs(args) ? 30000 : 15000 }, }), }); const taskId = (dispatch as { body?: { taskId?: string } }).body?.taskId ?? ""; @@ -427,7 +538,7 @@ async function runRemoteCliOverFrontend(options: RemoteCliOptions, config: UniDe emitRemoteJson(name, { transport: "frontend", baseUrl: session.baseUrl, - commands: ["debug health", "debug dispatch", "debug task", "ssh ", "microservice list", "microservice status ", "microservice health ", "microservice proxy "], + commands: ["debug health", "debug dispatch", "debug task", "ssh ", "ssh skills", "microservice list", "microservice status ", "microservice health ", "microservice proxy ", "codex task "], }); return 0; } @@ -447,6 +558,10 @@ async function runRemoteCliOverFrontend(options: RemoteCliOptions, config: UniDe emitRemoteJson(name, await remoteMicroservice(session, args)); return 0; } + if (top === "codex") { + emitRemoteJson(name, await remoteCodexQueue(session, args)); + return 0; + } if (top === "ssh") { return await runRemoteSshOverFrontend(session, sub, args.slice(2)); } diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index a55fe6bc..e5a9a68e 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -212,6 +212,256 @@ def main(): return 0 +if __name__ == "__main__": + raise SystemExit(main()) +`; + +const remoteSkillDiscoverSource = String.raw`#!/usr/bin/env python3 +import argparse +import getpass +import json +import os +import platform +import socket +import sys +from datetime import datetime, timezone +from pathlib import Path + + +SKIP_PARTS = {"node_modules", ".git", ".state", "logs", "references", "__pycache__"} + + +def is_wsl(): + try: + release = Path("/proc/sys/kernel/osrelease").read_text(errors="ignore").lower() + except Exception: + release = "" + return "microsoft" in release or "wsl" in release or "WSL_INTEROP" in os.environ + + +def to_windows_path(path): + text = str(path) + if text.startswith("/mnt/") and len(text) >= 7 and text[5].isalpha() and text[6] == "/": + drive = text[5].upper() + rest = text[7:].replace("/", "\\") + return drive + ":\\" + rest + return None + + +def read_bounded(path, limit=16384): + try: + data = path.read_bytes()[:limit] + return data.decode("utf-8", errors="replace") + except Exception: + return "" + + +def frontmatter_value(line): + if ":" not in line: + return None + key, value = line.split(":", 1) + return key.strip().lower(), value.strip().strip("\"'") + + +def parse_skill_metadata(skill_md): + text = read_bounded(skill_md) + name = skill_md.parent.name + description = "" + lines = text.splitlines() + if lines and lines[0].strip() == "---": + for line in lines[1:]: + if line.strip() == "---": + break + item = frontmatter_value(line) + if item is None: + continue + key, value = item + if key == "name" and value: + name = value + if key == "description" and value: + description = value + if not description: + for line in lines: + stripped = line.strip() + if stripped and not stripped.startswith("---") and not stripped.startswith("#"): + description = stripped + break + return name, description + + +def iter_skill_files(root, max_depth): + try: + iterator = root.rglob("SKILL.md") + for skill_md in iterator: + try: + rel = skill_md.relative_to(root) + except ValueError: + continue + directory_parts = rel.parts[:-1] + if len(directory_parts) == 0 or len(directory_parts) > max_depth: + continue + if any(part in SKIP_PARTS for part in directory_parts): + continue + yield skill_md + except Exception as exc: + raise RuntimeError(str(exc)) from exc + + +def unique_paths(paths): + seen = set() + output = [] + for raw in paths: + path = Path(raw).expanduser() + key = str(path) + if key in seen: + continue + seen.add(key) + output.append(path) + return output + + +def default_wsl_roots(): + home = Path.home() + roots = [home / ".agents" / "skills", home / ".codex" / "skills"] + for raw in ("/root/.agents/skills", "/root/.codex/skills"): + path = Path(raw) + if str(path) not in {str(item) for item in roots}: + roots.append(path) + return roots + + +def default_windows_roots(): + if not is_wsl(): + return [] + users = Path("/mnt/c/Users") + roots = [] + try: + children = list(users.iterdir()) if users.exists() else [] + except Exception: + children = [] + for child in children: + try: + if not child.is_dir(): + continue + except Exception: + continue + lower = child.name.lower() + if lower in {"all users", "default", "default user", "public"}: + continue + roots.append(child / ".agents" / "skills") + roots.append(child / ".codex" / "skills") + return roots + + +def scan_root(scope, root, max_depth): + try: + root_exists = root.exists() + root_error = None + except Exception as exc: + root_exists = False + root_error = str(exc) + record = { + "scope": scope, + "path": str(root), + "windowsPath": to_windows_path(root), + "exists": root_exists, + "skillCount": 0, + "error": root_error, + } + skills = [] + if not record["exists"]: + return record, skills + try: + for skill_md in iter_skill_files(root, max_depth): + name, description = parse_skill_metadata(skill_md) + skill = { + "scope": scope, + "name": name, + "description": description, + "path": str(skill_md.parent), + "skillMd": str(skill_md), + "windowsPath": to_windows_path(skill_md.parent), + "root": str(root), + } + skills.append(skill) + except Exception as exc: + record["error"] = str(exc) + record["skillCount"] = len(skills) + return record, skills + + +def main(): + parser = argparse.ArgumentParser(description="discover WSL/Linux and Windows skill directories from a UniDesk ssh passthrough session") + parser.add_argument("--scope", choices=["all", "wsl", "windows"], default="all", help="which skill roots to scan") + parser.add_argument("--max-depth", type=int, default=4, help="maximum directory depth below each skill root") + parser.add_argument("--limit", type=int, default=0, help="maximum skill rows to return; 0 means unlimited") + parser.add_argument("--root", action="append", default=[], help="extra WSL/Linux skill root") + parser.add_argument("--windows-root", action="append", default=[], help="extra Windows skill root, expressed as /mnt//...") + args = parser.parse_args() + + if args.max_depth <= 0: + print(json.dumps({"ok": False, "error": "--max-depth must be positive"}, ensure_ascii=False)) + return 2 + if args.limit < 0: + print(json.dumps({"ok": False, "error": "--limit must be >= 0"}, ensure_ascii=False)) + return 2 + + roots = [] + if args.scope in ("all", "wsl"): + roots.extend(("wsl", path) for path in default_wsl_roots()) + roots.extend(("wsl", Path(raw).expanduser()) for raw in args.root) + if args.scope in ("all", "windows"): + roots.extend(("windows", path) for path in default_windows_roots()) + roots.extend(("windows", Path(raw).expanduser()) for raw in args.windows_root) + + seen = set() + unique = [] + for scope, path in roots: + key = (scope, str(path)) + if key in seen: + continue + seen.add(key) + unique.append((scope, path)) + + root_records = [] + skills = [] + for scope, root in unique: + record, found = scan_root(scope, root, args.max_depth) + root_records.append(record) + skills.extend(found) + + scope_order = {"wsl": 0, "windows": 1} + skills.sort(key=lambda item: (scope_order.get(str(item["scope"]), 9), str(item["name"]).lower(), str(item["path"]))) + total_before_limit = len(skills) + if args.limit > 0: + skills = skills[:args.limit] + + counts = {"total": len(skills), "totalBeforeLimit": total_before_limit, "wsl": 0, "windows": 0} + for skill in skills: + scope = str(skill["scope"]) + if scope in counts: + counts[scope] += 1 + + payload = { + "ok": True, + "command": "unidesk ssh skills", + "generatedAt": datetime.now(timezone.utc).isoformat(), + "node": { + "hostname": socket.gethostname(), + "user": getpass.getuser(), + "home": str(Path.home()), + "platform": platform.platform(), + "isWsl": is_wsl(), + "python": sys.version.split()[0], + }, + "counts": counts, + "roots": root_records, + "skills": skills, + } + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 + + if __name__ == "__main__": raise SystemExit(main()) `; @@ -220,8 +470,17 @@ const sshOptionsWithValue = new Set([ "-B", "-b", "-c", "-D", "-E", "-e", "-F", "-I", "-i", "-J", "-L", "-l", "-m", "-O", "-o", "-p", "-Q", "-R", "-S", "-W", "-w", ]); +export function isSshSkillDiscoveryArgs(args: string[]): boolean { + const subcommand = args[0] ?? ""; + return subcommand === "skills" || subcommand === "skill-discover" || subcommand === "discover-skills" || (subcommand === "skill" && args[1] === "discover"); +} + export function parseSshArgs(args: string[]): ParsedSshArgs { const subcommand = args[0] ?? ""; + if (isSshSkillDiscoveryArgs(args)) { + const toolArgs = subcommand === "skill" ? ["skill-discover", ...args.slice(2)] : ["skill-discover", ...args.slice(1)]; + return { remoteCommand: shellArgv(toolArgs), requiresStdin: false }; + } if (subcommand === "apply-patch" || subcommand === "patch") { const toolArgs = ["apply_patch", ...args.slice(1)]; return { remoteCommand: shellArgv(toolArgs), requiresStdin: true }; @@ -379,18 +638,21 @@ function buildPythonStdinCommand(args: string[]): string { function remoteToolBootstrapCommand(): string { const encodedApplyPatch = Buffer.from(remoteApplyPatchSource, "utf8").toString("base64"); const encodedGlob = Buffer.from(remoteGlobSource, "utf8").toString("base64"); + const encodedSkillDiscover = Buffer.from(remoteSkillDiscoverSource, "utf8").toString("base64"); return [ "UNIDESK_SSH_TOOL_DIR=/tmp/unidesk-ssh-tools", 'mkdir -p "$UNIDESK_SSH_TOOL_DIR"', `printf %s ${shellQuote(encodedApplyPatch)} | base64 -d > "$UNIDESK_SSH_TOOL_DIR/apply_patch"`, `printf %s ${shellQuote(encodedGlob)} | base64 -d > "$UNIDESK_SSH_TOOL_DIR/glob"`, + `printf %s ${shellQuote(encodedSkillDiscover)} | base64 -d > "$UNIDESK_SSH_TOOL_DIR/skill-discover"`, 'chmod 700 "$UNIDESK_SSH_TOOL_DIR/apply_patch"', 'chmod 700 "$UNIDESK_SSH_TOOL_DIR/glob"', + 'chmod 700 "$UNIDESK_SSH_TOOL_DIR/skill-discover"', 'export PATH="$UNIDESK_SSH_TOOL_DIR:$PATH"', ].join("; "); } -function wrapRemoteCommand(command: string | null): string { +export function wrapSshRemoteCommand(command: string | null): string { const bootstrap = remoteToolBootstrapCommand(); if (command === null) return `${bootstrap}; exec "\${SHELL:-/bin/bash}" -l`; return `${bootstrap}; stty -echo 2>/dev/null || true; ${command}`; @@ -533,7 +795,7 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st const openTimeoutMs = Math.max(15000, Number(process.env.UNIDESK_SSH_OPEN_TIMEOUT_MS || 60000)); const payload = { providerId, - command: wrapRemoteCommand(parsed.remoteCommand), + command: wrapSshRemoteCommand(parsed.remoteCommand), tty: parsed.remoteCommand === null, stdinEotOnEnd: parsed.remoteCommand !== null, openTimeoutMs, diff --git a/src/components/backend-core/src/index.ts b/src/components/backend-core/src/index.ts index a1baf1bc..da2a7be6 100644 --- a/src/components/backend-core/src/index.ts +++ b/src/components/backend-core/src/index.ts @@ -84,6 +84,44 @@ type ProviderSocket = ServerWebSocket; type SqlClient = ReturnType; +interface RequestPerformanceSample { + at: string; + component: string; + method: string; + path: string; + status: number; + durationMs: number; + ok: boolean; +} + +interface OperationPerformanceSample { + at: string; + service: string; + operation: string; + durationMs: number; + ok: boolean; + detail: string; +} + +interface RawTaskRow { + id: string; + provider_id: string; + command: string; + status: string; + payload: JsonValue; + result: JsonValue | null; + updated_at: Date | string; +} + +type TaskTerminalWaiter = (task: RawTaskRow | null) => void; + +interface MicroserviceProxyCacheEntry { + expiresAt: number; + status: number; + contentType: string; + bodyText: string; +} + const recentLogs: unknown[] = []; const activeProviders = new Map(); const activeSshClients = new Map(); @@ -97,6 +135,14 @@ const sql = postgres(config.databaseUrl, { }); let dbReady = false; +const requestPerformanceSamples: RequestPerformanceSample[] = []; +const operationPerformanceSamples: OperationPerformanceSample[] = []; +const maxPerformanceSamples = 3000; +const taskTerminalWaiters = new Map>(); +const microserviceProxyCache = new Map(); +let lastTaskSweepAt = 0; +let taskSweepInFlight: Promise | null = null; +const microserviceProxyMaxBodyTextLength = 8 * 1024 * 1024; function requiredEnv(name: string): string { const value = process.env[name]; @@ -264,6 +310,197 @@ function textResponse(text: string, status = 200): Response { }); } +function trimPerformanceBuffers(): void { + while (requestPerformanceSamples.length > maxPerformanceSamples) requestPerformanceSamples.shift(); + while (operationPerformanceSamples.length > maxPerformanceSamples) operationPerformanceSamples.shift(); +} + +function classifyRequestComponent(pathname: string): string { + if (pathname.startsWith("/api/microservices/") && pathname.includes("/proxy/")) return "microservice_proxy"; + if (pathname.startsWith("/api/microservices/")) return "microservice_registry"; + if (pathname.startsWith("/api/nodes/")) return "node_metrics_api"; + if (pathname === "/api/nodes") return "node_inventory_api"; + if (pathname.startsWith("/api/tasks")) return "scheduler_api"; + if (pathname.startsWith("/api/events")) return "event_api"; + if (pathname.startsWith("/api/performance")) return "performance_api"; + if (pathname.startsWith("/api/")) return "core_api"; + if (pathname.startsWith("/ws/")) return "websocket_api"; + if (pathname === "/logs") return "logs_api"; + return "core_http"; +} + +function recordRequestPerformance(req: Request, pathname: string, response: Response | undefined, durationMs: number): void { + const status = response?.status ?? 101; + requestPerformanceSamples.push({ + at: new Date().toISOString(), + component: classifyRequestComponent(pathname), + method: req.method, + path: pathname, + status, + durationMs, + ok: status < 400, + }); + trimPerformanceBuffers(); +} + +function recordOperationPerformance(service: string, operation: string, durationMs: number, ok: boolean, detail = "-"): void { + operationPerformanceSamples.push({ + at: new Date().toISOString(), + service, + operation, + durationMs, + ok, + detail: detail.length > 260 ? `${detail.slice(0, 257)}...` : detail, + }); + trimPerformanceBuffers(); +} + +async function withPerformanceOperation(service: string, operation: string, detail: string, fn: () => Promise): Promise { + const started = performance.now(); + try { + const result = await fn(); + recordOperationPerformance(service, operation, performance.now() - started, true, detail); + return result; + } catch (error) { + recordOperationPerformance(service, operation, performance.now() - started, false, error instanceof Error ? error.message : String(error)); + throw error; + } +} + +function percentile(values: number[], ratio: number): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((left, right) => left - right); + const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * ratio) - 1)); + return sorted[index] ?? 0; +} + +function average(values: number[]): number { + if (values.length === 0) return 0; + return values.reduce((sum, value) => sum + value, 0) / values.length; +} + +function roundMs(value: number): number { + return Math.round(value * 10) / 10; +} + +function summarizeRequestPerformance(): JsonValue[] { + const groups = new Map(); + for (const sample of requestPerformanceSamples) { + const rows = groups.get(sample.component) ?? []; + rows.push(sample); + groups.set(sample.component, rows); + } + return Array.from(groups.entries()).map(([component, rows]) => { + const durations = rows.map((row) => row.durationMs); + const failed = rows.filter((row) => !row.ok).length; + return { + component, + requestCount: rows.length, + failureCount: failed, + failureRate: rows.length === 0 ? 0 : failed / rows.length, + averageLatencyMs: roundMs(average(durations)), + p95LatencyMs: roundMs(percentile(durations, 0.95)), + maxLatencyMs: roundMs(Math.max(0, ...durations)), + }; + }).sort((left, right) => Number((right as Record).requestCount ?? 0) - Number((left as Record).requestCount ?? 0)) as JsonValue[]; +} + +function summarizeOperationPerformance(): JsonValue[] { + const groups = new Map(); + for (const sample of operationPerformanceSamples) { + const key = `${sample.service}:${sample.operation}`; + const rows = groups.get(key) ?? []; + rows.push(sample); + groups.set(key, rows); + } + return Array.from(groups.entries()).map(([key, rows]) => { + const [service, ...operationParts] = key.split(":"); + const durations = rows.map((row) => row.durationMs); + const failed = rows.filter((row) => !row.ok).length; + return { + service, + operation: operationParts.join(":"), + count: rows.length, + failureCount: failed, + averageLatencyMs: roundMs(average(durations)), + p95LatencyMs: roundMs(percentile(durations, 0.95)), + maxLatencyMs: roundMs(Math.max(0, ...durations)), + }; + }).sort((left, right) => Number((right as Record).count ?? 0) - Number((left as Record).count ?? 0)) as JsonValue[]; +} + +async function getCodexQueueStoragePerformance(): Promise { + try { + const rows = await sql>` + SELECT status, count(*)::int AS count + FROM unidesk_codex_queue_tasks + GROUP BY status + ORDER BY status ASC + `; + return { + ok: true, + table: "unidesk_codex_queue_tasks", + counts: Object.fromEntries(rows.map((row) => [row.status, Number(row.count)])) as Record, + total: rows.reduce((sum, row) => sum + Number(row.count), 0), + }; + } catch (error) { + return { + ok: false, + table: "unidesk_codex_queue_tasks", + error: error instanceof Error ? error.message : String(error), + }; + } +} + +async function getPerformance(): Promise { + const memory = process.memoryUsage(); + const pgdata = await withPerformanceOperation("database", "pgdata_usage", "pg_database_size", () => getPgdataUsage()); + const codexQueueStorage = await withPerformanceOperation("database", "codex_queue_storage", "unidesk_codex_queue_tasks", () => getCodexQueueStoragePerformance()); + const recentFailures = requestPerformanceSamples + .filter((sample) => !sample.ok) + .slice(-20) + .reverse() + .map((sample) => ({ ...sample }) as Record); + const recentOperationCutoff = Date.now() - 10 * 60 * 1000; + const recentSlowOperations = operationPerformanceSamples + .filter((sample) => { + const at = Date.parse(sample.at); + return Number.isFinite(at) && at >= recentOperationCutoff; + }) + .sort((left, right) => Date.parse(right.at) - Date.parse(left.at)) + .slice(0, 20) + .map((sample) => ({ ...sample }) as Record); + return { + ok: true, + service: "backend-core", + generatedAt: new Date().toISOString(), + startedAt: serviceStartedAt.toISOString(), + uptimeSeconds: Math.floor((Date.now() - serviceStartedAt.getTime()) / 1000), + requests: { + sampleCount: requestPerformanceSamples.length, + componentSummary: summarizeRequestPerformance(), + recentFailures, + }, + operations: { + sampleCount: operationPerformanceSamples.length, + summary: summarizeOperationPerformance(), + recentSlowOperations, + }, + process: { + rssBytes: memory.rss, + heapUsedBytes: memory.heapUsed, + heapTotalBytes: memory.heapTotal, + externalBytes: memory.external, + arrayBuffersBytes: memory.arrayBuffers, + }, + database: { + ready: dbReady, + pgdata, + codexQueueStorage, + }, + }; +} + async function initDatabase(client: SqlClient): Promise { logger("info", "database_init_start", { databaseUrl: redactDatabaseUrl(config.databaseUrl) }); await client` @@ -326,6 +563,8 @@ async function initDatabase(client: SqlClient): Promise { created_at TIMESTAMPTZ NOT NULL DEFAULT now() ) `; + await client`CREATE INDEX IF NOT EXISTS idx_unidesk_tasks_updated_at ON unidesk_tasks(updated_at DESC)`; + await client`CREATE INDEX IF NOT EXISTS idx_unidesk_tasks_status_updated_at ON unidesk_tasks(status, updated_at DESC)`; await client`CREATE INDEX IF NOT EXISTS idx_unidesk_node_system_status_updated_at ON unidesk_node_system_status(updated_at DESC)`; await client`CREATE INDEX IF NOT EXISTS idx_unidesk_node_metric_samples_provider_time ON unidesk_node_metric_samples(provider_id, collected_at DESC)`; dbReady = true; @@ -560,9 +799,26 @@ async function markStaleTasksFailed(): Promise { previousUpdatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : String(row.updated_at), timeoutMs, }); + notifyTaskTerminal(row.id).catch((error) => logger("error", "task_waiter_notify_failed", { taskId: row.id, error: errorToJson(error) })); } } +async function maybeMarkStaleTasksFailed(minIntervalMs = 15_000): Promise { + if (!dbReady) return; + const now = Date.now(); + if (taskSweepInFlight !== null) return taskSweepInFlight; + if (now - lastTaskSweepAt < minIntervalMs) return; + lastTaskSweepAt = now; + taskSweepInFlight = markStaleTasksFailed() + .catch((error) => { + logger("error", "task_timeout_sweep_failed", { error: errorToJson(error) }); + }) + .finally(() => { + taskSweepInFlight = null; + }); + return taskSweepInFlight; +} + function parseMessage(raw: string | Buffer): ProviderToCoreMessage { const text = typeof raw === "string" ? raw : raw.toString("utf8"); const parsed = JSON.parse(text) as unknown; @@ -698,6 +954,9 @@ async function handleProviderMessage(ws: ProviderSocket, raw: string | Buffer): message: message.message, result: message.result ?? null, }); + if (isTerminalTaskStatus(message.status)) { + await notifyTaskTerminal(message.taskId); + } } async function getNodes(): Promise { @@ -754,14 +1013,17 @@ async function getNodeSystemStatuses(limit: number): Promise>>` - SELECT provider_id, collected_at, sample - FROM ( - SELECT provider_id, collected_at, sample, - row_number() OVER (PARTITION BY provider_id ORDER BY collected_at DESC) AS rn - FROM unidesk_node_metric_samples - ) ranked - WHERE rn <= ${limit} - ORDER BY provider_id ASC, collected_at ASC + SELECT n.provider_id, recent.collected_at, recent.sample + FROM unidesk_nodes n + LEFT JOIN LATERAL ( + SELECT collected_at, sample + FROM unidesk_node_metric_samples m + WHERE m.provider_id = n.provider_id + ORDER BY collected_at DESC + LIMIT ${limit} + ) recent ON true + WHERE recent.collected_at IS NOT NULL + ORDER BY n.provider_id ASC, recent.collected_at ASC `; const historyByProvider = new Map(); for (const row of sampleRows) { @@ -800,36 +1062,79 @@ async function getEvents(limit: number): Promise { })); } -async function getTasks(limit: number, statusFilter = "all"): Promise { - await markStaleTasksFailed(); +async function getTasks(limit: number, statusFilter = "all", lite = false): Promise { + await maybeMarkStaleTasksFailed(); const rows = statusFilter === "pending" - ? await sql>>` - SELECT id, provider_id, command, status, payload, result, created_at, updated_at - FROM unidesk_tasks - WHERE status IN ('queued', 'dispatched', 'running') - ORDER BY updated_at DESC - LIMIT ${limit} - ` - : await sql>>` - SELECT id, provider_id, command, status, payload, result, created_at, updated_at - FROM unidesk_tasks - ORDER BY updated_at DESC - LIMIT ${limit} - `; + ? lite + ? await sql>>` + SELECT id, provider_id, command, status, created_at, updated_at + FROM unidesk_tasks + WHERE status IN ('queued', 'dispatched', 'running') + ORDER BY updated_at DESC + LIMIT ${limit} + ` + : await sql>>` + SELECT + id, + provider_id, + command, + status, + CASE + WHEN payload ? 'bodyText' THEN jsonb_set(payload - 'bodyText', '{bodyText}', to_jsonb(('>'bodyText')::text || ' chars>')::text)) + ELSE payload + END AS payload, + CASE + WHEN result IS NOT NULL AND result ? 'bodyText' THEN jsonb_set(result - 'bodyText', '{bodyText}', to_jsonb(('>'bodyText')::text || ' chars>')::text)) + ELSE result + END AS result, + created_at, + updated_at + FROM unidesk_tasks + WHERE status IN ('queued', 'dispatched', 'running') + ORDER BY updated_at DESC + LIMIT ${limit} + ` + : lite + ? await sql>>` + SELECT id, provider_id, command, status, created_at, updated_at + FROM unidesk_tasks + ORDER BY updated_at DESC + LIMIT ${limit} + ` + : await sql>>` + SELECT + id, + provider_id, + command, + status, + CASE + WHEN payload ? 'bodyText' THEN jsonb_set(payload - 'bodyText', '{bodyText}', to_jsonb(('>'bodyText')::text || ' chars>')::text)) + ELSE payload + END AS payload, + CASE + WHEN result IS NOT NULL AND result ? 'bodyText' THEN jsonb_set(result - 'bodyText', '{bodyText}', to_jsonb(('>'bodyText')::text || ' chars>')::text)) + ELSE result + END AS result, + created_at, + updated_at + FROM unidesk_tasks + ORDER BY updated_at DESC + LIMIT ${limit} + `; return rows.map((row) => ({ id: String(row.id), providerId: String(row.provider_id), command: String(row.command), status: String(row.status), - payload: compactJson(row.payload ?? {}), - result: compactJson(row.result ?? null), + payload: lite ? {} : compactJson(row.payload ?? {}), + result: lite ? null : compactJson(row.result ?? null), createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at), updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : String(row.updated_at), })); } async function countPendingTasks(): Promise { - await markStaleTasksFailed(); + await maybeMarkStaleTasksFailed(); const rows = await sql>` SELECT count(*)::int AS count FROM unidesk_tasks @@ -948,8 +1253,8 @@ async function createAndSendTask( return { taskId, providerOnline: true }; } -async function rawTask(taskId: string): Promise<{ id: string; provider_id: string; command: string; status: string; payload: JsonValue; result: JsonValue | null; updated_at: Date | string } | null> { - const rows = await sql>` +async function rawTask(taskId: string): Promise { + const rows = await sql` SELECT id, provider_id, command, status, payload, result, updated_at FROM unidesk_tasks WHERE id = ${taskId} @@ -958,15 +1263,43 @@ async function rawTask(taskId: string): Promise<{ id: string; provider_id: strin return rows[0] ?? null; } -async function waitForTaskTerminal(taskId: string, timeoutMs: number): Promise extends Promise ? T : never> { - const started = Date.now(); +function isTerminalTaskStatus(status: string): boolean { + return status === "succeeded" || status === "failed"; +} + +async function notifyTaskTerminal(taskId: string): Promise { + const waiters = taskTerminalWaiters.get(taskId); + if (waiters === undefined || waiters.size === 0) return; + const task = await rawTask(taskId); + if (task === null || !isTerminalTaskStatus(task.status)) return; + taskTerminalWaiters.delete(taskId); + for (const waiter of waiters) waiter(task); +} + +async function waitForTaskTerminal(taskId: string, timeoutMs: number): Promise { let latest = await rawTask(taskId); - while (Date.now() - started < timeoutMs) { - latest = await rawTask(taskId); - if (latest !== null && (latest.status === "succeeded" || latest.status === "failed")) return latest; - await Bun.sleep(200); - } - return latest; + if (latest !== null && isTerminalTaskStatus(latest.status)) return latest; + return await new Promise((resolve) => { + let settled = false; + let timer: ReturnType; + const settle = (task: RawTaskRow | null): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + const waiters = taskTerminalWaiters.get(taskId); + waiters?.delete(settle); + if (waiters !== undefined && waiters.size === 0) taskTerminalWaiters.delete(taskId); + resolve(task); + }; + const waiters = taskTerminalWaiters.get(taskId) ?? new Set(); + waiters.add(settle); + taskTerminalWaiters.set(taskId, waiters); + timer = setTimeout(() => { + rawTask(taskId) + .then((task) => settle(task ?? latest)) + .catch(() => settle(latest)); + }, Math.max(1, timeoutMs)); + }); } function isMicroservicePathAllowed(service: MicroserviceConfig, path: string): boolean { @@ -994,6 +1327,74 @@ function readMicroserviceArrayLimits(url: URL): { query: string; jsonArrayLimits return { query: search.length > 0 ? `?${search}` : "", jsonArrayLimits }; } +function truncateText(value: string, maxBytes: number): string { + return value.length <= maxBytes ? value : value.slice(0, maxBytes); +} + +function contentTypeIsJson(contentType: string): boolean { + return contentType.toLowerCase().includes("json"); +} + +function boundedMicroserviceBodyText( + bodyText: string, + contentType: string, + metadata: { serviceId: string; targetPath: string; status: number; upstreamBodyBytes: number }, +): { bodyText: string; truncated: boolean } { + if (bodyText.length <= microserviceProxyMaxBodyTextLength) { + return { bodyText, truncated: false }; + } + if (contentTypeIsJson(contentType)) { + return { + bodyText: JSON.stringify({ + ok: false, + error: "microservice proxy response body is too large", + serviceId: metadata.serviceId, + targetPath: metadata.targetPath, + upstreamStatus: metadata.status, + upstreamBodyBytes: metadata.upstreamBodyBytes, + transformedBodyBytes: bodyText.length, + responseBodyLimitBytes: microserviceProxyMaxBodyTextLength, + hint: "Use a paged endpoint or tighten __unideskArrayLimit so the response stays below the proxy safety limit.", + }), + truncated: true, + }; + } + return { bodyText: truncateText(bodyText, microserviceProxyMaxBodyTextLength), truncated: true }; +} + +function arrayAtPath(value: unknown, path: string): JsonValue[] | null { + let current: unknown = value; + for (const part of path.split(".")) { + if (typeof current !== "object" || current === null || Array.isArray(current)) return null; + current = (current as Record)[part]; + } + return Array.isArray(current) ? current as JsonValue[] : null; +} + +function applyJsonArrayLimits(bodyText: string, contentType: string, limits: Record): string { + const entries = Object.entries(limits); + if (entries.length === 0 || !contentType.toLowerCase().includes("json")) return bodyText; + try { + const parsed = JSON.parse(bodyText) as unknown; + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return bodyText; + const root = parsed as Record; + const applied: Record = {}; + for (const [path, rawLimit] of entries) { + const limit = Number(rawLimit); + if (!Number.isInteger(limit) || limit <= 0 || limit > 500) continue; + const array = arrayAtPath(root, path); + if (array === null) continue; + const originalLength = array.length; + if (array.length > limit) array.splice(limit); + applied[path] = { limit, originalLength, returnedLength: array.length }; + } + root._unidesk = { arrayLimits: applied }; + return JSON.stringify(parsed); + } catch { + return bodyText; + } +} + function responseFromMicroserviceResult(task: Awaited>): Response { if (task === null) return jsonResponse({ ok: false, error: "microservice proxy task missing" }, 502); if (task.status !== "succeeded") return jsonResponse({ ok: false, error: "microservice proxy task failed", task }, 502); @@ -1004,12 +1405,151 @@ function responseFromMicroserviceResult(task: Awaited if (!Number.isInteger(status) || status < 100 || status > 599) { return jsonResponse({ ok: false, error: "microservice proxy returned invalid upstream status", task }, 502); } + if (result.truncated === true && contentTypeIsJson(contentType)) { + try { + JSON.parse(bodyText); + } catch { + return jsonResponse({ + ok: false, + error: "microservice proxy response was truncated before a JSON boundary", + providerId: task.provider_id, + command: task.command, + upstreamStatus: status, + upstreamBodyBytes: result.upstreamBodyBytes ?? null, + returnedBodyBytes: result.returnedBodyBytes ?? bodyText.length, + responseBodyLimitBytes: result.responseBodyLimitBytes ?? null, + hint: "Upgrade the provider-gateway or request a smaller/paged microservice response.", + }, 502); + } + } return new Response(bodyText, { status, - headers: { "content-type": contentType }, + headers: { + "content-type": contentType, + "x-unidesk-response-truncated": result.truncated === true ? "true" : "false", + }, }); } +function microserviceCacheTtlMs(serviceId: string, targetPath: string): number { + if (serviceId === "met-nonlinear" && (targetPath === "/api/images" || targetPath === "/api/projects")) return 15_000; + if (serviceId === "met-nonlinear" && (targetPath === "/api/queue" || targetPath === "/api/summary" || targetPath === "/api/history")) return 1_500; + if (serviceId === "codex-queue" && targetPath.includes("/transcript")) return 1_000; + return 750; +} + +function providerMicroserviceCacheTtlMs(serviceId: string, targetPath: string): number { + if (serviceId === "met-nonlinear" && (targetPath === "/api/images" || targetPath === "/api/projects")) return 60_000; + if (serviceId === "met-nonlinear" && targetPath === "/api/history") return 10_000; + if (serviceId === "met-nonlinear" && (targetPath === "/api/queue" || targetPath === "/api/summary")) return 3_000; + if (serviceId === "pipeline" && (targetPath === "/api/snapshot" || targetPath.startsWith("/api/oa-event-flow/"))) return 2_000; + if (serviceId === "findjob" && (targetPath === "/api/summary" || targetPath === "/api/jobs")) return 2_000; + return 1_000; +} + +function microserviceCacheKey(service: MicroserviceConfig, method: string, targetPath: string, proxyOptions: { query: string; jsonArrayLimits: Record }): string { + return JSON.stringify([service.id, method, targetPath, proxyOptions.query, proxyOptions.jsonArrayLimits]); +} + +function readMicroserviceCache(key: string): Response | null { + const entry = microserviceProxyCache.get(key); + if (entry === undefined) return null; + if (entry.expiresAt <= Date.now()) { + microserviceProxyCache.delete(key); + return null; + } + return new Response(entry.bodyText, { + status: entry.status, + headers: { + "content-type": entry.contentType, + "x-unidesk-cache": "hit", + }, + }); +} + +async function cacheableResponseSnapshot(response: Response): Promise { + if (response.status < 200 || response.status >= 300) return null; + if (response.headers.get("x-unidesk-response-truncated") === "true") return null; + const bodyText = await response.clone().text(); + if (bodyText.length > 2 * 1024 * 1024) return null; + return { + expiresAt: 0, + status: response.status, + contentType: response.headers.get("content-type") ?? "application/octet-stream", + bodyText, + }; +} + +function rememberMicroserviceCache(key: string, ttlMs: number, entry: MicroserviceProxyCacheEntry | null): void { + if (entry === null || ttlMs <= 0) return; + entry.expiresAt = Date.now() + ttlMs; + microserviceProxyCache.set(key, entry); + if (microserviceProxyCache.size > 300) { + const now = Date.now(); + for (const [cacheKey, cacheEntry] of microserviceProxyCache) { + if (cacheEntry.expiresAt <= now || microserviceProxyCache.size > 240) microserviceProxyCache.delete(cacheKey); + } + } +} + +function invalidateMicroserviceCache(serviceId: string): void { + const prefix = `["${serviceId}",`; + for (const key of microserviceProxyCache.keys()) { + if (key.startsWith(prefix)) microserviceProxyCache.delete(key); + } +} + +function canDirectProxyMicroservice(service: MicroserviceConfig): boolean { + return service.providerId === "main-server"; +} + +async function directMicroserviceResponse( + service: MicroserviceConfig, + method: string, + targetPath: string, + proxyOptions: { query: string; jsonArrayLimits: Record }, + requestHeaders: Record, + bodyText: string, +): Promise { + const baseUrl = new URL(service.backend.nodeBaseUrl); + const upstreamUrl = new URL(targetPath, baseUrl); + upstreamUrl.search = proxyOptions.query; + const headers = new Headers(); + const contentType = typeof requestHeaders["content-type"] === "string" ? requestHeaders["content-type"] : ""; + if (contentType.length > 0) headers.set("content-type", contentType); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), Math.max(1000, service.backend.timeoutMs)); + try { + const upstream = await fetch(upstreamUrl, { + method, + headers, + body: method === "GET" || method === "HEAD" ? undefined : bodyText, + signal: controller.signal, + }); + const rawBodyText = await upstream.text(); + const upstreamContentType = upstream.headers.get("content-type") ?? "text/plain; charset=utf-8"; + const limitedBodyText = applyJsonArrayLimits(rawBodyText, upstreamContentType, proxyOptions.jsonArrayLimits); + const bounded = boundedMicroserviceBodyText(limitedBodyText, upstreamContentType, { + serviceId: service.id, + targetPath, + status: upstream.status, + upstreamBodyBytes: rawBodyText.length, + }); + return new Response(bounded.bodyText, { + status: upstream.status, + headers: { + "content-type": upstreamContentType, + "x-unidesk-proxy-mode": "direct", + "x-unidesk-response-truncated": bounded.truncated ? "true" : "false", + }, + }); + } catch (error) { + return jsonResponse({ ok: false, error: "direct microservice proxy failed", serviceId: service.id, detail: errorToJson(error) }, 502); + } finally { + clearTimeout(timer); + } +} + async function microserviceRoute(req: Request, url: URL): Promise { const rest = url.pathname.slice("/api/microservices/".length); const slashIndex = rest.indexOf("/"); @@ -1040,10 +1580,18 @@ async function microserviceRoute(req: Request, url: URL): Promise { if (!isMicroservicePathAllowed(service, targetPath)) { return jsonResponse({ ok: false, error: "microservice path is not allowed", serviceId, targetPath }, 403); } - if (!(await providerSupports(service.providerId, "microservice.http"))) { + const directProxy = canDirectProxyMicroservice(service); + if (!directProxy && !(await providerSupports(service.providerId, "microservice.http"))) { return jsonResponse({ ok: false, error: `provider does not declare microservice.http capability: ${service.providerId}` }, 409); } const proxyOptions = readMicroserviceArrayLimits(url); + const cacheKey = microserviceCacheKey(service, method, targetPath, proxyOptions); + if (method === "GET" || method === "HEAD") { + const cached = readMicroserviceCache(cacheKey); + if (cached !== null) return cached; + } else { + invalidateMicroserviceCache(service.id); + } const bodyText = method === "GET" || method === "HEAD" ? "" : await req.text(); if (bodyText.length > 1024 * 1024) { return jsonResponse({ ok: false, error: "microservice request body is too large", maxBytes: 1024 * 1024 }, 413); @@ -1051,6 +1599,13 @@ async function microserviceRoute(req: Request, url: URL): Promise { const requestHeaders: Record = {}; const contentType = req.headers.get("content-type"); if (contentType !== null) requestHeaders["content-type"] = contentType.slice(0, 200); + if (directProxy) { + const response = await directMicroserviceResponse(service, method, targetPath, proxyOptions, requestHeaders, bodyText); + if (method === "GET" || method === "HEAD") { + rememberMicroserviceCache(cacheKey, microserviceCacheTtlMs(service.id, targetPath), await cacheableResponseSnapshot(response)); + } + return response; + } const { taskId, providerOnline } = await createAndSendTask(service.providerId, "microservice.http", { source: "microservice-frontend-proxy", serviceId: service.id, @@ -1062,10 +1617,15 @@ async function microserviceRoute(req: Request, url: URL): Promise { bodyText, jsonArrayLimits: proxyOptions.jsonArrayLimits, timeoutMs: service.backend.timeoutMs, + cacheTtlMs: providerMicroserviceCacheTtlMs(service.id, targetPath), }); if (!providerOnline) return jsonResponse({ ok: false, error: `provider is offline: ${service.providerId}`, taskId }, 503); const task = await waitForTaskTerminal(taskId, service.backend.timeoutMs + 3000); - return responseFromMicroserviceResult(task); + const response = responseFromMicroserviceResult(task); + if (method === "GET" || method === "HEAD") { + rememberMicroserviceCache(cacheKey, microserviceCacheTtlMs(service.id, targetPath), await cacheableResponseSnapshot(response)); + } + return response; } async function dispatchTask(req: Request): Promise { @@ -1089,6 +1649,177 @@ async function dispatchTask(req: Request): Promise { return jsonResponse({ ok: true, taskId, status: providerOnline ? "dispatched" : "queued", providerOnline }); } +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function safePerfRunId(): string { + return `codex_queue_perf_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`; +} + +function rawTaskJson(task: RawTaskRow | null): JsonValue { + if (task === null) return null; + return { + id: task.id, + providerId: task.provider_id, + command: task.command, + status: task.status, + payload: task.payload, + result: task.result, + updatedAt: task.updated_at instanceof Date ? task.updated_at.toISOString() : String(task.updated_at), + }; +} + +function recordFromJson(value: JsonValue | null): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +} + +async function runHostSshPerfCommand(providerId: string, command: string, timeoutMs = 18_000): Promise<{ ok: boolean; taskId: string; task: RawTaskRow | null; stdout: string; stderr: string; exitCode: number | null; timedOut: boolean }> { + const { taskId, providerOnline } = await createAndSendTask(providerId, "host.ssh", { + source: "codex-queue-performance-panel", + mode: "exec", + cwd: "/root/unidesk", + timeoutMs: 15_000, + command, + }); + if (!providerOnline) { + return { ok: false, taskId, task: null, stdout: "", stderr: `provider is offline: ${providerId}`, exitCode: null, timedOut: false }; + } + const task = await waitForTaskTerminal(taskId, timeoutMs); + const result = recordFromJson(task?.result ?? null); + return { + ok: task?.status === "succeeded" && result.ok === true, + taskId, + task, + stdout: typeof result.stdout === "string" ? result.stdout : "", + stderr: typeof result.stderr === "string" ? result.stderr : "", + exitCode: typeof result.exitCode === "number" ? result.exitCode : null, + timedOut: result.timedOut === true, + }; +} + +function parsePerfPoll(stdout: string): { ready: boolean; exitCode: number | null; body: string; stderr: string } { + if (!stdout.includes("__UNIDESK_CODEX_QUEUE_PERF_READY__")) { + return { ready: false, exitCode: null, body: "", stderr: "" }; + } + const exitMatch = stdout.match(/__UNIDESK_CODEX_QUEUE_PERF_READY__\r?\n([0-9]+)/u); + const bodyMatch = stdout.match(/__UNIDESK_CODEX_QUEUE_PERF_STDOUT__\r?\n([\s\S]*?)\r?\n__UNIDESK_CODEX_QUEUE_PERF_STDERR__/u); + const stderrMatch = stdout.match(/__UNIDESK_CODEX_QUEUE_PERF_STDERR__\r?\n([\s\S]*)$/u); + const exitCode = exitMatch === null ? null : Number(exitMatch[1]); + return { + ready: true, + exitCode: Number.isFinite(exitCode) ? exitCode : null, + body: bodyMatch?.[1]?.trim() ?? "", + stderr: stderrMatch?.[1]?.trim() ?? "", + }; +} + +function parsePerfJson(body: string): Record | null { + for (const line of body.split(/\r?\n/u).reverse()) { + const trimmed = line.trim(); + if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) continue; + try { + const parsed = JSON.parse(trimmed) as unknown; + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) return parsed as Record; + } catch { + // Continue scanning for the compact JSON line emitted by the Playwright script. + } + } + return null; +} + +async function codexQueueLoadTest(req: Request): Promise { + const body = req.method === "POST" ? (await req.json().catch(() => ({}))) as Record : {}; + const codexService = microserviceById("codex-queue"); + const providerId = typeof body.providerId === "string" && body.providerId.length > 0 + ? body.providerId + : codexService?.providerId ?? "main-server"; + if (!(await providerSupports(providerId, "host.ssh"))) { + return jsonResponse({ ok: true, measurementOk: false, error: `provider does not declare host.ssh capability: ${providerId}`, providerId }, 200); + } + + const timeoutMs = numberFromUnknown(body.timeoutMs, 90_000, 5_000, 180_000); + const targetMs = numberFromUnknown(body.targetMs, 1_000, 100, 60_000); + const runId = safePerfRunId(); + const dir = ".state/codex-queue-perf"; + const browsersPath = ".state/playwright-browsers"; + const outputPath = `${dir}/${runId}.json`; + const stderrPath = `${dir}/${runId}.stderr`; + const exitPath = `${dir}/${runId}.exit`; + const urlArg = typeof body.url === "string" && body.url.length > 0 ? ` --url ${shellQuote(body.url)}` : ""; + const chromePath = `${browsersPath}/chromium-1217/chrome-linux64/chrome`; + const playwrightSetup = [ + `if ! test -x ${shellQuote(chromePath)}; then PLAYWRIGHT_BROWSERS_PATH=${shellQuote(browsersPath)} npx playwright install chromium; fi`, + `if test -x ${shellQuote(chromePath)} && ldd ${shellQuote(chromePath)} 2>&1 | grep -q 'not found'; then npx playwright install-deps chromium; fi`, + ].join(" && "); + const startCommand = [ + `mkdir -p ${shellQuote(dir)}`, + `rm -f ${shellQuote(outputPath)} ${shellQuote(stderrPath)} ${shellQuote(exitPath)}`, + `(${playwrightSetup}; PLAYWRIGHT_BROWSERS_PATH=${shellQuote(browsersPath)} bun scripts/src/codex-queue-perf.ts --json --timeout-ms ${timeoutMs} --target-ms ${targetMs}${urlArg} > ${shellQuote(outputPath)}; printf '%s' "$?" > ${shellQuote(exitPath)}) > ${shellQuote(stderrPath)} 2>&1 & printf '%s\\n' ${shellQuote(runId)}`, + ].join("; "); + const startedAt = Date.now(); + const start = await runHostSshPerfCommand(providerId, startCommand); + if (!start.ok) { + return jsonResponse({ ok: true, measurementOk: false, providerId, runId, stage: "start", error: start.stderr || "failed to start Playwright benchmark", taskId: start.taskId, task: rawTaskJson(start.task) }, 200); + } + + const pollCommand = [ + `if test -f ${shellQuote(exitPath)}; then`, + "printf '__UNIDESK_CODEX_QUEUE_PERF_READY__\\n';", + `cat ${shellQuote(exitPath)};`, + "printf '\\n__UNIDESK_CODEX_QUEUE_PERF_STDOUT__\\n';", + `cat ${shellQuote(outputPath)} 2>/dev/null || true;`, + "printf '\\n__UNIDESK_CODEX_QUEUE_PERF_STDERR__\\n';", + `tail -c 3000 ${shellQuote(stderrPath)} 2>/dev/null || true;`, + "else printf '__UNIDESK_CODEX_QUEUE_PERF_PENDING__\\n'; fi", + ].join(" "); + + let latestPoll: Awaited> | null = null; + while (Date.now() - startedAt < timeoutMs + 20_000) { + await Bun.sleep(1_500); + latestPoll = await runHostSshPerfCommand(providerId, pollCommand); + const parsedPoll = parsePerfPoll(latestPoll.stdout); + if (!parsedPoll.ready) continue; + const result = parsePerfJson(parsedPoll.body); + if (result === null) { + return jsonResponse({ + ok: true, + measurementOk: false, + providerId, + runId, + stage: "parse", + exitCode: parsedPoll.exitCode, + error: "Playwright benchmark did not emit JSON", + stderr: parsedPoll.stderr, + taskId: latestPoll.taskId, + }, 200); + } + return jsonResponse({ + ok: true, + measurementOk: result.ok === true, + providerId, + runId, + taskId: latestPoll.taskId, + elapsedMs: Date.now() - startedAt, + exitCode: parsedPoll.exitCode, + result, + stderr: parsedPoll.stderr, + }); + } + + return jsonResponse({ + ok: true, + measurementOk: false, + providerId, + runId, + stage: "timeout", + elapsedMs: Date.now() - startedAt, + error: `Codex Queue Playwright benchmark did not finish within ${timeoutMs}ms`, + latestTaskId: latestPoll?.taskId ?? start.taskId, + latestTask: rawTaskJson(latestPoll?.task ?? start.task), + }, 200); +} + function numberFromUnknown(value: unknown, fallback: number, min: number, max: number): number { const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : fallback; if (!Number.isFinite(parsed)) return fallback; @@ -1191,7 +1922,7 @@ async function sshRoute(req: Request, server: Server): Promise): Promise { +async function routeInner(req: Request, server: Server): Promise { const url = new URL(req.url); if (req.method === "OPTIONS") return jsonResponse({ ok: true }); @@ -1203,15 +1934,20 @@ async function route(req: Request, server: Server): Promise getOverview())); + if (url.pathname === "/api/nodes") return jsonResponse({ ok: true, nodes: await withPerformanceOperation("core", "nodes", url.pathname, () => getNodes()) }); + if (url.pathname === "/api/nodes/system-status") return jsonResponse({ ok: true, systemStatuses: await withPerformanceOperation("core", "node_system_status", url.search, () => getNodeSystemStatuses(readLimit(url, 60))) }); + if (url.pathname === "/api/nodes/docker-status") return jsonResponse({ ok: true, dockerStatuses: await withPerformanceOperation("core", "node_docker_status", url.pathname, () => getNodeDockerStatuses()) }); + if (url.pathname === "/api/events") return jsonResponse({ ok: true, events: await withPerformanceOperation("core", "events", url.search, () => getEvents(readLimit(url, 100))) }); + if (url.pathname === "/api/tasks") { + const lite = ["1", "true", "yes"].includes((url.searchParams.get("lite") ?? "").toLowerCase()); + return jsonResponse({ ok: true, tasks: await withPerformanceOperation("core", "tasks", url.search, () => getTasks(readLimit(url, 100), url.searchParams.get("status") ?? "all", lite)) }); + } + if (url.pathname === "/api/microservices") return jsonResponse({ ok: true, microservices: await withPerformanceOperation("core", "microservices", url.pathname, () => getMicroservices()) }); + if (url.pathname === "/api/performance") return jsonResponse(await getPerformance()); + if (url.pathname === "/api/codex-queue-load-test" && (req.method === "GET" || req.method === "POST")) return withPerformanceOperation("performance", "codex_queue_load_test", url.pathname, () => codexQueueLoadTest(req)); + if (url.pathname.startsWith("/api/microservices/")) return withPerformanceOperation("microservices", "route", url.pathname, () => microserviceRoute(req, url)); + if (url.pathname === "/api/dispatch" && req.method === "POST") return withPerformanceOperation("scheduler", "dispatch", url.pathname, () => dispatchTask(req)); if (url.pathname === "/logs") return jsonResponse({ ok: true, logs: recentLogs.slice(-readLimit(url, 100)) }); if (url.pathname === "/favicon.ico") return textResponse("", 204); return jsonResponse({ ok: false, error: "not found", path: url.pathname }, 404); @@ -1221,6 +1957,18 @@ async function route(req: Request, server: Server): Promise): Promise { + const started = performance.now(); + const url = new URL(req.url); + let response: Response | undefined; + try { + response = await routeInner(req, server); + return response; + } finally { + recordRequestPerformance(req, url.pathname, response, performance.now() - started); + } +} + async function providerRoute(req: Request, server: Server): Promise { const url = new URL(req.url); if (url.pathname === "/" || url.pathname === "/health") { @@ -1289,7 +2037,17 @@ const apiServer = Bun.serve({ const providerServer = Bun.serve({ port: config.providerPort, hostname: "0.0.0.0", - fetch: providerRoute, + async fetch(req, server) { + const started = performance.now(); + const url = new URL(req.url); + let response: Response | undefined; + try { + response = await providerRoute(req, server); + return response; + } finally { + recordRequestPerformance(req, url.pathname, response, performance.now() - started); + } + }, websocket: { open(ws) { logger("info", "provider_socket_open", { remoteAddress: ws.remoteAddress }); diff --git a/src/components/frontend/package.json b/src/components/frontend/package.json index 38c8e69d..1357b5cd 100644 --- a/src/components/frontend/package.json +++ b/src/components/frontend/package.json @@ -4,6 +4,7 @@ "type": "module", "scripts": { "start": "bun run src/index.ts", + "build": "bun run scripts/build.ts", "check": "tsc -p tsconfig.json --noEmit" }, "dependencies": { diff --git a/src/components/frontend/public/app.js b/src/components/frontend/public/app.js new file mode 100644 index 00000000..a4a788c9 --- /dev/null +++ b/src/components/frontend/public/app.js @@ -0,0 +1,84 @@ +(()=>{var gH=Object.create;var{getPrototypeOf:nH,defineProperty:EA,getOwnPropertyNames:tH}=Object;var sH=Object.prototype.hasOwnProperty;function oH(f){return this[f]}var aH,dH,Sf=(f,u,_)=>{var y=f!=null&&typeof f==="object";if(y){var l=u?aH??=new WeakMap:dH??=new WeakMap,$=l.get(f);if($)return $}_=f!=null?gH(nH(f)):{};let j=u||!f||!f.__esModule?EA(_,"default",{value:f,enumerable:!0}):_;for(let J of tH(f))if(!sH.call(j,J))EA(j,J,{get:oH.bind(f,J),enumerable:!0});if(y)l.set(f,j);return j};var Mu=(f,u)=>()=>(u||f((u={exports:{}}).exports,u),u.exports);var pf=((f)=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(f,{get:(u,_)=>(typeof require<"u"?require:u)[_]}):f)(function(f){if(typeof require<"u")return require.apply(this,arguments);throw Error('Dynamic require of "'+f+'" is not supported')});var SA=Mu((bf)=>{var Tl=Symbol.for("react.element"),eH=Symbol.for("react.portal"),fV=Symbol.for("react.fragment"),uV=Symbol.for("react.strict_mode"),_V=Symbol.for("react.profiler"),yV=Symbol.for("react.provider"),lV=Symbol.for("react.context"),$V=Symbol.for("react.forward_ref"),jV=Symbol.for("react.suspense"),JV=Symbol.for("react.memo"),FV=Symbol.for("react.lazy"),OA=Symbol.iterator;function AV(f){if(f===null||typeof f!=="object")return null;return f=OA&&f[OA]||f["@@iterator"],typeof f==="function"?f:null}var LA={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},YA=Object.assign,BA={};function j3(f,u,_){this.props=f,this.context=u,this.refs=BA,this.updater=_||LA}j3.prototype.isReactComponent={};j3.prototype.setState=function(f,u){if(typeof f!=="object"&&typeof f!=="function"&&f!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,f,u,"setState")};j3.prototype.forceUpdate=function(f){this.updater.enqueueForceUpdate(this,f,"forceUpdate")};function wA(){}wA.prototype=j3.prototype;function L2(f,u,_){this.props=f,this.context=u,this.refs=BA,this.updater=_||LA}var Y2=L2.prototype=new wA;Y2.constructor=L2;YA(Y2,j3.prototype);Y2.isPureReactComponent=!0;var XA=Array.isArray,DA=Object.prototype.hasOwnProperty,B2={current:null},TA={key:!0,ref:!0,__self:!0,__source:!0};function MA(f,u,_){var y,l={},$=null,j=null;if(u!=null)for(y in u.ref!==void 0&&(j=u.ref),u.key!==void 0&&($=""+u.key),u)DA.call(u,y)&&!TA.hasOwnProperty(y)&&(l[y]=u[y]);var J=arguments.length-2;if(J===1)l.children=_;else if(1{PA.exports=SA()});var IA=Mu((y0)=>{function T2(f,u){var _=f.length;f.push(u);f:for(;0<_;){var y=_-1>>>1,l=f[y];if(0>>1;y<$;){var j=2*(y+1)-1,J=f[j],F=j+1,Q=f[F];if(0>a6(J,_))Fa6(Q,J)?(f[y]=Q,f[F]=_,y=F):(f[y]=J,f[j]=_,y=j);else if(Fa6(Q,_))f[y]=Q,f[F]=_,y=F;else break f}}return u}function a6(f,u){var _=f.sortIndex-u.sortIndex;return _!==0?_:f.id-u.id}if(typeof performance==="object"&&typeof performance.now==="function")M2=performance,y0.unstable_now=function(){return M2.now()};else d6=Date,r2=d6.now(),y0.unstable_now=function(){return d6.now()-r2};var M2,d6,r2,w1=[],G_=[],GV=1,tu=null,fu=3,_8=!1,Uy=!1,rl=!1,RA=typeof setTimeout==="function"?setTimeout:null,xA=typeof clearTimeout==="function"?clearTimeout:null,CA=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function S2(f){for(var u=F1(G_);u!==null;){if(u.callback===null)u8(G_);else if(u.startTime<=f)u8(G_),u.sortIndex=u.expirationTime,T2(w1,u);else break;u=F1(G_)}}function C2(f){if(rl=!1,S2(f),!Uy)if(F1(w1)!==null)Uy=!0,x2(R2);else{var u=F1(G_);u!==null&&v2(C2,u.startTime-f)}}function R2(f,u){Uy=!1,rl&&(rl=!1,xA(Sl),Sl=-1),_8=!0;var _=fu;try{S2(u);for(tu=F1(w1);tu!==null&&(!(tu.expirationTime>u)||f&&!hA());){var y=tu.callback;if(typeof y==="function"){tu.callback=null,fu=tu.priorityLevel;var l=y(tu.expirationTime<=u);u=y0.unstable_now(),typeof l==="function"?tu.callback=l:tu===F1(w1)&&u8(w1),S2(u)}else u8(w1);tu=F1(w1)}if(tu!==null)var $=!0;else{var j=F1(G_);j!==null&&v2(C2,j.startTime-u),$=!1}return $}finally{tu=null,fu=_,_8=!1}}var y8=!1,e6=null,Sl=-1,vA=5,bA=-1;function hA(){return y0.unstable_now()-bAf||125y?(f.sortIndex=_,T2(G_,f),F1(w1)===null&&f===F1(G_)&&(rl?(xA(Sl),Sl=-1):rl=!0,v2(C2,_-y))):(f.sortIndex=l,T2(w1,f),Uy||_8||(Uy=!0,x2(R2))),f};y0.unstable_shouldYield=hA;y0.unstable_wrapCallback=function(f){var u=fu;return function(){var _=fu;fu=u;try{return f.apply(this,arguments)}finally{fu=_}}}});var pA=Mu((lS,cA)=>{cA.exports=IA()});var kW=Mu((xu)=>{var KV=I0(),Cu=pA();function Ff(f){for(var u="https://reactjs.org/docs/error-decoder.html?invariant="+f,_=1;_"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),$9=Object.prototype.hasOwnProperty,ZV=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,mA={},kA={};function qV(f){if($9.call(kA,f))return!0;if($9.call(mA,f))return!1;if(ZV.test(f))return kA[f]=!0;return mA[f]=!0,!1}function HV(f,u,_,y){if(_!==null&&_.type===0)return!1;switch(typeof u){case"function":case"symbol":return!0;case"boolean":if(y)return!1;if(_!==null)return!_.acceptsBooleans;return f=f.toLowerCase().slice(0,5),f!=="data-"&&f!=="aria-";default:return!1}}function VV(f,u,_,y){if(u===null||typeof u>"u"||HV(f,u,_,y))return!0;if(y)return!1;if(_!==null)switch(_.type){case 3:return!u;case 4:return u===!1;case 5:return isNaN(u);case 6:return isNaN(u)||1>u}return!1}function Ku(f,u,_,y,l,$,j){this.acceptsBooleans=u===2||u===3||u===4,this.attributeName=y,this.attributeNamespace=l,this.mustUseProperty=_,this.propertyName=f,this.type=u,this.sanitizeURL=$,this.removeEmptyString=j}var s0={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(f){s0[f]=new Ku(f,0,!1,f,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(f){var u=f[0];s0[u]=new Ku(u,1,!1,f[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(f){s0[f]=new Ku(f,2,!1,f.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(f){s0[f]=new Ku(f,2,!1,f,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(f){s0[f]=new Ku(f,3,!1,f.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(f){s0[f]=new Ku(f,3,!0,f,null,!1,!1)});["capture","download"].forEach(function(f){s0[f]=new Ku(f,4,!1,f,null,!1,!1)});["cols","rows","size","span"].forEach(function(f){s0[f]=new Ku(f,6,!1,f,null,!1,!1)});["rowSpan","start"].forEach(function(f){s0[f]=new Ku(f,5,!1,f.toLowerCase(),null,!1,!1)});var e9=/[\-:]([a-z])/g;function f7(f){return f[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(f){var u=f.replace(e9,f7);s0[u]=new Ku(u,1,!1,f,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(f){var u=f.replace(e9,f7);s0[u]=new Ku(u,1,!1,f,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(f){var u=f.replace(e9,f7);s0[u]=new Ku(u,1,!1,f,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(f){s0[f]=new Ku(f,1,!1,f.toLowerCase(),null,!1,!1)});s0.xlinkHref=new Ku("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(f){s0[f]=new Ku(f,1,!1,f.toLowerCase(),null,!0,!0)});function u7(f,u,_,y){var l=s0.hasOwnProperty(u)?s0[u]:null;if(l!==null?l.type!==0:y||!(2J||l[j]!==$[J]){var F=` +`+l[j].replace(" at new "," at ");return f.displayName&&F.includes("")&&(F=F.replace("",f.displayName)),F}while(1<=j&&0<=J);break}}}finally{h2=!1,Error.prepareStackTrace=_}return(f=f?f.displayName||f.name:"")?Il(f):""}function EV(f){switch(f.tag){case 5:return Il(f.type);case 16:return Il("Lazy");case 13:return Il("Suspense");case 19:return Il("SuspenseList");case 0:case 2:case 15:return f=I2(f.type,!1),f;case 11:return f=I2(f.type.render,!1),f;case 1:return f=I2(f.type,!0),f;default:return""}}function A9(f){if(f==null)return null;if(typeof f==="function")return f.displayName||f.name||null;if(typeof f==="string")return f;switch(f){case Q3:return"Fragment";case A3:return"Portal";case j9:return"Profiler";case _7:return"StrictMode";case J9:return"Suspense";case F9:return"SuspenseList"}if(typeof f==="object")switch(f.$$typeof){case sQ:return(f.displayName||"Context")+".Consumer";case tQ:return(f._context.displayName||"Context")+".Provider";case y7:var u=f.render;return f=f.displayName,f||(f=u.displayName||u.name||"",f=f!==""?"ForwardRef("+f+")":"ForwardRef"),f;case l7:return u=f.displayName||null,u!==null?u:A9(f.type)||"Memo";case Z_:u=f._payload,f=f._init;try{return A9(f(u))}catch(_){}}return null}function OV(f){var u=f.type;switch(f.tag){case 24:return"Cache";case 9:return(u.displayName||"Context")+".Consumer";case 10:return(u._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return f=u.render,f=f.displayName||f.name||"",u.displayName||(f!==""?"ForwardRef("+f+")":"ForwardRef");case 7:return"Fragment";case 5:return u;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return A9(u);case 8:return u===_7?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof u==="function")return u.displayName||u.name||null;if(typeof u==="string")return u}return null}function M_(f){switch(typeof f){case"boolean":case"number":case"string":case"undefined":return f;case"object":return f;default:return""}}function aQ(f){var u=f.type;return(f=f.nodeName)&&f.toLowerCase()==="input"&&(u==="checkbox"||u==="radio")}function XV(f){var u=aQ(f)?"checked":"value",_=Object.getOwnPropertyDescriptor(f.constructor.prototype,u),y=""+f[u];if(!f.hasOwnProperty(u)&&typeof _<"u"&&typeof _.get==="function"&&typeof _.set==="function"){var{get:l,set:$}=_;return Object.defineProperty(f,u,{configurable:!0,get:function(){return l.call(this)},set:function(j){y=""+j,$.call(this,j)}}),Object.defineProperty(f,u,{enumerable:_.enumerable}),{getValue:function(){return y},setValue:function(j){y=""+j},stopTracking:function(){f._valueTracker=null,delete f[u]}}}}function $8(f){f._valueTracker||(f._valueTracker=XV(f))}function dQ(f){if(!f)return!1;var u=f._valueTracker;if(!u)return!0;var _=u.getValue(),y="";return f&&(y=aQ(f)?f.checked?"true":"false":f.value),f=y,f!==_?(u.setValue(f),!0):!1}function M8(f){if(f=f||(typeof document<"u"?document:void 0),typeof f>"u")return null;try{return f.activeElement||f.body}catch(u){return f.body}}function Q9(f,u){var _=u.checked;return q0({},u,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:_!=null?_:f._wrapperState.initialChecked})}function gA(f,u){var _=u.defaultValue==null?"":u.defaultValue,y=u.checked!=null?u.checked:u.defaultChecked;_=M_(u.value!=null?u.value:_),f._wrapperState={initialChecked:y,initialValue:_,controlled:u.type==="checkbox"||u.type==="radio"?u.checked!=null:u.value!=null}}function eQ(f,u){u=u.checked,u!=null&&u7(f,"checked",u,!1)}function U9(f,u){eQ(f,u);var _=M_(u.value),y=u.type;if(_!=null)if(y==="number"){if(_===0&&f.value===""||f.value!=_)f.value=""+_}else f.value!==""+_&&(f.value=""+_);else if(y==="submit"||y==="reset"){f.removeAttribute("value");return}u.hasOwnProperty("value")?W9(f,u.type,_):u.hasOwnProperty("defaultValue")&&W9(f,u.type,M_(u.defaultValue)),u.checked==null&&u.defaultChecked!=null&&(f.defaultChecked=!!u.defaultChecked)}function nA(f,u,_){if(u.hasOwnProperty("value")||u.hasOwnProperty("defaultValue")){var y=u.type;if(!(y!=="submit"&&y!=="reset"||u.value!==void 0&&u.value!==null))return;u=""+f._wrapperState.initialValue,_||u===f.value||(f.value=u),f.defaultValue=u}_=f.name,_!==""&&(f.name=""),f.defaultChecked=!!f._wrapperState.initialChecked,_!==""&&(f.name=_)}function W9(f,u,_){if(u!=="number"||M8(f.ownerDocument)!==f)_==null?f.defaultValue=""+f._wrapperState.initialValue:f.defaultValue!==""+_&&(f.defaultValue=""+_)}var cl=Array.isArray;function O3(f,u,_,y){if(f=f.options,u){u={};for(var l=0;l<_.length;l++)u["$"+_[l]]=!0;for(_=0;_"+u.valueOf().toString()+"";for(u=j8.firstChild;f.firstChild;)f.removeChild(f.firstChild);for(;u.firstChild;)f.appendChild(u.firstChild)}});function l$(f,u){if(u){var _=f.firstChild;if(_&&_===f.lastChild&&_.nodeType===3){_.nodeValue=u;return}}f.textContent=u}var tl={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},NV=["Webkit","ms","Moz","O"];Object.keys(tl).forEach(function(f){NV.forEach(function(u){u=u+f.charAt(0).toUpperCase()+f.substring(1),tl[u]=tl[f]})});function yU(f,u,_){return u==null||typeof u==="boolean"||u===""?"":_||typeof u!=="number"||u===0||tl.hasOwnProperty(f)&&tl[f]?(""+u).trim():u+"px"}function lU(f,u){f=f.style;for(var _ in u)if(u.hasOwnProperty(_)){var y=_.indexOf("--")===0,l=yU(_,u[_],y);_==="float"&&(_="cssFloat"),y?f.setProperty(_,l):f[_]=l}}var LV=q0({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function K9(f,u){if(u){if(LV[f]&&(u.children!=null||u.dangerouslySetInnerHTML!=null))throw Error(Ff(137,f));if(u.dangerouslySetInnerHTML!=null){if(u.children!=null)throw Error(Ff(60));if(typeof u.dangerouslySetInnerHTML!=="object"||!("__html"in u.dangerouslySetInnerHTML))throw Error(Ff(61))}if(u.style!=null&&typeof u.style!=="object")throw Error(Ff(62))}}function Z9(f,u){if(f.indexOf("-")===-1)return typeof u.is==="string";switch(f){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var q9=null;function $7(f){return f=f.target||f.srcElement||window,f.correspondingUseElement&&(f=f.correspondingUseElement),f.nodeType===3?f.parentNode:f}var H9=null,X3=null,N3=null;function oA(f){if(f=X$(f)){if(typeof H9!=="function")throw Error(Ff(280));var u=f.stateNode;u&&(u=l4(u),H9(f.stateNode,f.type,u))}}function $U(f){X3?N3?N3.push(f):N3=[f]:X3=f}function jU(){if(X3){var f=X3,u=N3;if(N3=X3=null,oA(f),u)for(f=0;f>>=0,f===0?32:31-(RV(f)/xV|0)|0}var J8=64,F8=4194304;function pl(f){switch(f&-f){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return f&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return f&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return f}}function C8(f,u){var _=f.pendingLanes;if(_===0)return 0;var y=0,l=f.suspendedLanes,$=f.pingedLanes,j=_&268435455;if(j!==0){var J=j&~l;J!==0?y=pl(J):($&=j,$!==0&&(y=pl($)))}else j=_&~l,j!==0?y=pl(j):$!==0&&(y=pl($));if(y===0)return 0;if(u!==0&&u!==y&&(u&l)===0&&(l=y&-y,$=u&-u,l>=$||l===16&&($&4194240)!==0))return u;if((y&4)!==0&&(y|=_&16),u=f.entangledLanes,u!==0)for(f=f.entanglements,u&=y;0_;_++)u.push(f);return u}function E$(f,u,_){f.pendingLanes|=u,u!==536870912&&(f.suspendedLanes=0,f.pingedLanes=0),f=f.eventTimes,u=31-z1(u),f[u]=_}function IV(f,u){var _=f.pendingLanes&~u;f.pendingLanes=u,f.suspendedLanes=0,f.pingedLanes=0,f.expiredLanes&=u,f.mutableReadLanes&=u,f.entangledLanes&=u,u=f.entanglements;var y=f.eventTimes;for(f=f.expirationTimes;0<_;){var l=31-z1(_),$=1<=ol),$Q=String.fromCharCode(32),jQ=!1;function BU(f,u){switch(f){case"keyup":return GE.indexOf(u.keyCode)!==-1;case"keydown":return u.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function wU(f){return f=f.detail,typeof f==="object"&&"data"in f?f.data:null}var U3=!1;function ZE(f,u){switch(f){case"compositionend":return wU(u);case"keypress":if(u.which!==32)return null;return jQ=!0,$Q;case"textInput":return f=u.data,f===$Q&&jQ?null:f;default:return null}}function qE(f,u){if(U3)return f==="compositionend"||!z7&&BU(f,u)?(f=LU(),E8=Q7=E_=null,U3=!1,f):null;switch(f){case"paste":return null;case"keypress":if(!(u.ctrlKey||u.altKey||u.metaKey)||u.ctrlKey&&u.altKey){if(u.char&&1=u)return{node:_,offset:u-f};f=y}f:{for(;_;){if(_.nextSibling){_=_.nextSibling;break f}_=_.parentNode}_=void 0}_=AQ(_)}}function rU(f,u){return f&&u?f===u?!0:f&&f.nodeType===3?!1:u&&u.nodeType===3?rU(f,u.parentNode):("contains"in f)?f.contains(u):f.compareDocumentPosition?!!(f.compareDocumentPosition(u)&16):!1:!1}function SU(){for(var f=window,u=M8();u instanceof f.HTMLIFrameElement;){try{var _=typeof u.contentWindow.location.href==="string"}catch(y){_=!1}if(_)f=u.contentWindow;else break;u=M8(f.document)}return u}function G7(f){var u=f&&f.nodeName&&f.nodeName.toLowerCase();return u&&(u==="input"&&(f.type==="text"||f.type==="search"||f.type==="tel"||f.type==="url"||f.type==="password")||u==="textarea"||f.contentEditable==="true")}function BE(f){var u=SU(),_=f.focusedElem,y=f.selectionRange;if(u!==_&&_&&_.ownerDocument&&rU(_.ownerDocument.documentElement,_)){if(y!==null&&G7(_)){if(u=y.start,f=y.end,f===void 0&&(f=u),"selectionStart"in _)_.selectionStart=u,_.selectionEnd=Math.min(f,_.value.length);else if(f=(u=_.ownerDocument||document)&&u.defaultView||window,f.getSelection){f=f.getSelection();var l=_.textContent.length,$=Math.min(y.start,l);y=y.end===void 0?$:Math.min(y.end,l),!f.extend&&$>y&&(l=y,y=$,$=l),l=QQ(_,$);var j=QQ(_,y);l&&j&&(f.rangeCount!==1||f.anchorNode!==l.node||f.anchorOffset!==l.offset||f.focusNode!==j.node||f.focusOffset!==j.offset)&&(u=u.createRange(),u.setStart(l.node,l.offset),f.removeAllRanges(),$>y?(f.addRange(u),f.extend(j.node,j.offset)):(u.setEnd(j.node,j.offset),f.addRange(u)))}}u=[];for(f=_;f=f.parentNode;)f.nodeType===1&&u.push({element:f,left:f.scrollLeft,top:f.scrollTop});typeof _.focus==="function"&&_.focus();for(_=0;_=document.documentMode,W3=null,L9=null,dl=null,Y9=!1;function UQ(f,u,_){var y=_.window===_?_.document:_.nodeType===9?_:_.ownerDocument;Y9||W3==null||W3!==M8(y)||(y=W3,("selectionStart"in y)&&G7(y)?y={start:y.selectionStart,end:y.selectionEnd}:(y=(y.ownerDocument&&y.ownerDocument.defaultView||window).getSelection(),y={anchorNode:y.anchorNode,anchorOffset:y.anchorOffset,focusNode:y.focusNode,focusOffset:y.focusOffset}),dl&&Q$(dl,y)||(dl=y,y=v8(L9,"onSelect"),0K3||(f.current=P9[K3],P9[K3]=null,K3--)}function l0(f,u){K3++,P9[K3]=f.current,f.current=u}var r_={},lu=P_(r_),Ou=P_(!1),Ey=r_;function D3(f,u){var _=f.type.contextTypes;if(!_)return r_;var y=f.stateNode;if(y&&y.__reactInternalMemoizedUnmaskedChildContext===u)return y.__reactInternalMemoizedMaskedChildContext;var l={},$;for($ in _)l[$]=u[$];return y&&(f=f.stateNode,f.__reactInternalMemoizedUnmaskedChildContext=u,f.__reactInternalMemoizedMaskedChildContext=l),l}function Xu(f){return f=f.childContextTypes,f!==null&&f!==void 0}function h8(){A0(Ou),A0(lu)}function HQ(f,u,_){if(lu.current!==r_)throw Error(Ff(168));l0(lu,u),l0(Ou,_)}function cU(f,u,_){var y=f.stateNode;if(u=u.childContextTypes,typeof y.getChildContext!=="function")return _;y=y.getChildContext();for(var l in y)if(!(l in u))throw Error(Ff(108,OV(f)||"Unknown",l));return q0({},_,y)}function I8(f){return f=(f=f.stateNode)&&f.__reactInternalMemoizedMergedChildContext||r_,Ey=lu.current,l0(lu,f),l0(Ou,Ou.current),!0}function VQ(f,u,_){var y=f.stateNode;if(!y)throw Error(Ff(169));_?(f=cU(f,u,Ey),y.__reactInternalMemoizedMergedChildContext=f,A0(Ou),A0(lu),l0(lu,f)):A0(Ou),l0(Ou,_)}var n1=null,$4=!1,o2=!1;function pU(f){n1===null?n1=[f]:n1.push(f)}function xE(f){$4=!0,pU(f)}function C_(){if(!o2&&n1!==null){o2=!0;var f=0,u=af;try{var _=n1;for(af=1;f<_.length;f++){var y=_[f];do y=y(!0);while(y!==null)}n1=null,$4=!1}catch(l){throw n1!==null&&(n1=n1.slice(f+1)),zU(j7,C_),l}finally{af=u,o2=!1}}return null}var Z3=[],q3=0,c8=null,p8=0,su=[],ou=0,Oy=null,t1=1,s1="";function zy(f,u){Z3[q3++]=p8,Z3[q3++]=c8,c8=f,p8=u}function mU(f,u,_){su[ou++]=t1,su[ou++]=s1,su[ou++]=Oy,Oy=f;var y=t1;f=s1;var l=32-z1(y)-1;y&=~(1<>=j,l-=j,t1=1<<32-z1(u)+l|_<w?(R=N,N=null):R=N.sibling;var p=W(G,N,Z[w],E);if(p===null){N===null&&(N=R);break}f&&N&&p.alternate===null&&u(G,N),H=$(p,H,w),M===null?L=p:M.sibling=p,M=p,N=R}if(w===Z.length)return _(G,N),U0&&zy(G,w),L;if(N===null){for(;ww?(R=N,N=null):R=N.sibling;var x=W(G,N,p.value,E);if(x===null){N===null&&(N=R);break}f&&N&&x.alternate===null&&u(G,N),H=$(x,H,w),M===null?L=x:M.sibling=x,M=x,N=R}if(p.done)return _(G,N),U0&&zy(G,w),L;if(N===null){for(;!p.done;w++,p=Z.next())p=z(G,p.value,E),p!==null&&(H=$(p,H,w),M===null?L=p:M.sibling=p,M=p);return U0&&zy(G,w),L}for(N=y(G,N);!p.done;w++,p=Z.next())p=K(N,G,w,p.value,E),p!==null&&(f&&p.alternate!==null&&N.delete(p.key===null?w:p.key),H=$(p,H,w),M===null?L=p:M.sibling=p,M=p);return f&&N.forEach(function(C){return u(G,C)}),U0&&zy(G,w),L}function O(G,H,Z,E){if(typeof Z==="object"&&Z!==null&&Z.type===Q3&&Z.key===null&&(Z=Z.props.children),typeof Z==="object"&&Z!==null){switch(Z.$$typeof){case l8:f:{for(var L=Z.key,M=H;M!==null;){if(M.key===L){if(L=Z.type,L===Q3){if(M.tag===7){_(G,M.sibling),H=l(M,Z.props.children),H.return=G,G=H;break f}}else if(M.elementType===L||typeof L==="object"&&L!==null&&L.$$typeof===Z_&&XQ(L)===M.type){_(G,M.sibling),H=l(M,Z.props),H.ref=vl(G,M,Z),H.return=G,G=H;break f}_(G,M);break}else u(G,M);M=M.sibling}Z.type===Q3?(H=Vy(Z.props.children,G.mode,E,Z.key),H.return=G,G=H):(E=T8(Z.type,Z.key,Z.props,null,G.mode,E),E.ref=vl(G,H,Z),E.return=G,G=E)}return j(G);case A3:f:{for(M=Z.key;H!==null;){if(H.key===M)if(H.tag===4&&H.stateNode.containerInfo===Z.containerInfo&&H.stateNode.implementation===Z.implementation){_(G,H.sibling),H=l(H,Z.children||[]),H.return=G,G=H;break f}else{_(G,H);break}else u(G,H);H=H.sibling}H=l9(Z,G.mode,E),H.return=G,G=H}return j(G);case Z_:return M=Z._init,O(G,H,M(Z._payload),E)}if(cl(Z))return q(G,H,Z,E);if(Pl(Z))return V(G,H,Z,E);K8(G,Z)}return typeof Z==="string"&&Z!==""||typeof Z==="number"?(Z=""+Z,H!==null&&H.tag===6?(_(G,H.sibling),H=l(H,Z),H.return=G,G=H):(_(G,H),H=y9(Z,G.mode,E),H.return=G,G=H),j(G)):_(G,H)}return O}var M3=gU(!0),nU=gU(!1),m8=P_(null),k8=null,H3=null,H7=null;function V7(){H7=H3=k8=null}function E7(f){var u=m8.current;A0(m8),f._currentValue=u}function x9(f,u,_){for(;f!==null;){var y=f.alternate;if((f.childLanes&u)!==u?(f.childLanes|=u,y!==null&&(y.childLanes|=u)):y!==null&&(y.childLanes&u)!==u&&(y.childLanes|=u),f===_)break;f=f.return}}function Y3(f,u){k8=f,H7=H3=null,f=f.dependencies,f!==null&&f.firstContext!==null&&((f.lanes&u)!==0&&(Eu=!0),f.firstContext=null)}function eu(f){var u=f._currentValue;if(H7!==f)if(f={context:f,memoizedValue:u,next:null},H3===null){if(k8===null)throw Error(Ff(308));H3=f,k8.dependencies={lanes:0,firstContext:f}}else H3=H3.next=f;return u}var Zy=null;function O7(f){Zy===null?Zy=[f]:Zy.push(f)}function tU(f,u,_,y){var l=u.interleaved;return l===null?(_.next=_,O7(u)):(_.next=l.next,l.next=_),u.interleaved=_,e1(f,y)}function e1(f,u){f.lanes|=u;var _=f.alternate;_!==null&&(_.lanes|=u),_=f;for(f=f.return;f!==null;)f.childLanes|=u,_=f.alternate,_!==null&&(_.childLanes|=u),_=f,f=f.return;return _.tag===3?_.stateNode:null}var q_=!1;function X7(f){f.updateQueue={baseState:f.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function sU(f,u){f=f.updateQueue,u.updateQueue===f&&(u.updateQueue={baseState:f.baseState,firstBaseUpdate:f.firstBaseUpdate,lastBaseUpdate:f.lastBaseUpdate,shared:f.shared,effects:f.effects})}function o1(f,u){return{eventTime:f,lane:u,tag:0,payload:null,callback:null,next:null}}function B_(f,u,_){var y=f.updateQueue;if(y===null)return null;if(y=y.shared,(gf&2)!==0){var l=y.pending;return l===null?u.next=u:(u.next=l.next,l.next=u),y.pending=u,e1(f,_)}return l=y.interleaved,l===null?(u.next=u,O7(y)):(u.next=l.next,l.next=u),y.interleaved=u,e1(f,_)}function N8(f,u,_){if(u=u.updateQueue,u!==null&&(u=u.shared,(_&4194240)!==0)){var y=u.lanes;y&=f.pendingLanes,_|=y,u.lanes=_,J7(f,_)}}function NQ(f,u){var{updateQueue:_,alternate:y}=f;if(y!==null&&(y=y.updateQueue,_===y)){var l=null,$=null;if(_=_.firstBaseUpdate,_!==null){do{var j={eventTime:_.eventTime,lane:_.lane,tag:_.tag,payload:_.payload,callback:_.callback,next:null};$===null?l=$=j:$=$.next=j,_=_.next}while(_!==null);$===null?l=$=u:$=$.next=u}else l=$=u;_={baseState:y.baseState,firstBaseUpdate:l,lastBaseUpdate:$,shared:y.shared,effects:y.effects},f.updateQueue=_;return}f=_.lastBaseUpdate,f===null?_.firstBaseUpdate=u:f.next=u,_.lastBaseUpdate=u}function i8(f,u,_,y){var l=f.updateQueue;q_=!1;var{firstBaseUpdate:$,lastBaseUpdate:j}=l,J=l.shared.pending;if(J!==null){l.shared.pending=null;var F=J,Q=F.next;F.next=null,j===null?$=Q:j.next=Q,j=F;var U=f.alternate;U!==null&&(U=U.updateQueue,J=U.lastBaseUpdate,J!==j&&(J===null?U.firstBaseUpdate=Q:J.next=Q,U.lastBaseUpdate=F))}if($!==null){var z=l.baseState;j=0,U=Q=F=null,J=$;do{var{lane:W,eventTime:K}=J;if((y&W)===W){U!==null&&(U=U.next={eventTime:K,lane:0,tag:J.tag,payload:J.payload,callback:J.callback,next:null});f:{var q=f,V=J;switch(W=u,K=_,V.tag){case 1:if(q=V.payload,typeof q==="function"){z=q.call(K,z,W);break f}z=q;break f;case 3:q.flags=q.flags&-65537|128;case 0:if(q=V.payload,W=typeof q==="function"?q.call(K,z,W):q,W===null||W===void 0)break f;z=q0({},z,W);break f;case 2:q_=!0}}J.callback!==null&&J.lane!==0&&(f.flags|=64,W=l.effects,W===null?l.effects=[J]:W.push(J))}else K={eventTime:K,lane:W,tag:J.tag,payload:J.payload,callback:J.callback,next:null},U===null?(Q=U=K,F=z):U=U.next=K,j|=W;if(J=J.next,J===null)if(J=l.shared.pending,J===null)break;else W=J,J=W.next,W.next=null,l.lastBaseUpdate=W,l.shared.pending=null}while(1);if(U===null&&(F=z),l.baseState=F,l.firstBaseUpdate=Q,l.lastBaseUpdate=U,u=l.shared.interleaved,u!==null){l=u;do j|=l.lane,l=l.next;while(l!==u)}else $===null&&(l.shared.lanes=0);Ny|=j,f.lanes=j,f.memoizedState=z}}function LQ(f,u,_){if(f=u.effects,u.effects=null,f!==null)for(u=0;u_?_:4,f(!0);var y=d2.transition;d2.transition={};try{f(!1),u()}finally{af=_,d2.transition=y}}function WW(){return f1().memoizedState}function IE(f,u,_){var y=D_(f);if(_={lane:y,action:_,hasEagerState:!1,eagerState:null,next:null},zW(f))GW(u,_);else if(_=tU(f,u,_,y),_!==null){var l=Gu();G1(_,f,y,l),KW(_,u,y)}}function cE(f,u,_){var y=D_(f),l={lane:y,action:_,hasEagerState:!1,eagerState:null,next:null};if(zW(f))GW(u,l);else{var $=f.alternate;if(f.lanes===0&&($===null||$.lanes===0)&&($=u.lastRenderedReducer,$!==null))try{var j=u.lastRenderedState,J=$(j,_);if(l.hasEagerState=!0,l.eagerState=J,K1(J,j)){var F=u.interleaved;F===null?(l.next=l,O7(u)):(l.next=F.next,F.next=l),u.interleaved=l;return}}catch(Q){}finally{}_=tU(f,u,l,y),_!==null&&(l=Gu(),G1(_,f,y,l),KW(_,u,y))}}function zW(f){var u=f.alternate;return f===Z0||u!==null&&u===Z0}function GW(f,u){el=n8=!0;var _=f.pending;_===null?u.next=u:(u.next=_.next,_.next=u),f.pending=u}function KW(f,u,_){if((_&4194240)!==0){var y=u.lanes;y&=f.pendingLanes,_|=y,u.lanes=_,J7(f,_)}}var t8={readContext:eu,useCallback:uu,useContext:uu,useEffect:uu,useImperativeHandle:uu,useInsertionEffect:uu,useLayoutEffect:uu,useMemo:uu,useReducer:uu,useRef:uu,useState:uu,useDebugValue:uu,useDeferredValue:uu,useTransition:uu,useMutableSource:uu,useSyncExternalStore:uu,useId:uu,unstable_isNewReconciler:!1},pE={readContext:eu,useCallback:function(f,u){return T1().memoizedState=[f,u===void 0?null:u],f},useContext:eu,useEffect:BQ,useImperativeHandle:function(f,u,_){return _=_!==null&&_!==void 0?_.concat([f]):null,Y8(4194308,4,JW.bind(null,u,f),_)},useLayoutEffect:function(f,u){return Y8(4194308,4,f,u)},useInsertionEffect:function(f,u){return Y8(4,2,f,u)},useMemo:function(f,u){var _=T1();return u=u===void 0?null:u,f=f(),_.memoizedState=[f,u],f},useReducer:function(f,u,_){var y=T1();return u=_!==void 0?_(u):u,y.memoizedState=y.baseState=u,f={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:f,lastRenderedState:u},y.queue=f,f=f.dispatch=IE.bind(null,Z0,f),[y.memoizedState,f]},useRef:function(f){var u=T1();return f={current:f},u.memoizedState=f},useState:YQ,useDebugValue:M7,useDeferredValue:function(f){return T1().memoizedState=f},useTransition:function(){var f=YQ(!1),u=f[0];return f=hE.bind(null,f[1]),T1().memoizedState=f,[u,f]},useMutableSource:function(){},useSyncExternalStore:function(f,u,_){var y=Z0,l=T1();if(U0){if(_===void 0)throw Error(Ff(407));_=_()}else{if(_=u(),p0===null)throw Error(Ff(349));(Xy&30)!==0||eU(y,u,_)}l.memoizedState=_;var $={value:_,getSnapshot:u};return l.queue=$,BQ(uW.bind(null,y,$,f),[f]),y.flags|=2048,H$(9,fW.bind(null,y,$,_,u),void 0,null),_},useId:function(){var f=T1(),u=p0.identifierPrefix;if(U0){var _=s1,y=t1;_=(y&~(1<<32-z1(y)-1)).toString(32)+_,u=":"+u+"R"+_,_=Z$++,0<_&&(u+="H"+_.toString(32)),u+=":"}else _=bE++,u=":"+u+"r"+_.toString(32)+":";return f.memoizedState=u},unstable_isNewReconciler:!1},mE={readContext:eu,useCallback:AW,useContext:eu,useEffect:T7,useImperativeHandle:FW,useInsertionEffect:$W,useLayoutEffect:jW,useMemo:QW,useReducer:e2,useRef:lW,useState:function(){return e2(q$)},useDebugValue:M7,useDeferredValue:function(f){var u=f1();return UW(u,S0.memoizedState,f)},useTransition:function(){var f=e2(q$)[0],u=f1().memoizedState;return[f,u]},useMutableSource:aU,useSyncExternalStore:dU,useId:WW,unstable_isNewReconciler:!1},kE={readContext:eu,useCallback:AW,useContext:eu,useEffect:T7,useImperativeHandle:FW,useInsertionEffect:$W,useLayoutEffect:jW,useMemo:QW,useReducer:f9,useRef:lW,useState:function(){return f9(q$)},useDebugValue:M7,useDeferredValue:function(f){var u=f1();return S0===null?u.memoizedState=f:UW(u,S0.memoizedState,f)},useTransition:function(){var f=f9(q$)[0],u=f1().memoizedState;return[f,u]},useMutableSource:aU,useSyncExternalStore:dU,useId:WW,unstable_isNewReconciler:!1};function Q1(f,u){if(f&&f.defaultProps){u=q0({},u),f=f.defaultProps;for(var _ in f)u[_]===void 0&&(u[_]=f[_]);return u}return u}function v9(f,u,_,y){u=f.memoizedState,_=_(y,u),_=_===null||_===void 0?u:q0({},u,_),f.memoizedState=_,f.lanes===0&&(f.updateQueue.baseState=_)}var J4={isMounted:function(f){return(f=f._reactInternals)?By(f)===f:!1},enqueueSetState:function(f,u,_){f=f._reactInternals;var y=Gu(),l=D_(f),$=o1(y,l);$.payload=u,_!==void 0&&_!==null&&($.callback=_),u=B_(f,$,l),u!==null&&(G1(u,f,l,y),N8(u,f,l))},enqueueReplaceState:function(f,u,_){f=f._reactInternals;var y=Gu(),l=D_(f),$=o1(y,l);$.tag=1,$.payload=u,_!==void 0&&_!==null&&($.callback=_),u=B_(f,$,l),u!==null&&(G1(u,f,l,y),N8(u,f,l))},enqueueForceUpdate:function(f,u){f=f._reactInternals;var _=Gu(),y=D_(f),l=o1(_,y);l.tag=2,u!==void 0&&u!==null&&(l.callback=u),u=B_(f,l,y),u!==null&&(G1(u,f,y,_),N8(u,f,y))}};function wQ(f,u,_,y,l,$,j){return f=f.stateNode,typeof f.shouldComponentUpdate==="function"?f.shouldComponentUpdate(y,$,j):u.prototype&&u.prototype.isPureReactComponent?!Q$(_,y)||!Q$(l,$):!0}function ZW(f,u,_){var y=!1,l=r_,$=u.contextType;return typeof $==="object"&&$!==null?$=eu($):(l=Xu(u)?Ey:lu.current,y=u.contextTypes,$=(y=y!==null&&y!==void 0)?D3(f,l):r_),u=new u(_,$),f.memoizedState=u.state!==null&&u.state!==void 0?u.state:null,u.updater=J4,f.stateNode=u,u._reactInternals=f,y&&(f=f.stateNode,f.__reactInternalMemoizedUnmaskedChildContext=l,f.__reactInternalMemoizedMaskedChildContext=$),u}function DQ(f,u,_,y){f=u.state,typeof u.componentWillReceiveProps==="function"&&u.componentWillReceiveProps(_,y),typeof u.UNSAFE_componentWillReceiveProps==="function"&&u.UNSAFE_componentWillReceiveProps(_,y),u.state!==f&&J4.enqueueReplaceState(u,u.state,null)}function b9(f,u,_,y){var l=f.stateNode;l.props=_,l.state=f.memoizedState,l.refs={},X7(f);var $=u.contextType;typeof $==="object"&&$!==null?l.context=eu($):($=Xu(u)?Ey:lu.current,l.context=D3(f,$)),l.state=f.memoizedState,$=u.getDerivedStateFromProps,typeof $==="function"&&(v9(f,u,$,_),l.state=f.memoizedState),typeof u.getDerivedStateFromProps==="function"||typeof l.getSnapshotBeforeUpdate==="function"||typeof l.UNSAFE_componentWillMount!=="function"&&typeof l.componentWillMount!=="function"||(u=l.state,typeof l.componentWillMount==="function"&&l.componentWillMount(),typeof l.UNSAFE_componentWillMount==="function"&&l.UNSAFE_componentWillMount(),u!==l.state&&J4.enqueueReplaceState(l,l.state,null),i8(f,_,l,y),l.state=f.memoizedState),typeof l.componentDidMount==="function"&&(f.flags|=4194308)}function S3(f,u){try{var _="",y=u;do _+=EV(y),y=y.return;while(y);var l=_}catch($){l=` +Error generating stack: `+$.message+` +`+$.stack}return{value:f,source:u,stack:l,digest:null}}function u9(f,u,_){return{value:f,source:null,stack:_!=null?_:null,digest:u!=null?u:null}}function h9(f,u){try{console.error(u.value)}catch(_){setTimeout(function(){throw _})}}var iE=typeof WeakMap==="function"?WeakMap:Map;function qW(f,u,_){_=o1(-1,_),_.tag=3,_.payload={element:null};var y=u.value;return _.callback=function(){o8||(o8=!0,s9=y),h9(f,u)},_}function HW(f,u,_){_=o1(-1,_),_.tag=3;var y=f.type.getDerivedStateFromError;if(typeof y==="function"){var l=u.value;_.payload=function(){return y(l)},_.callback=function(){h9(f,u)}}var $=f.stateNode;return $!==null&&typeof $.componentDidCatch==="function"&&(_.callback=function(){h9(f,u),typeof y!=="function"&&(w_===null?w_=new Set([this]):w_.add(this));var j=u.stack;this.componentDidCatch(u.value,{componentStack:j!==null?j:""})}),_}function TQ(f,u,_){var y=f.pingCache;if(y===null){y=f.pingCache=new iE;var l=new Set;y.set(u,l)}else l=y.get(u),l===void 0&&(l=new Set,y.set(u,l));l.has(_)||(l.add(_),f=$O.bind(null,f,u,_),u.then(f,f))}function MQ(f){do{var u;if(u=f.tag===13)u=f.memoizedState,u=u!==null?u.dehydrated!==null?!0:!1:!0;if(u)return f;f=f.return}while(f!==null);return null}function rQ(f,u,_,y,l){if((f.mode&1)===0)return f===u?f.flags|=65536:(f.flags|=128,_.flags|=131072,_.flags&=-52805,_.tag===1&&(_.alternate===null?_.tag=17:(u=o1(-1,1),u.tag=2,B_(_,u,1))),_.lanes|=1),f;return f.flags|=65536,f.lanes=l,f}var gE=u_.ReactCurrentOwner,Eu=!1;function zu(f,u,_,y){u.child=f===null?nU(u,null,_,y):M3(u,f.child,_,y)}function SQ(f,u,_,y,l){_=_.render;var $=u.ref;if(Y3(u,l),y=w7(f,u,_,y,$,l),_=D7(),f!==null&&!Eu)return u.updateQueue=f.updateQueue,u.flags&=-2053,f.lanes&=~l,f_(f,u,l);return U0&&_&&K7(u),u.flags|=1,zu(f,u,y,l),u.child}function PQ(f,u,_,y,l){if(f===null){var $=_.type;if(typeof $==="function"&&!b7($)&&$.defaultProps===void 0&&_.compare===null&&_.defaultProps===void 0)return u.tag=15,u.type=$,VW(f,u,$,y,l);return f=T8(_.type,null,y,u,u.mode,l),f.ref=u.ref,f.return=u,u.child=f}if($=f.child,(f.lanes&l)===0){var j=$.memoizedProps;if(_=_.compare,_=_!==null?_:Q$,_(j,y)&&f.ref===u.ref)return f_(f,u,l)}return u.flags|=1,f=T_($,y),f.ref=u.ref,f.return=u,u.child=f}function VW(f,u,_,y,l){if(f!==null){var $=f.memoizedProps;if(Q$($,y)&&f.ref===u.ref)if(Eu=!1,u.pendingProps=y=$,(f.lanes&l)!==0)(f.flags&131072)!==0&&(Eu=!0);else return u.lanes=f.lanes,f_(f,u,l)}return I9(f,u,_,y,l)}function EW(f,u,_){var y=u.pendingProps,l=y.children,$=f!==null?f.memoizedState:null;if(y.mode==="hidden")if((u.mode&1)===0)u.memoizedState={baseLanes:0,cachePool:null,transitions:null},l0(E3,ru),ru|=_;else{if((_&1073741824)===0)return f=$!==null?$.baseLanes|_:_,u.lanes=u.childLanes=1073741824,u.memoizedState={baseLanes:f,cachePool:null,transitions:null},u.updateQueue=null,l0(E3,ru),ru|=f,null;u.memoizedState={baseLanes:0,cachePool:null,transitions:null},y=$!==null?$.baseLanes:_,l0(E3,ru),ru|=y}else $!==null?(y=$.baseLanes|_,u.memoizedState=null):y=_,l0(E3,ru),ru|=y;return zu(f,u,l,_),u.child}function OW(f,u){var _=u.ref;if(f===null&&_!==null||f!==null&&f.ref!==_)u.flags|=512,u.flags|=2097152}function I9(f,u,_,y,l){var $=Xu(_)?Ey:lu.current;if($=D3(u,$),Y3(u,l),_=w7(f,u,_,y,$,l),y=D7(),f!==null&&!Eu)return u.updateQueue=f.updateQueue,u.flags&=-2053,f.lanes&=~l,f_(f,u,l);return U0&&y&&K7(u),u.flags|=1,zu(f,u,_,l),u.child}function CQ(f,u,_,y,l){if(Xu(_)){var $=!0;I8(u)}else $=!1;if(Y3(u,l),u.stateNode===null)B8(f,u),ZW(u,_,y),b9(u,_,y,l),y=!0;else if(f===null){var{stateNode:j,memoizedProps:J}=u;j.props=J;var F=j.context,Q=_.contextType;typeof Q==="object"&&Q!==null?Q=eu(Q):(Q=Xu(_)?Ey:lu.current,Q=D3(u,Q));var U=_.getDerivedStateFromProps,z=typeof U==="function"||typeof j.getSnapshotBeforeUpdate==="function";z||typeof j.UNSAFE_componentWillReceiveProps!=="function"&&typeof j.componentWillReceiveProps!=="function"||(J!==y||F!==Q)&&DQ(u,j,y,Q),q_=!1;var W=u.memoizedState;j.state=W,i8(u,y,j,l),F=u.memoizedState,J!==y||W!==F||Ou.current||q_?(typeof U==="function"&&(v9(u,_,U,y),F=u.memoizedState),(J=q_||wQ(u,_,J,y,W,F,Q))?(z||typeof j.UNSAFE_componentWillMount!=="function"&&typeof j.componentWillMount!=="function"||(typeof j.componentWillMount==="function"&&j.componentWillMount(),typeof j.UNSAFE_componentWillMount==="function"&&j.UNSAFE_componentWillMount()),typeof j.componentDidMount==="function"&&(u.flags|=4194308)):(typeof j.componentDidMount==="function"&&(u.flags|=4194308),u.memoizedProps=y,u.memoizedState=F),j.props=y,j.state=F,j.context=Q,y=J):(typeof j.componentDidMount==="function"&&(u.flags|=4194308),y=!1)}else{j=u.stateNode,sU(f,u),J=u.memoizedProps,Q=u.type===u.elementType?J:Q1(u.type,J),j.props=Q,z=u.pendingProps,W=j.context,F=_.contextType,typeof F==="object"&&F!==null?F=eu(F):(F=Xu(_)?Ey:lu.current,F=D3(u,F));var K=_.getDerivedStateFromProps;(U=typeof K==="function"||typeof j.getSnapshotBeforeUpdate==="function")||typeof j.UNSAFE_componentWillReceiveProps!=="function"&&typeof j.componentWillReceiveProps!=="function"||(J!==z||W!==F)&&DQ(u,j,y,F),q_=!1,W=u.memoizedState,j.state=W,i8(u,y,j,l);var q=u.memoizedState;J!==z||W!==q||Ou.current||q_?(typeof K==="function"&&(v9(u,_,K,y),q=u.memoizedState),(Q=q_||wQ(u,_,Q,y,W,q,F)||!1)?(U||typeof j.UNSAFE_componentWillUpdate!=="function"&&typeof j.componentWillUpdate!=="function"||(typeof j.componentWillUpdate==="function"&&j.componentWillUpdate(y,q,F),typeof j.UNSAFE_componentWillUpdate==="function"&&j.UNSAFE_componentWillUpdate(y,q,F)),typeof j.componentDidUpdate==="function"&&(u.flags|=4),typeof j.getSnapshotBeforeUpdate==="function"&&(u.flags|=1024)):(typeof j.componentDidUpdate!=="function"||J===f.memoizedProps&&W===f.memoizedState||(u.flags|=4),typeof j.getSnapshotBeforeUpdate!=="function"||J===f.memoizedProps&&W===f.memoizedState||(u.flags|=1024),u.memoizedProps=y,u.memoizedState=q),j.props=y,j.state=q,j.context=F,y=Q):(typeof j.componentDidUpdate!=="function"||J===f.memoizedProps&&W===f.memoizedState||(u.flags|=4),typeof j.getSnapshotBeforeUpdate!=="function"||J===f.memoizedProps&&W===f.memoizedState||(u.flags|=1024),y=!1)}return c9(f,u,_,y,$,l)}function c9(f,u,_,y,l,$){OW(f,u);var j=(u.flags&128)!==0;if(!y&&!j)return l&&VQ(u,_,!1),f_(f,u,$);y=u.stateNode,gE.current=u;var J=j&&typeof _.getDerivedStateFromError!=="function"?null:y.render();return u.flags|=1,f!==null&&j?(u.child=M3(u,f.child,null,$),u.child=M3(u,null,J,$)):zu(f,u,J,$),u.memoizedState=y.state,l&&VQ(u,_,!0),u.child}function XW(f){var u=f.stateNode;u.pendingContext?HQ(f,u.pendingContext,u.pendingContext!==u.context):u.context&&HQ(f,u.context,!1),N7(f,u.containerInfo)}function RQ(f,u,_,y,l){return T3(),q7(l),u.flags|=256,zu(f,u,_,y),u.child}var p9={dehydrated:null,treeContext:null,retryLane:0};function m9(f){return{baseLanes:f,cachePool:null,transitions:null}}function NW(f,u,_){var y=u.pendingProps,l=K0.current,$=!1,j=(u.flags&128)!==0,J;if((J=j)||(J=f!==null&&f.memoizedState===null?!1:(l&2)!==0),J)$=!0,u.flags&=-129;else if(f===null||f.memoizedState!==null)l|=1;if(l0(K0,l&1),f===null){if(R9(u),f=u.memoizedState,f!==null&&(f=f.dehydrated,f!==null))return(u.mode&1)===0?u.lanes=1:f.data==="$!"?u.lanes=8:u.lanes=1073741824,null;return j=y.children,f=y.fallback,$?(y=u.mode,$=u.child,j={mode:"hidden",children:j},(y&1)===0&&$!==null?($.childLanes=0,$.pendingProps=j):$=Q4(j,y,0,null),f=Vy(f,y,_,null),$.return=u,f.return=u,$.sibling=f,u.child=$,u.child.memoizedState=m9(_),u.memoizedState=p9,f):r7(u,j)}if(l=f.memoizedState,l!==null&&(J=l.dehydrated,J!==null))return nE(f,u,j,y,J,l,_);if($){$=y.fallback,j=u.mode,l=f.child,J=l.sibling;var F={mode:"hidden",children:y.children};return(j&1)===0&&u.child!==l?(y=u.child,y.childLanes=0,y.pendingProps=F,u.deletions=null):(y=T_(l,F),y.subtreeFlags=l.subtreeFlags&14680064),J!==null?$=T_(J,$):($=Vy($,j,_,null),$.flags|=2),$.return=u,y.return=u,y.sibling=$,u.child=y,y=$,$=u.child,j=f.child.memoizedState,j=j===null?m9(_):{baseLanes:j.baseLanes|_,cachePool:null,transitions:j.transitions},$.memoizedState=j,$.childLanes=f.childLanes&~_,u.memoizedState=p9,y}return $=f.child,f=$.sibling,y=T_($,{mode:"visible",children:y.children}),(u.mode&1)===0&&(y.lanes=_),y.return=u,y.sibling=null,f!==null&&(_=u.deletions,_===null?(u.deletions=[f],u.flags|=16):_.push(f)),u.child=y,u.memoizedState=null,y}function r7(f,u){return u=Q4({mode:"visible",children:u},f.mode,0,null),u.return=f,f.child=u}function Z8(f,u,_,y){return y!==null&&q7(y),M3(u,f.child,null,_),f=r7(u,u.pendingProps.children),f.flags|=2,u.memoizedState=null,f}function nE(f,u,_,y,l,$,j){if(_){if(u.flags&256)return u.flags&=-257,y=u9(Error(Ff(422))),Z8(f,u,j,y);if(u.memoizedState!==null)return u.child=f.child,u.flags|=128,null;return $=y.fallback,l=u.mode,y=Q4({mode:"visible",children:y.children},l,0,null),$=Vy($,l,j,null),$.flags|=2,y.return=u,$.return=u,y.sibling=$,u.child=y,(u.mode&1)!==0&&M3(u,f.child,null,j),u.child.memoizedState=m9(j),u.memoizedState=p9,$}if((u.mode&1)===0)return Z8(f,u,j,null);if(l.data==="$!"){if(y=l.nextSibling&&l.nextSibling.dataset,y)var J=y.dgst;return y=J,$=Error(Ff(419)),y=u9($,y,void 0),Z8(f,u,j,y)}if(J=(j&f.childLanes)!==0,Eu||J){if(y=p0,y!==null){switch(j&-j){case 4:l=2;break;case 16:l=8;break;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:l=32;break;case 536870912:l=268435456;break;default:l=0}l=(l&(y.suspendedLanes|j))!==0?0:l,l!==0&&l!==$.retryLane&&($.retryLane=l,e1(f,l),G1(y,f,l,-1))}return v7(),y=u9(Error(Ff(421))),Z8(f,u,j,y)}if(l.data==="$?")return u.flags|=128,u.child=f.child,u=jO.bind(null,f),l._reactRetry=u,null;return f=$.treeContext,Su=Y_(l.nextSibling),Pu=u,U0=!0,W1=null,f!==null&&(su[ou++]=t1,su[ou++]=s1,su[ou++]=Oy,t1=f.id,s1=f.overflow,Oy=u),u=r7(u,y.children),u.flags|=4096,u}function xQ(f,u,_){f.lanes|=u;var y=f.alternate;y!==null&&(y.lanes|=u),x9(f.return,u,_)}function _9(f,u,_,y,l){var $=f.memoizedState;$===null?f.memoizedState={isBackwards:u,rendering:null,renderingStartTime:0,last:y,tail:_,tailMode:l}:($.isBackwards=u,$.rendering=null,$.renderingStartTime=0,$.last=y,$.tail=_,$.tailMode=l)}function LW(f,u,_){var y=u.pendingProps,l=y.revealOrder,$=y.tail;if(zu(f,u,y.children,_),y=K0.current,(y&2)!==0)y=y&1|2,u.flags|=128;else{if(f!==null&&(f.flags&128)!==0)f:for(f=u.child;f!==null;){if(f.tag===13)f.memoizedState!==null&&xQ(f,_,u);else if(f.tag===19)xQ(f,_,u);else if(f.child!==null){f.child.return=f,f=f.child;continue}if(f===u)break f;for(;f.sibling===null;){if(f.return===null||f.return===u)break f;f=f.return}f.sibling.return=f.return,f=f.sibling}y&=1}if(l0(K0,y),(u.mode&1)===0)u.memoizedState=null;else switch(l){case"forwards":_=u.child;for(l=null;_!==null;)f=_.alternate,f!==null&&g8(f)===null&&(l=_),_=_.sibling;_=l,_===null?(l=u.child,u.child=null):(l=_.sibling,_.sibling=null),_9(u,!1,l,_,$);break;case"backwards":_=null,l=u.child;for(u.child=null;l!==null;){if(f=l.alternate,f!==null&&g8(f)===null){u.child=l;break}f=l.sibling,l.sibling=_,_=l,l=f}_9(u,!0,_,null,$);break;case"together":_9(u,!1,null,null,void 0);break;default:u.memoizedState=null}return u.child}function B8(f,u){(u.mode&1)===0&&f!==null&&(f.alternate=null,u.alternate=null,u.flags|=2)}function f_(f,u,_){if(f!==null&&(u.dependencies=f.dependencies),Ny|=u.lanes,(_&u.childLanes)===0)return null;if(f!==null&&u.child!==f.child)throw Error(Ff(153));if(u.child!==null){f=u.child,_=T_(f,f.pendingProps),u.child=_;for(_.return=u;f.sibling!==null;)f=f.sibling,_=_.sibling=T_(f,f.pendingProps),_.return=u;_.sibling=null}return u.child}function tE(f,u,_){switch(u.tag){case 3:XW(u),T3();break;case 5:oU(u);break;case 1:Xu(u.type)&&I8(u);break;case 4:N7(u,u.stateNode.containerInfo);break;case 10:var y=u.type._context,l=u.memoizedProps.value;l0(m8,y._currentValue),y._currentValue=l;break;case 13:if(y=u.memoizedState,y!==null){if(y.dehydrated!==null)return l0(K0,K0.current&1),u.flags|=128,null;if((_&u.child.childLanes)!==0)return NW(f,u,_);return l0(K0,K0.current&1),f=f_(f,u,_),f!==null?f.sibling:null}l0(K0,K0.current&1);break;case 19:if(y=(_&u.childLanes)!==0,(f.flags&128)!==0){if(y)return LW(f,u,_);u.flags|=128}if(l=u.memoizedState,l!==null&&(l.rendering=null,l.tail=null,l.lastEffect=null),l0(K0,K0.current),y)break;else return null;case 22:case 23:return u.lanes=0,EW(f,u,_)}return f_(f,u,_)}var YW,k9,BW,wW;YW=function(f,u){for(var _=u.child;_!==null;){if(_.tag===5||_.tag===6)f.appendChild(_.stateNode);else if(_.tag!==4&&_.child!==null){_.child.return=_,_=_.child;continue}if(_===u)break;for(;_.sibling===null;){if(_.return===null||_.return===u)return;_=_.return}_.sibling.return=_.return,_=_.sibling}};k9=function(){};BW=function(f,u,_,y){var l=f.memoizedProps;if(l!==y){f=u.stateNode,qy(S1.current);var $=null;switch(_){case"input":l=Q9(f,l),y=Q9(f,y),$=[];break;case"select":l=q0({},l,{value:void 0}),y=q0({},y,{value:void 0}),$=[];break;case"textarea":l=z9(f,l),y=z9(f,y),$=[];break;default:typeof l.onClick!=="function"&&typeof y.onClick==="function"&&(f.onclick=b8)}K9(_,y);var j;_=null;for(Q in l)if(!y.hasOwnProperty(Q)&&l.hasOwnProperty(Q)&&l[Q]!=null)if(Q==="style"){var J=l[Q];for(j in J)J.hasOwnProperty(j)&&(_||(_={}),_[j]="")}else Q!=="dangerouslySetInnerHTML"&&Q!=="children"&&Q!=="suppressContentEditableWarning"&&Q!=="suppressHydrationWarning"&&Q!=="autoFocus"&&(y$.hasOwnProperty(Q)?$||($=[]):($=$||[]).push(Q,null));for(Q in y){var F=y[Q];if(J=l!=null?l[Q]:void 0,y.hasOwnProperty(Q)&&F!==J&&(F!=null||J!=null))if(Q==="style")if(J){for(j in J)!J.hasOwnProperty(j)||F&&F.hasOwnProperty(j)||(_||(_={}),_[j]="");for(j in F)F.hasOwnProperty(j)&&J[j]!==F[j]&&(_||(_={}),_[j]=F[j])}else _||($||($=[]),$.push(Q,_)),_=F;else Q==="dangerouslySetInnerHTML"?(F=F?F.__html:void 0,J=J?J.__html:void 0,F!=null&&J!==F&&($=$||[]).push(Q,F)):Q==="children"?typeof F!=="string"&&typeof F!=="number"||($=$||[]).push(Q,""+F):Q!=="suppressContentEditableWarning"&&Q!=="suppressHydrationWarning"&&(y$.hasOwnProperty(Q)?(F!=null&&Q==="onScroll"&&F0("scroll",f),$||J===F||($=[])):($=$||[]).push(Q,F))}_&&($=$||[]).push("style",_);var Q=$;if(u.updateQueue=Q)u.flags|=4}};wW=function(f,u,_,y){_!==y&&(u.flags|=4)};function bl(f,u){if(!U0)switch(f.tailMode){case"hidden":u=f.tail;for(var _=null;u!==null;)u.alternate!==null&&(_=u),u=u.sibling;_===null?f.tail=null:_.sibling=null;break;case"collapsed":_=f.tail;for(var y=null;_!==null;)_.alternate!==null&&(y=_),_=_.sibling;y===null?u||f.tail===null?f.tail=null:f.tail.sibling=null:y.sibling=null}}function _u(f){var u=f.alternate!==null&&f.alternate.child===f.child,_=0,y=0;if(u)for(var l=f.child;l!==null;)_|=l.lanes|l.childLanes,y|=l.subtreeFlags&14680064,y|=l.flags&14680064,l.return=f,l=l.sibling;else for(l=f.child;l!==null;)_|=l.lanes|l.childLanes,y|=l.subtreeFlags,y|=l.flags,l.return=f,l=l.sibling;return f.subtreeFlags|=y,f.childLanes=_,u}function sE(f,u,_){var y=u.pendingProps;switch(Z7(u),u.tag){case 2:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return _u(u),null;case 1:return Xu(u.type)&&h8(),_u(u),null;case 3:if(y=u.stateNode,r3(),A0(Ou),A0(lu),Y7(),y.pendingContext&&(y.context=y.pendingContext,y.pendingContext=null),f===null||f.child===null)G8(u)?u.flags|=4:f===null||f.memoizedState.isDehydrated&&(u.flags&256)===0||(u.flags|=1024,W1!==null&&(d9(W1),W1=null));return k9(f,u),_u(u),null;case 5:L7(u);var l=qy(K$.current);if(_=u.type,f!==null&&u.stateNode!=null)BW(f,u,_,y,l),f.ref!==u.ref&&(u.flags|=512,u.flags|=2097152);else{if(!y){if(u.stateNode===null)throw Error(Ff(166));return _u(u),null}if(f=qy(S1.current),G8(u)){y=u.stateNode,_=u.type;var $=u.memoizedProps;switch(y[M1]=u,y[z$]=$,f=(u.mode&1)!==0,_){case"dialog":F0("cancel",y),F0("close",y);break;case"iframe":case"object":case"embed":F0("load",y);break;case"video":case"audio":for(l=0;l",f=f.removeChild(f.firstChild)):typeof y.is==="string"?f=j.createElement(_,{is:y.is}):(f=j.createElement(_),_==="select"&&(j=f,y.multiple?j.multiple=!0:y.size&&(j.size=y.size))):f=j.createElementNS(f,_),f[M1]=u,f[z$]=y,YW(f,u,!1,!1),u.stateNode=f;f:{switch(j=Z9(_,y),_){case"dialog":F0("cancel",f),F0("close",f),l=y;break;case"iframe":case"object":case"embed":F0("load",f),l=y;break;case"video":case"audio":for(l=0;lP3&&(u.flags|=128,y=!0,bl($,!1),u.lanes=4194304)}else{if(!y)if(f=g8(j),f!==null){if(u.flags|=128,y=!0,_=f.updateQueue,_!==null&&(u.updateQueue=_,u.flags|=4),bl($,!0),$.tail===null&&$.tailMode==="hidden"&&!j.alternate&&!U0)return _u(u),null}else 2*w0()-$.renderingStartTime>P3&&_!==1073741824&&(u.flags|=128,y=!0,bl($,!1),u.lanes=4194304);$.isBackwards?(j.sibling=u.child,u.child=j):(_=$.last,_!==null?_.sibling=j:u.child=j,$.last=j)}if($.tail!==null)return u=$.tail,$.rendering=u,$.tail=u.sibling,$.renderingStartTime=w0(),u.sibling=null,_=K0.current,l0(K0,y?_&1|2:_&1),u;return _u(u),null;case 22:case 23:return x7(),y=u.memoizedState!==null,f!==null&&f.memoizedState!==null!==y&&(u.flags|=8192),y&&(u.mode&1)!==0?(ru&1073741824)!==0&&(_u(u),u.subtreeFlags&6&&(u.flags|=8192)):_u(u),null;case 24:return null;case 25:return null}throw Error(Ff(156,u.tag))}function oE(f,u){switch(Z7(u),u.tag){case 1:return Xu(u.type)&&h8(),f=u.flags,f&65536?(u.flags=f&-65537|128,u):null;case 3:return r3(),A0(Ou),A0(lu),Y7(),f=u.flags,(f&65536)!==0&&(f&128)===0?(u.flags=f&-65537|128,u):null;case 5:return L7(u),null;case 13:if(A0(K0),f=u.memoizedState,f!==null&&f.dehydrated!==null){if(u.alternate===null)throw Error(Ff(340));T3()}return f=u.flags,f&65536?(u.flags=f&-65537|128,u):null;case 19:return A0(K0),null;case 4:return r3(),null;case 10:return E7(u.type._context),null;case 22:case 23:return x7(),null;case 24:return null;default:return null}}var q8=!1,yu=!1,aE=typeof WeakSet==="function"?WeakSet:Set,qf=null;function V3(f,u){var _=f.ref;if(_!==null)if(typeof _==="function")try{_(null)}catch(y){L0(f,u,y)}else _.current=null}function i9(f,u,_){try{_()}catch(y){L0(f,u,y)}}var vQ=!1;function dE(f,u){if(D9=R8,f=SU(),G7(f)){if("selectionStart"in f)var _={start:f.selectionStart,end:f.selectionEnd};else f:{_=(_=f.ownerDocument)&&_.defaultView||window;var y=_.getSelection&&_.getSelection();if(y&&y.rangeCount!==0){_=y.anchorNode;var{anchorOffset:l,focusNode:$}=y;y=y.focusOffset;try{_.nodeType,$.nodeType}catch(E){_=null;break f}var j=0,J=-1,F=-1,Q=0,U=0,z=f,W=null;u:for(;;){for(var K;;){if(z!==_||l!==0&&z.nodeType!==3||(J=j+l),z!==$||y!==0&&z.nodeType!==3||(F=j+y),z.nodeType===3&&(j+=z.nodeValue.length),(K=z.firstChild)===null)break;W=z,z=K}for(;;){if(z===f)break u;if(W===_&&++Q===l&&(J=j),W===$&&++U===y&&(F=j),(K=z.nextSibling)!==null)break;z=W,W=z.parentNode}z=K}_=J===-1||F===-1?null:{start:J,end:F}}else _=null}_=_||{start:0,end:0}}else _=null;T9={focusedElem:f,selectionRange:_},R8=!1;for(qf=u;qf!==null;)if(u=qf,f=u.child,(u.subtreeFlags&1028)!==0&&f!==null)f.return=u,qf=f;else for(;qf!==null;){u=qf;try{var q=u.alternate;if((u.flags&1024)!==0)switch(u.tag){case 0:case 11:case 15:break;case 1:if(q!==null){var{memoizedProps:V,memoizedState:O}=q,G=u.stateNode,H=G.getSnapshotBeforeUpdate(u.elementType===u.type?V:Q1(u.type,V),O);G.__reactInternalSnapshotBeforeUpdate=H}break;case 3:var Z=u.stateNode.containerInfo;Z.nodeType===1?Z.textContent="":Z.nodeType===9&&Z.documentElement&&Z.removeChild(Z.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(Ff(163))}}catch(E){L0(u,u.return,E)}if(f=u.sibling,f!==null){f.return=u.return,qf=f;break}qf=u.return}return q=vQ,vQ=!1,q}function f$(f,u,_){var y=u.updateQueue;if(y=y!==null?y.lastEffect:null,y!==null){var l=y=y.next;do{if((l.tag&f)===f){var $=l.destroy;l.destroy=void 0,$!==void 0&&i9(u,_,$)}l=l.next}while(l!==y)}}function F4(f,u){if(u=u.updateQueue,u=u!==null?u.lastEffect:null,u!==null){var _=u=u.next;do{if((_.tag&f)===f){var y=_.create;_.destroy=y()}_=_.next}while(_!==u)}}function g9(f){var u=f.ref;if(u!==null){var _=f.stateNode;switch(f.tag){case 5:f=_;break;default:f=_}typeof u==="function"?u(f):u.current=f}}function DW(f){var u=f.alternate;u!==null&&(f.alternate=null,DW(u)),f.child=null,f.deletions=null,f.sibling=null,f.tag===5&&(u=f.stateNode,u!==null&&(delete u[M1],delete u[z$],delete u[S9],delete u[CE],delete u[RE])),f.stateNode=null,f.return=null,f.dependencies=null,f.memoizedProps=null,f.memoizedState=null,f.pendingProps=null,f.stateNode=null,f.updateQueue=null}function TW(f){return f.tag===5||f.tag===3||f.tag===4}function bQ(f){f:for(;;){for(;f.sibling===null;){if(f.return===null||TW(f.return))return null;f=f.return}f.sibling.return=f.return;for(f=f.sibling;f.tag!==5&&f.tag!==6&&f.tag!==18;){if(f.flags&2)continue f;if(f.child===null||f.tag===4)continue f;else f.child.return=f,f=f.child}if(!(f.flags&2))return f.stateNode}}function n9(f,u,_){var y=f.tag;if(y===5||y===6)f=f.stateNode,u?_.nodeType===8?_.parentNode.insertBefore(f,u):_.insertBefore(f,u):(_.nodeType===8?(u=_.parentNode,u.insertBefore(f,_)):(u=_,u.appendChild(f)),_=_._reactRootContainer,_!==null&&_!==void 0||u.onclick!==null||(u.onclick=b8));else if(y!==4&&(f=f.child,f!==null))for(n9(f,u,_),f=f.sibling;f!==null;)n9(f,u,_),f=f.sibling}function t9(f,u,_){var y=f.tag;if(y===5||y===6)f=f.stateNode,u?_.insertBefore(f,u):_.appendChild(f);else if(y!==4&&(f=f.child,f!==null))for(t9(f,u,_),f=f.sibling;f!==null;)t9(f,u,_),f=f.sibling}var n0=null,U1=!1;function K_(f,u,_){for(_=_.child;_!==null;)MW(f,u,_),_=_.sibling}function MW(f,u,_){if(r1&&typeof r1.onCommitFiberUnmount==="function")try{r1.onCommitFiberUnmount(f4,_)}catch(J){}switch(_.tag){case 5:yu||V3(_,u);case 6:var y=n0,l=U1;n0=null,K_(f,u,_),n0=y,U1=l,n0!==null&&(U1?(f=n0,_=_.stateNode,f.nodeType===8?f.parentNode.removeChild(_):f.removeChild(_)):n0.removeChild(_.stateNode));break;case 18:n0!==null&&(U1?(f=n0,_=_.stateNode,f.nodeType===8?s2(f.parentNode,_):f.nodeType===1&&s2(f,_),F$(f)):s2(n0,_.stateNode));break;case 4:y=n0,l=U1,n0=_.stateNode.containerInfo,U1=!0,K_(f,u,_),n0=y,U1=l;break;case 0:case 11:case 14:case 15:if(!yu&&(y=_.updateQueue,y!==null&&(y=y.lastEffect,y!==null))){l=y=y.next;do{var $=l,j=$.destroy;$=$.tag,j!==void 0&&(($&2)!==0?i9(_,u,j):($&4)!==0&&i9(_,u,j)),l=l.next}while(l!==y)}K_(f,u,_);break;case 1:if(!yu&&(V3(_,u),y=_.stateNode,typeof y.componentWillUnmount==="function"))try{y.props=_.memoizedProps,y.state=_.memoizedState,y.componentWillUnmount()}catch(J){L0(_,u,J)}K_(f,u,_);break;case 21:K_(f,u,_);break;case 22:_.mode&1?(yu=(y=yu)||_.memoizedState!==null,K_(f,u,_),yu=y):K_(f,u,_);break;default:K_(f,u,_)}}function hQ(f){var u=f.updateQueue;if(u!==null){f.updateQueue=null;var _=f.stateNode;_===null&&(_=f.stateNode=new aE),u.forEach(function(y){var l=JO.bind(null,f,y);_.has(y)||(_.add(y),y.then(l,l))})}}function A1(f,u){var _=u.deletions;if(_!==null)for(var y=0;y<_.length;y++){var l=_[y];try{var $=f,j=u,J=j;f:for(;J!==null;){switch(J.tag){case 5:n0=J.stateNode,U1=!1;break f;case 3:n0=J.stateNode.containerInfo,U1=!0;break f;case 4:n0=J.stateNode.containerInfo,U1=!0;break f}J=J.return}if(n0===null)throw Error(Ff(160));MW($,j,l),n0=null,U1=!1;var F=l.alternate;F!==null&&(F.return=null),l.return=null}catch(Q){L0(l,u,Q)}}if(u.subtreeFlags&12854)for(u=u.child;u!==null;)rW(u,f),u=u.sibling}function rW(f,u){var{alternate:_,flags:y}=f;switch(f.tag){case 0:case 11:case 14:case 15:if(A1(u,f),D1(f),y&4){try{f$(3,f,f.return),F4(3,f)}catch(V){L0(f,f.return,V)}try{f$(5,f,f.return)}catch(V){L0(f,f.return,V)}}break;case 1:A1(u,f),D1(f),y&512&&_!==null&&V3(_,_.return);break;case 5:if(A1(u,f),D1(f),y&512&&_!==null&&V3(_,_.return),f.flags&32){var l=f.stateNode;try{l$(l,"")}catch(V){L0(f,f.return,V)}}if(y&4&&(l=f.stateNode,l!=null)){var $=f.memoizedProps,j=_!==null?_.memoizedProps:$,J=f.type,F=f.updateQueue;if(f.updateQueue=null,F!==null)try{J==="input"&&$.type==="radio"&&$.name!=null&&eQ(l,$),Z9(J,j);var Q=Z9(J,$);for(j=0;jl&&(l=j),y&=~$}if(y=l,y=w0()-y,y=(120>y?120:480>y?480:1080>y?1080:1920>y?1920:3000>y?3000:4320>y?4320:1960*fO(y/1960))-y,10f?16:f,O_===null)var y=!1;else{if(f=O_,O_=null,a8=0,(gf&6)!==0)throw Error(Ff(331));var l=gf;gf|=4;for(qf=f.current;qf!==null;){var $=qf,j=$.child;if((qf.flags&16)!==0){var J=$.deletions;if(J!==null){for(var F=0;Fw0()-C7?Hy(f,0):P7|=_),Nu(f,u)}function bW(f,u){u===0&&((f.mode&1)===0?u=1:(u=F8,F8<<=1,(F8&130023424)===0&&(F8=4194304)));var _=Gu();f=e1(f,u),f!==null&&(E$(f,u,_),Nu(f,_))}function jO(f){var u=f.memoizedState,_=0;u!==null&&(_=u.retryLane),bW(f,_)}function JO(f,u){var _=0;switch(f.tag){case 13:var{stateNode:y,memoizedState:l}=f;l!==null&&(_=l.retryLane);break;case 19:y=f.stateNode;break;default:throw Error(Ff(314))}y!==null&&y.delete(u),bW(f,_)}var hW;hW=function(f,u,_){if(f!==null)if(f.memoizedProps!==u.pendingProps||Ou.current)Eu=!0;else{if((f.lanes&_)===0&&(u.flags&128)===0)return Eu=!1,tE(f,u,_);Eu=(f.flags&131072)!==0?!0:!1}else Eu=!1,U0&&(u.flags&1048576)!==0&&mU(u,p8,u.index);switch(u.lanes=0,u.tag){case 2:var y=u.type;B8(f,u),f=u.pendingProps;var l=D3(u,lu.current);Y3(u,_),l=w7(null,u,y,f,l,_);var $=D7();return u.flags|=1,typeof l==="object"&&l!==null&&typeof l.render==="function"&&l.$$typeof===void 0?(u.tag=1,u.memoizedState=null,u.updateQueue=null,Xu(y)?($=!0,I8(u)):$=!1,u.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,X7(u),l.updater=J4,u.stateNode=l,l._reactInternals=u,b9(u,y,f,_),u=c9(null,u,y,!0,$,_)):(u.tag=0,U0&&$&&K7(u),zu(null,u,l,_),u=u.child),u;case 16:y=u.elementType;f:{switch(B8(f,u),f=u.pendingProps,l=y._init,y=l(y._payload),u.type=y,l=u.tag=AO(y),f=Q1(y,f),l){case 0:u=I9(null,u,y,f,_);break f;case 1:u=CQ(null,u,y,f,_);break f;case 11:u=SQ(null,u,y,f,_);break f;case 14:u=PQ(null,u,y,Q1(y.type,f),_);break f}throw Error(Ff(306,y,""))}return u;case 0:return y=u.type,l=u.pendingProps,l=u.elementType===y?l:Q1(y,l),I9(f,u,y,l,_);case 1:return y=u.type,l=u.pendingProps,l=u.elementType===y?l:Q1(y,l),CQ(f,u,y,l,_);case 3:f:{if(XW(u),f===null)throw Error(Ff(387));y=u.pendingProps,$=u.memoizedState,l=$.element,sU(f,u),i8(u,y,null,_);var j=u.memoizedState;if(y=j.element,$.isDehydrated)if($={element:y,isDehydrated:!1,cache:j.cache,pendingSuspenseBoundaries:j.pendingSuspenseBoundaries,transitions:j.transitions},u.updateQueue.baseState=$,u.memoizedState=$,u.flags&256){l=S3(Error(Ff(423)),u),u=RQ(f,u,y,_,l);break f}else if(y!==l){l=S3(Error(Ff(424)),u),u=RQ(f,u,y,_,l);break f}else for(Su=Y_(u.stateNode.containerInfo.firstChild),Pu=u,U0=!0,W1=null,_=nU(u,null,y,_),u.child=_;_;)_.flags=_.flags&-3|4096,_=_.sibling;else{if(T3(),y===l){u=f_(f,u,_);break f}zu(f,u,y,_)}u=u.child}return u;case 5:return oU(u),f===null&&R9(u),y=u.type,l=u.pendingProps,$=f!==null?f.memoizedProps:null,j=l.children,M9(y,l)?j=null:$!==null&&M9(y,$)&&(u.flags|=32),OW(f,u),zu(f,u,j,_),u.child;case 6:return f===null&&R9(u),null;case 13:return NW(f,u,_);case 4:return N7(u,u.stateNode.containerInfo),y=u.pendingProps,f===null?u.child=M3(u,null,y,_):zu(f,u,y,_),u.child;case 11:return y=u.type,l=u.pendingProps,l=u.elementType===y?l:Q1(y,l),SQ(f,u,y,l,_);case 7:return zu(f,u,u.pendingProps,_),u.child;case 8:return zu(f,u,u.pendingProps.children,_),u.child;case 12:return zu(f,u,u.pendingProps.children,_),u.child;case 10:f:{if(y=u.type._context,l=u.pendingProps,$=u.memoizedProps,j=l.value,l0(m8,y._currentValue),y._currentValue=j,$!==null)if(K1($.value,j)){if($.children===l.children&&!Ou.current){u=f_(f,u,_);break f}}else for($=u.child,$!==null&&($.return=u);$!==null;){var J=$.dependencies;if(J!==null){j=$.child;for(var F=J.firstContext;F!==null;){if(F.context===y){if($.tag===1){F=o1(-1,_&-_),F.tag=2;var Q=$.updateQueue;if(Q!==null){Q=Q.shared;var U=Q.pending;U===null?F.next=F:(F.next=U.next,U.next=F),Q.pending=F}}$.lanes|=_,F=$.alternate,F!==null&&(F.lanes|=_),x9($.return,_,u),J.lanes|=_;break}F=F.next}}else if($.tag===10)j=$.type===u.type?null:$.child;else if($.tag===18){if(j=$.return,j===null)throw Error(Ff(341));j.lanes|=_,J=j.alternate,J!==null&&(J.lanes|=_),x9(j,_,u),j=$.sibling}else j=$.child;if(j!==null)j.return=$;else for(j=$;j!==null;){if(j===u){j=null;break}if($=j.sibling,$!==null){$.return=j.return,j=$;break}j=j.return}$=j}zu(f,u,l.children,_),u=u.child}return u;case 9:return l=u.type,y=u.pendingProps.children,Y3(u,_),l=eu(l),y=y(l),u.flags|=1,zu(f,u,y,_),u.child;case 14:return y=u.type,l=Q1(y,u.pendingProps),l=Q1(y.type,l),PQ(f,u,y,l,_);case 15:return VW(f,u,u.type,u.pendingProps,_);case 17:return y=u.type,l=u.pendingProps,l=u.elementType===y?l:Q1(y,l),B8(f,u),u.tag=1,Xu(y)?(f=!0,I8(u)):f=!1,Y3(u,_),ZW(u,y,l),b9(u,y,l,_),c9(null,u,y,!0,f,_);case 19:return LW(f,u,_);case 22:return EW(f,u,_)}throw Error(Ff(156,u.tag))};function IW(f,u){return zU(f,u)}function FO(f,u,_,y){this.tag=f,this.key=_,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=u,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=y,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function au(f,u,_,y){return new FO(f,u,_,y)}function b7(f){return f=f.prototype,!(!f||!f.isReactComponent)}function AO(f){if(typeof f==="function")return b7(f)?1:0;if(f!==void 0&&f!==null){if(f=f.$$typeof,f===y7)return 11;if(f===l7)return 14}return 2}function T_(f,u){var _=f.alternate;return _===null?(_=au(f.tag,u,f.key,f.mode),_.elementType=f.elementType,_.type=f.type,_.stateNode=f.stateNode,_.alternate=f,f.alternate=_):(_.pendingProps=u,_.type=f.type,_.flags=0,_.subtreeFlags=0,_.deletions=null),_.flags=f.flags&14680064,_.childLanes=f.childLanes,_.lanes=f.lanes,_.child=f.child,_.memoizedProps=f.memoizedProps,_.memoizedState=f.memoizedState,_.updateQueue=f.updateQueue,u=f.dependencies,_.dependencies=u===null?null:{lanes:u.lanes,firstContext:u.firstContext},_.sibling=f.sibling,_.index=f.index,_.ref=f.ref,_}function T8(f,u,_,y,l,$){var j=2;if(y=f,typeof f==="function")b7(f)&&(j=1);else if(typeof f==="string")j=5;else f:switch(f){case Q3:return Vy(_.children,l,$,u);case _7:j=8,l|=8;break;case j9:return f=au(12,_,u,l|2),f.elementType=j9,f.lanes=$,f;case J9:return f=au(13,_,u,l),f.elementType=J9,f.lanes=$,f;case F9:return f=au(19,_,u,l),f.elementType=F9,f.lanes=$,f;case oQ:return Q4(_,l,$,u);default:if(typeof f==="object"&&f!==null)switch(f.$$typeof){case tQ:j=10;break f;case sQ:j=9;break f;case y7:j=11;break f;case l7:j=14;break f;case Z_:j=16,y=null;break f}throw Error(Ff(130,f==null?f:typeof f,""))}return u=au(j,_,u,l),u.elementType=f,u.type=y,u.lanes=$,u}function Vy(f,u,_,y){return f=au(7,f,y,u),f.lanes=_,f}function Q4(f,u,_,y){return f=au(22,f,y,u),f.elementType=oQ,f.lanes=_,f.stateNode={isHidden:!1},f}function y9(f,u,_){return f=au(6,f,null,u),f.lanes=_,f}function l9(f,u,_){return u=au(4,f.children!==null?f.children:[],f.key,u),u.lanes=_,u.stateNode={containerInfo:f.containerInfo,pendingChildren:null,implementation:f.implementation},u}function QO(f,u,_,y,l){this.tag=u,this.containerInfo=f,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=p2(0),this.expirationTimes=p2(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=p2(0),this.identifierPrefix=y,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function h7(f,u,_,y,l,$,j,J,F){return f=new QO(f,u,_,J,F),u===1?(u=1,$===!0&&(u|=8)):u=0,$=au(3,null,null,u),f.current=$,$.stateNode=f,$.memoizedState={element:y,isDehydrated:_,cache:null,transitions:null,pendingSuspenseBoundaries:null},X7($),f}function UO(f,u,_){var y=3{function iW(){if(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=="function")return;try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(iW)}catch(f){console.error(f)}}iW(),gW.exports=kW()});var tW=Mu((k7)=>{var nW=m7();k7.createRoot=nW.createRoot,k7.hydrateRoot=nW.hydrateRoot;var ZO});var jG=Mu((h4)=>{var NN=I0(),LN=Symbol.for("react.element"),YN=Symbol.for("react.fragment"),BN=Object.prototype.hasOwnProperty,wN=NN.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,DN={key:!0,ref:!0,__self:!0,__source:!0};function $G(f,u,_){var y,l={},$=null,j=null;_!==void 0&&($=""+_),u.key!==void 0&&($=""+u.key),u.ref!==void 0&&(j=u.ref);for(y in u)BN.call(u,y)&&!DN.hasOwnProperty(y)&&(l[y]=u[y]);if(f&&f.defaultProps)for(y in u=f.defaultProps,u)l[y]===void 0&&(l[y]=u[y]);return{$$typeof:LN,type:f,key:$,ref:j,props:l,_owner:wN.current}}h4.Fragment=YN;h4.jsx=$G;h4.jsxs=$G});var FG=Mu((DS,JG)=>{JG.exports=jG()});var hK=Mu((bK)=>{var Jl=I0();function iB(f,u){return f===u&&(f!==0||1/f===1/u)||f!==f&&u!==u}var gB=typeof Object.is==="function"?Object.is:iB,nB=Jl.useState,tB=Jl.useEffect,sB=Jl.useLayoutEffect,oB=Jl.useDebugValue;function aB(f,u){var _=u(),y=nB({inst:{value:_,getSnapshot:u}}),l=y[0].inst,$=y[1];return sB(function(){l.value=_,l.getSnapshot=u,XF(l)&&$({inst:l})},[f,_,u]),tB(function(){return XF(l)&&$({inst:l}),f(function(){XF(l)&&$({inst:l})})},[f]),oB(_),_}function XF(f){var u=f.getSnapshot;f=f.value;try{var _=u();return!gB(f,_)}catch(y){return!0}}function dB(f,u){return u()}var eB=typeof window>"u"||typeof window.document>"u"||typeof window.document.createElement>"u"?dB:aB;bK.useSyncExternalStore=Jl.useSyncExternalStore!==void 0?Jl.useSyncExternalStore:eB});var cK=Mu((Ob,IK)=>{IK.exports=hK()});var mK=Mu((pK)=>{var S5=I0(),fw=cK();function uw(f,u){return f===u&&(f!==0||1/f===1/u)||f!==f&&u!==u}var _w=typeof Object.is==="function"?Object.is:uw,yw=fw.useSyncExternalStore,lw=S5.useRef,$w=S5.useEffect,jw=S5.useMemo,Jw=S5.useDebugValue;pK.useSyncExternalStoreWithSelector=function(f,u,_,y,l){var $=lw(null);if($.current===null){var j={hasValue:!1,value:null};$.current=j}else j=$.current;$=jw(function(){function F(K){if(!Q){if(Q=!0,U=K,K=y(K),l!==void 0&&j.hasValue){var q=j.value;if(l(q,K))return z=q}return z=K}if(q=z,_w(U,K))return q;var V=y(K);if(l!==void 0&&l(q,V))return U=K,q;return U=K,z=V}var Q=!1,U,z,W=_===void 0?null:_;return[function(){return F(u())},W===null?void 0:function(){return F(W())}]},[u,_,y,l]);var J=yw(f,$[0],$[1]);return $w(function(){j.hasValue=!0,j.value=J},[J]),Jw(J),J}});var iK=Mu((Nb,kK)=>{kK.exports=mK()});var Q2=Sf(I0(),1),KH=Sf(tW(),1);var H4=Sf(I0(),1);class x3 extends Error{unideskRequestError=!0;meta;constructor(f,u){super(f);this.name="UniDeskRequestError",this.meta=u}}function qO(f){return new Promise((u)=>setTimeout(u,f))}function B$(f,u="操作失败"){return f instanceof Error?f.message:String(f||u)}function K4(f,u=500){if(f===null||f===void 0)return"";let _=typeof f==="string"?f:JSON.stringify(f),y=String(_||"").replace(/\s+/gu," ").trim();return y.length>u?`${y.slice(0,u)}...`:y}function HO(f){try{let u=typeof location<"u"&&location.origin?location.origin:"http://localhost";return new URL(f,u).toString()}catch{return f}}function sW(f){return String(f.method||"GET").toUpperCase()}function VO(f){if(f===null||f===void 0)return!1;if(typeof f!=="object")return!1;if(typeof Blob<"u"&&f instanceof Blob)return!1;if(typeof FormData<"u"&&f instanceof FormData)return!1;if(typeof URLSearchParams<"u"&&f instanceof URLSearchParams)return!1;if(typeof ArrayBuffer<"u"&&f instanceof ArrayBuffer)return!1;return!0}function oW(f){let u=new Headers(f.headers||{}),_=VO(f.body)?JSON.stringify(f.body):f.body;if(_&&!u.has("content-type")&&typeof _==="string")u.set("content-type","application/json");return{...f,credentials:f.credentials||"same-origin",body:_,headers:u}}function aW(f){if(f?.error&&typeof f.error==="object"&&typeof f.error.message==="string")return f.error.message;if(typeof f?.error==="string")return f.error;if(typeof f?.message==="string")return f.message;if(typeof f?.detail==="string")return f.detail;return""}function EO(f,u){if(!f||typeof f!=="object"||Array.isArray(f))return!1;return u.some((_)=>_!==!1&&f[_]===!1)}function L$(f,u,_,y,l={}){return{kind:f,method:_,url:HO(u),occurredAt:y.toISOString(),...l}}function Y$(f,u){if(!f)return"请求失败";return`HTTP ${f}${u?` ${u}`:""}`}function dW(f){try{return{body:f?JSON.parse(f):null,parseError:""}}catch(u){return{body:{text:f},parseError:B$(u,"parse failed")}}}async function Df(f,u={},_=0){let{failureFields:y=["ok"],strictJson:l=!1,retryInvalidJson:$=0,retryDelayMs:j=120,invalidJsonPrefix:J="服务返回了无效 JSON",invalidJsonPreview:F=!1,responsePreviewLength:Q=500,...U}=u,z=sW(U),W=new Date,K;try{K=await fetch(f,oW(U))}catch(O){let G=B$(O,"网络请求失败");throw new x3(G,L$("network",f,z,W,{upstreamMessage:G}))}let q=await K.text(),V=dW(q);if(V.parseError){if(l&&z==="GET"&&_<$)return await qO(j),Df(f,u,_+1);if(l){let O=F?`;响应预览:${K4(q,180)}`:"";throw new x3(`${J}(${q.length} bytes):${V.parseError}${O}`,L$("parse",f,z,W,{status:K.status,statusText:K.statusText,parseError:V.parseError,responsePreview:K4(q,Q)}))}}if(!K.ok||EO(V.body,y)){let O=aW(V.body),G=O||Y$(K.status,K.statusText);throw new x3(G,L$("http",f,z,W,{status:K.status,statusText:K.statusText,upstreamMessage:O,responsePreview:K4(V.parseError?q:V.body,Q)}))}return V.body}async function eW(f,u={}){let _=sW(u),y=new Date,l;try{l=await fetch(f,oW(u))}catch(Q){let U=B$(Q,"网络请求失败");throw new x3(U,L$("network",f,_,y,{upstreamMessage:U}))}if(l.ok)return l.blob();let $=await l.text(),j=dW($),J=aW(j.body),F=J||Y$(l.status,l.statusText);throw new x3(F,L$("http",f,_,y,{status:l.status,statusText:l.statusText,upstreamMessage:J,responsePreview:K4(j.parseError?$:j.body),parseError:j.parseError||void 0}))}function fz(f){return Boolean(f&&typeof f==="object"&&f.unideskRequestError===!0&&f.meta)}function OO(f){if(!f)return"";let u=new Date(f);if(Number.isNaN(u.getTime()))return f;return`${u.toLocaleString("zh-CN",{hour12:!1})} / ${u.toISOString()}`}function i7(f,u="操作失败"){if(fz(f)){let l=f.meta.kind==="parse"?"响应解析失败":f.meta.kind==="network"?"网络请求失败":f.meta.status&&(f.meta.status<200||f.meta.status>=300)?Y$(f.meta.status,f.meta.statusText):"应用请求失败",$=f.meta.status?Y$(f.meta.status):"",j=(F)=>!F||F===l||F===$,J=!j(f.message)?f.message:j(f.meta.upstreamMessage)?"":f.meta.upstreamMessage||"";return{title:l,message:J,status:f.meta.status,statusText:f.meta.statusText,method:f.meta.method,url:f.meta.url,occurredAt:OO(f.meta.occurredAt),responsePreview:f.meta.responsePreview,parseError:f.meta.parseError,structured:!0}}let y=B$(f,u).split(/\r?\n/u);return{title:y[0]||u,message:y.slice(1).join(` +`),structured:y.length>1}}function XO(f,u="操作失败"){let _=i7(f,u),y=[_.title];if(_.message)y.push(`原因: ${_.message}`);if(_.method||_.url)y.push(`请求: ${[_.method,_.url].filter(Boolean).join(" ")}`);if(_.status)y.push(`状态: ${Y$(_.status,_.statusText)}`);if(_.occurredAt)y.push(`时间: ${_.occurredAt}`);if(_.parseError)y.push(`解析错误: ${_.parseError}`);if(_.responsePreview&&_.responsePreview!==_.message)y.push(`响应预览: ${_.responsePreview}`);return y.filter(Boolean).join(` +`)}function Tf(f,u="操作失败"){return fz(f)?XO(f,u):B$(f,u)}var uz=Sf(I0(),1);var R_=uz.default.createElement;function w$(f,u){return u?[R_("dt",{key:`${f}-label`},f),R_("dd",{key:f},u)]:null}function H0({error:f,wide:u=!1,fallback:_="操作失败",className:y=""}){if(!f)return null;let l=i7(f,_),$=[w$("请求",[l.method,l.url].filter(Boolean).join(" ")),w$("状态",l.status?`HTTP ${l.status}${l.statusText?` ${l.statusText}`:""}`:""),w$("时间",l.occurredAt),w$("解析错误",l.parseError),w$("响应预览",l.responsePreview)].filter(Boolean);return R_("div",{className:`form-error unidesk-error${u?" wide":""}${y?` ${y}`:""}`,role:"alert","data-testid":"unidesk-error"},R_("div",{className:"unidesk-error-title"},R_("strong",null,l.title),l.status?R_("span",{className:"unidesk-error-code"},`HTTP ${l.status}`):null),l.message?R_("pre",{className:"unidesk-error-message"},l.message):null,$.length>0?R_("dl",{className:"unidesk-error-details"},$):null)}var i=H4.default.createElement,{useEffect:NO}=H4.default,Z4=H4.default.useState,wy={label:"主用户私聊账号",userId:645275593};function D$(f){if(!f)return"--";let u=new Date(f);if(Number.isNaN(u.getTime()))return"--";return u.toLocaleString("zh-CN",{hour12:!1})}function LO(f){return f.toLocaleTimeString("zh-CN",{hour12:!1})}function g7(f){let u=Number(f);return Number.isFinite(u)?u.toLocaleString("zh-CN"):"--"}async function __(f,u={}){return Df(f,{failureFields:["ok","success"],...u})}function q4({status:f,children:u}){let _=String(f||"unknown").toLowerCase();return i("span",{className:`status-badge ${_}`},u||f||"unknown")}function v3({label:f,value:u,hint:_,tone:y}){return i("article",{className:`metric-card ${y||""}`},i("div",{className:"metric-label"},f),i("div",{className:"metric-value"},u),i("div",{className:"metric-hint"},_))}function b3({title:f,eyebrow:u,actions:_,children:y,className:l}){return i("section",{className:`panel ${l||""}`},i("div",{className:"panel-head"},i("div",null,u?i("p",{className:"panel-eyebrow"},u):null,i("h2",null,f)),_?i("div",{className:"panel-actions"},_):null),i("div",{className:"panel-body"},y))}function T$({title:f,data:u,onOpen:_,testId:y}){return i("button",{type:"button",className:"ghost-btn","data-testid":y,onClick:(l)=>{l?.stopPropagation?.(),_(f,u)}},"查看原始JSON")}function M$({title:f,text:u}){return i("div",{className:"empty-state"},i("strong",null,f),i("span",null,u))}function YO(f){return f?.runtime&&typeof f.runtime==="object"&&!Array.isArray(f.runtime)?f.runtime:{}}function BO(f){return f?.backend&&typeof f.backend==="object"&&!Array.isArray(f.backend)?f.backend:{}}function wO(f){return f?.repository&&typeof f.repository==="object"&&!Array.isArray(f.repository)?f.repository:{}}function x_(f,u){return`${f}/microservices/claudeqq/proxy${u}`}function DO(f){return Array.isArray(f?.events)?f.events.slice(0,80):[]}function TO(f){return Array.isArray(f?.subscriptions)?f.subscriptions.slice(0,50):[]}function MO(f){return Array.isArray(f?.messages)?f.messages.slice(0,30):[]}function _z(f){let u=f?.text??f?.message??f?.raw?.raw_message;if(typeof u!=="string")return"--";return u.length>180?`${u.slice(0,177)}...`:u}function yz(f){let u=f?.groupId??f?.group_id??(f?.message_type==="group"?f?.target_id:void 0),_=f?.userId??f?.user_id??(f?.message_type==="private"?f?.target_id:void 0);if(u)return`群 ${u}`;if(_)return`私聊 ${_}`;return"--"}function lz({microservices:f,onRaw:u,apiBaseUrl:_="/api"}){let y=f.find((r)=>r.id==="claudeqq")||null,[l,$]=Z4({loading:!1,qrLoading:!1,error:"",health:null,status:null,napcatLogin:null,napcatQrcode:null,qrcodeFetched:!1,qrcodeRefreshedAt:null,events:null,subscriptions:null,sent:null,refreshedAt:null}),[j,J]=Z4({targetType:"private",targetId:String(wy.userId),message:""}),[F,Q]=Z4({name:"unidesk-callback",targetUrl:"",eventTypes:"message",secret:""}),[U,z]=Z4("");async function W(){if(!y)return;$((r)=>({...r,loading:!0,error:""}));try{let[r,Y,v,m,c]=await Promise.all([__(`${_}/microservices/claudeqq/health`),__(x_(_,"/api/server/status")),__(x_(_,"/api/events/recent?limit=60")),__(x_(_,"/api/events/subscriptions")),__(x_(_,"/api/messages/sent?limit=20"))]);if($((o)=>({...o,loading:!1,error:"",health:r,status:Y,events:v,subscriptions:m,sent:c,refreshedAt:new Date})),!l.qrcodeFetched)K(!1)}catch(r){$((Y)=>({...Y,loading:!1,error:Tf(r,"ClaudeQQ 加载失败")}))}}async function K(r=!0){if(!y)return;$((Y)=>({...Y,qrLoading:!0,error:r?"":Y.error}));try{let Y=await __(x_(_,"/api/napcat/login")),v=Y?.napcat?.qrcode||Y?.qrcode||null;$((m)=>({...m,qrLoading:!1,error:"",napcatLogin:Y,napcatQrcode:v,qrcodeFetched:!0,qrcodeRefreshedAt:new Date}))}catch(Y){$((v)=>({...v,qrLoading:!1,error:r||!v.napcatQrcode?Tf(Y,"NapCat 二维码加载失败"):v.error}))}}async function q(r){r.preventDefault(),z("");let Y=Number(j.targetId);if(!Number.isFinite(Y)||Y<=0||j.message.trim().length===0){$((v)=>({...v,error:"请填写 QQ 目标和消息内容"}));return}try{await __(x_(_,"/api/push/text"),{method:"POST",body:JSON.stringify({userId:j.targetType==="private"?Y:void 0,groupId:j.targetType==="group"?Y:void 0,message:j.message})}),J((v)=>({...v,targetType:"private",targetId:String(wy.userId),message:""})),z("消息推送请求已提交"),await W()}catch(v){$((m)=>({...m,error:Tf(v,"发送失败")}))}}async function V(r){if(r.preventDefault(),z(""),F.targetUrl.trim().length===0){$((Y)=>({...Y,error:"请填写订阅回调 URL"}));return}try{await __(x_(_,"/api/events/subscriptions"),{method:"POST",body:JSON.stringify({name:F.name,targetUrl:F.targetUrl,eventTypes:F.eventTypes.split(",").map((Y)=>Y.trim()).filter(Boolean),secret:F.secret||void 0,enabled:!0})}),z("事件订阅已创建"),await W()}catch(Y){$((v)=>({...v,error:Tf(Y,"订阅失败")}))}}async function O(r){if(!r)return;z("");try{await __(x_(_,`/api/events/subscriptions/${encodeURIComponent(r)}`),{method:"DELETE"}),z("事件订阅已删除"),await W()}catch(Y){$((v)=>({...v,error:Tf(Y,"删除订阅失败")}))}}if(NO(()=>{if(!y)return;W();return},[y?.id,y?.runtime?.providerStatus]),!y)return i(M$,{title:"ClaudeQQ 未登记",text:"请在 config.json 的 microservices 中登记用户服务 id=claudeqq"});let G=YO(y),H=wO(y),Z=BO(y),E=l.health||{},L=l.status||{},M=l.napcatLogin||{},N=E.napcat||L.napcat||{},w={...M.napcat||{},...N,qrcode:l.napcatQrcode||{},webui:N.webui||M.napcat?.webui},R=M.login||{},p=l.napcatQrcode||{},x=DO(l.events),C=TO(l.subscriptions),P=MO(l.sent),D=Boolean(w.httpConnected||R.ready),T=String(w.loginState||R.state||(D?"logged_in":"unknown")),S=Boolean(p.available&&p.dataUrl);return i("div",{className:"claudeqq-page","data-testid":"claudeqq-page"},i(b3,{title:"ClaudeQQ 工作台",eyebrow:"D601 QQ Event Gateway",actions:i("div",{className:"panel-actions"},i("button",{type:"button",className:"ghost-btn",onClick:W,disabled:l.loading,"data-testid":"claudeqq-refresh-button"},l.loading?"刷新中":"刷新"),i(T$,{title:"ClaudeQQ 用户服务",data:y,onOpen:u,testId:"raw-claudeqq-service"}))},i("div",{className:"findjob-hero"},i("div",null,i("div",{className:"node-version-line"},i(q4,{status:G.providerStatus==="online"?"online":"warn"},G.providerStatus||"unknown"),i("span",null,y.providerId),i("span",null,Z.public?"公网暴露":"仅 UniDesk frontend 代理访问")),i("p",{className:"muted paragraph"},y.description)),i("div",{className:"microservice-ref-card"},i("span",null,"Repo"),i("strong",null,H.url||"--"),i("code",null,H.commitId||"--")),i("div",{className:"microservice-ref-card"},i("span",null,"D601 Docker"),i("strong",null,`${Z.nodeBindHost||"--"}:${Z.nodePort||"--"}`),i("code",null,`${H.composeFile||"--"} / ${H.composeService||"--"}`))),i(H0,{error:l.error,wide:!0}),U?i("div",{className:"form-success wide"},U):null),i("div",{className:"metric-grid"},i(v3,{label:"Health",value:E.ok||E.status==="ok"?"OK":"--",hint:"D601 /health",tone:E.ok||E.status==="ok"?"ok":"warn"}),i(v3,{label:"NapCat HTTP",value:w.httpConnected||w.http?.connected?"OK":"离线",hint:`${w.httpHost||E.napcat?.httpHost||"--"}:${w.httpPort||E.napcat?.httpPort||"--"}`}),i(v3,{label:"NapCat WS",value:w.wsConnected||w.ws?.connected?"OK":"离线",hint:`${w.wsHost||E.napcat?.wsHost||"--"}:${w.wsPort||E.napcat?.wsPort||"--"}`}),i(v3,{label:"事件缓存",value:g7(l.events?.count??x.length),hint:"recent QQ events"}),i(v3,{label:"订阅",value:g7(l.subscriptions?.count??C.length),hint:"webhook subscribers"}),i(v3,{label:"已发送",value:g7(l.sent?.count??P.length),hint:"sent message log"})),i("div",{className:"findjob-grid"},i(b3,{title:"NapCat 容器登录",eyebrow:"QR Login",className:"claudeqq-login-panel",actions:i("div",{className:"panel-actions inline-actions"},i("button",{type:"button",className:"ghost-btn",onClick:()=>K(!0),disabled:l.qrLoading,"data-testid":"claudeqq-napcat-refresh"},l.qrLoading?"刷新中":"手动刷新二维码"),i(T$,{title:"NapCat Login",data:l.napcatLogin,onOpen:u,testId:"raw-claudeqq-napcat-login"}))},i("div",{className:"claudeqq-login-card","data-testid":"claudeqq-napcat-login"},i("div",{className:"claudeqq-qr-frame"},S?i("img",{src:p.dataUrl,alt:"NapCat QQ 登录二维码","data-testid":"claudeqq-napcat-qrcode"}):i(M$,{title:"等待二维码",text:"NapCat 容器启动后会把登录二维码写入 cache/qrcode.png"})),i("div",{className:"claudeqq-login-copy"},i("div",{className:"node-version-line"},i(q4,{status:D?"online":S?"warn":"unknown"},D?"已登录":S?"待扫码":"等待二维码"),i("span",null,T),i("span",null,"D601 containerized")),i("p",{className:"muted paragraph"},D?"NapCat 已登录,ClaudeQQ 可通过容器内 HTTP/WS 链路收发 QQ 消息。":"用手机 QQ 扫描二维码授权登录。二维码只在首次加载或手动刷新时更新,D601 的 NapCat 端口仍只绑定 127.0.0.1。"),i("div",{className:"microservice-ref-card"},i("span",null,"NapCat WebUI"),i("strong",null,w.webui?.url||"http://napcat:6099/webui"),i("code",null,"local-only / proxied QR login")),i("div",{className:"microservice-ref-card"},i("span",null,"QR Source"),i("strong",null,p.modifiedAt?D$(p.modifiedAt):l.qrcodeRefreshedAt?D$(l.qrcodeRefreshedAt):"--"),i("code",null,p.file||"/napcat/cache/qrcode.png"))))),i(b3,{title:"消息推送",eyebrow:"Push API"},i("div",{className:"microservice-ref-card"},i("span",null,wy.label),i("strong",null,String(wy.userId)),i("code",null,"private userId / 默认推送测试目标")),i("form",{className:"stack-form",onSubmit:q,"data-testid":"claudeqq-push-form"},i("label",null,"目标类型",i("select",{value:j.targetType,onChange:(r)=>J((Y)=>({...Y,targetType:r.target.value}))},i("option",{value:"private"},"私聊 userId"),i("option",{value:"group"},"群 groupId"))),i("label",null,"QQ ID",i("input",{value:j.targetId,onChange:(r)=>J((Y)=>({...Y,targetId:r.target.value})),placeholder:String(wy.userId)})),i("label",null,"消息内容",i("textarea",{value:j.message,onChange:(r)=>J((Y)=>({...Y,message:r.target.value})),rows:4,placeholder:"通过 ClaudeQQ 推送一条 QQ 消息"})),i("button",{type:"submit",className:"primary-btn"},"发送 QQ 消息")),i("p",{className:"muted paragraph"},`主 server 和其他用户服务可通过 UniDesk 同源代理调用 /api/push/text;当前人工推送测试默认使用 ${wy.label} ${wy.userId},不需要暴露 D601 后端端口。`)),i(b3,{title:"QQ 事件订阅",eyebrow:"Webhook Subscription"},i("form",{className:"stack-form",onSubmit:V,"data-testid":"claudeqq-subscription-form"},i("label",null,"订阅名称",i("input",{value:F.name,onChange:(r)=>Q((Y)=>({...Y,name:r.target.value}))})),i("label",null,"回调 URL",i("input",{value:F.targetUrl,onChange:(r)=>Q((Y)=>({...Y,targetUrl:r.target.value})),placeholder:"http://host.docker.internal:18080/..."})),i("label",null,"事件类型",i("input",{value:F.eventTypes,onChange:(r)=>Q((Y)=>({...Y,eventTypes:r.target.value})),placeholder:"message,notice"})),i("label",null,"签名密钥",i("input",{value:F.secret,onChange:(r)=>Q((Y)=>({...Y,secret:r.target.value})),placeholder:"可选,生成 x-claudeqq-signature"})),i("button",{type:"submit",className:"primary-btn"},"创建订阅")),C.length===0?i(M$,{title:"暂无订阅",text:"可以为 main server 或其他用户服务注册 HTTP webhook"}):i("div",{className:"table-wrap","data-testid":"claudeqq-subscription-table"},i("table",null,i("thead",null,i("tr",null,i("th",null,"名称"),i("th",null,"状态"),i("th",null,"事件"),i("th",null,"回调"),i("th",null,"最近投递"),i("th",null,"操作"))),i("tbody",null,C.map((r)=>i("tr",{key:r.id},i("td",null,i("strong",null,r.name||r.id),i("code",null,r.id||"--")),i("td",null,i(q4,{status:r.enabled?"online":"warn"},r.enabled?"enabled":"disabled")),i("td",null,Array.isArray(r.eventTypes)?r.eventTypes.join(", "):"message"),i("td",null,r.targetUrl||"--"),i("td",null,r.lastDelivery?`${r.lastDelivery.ok?"OK":"FAIL"} ${D$(r.lastDelivery.at)}`:"--"),i("td",null,i("button",{type:"button",className:"ghost-btn",onClick:()=>O(r.id)},"删除"))))))),i("div",{className:"panel-actions inline-actions"},i(T$,{title:"ClaudeQQ Subscriptions",data:l.subscriptions,onOpen:u,testId:"raw-claudeqq-subscriptions"}))),i(b3,{title:"最近 QQ 事件",eyebrow:l.refreshedAt?`Updated ${LO(l.refreshedAt)}`:"Event Stream"},x.length===0?i(M$,{title:"暂无事件",text:"等待 NapCat WebSocket 上报 QQ 消息事件,或通过订阅 API 消费后续事件"}):i("div",{className:"table-wrap","data-testid":"claudeqq-event-list"},i("table",null,i("thead",null,i("tr",null,i("th",null,"时间"),i("th",null,"类型"),i("th",null,"会话"),i("th",null,"消息"),i("th",null,"ID"))),i("tbody",null,x.map((r)=>i("tr",{key:r.id},i("td",null,D$(r.receivedAt||r.timestamp)),i("td",null,i(q4,{status:r.postType||r.eventType},r.postType||r.eventType||"--")),i("td",null,yz(r)),i("td",null,_z(r)),i("td",null,i("code",null,r.messageId||r.id||"--"))))))),i("div",{className:"panel-actions inline-actions"},i(T$,{title:"ClaudeQQ Events",data:l.events,onOpen:u,testId:"raw-claudeqq-events"}))),i(b3,{title:"已发送消息",eyebrow:`${P.length} Sent`},P.length===0?i(M$,{title:"暂无发送记录",text:"发送日志来自 ClaudeQQ bot_workspace/messages/sent_messages.jsonl"}):i("div",{className:"table-wrap"},i("table",null,i("thead",null,i("tr",null,i("th",null,"时间"),i("th",null,"目标"),i("th",null,"消息"),i("th",null,"结果"))),i("tbody",null,P.map((r,Y)=>i("tr",{key:r.id||Y},i("td",null,D$(r.timestamp||r.sentAt||r.createdAt)),i("td",null,yz(r)),i("td",null,_z(r)),i("td",null,r.status||r.messageId||r.message_id||"--")))))),i("div",{className:"panel-actions inline-actions"},i(T$,{title:"ClaudeQQ Sent Messages",data:l.sent,onOpen:u,testId:"raw-claudeqq-sent"})))))}var D4=Sf(I0(),1);var uj=Sf(I0(),1),Lf=uj.default.createElement,{useEffect:rO,useRef:$z}=uj.default;function SO(f,u){return Xz(f.toTrace(u))}function zz(f){if(!f)return"--";let u=new Date(f);if(Number.isNaN(u.getTime()))return"--";return u.toLocaleString("zh-CN",{hour12:!1})}function PO(f){let u=Number(f);if(!Number.isFinite(u)||u<0)return"--";let _=Math.floor(u/1000),y=Math.floor(_/3600),l=Math.floor(_%3600/60),$=_%60;if(y>0)return`${y}h ${String(l).padStart(2,"0")}m`;if(l>0)return`${l}m ${String($).padStart(2,"0")}s`;return`${$}s`}function Dy(f){let u=Number(f);return Number.isFinite(u)&&u>=0?u:null}function Gz(f,u=180){let _=String(f||"").replace(/\s+/gu," ").trim();return _.length>u?`${_.slice(0,u-1)}…`:_}function CO(f){if(!f)return 0;return f.split(/\r?\n/u).length}function a7(f){return{ran:"Ran",explored:"Explored",edited:"Edited",toolGroup:"Tool calls",plan:"Plan",message:"Message",system:"System",error:"Error"}[f]||"Message"}function d7(f){let u=Number(f||0);return Number.isFinite(u)&&u>0?`… +${Math.floor(u)} lines`:""}function RO(f){return(Array.isArray(f)?f:[]).reduce((u,_)=>Math.max(u,Number(_?.seq??0)),0)}function jz(f){return["explored","edited","ran"].includes(String(f?.kind||""))}function Kz(f){let u={read:0,edit:0,run:0};for(let _ of f){let y=String(_?.kind||"");if(y==="explored")u.read+=1;else if(y==="edited")u.edit+=1;else if(y==="ran")u.run+=1}return u}function Zz(f){let u=Kz(f);return`${u.read} read, ${u.edit} edit, ${u.run} run`}function qz(f){return f.replace(/^['"`([{<]+/u,"").replace(/['"`)\]}>.,;:]+$/u,"").replace(/:\d+(?::\d+)?$/u,"").trim()}function Jz(f){let _=String(f||"").match(/(?:~|\.{1,2}|\/)?(?:[A-Za-z0-9_.@+-]+\/)+[A-Za-z0-9_.@+-]+|[A-Za-z0-9_.@+-]+\.(?:c|cc|cpp|h|hpp|js|jsx|ts|tsx|json|md|py|sh|toml|ya?ml|txt|log|lock)/gu)||[],y=[];for(let l of _){let $=qz(l);if($.length<2||$.includes("..."))continue;if(/^(http|https|status|method)$/iu.test($))continue;if(!y.includes($))y.push($)}return y}function n7(f,u=4){if(f.length===0)return"--";let _=f.slice(0,u).join(", ");return f.length>u?`${_} +${f.length-u}`:_}function Fz(f){let u="";for(let _ of f){if(_.length===0)continue;if(u.length>0&&!u.endsWith(` +`)&&!_.startsWith(` +`))u+=` +`;u+=_}return u}function Hz(f){let u=String(f||"").replace(/\r\n/gu,` +`).replace(/\r/gu,` +`).trimEnd();return u.length>0?u.split(` +`):[]}function e7(f){let u=String(f.status||"").trim();if(u.length>0)return u;let _=String(f.bodyPreview||"");return/^(item\/[A-Za-z]+(?:\/[A-Za-z]+)?):/u.exec(_)?.[1]||"item/fileChange"}function xO(f){let u=String(f.bodyPreview||"");return/file changes status=([A-Za-z0-9_-]+)/u.exec(u)?.[1]}function vO(f){return/^item\/(?:started|completed): file changes status=/u.test(String(f||"").trim())}function Vz(f){if(String(f.kind||"")!=="edited")return!1;let u=String(f.status||""),_=String(f.title||""),y=String(f.bodyPreview||""),l=String(f.commandPreview||"");if(_==="Edited files")return!0;if(/^item\/fileChange\//u.test(u))return!0;if((u==="item/started"||u==="item/completed")&&/file changes status=/u.test(y))return!0;if(/^Success\. Updated the following files:/mu.test(y))return!0;if(/^diff --git /mu.test(y))return!0;return l.length===0&&/^([AMDRCU?]{1,2})\s+\S+/mu.test(y)}function h3(f){return qz(String(f||"").replace(/^[ab]\//u,"").trim())}function _j(f){let u=/^([AMDRCU?]{1,2})\s+(.+)$/u.exec(f);if(!u)return null;let _=h3(u[2]||"");return _.length>0?{status:u[1]||"M",path:_}:null}function yj(f){let u=/^\*\*\*\s+(Add|Update|Delete)\s+File:\s+(.+)$/u.exec(f);if(u){let y=u[1]==="Add"?"A":u[1]==="Delete"?"D":"M",l=h3(u[2]||"");return l.length>0?{status:y,path:l}:null}let _=/^\*\*\*\s+Move to:\s+(.+)$/u.exec(f);if(_){let y=h3(_[1]||"");return y.length>0?{status:"R",path:y}:null}return null}function bO(f){let u=[],_=(l,$)=>{let j=h3($);if(j.length===0||j==="/dev/null")return;let J=u.find((F)=>F.path===j);if(J){if(J.status==="M"&&l!=="M")J.status=l;return}u.push({status:l,path:j})},y="";for(let l of Hz(f)){let $=_j(l)||yj(l);if($!==null){_($.status,$.path),y=$.path;continue}let j=/^diff --git a\/(.+?) b\/(.+)$/u.exec(l);if(j){let z=j[2]||j[1]||"";_("M",z),y=h3(z);continue}let J=/^\+\+\+ b\/(.+)$/u.exec(l);if(J&&J[1]!=="/dev/null"){_("M",J[1]||""),y=h3(J[1]||"");continue}if(/^new file mode /u.exec(l)&&y)_("A",y);if(/^deleted file mode /u.exec(l)&&y)_("D",y);let U=/^rename to (.+)$/u.exec(l);if(U)_("R",U[1]||"")}return u}function hO(f){if(_j(f)!==null||yj(f)!==null)return"file";if(/^(diff --git |index |--- |\+\+\+ |\*\*\* Begin Patch|\*\*\* End Patch)/u.test(f))return"meta";if(/^@@ /u.test(f))return"hunk";if(/^\+/u.test(f))return"add";if(/^-/u.test(f))return"del";if(/^(Success\.|No changes|Updated\b|Created\b|Deleted\b|Added\s+\d+\s+lines?|Wrote\s+\d+\s+lines?|Read\s+\d+\s+files?|\.\.\.\[patch content truncated)/iu.test(f))return"note";return"context"}function IO(f){return Hz(f).map((u)=>{let _=_j(u)||yj(u);if(_!==null)return{text:u,kind:"file",path:_.path,status:_.status};return{text:u,kind:hO(u)}})}function cO(f){return f.reduce((u,_)=>{if(_.kind==="add")u.added+=1;else if(_.kind==="del")u.removed+=1;return u},{added:0,removed:0})}function Az(f,u){return`${u} ${f} line${f===1?"":"s"}`}function pO(f,u){let _=[];if(f>0)_.push(Az(f,"Added"));if(u>0)_.push(Az(u,"removed"));return _.join(", ")}function mO(f){for(let _=f.length-1;_>=0;_-=1){let y=String(f[_]?.status||"").trim();if(y.length>0)return y}let u=String(f[f.length-1]?.method||"").trim();if(u==="item/fileChange/outputDelta")return"updated";if(u==="item/started")return"started";if(u==="item/completed")return"completed";return u.replace(/^item\//u,"")||"changed"}function kO(f){return`${f} file${f===1?"":"s"}`}function Ez(f){let u=f.length>0?f:[],_=Fz(u.map((W)=>String(W.bodyPreview||""))),l=Fz(u.map((W)=>String(W.bodyPreview||"")).filter((W)=>W.trim().length>0&&!vO(W)))||_,$=bO(l||_),j=u.map((W)=>({method:e7(W),status:xO(W),at:W.at})),J=IO(l||_),F=cO(J),Q=pO(F.added,F.removed),U=$.length>0?kO($.length):"",z=Q.length>0?`${Q}${U?` in ${U}`:""}`:$.length>0?U:Gz(l||_||"File changes",72);return{status:mO(j),summary:z,files:$,stages:j,lines:J,addedLines:F.added,removedLines:F.removed,rawText:_}}function iO(f){let u=f[0],_=f[f.length-1]||u,y=Ez(f);return{...u,seq:Number.isFinite(Number(_?.seq))?Number(_?.seq):Number(u?.seq??0),at:_?.at||u?.at,title:y.files.length>0?`Edited ${y.summary}`:"Edited files",status:y.status,commandPreview:"",commandOmittedLines:void 0,bodyPreview:y.rawText,bodyOmittedLines:f.reduce((l,$)=>l+Number($.bodyOmittedLines||0),0)||void 0,rawSeqs:f.flatMap((l)=>Array.isArray(l?.rawSeqs)?l.rawSeqs:[l?.seq]).filter((l)=>l!==void 0),editObservation:y}}function gO(f){let u=Array.isArray(f)?f:[],_=[],y=[],l=()=>{if(y.length===0)return;_.push(iO(y)),y=[]};for(let $ of u){if(Vz($)){if(e7($)==="item/started"&&y.length>0)l();if(y.push($),e7($)==="item/completed")l();continue}l(),_.push($)}return l(),_}function Oz(f){let u=[],_=[],y=[],l=(Q,U)=>{for(let z of U)if(!Q.includes(z))Q.push(z)};for(let Q of f){let U=String(Q?.kind||""),z=[Q?.commandPreview,Q?.bodyPreview,Q?.title].map((W)=>String(W||"")).join(` +`);if(U==="explored")l(u,Jz(z));else if(U==="edited")l(_,Jz(z));else if(U==="ran"){let W=String(Q?.commandPreview||Q?.title||"").trim();if(W.length>0&&!y.includes(W))y.push(Gz(W,90))}}let $=f.map((Q)=>Date.parse(String(Q?.at||""))).filter((Q)=>Number.isFinite(Q)),j=$.length>=2?Math.max(0,Math.max(...$)-Math.min(...$)):0,J=f.reduce((Q,U)=>Q+(Dy(U?.durationMs)??Dy(U?.elapsedMs)??0),0),F=j>0?j:J;return{readFiles:u,editedFiles:_,runCommands:y,durationLabel:PO(F)}}function nO(f,u=3){let _=Array.isArray(f)?f:[],y=[],l=[],$=Math.max(0,u),j=new Set;for(let F=_.length-1;F>=0&&$>0;F-=1){let Q=_[F];if(!jz(Q))continue;j.add(Q),$-=1}let J=()=>{if(l.length>=2){let F=Kz(l);y.push({seq:Number(l[0]?.seq??0),at:l[0]?.at||l.at(-1)?.at,kind:"toolGroup",title:Zz(l),status:`${l.length} calls`,items:l,counts:F,digest:Oz(l),rawSeqs:l.flatMap((Q)=>Array.isArray(Q?.rawSeqs)?Q.rawSeqs:[Q?.seq]).filter((Q)=>Q!==void 0)})}else y.push(...l);l=[]};for(let F of _){if(jz(F)&&!j.has(F)){l.push(F);continue}J(),y.push(F)}return J(),y}function Xz(f){return(Array.isArray(f)?f:[]).map((u,_)=>({...u,seq:Number.isFinite(Number(u?.seq))?Number(u.seq):_+1,kind:String(u?.kind||"message"),at:u?.at===void 0?void 0:String(u.at),durationMs:Dy(u?.durationMs)??void 0,title:u?.title===void 0?void 0:String(u.title),status:u?.status===void 0?void 0:String(u.status)}))}function t7(f){return Dy(f?.durationMs)??Dy(f?.elapsedMs)??Dy(f?.timing?.durationMs)??Dy(f?.metadata?.durationMs)??void 0}function s7(f,u){return f?.createdAt||f?.updatedAt||f?.completedAt||u||void 0}function o7(f,u){return f?.id||f?.messageId||u}function fj(f,u){let _=new Set(u.map((y)=>y.toLowerCase()));for(let y of Array.isArray(f?.inputFields)?f.inputFields:[]){let l=String(y?.key||"").toLowerCase();if(_.has(l))return String(y?.value||"")}return""}function tO(f){let u=String(f?.tool||f?.title||"").toLowerCase();if(/read|grep|glob|list|ls|find|search|view|cat|sed|rg/u.test(u))return"explored";if(/edit|write|patch|apply|update|create|delete/u.test(u))return"edited";let _=fj(f,["command","cmd"]);if(/\b(rg|grep|find|ls|cat|sed|tail|head|git status|git diff|ps)\b/u.test(_))return"explored";if(/\b(apply_patch|git apply|cat >|tee .*<<|sed -i|python3? .*write_text)\b/u.test(_))return"edited";return"ran"}function sO(f){let u=[],_=1;for(let y of Array.isArray(f)?f:[]){let l=y?.createdAt||y?.updatedAt||y?.completedAt,$=String(y?.role||"assistant").toLowerCase(),j=Array.isArray(y?.parts)?y.parts:[];for(let J of j){let F=String(J?.type||"").toLowerCase();if(F==="step-start"||F==="step-finish")continue;if(F==="text"||F==="reasoning"){let U=String(J?.textPreview||y?.textPreview||"").trim();if(U.length===0)continue;u.push({seq:_++,at:s7(J,l),kind:"message",title:F==="reasoning"?"Reasoning":$==="user"?"User message":$==="system"?"System message":"Assistant message",status:F==="reasoning"?"reasoning":$,bodyPreview:U,durationMs:t7(J),rawSeqs:[o7(J,_)]});continue}if(F==="tool"){let U=fj(J,["command","cmd"])||fj(J,["filePath","filepath","path"])||String(J?.title||J?.tool||"tool"),z=String(J?.outputPreview&&J.outputPreview!=="--"?J.outputPreview:J?.textPreview||"");u.push({seq:_++,at:s7(J,l),kind:tO(J),title:String(J?.title||J?.tool||"tool"),status:String(J?.status||""),commandPreview:U,bodyPreview:z,durationMs:t7(J),rawSeqs:[o7(J,_)]});continue}let Q=String(J?.textPreview||J?.title||F||"").trim();if(Q)u.push({seq:_++,at:s7(J,l),kind:"system",title:F||"part",bodyPreview:Q,status:String(J?.status||""),durationMs:t7(J),rawSeqs:[o7(J,_)]})}if(j.length===0&&y?.textPreview)u.push({seq:_++,at:l,kind:"message",title:`${$||"assistant"} message`,status:$,bodyPreview:String(y.textPreview),rawSeqs:[y?.messageId||_]})}return u}var Nz={source:"opencode",toTrace:sO};function oO(f){return String(f||"unknown").toLowerCase().replace(/[^a-z0-9_-]+/gu,"-")||"unknown"}function Qz(f){let u=String(f||"M").toUpperCase();if(u.startsWith("A")||u==="??")return"added";if(u.startsWith("D"))return"deleted";if(u.startsWith("R"))return"renamed";return"modified"}function aO(f){if(f==="item/fileChange/outputDelta")return"delta";return f.replace(/^item\//u,"")}function dO(f,u){if(f.kind==="file"){let l=String(f.status||"M");return Lf("div",{key:`${u}-${f.text}`,className:`codex-edit-diff-line file ${Qz(l)}`},Lf("span",{className:`codex-edit-file-status ${Qz(l)}`},l),Lf("code",null,f.path||f.text.replace(/^([AMDRCU?]{1,2})\s+/u,"")))}let _=f.kind==="add"||f.kind==="del"?f.text.slice(0,1):f.kind==="hunk"?"@@":f.kind==="note"?"ok":"",y=f.kind==="add"||f.kind==="del"?f.text.slice(1):f.text;return Lf("div",{key:`${u}-${f.text}`,className:`codex-edit-diff-line ${f.kind}`},Lf("span",{className:"codex-edit-diff-sign"},_),Lf("code",null,y||" "))}function eO(f,u){let _=f.lines.length>0?f.lines:f.files.map((l)=>({text:`${l.status} ${l.path}`,kind:"file",path:l.path,status:l.status})),y=Number(f.addedLines||0)+Number(f.removedLines||0)>0;return Lf("div",{className:"codex-edit-observation","data-testid":"codex-edit-observation"},Lf("div",{className:"codex-edit-observation-head"},Lf("span",{className:"codex-edit-window-controls","aria-hidden":"true"},Lf("i",null),Lf("i",null),Lf("i",null)),Lf("strong",null,y?"git diff":"git diff --stat"),Lf("code",null,f.summary||"File changes")),f.stages.length>0?Lf("div",{className:"codex-edit-stage-strip"},f.stages.map((l,$)=>Lf("span",{key:`${l.method}-${$}`,className:`codex-edit-stage ${oO(l.status||l.method)}`},Lf("b",null,aO(l.method)),l.status?Lf("em",null,l.status):null))):null,_.length>0?Lf("div",{className:"codex-edit-diff",role:"list"},_.map(dO)):null,u?Lf("div",{className:"codex-edit-omitted"},`${u} (查看原始JSON获取完整记录)`):null)}function Uz(f,u,_){let y=d7(_);return Lf("div",{className:`codex-transcript-stream ${f}`,"data-testid":`codex-trace-${f}`},Lf("span",{className:"codex-transcript-stream-label"},f),Lf("pre",{className:"codex-transcript-body"},u,y?` +${y} (查看原始JSON获取完整记录)`:""))}function Lz(f,u=!1){let _=String(f.kind||"message"),y=["ran","explored","edited"].includes(_),l=d7(f.commandOmittedLines),$=d7(f.bodyOmittedLines),j=String(f.commandPreview||(y?f.title||"":"")),J=String(f.stdoutPreview||""),F=String(f.stderrPreview||""),Q=J.length>0||F.length>0,U=Boolean(f.foldedReferencePrompt)&&String(f.fullPrompt||"").length>0,z=_==="edited"&&(f.editObservation!==void 0||Vz(f))?f.editObservation||Ez([f]):null;return Lf("article",{key:`${f.seq}-${_}`,className:`codex-transcript-item ${_} ${u?"nested":""}`},Lf("div",{className:"codex-transcript-main"},Lf("div",{className:"codex-transcript-title"},Lf("span",{className:"codex-output-channel"},a7(_)),y&&z===null?null:Lf("strong",null,z!==null?"File changes":String(f.title||a7(_))),f.status?Lf("code",null,String(z?.status||f.status)):null,Lf("time",null,zz(f.at))),j&&z===null?Lf("pre",{className:"codex-transcript-command"},j,l?` +${l}`:""):null,z!==null?eO(z,$):Q?Lf("div",{className:"codex-transcript-streams"},J.length>0?Uz("stdout",J,f.stdoutOmittedLines):null,F.length>0?Uz("stderr",F,f.stderrOmittedLines):null):f.bodyPreview?Lf("pre",{className:"codex-transcript-body"},String(f.bodyPreview),$?` +${$} (查看原始JSON获取完整记录)`:""):null,U?Lf("details",{className:"codex-initial-prompt-full","data-testid":"codex-initial-prompt-full"},Lf("summary",null,Lf("span",null,"引用注入已折叠,点击查看最终传入 Codex 的完整 prompt"),Lf("code",null,`${f.fullPromptLines||CO(String(f.fullPrompt||""))} lines / ${f.fullPromptChars||String(f.fullPrompt||"").length} chars`)),Lf("pre",{className:"codex-transcript-body codex-transcript-full-prompt","data-testid":"codex-initial-prompt-full-text"},String(f.fullPrompt||""))):null))}function fX(f){let u=Array.isArray(f.items)?f.items:[],_=f.digest&&typeof f.digest==="object"?f.digest:Oz(u);return Lf("article",{key:`${f.seq}-toolGroup`,className:"codex-transcript-item toolGroup"},Lf("div",{className:"codex-transcript-main"},Lf("details",{className:"codex-tool-group","data-testid":"codex-tool-group"},Lf("summary",null,Lf("div",{className:"codex-tool-group-head"},Lf("span",{className:"codex-output-channel"},a7("toolGroup")),Lf("strong",null,String(f.title||Zz(u))),Lf("code",null,String(f.status||`${u.length} calls`)),Lf("time",null,zz(f.at)))),Lf("div",{className:"codex-tool-group-digest"},Lf("span",null,`read: ${n7(Array.isArray(_.readFiles)?_.readFiles:[])}`),Lf("span",null,`edit: ${n7(Array.isArray(_.editedFiles)?_.editedFiles:[])}`),Lf("span",null,`run: ${n7(Array.isArray(_.runCommands)?_.runCommands:[],2)}`),Lf("span",null,`duration: ${_.durationLabel||"--"}`)),Lf("div",{className:"codex-tool-group-items"},u.map((y)=>Lz(y,!0))))))}var uX=16;function Wz(f){return f.scrollHeight-f.scrollTop-f.clientHeight<=uX}function V4({items:f,input:u,port:_,autoScroll:y=!1,loading:l=!1,hasDetail:$=!0,emptyText:j="等待 Trace 输出...",loadingText:J="正在加载完整 Trace...",testId:F="trace-output",className:Q="codex-transcript",keepRecentToolCalls:U=3,collapseTools:z=!0}){let W=$z(null),K=$z(!0),q=gO(_?SO(_,u):Xz(f)),V=z?nO(q,U):q,O=RO(q);rO(()=>{let Z=W.current;if(!y||!Z)return;if(!K.current&&!Wz(Z))return;Z.scrollTop=Z.scrollHeight,K.current=!0},[y,q.length,O]);let H={className:Q,ref:W,onScroll:(Z)=>{let E=Z.currentTarget;K.current=Wz(E)},"data-testid":F};if(l&&!$)return Lf("div",H,Lf("div",{className:"codex-output-empty"},J));return Lf("div",H,V.length===0?Lf("div",{className:"codex-output-empty"},j):V.map((Z)=>String(Z.kind||"")==="toolGroup"?fX(Z):Lz(Z)))}var B=D4.default.createElement,{useEffect:P$,useMemo:Yz,useRef:Zu}=D4.default,df=D4.default.useState,_X=120,Qj=24,yX=48;function Sy(f){if(!f)return"--";let u=new Date(f);if(Number.isNaN(u.getTime()))return"--";return u.toLocaleString("zh-CN",{hour12:!1})}function lX(f){return f.toLocaleTimeString("zh-CN",{hour12:!1})}function N4(f){let u=Number(f);if(!Number.isFinite(u)||u<0)return"--";let _=Math.floor(u/1000),y=Math.floor(_/3600),l=Math.floor(_%3600/60),$=_%60;if(y>0)return`${y}h ${String(l).padStart(2,"0")}m`;if(l>0)return`${l}m ${String($).padStart(2,"0")}s`;return`${$}s`}function lj(f){let u=Number(f);if(!Number.isFinite(u)||u<0)return"--";if(u<1000)return`${Math.round(u)}ms`;return`${(u/1000).toFixed(u<1e4?2:1)}s`}function Rz(f,u=180){let _=String(f||"").replace(/\s+/gu," ").trim();return _.length>u?`${_.slice(0,u-1)}…`:_}async function M0(f,u={}){return Df(f,{strictJson:!0,retryInvalidJson:1,invalidJsonPrefix:"Codex Queue 返回了无效 JSON",invalidJsonPreview:!0,...u})}function y_({status:f,children:u}){let _=String(f||"unknown").toLowerCase();return B("span",{className:`status-badge ${_}`},u||f||"unknown")}function Ty({label:f,value:u,hint:_,tone:y}){return B("article",{className:`metric-card ${y||""}`},B("div",{className:"metric-label"},f),B("div",{className:"metric-value"},u),B("div",{className:"metric-hint"},_))}function My({title:f,eyebrow:u,summary:_,actions:y,children:l,className:$}){return B("section",{className:`panel ${$||""}`},B("div",{className:"panel-head"},B("div",null,u?B("p",{className:"panel-eyebrow"},u):null,B("h2",null,f),_?B("div",{className:"panel-summary"},_):null),y?B("div",{className:"panel-actions"},y):null),B("div",{className:"panel-body"},l))}function Bz({title:f,data:u,onOpen:_,testId:y}){return B("button",{type:"button",className:"ghost-btn","data-testid":y,onClick:()=>_(f,u)},"查看原始JSON")}function ry({title:f,text:u}){return B("div",{className:"empty-state"},B("strong",null,f),B("span",null,u))}function $X(f){return f?.runtime&&typeof f.runtime==="object"&&!Array.isArray(f.runtime)?f.runtime:{}}function jX(f){return f?.backend&&typeof f.backend==="object"&&!Array.isArray(f.backend)?f.backend:{}}function JX(f){return f?.repository&&typeof f.repository==="object"&&!Array.isArray(f.repository)?f.repository:{}}function r0(f,u){return`${f}/codex-queue-direct${u}`}function Z1(f){return Array.isArray(f?.tasks)?f.tasks:[]}function r$(f){return f?.pagination&&typeof f.pagination==="object"&&!Array.isArray(f.pagination)?f.pagination:{}}function wz(f){let u=Date.parse(String(f?.updatedAt||f?.createdAt||""));return Number.isFinite(u)?u:0}function xz(f,u=""){let _=new Map;for(let y of f)for(let l of y){let $=String(l?.id||"");if($.length>0&&!_.has($))_.set($,l)}return Array.from(_.values()).sort((y,l)=>{let $=Sz(y)-Sz(l);if($!==0)return $;let j=String(y?.id||"")===u?0:1,J=String(l?.id||"")===u?0:1;if(j!==J)return j-J;return wz(l)-wz(y)})}function E4(f,u=""){let _=new Map;for(let y of f)for(let l of y){let $=String(l?.id||"");if($.length===0)continue;_.set($,{..._.get($)||{},...l})}return xz([Array.from(_.values())],u)}function L4(f){return Array.isArray(f?.activeTaskIds)?f.activeTaskIds.map((u)=>String(u||"")).filter(Boolean):[String(f?.activeTaskId||"")].filter(Boolean)}var v_="__all__",FX="(max-width: 760px)",AX="(min-width: 761px)",vz="unidesk:codex-queue:read-at:v1";function vu(f){return!f||f===v_}function QX(){return typeof window<"u"&&window.matchMedia(FX).matches}function qj(f){return vu(f)?"":`&queueId=${encodeURIComponent(f)}`}function O4(f,u){return Number(f?.counts?.[u]||0)}function Dz(f,u=""){let _=new Map;for(let l of Array.isArray(f?.queues)?f.queues:[]){let $=String(l?.id||"").trim();if($.length>0)_.set($,l)}for(let l of[String(f?.defaultQueueId||"default"),u].map(($)=>$.trim()).filter(Boolean))if(!_.has(l))_.set(l,{id:l,total:0,counts:{},activeTaskId:null,runnableTaskId:null,processing:!1});return Array.from(_.values()).sort((l,$)=>{let j=String(l?.id||"")===String(f?.defaultQueueId||"default")?0:1,J=String($?.id||"")===String(f?.defaultQueueId||"default")?0:1;if(j!==J)return j-J;return String(l?.id||"").localeCompare(String($?.id||""))})}function Uj(f){let u=String(f?.id||"default"),_=O4(f,"running")+O4(f,"judging"),y=O4(f,"queued")+O4(f,"retry_wait"),l=Number(f?.total||0),$=[`${u}`,`${l} tasks`];if(_>0)$.push(`${_} running`);if(y>0)$.push(`${y} queued`);return $.join(" · ")}function B4(f,u){if(vu(u))return null;return f.find((_)=>String(_?.id||"")===u)||null}function Tz(f,u,_,y){if(vu(_)){let $=L4(f);return String(f?.activeTaskId||$[0]||y.find((j)=>rz(j))?.id||"")}let l=B4(u,_);return String(l?.activeTaskId||y.find(($)=>rz($))?.id||"")}function UX(f,u,_){if(!vu(u)){let y=B4(f,u);return String(y?.runnableTaskId||_.find((l)=>String(l?.status||"")==="queued"||String(l?.status||"")==="retry_wait")?.id||"")}return String(_.find((y)=>String(y?.status||"")==="queued"||String(y?.status||"")==="retry_wait")?.id||"")}async function WX(f,u,_=v_){let y=qj(_);try{return await M0(r0(f,`/api/tasks?limit=${Qj}&lite=1&devReady=0${y}`))}catch{let $=await Promise.all(["running","judging","retry_wait","queued"].map(async(Q)=>{try{return await M0(r0(f,`/api/tasks?status=${encodeURIComponent(Q)}&limit=80&lite=1&devReady=0${y}`))}catch{return null}})),j=await M0(r0(f,`/api/tasks?limit=${Qj}&lite=1&devReady=0${y}`)).catch(()=>null),J=$.find((Q)=>Q?.queue)?.queue||j?.queue||u?.queue||u?.body?.queue||{},F=xz([...$.map((Q)=>Z1(Q)),Z1(j)],String(J?.activeTaskId||""));if(F.length>0)return{ok:!0,queue:J,tasks:F};return M0(r0(f,`/api/tasks?limit=5&lite=1&devReady=0${y}`))}}async function zX(f,u,_=0,y=v_){return M0(r0(f,`/api/tasks/overview?limit=${Qj}&transcriptLimit=3&compact=1&afterSeq=${encodeURIComponent(String(Math.max(0,_)))}&preferId=${encodeURIComponent(u)}${qj(y)}`))}async function GX(f,u,_,y=yX){return M0(r0(f,`/api/tasks?limit=${encodeURIComponent(String(y))}&lite=1&devReady=0&includeActive=0&beforeId=${encodeURIComponent(_)}${qj(u)}`))}async function KX(f,u){return M0(r0(f,`/api/tasks/${encodeURIComponent(u)}/trace-summary`))}async function ZX(f,u,_,y=null){let l=y===null||y===void 0||String(y).length===0?"":`&attempt=${encodeURIComponent(String(y))}`;return M0(r0(f,`/api/tasks/${encodeURIComponent(u)}/prompt?part=${encodeURIComponent(_)}${l}`))}async function qX(f,u,_=0,y=500,l=null){let $=l===null||l===void 0||String(l).length===0?"":`&attempt=${encodeURIComponent(String(l))}`;return M0(r0(f,`/api/tasks/${encodeURIComponent(u)}/trace-steps?afterSeq=${encodeURIComponent(String(_))}&limit=${encodeURIComponent(String(y))}${$}`))}async function HX(f,u,_){return M0(r0(f,`/api/tasks/${encodeURIComponent(u)}/trace-step?seq=${encodeURIComponent(String(_))}`))}async function VX(f,u){return M0(r0(f,`/api/tasks/${encodeURIComponent(u)}/read`),{method:"POST",body:{}})}async function EX(f){return M0(r0(f,"/api/tasks/read-all"),{method:"POST",body:{}})}function OX(f){return Array.isArray(f?.output)?f.output:[]}function bz(f){return Array.isArray(f?.attempts)?f.attempts:[]}function $j(f){return f?.counts&&typeof f.counts==="object"&&!Array.isArray(f.counts)?f.counts:{}}function XX(f){return f.split(/^\s*---+\s*$/gmu).map((u)=>u.trim()).filter(Boolean)}function Mz(f){let u=Number(f);return Number.isFinite(u)?Math.max(1,Math.min(50,Math.floor(u))):1}function Y4(f){let u=[];for(let _ of f.split(/[\s,,;;]+/u)){let y=_.trim();if(/^codex_\d+_[A-Za-z0-9_-]+$/u.test(y)&&!u.includes(y))u.push(y)}return u}function NX(f,u){let _=Y4(u);if(_.length===0)return f;return[`引用 Codex Queue 任务 ${_.join(" ")}。后端会在入队时只注入这些任务的 initial prompt 和 final response 全文;中间执行过程不注入,如需补充核查可运行:${_.map((y)=>`bun scripts/cli.ts codex task ${y}`).join(";")}`,"","本次任务:",f].join(` +`)}function LX(f){let y=f.trimStart();if(!y.startsWith("# Codex Queue 已解析引用上下文"))return{hasInjection:!1,reference:"",userPrompt:f};let l=f.length-y.length,$=f.lastIndexOf(` +# 本次任务 +`);if($0?f.split(/\r\n|\r|\n/u).length:0}function hz(f){let u=String(f?.displayPrompt||"");if(u.length>0)return u;let _=String(f?.prompt||"");return YX(LX(_).userPrompt)}function Py(f){return f?._traceSummary&&typeof f._traceSummary==="object"&&!Array.isArray(f._traceSummary)?f._traceSummary:null}function C$(f){return f?._promptDetails&&typeof f._promptDetails==="object"&&!Array.isArray(f._promptDetails)?f._promptDetails:{}}function T4(f){let u=Py(f)?.prompt;return u&&typeof u==="object"&&!Array.isArray(u)?u:{}}function Wj(f){let u=Py(f)?.execution;return u&&typeof u==="object"&&!Array.isArray(u)?u:{}}function Iz(f){let u=T4(f),_=String(u.basePrompt||"");return _.length>0?_:hz(f)}function zj(f){let u=Py(f);return String(u?.finalResponse||f?.finalResponse||"").trimEnd()}function Gj(f){let _=Py(f)?.lastJudge||f?.lastJudge;return _&&typeof _==="object"&&!Array.isArray(_)?_:null}function b_(f){return f&&typeof f==="object"&&!Array.isArray(f)?f:null}function BX(f){let u=Py(f)?.attempts;if(Array.isArray(u)&&u.length>0)return u;let _=bz(f);if(_.length>0)return _.map((j,J)=>({...j,index:Number(j?.index||J+1),execution:J===_.length-1?Wj(f):b_(j?.execution)||{},finalResponse:String(j?.finalResponse||j?.finalResponsePreview||(J===_.length-1?zj(f):"")),judge:b_(j?.judge)||(J===_.length-1?Gj(f):null)}));let y=Wj(f),l=zj(f),$=Gj(f);if(Object.keys(y).length===0&&l.length===0&&$===null)return[];return[{index:Number(f?.currentAttempt||1),mode:f?.currentMode||"initial",startedAt:f?.startedAt,finishedAt:f?.finishedAt,terminalStatus:f?.status,execution:y,finalResponse:l,finalResponseChars:l.length,judge:$}]}function wX(f,u){return b_(u?.execution)||Wj(f)}function DX(f,u){let _=String(u?.finalResponse||u?.finalResponsePreview||"");if(Object.prototype.hasOwnProperty.call(u||{},"finalResponse")||Object.prototype.hasOwnProperty.call(u||{},"finalResponsePreview"))return _.trimEnd();return _.length>0?_.trimEnd():zj(f)}function cz(f,u){if(Object.prototype.hasOwnProperty.call(u||{},"judge"))return b_(u?.judge);return Gj(f)}function pz(f){return`feedback:${String(f||"latest")}`}function TX(f,u,_){let y=String(u?.feedbackPrompt||"").trimEnd(),l=String(u?.feedbackPromptPreview||y||"").trimEnd(),$=Number(u?.feedbackPromptChars||y.length||l.length||0),j=Number(u?.feedbackPromptLines||q1(y||l));if(y.length>0||l.length>0||$>0)return{text:y,preview:l,chars:$,lines:j,source:u?.feedbackPromptSource||"judge-feedback",forAttempt:u?.feedbackPromptForAttempt||Number(_||0)+1,truncated:Boolean(u?.feedbackPromptTruncated)};let J=cz(f,u),F=String(J?.continuePrompt||"").trimEnd();if(J?.decision==="retry"&&F.length>0)return{text:"",preview:F,chars:F.length,lines:q1(F),source:"judge-continue-prompt",forAttempt:Number(_||0)+1,truncated:!1};return null}function mz(f){let u=T4(f);return Boolean(u.hasReferenceInjection||Number(u.referencePromptChars||0)>0||f?.referenceInjection||f?.referenceInjectionSummary)}function MX(f,u=null){if(u!==null&&u!==void 0){let y=(b_(f?._traceStepsByAttempt)||{})[String(u)];return Array.isArray(y)?y:[]}return Array.isArray(f?._traceSteps)?f._traceSteps:[]}function kz(f,u=null){if(u!==null&&u!==void 0){let _=b_(f?._traceStepsLoadedByAttempt)||{};return Boolean(_[String(u)])}return Boolean(f?._traceStepsLoaded)}function Kj(f){return f?._traceStepDetails&&typeof f._traceStepDetails==="object"&&!Array.isArray(f._traceStepDetails)?f._traceStepDetails:{}}function rX(f){let u=f?.timing&&typeof f.timing==="object"?f.timing:{},_=String(f?.status||"");if(["queued"].includes(_))return`等待 ${N4(u.queueWaitMs??u.totalElapsedMs)}`;if(["running","judging","retry_wait"].includes(_))return`耗时 ${N4(u.durationMs??u.totalElapsedMs)}`;return`耗时 ${N4(u.durationMs??u.totalElapsedMs)}`}function w4(f){return String(f?.queueId||"default")}function SX(f){return{system:"SYS",user:"YOU",assistant:"GPT",reasoning:"THINK",command:"CMD",diff:"DIFF",tool:"TOOL",error:"ERR"}[f]||f.toUpperCase()}function rz(f){return["running","judging","retry_wait"].includes(String(f?.status||""))}function P1(f){return["succeeded","failed","canceled"].includes(String(f?.status||""))}function I3(f){if(!P1(f))return!1;if(f?.terminalUnread===!0)return!0;if(f?.terminalUnread===!1)return!1;return!f?.readAt}function PX(){if(typeof window>"u")return{};try{let f=JSON.parse(window.localStorage.getItem(vz)||"{}");return f&&typeof f==="object"&&!Array.isArray(f)?f:{}}catch{return{}}}function CX(f){if(typeof window>"u")return;try{window.localStorage.setItem(vz,JSON.stringify(f))}catch{}}function jj(f,u){let _=String(f?.id||""),y=String(u?.[_]||"");if(!P1(f)||y.length===0)return f;return{...f,readAt:f?.readAt||y,terminalUnread:!1}}function R$(f){let u=Number(f||0);return Number.isFinite(u)?u:0}function RX(f){return R$(f.queued)+R$(f.retry_wait)}function xX(f){return R$(f.running)+R$(f.judging)}function Sz(f){if(I3(f))return 0;return{running:1,judging:2,retry_wait:3,queued:4,succeeded:8,failed:8,canceled:8}[String(f?.status||"")]??9}function S$(f){if(!f)return!1;if(f?._traceSummaryLoaded===!0)return!1;return f?.summaryOnly===!0||f?._metaLoaded!==!0}function vX(f){return Boolean(f?._metaLoaded)||f?.summaryOnly===!1}function bX(f,u,_){let y=String(f?.[_]||""),l=String(u?.[_]||"");return y.length>l.length?y:l}function Zj(f,u,_){let y=Array.isArray(f?.[_])?f[_]:[],l=Array.isArray(u?.[_])?u[_]:[];if(l.length===0&&y.length>0)return y;return y.length>l.length?y:l}function hX(f,u){let _=u?.summaryOnly===!0&&vX(f),y={...f,...u};if(!_)return y;for(let l of["prompt","basePrompt","displayPrompt","finalResponse"])y[l]=bX(f,u,l);for(let l of["promptHistory","attempts","output","events"])y[l]=Zj(f,u,l);if(f?.referenceInjection?.items&&!u?.referenceInjection?.items)y.referenceInjection=f.referenceInjection;if(f?.referenceInjectionSummary&&!u?.referenceInjectionSummary)y.referenceInjectionSummary=f.referenceInjectionSummary;y.summaryOnly=f?.summaryOnly===!1?!1:u.summaryOnly,y._metaLoaded=f?._metaLoaded,y._detailLoaded=f?._detailLoaded,y._transcriptComplete=f?._transcriptComplete,y._transcriptPreview=Object.prototype.hasOwnProperty.call(u,"_transcriptPreview")?u._transcriptPreview:f?._transcriptPreview;for(let l of["_traceSummary","_traceSummaryLoaded","_traceSteps","_traceStepsLoaded","_traceStepsByAttempt","_traceStepsLoadedByAttempt","_traceStepDetails","_promptDetails"])if(!Object.prototype.hasOwnProperty.call(u,l)&&Object.prototype.hasOwnProperty.call(f||{},l))y[l]=f[l];return y}function IX(f){let u=f?.selected,_=u?.task&&typeof u.task==="object"?u.task:null;if(_!==null){let l=Boolean(u?.preview);return{..._,transcript:Array.isArray(u?.transcript)?u.transcript:[],_detailLoaded:Array.isArray(u?.transcript)&&u.transcript.length>0,_transcriptComplete:Boolean(!l&&!u?.hasMore&&P1(_)),_transcriptPreview:l,_summaryLoaded:!0}}let y=Z1(f)[0];return y?{...y,_summaryLoaded:!0}:null}function Jj(f,u){let _=new Map;for(let y of[...Array.isArray(f)?f:[],...Array.isArray(u)?u:[]]){let l=`${Number(y?.seq??0)}:${String(y?.kind||"message")}`,$=_.get(l);if(!$){_.set(l,y);continue}let j={...$,...y};for(let[J,F]of[["bodyPreview","bodyOmittedLines"],["commandPreview","commandOmittedLines"]]){let Q=String($?.[J]||""),U=String(y?.[J]||"");if(Q.length>U.length)j[J]=$[J],j[F]=$[F]}_.set(l,j)}return Array.from(_.values()).sort((y,l)=>Number(y?.seq??0)-Number(l?.seq??0))}function X4(f){return(Array.isArray(f)?f:[]).reduce((u,_)=>Math.max(u,Number(_?.seq??0)),0)}function Pz(f,u=8){let _=Array.from(new Set((Array.isArray(f)?f:[]).map((l)=>Number(l?.seq??0)).filter((l)=>Number.isFinite(l)&&l>0))).sort((l,$)=>l-$);if(_.length===0)return 0;let y=_[Math.max(0,_.length-u)]??0;return Math.max(0,y-0.001)}function Fj(f,u){let _=Number(f[u]??0);return Number.isFinite(_)?String(_):"0"}function cX(f,u){let _=Array.isArray(f?.codexModels)?f.codexModels:[],y=["gpt-5.5","gpt-5.4-mini","gpt-5.4"];return Array.from(new Set([..._,...y,u].map((l)=>String(l||"").trim()).filter(Boolean)))}function pX({task:f,selected:u,onSelect:_,onCopy:y,onReference:l,onMarkRead:$,copied:j,markingRead:J}){let F=f?.lastJudge||{},Q=String(f?.id||""),U=I3(f);return B("article",{role:"button",tabIndex:0,className:`codex-task-card ${u?"selected":""} ${U?"unread-terminal":""}`,onClick:_,onKeyDown:(z)=>{if(z.key==="Enter"||z.key===" ")z.preventDefault(),_()},"data-unread-terminal":U?"true":"false","data-testid":`codex-task-${f?.id||"unknown"}`},U?B("span",{className:"codex-unread-badge",title:"待读","aria-label":"待读","data-testid":`codex-unread-task-${Q||"unknown"}`}):null,B("div",{className:"codex-task-card-head"},B("div",{className:"codex-task-status-line"},B(y_,{status:f?.status},f?.status||"unknown")),B("span",{className:"mono-text"},`${f?.currentAttempt||0}/${f?.maxAttempts||0}`)),B("div",{className:"codex-task-id-row"},B("code",{title:Q},Q||"unknown"),B("div",{className:"codex-task-id-actions"},B("button",{type:"button",className:"codex-copy-id-btn",onClick:(z)=>{z.stopPropagation(),l(Q)},"data-testid":`codex-reference-task-${Q||"unknown"}`},"引用"),B("button",{type:"button",className:"codex-copy-id-btn",onClick:(z)=>{z.stopPropagation(),y(Q)},"data-testid":`codex-copy-task-id-${Q||"unknown"}`},j?"已复制":"复制ID"),U?B("button",{type:"button",className:"codex-copy-id-btn codex-mark-read-btn",disabled:Boolean(J),onClick:(z)=>{z.stopPropagation(),$(Q)},"data-testid":`codex-mark-task-read-${Q||"unknown"}`},J?"标记中":"标为已读"):null)),B("strong",null,Rz(hz(f),120)||"空任务"),B("div",{className:"codex-task-meta"},B("span",null,`queue=${w4(f)}`),B("span",null,f?.model||"--"),B("span",null,rX(f))),B("div",{className:"codex-task-meta"},B("span",null,Sy(f?.updatedAt))),F?.decision?B("div",{className:"codex-judge-line"},`judge=${F.decision} ${Math.round(Number(F.confidence||0)*100)}%`):null)}function Aj({title:f,tasks:u,selectedId:_,onSelect:y,onCopy:l,onReference:$,onMarkRead:j,copiedTaskId:J,markingReadTaskId:F,emptyText:Q}){let U=Array.isArray(u)?u:[];return B("section",{className:"codex-task-section"},B("div",{className:"codex-task-section-head"},B("span",null,f),B("code",null,String(U.length))),U.length===0?B("p",{className:"codex-task-section-empty"},Q):B("div",{className:"codex-task-section-list"},U.map((z)=>B(pX,{key:z.id,task:z,selected:_===z.id,onSelect:()=>y(z.id),onCopy:l,onReference:$,onMarkRead:j,copied:J===z.id,markingRead:F===z.id}))))}function mX({task:f,queueRows:u,busy:_,onMove:y}){let l=String(f?.id||""),$=w4(f),[j,J]=df($);P$(()=>{J($)},[l,$]);let F=!l||_||["running","judging","retry_wait"].includes(String(f?.status||""));return B("div",{className:"codex-task-move-control","data-testid":"codex-task-queue-move-control"},B("label",null,"任务 queue",B("select",{value:j,disabled:!l||_,onChange:(Q)=>J(String(Q.target.value||$)),"data-testid":"codex-task-queue-move-select"},u.map((Q)=>B("option",{key:String(Q?.id||""),value:String(Q?.id||"")},Uj(Q))))),B("button",{type:"button",className:"ghost-btn",disabled:F||j===$,onClick:()=>y(j),title:F?"运行中 / judging / retry_wait 的任务不能移动;请先打断或等当前 turn 结束":"移动已创建任务到另一个 queue","data-testid":"codex-task-queue-move-button"},"移动"))}function Cz(f,u=4){let _=(Array.isArray(f)?f:[]).map((l)=>String(l||"").trim()).filter(Boolean);if(_.length===0)return"--";let y=_.slice(0,u).join(" / ");return _.length>u?`${y} +${_.length-u}`:y}function kX({task:f,loading:u,onLoadPromptPart:_,testId:y="codex-initial-prompt-full",textTestId:l="codex-initial-prompt-full-text",baseTextTestId:$="codex-initial-prompt-base"}){let j=T4(f),J=C$(f),F=Iz(f).trimEnd(),Q=String(J.full?.text||""),U=mz(f),z=Number(j.promptChars||f?.promptChars||Q.length),W=Number(j.basePromptLines||q1(F)),K=Number(j.promptLines||q1(Q));return B("section",{className:"codex-progressive-card codex-progressive-prompt","data-testid":"codex-progressive-prompt"},B("div",{className:"codex-progressive-card-head"},B("span",{className:"codex-output-channel"},"Prompt"),B("strong",null,"Submitted prompt / 原始用户 prompt"),B("code",null,`${W||q1(F)} lines / ${F.length} chars`)),B("pre",{className:"codex-prompt-full","data-testid":$},F||"空 prompt"),U?B("details",{className:"codex-reference-injection codex-progressive-full-prompt","data-testid":y,onToggle:(q)=>{if(q.currentTarget?.open&&!Q)_?.("full")}},B("summary",null,B("span",null,"引用注入已折叠,点击按需拉取最终进入 opencode 的完整 prompt"),B("code",null,Q?`${K||q1(Q)} lines / ${Q.length} chars`:`${Number.isFinite(z)&&z>0?z:"--"} chars`)),B("pre",{className:"codex-prompt-full codex-prompt-final-full","data-testid":l},Q||(u?"正在按需拉取完整 prompt...":"展开后将只请求 full prompt,不拉取完整 transcript。"))):null)}function iz({task:f,attempt:u,attemptIndex:_,loading:y,onLoadSteps:l,onLoadStep:$,testId:j="codex-execution-summary"}){let J=wX(f,u),F=MX(f,_),Q=Kj(f),U=kz(f,_),z=Number(J.toolCallCount||0),W=Array.isArray(J.editedFiles)?J.editedFiles:[],K=Array.isArray(J.commands)?J.commands:[],q=_?` #${_}`:"";return B("details",{className:"codex-progressive-card codex-execution-summary","data-testid":j,"data-attempt-index":_?String(_):void 0,onToggle:(V)=>{if(V.currentTarget?.open&&!U)l?.(_)}},B("summary",null,B("div",{className:"codex-progressive-card-head"},B("span",{className:"codex-output-channel"},"Summary"),B("strong",null,`执行过程摘要${q}`),B("code",null,`${N4(J.durationMs??J.totalElapsedMs)} / ${z} tools`)),B("div",{className:"codex-execution-digest"},B("span",null,`read ${Number(J.readCount||0)}`),B("span",null,`edit ${Number(J.editCount||0)}`),B("span",null,`run ${Number(J.runCount||0)}`),B("span",null,`${Number(J.stepCount||F.length||0)} steps`))),B("div",{className:"codex-execution-digest expanded"},B("span",null,`修改文件:${Cz(W,6)}`),B("span",null,`执行命令:${Cz(K,4)}`)),F.length===0?B("div",{className:"codex-output-empty"},y?"正在按需拉取步骤 summary...":"展开后将只请求执行步骤 summary,不拉取单步骤全量。"):B("div",{className:"codex-trace-step-list"},F.map((V)=>{let O=String(V?.seq??""),G=Q[O],H=Array.isArray(V?.summaryLines)?V.summaryLines.slice(0,4):[];return B("details",{key:O||`${V?.title}-${V?.at}`,className:`codex-trace-step ${String(V?.kind||"message")}`,"data-testid":`codex-trace-step-${O||"unknown"}`,onToggle:(Z)=>{if(Z.currentTarget?.open&&!G)$?.(V?.seq)}},B("summary",null,B("span",{className:"codex-output-channel"},iX(V?.kind)),B("strong",null,String(V?.title||"Trace step")),V?.status?B("code",null,String(V.status)):null,B("time",null,Sy(V?.at))),B("div",{className:"codex-trace-step-summary"},H.length>0?H.map((Z,E)=>B("pre",{key:`${O}-${E}`},String(Z||""))):B("span",null,"无 summary")),G?.line?B(V4,{items:[G.line],autoScroll:!1,loading:!1,hasDetail:!0,emptyText:"无步骤详情",testId:`codex-trace-step-detail-${O||"unknown"}`,className:"codex-transcript codex-step-detail-transcript",collapseTools:!1}):B("div",{className:"codex-output-empty"},y?"正在按需拉取这个步骤的全量数据...":"展开后将只请求这个单步骤的全量数据。"))})))}function iX(f){let u=String(f||"");if(u==="ran")return"Ran";if(u==="explored")return"Explored";if(u==="edited")return"Edited";if(u==="error")return"Error";if(u==="system")return"System";return"Message"}function gz({task:f,attempt:u,attemptIndex:_,testId:y="codex-final-response"}){let l=DX(f,u),$=Number(u?.finalResponseChars||l.length),j=_?` #${_}`:"";return B("section",{className:"codex-progressive-card codex-final-response","data-testid":y,"data-attempt-index":_?String(_):void 0},B("div",{className:"codex-progressive-card-head"},B("span",{className:"codex-output-channel"},"Final"),B("strong",null,`最终 response${j}`),B("code",null,`${Number.isFinite($)?$:l.length} chars`)),B("pre",{className:"codex-transcript-body"},l||"暂无最终 response"))}function nz({task:f,attempt:u,attemptIndex:_,testId:y="codex-progressive-judge"}){let l=cz(f,u),$=_?` #${_}`:"";return B("section",{className:"codex-progressive-card codex-progressive-judge","data-testid":y,"data-attempt-index":_?String(_):void 0},B("div",{className:"codex-progressive-card-head"},B("span",{className:"codex-output-channel"},"Judge"),B("strong",null,`完成判定${$}`),l?.decision?B("code",null,`${l.decision} ${Math.round(Number(l.confidence||0)*100)}%`):null),l?B("div",{className:"codex-judge-card","data-testid":`${y}-card`},B(y_,{status:l.decision},l.decision),B("strong",null,`${Math.round(Number(l.confidence||0)*100)}% confidence`),B("p",{"data-testid":`${y}-reason`},l.reason||"--"),l.continuePrompt?B("pre",{"data-testid":`${y}-continue-prompt`},String(l.continuePrompt||"")):null):B("div",{className:"codex-output-empty"},"尚未判定"))}function gX({task:f,attempt:u,attemptIndex:_,loading:y,onLoadPromptPart:l,testId:$="codex-judge-feedback-prompt"}){let j=TX(f,u,_);if(j===null)return null;let J=pz(_),Q=C$(f)[J],U=String(Q?.text||"").trimEnd(),z=String(j.preview||j.text||"").trimEnd(),W=U||String(j.text||"").trimEnd(),K=Number(Q?.chars||j.chars||W.length||z.length),q=Number(Q?.lines||j.lines||q1(W||z)),V=Q?.forAttempt||j.forAttempt||Number(_||0)+1;return B("details",{className:"codex-progressive-card codex-judge-feedback-prompt","data-testid":$,"data-attempt-index":_?String(_):void 0,onToggle:(O)=>{if(O.currentTarget?.open&&!U)l?.("feedback",_)}},B("summary",null,B("div",{className:"codex-progressive-card-head"},B("span",{className:"codex-output-channel"},"Prompt"),B("strong",null,`judge feedback prompt #${_} -> #${V}`),B("code",null,`${q||"--"} lines / ${Number.isFinite(K)?K:z.length} chars`)),B("p",{className:"codex-feedback-preview","data-testid":`${$}-preview`},z||"展开后按需拉取 judge feedback prompt。")),B("pre",{className:"codex-prompt-full codex-feedback-full","data-testid":`${$}-text`},W||(y?"正在按需拉取 judge feedback prompt...":"展开后将只请求这一次 judge feedback prompt。")))}function nX({task:f,attempt:u,position:_,loading:y,onLoadPromptPart:l,onLoadSteps:$,onLoadStep:j}){let J=Number(u?.index||_+1),F=_===0;return B("section",{className:"codex-attempt-cycle","data-testid":`codex-attempt-cycle-${J}`},B("div",{className:"codex-attempt-cycle-head"},B("span",{className:"codex-output-channel"},`Attempt ${J}`),B("strong",null,String(u?.mode||(J<=1?"initial":"retry"))),u?.terminalStatus?B(y_,{status:u.terminalStatus},u.terminalStatus):null,B("code",null,`${Sy(u?.startedAt)} -> ${Sy(u?.finishedAt)}`)),B(iz,{task:f,attempt:u,attemptIndex:J,loading:y,onLoadSteps:$,onLoadStep:j,testId:F?"codex-execution-summary":`codex-execution-summary-attempt-${J}`}),B(gz,{task:f,attempt:u,attemptIndex:J,testId:F?"codex-final-response":`codex-final-response-attempt-${J}`}),B(nz,{task:f,attempt:u,attemptIndex:J,testId:F?"codex-progressive-judge":`codex-progressive-judge-attempt-${J}`}),B(gX,{task:f,attempt:u,attemptIndex:J,loading:y,onLoadPromptPart:l,testId:F?"codex-judge-feedback-prompt":`codex-judge-feedback-prompt-attempt-${J}`}))}function tX({task:f,loading:u,onLoadPromptPart:_,onLoadSteps:y,onLoadStep:l}){if(!f)return B(ry,{title:"未选择任务",text:"从左侧队列选择任务,或提交新 Codex 任务。"});let $=BX(f);return B("div",{className:"codex-transcript codex-progressive-trace","data-testid":"codex-output"},u&&!Py(f)?B("div",{className:"codex-output-empty"},"正在加载 Trace Summary..."):null,B(kX,{task:f,loading:u,onLoadPromptPart:_}),$.length>0?$.map((j,J)=>B(nX,{key:`${j?.index||J+1}-${j?.startedAt||J}`,task:f,attempt:j,position:J,loading:u,onLoadPromptPart:_,onLoadSteps:y,onLoadStep:l})):[B(iz,{key:"execution",task:f,loading:u,onLoadSteps:y,onLoadStep:l}),B(gz,{key:"final",task:f}),B(nz,{key:"judge",task:f})])}function sX({task:f,loading:u,onLoadPromptPart:_}){if(!f)return B(ry,{title:"未选择任务",text:"选择队列或历史 session 后,这里显示完整 prompt、模型和工作目录。"});let y=T4(f),l=C$(f),$=Iz(f).trimEnd(),j=String(l.full?.text||""),J=mz(f),F=Number(y.basePromptLines||q1($)),Q=Number(y.promptLines||q1(j)),U=Number(y.referencePromptLines||0),z=Number(y.promptChars||f?.promptChars||j.length);return B("div",{className:"codex-prompt-detail","data-testid":"codex-task-prompt-detail"},B("div",{className:"codex-prompt-meta"},B(y_,{status:f?.status},f?.status||"unknown"),B("span",null,`model=${f?.model||"--"}`),B("span",null,`cwd=${f?.cwd||"--"}`),B("span",null,`created=${Sy(f?.createdAt)}`),B("span",null,J?`task ${F} lines / total ${Number.isFinite(Q)&&Q>0?Q:"--"} lines`:`${F} lines / ${$.length} chars`)),B("div",{className:"codex-lazy-detail-callout","data-testid":"codex-task-summary-callout"},B("div",null,B("strong",null,"渐进式 Trace"),B("span",null,"首屏使用后端 Summary;展开 prompt / 步骤时只按需拉取对应片段,不一次性拉取完整 transcript。"))),J?B("details",{className:"codex-reference-injection codex-final-prompt-injection","data-testid":"codex-final-prompt-full",onToggle:(W)=>{if(W.currentTarget?.open&&!j)_?.("full")}},B("summary",null,B("span",null,"最终传入 Codex 的真实完整 prompt"),B("code",null,j?`${Q||q1(j)} lines / ${j.length} chars`:`${Number.isFinite(z)&&z>0?z:"--"} chars`)),B("pre",{className:"codex-prompt-full codex-prompt-final-full","data-testid":"codex-task-final-prompt-full"},j||(u?"正在按需拉取完整 prompt...":"展开后将只请求完整 prompt。"))):null,J?B("details",{className:"codex-reference-injection","data-testid":"codex-reference-injection",onToggle:(W)=>{if(W.currentTarget?.open&&!l.reference?.text)_?.("reference")}},B("summary",null,B("span",null,"引用注入已折叠"),B("code",null,l.reference?.text?`${q1(String(l.reference.text||""))} lines / ${String(l.reference.text||"").length} chars`:`${U||"--"} lines`)),B("pre",{className:"codex-prompt-full codex-prompt-reference-full","data-testid":"codex-task-reference-full"},String(l.reference?.text||"")||(u?"正在按需拉取引用注入...":"展开后将只请求引用注入片段。"))):null,B("pre",{className:"codex-prompt-full","data-testid":"codex-task-prompt-full"},$||"空 prompt"))}function oX({task:f}){let u=OX(f);if(!f||u.length===0)return B(ry,{title:"暂无原始消息",text:"原始 Codex app-server 消息会保留在任务 JSON 中。"});return B("details",{className:"codex-raw-output"},B("summary",null,`原始 messages (${u.length})`),B("div",null,u.map((_)=>B("article",{key:`${_.seq}-${_.channel}`,className:`codex-output-line ${_.channel||"system"}`},B("div",{className:"codex-output-meta"},B("span",{className:"codex-output-channel"},SX(String(_.channel||"system"))),B("span",null,Sy(_.at)),_.method?B("code",null,_.method):null),B("pre",null,String(_.text||""))))))}function aX({task:f}){let u=bz(f).slice().reverse();if(u.length===0)return B(ry,{title:"尚无 attempt",text:"任务开始运行后,这里会记录 Codex 终态、传输中断和 stderr tail。"});return B("div",{className:"table-wrap codex-attempt-table"},B("table",null,B("thead",null,B("tr",null,B("th",null,"#"),B("th",null,"模式"),B("th",null,"终态"),B("th",null,"传输"),B("th",null,"退出"),B("th",null,"完成时间"))),B("tbody",null,u.map((_)=>B("tr",{key:`${_.index}-${_.startedAt}`},B("td",null,_.index),B("td",null,_.mode),B("td",null,B(y_,{status:_.terminalStatus||"unknown"},_.terminalStatus||"unknown")),B("td",null,_.transportClosedBeforeTerminal?B(y_,{status:"failed"},"closed-before-terminal"):B(y_,{status:"succeeded"},"normal")),B("td",null,`code=${_.appServerExitCode??"--"} signal=${_.appServerSignal??"--"}`),B("td",null,Sy(_.finishedAt)))))))}function tz({microservices:f,onRaw:u,apiBaseUrl:_="/api",initialTasksData:y=null,standalone:l=!1}){let $=f.find((k)=>k.id==="codex-queue")||null,j=IX(y),J=String(j?.id||""),F=new Map;if(j!==null&&J.length>0)F.set(J,{task:j,maxSeq:X4(Array.isArray(j.transcript)?j.transcript:[]),complete:Boolean(j._transcriptComplete),completeUpdatedAt:j._transcriptComplete?String(j.updatedAt||""):""});let Q=typeof performance>"u"?0:performance.now(),U=Zu(J),z=Zu(0),W=Zu(0),K=Zu(!1),q=Zu(!1),V=Zu(null),O=Zu(new Map),G=Zu(new Map),H=Zu(new Map),Z=Zu(new Map),E=Zu(new Set),L=Zu(!1),M=Zu(Boolean(y)),N=Zu(F),w=Zu(y),[R,p]=df(null),[x,C]=df(y),[P,D]=df(J),[T,S]=df(j),[r,Y]=df(!1),[v,m]=df(""),[c,o]=df(""),[ff,n]=df("default"),[lf,Gf]=df(v_),[zf,jf]=df("gpt-5.5"),[Wf,Vf]=df("/root/unidesk"),[Kf,h]=df(99),[g,I]=df(1),[yf,$f]=df(!1),[Qf,Yf]=df(!1),[xf,tf]=df(""),[j0,u0]=df(!0),[D0,Fu]=df(()=>typeof window>"u"?!0:window.matchMedia(AX).matches),[O0,x0]=df(!1),[ku,X0]=df(""),[Au,uf]=df(""),[vf,a0]=df(""),[Bf,v0]=df(""),[i0,d0]=df(!1),[b0,m1]=df(PX),[ef,iu]=df(y?{phase:"complete",taskId:J,queueMs:0,detailMs:0,totalMs:Q,chunks:j?1:0,transcriptRows:Array.isArray(j?.transcript)?j.transcript.length:0,partial:Boolean(y?.selected?.hasMore||S$(j)),completedAt:new Date}:null),[ey,f3]=df(y?new Date:null),[s,Nf]=df(!1),Of=Z1(x).map((k)=>jj(k,b0)),Cf=Of.filter(I3),_0=Of.filter((k)=>!P1(k)),G0=Of.filter((k)=>P1(k)&&!I3(k)),hf=x?.queue||R?.body?.queue||R?.queue||{},h0=r$(x),Qu=Dz(hf,ff),P6=B4(Qu,lf),L1=Number((vu(lf)?hf?.total:P6?.total)??h0.total??Of.length),C6=h0.hasMore===!0&&String(h0.nextBeforeId||"").length>0,u3=L4(hf),_3=vu(lf)?u3:[String(B4(Qu,lf)?.activeTaskId||"")].filter(Boolean),$y=Tz(hf,Qu,lf,Of),W_=vu(lf)?$j(hf):$j(P6||{}),R6=$j(hf),z2=RX(R6),x6=Math.max(xX(R6),u3.length),k1=R$(hf?.unreadTerminal??Cf.length),jy=vu(lf)?"All queues":lf,v6=$?$X($):{},G2=$?JX($):{},Vl=$?jX($):{},El=Yz(()=>XX(v),[v]),j1=Yz(()=>{let k=Mz(g);return El.flatMap((a)=>Array.from({length:k},()=>NX(a,c)))},[El,g,c]),Jy=j1.length,Ol=Jy>1&&!yf,K2=Qf||O0||Jy===0||Ol,b6=cX(hf,zf),Xl=T?.id&&T?.activeTurnId&&String(T?.status)==="running",Z2=T?.id&&!["succeeded","failed","canceled"].includes(String(T?.status||"")),q2=T?.id&&["succeeded","failed","canceled"].includes(String(T?.status||""));function i1(k){let a=typeof k==="function"?k(w.current):k;return w.current=a,C(a),a}function h6(k,a,Af=null,Zf=null){let wf=new Set(k.map((Ef)=>String(Ef||"")).filter(Boolean));if(wf.size===0&&Zf===null&&Af===null)return;i1((Ef)=>{if(!Ef)return Ef;let rf=Z1(Ef).map((Rf)=>{let cf=String(Rf?.id||"");if(!wf.has(cf))return Rf;let nf=Zf&&String(Zf?.id||"")===cf?Zf:{};return{...Rf,...nf,readAt:a,terminalUnread:!1}});return{...Ef,queue:Af||Ef.queue,tasks:wf.size>0?E4([rf],$y):rf}});for(let Ef of wf){let rf=N.current.get(Ef);if(rf?.task){let Rf=Zf&&String(Zf?.id||"")===Ef?Zf:{},cf={...rf.task,...Rf,readAt:a,terminalUnread:!1};if(N.current.set(Ef,{...rf,task:cf}),U.current===Ef)S(cf)}}}function Nl(k,a){let Af=k.map((Zf)=>String(Zf||"")).filter(Boolean);if(Af.length===0)return;m1((Zf)=>{let wf={...Zf||{}};for(let Ef of Af)wf[Ef]=a;return CX(wf),wf})}P$(()=>{$f(!1)},[v,g,c]);function Y1(k,a,Af){let Zf=N.current.get(k)||{},wf=Zf.task||{},Ef=Array.isArray(wf.transcript)?wf.transcript:[],rf=hX(wf,a),Rf=Object.prototype.hasOwnProperty.call(a,"transcript")?Jj(Ef,Array.isArray(a.transcript)?a.transcript:[]):Ef,cf={...wf,...rf,transcript:Rf,output:Array.isArray(rf.output)?Zj(wf,rf,"output"):Array.isArray(wf.output)?wf.output:[],events:Array.isArray(rf.events)?Zj(wf,rf,"events"):Array.isArray(wf.events)?wf.events:[]},nf=String(cf?.updatedAt||""),of=Boolean(a._transcriptComplete)&&P1(cf),J0=Boolean(Zf.complete)&&P1(cf)&&String(Zf.completeUpdatedAt||"")===nf,e0=of||J0,J1={...Zf,task:cf,maxSeq:X4(Rf),complete:e0,completeUpdatedAt:e0?nf:""};if(N.current.set(k,J1),Af===W.current&&U.current===k)S(cf);return J1}async function Ll(k,a=!1,Af,Zf){if(!$||!k)return;let Ef=N.current.get(k)?.task,rf=String(Ef?._traceSummaryUpdatedAt||""),Rf=String(Ef?.updatedAt||"");if(!a&&Ef?._traceSummaryLoaded===!0&&rf===Rf)return;let cf=k,nf=O.current.get(cf);if(nf)return nf;let of=W.current,J0=performance.now();if(U.current===k)Y(!0);let e0=(async()=>{try{let J1=await KX(_,k);if(of!==W.current||U.current!==k)return;let N0=J1?.summary||{};Y1(k,{id:k,status:N0.status,updatedAt:N0.updatedAt,startedAt:N0.startedAt,finishedAt:N0.finishedAt,currentAttempt:N0.currentAttempt,maxAttempts:N0.maxAttempts,finalResponse:N0.finalResponse,lastJudge:N0.lastJudge,lastError:N0.lastError,attempts:Array.isArray(N0.attempts)?N0.attempts:[],timing:N0.timing,_traceSummary:N0,_traceSummaryLoaded:!0,_traceSummaryUpdatedAt:String(N0.updatedAt||""),_detailLoaded:!0},of),iu({phase:"complete",taskId:k,queueMs:Zf??0,detailMs:performance.now()-J0,totalMs:Af===void 0?performance.now()-J0:performance.now()-Af,chunks:1,transcriptRows:Number(N0?.execution?.stepCount||0),partial:!1,completedAt:new Date})}finally{if(O.current.delete(cf),of===W.current&&U.current===k)Y(!1)}})();O.current.set(cf,e0),await e0}async function I6(k,a=null){let Af=U.current;if(!$||!Af||!k)return;let Zf=N.current.get(Af)?.task,wf=C$(Zf),Ef=k==="feedback"||k==="judge-feedback"?pz(a):k;if(wf[Ef]?.text)return;let rf=`${Af}:${Ef}`,Rf=G.current.get(rf);if(Rf)return Rf;let cf=W.current;if(U.current===Af)Y(!0);let nf=(async()=>{try{let of=await ZX(_,Af,k,a);if(cf!==W.current||U.current!==Af)return;let J0=N.current.get(Af)?.task,e0=C$(J0);Y1(Af,{...k==="full"?{prompt:String(of?.text||""),promptChars:Number(of?.chars||0)}:{},_promptDetails:{...e0,[Ef]:of}},cf)}finally{if(G.current.delete(rf),cf===W.current&&U.current===Af)Y(!1)}})();G.current.set(rf,nf),await nf}async function H2(k=null){let a=U.current;if(!$||!a)return;let Af=N.current.get(a)?.task,Zf=k===null||k===void 0||String(k).length===0?"":String(k);if(kz(Af,Zf||null))return;let wf=`${a}:${Zf||"all"}`,Ef=H.current.get(wf);if(Ef)return Ef;let rf=W.current;if(U.current===a)Y(!0);let Rf=(async()=>{try{let cf=await qX(_,a,0,500,Zf||null);if(rf!==W.current||U.current!==a)return;let nf=Array.isArray(cf?.steps)?cf.steps:[];if(Zf){let of=N.current.get(a)?.task,J0=b_(of?._traceStepsByAttempt)||{},e0=b_(of?._traceStepsLoadedByAttempt)||{};Y1(a,{_traceStepsByAttempt:{...J0,[Zf]:nf},_traceStepsLoadedByAttempt:{...e0,[Zf]:!0}},rf)}else Y1(a,{_traceSteps:nf,_traceStepsLoaded:!0,_traceStepsHasMore:Boolean(cf?.hasMore),_traceStepsNextAfterSeq:cf?.nextAfterSeq},rf)}finally{if(H.current.delete(wf),rf===W.current&&U.current===a)Y(!1)}})();H.current.set(wf,Rf),await Rf}async function V2(k){let a=U.current,Af=String(k??"");if(!$||!a||Af.length===0)return;let Zf=N.current.get(a)?.task;if(Kj(Zf)[Af]?.line)return;let Ef=`${a}:${Af}`,rf=Z.current.get(Ef);if(rf)return rf;let Rf=W.current;if(U.current===a)Y(!0);let cf=(async()=>{try{let nf=await HX(_,a,k);if(Rf!==W.current||U.current!==a)return;let of=N.current.get(a)?.task,J0=Kj(of);Y1(a,{_traceStepDetails:{...J0,[Af]:nf}},Rf)}finally{if(Z.current.delete(Ef),Rf===W.current&&U.current===a)Y(!1)}})();Z.current.set(Ef,cf),await cf}async function zA(k,a,Af){if(!$||!k)return;let Zf=performance.now(),wf=W.current,Ef=N.current.get(k);if(Ef?.task){if(S(Ef.task),Y(S$(Ef.task)||!Ef.complete),!S$(Ef.task)&&Ef.complete&&P1(Ef.task)&&String(Ef.completeUpdatedAt||"")===String(Ef.task?.updatedAt||"")){iu({phase:"complete",taskId:k,queueMs:Af??0,detailMs:0,totalMs:a===void 0?0:performance.now()-a,chunks:0,transcriptRows:Array.isArray(Ef.task.transcript)?Ef.task.transcript.length:0,completedAt:new Date});return}}else Y(!0);let rf=V.current;if(rf?.taskId===k&&rf.token===wf)return rf.promise;let Rf=(async()=>{try{let cf=await M0(r0(_,`/api/tasks/${encodeURIComponent(k)}?meta=1`));if(wf!==W.current||U.current!==k)return;let nf=N.current.get(k),of=Array.isArray(nf?.task?.transcript)?nf.task.transcript:[],J0=cf?.task||{},e0=Boolean(nf?.complete)&&String(nf?.completeUpdatedAt||"")===String(J0?.updatedAt||"");Y1(k,{...J0,summaryOnly:!1,_metaLoaded:!0,transcript:of,_detailLoaded:of.length>0,_transcriptComplete:e0},wf);let J1=S$(nf?.task)||Boolean(nf?.task?._transcriptPreview),N0=J1?0:of.length>0?Pz(of):0,z_=!J1&&nf?.complete&&P1(J0)&&String(nf?.completeUpdatedAt||"")===String(J0?.updatedAt||"")?X4(of):N0,l3=!0,k6=0,i6=of.length;while(l3){let nu=await M0(r0(_,`/api/tasks/${encodeURIComponent(k)}/transcript?afterSeq=${encodeURIComponent(String(z_))}&limit=${_X}&fullText=1`));if(wf!==W.current||U.current!==k)return;let B1=N.current.get(k),Fy=Array.isArray(B1?.task?.transcript)?B1.task.transcript:[],Ay=Jj(Fy,Array.isArray(nu?.transcript)?nu.transcript:[]);k6+=1,i6=Ay.length;let g0=Boolean(!nu?.hasMore);if(Y1(k,{status:nu?.status||J0.status,updatedAt:nu?.updatedAt||J0.updatedAt,transcript:Ay,_detailLoaded:g0||Ay.length>0,_transcriptComplete:g0,_transcriptPreview:J1&&!g0},wf),l3=Boolean(nu?.hasMore),z_=Number(nu?.nextAfterSeq??X4(Ay)),!l3)break;await new Promise((VA)=>window.setTimeout(VA,0))}iu({phase:"complete",taskId:k,queueMs:Af??0,detailMs:performance.now()-Zf,totalMs:a===void 0?performance.now()-Zf:performance.now()-a,chunks:k6,transcriptRows:i6,completedAt:new Date})}finally{if(V.current?.taskId===k&&V.current?.token===wf)V.current=null;if(wf===W.current&&U.current===k)Y(!1)}})();V.current={taskId:k,token:wf,promise:Rf},await Rf}async function gu(k=U.current,a=!0,Af=lf){if(!$)return;if(!a&&L.current)return;let Zf=performance.now();if(a)L.current=!0;if(a)iu({phase:"loading",taskId:String(k||U.current||""),startedAt:new Date});let wf=z.current+1;z.current=wf;let Ef=String(k||U.current||""),rf=Ef?N.current.get(Ef):null,Rf=Array.isArray(rf?.task?.transcript)?rf.task.transcript:[],cf=Pz(Rf),nf=R||{},of=null;try{of=await zX(_,Ef,cf,Af)}catch{of=await WX(_,nf,Af)}if(wf!==z.current){if(a)L.current=!1;return}let J0=performance.now()-Zf;p(nf);let e0=of?.queue||{},J1=String(e0?.activeTaskId||L4(e0)[0]||""),N0=of;i1((Uu)=>{let Dl=Z1(of),Qy=Z1(Uu),$3=Qy.length>0?E4([Qy,Dl],J1):E4([Dl],J1),mH=r$(of),n6=r$(Uu),kH=Qy.length>Dl.length&&(n6.hasMore===!1||String(n6.nextBeforeId||"").length>0),iH={...mH,...kH?{hasMore:n6.hasMore,nextBeforeId:n6.nextBeforeId}:{},returned:$3.length};return N0={...of,tasks:$3,pagination:iH},N0});let z_=Z1(N0),l3=Dz(e0,ff),k6=Tz(e0,l3,Af,z_),i6=UX(l3,Af,z_),nu=Ef||U.current,B1=N0?.selected||null,Fy=B1?.task||null,Ay=Array.isArray(B1?.transcript)?B1.transcript:null,g0=nu&&(z_.some((Uu)=>Uu.id===nu)||String(Fy?.id||"")===nu)?nu:k6||i6||z_[0]?.id||"";if(U.current!==g0)W.current+=1;U.current=g0,D(g0);let g6=z_.find((Uu)=>Uu.id===g0);if(g6){let Uu=N.current.get(g0);if(Uu?.task)N.current.set(g0,{...Uu,task:{...g6,...Uu.task,status:g6.status,updatedAt:g6.updatedAt}})}if(Fy?.id===g0&&Ay!==null){let Uu=N.current.get(g0),Dl=Array.isArray(Uu?.task?.transcript)?Uu.task.transcript:[],Qy=Jj(Dl,Ay),$3=Boolean(B1?.preview);if(Y1(g0,{...Fy,_summaryLoaded:!0,transcript:Qy,_detailLoaded:!B1?.hasMore||Qy.length>0,_transcriptComplete:!$3&&!B1?.hasMore&&P1(Fy),_transcriptPreview:$3},W.current),Y(!1),a)iu({phase:"complete",taskId:g0,queueMs:J0,detailMs:Math.max(0,performance.now()-Zf-J0),totalMs:performance.now()-Zf,chunks:1,transcriptRows:Qy.length,partial:Boolean($3||B1?.hasMore||S$(Fy)),completedAt:new Date});if(f3(new Date),a)L.current=!1;return}if(a)iu({phase:"session",taskId:g0,queueMs:J0,totalMs:J0,startedAt:new Date(Date.now()-J0)});if(g0)Ll(g0,!0,a?Zf:void 0,a?J0:void 0).catch((Uu)=>X0(Tf(Uu,"加载 Codex Trace Summary 失败")));else if(W.current+=1,S(null),Y(!1),a)iu({phase:"complete",taskId:"",queueMs:J0,detailMs:0,totalMs:performance.now()-Zf,chunks:0,transcriptRows:0,completedAt:new Date});if(f3(new Date),a)L.current=!1}async function c6(){if(!$||s||q.current)return;let k=String(r$(x).nextBeforeId||"");if(!k)return;q.current=!0,Nf(!0),X0("");try{let a=await GX(_,lf,k),Af=Z1(a),Zf=a?.queue||hf||{},wf=String(Zf?.activeTaskId||L4(Zf)[0]||$y||"");i1((Ef)=>{let rf=E4([Z1(Ef),Af],wf),Rf=r$(a);return{...Ef||{},queue:Zf,tasks:rf,pagination:{...Rf,returned:rf.length}}})}catch(a){X0(Tf(a,"加载更早 Codex tasks 失败"))}finally{q.current=!1,Nf(!1)}}function p6(k){let a=k.currentTarget;if(!a||s||!C6)return;if(a.scrollHeight-a.scrollTop-a.clientHeight<120)c6()}async function Tu(k,a){x0(!0),X0("");try{await k()}catch(Af){X0(Tf(Af,a))}finally{x0(!1)}}async function y3(k){if(!k)return;try{let a=!1;try{if(navigator.clipboard?.writeText)await navigator.clipboard.writeText(k),a=!0}catch{a=!1}if(!a){let Af=document.createElement("textarea");Af.value=k,Af.style.position="fixed",Af.style.opacity="0",document.body.appendChild(Af),Af.select(),a=document.execCommand("copy"),document.body.removeChild(Af)}if(!a)throw Error("browser clipboard rejected the copy request");a0(k),uf(`已复制任务 ID:${k}`),window.setTimeout(()=>a0((Af)=>Af===k?"":Af),1600)}catch(a){X0(`复制任务 ID 失败:${Tf(a)}`)}}function Yl(k){if(!k)return;o(k),uf(`已引用任务 ID:${k};提交时后端会读取并注入该任务上下文`)}async function Bl(k){if(!$||!k)return;v0(k),await Tu(async()=>{let a=null,Af=!1;try{a=await VX(_,k)}catch{Af=!0}let Zf=a?.task||{id:k,readAt:new Date().toISOString(),terminalUnread:!1},wf=String(Zf?.readAt||new Date().toISOString());Nl([k],wf),h6([k],wf,a?.queue||null,Zf),uf(Af?`已在本浏览器将任务 ${k} 标为已读;后端升级后会同步持久化`:`已将任务 ${k} 标为已读`)},"标记 Codex task 已读失败"),v0((a)=>a===k?"":a)}async function wl(){if(!$||i0)return;d0(!0),await Tu(async()=>{let k=null,a=!1;try{k=await EX(_)}catch{a=!0}let Af=String(k?.readAt||new Date().toISOString()),Zf=Z1(w.current).map((Rf)=>jj(Rf,b0)).filter(I3).map((Rf)=>String(Rf?.id||"")).filter(Boolean),wf=Array.from(N.current.entries()).filter(([,Rf])=>I3(jj(Rf?.task,b0))).map(([Rf])=>Rf),Ef=Array.from(new Set([...Zf,...wf]));Nl(Ef,Af),h6(Ef,Af,k?.queue||null);let rf=a?Ef.length:Number(k?.count||Ef.length);uf(a?`已在本浏览器将 ${rf} 个已结束未读任务标为已读;后端升级后会同步持久化`:`已将 ${rf} 个已结束未读任务标为已读`)},"全部标为已读失败"),d0(!1)}function E2(k){let a=k||v_;if(Gf(a),!vu(a))n(a);if(i1(null),!(vu(a)?U.current:""))U.current="",W.current+=1,D(""),S(null),Y(!0)}async function O2(){let k=typeof window>"u"?"":window.prompt("输入新的 Codex queue ID(字母/数字/._-,最长 64)","new-lane"),a=String(k||"").trim();if(!a)return;await Tu(async()=>{let Af=await M0(r0(_,"/api/queues"),{method:"POST",body:{queueId:a}}),Zf=String(Af?.queue?.id||a);n(Zf),Gf(Zf),i1(null),U.current="",W.current+=1,D(""),S(null),uf(`已创建并切换到 queue:${Zf}`),await gu("",!0,Zf)},"创建 Codex queue 失败")}async function m6(k){if(k.preventDefault(),K.current){uf("任务正在提交中,请等待当前请求完成,已阻止重复提交。");return}if(j1.length>1&&!yf){X0(`检测到将创建 ${j1.length} 个任务;请先勾选“确认批量入队”,避免误传多个任务。`);return}K.current=!0,Yf(!0),uf("正在提交 Codex Queue 任务,请等待后端确认,输入已临时锁定。"),await Tu(async()=>{if(j1.length===0)throw Error("prompt 不能为空");let a=Y4(c),Af=ff.trim()||"default",Zf=[...j1],wf=(nf)=>({prompt:nf,queueId:Af,model:zf,cwd:Wf,maxAttempts:Number(Kf),...a.length>0?{referenceTaskIds:a}:{}}),Ef=Zf.length===1?wf(Zf[0]):{tasks:Zf.map(wf)},rf=await M0(r0(_,Zf.length===1?"/api/tasks":"/api/tasks/batch"),{method:"POST",body:Ef}),Rf=rf?.tasks?.[0]?.id||"",cf=Array.isArray(rf?.tasks)?rf.tasks.map((nf)=>String(nf?.id||"")).filter(Boolean):[];if(uf(`已创建 ${cf.length||Zf.length} 个任务${cf.length>0?`:${cf.join(" / ")}`:""}`),m(""),o(""),$f(!1),U.current=Rf,lf!==Af)i1(null);Gf(Af),n(Af),await gu(Rf,!0,Af)},"Codex 任务入队失败"),K.current=!1,Yf(!1)}async function PH(k){if(k.preventDefault(),!T?.id)return;await Tu(async()=>{await M0(r0(_,`/api/tasks/${encodeURIComponent(T.id)}/steer`),{method:"POST",body:{prompt:xf}}),tf(""),await gu(T.id)},"追加 prompt 失败")}async function CH(){if(!T?.id)return;await Tu(async()=>{await M0(r0(_,`/api/tasks/${encodeURIComponent(T.id)}/interrupt`),{method:"POST",body:{}}),await gu(T.id)},"打断 Codex session 失败")}async function RH(){if(!T?.id)return;await Tu(async()=>{await M0(r0(_,`/api/tasks/${encodeURIComponent(T.id)}/retry`),{method:"POST",body:{}}),await gu(T.id)},"重新入队失败")}async function xH(k){let a=String(T?.id||""),Af=String(k||"").trim();if(!a||!Af)return;let Zf=w4(T);if(Af===Zf){uf(`任务 ${a} 已在 queue=${Af}`);return}await Tu(async()=>{let Ef=(await M0(r0(_,`/api/tasks/${encodeURIComponent(a)}/move`),{method:"POST",body:{queueId:Af}}))?.task||{...T,queueId:Af};if(N.current.set(a,{...N.current.get(a)||{},task:Ef}),U.current=a,S(Ef),D(a),n(Af),!vu(lf))i1(null),Gf(Af);uf(`已将任务 ${a} 从 ${Zf} 移动到 ${Af}`),await gu(a,!0,vu(lf)?v_:Af)},"移动任务 queue 失败")}async function vH(){let k=U.current;if(!k)return;let a=performance.now();await Tu(async()=>{iu({phase:"session",taskId:k,queueMs:0,totalMs:0,partial:!0,startedAt:new Date}),await Ll(k,!0,a,0)},"刷新 Trace Summary 失败")}function bH(k){U.current=k,W.current+=1,D(k);let a=N.current.get(k);if(a?.task)S(a.task),Y(!1);else{Y(!0);let Af=Of.find((Zf)=>Zf.id===k);if(Af)S(Af);else S(null)}gu(k).catch((Af)=>X0(Tf(Af,"切换 Codex session 失败")))}function X2(k){if(bH(k),QX())Fu(!1)}P$(()=>{if(M.current){M.current=!1;return}Tu(()=>gu(U.current),"Codex Queue 加载失败")},[$?.id,lf]),P$(()=>{if(!$)return;let k=window.setInterval(()=>{gu(U.current,!1).catch((a)=>X0(Tf(a,"Codex Queue 轮询失败")))},1500);return()=>window.clearInterval(k)},[$?.id,lf]),P$(()=>{if(!$||!T||r)return;let k=String(T.id||"");if(!k)return;let a=String(T.updatedAt||""),Af=String(T._traceSummaryUpdatedAt||"");if(T._traceSummaryLoaded===!0&&Af===a)return;let Zf=`${k}:${a||"unknown"}`;if(E.current.has(Zf))return;E.current.add(Zf),Ll(k,!0).catch((wf)=>X0(Tf(wf,"自动加载 Trace Summary 失败")))},[$?.id,T?.id,T?.updatedAt,T?._traceSummaryUpdatedAt,T?._traceSummaryLoaded,r]);let hH=Of.length===0?B(ry,{title:"队列为空",text:"提交一个任务后,Codex 会串行执行并保存输出。"}):[Cf.length>0?B(Aj,{key:"unread",title:"已结束未读",tasks:Cf,selectedId:P,emptyText:"暂无已结束未读任务。",onSelect:X2,onCopy:y3,onReference:Yl,onMarkRead:Bl,copiedTaskId:vf,markingReadTaskId:Bf}):null,B(Aj,{key:"active",title:"运行 / 排队",tasks:_0,selectedId:P,emptyText:"当前没有运行或排队任务。",onSelect:X2,onCopy:y3,onReference:Yl,onMarkRead:Bl,copiedTaskId:vf,markingReadTaskId:Bf}),B(Aj,{key:"history",title:"历史 session",tasks:G0,selectedId:P,emptyText:"最近没有完成、失败或取消的 session。",onSelect:X2,onCopy:y3,onReference:Yl,onMarkRead:Bl,copiedTaskId:vf,markingReadTaskId:Bf}),B("div",{key:"pagination",className:"codex-task-pagination","data-testid":"codex-task-pagination"},B("span",null,`已加载 ${Of.length} / ${Number.isFinite(L1)?L1:Of.length}`),C6?B("button",{type:"button",className:"ghost-btn",disabled:s,onClick:()=>void c6(),"data-testid":"codex-load-more-tasks-button"},s?"加载中":"加载更早任务"):B("code",null,"已到队列末尾"))],GA=(k,a=!1)=>B("label",{className:`codex-queue-switcher ${a?"compact":""}`},B("span",null,a?"Queue":"查看 queue"),B("select",{value:lf,onChange:(Af)=>E2(String(Af.target.value||v_)),"data-testid":k},B("option",{value:v_},`All queues · ${Number.isFinite(L1)?L1:Of.length} tasks · ${u3.length} running`),Qu.map((Af)=>B("option",{key:String(Af?.id||""),value:String(Af?.id||"")},Uj(Af))))),IH=B("div",{className:"codex-trace-status","data-testid":"codex-trace-status-summary"},B("span",{className:"codex-trace-status-chip queued"},B("b",null,"排队"),String(z2)),B("span",{className:"codex-trace-status-chip running"},B("b",null,"运行"),String(x6)),B("span",{className:`codex-trace-status-chip unread ${k1>0?"warn":""}`},B("b",null,"结束未读"),String(k1))),cH=B(My,{title:T?`Trace ${String(T.id).slice(0,22)}`:"Trace 输出",eyebrow:T?`${T.status} / view=${jy} / task queue=${w4(T)} / ${T.model} / agent loop trace`:`Agent loop trace / view=${jy}`,summary:IH,actions:B("div",{className:"panel-actions"},GA("codex-queue-filter-select"),B("button",{type:"button",className:"ghost-btn codex-mark-all-read-btn",disabled:k1===0||O0||i0,onClick:()=>void wl(),"data-testid":"codex-mark-all-read-button"},i0?"标记中":`全部标已读${k1>0?` (${k1})`:""}`),T?B("button",{type:"button",className:"ghost-btn",disabled:r||O0,onClick:()=>void vH(),"data-testid":"codex-load-full-trace-button"},r?"加载中":Py(T)?"刷新 Summary":"加载 Summary"):null,B("button",{type:"button",className:"codex-session-title-toggle",onClick:()=>Fu((k)=>!k),"data-testid":"codex-queue-sidebar-toggle"},D0?"收起队列":"展开队列"),B("label",{className:"inline-check"},B("input",{type:"checkbox",checked:j0,onChange:(k)=>u0(Boolean(k.target.checked))}),"自动滚动"),B("button",{type:"button",className:"ghost-btn",disabled:!Z2||O0,onClick:()=>void CH(),"data-testid":"codex-interrupt-button"},"打断"),B("button",{type:"button",className:"ghost-btn",disabled:!q2||O0,onClick:()=>void RH()},"重试"),T?B(Bz,{title:"Codex Task",data:T,onOpen:u,testId:"raw-codex-task"}):null),className:"codex-output-panel"},B("div",{className:`codex-session-shell ${D0?"":"queue-collapsed"}`},D0?B("aside",{className:"codex-session-sidebar","data-testid":"codex-session-sidebar"},B("div",{className:"codex-session-sidebar-head"},B("div",null,B("span",null,vu(lf)?"All queues":"Queue lane"),B("strong",null,`${jy} · ${Of.length}/${Number.isFinite(L1)?L1:Of.length} sessions · 未读 ${k1}`)),B("button",{type:"button",className:"ghost-btn",onClick:()=>Fu(!1)},"收起")),GA("codex-queue-filter-sidebar",!0),B("div",{className:"codex-task-list codex-task-list-session",onScroll:p6,"data-testid":"codex-task-list-scroll"},hH)):null,B("div",{className:"codex-session-main"},B("div",{className:"codex-output-stack"},B(tX,{task:T,loading:r,onLoadPromptPart:I6,onLoadSteps:H2,onLoadStep:V2}),B(oX,{task:T})))));if(!$)return B(ry,{title:"Codex Queue 未登记",text:"请在 config.json 的 microservices 中登记用户服务 id=codex-queue"});let KA=Number(ef?.totalMs),ZA=Number(ef?.queueMs),qA=Number(ef?.detailMs),HA=Number(ef?.transcriptRows),pH=ef?.phase==="complete"?"complete":String(ef?.phase||"idle");return B("div",{className:`codex-queue-page ${l?"codex-standalone-page":""}`,"data-testid":"codex-queue-page","data-load-state":pH,"data-load-total-ms":Number.isFinite(KA)?String(Math.round(KA*10)/10):"","data-load-queue-ms":Number.isFinite(ZA)?String(Math.round(ZA*10)/10):"","data-load-detail-ms":Number.isFinite(qA)?String(Math.round(qA*10)/10):"","data-load-transcript-rows":Number.isFinite(HA)?String(HA):"","data-load-task-id":String(ef?.taskId||P||""),"data-load-partial":ef?.partial?"true":"false"},B(H0,{error:ku,wide:!0}),Au?B("div",{className:"form-success wide","data-testid":"codex-create-success"},Au):null,B("div",{className:"codex-session-stage codex-session-stage-top"},cH),B("div",{className:"codex-queue-layout"},B("div",{className:"codex-left-rail"},B(My,{title:"提交任务",eyebrow:Qf?"Submitting...":j1.length>1?`${j1.length} tasks`:"Single or Batch",className:"codex-compose-panel"},B("form",{className:`codex-task-form ${Qf?"is-submitting":""}`,onSubmit:m6,"data-testid":"codex-queue-task-form","aria-busy":Qf?"true":"false"},B("label",null,"Prompt / 多任务用单独一行 --- 分隔",B("textarea",{value:v,rows:8,disabled:Qf,onChange:(k)=>m(k.target.value),placeholder:"写入 Codex 任务;多个任务之间用 --- 分隔。"})),B("label",{className:"codex-reference-field"},"引用任务 ID(可选)",B("input",{value:c,disabled:Qf,onChange:(k)=>o(k.target.value),placeholder:"codex_...;支持空格/逗号分隔多个 ID","data-testid":"codex-reference-task-id"}),Y4(c).length>0?B("code",null,`后端将解析并注入:${Y4(c).join(" / ")}`):null),B("div",{className:"codex-form-grid"},B("label",{className:"codex-submit-queue-field"},"Queue",B("div",{className:"codex-submit-queue-row"},B("select",{value:ff,disabled:Qf,onChange:(k)=>n(String(k.target.value||"default")),"data-testid":"codex-queue-id-select"},Qu.map((k)=>B("option",{key:String(k?.id||""),value:String(k?.id||"")},Uj(k)))),B("button",{type:"button",className:"ghost-btn codex-create-queue-btn",onClick:()=>void O2(),disabled:O0||Qf,"data-testid":"codex-create-queue-button"},"创建 queue"))),B("label",null,"模型",B("select",{value:zf,disabled:Qf,onChange:(k)=>jf(k.target.value),"data-testid":"codex-model-select"},b6.map((k)=>B("option",{key:k,value:k},k)))),B("label",null,"工作目录",B("input",{value:Wf,disabled:Qf,onChange:(k)=>Vf(k.target.value),placeholder:hf?.defaultWorkdir||"/root/unidesk"})),B("label",null,"最大尝试",B("input",{type:"number",min:1,max:99,value:Kf,disabled:Qf,onChange:(k)=>h(Number(k.target.value)),"data-testid":"codex-max-attempts-input"})),B("label",null,"入队份数",B("input",{type:"number",min:1,max:50,value:g,disabled:Qf,onChange:(k)=>I(Number(k.target.value)),"data-testid":"codex-repeat-count-input"}))),Jy>1?B("label",{className:`codex-batch-confirm ${yf?"confirmed":""}`,"data-testid":"codex-batch-confirm-row"},B("input",{type:"checkbox",checked:yf,disabled:Qf,onChange:(k)=>$f(Boolean(k.target.checked)),"data-testid":"codex-batch-confirm-checkbox"}),B("span",null,`确认批量入队 ${Jy} 个任务(prompt 分段 ${El.length} × 入队份数 ${Mz(g)})`)):null,Qf?B("div",{className:"codex-submit-wait","data-testid":"codex-submit-wait"},"正在提交到后端,已锁定输入以防重复提交..."):null,B("div",{className:"codex-form-actions"},B("button",{type:"button",className:"ghost-btn",disabled:O0||Qf||v.length===0&&c.length===0,onClick:()=>{m(""),o(""),$f(!1),uf("已清空任务输入栏")},"data-testid":"codex-clear-input-button"},"清空输入"),B("button",{type:"submit",className:"primary-btn",disabled:K2,"data-testid":"codex-enqueue-button"},Qf?"提交中,请等待...":Ol?`请确认批量入队 ${Jy} 个任务`:j1.length>1?`批量入队 ${j1.length} 个任务`:"入队并运行"))))),B("div",{className:"codex-main-stage"},B("div",{className:"codex-detail-grid"},B(My,{title:"Prompt 全量",eyebrow:T?String(T.id):"selected task",className:"codex-prompt-panel"},B(sX,{task:T,loading:r,onLoadPromptPart:I6})),B(My,{title:"运行控制",eyebrow:Xl?"Active turn steer":"Steer when running"},B("div",{className:"codex-run-control-stack"},B(mX,{task:T,queueRows:Qu,busy:O0,onMove:xH}),B("form",{className:"codex-steer-form",onSubmit:PH},B("label",null,"追加 prompt",B("textarea",{value:xf,rows:4,onChange:(k)=>tf(k.target.value),placeholder:"给正在运行的 Codex session 推入新的指令或纠偏。",disabled:!Xl})),B("button",{type:"submit",className:"primary-btn",disabled:!Xl||O0||xf.trim().length===0,"data-testid":"codex-steer-button"},"推入运行中 session")))),B(My,{title:"完成判定",eyebrow:T?.lastJudge?T.lastJudge.source:"judge"},T?.lastJudge?B("div",{className:"codex-judge-card","data-testid":"codex-task-judge-card"},B(y_,{status:T.lastJudge.decision},T.lastJudge.decision),B("strong",null,`${Math.round(Number(T.lastJudge.confidence||0)*100)}% confidence`),B("p",{"data-testid":"codex-task-judge-reason"},T.lastJudge.reason||"--"),T.lastJudge.continuePrompt?B("code",{"data-testid":"codex-task-judge-continue-prompt"},Rz(T.lastJudge.continuePrompt,220)):null):B(ry,{title:"尚未判定",text:"Codex turn 结束后会由 MiniMax M2.7 或 fallback judge 判定 complete/retry/fail;retry 会在已有 thread 追加继续执行 prompt。"}))),B(My,{title:"Attempts",eyebrow:"terminal vs interruption"},B(aX,{task:T})))),B(My,{title:"运行概要",eyebrow:"用户服务",actions:B("div",{className:"panel-actions"},B("button",{type:"button",className:"ghost-btn",onClick:()=>void Tu(()=>gu(P),"刷新失败"),disabled:O0,"data-testid":"codex-refresh-button"},O0?"同步中":"刷新"),B(Bz,{title:"Codex Queue 用户服务",data:$,onOpen:u,testId:"raw-codex-queue-service"}))},B("div",{className:"codex-queue-hero"},B("div",null,B("div",{className:"node-version-line"},B(y_,{status:v6.providerStatus==="online"?"online":"warn"},v6.providerStatus||"unknown"),B("span",null,$.providerId),B("span",null,Vl.public?"公网暴露":"仅 UniDesk frontend 代理访问"),B("span",null,hf?.judgeConfigured?`MiniMax ${hf?.minimaxModel||"M2.7"}`:"Fallback judge")),B("p",{className:"muted paragraph"},$.description)),B("div",{className:"microservice-ref-card"},B("span",null,"Queue view"),B("strong",null,jy),B("code",null,`${Of.length}/${Number.isFinite(L1)?L1:Of.length} loaded / ${u3.length} active lanes`),B("code",null,`models: ${b6.join(" / ")}`)),B("div",{className:"microservice-ref-card"},B("span",null,"Backend"),B("strong",null,`${Vl.nodeBindHost||"--"}:${Vl.nodePort||"--"}`),B("code",null,G2.containerName||"codex-queue-backend")))),B("div",{className:"codex-queue-metrics"},B(Ty,{label:"Queues",value:String(hf?.queueCount??Qu.length??1),hint:`${Number(_3.length||0)} active lanes`,tone:_3.length>1?"warn":""}),B(Ty,{label:"排队",value:Fj(W_,"queued"),hint:"waiting turns"}),B(Ty,{label:"运行",value:Fj(W_,"running"),hint:_3.length>1?`${_3.length} parallel`:$y?`active ${String($y).slice(0,16)}`:"idle",tone:$y?"warn":"ok"}),B(Ty,{label:"成功",value:Fj(W_,"succeeded"),hint:"completed tasks",tone:"ok"}),B(Ty,{label:"异常/取消",value:String(Number(W_.failed||0)+Number(W_.canceled||0)),hint:"terminal non-success",tone:Number(W_.failed||0)>0?"fail":""}),B(Ty,{label:"加载耗时",value:lj(ef?.totalMs),hint:ef?.phase==="complete"?`queue ${lj(ef?.queueMs)} / session ${lj(ef?.detailMs)} / ${ef?.chunks??0} chunks${ef?.partial?" / preview":""}`:`${ef?.phase||"idle"}...`,tone:Number(ef?.totalMs||0)>1000?"warn":"ok"}),B(Ty,{label:"最近刷新",value:ey?lX(ey):"--",hint:"1.5s polling"})))}var P4=Sf(I0(),1);var Jf=P4.default.createElement,{useEffect:dX}=P4.default,eX=P4.default.useState;function fN(f){if(!f)return"--";let u=new Date(f);if(Number.isNaN(u.getTime()))return"--";return u.toLocaleString("zh-CN",{hour12:!1})}function uN(f){return f.toLocaleTimeString("zh-CN",{hour12:!1})}function M4({status:f,children:u}){let _=String(f||"unknown").toLowerCase();return Jf("span",{className:`status-badge ${_}`},u||f||"unknown")}function h_({label:f,value:u,hint:_,tone:y}){return Jf("article",{className:`metric-card ${y||""}`},Jf("div",{className:"metric-label"},f),Jf("div",{className:"metric-value"},u),Jf("div",{className:"metric-hint"},_))}function r4({title:f,eyebrow:u,actions:_,children:y,className:l}){return Jf("section",{className:`panel ${l||""}`},Jf("div",{className:"panel-head"},Jf("div",null,u?Jf("p",{className:"panel-eyebrow"},u):null,Jf("h2",null,f)),_?Jf("div",{className:"panel-actions"},_):null),Jf("div",{className:"panel-body"},y))}function S4({title:f,data:u,onOpen:_,testId:y}){return Jf("button",{type:"button",className:"ghost-btn","data-testid":y,onClick:()=>_(f,u)},"查看原始JSON")}function Hj({title:f,text:u}){return Jf("div",{className:"empty-state"},Jf("strong",null,f),Jf("span",null,u))}function _N(f){return f?.runtime&&typeof f.runtime==="object"&&!Array.isArray(f.runtime)?f.runtime:{}}function yN(f){return f?.backend&&typeof f.backend==="object"&&!Array.isArray(f.backend)?f.backend:{}}function lN(f){return f?.repository&&typeof f.repository==="object"&&!Array.isArray(f.repository)?f.repository:{}}function Cy(f,u){let _=f&&typeof f==="object"?f[u]:void 0;return Number.isFinite(Number(_))?String(_):"--"}function $N(f){return(Array.isArray(f?.jobs)?f.jobs:[]).slice(0,40)}function jN(f){return(Array.isArray(f?.drafts)?f.drafts:[]).slice(0,12)}function sz({microservices:f,onRaw:u,apiBaseUrl:_="/api"}){let y=f.find((q)=>q.id==="findjob")||null,[l,$]=eX({loading:!1,error:"",health:null,summary:null,jobs:null,drafts:null,refreshedAt:null});async function j(){if(!y)return;$((q)=>({...q,loading:!0,error:""}));try{let[q,V,O,G]=await Promise.all([Df(`${_}/microservices/findjob/health`),Df(`${_}/microservices/findjob/proxy/api/summary`),Df(`${_}/microservices/findjob/proxy/api/jobs?__unideskArrayLimit=jobs:40`),Df(`${_}/microservices/findjob/proxy/api/drafts`)]);$({loading:!1,error:"",health:q,summary:V,jobs:O,drafts:G,refreshedAt:new Date})}catch(q){$((V)=>({...V,loading:!1,error:Tf(q,"FindJob 加载失败")}))}}if(dX(()=>{j()},[y?.id,y?.runtime?.providerStatus]),!y)return Jf(Hj,{title:"FindJob 未登记",text:"请在 config.json 的 microservices 中登记用户服务 id=findjob"});let J=_N(y),F=lN(y),Q=yN(y),U=l.summary||{},z=$N(l.jobs),W=jN(l.drafts),K=l.jobs?._unidesk?.arrayLimits?.jobs;return Jf("div",{className:"findjob-page","data-testid":"findjob-page"},Jf(r4,{title:"FindJob 工作台",eyebrow:"D601 用户服务",actions:Jf("div",{className:"panel-actions"},Jf("button",{type:"button",className:"ghost-btn",onClick:j,disabled:l.loading,"data-testid":"findjob-refresh-button"},l.loading?"刷新中":"刷新"),Jf(S4,{title:"FindJob 用户服务",data:y,onOpen:u,testId:"raw-findjob-service"}))},Jf("div",{className:"findjob-hero"},Jf("div",null,Jf("div",{className:"node-version-line"},Jf(M4,{status:J.providerStatus==="online"?"online":"warn"},J.providerStatus||"unknown"),Jf("span",null,y.providerId),Jf("span",null,Q.public?"公网暴露":"仅 UniDesk frontend 代理访问")),Jf("p",{className:"muted paragraph"},y.description)),Jf("div",{className:"microservice-ref-card"},Jf("span",null,"Repo"),Jf("strong",null,F.url||"--"),Jf("code",null,F.commitId||"--")),Jf("div",{className:"microservice-ref-card"},Jf("span",null,"D601 Docker"),Jf("strong",null,`${Q.nodeBindHost||"--"}:${Q.nodePort||"--"}`),Jf("code",null,`${F.composeFile||"--"} / ${F.composeService||"--"}`))),Jf(H0,{error:l.error,wide:!0})),Jf("div",{className:"findjob-grid"},Jf(r4,{title:"岗位指标",eyebrow:l.refreshedAt?`Updated ${uN(l.refreshedAt)}`:"Summary"},Jf("div",{className:"metric-grid"},Jf(h_,{label:"岗位总量",value:Cy(U,"totalJobs"),hint:"tracked jobs",tone:"ok"}),Jf(h_,{label:"原始岗位",value:Cy(U,"rawJobs"),hint:"raw queue"}),Jf(h_,{label:"已验证",value:Cy(U,"verifiedJobs"),hint:"verified set"}),Jf(h_,{label:"优先处理",value:Cy(U,"prioritizedJobs"),hint:"prioritized"}),Jf(h_,{label:"过期",value:Cy(U,"staleJobs"),hint:"stale jobs",tone:"warn"}),Jf(h_,{label:"无效",value:Cy(U,"invalidJobs"),hint:"invalid jobs",tone:"warn"}),Jf(h_,{label:"上海",value:Cy(U,"shanghaiJobs"),hint:"city filter"}),Jf(h_,{label:"Health",value:l.health?.ok?"OK":"--",hint:"D601 /api/health"})),Jf("div",{className:"panel-actions inline-actions"},Jf(S4,{title:"FindJob Summary",data:U,onOpen:u,testId:"raw-findjob-summary"}))),Jf(r4,{title:"近期岗位",eyebrow:K?`${K.returnedLength}/${K.originalLength} Preview`:`${z.length} Preview`},z.length===0?Jf(Hj,{title:"暂无岗位预览",text:"等待 D601 findjob backend 返回 /api/jobs"}):Jf("div",{className:"table-wrap findjob-job-table"},Jf("table",null,Jf("thead",null,Jf("tr",null,Jf("th",null,"优先级"),Jf("th",null,"状态"),Jf("th",null,"单位"),Jf("th",null,"职位"),Jf("th",null,"城市"),Jf("th",null,"阶段"),Jf("th",null,"截止"),Jf("th",null,"证据"))),Jf("tbody",null,z.map((q)=>Jf("tr",{key:q.id},Jf("td",null,Jf(M4,{status:String(q.priority||"").toLowerCase()||"unknown"},q.priority||"--")),Jf("td",null,Jf(M4,{status:String(q.status||"").toLowerCase()||"unknown"},q.status||"--")),Jf("td",null,q.organization_name||"--",Jf("code",null,q.id||"--")),Jf("td",null,q.display_title||q.title||"--"),Jf("td",null,q.display_city||q.city||"--"),Jf("td",null,q.workflow_stage||"--"),Jf("td",null,q.deadline||"--"),Jf("td",null,q.evidence_url?Jf("a",{href:q.evidence_url,target:"_blank",rel:"noreferrer"},"打开"):Jf("span",{className:"muted"},"无"))))))),Jf("div",{className:"panel-actions inline-actions"},Jf(S4,{title:"FindJob Jobs Preview",data:l.jobs,onOpen:u,testId:"raw-findjob-jobs"}))),Jf(r4,{title:"草稿与报告",eyebrow:`${W.length} Drafts`},W.length===0?Jf(Hj,{title:"暂无草稿",text:"D601 findjob backend 未返回 drafts"}):Jf("div",{className:"draft-list"},W.map((q)=>Jf("article",{key:q.id,className:"draft-card"},Jf("div",{className:"node-card-head"},Jf("strong",null,q.id),Jf(M4,{status:q.status},q.status||"--")),Jf("div",{className:"docker-meta compact"},Jf("span",null,q.workflow_stage||"--"),Jf("span",null,`jobs ${q.counts?.jobs??0}`),Jf("span",null,`reports ${q.counts?.reports??0}`)),Jf("span",null,q.latestReportPath||"暂无报告"),Jf("code",null,fN(q.updated_at||q.updatedAt))))),Jf("div",{className:"panel-actions inline-actions"},Jf(S4,{title:"FindJob Drafts",data:l.drafts,onOpen:u,testId:"raw-findjob-drafts"})))))}var h$=Sf(I0(),1);var b=h$.default.createElement,{useEffect:JN}=h$.default,Vj=h$.default.useState;function FN(f){if(!f)return"--";let u=new Date(f);if(Number.isNaN(u.getTime()))return"--";return u.toLocaleString("zh-CN",{hour12:!1})}function AN(f){return f.toLocaleTimeString("zh-CN",{hour12:!1})}function x$(f){let u=Number(f);return Number.isFinite(u)?`${Math.max(0,Math.min(100,u)).toFixed(1)}%`:"--"}function Oj(f){if(f===null||f===void 0||f==="")return"--";let u=Number(f);if(!Number.isFinite(u))return"--";if(u<60)return`${Math.max(0,Math.round(u))}s`;if(u<3600)return`${Math.floor(u/60)}m ${Math.round(u%60)}s`;return`${Math.floor(u/3600)}h ${Math.floor(u%3600/60)}m`}function Xj(f,u=2){let _=Number(f);if(!Number.isFinite(_))return f===!1?"false":f===!0?"true":"--";let y=Math.abs(_);if(Number.isInteger(_)||y>=1000)return _.toLocaleString("zh-CN",{maximumFractionDigits:0});if(y>=1)return _.toLocaleString("zh-CN",{maximumFractionDigits:u});return _.toLocaleString("zh-CN",{maximumFractionDigits:Math.max(u,6)})}function b$(f){if(f===null||f===void 0||f==="")return"--";if(typeof f==="boolean")return f?"true":"false";if(typeof f==="number")return Xj(f,4);if(Array.isArray(f))return f.map((u)=>b$(u)).join(" x ");if(typeof f==="object")return"已上报";return String(f)}function C4(f){let u=Number(f);if(!Number.isFinite(u)||u<=0)return"--";let _=u>=100?0:u>=10?1:2;return`${u.toLocaleString("zh-CN",{maximumFractionDigits:_})} epoch/h`}function R4(f){return f.replace(/[^a-zA-Z0-9_-]/g,"-")}function Lu(f){return f&&typeof f==="object"&&!Array.isArray(f)?f:{}}function v$({status:f,children:u}){let _=String(f||"unknown").toLowerCase();return b("span",{className:`status-badge ${_}`},u||f||"unknown")}function I_({label:f,value:u,hint:_,tone:y}){return b("article",{className:`metric-card ${y||""}`},b("div",{className:"metric-label"},f),b("div",{className:"metric-value"},u),b("div",{className:"metric-hint"},_))}function Ej({title:f,eyebrow:u,actions:_,children:y,className:l}){return b("section",{className:`panel ${l||""}`},b("div",{className:"panel-head"},b("div",null,u?b("p",{className:"panel-eyebrow"},u):null,b("h2",null,f)),_?b("div",{className:"panel-actions"},_):null),b("div",{className:"panel-body"},y))}function c3({title:f,data:u,onOpen:_,testId:y}){return b("button",{type:"button",className:"ghost-btn","data-testid":y,onClick:(l)=>{l?.stopPropagation?.(),_(f,u)}},"查看原始JSON")}function l_({title:f,text:u}){return b("div",{className:"empty-state"},b("strong",null,f),b("span",null,u))}function QN(f){return f?.runtime&&typeof f.runtime==="object"&&!Array.isArray(f.runtime)?f.runtime:{}}function UN(f){return f?.backend&&typeof f.backend==="object"&&!Array.isArray(f.backend)?f.backend:{}}function WN(f){return f?.repository&&typeof f.repository==="object"&&!Array.isArray(f.repository)?f.repository:{}}function zN(f){return f?.counts&&typeof f.counts==="object"&&!Array.isArray(f.counts)?f.counts:{}}function GN(f){return Array.isArray(f?.jobs)?f.jobs.slice(0,240):[]}function KN(f){return Array.isArray(f?.projects)?f.projects.slice(0,1000):[]}function x4(f){return Array.isArray(f?.projects)?f.projects:[]}function ZN(f,u){if(Array.isArray(u?.gpu))return u.gpu;if(Array.isArray(f?.gpu))return f.gpu;return[]}function bu(f,u){return`${f}/microservices/met-nonlinear/proxy${u}`}function oz(f){return f.startedAt&&f.finishedAt?Oj((Date.parse(f.finishedAt)-Date.parse(f.startedAt))/1000):"--"}function qN(f){let u=f.progress||{};if(u.etaSeconds!==null&&u.etaSeconds!==void 0&&u.etaSeconds!==""){let j=Number(u.etaSeconds);if(Number.isFinite(j))return Math.max(0,j)}let _=Number(u.currentEpoch),y=Number(u.epochTarget??f.epochTarget),l=Date.parse(f.startedAt||"");if(!Number.isFinite(_)||_<=0||!Number.isFinite(y)||y<=_||!Number.isFinite(l))return null;let $=Math.max(0,(Date.now()-l)/1000);if($<=0)return null;return Math.max(0,$/_*(y-_))}function az(f){let u=f.progress||{},_=Number(u.epochPerHour);if(Number.isFinite(_)&&_>0)return _;let y=Date.parse(f.startedAt||""),l=["succeeded","failed","canceled"].includes(f.status)?Date.parse(f.finishedAt||""):Date.now();if(!Number.isFinite(y)||!Number.isFinite(l)||l<=y)return null;let $=Number(u.currentEpoch??f.epochTarget);if(!Number.isFinite($)||$<=0)return null;return $/((l-y)/3600000)}function dz(f){if(f==="staged")return"待启动";if(f==="queued")return"排队中";if(f==="running")return"训练中";if(f==="succeeded")return"已完成";if(f==="failed")return"失败";if(f==="canceled")return"已取消";return f||"unknown"}function ez(f,u,_){return{name:f,path:u,depth:_,count:0,children:[],project:null}}function HN(f){let u=ez("","",-1);for(let y of f){let $=String(y?.projectPath||"").replace(/\\/g,"/").split("/").filter(Boolean);if($.length===0)continue;let j=u,J=[];for(let[F,Q]of $.entries()){J.push(Q);let U=J.join("/"),z=j.children.find((W)=>W.path===U);if(!z)z=ez(Q,U,F),j.children.push(z);if(F===$.length-1)z.project=y;j=z}}let _=(y)=>{let l=y.children.reduce(($,j)=>$+_(j),0);return y.count=(y.project?1:0)+l,y.children.sort(($,j)=>{if(Boolean($.project)!==Boolean(j.project))return $.project?1:-1;return $.name.localeCompare(j.name,"zh-CN",{numeric:!0,sensitivity:"base"})}),y.count};return _(u),u}function VN(f){let u=Lu(f.data);return Lu(u.project).projectPath?Lu(u.project):u}function EN(f){return Lu(Lu(f.data).job)}function fG({microservices:f,onRaw:u,apiBaseUrl:_="/api"}){let y=f.find((h)=>h.id==="met-nonlinear")||null,[l,$]=Vj({loading:!1,actionBusy:!1,error:"",health:null,summary:null,queue:null,projects:null,history:null,images:null,refreshedAt:null}),[j,J]=Vj({loading:!1,error:"",kind:"",key:"",title:"",data:null}),[F,Q]=Vj(()=>({activeTab:"projects",selectedProjects:{},expandedProjectDirs:{},sourceProject:"",forkCount:1,forkEpochs:200,forkPrefix:`ui_fork_${Date.now()}`,maxConcurrency:3,targetGpuName:"2080 Ti",actionMessage:""}));function U(h){Q((g)=>({...g,...h}))}async function z(h=F.activeTab){if(!y)return;$((g)=>({...g,loading:!0,error:""}));try{let g=[["health",Df(`${_}/microservices/met-nonlinear/health`)],["summary",Df(bu(_,"/api/summary"))]];if(h==="projects")g.push(["projectsRoot",Df(bu(_,"/api/projects?root=projects&limit=500"))]),g.push(["exProjectsRoot",Df(bu(_,"/api/projects?root=ex_projects&limit=500"))]);if(h==="current"||h==="completed"||h==="failed")g.push(["queue",Df(bu(_,"/api/queue"))]);if(h==="completed"||h==="failed")g.push(["history",Df(bu(_,"/api/history"))]);if(h==="gpu")g.push(["images",Df(bu(_,"/api/images"))]);let I=Object.fromEntries(await Promise.all(g.map(async([$f,Qf])=>[$f,await Qf]))),yf={loading:!1,actionBusy:!1,error:"",health:I.health,summary:I.summary,refreshedAt:new Date};if(I.projectsRoot||I.exProjectsRoot){let{projectsRoot:$f,exProjectsRoot:Qf}=I;yf.projects={ok:$f?.ok!==!1&&Qf?.ok!==!1,roots:[{root:"projects",count:x4($f).length},{root:"ex_projects",count:x4(Qf).length}],projects:[...x4($f),...x4(Qf)]}}if(I.queue)yf.queue=I.queue;if(I.history)yf.history=I.history;if(I.images)yf.images=I.images;$(($f)=>({...$f,...yf}))}catch(g){$((I)=>({...I,loading:!1,actionBusy:!1,error:Tf(g,"MET Nonlinear 加载失败")}))}}async function W(h,g){$((I)=>({...I,actionBusy:!0,error:""})),U({actionMessage:`${h}...`});try{let I=await g();U({actionMessage:I||`${h}完成`}),await z()}catch(I){$((yf)=>({...yf,actionBusy:!1,error:Tf(I,`${h}失败`)}))}}async function K(){await W("保存并发设置",async()=>{await Df(bu(_,"/api/queue/settings"),{method:"PUT",body:JSON.stringify({maxConcurrency:Number(F.maxConcurrency),targetGpuName:F.targetGpuName})})})}function q(){return Object.entries(F.selectedProjects).filter(([,h])=>h).map(([h])=>h)}async function V(){let h=q();if(h.length===0)throw Error("请先选择至少一个 project");await W("加入待启动队列",async()=>{await Df(bu(_,"/api/queue"),{method:"POST",body:JSON.stringify({projectPaths:h,maxConcurrency:Number(F.maxConcurrency),targetGpuName:F.targetGpuName,start:!1})}),U({activeTab:"current",selectedProjects:{}})})}async function O(){let h=F.sourceProject||P[0]?.projectPath;if(!h)throw Error("请先选择源 project");await W("Fork Project",async()=>{let g=await Df(bu(_,"/api/projects/fork"),{method:"POST",body:JSON.stringify({sourceProject:h,count:Number(F.forkCount),epochs:Number(F.forkEpochs),prefix:F.forkPrefix})}),I=Array.isArray(g.projectPaths)?g.projectPaths:[],yf=I.reduce(($f,Qf)=>{return $f[Qf]=!0,$f},{...F.selectedProjects});return U({selectedProjects:yf}),`已 fork ${I.length} 个 project,并已自动勾选;请确认后点击加入待启动队列。`})}async function G(){await W("启动队列",async()=>{await Df(bu(_,"/api/queue/start"),{method:"POST",body:JSON.stringify({maxConcurrency:Number(F.maxConcurrency),targetGpuName:F.targetGpuName})}),U({activeTab:"current"})})}async function H(h){await W("取消任务",async()=>{await Df(bu(_,`/api/jobs/${encodeURIComponent(h.id)}/cancel`),{method:"POST",body:JSON.stringify({})})})}async function Z(h){let g=String(h?.projectPath||"");if(!g)return;J({loading:!0,error:"",kind:"project",key:g,title:g,data:null});try{let I=await Df(bu(_,`/api/projects/config?path=${encodeURIComponent(g)}`));J({loading:!1,error:"",kind:"project",key:g,title:g,data:I})}catch(I){J({loading:!1,error:Tf(I,"Project 详情加载失败"),kind:"project",key:g,title:g,data:null})}}async function E(h){let g=String(h?.id||"");if(!g)return;J({loading:!0,error:"",kind:"job",key:g,title:h.projectPath||g,data:null});try{let I=await Df(bu(_,`/api/jobs/${encodeURIComponent(g)}`));J({loading:!1,error:"",kind:"job",key:g,title:I?.job?.projectPath||h.projectPath||g,data:I})}catch(I){J({loading:!1,error:Tf(I,"Job 详情加载失败"),kind:"job",key:g,title:h.projectPath||g,data:null})}}if(JN(()=>{z(F.activeTab)},[y?.id,y?.runtime?.providerStatus,F.activeTab]),!y)return b(l_,{title:"MET Nonlinear 未登记",text:"请在 config.json 的 microservices 中登记用户服务 id=met-nonlinear"});let L=QN(y),M=WN(y),N=UN(y),w=zN(l.queue?.queue||l.summary?.queue),R=ZN(l.health,l.queue),p=l.health?.targetGpu||l.summary?.targetGpu||R.find((h)=>String(h.name||"").includes("2080")),x=l.images?.mlImage||l.health?.image||{},C=GN(l.queue),P=KN(l.projects),D=HN(P),T=F.sourceProject||P[0]?.projectPath||"",S=C.filter((h)=>["staged","queued","running"].includes(h.status)),r=C.filter((h)=>h.status==="succeeded"),Y=C.filter((h)=>["failed","canceled"].includes(h.status)),v=Array.isArray(l.history?.jobs)?l.history.jobs.slice(0,120):[],m=[{id:"projects",label:"项目库",count:P.length},{id:"current",label:"当前队列",count:S.length||Number(w.staged||0)+Number(w.queued||0)+Number(w.running||0)},{id:"completed",label:"已完成",count:r.length||Number(w.succeeded||0)},{id:"failed",label:"失败诊断",count:Y.length||Number(w.failed||0)+Number(w.canceled||0)},{id:"gpu",label:"GPU/镜像",count:R.length}];function c(h,g){if(h.length===0)return b(l_,{title:g==="current"?"当前队列为空":"暂无记录",text:g==="current"?"从项目库选择或 fork project 后先加入待启动队列,再启动队列。":"终态任务会显示耗时、exit code 和失败诊断。"});return b("div",{className:"table-wrap met-job-table"},b("table",null,b("thead",null,b("tr",null,b("th",null,"状态"),b("th",null,"Project"),b("th",null,"Epoch"),b("th",null,"速度"),b("th",null,"ETA/耗时"),b("th",null,"GPU"),b("th",null,"Exit"),b("th",null,"更新时间"),b("th",null,"操作"))),b("tbody",null,h.map((I)=>{let yf=I.progress||{},$f=["staged","queued","running"].includes(I.status),Qf=j.kind==="job"&&j.key===I.id;return b("tr",{key:I.id,className:`met-click-row ${Qf?"active":""}`,onClick:()=>E(I),"data-testid":`met-job-row-${R4(I.id)}`},b("td",null,b(v$,{status:I.status},dz(I.status))),b("td",null,b("button",{type:"button",className:"met-inline-link",onClick:(Yf)=>{Yf.stopPropagation(),E(I)}},I.projectPath),b("code",null,I.id)),b("td",null,b("span",null,`${yf.currentEpoch??"--"} / ${yf.epochTarget??I.epochTarget??"--"}`),b("div",{className:"met-progress"},b("span",{style:{width:x$(yf.progressPercent)}}))),b("td",null,b("strong",null,C4(az(I)))),b("td",null,I.status==="succeeded"||I.status==="failed"||I.status==="canceled"?oz(I):I.status==="running"?`ETA ${Oj(qN(I))}`:"--"),b("td",null,I.gpuName||"--"),b("td",null,I.exitCode??"--"),b("td",null,FN(I.updatedAt)),b("td",null,$f?b("button",{type:"button",className:"ghost-btn mini",onClick:(Yf)=>{Yf.stopPropagation(),H(I)},disabled:l.actionBusy},"取消"):null,b(c3,{title:`MET Job ${I.id}`,data:I,onOpen:u,testId:`raw-met-job-${I.id}`})))}))))}function o(){return b("div",{className:"met-queue-summary","data-testid":"met-current-summary"},b(v$,{status:"staged"},`待启动 ${w.staged??0}`),b(v$,{status:"queued"},`排队中 ${w.queued??0}`),b(v$,{status:"running"},`训练中 ${w.running??0}`),b("span",null,`最大并发 ${l.summary?.queue?.maxConcurrency??l.queue?.queue?.maxConcurrency??F.maxConcurrency}`),b("span",null,`目标 GPU ${l.summary?.queue?.targetGpuName??l.queue?.queue?.targetGpuName??F.targetGpuName}`))}function ff(h,g){let I=F.expandedProjectDirs[h];return I===void 0?g<2:Boolean(I)}function n(h,g){let I=ff(h,g);U({expandedProjectDirs:{...F.expandedProjectDirs,[h]:!I}})}function lf(h){let g=8+Math.max(0,h.depth)*16;if(Boolean(h.project)){let $f=h.project,Qf=Boolean(F.selectedProjects[$f.projectPath]),Yf=j.kind==="project"&&j.key===$f.projectPath;return b("div",{key:h.path,className:`met-tree-row project ${Qf?"selected":""} ${Yf?"active":""}`,style:{paddingLeft:g},onClick:()=>Z($f),"data-testid":`met-project-node-${R4($f.projectPath)}`},b("div",{className:"met-tree-name"},b("input",{type:"checkbox",checked:Qf,onClick:(xf)=>xf.stopPropagation(),onChange:(xf)=>U({selectedProjects:{...F.selectedProjects,[$f.projectPath]:xf.target.checked}}),"data-testid":`met-project-checkbox-${R4($f.projectPath)}`}),b("button",{type:"button",className:"met-inline-link project-path",onClick:(xf)=>{xf.stopPropagation(),Z($f)}},h.name)),b("span",null,$f.useModel||"--"),b("span",null,$f.epochTrain??"--"),b("span",null,x$($f.progress?.progressPercent)),b("span",null,C4($f.progress?.epochPerHour)))}let yf=ff(h.path,h.depth);return b(h$.default.Fragment,{key:h.path},b("div",{className:"met-tree-row folder",style:{paddingLeft:g},"data-testid":`met-project-folder-${R4(h.path)}`},b("button",{type:"button",className:"met-tree-toggle",onClick:()=>n(h.path,h.depth),"aria-label":yf?`折叠 ${h.path}`:`展开 ${h.path}`},yf?"-":"+"),b("strong",null,h.name),b("span",{className:"met-tree-count"},`${h.count} projects`)),yf?h.children.map(($f)=>lf($f)):null)}function Gf(h){return b("div",{className:"met-detail-kv"},h.map((g)=>b("div",{key:g.label,className:"met-detail-kv-item"},b("span",null,g.label),b("strong",null,b$(g.value)),g.hint?b("small",null,g.hint):null)))}function zf(h,g){return b("div",{className:"met-detail-section"},b("h3",null,h),Gf(g))}function jf(h){if(!Array.isArray(h)||h.length===0)return b(l_,{title:"模型层未上报",text:"等待 data/model_info.json 或 compute_analysis.json 生成。"});return b("div",{className:"table-wrap met-layer-table"},b("table",null,b("thead",null,b("tr",null,b("th",null,"Layer"),b("th",null,"Type"),b("th",null,"Params"),b("th",null,"Trainable"),b("th",null,"Compute"))),b("tbody",null,h.slice(0,18).map((g,I)=>b("tr",{key:`${g.name||"layer"}-${I}`},b("td",null,g.name||`#${I+1}`),b("td",null,g.type||"--"),b("td",null,Xj(g.num_params)),b("td",null,g.trainable===void 0?"--":String(Boolean(g.trainable))),b("td",null,Xj(g.compute?.total??g.estimated_cost?.weighted_units?.total)))))))}function Wf(h){let g=Array.isArray(h)?h:[];if(g.length===0)return b(l_,{title:"data/ 暂无文件",text:"训练或评估完成后会生成 training_state、metrics、model_info 等文件。"});return b("div",{className:"met-file-chip-grid"},g.slice(0,48).map((I)=>b("span",{key:I},I)),g.length>48?b("span",null,`+${g.length-48}`):null)}function Vf(h){let g=String(h||"").replace(/\x1b\[[0-9;]*[A-Za-z]/g,"").split(/\r?\n/).map((I)=>I.trim()).filter(Boolean).slice(-12);if(g.length===0)return b(l_,{title:"暂无日志尾部",text:"该任务未上报 logTail 或日志已轮转。"});return b("div",{className:"met-log-lines"},g.map((I,yf)=>b("div",{key:`${yf}-${I.slice(0,16)}`},I)))}function Kf(){if(j.loading)return b("section",{className:"met-detail-panel","data-testid":"met-detail-panel"},b(l_,{title:"详情加载中",text:j.title||"正在读取 D601 data/ 和 config.json"}));if(j.error)return b("section",{className:"met-detail-panel","data-testid":"met-detail-panel"},b(H0,{error:j.error,wide:!0}));if(!j.data)return b("section",{className:"met-detail-panel muted","data-testid":"met-detail-panel"},b(l_,{title:"选择一个项目或任务查看详情",text:"项目库、当前队列、已完成和失败诊断中的行都可以点击;默认只展示结构化字段,原始 JSON 需显式点击按钮。"}));let h=VN(j),g=EN(j),I=Lu(h.config),yf=Lu(h.progress||g.progress),$f=Lu(h.data),Qf=Lu(h.metrics||$f.metrics||yf.trainingInfo?.evaluation_metrics),Yf=Lu($f.trainingInfo||yf.trainingInfo),xf=Lu($f.trainingState),tf=Lu(h.model||$f.model),j0=Array.isArray(tf.modelSummary)&&tf.modelSummary.length>0?tf.modelSummary:tf.computeLayers,u0=Lu(Yf.evaluation_metrics),D0=j.kind==="job"?"训练任务详情":"Project 详情";return b("section",{className:"met-detail-panel","data-testid":"met-detail-panel"},b("div",{className:"panel-head compact"},b("div",null,b("p",{className:"panel-eyebrow"},j.kind==="job"?"Job + Project Detail":"Project Library Detail"),b("h2",null,D0),b("code",null,h.projectPath||g.projectPath||j.title)),b("div",{className:"panel-actions"},b(c3,{title:`MET ${D0}`,data:j.data,onOpen:u,testId:"raw-met-detail"}))),j.kind==="job"?zf("任务状态",[{label:"Job ID",value:g.id},{label:"状态",value:dz(g.status)},{label:"GPU",value:g.gpuName},{label:"Exit Code",value:g.exitCode},{label:"耗时",value:oz(g)},{label:"训练速度",value:C4(az({...g,progress:yf}))}]):null,zf("config.json",[{label:"use_model",value:I.use_model},{label:"epoch_train",value:I.epoch_train},{label:"step_per_epoch",value:I.step_per_epoch},{label:"learning_rate",value:I.learning_rate},{label:"using_gpu",value:I.using_gpu},{label:"use_points",value:I.use_points},{label:"sample_rate",value:I.sample_rate},{label:"time_clipped_s",value:I.time_clipped_s},{label:"H_UNITS",value:I.H_UNITS},{label:"INNER_KAN_UNITS",value:I.INNER_KAN_UNITS},{label:"INNER_KAN_LAYERS",value:I.INNER_KAN_LAYERS},{label:"GRID_SIZE",value:I.GRID_SIZE},{label:"SPLINE_ORDER",value:I.SPLINE_ORDER},{label:"USE_FAST_MODEL",value:I.USE_FAST_MODEL},{label:"IIR_TRAINABLE",value:I.IIR_TRAINABLE}]),zf("data/ 训练状态",[{label:"Epoch",value:`${yf.currentEpoch??xf.current_epoch??xf.completed_epoch??"--"} / ${yf.epochTarget??I.epoch_train??"--"}`},{label:"Progress",value:x$(yf.progressPercent)},{label:"Last Loss",value:yf.lastLoss??xf.loss},{label:"Last Val Loss",value:yf.lastValLoss??xf.val_loss},{label:"Min Loss",value:Yf.min_loss??xf.min_loss},{label:"Min Val Loss",value:Yf.min_val_loss??xf.min_val_loss},{label:"Log Lines",value:yf.logLineCount},{label:"ETA",value:Oj(yf.etaSeconds??xf.remaining_time)},{label:"训练速度",value:C4(yf.epochPerHour??xf.smoothed_speed)},{label:"Training Alive",value:xf.training_alive}]),zf("模型参数",[{label:"Model Type",value:tf.modelType??I.use_model},{label:"Total Params",value:tf.totalParams,hint:tf.totalParams===null||tf.totalParams===void 0?"未上报":"data/model_info.json"},{label:"Trainable",value:tf.trainableParams},{label:"Non-trainable",value:tf.nonTrainableParams},{label:"Compute Cost",value:tf.computeCost},{label:"Estimate Status",value:tf.estimateStatus},{label:"Unsupported Layers",value:tf.unsupportedLayerCount}]),zf("指标",[{label:"train_loss",value:Qf.train_loss??u0.train_loss},{label:"val_loss",value:Qf.val_loss??u0.val_loss},{label:"train_mae",value:Qf.train_mae??u0.train_mae},{label:"val_mae",value:Qf.val_mae??u0.val_mae},{label:"train_afmae",value:Qf.train_afmae??u0.train_afmae},{label:"val_afmae",value:Qf.val_afmae??u0.val_afmae},{label:"freq_drift_hz",value:Qf.freq_drift_hz},{label:"sens_drift_percent",value:Qf.sens_drift_percent},{label:"linearity_percent",value:Qf.linearity_percent},{label:"weights_source",value:Qf.weights_source??u0.weights_source},{label:"lr min/mean/max",value:`${b$(Yf.learning_rate_min)} / ${b$(Yf.learning_rate_mean)} / ${b$(Yf.learning_rate_max)}`}]),b("div",{className:"met-detail-section"},b("h3",null,"模型层"),jf(j0)),b("div",{className:"met-detail-section"},b("h3",null,"data/ 文件"),Wf($f.files)),j.kind==="job"?b("div",{className:"met-detail-section"},b("h3",null,"日志尾部"),Vf(Lu(j.data).logTail)):null)}return b("div",{className:"met-page","data-testid":"met-nonlinear-page"},b(Ej,{title:"MET Nonlinear 训练编排",eyebrow:"D601 GPU 用户服务",actions:b("div",{className:"panel-actions"},b("button",{type:"button",className:"ghost-btn",onClick:z,disabled:l.loading,"data-testid":"met-refresh-button"},l.loading?"刷新中":"刷新"),b(c3,{title:"MET Nonlinear 用户服务",data:y,onOpen:u,testId:"raw-met-service"}))},b("div",{className:"findjob-hero"},b("div",null,b("div",{className:"node-version-line"},b(v$,{status:L.providerStatus==="online"?"online":"warn"},L.providerStatus||"unknown"),b("span",null,y.providerId),b("span",null,N.public?"公网暴露":"仅 UniDesk frontend 代理访问")),b("p",{className:"muted paragraph"},y.description)),b("div",{className:"microservice-ref-card"},b("span",null,"Repo"),b("strong",null,M.url||"--"),b("code",null,M.commitId||"--")),b("div",{className:"microservice-ref-card"},b("span",null,"D601 Docker"),b("strong",null,`${N.nodeBindHost||"--"}:${N.nodePort||"--"}`),b("code",null,`${M.composeFile||"--"} / ${M.containerName||"--"}`))),b(H0,{error:l.error,wide:!0}),F.actionMessage?b("div",{className:"met-action-log","data-testid":"met-action-message"},F.actionMessage):null),b("div",{className:"met-grid"},b(Ej,{title:"核心状态",eyebrow:l.refreshedAt?`Updated ${AN(l.refreshedAt)}`:"Queue + GPU"},b("div",{className:"metric-grid"},b(I_,{label:"Staged",value:w.staged??0,hint:"加入队列未开始",tone:Number(w.staged||0)>0?"warn":""}),b(I_,{label:"Queued",value:w.queued??0,hint:"排队等待调度",tone:Number(w.queued||0)>0?"warn":""}),b(I_,{label:"Running",value:w.running??0,hint:`max ${l.summary?.queue?.maxConcurrency??l.queue?.queue?.maxConcurrency??"--"}`,tone:Number(w.running||0)>0?"ok":""}),b(I_,{label:"Succeeded",value:w.succeeded??0,hint:"已完成"}),b(I_,{label:"Failed",value:w.failed??0,hint:"需要诊断",tone:Number(w.failed||0)>0?"warn":""}),b(I_,{label:"2080Ti Free",value:p?x$(Number(p.freeRatio)*100):"--",hint:p?`${p.memoryFreeMiB}/${p.memoryTotalMiB} MiB`:"等待 GPU 上报"}),b(I_,{label:"ML Image",value:x.present?"READY":"MISSING",hint:x.image||"met-nonlinear-ml:tf26",tone:x.present?"ok":"warn"}),b(I_,{label:"Health",value:l.health?.ok?"OK":"--",hint:"D601 /health"}))),b(Ej,{title:"队列控制",eyebrow:"Downloader-like staging"},b("div",{className:"met-control-strip"},b("label",null,"最大并发",b("input",{type:"number",min:1,max:16,value:F.maxConcurrency,"data-testid":"met-max-concurrency-input",onChange:(h)=>U({maxConcurrency:h.target.value})})),b("label",null,"目标 GPU",b("input",{value:F.targetGpuName,"data-testid":"met-target-gpu-input",onChange:(h)=>U({targetGpuName:h.target.value})})),b("button",{type:"button",className:"ghost-btn",onClick:K,disabled:l.actionBusy,"data-testid":"met-save-settings-button"},"保存设置"),b("button",{type:"button",className:"primary-btn",onClick:G,disabled:l.actionBusy||Number(w.staged||0)===0,"data-testid":"met-start-queue-button"},"启动队列")),b("p",{className:"muted paragraph"},"Project 先进入待启动队列,不会立即训练;点击启动队列后才切换为排队中,并由 D601 scheduler 按最大并发和 2080Ti 显存策略调度。")),b("section",{className:"panel met-workspace"},b("div",{className:"met-tabs",role:"tablist"},m.map((h)=>b("button",{key:h.id,type:"button",className:F.activeTab===h.id?"active":"",onClick:()=>U({activeTab:h.id}),"data-testid":`met-tab-${h.id}`},`${h.label} ${h.count}`))),b("div",{className:"panel-body"},F.activeTab==="projects"?b("div",{className:"met-form-grid","data-testid":"met-projects-pane"},b("div",{className:"met-fork-card"},b("h3",null,"Fork Project"),b("label",null,"源 Project",b("select",{value:T,"data-testid":"met-source-project-select",onChange:(h)=>U({sourceProject:h.target.value})},P.map((h)=>b("option",{key:h.projectPath,value:h.projectPath},`${h.projectPath} · ${h.useModel||"model?"}`)))),b("label",null,"Fork 数量",b("input",{type:"number",min:1,max:100,value:F.forkCount,"data-testid":"met-fork-count-input",onChange:(h)=>U({forkCount:h.target.value})})),b("label",null,"训练轮数",b("input",{type:"number",min:1,max:1e5,value:F.forkEpochs,"data-testid":"met-fork-epochs-input",onChange:(h)=>U({forkEpochs:h.target.value})})),b("label",null,"目标前缀",b("input",{value:F.forkPrefix,"data-testid":"met-fork-prefix-input",onChange:(h)=>U({forkPrefix:h.target.value})})),b("button",{type:"button",className:"primary-btn",onClick:O,disabled:l.actionBusy||!T,"data-testid":"met-fork-button"},"Fork Project"),b("p",{className:"muted paragraph"},"Fork 只创建新 Project 并自动勾选,不会直接训练;需要在右侧确认后加入待启动队列。")),b("div",{className:"met-project-list"},b("div",{className:"panel-head compact"},b("div",null,b("p",{className:"panel-eyebrow"},`Existing Projects · ${(l.projects?.roots||[]).map((h)=>`${h.root} ${h.count}`).join(" / ")}`),b("h2",null,"选择已有 Project")),b("button",{type:"button",className:"ghost-btn",onClick:V,disabled:l.actionBusy||q().length===0,"data-testid":"met-stage-selected-button"},`加入待启动队列 (${q().length})`)),P.length===0?b(l_,{title:"暂无 project",text:"等待 D601 返回 /api/projects"}):b("div",{className:"met-project-table","data-testid":"met-project-tree"},b("div",{className:"met-tree-header"},b("span",null,"文件树 Project"),b("span",null,"Model"),b("span",null,"Epochs"),b("span",null,"Progress"),b("span",null,"速度")),D.children.map((h)=>lf(h)))),Kf()):null,F.activeTab==="current"?b("div",{"data-testid":"met-current-pane"},o(),c(S,"current"),Kf(),b("div",{className:"panel-actions inline-actions"},b(c3,{title:"MET Queue",data:l.queue,onOpen:u,testId:"raw-met-queue"}))):null,F.activeTab==="completed"?b("div",{"data-testid":"met-completed-pane"},c(r.length>0?r:v.filter((h)=>h.status==="succeeded"),"completed"),Kf()):null,F.activeTab==="failed"?b("div",{"data-testid":"met-failed-pane"},c(Y.length>0?Y:v.filter((h)=>["failed","canceled"].includes(h.status)),"failed"),Kf(),b("div",{className:"panel-actions inline-actions"},b(c3,{title:"MET History",data:l.history,onOpen:u,testId:"raw-met-history"}))):null,F.activeTab==="gpu"?b("div",{className:"met-gpu-pane","data-testid":"met-gpu-pane"},R.length===0?b(l_,{title:"暂无 GPU 上报",text:"等待 D601 met-nonlinear-ts 或 ML image 提供 nvidia-smi 数据"}):b("div",{className:"table-wrap"},b("table",null,b("thead",null,b("tr",null,b("th",null,"Index"),b("th",null,"Name"),b("th",null,"Free"),b("th",null,"Policy"))),b("tbody",null,R.map((h)=>b("tr",{key:h.index},b("td",null,h.index),b("td",null,h.name),b("td",null,`${h.memoryFreeMiB} / ${h.memoryTotalMiB} MiB`,b("div",{className:"met-progress"},b("span",{style:{width:x$(Number(h.freeRatio)*100)}}))),b("td",null,String(h.name||"").includes("2080")?"target 2080Ti, <20% 限制并发":"non-target")))))),b("div",{className:"panel-actions inline-actions"},b(c3,{title:"MET Images",data:l.images,onOpen:u,testId:"raw-met-images"}))):null))))}var b4=[{id:"ops",label:"运行总览",code:"OPS",tabs:[{id:"status",label:"态势总览"},{id:"performance",label:"性能面板"},{id:"events",label:"事件摘要"},{id:"logs",label:"服务日志"}]},{id:"nodes",label:"资源节点",code:"NODE",tabs:[{id:"list",label:"节点清单"},{id:"monitor",label:"资源监控"},{id:"docker",label:"Docker 状态"},{id:"gateway",label:"网关版本"},{id:"labels",label:"资源标签"},{id:"heartbeats",label:"心跳状态"}]},{id:"tasks",label:"任务调度",code:"TASK",tabs:[{id:"dispatch",label:"下发任务"},{id:"pending",label:"待处理任务"},{id:"history",label:"任务历史"},{id:"results",label:"执行结果"}]},{id:"apps",label:"用户服务",code:"APP",routeSegment:"app",tabs:[{id:"catalog",label:"服务目录"},{id:"todo-note",label:"Todo Note"},{id:"findjob",label:"FindJob"},{id:"pipeline",label:"Pipeline"},{id:"met-nonlinear",label:"MET Nonlinear"},{id:"claudeqq",label:"ClaudeQQ"},{id:"codex-queue",label:"Codex Queue"},{id:"project-manager",label:"Project Manager"}]},{id:"config",label:"系统配置",code:"CFG",tabs:[{id:"topology",label:"连接拓扑"},{id:"auth",label:"认证策略"},{id:"security",label:"安全边界"}]}],p3=Object.fromEntries(b4.map((f)=>[f.id,f.tabs[0]?.id??""]));function ON(f){let u=String(f||"").trim();if(!u)return"";try{return decodeURIComponent(u)}catch{return u}}function v4(f){let u=String(f||"/"),[_]=u.split(/[?#]/u,1);if(_==="/")return"/";let l=`/${_.split("/").map(ON).filter(Boolean).join("/")}`;return l.endsWith("/")?l:`${l}/`}function XN(f){let u=2166136261;for(let _ of f)u^=_.charCodeAt(0),u=Math.imul(u,16777619);return Math.abs(u>>>0).toString(36)}function Nj(f){return String(f||"").normalize("NFKD").replace(/[\u0300-\u036f]/gu,"").toLowerCase().replace(/[^a-z0-9]+/gu,"-").replace(/^-+|-+$/gu,"")}function uG(f){return String(f||"").trim().toLowerCase().replace(/[\s/\\?#%]+/gu,"-").replace(/-+/gu,"-").replace(/^-+|-+$/gu,"")}function _G(f){let u=Nj(f.routeSegment||"")||uG(f.routeSegment||"");if(u)return u;let _=Nj(f.id||"");if(_)return _;let y=Nj(f.label||"")||uG(f.label||"");if(y)return y;return`route-${XN(JSON.stringify(f))}`}function Lj(f,u){return`${f}:${u}`}function yG(f){let u=f.map((F)=>{let Q=_G(F);return{...F,routeSegment:Q,tabs:F.tabs.map((U)=>({...U,routeSegment:_G(U)}))}}),_={},y={},l={},$=u.map((F)=>{let Q=F.tabs[0]?.id??"";l[F.id]=Q;let U=F.tabs.map((K)=>{let q=`/${F.routeSegment}/${K.routeSegment}/`,V=[q],O={moduleId:F.id,tabId:K.id};for(let G of V)_[v4(G)]=O;return y[Lj(F.id,K.id)]=q,{...K,canonicalPath:q,aliases:V}}),z=`/${F.routeSegment}/`,W={moduleId:F.id,tabId:Q};return _[v4(z)]=W,{...F,routeSegment:F.routeSegment,canonicalPath:z,tabs:U}}),j=$[0],J={moduleId:j?.id||"",tabId:j?.tabs[0]?.id||""};return _["/"]=J,{modules:$,moduleById:Object.fromEntries($.map((F)=>[F.id,F])),defaultActiveTabs:l,routeMap:_,canonicalPathByTarget:y,fallbackTarget:J}}function Yj(f,u){return f.routeMap[v4(u)]||f.fallbackTarget}function I$(f,u,_){return f.canonicalPathByTarget[Lj(u,_)]||f.canonicalPathByTarget[Lj(f.fallbackTarget.moduleId,f.fallbackTarget.tabId)]||"/"}function lG(f,u){let _=f.routeMap[v4(u)];if(!_)return null;return I$(f,_.moduleId,_.tabId)}var fy=Sf(I0(),1);var t=Sf(FG(),1),d=Sf(I0(),1);function Y0(f){if(typeof f==="string"||typeof f==="number")return""+f;let u="";if(Array.isArray(f)){for(let _=0,y;_{}};function QG(){for(var f=0,u=arguments.length,_={},y;f=0)y=_.slice(l+1),_=_.slice(0,l);if(_&&!u.hasOwnProperty(_))throw Error("unknown type: "+_);return{type:_,name:y}})}I4.prototype=QG.prototype={constructor:I4,on:function(f,u){var _=this._,y=MN(f+"",_),l,$=-1,j=y.length;if(arguments.length<2){while(++$0)for(var _=Array(l),y=0,l,$;y=0&&(u=f.slice(0,_))!=="xmlns")f=f.slice(_+1);return Bj.hasOwnProperty(u)?{space:Bj[u],local:f}:f}function wj(f){let u;while(u=f.sourceEvent)f=u;return f}function $u(f,u){if(f=wj(f),u===void 0)u=f.currentTarget;if(u){var _=u.ownerSVGElement||u;if(_.createSVGPoint){var y=_.createSVGPoint();return y.x=f.clientX,y.y=f.clientY,y=y.matrixTransform(u.getScreenCTM().inverse()),[y.x,y.y]}if(u.getBoundingClientRect){var l=u.getBoundingClientRect();return[f.clientX-l.left-u.clientLeft,f.clientY-l.top-u.clientTop]}}return[f.pageX,f.pageY]}function SN(){}function c_(f){return f==null?SN:function(){return this.querySelector(f)}}function Dj(f){if(typeof f!=="function")f=c_(f);for(var u=this._groups,_=u.length,y=Array(_),l=0;l<_;++l)for(var $=u[l],j=$.length,J=y[l]=Array(j),F,Q,U=0;U=Z)Z=H+1;while(!(L=O[Z])&&++Z=0;)if(j=y[l]){if($&&j.compareDocumentPosition($)^4)$.parentNode.insertBefore(j,$);$=j}return this}function cj(f){if(!f)f=iN;function u(z,W){return z&&W?f(z.__data__,W.__data__):!z-!W}for(var _=this._groups,y=_.length,l=Array(y),$=0;$u?1:f>=u?0:NaN}function pj(){var f=arguments[0];return arguments[0]=this,f.apply(null,arguments),this}function mj(){return Array.from(this)}function kj(){for(var f=this._groups,u=0,_=f.length;u<_;++u)for(var y=f[u],l=0,$=y.length;l<$;++l){var j=y[l];if(j)return j}return null}function ij(){let f=0;for(let u of this)++f;return f}function gj(){return!this.node()}function nj(f){for(var u=this._groups,_=0,y=u.length;_1?this.each((u==null?dN:typeof u==="function"?fL:eN)(f,u,_==null?"":_)):p_(this.node(),f)}function p_(f,u){return f.style.getPropertyValue(u)||m$(f).getComputedStyle(f,null).getPropertyValue(u)}function uL(f){return function(){delete this[f]}}function _L(f,u){return function(){this[f]=u}}function yL(f,u){return function(){var _=u.apply(this,arguments);if(_==null)delete this[f];else this[f]=_}}function oj(f,u){return arguments.length>1?this.each((u==null?uL:typeof u==="function"?yL:_L)(f,u)):this.node()[f]}function UG(f){return f.trim().split(/^|\s+/)}function aj(f){return f.classList||new WG(f)}function WG(f){this._node=f,this._names=UG(f.getAttribute("class")||"")}WG.prototype={add:function(f){var u=this._names.indexOf(f);if(u<0)this._names.push(f),this._node.setAttribute("class",this._names.join(" "))},remove:function(f){var u=this._names.indexOf(f);if(u>=0)this._names.splice(u,1),this._node.setAttribute("class",this._names.join(" "))},contains:function(f){return this._names.indexOf(f)>=0}};function zG(f,u){var _=aj(f),y=-1,l=u.length;while(++y=0)_=u.slice(y+1),u=u.slice(0,y);return{type:u,name:_}})}function NL(f){return function(){var u=this.__on;if(!u)return;for(var _=0,y=-1,l=u.length,$;_()=>f;function n$(f,{sourceEvent:u,subject:_,target:y,identifier:l,active:$,x:j,y:J,dx:F,dy:Q,dispatch:U}){Object.defineProperties(this,{type:{value:f,enumerable:!0,configurable:!0},sourceEvent:{value:u,enumerable:!0,configurable:!0},subject:{value:_,enumerable:!0,configurable:!0},target:{value:y,enumerable:!0,configurable:!0},identifier:{value:l,enumerable:!0,configurable:!0},active:{value:$,enumerable:!0,configurable:!0},x:{value:j,enumerable:!0,configurable:!0},y:{value:J,enumerable:!0,configurable:!0},dx:{value:F,enumerable:!0,configurable:!0},dy:{value:Q,enumerable:!0,configurable:!0},_:{value:U}})}n$.prototype.on=function(){var f=this._.on.apply(this._,arguments);return f===this._?this:f};function RL(f){return!f.ctrlKey&&!f.button}function xL(){return this.parentNode}function vL(f,u){return u==null?{x:f.x,y:f.y}:u}function bL(){return navigator.maxTouchPoints||"ontouchstart"in this}function t$(){var f=RL,u=xL,_=vL,y=bL,l={},$=Ry("start","drag","end"),j=0,J,F,Q,U,z=0;function W(E){E.on("mousedown.drag",K).filter(y).on("touchstart.drag",O).on("touchmove.drag",G,qG).on("touchend.drag touchcancel.drag",H).style("touch-action","none").style("-webkit-tap-highlight-color","rgba(0,0,0,0)")}function K(E,L){if(U||!f.call(this,E,L))return;var M=Z(this,u.call(this,E,L),E,L,"mouse");if(!M)return;C0(E.view).on("mousemove.drag",q,xy).on("mouseup.drag",V,xy),i3(E.view),m4(E),Q=!1,J=E.clientX,F=E.clientY,M("start",E)}function q(E){if(j_(E),!Q){var L=E.clientX-J,M=E.clientY-F;Q=L*L+M*M>z}l.mouse("drag",E)}function V(E){C0(E.view).on("mousemove.drag mouseup.drag",null),i$(E.view,Q),j_(E),l.mouse("end",E)}function O(E,L){if(!f.call(this,E,L))return;var M=E.changedTouches,N=u.call(this,E,L),w=M.length,R,p;for(R=0;R>8&15|u>>4&240,u>>4&15|u&240,(u&15)<<4|u&15,1):_===8?k4(u>>24&255,u>>16&255,u>>8&255,(u&255)/255):_===4?k4(u>>12&15|u>>8&240,u>>8&15|u>>4&240,u>>4&15|u&240,((u&15)<<4|u&15)/255):null):(u=IL.exec(f))?new Yu(u[1],u[2],u[3],1):(u=cL.exec(f))?new Yu(u[1]*255/100,u[2]*255/100,u[3]*255/100,1):(u=pL.exec(f))?k4(u[1],u[2],u[3],u[4]):(u=mL.exec(f))?k4(u[1]*255/100,u[2]*255/100,u[3]*255/100,u[4]):(u=kL.exec(f))?LG(u[1],u[2]/100,u[3]/100,1):(u=iL.exec(f))?LG(u[1],u[2]/100,u[3]/100,u[4]):HG.hasOwnProperty(f)?OG(HG[f]):f==="transparent"?new Yu(NaN,NaN,NaN,0):null}function OG(f){return new Yu(f>>16&255,f>>8&255,f&255,1)}function k4(f,u,_,y){if(y<=0)f=u=_=NaN;return new Yu(f,u,_,y)}function tL(f){if(!(f instanceof d$))f=V1(f);if(!f)return new Yu;return f=f.rgb(),new Yu(f.r,f.g,f.b,f.opacity)}function n3(f,u,_,y){return arguments.length===1?tL(f):new Yu(f,u,_,y==null?1:y)}function Yu(f,u,_,y){this.r=+f,this.g=+u,this.b=+_,this.opacity=+y}s$(Yu,n3,WJ(d$,{brighter(f){return f=f==null?g4:Math.pow(g4,f),new Yu(this.r*f,this.g*f,this.b*f,this.opacity)},darker(f){return f=f==null?o$:Math.pow(o$,f),new Yu(this.r*f,this.g*f,this.b*f,this.opacity)},rgb(){return this},clamp(){return new Yu(by(this.r),by(this.g),by(this.b),n4(this.opacity))},displayable(){return-0.5<=this.r&&this.r<255.5&&(-0.5<=this.g&&this.g<255.5)&&(-0.5<=this.b&&this.b<255.5)&&(0<=this.opacity&&this.opacity<=1)},hex:XG,formatHex:XG,formatHex8:sL,formatRgb:NG,toString:NG}));function XG(){return`#${vy(this.r)}${vy(this.g)}${vy(this.b)}`}function sL(){return`#${vy(this.r)}${vy(this.g)}${vy(this.b)}${vy((isNaN(this.opacity)?1:this.opacity)*255)}`}function NG(){let f=n4(this.opacity);return`${f===1?"rgb(":"rgba("}${by(this.r)}, ${by(this.g)}, ${by(this.b)}${f===1?")":`, ${f})`}`}function n4(f){return isNaN(f)?1:Math.max(0,Math.min(1,f))}function by(f){return Math.max(0,Math.min(255,Math.round(f)||0))}function vy(f){return f=by(f),(f<16?"0":"")+f.toString(16)}function LG(f,u,_,y){if(y<=0)f=u=_=NaN;else if(_<=0||_>=1)f=u=NaN;else if(u<=0)f=NaN;return new H1(f,u,_,y)}function BG(f){if(f instanceof H1)return new H1(f.h,f.s,f.l,f.opacity);if(!(f instanceof d$))f=V1(f);if(!f)return new H1;if(f instanceof H1)return f;f=f.rgb();var u=f.r/255,_=f.g/255,y=f.b/255,l=Math.min(u,_,y),$=Math.max(u,_,y),j=NaN,J=$-l,F=($+l)/2;if(J){if(u===$)j=(_-y)/J+(_0&&F<1?0:j;return new H1(j,J,F,f.opacity)}function wG(f,u,_,y){return arguments.length===1?BG(f):new H1(f,u,_,y==null?1:y)}function H1(f,u,_,y){this.h=+f,this.s=+u,this.l=+_,this.opacity=+y}s$(H1,wG,WJ(d$,{brighter(f){return f=f==null?g4:Math.pow(g4,f),new H1(this.h,this.s,this.l*f,this.opacity)},darker(f){return f=f==null?o$:Math.pow(o$,f),new H1(this.h,this.s,this.l*f,this.opacity)},rgb(){var f=this.h%360+(this.h<0)*360,u=isNaN(f)||isNaN(this.s)?0:this.s,_=this.l,y=_+(_<0.5?_:1-_)*u,l=2*_-y;return new Yu(zJ(f>=240?f-240:f+120,l,y),zJ(f,l,y),zJ(f<120?f+240:f-120,l,y),this.opacity)},clamp(){return new H1(YG(this.h),i4(this.s),i4(this.l),n4(this.opacity))},displayable(){return(0<=this.s&&this.s<=1||isNaN(this.s))&&(0<=this.l&&this.l<=1)&&(0<=this.opacity&&this.opacity<=1)},formatHsl(){let f=n4(this.opacity);return`${f===1?"hsl(":"hsla("}${YG(this.h)}, ${i4(this.s)*100}%, ${i4(this.l)*100}%${f===1?")":`, ${f})`}`}}));function YG(f){return f=(f||0)%360,f<0?f+360:f}function i4(f){return Math.max(0,Math.min(1,f||0))}function zJ(f,u,_){return(f<60?u+(_-u)*f/60:f<180?_:f<240?u+(_-u)*(240-f)/60:u)*255}function GJ(f,u,_,y,l){var $=f*f,j=$*f;return((1-3*f+3*$-j)*u+(4-6*$+3*j)*_+(1+3*f+3*$-3*j)*y+j*l)/6}function KJ(f){var u=f.length-1;return function(_){var y=_<=0?_=0:_>=1?(_=1,u-1):Math.floor(_*u),l=f[y],$=f[y+1],j=y>0?f[y-1]:2*l-$,J=y()=>f;function aL(f,u){return function(_){return f+_*u}}function dL(f,u,_){return f=Math.pow(f,_),u=Math.pow(u,_)-f,_=1/_,function(y){return Math.pow(f+y*u,_)}}function DG(f){return(f=+f)===1?s4:function(u,_){return _-u?dL(u,_,f):e$(isNaN(u)?_:u)}}function s4(f,u){var _=u-f;return _?aL(f,_):e$(isNaN(f)?u:f)}var hy=function f(u){var _=DG(u);function y(l,$){var j=_((l=n3(l)).r,($=n3($)).r),J=_(l.g,$.g),F=_(l.b,$.b),Q=s4(l.opacity,$.opacity);return function(U){return l.r=j(U),l.g=J(U),l.b=F(U),l.opacity=Q(U),l+""}}return y.gamma=f,y}(1);function TG(f){return function(u){var _=u.length,y=Array(_),l=Array(_),$=Array(_),j,J;for(j=0;j<_;++j)J=n3(u[j]),y[j]=J.r||0,l[j]=J.g||0,$[j]=J.b||0;return y=f(y),l=f(l),$=f($),J.opacity=1,function(F){return J.r=y(F),J.g=l(F),J.b=$(F),J+""}}}var eL=TG(KJ),fY=TG(ZJ);function qJ(f,u){if(!u)u=[];var _=f?Math.min(u.length,f.length):0,y=u.slice(),l;return function($){for(l=0;l<_;++l)y[l]=f[l]*(1-$)+u[l]*$;return y}}function MG(f){return ArrayBuffer.isView(f)&&!(f instanceof DataView)}function rG(f,u){var _=u?u.length:0,y=f?Math.min(_,f.length):0,l=Array(y),$=Array(_),j;for(j=0;j_)if($=u.slice(_,$),J[j])J[j]+=$;else J[++j]=$;if((y=y[0])===(l=l[0]))if(J[j])J[j]+=l;else J[++j]=l;else J[++j]=null,F.push({i:j,x:ju(y,l)});_=EJ.lastIndex}if(_180)U+=360;else if(U-Q>180)Q+=360;W.push({i:z.push(l(z)+"rotate(",null,y)-2,x:ju(Q,U)})}else if(U)z.push(l(z)+"rotate("+U+y)}function J(Q,U,z,W){if(Q!==U)W.push({i:z.push(l(z)+"skewX(",null,y)-2,x:ju(Q,U)});else if(U)z.push(l(z)+"skewX("+U+y)}function F(Q,U,z,W,K,q){if(Q!==z||U!==W){var V=K.push(l(K)+"scale(",null,",",null,")");q.push({i:V-4,x:ju(Q,z)},{i:V-2,x:ju(U,W)})}else if(z!==1||W!==1)K.push(l(K)+"scale("+z+","+W+")")}return function(Q,U){var z=[],W=[];return Q=f(Q),U=f(U),$(Q.translateX,Q.translateY,U.translateX,U.translateY,z,W),j(Q.rotate,U.rotate,z,W),J(Q.skewX,U.skewX,z,W),F(Q.scaleX,Q.scaleY,U.scaleX,U.scaleY,z,W),Q=U=null,function(K){var q=-1,V=W.length,O;while(++q=0)f._call.call(void 0,u);f=f._next}--s3}function IG(){cy=(f5=y6.now())+u5,s3=u6=0;try{mG()}finally{s3=0,EY(),cy=0}}function VY(){var f=y6.now(),u=f-f5;if(u>cG)u5-=u,f5=f}function EY(){var f,u=e4,_,y=1/0;while(u)if(u._call){if(y>u._time)y=u._time;f=u,u=u._next}else _=u._next,u._next=null,u=f?f._next=_:e4=_;_6=f,LJ(y)}function LJ(f){if(s3)return;if(u6)u6=clearTimeout(u6);var u=f-cy;if(u>24){if(f<1/0)u6=setTimeout(IG,f-y6.now()-u5);if(f6)f6=clearInterval(f6)}else{if(!f6)f5=y6.now(),f6=setInterval(VY,cG);s3=1,pG(IG)}}function j6(f,u,_){var y=new l6;return u=u==null?0:+u,y.restart((l)=>{y.stop(),f(l+u)},u,_),y}var XY=Ry("start","end","cancel","interrupt"),NY=[],gG=0,kG=1,l5=2,y5=3,iG=4,$5=5,J6=6;function J_(f,u,_,y,l,$){var j=f.__transition;if(!j)f.__transition={};else if(_ in j)return;LY(f,_,{name:u,index:y,group:l,on:XY,tween:NY,time:$.time,delay:$.delay,duration:$.duration,ease:$.ease,timer:null,state:gG})}function F6(f,u){var _=R0(f,u);if(_.state>gG)throw Error("too late; already scheduled");return _}function o0(f,u){var _=R0(f,u);if(_.state>y5)throw Error("too late; already running");return _}function R0(f,u){var _=f.__transition;if(!_||!(_=_[u]))throw Error("transition not found");return _}function LY(f,u,_){var y=f.__transition,l;y[u]=_,_.timer=_5($,0,_.time);function $(Q){if(_.state=kG,_.timer.restart(j,_.delay,_.time),_.delay<=Q)j(Q-_.delay)}function j(Q){var U,z,W,K;if(_.state!==kG)return F();for(U in y){if(K=y[U],K.name!==_.name)continue;if(K.state===y5)return j6(j);if(K.state===iG)K.state=J6,K.timer.stop(),K.on.call("interrupt",f,f.__data__,K.index,K.group),delete y[U];else if(+Ul5&&y.state<$5,y.state=J6,y.timer.stop(),y.on.call(l?"interrupt":"cancel",f,f.__data__,y.index,y.group),delete _[j]}if($)delete f.__transition}function YJ(f){return this.each(function(){m_(this,f)})}function YY(f,u){var _,y;return function(){var l=o0(this,f),$=l.tween;if($!==_){y=_=$;for(var j=0,J=y.length;j=0)u=u.slice(0,_);return!u||u==="start"})}function kY(f,u,_){var y,l,$=mY(u)?F6:o0;return function(){var j=$(this,f),J=j.on;if(J!==y)(l=(y=J).copy()).on(u,_);j.on=l}}function RJ(f,u){var _=this._id;return arguments.length<2?R0(this.node(),_).on.on(f):this.each(kY(_,f,u))}function iY(f){return function(){var u=this.parentNode;for(var _ in this.__transition)if(+_!==f)return;if(u)u.removeChild(this)}}function xJ(){return this.on("end.remove",iY(this._id))}function vJ(f){var u=this._name,_=this._id;if(typeof f!=="function")f=c_(f);for(var y=this._groups,l=y.length,$=Array(l),j=0;j()=>f;function nJ(f,{sourceEvent:u,target:_,transform:y,dispatch:l}){Object.defineProperties(this,{type:{value:f,enumerable:!0,configurable:!0},sourceEvent:{value:u,enumerable:!0,configurable:!0},target:{value:_,enumerable:!0,configurable:!0},transform:{value:y,enumerable:!0,configurable:!0},_:{value:l}})}function E1(f,u,_){this.k=f,this.x=u,this.y=_}E1.prototype={constructor:E1,scale:function(f){return f===1?this:new E1(this.k*f,this.x,this.y)},translate:function(f,u){return f===0&u===0?this:new E1(this.k,this.x+this.k*f,this.y+this.k*u)},apply:function(f){return[f[0]*this.k+this.x,f[1]*this.k+this.y]},applyX:function(f){return f*this.k+this.x},applyY:function(f){return f*this.k+this.y},invert:function(f){return[(f[0]-this.x)/this.k,(f[1]-this.y)/this.k]},invertX:function(f){return(f-this.x)/this.k},invertY:function(f){return(f-this.y)/this.k},rescaleX:function(f){return f.copy().domain(f.range().map(this.invertX,this).map(f.invert,f))},rescaleY:function(f){return f.copy().domain(f.range().map(this.invertY,this).map(f.invert,f))},toString:function(){return"translate("+this.x+","+this.y+") scale("+this.k+")"}};var py=new E1(1,0,0);U6.prototype=E1.prototype;function U6(f){while(!f.__zoom)if(!(f=f.parentNode))return py;return f.__zoom}function Z5(f){f.stopImmediatePropagation()}function my(f){f.preventDefault(),f.stopImmediatePropagation()}function JB(f){return(!f.ctrlKey||f.type==="wheel")&&!f.button}function FB(){var f=this;if(f instanceof SVGElement){if(f=f.ownerSVGElement||f,f.hasAttribute("viewBox"))return f=f.viewBox.baseVal,[[f.x,f.y],[f.x+f.width,f.y+f.height]];return[[0,0],[f.width.baseVal.value,f.height.baseVal.value]]}return[[0,0],[f.clientWidth,f.clientHeight]]}function sG(){return this.__zoom||py}function AB(f){return-f.deltaY*(f.deltaMode===1?0.05:f.deltaMode?1:0.002)*(f.ctrlKey?10:1)}function QB(){return navigator.maxTouchPoints||"ontouchstart"in this}function UB(f,u,_){var y=f.invertX(u[0][0])-_[0][0],l=f.invertX(u[1][0])-_[1][0],$=f.invertY(u[0][1])-_[0][1],j=f.invertY(u[1][1])-_[1][1];return f.translate(l>y?(y+l)/2:Math.min(0,y)||Math.max(0,l),j>$?($+j)/2:Math.min(0,$)||Math.max(0,j))}function W6(){var f=JB,u=FB,_=UB,y=AB,l=QB,$=[0,1/0],j=[[-1/0,-1/0],[1/0,1/0]],J=250,F=Iy,Q=Ry("start","zoom","end"),U,z,W,K=500,q=150,V=0,O=10;function G(D){D.property("__zoom",sG).on("wheel.zoom",w,{passive:!1}).on("mousedown.zoom",R).on("dblclick.zoom",p).filter(l).on("touchstart.zoom",x).on("touchmove.zoom",C).on("touchend.zoom touchcancel.zoom",P).style("-webkit-tap-highlight-color","rgba(0,0,0,0)")}G.transform=function(D,T,S,r){var Y=D.selection?D.selection():D;if(Y.property("__zoom",sG),D!==Y)L(D,T,S,r);else Y.interrupt().each(function(){M(this,arguments).event(r).start().zoom(null,typeof T==="function"?T.apply(this,arguments):T).end()})},G.scaleBy=function(D,T,S,r){G.scaleTo(D,function(){var Y=this.__zoom.k,v=typeof T==="function"?T.apply(this,arguments):T;return Y*v},S,r)},G.scaleTo=function(D,T,S,r){G.transform(D,function(){var Y=u.apply(this,arguments),v=this.__zoom,m=S==null?E(Y):typeof S==="function"?S.apply(this,arguments):S,c=v.invert(m),o=typeof T==="function"?T.apply(this,arguments):T;return _(Z(H(v,o),m,c),Y,j)},S,r)},G.translateBy=function(D,T,S,r){G.transform(D,function(){return _(this.__zoom.translate(typeof T==="function"?T.apply(this,arguments):T,typeof S==="function"?S.apply(this,arguments):S),u.apply(this,arguments),j)},null,r)},G.translateTo=function(D,T,S,r,Y){G.transform(D,function(){var v=u.apply(this,arguments),m=this.__zoom,c=r==null?E(v):typeof r==="function"?r.apply(this,arguments):r;return _(py.translate(c[0],c[1]).scale(m.k).translate(typeof T==="function"?-T.apply(this,arguments):-T,typeof S==="function"?-S.apply(this,arguments):-S),v,j)},r,Y)};function H(D,T){return T=Math.max($[0],Math.min($[1],T)),T===D.k?D:new E1(T,D.x,D.y)}function Z(D,T,S){var r=T[0]-S[0]*D.k,Y=T[1]-S[1]*D.k;return r===D.x&&Y===D.y?D:new E1(D.k,r,Y)}function E(D){return[(+D[0][0]+ +D[1][0])/2,(+D[0][1]+ +D[1][1])/2]}function L(D,T,S,r){D.on("start.zoom",function(){M(this,arguments).event(r).start()}).on("interrupt.zoom end.zoom",function(){M(this,arguments).event(r).end()}).tween("zoom",function(){var Y=this,v=arguments,m=M(Y,v).event(r),c=u.apply(Y,v),o=S==null?E(c):typeof S==="function"?S.apply(Y,v):S,ff=Math.max(c[1][0]-c[0][0],c[1][1]-c[0][1]),n=Y.__zoom,lf=typeof T==="function"?T.apply(Y,v):T,Gf=F(n.invert(o).concat(ff/n.k),lf.invert(o).concat(ff/lf.k));return function(zf){if(zf===1)zf=lf;else{var jf=Gf(zf),Wf=ff/jf[2];zf=new E1(Wf,o[0]-jf[0]*Wf,o[1]-jf[1]*Wf)}m.zoom(null,zf)}})}function M(D,T,S){return!S&&D.__zooming||new N(D,T)}function N(D,T){this.that=D,this.args=T,this.active=0,this.sourceEvent=null,this.extent=u.apply(D,T),this.taps=0}N.prototype={event:function(D){if(D)this.sourceEvent=D;return this},start:function(){if(++this.active===1)this.that.__zooming=this,this.emit("start");return this},zoom:function(D,T){if(this.mouse&&D!=="mouse")this.mouse[1]=T.invert(this.mouse[0]);if(this.touch0&&D!=="touch")this.touch0[1]=T.invert(this.touch0[0]);if(this.touch1&&D!=="touch")this.touch1[1]=T.invert(this.touch1[0]);return this.that.__zoom=T,this.emit("zoom"),this},end:function(){if(--this.active===0)delete this.that.__zooming,this.emit("end");return this},emit:function(D){var T=C0(this.that).datum();Q.call(D,this.that,new nJ(D,{sourceEvent:this.sourceEvent,target:G,type:D,transform:this.that.__zoom,dispatch:Q}),T)}};function w(D,...T){if(!f.apply(this,arguments))return;var S=M(this,T).event(D),r=this.__zoom,Y=Math.max($[0],Math.min($[1],r.k*Math.pow(2,y.apply(this,arguments)))),v=$u(D);if(S.wheel){if(S.mouse[0][0]!==v[0]||S.mouse[0][1]!==v[1])S.mouse[1]=r.invert(S.mouse[0]=v);clearTimeout(S.wheel)}else if(r.k===Y)return;else S.mouse=[v,r.invert(v)],m_(this),S.start();my(D),S.wheel=setTimeout(m,q),S.zoom("mouse",_(Z(H(r,Y),S.mouse[0],S.mouse[1]),S.extent,j));function m(){S.wheel=null,S.end()}}function R(D,...T){if(W||!f.apply(this,arguments))return;var S=D.currentTarget,r=M(this,T,!0).event(D),Y=C0(D.view).on("mousemove.zoom",o,!0).on("mouseup.zoom",ff,!0),v=$u(D,S),m=D.clientX,c=D.clientY;i3(D.view),Z5(D),r.mouse=[v,this.__zoom.invert(v)],m_(this),r.start();function o(n){if(my(n),!r.moved){var lf=n.clientX-m,Gf=n.clientY-c;r.moved=lf*lf+Gf*Gf>V}r.event(n).zoom("mouse",_(Z(r.that.__zoom,r.mouse[0]=$u(n,S),r.mouse[1]),r.extent,j))}function ff(n){Y.on("mousemove.zoom mouseup.zoom",null),i$(n.view,r.moved),my(n),r.event(n).end()}}function p(D,...T){if(!f.apply(this,arguments))return;var S=this.__zoom,r=$u(D.changedTouches?D.changedTouches[0]:D,this),Y=S.invert(r),v=S.k*(D.shiftKey?0.5:2),m=_(Z(H(S,v),r,Y),u.apply(this,T),j);if(my(D),J>0)C0(this).transition().duration(J).call(L,m,r,D);else C0(this).call(G.transform,m,r,D)}function x(D,...T){if(!f.apply(this,arguments))return;var S=D.touches,r=S.length,Y=M(this,T,D.changedTouches.length===r).event(D),v,m,c,o;Z5(D);for(m=0;m"[React Flow]: Seems like you have not used zustand provider as an ancestor. Help: https://reactflow.dev/error#001",error002:()=>"It looks like you've created a new nodeTypes or edgeTypes object. If this wasn't on purpose please define the nodeTypes/edgeTypes outside of the component or memoize them.",error003:(f)=>`Node type "${f}" not found. Using fallback type "default".`,error004:()=>"The React Flow parent container needs a width and a height to render the graph.",error005:()=>"Only child nodes can use a parent extent.",error006:()=>"Can't create edge. An edge needs a source and a target.",error007:(f)=>`The old edge with id=${f} does not exist.`,error009:(f)=>`Marker type "${f}" doesn't exist.`,error008:(f,{id:u,sourceHandle:_,targetHandle:y})=>`Couldn't create edge for ${f} handle id: "${f==="source"?_:y}", edge id: ${u}.`,error010:()=>"Handle: No node id found. Make sure to only use a Handle inside a custom Node.",error011:(f)=>`Edge type "${f}" not found. Using fallback type "default".`,error012:(f)=>`Node with id "${f}" does not exist, it may have been removed. This can happen when a node is deleted before the "onNodeClick" handler is called.`,error013:(f="react")=>`It seems that you haven't loaded the styles. Please import '@xyflow/${f}/dist/style.css' or base.css to make sure everything is working properly.`,error014:()=>"useNodeConnections: No node ID found. Call useNodeConnections inside a custom Node or provide a node ID.",error015:()=>"It seems that you are trying to drag a node that is not initialized. Please use onNodesChange as explained in the docs."},ul=[[Number.NEGATIVE_INFINITY,Number.NEGATIVE_INFINITY],[Number.POSITIVE_INFINITY,Number.POSITIVE_INFINITY]],dJ=["Enter"," ","Escape"],eJ={"node.a11yDescription.default":"Press enter or space to select a node. Press delete to remove it and escape to cancel.","node.a11yDescription.keyboardDisabled":"Press enter or space to select a node. You can then use the arrow keys to move the node around. Press delete to remove it and escape to cancel.","node.a11yDescription.ariaLiveMessage":({direction:f,x:u,y:_})=>`Moved selected node ${f}. New position, x: ${u}, y: ${_}`,"edge.a11yDescription.default":"Press enter or space to select an edge. You can then press delete to remove it or escape to cancel.","controls.ariaLabel":"Control Panel","controls.zoomIn.ariaLabel":"Zoom In","controls.zoomOut.ariaLabel":"Zoom Out","controls.fitView.ariaLabel":"Fit View","controls.interactive.ariaLabel":"Toggle Interactivity","minimap.ariaLabel":"Mini Map","handle.ariaLabel":"Handle"},g_;(function(f){f.Strict="strict",f.Loose="loose"})(g_||(g_={}));var A_;(function(f){f.Free="free",f.Vertical="vertical",f.Horizontal="horizontal"})(A_||(A_={}));var ky;(function(f){f.Partial="partial",f.Full="full"})(ky||(ky={}));var fF={inProgress:!1,isValid:null,from:null,fromHandle:null,fromPosition:null,fromNode:null,to:null,toHandle:null,toPosition:null,toNode:null,pointer:null},v1;(function(f){f.Bezier="default",f.Straight="straight",f.Step="step",f.SmoothStep="smoothstep",f.SimpleBezier="simplebezier"})(v1||(v1={}));var n_;(function(f){f.Arrow="arrow",f.ArrowClosed="arrowclosed"})(n_||(n_={}));var Uf;(function(f){f.Left="left",f.Top="top",f.Right="right",f.Bottom="bottom"})(Uf||(Uf={}));var oG={[Uf.Left]:Uf.Right,[Uf.Right]:Uf.Left,[Uf.Top]:Uf.Bottom,[Uf.Bottom]:Uf.Top};function uF(f){return f===null?null:f?"valid":"invalid"}var _F=(f)=>("id"in f)&&("source"in f)&&("target"in f),AK=(f)=>("id"in f)&&("position"in f)&&!("source"in f)&&!("target"in f),yF=(f)=>("id"in f)&&("internals"in f)&&!("source"in f)&&!("target"in f);var K6=(f,u=[0,0])=>{let{width:_,height:y}=b1(f),l=f.origin??u,$=_*l[0],j=y*l[1];return{x:f.position.x-$,y:f.position.y-j}},lF=(f,u={nodeOrigin:[0,0]})=>{if(f.length===0)return{x:0,y:0,width:0,height:0};let _=f.reduce((y,l)=>{let $=typeof l==="string",j=!u.nodeLookup&&!$?l:void 0;if(u.nodeLookup)j=$?u.nodeLookup.get(l):!yF(l)?u.nodeLookup.get(l.id):l;let J=j?V5(j,u.nodeOrigin):{x:0,y:0,x2:0,y2:0};return O5(y,J)},{x:1/0,y:1/0,x2:-1/0,y2:-1/0});return X5(_)},_l=(f,u={})=>{let _={x:1/0,y:1/0,x2:-1/0,y2:-1/0},y=!1;return f.forEach((l)=>{if(u.filter===void 0||u.filter(l))_=O5(_,V5(l)),y=!0}),y?X5(_):{x:0,y:0,width:0,height:0}},E5=(f,u,[_,y,l]=[0,0,1],$=!1,j=!1)=>{let J={...$l(u,[_,y,l]),width:u.width/l,height:u.height/l},F=[];for(let Q of f.values()){let{measured:U,selectable:z=!0,hidden:W=!1}=Q;if(j&&!z||W)continue;let K=U.width??Q.width??Q.initialWidth??null,q=U.height??Q.height??Q.initialHeight??null,V=yl(J,gy(Q)),O=(K??0)*(q??0),G=$&&V>0;if(!Q.internals.handleBounds||G||V>=O||Q.dragging)F.push(Q)}return F},QK=(f,u)=>{let _=new Set;return f.forEach((y)=>{_.add(y.id)}),u.filter((y)=>_.has(y.source)||_.has(y.target))};function WB(f,u){let _=new Map,y=u?.nodes?new Set(u.nodes.map((l)=>l.id)):null;return f.forEach((l)=>{if(l.measured.width&&l.measured.height&&(u?.includeHiddenNodes||!l.hidden)&&(!y||y.has(l.id)))_.set(l.id,l)}),_}async function UK({nodes:f,width:u,height:_,panZoom:y,minZoom:l,maxZoom:$},j){if(f.size===0)return Promise.resolve(!0);let J=WB(f,j),F=_l(J),Q=Z6(F,u,_,j?.minZoom??l,j?.maxZoom??$,j?.padding??0.1);return await y.setViewport(Q,{duration:j?.duration,ease:j?.ease,interpolate:j?.interpolate}),Promise.resolve(!0)}function $F({nodeId:f,nextPosition:u,nodeLookup:_,nodeOrigin:y=[0,0],nodeExtent:l,onError:$}){let j=_.get(f),J=j.parentId?_.get(j.parentId):void 0,{x:F,y:Q}=J?J.internals.positionAbsolute:{x:0,y:0},U=j.origin??y,z=j.extent||l;if(j.extent==="parent"&&!j.expandParent)if(!J)$?.("005",hu.error005());else{let K=J.measured.width,q=J.measured.height;if(K&&q)z=[[F,Q],[F+K,Q+q]]}else if(J&&fl(j.extent))z=[[j.extent[0][0]+F,j.extent[0][1]+Q],[j.extent[1][0]+F,j.extent[1][1]+Q]];let W=fl(z)?iy(u,z,j.measured):u;if(j.measured.width===void 0||j.measured.height===void 0)$?.("015",hu.error015());return{position:{x:W.x-F+(j.measured.width??0)*U[0],y:W.y-Q+(j.measured.height??0)*U[1]},positionAbsolute:W}}async function WK({nodesToRemove:f=[],edgesToRemove:u=[],nodes:_,edges:y,onBeforeDelete:l}){let $=new Set(f.map((W)=>W.id)),j=[];for(let W of _){if(W.deletable===!1)continue;let K=$.has(W.id),q=!K&&W.parentId&&j.find((V)=>V.id===W.parentId);if(K||q)j.push(W)}let J=new Set(u.map((W)=>W.id)),F=y.filter((W)=>W.deletable!==!1),U=QK(j,F);for(let W of F)if(J.has(W.id)&&!U.find((q)=>q.id===W.id))U.push(W);if(!l)return{edges:U,nodes:j};let z=await l({nodes:j,edges:U});if(typeof z==="boolean")return z?{edges:U,nodes:j}:{edges:[],nodes:[]};return z}var e3=(f,u=0,_=1)=>Math.min(Math.max(f,u),_),iy=(f={x:0,y:0},u,_)=>({x:e3(f.x,u[0][0],u[1][0]-(_?.width??0)),y:e3(f.y,u[0][1],u[1][1]-(_?.height??0))});function zK(f,u,_){let{width:y,height:l}=b1(_),{x:$,y:j}=_.internals.positionAbsolute;return iy(f,[[$,j],[$+y,j+l]],u)}var aG=(f,u,_)=>{if(f_)return-e3(Math.abs(f-_),1,u)/u;return 0},GK=(f,u,_=15,y=40)=>{let l=aG(f.x,y,u.width-y)*_,$=aG(f.y,y,u.height-y)*_;return[l,$]},O5=(f,u)=>({x:Math.min(f.x,u.x),y:Math.min(f.y,u.y),x2:Math.max(f.x2,u.x2),y2:Math.max(f.y2,u.y2)}),aJ=({x:f,y:u,width:_,height:y})=>({x:f,y:u,x2:f+_,y2:u+y}),X5=({x:f,y:u,x2:_,y2:y})=>({x:f,y:u,width:_-f,height:y-u}),gy=(f,u=[0,0])=>{let{x:_,y}=yF(f)?f.internals.positionAbsolute:K6(f,u);return{x:_,y,width:f.measured?.width??f.width??f.initialWidth??0,height:f.measured?.height??f.height??f.initialHeight??0}},V5=(f,u=[0,0])=>{let{x:_,y}=yF(f)?f.internals.positionAbsolute:K6(f,u);return{x:_,y,x2:_+(f.measured?.width??f.width??f.initialWidth??0),y2:y+(f.measured?.height??f.height??f.initialHeight??0)}},jF=(f,u)=>X5(O5(aJ(f),aJ(u))),yl=(f,u)=>{let _=Math.max(0,Math.min(f.x+f.width,u.x+u.width)-Math.max(f.x,u.x)),y=Math.max(0,Math.min(f.y+f.height,u.y+u.height)-Math.max(f.y,u.y));return Math.ceil(_*y)},JF=(f)=>u1(f.width)&&u1(f.height)&&u1(f.x)&&u1(f.y),u1=(f)=>!isNaN(f)&&isFinite(f),FF=(f,u)=>{},ll=(f,u=[1,1])=>{return{x:u[0]*Math.round(f.x/u[0]),y:u[1]*Math.round(f.y/u[1])}},$l=({x:f,y:u},[_,y,l],$=!1,j=[1,1])=>{let J={x:(f-_)/l,y:(u-y)/l};return $?ll(J,j):J},G6=({x:f,y:u},[_,y,l])=>{return{x:f*l+_,y:u*l+y}};function a3(f,u){if(typeof f==="number")return Math.floor((u-u/(1+f))*0.5);if(typeof f==="string"&&f.endsWith("px")){let _=parseFloat(f);if(!Number.isNaN(_))return Math.floor(_)}if(typeof f==="string"&&f.endsWith("%")){let _=parseFloat(f);if(!Number.isNaN(_))return Math.floor(u*_*0.01)}return console.error(`[React Flow] The padding value "${f}" is invalid. Please provide a number or a string with a valid unit (px or %).`),0}function zB(f,u,_){if(typeof f==="string"||typeof f==="number"){let y=a3(f,_),l=a3(f,u);return{top:y,right:l,bottom:y,left:l,x:l*2,y:y*2}}if(typeof f==="object"){let y=a3(f.top??f.y??0,_),l=a3(f.bottom??f.y??0,_),$=a3(f.left??f.x??0,u),j=a3(f.right??f.x??0,u);return{top:y,right:j,bottom:l,left:$,x:$+j,y:y+l}}return{top:0,right:0,bottom:0,left:0,x:0,y:0}}function GB(f,u,_,y,l,$){let{x:j,y:J}=G6(f,[u,_,y]),{x:F,y:Q}=G6({x:f.x+f.width,y:f.y+f.height},[u,_,y]),U=l-F,z=$-Q;return{left:Math.floor(j),top:Math.floor(J),right:Math.floor(U),bottom:Math.floor(z)}}var Z6=(f,u,_,y,l,$)=>{let j=zB($,u,_),J=(u-j.x)/f.width,F=(_-j.y)/f.height,Q=Math.min(J,F),U=e3(Q,y,l),z=f.x+f.width/2,W=f.y+f.height/2,K=u/2-z*U,q=_/2-W*U,V=GB(f,K,q,U,u,_),O={left:Math.min(V.left-j.left,0),top:Math.min(V.top-j.top,0),right:Math.min(V.right-j.right,0),bottom:Math.min(V.bottom-j.bottom,0)};return{x:K-O.left+O.right,y:q-O.top+O.bottom,zoom:U}},jl=()=>typeof navigator<"u"&&navigator?.userAgent?.indexOf("Mac")>=0;function fl(f){return f!==void 0&&f!==null&&f!=="parent"}function b1(f){return{width:f.measured?.width??f.width??f.initialWidth??0,height:f.measured?.height??f.height??f.initialHeight??0}}function AF(f){return(f.measured?.width??f.width??f.initialWidth)!==void 0&&(f.measured?.height??f.height??f.initialHeight)!==void 0}function QF(f,u={width:0,height:0},_,y,l){let $={...f},j=y.get(_);if(j){let J=j.origin||l;$.x+=j.internals.positionAbsolute.x-(u.width??0)*J[0],$.y+=j.internals.positionAbsolute.y-(u.height??0)*J[1]}return $}function UF(f,u){if(f.size!==u.size)return!1;for(let _ of f)if(!u.has(_))return!1;return!0}function KK(){let f,u;return{promise:new Promise((y,l)=>{f=y,u=l}),resolve:f,reject:u}}function ZK(f){return{...eJ,...f||{}}}function z6(f,{snapGrid:u=[0,0],snapToGrid:_=!1,transform:y,containerBounds:l}){let{x:$,y:j}=_1(f),J=$l({x:$-(l?.left??0),y:j-(l?.top??0)},y),{x:F,y:Q}=_?ll(J,u):J;return{xSnapped:F,ySnapped:Q,...J}}var N5=(f)=>({width:f.offsetWidth,height:f.offsetHeight}),WF=(f)=>f?.getRootNode?.()||window?.document,KB=["INPUT","SELECT","TEXTAREA"];function zF(f){let u=f.composedPath?.()?.[0]||f.target;if(u?.nodeType!==1)return!1;return KB.includes(u.nodeName)||u.hasAttribute("contenteditable")||!!u.closest(".nokey")}var GF=(f)=>("clientX"in f),_1=(f,u)=>{let _=GF(f),y=_?f.clientX:f.touches?.[0].clientX,l=_?f.clientY:f.touches?.[0].clientY;return{x:y-(u?.left??0),y:l-(u?.top??0)}},dG=(f,u,_,y,l)=>{let $=u.querySelectorAll(`.${f}`);if(!$||!$.length)return null;return Array.from($).map((j)=>{let J=j.getBoundingClientRect();return{id:j.getAttribute("data-handleid"),type:f,nodeId:l,position:j.getAttribute("data-handlepos"),x:(J.left-_.left)/y,y:(J.top-_.top)/y,...N5(j)}})};function L5({sourceX:f,sourceY:u,targetX:_,targetY:y,sourceControlX:l,sourceControlY:$,targetControlX:j,targetControlY:J}){let F=f*0.125+l*0.375+j*0.375+_*0.125,Q=u*0.125+$*0.375+J*0.375+y*0.125,U=Math.abs(F-f),z=Math.abs(Q-u);return[F,Q,U,z]}function q5(f,u){if(f>=0)return 0.5*f;return u*25*Math.sqrt(-f)}function eG({pos:f,x1:u,y1:_,x2:y,y2:l,c:$}){switch(f){case Uf.Left:return[u-q5(u-y,$),_];case Uf.Right:return[u+q5(y-u,$),_];case Uf.Top:return[u,_-q5(_-l,$)];case Uf.Bottom:return[u,_+q5(l-_,$)]}}function Y5({sourceX:f,sourceY:u,sourcePosition:_=Uf.Bottom,targetX:y,targetY:l,targetPosition:$=Uf.Top,curvature:j=0.25}){let[J,F]=eG({pos:_,x1:f,y1:u,x2:y,y2:l,c:j}),[Q,U]=eG({pos:$,x1:y,y1:l,x2:f,y2:u,c:j}),[z,W,K,q]=L5({sourceX:f,sourceY:u,targetX:y,targetY:l,sourceControlX:J,sourceControlY:F,targetControlX:Q,targetControlY:U});return[`M${f},${u} C${J},${F} ${Q},${U} ${y},${l}`,z,W,K,q]}function KF({sourceX:f,sourceY:u,targetX:_,targetY:y}){let l=Math.abs(_-f)/2,$=_0}var ZB=({source:f,sourceHandle:u,target:_,targetHandle:y})=>`xy-edge__${f}${u||""}-${_}${y||""}`,qB=(f,u)=>{return u.some((_)=>_.source===f.source&&_.target===f.target&&(_.sourceHandle===f.sourceHandle||!_.sourceHandle&&!f.sourceHandle)&&(_.targetHandle===f.targetHandle||!_.targetHandle&&!f.targetHandle))},ZF=(f,u,_={})=>{if(!f.source||!f.target)return FF("006",hu.error006()),u;let y=_.getEdgeId||ZB,l;if(_F(f))l={...f};else l={...f,id:y(f)};if(qB(l,u))return u;if(l.sourceHandle===null)delete l.sourceHandle;if(l.targetHandle===null)delete l.targetHandle;return u.concat(l)};function B5({sourceX:f,sourceY:u,targetX:_,targetY:y}){let[l,$,j,J]=KF({sourceX:f,sourceY:u,targetX:_,targetY:y});return[`M ${f},${u}L ${_},${y}`,l,$,j,J]}var fK={[Uf.Left]:{x:-1,y:0},[Uf.Right]:{x:1,y:0},[Uf.Top]:{x:0,y:-1},[Uf.Bottom]:{x:0,y:1}},HB=({source:f,sourcePosition:u=Uf.Bottom,target:_})=>{if(u===Uf.Left||u===Uf.Right)return f.x<_.x?{x:1,y:0}:{x:-1,y:0};return f.y<_.y?{x:0,y:1}:{x:0,y:-1}},uK=(f,u)=>Math.sqrt(Math.pow(u.x-f.x,2)+Math.pow(u.y-f.y,2));function VB({source:f,sourcePosition:u=Uf.Bottom,target:_,targetPosition:y=Uf.Top,center:l,offset:$,stepPosition:j}){let J=fK[u],F=fK[y],Q={x:f.x+J.x*$,y:f.y+J.y*$},U={x:_.x+F.x*$,y:_.y+F.y*$},z=HB({source:Q,sourcePosition:u,target:U}),W=z.x!==0?"x":"y",K=z[W],q=[],V,O,G={x:0,y:0},H={x:0,y:0},[,,Z,E]=KF({sourceX:f.x,sourceY:f.y,targetX:_.x,targetY:_.y});if(J[W]*F[W]===-1){if(W==="x")V=l.x??Q.x+(U.x-Q.x)*j,O=l.y??(Q.y+U.y)/2;else V=l.x??(Q.x+U.x)/2,O=l.y??Q.y+(U.y-Q.y)*j;let w=[{x:V,y:Q.y},{x:V,y:U.y}],R=[{x:Q.x,y:O},{x:U.x,y:O}];if(J[W]===K)q=W==="x"?w:R;else q=W==="x"?R:w}else{let w=[{x:Q.x,y:U.y}],R=[{x:U.x,y:Q.y}];if(W==="x")q=J.x===K?R:w;else q=J.y===K?w:R;if(u===y){let D=Math.abs(f[W]-_[W]);if(D<=$){let T=Math.min($-1,$-D);if(J[W]===K)G[W]=(Q[W]>f[W]?-1:1)*T;else H[W]=(U[W]>_[W]?-1:1)*T}}if(u!==y){let D=W==="x"?"y":"x",T=J[W]===F[D],S=Q[D]>U[D],r=Q[D]=P)V=(p.x+x.x)/2,O=q[0].y;else V=q[0].x,O=(p.y+x.y)/2}let L={x:Q.x+G.x,y:Q.y+G.y},M={x:U.x+H.x,y:U.y+H.y};return[[f,...L.x!==q[0].x||L.y!==q[0].y?[L]:[],...q,...M.x!==q[q.length-1].x||M.y!==q[q.length-1].y?[M]:[],_],V,O,Z,E]}function EB(f,u,_,y){let l=Math.min(uK(f,u)/2,uK(u,_)/2,y),{x:$,y:j}=u;if(f.x===$&&$===_.x||f.y===j&&j===_.y)return`L${$} ${j}`;if(f.y===j){let Q=f.x<_.x?-1:1,U=f.y<_.y?1:-1;return`L ${$+l*Q},${j}Q ${$},${j} ${$},${j+l*U}`}let J=f.x<_.x?1:-1,F=f.y<_.y?-1:1;return`L ${$},${j+l*F}Q ${$},${j} ${$+l*J},${j}`}function q6({sourceX:f,sourceY:u,sourcePosition:_=Uf.Bottom,targetX:y,targetY:l,targetPosition:$=Uf.Top,borderRadius:j=5,centerX:J,centerY:F,offset:Q=20,stepPosition:U=0.5}){let[z,W,K,q,V]=VB({source:{x:f,y:u},sourcePosition:_,target:{x:y,y:l},targetPosition:$,center:{x:J,y:F},offset:Q,stepPosition:U}),O=`M${z[0].x} ${z[0].y}`;for(let G=1;G_.id===u))||null}function w5(f,u){if(!f)return"";if(typeof f==="string")return f;return`${u?`${u}__`:""}${Object.keys(f).sort().map((y)=>`${y}=${f[y]}`).join("&")}`}function EK(f,{id:u,defaultColor:_,defaultMarkerStart:y,defaultMarkerEnd:l}){let $=new Set;return f.reduce((j,J)=>{return[J.markerStart||y,J.markerEnd||l].forEach((F)=>{if(F&&typeof F==="object"){let Q=w5(F,u);if(!$.has(Q))j.push({id:Q,color:F.color||_,...F}),$.add(Q)}}),j},[]).sort((j,J)=>j.id.localeCompare(J.id))}var OK=1000,OB=10,qF={nodeOrigin:[0,0],nodeExtent:ul,elevateNodesOnSelect:!0,zIndexMode:"basic",defaults:{}},XB={...qF,checkEquality:!0};function HF(f,u){let _={...f};for(let y in u)if(u[y]!==void 0)_[y]=u[y];return _}function XK(f,u,_){let y=HF(qF,_);for(let l of f.values())if(l.parentId)EF(l,f,u,y);else{let $=K6(l,y.nodeOrigin),j=fl(l.extent)?l.extent:y.nodeExtent,J=iy($,j,b1(l));l.internals.positionAbsolute=J}}function NB(f,u){if(!f.handles)return!f.measured?void 0:u?.internals.handleBounds;let _=[],y=[];for(let l of f.handles){let $={id:l.id,width:l.width??1,height:l.height??1,nodeId:f.id,x:l.x,y:l.y,position:l.position,type:l.type};if(l.type==="source")_.push($);else if(l.type==="target")y.push($)}return{source:_,target:y}}function VF(f){return f==="manual"}function D5(f,u,_,y={}){let l=HF(XB,y),$={i:0},j=new Map(u),J=l?.elevateNodesOnSelect&&!VF(l.zIndexMode)?OK:0,F=f.length>0,Q=!1;u.clear(),_.clear();for(let U of f){let z=j.get(U.id);if(l.checkEquality&&U===z?.internals.userNode)u.set(U.id,z);else{let W=K6(U,l.nodeOrigin),K=fl(U.extent)?U.extent:l.nodeExtent,q=iy(W,K,b1(U));z={...l.defaults,...U,measured:{width:U.measured?.width,height:U.measured?.height},internals:{positionAbsolute:q,handleBounds:NB(U,z),z:NK(U,J,l.zIndexMode),userNode:U}},u.set(U.id,z)}if((z.measured===void 0||z.measured.width===void 0||z.measured.height===void 0)&&!z.hidden)F=!1;if(U.parentId)EF(z,u,_,y,$);Q||=U.selected??!1}return{nodesInitialized:F,hasSelectedNodes:Q}}function LB(f,u){if(!f.parentId)return;let _=u.get(f.parentId);if(_)_.set(f.id,f);else u.set(f.parentId,new Map([[f.id,f]]))}function EF(f,u,_,y,l){let{elevateNodesOnSelect:$,nodeOrigin:j,nodeExtent:J,zIndexMode:F}=HF(qF,y),Q=f.parentId,U=u.get(Q);if(!U){console.warn(`Parent node ${Q} not found. Please make sure that parent nodes are in front of their child nodes in the nodes array.`);return}if(LB(f,_),l&&!U.parentId&&U.internals.rootParentIndex===void 0&&F==="auto")U.internals.rootParentIndex=++l.i,U.internals.z=U.internals.z+l.i*OB;if(l&&U.internals.rootParentIndex!==void 0)l.i=U.internals.rootParentIndex;let z=$&&!VF(F)?OK:0,{x:W,y:K,z:q}=YB(f,U,j,J,z,F),{positionAbsolute:V}=f.internals,O=W!==V.x||K!==V.y;if(O||q!==f.internals.z)u.set(f.id,{...f,internals:{...f.internals,positionAbsolute:O?{x:W,y:K}:V,z:q}})}function NK(f,u,_){let y=u1(f.zIndex)?f.zIndex:0;if(VF(_))return y;return y+(f.selected?u:0)}function YB(f,u,_,y,l,$){let{x:j,y:J}=u.internals.positionAbsolute,F=b1(f),Q=K6(f,_),U=fl(f.extent)?iy(Q,f.extent,F):Q,z=iy({x:j+U.x,y:J+U.y},y,F);if(f.extent==="parent")z=zK(z,F,u);let W=NK(f,l,$),K=u.internals.z??0;return{x:z.x,y:z.y,z:K>=W?K+1:W}}function T5(f,u,_,y=[0,0]){let l=[],$=new Map;for(let j of f){let J=u.get(j.parentId);if(!J)continue;let F=$.get(j.parentId)?.expandedRect??gy(J),Q=jF(F,j.rect);$.set(j.parentId,{expandedRect:Q,parent:J})}if($.size>0)$.forEach(({expandedRect:j,parent:J},F)=>{let Q=J.internals.positionAbsolute,U=b1(J),z=J.origin??y,W=j.x0||K>0||O||G)l.push({id:F,type:"position",position:{x:J.position.x-W+O,y:J.position.y-K+G}}),_.get(F)?.forEach((H)=>{if(!f.some((Z)=>Z.id===H.id))l.push({id:H.id,type:"position",position:{x:H.position.x+W,y:H.position.y+K}})});if(U.width0){let K=T5(W,u,_,l);Q.push(...K)}return{changes:Q,updatedInternals:F}}async function YK({delta:f,panZoom:u,transform:_,translateExtent:y,width:l,height:$}){if(!u||!f.x&&!f.y)return Promise.resolve(!1);let j=await u.setViewportConstrained({x:_[0]+f.x,y:_[1]+f.y,zoom:_[2]},[[0,0],[l,$]],y),J=!!j&&(j.x!==_[0]||j.y!==_[1]||j.k!==_[2]);return Promise.resolve(J)}function $K(f,u,_,y,l,$){let j=l,J=y.get(j)||new Map;y.set(j,J.set(_,u)),j=`${l}-${f}`;let F=y.get(j)||new Map;if(y.set(j,F.set(_,u)),$){j=`${l}-${f}-${$}`;let Q=y.get(j)||new Map;y.set(j,Q.set(_,u))}}function OF(f,u,_){f.clear(),u.clear();for(let y of _){let{source:l,target:$,sourceHandle:j=null,targetHandle:J=null}=y,F={edgeId:y.id,source:l,target:$,sourceHandle:j,targetHandle:J},Q=`${l}-${j}--${$}-${J}`,U=`${$}-${J}--${l}-${j}`;$K("source",F,U,f,l,j),$K("target",F,Q,f,$,J),u.set(y.id,y)}}function BK(f,u){if(!f.parentId)return!1;let _=u.get(f.parentId);if(!_)return!1;if(_.selected)return!0;return BK(_,u)}function jK(f,u,_){let y=f;do{if(y?.matches?.(u))return!0;if(y===_)return!1;y=y?.parentElement}while(y);return!1}function BB(f,u,_,y){let l=new Map;for(let[$,j]of f)if((j.selected||j.id===y)&&(!j.parentId||!BK(j,f))&&(j.draggable||u&&typeof j.draggable>"u")){let J=f.get($);if(J)l.set($,{id:$,position:J.position||{x:0,y:0},distance:{x:_.x-J.internals.positionAbsolute.x,y:_.y-J.internals.positionAbsolute.y},extent:J.extent,parentId:J.parentId,origin:J.origin,expandParent:J.expandParent,internals:{positionAbsolute:J.internals.positionAbsolute||{x:0,y:0}},measured:{width:J.measured.width??0,height:J.measured.height??0}})}return l}function tJ({nodeId:f,dragItems:u,nodeLookup:_,dragging:y=!0}){let l=[];for(let[j,J]of u){let F=_.get(j)?.internals.userNode;if(F)l.push({...F,position:J.position,dragging:y})}if(!f)return[l[0],l];let $=_.get(f)?.internals.userNode;return[!$?l[0]:{...$,position:u.get(f)?.position||$.position,dragging:y},l]}function wB({dragItems:f,snapGrid:u,x:_,y}){let l=f.values().next().value;if(!l)return null;let $={x:_-l.distance.x,y:y-l.distance.y},j=ll($,u);return{x:j.x-$.x,y:j.y-$.y}}function wK({onNodeMouseDown:f,getStoreItems:u,onDragStart:_,onDrag:y,onDragStop:l}){let $={x:null,y:null},j=0,J=new Map,F=!1,Q={x:0,y:0},U=null,z=!1,W=null,K=!1,q=!1,V=null;function O({noDragClassName:H,handleSelector:Z,domNode:E,isSelectable:L,nodeId:M,nodeClickDistance:N=0}){W=C0(E);function w({x:C,y:P}){let{nodeLookup:D,nodeExtent:T,snapGrid:S,snapToGrid:r,nodeOrigin:Y,onNodeDrag:v,onSelectionDrag:m,onError:c,updateNodePositions:o}=u();$={x:C,y:P};let ff=!1,n=J.size>1,lf=n&&T?aJ(_l(J)):null,Gf=n&&r?wB({dragItems:J,snapGrid:S,x:C,y:P}):null;for(let[zf,jf]of J){if(!D.has(zf))continue;let Wf={x:C-jf.distance.x,y:P-jf.distance.y};if(r)Wf=Gf?{x:Math.round(Wf.x+Gf.x),y:Math.round(Wf.y+Gf.y)}:ll(Wf,S);let Vf=null;if(n&&T&&!jf.extent&&lf){let{positionAbsolute:g}=jf.internals,I=g.x-lf.x+T[0][0],yf=g.x+jf.measured.width-lf.x2+T[1][0],$f=g.y-lf.y+T[0][1],Qf=g.y+jf.measured.height-lf.y2+T[1][1];Vf=[[I,$f],[yf,Qf]]}let{position:Kf,positionAbsolute:h}=$F({nodeId:zf,nextPosition:Wf,nodeLookup:D,nodeExtent:Vf?Vf:T,nodeOrigin:Y,onError:c});ff=ff||jf.position.x!==Kf.x||jf.position.y!==Kf.y,jf.position=Kf,jf.internals.positionAbsolute=h}if(q=q||ff,!ff)return;if(o(J,!0),V&&(y||v||!M&&m)){let[zf,jf]=tJ({nodeId:M,dragItems:J,nodeLookup:D});if(y?.(V,J,zf,jf),v?.(V,zf,jf),!M)m?.(V,jf)}}async function R(){if(!U)return;let{transform:C,panBy:P,autoPanSpeed:D,autoPanOnNodeDrag:T}=u();if(!T){F=!1,cancelAnimationFrame(j);return}let[S,r]=GK(Q,U,D);if(S!==0||r!==0){if($.x=($.x??0)-S/C[2],$.y=($.y??0)-r/C[2],await P({x:S,y:r}))w($)}j=requestAnimationFrame(R)}function p(C){let{nodeLookup:P,multiSelectionActive:D,nodesDraggable:T,transform:S,snapGrid:r,snapToGrid:Y,selectNodesOnDrag:v,onNodeDragStart:m,onSelectionDragStart:c,unselectNodesAndEdges:o}=u();if(z=!0,(!v||!L)&&!D&&M){if(!P.get(M)?.selected)o()}if(L&&v&&M)f?.(M);let ff=z6(C.sourceEvent,{transform:S,snapGrid:r,snapToGrid:Y,containerBounds:U});if($=ff,J=BB(P,T,ff,M),J.size>0&&(_||m||!M&&c)){let[n,lf]=tJ({nodeId:M,dragItems:J,nodeLookup:P});if(_?.(C.sourceEvent,J,n,lf),m?.(C.sourceEvent,n,lf),!M)c?.(C.sourceEvent,lf)}}let x=t$().clickDistance(N).on("start",(C)=>{let{domNode:P,nodeDragThreshold:D,transform:T,snapGrid:S,snapToGrid:r}=u();if(U=P?.getBoundingClientRect()||null,K=!1,q=!1,V=C.sourceEvent,D===0)p(C);$=z6(C.sourceEvent,{transform:T,snapGrid:S,snapToGrid:r,containerBounds:U}),Q=_1(C.sourceEvent,U)}).on("drag",(C)=>{let{autoPanOnNodeDrag:P,transform:D,snapGrid:T,snapToGrid:S,nodeDragThreshold:r,nodeLookup:Y}=u(),v=z6(C.sourceEvent,{transform:D,snapGrid:T,snapToGrid:S,containerBounds:U});if(V=C.sourceEvent,C.sourceEvent.type==="touchmove"&&C.sourceEvent.touches.length>1||M&&!Y.has(M))K=!0;if(K)return;if(!F&&P&&z)F=!0,R();if(!z){let m=_1(C.sourceEvent,U),c=m.x-Q.x,o=m.y-Q.y;if(Math.sqrt(c*c+o*o)>r)p(C)}if(($.x!==v.xSnapped||$.y!==v.ySnapped)&&J&&z)Q=_1(C.sourceEvent,U),w(v)}).on("end",(C)=>{if(!z||K)return;if(F=!1,z=!1,cancelAnimationFrame(j),J.size>0){let{nodeLookup:P,updateNodePositions:D,onNodeDragStop:T,onSelectionDragStop:S}=u();if(q)D(J,!1),q=!1;if(l||T||!M&&S){let[r,Y]=tJ({nodeId:M,dragItems:J,nodeLookup:P,dragging:!1});if(l?.(C.sourceEvent,J,r,Y),T?.(C.sourceEvent,r,Y),!M)S?.(C.sourceEvent,Y)}}}).filter((C)=>{let P=C.target;return!C.button&&(!H||!jK(P,`.${H}`,E))&&(!Z||jK(P,Z,E))});W.call(x)}function G(){W?.on(".drag",null)}return{update:O,destroy:G}}function DB(f,u,_){let y=[],l={x:f.x-_,y:f.y-_,width:_*2,height:_*2};for(let $ of u.values())if(yl(l,gy($))>0)y.push($);return y}var TB=250;function MB(f,u,_,y){let l=[],$=1/0,j=DB(f,_,u+TB);for(let J of j){let F=[...J.internals.handleBounds?.source??[],...J.internals.handleBounds?.target??[]];for(let Q of F){if(y.nodeId===Q.nodeId&&y.type===Q.type&&y.id===Q.id)continue;let{x:U,y:z}=t_(J,Q,Q.position,!0),W=Math.sqrt(Math.pow(U-f.x,2)+Math.pow(z-f.y,2));if(W>u)continue;if(W<$)l=[{...Q,x:U,y:z}],$=W;else if(W===$)l.push({...Q,x:U,y:z})}}if(!l.length)return null;if(l.length>1){let J=y.type==="source"?"target":"source";return l.find((F)=>F.type===J)??l[0]}return l[0]}function DK(f,u,_,y,l,$=!1){let j=y.get(f);if(!j)return null;let J=l==="strict"?j.internals.handleBounds?.[u]:[...j.internals.handleBounds?.source??[],...j.internals.handleBounds?.target??[]],F=(_?J?.find((Q)=>Q.id===_):J?.[0])??null;return F&&$?{...F,...t_(j,F,F.position,!0)}:F}function TK(f,u){if(f)return f;else if(u?.classList.contains("target"))return"target";else if(u?.classList.contains("source"))return"source";return null}function rB(f,u){let _=null;if(u)_=!0;else if(f&&!u)_=!1;return _}var MK=()=>!0;function SB(f,{connectionMode:u,connectionRadius:_,handleId:y,nodeId:l,edgeUpdaterType:$,isTarget:j,domNode:J,nodeLookup:F,lib:Q,autoPanOnConnect:U,flowId:z,panBy:W,cancelConnection:K,onConnectStart:q,onConnect:V,onConnectEnd:O,isValidConnection:G=MK,onReconnectEnd:H,updateConnection:Z,getTransform:E,getFromHandle:L,autoPanSpeed:M,dragThreshold:N=1,handleDomNode:w}){let R=WF(f.target),p=0,x,{x:C,y:P}=_1(f),D=TK($,w),T=J?.getBoundingClientRect(),S=!1;if(!T||!D)return;let r=DK(l,D,y,F,u);if(!r)return;let Y=_1(f,T),v=!1,m=null,c=!1,o=null;function ff(){if(!U||!T)return;let[Kf,h]=GK(Y,T,M);W({x:Kf,y:h}),p=requestAnimationFrame(ff)}let n={...r,nodeId:l,type:D,position:r.position},lf=F.get(l),zf={inProgress:!0,isValid:null,from:t_(lf,n,Uf.Left,!0),fromHandle:n,fromPosition:n.position,fromNode:lf,to:Y,toHandle:null,toPosition:oG[n.position],toNode:null,pointer:Y};function jf(){S=!0,Z(zf),q?.(f,{nodeId:l,handleId:y,handleType:D})}if(N===0)jf();function Wf(Kf){if(!S){let{x:Qf,y:Yf}=_1(Kf),xf=Qf-C,tf=Yf-P;if(!(xf*xf+tf*tf>N*N))return;jf()}if(!L()||!n){Vf(Kf);return}let h=E();if(Y=_1(Kf,T),x=MB($l(Y,h,!1,[1,1]),_,F,n),!v)ff(),v=!0;let g=rK(Kf,{handle:x,connectionMode:u,fromNodeId:l,fromHandleId:y,fromType:j?"target":"source",isValidConnection:G,doc:R,lib:Q,flowId:z,nodeLookup:F});o=g.handleDomNode,m=g.connection,c=rB(!!x,g.isValid);let I=F.get(l),yf=I?t_(I,n,Uf.Left,!0):zf.from,$f={...zf,from:yf,isValid:c,to:g.toHandle&&c?G6({x:g.toHandle.x,y:g.toHandle.y},h):Y,toHandle:g.toHandle,toPosition:c&&g.toHandle?g.toHandle.position:oG[n.position],toNode:g.toHandle?F.get(g.toHandle.nodeId):null,pointer:Y};Z($f),zf=$f}function Vf(Kf){if("touches"in Kf&&Kf.touches.length>0)return;if(S){if((x||o)&&m&&c)V?.(m);let{inProgress:h,...g}=zf,I={...g,toPosition:zf.toHandle?zf.toPosition:null};if(O?.(Kf,I),$)H?.(Kf,I)}K(),cancelAnimationFrame(p),v=!1,c=!1,m=null,o=null,R.removeEventListener("mousemove",Wf),R.removeEventListener("mouseup",Vf),R.removeEventListener("touchmove",Wf),R.removeEventListener("touchend",Vf)}R.addEventListener("mousemove",Wf),R.addEventListener("mouseup",Vf),R.addEventListener("touchmove",Wf),R.addEventListener("touchend",Vf)}function rK(f,{handle:u,connectionMode:_,fromNodeId:y,fromHandleId:l,fromType:$,doc:j,lib:J,flowId:F,isValidConnection:Q=MK,nodeLookup:U}){let z=$==="target",W=u?j.querySelector(`.${J}-flow__handle[data-id="${F}-${u?.nodeId}-${u?.id}-${u?.type}"]`):null,{x:K,y:q}=_1(f),V=j.elementFromPoint(K,q),O=V?.classList.contains(`${J}-flow__handle`)?V:W,G={handleDomNode:O,isValid:!1,connection:null,toHandle:null};if(O){let H=TK(void 0,O),Z=O.getAttribute("data-nodeid"),E=O.getAttribute("data-handleid"),L=O.classList.contains("connectable"),M=O.classList.contains("connectableend");if(!Z||!H)return G;let N={source:z?Z:y,sourceHandle:z?E:l,target:z?y:Z,targetHandle:z?l:E};G.connection=N;let R=L&&M&&(_===g_.Strict?z&&H==="source"||!z&&H==="target":Z!==y||E!==l);G.isValid=R&&Q(N),G.toHandle=DK(Z,H,E,U,_,!0)}return G}var M5={onPointerDown:SB,isValid:rK};function SK({domNode:f,panZoom:u,getTransform:_,getViewScale:y}){let l=C0(f);function $({translateExtent:J,width:F,height:Q,zoomStep:U=1,pannable:z=!0,zoomable:W=!0,inversePan:K=!1}){let q=(Z)=>{if(Z.sourceEvent.type!=="wheel"||!u)return;let E=_(),L=Z.sourceEvent.ctrlKey&&jl()?10:1,M=-Z.sourceEvent.deltaY*(Z.sourceEvent.deltaMode===1?0.05:Z.sourceEvent.deltaMode?1:0.002)*U,N=E[2]*Math.pow(2,M*L);u.scaleTo(N)},V=[0,0],O=(Z)=>{if(Z.sourceEvent.type==="mousedown"||Z.sourceEvent.type==="touchstart")V=[Z.sourceEvent.clientX??Z.sourceEvent.touches[0].clientX,Z.sourceEvent.clientY??Z.sourceEvent.touches[0].clientY]},G=(Z)=>{let E=_();if(Z.sourceEvent.type!=="mousemove"&&Z.sourceEvent.type!=="touchmove"||!u)return;let L=[Z.sourceEvent.clientX??Z.sourceEvent.touches[0].clientX,Z.sourceEvent.clientY??Z.sourceEvent.touches[0].clientY],M=[L[0]-V[0],L[1]-V[1]];V=L;let N=y()*Math.max(E[2],Math.log(E[2]))*(K?-1:1),w={x:E[0]-M[0]*N,y:E[1]-M[1]*N},R=[[0,0],[F,Q]];u.setViewportConstrained({x:w.x,y:w.y,zoom:E[2]},R,J)},H=W6().on("start",O).on("zoom",z?G:null).on("zoom.wheel",W?q:null);l.call(H,{})}function j(){l.on("zoom",null)}return{update:$,destroy:j,pointer:$u}}var r5=(f)=>({x:f.x,y:f.y,zoom:f.k}),sJ=({x:f,y:u,zoom:_})=>py.translate(f,u).scale(_),d3=(f,u)=>f.target.closest(`.${u}`),PK=(f,u)=>u===2&&Array.isArray(f)&&f.includes(2),PB=(f)=>((f*=2)<=1?f*f*f:(f-=2)*f*f+2)/2,oJ=(f,u=0,_=PB,y=()=>{})=>{let l=typeof u==="number"&&u>0;if(!l)y();return l?f.transition().duration(u).ease(_).on("end",y):f},CK=(f)=>{let u=f.ctrlKey&&jl()?10:1;return-f.deltaY*(f.deltaMode===1?0.05:f.deltaMode?1:0.002)*u};function CB({zoomPanValues:f,noWheelClassName:u,d3Selection:_,d3Zoom:y,panOnScrollMode:l,panOnScrollSpeed:$,zoomOnPinch:j,onPanZoomStart:J,onPanZoom:F,onPanZoomEnd:Q}){return(U)=>{if(d3(U,u)){if(U.ctrlKey)U.preventDefault();return!1}U.preventDefault(),U.stopImmediatePropagation();let z=_.property("__zoom").k||1;if(U.ctrlKey&&j){let O=$u(U),G=CK(U),H=z*Math.pow(2,G);y.scaleTo(_,H,O,U);return}let W=U.deltaMode===1?20:1,K=l===A_.Vertical?0:U.deltaX*W,q=l===A_.Horizontal?0:U.deltaY*W;if(!jl()&&U.shiftKey&&l!==A_.Vertical)K=U.deltaY*W,q=0;y.translateBy(_,-(K/z)*$,-(q/z)*$,{internal:!0});let V=r5(_.property("__zoom"));if(clearTimeout(f.panScrollTimeout),!f.isPanScrolling)f.isPanScrolling=!0,J?.(U,V);else F?.(U,V),f.panScrollTimeout=setTimeout(()=>{Q?.(U,V),f.isPanScrolling=!1},150)}}function RB({noWheelClassName:f,preventScrolling:u,d3ZoomHandler:_}){return function(y,l){let $=y.type==="wheel",j=!u&&$&&!y.ctrlKey,J=d3(y,f);if(y.ctrlKey&&$&&J)y.preventDefault();if(j||J)return null;y.preventDefault(),_.call(this,y,l)}}function xB({zoomPanValues:f,onDraggingChange:u,onPanZoomStart:_}){return(y)=>{if(y.sourceEvent?.internal)return;let l=r5(y.transform);if(f.mouseButton=y.sourceEvent?.button||0,f.isZoomingOrPanning=!0,f.prevViewport=l,y.sourceEvent?.type==="mousedown")u(!0);if(_)_?.(y.sourceEvent,l)}}function vB({zoomPanValues:f,panOnDrag:u,onPaneContextMenu:_,onTransformChange:y,onPanZoom:l}){return($)=>{if(f.usedRightMouseButton=!!(_&&PK(u,f.mouseButton??0)),!$.sourceEvent?.sync)y([$.transform.x,$.transform.y,$.transform.k]);if(l&&!$.sourceEvent?.internal)l?.($.sourceEvent,r5($.transform))}}function bB({zoomPanValues:f,panOnDrag:u,panOnScroll:_,onDraggingChange:y,onPanZoomEnd:l,onPaneContextMenu:$}){return(j)=>{if(j.sourceEvent?.internal)return;if(f.isZoomingOrPanning=!1,$&&PK(u,f.mouseButton??0)&&!f.usedRightMouseButton&&j.sourceEvent)$(j.sourceEvent);if(f.usedRightMouseButton=!1,y(!1),l){let J=r5(j.transform);f.prevViewport=J,clearTimeout(f.timerId),f.timerId=setTimeout(()=>{l?.(j.sourceEvent,J)},_?150:0)}}}function hB({zoomActivationKeyPressed:f,zoomOnScroll:u,zoomOnPinch:_,panOnDrag:y,panOnScroll:l,zoomOnDoubleClick:$,userSelectionActive:j,noWheelClassName:J,noPanClassName:F,lib:Q,connectionInProgress:U}){return(z)=>{let W=f||u,K=_&&z.ctrlKey,q=z.type==="wheel";if(z.button===1&&z.type==="mousedown"&&(d3(z,`${Q}-flow__node`)||d3(z,`${Q}-flow__edge`)))return!0;if(!y&&!W&&!l&&!$&&!_)return!1;if(j)return!1;if(U&&!q)return!1;if(d3(z,J)&&q)return!1;if(d3(z,F)&&(!q||l&&q&&!f))return!1;if(!_&&z.ctrlKey&&q)return!1;if(!_&&z.type==="touchstart"&&z.touches?.length>1)return z.preventDefault(),!1;if(!W&&!l&&!K&&q)return!1;if(!y&&(z.type==="mousedown"||z.type==="touchstart"))return!1;if(Array.isArray(y)&&!y.includes(z.button)&&z.type==="mousedown")return!1;let V=Array.isArray(y)&&y.includes(z.button)||!z.button||z.button<=1;return(!z.ctrlKey||q)&&V}}function RK({domNode:f,minZoom:u,maxZoom:_,translateExtent:y,viewport:l,onPanZoom:$,onPanZoomStart:j,onPanZoomEnd:J,onDraggingChange:F}){let Q={isZoomingOrPanning:!1,usedRightMouseButton:!1,prevViewport:{x:0,y:0,zoom:0},mouseButton:0,timerId:void 0,panScrollTimeout:void 0,isPanScrolling:!1},U=f.getBoundingClientRect(),z=W6().scaleExtent([u,_]).translateExtent(y),W=C0(f).call(z);H({x:l.x,y:l.y,zoom:e3(l.zoom,u,_)},[[0,0],[U.width,U.height]],y);let K=W.on("wheel.zoom"),q=W.on("dblclick.zoom");z.wheelDelta(CK);function V(x,C){if(W)return new Promise((P)=>{z?.interpolate(C?.interpolate==="linear"?x1:Iy).transform(oJ(W,C?.duration,C?.ease,()=>P(!0)),x)});return Promise.resolve(!1)}function O({noWheelClassName:x,noPanClassName:C,onPaneContextMenu:P,userSelectionActive:D,panOnScroll:T,panOnDrag:S,panOnScrollMode:r,panOnScrollSpeed:Y,preventScrolling:v,zoomOnPinch:m,zoomOnScroll:c,zoomOnDoubleClick:o,zoomActivationKeyPressed:ff,lib:n,onTransformChange:lf,connectionInProgress:Gf,paneClickDistance:zf,selectionOnDrag:jf}){if(D&&!Q.isZoomingOrPanning)G();let Wf=T&&!ff&&!D;z.clickDistance(jf?1/0:!u1(zf)||zf<0?0:zf);let Vf=Wf?CB({zoomPanValues:Q,noWheelClassName:x,d3Selection:W,d3Zoom:z,panOnScrollMode:r,panOnScrollSpeed:Y,zoomOnPinch:m,onPanZoomStart:j,onPanZoom:$,onPanZoomEnd:J}):RB({noWheelClassName:x,preventScrolling:v,d3ZoomHandler:K});if(W.on("wheel.zoom",Vf,{passive:!1}),!D){let h=xB({zoomPanValues:Q,onDraggingChange:F,onPanZoomStart:j});z.on("start",h);let g=vB({zoomPanValues:Q,panOnDrag:S,onPaneContextMenu:!!P,onPanZoom:$,onTransformChange:lf});z.on("zoom",g);let I=bB({zoomPanValues:Q,panOnDrag:S,panOnScroll:T,onPaneContextMenu:P,onPanZoomEnd:J,onDraggingChange:F});z.on("end",I)}let Kf=hB({zoomActivationKeyPressed:ff,panOnDrag:S,zoomOnScroll:c,panOnScroll:T,zoomOnDoubleClick:o,zoomOnPinch:m,userSelectionActive:D,noPanClassName:C,noWheelClassName:x,lib:n,connectionInProgress:Gf});if(z.filter(Kf),o)W.on("dblclick.zoom",q);else W.on("dblclick.zoom",null)}function G(){z.on("zoom",null)}async function H(x,C,P){let D=sJ(x),T=z?.constrain()(D,C,P);if(T)await V(T);return new Promise((S)=>S(T))}async function Z(x,C){let P=sJ(x);return await V(P,C),new Promise((D)=>D(P))}function E(x){if(W){let C=sJ(x),P=W.property("__zoom");if(P.k!==x.zoom||P.x!==x.x||P.y!==x.y)z?.transform(W,C,null,{sync:!0})}}function L(){let x=W?U6(W.node()):{x:0,y:0,k:1};return{x:x.x,y:x.y,zoom:x.k}}function M(x,C){if(W)return new Promise((P)=>{z?.interpolate(C?.interpolate==="linear"?x1:Iy).scaleTo(oJ(W,C?.duration,C?.ease,()=>P(!0)),x)});return Promise.resolve(!1)}function N(x,C){if(W)return new Promise((P)=>{z?.interpolate(C?.interpolate==="linear"?x1:Iy).scaleBy(oJ(W,C?.duration,C?.ease,()=>P(!0)),x)});return Promise.resolve(!1)}function w(x){z?.scaleExtent(x)}function R(x){z?.translateExtent(x)}function p(x){let C=!u1(x)||x<0?0:x;z?.clickDistance(C)}return{update:O,destroy:G,setViewport:Z,setViewportConstrained:H,getViewport:L,scaleTo:M,scaleBy:N,setScaleExtent:w,setTranslateExtent:R,syncViewport:E,setClickDistance:p}}var s_;(function(f){f.Line="line",f.Handle="handle"})(s_||(s_={}));function IB({width:f,prevWidth:u,height:_,prevHeight:y,affectsX:l,affectsY:$}){let j=f-u,J=_-y,F=[j>0?1:j<0?-1:0,J>0?1:J<0?-1:0];if(j&&l)F[0]=F[0]*-1;if(J&&$)F[1]=F[1]*-1;return F}function JK(f){let u=f.includes("right")||f.includes("left"),_=f.includes("bottom")||f.includes("top"),y=f.includes("left"),l=f.includes("top");return{isHorizontal:u,isVertical:_,affectsX:y,affectsY:l}}function k_(f,u){return Math.max(0,u-f)}function i_(f,u){return Math.max(0,f-u)}function H5(f,u,_){return Math.max(0,u-f,f-_)}function FK(f,u){return f?!u:u}function cB(f,u,_,y,l,$,j,J){let{affectsX:F,affectsY:Q}=u,{isHorizontal:U,isVertical:z}=u,W=U&&z,{xSnapped:K,ySnapped:q}=_,{minWidth:V,maxWidth:O,minHeight:G,maxHeight:H}=y,{x:Z,y:E,width:L,height:M,aspectRatio:N}=f,w=Math.floor(U?K-f.pointerX:0),R=Math.floor(z?q-f.pointerY:0),p=L+(F?-w:w),x=M+(Q?-R:R),C=-$[0]*L,P=-$[1]*M,D=H5(p,V,O),T=H5(x,G,H);if(j){let Y=0,v=0;if(F&&w<0)Y=k_(Z+w+C,j[0][0]);else if(!F&&w>0)Y=i_(Z+p+C,j[1][0]);if(Q&&R<0)v=k_(E+R+P,j[0][1]);else if(!Q&&R>0)v=i_(E+x+P,j[1][1]);D=Math.max(D,Y),T=Math.max(T,v)}if(J){let Y=0,v=0;if(F&&w>0)Y=i_(Z+w,J[0][0]);else if(!F&&w<0)Y=k_(Z+p,J[1][0]);if(Q&&R>0)v=i_(E+R,J[0][1]);else if(!Q&&R<0)v=k_(E+x,J[1][1]);D=Math.max(D,Y),T=Math.max(T,v)}if(l){if(U){let Y=H5(p/N,G,H)*N;if(D=Math.max(D,Y),j){let v=0;if(!F&&!Q||F&&!Q&&W)v=i_(E+P+p/N,j[1][1])*N;else v=k_(E+P+(F?w:-w)/N,j[0][1])*N;D=Math.max(D,v)}if(J){let v=0;if(!F&&!Q||F&&!Q&&W)v=k_(E+p/N,J[1][1])*N;else v=i_(E+(F?w:-w)/N,J[0][1])*N;D=Math.max(D,v)}}if(z){let Y=H5(x*N,V,O)/N;if(T=Math.max(T,Y),j){let v=0;if(!F&&!Q||Q&&!F&&W)v=i_(Z+x*N+C,j[1][0])/N;else v=k_(Z+(Q?R:-R)*N+C,j[0][0])/N;T=Math.max(T,v)}if(J){let v=0;if(!F&&!Q||Q&&!F&&W)v=k_(Z+x*N,J[1][0])/N;else v=i_(Z+(Q?R:-R)*N,J[0][0])/N;T=Math.max(T,v)}}}if(R=R+(R<0?T:-T),w=w+(w<0?D:-D),l)if(W)if(p>x*N)R=(FK(F,Q)?-w:w)/N;else w=(FK(F,Q)?-R:R)*N;else if(U)R=w/N,Q=F;else w=R*N,F=Q;let S=F?Z+w:Z,r=Q?E+R:E;return{width:L+(F?-w:w),height:M+(Q?-R:R),x:$[0]*w*(!F?1:-1)+S,y:$[1]*R*(!Q?1:-1)+r}}var xK={width:0,height:0,x:0,y:0},pB={...xK,pointerX:0,pointerY:0,aspectRatio:1};function mB(f){return[[0,0],[f.measured.width,f.measured.height]]}function kB(f,u,_){let y=u.position.x+f.position.x,l=u.position.y+f.position.y,$=f.measured.width??0,j=f.measured.height??0,J=_[0]*$,F=_[1]*j;return[[y-J,l-F],[y+$-J,l+j-F]]}function vK({domNode:f,nodeId:u,getStoreItems:_,onChange:y,onEnd:l}){let $=C0(f),j={controlDirection:JK("bottom-right"),boundaries:{minWidth:0,minHeight:0,maxWidth:Number.MAX_VALUE,maxHeight:Number.MAX_VALUE},resizeDirection:void 0,keepAspectRatio:!1};function J({controlPosition:Q,boundaries:U,keepAspectRatio:z,resizeDirection:W,onResizeStart:K,onResize:q,onResizeEnd:V,shouldResize:O}){let G={...xK},H={...pB};j={boundaries:U,resizeDirection:W,keepAspectRatio:z,controlDirection:JK(Q)};let Z=void 0,E=null,L=[],M=void 0,N=void 0,w=void 0,R=!1,p=t$().on("start",(x)=>{let{nodeLookup:C,transform:P,snapGrid:D,snapToGrid:T,nodeOrigin:S,paneDomNode:r}=_();if(Z=C.get(u),!Z)return;E=r?.getBoundingClientRect()??null;let{xSnapped:Y,ySnapped:v}=z6(x.sourceEvent,{transform:P,snapGrid:D,snapToGrid:T,containerBounds:E});if(G={width:Z.measured.width??0,height:Z.measured.height??0,x:Z.position.x??0,y:Z.position.y??0},H={...G,pointerX:Y,pointerY:v,aspectRatio:G.width/G.height},M=void 0,Z.parentId&&(Z.extent==="parent"||Z.expandParent))M=C.get(Z.parentId),N=M&&Z.extent==="parent"?mB(M):void 0;L=[],w=void 0;for(let[m,c]of C)if(c.parentId===u){if(L.push({id:m,position:{...c.position},extent:c.extent}),c.extent==="parent"||c.expandParent){let o=kB(c,Z,c.origin??S);if(w)w=[[Math.min(o[0][0],w[0][0]),Math.min(o[0][1],w[0][1])],[Math.max(o[1][0],w[1][0]),Math.max(o[1][1],w[1][1])]];else w=o}}K?.(x,{...G})}).on("drag",(x)=>{let{transform:C,snapGrid:P,snapToGrid:D,nodeOrigin:T}=_(),S=z6(x.sourceEvent,{transform:C,snapGrid:P,snapToGrid:D,containerBounds:E}),r=[];if(!Z)return;let{x:Y,y:v,width:m,height:c}=G,o={},ff=Z.origin??T,{width:n,height:lf,x:Gf,y:zf}=cB(H,j.controlDirection,S,j.boundaries,j.keepAspectRatio,ff,N,w),jf=n!==m,Wf=lf!==c,Vf=Gf!==Y&&jf,Kf=zf!==v&&Wf;if(!Vf&&!Kf&&!jf&&!Wf)return;if(Vf||Kf||ff[0]===1||ff[1]===1){if(o.x=Vf?Gf:G.x,o.y=Kf?zf:G.y,G.x=o.x,G.y=o.y,L.length>0){let yf=Gf-Y,$f=zf-v;for(let Qf of L)Qf.position={x:Qf.position.x-yf+ff[0]*(n-m),y:Qf.position.y-$f+ff[1]*(lf-c)},r.push(Qf)}}if(jf||Wf)o.width=jf&&(!j.resizeDirection||j.resizeDirection==="horizontal")?n:G.width,o.height=Wf&&(!j.resizeDirection||j.resizeDirection==="vertical")?lf:G.height,G.width=o.width,G.height=o.height;if(M&&Z.expandParent){let yf=ff[0]*(o.width??0);if(o.x&&o.x{if(!R)return;V?.(x,{...G}),l?.({...G}),R=!1});$.call(p)}function F(){$.on(".drag",null)}return{update:J,destroy:F}}var sK=Sf(I0(),1),oK=Sf(iK(),1);var gK=(f)=>{let u,_=new Set,y=(U,z)=>{let W=typeof U==="function"?U(u):U;if(!Object.is(W,u)){let K=u;u=(z!=null?z:typeof W!=="object"||W===null)?W:Object.assign({},u,W),_.forEach((q)=>q(u,K))}},l=()=>u,F={setState:y,getState:l,getInitialState:()=>Q,subscribe:(U)=>{return _.add(U),()=>_.delete(U)},destroy:()=>{_.clear()}},Q=u=f(y,l,F);return F},nK=(f)=>f?gK(f):gK;var{useDebugValue:Fw}=sK.default,{useSyncExternalStoreWithSelector:Aw}=oK.default,Qw=(f)=>f;function NF(f,u=Qw,_){let y=Aw(f.subscribe,f.getState,f.getServerState||f.getInitialState,u,_);return Fw(y),y}var tK=(f,u)=>{let _=nK(f),y=(l,$=u)=>NF(_,l,$);return Object.assign(y,_),y},aK=(f,u)=>f?tK(f,u):tK;function Q0(f,u){if(Object.is(f,u))return!0;if(typeof f!=="object"||f===null||typeof u!=="object"||u===null)return!1;if(f instanceof Map&&u instanceof Map){if(f.size!==u.size)return!1;for(let[y,l]of f)if(!Object.is(l,u.get(y)))return!1;return!0}if(f instanceof Set&&u instanceof Set){if(f.size!==u.size)return!1;for(let y of f)if(!u.has(y))return!1;return!0}let _=Object.keys(f);if(_.length!==Object.keys(u).length)return!1;for(let y of _)if(!Object.prototype.hasOwnProperty.call(u,y)||!Object.is(f[y],u[y]))return!1;return!0}var Uw=Sf(m7(),1),x5=d.createContext(null),Ww=x5.Provider,OZ=hu.error001();function mf(f,u){let _=d.useContext(x5);if(_===null)throw Error(OZ);return NF(_,f,u)}function W0(){let f=d.useContext(x5);if(f===null)throw Error(OZ);return d.useMemo(()=>({getState:f.getState,setState:f.setState,subscribe:f.subscribe}),[f])}var dK={display:"none"},zw={position:"absolute",width:1,height:1,margin:-1,border:0,padding:0,overflow:"hidden",clip:"rect(0px, 0px, 0px, 0px)",clipPath:"inset(100%)"},XZ="react-flow__node-desc",NZ="react-flow__edge-desc",Gw="react-flow__aria-live",Kw=(f)=>f.ariaLiveMessage,Zw=(f)=>f.ariaLabelConfig;function qw({rfId:f}){let u=mf(Kw);return t.jsx("div",{id:`${Gw}-${f}`,"aria-live":"assertive","aria-atomic":"true",style:zw,children:u})}function Hw({rfId:f,disableKeyboardA11y:u}){let _=mf(Zw);return t.jsxs(t.Fragment,{children:[t.jsx("div",{id:`${XZ}-${f}`,style:dK,children:u?_["node.a11yDescription.default"]:_["node.a11yDescription.keyboardDisabled"]}),t.jsx("div",{id:`${NZ}-${f}`,style:dK,children:_["edge.a11yDescription.default"]}),!u&&t.jsx(qw,{rfId:f})]})}var v5=d.forwardRef(({position:f="top-left",children:u,className:_,style:y,...l},$)=>{let j=`${f}`.split("-");return t.jsx("div",{className:Y0(["react-flow__panel",_,...j]),style:y,ref:$,...l,children:u})});v5.displayName="Panel";function Vw({proOptions:f,position:u="bottom-right"}){if(f?.hideAttribution)return null;return t.jsx(v5,{position:u,className:"react-flow__attribution","data-message":"Please only hide this attribution when you are subscribed to React Flow Pro: https://pro.reactflow.dev",children:t.jsx("a",{href:"https://reactflow.dev",target:"_blank",rel:"noopener noreferrer","aria-label":"React Flow attribution",children:"React Flow"})})}var Ew=(f)=>{let u=[],_=[];for(let[,y]of f.nodeLookup)if(y.selected)u.push(y.internals.userNode);for(let[,y]of f.edgeLookup)if(y.selected)_.push(y);return{selectedNodes:u,selectedEdges:_}},P5=(f)=>f.id;function Ow(f,u){return Q0(f.selectedNodes.map(P5),u.selectedNodes.map(P5))&&Q0(f.selectedEdges.map(P5),u.selectedEdges.map(P5))}function Xw({onSelectionChange:f}){let u=W0(),{selectedNodes:_,selectedEdges:y}=mf(Ew,Ow);return d.useEffect(()=>{let l={nodes:_,edges:y};f?.(l),u.getState().onSelectionChangeHandlers.forEach(($)=>$(l))},[_,y,f]),null}var Nw=(f)=>!!f.onSelectionChangeHandlers;function Lw({onSelectionChange:f}){let u=mf(Nw);if(f||u)return t.jsx(Xw,{onSelectionChange:f});return null}var BF=typeof window<"u"?d.useLayoutEffect:d.useEffect,LZ=[0,0],Yw={x:0,y:0,zoom:1},Bw=["nodes","edges","defaultNodes","defaultEdges","onConnect","onConnectStart","onConnectEnd","onClickConnectStart","onClickConnectEnd","nodesDraggable","autoPanOnNodeFocus","nodesConnectable","nodesFocusable","edgesFocusable","edgesReconnectable","elevateNodesOnSelect","elevateEdgesOnSelect","minZoom","maxZoom","nodeExtent","onNodesChange","onEdgesChange","elementsSelectable","connectionMode","snapGrid","snapToGrid","translateExtent","connectOnClick","defaultEdgeOptions","fitView","fitViewOptions","onNodesDelete","onEdgesDelete","onDelete","onNodeDrag","onNodeDragStart","onNodeDragStop","onSelectionDrag","onSelectionDragStart","onSelectionDragStop","onMoveStart","onMove","onMoveEnd","noPanClassName","nodeOrigin","autoPanOnConnect","autoPanOnNodeDrag","onError","connectionRadius","isValidConnection","selectNodesOnDrag","nodeDragThreshold","connectionDragThreshold","onBeforeDelete","debug","autoPanSpeed","ariaLabelConfig","zIndexMode"],eK=[...Bw,"rfId"],ww=(f)=>({setNodes:f.setNodes,setEdges:f.setEdges,setMinZoom:f.setMinZoom,setMaxZoom:f.setMaxZoom,setTranslateExtent:f.setTranslateExtent,setNodeExtent:f.setNodeExtent,reset:f.reset,setDefaultNodesAndEdges:f.setDefaultNodesAndEdges}),fZ={translateExtent:ul,nodeOrigin:LZ,minZoom:0.5,maxZoom:2,elementsSelectable:!0,noPanClassName:"nopan",rfId:"1"};function Dw(f){let{setNodes:u,setEdges:_,setMinZoom:y,setMaxZoom:l,setTranslateExtent:$,setNodeExtent:j,reset:J,setDefaultNodesAndEdges:F}=mf(ww,Q0),Q=W0();BF(()=>{return F(f.defaultNodes,f.defaultEdges),()=>{U.current=fZ,J()}},[]);let U=d.useRef(fZ);return BF(()=>{for(let z of eK){let W=f[z],K=U.current[z];if(W===K)continue;if(typeof f[z]>"u")continue;if(z==="nodes")u(W);else if(z==="edges")_(W);else if(z==="minZoom")y(W);else if(z==="maxZoom")l(W);else if(z==="translateExtent")$(W);else if(z==="nodeExtent")j(W);else if(z==="ariaLabelConfig")Q.setState({ariaLabelConfig:ZK(W)});else if(z==="fitView")Q.setState({fitViewQueued:W});else if(z==="fitViewOptions")Q.setState({fitViewOptions:W});else Q.setState({[z]:W})}U.current=f},eK.map((z)=>f[z])),null}function uZ(){if(typeof window>"u"||!window.matchMedia)return null;return window.matchMedia("(prefers-color-scheme: dark)")}function Tw(f){let[u,_]=d.useState(f==="system"?null:f);return d.useEffect(()=>{if(f!=="system"){_(f);return}let y=uZ(),l=()=>_(y?.matches?"dark":"light");return l(),y?.addEventListener("change",l),()=>{y?.removeEventListener("change",l)}},[f]),u!==null?u:uZ()?.matches?"dark":"light"}var _Z=typeof document<"u"?document:null;function H6(f=null,u={target:_Z,actInsideInputWithModifier:!0}){let[_,y]=d.useState(!1),l=d.useRef(!1),$=d.useRef(new Set([])),[j,J]=d.useMemo(()=>{if(f!==null){let Q=(Array.isArray(f)?f:[f]).filter((z)=>typeof z==="string").map((z)=>z.replace("+",` +`).replace(` + +`,` ++`).split(` +`)),U=Q.reduce((z,W)=>z.concat(...W),[]);return[Q,U]}return[[],[]]},[f]);return d.useEffect(()=>{let F=u?.target??_Z,Q=u?.actInsideInputWithModifier??!0;if(f!==null){let U=(K)=>{if(l.current=K.ctrlKey||K.metaKey||K.shiftKey||K.altKey,(!l.current||l.current&&!Q)&&zF(K))return!1;let V=lZ(K.code,J);if($.current.add(K[V]),yZ(j,$.current,!1)){let O=K.composedPath?.()?.[0]||K.target,G=O?.nodeName==="BUTTON"||O?.nodeName==="A";if(u.preventDefault!==!1&&(l.current||!G))K.preventDefault();y(!0)}},z=(K)=>{let q=lZ(K.code,J);if(yZ(j,$.current,!0))y(!1),$.current.clear();else $.current.delete(K[q]);if(K.key==="Meta")$.current.clear();l.current=!1},W=()=>{$.current.clear(),y(!1)};return F?.addEventListener("keydown",U),F?.addEventListener("keyup",z),window.addEventListener("blur",W),window.addEventListener("contextmenu",W),()=>{F?.removeEventListener("keydown",U),F?.removeEventListener("keyup",z),window.removeEventListener("blur",W),window.removeEventListener("contextmenu",W)}}},[f,y]),_}function yZ(f,u,_){return f.filter((y)=>_||y.length===u.size).some((y)=>y.every((l)=>u.has(l)))}function lZ(f,u){return u.includes(f)?"code":"key"}var Mw=()=>{let f=W0();return d.useMemo(()=>{return{zoomIn:(u)=>{let{panZoom:_}=f.getState();return _?_.scaleBy(1.2,u):Promise.resolve(!1)},zoomOut:(u)=>{let{panZoom:_}=f.getState();return _?_.scaleBy(0.8333333333333334,u):Promise.resolve(!1)},zoomTo:(u,_)=>{let{panZoom:y}=f.getState();return y?y.scaleTo(u,_):Promise.resolve(!1)},getZoom:()=>f.getState().transform[2],setViewport:async(u,_)=>{let{transform:[y,l,$],panZoom:j}=f.getState();if(!j)return Promise.resolve(!1);return await j.setViewport({x:u.x??y,y:u.y??l,zoom:u.zoom??$},_),Promise.resolve(!0)},getViewport:()=>{let[u,_,y]=f.getState().transform;return{x:u,y:_,zoom:y}},setCenter:async(u,_,y)=>{return f.getState().setCenter(u,_,y)},fitBounds:async(u,_)=>{let{width:y,height:l,minZoom:$,maxZoom:j,panZoom:J}=f.getState(),F=Z6(u,y,l,$,j,_?.padding??0.1);if(!J)return Promise.resolve(!1);return await J.setViewport(F,{duration:_?.duration,ease:_?.ease,interpolate:_?.interpolate}),Promise.resolve(!0)},screenToFlowPosition:(u,_={})=>{let{transform:y,snapGrid:l,snapToGrid:$,domNode:j}=f.getState();if(!j)return u;let{x:J,y:F}=j.getBoundingClientRect(),Q={x:u.x-J,y:u.y-F},U=_.snapGrid??l,z=_.snapToGrid??$;return $l(Q,y,z,U)},flowToScreenPosition:(u)=>{let{transform:_,domNode:y}=f.getState();if(!y)return u;let{x:l,y:$}=y.getBoundingClientRect(),j=G6(u,_);return{x:j.x+l,y:j.y+$}}}},[])};function YZ(f,u){let _=[],y=new Map,l=[];for(let $ of f)if($.type==="add"){l.push($);continue}else if($.type==="remove"||$.type==="replace")y.set($.id,[$]);else{let j=y.get($.id);if(j)j.push($);else y.set($.id,[$])}for(let $ of u){let j=y.get($.id);if(!j){_.push($);continue}if(j[0].type==="remove")continue;if(j[0].type==="replace"){_.push({...j[0].item});continue}let J={...$};for(let F of j)rw(F,J);_.push(J)}if(l.length)l.forEach(($)=>{if($.index!==void 0)_.splice($.index,0,{...$.item});else _.push({...$.item})});return _}function rw(f,u){switch(f.type){case"select":{u.selected=f.selected;break}case"position":{if(typeof f.position<"u")u.position=f.position;if(typeof f.dragging<"u")u.dragging=f.dragging;break}case"dimensions":{if(typeof f.dimensions<"u"){if(u.measured={...f.dimensions},f.setAttributes){if(f.setAttributes===!0||f.setAttributes==="width")u.width=f.dimensions.width;if(f.setAttributes===!0||f.setAttributes==="height")u.height=f.dimensions.height}}if(typeof f.resizing==="boolean")u.resizing=f.resizing;break}}}function Sw(f,u){return YZ(f,u)}function Pw(f,u){return YZ(f,u)}function ny(f,u){return{id:f,type:"select",selected:u}}function Fl(f,u=new Set,_=!1){let y=[];for(let[l,$]of f){let j=u.has(l);if(!($.selected===void 0&&!j)&&$.selected!==j){if(_)$.selected=j;y.push(ny($.id,j))}}return y}function $Z({items:f=[],lookup:u}){let _=[],y=new Map(f.map((l)=>[l.id,l]));for(let[l,$]of f.entries()){let j=u.get($.id),J=j?.internals?.userNode??j;if(J!==void 0&&J!==$)_.push({id:$.id,item:$,type:"replace"});if(J===void 0)_.push({item:$,type:"add",index:l})}for(let[l]of u)if(y.get(l)===void 0)_.push({id:l,type:"remove"});return _}function jZ(f){return{id:f.id,type:"remove"}}var JZ=(f)=>AK(f),Cw=(f)=>_F(f);function BZ(f){return d.forwardRef(f)}function FZ(f){let[u,_]=d.useState(BigInt(0)),[y]=d.useState(()=>Rw(()=>_((l)=>l+BigInt(1))));return BF(()=>{let l=y.get();if(l.length)f(l),y.reset()},[u]),y}function Rw(f){let u=[];return{get:()=>u,reset:()=>{u=[]},push:(_)=>{u.push(_),f()}}}var wZ=d.createContext(null);function xw({children:f}){let u=W0(),_=d.useCallback((J)=>{let{nodes:F=[],setNodes:Q,hasDefaultNodes:U,onNodesChange:z,nodeLookup:W,fitViewQueued:K,onNodesChangeMiddlewareMap:q}=u.getState(),V=F;for(let G of J)V=typeof G==="function"?G(V):G;let O=$Z({items:V,lookup:W});for(let G of q.values())O=G(O);if(U)Q(V);if(O.length>0)z?.(O);else if(K)window.requestAnimationFrame(()=>{let{fitViewQueued:G,nodes:H,setNodes:Z}=u.getState();if(G)Z(H)})},[]),y=FZ(_),l=d.useCallback((J)=>{let{edges:F=[],setEdges:Q,hasDefaultEdges:U,onEdgesChange:z,edgeLookup:W}=u.getState(),K=F;for(let q of J)K=typeof q==="function"?q(K):q;if(U)Q(K);else if(z)z($Z({items:K,lookup:W}))},[]),$=FZ(l),j=d.useMemo(()=>({nodeQueue:y,edgeQueue:$}),[]);return t.jsx(wZ.Provider,{value:j,children:f})}function vw(){let f=d.useContext(wZ);if(!f)throw Error("useBatchContext must be used within a BatchProvider");return f}var bw=(f)=>!!f.panZoom;function DF(){let f=Mw(),u=W0(),_=vw(),y=mf(bw),l=d.useMemo(()=>{let $=(z)=>u.getState().nodeLookup.get(z),j=(z)=>{_.nodeQueue.push(z)},J=(z)=>{_.edgeQueue.push(z)},F=(z)=>{let{nodeLookup:W,nodeOrigin:K}=u.getState(),q=JZ(z)?z:W.get(z.id),V=q.parentId?QF(q.position,q.measured,q.parentId,W,K):q.position,O={...q,position:V,width:q.measured?.width??q.width,height:q.measured?.height??q.height};return gy(O)},Q=(z,W,K={replace:!1})=>{j((q)=>q.map((V)=>{if(V.id===z){let O=typeof W==="function"?W(V):W;return K.replace&&JZ(O)?O:{...V,...O}}return V}))},U=(z,W,K={replace:!1})=>{J((q)=>q.map((V)=>{if(V.id===z){let O=typeof W==="function"?W(V):W;return K.replace&&Cw(O)?O:{...V,...O}}return V}))};return{getNodes:()=>u.getState().nodes.map((z)=>({...z})),getNode:(z)=>$(z)?.internals.userNode,getInternalNode:$,getEdges:()=>{let{edges:z=[]}=u.getState();return z.map((W)=>({...W}))},getEdge:(z)=>u.getState().edgeLookup.get(z),setNodes:j,setEdges:J,addNodes:(z)=>{let W=Array.isArray(z)?z:[z];_.nodeQueue.push((K)=>[...K,...W])},addEdges:(z)=>{let W=Array.isArray(z)?z:[z];_.edgeQueue.push((K)=>[...K,...W])},toObject:()=>{let{nodes:z=[],edges:W=[],transform:K}=u.getState(),[q,V,O]=K;return{nodes:z.map((G)=>({...G})),edges:W.map((G)=>({...G})),viewport:{x:q,y:V,zoom:O}}},deleteElements:async({nodes:z=[],edges:W=[]})=>{let{nodes:K,edges:q,onNodesDelete:V,onEdgesDelete:O,triggerNodeChanges:G,triggerEdgeChanges:H,onDelete:Z,onBeforeDelete:E}=u.getState(),{nodes:L,edges:M}=await WK({nodesToRemove:z,edgesToRemove:W,nodes:K,edges:q,onBeforeDelete:E}),N=M.length>0,w=L.length>0;if(N){let R=M.map(jZ);O?.(M),H(R)}if(w){let R=L.map(jZ);V?.(L),G(R)}if(w||N)Z?.({nodes:L,edges:M});return{deletedNodes:L,deletedEdges:M}},getIntersectingNodes:(z,W=!0,K)=>{let q=JF(z),V=q?z:F(z),O=K!==void 0;if(!V)return[];return(K||u.getState().nodes).filter((G)=>{let H=u.getState().nodeLookup.get(G.id);if(H&&!q&&(G.id===z.id||!H.internals.positionAbsolute))return!1;let Z=gy(O?G:H),E=yl(Z,V);return W&&E>0||E>=Z.width*Z.height||E>=V.width*V.height})},isNodeIntersecting:(z,W,K=!0)=>{let V=JF(z)?z:F(z);if(!V)return!1;let O=yl(V,W);return K&&O>0||O>=W.width*W.height||O>=V.width*V.height},updateNode:Q,updateNodeData:(z,W,K={replace:!1})=>{Q(z,(q)=>{let V=typeof W==="function"?W(q):W;return K.replace?{...q,data:V}:{...q,data:{...q.data,...V}}},K)},updateEdge:U,updateEdgeData:(z,W,K={replace:!1})=>{U(z,(q)=>{let V=typeof W==="function"?W(q):W;return K.replace?{...q,data:V}:{...q,data:{...q.data,...V}}},K)},getNodesBounds:(z)=>{let{nodeLookup:W,nodeOrigin:K}=u.getState();return lF(z,{nodeLookup:W,nodeOrigin:K})},getHandleConnections:({type:z,id:W,nodeId:K})=>Array.from(u.getState().connectionLookup.get(`${K}-${z}${W?`-${W}`:""}`)?.values()??[]),getNodeConnections:({type:z,handleId:W,nodeId:K})=>Array.from(u.getState().connectionLookup.get(`${K}${z?W?`-${z}-${W}`:`-${z}`:""}`)?.values()??[]),fitView:async(z)=>{let W=u.getState().fitViewResolver??KK();return u.setState({fitViewQueued:!0,fitViewOptions:z,fitViewResolver:W}),_.nodeQueue.push((K)=>[...K]),W.promise}}},[]);return d.useMemo(()=>{return{...l,...f,viewportInitialized:y}},[y])}var AZ=(f)=>f.selected,hw=typeof window<"u"?window:void 0;function Iw({deleteKeyCode:f,multiSelectionKeyCode:u}){let _=W0(),{deleteElements:y}=DF(),l=H6(f,{actInsideInputWithModifier:!1}),$=H6(u,{target:hw});d.useEffect(()=>{if(l){let{edges:j,nodes:J}=_.getState();y({nodes:J.filter(AZ),edges:j.filter(AZ)}),_.setState({nodesSelectionActive:!1})}},[l]),d.useEffect(()=>{_.setState({multiSelectionActive:$})},[$])}function cw(f){let u=W0();d.useEffect(()=>{let _=()=>{if(!f.current||!(f.current.checkVisibility?.()??!0))return!1;let y=N5(f.current);if(y.height===0||y.width===0)u.getState().onError?.("004",hu.error004());u.setState({width:y.width||500,height:y.height||500})};if(f.current){_(),window.addEventListener("resize",_);let y=new ResizeObserver(()=>_());return y.observe(f.current),()=>{if(window.removeEventListener("resize",_),y&&f.current)y.unobserve(f.current)}}},[])}var b5={position:"absolute",width:"100%",height:"100%",top:0,left:0},pw=(f)=>({userSelectionActive:f.userSelectionActive,lib:f.lib,connectionInProgress:f.connection.inProgress});function mw({onPaneContextMenu:f,zoomOnScroll:u=!0,zoomOnPinch:_=!0,panOnScroll:y=!1,panOnScrollSpeed:l=0.5,panOnScrollMode:$=A_.Free,zoomOnDoubleClick:j=!0,panOnDrag:J=!0,defaultViewport:F,translateExtent:Q,minZoom:U,maxZoom:z,zoomActivationKeyCode:W,preventScrolling:K=!0,children:q,noWheelClassName:V,noPanClassName:O,onViewportChange:G,isControlledViewport:H,paneClickDistance:Z,selectionOnDrag:E}){let L=W0(),M=d.useRef(null),{userSelectionActive:N,lib:w,connectionInProgress:R}=mf(pw,Q0),p=H6(W),x=d.useRef();cw(M);let C=d.useCallback((P)=>{if(G?.({x:P[0],y:P[1],zoom:P[2]}),!H)L.setState({transform:P})},[G,H]);return d.useEffect(()=>{if(M.current){x.current=RK({domNode:M.current,minZoom:U,maxZoom:z,translateExtent:Q,viewport:F,onDraggingChange:(S)=>L.setState((r)=>r.paneDragging===S?r:{paneDragging:S}),onPanZoomStart:(S,r)=>{let{onViewportChangeStart:Y,onMoveStart:v}=L.getState();v?.(S,r),Y?.(r)},onPanZoom:(S,r)=>{let{onViewportChange:Y,onMove:v}=L.getState();v?.(S,r),Y?.(r)},onPanZoomEnd:(S,r)=>{let{onViewportChangeEnd:Y,onMoveEnd:v}=L.getState();v?.(S,r),Y?.(r)}});let{x:P,y:D,zoom:T}=x.current.getViewport();return L.setState({panZoom:x.current,transform:[P,D,T],domNode:M.current.closest(".react-flow")}),()=>{x.current?.destroy()}}},[]),d.useEffect(()=>{x.current?.update({onPaneContextMenu:f,zoomOnScroll:u,zoomOnPinch:_,panOnScroll:y,panOnScrollSpeed:l,panOnScrollMode:$,zoomOnDoubleClick:j,panOnDrag:J,zoomActivationKeyPressed:p,preventScrolling:K,noPanClassName:O,userSelectionActive:N,noWheelClassName:V,lib:w,onTransformChange:C,connectionInProgress:R,selectionOnDrag:E,paneClickDistance:Z})},[f,u,_,y,l,$,j,J,p,K,O,N,V,w,C,R,E,Z]),t.jsx("div",{className:"react-flow__renderer",ref:M,style:b5,children:q})}var kw=(f)=>({userSelectionActive:f.userSelectionActive,userSelectionRect:f.userSelectionRect});function iw(){let{userSelectionActive:f,userSelectionRect:u}=mf(kw,Q0);if(!(f&&u))return null;return t.jsx("div",{className:"react-flow__selection react-flow__container",style:{width:u.width,height:u.height,transform:`translate(${u.x}px, ${u.y}px)`}})}var LF=(f,u)=>{return(_)=>{if(_.target!==u.current)return;f?.(_)}},gw=(f)=>({userSelectionActive:f.userSelectionActive,elementsSelectable:f.elementsSelectable,connectionInProgress:f.connection.inProgress,dragging:f.paneDragging});function nw({isSelecting:f,selectionKeyPressed:u,selectionMode:_=ky.Full,panOnDrag:y,paneClickDistance:l,selectionOnDrag:$,onSelectionStart:j,onSelectionEnd:J,onPaneClick:F,onPaneContextMenu:Q,onPaneScroll:U,onPaneMouseEnter:z,onPaneMouseMove:W,onPaneMouseLeave:K,children:q}){let V=W0(),{userSelectionActive:O,elementsSelectable:G,dragging:H,connectionInProgress:Z}=mf(gw,Q0),E=G&&(f||O),L=d.useRef(null),M=d.useRef(),N=d.useRef(new Set),w=d.useRef(new Set),R=d.useRef(!1),p=(Y)=>{if(R.current||Z){R.current=!1;return}F?.(Y),V.getState().resetSelectedElements(),V.setState({nodesSelectionActive:!1})},x=(Y)=>{if(Array.isArray(y)&&y?.includes(2)){Y.preventDefault();return}Q?.(Y)},C=U?(Y)=>U(Y):void 0,P=(Y)=>{if(R.current)Y.stopPropagation(),R.current=!1},D=(Y)=>{let{domNode:v}=V.getState();if(M.current=v?.getBoundingClientRect(),!M.current)return;let m=Y.target===L.current;if(!m&&!!Y.target.closest(".nokey")||!f||!($&&m||u)||Y.button!==0||!Y.isPrimary)return;Y.target?.setPointerCapture?.(Y.pointerId),R.current=!1;let{x:ff,y:n}=_1(Y.nativeEvent,M.current);if(V.setState({userSelectionRect:{width:0,height:0,startX:ff,startY:n,x:ff,y:n}}),!m)Y.stopPropagation(),Y.preventDefault()},T=(Y)=>{let{userSelectionRect:v,transform:m,nodeLookup:c,edgeLookup:o,connectionLookup:ff,triggerNodeChanges:n,triggerEdgeChanges:lf,defaultEdgeOptions:Gf,resetSelectedElements:zf}=V.getState();if(!M.current||!v)return;let{x:jf,y:Wf}=_1(Y.nativeEvent,M.current),{startX:Vf,startY:Kf}=v;if(!R.current){let $f=u?0:l;if(Math.hypot(jf-Vf,Wf-Kf)<=$f)return;zf(),j?.(Y)}R.current=!0;let h={startX:Vf,startY:Kf,x:jf$f.id)),w.current=new Set;let yf=Gf?.selectable??!0;for(let $f of N.current){let Qf=ff.get($f);if(!Qf)continue;for(let{edgeId:Yf}of Qf.values()){let xf=o.get(Yf);if(xf&&(xf.selectable??yf))w.current.add(Yf)}}if(!UF(g,N.current)){let $f=Fl(c,N.current,!0);n($f)}if(!UF(I,w.current)){let $f=Fl(o,w.current);lf($f)}V.setState({userSelectionRect:h,userSelectionActive:!0,nodesSelectionActive:!1})},S=(Y)=>{if(Y.button!==0)return;if(Y.target?.releasePointerCapture?.(Y.pointerId),!O&&Y.target===L.current&&V.getState().userSelectionRect)p?.(Y);if(V.setState({userSelectionActive:!1,userSelectionRect:null}),R.current)J?.(Y),V.setState({nodesSelectionActive:N.current.size>0})},r=y===!0||Array.isArray(y)&&y.includes(0);return t.jsxs("div",{className:Y0(["react-flow__pane",{draggable:r,dragging:H,selection:f}]),onClick:E?void 0:LF(p,L),onContextMenu:LF(x,L),onWheel:LF(C,L),onPointerEnter:E?void 0:z,onPointerMove:E?T:W,onPointerUp:E?S:void 0,onPointerDownCapture:E?D:void 0,onClickCapture:E?P:void 0,onPointerLeave:K,ref:L,style:b5,children:[q,t.jsx(iw,{})]})}function wF({id:f,store:u,unselect:_=!1,nodeRef:y}){let{addSelectedNodes:l,unselectNodesAndEdges:$,multiSelectionActive:j,nodeLookup:J,onError:F}=u.getState(),Q=J.get(f);if(!Q){F?.("012",hu.error012(f));return}if(u.setState({nodesSelectionActive:!1}),!Q.selected)l([f]);else if(_||Q.selected&&j)$({nodes:[Q],edges:[]}),requestAnimationFrame(()=>y?.current?.blur())}function DZ({nodeRef:f,disabled:u=!1,noDragClassName:_,handleSelector:y,nodeId:l,isSelectable:$,nodeClickDistance:j}){let J=W0(),[F,Q]=d.useState(!1),U=d.useRef();return d.useEffect(()=>{U.current=wK({getStoreItems:()=>J.getState(),onNodeMouseDown:(z)=>{wF({id:z,store:J,nodeRef:f})},onDragStart:()=>{Q(!0)},onDragStop:()=>{Q(!1)}})},[]),d.useEffect(()=>{if(u||!f.current||!U.current)return;return U.current.update({noDragClassName:_,handleSelector:y,domNode:f.current,isSelectable:$,nodeId:l,nodeClickDistance:j}),()=>{U.current?.destroy()}},[_,y,u,$,f,l,j]),F}var tw=(f)=>(u)=>u.selected&&(u.draggable||f&&typeof u.draggable>"u");function TZ(){let f=W0();return d.useCallback((_)=>{let{nodeExtent:y,snapToGrid:l,snapGrid:$,nodesDraggable:j,onError:J,updateNodePositions:F,nodeLookup:Q,nodeOrigin:U}=f.getState(),z=new Map,W=tw(j),K=l?$[0]:5,q=l?$[1]:5,V=_.direction.x*K*_.factor,O=_.direction.y*q*_.factor;for(let[,G]of Q){if(!W(G))continue;let H={x:G.internals.positionAbsolute.x+V,y:G.internals.positionAbsolute.y+O};if(l)H=ll(H,$);let{position:Z,positionAbsolute:E}=$F({nodeId:G.id,nextPosition:H,nodeLookup:Q,nodeExtent:y,nodeOrigin:U,onError:J});G.position=Z,G.internals.positionAbsolute=E,z.set(G.id,G)}F(z)},[])}var TF=d.createContext(null),sw=TF.Provider;TF.Consumer;var MZ=()=>{return d.useContext(TF)},ow=(f)=>({connectOnClick:f.connectOnClick,noPanClassName:f.noPanClassName,rfId:f.rfId}),aw=(f,u,_)=>(y)=>{let{connectionClickStartHandle:l,connectionMode:$,connection:j}=y,{fromHandle:J,toHandle:F,isValid:Q}=j,U=F?.nodeId===f&&F?.id===u&&F?.type===_;return{connectingFrom:J?.nodeId===f&&J?.id===u&&J?.type===_,connectingTo:U,clickConnecting:l?.nodeId===f&&l?.id===u&&l?.type===_,isPossibleEndHandle:$===g_.Strict?J?.type!==_:f!==J?.nodeId||u!==J?.id,connectionInProcess:!!J,clickConnectionInProcess:!!l,valid:U&&Q}};function dw({type:f="source",position:u=Uf.Top,isValidConnection:_,isConnectable:y=!0,isConnectableStart:l=!0,isConnectableEnd:$=!0,id:j,onConnect:J,children:F,className:Q,onMouseDown:U,onTouchStart:z,...W},K){let q=j||null,V=f==="target",O=W0(),G=MZ(),{connectOnClick:H,noPanClassName:Z,rfId:E}=mf(ow,Q0),{connectingFrom:L,connectingTo:M,clickConnecting:N,isPossibleEndHandle:w,connectionInProcess:R,clickConnectionInProcess:p,valid:x}=mf(aw(G,q,f),Q0);if(!G)O.getState().onError?.("010",hu.error010());let C=(T)=>{let{defaultEdgeOptions:S,onConnect:r,hasDefaultEdges:Y}=O.getState(),v={...S,...T};if(Y){let{edges:m,setEdges:c}=O.getState();c(ZF(v,m))}r?.(v),J?.(v)},P=(T)=>{if(!G)return;let S=GF(T.nativeEvent);if(l&&(S&&T.button===0||!S)){let r=O.getState();M5.onPointerDown(T.nativeEvent,{handleDomNode:T.currentTarget,autoPanOnConnect:r.autoPanOnConnect,connectionMode:r.connectionMode,connectionRadius:r.connectionRadius,domNode:r.domNode,nodeLookup:r.nodeLookup,lib:r.lib,isTarget:V,handleId:q,nodeId:G,flowId:r.rfId,panBy:r.panBy,cancelConnection:r.cancelConnection,onConnectStart:r.onConnectStart,onConnectEnd:(...Y)=>O.getState().onConnectEnd?.(...Y),updateConnection:r.updateConnection,onConnect:C,isValidConnection:_||((...Y)=>O.getState().isValidConnection?.(...Y)??!0),getTransform:()=>O.getState().transform,getFromHandle:()=>O.getState().connection.fromHandle,autoPanSpeed:r.autoPanSpeed,dragThreshold:r.connectionDragThreshold})}if(S)U?.(T);else z?.(T)},D=(T)=>{let{onClickConnectStart:S,onClickConnectEnd:r,connectionClickStartHandle:Y,connectionMode:v,isValidConnection:m,lib:c,rfId:o,nodeLookup:ff,connection:n}=O.getState();if(!G||!Y&&!l)return;if(!Y){S?.(T.nativeEvent,{nodeId:G,handleId:q,handleType:f}),O.setState({connectionClickStartHandle:{nodeId:G,type:f,id:q}});return}let lf=WF(T.target),Gf=_||m,{connection:zf,isValid:jf}=M5.isValid(T.nativeEvent,{handle:{nodeId:G,id:q,type:f},connectionMode:v,fromNodeId:Y.nodeId,fromHandleId:Y.id||null,fromType:Y.type,isValidConnection:Gf,flowId:o,doc:lf,lib:c,nodeLookup:ff});if(jf&&zf)C(zf);let Wf=structuredClone(n);delete Wf.inProgress,Wf.toPosition=Wf.toHandle?Wf.toHandle.position:null,r?.(T,Wf),O.setState({connectionClickStartHandle:null})};return t.jsx("div",{"data-handleid":q,"data-nodeid":G,"data-handlepos":u,"data-id":`${E}-${G}-${q}-${f}`,className:Y0(["react-flow__handle",`react-flow__handle-${u}`,"nodrag",Z,Q,{source:!V,target:V,connectable:y,connectablestart:l,connectableend:$,clickconnecting:N,connectingfrom:L,connectingto:M,valid:x,connectionindicator:y&&(!R||w)&&(R||p?$:l)}]),onMouseDown:P,onTouchStart:P,onClick:H?D:void 0,ref:K,...W,children:F})}var ty=d.memo(BZ(dw));function ew({data:f,isConnectable:u,sourcePosition:_=Uf.Bottom}){return t.jsxs(t.Fragment,{children:[f?.label,t.jsx(ty,{type:"source",position:_,isConnectable:u})]})}function fD({data:f,isConnectable:u,targetPosition:_=Uf.Top,sourcePosition:y=Uf.Bottom}){return t.jsxs(t.Fragment,{children:[t.jsx(ty,{type:"target",position:_,isConnectable:u}),f?.label,t.jsx(ty,{type:"source",position:y,isConnectable:u})]})}function uD(){return null}function _D({data:f,isConnectable:u,targetPosition:_=Uf.Top}){return t.jsxs(t.Fragment,{children:[t.jsx(ty,{type:"target",position:_,isConnectable:u}),f?.label]})}var R5={ArrowUp:{x:0,y:-1},ArrowDown:{x:0,y:1},ArrowLeft:{x:-1,y:0},ArrowRight:{x:1,y:0}},QZ={input:ew,default:fD,output:_D,group:uD};function yD(f){if(f.internals.handleBounds===void 0)return{width:f.width??f.initialWidth??f.style?.width,height:f.height??f.initialHeight??f.style?.height};return{width:f.width??f.style?.width,height:f.height??f.style?.height}}var lD=(f)=>{let{width:u,height:_,x:y,y:l}=_l(f.nodeLookup,{filter:($)=>!!$.selected});return{width:u1(u)?u:null,height:u1(_)?_:null,userSelectionActive:f.userSelectionActive,transformString:`translate(${f.transform[0]}px,${f.transform[1]}px) scale(${f.transform[2]}) translate(${y}px,${l}px)`}};function $D({onSelectionContextMenu:f,noPanClassName:u,disableKeyboardA11y:_}){let y=W0(),{width:l,height:$,transformString:j,userSelectionActive:J}=mf(lD,Q0),F=TZ(),Q=d.useRef(null);d.useEffect(()=>{if(!_)Q.current?.focus({preventScroll:!0})},[_]);let U=!J&&l!==null&&$!==null;if(DZ({nodeRef:Q,disabled:!U}),!U)return null;let z=f?(K)=>{let q=y.getState().nodes.filter((V)=>V.selected);f(K,q)}:void 0,W=(K)=>{if(Object.prototype.hasOwnProperty.call(R5,K.key))K.preventDefault(),F({direction:R5[K.key],factor:K.shiftKey?4:1})};return t.jsx("div",{className:Y0(["react-flow__nodesselection","react-flow__container",u]),style:{transform:j},children:t.jsx("div",{ref:Q,className:"react-flow__nodesselection-rect",onContextMenu:z,tabIndex:_?void 0:-1,onKeyDown:_?void 0:W,style:{width:l,height:$}})})}var UZ=typeof window<"u"?window:void 0,jD=(f)=>{return{nodesSelectionActive:f.nodesSelectionActive,userSelectionActive:f.userSelectionActive}};function rZ({children:f,onPaneClick:u,onPaneMouseEnter:_,onPaneMouseMove:y,onPaneMouseLeave:l,onPaneContextMenu:$,onPaneScroll:j,paneClickDistance:J,deleteKeyCode:F,selectionKeyCode:Q,selectionOnDrag:U,selectionMode:z,onSelectionStart:W,onSelectionEnd:K,multiSelectionKeyCode:q,panActivationKeyCode:V,zoomActivationKeyCode:O,elementsSelectable:G,zoomOnScroll:H,zoomOnPinch:Z,panOnScroll:E,panOnScrollSpeed:L,panOnScrollMode:M,zoomOnDoubleClick:N,panOnDrag:w,defaultViewport:R,translateExtent:p,minZoom:x,maxZoom:C,preventScrolling:P,onSelectionContextMenu:D,noWheelClassName:T,noPanClassName:S,disableKeyboardA11y:r,onViewportChange:Y,isControlledViewport:v}){let{nodesSelectionActive:m,userSelectionActive:c}=mf(jD,Q0),o=H6(Q,{target:UZ}),ff=H6(V,{target:UZ}),n=ff||w,lf=ff||E,Gf=U&&n!==!0,zf=o||c||Gf;return Iw({deleteKeyCode:F,multiSelectionKeyCode:q}),t.jsx(mw,{onPaneContextMenu:$,elementsSelectable:G,zoomOnScroll:H,zoomOnPinch:Z,panOnScroll:lf,panOnScrollSpeed:L,panOnScrollMode:M,zoomOnDoubleClick:N,panOnDrag:!o&&n,defaultViewport:R,translateExtent:p,minZoom:x,maxZoom:C,zoomActivationKeyCode:O,preventScrolling:P,noWheelClassName:T,noPanClassName:S,onViewportChange:Y,isControlledViewport:v,paneClickDistance:J,selectionOnDrag:Gf,children:t.jsxs(nw,{onSelectionStart:W,onSelectionEnd:K,onPaneClick:u,onPaneMouseEnter:_,onPaneMouseMove:y,onPaneMouseLeave:l,onPaneContextMenu:$,onPaneScroll:j,panOnDrag:n,isSelecting:!!zf,selectionMode:z,selectionKeyPressed:o,paneClickDistance:J,selectionOnDrag:Gf,children:[f,m&&t.jsx($D,{onSelectionContextMenu:D,noPanClassName:S,disableKeyboardA11y:r})]})})}rZ.displayName="FlowRenderer";var JD=d.memo(rZ),FD=(f)=>(u)=>{return f?E5(u.nodeLookup,{x:0,y:0,width:u.width,height:u.height},u.transform,!0).map((_)=>_.id):Array.from(u.nodeLookup.keys())};function AD(f){return mf(d.useCallback(FD(f),[f]),Q0)}var QD=(f)=>f.updateNodeInternals;function UD(){let f=mf(QD),[u]=d.useState(()=>{if(typeof ResizeObserver>"u")return null;return new ResizeObserver((_)=>{let y=new Map;_.forEach((l)=>{let $=l.target.getAttribute("data-id");y.set($,{id:$,nodeElement:l.target,force:!0})}),f(y)})});return d.useEffect(()=>{return()=>{u?.disconnect()}},[u]),u}function WD({node:f,nodeType:u,hasDimensions:_,resizeObserver:y}){let l=W0(),$=d.useRef(null),j=d.useRef(null),J=d.useRef(f.sourcePosition),F=d.useRef(f.targetPosition),Q=d.useRef(u),U=_&&!!f.internals.handleBounds;return d.useEffect(()=>{if($.current&&!f.hidden&&(!U||j.current!==$.current)){if(j.current)y?.unobserve(j.current);y?.observe($.current),j.current=$.current}},[U,f.hidden]),d.useEffect(()=>{return()=>{if(j.current)y?.unobserve(j.current),j.current=null}},[]),d.useEffect(()=>{if($.current){let z=Q.current!==u,W=J.current!==f.sourcePosition,K=F.current!==f.targetPosition;if(z||W||K)Q.current=u,J.current=f.sourcePosition,F.current=f.targetPosition,l.getState().updateNodeInternals(new Map([[f.id,{id:f.id,nodeElement:$.current,force:!0}]]))}},[f.id,u,f.sourcePosition,f.targetPosition]),$}function zD({id:f,onClick:u,onMouseEnter:_,onMouseMove:y,onMouseLeave:l,onContextMenu:$,onDoubleClick:j,nodesDraggable:J,elementsSelectable:F,nodesConnectable:Q,nodesFocusable:U,resizeObserver:z,noDragClassName:W,noPanClassName:K,disableKeyboardA11y:q,rfId:V,nodeTypes:O,nodeClickDistance:G,onError:H}){let{node:Z,internals:E,isParent:L}=mf((jf)=>{let Wf=jf.nodeLookup.get(f),Vf=jf.parentLookup.has(f);return{node:Wf,internals:Wf.internals,isParent:Vf}},Q0),M=Z.type||"default",N=O?.[M]||QZ[M];if(N===void 0)H?.("003",hu.error003(M)),M="default",N=O?.default||QZ.default;let w=!!(Z.draggable||J&&typeof Z.draggable>"u"),R=!!(Z.selectable||F&&typeof Z.selectable>"u"),p=!!(Z.connectable||Q&&typeof Z.connectable>"u"),x=!!(Z.focusable||U&&typeof Z.focusable>"u"),C=W0(),P=AF(Z),D=WD({node:Z,nodeType:M,hasDimensions:P,resizeObserver:z}),T=DZ({nodeRef:D,disabled:Z.hidden||!w,noDragClassName:W,handleSelector:Z.dragHandle,nodeId:f,isSelectable:R,nodeClickDistance:G}),S=TZ();if(Z.hidden)return null;let r=b1(Z),Y=yD(Z),v=R||w||u||_||y||l,m=_?(jf)=>_(jf,{...E.userNode}):void 0,c=y?(jf)=>y(jf,{...E.userNode}):void 0,o=l?(jf)=>l(jf,{...E.userNode}):void 0,ff=$?(jf)=>$(jf,{...E.userNode}):void 0,n=j?(jf)=>j(jf,{...E.userNode}):void 0,lf=(jf)=>{let{selectNodesOnDrag:Wf,nodeDragThreshold:Vf}=C.getState();if(R&&(!Wf||!w||Vf>0))wF({id:f,store:C,nodeRef:D});if(u)u(jf,{...E.userNode})},Gf=(jf)=>{if(zF(jf.nativeEvent)||q)return;if(dJ.includes(jf.key)&&R){let Wf=jf.key==="Escape";wF({id:f,store:C,unselect:Wf,nodeRef:D})}else if(w&&Z.selected&&Object.prototype.hasOwnProperty.call(R5,jf.key)){jf.preventDefault();let{ariaLabelConfig:Wf}=C.getState();C.setState({ariaLiveMessage:Wf["node.a11yDescription.ariaLiveMessage"]({direction:jf.key.replace("Arrow","").toLowerCase(),x:~~E.positionAbsolute.x,y:~~E.positionAbsolute.y})}),S({direction:R5[jf.key],factor:jf.shiftKey?4:1})}},zf=()=>{if(q||!D.current?.matches(":focus-visible"))return;let{transform:jf,width:Wf,height:Vf,autoPanOnNodeFocus:Kf,setCenter:h}=C.getState();if(!Kf)return;if(!(E5(new Map([[f,Z]]),{x:0,y:0,width:Wf,height:Vf},jf,!0).length>0))h(Z.position.x+r.width/2,Z.position.y+r.height/2,{zoom:jf[2]})};return t.jsx("div",{className:Y0(["react-flow__node",`react-flow__node-${M}`,{[K]:w},Z.className,{selected:Z.selected,selectable:R,parent:L,draggable:w,dragging:T}]),ref:D,style:{zIndex:E.z,transform:`translate(${E.positionAbsolute.x}px,${E.positionAbsolute.y}px)`,pointerEvents:v?"all":"none",visibility:P?"visible":"hidden",...Z.style,...Y},"data-id":f,"data-testid":`rf__node-${f}`,onMouseEnter:m,onMouseMove:c,onMouseLeave:o,onContextMenu:ff,onClick:lf,onDoubleClick:n,onKeyDown:x?Gf:void 0,tabIndex:x?0:void 0,onFocus:x?zf:void 0,role:Z.ariaRole??(x?"group":void 0),"aria-roledescription":"node","aria-describedby":q?void 0:`${XZ}-${V}`,"aria-label":Z.ariaLabel,...Z.domAttributes,children:t.jsx(sw,{value:f,children:t.jsx(N,{id:f,data:Z.data,type:M,positionAbsoluteX:E.positionAbsolute.x,positionAbsoluteY:E.positionAbsolute.y,selected:Z.selected??!1,selectable:R,draggable:w,deletable:Z.deletable??!0,isConnectable:p,sourcePosition:Z.sourcePosition,targetPosition:Z.targetPosition,dragging:T,dragHandle:Z.dragHandle,zIndex:E.z,parentId:Z.parentId,...r})})})}var GD=d.memo(zD),KD=(f)=>({nodesDraggable:f.nodesDraggable,nodesConnectable:f.nodesConnectable,nodesFocusable:f.nodesFocusable,elementsSelectable:f.elementsSelectable,onError:f.onError});function SZ(f){let{nodesDraggable:u,nodesConnectable:_,nodesFocusable:y,elementsSelectable:l,onError:$}=mf(KD,Q0),j=AD(f.onlyRenderVisibleElements),J=UD();return t.jsx("div",{className:"react-flow__nodes",style:b5,children:j.map((F)=>{return t.jsx(GD,{id:F,nodeTypes:f.nodeTypes,nodeExtent:f.nodeExtent,onClick:f.onNodeClick,onMouseEnter:f.onNodeMouseEnter,onMouseMove:f.onNodeMouseMove,onMouseLeave:f.onNodeMouseLeave,onContextMenu:f.onNodeContextMenu,onDoubleClick:f.onNodeDoubleClick,noDragClassName:f.noDragClassName,noPanClassName:f.noPanClassName,rfId:f.rfId,disableKeyboardA11y:f.disableKeyboardA11y,resizeObserver:J,nodesDraggable:u,nodesConnectable:_,nodesFocusable:y,elementsSelectable:l,nodeClickDistance:f.nodeClickDistance,onError:$},F)})})}SZ.displayName="NodeRenderer";var ZD=d.memo(SZ);function qD(f){return mf(d.useCallback((_)=>{if(!f)return _.edges.map((l)=>l.id);let y=[];if(_.width&&_.height)for(let l of _.edges){let $=_.nodeLookup.get(l.source),j=_.nodeLookup.get(l.target);if($&&j&&HK({sourceNode:$,targetNode:j,width:_.width,height:_.height,transform:_.transform}))y.push(l.id)}return y},[f]),Q0)}var HD=({color:f="none",strokeWidth:u=1})=>{let _={strokeWidth:u,...f&&{stroke:f}};return t.jsx("polyline",{className:"arrow",style:_,strokeLinecap:"round",fill:"none",strokeLinejoin:"round",points:"-5,-4 0,0 -5,4"})},VD=({color:f="none",strokeWidth:u=1})=>{let _={strokeWidth:u,...f&&{stroke:f,fill:f}};return t.jsx("polyline",{className:"arrowclosed",style:_,strokeLinecap:"round",strokeLinejoin:"round",points:"-5,-4 0,0 -5,4 -5,-4"})},WZ={[n_.Arrow]:HD,[n_.ArrowClosed]:VD};function ED(f){let u=W0();return d.useMemo(()=>{if(!Object.prototype.hasOwnProperty.call(WZ,f))return u.getState().onError?.("009",hu.error009(f)),null;return WZ[f]},[f])}var OD=({id:f,type:u,color:_,width:y=12.5,height:l=12.5,markerUnits:$="strokeWidth",strokeWidth:j,orient:J="auto-start-reverse"})=>{let F=ED(u);if(!F)return null;return t.jsx("marker",{className:"react-flow__arrowhead",id:f,markerWidth:`${y}`,markerHeight:`${l}`,viewBox:"-10 -10 20 20",markerUnits:$,orient:J,refX:"0",refY:"0",children:t.jsx(F,{color:_,strokeWidth:j})})},PZ=({defaultColor:f,rfId:u})=>{let _=mf(($)=>$.edges),y=mf(($)=>$.defaultEdgeOptions),l=d.useMemo(()=>{return EK(_,{id:u,defaultColor:f,defaultMarkerStart:y?.markerStart,defaultMarkerEnd:y?.markerEnd})},[_,y,u,f]);if(!l.length)return null;return t.jsx("svg",{className:"react-flow__marker","aria-hidden":"true",children:t.jsx("defs",{children:l.map(($)=>t.jsx(OD,{id:$.id,type:$.type,color:$.color,width:$.width,height:$.height,markerUnits:$.markerUnits,strokeWidth:$.strokeWidth,orient:$.orient},$.id))})})};PZ.displayName="MarkerDefinitions";var XD=d.memo(PZ);function CZ({x:f,y:u,label:_,labelStyle:y,labelShowBg:l=!0,labelBgStyle:$,labelBgPadding:j=[2,4],labelBgBorderRadius:J=2,children:F,className:Q,...U}){let[z,W]=d.useState({x:1,y:0,width:0,height:0}),K=Y0(["react-flow__edge-textwrapper",Q]),q=d.useRef(null);if(d.useEffect(()=>{if(q.current){let V=q.current.getBBox();W({x:V.x,y:V.y,width:V.width,height:V.height})}},[_]),!_)return null;return t.jsxs("g",{transform:`translate(${f-z.width/2} ${u-z.height/2})`,className:K,visibility:z.width?"visible":"hidden",...U,children:[l&&t.jsx("rect",{width:z.width+2*j[0],x:-j[0],y:-j[1],height:z.height+2*j[1],className:"react-flow__edge-textbg",style:$,rx:J,ry:J}),t.jsx("text",{className:"react-flow__edge-text",y:z.height/2,dy:"0.3em",ref:q,style:y,children:_}),F]})}CZ.displayName="EdgeText";var ND=d.memo(CZ);function Al({path:f,labelX:u,labelY:_,label:y,labelStyle:l,labelShowBg:$,labelBgStyle:j,labelBgPadding:J,labelBgBorderRadius:F,interactionWidth:Q=20,...U}){return t.jsxs(t.Fragment,{children:[t.jsx("path",{...U,d:f,fill:"none",className:Y0(["react-flow__edge-path",U.className])}),Q?t.jsx("path",{d:f,fill:"none",strokeOpacity:0,strokeWidth:Q,className:"react-flow__edge-interaction"}):null,y&&u1(u)&&u1(_)?t.jsx(ND,{x:u,y:_,label:y,labelStyle:l,labelShowBg:$,labelBgStyle:j,labelBgPadding:J,labelBgBorderRadius:F}):null]})}function zZ({pos:f,x1:u,y1:_,x2:y,y2:l}){if(f===Uf.Left||f===Uf.Right)return[0.5*(u+y),_];return[u,0.5*(_+l)]}function RZ({sourceX:f,sourceY:u,sourcePosition:_=Uf.Bottom,targetX:y,targetY:l,targetPosition:$=Uf.Top}){let[j,J]=zZ({pos:_,x1:f,y1:u,x2:y,y2:l}),[F,Q]=zZ({pos:$,x1:y,y1:l,x2:f,y2:u}),[U,z,W,K]=L5({sourceX:f,sourceY:u,targetX:y,targetY:l,sourceControlX:j,sourceControlY:J,targetControlX:F,targetControlY:Q});return[`M${f},${u} C${j},${J} ${F},${Q} ${y},${l}`,U,z,W,K]}function xZ(f){return d.memo(({id:u,sourceX:_,sourceY:y,targetX:l,targetY:$,sourcePosition:j,targetPosition:J,label:F,labelStyle:Q,labelShowBg:U,labelBgStyle:z,labelBgPadding:W,labelBgBorderRadius:K,style:q,markerEnd:V,markerStart:O,interactionWidth:G})=>{let[H,Z,E]=RZ({sourceX:_,sourceY:y,sourcePosition:j,targetX:l,targetY:$,targetPosition:J}),L=f.isInternal?void 0:u;return t.jsx(Al,{id:L,path:H,labelX:Z,labelY:E,label:F,labelStyle:Q,labelShowBg:U,labelBgStyle:z,labelBgPadding:W,labelBgBorderRadius:K,style:q,markerEnd:V,markerStart:O,interactionWidth:G})})}var LD=xZ({isInternal:!1}),vZ=xZ({isInternal:!0});LD.displayName="SimpleBezierEdge";vZ.displayName="SimpleBezierEdgeInternal";function bZ(f){return d.memo(({id:u,sourceX:_,sourceY:y,targetX:l,targetY:$,label:j,labelStyle:J,labelShowBg:F,labelBgStyle:Q,labelBgPadding:U,labelBgBorderRadius:z,style:W,sourcePosition:K=Uf.Bottom,targetPosition:q=Uf.Top,markerEnd:V,markerStart:O,pathOptions:G,interactionWidth:H})=>{let[Z,E,L]=q6({sourceX:_,sourceY:y,sourcePosition:K,targetX:l,targetY:$,targetPosition:q,borderRadius:G?.borderRadius,offset:G?.offset,stepPosition:G?.stepPosition}),M=f.isInternal?void 0:u;return t.jsx(Al,{id:M,path:Z,labelX:E,labelY:L,label:j,labelStyle:J,labelShowBg:F,labelBgStyle:Q,labelBgPadding:U,labelBgBorderRadius:z,style:W,markerEnd:V,markerStart:O,interactionWidth:H})})}var hZ=bZ({isInternal:!1}),IZ=bZ({isInternal:!0});hZ.displayName="SmoothStepEdge";IZ.displayName="SmoothStepEdgeInternal";function cZ(f){return d.memo(({id:u,..._})=>{let y=f.isInternal?void 0:u;return t.jsx(hZ,{..._,id:y,pathOptions:d.useMemo(()=>({borderRadius:0,offset:_.pathOptions?.offset}),[_.pathOptions?.offset])})})}var YD=cZ({isInternal:!1}),pZ=cZ({isInternal:!0});YD.displayName="StepEdge";pZ.displayName="StepEdgeInternal";function mZ(f){return d.memo(({id:u,sourceX:_,sourceY:y,targetX:l,targetY:$,label:j,labelStyle:J,labelShowBg:F,labelBgStyle:Q,labelBgPadding:U,labelBgBorderRadius:z,style:W,markerEnd:K,markerStart:q,interactionWidth:V})=>{let[O,G,H]=B5({sourceX:_,sourceY:y,targetX:l,targetY:$}),Z=f.isInternal?void 0:u;return t.jsx(Al,{id:Z,path:O,labelX:G,labelY:H,label:j,labelStyle:J,labelShowBg:F,labelBgStyle:Q,labelBgPadding:U,labelBgBorderRadius:z,style:W,markerEnd:K,markerStart:q,interactionWidth:V})})}var BD=mZ({isInternal:!1}),kZ=mZ({isInternal:!0});BD.displayName="StraightEdge";kZ.displayName="StraightEdgeInternal";function iZ(f){return d.memo(({id:u,sourceX:_,sourceY:y,targetX:l,targetY:$,sourcePosition:j=Uf.Bottom,targetPosition:J=Uf.Top,label:F,labelStyle:Q,labelShowBg:U,labelBgStyle:z,labelBgPadding:W,labelBgBorderRadius:K,style:q,markerEnd:V,markerStart:O,pathOptions:G,interactionWidth:H})=>{let[Z,E,L]=Y5({sourceX:_,sourceY:y,sourcePosition:j,targetX:l,targetY:$,targetPosition:J,curvature:G?.curvature}),M=f.isInternal?void 0:u;return t.jsx(Al,{id:M,path:Z,labelX:E,labelY:L,label:F,labelStyle:Q,labelShowBg:U,labelBgStyle:z,labelBgPadding:W,labelBgBorderRadius:K,style:q,markerEnd:V,markerStart:O,interactionWidth:H})})}var wD=iZ({isInternal:!1}),gZ=iZ({isInternal:!0});wD.displayName="BezierEdge";gZ.displayName="BezierEdgeInternal";var GZ={default:gZ,straight:kZ,step:pZ,smoothstep:IZ,simplebezier:vZ},KZ={sourceX:null,sourceY:null,targetX:null,targetY:null,sourcePosition:null,targetPosition:null},DD=(f,u,_)=>{if(_===Uf.Left)return f-u;if(_===Uf.Right)return f+u;return f},TD=(f,u,_)=>{if(_===Uf.Top)return f-u;if(_===Uf.Bottom)return f+u;return f},ZZ="react-flow__edgeupdater";function qZ({position:f,centerX:u,centerY:_,radius:y=10,onMouseDown:l,onMouseEnter:$,onMouseOut:j,type:J}){return t.jsx("circle",{onMouseDown:l,onMouseEnter:$,onMouseOut:j,className:Y0([ZZ,`${ZZ}-${J}`]),cx:DD(u,y,f),cy:TD(_,y,f),r:y,stroke:"transparent",fill:"transparent"})}function MD({isReconnectable:f,reconnectRadius:u,edge:_,sourceX:y,sourceY:l,targetX:$,targetY:j,sourcePosition:J,targetPosition:F,onReconnect:Q,onReconnectStart:U,onReconnectEnd:z,setReconnecting:W,setUpdateHover:K}){let q=W0(),V=(E,L)=>{if(E.button!==0)return;let{autoPanOnConnect:M,domNode:N,connectionMode:w,connectionRadius:R,lib:p,onConnectStart:x,cancelConnection:C,nodeLookup:P,rfId:D,panBy:T,updateConnection:S}=q.getState(),r=L.type==="target",Y=(c,o)=>{W(!1),z?.(c,_,L.type,o)},v=(c)=>Q?.(_,c),m=(c,o)=>{W(!0),U?.(E,_,L.type),x?.(c,o)};M5.onPointerDown(E.nativeEvent,{autoPanOnConnect:M,connectionMode:w,connectionRadius:R,domNode:N,handleId:L.id,nodeId:L.nodeId,nodeLookup:P,isTarget:r,edgeUpdaterType:L.type,lib:p,flowId:D,cancelConnection:C,panBy:T,isValidConnection:(...c)=>q.getState().isValidConnection?.(...c)??!0,onConnect:v,onConnectStart:m,onConnectEnd:(...c)=>q.getState().onConnectEnd?.(...c),onReconnectEnd:Y,updateConnection:S,getTransform:()=>q.getState().transform,getFromHandle:()=>q.getState().connection.fromHandle,dragThreshold:q.getState().connectionDragThreshold,handleDomNode:E.currentTarget})},O=(E)=>V(E,{nodeId:_.target,id:_.targetHandle??null,type:"target"}),G=(E)=>V(E,{nodeId:_.source,id:_.sourceHandle??null,type:"source"}),H=()=>K(!0),Z=()=>K(!1);return t.jsxs(t.Fragment,{children:[(f===!0||f==="source")&&t.jsx(qZ,{position:J,centerX:y,centerY:l,radius:u,onMouseDown:O,onMouseEnter:H,onMouseOut:Z,type:"source"}),(f===!0||f==="target")&&t.jsx(qZ,{position:F,centerX:$,centerY:j,radius:u,onMouseDown:G,onMouseEnter:H,onMouseOut:Z,type:"target"})]})}function rD({id:f,edgesFocusable:u,edgesReconnectable:_,elementsSelectable:y,onClick:l,onDoubleClick:$,onContextMenu:j,onMouseEnter:J,onMouseMove:F,onMouseLeave:Q,reconnectRadius:U,onReconnect:z,onReconnectStart:W,onReconnectEnd:K,rfId:q,edgeTypes:V,noPanClassName:O,onError:G,disableKeyboardA11y:H}){let Z=mf((h)=>h.edgeLookup.get(f)),E=mf((h)=>h.defaultEdgeOptions);Z=E?{...E,...Z}:Z;let L=Z.type||"default",M=V?.[L]||GZ[L];if(M===void 0)G?.("011",hu.error011(L)),L="default",M=V?.default||GZ.default;let N=!!(Z.focusable||u&&typeof Z.focusable>"u"),w=typeof z<"u"&&(Z.reconnectable||_&&typeof Z.reconnectable>"u"),R=!!(Z.selectable||y&&typeof Z.selectable>"u"),p=d.useRef(null),[x,C]=d.useState(!1),[P,D]=d.useState(!1),T=W0(),{zIndex:S,sourceX:r,sourceY:Y,targetX:v,targetY:m,sourcePosition:c,targetPosition:o}=mf(d.useCallback((h)=>{let g=h.nodeLookup.get(Z.source),I=h.nodeLookup.get(Z.target);if(!g||!I)return{zIndex:Z.zIndex,...KZ};let yf=VK({id:f,sourceNode:g,targetNode:I,sourceHandle:Z.sourceHandle||null,targetHandle:Z.targetHandle||null,connectionMode:h.connectionMode,onError:G});return{zIndex:qK({selected:Z.selected,zIndex:Z.zIndex,sourceNode:g,targetNode:I,elevateOnSelect:h.elevateEdgesOnSelect,zIndexMode:h.zIndexMode}),...yf||KZ}},[Z.source,Z.target,Z.sourceHandle,Z.targetHandle,Z.selected,Z.zIndex]),Q0),ff=d.useMemo(()=>Z.markerStart?`url('#${w5(Z.markerStart,q)}')`:void 0,[Z.markerStart,q]),n=d.useMemo(()=>Z.markerEnd?`url('#${w5(Z.markerEnd,q)}')`:void 0,[Z.markerEnd,q]);if(Z.hidden||r===null||Y===null||v===null||m===null)return null;let lf=(h)=>{let{addSelectedEdges:g,unselectNodesAndEdges:I,multiSelectionActive:yf}=T.getState();if(R)if(T.setState({nodesSelectionActive:!1}),Z.selected&&yf)I({nodes:[],edges:[Z]}),p.current?.blur();else g([f]);if(l)l(h,Z)},Gf=$?(h)=>{$(h,{...Z})}:void 0,zf=j?(h)=>{j(h,{...Z})}:void 0,jf=J?(h)=>{J(h,{...Z})}:void 0,Wf=F?(h)=>{F(h,{...Z})}:void 0,Vf=Q?(h)=>{Q(h,{...Z})}:void 0,Kf=(h)=>{if(!H&&dJ.includes(h.key)&&R){let{unselectNodesAndEdges:g,addSelectedEdges:I}=T.getState();if(h.key==="Escape")p.current?.blur(),g({edges:[Z]});else I([f])}};return t.jsx("svg",{style:{zIndex:S},children:t.jsxs("g",{className:Y0(["react-flow__edge",`react-flow__edge-${L}`,Z.className,O,{selected:Z.selected,animated:Z.animated,inactive:!R&&!l,updating:x,selectable:R}]),onClick:lf,onDoubleClick:Gf,onContextMenu:zf,onMouseEnter:jf,onMouseMove:Wf,onMouseLeave:Vf,onKeyDown:N?Kf:void 0,tabIndex:N?0:void 0,role:Z.ariaRole??(N?"group":"img"),"aria-roledescription":"edge","data-id":f,"data-testid":`rf__edge-${f}`,"aria-label":Z.ariaLabel===null?void 0:Z.ariaLabel||`Edge from ${Z.source} to ${Z.target}`,"aria-describedby":N?`${NZ}-${q}`:void 0,ref:p,...Z.domAttributes,children:[!P&&t.jsx(M,{id:f,source:Z.source,target:Z.target,type:Z.type,selected:Z.selected,animated:Z.animated,selectable:R,deletable:Z.deletable??!0,label:Z.label,labelStyle:Z.labelStyle,labelShowBg:Z.labelShowBg,labelBgStyle:Z.labelBgStyle,labelBgPadding:Z.labelBgPadding,labelBgBorderRadius:Z.labelBgBorderRadius,sourceX:r,sourceY:Y,targetX:v,targetY:m,sourcePosition:c,targetPosition:o,data:Z.data,style:Z.style,sourceHandleId:Z.sourceHandle,targetHandleId:Z.targetHandle,markerStart:ff,markerEnd:n,pathOptions:"pathOptions"in Z?Z.pathOptions:void 0,interactionWidth:Z.interactionWidth}),w&&t.jsx(MD,{edge:Z,isReconnectable:w,reconnectRadius:U,onReconnect:z,onReconnectStart:W,onReconnectEnd:K,sourceX:r,sourceY:Y,targetX:v,targetY:m,sourcePosition:c,targetPosition:o,setUpdateHover:C,setReconnecting:D})]})})}var SD=d.memo(rD),PD=(f)=>({edgesFocusable:f.edgesFocusable,edgesReconnectable:f.edgesReconnectable,elementsSelectable:f.elementsSelectable,connectionMode:f.connectionMode,onError:f.onError});function nZ({defaultMarkerColor:f,onlyRenderVisibleElements:u,rfId:_,edgeTypes:y,noPanClassName:l,onReconnect:$,onEdgeContextMenu:j,onEdgeMouseEnter:J,onEdgeMouseMove:F,onEdgeMouseLeave:Q,onEdgeClick:U,reconnectRadius:z,onEdgeDoubleClick:W,onReconnectStart:K,onReconnectEnd:q,disableKeyboardA11y:V}){let{edgesFocusable:O,edgesReconnectable:G,elementsSelectable:H,onError:Z}=mf(PD,Q0),E=qD(u);return t.jsxs("div",{className:"react-flow__edges",children:[t.jsx(XD,{defaultColor:f,rfId:_}),E.map((L)=>{return t.jsx(SD,{id:L,edgesFocusable:O,edgesReconnectable:G,elementsSelectable:H,noPanClassName:l,onReconnect:$,onContextMenu:j,onMouseEnter:J,onMouseMove:F,onMouseLeave:Q,onClick:U,reconnectRadius:z,onDoubleClick:W,onReconnectStart:K,onReconnectEnd:q,rfId:_,onError:Z,edgeTypes:y,disableKeyboardA11y:V},L)})]})}nZ.displayName="EdgeRenderer";var CD=d.memo(nZ),RD=(f)=>`translate(${f.transform[0]}px,${f.transform[1]}px) scale(${f.transform[2]})`;function xD({children:f}){let u=mf(RD);return t.jsx("div",{className:"react-flow__viewport xyflow__viewport react-flow__container",style:{transform:u},children:f})}function vD(f){let u=DF(),_=d.useRef(!1);d.useEffect(()=>{if(!_.current&&u.viewportInitialized&&f)setTimeout(()=>f(u),1),_.current=!0},[f,u.viewportInitialized])}var bD=(f)=>f.panZoom?.syncViewport;function hD(f){let u=mf(bD),_=W0();return d.useEffect(()=>{if(f)u?.(f),_.setState({transform:[f.x,f.y,f.zoom]})},[f,u]),null}function HZ(f){return f.connection.inProgress?{...f.connection,to:$l(f.connection.to,f.transform)}:{...f.connection}}function ID(f){if(f)return(_)=>{let y=HZ(_);return f(y)};return HZ}function cD(f){let u=ID(f);return mf(u,Q0)}var pD=(f)=>({nodesConnectable:f.nodesConnectable,isValid:f.connection.isValid,inProgress:f.connection.inProgress,width:f.width,height:f.height});function mD({containerStyle:f,style:u,type:_,component:y}){let{nodesConnectable:l,width:$,height:j,isValid:J,inProgress:F}=mf(pD,Q0);if(!($&&l&&F))return null;return t.jsx("svg",{style:f,width:$,height:j,className:"react-flow__connectionline react-flow__container",children:t.jsx("g",{className:Y0(["react-flow__connection",uF(J)]),children:t.jsx(tZ,{style:u,type:_,CustomComponent:y,isValid:J})})})}var tZ=({style:f,type:u=v1.Bezier,CustomComponent:_,isValid:y})=>{let{inProgress:l,from:$,fromNode:j,fromHandle:J,fromPosition:F,to:Q,toNode:U,toHandle:z,toPosition:W,pointer:K}=cD();if(!l)return;if(_)return t.jsx(_,{connectionLineType:u,connectionLineStyle:f,fromNode:j,fromHandle:J,fromX:$.x,fromY:$.y,toX:Q.x,toY:Q.y,fromPosition:F,toPosition:W,connectionStatus:uF(y),toNode:U,toHandle:z,pointer:K});let q="",V={sourceX:$.x,sourceY:$.y,sourcePosition:F,targetX:Q.x,targetY:Q.y,targetPosition:W};switch(u){case v1.Bezier:[q]=Y5(V);break;case v1.SimpleBezier:[q]=RZ(V);break;case v1.Step:[q]=q6({...V,borderRadius:0});break;case v1.SmoothStep:[q]=q6(V);break;default:[q]=B5(V)}return t.jsx("path",{d:q,fill:"none",className:"react-flow__connection-path",style:f})};tZ.displayName="ConnectionLine";var kD={};function VZ(f=kD){let u=d.useRef(f),_=W0();d.useEffect(()=>{},[f])}function iD(){let f=W0(),u=d.useRef(!1);d.useEffect(()=>{},[])}function sZ({nodeTypes:f,edgeTypes:u,onInit:_,onNodeClick:y,onEdgeClick:l,onNodeDoubleClick:$,onEdgeDoubleClick:j,onNodeMouseEnter:J,onNodeMouseMove:F,onNodeMouseLeave:Q,onNodeContextMenu:U,onSelectionContextMenu:z,onSelectionStart:W,onSelectionEnd:K,connectionLineType:q,connectionLineStyle:V,connectionLineComponent:O,connectionLineContainerStyle:G,selectionKeyCode:H,selectionOnDrag:Z,selectionMode:E,multiSelectionKeyCode:L,panActivationKeyCode:M,zoomActivationKeyCode:N,deleteKeyCode:w,onlyRenderVisibleElements:R,elementsSelectable:p,defaultViewport:x,translateExtent:C,minZoom:P,maxZoom:D,preventScrolling:T,defaultMarkerColor:S,zoomOnScroll:r,zoomOnPinch:Y,panOnScroll:v,panOnScrollSpeed:m,panOnScrollMode:c,zoomOnDoubleClick:o,panOnDrag:ff,onPaneClick:n,onPaneMouseEnter:lf,onPaneMouseMove:Gf,onPaneMouseLeave:zf,onPaneScroll:jf,onPaneContextMenu:Wf,paneClickDistance:Vf,nodeClickDistance:Kf,onEdgeContextMenu:h,onEdgeMouseEnter:g,onEdgeMouseMove:I,onEdgeMouseLeave:yf,reconnectRadius:$f,onReconnect:Qf,onReconnectStart:Yf,onReconnectEnd:xf,noDragClassName:tf,noWheelClassName:j0,noPanClassName:u0,disableKeyboardA11y:D0,nodeExtent:Fu,rfId:O0,viewport:x0,onViewportChange:ku}){return VZ(f),VZ(u),iD(),vD(_),hD(x0),t.jsx(JD,{onPaneClick:n,onPaneMouseEnter:lf,onPaneMouseMove:Gf,onPaneMouseLeave:zf,onPaneContextMenu:Wf,onPaneScroll:jf,paneClickDistance:Vf,deleteKeyCode:w,selectionKeyCode:H,selectionOnDrag:Z,selectionMode:E,onSelectionStart:W,onSelectionEnd:K,multiSelectionKeyCode:L,panActivationKeyCode:M,zoomActivationKeyCode:N,elementsSelectable:p,zoomOnScroll:r,zoomOnPinch:Y,zoomOnDoubleClick:o,panOnScroll:v,panOnScrollSpeed:m,panOnScrollMode:c,panOnDrag:ff,defaultViewport:x,translateExtent:C,minZoom:P,maxZoom:D,onSelectionContextMenu:z,preventScrolling:T,noDragClassName:tf,noWheelClassName:j0,noPanClassName:u0,disableKeyboardA11y:D0,onViewportChange:ku,isControlledViewport:!!x0,children:t.jsxs(xD,{children:[t.jsx(CD,{edgeTypes:u,onEdgeClick:l,onEdgeDoubleClick:j,onReconnect:Qf,onReconnectStart:Yf,onReconnectEnd:xf,onlyRenderVisibleElements:R,onEdgeContextMenu:h,onEdgeMouseEnter:g,onEdgeMouseMove:I,onEdgeMouseLeave:yf,reconnectRadius:$f,defaultMarkerColor:S,noPanClassName:u0,disableKeyboardA11y:D0,rfId:O0}),t.jsx(mD,{style:V,type:q,component:O,containerStyle:G}),t.jsx("div",{className:"react-flow__edgelabel-renderer"}),t.jsx(ZD,{nodeTypes:f,onNodeClick:y,onNodeDoubleClick:$,onNodeMouseEnter:J,onNodeMouseMove:F,onNodeMouseLeave:Q,onNodeContextMenu:U,nodeClickDistance:Kf,onlyRenderVisibleElements:R,noPanClassName:u0,noDragClassName:tf,disableKeyboardA11y:D0,nodeExtent:Fu,rfId:O0}),t.jsx("div",{className:"react-flow__viewport-portal"})]})})}sZ.displayName="GraphView";var gD=d.memo(sZ),EZ=({nodes:f,edges:u,defaultNodes:_,defaultEdges:y,width:l,height:$,fitView:j,fitViewOptions:J,minZoom:F=0.5,maxZoom:Q=2,nodeOrigin:U,nodeExtent:z,zIndexMode:W="basic"}={})=>{let K=new Map,q=new Map,V=new Map,O=new Map,G=y??u??[],H=_??f??[],Z=U??[0,0],E=z??ul;OF(V,O,G);let{nodesInitialized:L}=D5(H,K,q,{nodeOrigin:Z,nodeExtent:E,zIndexMode:W}),M=[0,0,1];if(j&&l&&$){let N=_l(K,{filter:(x)=>!!((x.width||x.initialWidth)&&(x.height||x.initialHeight))}),{x:w,y:R,zoom:p}=Z6(N,l,$,F,Q,J?.padding??0.1);M=[w,R,p]}return{rfId:"1",width:l??0,height:$??0,transform:M,nodes:H,nodesInitialized:L,nodeLookup:K,parentLookup:q,edges:G,edgeLookup:O,connectionLookup:V,onNodesChange:null,onEdgesChange:null,hasDefaultNodes:_!==void 0,hasDefaultEdges:y!==void 0,panZoom:null,minZoom:F,maxZoom:Q,translateExtent:ul,nodeExtent:E,nodesSelectionActive:!1,userSelectionActive:!1,userSelectionRect:null,connectionMode:g_.Strict,domNode:null,paneDragging:!1,noPanClassName:"nopan",nodeOrigin:Z,nodeDragThreshold:1,connectionDragThreshold:1,snapGrid:[15,15],snapToGrid:!1,nodesDraggable:!0,nodesConnectable:!0,nodesFocusable:!0,edgesFocusable:!0,edgesReconnectable:!0,elementsSelectable:!0,elevateNodesOnSelect:!0,elevateEdgesOnSelect:!0,selectNodesOnDrag:!0,multiSelectionActive:!1,fitViewQueued:j??!1,fitViewOptions:J,fitViewResolver:null,connection:{...fF},connectionClickStartHandle:null,connectOnClick:!0,ariaLiveMessage:"",autoPanOnConnect:!0,autoPanOnNodeDrag:!0,autoPanOnNodeFocus:!0,autoPanSpeed:15,connectionRadius:20,onError:FF,isValidConnection:void 0,onSelectionChangeHandlers:[],lib:"react",debug:!1,ariaLabelConfig:eJ,zIndexMode:W,onNodesChangeMiddlewareMap:new Map,onEdgesChangeMiddlewareMap:new Map}},nD=({nodes:f,edges:u,defaultNodes:_,defaultEdges:y,width:l,height:$,fitView:j,fitViewOptions:J,minZoom:F,maxZoom:Q,nodeOrigin:U,nodeExtent:z,zIndexMode:W})=>aK((K,q)=>{async function V(){let{nodeLookup:O,panZoom:G,fitViewOptions:H,fitViewResolver:Z,width:E,height:L,minZoom:M,maxZoom:N}=q();if(!G)return;await UK({nodes:O,width:E,height:L,panZoom:G,minZoom:M,maxZoom:N},H),Z?.resolve(!0),K({fitViewResolver:null})}return{...EZ({nodes:f,edges:u,width:l,height:$,fitView:j,fitViewOptions:J,minZoom:F,maxZoom:Q,nodeOrigin:U,nodeExtent:z,defaultNodes:_,defaultEdges:y,zIndexMode:W}),setNodes:(O)=>{let{nodeLookup:G,parentLookup:H,nodeOrigin:Z,elevateNodesOnSelect:E,fitViewQueued:L,zIndexMode:M,nodesSelectionActive:N}=q(),{nodesInitialized:w,hasSelectedNodes:R}=D5(O,G,H,{nodeOrigin:Z,nodeExtent:z,elevateNodesOnSelect:E,checkEquality:!0,zIndexMode:M}),p=N&&R;if(L&&w)V(),K({nodes:O,nodesInitialized:w,fitViewQueued:!1,fitViewOptions:void 0,nodesSelectionActive:p});else K({nodes:O,nodesInitialized:w,nodesSelectionActive:p})},setEdges:(O)=>{let{connectionLookup:G,edgeLookup:H}=q();OF(G,H,O),K({edges:O})},setDefaultNodesAndEdges:(O,G)=>{if(O){let{setNodes:H}=q();H(O),K({hasDefaultNodes:!0})}if(G){let{setEdges:H}=q();H(G),K({hasDefaultEdges:!0})}},updateNodeInternals:(O)=>{let{triggerNodeChanges:G,nodeLookup:H,parentLookup:Z,domNode:E,nodeOrigin:L,nodeExtent:M,debug:N,fitViewQueued:w,zIndexMode:R}=q(),{changes:p,updatedInternals:x}=LK(O,H,Z,E,L,M,R);if(!x)return;if(XK(H,Z,{nodeOrigin:L,nodeExtent:M,zIndexMode:R}),w)V(),K({fitViewQueued:!1,fitViewOptions:void 0});else K({});if(p?.length>0){if(N)console.log("React Flow: trigger node changes",p);G?.(p)}},updateNodePositions:(O,G=!1)=>{let H=[],Z=[],{nodeLookup:E,triggerNodeChanges:L,connection:M,updateConnection:N,onNodesChangeMiddlewareMap:w}=q();for(let[R,p]of O){let x=E.get(R),C=!!(x?.expandParent&&x?.parentId&&p?.position),P={id:R,type:"position",position:C?{x:Math.max(0,p.position.x),y:Math.max(0,p.position.y)}:p.position,dragging:G};if(x&&M.inProgress&&M.fromNode.id===x.id){let D=t_(x,M.fromHandle,Uf.Left,!0);N({...M,from:D})}if(C&&x.parentId)H.push({id:R,parentId:x.parentId,rect:{...p.internals.positionAbsolute,width:p.measured.width??0,height:p.measured.height??0}});Z.push(P)}if(H.length>0){let{parentLookup:R,nodeOrigin:p}=q(),x=T5(H,E,R,p);Z.push(...x)}for(let R of w.values())Z=R(Z);L(Z)},triggerNodeChanges:(O)=>{let{onNodesChange:G,setNodes:H,nodes:Z,hasDefaultNodes:E,debug:L}=q();if(O?.length){if(E){let M=Sw(O,Z);H(M)}if(L)console.log("React Flow: trigger node changes",O);G?.(O)}},triggerEdgeChanges:(O)=>{let{onEdgesChange:G,setEdges:H,edges:Z,hasDefaultEdges:E,debug:L}=q();if(O?.length){if(E){let M=Pw(O,Z);H(M)}if(L)console.log("React Flow: trigger edge changes",O);G?.(O)}},addSelectedNodes:(O)=>{let{multiSelectionActive:G,edgeLookup:H,nodeLookup:Z,triggerNodeChanges:E,triggerEdgeChanges:L}=q();if(G){let M=O.map((N)=>ny(N,!0));E(M);return}E(Fl(Z,new Set([...O]),!0)),L(Fl(H))},addSelectedEdges:(O)=>{let{multiSelectionActive:G,edgeLookup:H,nodeLookup:Z,triggerNodeChanges:E,triggerEdgeChanges:L}=q();if(G){let M=O.map((N)=>ny(N,!0));L(M);return}L(Fl(H,new Set([...O]))),E(Fl(Z,new Set,!0))},unselectNodesAndEdges:({nodes:O,edges:G}={})=>{let{edges:H,nodes:Z,nodeLookup:E,triggerNodeChanges:L,triggerEdgeChanges:M}=q(),N=O?O:Z,w=G?G:H,R=[];for(let x of N){if(!x.selected)continue;let C=E.get(x.id);if(C)C.selected=!1;R.push(ny(x.id,!1))}let p=[];for(let x of w){if(!x.selected)continue;p.push(ny(x.id,!1))}L(R),M(p)},setMinZoom:(O)=>{let{panZoom:G,maxZoom:H}=q();G?.setScaleExtent([O,H]),K({minZoom:O})},setMaxZoom:(O)=>{let{panZoom:G,minZoom:H}=q();G?.setScaleExtent([H,O]),K({maxZoom:O})},setTranslateExtent:(O)=>{q().panZoom?.setTranslateExtent(O),K({translateExtent:O})},resetSelectedElements:()=>{let{edges:O,nodes:G,triggerNodeChanges:H,triggerEdgeChanges:Z,elementsSelectable:E}=q();if(!E)return;let L=G.reduce((N,w)=>w.selected?[...N,ny(w.id,!1)]:N,[]),M=O.reduce((N,w)=>w.selected?[...N,ny(w.id,!1)]:N,[]);H(L),Z(M)},setNodeExtent:(O)=>{let{nodes:G,nodeLookup:H,parentLookup:Z,nodeOrigin:E,elevateNodesOnSelect:L,nodeExtent:M,zIndexMode:N}=q();if(O[0][0]===M[0][0]&&O[0][1]===M[0][1]&&O[1][0]===M[1][0]&&O[1][1]===M[1][1])return;D5(G,H,Z,{nodeOrigin:E,nodeExtent:O,elevateNodesOnSelect:L,checkEquality:!1,zIndexMode:N}),K({nodeExtent:O})},panBy:(O)=>{let{transform:G,width:H,height:Z,panZoom:E,translateExtent:L}=q();return YK({delta:O,panZoom:E,transform:G,translateExtent:L,width:H,height:Z})},setCenter:async(O,G,H)=>{let{width:Z,height:E,maxZoom:L,panZoom:M}=q();if(!M)return Promise.resolve(!1);let N=typeof H?.zoom<"u"?H.zoom:L;return await M.setViewport({x:Z/2-O*N,y:E/2-G*N,zoom:N},{duration:H?.duration,ease:H?.ease,interpolate:H?.interpolate}),Promise.resolve(!0)},cancelConnection:()=>{K({connection:{...fF}})},updateConnection:(O)=>{K({connection:O})},reset:()=>K({...EZ()})}},Object.is);function tD({initialNodes:f,initialEdges:u,defaultNodes:_,defaultEdges:y,initialWidth:l,initialHeight:$,initialMinZoom:j,initialMaxZoom:J,initialFitViewOptions:F,fitView:Q,nodeOrigin:U,nodeExtent:z,zIndexMode:W,children:K}){let[q]=d.useState(()=>nD({nodes:f,edges:u,defaultNodes:_,defaultEdges:y,width:l,height:$,fitView:Q,minZoom:j,maxZoom:J,fitViewOptions:F,nodeOrigin:U,nodeExtent:z,zIndexMode:W}));return t.jsx(Ww,{value:q,children:t.jsx(xw,{children:K})})}function sD({children:f,nodes:u,edges:_,defaultNodes:y,defaultEdges:l,width:$,height:j,fitView:J,fitViewOptions:F,minZoom:Q,maxZoom:U,nodeOrigin:z,nodeExtent:W,zIndexMode:K}){if(d.useContext(x5))return t.jsx(t.Fragment,{children:f});return t.jsx(tD,{initialNodes:u,initialEdges:_,defaultNodes:y,defaultEdges:l,initialWidth:$,initialHeight:j,fitView:J,initialFitViewOptions:F,initialMinZoom:Q,initialMaxZoom:U,nodeOrigin:z,nodeExtent:W,zIndexMode:K,children:f})}var oD={width:"100%",height:"100%",overflow:"hidden",position:"relative",zIndex:0};function aD({nodes:f,edges:u,defaultNodes:_,defaultEdges:y,className:l,nodeTypes:$,edgeTypes:j,onNodeClick:J,onEdgeClick:F,onInit:Q,onMove:U,onMoveStart:z,onMoveEnd:W,onConnect:K,onConnectStart:q,onConnectEnd:V,onClickConnectStart:O,onClickConnectEnd:G,onNodeMouseEnter:H,onNodeMouseMove:Z,onNodeMouseLeave:E,onNodeContextMenu:L,onNodeDoubleClick:M,onNodeDragStart:N,onNodeDrag:w,onNodeDragStop:R,onNodesDelete:p,onEdgesDelete:x,onDelete:C,onSelectionChange:P,onSelectionDragStart:D,onSelectionDrag:T,onSelectionDragStop:S,onSelectionContextMenu:r,onSelectionStart:Y,onSelectionEnd:v,onBeforeDelete:m,connectionMode:c,connectionLineType:o=v1.Bezier,connectionLineStyle:ff,connectionLineComponent:n,connectionLineContainerStyle:lf,deleteKeyCode:Gf="Backspace",selectionKeyCode:zf="Shift",selectionOnDrag:jf=!1,selectionMode:Wf=ky.Full,panActivationKeyCode:Vf="Space",multiSelectionKeyCode:Kf=jl()?"Meta":"Control",zoomActivationKeyCode:h=jl()?"Meta":"Control",snapToGrid:g,snapGrid:I,onlyRenderVisibleElements:yf=!1,selectNodesOnDrag:$f,nodesDraggable:Qf,autoPanOnNodeFocus:Yf,nodesConnectable:xf,nodesFocusable:tf,nodeOrigin:j0=LZ,edgesFocusable:u0,edgesReconnectable:D0,elementsSelectable:Fu=!0,defaultViewport:O0=Yw,minZoom:x0=0.5,maxZoom:ku=2,translateExtent:X0=ul,preventScrolling:Au=!0,nodeExtent:uf,defaultMarkerColor:vf="#b1b1b7",zoomOnScroll:a0=!0,zoomOnPinch:Bf=!0,panOnScroll:v0=!1,panOnScrollSpeed:i0=0.5,panOnScrollMode:d0=A_.Free,zoomOnDoubleClick:b0=!0,panOnDrag:m1=!0,onPaneClick:ef,onPaneMouseEnter:iu,onPaneMouseMove:ey,onPaneMouseLeave:f3,onPaneScroll:s,onPaneContextMenu:Nf,paneClickDistance:Of=1,nodeClickDistance:Cf=0,children:_0,onReconnect:G0,onReconnectStart:hf,onReconnectEnd:h0,onEdgeContextMenu:Qu,onEdgeDoubleClick:P6,onEdgeMouseEnter:L1,onEdgeMouseMove:C6,onEdgeMouseLeave:u3,reconnectRadius:_3=10,onNodesChange:$y,onEdgesChange:W_,noDragClassName:R6="nodrag",noWheelClassName:z2="nowheel",noPanClassName:x6="nopan",fitView:k1,fitViewOptions:jy,connectOnClick:v6,attributionPosition:G2,proOptions:Vl,defaultEdgeOptions:El,elevateNodesOnSelect:j1=!0,elevateEdgesOnSelect:Jy=!1,disableKeyboardA11y:Ol=!1,autoPanOnConnect:K2,autoPanOnNodeDrag:b6,autoPanSpeed:Xl,connectionRadius:Z2,isValidConnection:q2,onError:i1,style:h6,id:Nl,nodeDragThreshold:Y1,connectionDragThreshold:Ll,viewport:I6,onViewportChange:H2,width:V2,height:zA,colorMode:gu="light",debug:c6,onScroll:p6,ariaLabelConfig:Tu,zIndexMode:y3="basic",...Yl},Bl){let wl=Nl||"1",E2=Tw(gu),O2=d.useCallback((m6)=>{m6.currentTarget.scrollTo({top:0,left:0,behavior:"instant"}),p6?.(m6)},[p6]);return t.jsx("div",{"data-testid":"rf__wrapper",...Yl,onScroll:O2,style:{...h6,...oD},ref:Bl,className:Y0(["react-flow",l,E2]),id:Nl,role:"application",children:t.jsxs(sD,{nodes:f,edges:u,width:V2,height:zA,fitView:k1,fitViewOptions:jy,minZoom:x0,maxZoom:ku,nodeOrigin:j0,nodeExtent:uf,zIndexMode:y3,children:[t.jsx(Dw,{nodes:f,edges:u,defaultNodes:_,defaultEdges:y,onConnect:K,onConnectStart:q,onConnectEnd:V,onClickConnectStart:O,onClickConnectEnd:G,nodesDraggable:Qf,autoPanOnNodeFocus:Yf,nodesConnectable:xf,nodesFocusable:tf,edgesFocusable:u0,edgesReconnectable:D0,elementsSelectable:Fu,elevateNodesOnSelect:j1,elevateEdgesOnSelect:Jy,minZoom:x0,maxZoom:ku,nodeExtent:uf,onNodesChange:$y,onEdgesChange:W_,snapToGrid:g,snapGrid:I,connectionMode:c,translateExtent:X0,connectOnClick:v6,defaultEdgeOptions:El,fitView:k1,fitViewOptions:jy,onNodesDelete:p,onEdgesDelete:x,onDelete:C,onNodeDragStart:N,onNodeDrag:w,onNodeDragStop:R,onSelectionDrag:T,onSelectionDragStart:D,onSelectionDragStop:S,onMove:U,onMoveStart:z,onMoveEnd:W,noPanClassName:x6,nodeOrigin:j0,rfId:wl,autoPanOnConnect:K2,autoPanOnNodeDrag:b6,autoPanSpeed:Xl,onError:i1,connectionRadius:Z2,isValidConnection:q2,selectNodesOnDrag:$f,nodeDragThreshold:Y1,connectionDragThreshold:Ll,onBeforeDelete:m,debug:c6,ariaLabelConfig:Tu,zIndexMode:y3}),t.jsx(gD,{onInit:Q,onNodeClick:J,onEdgeClick:F,onNodeMouseEnter:H,onNodeMouseMove:Z,onNodeMouseLeave:E,onNodeContextMenu:L,onNodeDoubleClick:M,nodeTypes:$,edgeTypes:j,connectionLineType:o,connectionLineStyle:ff,connectionLineComponent:n,connectionLineContainerStyle:lf,selectionKeyCode:zf,selectionOnDrag:jf,selectionMode:Wf,deleteKeyCode:Gf,multiSelectionKeyCode:Kf,panActivationKeyCode:Vf,zoomActivationKeyCode:h,onlyRenderVisibleElements:yf,defaultViewport:O0,translateExtent:X0,minZoom:x0,maxZoom:ku,preventScrolling:Au,zoomOnScroll:a0,zoomOnPinch:Bf,zoomOnDoubleClick:b0,panOnScroll:v0,panOnScrollSpeed:i0,panOnScrollMode:d0,panOnDrag:m1,onPaneClick:ef,onPaneMouseEnter:iu,onPaneMouseMove:ey,onPaneMouseLeave:f3,onPaneScroll:s,onPaneContextMenu:Nf,paneClickDistance:Of,nodeClickDistance:Cf,onSelectionContextMenu:r,onSelectionStart:Y,onSelectionEnd:v,onReconnect:G0,onReconnectStart:hf,onReconnectEnd:h0,onEdgeContextMenu:Qu,onEdgeDoubleClick:P6,onEdgeMouseEnter:L1,onEdgeMouseMove:C6,onEdgeMouseLeave:u3,reconnectRadius:_3,defaultMarkerColor:vf,noDragClassName:R6,noWheelClassName:z2,noPanClassName:x6,rfId:wl,disableKeyboardA11y:Ol,nodeExtent:uf,viewport:I6,onViewportChange:H2}),t.jsx(Lw,{onSelectionChange:P}),_0,t.jsx(Vw,{proOptions:Vl,position:G2}),t.jsx(Hw,{rfId:wl,disableKeyboardA11y:Ol})]})})}var oZ=BZ(aD);var pb=hu.error014();function dD({dimensions:f,lineWidth:u,variant:_,className:y}){return t.jsx("path",{strokeWidth:u,d:`M${f[0]/2} 0 V${f[1]} M0 ${f[1]/2} H${f[0]}`,className:Y0(["react-flow__background-pattern",_,y])})}function eD({radius:f,className:u}){return t.jsx("circle",{cx:f,cy:f,r:f,className:Y0(["react-flow__background-pattern","dots",u])})}var o_;(function(f){f.Lines="lines",f.Dots="dots",f.Cross="cross"})(o_||(o_={}));var fT={[o_.Dots]:1,[o_.Lines]:1,[o_.Cross]:6},uT=(f)=>({transform:f.transform,patternId:`pattern-${f.rfId}`});function aZ({id:f,variant:u=o_.Dots,gap:_=20,size:y,lineWidth:l=1,offset:$=0,color:j,bgColor:J,style:F,className:Q,patternClassName:U}){let z=d.useRef(null),{transform:W,patternId:K}=mf(uT,Q0),q=y||fT[u],V=u===o_.Dots,O=u===o_.Cross,G=Array.isArray(_)?_:[_,_],H=[G[0]*W[2]||1,G[1]*W[2]||1],Z=q*W[2],E=Array.isArray($)?$:[$,$],L=O?[Z,Z]:H,M=[E[0]*W[2]||1+L[0]/2,E[1]*W[2]||1+L[1]/2],N=`${K}${f?f:""}`;return t.jsxs("svg",{className:Y0(["react-flow__background",Q]),style:{...F,...b5,"--xy-background-color-props":J,"--xy-background-pattern-color-props":j},ref:z,"data-testid":"rf__background",children:[t.jsx("pattern",{id:N,x:W[0]%H[0],y:W[1]%H[1],width:H[0],height:H[1],patternUnits:"userSpaceOnUse",patternTransform:`translate(-${M[0]},-${M[1]})`,children:V?t.jsx(eD,{radius:Z/2,className:U}):t.jsx(dD,{dimensions:L,lineWidth:l,variant:u,className:U})}),t.jsx("rect",{x:"0",y:"0",width:"100%",height:"100%",fill:`url(#${N})`})]})}aZ.displayName="Background";var dZ=d.memo(aZ);function _T(){return t.jsx("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 32 32",children:t.jsx("path",{d:"M32 18.133H18.133V32h-4.266V18.133H0v-4.266h13.867V0h4.266v13.867H32z"})})}function yT(){return t.jsx("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 32 5",children:t.jsx("path",{d:"M0 0h32v4.2H0z"})})}function lT(){return t.jsx("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 32 30",children:t.jsx("path",{d:"M3.692 4.63c0-.53.4-.938.939-.938h5.215V0H4.708C2.13 0 0 2.054 0 4.63v5.216h3.692V4.631zM27.354 0h-5.2v3.692h5.17c.53 0 .984.4.984.939v5.215H32V4.631A4.624 4.624 0 0027.354 0zm.954 24.83c0 .532-.4.94-.939.94h-5.215v3.768h5.215c2.577 0 4.631-2.13 4.631-4.707v-5.139h-3.692v5.139zm-23.677.94c-.531 0-.939-.4-.939-.94v-5.138H0v5.139c0 2.577 2.13 4.707 4.708 4.707h5.138V25.77H4.631z"})})}function $T(){return t.jsx("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 25 32",children:t.jsx("path",{d:"M21.333 10.667H19.81V7.619C19.81 3.429 16.38 0 12.19 0 8 0 4.571 3.429 4.571 7.619v3.048H3.048A3.056 3.056 0 000 13.714v15.238A3.056 3.056 0 003.048 32h18.285a3.056 3.056 0 003.048-3.048V13.714a3.056 3.056 0 00-3.048-3.047zM12.19 24.533a3.056 3.056 0 01-3.047-3.047 3.056 3.056 0 013.047-3.048 3.056 3.056 0 013.048 3.048 3.056 3.056 0 01-3.048 3.047zm4.724-13.866H7.467V7.619c0-2.59 2.133-4.724 4.723-4.724 2.591 0 4.724 2.133 4.724 4.724v3.048z"})})}function jT(){return t.jsx("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 25 32",children:t.jsx("path",{d:"M21.333 10.667H19.81V7.619C19.81 3.429 16.38 0 12.19 0c-4.114 1.828-1.37 2.133.305 2.438 1.676.305 4.42 2.59 4.42 5.181v3.048H3.047A3.056 3.056 0 000 13.714v15.238A3.056 3.056 0 003.048 32h18.285a3.056 3.056 0 003.048-3.048V13.714a3.056 3.056 0 00-3.048-3.047zM12.19 24.533a3.056 3.056 0 01-3.047-3.047 3.056 3.056 0 013.047-3.048 3.056 3.056 0 013.048 3.048 3.056 3.056 0 01-3.048 3.047z"})})}function C5({children:f,className:u,..._}){return t.jsx("button",{type:"button",className:Y0(["react-flow__controls-button",u]),..._,children:f})}var JT=(f)=>({isInteractive:f.nodesDraggable||f.nodesConnectable||f.elementsSelectable,minZoomReached:f.transform[2]<=f.minZoom,maxZoomReached:f.transform[2]>=f.maxZoom,ariaLabelConfig:f.ariaLabelConfig});function eZ({style:f,showZoom:u=!0,showFitView:_=!0,showInteractive:y=!0,fitViewOptions:l,onZoomIn:$,onZoomOut:j,onFitView:J,onInteractiveChange:F,className:Q,children:U,position:z="bottom-left",orientation:W="vertical","aria-label":K}){let q=W0(),{isInteractive:V,minZoomReached:O,maxZoomReached:G,ariaLabelConfig:H}=mf(JT,Q0),{zoomIn:Z,zoomOut:E,fitView:L}=DF(),M=()=>{Z(),$?.()},N=()=>{E(),j?.()},w=()=>{L(l),J?.()},R=()=>{q.setState({nodesDraggable:!V,nodesConnectable:!V,elementsSelectable:!V}),F?.(!V)};return t.jsxs(v5,{className:Y0(["react-flow__controls",W==="horizontal"?"horizontal":"vertical",Q]),position:z,style:f,"data-testid":"rf__controls","aria-label":K??H["controls.ariaLabel"],children:[u&&t.jsxs(t.Fragment,{children:[t.jsx(C5,{onClick:M,className:"react-flow__controls-zoomin",title:H["controls.zoomIn.ariaLabel"],"aria-label":H["controls.zoomIn.ariaLabel"],disabled:G,children:t.jsx(_T,{})}),t.jsx(C5,{onClick:N,className:"react-flow__controls-zoomout",title:H["controls.zoomOut.ariaLabel"],"aria-label":H["controls.zoomOut.ariaLabel"],disabled:O,children:t.jsx(yT,{})})]}),_&&t.jsx(C5,{className:"react-flow__controls-fitview",onClick:w,title:H["controls.fitView.ariaLabel"],"aria-label":H["controls.fitView.ariaLabel"],children:t.jsx(lT,{})}),y&&t.jsx(C5,{className:"react-flow__controls-interactive",onClick:R,title:H["controls.interactive.ariaLabel"],"aria-label":H["controls.interactive.ariaLabel"],children:V?t.jsx(jT,{}):t.jsx($T,{})}),U]})}eZ.displayName="Controls";var fq=d.memo(eZ);function FT({id:f,x:u,y:_,width:y,height:l,style:$,color:j,strokeColor:J,strokeWidth:F,className:Q,borderRadius:U,shapeRendering:z,selected:W,onClick:K}){let{background:q,backgroundColor:V}=$||{},O=j||q||V;return t.jsx("rect",{className:Y0(["react-flow__minimap-node",{selected:W},Q]),x:u,y:_,rx:U,ry:U,width:y,height:l,style:{fill:O,stroke:J,strokeWidth:F},shapeRendering:z,onClick:K?(G)=>K(G,f):void 0})}var AT=d.memo(FT),QT=(f)=>f.nodes.map((u)=>u.id),YF=(f)=>f instanceof Function?f:()=>f;function UT({nodeStrokeColor:f,nodeColor:u,nodeClassName:_="",nodeBorderRadius:y=5,nodeStrokeWidth:l,nodeComponent:$=AT,onClick:j}){let J=mf(QT,Q0),F=YF(u),Q=YF(f),U=YF(_),z=typeof window>"u"||!!window.chrome?"crispEdges":"geometricPrecision";return t.jsx(t.Fragment,{children:J.map((W)=>t.jsx(zT,{id:W,nodeColorFunc:F,nodeStrokeColorFunc:Q,nodeClassNameFunc:U,nodeBorderRadius:y,nodeStrokeWidth:l,NodeComponent:$,onClick:j,shapeRendering:z},W))})}function WT({id:f,nodeColorFunc:u,nodeStrokeColorFunc:_,nodeClassNameFunc:y,nodeBorderRadius:l,nodeStrokeWidth:$,shapeRendering:j,NodeComponent:J,onClick:F}){let{node:Q,x:U,y:z,width:W,height:K}=mf((q)=>{let V=q.nodeLookup.get(f);if(!V)return{node:void 0,x:0,y:0,width:0,height:0};let O=V.internals.userNode,{x:G,y:H}=V.internals.positionAbsolute,{width:Z,height:E}=b1(O);return{node:O,x:G,y:H,width:Z,height:E}},Q0);if(!Q||Q.hidden||!AF(Q))return null;return t.jsx(J,{x:U,y:z,width:W,height:K,style:Q.style,selected:!!Q.selected,className:y(Q),color:u(Q),borderRadius:l,strokeColor:_(Q),strokeWidth:$,shapeRendering:j,onClick:F,id:Q.id})}var zT=d.memo(WT),GT=d.memo(UT),KT=200,ZT=150,qT=(f)=>!f.hidden,HT=(f)=>{let u={x:-f.transform[0]/f.transform[2],y:-f.transform[1]/f.transform[2],width:f.width/f.transform[2],height:f.height/f.transform[2]};return{viewBB:u,boundingRect:f.nodeLookup.size>0?jF(_l(f.nodeLookup,{filter:qT}),u):u,rfId:f.rfId,panZoom:f.panZoom,translateExtent:f.translateExtent,flowWidth:f.width,flowHeight:f.height,ariaLabelConfig:f.ariaLabelConfig}},VT="react-flow__minimap-desc";function uq({style:f,className:u,nodeStrokeColor:_,nodeColor:y,nodeClassName:l="",nodeBorderRadius:$=5,nodeStrokeWidth:j,nodeComponent:J,bgColor:F,maskColor:Q,maskStrokeColor:U,maskStrokeWidth:z,position:W="bottom-right",onClick:K,onNodeClick:q,pannable:V=!1,zoomable:O=!1,ariaLabel:G,inversePan:H,zoomStep:Z=1,offsetScale:E=5}){let L=W0(),M=d.useRef(null),{boundingRect:N,viewBB:w,rfId:R,panZoom:p,translateExtent:x,flowWidth:C,flowHeight:P,ariaLabelConfig:D}=mf(HT,Q0),T=f?.width??KT,S=f?.height??ZT,r=N.width/T,Y=N.height/S,v=Math.max(r,Y),m=v*T,c=v*S,o=E*v,ff=N.x-(m-N.width)/2-o,n=N.y-(c-N.height)/2-o,lf=m+o*2,Gf=c+o*2,zf=`${VT}-${R}`,jf=d.useRef(0),Wf=d.useRef();jf.current=v,d.useEffect(()=>{if(M.current&&p)return Wf.current=SK({domNode:M.current,panZoom:p,getTransform:()=>L.getState().transform,getViewScale:()=>jf.current}),()=>{Wf.current?.destroy()}},[p]),d.useEffect(()=>{Wf.current?.update({translateExtent:x,width:C,height:P,inversePan:H,pannable:V,zoomStep:Z,zoomable:O})},[V,O,H,Z,x,C,P]);let Vf=K?(g)=>{let[I,yf]=Wf.current?.pointer(g)||[0,0];K(g,{x:I,y:yf})}:void 0,Kf=q?d.useCallback((g,I)=>{let yf=L.getState().nodeLookup.get(I).internals.userNode;q(g,yf)},[]):void 0,h=G??D["minimap.ariaLabel"];return t.jsx(v5,{position:W,style:{...f,"--xy-minimap-background-color-props":typeof F==="string"?F:void 0,"--xy-minimap-mask-background-color-props":typeof Q==="string"?Q:void 0,"--xy-minimap-mask-stroke-color-props":typeof U==="string"?U:void 0,"--xy-minimap-mask-stroke-width-props":typeof z==="number"?z*v:void 0,"--xy-minimap-node-background-color-props":typeof y==="string"?y:void 0,"--xy-minimap-node-stroke-color-props":typeof _==="string"?_:void 0,"--xy-minimap-node-stroke-width-props":typeof j==="number"?j:void 0},className:Y0(["react-flow__minimap",u]),"data-testid":"rf__minimap",children:t.jsxs("svg",{width:T,height:S,viewBox:`${ff} ${n} ${lf} ${Gf}`,className:"react-flow__minimap-svg",role:"img","aria-labelledby":zf,ref:M,onClick:Vf,children:[h&&t.jsx("title",{id:zf,children:h}),t.jsx(GT,{onClick:Kf,nodeColor:y,nodeStrokeColor:_,nodeBorderRadius:$,nodeClassName:l,nodeStrokeWidth:j,nodeComponent:J}),t.jsx("path",{className:"react-flow__minimap-mask",d:`M${ff-o},${n-o}h${lf+o*2}v${Gf+o*2}h${-lf-o*2}z + M${w.x},${w.y}h${w.width}v${w.height}h${-w.width}z`,fillRule:"evenodd",pointerEvents:"none"})]})})}uq.displayName="MiniMap";var mb=d.memo(uq),ET=(f)=>(u)=>f?`${Math.max(1/u.transform[2],1)}`:void 0,OT={[s_.Line]:"right",[s_.Handle]:"bottom-right"};function XT({nodeId:f,position:u,variant:_=s_.Handle,className:y,style:l=void 0,children:$,color:j,minWidth:J=10,minHeight:F=10,maxWidth:Q=Number.MAX_VALUE,maxHeight:U=Number.MAX_VALUE,keepAspectRatio:z=!1,resizeDirection:W,autoScale:K=!0,shouldResize:q,onResizeStart:V,onResize:O,onResizeEnd:G}){let H=MZ(),Z=typeof f==="string"?f:H,E=W0(),L=d.useRef(null),M=_===s_.Handle,N=mf(d.useCallback(ET(M&&K),[M,K]),Q0),w=d.useRef(null),R=u??OT[_];d.useEffect(()=>{if(!L.current||!Z)return;if(!w.current)w.current=vK({domNode:L.current,nodeId:Z,getStoreItems:()=>{let{nodeLookup:x,transform:C,snapGrid:P,snapToGrid:D,nodeOrigin:T,domNode:S}=E.getState();return{nodeLookup:x,transform:C,snapGrid:P,snapToGrid:D,nodeOrigin:T,paneDomNode:S}},onChange:(x,C)=>{let{triggerNodeChanges:P,nodeLookup:D,parentLookup:T,nodeOrigin:S}=E.getState(),r=[],Y={x:x.x,y:x.y},v=D.get(Z);if(v&&v.expandParent&&v.parentId){let m=v.origin??S,c=x.width??v.measured.width??0,o=x.height??v.measured.height??0,ff={id:v.id,parentId:v.parentId,rect:{width:c,height:o,...QF({x:x.x??v.position.x,y:x.y??v.position.y},{width:c,height:o},v.parentId,D,m)}},n=T5([ff],D,T,S);r.push(...n),Y.x=x.x?Math.max(m[0]*c,x.x):void 0,Y.y=x.y?Math.max(m[1]*o,x.y):void 0}if(Y.x!==void 0&&Y.y!==void 0){let m={id:Z,type:"position",position:{...Y}};r.push(m)}if(x.width!==void 0&&x.height!==void 0){let c={id:Z,type:"dimensions",resizing:!0,setAttributes:!W?!0:W==="horizontal"?"width":"height",dimensions:{width:x.width,height:x.height}};r.push(c)}for(let m of C){let c={...m,type:"position"};r.push(c)}P(r)},onEnd:({width:x,height:C})=>{let P={id:Z,type:"dimensions",resizing:!1,dimensions:{width:x,height:C}};E.getState().triggerNodeChanges([P])}});return w.current.update({controlPosition:R,boundaries:{minWidth:J,minHeight:F,maxWidth:Q,maxHeight:U},keepAspectRatio:z,resizeDirection:W,onResizeStart:V,onResize:O,onResizeEnd:G,shouldResize:q}),()=>{w.current?.destroy()}},[R,J,F,Q,U,z,V,O,G,q]);let p=R.split("-");return t.jsx("div",{className:Y0(["react-flow__resize-control","nodrag",...p,_,y]),ref:L,style:{...l,scale:N,...j&&{[M?"backgroundColor":"borderColor"]:j}},children:$})}var kb=d.memo(XT);var X=fy.default.createElement,{useEffect:c1}=fy.default,Bu=fy.default.useState,e_=fy.default.useRef,Y6=[{id:"in-left",side:"left",position:Uf.Left,style:{top:"50%"}},{id:"in-top-left",side:"top",slot:"left",slotIndex:-1,position:Uf.Top,style:{left:"28%"}},{id:"in-top-mid",side:"top",slot:"mid",slotIndex:0,position:Uf.Top,style:{left:"50%"}},{id:"in-top-right",side:"top",slot:"right",slotIndex:1,position:Uf.Top,style:{left:"72%"}},{id:"in-bottom-left",side:"bottom",slot:"left",slotIndex:-1,position:Uf.Bottom,style:{left:"28%"}},{id:"in-bottom-mid",side:"bottom",slot:"mid",slotIndex:0,position:Uf.Bottom,style:{left:"50%"}},{id:"in-bottom-right",side:"bottom",slot:"right",slotIndex:1,position:Uf.Bottom,style:{left:"72%"}}],O6=[{id:"out-right",position:Uf.Right,style:{top:"50%"}}],_q=["#4eb7a8","#d7a13a","#69aee8","#e0835f","#b7d86b","#d98bd2","#5fc6bf"],Ql=236,Ul=88,yq=15000,NT=10,MF=96,h1=72,rF=64,lq=12;function $q(f,u){let _=Number.parseFloat(String(f||""));return Number.isFinite(_)?_/100:u}function LT(f,u,_){let y=String(f.side||"");if(y!=="top"&&y!=="bottom")return 0;let l=Number(f.slotIndex||0),$=y==="top"?"in-top-mid":"in-bottom-mid",j=u.get(f.id)||0,J=u.get($)||0;if(l===0)return J===0?-26:28+j*74;let F=_===0?Math.abs(l)*2:Math.sign(_)===Math.sign(l)?-3:3;if(J>0&&j===0)return-14+F;return 8+j*74+F}function h5(f){let u=f.filter(($,j)=>{let J=f[j-1];return!J||Math.abs(J.x-$.x)>0.5||Math.abs(J.y-$.y)>0.5});if(u.length<2)return"";let _=`M ${u[0].x},${u[0].y}`,y=u[0];for(let $=1;$0.5||Math.abs(W.y-y.y)>0.5)_+=` L ${W.x},${W.y}`;_+=` Q ${J.x},${J.y} ${K.x},${K.y}`,y=K}let l=u[u.length-1];return`${_} L ${l.x},${l.y}`}function Bq(f,u,_,y,l,$,j=""){let J=_>=f,F=Math.max(1,Math.abs(_-f)),Q=Math.abs(y-u),U=Math.max(34,Math.min(118,F*0.26)),z=Math.min(280,Math.abs($));if(J&&l===Uf.Left&&z<4&&Q<28&&F<420)return`M ${f},${u} C ${f+U},${u} ${_-U},${y} ${_},${y}`;if(J&&l===Uf.Left&&(j==="direct-forward-left"||F<=260&&Q<=210)){let G=Math.max(42,Math.min(140,F*0.48)),H=Math.max(-28,Math.min(28,$*0.18));return`M ${f},${u} C ${f+G},${u+H} ${_-G},${y} ${_},${y}`}if(J){let G=f+U;if(l===Uf.Top||l===Uf.Bottom){let E=l===Uf.Top?-1:1,L=y+E*(54+z*0.42);return h5([{x:f,y:u},{x:G,y:u},{x:G+Math.min(120,F*0.18),y:L},{x:_,y:L},{x:_,y:y+E*34},{x:_,y}])}let H=_-U,Z=(u+y)/2+$;return h5([{x:f,y:u},{x:G,y:u},{x:G+Math.min(110,F*0.16),y:Z},{x:H-Math.min(90,F*0.12),y:Z},{x:H,y},{x:_,y}])}let q=l===Uf.Bottom?1:l===Uf.Top?-1:$>=0?1:-1,V=Math.max(f,_)+92+Math.min(180,z*0.52),O=q<0?Math.min(u,y)-84-z*0.62:Math.max(u,y)+84+z*0.62;if(l===Uf.Top||l===Uf.Bottom)return h5([{x:f,y:u},{x:f+U,y:u},{x:V,y:O},{x:_,y:O},{x:_,y:y+q*38},{x:_,y}]);return h5([{x:f,y:u},{x:f+U,y:u},{x:V,y:O},{x:_-U,y:O},{x:_-U,y},{x:_,y}])}function YT({data:f}){return X("div",{className:"pipeline-flow-node-body"},Y6.map((u)=>X(ty,{key:u.id,id:u.id,type:"target",position:u.position,isConnectable:!1,className:`pipeline-flow-handle input ${u.side} slot-${u.slot||"mid"}`,style:u.style})),O6.map((u)=>X(ty,{key:u.id,id:u.id,type:"source",position:u.position,isConnectable:!1,className:"pipeline-flow-handle output right",style:u.style})),f?.label)}function BT({id:f,sourceX:u,sourceY:_,targetX:y,targetY:l,targetPosition:$,markerEnd:j,markerStart:J,style:F,data:Q}){let U=Number(Q?.laneOffset||0),z=Bq(u,_,y,l,$,U,String(Q?.routeMode||""));return X(Al,{id:f,path:z,markerEnd:j,markerStart:J,style:F,interactionWidth:28})}var wT={pipelineCurve:BT},DT={pipelineNode:YT};function z0(f){if(!f)return"--";let u=new Date(f);if(Number.isNaN(u.getTime()))return"--";return u.toLocaleString("zh-CN",{hour12:!1})}function a5(f){return f.toLocaleTimeString("zh-CN",{hour12:!1})}function m5(f){if(!f)return"--";let u=new Date(f);if(Number.isNaN(u.getTime()))return"--";return a5(u)}function l1(f){let u=Number(f);if(!Number.isFinite(u)||u<0)return"--";let _=Math.round(u/1000);if(_<60)return`${_}s`;if(_<3600)return`${Math.floor(_/60)}m ${_%60}s`;return`${Math.floor(_/3600)}h ${Math.floor(_%3600/60)}m`}function SF(f){let u=Number(f);if(!Number.isFinite(u))return"--";return u.toLocaleString("zh-CN")}function jq(f){let u=Number(f);if(!Number.isFinite(u))return"--";return`${Math.round(Math.max(0,Math.min(1,u))*100)}%`}function Xf(f){return typeof f==="object"&&f!==null&&!Array.isArray(f)}function Hf(f){return Array.isArray(f)?f:[]}function Pf(f){if(!f)return null;let u=new Date(f);return Number.isNaN(u.getTime())?null:u.getTime()}function B6(f){return Number.isFinite(Number(f))?new Date(Number(f)).toISOString():""}function T6(...f){for(let u of f){let _=Pf(u);if(_!==null)return new Date(_).toISOString()}return""}function gF(...f){let u=f.map(Pf).filter((_)=>_!==null);return u.length>0?new Date(Math.max(...u)).toISOString():""}function nF(f){return["succeeded","failed","skipped","cancelled","canceled","completed"].includes(String(f||"").toLowerCase())}function wq(f){let u=Mq(f).toLowerCase();return["running","active","in-progress","in_progress"].includes(u)}function Jq(f,u="status"){return f.reduce((_,y)=>{let l=String(y?.[u]||"unknown").toLowerCase();return _[l]=(_[l]||0)+1,_},{})}function Dq(f){if(!f||typeof f!=="string")return null;try{let u=JSON.parse(f);return Xf(u)?u:null}catch{return null}}function PF(f){let u=f.map(Dq).filter(($)=>Boolean($)),_=u.flatMap(($)=>[$.timestamp,$.createdAt,$.updatedAt]).filter(Boolean),y=gF(..._),l=Array.from(new Set(u.map(($)=>String($.event||$.action||$.type||"")).filter(Boolean))).slice(0,3);return{total:f.length,parsed:u.length,lastAt:y,eventKinds:l}}function k5(f){if(f===null||f===void 0)return"--";if(typeof f==="boolean")return f?"是":"否";if(typeof f==="number")return String(f);if(typeof f==="string")return f.length>80?`${f.slice(0,77)}...`:f;if(Array.isArray(f))return`${f.length} 项`;if(typeof f==="object")return`${Object.keys(f).length} 字段`;return String(f)}function Tq(f,u=280){if(f===null||f===void 0)return"";let y=(typeof f==="string"?f:String(f)).replace(/\r\n/gu,` +`).trim();return y.length>u?`${y.slice(0,Math.max(0,u-1))}...`:y}function Mq(f){if(typeof f==="string")return f;if(Xf(f))return String(f.status||f.state||f.phase||"unknown");return"unknown"}function TT(f){return f.filter((u)=>u&&u.value!==void 0&&u.value!==null&&String(u.value)!=="")}function hF({items:f}){let u=TT(Hf(f));return X("div",{className:"pipeline-kv-grid"},u.map((_)=>X("span",{key:_.label},X("b",null,_.label),X("span",null,_.value))))}function tF({items:f}){let u=Hf(f).map((_)=>String(_||"")).filter(Boolean);if(u.length===0)return null;return X("div",{className:"pipeline-chip-row"},u.map((_,y)=>X("span",{key:`${y}-${_}`},_)))}function IF(f,u){let _=String(u?.procedureRunId||""),y=Hf(f?.procedureRuns);return y.find((l)=>String($1(l))===_)||y.at(-1)||null}function MT(f,u){let _=String(u||"");if(!_)return null;return Hf(f?.procedureRuns).find((y)=>$1(y)===_)||null}function CF(f){return Hf(f?.attempts).length}function Fq(f){return Hf(f?.attempts).reduce((u,_)=>u+d5(_).length,0)}function d5(f){return Hf(f?.opencodeMessages?.steps).filter(Xf)}function rq(f){let u=String(f?.status||"").toLowerCase();if(["error","failed","failure"].includes(u))return"failed";if(["completed","succeeded","success"].includes(u))return"succeeded";if(["running","started","in_progress"].includes(u))return"running";return"unknown"}function rT(f,u){let _=cF(f.map(($)=>$?.agent)).slice(0,3),y=cF(f.map(($)=>$?.model)).slice(0,3),l=u.length<=2?u.map(($)=>`session ${$}`):[`sessions ${u.length}`,...u.slice(0,2).map(($)=>`session ${$}`)];return[..._.map(($)=>`agent ${$}`),...y.map(($)=>`model ${$}`),...l]}function X6(f,u=0){return String(f?.messageId||f?.index||"")||`step-${u}`}function ST({steps:f,sessionIds:u,sessionFacts:_,matchedStepKey:y}){let l=Hf(f),$=l.findIndex((O,G)=>X6(O,G)===y),j=$>=0?l[$]:null,J=l.flatMap((O)=>[Pf(O?.createdAt),Pf(O?.completedAt)]).filter((O)=>O!==null),F=J.length>0?Math.min(...J):null,Q=J.length>0?Math.max(...J):null,U=F!==null&&Q!==null?Math.max(0,Q-F):null,z=l.reduce((O,G)=>O+Hf(G?.parts).filter((H)=>String(H?.type||"").toLowerCase()==="tool").length,0),W=l.reduce((O,G)=>O+Hf(G?.parts).filter((H)=>["text","reasoning"].includes(String(H?.type||"").toLowerCase())).length,0),K=l.reduce((O,G)=>O+Hf(G?.parts).filter((H)=>String(H?.type||"").toLowerCase()==="tool"&&rq(H)==="failed").length,0),q=[`${l.length} steps`,`${u.length} sessions`,`${W} messages`,`${z} tools`,U!==null?`duration ${l1(U)}`:"",K>0?`${K} failed tools`:""].filter(Boolean),V=j?[`Step ${j?.index??$+1}`,String(j?.role||"role --"),j?.model?`model ${j.model}`:"",j?.finish?`finish ${j.finish}`:"",j?.durationMs!==void 0&&j?.durationMs!==null?`duration ${l1(j.durationMs)}`:""].filter(Boolean):[];return X("section",{className:"pipeline-trace-timeline","data-testid":"pipeline-step-timeline"},X("div",{className:"pipeline-trace-head"},X("div",null,X("b",null,"OpenCode Trace"),X("span",null,"Trace 使用 Codex Queue 统一样式展示完整 agent loop;Pipeline 旧 step/message/tool 卡片样式已废弃。")),X("div",{className:"pipeline-trace-session-head","data-testid":"pipeline-step-timeline-session"},X("span",null,q.join(" / ")||"Trace"),_.length>0?X(tF,{items:_}):null)),j?X("div",{className:"pipeline-trace-focus","data-testid":"pipeline-trace-matched-step"},X("span",{className:"codex-output-channel"},"Matched"),X("strong",null,`Gantt selection -> ${V.join(" / ")}`),X("time",null,`${m5(j?.createdAt)} -> ${m5(j?.completedAt)}`)):null,X(V4,{port:Nz,input:l,className:"codex-transcript pipeline-trace",testId:"pipeline-opencode-step-trace",emptyText:"暂无 OpenCode Trace 输出",keepRecentToolCalls:3}))}function N6(f){return Hf(f).flatMap((u)=>{if(Xf(u))return[u];let _=Dq(u);return _?[_]:[]})}function O1(f){return String(f?.event||f?.action||f?.requestedAction||f?.type||"").toLowerCase()}function sy(f){return T6(f?.timestamp,f?.createdAt,f?.updatedAt,f?.startedAt,f?.finishedAt)}function PT(f){return Pf(sy(f))}function e5(f){return String(f?.attempt||f?.id||"")}function cF(f){let u=new Set,_=[];for(let y of f){let l=String(y||"");if(!l||u.has(l))continue;u.add(l),_.push(l)}return _}function Aq(f){switch(String(f||"").toLowerCase()){case"monitor":return"monitor";case"webui":return"webui";case"cli":return"cli";case"system":return"runner";default:return String(f||"--")}}function oy(f){return String(f?.requestedAction||f?.action||"").toLowerCase()}function L6(f){switch(oy(f)){case"guide":return"引导";case"modify":return"修改";case"approve":return"审核通过";case"restart":return"重启";case"redo":return"重做";default:return String(f?.requestedAction||f?.action||"控制")}}function Qq(f){switch(O1(f)){case"initial-prompt-delivered":return"初始 prompt";case"append-prompt-delivered":return"追加 prompt";case"append-prompt-queued":return"追加 prompt 已排队";case"monitor-prompt-delivered":return"Monitor prompt";case"node-long-running-observation":return"长任务观察";case"node-finished":return"节点完成";case"oa-policy-downstream-evaluated":return"OA 下游策略";case"control-command-queued":return`${L6(f)} 已发起`;case"control-command-applied":return`${L6(f)} 已生效`;case"control-command-ignored":return`${L6(f)} 已忽略`;default:return String(f?.event||f?.action||f?.requestedAction||"event")}}function Uq(f){return Tq(f?.promptPreview||f?.reasonPreview||f?.prompt||f?.reason||"",240)}function CT(f){let u=String(f?.prompt||""),_=String(f?.reason||f?.restartReason||""),y=u?"":String(f?.promptPreview||""),l=_?"":String(f?.reasonPreview||"");return[u||y?{label:u?"prompt":"prompt preview",value:u||y}:null,_||l?{label:_?"reason":"reason preview",value:_||l}:null,Hf(f?.resetNodeIds).length>0?{label:"reset nodes",value:Hf(f.resetNodeIds).join(", ")}:null,Hf(f?.runningResetNodeIds).length>0?{label:"interrupted running nodes",value:Hf(f.runningResetNodeIds).join(", ")}:null,Hf(f?.interruptedProcedureRunIds).length>0?{label:"interrupted procedures",value:Hf(f.interruptedProcedureRunIds).join(", ")}:null,f?.interruptedProcedureRunId?{label:"interrupted procedure",value:String(f.interruptedProcedureRunId)}:null].filter(Boolean)}function RF(f){let u=d5(f),_=u.map((F)=>Pf(F?.createdAt)).filter((F)=>F!==null),y=u.map((F)=>Pf(F?.completedAt)??Pf(F?.createdAt)).filter((F)=>F!==null),l=N6(f?.controlEventRecords).map((F)=>PT(F)).filter((F)=>F!==null),$=Hf(f?.assistantOutputs).map((F)=>Pf(F?.updatedAt)).filter((F)=>F!==null),j=_[0]??l[0]??$[0]??null,J=y.at(-1)??l.at(-1)??$.at(-1)??j;return{startMs:j,endMs:J}}function RT(f,u,_,y,l=""){let $=Hf(f?.procedureRuns).filter((J)=>f2(J,u)===_);if($.length===0)return null;if(l){let J=$.find((F)=>$1(F)===l);if(J)return J}if(y===null)return $.at(-1)||null;let j=$.find((J)=>{let F=Pf(I5(J,f)),Q=Pf(c5(J,f))??F;return F!==null&&Q!==null&&y>=F-1000&&y<=Q+1000});if(j)return j;return $.slice().sort((J,F)=>{let Q=Pf(I5(J,f))??y,U=Pf(c5(J,f))??Q,z=Pf(I5(F,f))??y,W=Pf(c5(F,f))??z,K=Math.min(Math.abs(Q-y),Math.abs(U-y)),q=Math.min(Math.abs(z-y),Math.abs(W-y));return K-q})[0]||null}function Sq(f,u){let _=Hf(f?.attempts).filter(Xf);if(_.length===0)return null;let y=String(u?.attempt||"");if(y){let j=_.find((J)=>e5(J)===y);if(j)return j}let l=Number.isFinite(Number(u?.ms))?Number(u.ms):null;if(l===null)return _.at(-1)||null;let $=_.find((j)=>{let J=RF(j);return Number.isFinite(J.startMs)&&Number.isFinite(J.endMs)&&l>=Number(J.startMs)-1000&&l<=Number(J.endMs)+1000});if($)return $;return _.slice().sort((j,J)=>{let F=RF(j),Q=RF(J),U=Math.min(Math.abs(Number(F.startMs??l)-l),Math.abs(Number(F.endMs??l)-l)),z=Math.min(Math.abs(Number(Q.startMs??l)-l),Math.abs(Number(Q.endMs??l)-l));return U-z})[0]||_.at(-1)||null}function Pq(f,u){let _=d5(f);if(_.length===0)return{step:null,stepIndex:-1,stepKey:""};if(u===null){let $=_[0];return{step:$,stepIndex:0,stepKey:X6($,0)}}for(let $=0;$<_.length;$+=1){let j=_[$],J=Pf(j?.createdAt)??Pf(j?.completedAt),F=Pf(j?.completedAt)??J;if(J!==null&&F!==null&&u>=J-1000&&u<=F+1000)return{step:j,stepIndex:$,stepKey:X6(j,$)}}let y=_.findIndex(($)=>{let j=Pf($?.createdAt)??Pf($?.completedAt);return j!==null&&j>=u});if(y>=0){let $=_[y];return{step:$,stepIndex:y,stepKey:X6($,y)}}let l=Math.max(0,_.length-1);return{step:_[l],stepIndex:l,stepKey:X6(_[l],l)}}function xT(f,u){let _=String(u?.runId||f?.runId||"");if(String(u?.mode||"")==="interval"){let Q=u?.interval||{},U=IF(f,Q)||Q.raw||{};return{mode:"interval",runId:_,interval:Q,marker:null,nodeId:String(Q?.nodeId||f2(U,_)||""),procedure:U,attempt:null,matchedStep:null,matchedStepIndex:-1,matchedStepKey:""}}let y=Xf(u?.marker)?u.marker:{},l=Number.isFinite(Number(y?.ms))?Number(y.ms):null,$=String(y?.nodeId||""),j=$?RT(f,_,$,l,String(y?.procedureRunId||"")):null,J=j?Sq(j,y):null,F=J?Pq(J,l):{step:null,stepIndex:-1,stepKey:""};return{mode:"event",runId:_,interval:null,marker:y,nodeId:$,procedure:j,attempt:J,matchedStep:F.step,matchedStepIndex:F.stepIndex,matchedStepKey:F.stepKey}}function vT({procedure:f,matchedStepKey:u="",matchedAttemptId:_=""}){let y=Hf(f?.attempts);if(y.length===0)return X(cu,{title:"暂无 attempt 详情",text:"当前 procedure 还没有可展示的 attempt / OpenCode Trace;若刚点击甘特线,请等待 node 详情抓取完成。"});return y.map((l,$)=>{let j=l?.opencodeMessages||{},J=d5(l),F=Hf(j.sessionIds).map((W)=>String(W)).filter(Boolean),Q=rT(J,F),U=e5(l)||`attempt-${$+1}`,z=J.reduce((W,K)=>W+Hf(K?.parts).filter((q)=>String(q?.type||"").toLowerCase()==="tool"&&rq(q)==="failed").length,0);return X("article",{key:U,className:`pipeline-attempt-card ${_===U?"matched":""}`},X("div",{className:"pipeline-attempt-head"},X("div",null,X("strong",null,U),X("span",null,j.source||"opencode")),X("div",{className:"pipeline-attempt-badges"},X("span",null,`${J.length} steps`),X("span",null,`${j.toolCallCount??"--"} tools`),z>0?X("span",{className:"danger"},`${z} failed`):null)),X(hF,{items:[{label:"messages",value:j.messageCount??"--"},{label:"steps",value:j.stepCount??J.length},{label:"tools",value:j.toolCallCount??"--"},{label:"updated",value:z0(j.updatedAt)},{label:"sessions",value:F.join(", ")||"--"}]}),J.length===0?X("p",{className:"muted paragraph"},"当前 attempt 尚未返回 OpenCode Trace;请确认 D601 pipeline-control 已重建并重新抓取。"):X(ST,{steps:J,sessionIds:F,sessionFacts:Q,matchedStepKey:u}))})}function xF(f,u){return`${f}::${u}`}function i5(f,u,_){if(!Xf(f))return null;return String(f.runId||"")===u&&String(f.nodeId||"")===_?f:null}function bT(f,u){let _=Xf(f)?f:{};if(!Xf(u))return _;let y=Hf(u.attempts),l=Hf(_.attempts);return{..._,...u,attempts:y.length>0?y:l}}function hT(f,u,_,y){if(!i5(u,_,y))return f;let l=Hf(u.procedureRuns),$=Xf(f)?f:{};return{...$,...u,controlCommands:Hf(u.controlCommands).length>0?u.controlCommands:$.controlCommands,controlEvents:Hf(u.controlEvents).length>0?u.controlEvents:$.controlEvents,procedureRuns:l.length>0?l:$.procedureRuns}}function IT({selection:f,runDetails:u,nodeDetails:_,nodeDetailsState:y,onRaw:l,onCollapse:$}){if(!f?.mode)return X("aside",{className:"pipeline-gantt-detail-panel empty","data-testid":"pipeline-gantt-detail-panel"},X("div",{className:"pipeline-gantt-detail-head"},X("div",null,X("span",{className:"panel-eyebrow"},"Gantt Detail"),X("h3",null,"未选择元素")),X("button",{type:"button",className:"ghost-btn mini",onClick:$,"data-testid":"pipeline-gantt-sidebar-collapse"},"收起")),X(cu,{title:"选择一条执行线或一个控制点",text:"点击甘特图中的 node 执行线、prompt 点或控制点,在这里查看结构化过程和 OpenCode step。"}));let j=String(f?.runId||""),J=String(f?.interval?.nodeId||f?.marker?.nodeId||""),F=u?.runId===j?u.details:null,Q=i5(_,j,J),U=String(y?.runId||"")===j&&String(y?.nodeId||"")===J,z=hT(F,Q,j,J),W=(String(u?.runId||"")!==j||Boolean(u?.loading))&&!z,K=String(u?.runId||"")===j?String(u?.error||""):"",q=U?String(y?.error||""):"",V=z?xT(z,f):null,O=V?.interval||f?.interval||null,G=V?.marker||f?.marker||null,H=String(O?.procedureRunId||G?.procedureRunId||""),Z=Q?MT(Q,H)||IF(Q,O||{procedureRunId:H}):null,E=V?.procedure||(z?IF(z,O||{procedureRunId:H}):null)||O?.raw||{};if(Z&&(CF(E)===0||Fq(Z)>=Fq(E)))E=bT(E,Z);let L=V?.attempt||null,M=String(V?.matchedStepKey||"");if(!L&&G&&CF(E)>0)L=Sq(E,G),M=String(Pq(L,Number.isFinite(Number(G?.ms))?Number(G.ms):null).stepKey||"");let N=e5(L),w=CF(E)>0,R=U&&Boolean(y?.loading)&&!w,p=Boolean(W||R),x=[w?"":K,q].filter(Boolean).join(" / "),C=U&&y?.fetchedAt?y.fetchedAt:u?.fetchedAt,P=Mq(E?.status||O?.status||G?.status||G?.event),D=f?.mode==="event"?G?.label||Qq(G?.raw||G)||"event":V?.nodeId||O?.nodeId||"node",T=G?CT(G?.raw||G):[],S=G?[O1(G?.raw||G)?`event ${O1(G?.raw||G)}`:"",G?.promptEvent?`prompt ${G.promptEvent}`:"",G?.action?`action ${G.action}`:"",G?.sourceKind?`source ${Aq(G.sourceKind)}`:"",G?.sourceNodeId?`from ${G.sourceNodeId}`:"",G?.targetNodeId?`to ${G.targetNodeId}`:"",G?.snapReason?`draw ${G.snapReason}`:""].filter(Boolean):[];return X("aside",{className:"pipeline-gantt-detail-panel","data-testid":"pipeline-gantt-detail-panel"},X("div",{className:"pipeline-gantt-detail-head"},X("div",null,X("span",{className:"panel-eyebrow"},f?.mode==="event"?"Gantt Event Detail":"Gantt Line Detail"),X("h3",null,D)),X("div",{className:"pipeline-gantt-detail-head-actions"},X(uy,{status:P},P),X("button",{type:"button",className:"ghost-btn mini",onClick:$,"data-testid":"pipeline-gantt-sidebar-collapse"},"收起"))),G?X("article",{className:"pipeline-event-card"},X("div",{className:"pipeline-event-card-head"},X("strong",null,G?.label||Qq(G?.raw||G)),X(tF,{items:S})),X(hF,{items:[{label:"event time",value:z0(G?.timestampIso||G?.timestamp||"--")},G?.snapped?{label:"drawn time",value:z0(G?.renderedTimestampIso||G?.ms)}:null,{label:"node",value:G?.nodeId||"--"},{label:"procedure",value:G?.procedureRunId||$1(E)||"--"},{label:"attempt",value:G?.attempt||N||"--"},{label:"source kind",value:G?.sourceKind?Aq(G.sourceKind):"--"},{label:"source node",value:G?.sourceNodeId||"--"},{label:"target node",value:G?.targetNodeId||"--"},{label:"command",value:G?.commandId||G?.eventId||"--"},G?.snapReason?{label:"placement",value:G.snapReason}:null]}),T.length>0?X("div",{className:"pipeline-event-blocks"},T.map((r,Y)=>X("section",{key:`${r.label}-${Y}`,className:"pipeline-event-text-block"},X("b",null,r.label),X("p",null,r.value)))):null,Uq(G?.raw||G)?X("p",{className:"pipeline-text-preview"},Uq(G?.raw||G)):null):null,X(hF,{items:[{label:"epoch",value:j||O?.runId||"--"},{label:"node",value:V?.nodeId||O?.nodeId||G?.nodeId||"--"},{label:"procedure",value:O?.procedureRunId||G?.procedureRunId||$1(E)||"--"},{label:"started",value:z0(O?.startedAt||E?.startedAt)},{label:"finished",value:z0(O?.finishedAt||E?.finishedAt)},{label:"duration",value:l1(O?.durationMs||E?.durationMs)},{label:"fetched",value:C?a5(C):"--"},V?.matchedStep?{label:"matched step",value:`Step ${V.matchedStep.index??V.matchedStepIndex+1}`}:null]}),p?X("div",{className:"form-success"},R?"正在抓取该 node 的 attempt / Trace...":"正在抓取 epoch 执行过程..."):null,X(H0,{error:x}),X("div",{className:"pipeline-gantt-detail-actions"},X(X1,{title:`Procedure ${O?.procedureRunId||G?.procedureRunId||V?.nodeId||"node"}`,data:E,onOpen:l,testId:"raw-pipeline-gantt-procedure"}),G?X(X1,{title:`Pipeline event ${G?.id||G?.commandId||G?.eventId||V?.nodeId||"event"}`,data:G?.raw||G,onOpen:l,testId:"raw-pipeline-gantt-event"}):null,z?X(X1,{title:`Pipeline run ${j||"--"}`,data:z,onOpen:l,testId:"raw-pipeline-gantt-node-details"}):null),!p&&!$1(E)&&!G?X(cu,{title:"暂无过程详情",text:"当前选择还没有可匹配的 procedure 运行记录。"}):null,!p&&$1(E)?X(vT,{procedure:E,matchedStepKey:M,matchedAttemptId:N}):null)}function cT({value:f}){let _=String(f||"--").split(/([_-])/u);return X(fy.default.Fragment,null,_.map((y,l)=>y==="-"||y==="_"?X(fy.default.Fragment,{key:l},y,X("wbr",null)):X(fy.default.Fragment,{key:l},y)))}async function a_(f,u={}){return Df(f,{invalidJsonPrefix:"Pipeline 返回了无效 JSON",...u})}function uy({status:f,children:u}){let _=String(f||"unknown").toLowerCase();return X("span",{className:`status-badge ${_}`},u||f||"unknown")}function Hu({label:f,value:u,hint:_,tone:y}){return X("article",{className:`metric-card ${y||""}`},X("div",{className:"metric-label"},f),X("div",{className:"metric-value"},u),X("div",{className:"metric-hint"},_))}function I1({title:f,eyebrow:u,actions:_,children:y,className:l}){return X("section",{className:`panel ${l||""}`},X("div",{className:"panel-head"},X("div",null,u?X("p",{className:"panel-eyebrow"},u):null,X("h2",null,f)),_?X("div",{className:"panel-actions"},_):null),X("div",{className:"panel-body"},y))}function X1({title:f,data:u,onOpen:_,testId:y}){return X("button",{type:"button",className:"ghost-btn","data-testid":y,onClick:()=>_(f,u)},"查看原始JSON")}function y1({title:f,subtitle:u,facts:_,data:y,onRaw:l,testId:$}){let j=Hf(_).map((J)=>String(J||"")).filter(Boolean);return X("article",{className:"pipeline-evidence-row"},X("div",{className:"pipeline-evidence-main"},X("strong",null,f),u?X("span",null,u):null),X("div",{className:"pipeline-evidence-facts"},j.map((J,F)=>X("span",{key:`${F}-${J.slice(0,16)}`},J))),y!==void 0?X(X1,{title:f,data:y,onOpen:l,testId:$}):null)}function cu({title:f,text:u}){return X("div",{className:"empty-state"},X("strong",null,f),X("span",null,u))}function pT(f){return f?.runtime&&typeof f.runtime==="object"&&!Array.isArray(f.runtime)?f.runtime:{}}function mT(f){return f?.backend&&typeof f.backend==="object"&&!Array.isArray(f.backend)?f.backend:{}}function kT(f){return f?.repository&&typeof f.repository==="object"&&!Array.isArray(f.repository)?f.repository:{}}function iT(f){return{components:Array.isArray(f?.registry?.components)?f.registry.components:[],pipelines:Array.isArray(f?.pipelines)?f.pipelines:[],runs:Array.isArray(f?.runs)?f.runs:[]}}function Wq(f,u,_){let y=f?._unidesk?.arrayLimits?.[u],l=Number(y?.originalLength);return Number.isFinite(l)?l:_}function Cq(f){if(!f||typeof f!=="object"||Array.isArray(f))return"--";return`${f.componentClass||"--"}/${f.id||"--"}`}function g5(f){if(!f||typeof f!=="object"||Array.isArray(f))return"";let u=String(f.componentClass||"").trim(),_=String(f.id||"").trim();return u&&_?`${u}/${_}`:""}function sF(f){return f?.config&&typeof f.config==="object"&&!Array.isArray(f.config)?f.config:{}}function Rq(f){let u=sF(f),_=Array.isArray(u.nodes)?u.nodes:Array.isArray(f?.nodes)?f.nodes:[],y=new Map;for(let j of _){let J=String(j?.id||j?.nodeId||"");if(J)y.set(J,{...j,id:J})}let l=oF(f),$=(j)=>{if(j&&!y.has(j))y.set(j,{id:j})};for(let j of aF(f))w6(j).forEach($);for(let j of l)$(String(j?.from||j?.source||"")),$(String(j?.to||j?.target||""));return Array.from(y.values())}function oF(f){let u=sF(f);return Array.isArray(u.edges)?u.edges:Array.isArray(f?.edges)?f.edges:[]}function aF(f){let u=sF(f);return Array.isArray(u.topologicalBatches)?u.topologicalBatches:Array.isArray(f?.topologicalBatches)?f.topologicalBatches:[]}function gT(f){let u=new Map;for(let _ of f){let y=g5(_);if(y)u.set(y,_);let l=Array.isArray(_?.refs)?_.refs:[];for(let $ of l){let j=g5($);if(j)u.set(j,_)}}return u}function zq(f,u){let _=u.get(g5(f?.componentRef));if(_)return _;let y=g5({componentClass:f?.kind,id:f?.id});return y?u.get(y)||null:null}function Gq(f,u){let _=xq(f,u);return String(_?.status||"pending")}function xq(f,u){return(Array.isArray(f?.nodes)?f.nodes:[]).find((y)=>y?.nodeId===u||y?.id===u)||null}function nT(f){return f.reduce((u,_)=>{let y=String(_?.status||"unknown").toLowerCase();return u[y]=(u[y]||0)+1,u},{})}function tT(f){if(Array.isArray(f?.scorers))return f.scorers.filter(Xf);if(Array.isArray(f?.summary?.scorers))return f.summary.scorers.filter(Xf);if(Array.isArray(f?.artifact?.summary?.scorers))return f.artifact.summary.scorers.filter(Xf);return[]}function sT(f){if(Xf(f?.run))return f.run;if(Xf(f?.runSummary))return f.runSummary;return null}function oT(f,u){if(!Xf(f)&&!Xf(u))return null;if(!Xf(f))return u;if(!Xf(u))return f;return{...f,...u,request:Xf(f.request)||Xf(u.request)?{...Xf(f.request)?f.request:{},...Xf(u.request)?u.request:{}}:u.request??f.request,artifact:Xf(f.artifact)||Xf(u.artifact)?{...Xf(f.artifact)?f.artifact:{},...Xf(u.artifact)?u.artifact:{}}:u.artifact??f.artifact,summary:Xf(f.summary)||Xf(u.summary)?{...Xf(f.summary)?f.summary:{},...Xf(u.summary)?u.summary:{}}:u.summary??f.summary}}function n5(f){let u=tT(f),_=u.find((U)=>Xf(U?.score))||u[0]||null,y=Xf(_?.score)?_.score:{},l=Number(y.passed),$=Number(y.total),j=Number(y.ratio),J=Number.isFinite(j)?j:Number.isFinite(l)&&Number.isFinite($)&&$>0?l/$:null,F=J===null?null:Math.round(Math.max(0,Math.min(100,J<=1?J*100:J))),Q=String(y.text||(Number.isFinite(l)&&Number.isFinite($)?`${l}/${$}`:""));return{scorer:_,scorers:u,score:y,passed:Number.isFinite(l)?l:null,total:Number.isFinite($)?$:null,percent:F,text:Q}}function pF(f){let u=n5(f);return u.text||(u.scorers.length>0?String(u.scorer?.status||"pending"):"--")}function dF(f){let u=n5(f);if(u.total>0&&u.passed===u.total)return"succeeded";if(u.total>0&&u.passed>0)return"running";if(u.scorers.length>0)return"failed";return"pending"}function aT(f){return Array.isArray(f?.items)?f.items.filter(Xf):[]}function dT({run:f}){let u=pF(f);return X("span",{className:`pipeline-score-badge ${dF(f)}`},`score ${u}`)}function eT({run:f,onRaw:u}){let y=n5(f).scorers;if(!f)return X(cu,{title:"暂无评分",text:"选择一个 epoch 后会显示 scorer 结果。"});if(y.length===0)return X("div",{className:"pipeline-score-empty"},X("strong",null,"评分器等待中"),X("span",null,"DAG 完成后,Pipeline control backend 会把 scorer summary 追加到 run artifact,并通过 UniDesk 显示。"));return X("div",{className:"pipeline-score-board","data-testid":"pipeline-score-board"},y.map((l,$)=>{let j=n5({scorers:[l]}),J=aT(l),F=j.percent??0;return X("article",{key:`${l.scorerId||l.component||$}`,className:`pipeline-score-card ${dF({scorers:[l]})}`},X("div",{className:"pipeline-score-head"},X("div",null,X("span",null,l.scorerId||l.component||"scorer"),X("strong",null,j.text||l.status||"--")),X(uy,{status:l.status||"unknown"},l.status||"unknown")),X("div",{className:"pipeline-score-meter","aria-label":`score ${F}%`},X("span",{style:{width:`${F}%`}})),X("div",{className:"pipeline-score-facts"},X("span",null,`${F}%`),X("span",null,l.component||"--"),X("span",null,l.applicationCheckoutRef||"--")),J.length>0?X("div",{className:"pipeline-score-items"},J.map((Q)=>X("span",{key:`${Q.id||Q.filter}`,className:`pipeline-score-item ${String(Q.status||"").toLowerCase()}`,title:`${Q.filter||"--"} / ran=${Q.ran??"?"}`},X("b",null,Q.id||"--"),X("small",null,Q.status||"--")))):X("p",{className:"muted paragraph"},"当前 scorer 尚未返回 item 级结果。"),l.error?X("p",{className:"pipeline-score-error"},Tq(l.error,360)):null,X("div",{className:"panel-actions inline-actions"},X(X1,{title:`Scorer ${l.scorerId||$}`,data:l,onOpen:u,testId:"raw-pipeline-score"})))}))}function fM(f){let u=f.reduce((_,y)=>{let l=String(y?.componentClass||"unknown");return _[l]=(_[l]||0)+1,_},{});return Object.entries(u).map(([_,y])=>({name:_,count:Number(y)})).sort((_,y)=>y.count-_.count||_.name.localeCompare(y.name))}function w6(f){if(Array.isArray(f))return f.map((u)=>typeof u==="string"?u:String(u?.id||u?.nodeId||"")).filter(Boolean);if(Array.isArray(f?.nodes))return w6(f.nodes);if(Array.isArray(f?.nodeIds))return w6(f.nodeIds);return[]}function uM(f){return Xf(f?.instanceInputs?.monitor)?f.instanceInputs.monitor:{}}function vq(f,u){if(String(f?.kind||"").toLowerCase()!=="procedure")return!1;let _=uM(f);if(f?.instanceInputs?.monitorMode===!0||_.enabled===!0)return!0;let y=Cq(f?.componentRef);return String(u?.id||u?.config?.id||y||"").toLowerCase().includes("monitor")}function _M(f){return f.filter((u)=>vq(u)).map((u)=>String(u?.id||"")).filter(Boolean)}function yM(f,u){if(u.length===0)return f;let _=new Set(u),y=u.filter((l)=>f.includes(l));if(y.length===0)return f;return[...y,...f.filter((l)=>!_.has(l))]}function lM(f,u){if(u.length===0)return f;let _=new Set(u),y=u.filter(($)=>f.some((j)=>j.includes($)));if(y.length===0)return f;let l=f.map(($)=>$.filter((j)=>!_.has(j))).filter(($)=>$.length>0);return[y,...l]}function $M(f,u,_){let l=aF(f).map(w6).filter((W)=>W.length>0);if(l.length>0)return l;let $=u.map((W)=>String(W?.id||"")).filter(Boolean),j=new Set($),J=new Map($.map((W)=>[W,0])),F=new Map($.map((W)=>[W,[]]));for(let W of _){let K=String(W?.from||W?.source||""),q=String(W?.to||W?.target||"");if(!j.has(K)||!j.has(q))continue;F.get(K)?.push(q),J.set(q,(J.get(q)||0)+1)}let Q=new Map,U=$.filter((W)=>(J.get(W)||0)===0);for(let W of U)Q.set(W,0);while(U.length>0){let W=U.shift(),K=(Q.get(W)||0)+1;for(let q of F.get(W)||[])if(J.set(q,Math.max(0,(J.get(q)||0)-1)),Q.set(q,Math.max(Q.get(q)||0,K)),(J.get(q)||0)===0)U.push(q)}$.forEach((W)=>{if(!Q.has(W))Q.set(W,0)});let z=Math.max(0,...Array.from(Q.values()));return Array.from({length:z+1},(W,K)=>$.filter((q)=>Q.get(q)===K)).filter((W)=>W.length>0)}function jM(f,u,_){let l=aF(f).map(w6).filter((J)=>J.length>0),$=l.length>0?l.flatMap((J)=>J):(()=>{let J=u.map((V)=>String(V?.id||"")).filter(Boolean),F=new Set(J),Q=_.filter((V)=>String(V?.edgeType||"").toLowerCase()!=="rework"),U=new Map(J.map((V)=>[V,0])),z=new Map(J.map((V)=>[V,[]]));for(let V of Q){let O=String(V?.from||V?.source||""),G=String(V?.to||V?.target||"");if(!F.has(O)||!F.has(G))continue;z.get(O)?.push(G),U.set(G,(U.get(G)||0)+1)}let W=new Map,K=J.filter((V)=>(U.get(V)||0)===0);for(let V of K)W.set(V,0);while(K.length>0){let V=K.shift(),O=(W.get(V)||0)+1;for(let G of z.get(V)||[])if(U.set(G,Math.max(0,(U.get(G)||0)-1)),W.set(G,Math.max(W.get(G)||0,O)),(U.get(G)||0)===0)K.push(G)}J.forEach((V)=>{if(!W.has(V))W.set(V,0)});let q=Math.max(0,...Array.from(W.values()));return Array.from({length:q+1},(V,O)=>J.filter((G)=>W.get(G)===O)).flatMap((V)=>V)})(),j=new Set($);for(let J of u){let F=String(J?.id||"");if(!F||j.has(F))continue;$.push(F),j.add(F)}return yM($,_M(u))}function V6(f){return`${f.source}->${f.target}-${f.index}`}function Kq(f,u,_){let y=Rq(f),l=oF(f),$=gT(_),j=new Map(y.map((P)=>[String(P?.id||""),P])),J=y.filter((P)=>vq(P,zq(P,$))).map((P)=>String(P?.id||"")).filter(Boolean),F=lM($M(f,y,l),J),Q=[],U=new Map,z=330,W=122;F.forEach((P,D)=>{let T=P.length*122;P.forEach((S,r)=>{let Y=j.get(S)||{id:S},v=zq(Y,$),m=Gq(u,S).toLowerCase(),c=String(Y.kind||v?.componentClass||"node").toLowerCase(),o=Cq(Y.componentRef||v),ff=String(v?.config?.version||v?.version||""),n=String(v?.config?.description||v?.description||""),lf=r*122-Math.floor(T/2);U.set(S,{column:D,row:r,y:lf}),Q.push({id:S,type:"pipelineNode",position:{x:D*330,y:lf},data:{exportLabel:{id:S,kind:c,componentRef:o,componentVersion:ff,componentDescription:n,status:m},label:X("div",{className:"flow-node-label"},X("strong",null,S),X("span",null,c),X("code",{title:n||o},ff?`${o}@${ff}`:o),X(uy,{status:m},m))},className:`pipeline-flow-node ${c} ${m}`})})});let K=l.flatMap((P,D)=>{let T=String(P?.from||P?.source||""),S=String(P?.to||P?.target||"");if(!j.has(T)||!j.has(S))return[];return[{source:T,target:S,index:D,condition:P?.condition,edgeType:P?.edgeType}]}),q=K.reduce((P,D)=>P.set(D.source,(P.get(D.source)||0)+1),new Map),V=K.reduce((P,D)=>P.set(D.target,(P.get(D.target)||0)+1),new Map),O=K.reduce((P,D)=>{let T=`${D.source}->${D.target}`;return P.set(T,(P.get(T)||0)+1)},new Map),G=new Map,H=new Map,Z=new Map,E=new Map,L=new Map,M=new Map,N=K.reduce((P,D)=>{let T=U.get(D.source),S=U.get(D.target),r=(S?.column||0)-(T?.column||0);if(r<=0||String(D.edgeType||"").toLowerCase()==="rework"||r!==1)return P;let v=`${D.source}->column:${S?.column??""}`,m=P.get(v)||[];return m.push(D),P.set(v,m),P},new Map);for(let P of N.values()){if(P.length<2)continue;P.slice().sort((D,T)=>{let S=U.get(D.target),r=U.get(T.target);return(S?.y||0)-(r?.y||0)||D.index-T.index}).forEach((D,T,S)=>{M.set(V6(D),{slot:T-(S.length-1)/2,count:S.length})})}[...K].sort((P,D)=>{let T=U.get(P.source),S=U.get(P.target),r=U.get(D.source),Y=U.get(D.target),v=Math.abs((S?.column||0)-(T?.column||0))*330+Math.abs((S?.y||0)-(T?.y||0)),m=Math.abs((Y?.column||0)-(r?.column||0))*330+Math.abs((Y?.y||0)-(r?.y||0));return v-m||P.index-D.index}).forEach((P)=>{let D=U.get(P.source)||{column:0,row:0,y:0},T=U.get(P.target)||{column:0,row:0,y:0},S=T.column-D.column,r=Math.max(0,S),Y=S<=0||String(P.edgeType||"").toLowerCase()==="rework",v=D.y-T.y,m=V.get(P.target)||1,c=M.has(V6(P)),o=!Y&&r<=1&&(c||m===1),ff=L.get(P.target)||new Map;L.set(P.target,ff);let n=Y6.slice().sort((lf,Gf)=>{let zf=(Kf)=>{let h=String(Kf.side),g=0;if(Y){if(h==="left")g+=86;if(h==="top")g+=T.y<=0?-22:12;if(h==="bottom")g+=T.y>=0?-22:12;if(Math.abs(T.y)<12&&h!=="left")g+=P.index%2===0?h==="top"?-6:6:h==="bottom"?-6:6;return g}if(o){if(h==="left")g-=c?72:44;if(h!=="left")g+=c?72:44;return g+Math.abs(v)*0.02}if(h==="left")g+=r<=1?0:24;if(h==="top")g+=v<-36?-18:42;if(h==="bottom")g+=v>36?-18:42;if(r<=1&&Math.abs(v)<=82&&h!=="left")g+=38;if(r>1&&h!=="left")g-=10;return g},jf=D.y-T.y,Wf=jf!==0?jf:P.index%2===0?-1:1,Vf=(Kf)=>{let h=ff.get(Kf.id)||0;return zf(Kf)+h*64+LT(Kf,ff,Wf)};return Vf(lf)-Vf(Gf)||String(lf.id).localeCompare(String(Gf.id))})[0];ff.set(n.id,(ff.get(n.id)||0)+1),E.set(V6(P),n)});let R=K.map((P)=>{let D=Gq(u,P.target).toLowerCase(),T=`${P.source}->${P.target}`,S=G.get(P.source)||0,r=H.get(P.target)||0,Y=Z.get(T)||0;G.set(P.source,S+1),H.set(P.target,r+1),Z.set(T,Y+1);let v=S-((q.get(P.source)||1)-1)/2,m=r-((V.get(P.target)||1)-1)/2,c=Y-((O.get(T)||1)-1)/2,o=U.get(P.source),ff=U.get(P.target),n=(ff?.column||0)-(o?.column||0),lf=Math.max(1,Math.abs(n)),Gf=n<=0||String(P.edgeType||"").toLowerCase()==="rework",zf=Math.abs((ff?.y||0)-(o?.y||0)),jf=M.get(V6(P)),Wf=!Gf&&n===1&&(V.get(P.target)||0)>1,Vf=jf?jf.slot:c*2+v+m*0.45,Kf=Vf===0?P.index%2===0?-1:1:Math.sign(Vf),h=E.get(V6(P))||Y6[1],g=h.side==="top"?-1:h.side==="bottom"?1:Kf,I=Gf||lf>1||zf>96||Math.abs(Vf)>0.2||h.side!=="left",yf=Gf?118+lf*18:22+lf*16,$f=h.side==="left"?0:28,Qf=I?Math.max(-280,Math.min(280,g*Math.min(180,yf+$f+zf*0.22)+Vf*28)):0,Yf=Math.max(0,Math.min(O6.length-1,Math.round(v+(O6.length-1)/2))),xf=O6[Yf]||O6[1],tf=D==="succeeded"?"var(--accent-2)":D==="running"?"var(--accent)":D==="failed"?"var(--danger)":"rgba(129, 147, 159, 0.78)",j0=o?.column||0,u0=ff?.column||0,D0=Qf===0?0:Math.sign(Qf),Fu=Gf?`feedback:${j0}->${u0}:${D0}`:jf?`fanout:${j0}->${u0}:${P.source}`:Wf?`fanin:${j0}->${u0}:${P.target}`:h.side!=="left"||lf>1?`corridor:${j0}->${u0}:${h.side}:${D0}:${Math.round(Math.abs(Qf)/56)}`:"";return{id:`${P.source}->${P.target}-${P.index}`,source:P.source,target:P.target,sourceHandle:xf.id,targetHandle:h.id,type:"pipelineCurve",zIndex:12,animated:D==="running",data:{baseEdgeColor:tf,laneOffset:Qf,routeMode:jf&&h.side==="left"?"direct-forward-left":"",targetSide:h.side,isFeedback:Gf,overlapGroup:Fu},targetStatus:D}}),p=R.reduce((P,D)=>{let T=String(D.data?.overlapGroup||"");return T?P.set(T,(P.get(T)||0)+1):P},new Map),x=new Map,C=R.map((P)=>{let D=String(P.targetStatus||"pending"),T={...P};delete T.targetStatus;let S=String(P.data?.overlapGroup||""),r=S?p.get(S)||0:0,Y=r>1?x.get(S)||0:-1;if(r>1)x.set(S,Y+1);let v=Y>=0?_q[Y%_q.length]:String(P.data.baseEdgeColor),m={stroke:v};if(P.data.isFeedback)m.strokeDasharray="9 7";return{...T,data:{...P.data,edgeColor:v,overlapSlot:Y,overlapCount:r},style:m,markerEnd:{type:n_.ArrowClosed,color:v},className:`pipeline-flow-edge ${D} ${P.data.isFeedback?"feedback":""} ${Y>=0?"overlap-colored":""}`}});return{nodes:Q,edges:C}}function wu(f){return String(f??"").replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}function Zq(f){let u=String(f||"");if(u.includes("--accent-2"))return"#4eb7a8";if(u.includes("--accent"))return"#d7a13a";if(u.includes("--danger"))return"#cf6a54";return u.startsWith("#")?u:"#81939f"}function t5(f){return`arrow-${f.replace(/[^a-zA-Z0-9_-]+/g,"")}`}function bq(f,u="pipeline"){return String(f||u).replace(/[^a-zA-Z0-9_-]+/g,"-").replace(/^-|-$/g,"")||u}function qq(f,u){let _=f.position.x,y=f.position.y,l=Y6.find(($)=>$.id===u);if(l?.side==="top")return{x:_+Ql*$q(l.style?.left,0.5),y,position:Uf.Top};if(l?.side==="bottom")return{x:_+Ql*$q(l.style?.left,0.5),y:y+Ul,position:Uf.Bottom};return{x:_,y:y+Ul/2,position:Uf.Left}}function JM(f){return{x:f.position.x+Ql,y:f.position.y+Ul/2}}function FM(f,u){let _=Math.min(...f.nodes.map((V)=>V.position.x),0)-220,y=Math.min(...f.nodes.map((V)=>V.position.y),0)-220,l=Math.max(...f.nodes.map((V)=>V.position.x+Ql),1)+220,$=Math.max(...f.nodes.map((V)=>V.position.y+Ul),1)+220,j=Math.ceil(l-_),J=Math.ceil($-y),F=new Map(f.nodes.map((V)=>[V.id,V])),Q=f.edges.map((V)=>Zq(V.data?.edgeColor||V.style?.stroke)),z=Array.from(new Set(["#4eb7a8","#d7a13a","#cf6a54","#81939f",...Q])).map((V)=>``).join(""),W=f.edges.flatMap((V)=>{let O=F.get(V.source),G=F.get(V.target);if(!O||!G)return[];let H=JM(O),Z=qq(G,String(V.targetHandle||"in-left")),E=Bq(H.x,H.y,Z.x,Z.y,Z.position,Number(V.data?.laneOffset||0),String(V.data?.routeMode||"")),L=Zq(V.data?.edgeColor||V.style?.stroke),M=V.data?.isFeedback?' stroke-dasharray="9 7"':"";return``}).join(` +`),K=f.nodes.map((V)=>{let O=V.data?.exportLabel||{},G=String(O.status||"pending").toLowerCase(),H=G==="succeeded"?"#4eb7a8":G==="running"?"#d7a13a":G==="failed"?"#cf6a54":"#81939f",Z=V.position.x,E=V.position.y,L=Y6.map((M)=>{let N=qq(V,M.id);if(M.side==="top"||M.side==="bottom")return``;return``}).join(` +`);return` + + ${L} + + ${wu(O.id||V.id)} + ${wu(O.kind||"node")} + ${wu(O.componentRef||"--")} + ${wu(G)} + `}).join(` +`);return{svg:` + ${z} + + + ${wu(u)} + ${K}${W} + `,width:j,height:J}}function AM(f){let u=String(f||"").toLowerCase();if(u==="succeeded"||u==="completed")return"#4eb7a8";if(u==="failed")return"#cf6a54";if(wq(u))return"#69aee8";return"#d7a13a"}function QM(f){let u=String(f?.kind||""),_=String(f?.tone||f?.status||"").toLowerCase();if(u==="prompt"&&_==="initial")return"#d7a13a";if(u==="prompt"&&_==="monitor")return"#69aee8";if(u==="prompt")return"#4eb7a8";if(_==="modify")return"#e0b95a";if(_==="approve"||_==="guide"||_==="monitor")return"#4eb7a8";if(_==="restart"||_==="redo")return"#d7a13a";if(_==="ignored")return"#81939f";if(_==="webui")return"#69aee8";if(_==="cli")return"#d7a13a";return"#a7bac5"}function Hq(f){let u=String(f?.sourceKind||"").toLowerCase(),_=String(f?.action||"").toLowerCase(),y=String(f?.status||"").toLowerCase();if(_==="observe"||y==="observation"||u==="monitor")return"#4eb7a8";if(u==="webui")return"#69aee8";if(u==="cli")return"#d7a13a";if(y.includes("ignored"))return"#81939f";return"#8aa0ad"}function UM(f,u,_){let y=QM(f),l=String(f?.kind||"");if(l==="control-source")return``;if(l==="control-target"){let j=String(f?.tone||"").toLowerCase()==="approve"?"rgba(78,183,168,0.22)":"#081118";return``}return``}function WM(f){let u=Hf(f.visibleNodeIds).map((Y)=>String(Y||"")).filter(Boolean),_=Hf(f.intervals).filter(Xf),y=Hf(f.markers).filter(Xf),l=Hf(f.arrows).filter(Xf),$=Hf(f.ticks).filter(Xf),j=Xf(f.bounds)?f.bounds:{},J=Xf(f.backendLayout)?f.backendLayout:null,F=Math.max(240,Math.round(Number(f.chartHeight||360))),Q=Math.max(h1,108),U=128,z=24,W=58,K=56,q=128+Math.max(1,u.length)*Q,V=Math.max(760,q+48),O=114+F+24,G=24,H=58,Z=114,E=(Y)=>152+Y*Q,L=(Y)=>E(Y)+Q/2,M=Hf(f.meta).map((Y)=>String(Y||"")).filter(Boolean).slice(0,4).join(" · "),N=new Map(y.map((Y)=>[String(Y.id||""),Y])),R=Array.from(new Set(["#4eb7a8","#69aee8","#d7a13a","#cf6a54","#8aa0ad",...l.map(Hq)])).map((Y)=>``).join(""),p=$.map((Y)=>{let v=114+gq(Y,j,F,J);return` + + ${wu(z0(Y.ms))} + +${wu(l1(Number(Y.offsetMs??Number(Y.ms)-Number(j.startMs))))} + `}).join(` +`),x=['','TIME',...u.map((Y,v)=>{let m=E(v),c=Y.length>18?`${Y.slice(0,16)}…`:Y;return` + + ${wu(c)} + node ${v+1} + `})].join(` +`),C=u.map((Y,v)=>{return``}).join(` +`),P=_.map((Y)=>{let v=u.indexOf(String(Y.nodeId||""));if(v<0)return"";let m=114+o5(Y,j,F,J),c=Math.max(2,iq(Y,j,F,J)),o=AM(Y.status),ff=L(v)-3.5,n=Y.live?``:"",lf=c>=28?`${wu(String(Y.status||"working"))} + ${wu(l1(Y.durationMs))}`:"";return` + + ${n} + ${lf} + `}).join(` +`),D=y.map((Y)=>{let v=u.indexOf(String(Y.nodeId||""));if(v<0)return"";let m=114+Iu(Y,j,F,J);return UM(Y,L(v),m)}).join(` +`),T=l.map((Y)=>{let v=N.get(String(Y.targetMarkerId||""));if(!v)return"";let m=N.get(String(Y.sourceMarkerId||"")),c=String(m?.nodeId||Y.sourceNodeId||""),o=String(v.nodeId||Y.targetNodeId||""),ff=u.indexOf(c),n=u.indexOf(o);if(ff<0||n<0)return"";let lf=L(ff)-24-128,Gf=L(n)-24-128,zf=Wl(J)?V0(Y.sourceY??Y.y1)??(m?Iu(m,j,F,J):Iu(v,j,F,J)):m?Iu(m,j,F,J):Iu(v,j,F,J),jf=Wl(J)?V0(Y.targetY??Y.y2)??Iu(v,j,F,J):Iu(v,j,F,J),Wf=Hq(Y),Vf=String(Y.action||"").toLowerCase()==="observe"?"3 4":"6 5",Kf=wu(nq(lf,zf,Gf,jf));return` + `}).join(` +`),S=u.length===0?'No visible Gantt nodes':"";return{svg:` + ${R} + + + + ${wu(f.title||"Pipeline Epoch Gantt")} + ${wu(M)} + ${x} + + ${C} + ${p} + ${P} + ${T} + ${D} + ${S} + `,width:V,height:O}}function s5(f,u){let _=URL.createObjectURL(f),y=document.createElement("a");y.href=_,y.download=u,y.click(),setTimeout(()=>URL.revokeObjectURL(_),1000)}async function hq(f,u){let _=bq(u,"pipeline"),{svg:y,width:l,height:$}=FM(f,u),j=new Blob([y],{type:"image/svg+xml;charset=utf-8"}),J=URL.createObjectURL(j);try{let F=new Image;await new Promise((W,K)=>{F.onload=()=>W(),F.onerror=()=>K(Error("svg image load failed")),F.src=J});let Q=document.createElement("canvas");Q.width=l,Q.height=$;let U=Q.getContext("2d");if(!U)throw Error("canvas unavailable");U.drawImage(F,0,0);let z=await new Promise((W)=>Q.toBlob(W,"image/png"));if(!z)throw Error("png export failed");s5(z,`${_}.png`)}catch{s5(j,`${_}.svg`)}finally{URL.revokeObjectURL(J)}}async function zM(f){let u=bq(String(f?.title||"pipeline-gantt"),"pipeline-gantt"),{svg:_,width:y,height:l}=WM(f),$=new Blob([_],{type:"image/svg+xml;charset=utf-8"}),j=URL.createObjectURL($);try{let J=new Image;await new Promise((z,W)=>{J.onload=()=>z(),J.onerror=()=>W(Error("gantt svg image load failed")),J.src=j});let F=document.createElement("canvas");F.width=y,F.height=l;let Q=F.getContext("2d");if(!Q)throw Error("canvas unavailable");Q.drawImage(J,0,0);let U=await new Promise((z)=>F.toBlob(z,"image/png"));if(!U)throw Error("gantt png export failed");s5(U,`${u}.png`)}catch{s5($,`${u}.svg`)}finally{URL.revokeObjectURL(j)}}async function GM(f){for(let u of f){if(u.flow.nodes.length===0)continue;await hq(u.flow,u.title),await new Promise((_)=>setTimeout(_,750))}}function Vq(f,u){return f.find((_)=>String(_?.pipelineId||"")===u)||null}function Eq(f){return Pf(f?.startedAt)??Pf(f?.artifact?.startedAt)??Pf(f?.request?.createdAt)??Pf(f?.updatedAt)??0}function KM(f,u){return f.filter((_)=>String(_?.pipelineId||"")===u).slice().sort((_,y)=>Eq(_)-Eq(y)||String(_?.runId||"").localeCompare(String(y?.runId||"")))}function mF(f,u){let _=String(u?.runId||""),y=f.findIndex((j)=>String(j?.runId||"")===_),l=y>=0?y+1:f.length,$=String(u?.status||"--");return`Epoch ${l} / ${_||"--"} / ${$}`}function $1(f){return String(f?.procedureRunId||f?.runId||"")}function f2(f,u){let _=String(f?.nodeId||f?.request?.nodeId||"");if(_)return _;let y=$1(f),l=`${u}__`;if(y.startsWith(l))return y.slice(l.length).replace(/__\d+$/u,"");return""}function I5(f,u){let _=Xf(f?.artifact)?f.artifact:{},y=Xf(f?.request)?f.request:{};return T6(f?.startedAt,_.startedAt,y.createdAt,y.startedAt,f?.createdAt,f?.updatedAt,u?.startedAt,u?.request?.createdAt)}function c5(f,u){let _=String(f?.status?.status||f?.artifact?.status||f?.status||"").toLowerCase(),y=Xf(f?.artifact)?f.artifact:{},l=nF(_);return T6(f?.finishedAt,y.finishedAt,f?.completedAt,l?f?.updatedAt:void 0,l?y.updatedAt:void 0,l?u?.updatedAt:void 0)}function Iq(f,u,_=Date.now()){let y=String(f?.runId||""),l=new Set(u.map(($)=>String($?.id||"")).filter(Boolean));return Hf(f?.procedureRuns).flatMap(($)=>{let j=f2($,y);if(!j)return[];let J=String($?.status?.status||$?.artifact?.status||$?.status||"unknown").toLowerCase(),F=I5($,f),Q=Pf(F);if(Q===null)return[];let U=c5($,f),z=Pf(U)??(nF(J)?Pf($?.updatedAt)??Q+1000:_),W=Math.max(Q+1000,z);return[{nodeId:j,knownNode:l.has(j),procedureRunId:$1($),status:J,startMs:Q,endMs:W,startedAt:B6(Q),finishedAt:B6(W),durationMs:W-Q,runId:y,raw:$}]}).sort(($,j)=>$.startMs-j.startMs||$.endMs-j.endMs||$.nodeId.localeCompare(j.nodeId))}function ZM(f,u,_=[]){let y=u.map((U)=>Number(U.startMs)).filter(Number.isFinite),l=u.map((U)=>Number(U.endMs)).filter(Number.isFinite);for(let U of _){let z=V0(U?.eventMs??U?.ms);if(z!==null)y.push(z),l.push(z)}let $=Pf(f?.startedAt)??Pf(f?.artifact?.startedAt)??Pf(f?.request?.createdAt),j=Pf(f?.finishedAt)??Pf(f?.artifact?.finishedAt)??Pf(f?.updatedAt);if($!==null)y.push($);if(j!==null)l.push(j);let J=Date.now(),F=y.length>0?Math.min(...y):J-60000,Q=Math.max(F+60000,l.length>0?Math.max(...l):J);return{startMs:F,endMs:Q,durationMs:Q-F}}var p5=12,cq=20,kF=100,qM=!1;function _y(f){let u=Number(f);if(!Number.isFinite(u))return 0;return Math.max(0,Math.min(100,Math.round(u*100)/100))}function HM(f){let u=Math.max(p5,Number(f||p5)),_=Math.log(u/p5)/Math.log(cq);return _y(_*100)}var D6=HM(kF);function eF(f){let u=_y(f)/100,_=p5*Math.pow(cq,u),y=u<0.24?"全局":u<0.64?"均衡":"细节";return{value:_y(u*100),pxPerMinute:_,label:y}}function vF(f){let u=Math.round(Number(f));return Math.abs(u-kF)<=1?kF:u}function VM(f,u=D6){let _=Math.max(1,Number(f.durationMs||0)/60000),y=eF(u);return Math.round(Math.max(360,Math.min(7200,_*Number(y.pxPerMinute||48))))}function EM(f,u=7){let _=Math.max(1,Number(f.endMs||0)-Number(f.startMs||0));return Array.from({length:u},(y,l)=>{let $=u===1?0:l/(u-1);return{ms:Number(f.startMs)+_*$,percent:$*100}})}function OM(f,u){let _=Math.max(1,Number(u.endMs)-Number(u.startMs));return Math.max(0,Math.min(100,(f-Number(u.startMs))/_*100))}function V0(f){let u=Number(f);return Number.isFinite(u)?u:null}function fA(f){return wq(f?.status)&&!nF(f?.status)}function pq(f,u,_,y){let l=Math.max(1,_-u),$=Math.max(0,Math.min(1,(f-u)/l));return Number(($*y).toFixed(3))}function Oq(f,u){if(!u)return null;let _=V0(u?.startMs),y=V0(u?.endMs),l=V0(u?.chartHeight);if(_===null||y===null||l===null)return null;return pq(f,_,y,l)}function mq(f,u){let _=V0(f?.rawStartMs??f?.startMs)??V0(f?.startMs)??u,y=V0(f?.endMs)??_+1000;if(!fA(f))return Math.max(_+1000,y);return Math.max(_+1000,y,u)}function XM(f,u,_,y){let l=V0(f?.startMs)??y-60000,$=V0(f?.endMs)??y,j=_.reduce((q,V)=>Math.max(q,mq(V,y)),$),J=Math.max(l+60000,$,j),F=Math.max(1,J-l),Q={startMs:l,endMs:J,durationMs:F},U=VM(Q,u),z=eF(u),W=Math.max(5,Math.min(18,Math.round(U/150))),K=EM(Q,W).map((q)=>{let V=Number(q.ms),O=pq(V,l,J,U);return{...q,y:O,timestamp:B6(V),offsetMs:V-l}});return{source:"frontend-y",startMs:l,endMs:J,durationMs:F,chartHeight:U,scale:_y(u),normalizedScale:Number((_y(u)/100).toFixed(3)),pxPerMinute:Number(Number(z.pxPerMinute||0).toFixed(3)),ticks:K}}function NM(f,u,_){if(!fA(f))return f;let y=V0(f?.rawStartMs??f?.startMs)??V0(f?.startMs)??_,l=mq(f,_),$=Oq(y,u),j=Oq(l,u),J=V0($??f?.y1??f?.startY)??0,F=V0(j??f?.y2??f?.endY)??J+10,Q=Math.max(24,F-J);return{...f,live:!0,startMs:y,endMs:l,durationMs:Math.max(1000,l-y),finishedAt:B6(l),y1:J,y2:F,startY:J,endY:F,height:Q}}function uA(f,u,_){return OM(f,u)/100*_}function Wl(f){return Boolean(f&&String(f?.source||"")!=="frontend-y")}function kq(f,u,_,y,l){if(Wl(y))for(let j of l){let J=V0(f?.[j]);if(J!==null)return J}let $=V0(f?.ms??f?.eventMs??f?.startMs);return uA($??Number(u.startMs),u,_)}function o5(f,u,_,y){return kq(f,u,_,y,["y1","startY"])}function iF(f,u,_,y){if(Wl(y)){let $=V0(f?.y2??f?.endY);if($!==null)return $}let l=V0(f?.endMs)??Number(u.endMs);return uA(l,u,_)}function iq(f,u,_,y){if(Wl(y)){let $=V0(f?.height);if($!==null)return Math.max(1,$)}let l=f?.live?24:10;return Math.max(l,iF(f,u,_,y)-o5(f,u,_,y))}function Iu(f,u,_,y){return kq(f,u,_,y,["y","timeAxisY"])}function gq(f,u,_,y){if(Wl(y)||String(y?.source||"")==="frontend-y"){let j=V0(f?.y);if(j!==null)return j}let l=V0(f?.percent);if(l!==null)return l/100*_;let $=V0(f?.ms)??Number(u.startMs);return uA($,u,_)}function LM(f){let u=String(f?.promptEvent||f?.raw?.promptEvent||f?.event||"").toLowerCase();if(!["node-long-running-observation","node-finished"].includes(u))return"";let _=String(f?.sourceNodeId||f?.raw?.sourceNodeId||f?.raw?.detail?.nodeId||""),y=String(f?.nodeId||f?.targetNodeId||"");return _&&_!==y?_:""}function YM(f,u){let _=new Set(u.map((l)=>[String(l.sourceNodeId||""),String(l.targetNodeId||""),String(l.targetMarkerId||""),String(l.action||"")].join(":"))),y=[...u];for(let l of f){let $=LM(l),j=String(l?.nodeId||""),J=String(l?.id||"");if(!$||!j||!J)continue;let F=[$,j,J,"observe"].join(":");if(_.has(F))continue;_.add(F),y.push({id:`observation-arrow:${J}:${$}:${j}`,commandId:String(l?.commandId||l?.eventId||J),sourceNodeId:$,targetNodeId:j,sourceMarkerId:"",targetMarkerId:J,sourceKind:"monitor",action:"observe",status:"observation"})}return{markers:f,arrows:y}}function Xq(f,u=""){let _=O1(f)||u,y=String(f?.promptEvent||"");if(_==="initial-prompt-delivered")return"initial";if(y==="node-finished"||y==="node-long-running-observation"||y.startsWith("monitor-"))return"monitor";if(_==="monitor-prompt-delivered"||String(f?.sourceKind||"").toLowerCase()==="monitor"||u==="monitor-prompt-queued")return"monitor";return"append"}function BM(f){return Hf(f?.tags||f?.raw?.tags).map((u)=>String(u||"")).filter(Boolean)}function Nq(f,u=""){let _=O1(f)||u,y=String(f?.promptEvent||"");if(_==="initial-prompt-delivered")return"初始 prompt";if(y==="node-long-running-observation")return"长任务观察";if(y==="node-finished")return BM(f).includes("monitor.audit")?"节点完成 / OA 审核":"节点完成";if(y==="monitor-interval")return"旧版轮询";if(y==="monitor-start")return"Monitor start";if(y==="monitor-stop")return"Monitor stop";if(_==="monitor-prompt-delivered"||u==="monitor-prompt-queued")return"Monitor prompt";if(_==="append-prompt-queued")return"追加 prompt 已排队";return"追加 prompt"}function Lq(f){let u=O1(f);if(u==="control-command-applied")return 3;if(u==="control-command-ignored")return 2;if(u==="control-command-queued")return 1;return 0}function Yq(f,u){let _=String(f?.commandId||"");if(_)return`command:${_}`;return["fallback",sy(f)||T6(f?.createdAt,f?.timestamp)||`index-${u}`,String(f?.sourceKind||""),String(f?.sourceNodeId||""),String(f?.targetNodeId||""),oy(f)].join(":")}function wM(f){return cF([f?.targetNodeId,...Hf(f?.resetNodeIds)])}function DM(f,u){let _=L6(f),y=O1(f),l=String(f?.targetNodeId||""),$=Boolean(l)&&u!==l;if(y==="control-command-applied")return $?`${_} 波及`:`${_} 生效`;if(y==="control-command-ignored")return`${_} 忽略`;if(y==="control-command-queued")return`${_} 已发起`;return $?`${_} 波及`:_}function TM(f){if(O1(f)==="control-command-ignored")return"ignored";let _=oy(f);if(_==="restart"||_==="redo")return"restart";if(_==="modify")return"modify";if(_==="approve")return"approve";if(_==="guide")return"guide";return"pending"}function MM(f){let u=String(f?.sourceKind||"").toLowerCase();if(u==="monitor")return"monitor";if(u==="webui")return"webui";if(u==="cli")return"cli";return"system"}function rM(f,u,_,y){let l=f.filter((Q)=>String(Q.nodeId||"")===u).sort((Q,U)=>Number(Q.startMs)-Number(U.startMs)),$=l.find((Q)=>_>=Number(Q.startMs)-1000&&_<=Number(Q.endMs)+1000);if($)return{ms:_,onInterval:!0,snapReason:"inside-interval",procedureRunId:String($.procedureRunId||"")};let j=oy(y),J=l.slice().reverse().find((Q)=>Number(Q.endMs)<=_+1000);if(J&&j==="approve")return{ms:Number(J.endMs),onInterval:!0,snapReason:"previous-interval-end",procedureRunId:String(J.procedureRunId||"")};let F=l.find((Q)=>Number(Q.startMs)>=_-1000);if(F&&["guide","modify","restart","redo"].includes(j))return{ms:Number(F.startMs),onInterval:!0,snapReason:"next-interval-start",procedureRunId:String(F.procedureRunId||"")};return{ms:_,onInterval:!1,snapReason:"event-time",procedureRunId:String(y?.procedureRunId||"")}}function nq(f,u,_,y){let l=Math.hypot(_-f,y-u),$=l>lq?lq:0,j=$>0?_-(_-f)/l*$:_,J=$>0?y-(y-u)/l*$:y,F=j-f,Q=Math.max(16,Math.min(42,Math.abs(F)*0.45+12)),U=F===0?1:Math.sign(F);return`M ${f},${u} C ${f+U*Q},${u} ${j-U*Q},${J} ${j},${J}`}function SM(f,u){let _=String(f?.runId||u?.runId||""),y=Iq({...Xf(u)?u:{},...Xf(f)?f:{},runId:_,procedureRuns:Hf(f?.procedureRuns).length>0?f.procedureRuns:u?.procedureRuns},[]),l=[],$=[],j=[],J=new Set,F=new Map,Q=(W,K)=>{if(!W.nodeId||!Number.isFinite(Number(W.ms)))return;if(J.has(W.id))return;J.add(W.id),K.push(W)};for(let W of Hf(f?.procedureRuns)){let K=f2(W,_),q=$1(W);if(!K)continue;for(let V of Hf(W?.attempts)){let O=e5(V),G=new Set,H=new Set;for(let E of N6(V?.controlEventRecords)){let L=O1(E);if(!["initial-prompt-delivered","append-prompt-delivered","monitor-prompt-delivered"].includes(L))continue;let M=sy(E),N=Pf(M);if(N===null)continue;let w=String(E?.eventId||"");if(w)G.add(w);H.add(`${L}:${M}:${String(E?.sourceKind||"")}:${String(E?.promptPreview||"")}`),Q({id:`prompt:${w||`${q}:${O}:${L}:${N}`}`,runId:_,nodeId:K,procedureRunId:q,attempt:O,kind:"prompt",tone:Xq(E,L),status:"delivered",label:Nq(E,L),ms:N,timestampIso:M,sourceKind:String(E?.sourceKind||""),sourceNodeId:String(E?.sourceNodeId||""),targetNodeId:K,action:"",eventId:w,commandId:String(E?.commandId||""),raw:E},l)}let Z=[{records:N6(V?.controlPromptRecords),fallbackKind:"append-prompt-queued"},{records:N6(V?.monitorPromptRecords),fallbackKind:"monitor-prompt-queued"}];for(let E of Z)for(let L of E.records){let M=sy(L),N=Pf(M);if(N===null)continue;let w=String(L?.eventId||"");if(w&&G.has(w))continue;let p=`${E.fallbackKind==="monitor-prompt-queued"?"monitor-prompt-delivered":"append-prompt-delivered"}:${M}:${String(L?.sourceKind||"")}:${String(L?.promptPreview||"")}`;if(H.has(p))continue;Q({id:`prompt-fallback:${w||`${q}:${O}:${E.fallbackKind}:${N}`}`,runId:_,nodeId:K,procedureRunId:q,attempt:O,kind:"prompt",tone:Xq(L,E.fallbackKind),status:"queued",label:Nq(L,E.fallbackKind),ms:N,timestampIso:M,sourceKind:String(L?.sourceKind||""),sourceNodeId:String(L?.sourceNodeId||""),targetNodeId:K,action:"",eventId:w,commandId:String(L?.commandId||""),raw:L},l)}}}let U=new Map;N6(f?.controlEvents).forEach((W,K)=>{let q=Yq(W,K),V=U.get(q)||{key:q,events:[],commands:[]};V.events.push(W),U.set(q,V)}),Hf(f?.controlCommands).filter(Xf).forEach((W,K)=>{let q=Yq(W,K),V=U.get(q)||{key:q,events:[],commands:[]};V.commands.push(W),U.set(q,V)});for(let W of U.values()){let K=Hf(W.events).slice().sort((C,P)=>Lq(P)-Lq(C)),q=Hf(W.commands),V=Hf(W.events).find((C)=>O1(C)==="control-command-queued")||q[0]||null,O=K[0]||q[0]||V;if(!V&&!O)continue;let G=String(V?.sourceNodeId||O?.sourceNodeId||""),H=String(V?.sourceKind||O?.sourceKind||""),Z=sy(V)||sy(O)||T6(V?.createdAt,O?.createdAt),E=Pf(Z),L=String(O?.commandId||V?.commandId||W.key),M=(O1(O)||"control-command-queued").replace(/^control-command-/u,""),N="";if(G&&E!==null)N=`control-source:${L}:${G}`,F.set(L,N),Q({id:N,runId:_,nodeId:G,procedureRunId:String(V?.procedureRunId||O?.procedureRunId||""),attempt:"",kind:"control-source",tone:MM(V||O),status:M,label:`${L6(V||O)} 发起`,ms:E,timestampIso:Z,action:oy(V||O),sourceKind:H,sourceNodeId:G,targetNodeId:String(O?.targetNodeId||V?.targetNodeId||""),commandId:L,raw:V||O},$);let w=O||V,R=sy(w)||Z,p=Pf(R);if(p===null)continue;let x=wM(w);for(let C of x){let P=rM(y,C,p,w),D=`control-target:${L}:${C}`;if(Q({id:D,runId:_,nodeId:C,procedureRunId:P.procedureRunId,attempt:"",kind:"control-target",tone:TM(w),status:M,label:DM(w,C),ms:P.ms,eventMs:p,onInterval:P.onInterval,snapReason:P.snapReason,snapped:Number(P.ms)!==p,timestampIso:R,renderedTimestampIso:B6(Number(P.ms)),action:oy(w),sourceKind:H,sourceNodeId:G,targetNodeId:C,commandId:L,raw:w},$),N&&G&&G!==C)j.push({id:`control-arrow:${L}:${G}:${C}`,commandId:L,sourceNodeId:G,targetNodeId:C,sourceMarkerId:N,targetMarkerId:D,sourceKind:H,action:oy(w),status:M})}}let z=[...l,...$].sort((W,K)=>Number(W.ms)-Number(K.ms)||String(W.nodeId).localeCompare(String(K.nodeId))||String(W.id).localeCompare(String(K.id)));return{...YM(z,j),sourceMarkerByCommand:F}}function PM({details:f,selectedNodeId:u,selectedNodeRuntime:_,control:y,onRaw:l}){if(!f)return X("span",{className:"muted"},"点击“抓取过程”读取 node 运行材料;主界面只显示结构化摘要,完整内容需点开原始 JSON。");let $=Hf(f.procedureRuns),j=$.at(-1)||{},J=Hf(j.attempts),F=J.at(-1)||{},Q=Hf(j.workerLogTail),U=Hf(F.controlEventsTail),z=Hf(F.controlPromptsTail),W=Hf(F.monitorPromptsTail),K=PF(U),q=PF(z),V=PF(W),O=F.opencodeMessages||{};return X("div",{className:"pipeline-evidence-list compact"},X(y1,{title:"Node runtime",subtitle:u||"--",facts:[`status ${_?.status||"pending"}`,`attempts ${_?.attempts??J.length}`,`procedure ${_?.currentProcedureRunId||$1(j)||"--"}`,y.fetchedAt?`fetched ${a5(y.fetchedAt)}`:"not fetched"],data:f.node||f,onRaw:l,testId:"raw-pipeline-node-runtime"}),X(y1,{title:"Procedure runs",subtitle:`${$.length} groups`,facts:[`latest ${j.status?.status||j.status||"--"}`,`steps ${Hf(j.recentSteps).length}`,`duration ${l1(Pf(j.finishedAt)&&Pf(j.startedAt)?Number(Pf(j.finishedAt))-Number(Pf(j.startedAt)):j.durationMs)}`],data:$,onRaw:l,testId:"raw-pipeline-node-procedures"}),X(y1,{title:"OpenCode messages",subtitle:String(O.exists?"available":"not indexed"),facts:[`messages ${k5(O.messageCount)}`,`size ${k5(O.size)}`,`updated ${z0(O.updatedAt)}`],data:O,onRaw:l,testId:"raw-pipeline-node-messages"}),X(y1,{title:"Control prompts",subtitle:"manual / monitor append queues",facts:[`manual tail ${q.total}`,`monitor tail ${V.total}`,`last ${z0(gF(q.lastAt,V.lastAt))}`],data:{controlPromptsTail:z,monitorPromptsTail:W},onRaw:l,testId:"raw-pipeline-node-prompts"}),X(y1,{title:"Control events",subtitle:K.eventKinds.length>0?K.eventKinds.join(", "):"event tail",facts:[`tail ${K.total}`,`parsed ${K.parsed}`,`last ${z0(K.lastAt)}`],data:U,onRaw:l,testId:"raw-pipeline-node-events"}),X(y1,{title:"Worker log",subtitle:"tail is hidden on main canvas",facts:[`tail ${Q.length} lines`,"raw only via button",`procedure ${$1(j)||"--"}`],data:Q,onRaw:l,testId:"raw-pipeline-node-worker-log"}))}function CM({activeRun:f,onRaw:u}){if(!f)return X(cu,{title:"暂无运行材料",text:"没有 Pipeline epoch 时不会展示运行材料索引。"});let _=Hf(f.nodes),y=Hf(f.procedureRuns),l=Hf(f.submissions),$=Hf(f.workerLogTail),j=Jq(_),J=Jq(y),F=y.filter((U)=>String(U?.status||"").toLowerCase()==="failed"),Q=gF(...y.flatMap((U)=>[U.updatedAt,U.finishedAt,U.startedAt]));return X("div",{className:"pipeline-evidence-list"},X(y1,{title:"Epoch overview",subtitle:f.runId||"--",facts:[`pipeline ${f.pipelineId||"--"}`,`status ${f.status||"--"}`,`started ${z0(f.startedAt)}`,`updated ${z0(f.updatedAt)}`],data:f,onRaw:u,testId:"raw-pipeline-run"}),X(y1,{title:"Node states",subtitle:`${_.length} nodes`,facts:[`running ${j.running||0}`,`succeeded ${j.succeeded||0}`,`failed ${j.failed||0}`,`pending ${j.pending||0}`],data:_,onRaw:u,testId:"raw-pipeline-run-nodes"}),X(y1,{title:"Procedure run index",subtitle:`${y.length} procedure records`,facts:[`succeeded ${J.succeeded||0}`,`failed ${J.failed||0}`,`latest ${z0(Q)}`,`errors ${F.length}`],data:y,onRaw:u,testId:"raw-pipeline-run-procedures"}),X(y1,{title:"OA submissions",subtitle:`${l.length} submission files`,facts:[`records ${l.length}`,`task ${k5(f.task)}`,"raw grouped by run"],data:l,onRaw:u,testId:"raw-pipeline-run-submissions"}),X(y1,{title:"Worker log tail",subtitle:"hidden from main interface",facts:[`tail ${$.length} lines`,"display raw only after click",`updated ${z0(f.updatedAt)}`],data:$,onRaw:u,testId:"raw-pipeline-run-worker-log"}))}function RM({diagnostics:f,onRaw:u}){let _=Hf(f?.runs).filter(Xf),y=Hf(f?.forbiddenResiduals),l=Xf(f?.guarantees)?f.guarantees:{},$=f?.hasNeutralNodeFinishedEvidence===!0&&f?.hasNoAuditPolicyEvidence===!0&&f?.hasAuditPolicyEvidence===!0,j=f?.ok===!0&&$&&y.length===0,J=_[0]||null,F=[{label:"中性完成事实",ok:l.neutralNodeFinished===!0,hint:"node-finished 不携带流程策略"},{label:"Config 策略判定",ok:l.auditPolicyFromConfig===!0,hint:"OA backend 读取当前 epoch 配置"},{label:"控制命令来自 OA",ok:l.runnerConsumesControlCommandsFromOaEvents===!0,hint:"runner 只消费 OA control.command"},{label:"无独立审核事件",ok:l.noIndependentAuditRequestEvent===!0,hint:"审核由 node-finished + policy 派生"},{label:"无批次门禁",ok:l.noBatchFinishedControlGate===!0,hint:"下游启动由每个 node 完成驱动"}];return X("div",{className:"pipeline-oa-panel","data-testid":"pipeline-oa-event-flow-panel"},X("div",{className:"metric-grid compact"},X(Hu,{label:"OA Flow",value:j?"100%":"--",hint:String(f?.mode||"waiting diagnostics"),tone:j?"ok":"warn"}),X(Hu,{label:"禁止残留",value:y.length,hint:y.length===0?"source scan clean":"needs cleanup",tone:y.length===0?"ok":"warn"}),X(Hu,{label:"No-audit",value:f?.hasNoAuditPolicyEvidence?"OK":"--",hint:"OA 下游策略证据",tone:f?.hasNoAuditPolicyEvidence?"ok":"warn"}),X(Hu,{label:"Monitor 审核",value:f?.hasAuditPolicyEvidence?"OK":"--",hint:"OA 控制事件闭环",tone:f?.hasAuditPolicyEvidence?"ok":"warn"})),X("div",{className:"pipeline-oa-guarantees"},F.map((Q)=>X("article",{key:Q.label,className:`pipeline-oa-guarantee ${Q.ok?"ok":"warn"}`},X(uy,{status:Q.ok?"online":"warn"},Q.ok?"OK":"MISS"),X("div",null,X("strong",null,Q.label),X("span",null,Q.hint))))),X("div",{className:"pipeline-evidence-list compact"},_.slice(0,6).map((Q)=>X(y1,{key:Q.runId,title:String(Q.runId||"--"),subtitle:[Number(Q.monitorAuditNodeFinishedCount||0)>0?"monitor audit":"",Number(Q.noAuditPolicyCount||0)>0?"no-audit policy":""].filter(Boolean).join(" / ")||"event evidence",facts:[`events ${Q.eventCount||0}`,`node-finished ${Q.nodeFinishedCount||0}`,`policy-in-detail ${Q.nodeFinishedWithPolicyCount||0}`,`queued ${Q.controlQueuedCount||0}`,`applied ${Q.controlAppliedCount||0}`],data:Q,onRaw:u,testId:`raw-pipeline-oa-run-${String(Q.runId||"run").replace(/[^a-zA-Z0-9_.-]+/g,"-")}`}))),J?X("p",{className:"muted paragraph"},`最新证据 ${J.runId}: ${J.nodeFinishedCount||0} 个 node-finished,${J.controlAppliedCount||0} 个控制结果。`):X(cu,{title:"暂无 OA 事件流证据",text:"等待 Pipeline backend 暴露 diagnostics。"}),f?X("div",{className:"panel-actions inline-actions"},X(X1,{title:"Pipeline OA Event Flow Diagnostics",data:f,onOpen:u,testId:"raw-pipeline-oa-event-flow"})):null)}function xM({quota:f,onRaw:u}){let _=Xf(f?.summary)?f.summary:{},y=Xf(f?.target)?f.target:{},l=Xf(f?.cache)?f.cache:{},$=f?.ok===!0,j=String(f?.modelId||_.modelName||y.modelName||"MiniMax-M2.7"),J=_.totalCount??y.currentIntervalTotalCount,F=_.usageCount??y.currentIntervalUsageCount,Q=_.remainingCount??y.currentIntervalRemainingCount,U=_.remainingRatio??(Number.isFinite(Number(J))&&Number(J)>0&&Number.isFinite(Number(Q))?Number(Q)/Number(J):void 0),z=_.usageRatio??(Number.isFinite(Number(J))&&Number(J)>0&&Number.isFinite(Number(F))?Number(F)/Number(J):void 0),W=_.resetAt||y.endAt,K=_.remainsMs??y.remainsMs,q=Number(Q),V=!$||Number.isFinite(q)&&q<=0?"warn":"ok",O=[$?`endpoint ${f?.endpoint||"--"}`:"quota unavailable",`fetched ${m5(f?.fetchedAt)}`,l.hit?`cache ${l1(l.ageMs)}`:"live quota"];return X("div",{className:"pipeline-minimax-quota-panel","data-testid":"pipeline-minimax-quota-panel"},X("div",{className:"metric-grid compact"},X(Hu,{label:"MiniMax",value:$?j:"--",hint:f?.modelComponent||f?.error||"model/minimax-m27",tone:V}),X(Hu,{label:"当前窗口",value:`${SF(F)}/${SF(J)}`,hint:`已用 ${jq(z)}`,tone:V}),X(Hu,{label:"剩余额度",value:SF(Q),hint:`剩余 ${jq(U)}`,tone:V}),X(Hu,{label:"重置时间",value:m5(W),hint:K!==void 0?`约 ${l1(K)}`:z0(W),tone:V})),X(tF,{items:O}),$?X("p",{className:"muted paragraph"},`MiniMax 限额来自 D601 Pipeline 后端实时查询;当前模型匹配 ${_.modelName||y.modelName||j}。`):X(H0,{error:f?.error||"MiniMax 限额查询失败"}),f?X("div",{className:"panel-actions inline-actions"},X(X1,{title:"Pipeline MiniMax Quota",data:f,onOpen:u,testId:"raw-pipeline-minimax-quota"})):null)}function vM({epochs:f,activeRun:u,activePipeline:_,pipelineNodes:y,pipelineEdges:l,runDetails:$,nodeDetails:j,nodeDetailsState:J,ganttScale:F=D6,onGanttScaleChange:Q,onRunChange:U,onIntervalSelect:z,onMarkerSelect:W,selection:K,detailOpen:q,onDetailOpenChange:V,onRaw:O}){let[G,H]=Bu(qM),[Z,E]=Bu({startY:0,endY:0,startMs:0,endMs:0}),[L,M]=Bu(Date.now()),N=e_(null),w=String(u?.runId||""),R=Boolean(q),p=(uf)=>{if(typeof V==="function")V(uf)},x=_y(F??D6),C=String($?.runId||"")===w?$?.details:null,P=C?{...Xf(u)?u:{},...Xf(C)?C:{},runId:w,procedureRuns:Hf(C?.procedureRuns).length>0?C.procedureRuns:u?.procedureRuns}:u,D=Iq(P,y,L),T=C?SM(C,P):{markers:[],arrows:[]},S=Hf(T.markers),r=ZM(P,D,S),Y=XM(r,x,D,L),v=String(Y.source||"frontend-y"),m=D.map((uf)=>NM(uf,Y,L)),c={startMs:Number(Y.startMs),endMs:Number(Y.endMs),durationMs:Math.max(1,Number(Y.durationMs??Number(Y.endMs)-Number(Y.startMs)))},o=eF(x),ff={...o,pxPerMinute:Number(Y.pxPerMinute??o.pxPerMinute)},n=Math.round(Number(Y.chartHeight||360)),lf=D.some(fA);c1(()=>{if(!w||!lf)return;let uf=window.setInterval(()=>M(Date.now()),1000);return()=>window.clearInterval(uf)},[w,lf]);let Gf=jM(_,y,Array.isArray(l)?l:[]),zf=y.map((uf)=>String(uf?.id||"")).filter(Boolean),jf=m.map((uf)=>String(uf.nodeId||"")).filter(Boolean),Wf=S.map((uf)=>String(uf.nodeId||"")).filter(Boolean),Vf=Array.from(new Set([...Gf,...zf,...jf,...Wf])),Kf={startY:0,endY:n,startMs:Number(c.startMs),endMs:Number(c.endMs)},h=Number(Z?.endY||0)>0?Z:Kf,g=(uf)=>{return o5(uf,c,n,Y)<=Number(h.endY)&&iF(uf,c,n,Y)>=Number(h.startY)},I=(uf)=>{let vf=Iu(uf,c,n,Y);return vf>=Number(h.startY)&&vf<=Number(h.endY)},yf=new Set(Vf.filter((uf)=>m.some((vf)=>vf.nodeId===uf&&g(vf))||S.some((vf)=>vf.nodeId===uf&&I(vf)))),$f=G?Vf.filter((uf)=>yf.has(uf)):Vf,Qf=`${MF}px ${$f.length>0?$f.map(()=>`${h1}px`).join(" "):"minmax(160px, 1fr)"}`,Yf=Hf(Y.ticks).filter(Xf),xf=String(K?.mode==="interval"?K?.interval?.procedureRunId||"":""),tf=String(K?.mode==="event"?K?.marker?.id||"":""),j0=()=>{let uf=N.current;if(!uf){E(Kf);return}let vf=Math.max(0,uf.scrollTop-rF),a0=Math.max(120,uf.clientHeight-rF),Bf=Math.min(n,vf+a0),v0={startY:vf,endY:Bf,startMs:Number(c.startMs),endMs:Number(c.endMs)},i0=Math.max(0,Math.min(1,vf/n)),d0=Math.max(i0,Math.min(1,Bf/n)),b0=Math.max(1,Number(c.endMs)-Number(c.startMs));v0.startMs=Number(c.startMs)+b0*i0,v0.endMs=Number(c.startMs)+b0*d0,E(v0)};c1(()=>{let uf=N.current,vf=window.setTimeout(j0,0);return uf?.addEventListener("scroll",j0),window.addEventListener("resize",j0),()=>{window.clearTimeout(vf),uf?.removeEventListener("scroll",j0),window.removeEventListener("resize",j0)}},[w,c.startMs,c.endMs,n]);let u0=Math.max(0,Vf.length-$f.length),D0=new Set(S.filter((uf)=>$f.includes(String(uf.nodeId||""))&&I(uf)).map((uf)=>String(uf.id))),Fu=new Map(S.map((uf)=>[String(uf.id),uf])),O0=Hf(T.arrows).filter((uf)=>{if(!D0.has(String(uf.targetMarkerId||"")))return!1;if(String(uf.action||"")==="observe")return $f.includes(String(uf.sourceNodeId||""));return D0.has(String(uf.sourceMarkerId||""))}),x0=MF+Math.max(1,$f.length)*h1,ku=(uf)=>{let vf=_y(uf.target.value);if(typeof Q==="function")Q(vf);window.setTimeout(j0,0)},X0=()=>zM({title:`${_?.id||"pipeline"}-${w||"epoch"}-gantt`,meta:[`run ${w||"--"}`,`${z0(c.startMs)} -> ${z0(c.endMs)}`,`duration ${l1(c.durationMs)}`,`${ff.label} / ${vF(ff.pxPerMinute)} px/min`,`${$f.length}/${Vf.length} nodes`,`${S.length} markers`],visibleNodeIds:$f,intervals:m,markers:S.filter((uf)=>$f.includes(String(uf.nodeId||""))),arrows:O0,ticks:Yf,bounds:c,chartHeight:n,backendLayout:Y}),Au=Xf(C?.gantt?.diagnostics)?C.gantt.diagnostics:null;return X(I1,{title:"Epoch 甘特图",eyebrow:`${_?.id||"pipeline"} / ${f.length} epochs`,className:"pipeline-wide-panel",actions:X("div",{className:"pipeline-gantt-actions"},X("select",{value:w,disabled:f.length===0,onChange:(uf)=>U(uf.target.value),"data-testid":"pipeline-epoch-select"},f.map((uf)=>X("option",{key:uf.runId,value:uf.runId},mF(f,uf)))),X("label",{className:"pipeline-gantt-toggle"},X("input",{type:"checkbox","data-testid":"pipeline-gantt-auto-hide-idle",checked:G,onChange:(uf)=>{H(Boolean(uf.target.checked)),window.setTimeout(j0,0)}}),X("span",null,"自动隐藏空闲列")),X("label",{className:"pipeline-gantt-scale"},X("span",null,X("b",null,"时间尺度"),X("em",{"data-testid":"pipeline-gantt-scale-label"},`${ff.label} · ${vF(ff.pxPerMinute)} px/min`)),X("input",{type:"range",min:0,max:100,step:0.01,value:x,onChange:ku,"aria-label":"调整甘特图时间尺度","data-testid":"pipeline-gantt-time-scale"}),X("small",null,X("span",null,"全局"),X("span",null,"细节"))),u?X("button",{type:"button",className:"ghost-btn",onClick:X0,disabled:$f.length===0,"data-testid":"pipeline-export-gantt"},"导出甘特图"):null,u?X(X1,{title:`Pipeline Epoch ${u.runId}`,data:u,onOpen:O,testId:"raw-pipeline-epoch-gantt"}):null)},!u?X(cu,{title:"暂无 Epoch",text:"当前 pipeline 还没有完整运行记录。"}):m.length===0?X(cu,{title:"暂无时间区间",text:"等待 D601 Pipeline backend 在 procedure summary 中返回 startedAt / finishedAt。"}):X("div",{className:"pipeline-gantt-wrap"},X("div",{className:`pipeline-gantt-detail-layout ${R?"detail-open":"detail-collapsed"}`,"data-testid":"pipeline-gantt-detail-layout","data-sidebar-open":R?"true":"false"},X("div",{className:"pipeline-gantt-main"},X("div",{className:"pipeline-gantt-main-head"},X("div",{className:"pipeline-gantt-meta"},X("span",null,`time ${z0(c.startMs)} -> ${z0(c.endMs)}`),X("span",null,`duration ${l1(c.durationMs)}`),X("span",null,`scale ${ff.label} / ${vF(ff.pxPerMinute)} px/min`),X("span",null,`layout ${v}`),Au?X("span",null,`align ${Au.timeAxisAlignmentOk===!1?"check":"ok"}`):null,X("span",null,`visible ${$f.length}/${Vf.length} nodes`),C?X("span",null,`markers ${S.length}`):null,G&&u0>0?X("span",null,`hidden idle ${u0}`):null),!R?X("button",{type:"button",className:"pipeline-sidecar-tab right",disabled:!K?.mode,onClick:()=>p(!0),"data-testid":"pipeline-gantt-sidebar-toggle"},K?.mode?"展开详情":"点击甘特图元素展开详情"):null),X("div",{className:"pipeline-gantt-viewport",ref:N,"data-testid":"pipeline-epoch-gantt","data-pipeline-id":_?.id||"","data-run-id":w,"data-layout-source":v,"data-start-ms":String(c.startMs),"data-end-ms":String(c.endMs),"data-chart-height":String(n)},X("div",{className:"pipeline-gantt-board",style:{gridTemplateColumns:Qf,minWidth:`${x0}px`}},X("div",{className:"pipeline-gantt-head time"},"Time"),$f.length===0?X("div",{className:"pipeline-gantt-head empty"},"当前时间窗无工作节点"):$f.map((uf)=>X("div",{key:`head-${uf}`,className:"pipeline-gantt-head node",title:uf,"data-testid":"pipeline-gantt-head-node","data-node-id":uf},X(cT,{value:uf}))),X("div",{className:"pipeline-gantt-time-axis",style:{height:`${n}px`}},Yf.map((uf)=>{let vf=gq(uf,c,n,Y);return X("div",{key:`tick-${uf.ms}-${vf}`,className:"pipeline-gantt-tick",style:{top:`${vf}px`},"data-testid":"pipeline-gantt-tick","data-ms":String(uf.ms),"data-y":String(vf)},X("b",null,z0(uf.ms)),X("span",null,`+${l1(Number(uf.offsetMs??Number(uf.ms)-Number(c.startMs)))}`))})),$f.length>0?X("svg",{className:"pipeline-gantt-arrow-layer",width:$f.length*h1,height:n,viewBox:`0 0 ${$f.length*h1} ${n}`,style:{left:`${MF}px`,top:`${rF}px`,width:`${$f.length*h1}px`,height:`${n}px`},"aria-hidden":"true"},X("defs",null,X("marker",{id:"pipeline-gantt-arrowhead",viewBox:"0 0 10 10",refX:9,refY:5,markerWidth:6,markerHeight:6,orient:"auto-start-reverse"},X("path",{d:"M 0 0 L 10 5 L 0 10 z",fill:"context-stroke"}))),O0.map((uf)=>{let vf=Fu.get(String(uf.targetMarkerId||""));if(!vf)return null;let a0=Fu.get(String(uf.sourceMarkerId||"")),Bf=String(a0?.nodeId||uf.sourceNodeId||""),v0=$f.indexOf(Bf),i0=$f.indexOf(String(vf.nodeId||""));if(v0<0||i0<0)return null;let d0=v0*h1+h1/2,b0=i0*h1+h1/2,m1=a0?Iu(a0,c,n,Y):Iu(vf,c,n,Y),ef=Iu(vf,c,n,Y);return X("path",{key:uf.id,className:`pipeline-gantt-arrow ${String(uf.sourceKind||"").toLowerCase()} ${String(uf.status||"").toLowerCase()} ${String(uf.action||"").toLowerCase()}`,d:nq(d0,m1,b0,ef),markerEnd:"url(#pipeline-gantt-arrowhead)","data-testid":String(uf.action||"")==="observe"?"pipeline-gantt-observation-arrow":"pipeline-gantt-arrow","data-source-node-id":String(uf.sourceNodeId||""),"data-target-node-id":String(uf.targetNodeId||""),"data-target-marker-id":String(uf.targetMarkerId||""),"data-action":String(uf.action||""),"data-source-y":String(m1),"data-target-y":String(ef)})})):null,$f.length===0?X("div",{className:"pipeline-gantt-empty-col",style:{height:`${n}px`}},"滚动到有活动的时间段后,相关 node 列会自动出现。"):$f.map((uf)=>{let vf=m.filter((Bf)=>Bf.nodeId===uf),a0=S.filter((Bf)=>String(Bf.nodeId||"")===uf);return X("div",{key:`col-${uf}`,className:"pipeline-gantt-node-col",style:{height:`${n}px`}},vf.map((Bf)=>{let v0=o5(Bf,c,n,Y),i0=iF(Bf,c,n,Y),d0=iq(Bf,c,n,Y),b0=String(Bf.procedureRunId||`${uf}-${Bf.startMs}`);return X("button",{key:b0,type:"button",className:`pipeline-gantt-bar ${Bf.status} ${Bf.live?"live":""} ${xf===b0?"selected":""}`,style:{top:`${v0}px`,height:`${d0}px`},title:`${uf} ${Bf.status} ${z0(Bf.startedAt||Bf.startMs)} -> ${z0(Bf.finishedAt||Bf.endMs)}`,onClick:()=>z(Bf),"data-testid":"pipeline-gantt-line","data-node-id":uf,"data-procedure-run-id":String(Bf.procedureRunId||""),"data-status":String(Bf.status||""),"data-live":Bf.live?"true":"false","data-start-ms":String(Bf.startMs||""),"data-end-ms":String(Bf.endMs||""),"data-y1":String(v0),"data-y2":String(i0),"data-natural-height":String(Math.max(0,i0-v0))},X("strong",null,Bf.status||"working"),X("span",null,l1(Bf.durationMs)))}),a0.map((Bf)=>X("button",{key:Bf.id,type:"button",className:`pipeline-gantt-marker ${Bf.kind} ${Bf.tone||""} ${Bf.status||""} ${tf===String(Bf.id)?"selected":""}`,style:{top:`${Iu(Bf,c,n,Y)}px`},title:`${Bf.label||"event"} / ${z0(Bf.timestampIso||Bf.timestamp||Bf.ms)}`,onClick:()=>W(Bf),"data-testid":Bf.kind==="prompt"?"pipeline-gantt-prompt-marker":"pipeline-gantt-control-marker","data-marker-id":String(Bf.id||""),"data-ms":String(Bf.ms??Bf.eventMs??""),"data-y":String(Iu(Bf,c,n,Y))})))})))),R?X(IT,{selection:K,runDetails:$,nodeDetails:j,nodeDetailsState:J,onRaw:O,onCollapse:()=>p(!1)}):null)))}function Q_(){return{loading:!1,actionLoading:"",error:"",message:"",details:null,fetchedAt:null,appendPrompt:"",guidePrompt:"",modifyPrompt:"",approveReason:"",redoReason:""}}function d_(){return{mode:"",runId:"",interval:null,marker:null}}function bF(){return{runId:"",loading:!1,error:"",details:null,fetchedAt:null}}function E6(f,u){return`${f}/microservices/pipeline/proxy${u}`}function bM({activeRun:f,pipelineRuns:u,selectedRunId:_,onRunChange:y,selectedNodeId:l,selectedNodeConfig:$,selectedNodeRuntime:j,control:J,onControlChange:F,onFetch:Q,onAction:U,onRaw:z,onCollapse:W}){let K=String(f?.runId||""),q=String(j?.status||"pending"),V=!K||!l||J.loading||Boolean(J.actionLoading),O=(H)=>(Z)=>F({[H]:Z.target.value,error:"",message:""}),G=u.length>0?u:f?[f]:[];return X("aside",{className:"pipeline-node-control","data-testid":"pipeline-node-control"},X("div",{className:"pipeline-node-control-head"},X("div",null,X("p",{className:"panel-eyebrow"},"Manual Node Control"),X("h3",null,l||"点击控制图中的 node")),X("div",{className:"pipeline-node-control-head-actions"},l?X(uy,{status:q},q):X(uy,{status:"pending"},"idle"),X("button",{type:"button",className:"ghost-btn mini",onClick:W,"data-testid":"pipeline-node-sidebar-collapse"},"收起"))),X("div",{className:"pipeline-control-runbar"},X("label",null,X("span",null,"目标 run"),X("select",{value:K||_,disabled:G.length===0,onChange:(H)=>y(H.target.value),"data-testid":"pipeline-node-run-select"},G.map((H)=>X("option",{key:H.runId,value:H.runId},`${H.runId||"--"} / ${H.status||"--"}`)))),X("button",{type:"button",className:"ghost-btn",disabled:V,onClick:Q,"data-testid":"pipeline-node-fetch"},J.loading?"抓取中":"抓取过程"),J.details?X(X1,{title:`Pipeline Node ${l}`,data:J.details,onOpen:z,testId:"raw-pipeline-node-control"}):null),X("div",{className:"pipeline-control-meta"},X("span",null,X("b",null,"kind"),String($?.kind||"--")),X("span",null,X("b",null,"procedure"),String(j?.currentProcedureRunId||"--")),X("span",null,X("b",null,"attempts"),String(j?.attempts??"--")),X("span",null,X("b",null,"updated"),z0(f?.updatedAt))),!l?X(cu,{title:"未选择 node",text:"点击 React Flow 控制图中的任意 node 后,可抓取执行过程、追加 prompt、下发引导、增量修改、审核通过或重做。"}):null,X(H0,{error:J.error,wide:!0}),J.message?X("div",{className:"form-success wide"},J.message):null,X("div",{className:"pipeline-control-actions"},X("label",null,X("span",null,"实时追加 prompt(仅 running node)"),X("textarea",{value:J.appendPrompt,onChange:O("appendPrompt"),placeholder:"让当前执行中的 agent 继续、补充检查或调整当前步骤...",rows:4,disabled:!l,"data-testid":"pipeline-node-append-input"}),X("button",{type:"button",className:"primary-btn compact",disabled:V||!String(J.appendPrompt||"").trim(),onClick:()=>U("append"),"data-testid":"pipeline-node-append-button"},J.actionLoading==="append"?"追加中":"追加到运行中 node")),X("label",null,X("span",null,"下次尝试引导 prompt"),X("textarea",{value:J.guidePrompt,onChange:O("guidePrompt"),placeholder:"给该 node 下一次 attempt 的执行提示;不会立即打断当前 session。",rows:4,disabled:!l,"data-testid":"pipeline-node-guide-input"}),X("button",{type:"button",className:"ghost-btn compact",disabled:V||!String(J.guidePrompt||"").trim(),onClick:()=>U("guide"),"data-testid":"pipeline-node-guide-button"},J.actionLoading==="guide"?"下发中":"下发 guide")),X("label",null,X("span",null,"完成后增量修改 prompt"),X("textarea",{value:J.modifyPrompt,onChange:O("modifyPrompt"),placeholder:"在该 node 已完成结果基础上追加修改要求;runner 会重跑目标 node,并保留同 node 既有 OA 输出作为上下文。",rows:4,disabled:!l,"data-testid":"pipeline-node-modify-input"}),X("button",{type:"button",className:"ghost-btn compact",disabled:V||!String(J.modifyPrompt||"").trim(),onClick:()=>U("modify"),"data-testid":"pipeline-node-modify-button"},J.actionLoading==="modify"?"排队中":"增量修改 node")),X("label",null,X("span",null,"Monitor 审核通过原因"),X("textarea",{value:J.approveReason,onChange:O("approveReason"),placeholder:"当流程配置开启 monitor 审核时,记录审核通过原因并释放后续 node。",rows:3,disabled:!l,"data-testid":"pipeline-node-approve-input"}),X("button",{type:"button",className:"primary-btn compact",disabled:V||!String(J.approveReason||"").trim(),onClick:()=>U("approve"),"data-testid":"pipeline-node-approve-button"},J.actionLoading==="approve"?"提交中":"审核通过")),X("label",null,X("span",null,"重做 / restart 原因"),X("textarea",{value:J.redoReason,onChange:O("redoReason"),placeholder:"说明为什么需要重做;runner 会重置目标 node 以及非 rework 下游 node。",rows:4,disabled:!l,"data-testid":"pipeline-node-redo-input"}),X("button",{type:"button",className:"danger-btn compact",disabled:V||!String(J.redoReason||"").trim(),onClick:()=>U("redo"),"data-testid":"pipeline-node-redo-button"},J.actionLoading==="redo"?"排队中":"重做 node"))),X("div",{className:"pipeline-control-evidence"},X("strong",null,"Node 过程索引"),X(PM,{details:J.details,selectedNodeId:l,selectedNodeRuntime:j,control:J,onRaw:z})))}function tq({microservices:f,onRaw:u,apiBaseUrl:_="/api"}){let y=f.find((s)=>s.id==="pipeline")||null,[l,$]=Bu({loading:!1,error:"",health:null,snapshot:null,oaDiagnostics:null,minimaxQuota:null,refreshedAt:null}),[j,J]=Bu(""),[F,Q]=Bu(""),[U,z]=Bu(""),[W,K]=Bu(Q_()),[q,V]=Bu({}),[O,G]=Bu(d_()),[H,Z]=Bu(bF()),[E,L]=Bu(D6),[M,N]=Bu(!1),[w,R]=Bu(!1),p=e_(0),x=e_(!1),C=e_(0),P=e_(""),D=e_({}),T=e_(""),S=e_("");async function r(s={}){let Nf=s.silent===!0;if(!y)return;if(x.current)return;x.current=!0;let Of=p.current+1;if(p.current=Of,!Nf)$((Cf)=>({...Cf,loading:!0,error:""}));try{let Cf=`__unideskArrayLimit=registry.components:80,runs:${NT}&_=${Date.now()}`,[_0,G0,hf]=await Promise.all([a_(`${_}/microservices/pipeline/proxy/api/snapshot?${Cf}`,{cache:"no-store"}),a_(`${_}/microservices/pipeline/proxy/api/oa-event-flow/diagnostics?_=${Date.now()}`,{cache:"no-store"}).catch((Qu)=>({ok:!1,error:Tf(Qu,"OA event flow diagnostics failed")})),a_(`${_}/microservices/pipeline/proxy/api/model-quota/minimax?_=${Date.now()}`,{cache:"no-store"}).catch((Qu)=>({ok:!1,error:Tf(Qu,"MiniMax quota failed")}))]);if(Of!==p.current)return;let h0={ok:_0?.ok!==!1,service:"pipeline-v2-control snapshot"};$({loading:!1,error:"",health:h0,snapshot:_0,oaDiagnostics:G0,minimaxQuota:hf,refreshedAt:new Date})}catch(Cf){if(Of!==p.current)return;$((_0)=>({..._0,loading:!1,error:Tf(Cf,"Pipeline 加载失败")}))}finally{x.current=!1}}c1(()=>{if(r(),!y)return;let s=window.setInterval(()=>{r({silent:!0})},yq);return()=>window.clearInterval(s)},[y?.id,y?.runtime?.providerStatus,_]);let Y=pT(y),v=kT(y),m=mT(y),c=l.snapshot||{},o=l.oaDiagnostics||null,ff=l.minimaxQuota||null,{components:n,pipelines:lf,runs:Gf}=iT(c),zf=String(Gf[0]?.pipelineId||""),jf=(zf?lf.find((s)=>String(s.id||"")===zf):null)||lf[0]||{},Wf=lf.find((s)=>String(s.id||"")===j)||jf,Vf=String(Wf.id||""),Kf=Rq(Wf),h=oF(Wf),g=Vq(Gf,Vf),I=KM(Gf,Vf),yf=I.find((s)=>String(s?.runId||"")===F)||g,$f=String(H.runId||"")===String(yf?.runId||"")?sT(H.details):null,Qf=oT(yf,$f),Yf=String(Qf?.runId||""),xf=Kf.find((s)=>String(s?.id||"")===U)||null,tf=U?xq(Qf,U):null,j0=nT(Gf),u0=fM(n),D0=Number(l.health?.components)||Wq(c,"registry.components",n.length),Fu=Wq(c,"runs",Gf.length),O0=Kq(Wf,Qf,n),x0={nodes:O0.nodes.map((s)=>s.id===U?{...s,selected:!0,className:`${s.className||""} selected-control-node`}:s),edges:O0.edges},ku=lf.map((s)=>{let Nf=String(s.id||"pipeline"),Of=Vq(Gf,Nf);return{title:`${Nf}-${Of?.runId||"snapshot"}`,flow:Kq(s,Of,n)}}),X0=String(O?.runId||Yf||""),Au=String(O?.interval?.nodeId||O?.marker?.nodeId||""),uf=X0&&Au?q[xF(X0,Au)]||null:null,vf=i5(W.details,X0,Au),a0=i5(uf?.details,X0,Au)||vf,Bf=X0&&Au?{...Xf(uf)?uf:{},runId:X0,nodeId:Au,details:a0,loading:Boolean(uf?.loading)||!a0&&Boolean(W.loading)&&U===Au,error:String(uf?.error||""),fetchedAt:uf?.fetchedAt||(vf?W.fetchedAt:null)}:null,v0=I.map((s)=>String(s?.runId||"")).filter(Boolean).join("|"),i0=Kf.map((s)=>String(s?.id||"")).filter(Boolean).join("|");c1(()=>{T.current=U},[U]),c1(()=>{S.current=Yf},[Yf]),c1(()=>{if(!F||v0.split("|").includes(F))return;Q("")},[F,v0]),c1(()=>{if(!U||i0.split("|").includes(U))return;z(""),K(Q_()),G(d_()),N(!1),R(!1)},[U,i0]),c1(()=>{if(!U)N(!1)},[U]),c1(()=>{if(!O.mode)R(!1)},[O.mode]);async function d0(s=Yf,Nf={}){if(!s){Z(bF());return}let Of=_y(Nf.scale??E??D6),Cf=`${s}:timeline`;if(P.current===Cf)return;P.current=Cf;let _0=Nf.silent===!0,G0=C.current+1;C.current=G0,Z((hf)=>({runId:s,scale:Of,loading:!_0||String(hf.runId||"")!==s||!hf.details,error:"",details:_0&&hf.runId===s?hf.details:hf.runId===s?hf.details:null,fetchedAt:hf.runId===s?hf.fetchedAt:null}));try{let[hf,h0]=await Promise.all([a_(`${E6(_,`/api/node-control/runs/${encodeURIComponent(s)}?tail=160&view=timeline`)}&_=${Date.now()}`,{cache:"no-store",strictJson:!0}),a_(`${E6(_,`/api/runs/${encodeURIComponent(s)}`)}?_=${Date.now()}`,{cache:"no-store"}).catch((Qu)=>({ok:!1,runSummaryError:Tf(Qu,"抓取评分失败")}))]);if(G0!==C.current)return;Z({runId:s,scale:Of,loading:!1,error:"",details:{...hf,run:Xf(h0?.run)?h0.run:void 0,runSummaryError:h0?.runSummaryError},fetchedAt:new Date})}catch(hf){if(G0!==C.current)return;Z((h0)=>({runId:s,scale:Of,loading:!1,error:Tf(hf,"抓取 epoch 执行过程失败"),details:h0.runId===s?h0.details:null,fetchedAt:h0.runId===s?h0.fetchedAt:null}))}finally{if(P.current===Cf)P.current=""}}function b0(s,Nf,Of){let Cf=xF(s,Nf);V((_0)=>{let G0={..._0,[Cf]:{...Xf(_0?.[Cf])?_0[Cf]:{},runId:s,nodeId:Nf,...Of}},hf=Object.keys(G0);if(hf.length>32)for(let h0 of hf.slice(0,hf.length-32))delete G0[h0];return G0})}async function m1(s,Nf){if(!s||!Nf)return;let Of=xF(s,Nf),Cf=Number(D.current?.[Of]||0)+1;D.current={...D.current,[Of]:Cf},b0(s,Nf,{loading:!0,error:""});try{let _0=await a_(E6(_,`/api/node-control/runs/${encodeURIComponent(s)}/nodes/${encodeURIComponent(Nf)}?tail=160`),{cache:"no-store",strictJson:!0});if(Number(D.current?.[Of]||0)!==Cf)return;let G0=new Date;if(b0(s,Nf,{loading:!1,details:_0,fetchedAt:G0,error:""}),T.current===Nf&&S.current===s)K((hf)=>({...hf,loading:!1,details:_0,fetchedAt:G0,error:""}))}catch(_0){if(Number(D.current?.[Of]||0)!==Cf)return;b0(s,Nf,{loading:!1,error:Tf(_0,"抓取 Gantt node 详情失败")})}}c1(()=>{if(!Yf){Z(bF());return}d0(Yf);let s=window.setInterval(()=>{d0(Yf,{silent:!0})},yq);return()=>window.clearInterval(s)},[Yf,_]);async function ef(s=Yf,Nf=U){if(!s||!Nf){K((Of)=>({...Of,error:"请先选择 run 和 node",message:""}));return}K((Of)=>({...Of,loading:!0,error:"",message:""}));try{let Of=await a_(E6(_,`/api/node-control/runs/${encodeURIComponent(s)}/nodes/${encodeURIComponent(Nf)}?tail=160`),{cache:"no-store",strictJson:!0}),Cf=new Date;K((_0)=>({..._0,loading:!1,details:Of,fetchedAt:Cf,error:""})),b0(s,Nf,{loading:!1,details:Of,fetchedAt:Cf,error:""})}catch(Of){K((Cf)=>({...Cf,loading:!1,error:Tf(Of,"抓取 node 执行过程失败")}))}}async function iu(s){let Nf=String(s?.runId||Yf||""),Of=String(s?.nodeId||"");if(G({mode:"interval",runId:Nf,interval:s,marker:null}),R(!0),!Nf||!Of)return;if(Nf!==Yf)Q(Nf);z(Of),K(Q_()),d0(Nf,{silent:!0}),m1(Nf,Of)}async function ey(s){let Nf=String(s?.runId||Yf||""),Of=String(s?.nodeId||"");if(G({mode:"event",runId:Nf,interval:null,marker:s}),R(!0),!Nf)return;if(Nf!==Yf)Q(Nf);if(d0(Nf,{silent:!0}),!Of)return;z(Of),K(Q_()),m1(Nf,Of)}async function f3(s){if(!Yf||!U){K((Cf)=>({...Cf,error:"请先选择 run 和 node",message:""}));return}let Nf=s==="append"?"prompts":s,Of=s==="append"?W.appendPrompt:s==="guide"?W.guidePrompt:s==="modify"?W.modifyPrompt:s==="approve"?W.approveReason:W.redoReason;if(!String(Of||"").trim()){K((Cf)=>({...Cf,error:"操作内容不能为空",message:""}));return}K((Cf)=>({...Cf,actionLoading:s,error:"",message:""}));try{let Cf=s==="redo"||s==="approve"?{reason:Of,source:"unidesk-frontend",sourceKind:"webui"}:{prompt:Of,source:"unidesk-frontend",sourceKind:"webui"},_0=await a_(E6(_,`/api/node-control/runs/${encodeURIComponent(Yf)}/nodes/${encodeURIComponent(U)}/${Nf}`),{method:"POST",body:JSON.stringify(Cf)});if(K((G0)=>({...G0,actionLoading:"",details:_0,fetchedAt:new Date,appendPrompt:s==="append"?"":G0.appendPrompt,guidePrompt:s==="guide"?"":G0.guidePrompt,modifyPrompt:s==="modify"?"":G0.modifyPrompt,approveReason:s==="approve"?"":G0.approveReason,redoReason:s==="redo"?"":G0.redoReason,message:s==="append"?"已追加到运行中 node":s==="guide"?"已下发 guide,等待 runner 处理":s==="modify"?"已排队增量修改命令":s==="approve"?"已提交审核通过决策":"已排队重做命令"})),await ef(Yf,U),await d0(Yf,{silent:!0}),s!=="append")await r()}catch(Cf){K((_0)=>({..._0,actionLoading:"",error:Tf(Cf,"node 控制操作失败")}))}}if(!y)return X(cu,{title:"Pipeline 未登记",text:"请在 config.json 的 microservices 中登记用户服务 id=pipeline"});return X("div",{className:"pipeline-page","data-testid":"pipeline-page"},X(I1,{title:"Pipeline v2 工作台",eyebrow:"D601 Snapshot 用户服务",actions:X("div",{className:"panel-actions"},X("button",{type:"button",className:"ghost-btn",onClick:r,disabled:l.loading,"data-testid":"pipeline-refresh-button"},l.loading?"刷新中":"刷新"),X(X1,{title:"Pipeline 用户服务",data:y,onOpen:u,testId:"raw-pipeline-service"}))},X("div",{className:"pipeline-hero"},X("div",null,X("div",{className:"node-version-line"},X(uy,{status:Y.providerStatus==="online"?"online":"warn"},Y.providerStatus||"unknown"),X("span",null,y.providerId),X("span",null,m.public?"公网暴露":"仅 UniDesk frontend 代理访问")),X("p",{className:"muted paragraph"},y.description)),X("div",{className:"microservice-ref-card"},X("span",null,"Repo"),X("strong",null,v.url||"--"),X("code",null,v.commitId||"--")),X("div",{className:"microservice-ref-card"},X("span",null,"D601 Docker"),X("strong",null,`${m.nodeBindHost||"--"}:${m.nodePort||"--"}`),X("code",null,`${v.composeFile||"--"} / ${v.composeService||"--"}`))),X(H0,{error:l.error,wide:!0})),X("div",{className:"pipeline-grid"},X(I1,{title:"控制图",eyebrow:`${Wf.id||"pipeline"} / run ${Qf?.status||"--"}`,className:"pipeline-wide-panel",actions:X("div",{className:"pipeline-toolbar"},X("select",{value:Vf,disabled:lf.length===0,onChange:(s)=>{J(s.target.value),Q(""),z(""),K(Q_()),G(d_()),N(!1),R(!1)},"data-testid":"pipeline-select"},lf.map((s)=>X("option",{key:s.id,value:s.id},s.id||s.key))),X("select",{value:Yf,disabled:I.length===0,onChange:(s)=>{if(Q(s.target.value),K(Q_()),G(d_()),N(!1),R(!1),U)ef(s.target.value,U)},"data-testid":"pipeline-run-select"},I.map((s)=>X("option",{key:s.runId,value:s.runId},mF(I,s)))),X("button",{type:"button",className:"ghost-btn",disabled:x0.nodes.length===0,onClick:()=>hq(x0,`${Wf.id||"pipeline"}-${Qf?.runId||"snapshot"}`),"data-testid":"pipeline-export-graph"},"导出渲染图"),X("button",{type:"button",className:"ghost-btn",disabled:ku.every((s)=>s.flow.nodes.length===0),onClick:()=>GM(ku),"data-testid":"pipeline-export-all-graphs"},"批量导出"))},Kf.length===0?X(cu,{title:"暂无控制图",text:"等待 D601 pipeline backend 返回 config.nodes / config.edges"}):X("div",{className:`pipeline-control-shell ${M?"detail-open":"detail-collapsed"}`,"data-testid":"pipeline-control-shell","data-sidebar-open":M?"true":"false"},X("div",{className:"pipeline-flow-frame","data-testid":"pipeline-react-flow"},X(oZ,{nodes:x0.nodes,edges:x0.edges,nodeTypes:DT,edgeTypes:wT,fitView:!0,fitViewOptions:{padding:0.18},nodesDraggable:!1,nodesConnectable:!1,elementsSelectable:!0,minZoom:0.25,maxZoom:1.4,proOptions:{hideAttribution:!0},onNodeClick:(s,Nf)=>{let Of=String(Nf.id);if(z(Of),K(Q_()),N(!0),Yf)ef(Yf,Of)}},X(dZ,{gap:22,size:1,color:"rgba(215, 161, 58, 0.24)"}),X(fq,{showInteractive:!1})),!M?X("button",{type:"button",className:"pipeline-sidecar-tab right",disabled:!U,onClick:()=>N(!0),"data-testid":"pipeline-node-sidebar-toggle"},U?"展开 node 控制":"点击 node 展开控制"):null),M?X(bM,{activeRun:Qf,pipelineRuns:I,selectedRunId:F,onRunChange:(s)=>{if(Q(s),K(Q_()),G(d_()),U)ef(s,U)},selectedNodeId:U,selectedNodeConfig:xf,selectedNodeRuntime:tf,control:W,onControlChange:(s)=>K((Nf)=>({...Nf,...s})),onFetch:()=>ef(),onAction:f3,onRaw:u,onCollapse:()=>N(!1)}):null),X("div",{className:"pipeline-flow-summary"},X("span",null,`${x0.nodes.length} nodes`),X("span",null,`${x0.edges.length} edges`),X("span",null,`${lf.length} pipelines`),X("span",null,`source config+components(${n.length})`),X("span",null,`run ${Qf?.runId||"--"}`),X("span",null,`score ${pF(Qf)}`),X("span",null,U?`selected ${U}`:"click node to control"))),X(vM,{epochs:I,activeRun:Qf,activePipeline:Wf,pipelineNodes:Kf,pipelineEdges:h,selection:O,detailOpen:w,onDetailOpenChange:R,runDetails:H,nodeDetails:a0,nodeDetailsState:Bf,ganttScale:E,onGanttScaleChange:L,onIntervalSelect:iu,onMarkerSelect:ey,onRunChange:(s)=>{if(Q(s),K(Q_()),G(d_()),R(!1),U)ef(s,U)},onRaw:u}),X(I1,{title:"观测指标",eyebrow:l.refreshedAt?`Updated ${a5(l.refreshedAt)}`:"Snapshot"},X("div",{className:"metric-grid"},X(Hu,{label:"Health",value:l.health?.ok?"OK":"--",hint:l.health?.service||"D601 /health",tone:l.health?.ok?"ok":"warn"}),X(Hu,{label:"组件",value:D0,hint:"components registry",tone:c?.registry?.ok===!1?"warn":"ok"}),X(Hu,{label:"Pipeline",value:lf.length,hint:`${Kf.length} nodes / ${h.length} edges`}),X(Hu,{label:"运行记录",value:Fu,hint:`${j0.succeeded||0} succeeded / ${j0.running||0} running`}),X(Hu,{label:"OA 记录",value:Array.isArray(g?.submissions)?g.submissions.length:0,hint:g?.runId||"latest run"}),X(Hu,{label:"Procedure",value:Array.isArray(g?.procedureRuns)?g.procedureRuns.length:0,hint:g?.status||"no run"}),X(Hu,{label:"Score",value:pF(Qf),hint:Qf?.runId||"selected epoch",tone:dF(Qf)})),X("div",{className:"panel-actions inline-actions"},X(X1,{title:"Pipeline Snapshot",data:c,onOpen:u,testId:"raw-pipeline-snapshot"}))),X(I1,{title:"评分器",eyebrow:Qf?.runId||"selected epoch"},X(eT,{run:Qf,onRaw:u})),X(I1,{title:"MiniMax 限额",eyebrow:"model/minimax-m27 quota"},X(xM,{quota:ff,onRaw:u})),X(I1,{title:"OA 事件流",eyebrow:"100% event-driven diagnostics",className:"pipeline-wide-panel"},X(RM,{diagnostics:o,onRaw:u})),X(I1,{title:"组件矩阵",eyebrow:`${u0.length} classes`},u0.length===0?X(cu,{title:"暂无组件",text:"等待 D601 pipeline backend 返回 registry.components"}):X("div",{className:"component-strata"},u0.map((s)=>X("article",{key:s.name,className:"component-stratum"},X("span",null,s.name),X("strong",null,s.count)))),X("div",{className:"pipeline-component-list"},n.slice(0,12).map((s)=>X("span",{key:s.key,className:"data-chip"},X("b",null,s.componentClass||"--"),X("span",null,s.id||s.key||"--"))))),X(I1,{title:"Epoch 列表",eyebrow:`${I.length}/${Fu} preview`},I.length===0?X(cu,{title:"暂无运行记录",text:"当前 pipeline 在 .state/pipeline-runs 中还没有 epoch。"}):X("div",{className:"pipeline-run-list"},I.map((s)=>{let Nf=String(s?.runId||"")===Yf?Qf:s;return X("article",{key:s.runId,className:`pipeline-run-card ${String(s.runId||"")===Yf?"active":""}`,role:"button",tabIndex:0,onClick:()=>{Q(String(s.runId||"")),G(d_())},onKeyDown:(Of)=>{if(Of.key==="Enter"||Of.key===" ")Q(String(s.runId||"")),G(d_())}},X("div",{className:"node-card-head"},X("strong",null,mF(I,s)),X(uy,{status:s.status},s.status||"--")),X("div",{className:"docker-meta compact"},X("span",null,Nf?.pipelineId||"--"),X("span",null,`nodes ${Array.isArray(Nf?.nodes)?Nf.nodes.length:0}`),X("span",null,`oa ${Array.isArray(Nf?.submissions)?Nf.submissions.length:0}`),X("span",null,`procedures ${Array.isArray(Nf?.procedureRuns)?Nf.procedureRuns.length:0}`),X(dT,{run:Nf})),X("p",{className:"muted paragraph"},k5(Nf?.task)),X("span",{className:"pipeline-run-time"},z0(Nf?.updatedAt)))}))),X(I1,{title:"运行材料索引",eyebrow:Qf?.runId||"selected epoch",className:"pipeline-wide-panel"},X(CM,{activeRun:Qf,onRaw:u}))))}var y2=Sf(I0(),1);var e=y2.default.createElement,{useEffect:hM}=y2.default,u2=y2.default.useState,_A={id:"",sequenceNo:"",contractNo:"",name:"",currentStatus:"",pending:"",paymentStatus:"",notes:""};function IM(f){return f.toLocaleTimeString("zh-CN",{hour12:!1})}function cM({status:f,children:u}){let _=String(f||"unknown").toLowerCase();return e("span",{className:`status-badge ${_}`},u||f||"unknown")}function _2({label:f,value:u,hint:_,tone:y}){return e("article",{className:`metric-card ${y||""}`},e("div",{className:"metric-label"},f),e("div",{className:"metric-value"},u),e("div",{className:"metric-hint"},_))}function yA({title:f,eyebrow:u,actions:_,children:y,className:l}){return e("section",{className:`panel ${l||""}`},e("div",{className:"panel-head"},e("div",null,u?e("p",{className:"panel-eyebrow"},u):null,e("h2",null,f)),_?e("div",{className:"panel-actions"},_):null),e("div",{className:"panel-body"},y))}function sq({title:f,data:u,onOpen:_,testId:y}){return e("button",{type:"button",className:"ghost-btn","data-testid":y,onClick:()=>_(f,u)},"查看原始JSON")}function oq({title:f,text:u}){return e("div",{className:"empty-state"},e("strong",null,f),e("span",null,u))}function pM(f){return f?.runtime&&typeof f.runtime==="object"&&!Array.isArray(f.runtime)?f.runtime:{}}function mM(f){return f?.backend&&typeof f.backend==="object"&&!Array.isArray(f.backend)?f.backend:{}}function kM(f){return f?.repository&&typeof f.repository==="object"&&!Array.isArray(f.repository)?f.repository:{}}function zl(f,u){return`${f}/microservices/project-manager/proxy${u}`}function iM(f){return{id:String(f.id||""),sequenceNo:f.sequenceNo===null||f.sequenceNo===void 0?"":String(f.sequenceNo),contractNo:String(f.contractNo||""),name:String(f.name||""),currentStatus:String(f.currentStatus||""),pending:String(f.pending||""),paymentStatus:String(f.paymentStatus||""),notes:String(f.notes||"")}}function gM(f){return{sequenceNo:f.sequenceNo===""?null:Number(f.sequenceNo),contractNo:String(f.contractNo||"").trim(),name:String(f.name||"").trim(),currentStatus:String(f.currentStatus||"").trim(),pending:String(f.pending||"").trim(),paymentStatus:String(f.paymentStatus||"").trim(),paymentRatio:String(f.paymentStatus||"").trim(),notes:String(f.notes||"").trim()}}function lA(f){return String(f||"item").replace(/[^A-Za-z0-9_-]+/g,"-")}function nM(f){let u=new Uint8Array(f),_="",y=32768;for(let l=0;le("tr",{key:l.id,className:u===l.id?"active-row":"","data-testid":`project-manager-row-${lA(l.id)}`},e("td",null,l.sequenceNo??"--"),e("td",null,e("strong",null,l.contractNo||"--"),e("code",null,l.id||"--")),e("td",null,e("strong",null,l.name||"--"),e("span",{className:"muted block"},l.sourceFile||"--")),e("td",null,l.currentStatus||"--"),e("td",null,e("span",{className:"preline"},l.pending||"--")),e("td",null,e(cM,{status:Number(l.paymentRatio||0)>=1?"online":"warn"},l.paymentStatus||"--")),e("td",null,l.notes||"--"),e("td",null,e("div",{className:"inline-actions"},e("button",{type:"button",className:"ghost-btn",onClick:()=>_(l),"data-testid":`project-manager-edit-${lA(l.id)}`},"编辑"),e(sq,{title:`Project ${l.contractNo||l.id}`,data:l,onOpen:y,testId:`raw-project-${lA(l.id)}`}))))))))}function aq({microservices:f,onRaw:u,apiBaseUrl:_="/api"}){let y=f.find((N)=>N.id==="project-manager")||null,[l,$]=u2({loading:!1,saving:!1,importing:!1,exporting:!1,error:"",notice:"",health:null,list:null,refreshedAt:null}),[j,J]=u2({..._A}),[F,Q]=u2(""),[U,z]=u2("all");async function W(N=F,w=U){if(!y)return;$((R)=>({...R,loading:!0,error:""}));try{let R=new URLSearchParams({pageSize:"200",status:w});if(N.trim())R.set("q",N.trim());let[p,x]=await Promise.all([Df(`${_}/microservices/project-manager/health`),Df(zl(_,`/api/projects?${R.toString()}`))]);$((C)=>({...C,loading:!1,health:p,list:x,refreshedAt:new Date,error:""}))}catch(R){$((p)=>({...p,loading:!1,error:Tf(R,"Project Manager 加载失败")}))}}hM(()=>{W()},[y?.id,y?.runtime?.providerStatus]);async function K(N){N.preventDefault(),$((w)=>({...w,saving:!0,error:"",notice:""}));try{let w=gM(j);if(j.id)await Df(zl(_,`/api/projects/${encodeURIComponent(j.id)}`),{method:"PUT",body:JSON.stringify(w)});else await Df(zl(_,"/api/projects"),{method:"POST",body:JSON.stringify(w)});$((R)=>({...R,saving:!1,notice:j.id?"项目已更新":"项目已创建"})),await W()}catch(w){$((R)=>({...R,saving:!1,error:Tf(w,"保存项目失败")}))}}async function q(){if(!j.id)return;if(!window.confirm(`删除项目 ${j.contractNo||j.name||j.id} ?`))return;$((N)=>({...N,saving:!0,error:"",notice:""}));try{await Df(zl(_,`/api/projects/${encodeURIComponent(j.id)}`),{method:"DELETE"}),J({..._A}),$((N)=>({...N,saving:!1,notice:"项目已删除"})),await W()}catch(N){$((w)=>({...w,saving:!1,error:Tf(N,"删除项目失败")}))}}async function V(N){let w=N.target.files?.[0];if(!w)return;$((R)=>({...R,importing:!0,error:"",notice:""}));try{let R=nM(await w.arrayBuffer()),p=await Df(zl(_,"/api/import/excel"),{method:"POST",body:JSON.stringify({fileName:w.name,contentBase64:R,replace:!1})});$((x)=>({...x,importing:!1,notice:`Excel 已导入 ${p.imported||0} 条项目`})),N.target.value="",await W()}catch(R){$((p)=>({...p,importing:!1,error:Tf(R,"Excel 导入失败")}))}}async function O(){$((N)=>({...N,exporting:!0,error:""}));try{let N=await eW(zl(_,"/api/projects/export.xlsx")),w=URL.createObjectURL(N),R=document.createElement("a");R.href=w,R.download=`project-manager-${new Date().toISOString().slice(0,10)}.xlsx`,document.body.appendChild(R),R.click(),R.remove(),URL.revokeObjectURL(w),$((p)=>({...p,exporting:!1,notice:"Excel 已导出"}))}catch(N){$((w)=>({...w,exporting:!1,error:Tf(N,"Excel 导出失败")}))}}if(!y)return e(oq,{title:"Project Manager 未登记",text:"请在 config.json 的 microservices 中登记用户服务 id=project-manager"});let G=pM(y),H=kM(y),Z=mM(y),E=Array.isArray(l.list?.projects)?l.list.projects:[],L=l.list?.summary||{},M=l.health||{};return e("div",{className:"project-manager-page","data-testid":"project-manager-page"},e(yA,{title:"项目管理工作台",eyebrow:"Main Server PostgreSQL 用户服务",actions:e("div",{className:"panel-actions"},e("button",{type:"button",className:"ghost-btn",disabled:l.loading,onClick:()=>W(),"data-testid":"project-manager-refresh-button"},l.loading?"刷新中":"刷新"),e("button",{type:"button",className:"ghost-btn",disabled:l.exporting,onClick:O,"data-testid":"project-manager-export-button"},l.exporting?"导出中":"导出 Excel"),e(sq,{title:"Project Manager 用户服务",data:y,onOpen:u,testId:"raw-project-manager-service"}))},e("div",{className:"project-manager-hero"},e(_2,{label:"项目总数",value:L.total??E.length,hint:`PG 表 ${M.storage?.table||"project_manager_projects"}`,tone:"ok"}),e(_2,{label:"进行中",value:L.active??"--",hint:"当前状态未完全完成"}),e(_2,{label:"已完成",value:L.completed??"--",hint:"按 完成 关键字统计",tone:"ok"}),e(_2,{label:"未全款",value:L.unpaid??"--",hint:"付款比例 < 1",tone:Number(L.unpaid||0)>0?"warn":"ok"})),e(H0,{error:l.error}),l.notice?e("div",{className:"form-success"},l.notice):null),e("div",{className:"project-manager-hero"},e("div",{className:"microservice-ref-card"},e("span",null,"Repo"),e("strong",null,H.url||"--"),e("code",null,H.commitId||"--")),e("div",{className:"microservice-ref-card"},e("span",null,"Main Server Docker"),e("strong",null,`${Z.nodeBindHost||"--"}:${Z.nodePort||"--"}`),e("code",null,`${H.composeService||"--"} / ${H.containerName||"--"}`)),e("div",{className:"microservice-ref-card"},e("span",null,"Runtime"),e("strong",null,G.providerName||y.providerId),e("code",null,`Health ${M.ok?"OK":"--"} / ${l.refreshedAt?IM(l.refreshedAt):"--"}`)),e("div",{className:"microservice-ref-card"},e("span",null,"Import Source"),e("strong",null,"D601 WeChat Excel"),e("code",null,"合作项目列表_I_20260309.xlsx"))),e("div",{className:"project-manager-layout"},e(yA,{title:"项目清单",eyebrow:"CRUD + Excel Export",actions:e("div",{className:"inline-actions project-manager-filters"},e("input",{value:F,onChange:(N)=>Q(N.target.value),placeholder:"搜索合同号 / 项目名称 / 状态","data-testid":"project-manager-search"}),e("select",{value:U,onChange:(N)=>{z(N.target.value),W(F,N.target.value)},"data-testid":"project-manager-status-filter"},e("option",{value:"all"},"全部"),e("option",{value:"active"},"进行中"),e("option",{value:"completed"},"已完成"),e("option",{value:"unpaid"},"未全款")),e("button",{type:"button",className:"ghost-btn",onClick:()=>W(F,U)},"筛选"))},e(tM,{projects:E,activeId:j.id,onSelect:(N)=>J(iM(N)),onRaw:u})),e(yA,{title:j.id?"编辑项目":"新建项目",eyebrow:"PostgreSQL Write Path"},e("form",{className:"stack-form project-manager-form",onSubmit:K,"data-testid":"project-manager-form"},j.id?e("label",null,"项目 ID",e("input",{value:j.id,disabled:!0})):null,e("label",null,"序号",e("input",{type:"number",value:j.sequenceNo,onChange:(N)=>J((w)=>({...w,sequenceNo:N.target.value}))})),e("label",null,"合同号",e("input",{value:j.contractNo,onChange:(N)=>J((w)=>({...w,contractNo:N.target.value})),required:!0})),e("label",null,"项目名称",e("input",{value:j.name,onChange:(N)=>J((w)=>({...w,name:N.target.value})),required:!0})),e("label",null,"当前状况",e("textarea",{value:j.currentStatus,onChange:(N)=>J((w)=>({...w,currentStatus:N.target.value}))})),e("label",null,"待完成",e("textarea",{value:j.pending,onChange:(N)=>J((w)=>({...w,pending:N.target.value}))})),e("label",null,"付款情况",e("input",{value:j.paymentStatus,onChange:(N)=>J((w)=>({...w,paymentStatus:N.target.value})),placeholder:"例如 1 / 0.5 / 50%"})),e("label",null,"其它",e("input",{value:j.notes,onChange:(N)=>J((w)=>({...w,notes:N.target.value}))})),e("div",{className:"inline-actions"},e("button",{type:"submit",className:"primary-btn",disabled:l.saving,"data-testid":"project-manager-save-button"},l.saving?"保存中":j.id?"保存修改":"创建项目"),e("button",{type:"button",className:"ghost-btn",onClick:()=>J({..._A})},"清空"),j.id?e("button",{type:"button",className:"danger-btn",disabled:l.saving,onClick:q,"data-testid":"project-manager-delete-button"},"删除"):null)),e("div",{className:"project-manager-import"},e("p",{className:"muted paragraph"},"浏览器只访问 UniDesk frontend;后端通过同源用户服务代理写入主 PostgreSQL,不暴露 4233 公网端口。"),e("label",{className:"file-import"},l.importing?"导入中...":"导入 Excel",e("input",{type:"file",accept:".xlsx",onChange:V,disabled:l.importing,"data-testid":"project-manager-import-input"}))))))}var J2=Sf(I0(),1);var _f=J2.default.createElement,{useEffect:sM}=J2.default,Du=J2.default.useState;function dq(f){if(!f)return"--";let u=new Date(f);if(Number.isNaN(u.getTime()))return"--";return u.toLocaleString("zh-CN",{hour12:!1})}function oM(f){return f.toLocaleTimeString("zh-CN",{hour12:!1})}function aM({status:f,children:u}){let _=String(f||"unknown").toLowerCase();return _f("span",{className:`status-badge ${_}`},u||f||"unknown")}function l2({label:f,value:u,hint:_,tone:y}){return _f("article",{className:`metric-card ${y||""}`},_f("div",{className:"metric-label"},f),_f("div",{className:"metric-value"},u),_f("div",{className:"metric-hint"},_))}function $A({title:f,eyebrow:u,actions:_,children:y,className:l}){return _f("section",{className:`panel ${l||""}`},_f("div",{className:"panel-head"},_f("div",null,u?_f("p",{className:"panel-eyebrow"},u):null,_f("h2",null,f)),_?_f("div",{className:"panel-actions"},_):null),_f("div",{className:"panel-body"},y))}function eq({title:f,data:u,onOpen:_,testId:y}){return _f("button",{type:"button",className:"ghost-btn","data-testid":y,onClick:()=>_(f,u)},"查看原始JSON")}function $2({title:f,text:u}){return _f("div",{className:"empty-state"},_f("strong",null,f),_f("span",null,u))}function dM(f){return f?.runtime&&typeof f.runtime==="object"&&!Array.isArray(f.runtime)?f.runtime:{}}function eM(f){return f?.backend&&typeof f.backend==="object"&&!Array.isArray(f.backend)?f.backend:{}}function fr(f){return f?.repository&&typeof f.repository==="object"&&!Array.isArray(f.repository)?f.repository:{}}function uH(f){return String(f).replace(/[^a-zA-Z0-9_-]/g,"_")}function ur(f){if(!Number.isFinite(f))return"--";return`${f.toFixed(1)}%`}function Gl(f,u){return`${f}/microservices/todo-note/proxy${u}`}function _H(f){return f.reduce((u,_)=>{let y=_H(Array.isArray(_.children)?_.children:[]),l=Boolean(_.completed);return{total:u.total+1+y.total,completed:u.completed+(l?1:0)+y.completed,active:u.active+(l?0:1)+y.active}},{total:0,completed:0,active:0})}function j2(f,u){let _=u==="all"||(u==="completed"?Boolean(f.completed):!f.completed),y=Array.isArray(f.children)?f.children:[];return _||y.some((l)=>j2(l,u))}function _r(f){if(!f)return"";let u=new Date(f);if(Number.isNaN(u.getTime()))return"";return new Date(u.getTime()-u.getTimezoneOffset()*60000).toISOString().slice(0,16)}function yr(f){if(!f)return null;let u=new Date(f);return Number.isNaN(u.getTime())?null:u.toISOString()}function fH(f){return Array.isArray(f?.instances)?f.instances:[]}function yH({microservices:f,onRaw:u,apiBaseUrl:_="/api"}){let y=f.find((I)=>I.id==="todo-note")||null,[l,$]=Du(null),[j,J]=Du(null),[F,Q]=Du(""),[U,z]=Du(null),[W,K]=Du("all"),[q,V]=Du(13),[O,G]=Du(""),[H,Z]=Du(""),[E,L]=Du(""),[M,N]=Du(""),[w,R]=Du(""),[p,x]=Du(!1),[C,P]=Du(""),[D,T]=Du(null),S=fH(j),r=_H(Array.isArray(U?.todos)?U.todos:[]),Y=y?dM(y):{},v=y?fr(y):{},m=y?eM(y):{};async function c(I=F){let[yf,$f]=await Promise.all([Df(`${_}/microservices/todo-note/health`),Df(Gl(_,"/api/instances"))]);$(yf),J($f);let Qf=fH($f),Yf=Qf.some((xf)=>xf.id===I)?I:Qf[0]?.id||"";return Q(Yf),Yf}async function o(I=F){if(!I){z(null);return}let yf=await Df(Gl(_,`/api/instances/${encodeURIComponent(I)}`));z(yf)}async function ff(I=F){if(!y)return;x(!0),P("");try{let yf=await c(I);await o(yf),T(new Date)}catch(yf){P(Tf(yf,"Todo Note 加载失败"))}finally{x(!1)}}async function n(I){if(!F)return;P("");try{let yf=await Df(Gl(_,`/api/instances/${encodeURIComponent(F)}/actions`),{method:"POST",body:JSON.stringify({action:I})});z(yf),await c(F)}catch(yf){P(Tf(yf,"Todo 操作失败"))}}async function lf(I){I.preventDefault();let yf=O.trim();if(!yf)return;x(!0),P("");try{let $f=await Df(Gl(_,"/api/instances"),{method:"POST",body:JSON.stringify({name:yf})});G(""),await ff($f.id)}catch($f){P(Tf($f,"创建清单失败"))}finally{x(!1)}}async function Gf(I){if(!window.confirm("确认删除这个 Todo Note 清单?"))return;x(!0),P("");try{await Df(Gl(_,`/api/instances/${encodeURIComponent(I)}`),{method:"DELETE"}),await ff(F===I?"":F)}catch(yf){P(Tf(yf,"删除清单失败"))}finally{x(!1)}}async function zf(I){I.preventDefault();let yf=H.trim();if(!yf)return;Z(""),await n({type:"addTodo",title:yf})}async function jf(I){if(!F)return;P("");try{let yf=await Df(Gl(_,`/api/instances/${encodeURIComponent(F)}/${I}`),{method:"POST",body:JSON.stringify({})});z(yf),await c(F)}catch(yf){P(Tf(yf,`${I} 失败`))}}function Wf(I){L(I.id),N(String(I.title||""))}async function Vf(I){let yf=M.trim();if(L(""),N(""),yf)await n({type:"updateTodoTitle",todoId:I,title:yf})}async function Kf(I){let yf=window.prompt("新增子任务标题");if(yf&&yf.trim())await n({type:"addTodo",title:yf.trim(),parentId:I})}async function h(I,yf){if(!w)return;let $f={type:"moveTodo",todoId:w,targetIndex:yf};if(I)$f.targetParentId=I;R(""),await n($f)}if(sM(()=>{ff()},[y?.id,y?.runtime?.providerStatus]),!y)return _f($2,{title:"Todo Note 未登记",text:"请在 config.json 的 microservices 中登记用户服务 id=todo-note"});let g=S.find((I)=>I.id===F)||null;return _f("div",{className:"todo-note-page","data-testid":"todo-note-page"},_f($A,{title:"Todo Note 工作台",eyebrow:"Main Server 用户服务",actions:_f("div",{className:"panel-actions"},_f("button",{type:"button",className:"ghost-btn",disabled:p,onClick:()=>ff(F),"data-testid":"todo-note-refresh-button"},p?"刷新中":"刷新"),_f(eq,{title:"Todo Note 用户服务",data:y,onOpen:u,testId:"raw-todo-note-service"}))},_f("div",{className:"todo-note-hero"},_f("div",null,_f("div",{className:"node-version-line"},_f(aM,{status:Y.providerStatus==="online"?"online":"warn"},Y.providerStatus||"unknown"),_f("span",null,y.providerId),_f("span",null,m.public?"公网暴露":"仅 UniDesk frontend 代理访问"),_f("span",null,l?.ok?"Health OK":"Health --")),_f("p",{className:"muted paragraph"},y.description)),_f("div",{className:"microservice-ref-card"},_f("span",null,"Repo"),_f("strong",null,v.url||"--"),_f("code",null,v.commitId||"--")),_f("div",{className:"microservice-ref-card"},_f("span",null,"Main Server Docker"),_f("strong",null,`${m.nodeBindHost||"--"}:${m.nodePort||"--"}`),_f("code",null,`${v.composeService||"--"} / ${v.containerName||"--"}`))),_f(H0,{error:C,wide:!0})),_f("div",{className:"todo-note-layout"},_f($A,{title:"清单",eyebrow:`${S.length} Instances`,className:"todo-list-panel"},_f("form",{className:"todo-create-list",onSubmit:lf},_f("input",{placeholder:"新清单名称",value:O,onChange:(I)=>G(I.target.value),"aria-label":"新清单名称"}),_f("button",{type:"submit",className:"ghost-btn",disabled:p||!O.trim()},"创建")),S.length===0?_f($2,{title:"暂无清单",text:"迁移或创建清单后会出现在这里"}):_f("div",{className:"todo-instance-list"},S.map((I)=>_f("button",{key:I.id,type:"button",className:`todo-instance-row ${F===I.id?"active":""}`,onClick:()=>{Q(I.id),o(I.id)},"data-testid":`todo-instance-${uH(I.id)}`},_f("strong",null,I.name),_f("span",null,`${I.completedCount??0}/${I.todoCount??0} 完成`),_f("code",null,I.id))))),_f("div",{className:"todo-main-stack"},_f($A,{title:g?.name||"待选择清单",eyebrow:D?`Updated ${oM(D)}`:"Todo Tree",actions:U?_f("div",{className:"panel-actions"},_f("button",{type:"button",className:"ghost-btn",onClick:()=>n({type:"renameInstance",name:window.prompt("清单新名称",U.name)||U.name})},"重命名"),_f("button",{type:"button",className:"ghost-btn danger",onClick:()=>Gf(F)},"删除清单"),_f(eq,{title:`Todo Instance ${F}`,data:U,onOpen:u,testId:"raw-todo-instance"})):null},!U?_f($2,{title:"未选择清单",text:"左侧选择一个 Todo Note 清单"}):_f("div",{className:"todo-workbench",style:{"--todo-font-size":`${q}px`}},_f("div",{className:"todo-toolbar"},_f("form",{className:"todo-add-form",onSubmit:zf},_f("input",{placeholder:"新增根任务",value:H,onChange:(I)=>Z(I.target.value),"aria-label":"新增根任务"}),_f("button",{type:"submit",className:"ghost-btn",disabled:!H.trim()},"新增")),_f("div",{className:"todo-filter-strip"},["all","active","completed"].map((I)=>_f("button",{key:I,type:"button",className:`todo-filter ${W===I?"active":""}`,onClick:()=>K(I)},I==="all"?"全部":I==="active"?"未完成":"已完成"))),_f("div",{className:"todo-toolbar-actions"},_f("button",{type:"button",className:"ghost-btn",onClick:()=>n({type:"setAllTodosExpanded",expanded:!0})},"全部展开"),_f("button",{type:"button",className:"ghost-btn",onClick:()=>n({type:"setAllTodosExpanded",expanded:!1})},"全部收起"),_f("button",{type:"button",className:"ghost-btn",onClick:()=>jf("undo")},"撤销"),_f("button",{type:"button",className:"ghost-btn",onClick:()=>jf("redo")},"重做"),_f("label",{className:"todo-font-control"},"字号",_f("input",{type:"range",min:11,max:18,value:q,onChange:(I)=>V(Number(I.target.value))})))),_f("div",{className:"todo-stats-grid"},_f(l2,{label:"总任务",value:r.total,hint:`${S.length} lists`}),_f(l2,{label:"已完成",value:r.completed,hint:`${ur(r.total?r.completed/r.total*100:0)}`,tone:"ok"}),_f(l2,{label:"未完成",value:r.active,hint:W==="active"?"当前筛选":"active tasks",tone:r.active>0?"warn":"ok"}),_f(l2,{label:"历史指针",value:U.historyPointer??0,hint:"undo / redo"})),_f("div",{className:"todo-root-drop",onDragOver:(I)=>I.preventDefault(),onDrop:(I)=>{I.preventDefault(),h(null,(U.todos||[]).length)}},"拖到这里可移为根任务末尾"),_f("div",{className:"todo-tree","data-testid":"todo-note-tree"},(U.todos||[]).filter((I)=>j2(I,W)).length===0?_f($2,{title:"没有匹配任务",text:"调整筛选或新增任务"}):(U.todos||[]).filter((I)=>j2(I,W)).map((I,yf)=>_f(lH,{key:I.id,todo:I,depth:0,parentId:null,index:yf,siblingCount:U.todos.length,filter:W,editingId:E,editingTitle:M,setEditingTitle:N,beginEdit:Wf,saveEdit:Vf,applyTodoAction:n,addChild:Kf,dragTodoId:w,setDragTodoId:R,dropTodo:h}))))))))}function lH(f){let{todo:u,depth:_,parentId:y,index:l,siblingCount:$,filter:j,editingId:J,editingTitle:F,setEditingTitle:Q,beginEdit:U,saveEdit:z,applyTodoAction:W,addChild:K,dragTodoId:q,setDragTodoId:V,dropTodo:O}=f,G=Array.isArray(u.children)?u.children:[],H=G.filter((L)=>j2(L,j)),Z=J===u.id,E=y||null;return _f("div",{className:"todo-row-wrap"},_f("article",{className:`todo-row ${u.completed?"completed":""} ${q===u.id?"dragging":""}`,style:{"--todo-depth":_},draggable:!0,onDragStart:(L)=>{V(u.id),L.dataTransfer.effectAllowed="move"},onDragOver:(L)=>L.preventDefault(),onDrop:(L)=>{L.preventDefault(),O(u.id,G.length)},"data-testid":`todo-row-${uH(u.id)}`},_f("button",{type:"button",className:"todo-expand",disabled:G.length===0,onClick:()=>W({type:"toggleTodoExpanded",todoId:u.id})},G.length===0?"·":u.expanded?"▾":"▸"),_f("input",{type:"checkbox",checked:Boolean(u.completed),onChange:()=>W({type:"toggleTodoCompleted",todoId:u.id}),"aria-label":`完成 ${u.title}`}),_f("div",{className:"todo-title-cell",onDoubleClick:()=>U(u)},Z?_f("div",{className:"todo-edit-inline"},_f("input",{value:F,autoFocus:!0,onChange:(L)=>Q(L.target.value),onKeyDown:(L)=>{if(L.key==="Enter")z(u.id);if(L.key==="Escape")U({id:"",title:""})}}),_f("button",{type:"button",className:"ghost-btn",onClick:()=>z(u.id)},"保存")):_f("strong",null,u.title||"Untitled"),_f("div",{className:"todo-meta-line"},_f("span",null,`子项 ${G.length}`),_f("span",null,`更新 ${dq(u.updatedAt)}`),u.reminderAt?_f("span",{className:"todo-reminder"},`提醒 ${dq(u.reminderAt)}`):_f("span",null,"无提醒"))),_f("input",{className:"todo-reminder-input",type:"datetime-local",value:_r(u.reminderAt),onChange:(L)=>W({type:"setTodoReminder",todoId:u.id,reminderAt:yr(L.target.value)})}),_f("div",{className:"todo-row-actions"},_f("button",{type:"button",className:"ghost-btn",onClick:()=>U(u)},"编辑"),_f("button",{type:"button",className:"ghost-btn",onClick:()=>K(u.id)},"子项"),_f("button",{type:"button",className:"ghost-btn",disabled:l<=0,onClick:()=>W({type:"moveTodo",todoId:u.id,...E?{targetParentId:E}:{},targetIndex:l-1})},"上移"),_f("button",{type:"button",className:"ghost-btn",disabled:l>=$-1,onClick:()=>W({type:"moveTodo",todoId:u.id,...E?{targetParentId:E}:{},targetIndex:l+1})},"下移"),_f("button",{type:"button",className:"ghost-btn",disabled:!y,onClick:()=>W({type:"moveTodo",todoId:u.id,targetIndex:9999})},"提升"),_f("button",{type:"button",className:"ghost-btn danger",onClick:()=>W({type:"deleteTodo",todoId:u.id})},"删除"))),u.expanded&&H.length>0?_f("div",{className:"todo-children"},H.map((L,M)=>_f(lH,{key:L.id,todo:L,depth:_+1,parentId:u.id,index:M,siblingCount:G.length,filter:j,editingId:J,editingTitle:F,setEditingTitle:Q,beginEdit:U,saveEdit:z,applyTodoAction:W,addChild:K,dragTodoId:q,setDragTodoId:V,dropTodo:O}))):null)}var $H=Sf(I0(),1),yy=$H.default.createElement;function jH({title:f,items:u,actions:_,className:y,testId:l}){let $=Array.isArray(u)?u:[];return yy("section",{className:`top-status-bar ${y||""}`,"data-testid":l},yy("div",{className:"top-status-main"},f?yy("strong",{className:"top-status-title"},f):null,yy("div",{className:"top-status-chips"},$.map((j,J)=>yy("span",{key:j?.key||`${j?.label||"status"}-${J}`,className:`top-status-chip ${j?.tone||""}`,"data-testid":j?.testId},j?.label?yy("b",null,j.label):null,yy("span",null,j?.value??"--"))))),_?yy("div",{className:"top-status-actions"},_):null)}function ZH(f,u){let _=document.getElementById("root")?.getAttribute(f);if(!_)return u;try{let y=JSON.parse(_);return typeof y==="object"&&y!==null&&!Array.isArray(y)?y:u}catch{return u}}var sf=ZH("data-config",{apiBaseUrl:"/api",authUsername:"admin"}),lr=ZH("data-codex-overview",null),A=Q2.default.createElement,{useEffect:p1,useMemo:S6}=Q2.default,If=Q2.default.useState,mu=yG(b4),$r={id:"codex-queue",name:"Codex Queue",providerId:"main-server",description:"Codex Queue",repository:{containerName:"codex-queue-backend"},backend:{nodeBaseUrl:"http://codex-queue:4222",nodeBindHost:"codex-queue",nodePort:4222,public:!1},runtime:{providerStatus:"loading",providerName:"main-server"}};function m0(f){if(!f)return"--";let u=new Date(f);if(Number.isNaN(u.getTime()))return"--";return u.toLocaleString("zh-CN",{hour12:!1})}function F2(f){return f.toLocaleTimeString("zh-CN",{hour12:!1})}function ql(f){if(!Number.isFinite(f))return"--";let u=Math.max(0,f);if(u===0)return"0s";if(u<0.01)return"<0.01s";if(u<0.1)return`${u.toFixed(2)}s`;if(u<1)return`${u.toFixed(1)}s`;if(u<10&&!Number.isInteger(u))return`${u.toFixed(1)}s`;if(u<60)return`${Math.round(u)}s`;let _=Math.floor(u);if(_<3600)return`${Math.floor(_/60)}m ${_%60}s`;return`${Math.floor(_/3600)}h ${Math.floor(_%3600/60)}m`}function pu(f){let u=Number(f);if(!Number.isFinite(u))return"--";if(u<1)return`${Math.max(0,u).toFixed(1)}ms`;if(u<10)return`${u.toFixed(1)}ms`;if(u<1000)return`${Math.round(u)}ms`;return ql(u/1000)}function Ju(f){let u=Number(f);if(!Number.isFinite(u)||u<=0)return"--";let _=["B","KB","MB","GB","TB"],y=u,l=0;while(y>=1024&&l<_.length-1)y/=1024,l+=1;return`${y.toFixed(l===0?0:1)} ${_[l]}`}function ly(f){let u=Number(f);return Number.isFinite(u)?`${Math.max(0,Math.min(100,u)).toFixed(1)}%`:"--"}function jr(f){let u=Number(f);return Number.isFinite(u)?`${Math.max(0,u).toFixed(1)}%`:"--"}function jA(f){let u=Number(f);if(!Number.isFinite(u)||u<=0)return"0 B/s";return`${Ju(u)}/s`}function Mf(f,u=0){let _=Number(f);return Number.isFinite(_)?_:u}function Hl(f){return["queued","dispatched","running"].includes(String(f?.status||"").toLowerCase())}function AA(f){if(!f)return"--";let u=new Date(f);if(Number.isNaN(u.getTime()))return"--";return ql(Math.max(0,Math.floor((Date.now()-u.getTime())/1000)))}function Kl(f){if(!f)return null;let u=new Date(f);return Number.isNaN(u.getTime())?null:u.getTime()}function qH(f){let u=Kl(f?.createdAt);if(u===null)return null;let y=["succeeded","failed"].includes(String(f?.status||"").toLowerCase())?Kl(f?.updatedAt):Date.now();if(y===null)return null;return Math.max(0,(y-u)/1000)}function HH(f){if(String(f?.status||"").toLowerCase()!=="failed")return"";let u=f?.result;if(typeof u==="string")return u;if(u&&typeof u==="object"&&!Array.isArray(u)){let _=u;for(let y of["error","reason","message","stderr","detail"])if(typeof _[y]==="string"&&_[y].length>0)return _[y]}return"任务失败但 provider 未返回明确原因"}function ay(f){if(f===null||f===void 0)return"--";if(typeof f==="boolean")return f?"是":"否";if(typeof f==="number")return String(f);if(typeof f==="string")return f.length>80?`${f.slice(0,77)}...`:f;if(Array.isArray(f))return`${f.length} 项`;if(typeof f==="object")return`${Object.keys(f).length} 字段`;return String(f)}function Jr(f,u){if(f==="bodyText"&&typeof u==="string")return`${/^\s*[{[]/.test(u)?"JSON":"HTTP"} body ${u.length} chars`;return ay(u)}function VH(f){if(!f||typeof f!=="object"||Array.isArray(f))return[];return Object.entries(f)}function N1(f){return String(f).replace(/[^a-zA-Z0-9_-]/g,"_")}function QA(f,u){return f&&typeof f==="object"&&!Array.isArray(f)?f[u]:void 0}function U2(f,u,_="未知"){let y=QA(f?.labels,u);return typeof y==="string"&&y.length>0?y:_}function EH(f){return U2(f,"providerGatewayVersion")}function r6(f){return U2(f,"providerGatewayUpgradePolicy")}function JH(f){return U2(f,"providerGatewayStartedAt","")}function OH(f){let u=QA(f?.labels,"unideskCapabilities");if(typeof u==="string")return u.split(",").map((_)=>_.trim()).filter(Boolean);return Array.isArray(u)?u.filter((_)=>typeof _==="string"):[]}function XH(f,u){return OH(f).includes(u)}function FH(f,u){let _=QA(f?.labels,u);return _===!0||_==="true"||_==="1"}function Fr(f){if(!XH(f,"host.ssh"))return{tone:"fail",label:"不可用",detail:"未声明 host.ssh"};if(!FH(f,"hostSshConfigured"))return{tone:"warn",label:"未配置",detail:"缺少 SSH 环境变量"};if(!FH(f,"hostSshKeyPresent"))return{tone:"warn",label:"缺 key",detail:"私钥未挂载"};return{tone:"ok",label:"可用",detail:U2(f,"hostSshTarget","host.ssh ready")}}function Ar(f){if(!XH(f,"provider.upgrade"))return{tone:"fail",label:"不可用",detail:"未声明 provider.upgrade"};let u=r6(f);if(u!=="always-enabled")return{tone:"warn",label:"待确认",detail:`策略 ${u}`};return{tone:"ok",label:"可用",detail:"always-enabled"}}function UA(f){let u=typeof f==="string"&&f.length>0?f:"未知";if(u==="未知")return"版本未知";return u.startsWith("v")?u:`v${u}`}function NH(f){return f?.payload&&typeof f.payload==="object"&&!Array.isArray(f.payload)?f.payload:{}}function W2(f){return f?.result&&typeof f.result==="object"&&!Array.isArray(f.result)?f.result:{}}function A2(f){let u=NH(f),_=W2(f);return(u.mode??_.mode)==="schedule"?"schedule":"plan"}function Qr(f){let u=NH(f).source;return typeof u==="string"&&u.length>0?u:"unknown"}function Ur(f){let u=W2(f),y=(u.plan&&typeof u.plan==="object"&&!Array.isArray(u.plan)?u.plan:{}).policy;return typeof y==="string"&&y.length>0?y:"--"}function LH(f){let u=W2(f),_=u.plan&&typeof u.plan==="object"&&!Array.isArray(u.plan)?u.plan:{},y=u.targetProviderGatewayVersion??u.providerGatewayVersion??_.targetProviderGatewayVersion??_.providerGatewayVersion;return typeof y==="string"&&y.length>0?UA(y):"版本未知"}function YH(f){if(String(f?.status||"").toLowerCase()==="failed")return HH(f);if(Hl(f))return"等待 provider 回传升级终态";let _=W2(f);if(typeof _.updaterContainerId==="string"&&_.updaterContainerId.length>0)return`updater ${_.updaterContainerId.slice(0,18)}`;if(typeof _.message==="string"&&_.message.length>0)return _.message;if(_.plan)return"升级计划已生成";return"无升级结果摘要"}function BH(f,u){return f.filter((_)=>_?.providerId===u&&_?.command==="provider.upgrade").sort((_,y)=>(Kl(y.updatedAt)??0)-(Kl(_.updatedAt)??0))}function Wr(f){return f.find((u)=>A2(u)==="schedule")||f[0]||null}function wH(f){return f?.runtime&&typeof f.runtime==="object"&&!Array.isArray(f.runtime)?f.runtime:{}}function AH(f){return f?.backend&&typeof f.backend==="object"&&!Array.isArray(f.backend)?f.backend:{}}function zr(f){return f?.repository&&typeof f.repository==="object"&&!Array.isArray(f.repository)?f.repository:{}}function E0({status:f,children:u}){let _=String(f||"unknown").toLowerCase();return A("span",{className:`status-badge ${_}`},u||f||"unknown")}function f0({label:f,value:u,hint:_,tone:y,onClick:l,testId:$}){let j=typeof l==="function";return A("article",{className:`metric-card ${y||""} ${j?"clickable":""}`,role:j?"button":void 0,tabIndex:j?0:void 0,"data-testid":$,onClick:l,onKeyDown:j?(J)=>{if(J.key==="Enter"||J.key===" ")J.preventDefault(),l()}:void 0},A("div",{className:"metric-label"},f),A("div",{className:"metric-value"},u),A("div",{className:"metric-hint"},_))}function kf({title:f,eyebrow:u,actions:_,children:y,className:l}){return A("section",{className:`panel ${l||""}`},A("div",{className:"panel-head"},A("div",null,u?A("p",{className:"panel-eyebrow"},u):null,A("h2",null,f)),_?A("div",{className:"panel-actions"},_):null),A("div",{className:"panel-body"},y))}function k0({title:f,data:u,onOpen:_,testId:y}){return A("button",{type:"button",className:"ghost-btn","data-testid":y,onClick:()=>_(f,u)},"查看原始JSON")}function Gr({raw:f,onClose:u}){if(!f)return null;return A("div",{className:"modal-backdrop",role:"presentation"},A("section",{className:"raw-dialog",role:"dialog","aria-modal":"true","aria-label":f.title},A("div",{className:"raw-dialog-head"},A("h2",null,f.title),A("button",{type:"button",className:"ghost-btn",onClick:u},"关闭")),A("pre",{className:"raw-json","data-testid":"raw-json"},JSON.stringify(f.data,null,2))))}function DH({labels:f,limit:u=8}){let _=VH(f).slice(0,u);if(_.length===0)return A("span",{className:"muted"},"无标签");return A("div",{className:"chip-row"},_.map(([y,l])=>A("span",{key:y,className:"data-chip"},A("b",null,y),A("span",null,ay(l)))))}function Zl({node:f}){let u=EH(f);return A("span",{className:`version-chip ${u==="未知"?"unknown":""}`,"data-testid":`gateway-version-${N1(f?.providerId||"unknown")}`},UA(u))}function QH({title:f,state:u,testId:_}){return A("span",{className:`capability-badge ${u.tone}`,title:u.detail,"data-testid":_},A("b",null,f),A("strong",null,u.label),A("small",null,u.detail))}function WA({node:f}){let u=N1(f?.providerId||"unknown");return A("div",{className:"node-availability-strip"},A(QH,{title:"SSH 透传",state:Fr(f),testId:`ssh-availability-${u}`}),A(QH,{title:"远程更新",state:Ar(f),testId:`upgrade-availability-${u}`}))}function dy({data:f,empty:u="无数据"}){if(f===null||f===void 0)return A("span",{className:"muted"},u);if(typeof f!=="object")return A("span",{className:"summary-value"},ay(f));if(Array.isArray(f))return A("span",{className:"summary-value"},`${f.length} 项列表`);let _=Object.entries(f).slice(0,5);if(_.length===0)return A("span",{className:"muted"},u);return A("div",{className:"summary-grid"},_.map(([y,l])=>A("span",{key:y,className:"summary-item"},A("b",null,y),A("span",null,Jr(y,l)))))}function $0({title:f,text:u}){return A("div",{className:"empty-state"},A("strong",null,f),A("span",null,u))}function Kr({onLogin:f}){let[u,_]=If(sf.authUsername||"admin"),[y,l]=If(""),[$,j]=If(""),[J,F]=If(!1);async function Q(U){U.preventDefault(),F(!0),j("");try{let z=await Df("/login",{method:"POST",body:JSON.stringify({username:u,password:y})});f(z)}catch(z){j(Tf(z,"登录失败"))}finally{F(!1)}}return A("main",{className:"login-screen","data-testid":"login-screen"},A("section",{className:"login-card"},A("div",{className:"login-brand"},A("span",{className:"brand-mark"},"UD"),A("div",null,A("h1",null,"UniDesk"),A("p",null,"Control Plane Login"))),A("form",{className:"login-form",onSubmit:Q},A("label",null,"账号",A("input",{name:"username",autoComplete:"username",value:u,onChange:(U)=>_(U.target.value)})),A("label",null,"密码",A("input",{name:"password",type:"password",autoComplete:"current-password",value:y,onChange:(U)=>l(U.target.value)})),A(H0,{error:$}),A("button",{type:"submit",disabled:J},J?"登录中":"登录")),A("div",{className:"login-note"},"默认账号由 config.json 注入;公网入口只暴露前端登录面。")))}function Zr({connection:f,lastRefresh:u,onRefresh:_,onLogout:y,session:l,clock:$,activeStatusItems:j=[]}){let J=[{key:"core",label:"核心",value:f.text,tone:f.ok?"ok":"fail",testId:"conn-text"},...Array.isArray(j)?j:[],{key:"refresh",label:"刷新",value:u?F2(u):"未刷新"},{key:"clock",label:"时间",value:F2($)},{key:"user",label:"用户",value:l?.user?.username||"--",tone:"user"}];return A("header",{className:"topbar"},A("div",null,A("p",{className:"eyebrow"},"Distributed Work Platform"),A("h1",null,"UniDesk 控制平面")),A(jH,{className:"global-top-status",title:"状态",items:J,actions:[A("button",{key:"refresh",type:"button",className:"ghost-btn",onClick:_},"刷新"),A("button",{key:"logout",type:"button",className:"ghost-btn danger",onClick:y},"退出")]}))}function qr({activeModule:f,activeTabs:u,onNavigate:_,collapsed:y,onToggle:l}){return A("aside",{className:`rail ${y?"collapsed":""}`,"aria-label":"主模块"},A("div",{className:"brand"},A("span",{className:"brand-mark"},"UD"),A("span",{className:"brand-text"},"UniDesk"),A("button",{type:"button",className:"rail-toggle",onClick:l,"aria-label":y?"展开左侧边栏":"收起左侧边栏","data-testid":"rail-toggle"},y?"»":"«")),b4.map(($)=>A("button",{key:$.id,type:"button",className:`module ${f===$.id?"active":""}`,onClick:()=>_($.id,u[$.id]||p3[$.id]||$.tabs[0]?.id||""),title:$.label,"data-route":I$(mu,$.id,u[$.id]||p3[$.id]||$.tabs[0]?.id||"")},A("span",{className:"module-code"},$.code),A("span",null,$.label))))}function Hr({module:f,activeTab:u,onNavigate:_}){return A("nav",{className:"tabs","aria-label":`${f.label} 子功能`},f.tabs.map((y)=>A("button",{key:y.id,type:"button",className:`tab ${u===y.id?"active":""}`,onClick:()=>_(f.id,y.id),"data-route":I$(mu,f.id,y.id)},y.label)))}function Vr({data:f,onRaw:u,onNavigate:_}){let y=f.overview||{},l=f.nodes.filter((Q)=>Q.status==="online"),$=f.pendingTasks||f.tasks.filter(Hl),j=y.pendingTaskCount??$.length,J=f.tasks.slice(0,5),F=y.pgdata||{};return A("div",{className:"page-grid overview-grid","data-testid":"overview-page"},A(kf,{title:"核心指标",eyebrow:"Control"},A("div",{className:"metric-grid"},A(f0,{label:"数据库",value:y.dbReady?"READY":"WAIT",hint:"PostgreSQL internal network",tone:y.dbReady?"ok":"warn"}),A(f0,{label:"PGDATA",value:Ju(F.databaseBytes),hint:`${F.volumeName||"unidesk_pgdata_10gb"} / ${F.databasePretty||"--"}`,tone:"ok",testId:"pgdata-usage-card"}),A(f0,{label:"在线节点",value:y.onlineNodeCount??0,hint:`${y.nodeCount??0} registered`,tone:"ok"}),A(f0,{label:"WebSocket",value:y.activeSocketCount??0,hint:"Provider ingress sockets"}),A(f0,{label:"待处理任务",value:j,hint:j>0?"点击查看具体任务":`timeout ${ql(Math.floor((y.taskPendingTimeoutMs??0)/1000))}`,tone:j>0?"warn":"ok",onClick:()=>_("tasks","pending"),testId:"pending-task-card"}))),A(kf,{title:"本机 Provider",eyebrow:"Self Connected"},l.length===0?A($0,{title:"暂无在线节点",text:"provider-gateway 未完成自接入"}):A("div",{className:"node-card-list"},l.slice(0,4).map((Q)=>A(Er,{key:Q.providerId,node:Q,onRaw:u})))),A(kf,{title:"待处理任务明细",eyebrow:`${j} Pending`,actions:A("button",{type:"button",className:"ghost-btn",onClick:()=>_("tasks","pending"),"data-testid":"pending-task-detail-link"},"进入任务调度")},$.length===0?A($0,{title:"当前无待处理",text:"queued / dispatched / running 超时后会自动转为 failed,避免总览长期卡住"}):A("div",{className:"compact-list"},$.slice(0,5).map((Q)=>A(GH,{key:Q.id,task:Q,onRaw:u})))),A(kf,{title:"最近任务",eyebrow:"Dispatch"},J.length===0?A($0,{title:"暂无任务",text:"可以在任务调度模块发起 docker.ps 或 echo"}):A("div",{className:"compact-list"},J.map((Q)=>A(GH,{key:Q.id,task:Q,onRaw:u})))))}function Er({node:f,onRaw:u}){return A("article",{className:"node-card"},A("div",{className:"node-card-head"},A("div",null,A("strong",null,f.name),A("code",null,f.providerId)),A(E0,{status:f.status})),A("div",{className:"node-version-line"},A(Zl,{node:f}),A("span",null,`升级策略 ${r6(f)}`)),A(WA,{node:f}),A(DH,{labels:f.labels,limit:6}),A("div",{className:"node-card-foot"},A("span",null,`心跳 ${m0(f.lastHeartbeat)}`),A(k0,{title:`Provider ${f.providerId}`,data:f,onOpen:u,testId:`raw-node-${N1(f.providerId)}`})))}function Or({events:f,onRaw:u}){return A(kf,{title:"事件摘要",eyebrow:"Latest 100"},f.length===0?A($0,{title:"暂无事件",text:"Provider 注册、心跳超时和任务状态会写入事件流"}):A("div",{className:"table-wrap"},A("table",null,A("thead",null,A("tr",null,A("th",null,"ID"),A("th",null,"类型"),A("th",null,"来源"),A("th",null,"摘要"),A("th",null,"时间"),A("th",null,"操作"))),A("tbody",null,f.map((_)=>A("tr",{key:_.id},A("td",null,A("code",null,_.id)),A("td",null,A(E0,{status:_.type},_.type)),A("td",null,A("code",null,_.source)),A("td",null,A(dy,{data:_.payload})),A("td",null,m0(_.createdAt)),A("td",null,A(k0,{title:`Event ${_.id}`,data:_,onOpen:u}))))))))}function Xr({logs:f,onRaw:u}){return A(kf,{title:"服务日志",eyebrow:"Core Recent"},f.length===0?A($0,{title:"暂无日志",text:"backend-core 内存日志会在请求和 provider 事件后出现"}):A("div",{className:"log-list"},f.slice(-80).reverse().map((_,y)=>A("article",{key:y,className:`log-row ${_.level||"info"}`},A("span",null,m0(_.ts)),A("b",null,_.level||"info"),A("strong",null,_.message||"log"),A(dy,{data:_.data,empty:"无附加字段"}),A(k0,{title:`Log ${_.message||y}`,data:_,onOpen:u})))))}function Nr({nodes:f,onRaw:u}){return A(kf,{title:"节点清单",eyebrow:`${f.length} Providers`},f.length===0?A($0,{title:"暂无 Provider 节点",text:"确认 provider-gateway 已连接 provider ingress"}):A("div",{className:"table-wrap"},A("table",{className:"node-list-table"},A("thead",null,A("tr",null,A("th",null,"状态"),A("th",null,"Provider"),A("th",null,"网关版本"),A("th",null,"运维可用性"),A("th",null,"资源标签"),A("th",null,"连接时间"),A("th",null,"最后心跳"),A("th",null,"操作"))),A("tbody",null,f.map((_)=>A("tr",{key:_.providerId},A("td",null,A(E0,{status:_.status})),A("td",null,A("strong",null,_.name),A("code",null,_.providerId)),A("td",null,A("div",{className:"gateway-cell"},A(Zl,{node:_}),A("span",null,r6(_)))),A("td",null,A(WA,{node:_})),A("td",null,A(DH,{labels:_.labels,limit:5})),A("td",null,m0(_.connectedAt)),A("td",null,m0(_.lastHeartbeat)),A("td",null,A(k0,{title:`Provider ${_.providerId}`,data:_,onOpen:u,testId:`raw-node-table-${N1(_.providerId)}`}))))))))}function Lr({nodes:f}){let u=S6(()=>{let _=[];for(let y of f)for(let[l,$]of VH(y.labels))_.push({providerId:y.providerId,name:y.name,key:l,value:$});return _},[f]);return A(kf,{title:"资源标签",eyebrow:"Structured Labels"},u.length===0?A($0,{title:"暂无标签",text:"provider-gateway 注册消息会同步资源标签"}):A("div",{className:"label-matrix"},u.map((_)=>A("article",{key:`${_.providerId}-${_.key}`,className:"label-card"},A("span",null,_.key),A("strong",null,ay(_.value)),A("code",null,_.providerId)))))}function Yr({nodes:f}){return A(kf,{title:"心跳状态",eyebrow:"Provider Liveness"},f.length===0?A($0,{title:"无心跳",text:"等待 provider 注册和 heartbeat"}):A("div",{className:"heartbeat-list"},f.map((u)=>A("article",{key:u.providerId,className:"heartbeat-row"},A("span",{className:`pulse ${u.status}`}),A("div",null,A("strong",null,u.name),A("code",null,u.providerId)),A("div",null,A("span",null,"connected"),A("b",null,m0(u.connectedAt))),A("div",null,A("span",null,"last heartbeat"),A("b",null,m0(u.lastHeartbeat)))))))}function Br({nodes:f,systemStatuses:u,tasks:_,onRaw:y,refresh:l}){let[$,j]=If(""),J=S6(()=>f.map((V)=>{let O=u.find((G)=>G.providerId===V.providerId);return{...V,systemCurrent:O?.current||null,systemHistory:O?.history||[],systemUpdatedAt:O?.updatedAt||null}}),[f,u]),F=J.find((V)=>V.providerId===$)||J[0]||null;if(p1(()=>{if(!$&&J[0])j(J[0].providerId)},[J.length,$]),!F)return A($0,{title:"暂无资源监控",text:"等待 provider 上报 CPU、内存和硬盘指标"});let Q=F.systemCurrent,U=F.systemHistory||[],z=Q?.cpu||{},W=Q?.memory||{},K=Q?.disk||{},q=U.length>0?U:Q?[{at:Q.collectedAt,cpuPercent:Mf(z.percent),memoryPercent:Mf(W.percent),diskPercent:Mf(K.percent)}]:[];return A("div",{className:"monitor-page","data-testid":"node-monitor-page"},A("div",{className:"docker-node-strip"},J.map((V)=>A("button",{key:V.providerId,type:"button",className:`docker-node-tile ${F.providerId===V.providerId?"active":""}`,onClick:()=>j(V.providerId)},A("span",{className:`pulse ${V.status}`}),A("strong",null,V.name),A("code",null,V.providerId),A("span",null,V.systemCurrent?`CPU ${ly(V.systemCurrent.cpu?.percent)} / MEM ${ly(V.systemCurrent.memory?.percent)}`:"等待指标")))),A("div",{className:"monitor-layout"},A(kf,{title:"任务管理器视图",eyebrow:F.name,className:"monitor-main-panel",actions:Q?A(k0,{title:`System ${F.providerId}`,data:{current:Q,history:U},onOpen:y}):null},!Q?A($0,{title:"系统指标未上报",text:"provider-gateway 会周期性采集 /proc 与 df,并保存历史曲线"}):A("div",null,A("div",{className:"monitor-hero"},A("div",null,A("p",{className:"panel-eyebrow"},"Node Performance"),A("h3",null,F.name),A("div",{className:"docker-meta"},A("span",null,`${z.cores||0} CPU cores`),A("span",null,`load ${Mf(z.load1).toFixed(2)} / ${Mf(z.load5).toFixed(2)} / ${Mf(z.load15).toFixed(2)}`),A("span",null,`memory actual ${Ju(W.usedBytes)} / ${Ju(W.totalBytes)}`),A("span",null,`disk ${Ju(K.usedBytes)} / ${Ju(K.totalBytes)}`))),A(E0,{status:Q.ok?"online":"warn"},Q.ok?"METRICS READY":"METRICS DEGRADED")),A("div",{className:"monitor-chart-grid"},A(JA,{title:"CPU",metricKey:"cpuPercent",current:z.percent,points:q,detail:`${z.cores||0} cores / load ${Mf(z.load1).toFixed(2)}`,tone:"cpu",testId:"metric-chart-cpu"}),A(JA,{title:"Memory",metricKey:"memoryPercent",current:W.percent,points:q,detail:`${Ju(W.usedBytes)} actual / ${Ju(W.cacheBytes)} cache excluded`,tone:"memory",testId:"metric-chart-memory"}),A(JA,{title:"Disk",metricKey:"diskPercent",current:K.percent,points:q,detail:`${K.path||"/"} mounted ${K.mount||"--"}`,tone:"disk",testId:"metric-chart-disk"})),A("div",{className:"monitor-summary-grid"},A(f0,{label:"CPU 当前",value:ly(z.percent),hint:`history ${q.length} samples`,tone:"ok"}),A(f0,{label:"实际内存",value:Ju(W.usedBytes),hint:`${ly(W.percent)} 不含缓存`}),A(f0,{label:"硬盘已用",value:Ju(K.usedBytes),hint:ly(K.percent)}),A(f0,{label:"更新时间",value:m0(F.systemUpdatedAt||Q.collectedAt),hint:F.providerId})),A(wr,{current:Q,onRaw:y}))),A("div",{className:"monitor-side-stack"},A(Rr,{provider:F,refresh:l,onRaw:y}),A(xr,{provider:F,tasks:_,onRaw:y,limit:5}),A(kf,{title:"采样说明",eyebrow:"Retention"},A("div",{className:"monitor-note-list"},A("article",null,A("b",null,"CPU"),A("span",null,"从 /proc/stat 计算相邻采样差值,首个采样用 load/cores 近似")),A("article",null,A("b",null,"Memory"),A("span",null,"实际内存 = MemTotal - MemFree - Buffers - Cached - SReclaimable + Shmem,不把 page cache / buffer 计入占用")),A("article",null,A("b",null,"Disk"),A("span",null,"使用 df -PB1 对配置路径采样,默认监控根文件系统")),A("article",null,A("b",null,"Process"),A("span",null,"从 /proc/[pid] 采集进程 CPU、实际内存 RSS、线程数和磁盘 I/O 速率;表格默认按内存占用降序")))))))}function UH(f,u){if(u==="memory")return Mf(f.rssBytes);if(u==="cpu")return Mf(f.cpuPercent);if(u==="disk")return Mf(f.readBytesPerSecond)+Mf(f.writeBytesPerSecond);if(u==="pid")return Mf(f.pid);if(u==="threads")return Mf(f.threads);if(u==="runtime")return Mf(f.elapsedSeconds);if(u==="user")return String(f.user||"");return String(f.name||f.command||"")}function WH({value:f,label:u,tone:_}){let y=Math.max(1,Math.min(100,Mf(f)));return A("div",{className:`process-meter ${_||""}`},A("span",{style:{width:`${y}%`}}),A("b",null,u))}function wr({current:f,onRaw:u}){let[_,y]=If({key:"memory",direction:"desc"}),l=f?.processSummary&&typeof f.processSummary==="object"?f.processSummary:{},$=Array.isArray(f?.processes)?f.processes:[],j=S6(()=>{let F=_.direction==="asc"?1:-1;return[...$].sort((Q,U)=>{let z=UH(Q,_.key),W=UH(U,_.key);if(typeof z==="string"||typeof W==="string")return String(z).localeCompare(String(W),"zh-CN")*F;return(z-W)*F||Mf(Q.pid)-Mf(U.pid)})},[$,_.key,_.direction]),J=(F,Q)=>{let U=_.key===Q,z=U?_.direction==="asc"?"ascending":"descending":"none";return A("th",{"aria-sort":z},A("button",{type:"button",className:`process-sort-button ${U?"active":""}`,"data-testid":`process-sort-${Q}`,onClick:()=>y((W)=>({key:Q,direction:W.key===Q&&W.direction==="desc"?"asc":"desc"}))},F,A("span",null,U?_.direction==="desc"?"↓":"↑":"↕")))};return A("section",{className:"process-resource-panel","data-testid":"process-resource-panel"},A("div",{className:"process-resource-head"},A("div",null,A("p",{className:"panel-eyebrow"},"Windows Resource Monitor Style"),A("h3",null,"进程资源占用")),A("div",{className:"process-resource-actions"},A("span",{className:"data-chip"},"默认按内存排序"),A("span",{className:"data-chip"},`${Mf(l.visible,j.length)} / ${Mf(l.total,j.length)} 进程`),A(k0,{title:"Process Resource Snapshot",data:{processSummary:l,processes:$},onOpen:u,testId:"raw-process-resources"}))),j.length===0?A($0,{title:"暂无进程资源数据",text:"等待 provider-gateway 上报 /proc/[pid] 采样;旧版 provider 需要先升级到支持进程资源表的版本"}):A("div",{className:"process-table-wrap"},A("table",{className:"process-resource-table","data-testid":"process-resource-table"},A("thead",null,A("tr",null,J("进程","name"),J("PID","pid"),J("用户","user"),A("th",null,"状态"),J("CPU","cpu"),J("内存","memory"),A("th",null,"RSS"),J("磁盘 I/O","disk"),J("线程","threads"),J("运行时长","runtime"))),A("tbody",null,j.map((F)=>{let Q=Mf(F.readBytesPerSecond)+Mf(F.writeBytesPerSecond);return A("tr",{key:`${F.pid}-${F.startedAt}`,"data-testid":`process-row-${N1(F.pid)}`,"data-memory-bytes":String(Mf(F.rssBytes)),"data-cpu-percent":String(Mf(F.cpuPercent)),"data-disk-bps":String(Q),"data-pid":String(Mf(F.pid))},A("td",null,A("div",{className:"process-name-cell"},A("strong",null,F.name||"--"),A("span",{className:"process-command"},F.command||"--"))),A("td",null,A("code",null,F.pid||"--")),A("td",null,F.user||`uid:${F.uid??"--"}`),A("td",null,A("span",{className:`process-state state-${N1(F.state||"unknown")}`},F.state||"?")),A("td",null,A(WH,{value:F.cpuPercent,label:jr(F.cpuPercent),tone:"cpu"})),A("td",null,A(WH,{value:F.memoryPercent,label:ly(F.memoryPercent),tone:"memory"})),A("td",null,Ju(F.rssBytes)),A("td",null,A("div",{className:"process-io-cell"},A("strong",null,jA(Q)),A("span",null,`R ${jA(F.readBytesPerSecond)} / W ${jA(F.writeBytesPerSecond)}`))),A("td",null,F.threads||0),A("td",null,ql(Mf(F.elapsedSeconds))))})))))}function JA({title:f,metricKey:u,current:_,points:y,detail:l,tone:$,testId:j}){let J=y.map((W)=>Math.max(0,Math.min(100,Mf(W[u])))),F=J.length>1?J:[J[0]||0,J[0]||0],Q=F.length<=1?100:100/(F.length-1),U=F.map((W,K)=>`${(K*Q).toFixed(2)},${(46-W*0.42).toFixed(2)}`).join(" "),z=`0,48 ${U} 100,48`;return A("article",{className:`metric-chart ${$}`,"data-testid":j},A("div",{className:"metric-chart-head"},A("div",null,A("span",null,f),A("strong",null,ly(_))),A("code",null,`${y.length} pts`)),A("svg",{viewBox:"0 0 100 48",preserveAspectRatio:"none",role:"img","aria-label":`${f} usage curve`},A("polygon",{points:z}),A("polyline",{points:U}),A("line",{x1:"0",x2:"100",y1:"24",y2:"24"})),A("div",{className:"metric-chart-foot"},A("span",null,"0%"),A("span",null,l),A("span",null,"100%")))}function U_(f){return Array.isArray(f)?f:[]}function Dr(f){let u=U_(f?.core?.requests?.componentSummary);return[...U_(f?.frontend?.requests?.componentSummary),...u].sort((y,l)=>Mf(l.requestCount)-Mf(y.requestCount))}function Tr(f){let u=U_(f?.core?.operations?.summary);return[...U_(f?.frontend?.operations?.summary),...u].sort((y,l)=>Mf(l.count)-Mf(y.count))}function Mr(f){let u=U_(f?.core?.requests?.recentFailures).map((y)=>({source:"backend",...y}));return[...U_(f?.frontend?.requests?.recentFailures).map((y)=>({source:"frontend",...y})),...u].sort((y,l)=>(Kl(l.at)??0)-(Kl(y.at)??0)).slice(0,20)}function rr(f){let u=U_(f?.core?.operations?.recentSlowOperations);return[...U_(f?.frontend?.operations?.recentSlowOperations),...u].sort((y,l)=>Mf(l.durationMs)-Mf(y.durationMs)).slice(0,20)}function Sr(f){let u=performance.memory,_=Number(u?.usedJSHeapSize);if(Number.isFinite(_)&&_>0)return _;let y=Number(f?.appBundleBytes);if(Number.isFinite(y)&&y>0)return y;return Mf(f?.process?.heapUsedBytes)}function Pr({points:f}){let u=U_(f),_=u.map((W)=>Mf(W.mb)),y=Math.max(1,..._),l=Math.max(0,Math.min(..._,0)),$=Math.max(1,y-l),j=u.length>1?u:[...u,...u],J=j.length<=1?100:100/(j.length-1),F=j.map((W,K)=>{let q=Mf(W.mb);return`${(K*J).toFixed(2)},${(48-(q-l)/$*42).toFixed(2)}`}).join(" "),Q=`0,50 ${F} 100,50`,U=u.at(-1),z=u[0];return A("article",{className:"performance-memory-card","data-testid":"performance-memory-chart"},A("div",{className:"performance-memory-head"},A("strong",null,`Bwebui: ${U?`${Mf(U.mb).toFixed(1)}MB`:"--"}`),A("span",null,u.length>0?`${u.length} samples`:"等待采样")),A("svg",{viewBox:"0 0 100 50",preserveAspectRatio:"none",role:"img","aria-label":"Bwebui memory trend"},A("polygon",{points:Q}),A("polyline",{points:F}),A("line",{x1:"0",x2:"100",y1:"25",y2:"25"})),A("div",{className:"performance-axis-row"},A("span",null,z?F2(new Date(z.at)):"--"),A("span",null,"时间"),A("span",null,U?F2(new Date(U.at)):"--")),A("div",{className:"performance-axis-row"},A("span",null,`${l.toFixed(1)}`),A("span",null,"(MB)"),A("span",null,`${y.toFixed(1)}`)))}function Cr({onRaw:f}){let[u,_]=If({core:null,frontend:null}),[y,l]=If([]),[$,j]=If(""),[J,F]=If(!1),[Q,U]=If(null),[z,W]=If(!1);async function K(){F(!0),j("");try{let[C,P]=await Promise.all([Df(`${sf.apiBaseUrl}/performance`,{cache:"no-store"}),Df(`${sf.apiBaseUrl}/frontend-performance`,{cache:"no-store"})]);_({core:C,frontend:P});let D=Sr(P);l((T)=>[...T,{at:new Date().toISOString(),mb:D/1048576}].slice(-80))}catch(C){j(Tf(C,"性能指标加载失败"))}finally{F(!1)}}p1(()=>{K();let C=setInterval(()=>void K(),5000);return()=>clearInterval(C)},[]);async function q(){W(!0),j(""),U(null);try{let C=await Df(`${sf.apiBaseUrl}/codex-queue-load-test`,{method:"POST",body:JSON.stringify({targetMs:1000,timeoutMs:90000,url:sf.frontendPublicUrl||window.location.origin})});U(C),K()}catch(C){j(Tf(C,"Codex Queue Playwright 测量失败"))}finally{W(!1)}}let V=Dr(u),O=Mr(u),G=Tr(u),H=rr(u),Z=u.core?.process||{},E=u.frontend?.process||{},L=u.core?.database?.codexQueueStorage||{},M=Mf(L.total),N=Q?.result||{},w=Mf(N.wallMs,NaN),R=Mf(N.networkIdleMs,NaN),p=N.withinTarget===!0,x=z?"running":Q===null?"idle":Q.measurementOk===!0?p?"passed":"slow":"failed";return A("div",{className:"performance-page","data-testid":"performance-page"},A("div",{className:"performance-hero"},A("div",null,A("p",{className:"panel-eyebrow"},"Unified Performance"),A("h2",null,"性能面板"),A("p",null,"按组件统计 HTTP 请求、失败率、P95 延迟,并汇总 backend/frontend 内部操作耗时。")),A("div",{className:"inline-actions"},A("button",{type:"button",className:"ghost-btn",onClick:()=>void q(),disabled:z,"data-testid":"codex-queue-load-test-button"},z?"测试中...":"测试 Codex Queue 加载"),A("button",{type:"button",className:"ghost-btn",onClick:()=>void K(),disabled:J,"data-testid":"performance-refresh-button"},J?"刷新中":"刷新"),A(k0,{title:"Performance Snapshot",data:u,onOpen:f,testId:"raw-performance"}))),A(H0,{error:$}),A("div",{className:"performance-top-grid"},A(Pr,{points:y}),A("div",{className:"performance-metric-stack"},A(f0,{label:"backend RSS",value:Ju(Z.rssBytes),hint:`heap ${Ju(Z.heapUsedBytes)}`}),A(f0,{label:"frontend RSS",value:Ju(E.rssBytes),hint:`bundle ${Ju(u.frontend?.appBundleBytes)}`}),A(f0,{label:"Codex PG 任务",value:M||"--",hint:L.ok?"unidesk_codex_queue_tasks":"等待表初始化",tone:L.ok?"ok":"warn"}),A(f0,{label:"请求样本",value:Mf(u.core?.requests?.sampleCount)+Mf(u.frontend?.requests?.sampleCount),hint:"rolling window 3000"}))),A(kf,{title:"Codex Queue 加载基准",eyebrow:"Playwright / target <1s",className:"codex-load-test-panel",actions:A("div",{className:"panel-actions"},A("button",{type:"button",className:"primary-btn",onClick:()=>void q(),disabled:z,"data-testid":"codex-queue-load-test-panel-button"},z?"正在运行 Playwright...":"手动触发测试"),Q?A(k0,{title:"Codex Queue Load Test",data:Q,onOpen:f,testId:"raw-codex-queue-load-test"}):null)},A("div",{className:"codex-load-test-grid","data-testid":"codex-queue-load-test-result"},A(f0,{label:"总耗时",value:z?"运行中":Number.isFinite(w)?pu(w):"--",hint:Q===null?"点击按钮启动远端 Playwright":`目标 ${pu(N.targetMs||1000)} / ${N.url||"Codex Queue"}`,tone:x==="passed"?"ok":x==="failed"||x==="slow"?"warn":""}),A(f0,{label:"判定",value:z?"RUNNING":x==="passed"?"PASS <1s":x==="slow"?"SLOW":x==="failed"?"FAILED":"--",hint:Q?.measurementOk===!1?String(Q.error||N.error||"measurement failed").slice(0,120):"导航开始 -> DOMContentLoaded -> data-load-state=complete",tone:x==="passed"?"ok":x==="idle"||x==="running"?"":"fail"}),A(f0,{label:"Network idle",value:Number.isFinite(R)?pu(R):"--",hint:`DOMContentLoaded ${pu(N.domContentLoadedMs)} / ${N.networkIdleReached===!1?"未在 5s 内空闲":"已空闲"}`,tone:Number.isFinite(R)&&R<=1000?"ok":"warn"}),A(f0,{label:"组件耗时",value:Number.isFinite(Mf(N.componentLoadMs,NaN))?pu(N.componentLoadMs):"--",hint:`queue ${pu(N.queueMs)} / detail ${pu(N.detailMs)}`,tone:Mf(N.componentLoadMs)>1000?"warn":"ok"}),A(f0,{label:"Trace 规模",value:Number.isFinite(Mf(N.transcriptRows,NaN))?String(N.transcriptRows):"--",hint:`${N.visibleTaskCount??0} visible tasks / ${N.partial?"preview":"complete"}`})),z?A("div",{className:"performance-empty-line"},"正在通过 main-server Host SSH 启动 Playwright,完成后会显示 wall time、组件耗时和最慢 API。"):null,Q&&Array.isArray(N.slowestApi)&&N.slowestApi.length>0?A("div",{className:"table-wrap performance-table-wrap compact codex-load-api-table"},A("table",{className:"performance-table"},A("thead",null,A("tr",null,["API","状态","耗时"].map((C)=>A("th",{key:C},C)))),A("tbody",null,N.slowestApi.slice(0,5).map((C,P)=>A("tr",{key:`${C.url}-${P}`},A("td",null,A("code",null,C.url)),A("td",null,C.status),A("td",null,pu(C.durationMs))))))):null),A("div",{className:"performance-grid"},A(kf,{title:"组件汇总",eyebrow:"Requests"},V.length===0?A($0,{title:"暂无请求样本",text:"刷新几次或打开页面后会自动形成组件统计"}):A("div",{className:"table-wrap performance-table-wrap"},A("table",{className:"performance-table"},A("thead",null,A("tr",null,["组件","请求数","失败数","失败率","平均延迟","P95"].map((C)=>A("th",{key:C},C)))),A("tbody",null,V.map((C)=>A("tr",{key:C.component},A("td",null,A("code",null,C.component)),A("td",null,C.requestCount),A("td",null,C.failureCount),A("td",null,ly(Mf(C.failureRate)*100)),A("td",null,pu(C.averageLatencyMs)),A("td",null,pu(C.p95LatencyMs)))))))),A(kf,{title:"最近失败请求",eyebrow:"Failures"},O.length===0?A("div",{className:"performance-empty-line"},"最近没有失败请求"):A("div",{className:"table-wrap performance-table-wrap compact"},A("table",{className:"performance-table"},A("thead",null,A("tr",null,["时间","来源","组件","状态","路径"].map((C)=>A("th",{key:C},C)))),A("tbody",null,O.map((C,P)=>A("tr",{key:`${C.at}-${P}`},A("td",null,m0(C.at)),A("td",null,C.source),A("td",null,A("code",null,C.component)),A("td",null,A(E0,{status:"failed"},C.status)),A("td",null,A("code",null,C.path)))))))),A(kf,{title:"内部操作汇总",eyebrow:"Operations"},G.length===0?A($0,{title:"暂无内部操作样本",text:"API 查询和代理请求会自动记录内部操作耗时"}):A("div",{className:"table-wrap performance-table-wrap"},A("table",{className:"performance-table"},A("thead",null,A("tr",null,["服务","操作","次数","平均延迟","P95"].map((C)=>A("th",{key:C},C)))),A("tbody",null,G.map((C)=>A("tr",{key:`${C.service}-${C.operation}`},A("td",null,C.service),A("td",null,A("code",null,C.operation)),A("td",null,C.count),A("td",null,pu(C.averageLatencyMs)),A("td",null,pu(C.p95LatencyMs)))))))),A(kf,{title:"最近慢操作",eyebrow:"Slowest"},H.length===0?A($0,{title:"暂无慢操作",text:"后端会记录最近窗口内耗时最高的内部操作"}):A("div",{className:"table-wrap performance-table-wrap"},A("table",{className:"performance-table"},A("thead",null,A("tr",null,["时间","操作","耗时","结果","细节"].map((C)=>A("th",{key:C},C)))),A("tbody",null,H.map((C,P)=>A("tr",{key:`${C.at}-${C.operation}-${P}`},A("td",null,m0(C.at)),A("td",null,A("code",null,C.operation)),A("td",null,pu(C.durationMs)),A("td",null,C.ok?"成功":"失败"),A("td",null,C.detail||"-")))))))))}function Rr({provider:f,refresh:u,onRaw:_}){let[y,l]=If(""),[$,j]=If(null),[J,F]=If("");async function Q(U){l(U),F("");try{let z=await Df(`${sf.apiBaseUrl}/dispatch`,{method:"POST",body:JSON.stringify({providerId:f.providerId,command:"provider.upgrade",payload:{mode:U,source:"frontend-resource-monitor",requestedAt:new Date().toISOString()}})});j({mode:U,...z}),await u()}catch(z){F(Tf(z,"升级命令下发失败"))}finally{l("")}}return A(kf,{title:"Provider Gateway 升级",eyebrow:"Remote Control"},A("div",{className:"upgrade-control","data-testid":"provider-upgrade-control"},A("p",null,"通过 UniDesk WebSocket 向当前计算节点下发 provider.upgrade;预检只生成升级计划,执行升级会调度节点本地 updater 容器。"),A("div",{className:"upgrade-target-line"},A("span",null,"指定 Provider"),A("code",null,f.providerId),A(Zl,{node:f})),A("div",{className:"upgrade-actions"},A("button",{type:"button",className:"ghost-btn",disabled:Boolean(y),onClick:()=>Q("plan"),"data-testid":"upgrade-plan-button"},y==="plan"?"预检中":"预检升级"),A("button",{type:"button",className:"ghost-btn danger",disabled:Boolean(y),onClick:()=>Q("schedule"),"data-testid":"upgrade-schedule-button"},y==="schedule"?"调度中":"执行升级")),A(H0,{error:J}),$?A("div",{className:"upgrade-result"},A(E0,{status:$.status||"queued"},$.status||"queued"),A("span",null,`${$.mode==="schedule"?"执行升级":"预检升级"} 已下发`),A("span",null,`指定版本 ${UA(EH(f))}`),A("code",null,$.taskId||"--"),A(k0,{title:"Provider Upgrade Dispatch",data:$,onOpen:_})):A("span",{className:"muted"},"升级任务结果会进入任务历史;执行升级可能导致 provider 短暂重连。")))}function TH({records:f,onRaw:u,compact:_=!1}){if(f.length===0)return A($0,{title:"暂无远程更新记录",text:"该节点还没有 provider.upgrade 任务;执行预检或升级后会在这里形成结构化记录"});return A("div",{className:`upgrade-record-table-wrap table-wrap ${_?"compact":""}`},A("table",{className:"upgrade-record-table"},A("thead",null,A("tr",null,A("th",null,"状态"),A("th",null,"模式"),A("th",null,"任务"),A("th",null,"来源"),A("th",null,"耗时"),A("th",null,"策略"),A("th",null,"Gateway 版本"),A("th",null,"结果记录"),A("th",null,"更新时间"),A("th",null,"操作"))),A("tbody",null,f.map((y)=>A("tr",{key:y.id,"data-testid":`gateway-upgrade-record-${N1(y.id)}`},A("td",null,A(E0,{status:y.status})),A("td",null,A("span",{className:`mode-chip ${A2(y)}`},A2(y)==="schedule"?"执行升级":"预检")),A("td",null,A("strong",null,"provider.upgrade"),A("code",null,y.id)),A("td",null,Qr(y)),A("td",null,A(rH,{task:y})),A("td",null,Ur(y)),A("td",null,A("span",{className:"version-chip"},LH(y))),A("td",null,A("span",{className:`upgrade-outcome ${String(y.status||"").toLowerCase()}`},YH(y))),A("td",null,m0(y.updatedAt)),A("td",null,A(k0,{title:`Provider Upgrade Task ${y.id}`,data:y,onOpen:u})))))))}function xr({provider:f,tasks:u,onRaw:_,limit:y=5}){let l=BH(u,f.providerId).slice(0,y);return A(kf,{title:"远程更新记录",eyebrow:f.providerId,actions:A(Zl,{node:f}),className:"provider-upgrade-records-panel"},A("div",{"data-testid":`provider-upgrade-records-${N1(f.providerId)}`},A(TH,{records:l,onRaw:_,compact:!0})))}function vr({nodes:f,tasks:u,onRaw:_}){let y=S6(()=>f.map(($)=>{let j=BH(u,$.providerId);return{node:$,records:j,latest:Wr(j),capabilities:OH($)}}),[f,u]),l=y.reduce(($,j)=>$+j.records.length,0);return A("div",{className:"gateway-page","data-testid":"gateway-version-page"},A(kf,{title:"Provider Gateway 版本",eyebrow:`${f.length} Providers / ${l} 更新记录`},f.length===0?A($0,{title:"暂无 Provider 节点",text:"等待 provider-gateway 注册后显示版本号和升级记录"}):A("div",{className:"table-wrap gateway-version-table-wrap"},A("table",{className:"gateway-version-table"},A("thead",null,A("tr",null,A("th",null,"状态"),A("th",null,"Provider"),A("th",null,"Gateway 版本"),A("th",null,"升级策略"),A("th",null,"运维可用性"),A("th",null,"运行时间"),A("th",null,"能力"),A("th",null,"最近远程更新"),A("th",null,"操作"))),A("tbody",null,y.map(($)=>A("tr",{key:$.node.providerId},A("td",null,A(E0,{status:$.node.status})),A("td",null,A("strong",null,$.node.name),A("code",null,$.node.providerId)),A("td",null,A(Zl,{node:$.node})),A("td",null,r6($.node)),A("td",null,A(WA,{node:$.node})),A("td",null,JH($.node)?m0(JH($.node)):"待新版上报"),A("td",null,A("div",{className:"capability-row"},$.capabilities.length===0?A("span",{className:"muted"},"未声明"):$.capabilities.slice(0,5).map((j)=>A("span",{key:j,className:"data-chip"},j)))),A("td",null,$.latest?A("div",{className:"latest-upgrade-cell"},A(E0,{status:$.latest.status}),A("span",null,`${A2($.latest)==="schedule"?"执行升级":"预检"} / ${m0($.latest.updatedAt)}`),A("small",null,`Gateway ${LH($.latest)}`),A("small",null,YH($.latest))):A("span",{className:"muted"},"暂无记录")),A("td",null,A(k0,{title:`Provider ${$.node.providerId}`,data:$.node,onOpen:_})))))))),A(kf,{title:"远程更新记录",eyebrow:"Structured provider.upgrade records"},f.length===0?A($0,{title:"暂无记录",text:"没有 provider 节点时不会生成远程更新记录"}):A("div",{className:"gateway-record-grid"},y.map(($)=>A("article",{key:$.node.providerId,className:"gateway-record-card","data-testid":`gateway-records-${N1($.node.providerId)}`},A("div",{className:"gateway-record-head"},A("div",null,A("strong",null,$.node.name),A("code",null,$.node.providerId)),A(Zl,{node:$.node})),A("div",{className:"gateway-record-meta"},A("span",null,`心跳 ${m0($.node.lastHeartbeat)}`),A("span",null,`策略 ${r6($.node)}`),A("span",null,`${$.records.length} 条记录`)),A(TH,{records:$.records.slice(0,8),onRaw:_,compact:!0}))))))}function br(f){if(f==="running")return"online";if(f==="paused"||f==="restarting")return"warn";if(f==="exited"||f==="dead")return"offline";return"internal"}function MH(f){return/^[a-f0-9]{48,64}$/i.test(f)}function M6(f){let u=String(f?.name||""),_=String(f?.labels||"");return u==="unidesk_pgdata_10gb"||_.includes("com.docker.compose.volume=unidesk_pgdata_10gb")||u.toLowerCase().includes("pgdata")}function zH(f){let u=String(f?.name||""),_=String(f?.labels||"");if(M6(f))return 0;if(_.includes("com.docker.compose.project=unidesk"))return 1;if(!MH(u))return 2;return 3}function hr(f){return[...f].sort((u,_)=>{let y=zH(u)-zH(_);if(y!==0)return y;return String(u.name||"").localeCompare(String(_.name||""))})}function Ir({nodes:f,dockerStatuses:u,onRaw:_}){let[y,l]=If(""),$=S6(()=>f.map((H)=>{let Z=u.find((E)=>E.providerId===H.providerId);return{...H,dockerStatus:Z?.dockerStatus||null,dockerUpdatedAt:Z?.updatedAt||null}}),[f,u]),j=$.find((H)=>H.providerId===y)||$[0]||null;if(p1(()=>{if(!y&&$[0])l($[0].providerId)},[$.length,y]),!j)return A($0,{title:"暂无 Docker 节点",text:"等待 provider 上报 Docker daemon 状态"});let J=j.dockerStatus,F=j.providerId==="main-server",Q=J?.counts||{},U=J?.daemon||{},z=J?.containers||[],W=J?.images||[],K=hr(J?.volumes||[]),q=F?K.find(M6):null,V=J?.networks||[],O=z.filter((H)=>H.state==="running"),G=z.filter((H)=>H.state!=="running");return A("div",{className:"docker-page","data-testid":"docker-status-page"},A("div",{className:"docker-node-strip"},$.map((H)=>A("button",{key:H.providerId,type:"button",className:`docker-node-tile ${j.providerId===H.providerId?"active":""}`,onClick:()=>l(H.providerId)},A("span",{className:`pulse ${H.status}`}),A("strong",null,H.name),A("code",null,H.providerId),A("span",null,H.dockerStatus?`Docker ${H.dockerStatus.ok?"ready":"degraded"}`:"等待上报")))),A("div",{className:"docker-layout"},A(kf,{title:"Docker Desktop 视图",eyebrow:j.name,className:"docker-main-panel",actions:J?A(k0,{title:`Docker ${j.providerId}`,data:J,onOpen:_}):null},!J?A($0,{title:"Docker 状态未上报",text:"provider-gateway 会在连接后周期性采集 docker info / ps / images / volume / network"}):A("div",null,A("div",{className:"docker-hero"},A("div",null,A("p",{className:"panel-eyebrow"},"Daemon"),A("h3",null,U.name||j.providerId),A("div",{className:"docker-meta"},A("span",null,U.serverVersion?`Engine ${U.serverVersion}`:"Engine --"),A("span",null,U.operatingSystem||"OS --"),A("span",null,U.architecture||"arch --"),A("span",null,`${U.cpus||0} CPU / ${Ju(U.memoryBytes)}`))),A(E0,{status:J.ok?"online":"warn"},J.ok?"Docker Ready":"Docker Degraded")),A("div",{className:"docker-metrics"},A(f0,{label:"Containers",value:Q.containers??z.length,hint:`${Q.running??O.length} running / ${Q.stopped??G.length} stopped`,tone:"ok"}),A(f0,{label:"Images",value:Q.images??W.length,hint:`${Q.daemonImages??Q.images??W.length} daemon images`}),A(f0,{label:"Volumes",value:Q.volumes??K.length,hint:F?q?"database volume visible":"database volume missing":"node local volumes",tone:q?"ok":""}),A(f0,{label:"Networks",value:Q.networks??V.length,hint:U.driver?`driver ${U.driver}`:"docker networks"})),F?A(cr,{volume:q,volumeCount:K.length}):null,A("div",{className:"docker-section-head"},A("h3",null,"Containers"),A("span",null,`updated ${m0(j.dockerUpdatedAt||J.collectedAt)}`)),A("div",{className:"docker-container-table table-wrap","data-testid":"docker-container-table"},A("table",null,A("thead",null,A("tr",null,A("th",null,"状态"),A("th",null,"容器"),A("th",null,"镜像"),A("th",null,"端口"),A("th",null,"运行时间"),A("th",null,"大小"))),A("tbody",null,z.length===0?A("tr",null,A("td",{colSpan:6},"暂无容器")):z.map((H)=>A("tr",{key:`${H.id}-${H.name}`},A("td",null,A(E0,{status:br(H.state)},H.state||"unknown")),A("td",null,A("strong",null,H.name||"--"),A("code",null,H.id||"--")),A("td",null,H.image||"--"),A("td",null,H.ports||A("span",{className:"muted"},"未发布")),A("td",null,H.runningFor||H.status||"--"),A("td",null,H.size||"--")))))))),A("div",{className:"docker-side-stack"},A(FA,{title:"Images",items:W,render:(H)=>A("article",{key:`${H.id}-${H.repository}`,className:"docker-side-row"},A("strong",null,`${H.repository}:${H.tag}`),A("span",null,H.size||"--"),A("code",null,H.id||"--"))}),A(FA,{title:"Volumes",items:K,limit:K.length,render:(H)=>A("article",{key:H.name,className:`docker-side-row volume-row ${F&&M6(H)?"database-volume":""}`,"data-testid":F&&M6(H)?"database-volume-row":void 0},A("strong",null,H.name),A("span",null,F&&M6(H)?"PostgreSQL":MH(String(H.name||""))?"anonymous":"named"),A("code",null,H.mountpoint||H.driver||H.scope||"--"))}),A(FA,{title:"Networks",items:V,render:(H)=>A("article",{key:H.id||H.name,className:"docker-side-row"},A("strong",null,H.name),A("span",null,H.driver||"--"),A("code",null,H.id||"--"))}))))}function cr({volume:f,volumeCount:u}){return A("section",{className:`docker-volume-focus ${f?"ready":"missing"}`,"data-testid":"database-volume-card"},A("div",{className:"volume-focus-head"},A("span",{className:"panel-eyebrow"},"Database Named Volume"),A(E0,{status:f?"online":"warn"},f?"FOUND":"MISSING")),f?A("div",{className:"volume-focus-body"},A("strong",null,f.name),A("span",null,"PostgreSQL data volume for unidesk-database"),A("div",{className:"volume-route"},A("code",null,f.mountpoint||"/var/lib/docker/volumes/unidesk_pgdata_10gb/_data"),A("span",null,"->"),A("code",null,"unidesk-database:/var/lib/postgresql/data")),A("div",{className:"docker-meta compact"},A("span",null,`driver ${f.driver||"--"}`),A("span",null,`scope ${f.scope||"--"}`),A("span",null,`${u} volumes reported`))):A("div",{className:"volume-focus-body"},A("strong",null,"unidesk_pgdata_10gb"),A("span",null,"当前 Docker 快照没有发现数据库命名卷;请检查 provider-gateway 的 Docker volume 上报。")))}function FA({title:f,items:u,render:_,limit:y}){let l=u.slice(0,y??12),$=Math.max(0,u.length-l.length);return A(kf,{title:f,eyebrow:`${u.length} items`,className:"docker-side-panel"},u.length===0?A($0,{title:`暂无 ${f}`,text:"等待 Docker 状态采集"}):A("div",{className:"docker-side-list"},l.map(_),$>0?A("div",{className:"docker-side-more"},`+ ${$} more`):null))}function pr({microservices:f,onRaw:u,onNavigate:_}){let y=f.filter((l)=>AH(l).public===!1);return A("div",{className:"microservice-page","data-testid":"microservice-catalog-page"},A(kf,{title:"用户服务目录",eyebrow:"Provider Mounted User Services"},A("div",{className:"metric-grid"},A(f0,{label:"服务总数",value:f.length,hint:"config.json 用户服务登记"}),A(f0,{label:"私有后端",value:y.length,hint:"不直接暴露公网",tone:"ok"}),A(f0,{label:"D601 服务",value:f.filter((l)=>l.providerId==="D601").length,hint:"compute-node docker"}),A(f0,{label:"集成前端",value:f.filter((l)=>l.frontend?.integrated).length,hint:"UniDesk React 页面"}))),A(kf,{title:"服务映射",eyebrow:"Repo Reference + Runtime"},f.length===0?A($0,{title:"暂无用户服务",text:"在 config.json 的 microservices 中登记用户服务的 provider、仓库引用和后端映射"}):A("div",{className:"table-wrap"},A("table",{className:"microservice-table"},A("thead",null,A("tr",null,A("th",null,"服务"),A("th",null,"Provider"),A("th",null,"代码引用"),A("th",null,"Docker 引用"),A("th",null,"后端映射"),A("th",null,"开发入口"),A("th",null,"运行态"),A("th",null,"操作"))),A("tbody",null,f.map((l)=>{let $=wH(l),j=zr(l),J=AH(l);return A("tr",{key:l.id,"data-testid":`microservice-row-${N1(l.id)}`},A("td",null,A("strong",null,l.name),A("code",null,l.id)),A("td",null,A("strong",null,$.providerName||l.providerId),A("code",null,l.providerId)),A("td",null,A("span",null,j.url||"--"),A("code",null,j.commitId||"--")),A("td",null,A("span",null,j.composeFile||"--"),A("code",null,`${j.composeService||"--"} / ${j.containerName||"--"}`)),A("td",null,A(E0,{status:J.public?"warn":"online"},J.public?"public":"private"),A("code",null,`${J.nodeBindHost||"--"}:${J.nodePort||"--"} -> ${J.proxyMode||"--"}`)),A("td",null,A("span",null,l.development?.sshPassthrough?"SSH 透传":"未配置"),A("code",null,l.development?.worktreePath||"--")),A("td",null,A(E0,{status:$.providerStatus==="online"?"online":"warn"},$.providerStatus||"unknown"),A(dy,{data:$.container,empty:"容器快照未上报"})),A("td",null,A("div",{className:"microservice-actions"},l.id==="findjob"?A("button",{type:"button",className:"ghost-btn",onClick:()=>_("apps","findjob"),"data-testid":"open-findjob-button"},"打开"):null,l.id==="pipeline"?A("button",{type:"button",className:"ghost-btn",onClick:()=>_("apps","pipeline"),"data-testid":"open-pipeline-button"},"打开"):null,l.id==="todo-note"?A("button",{type:"button",className:"ghost-btn",onClick:()=>_("apps","todo-note"),"data-testid":"open-todo-note-button"},"打开"):null,l.id==="met-nonlinear"?A("button",{type:"button",className:"ghost-btn",onClick:()=>_("apps","met-nonlinear"),"data-testid":"open-met-nonlinear-button"},"打开"):null,l.id==="claudeqq"?A("button",{type:"button",className:"ghost-btn",onClick:()=>_("apps","claudeqq"),"data-testid":"open-claudeqq-button"},"打开"):null,l.id==="codex-queue"?A("button",{type:"button",className:"ghost-btn",onClick:()=>_("apps","codex-queue"),"data-testid":"open-codex-queue-button"},"打开"):null,l.id==="project-manager"?A("button",{type:"button",className:"ghost-btn",onClick:()=>_("apps","project-manager"),"data-testid":"open-project-manager-button"},"打开"):null,A(k0,{title:`用户服务 ${l.id}`,data:l,onOpen:u}))))}))))))}function mr({nodes:f,onDispatched:u,onRaw:_}){let y=f.filter((x)=>x.status==="online"),[l,$]=If(y[0]?.providerId||f[0]?.providerId||""),[j,J]=If("docker.ps"),[F,Q]=If("frontend"),[U,z]=If("operator-check"),[W,K]=If("normal"),[q,V]=If(!1),[O,G]=If(""),[H,Z]=If(!1),[E,L]=If(null),[M,N]=If("");p1(()=>{if(!l&&(y[0]?.providerId||f[0]?.providerId))$(y[0]?.providerId||f[0].providerId)},[f.length,y.length,l]);function w(){return{source:F,note:U,priority:W}}function R(){G(JSON.stringify(w(),null,2)),V(!0)}async function p(x){x.preventDefault(),Z(!0),N("");try{let C=q?JSON.parse(O||"{}"):w(),P=await Df(`${sf.apiBaseUrl}/dispatch`,{method:"POST",body:JSON.stringify({providerId:l,command:j,payload:C})});L(P),await u()}catch(C){N(Tf(C,"下发失败"))}finally{Z(!1)}}return A("div",{className:"page-grid dispatch-grid"},A(kf,{title:"下发任务",eyebrow:"Real WebSocket Dispatch"},A("form",{className:"dispatch-form",onSubmit:p},A("label",null,"Provider",A("select",{value:l,onChange:(x)=>$(x.target.value)},f.map((x)=>A("option",{key:x.providerId,value:x.providerId},`${x.name} / ${x.providerId}`)))),A("label",null,"Command",A("select",{value:j,onChange:(x)=>J(x.target.value)},A("option",{value:"docker.ps"},"docker.ps"),A("option",{value:"host.ssh"},"host.ssh"),A("option",{value:"microservice.http"},"microservice.http"),A("option",{value:"echo"},"echo"))),A("label",null,"来源",A("input",{value:F,onChange:(x)=>Q(x.target.value)})),A("label",null,"备注",A("input",{value:U,onChange:(x)=>z(x.target.value)})),A("label",null,"优先级",A("select",{value:W,onChange:(x)=>K(x.target.value)},A("option",{value:"normal"},"normal"),A("option",{value:"low"},"low"),A("option",{value:"urgent"},"urgent"))),A("div",{className:"dispatch-actions"},A("button",{type:"button",className:"ghost-btn",onClick:R},"查看原始JSON"),A("button",{type:"submit",disabled:H||!l},H?"下发中":"下发任务")),q?A("label",{className:"raw-editor-label"},"高级 Payload",A("textarea",{className:"raw-editor",value:O,onChange:(x)=>G(x.target.value)})):null,A(H0,{error:M,wide:!0}))),A(kf,{title:"下发结果",eyebrow:"Response"},E?A("div",{className:"result-card"},A(E0,{status:E.status||"queued"},E.status||"queued"),A("dl",null,A("dt",null,"Task ID"),A("dd",null,A("code",null,E.taskId||"--")),A("dt",null,"Provider 在线"),A("dd",null,ay(E.providerOnline))),A(k0,{title:"Dispatch Response",data:E,onOpen:_})):A($0,{title:"等待操作",text:"任务响应会以结构化结果卡展示"})))}function GH({task:f,onRaw:u}){return A("article",{className:"compact-row"},A(E0,{status:f.status}),A("div",null,A("strong",null,f.command),A("code",null,f.id)),A("span",null,Hl(f)?`已等待 ${AA(f.updatedAt)}`:`耗时 ${ql(qH(f)??0)}`),A(k0,{title:`Task ${f.id}`,data:f,onOpen:u}))}function rH({task:f}){let u=qH(f),_=Hl(f);return A("div",{className:"task-duration"},A("strong",null,u===null?"--":ql(u)),A("span",null,_?`已运行 / 创建 ${m0(f.createdAt)}`:`创建 ${m0(f.createdAt)}`))}function kr({task:f}){let u=String(f?.status||"").toLowerCase(),_=f?.result,y=_&&typeof _==="object"&&!Array.isArray(_)?_:{},$=["exitCode","code","signal","timeoutMs","previousStatus","mode"].filter((j)=>y[j]!==void 0&&y[j]!==null);if(u==="failed"){let j=HH(f);return A("div",{className:"task-diagnostic failed"},A("b",null,"失败原因"),A("span",{className:"diagnostic-reason"},ay(j)),$.length>0?A("div",{className:"diagnostic-meta"},$.map((J)=>A("span",{key:J,className:"data-chip"},A("b",null,J),A("span",null,ay(y[J]))))):null)}if(Hl(f))return A("div",{className:"task-diagnostic warn"},A("b",null,"等待终态"),A("span",null,`最后更新 ${AA(f.updatedAt)} 前`));return A("div",{className:"task-diagnostic ok"},A("b",null,"完成摘要"),A(dy,{data:_,empty:"无执行输出"}))}function ir({tasks:f,onRaw:u}){let _=f.filter(Hl);return A("div",{"data-testid":"pending-task-page"},A(kf,{title:"待处理任务",eyebrow:`${_.length} Pending`},_.length===0?A($0,{title:"当前无待处理任务",text:"queued / dispatched / running 会在超时后自动转为 failed;历史记录仍可在任务历史中查看"}):A("div",{className:"table-wrap","data-testid":"pending-task-table"},A("table",null,A("thead",null,A("tr",null,A("th",null,"状态"),A("th",null,"任务"),A("th",null,"Provider"),A("th",null,"已等待"),A("th",null,"载荷摘要"),A("th",null,"操作"))),A("tbody",null,_.map((y)=>A("tr",{key:y.id},A("td",null,A(E0,{status:y.status})),A("td",null,A("strong",null,y.command),A("code",null,y.id)),A("td",null,A("code",null,y.providerId)),A("td",null,AA(y.updatedAt)),A("td",null,A(dy,{data:y.payload})),A("td",null,A(k0,{title:`Pending Task ${y.id}`,data:y,onOpen:u})))))))))}function gr({tasks:f,onRaw:u}){return A("div",{"data-testid":"task-history-page"},A(kf,{title:"任务历史",eyebrow:`${f.length} Tasks`},f.length===0?A($0,{title:"暂无任务",text:"下发任务后会在这里看到生命周期"}):A("div",{className:"table-wrap"},A("table",{className:"task-history-table"},A("thead",null,A("tr",null,A("th",null,"状态"),A("th",null,"任务"),A("th",null,"Provider"),A("th",null,"任务耗时"),A("th",null,"载荷摘要"),A("th",null,"诊断信息"),A("th",null,"更新时间"),A("th",null,"操作"))),A("tbody",null,f.map((_)=>A("tr",{key:_.id,"data-testid":`task-row-${N1(_.id)}`},A("td",null,A(E0,{status:_.status})),A("td",null,A("strong",null,_.command),A("code",null,_.id)),A("td",null,A("code",null,_.providerId)),A("td",null,A(rH,{task:_})),A("td",null,A(dy,{data:_.payload})),A("td",null,A(kr,{task:_})),A("td",null,m0(_.updatedAt)),A("td",null,A(k0,{title:`Task ${_.id}`,data:_,onOpen:u})))))))))}function nr({tasks:f,onRaw:u}){let _=f.filter((y)=>["succeeded","failed"].includes(y.status));return A(kf,{title:"执行结果",eyebrow:"Finished Tasks"},_.length===0?A($0,{title:"暂无结果",text:"任务完成后展示 provider 返回的结构化摘要"}):A("div",{className:"result-grid"},_.map((y)=>A("article",{key:y.id,className:"result-card"},A("div",{className:"node-card-head"},A("strong",null,y.command),A(E0,{status:y.status})),A("code",null,y.id),A(dy,{data:y.result,empty:"无执行输出"}),A(k0,{title:`Task Result ${y.id}`,data:y,onOpen:u})))))}function tr({data:f}){let u=f.overview||{};return A("div",{className:"page-grid topology-grid"},A(kf,{title:"公开入口",eyebrow:"Public"},A("div",{className:"endpoint-list"},A("article",null,A("b",null,"Frontend"),A("span",null,sf.frontendPublicUrl||window.location.origin),A(E0,{status:"online"},"public")),A("article",null,A("b",null,"Provider Ingress"),A("span",null,sf.providerIngressPublicUrl||"ws://public/ws/provider"),A(E0,{status:"online"},"public")))),A(kf,{title:"内部服务",eyebrow:"Docker Network Only"},A("div",{className:"endpoint-list"},A("article",null,A("b",null,"backend-core API"),A("span",null,"http://backend-core:8080"),A(E0,{status:"internal"},"internal")),A("article",null,A("b",null,"database"),A("span",null,"postgres://database:5432/unidesk"),A(E0,{status:"internal"},"internal")))),A(kf,{title:"运行态",eyebrow:"Runtime"},A("div",{className:"metric-grid"},A(f0,{label:"DB Ready",value:u.dbReady?"YES":"NO",hint:"internal health"}),A(f0,{label:"Online Nodes",value:u.onlineNodeCount??0,hint:"provider-gateway self-link"}))))}function sr({session:f}){return A(kf,{title:"认证策略",eyebrow:"Frontend Login"},A("div",{className:"policy-grid"},A("article",null,A("span",null,"默认账号"),A("strong",null,sf.authUsername||"admin")),A("article",null,A("span",null,"当前会话"),A("strong",null,f?.user?.username||"--")),A("article",null,A("span",null,"Session TTL"),A("strong",null,`${sf.sessionTtlSeconds||0}s`)),A("article",null,A("span",null,"API 访问"),A("strong",null,"同源 Cookie 保护"))),A("p",{className:"muted paragraph"},"浏览器只访问 frontend 同源接口;frontend 容器使用 Docker 内网代理 backend-core API。"))}function or(){return A(kf,{title:"安全边界",eyebrow:"Exposure Rule"},A("div",{className:"security-board"},A("article",{className:"allow"},A("b",null,"允许公网"),A("span",null,"frontend 登录入口"),A("span",null,"provider ingress WebSocket/health")),A("article",{className:"deny"},A("b",null,"禁止公网"),A("span",null,"backend-core REST API"),A("span",null,"PostgreSQL database")),A("article",null,A("b",null,"数据库卷"),A("span",null,"named volume unidesk_pgdata_10gb"),A("span",null,"CLI stop/start 不删除数据卷"))))}function ar({activeModule:f,activeTab:u,data:_,session:y,refresh:l,onRaw:$,onNavigate:j}){if(f==="ops"&&u==="status")return A(Vr,{data:_,onRaw:$,onNavigate:j});if(f==="ops"&&u==="performance")return A(Cr,{onRaw:$});if(f==="ops"&&u==="events")return A(Or,{events:_.events,onRaw:$});if(f==="ops"&&u==="logs")return A(Xr,{logs:_.logs,onRaw:$});if(f==="nodes"&&u==="list")return A(Nr,{nodes:_.nodes,onRaw:$});if(f==="nodes"&&u==="monitor")return A(Br,{nodes:_.nodes,systemStatuses:_.systemStatuses,tasks:_.tasks,onRaw:$,refresh:l});if(f==="nodes"&&u==="docker")return A(Ir,{nodes:_.nodes,dockerStatuses:_.dockerStatuses,onRaw:$});if(f==="nodes"&&u==="gateway")return A(vr,{nodes:_.nodes,tasks:_.tasks,onRaw:$});if(f==="nodes"&&u==="labels")return A(Lr,{nodes:_.nodes});if(f==="nodes"&&u==="heartbeats")return A(Yr,{nodes:_.nodes});if(f==="tasks"&&u==="dispatch")return A(mr,{nodes:_.nodes,onDispatched:l,onRaw:$});if(f==="tasks"&&u==="pending")return A(ir,{tasks:_.pendingTasks,onRaw:$});if(f==="tasks"&&u==="history")return A(gr,{tasks:_.tasks,onRaw:$});if(f==="tasks"&&u==="results")return A(nr,{tasks:_.tasks,onRaw:$});if(f==="apps"&&u==="catalog")return A(pr,{microservices:_.microservices,onRaw:$,onNavigate:j});if(f==="apps"&&u==="todo-note")return A(yH,{microservices:_.microservices,onRaw:$,apiBaseUrl:sf.apiBaseUrl});if(f==="apps"&&u==="findjob")return A(sz,{microservices:_.microservices,onRaw:$,apiBaseUrl:sf.apiBaseUrl});if(f==="apps"&&u==="pipeline")return A(tq,{microservices:_.microservices,onRaw:$,apiBaseUrl:sf.apiBaseUrl});if(f==="apps"&&u==="met-nonlinear")return A(fG,{microservices:_.microservices,onRaw:$,apiBaseUrl:sf.apiBaseUrl});if(f==="apps"&&u==="claudeqq")return A(lz,{microservices:_.microservices,onRaw:$,apiBaseUrl:sf.apiBaseUrl});if(f==="apps"&&u==="codex-queue")return A(tz,{microservices:_.microservices,onRaw:$,apiBaseUrl:sf.apiBaseUrl,initialTasksData:lr});if(f==="apps"&&u==="project-manager")return A(aq,{microservices:_.microservices,onRaw:$,apiBaseUrl:sf.apiBaseUrl});if(f==="config"&&u==="topology")return A(tr,{data:_});if(f==="config"&&u==="auth")return A(sr,{session:y});if(f==="config"&&u==="security")return A(or);return A($0,{title:"未找到页面",text:"请选择左侧主模块和顶部子功能标签"})}function dr({session:f,onLogout:u}){let _=Yj(mu,window.location.pathname),[y,l]=If(_.moduleId),[$,j]=If({...p3,[_.moduleId]:_.tabId}),[J,F]=If({overview:null,nodes:[],systemStatuses:[],dockerStatuses:[],microservices:[],events:[],tasks:[],pendingTasks:[],logs:[]}),[Q,U]=If({ok:!1,text:"连接中"}),[z,W]=If(null),[K,q]=If(new Date),[V,O]=If(null),[G,H]=If(!1),Z=mu.moduleById[y]||mu.modules[0],E=$[y]||p3[y]||Z.tabs[0].id,L=Array.isArray(J.microservices)?J.microservices:[],M=L.length===0&&y==="apps"&&E==="codex-queue"?[$r]:L,N=M===L?J:{...J,microservices:M},w=y==="apps"?M.find((T)=>String(T?.id||"")===E):null,R=w?wH(w):{},p=Z.tabs.find((T)=>T.id===E)?.label||E,x=w?[{key:"microservice",label:"用户服务",value:`${p} ${R.providerStatus==="online"?"在线":R.providerStatus||"未知"}`,tone:R.providerStatus==="online"?"ok":"warn",testId:"active-microservice-status"}]:[];async function C(){try{let T=[],S=(c,o)=>{T.push([c,Df(o)])},r=y==="ops"&&E==="status",Y=y!=="apps",v=r||y==="nodes"||y==="tasks"&&E==="dispatch",m=y==="apps"&&E!=="codex-queue";if(Y)S("overview",`${sf.apiBaseUrl}/overview`);if(v)S("nodes",`${sf.apiBaseUrl}/nodes`);if(y==="nodes"&&E==="monitor")S("systemStatuses",`${sf.apiBaseUrl}/nodes/system-status?limit=60`),S("tasks",`${sf.apiBaseUrl}/tasks?limit=120`);else if(y==="nodes"&&E==="docker")S("dockerStatuses",`${sf.apiBaseUrl}/nodes/docker-status`);else if(y==="nodes"&&E==="gateway")S("tasks",`${sf.apiBaseUrl}/tasks?limit=300`);else if(y==="tasks"&&E==="pending")S("pendingTasks",`${sf.apiBaseUrl}/tasks?status=pending&limit=100`);else if(y==="tasks"&&(E==="history"||E==="results"))S("tasks",`${sf.apiBaseUrl}/tasks?limit=300`);else if(r)S("tasks",`${sf.apiBaseUrl}/tasks?limit=8&lite=1`),S("pendingTasks",`${sf.apiBaseUrl}/tasks?status=pending&limit=20&lite=1`);if(m)S("microservices",`${sf.apiBaseUrl}/microservices`);if(y==="ops"&&E==="events")S("events",`${sf.apiBaseUrl}/events?limit=100`);if(y==="ops"&&E==="logs")S("logs","/logs?limit=100");await Promise.all(T.map(async([c,o])=>{let ff=await o,n={};if(c==="overview")n.overview=ff;if(c==="nodes")n.nodes=ff.nodes||[];if(c==="systemStatuses")n.systemStatuses=ff.systemStatuses||[];if(c==="dockerStatuses")n.dockerStatuses=ff.dockerStatuses||[];if(c==="microservices")n.microservices=ff.microservices||[];if(c==="events")n.events=ff.events||[];if(c==="tasks")n.tasks=ff.tasks||[];if(c==="pendingTasks")n.pendingTasks=ff.tasks||[];if(c==="logs")n.logs=ff.logs||[];F((lf)=>({...lf,...n}))})),U({ok:!0,text:"核心在线"}),W(new Date)}catch(T){if(U({ok:!1,text:Tf(T,"连接失败")}),T.status===401)u(!1)}}p1(()=>{C();let T=setInterval(C,5000);return()=>clearInterval(T)},[y,E]),p1(()=>{let T=setInterval(()=>q(new Date),1000);return()=>clearInterval(T)},[]),p1(()=>{let T=lG(mu,window.location.pathname);if(T&&window.location.pathname!==T)window.history.replaceState(null,"",T)},[]),p1(()=>{let T=()=>{let S=Yj(mu,window.location.pathname);l(S.moduleId),j((r)=>({...r,[S.moduleId]:S.tabId})),O(null)};return window.addEventListener("popstate",T),()=>window.removeEventListener("popstate",T)},[]),p1(()=>{window.scrollTo({top:0,left:0,behavior:"auto"})},[y,E]);function P(T,S,r="push"){let Y=mu.moduleById[T]?T:mu.fallbackTarget.moduleId,v=mu.moduleById[Y]?.tabs.some((c)=>c.id===S)?S:p3[Y]||mu.moduleById[Y]?.tabs[0]?.id||mu.fallbackTarget.tabId;l(Y),j((c)=>({...c,[Y]:v}));let m=I$(mu,Y,v);if(window.location.pathname!==m){let c=r==="replace"?"replaceState":"pushState";window.history[c](null,"",m)}}function D(T,S){O({title:T,data:S})}return A("div",{className:`shell ${G?"rail-collapsed":""}`,"data-testid":"app-shell"},A(qr,{activeModule:y,activeTabs:$,onNavigate:P,collapsed:G,onToggle:()=>H((T)=>!T)}),A("main",{className:"workspace"},A(Zr,{connection:Q,lastRefresh:z,onRefresh:C,onLogout:()=>u(!0),session:f,clock:K,activeStatusItems:x}),A(Hr,{module:Z,activeTab:E,onNavigate:P}),A(ar,{activeModule:y,activeTab:E,data:N,session:f,refresh:C,onRaw:D,onNavigate:P})),A(Gr,{raw:V,onClose:()=>O(null)}))}function er(){let[f,u]=If(!0),[_,y]=If(null);async function l(){u(!0);try{let j=await Df("/api/session");y(j.authenticated?j:null)}catch{y(null)}finally{u(!1)}}async function $(j){if(j)try{await Df("/logout",{method:"POST"})}catch{}y(null)}if(p1(()=>{l()},[]),f)return A("main",{className:"loading-screen"},A("div",{className:"brand-mark"},"UD"),A("span",null,"加载会话"));if(!_)return A(Kr,{onLogin:y});return A(dr,{session:_,onLogout:$})}var SH=document.getElementById("root");if(SH===null)throw Error("root element not found");KH.createRoot(SH).render(A(er));})(); diff --git a/src/components/frontend/public/style.css b/src/components/frontend/public/style.css index f8ef617f..2c646818 100644 --- a/src/components/frontend/public/style.css +++ b/src/components/frontend/public/style.css @@ -20,6 +20,34 @@ } * { box-sizing: border-box; } +* { + scrollbar-width: thin; + scrollbar-color: rgba(78, 183, 168, 0.62) rgba(255,255,255,0.045); +} +*::-webkit-scrollbar { + width: 8px; + height: 8px; +} +*::-webkit-scrollbar-track { + background: rgba(255,255,255,0.035); + border-radius: 999px; +} +*::-webkit-scrollbar-thumb { + min-height: 36px; + border: 2px solid transparent; + border-radius: 999px; + background: + linear-gradient(180deg, rgba(78, 183, 168, 0.86), rgba(215, 161, 58, 0.70)) + padding-box; +} +*::-webkit-scrollbar-thumb:hover { + background: + linear-gradient(180deg, rgba(93, 210, 195, 0.95), rgba(241, 183, 75, 0.84)) + padding-box; +} +*::-webkit-scrollbar-corner { + background: transparent; +} html, body, #root { min-height: 100%; } body { margin: 0; @@ -147,6 +175,64 @@ h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.08em; } color: var(--muted); white-space: nowrap; } +.top-status-bar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + min-width: 0; + padding: 6px 8px; + border: 1px solid var(--line); + background: + linear-gradient(90deg, rgba(78, 183, 168, 0.08), rgba(215, 161, 58, 0.035)), + rgba(0,0,0,0.16); + color: var(--muted); +} +.top-status-main, +.top-status-chips, +.top-status-actions { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} +.top-status-title { + color: var(--text); + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + white-space: nowrap; +} +.top-status-chips { + flex-wrap: wrap; +} +.top-status-chip { + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 22px; + padding: 2px 7px; + border: 1px solid var(--line-soft); + background: rgba(255,255,255,0.025); + white-space: nowrap; + font-size: 11px; +} +.top-status-chip b { + color: var(--muted); + font-weight: 650; +} +.top-status-chip.ok { border-color: rgba(113, 191, 120, 0.42); color: var(--ok); } +.top-status-chip.warn { border-color: rgba(215, 161, 58, 0.45); color: var(--accent); } +.top-status-chip.fail { border-color: rgba(224, 105, 95, 0.45); color: var(--danger); } +.top-status-chip.user { color: var(--text); background: var(--panel-3); } +.top-status-actions { + flex: 0 0 auto; +} +.global-top-status { + padding: 0; + border: 0; + background: transparent; +} .dot, .pulse { width: 8px; @@ -236,7 +322,7 @@ h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.08em; } color: var(--muted); } -.panel-actions { display: flex; gap: 6px; align-items: center; } +.panel-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; align-items: center; min-width: 0; } .panel-body { padding: 10px; } .metric-grid { @@ -719,6 +805,133 @@ h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.08em; } margin-top: 8px; } +.performance-page { + display: grid; + gap: 10px; +} +.performance-hero { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; + padding: 12px; + border: 1px solid var(--line); + background: + linear-gradient(90deg, rgba(78, 183, 168, 0.12), transparent 42%), + linear-gradient(180deg, rgba(215, 161, 58, 0.08), transparent 64%), + var(--panel); +} +.performance-hero h2 { + font-size: 20px; + text-transform: none; + letter-spacing: 0.04em; +} +.performance-hero p:last-child { + margin: 6px 0 0; + color: var(--muted); +} +.performance-top-grid { + display: grid; + grid-template-columns: minmax(420px, 1.3fr) minmax(360px, 0.9fr); + gap: 10px; + align-items: stretch; +} +.performance-memory-card { + min-width: 0; + padding: 10px; + border: 1px solid var(--line); + background: + linear-gradient(135deg, rgba(215, 161, 58, 0.12), transparent 32%), + #0b141b; +} +.performance-memory-head, +.performance-axis-row { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; +} +.performance-memory-head strong { + font-size: 18px; + letter-spacing: 0.04em; +} +.performance-memory-head span, +.performance-axis-row { + color: var(--muted); + font-size: 11px; +} +.performance-memory-card svg { + width: 100%; + height: 180px; + margin: 8px 0; + border: 1px solid var(--line-soft); + background: + linear-gradient(180deg, transparent 48%, rgba(255,255,255,0.05) 49%, transparent 50%), + repeating-linear-gradient(90deg, rgba(255,255,255,0.04) 0, rgba(255,255,255,0.04) 1px, transparent 1px, transparent 16px), + #071015; +} +.performance-memory-card polygon { + fill: rgba(215, 161, 58, 0.14); +} +.performance-memory-card polyline { + fill: none; + stroke: var(--accent); + stroke-width: 1.9; + vector-effect: non-scaling-stroke; +} +.performance-memory-card line { + stroke: rgba(255,255,255,0.14); + stroke-width: 1; + vector-effect: non-scaling-stroke; +} +.performance-metric-stack { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} +.performance-grid { + display: grid; + grid-template-columns: minmax(520px, 1fr) minmax(460px, 0.9fr); + gap: 10px; + align-items: start; +} +.performance-table-wrap { + max-height: 360px; + overflow: auto; +} +.performance-table { + min-width: 720px; +} +.performance-table th, +.performance-table td { + padding: 7px 8px; +} +.performance-table code { + color: #d6e7e6; +} +.performance-empty-line { + padding: 12px; + border: 1px dashed var(--line-soft); + color: var(--muted); + background: rgba(255,255,255,0.02); +} +.codex-load-test-panel { + border-color: rgba(215, 161, 58, 0.32); + background: + linear-gradient(135deg, rgba(78, 183, 168, 0.08), transparent 28%), + linear-gradient(180deg, rgba(215, 161, 58, 0.08), transparent 70%), + var(--panel); +} +.codex-load-test-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} +.codex-load-api-table { + margin-top: 10px; + max-height: 220px; +} + .process-resource-panel { margin-top: 8px; border: 1px solid var(--line-soft); @@ -1079,6 +1292,18 @@ td { color: var(--text); } text-transform: uppercase; letter-spacing: 0.12em; } +.stack-form { + display: grid; + gap: 8px; +} +.stack-form label { + display: grid; + gap: 4px; + color: var(--muted); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.12em; +} .dispatch-actions { display: flex; gap: 6px; align-items: end; } .dispatch-actions button[type="submit"], .login-form button[type="submit"] { min-height: 32px; @@ -1153,6 +1378,37 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } align-items: start; } .findjob-grid .panel:nth-child(3) { grid-column: 1 / -1; } +.claudeqq-page .findjob-grid .panel:nth-child(n+3) { grid-column: 1 / -1; } +.claudeqq-login-card { + display: grid; + grid-template-columns: 176px minmax(0, 1fr); + gap: 12px; + align-items: center; +} +.claudeqq-qr-frame { + display: grid; + min-height: 176px; + place-items: center; + padding: 10px; + border: 1px solid var(--line-soft); + background: + linear-gradient(135deg, rgba(255,255,255,0.92), rgba(246,249,240,0.82)), + repeating-linear-gradient(45deg, rgba(24, 35, 31, 0.05) 0 6px, transparent 6px 12px); + box-shadow: inset 0 0 0 1px rgba(255,255,255,0.8); +} +.claudeqq-qr-frame img { + width: min(152px, 100%); + height: auto; + image-rendering: pixelated; +} +.claudeqq-login-copy { + display: grid; + gap: 8px; + min-width: 0; +} +.claudeqq-login-copy .microservice-ref-card { + padding: 7px; +} .pipeline-grid { display: grid; grid-template-columns: minmax(360px, 0.9fr) minmax(520px, 1.25fr); @@ -1212,7 +1468,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } } .codex-queue-metrics { display: grid; - grid-template-columns: repeat(5, minmax(140px, 1fr)); + grid-template-columns: repeat(6, minmax(130px, 1fr)); gap: 8px; } .codex-queue-layout { @@ -1243,14 +1499,21 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } } .codex-session-sidebar { display: grid; - grid-template-rows: auto minmax(0, 1fr); + grid-template-rows: auto auto minmax(0, 1fr); gap: 8px; + align-self: start; + align-content: start; + align-items: start; + justify-items: stretch; min-width: 0; padding: 10px; border-right: 1px solid var(--line); background: - radial-gradient(circle at 0 0, rgba(78, 183, 168, 0.11), transparent 42%), - rgba(6, 10, 13, 0.72); + radial-gradient(circle at 0 0, rgba(78, 183, 168, 0.16), transparent 42%), + #060a0d; +} +.codex-session-sidebar > * { + width: 100%; } .codex-session-sidebar-head { display: flex; @@ -1277,6 +1540,85 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } text-overflow: ellipsis; white-space: nowrap; } +.codex-queue-switcher { + display: grid; + gap: 3px; + min-width: min(260px, 48vw); + color: var(--muted); + font-size: 10px; + letter-spacing: 0.13em; + text-transform: uppercase; +} +.codex-queue-switcher select { + min-width: 0; + width: 100%; + max-width: 100%; + min-height: 34px; + padding: 6px 9px; + border: 1px solid rgba(78, 183, 168, 0.34); + color: var(--text); + background: + linear-gradient(135deg, rgba(78, 183, 168, 0.14), rgba(215, 161, 58, 0.05)), + #071014; + box-sizing: border-box; +} +.codex-queue-switcher.compact { + min-width: 0; + width: 100%; + margin-top: 2px; +} +.codex-queue-switcher.compact select { + min-height: 31px; + font-size: 12px; +} +.codex-output-panel .panel-summary { + margin-top: 6px; +} +.codex-trace-status { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 5px; + min-width: 0; +} +.codex-trace-status-chip { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 22px; + padding: 2px 7px; + border: 1px solid rgba(78, 183, 168, 0.28); + color: var(--text); + background: + linear-gradient(135deg, rgba(78, 183, 168, 0.10), rgba(255,255,255,0.018)), + rgba(0,0,0,0.16); + font-size: 11px; + letter-spacing: 0.05em; + white-space: nowrap; +} +.codex-trace-status-chip b { + color: var(--muted); + font-weight: 650; +} +.codex-trace-status-chip.queued { + border-color: rgba(215, 161, 58, 0.36); + color: #ffe0a2; +} +.codex-trace-status-chip.running { + border-color: rgba(78, 183, 168, 0.45); + color: var(--accent-2); +} +.codex-trace-status-chip.unread.warn { + border-color: rgba(215, 161, 58, 0.48); + color: #ffe0a2; + background: + linear-gradient(135deg, rgba(215, 161, 58, 0.13), rgba(78, 183, 168, 0.06)), + rgba(0,0,0,0.18); +} +.codex-mark-all-read-btn { + border-color: rgba(78, 183, 168, 0.40); + color: #bdece4; +} .codex-session-main { min-width: 0; } @@ -1288,6 +1630,31 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } display: grid; gap: 10px; } +.codex-run-control-stack { + display: grid; + gap: 10px; + min-width: 0; +} +.codex-task-move-control { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: end; + min-width: 0; + padding: 8px; + border: 1px solid rgba(78, 183, 168, 0.24); + background: + linear-gradient(135deg, rgba(78, 183, 168, 0.08), rgba(215, 161, 58, 0.04)), + rgba(255,255,255,0.02); +} +.codex-task-move-control label { + min-width: 0; +} +.codex-task-move-control select { + min-width: 0; + max-width: 100%; + box-sizing: border-box; +} .codex-compose-panel, .codex-compose-panel .panel-body, .codex-task-form, @@ -1295,6 +1662,15 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } min-width: 0; overflow: hidden; } +.codex-task-form.is-submitting { + position: relative; +} +.codex-task-form.is-submitting textarea, +.codex-task-form.is-submitting input, +.codex-task-form.is-submitting select { + opacity: 0.64; + cursor: wait; +} .codex-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -1305,27 +1681,127 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } } .codex-task-form textarea, .codex-steer-form textarea, +.codex-reference-field input, .codex-form-grid input, .codex-form-grid select { min-width: 0; max-width: 100%; box-sizing: border-box; } +.codex-submit-queue-field { + min-width: 0; +} +.codex-submit-queue-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 6px; + min-width: 0; + align-items: center; +} +.codex-create-queue-btn { + min-height: 32px; + white-space: nowrap; +} +.codex-reference-field { + display: grid; + gap: 5px; +} +.codex-reference-field code { + max-width: 100%; + padding: 5px 7px; + overflow-wrap: anywhere; + border: 1px solid rgba(78, 183, 168, 0.28); + color: var(--accent-2); + background: rgba(78, 183, 168, 0.055); + font-size: 11px; +} +.codex-form-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; + min-width: 0; +} +.codex-batch-confirm { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 8px 9px; + border: 1px solid rgba(215, 161, 58, 0.36); + color: #ffe0a2; + background: + linear-gradient(135deg, rgba(215, 161, 58, 0.11), rgba(78, 183, 168, 0.04)), + rgba(255,255,255,0.02); + font-size: 12px; + letter-spacing: 0.02em; +} +.codex-batch-confirm.confirmed { + border-color: rgba(78, 183, 168, 0.46); + color: #bdece4; +} +.codex-batch-confirm input { + flex: 0 0 auto; + margin-top: 2px; +} +.codex-submit-wait { + padding: 8px 9px; + border: 1px solid rgba(78, 183, 168, 0.36); + color: #bdece4; + background: + linear-gradient(90deg, rgba(78, 183, 168, 0.12), rgba(215, 161, 58, 0.06)), + rgba(7, 16, 20, 0.86); + font-size: 12px; +} .codex-task-list { display: grid; gap: 7px; + grid-auto-rows: max-content; + align-content: start; + align-items: start; max-height: calc(100vh - 460px); min-height: 180px; overflow: auto; - align-content: start; } .codex-task-list-session { + align-self: start; min-height: 0; max-height: calc(100vh - 318px); } +.codex-task-pagination, +.codex-lazy-detail-callout { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 9px; + border: 1px solid rgba(78, 183, 168, 0.26); + background: + linear-gradient(135deg, rgba(78, 183, 168, 0.10), rgba(215, 161, 58, 0.05)), + rgba(255,255,255,0.025); + color: var(--muted); + font-size: 12px; +} +.codex-lazy-detail-callout { + margin: 8px 0; +} +.codex-lazy-detail-callout > div { + display: grid; + gap: 2px; + min-width: min(100%, 280px); +} +.codex-lazy-detail-callout strong { + color: var(--text); +} +.codex-task-pagination code { + color: var(--accent-2); +} .codex-task-section { display: grid; gap: 7px; + grid-auto-rows: max-content; + align-content: start; + align-items: start; min-width: 0; } .codex-task-section + .codex-task-section { @@ -1349,6 +1825,9 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } .codex-task-section-list { display: grid; gap: 7px; + grid-auto-rows: max-content; + align-content: start; + align-items: start; min-width: 0; } .codex-task-section-empty { @@ -1360,6 +1839,8 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } background: rgba(255,255,255,0.02); } .codex-task-card { + position: relative; + align-self: start; display: grid; gap: 6px; width: 100%; @@ -1367,8 +1848,8 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } 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); + linear-gradient(135deg, rgba(78, 183, 168, 0.09), #0a1015 42%), + #0a1015; text-align: left; } .codex-task-card:hover, @@ -1376,17 +1857,97 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } 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); + linear-gradient(135deg, rgba(78, 183, 168, 0.18), #0c181c 42%), + #0c181c; +} +.codex-task-card.unread-terminal { + padding-right: 23px; + border-color: rgba(215, 161, 58, 0.32); + background: + linear-gradient(135deg, rgba(78, 183, 168, 0.09), #0a1015 42%), + #0a1015; +} +.codex-task-card.unread-terminal.selected, +.codex-task-card.unread-terminal:hover { + border-color: rgba(215, 161, 58, 0.62); + background: + linear-gradient(135deg, rgba(78, 183, 168, 0.18), #0c181c 42%), + #0c181c; +} +.codex-task-card:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; } .codex-task-card-head, .codex-task-meta, +.codex-task-id-row, .codex-judge-line { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.codex-task-status-line { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 5px; + min-width: 0; +} +.codex-unread-badge { + position: absolute; + top: 7px; + right: 7px; + width: 9px; + height: 9px; + padding: 0; + border: 2px solid #0a1015; + border-radius: 999px; + background: var(--accent); + box-shadow: + 0 0 0 2px rgba(215, 161, 58, 0.20), + 0 0 14px rgba(215, 161, 58, 0.36); + pointer-events: none; +} +.codex-task-card.selected .codex-unread-badge, +.codex-task-card:hover .codex-unread-badge { + border-color: #0c181c; +} +.codex-task-id-row { + min-width: 0; + align-items: center; +} +.codex-task-id-row code { + min-width: 0; + color: var(--accent-2); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.codex-task-id-actions { + display: inline-flex; + flex: 0 0 auto; + gap: 5px; + align-items: center; +} +.codex-copy-id-btn { + flex: 0 0 auto; + border: 1px solid rgba(215, 161, 58, 0.34); + color: var(--accent); + background: rgba(215, 161, 58, 0.07); + padding: 3px 7px; + font-size: 10px; + cursor: pointer; +} +.codex-copy-id-btn:hover { + border-color: var(--accent); + background: rgba(215, 161, 58, 0.14); +} +.codex-mark-read-btn { + border-color: rgba(78, 183, 168, 0.42); + color: #bdece4; + background: rgba(78, 183, 168, 0.08); +} .codex-task-card strong { overflow-wrap: anywhere; color: var(--text); @@ -1399,11 +1960,31 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } .codex-output-panel .panel-body { padding: 0; } +.codex-session-title-toggle { + min-height: 34px; + padding: 7px 13px; + border: 1px solid rgba(215, 161, 58, 0.62); + color: #ffe0a2; + background: + linear-gradient(135deg, rgba(215, 161, 58, 0.24), rgba(78, 183, 168, 0.08)), + #11110b; + box-shadow: 0 10px 22px rgba(0,0,0,0.26); + font-size: 13px; + font-weight: 700; + letter-spacing: 0.04em; + cursor: pointer; +} +.codex-session-title-toggle:hover { + border-color: var(--accent); + color: #fff0c8; + transform: translateY(-1px); +} .codex-output-stack { display: grid; min-width: 0; } .codex-transcript { + position: relative; min-height: 520px; max-height: calc(100vh - 300px); overflow: auto; @@ -1423,16 +2004,13 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } } .codex-transcript-item { display: grid; - grid-template-columns: 22px minmax(0, 1fr); - gap: 8px; + grid-template-columns: minmax(0, 1fr); + gap: 0; padding: 9px 0; border-bottom: 1px solid rgba(255,255,255,0.045); } .codex-transcript-bullet { - color: var(--accent); - font-size: 20px; - line-height: 1.1; - text-align: center; + display: none; } .codex-transcript-main { display: grid; @@ -1470,8 +2048,8 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; - border-left: 2px solid rgba(78, 183, 168, 0.26); - padding: 2px 0 2px 10px; + border-top: 1px solid rgba(78, 183, 168, 0.20); + padding: 3px 0 0; color: #d9e8e7; font-size: 12px; line-height: 1.48; @@ -1479,14 +2057,451 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } .codex-transcript-command { display: block; width: 100%; + max-width: 100%; margin-top: 2px; color: #a7c7c3; - border-color: rgba(215, 161, 58, 0.34); + border-color: rgba(215, 161, 58, 0.30); background: rgba(215, 161, 58, 0.035); } +.codex-transcript-streams { + display: grid; + gap: 6px; + min-width: 0; + margin-top: 2px; +} +.codex-transcript-stream { + min-width: 0; + border-top: 1px solid rgba(78, 183, 168, 0.20); + padding-top: 3px; +} +.codex-transcript-stream-label { + display: inline-flex; + margin-bottom: 2px; + color: #8fc7ee; + font-family: "Cascadia Mono", "IBM Plex Mono", "Liberation Mono", monospace; + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.codex-transcript-stream.stderr .codex-transcript-stream-label { + color: #ffb38a; +} +.codex-transcript-stream .codex-transcript-body { + border-top: 0; + padding-top: 0; +} +.codex-transcript-item.ran .codex-transcript-command, +.codex-transcript-item.ran .codex-transcript-body, +.codex-transcript-item.explored .codex-transcript-command, +.codex-transcript-item.explored .codex-transcript-body, +.codex-transcript-item.edited .codex-transcript-command, +.codex-transcript-item.edited .codex-transcript-body { + white-space: pre; + overflow-x: auto; + overflow-y: hidden; + overflow-wrap: normal; + word-break: normal; + overscroll-behavior-x: contain; +} +.codex-transcript-item.ran .codex-transcript-command, +.codex-transcript-item.ran .codex-transcript-body, +.codex-transcript-item.explored .codex-transcript-command, +.codex-transcript-item.explored .codex-transcript-body, +.codex-transcript-item.edited .codex-transcript-command, +.codex-transcript-item.edited .codex-transcript-body { + scrollbar-width: none; + -ms-overflow-style: none; +} +.codex-transcript-item.ran .codex-transcript-command::-webkit-scrollbar, +.codex-transcript-item.ran .codex-transcript-body::-webkit-scrollbar, +.codex-transcript-item.explored .codex-transcript-command::-webkit-scrollbar, +.codex-transcript-item.explored .codex-transcript-body::-webkit-scrollbar, +.codex-transcript-item.edited .codex-transcript-command::-webkit-scrollbar, +.codex-transcript-item.edited .codex-transcript-body::-webkit-scrollbar { + display: none; +} +.codex-transcript-item.message .codex-transcript-body, +.codex-initial-prompt-full .codex-transcript-body, +.codex-transcript-full-prompt { + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; + overflow-x: visible; +} .codex-transcript-item.explored .codex-output-channel { color: #8fc7ee; border-color: rgba(105, 174, 232, 0.46); background: rgba(105, 174, 232, 0.08); } .codex-transcript-item.edited .codex-output-channel { color: #b6da89; border-color: rgba(182, 218, 137, 0.42); background: rgba(182, 218, 137, 0.07); } .codex-transcript-item.error .codex-output-channel { color: var(--danger); border-color: rgba(207, 106, 84, 0.52); background: rgba(207, 106, 84, 0.08); } +.codex-progressive-trace { + display: grid; + align-content: start; + gap: 10px; +} +.codex-attempt-cycle { + display: grid; + gap: 8px; + min-width: 0; + padding: 8px; + border: 1px solid rgba(215, 161, 58, 0.22); + background: + linear-gradient(90deg, rgba(215, 161, 58, 0.09), transparent 28%), + rgba(255,255,255,0.018); +} +.codex-attempt-cycle-head { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} +.codex-attempt-cycle-head strong { + color: var(--text); +} +.codex-attempt-cycle-head code { + margin-left: auto; + color: var(--muted); + white-space: nowrap; +} +.codex-progressive-card { + min-width: 0; + border: 1px solid rgba(78, 183, 168, 0.22); + background: + linear-gradient(135deg, rgba(78, 183, 168, 0.08), rgba(215, 161, 58, 0.035) 46%, transparent), + rgba(6, 10, 13, 0.84); +} +.codex-progressive-card-head { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + padding: 8px 10px; + border-bottom: 1px solid rgba(255,255,255,0.055); +} +.codex-progressive-card-head strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.codex-progressive-card-head code { + margin-left: auto; + color: var(--accent-2); + white-space: nowrap; +} +.codex-judge-feedback-prompt { + border-color: rgba(148, 190, 255, 0.26); + background: + linear-gradient(135deg, rgba(148, 190, 255, 0.09), rgba(78, 183, 168, 0.045) 52%, transparent), + rgba(6, 10, 13, 0.86); +} +.codex-judge-feedback-prompt .codex-output-channel { + color: #a9c7ff; + border-color: rgba(148, 190, 255, 0.44); + background: rgba(148, 190, 255, 0.08); +} +.codex-feedback-preview { + margin: 0; + padding: 0 10px 9px; + color: var(--muted); + font-family: var(--mono); + font-size: 0.78rem; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; +} +.codex-feedback-full { + border-top: 1px solid rgba(255,255,255,0.055); +} +.codex-progressive-card > summary { + display: grid; + gap: 6px; + padding: 0; + cursor: pointer; + list-style: none; +} +.codex-progressive-card > summary::-webkit-details-marker, +.codex-trace-step > summary::-webkit-details-marker { + display: none; +} +.codex-execution-digest { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-width: 0; + padding: 0 10px 8px; +} +.codex-execution-digest.expanded { + padding-top: 8px; + border-top: 1px solid rgba(255,255,255,0.055); +} +.codex-execution-digest span { + max-width: 100%; + padding: 3px 7px; + border: 1px solid rgba(78, 183, 168, 0.18); + color: #a7c7c3; + background: rgba(78, 183, 168, 0.045); + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.codex-trace-step-list { + display: grid; + gap: 7px; + padding: 8px; +} +.codex-trace-step { + min-width: 0; + border: 1px solid rgba(255,255,255,0.08); + background: rgba(255,255,255,0.025); +} +.codex-trace-step > summary { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; + padding: 7px 9px; + cursor: pointer; +} +.codex-trace-step > summary strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.codex-trace-step > summary time { + margin-left: auto; + color: var(--muted); + font-size: 10px; + white-space: nowrap; +} +.codex-trace-step > summary code { + color: var(--accent-2); + white-space: nowrap; +} +.codex-trace-step-summary { + display: grid; + gap: 2px; + padding: 0 9px 8px; +} +.codex-trace-step-summary pre { + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #d9e8e7; + font-size: 11px; + line-height: 1.42; +} +.codex-step-detail-transcript { + min-height: 0; + max-height: 520px; + margin: 0 8px 8px; + padding: 8px; + background: rgba(4, 8, 10, 0.88); +} +.codex-progressive-prompt .codex-prompt-full, +.codex-final-response .codex-transcript-body, +.codex-progressive-judge .codex-judge-card { + margin: 8px; +} +.codex-final-response .codex-transcript-body { + max-height: 520px; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.codex-edit-observation { + min-width: 0; + margin-top: 2px; + overflow: hidden; + border: 1px solid rgba(182, 218, 137, 0.28); + background: + linear-gradient(135deg, rgba(182, 218, 137, 0.10), rgba(78, 183, 168, 0.025) 42%, rgba(0,0,0,0)), + rgba(4, 9, 10, 0.92); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.05); +} +.codex-edit-observation-head { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + padding: 7px 9px; + border-bottom: 1px solid rgba(182, 218, 137, 0.18); + color: var(--text); + background: rgba(182, 218, 137, 0.055); +} +.codex-edit-window-controls { + display: inline-flex; + flex: 0 0 auto; + gap: 4px; +} +.codex-edit-window-controls i { + width: 7px; + height: 7px; + border-radius: 999px; + background: rgba(182, 218, 137, 0.72); + box-shadow: 0 0 0 1px rgba(0,0,0,0.35); +} +.codex-edit-window-controls i:nth-child(2) { background: rgba(215, 161, 58, 0.82); } +.codex-edit-window-controls i:nth-child(3) { background: rgba(78, 183, 168, 0.82); } +.codex-edit-observation-head strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + letter-spacing: 0.04em; +} +.codex-edit-observation-head code { + margin-left: auto; + color: #d7f3b8; + white-space: nowrap; +} +.codex-edit-stage-strip { + display: flex; + flex-wrap: wrap; + gap: 5px; + padding: 6px 9px; + border-bottom: 1px solid rgba(255,255,255,0.055); + background: rgba(255,255,255,0.018); +} +.codex-edit-stage { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 100%; + padding: 2px 6px; + border: 1px solid rgba(182, 218, 137, 0.24); + color: #c9e8a6; + background: rgba(182, 218, 137, 0.055); + font-size: 10px; +} +.codex-edit-stage b, +.codex-edit-stage em { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-style: normal; +} +.codex-edit-stage.completed { + border-color: rgba(78, 183, 168, 0.34); + color: #bdece4; + background: rgba(78, 183, 168, 0.07); +} +.codex-edit-stage.inprogress { + border-color: rgba(215, 161, 58, 0.34); + color: #ffe0a2; + background: rgba(215, 161, 58, 0.07); +} +.codex-edit-diff { + max-width: 100%; + overflow-x: auto; + overflow-y: hidden; + padding: 7px 0; + scrollbar-width: thin; + scrollbar-color: rgba(182, 218, 137, 0.28) transparent; +} +.codex-edit-diff-line { + display: grid; + grid-template-columns: 34px minmax(max-content, 1fr); + align-items: start; + min-width: max-content; + padding: 1px 10px; + white-space: pre; + color: #d7e6e1; + font-size: 12px; + line-height: 1.45; +} +.codex-edit-diff-line code, +.codex-edit-diff-sign, +.codex-edit-file-status { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; +} +.codex-edit-diff-line code { + color: inherit; + background: transparent; + border: 0; + padding: 0; + white-space: pre; +} +.codex-edit-diff-sign { + color: rgba(215, 232, 231, 0.54); + text-align: center; +} +.codex-edit-diff-line.meta { color: #8fb7b6; } +.codex-edit-diff-line.hunk { + color: #ffd892; + background: rgba(215, 161, 58, 0.075); +} +.codex-edit-diff-line.add { + color: #d8f7c0; + background: rgba(91, 178, 104, 0.12); +} +.codex-edit-diff-line.del { + color: #ffb4a8; + background: rgba(207, 106, 84, 0.12); +} +.codex-edit-diff-line.note { color: #bdece4; } +.codex-edit-diff-line.file { + color: #e2f5cf; + background: rgba(182, 218, 137, 0.075); +} +.codex-edit-file-status { + display: inline-grid; + place-items: center; + width: 20px; + height: 18px; + justify-self: center; + border: 1px solid rgba(182, 218, 137, 0.36); + color: #d8f7c0; + background: rgba(182, 218, 137, 0.08); + font-size: 10px; + font-weight: 800; +} +.codex-edit-file-status.added { border-color: rgba(91, 178, 104, 0.45); color: #d8f7c0; background: rgba(91, 178, 104, 0.12); } +.codex-edit-file-status.deleted { border-color: rgba(207, 106, 84, 0.48); color: #ffb4a8; background: rgba(207, 106, 84, 0.12); } +.codex-edit-file-status.renamed { border-color: rgba(215, 161, 58, 0.46); color: #ffe0a2; background: rgba(215, 161, 58, 0.10); } +.codex-edit-omitted { + padding: 6px 9px; + border-top: 1px solid rgba(255,255,255,0.055); + color: var(--muted); + font-size: 11px; +} +.codex-initial-prompt-full { + min-width: 0; + border: 1px solid rgba(215, 161, 58, 0.28); + background: + linear-gradient(135deg, rgba(215, 161, 58, 0.10), transparent 42%), + rgba(6, 10, 13, 0.80); +} +.codex-initial-prompt-full summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 7px 9px; + color: #ffe0a2; + cursor: pointer; + font-size: 12px; +} +.codex-initial-prompt-full summary span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.codex-initial-prompt-full summary code { + flex: 0 0 auto; + color: var(--muted); +} +.codex-transcript-full-prompt { + max-height: 460px; + overflow: auto; + border-left-color: rgba(215, 161, 58, 0.68); + background: + linear-gradient(90deg, rgba(215, 161, 58, 0.08), transparent 34%), + rgba(5, 8, 10, 0.88); +} .codex-raw-output { border-top: 1px solid var(--line); background: rgba(6, 10, 13, 0.92); @@ -1586,17 +2601,81 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } font-size: 12px; line-height: 1.5; } +.codex-reference-injection { + min-width: 0; + border: 1px solid rgba(78, 183, 168, 0.24); + background: + linear-gradient(135deg, rgba(78, 183, 168, 0.08), transparent 38%), + rgba(6, 10, 13, 0.72); +} +.codex-reference-injection summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 0; + padding: 8px 10px; + color: var(--accent-2); + cursor: pointer; + font-size: 12px; +} +.codex-reference-injection summary span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.codex-reference-injection summary code { + flex: 0 0 auto; + color: var(--muted); +} +.codex-prompt-reference-full { + max-height: 460px; + border-color: rgba(78, 183, 168, 0.28); + border-left-color: rgba(78, 183, 168, 0.62); + background: + linear-gradient(90deg, rgba(78, 183, 168, 0.08), transparent 34%), + rgba(6, 10, 13, 0.86); +} +.codex-prompt-final-full { + max-height: 560px; + border-color: rgba(215, 161, 58, 0.34); + border-left-color: rgba(215, 161, 58, 0.72); + background: + linear-gradient(90deg, rgba(215, 161, 58, 0.10), transparent 34%), + rgba(6, 10, 13, 0.90); +} .codex-judge-card { display: grid; gap: 8px; + min-width: 0; + max-width: 100%; + overflow: hidden; padding: 10px; border: 1px solid var(--line-soft); background: var(--panel-3); } +.codex-judge-card > * { + min-width: 0; + max-width: 100%; +} +.codex-judge-card p, +.codex-judge-card pre, +.codex-judge-card code, +.codex-judge-card strong { + overflow-wrap: anywhere; + word-break: break-word; +} .codex-judge-card p { margin: 0; color: var(--muted); } +.codex-judge-card pre, +.codex-judge-card code { + display: block; + margin: 0; + white-space: pre-wrap; +} .codex-attempt-table { max-height: 260px; } @@ -1869,12 +2948,20 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } padding: 4px 8px; } .pipeline-control-shell { + position: relative; display: grid; grid-template-columns: minmax(620px, 1fr) minmax(320px, 0.38fr); gap: 10px; align-items: stretch; } +.pipeline-control-shell.detail-collapsed { + grid-template-columns: minmax(0, 1fr); +} +.pipeline-control-shell.detail-open { + grid-template-columns: minmax(620px, 1fr) minmax(320px, 0.38fr); +} .pipeline-flow-frame { + position: relative; height: min(68vh, 720px); min-height: 520px; border: 1px solid var(--line-soft); @@ -1883,6 +2970,34 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } linear-gradient(135deg, rgba(215, 161, 58, 0.09), transparent 44%), #0b1319; } +.pipeline-sidecar-tab { + min-height: 34px; + padding: 7px 11px; + border: 1px solid rgba(215, 161, 58, 0.44); + border-radius: 999px; + background: #0b1319; + color: var(--text); + box-shadow: 0 14px 32px rgba(0, 0, 0, 0.38); + font-weight: 800; + letter-spacing: 0.02em; + cursor: pointer; +} +.pipeline-sidecar-tab.right { + justify-self: end; +} +.pipeline-flow-frame .pipeline-sidecar-tab { + position: absolute; + z-index: 55; + top: 12px; + right: 12px; +} +.pipeline-sidecar-tab:disabled { + cursor: default; + color: var(--muted); + border-color: rgba(129, 147, 159, 0.26); + background: #0a1117; + box-shadow: none; +} .pipeline-flow-frame .react-flow__pane { cursor: grab; } .pipeline-flow-frame .react-flow__controls { border: 1px solid var(--line); @@ -2077,14 +3192,28 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } gap: 8px; } .pipeline-gantt-detail-layout { + position: relative; display: grid; grid-template-columns: minmax(0, 1fr) minmax(340px, 0.36fr); gap: 10px; align-items: start; } +.pipeline-gantt-detail-layout.detail-collapsed { + grid-template-columns: minmax(0, 1fr); +} +.pipeline-gantt-detail-layout.detail-open { + grid-template-columns: minmax(0, 1fr) minmax(340px, 0.36fr); +} .pipeline-gantt-main { min-width: 0; } +.pipeline-gantt-main-head { + display: flex; + gap: 8px; + align-items: flex-start; + justify-content: space-between; + min-width: 0; +} .pipeline-gantt-meta { display: flex; flex-wrap: wrap; @@ -2395,10 +3524,22 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } gap: 10px; align-items: start; } +.pipeline-gantt-detail-head > div:first-child, +.pipeline-node-control-head > div:first-child { + min-width: 0; +} .pipeline-gantt-detail-head h3 { margin: 0; overflow-wrap: anywhere; } +.pipeline-gantt-detail-head-actions, +.pipeline-node-control-head-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + justify-content: flex-end; + align-items: center; +} .pipeline-gantt-detail-actions { display: flex; flex-wrap: wrap; @@ -2485,6 +3626,13 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } display: grid; gap: 10px; } +.pipeline-minimax-quota-panel { + display: grid; + gap: 10px; +} +.pipeline-minimax-quota-panel .metric-card { + min-width: 0; +} .pipeline-oa-guarantees { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); @@ -2561,374 +3709,6 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } background: rgba(207, 106, 84, 0.12); color: #f0b7a8; } -.pipeline-opencode-timeline { - display: grid; - gap: 9px; - max-height: min(76vh, 780px); - min-width: 0; - overflow-y: auto; - overflow-x: hidden; - padding: 9px; - border: 1px solid rgba(105, 174, 232, 0.2); - background: - linear-gradient(180deg, rgba(105, 174, 232, 0.06), transparent 180px), - rgba(3, 8, 12, 0.34); -} -.pipeline-opencode-timeline-head { - position: sticky; - top: -9px; - z-index: 2; - display: flex; - justify-content: space-between; - align-items: start; - gap: 12px; - padding: 8px 6px 9px; - border-bottom: 1px solid rgba(105, 174, 232, 0.16); - background: rgba(7, 14, 20, 0.94); - backdrop-filter: blur(10px); - min-width: 0; -} -.pipeline-opencode-timeline-head b { - color: var(--text); - letter-spacing: 0.04em; -} -.pipeline-opencode-timeline-head span { - display: block; - color: var(--muted); - font-size: 11px; - line-height: 1.35; -} -.pipeline-opencode-session-head { - display: grid; - justify-items: start; - gap: 6px; - min-width: 0; - max-width: 100%; -} -.pipeline-opencode-flow { - display: grid; - gap: 9px; - position: relative; - width: 100%; - box-sizing: border-box; - padding-left: 12px; - min-width: 0; - overflow-x: clip; -} -.pipeline-opencode-flow::before { - content: ""; - display: none; -} -.pipeline-opencode-step { - position: relative; - overflow: hidden; - min-width: 0; - width: 100%; - box-sizing: border-box; - border: 1px solid rgba(255,255,255,0.085); - background: - linear-gradient(120deg, rgba(78, 183, 168, 0.06), transparent 42%), - rgba(0,0,0,0.18); - box-shadow: 0 10px 24px rgba(0,0,0,0.16); -} -.pipeline-opencode-step::before { - content: ""; - position: absolute; - left: -12px; - top: 18px; - width: 9px; - height: 9px; - border: 1px solid rgba(78, 183, 168, 0.7); - background: #0a1517; - box-shadow: 0 0 0 3px rgba(78, 183, 168, 0.12); -} -.pipeline-opencode-step.user { - background: - linear-gradient(120deg, rgba(105, 174, 232, 0.09), transparent 42%), - rgba(0,0,0,0.18); -} -.pipeline-opencode-step.failed { - border-color: rgba(207, 106, 84, 0.38); - box-shadow: inset 2px 0 0 rgba(207, 106, 84, 0.7), 0 10px 24px rgba(0,0,0,0.16); -} -.pipeline-opencode-step.running { - border-color: rgba(215, 161, 58, 0.38); - box-shadow: inset 2px 0 0 rgba(215, 161, 58, 0.72), 0 10px 24px rgba(0,0,0,0.16); -} -.pipeline-opencode-step.matched { - border-color: rgba(215, 161, 58, 0.48); - box-shadow: inset 3px 0 0 rgba(215, 161, 58, 0.78), 0 0 0 1px rgba(215, 161, 58, 0.12), 0 12px 28px rgba(0,0,0,0.18); -} -.pipeline-opencode-step > summary { - display: grid; - gap: 6px; - align-items: start; - padding: 8px; - cursor: pointer; - list-style: none; - min-width: 0; -} -.pipeline-opencode-step > summary::-webkit-details-marker { - display: none; -} -.pipeline-opencode-step[open] > summary { - border-bottom: 1px solid rgba(255,255,255,0.08); - background: rgba(255,255,255,0.018); -} -.pipeline-opencode-step-body { - display: grid; - gap: 8px; - padding: 9px; -} -.pipeline-step-role, -.pipeline-tool-badge, -.pipeline-part-kind { - display: inline-flex; - width: fit-content; - align-items: center; - justify-content: center; - padding: 2px 6px; - border: 1px solid rgba(255,255,255,0.12); - background: rgba(255,255,255,0.045); - color: var(--muted); - font-size: 10px; - font-weight: 700; - letter-spacing: 0.11em; - text-transform: uppercase; -} -.pipeline-step-role.user { - border-color: rgba(105, 174, 232, 0.38); - background: rgba(105, 174, 232, 0.12); - color: #c7e5ff; -} -.pipeline-step-role.assistant { - border-color: rgba(78, 183, 168, 0.34); - background: rgba(78, 183, 168, 0.11); - color: #bfe7dd; -} -.pipeline-step-role.system { - border-color: rgba(215, 161, 58, 0.35); - background: rgba(215, 161, 58, 0.11); - color: #f0d499; -} -.pipeline-step-time-card { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 3px 8px; - min-width: 0; - padding: 0 0 5px; - border-bottom: 1px solid rgba(255,255,255,0.08); - background: none; -} -.pipeline-step-time-card strong { - color: #d7e5e6; - font-size: 11px; - letter-spacing: 0.03em; - min-width: 0; -} -.pipeline-step-time-meta { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - align-items: center; - gap: 4px; - margin-left: auto; - min-width: 0; -} -.pipeline-step-time-card span { - color: var(--muted); - font-size: 9px; - min-width: 0; -} -.pipeline-step-message-card { - display: grid; - gap: 8px; - min-width: 0; - padding: 7px 8px; - border: 1px solid rgba(255,255,255,0.08); - background: - linear-gradient(90deg, rgba(78, 183, 168, 0.05), transparent 62%), - rgba(4, 9, 13, 0.54); -} -.pipeline-step-message-card.compact { - gap: 4px; - padding: 5px 6px; -} -.pipeline-step-message-card.user, -.pipeline-step-message-card.system { - background: - linear-gradient(90deg, rgba(105, 174, 232, 0.07), transparent 62%), - rgba(4, 9, 13, 0.56); -} -.pipeline-step-message-card-head { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 6px; - min-width: 0; -} -.pipeline-step-message-rows { - display: grid; - gap: 7px; - min-width: 0; -} -.pipeline-step-message-row.compact { - display: grid; - gap: 3px; - min-width: 0; -} -.pipeline-step-message-row.compact p { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: 4px 7px; -} -.pipeline-step-message-row.compact p { - margin: 0; - color: #d4e3e4; - white-space: normal; - overflow-wrap: anywhere; - line-height: 1.32; - min-width: 0; - display: -webkit-box; - overflow: hidden; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; -} -.pipeline-step-message-row.compact p b { - flex: 0 0 auto; -} -.pipeline-step-message-row.compact p span { - min-width: 0; - flex: 1 1 220px; - display: -webkit-box; - overflow: hidden; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; -} -.pipeline-step-message-row.compact.reasoning p { - color: #ead8a9; -} -.pipeline-step-message-row.expanded .pipeline-step-text-stack { - gap: 6px; -} -.pipeline-step-message-row.expanded .pipeline-step-text-block { - background: rgba(255,255,255,0.02); -} -.pipeline-step-tool-summary { - display: grid; - gap: 4px; - min-width: 0; - padding: 6px 7px; - border: 1px solid rgba(255,255,255,0.08); - background: - linear-gradient(90deg, rgba(105, 174, 232, 0.06), transparent 60%), - rgba(5, 10, 14, 0.52); -} -.pipeline-step-tool-summary.empty { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 6px 8px; -} -.pipeline-step-tool-summary-list { - display: grid; - gap: 5px; - min-width: 0; -} -.pipeline-step-tool-summary-item { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: 4px 7px; - min-width: 0; -} -.pipeline-step-tool-summary-item span { - color: var(--accent-2); - font-size: 10px; - letter-spacing: 0.12em; - text-transform: uppercase; - flex: 0 0 auto; -} -.pipeline-step-tool-summary-item p, -.pipeline-step-tool-summary > p { - margin: 0; - color: #d4e3e4; - white-space: normal; - overflow-wrap: anywhere; - line-height: 1.32; - min-width: 0; - flex: 1 1 220px; - display: -webkit-box; - overflow: hidden; - -webkit-line-clamp: 1; - -webkit-box-orient: vertical; -} -.pipeline-step-tool-summary small { - color: var(--muted); - font-size: 10px; -} -.pipeline-step-factbar { - display: flex; - flex-wrap: wrap; - gap: 6px; - align-items: center; -} -.pipeline-step-token-line { - color: var(--muted); - font-size: 10px; -} -.pipeline-step-text-stack { - display: grid; - gap: 7px; -} -.pipeline-step-text-block { - display: grid; - gap: 5px; - padding: 8px; - border: 1px solid rgba(255,255,255,0.07); - background: - linear-gradient(90deg, rgba(78, 183, 168, 0.055), transparent 56%), - rgba(4, 9, 13, 0.5); -} -.pipeline-step-text-block.user, -.pipeline-step-text-block.user-text { - border-color: rgba(105, 174, 232, 0.18); - background: - linear-gradient(90deg, rgba(105, 174, 232, 0.08), transparent 56%), - rgba(4, 9, 13, 0.52); -} -.pipeline-step-text-block.reasoning { - border-color: rgba(215, 161, 58, 0.16); - background: - linear-gradient(90deg, rgba(215, 161, 58, 0.07), transparent 56%), - rgba(4, 9, 13, 0.44); -} -.pipeline-step-text-block.failed { - border-color: rgba(207, 106, 84, 0.28); - background: rgba(207, 106, 84, 0.08); -} -.pipeline-step-text-block b, -.pipeline-tool-call-title b, -.pipeline-opencode-part-body b { - color: var(--accent); - font-size: 10px; - letter-spacing: 0.12em; - text-transform: uppercase; -} -.pipeline-step-text-block p { - margin: 0; - color: #bfd3d4; - white-space: pre-wrap; - overflow-wrap: anywhere; - line-height: 1.45; -} -.pipeline-step-text-overflow { - color: rgba(191, 211, 212, 0.72); - font-size: 11px; - letter-spacing: 0.02em; -} .pipeline-structured-payload { display: grid; gap: 6px; @@ -2955,66 +3735,6 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } font-size: 10px; text-transform: uppercase; } -.pipeline-tool-call-strip { - display: grid; - gap: 6px; - padding: 8px; - border: 1px solid rgba(78, 183, 168, 0.12); - background: rgba(78, 183, 168, 0.035); -} -.pipeline-tool-call-title { - display: flex; - justify-content: space-between; - gap: 8px; -} -.pipeline-tool-call-title span { - color: var(--muted); - font-size: 11px; -} -.pipeline-opencode-part { - border: 1px solid rgba(255,255,255,0.08); - background: rgba(0,0,0,0.15); -} -.pipeline-opencode-part.tool.failed { - border-color: rgba(207, 106, 84, 0.34); -} -.pipeline-opencode-part.tool.succeeded { - border-color: rgba(78, 183, 168, 0.2); -} -.pipeline-opencode-part > summary { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 6px; - padding: 8px; - cursor: pointer; -} -.pipeline-opencode-part > summary::-webkit-details-marker { - display: none; -} -.pipeline-step-index { - color: var(--accent-2); - font-size: 10px; - letter-spacing: 0.13em; - text-transform: uppercase; -} -.pipeline-step-match-badge { - display: inline-flex; - width: fit-content; - padding: 2px 6px; - border: 1px solid rgba(215, 161, 58, 0.46); - color: #f2da9f; - background: rgba(215, 161, 58, 0.12); - font-size: 10px; - letter-spacing: 0.08em; - text-transform: uppercase; -} -.pipeline-opencode-part-body { - display: grid; - gap: 8px; - padding: 8px; - border-top: 1px solid var(--line-soft); -} .pipeline-text-preview { margin: 0; padding: 7px 8px; @@ -3041,33 +3761,6 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } color: var(--muted); overflow-wrap: anywhere; } -.pipeline-opencode-part-list { - display: grid; - gap: 6px; -} -.pipeline-opencode-part-list.reasoning-list { - gap: 5px; -} -@media (max-width: 1120px) { - .pipeline-step-time-meta { - margin-left: 0; - justify-content: flex-start; - } -} -@media (max-width: 760px) { - .pipeline-opencode-timeline-head { - flex-direction: column; - align-items: stretch; - } - .pipeline-opencode-session-head { - justify-items: start; - min-width: 0; - } - .pipeline-step-tool-summary-item p, - .pipeline-step-tool-summary > p { - flex-basis: 100%; - } -} @keyframes ganttPulse { 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); } @@ -3419,6 +4112,8 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } } .empty-state strong { color: var(--text); } .muted { color: var(--muted); } +.block { display: block; } +.preline { white-space: pre-line; } .login-screen, .loading-screen { min-height: 100vh; @@ -3446,6 +4141,51 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } color: #ffd7cf; background: rgba(207,106,84,0.1); } +.unidesk-error { + display: grid; + gap: 8px; + line-height: 1.45; +} +.unidesk-error-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.unidesk-error-title strong { color: #ffe6df; } +.unidesk-error-code { + flex: 0 0 auto; + padding: 2px 7px; + border: 1px solid rgba(255,215,207,0.25); + border-radius: 999px; + color: #fff2ed; + background: rgba(255,215,207,0.08); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.unidesk-error-message { + margin: 0; + color: #ffd7cf; + white-space: pre-wrap; + overflow-wrap: anywhere; + font: inherit; +} +.unidesk-error-details { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 4px 10px; + margin: 0; +} +.unidesk-error-details dt { + color: rgba(255,215,207,0.72); + font-size: 12px; +} +.unidesk-error-details dd { + margin: 0; + color: #fff2ed; + overflow-wrap: anywhere; +} .form-success { padding: 7px 8px; border: 1px solid rgba(78,183,168,0.5); @@ -3487,16 +4227,90 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } line-height: 1.45; } +.project-manager-page { + display: grid; + gap: 10px; +} +.project-manager-hero { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + align-items: stretch; +} +.project-manager-layout { + display: grid; + grid-template-columns: minmax(620px, 1.55fr) minmax(320px, 0.75fr); + gap: 10px; + align-items: start; +} +.project-manager-table { + max-height: calc(100vh - 340px); +} +.project-manager-table table { + min-width: 1120px; +} +.project-manager-table tr.active-row { + background: rgba(78, 183, 168, 0.08); +} +.project-manager-table td { + max-width: 280px; +} +.project-manager-form textarea { + min-height: 82px; +} +.project-manager-filters { + margin-top: 0; +} +.project-manager-filters input { + width: min(320px, 42vw); +} +.project-manager-import { + display: grid; + gap: 8px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--line-soft); +} +.file-import { + position: relative; + display: inline-flex; + width: max-content; + min-height: 28px; + align-items: center; + padding: 4px 9px; + border: 1px solid var(--line); + color: var(--muted); + background: rgba(12, 18, 24, 0.62); + cursor: pointer; +} +.file-import:hover { + color: var(--text); + border-color: var(--accent); +} +.file-import input { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; +} + @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)); } + .metric-grid, .policy-grid, .security-board, .docker-metrics, .monitor-chart-grid, .monitor-summary-grid, .performance-metric-stack, .codex-load-test-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, .codex-queue-layout, .codex-queue-hero, .codex-detail-grid, .codex-session-shell { grid-template-columns: 1fr; } - .codex-session-sidebar { border-right: 0; border-bottom: 1px solid var(--line); } - .pipeline-control-shell { grid-template-columns: 1fr; } + .page-grid, .docker-layout, .monitor-layout, .performance-top-grid, .performance-grid, .findjob-grid, .findjob-hero, .pipeline-grid, .pipeline-hero, .met-grid, .met-form-grid, .codex-queue-layout, .codex-queue-hero, .codex-detail-grid, .project-manager-hero, .project-manager-layout { grid-template-columns: 1fr; } + .codex-session-shell { grid-template-columns: minmax(260px, 0.42fr) minmax(0, 1fr); position: relative; } + .codex-session-shell.queue-collapsed { grid-template-columns: minmax(0, 1fr); } + .codex-session-sidebar { border-right: 1px solid var(--line); border-bottom: 0; } + .pipeline-control-shell, + .pipeline-control-shell.detail-open, + .pipeline-control-shell.detail-collapsed, + .pipeline-gantt-detail-layout, + .pipeline-gantt-detail-layout.detail-open, + .pipeline-gantt-detail-layout.detail-collapsed { 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; } + .findjob-grid .panel:nth-child(3), .claudeqq-page .findjob-grid .panel:nth-child(n+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; } .gateway-record-grid { grid-template-columns: 1fr; } .overview-grid .panel:nth-child(n+3), .dispatch-grid .panel:first-child, .topology-grid .panel:nth-child(3) { grid-column: 1; } } @@ -3507,17 +4321,48 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } .pipeline-control-meta, .pipeline-control-evidence-grid, .pipeline-evidence-row, - .pipeline-gantt-detail-layout, .pipeline-oa-guarantees, .pipeline-kv-grid, - .pipeline-field-list { + .pipeline-field-list, + .performance-metric-stack, + .codex-load-test-grid { grid-template-columns: 1fr; } - .pipeline-gantt-actions { justify-content: stretch; } - .pipeline-gantt-actions select { width: 100%; } + .performance-hero { flex-direction: column; } + .pipeline-wide-panel .panel-head { + align-items: flex-start; + flex-direction: column; + } + .pipeline-wide-panel .panel-head > div:first-child, + .pipeline-wide-panel .panel-actions { + width: 100%; + } + .pipeline-gantt-actions { display: grid; grid-template-columns: 1fr; justify-content: stretch; width: 100%; } + .pipeline-gantt-actions select { width: 100%; max-width: 100%; } + .pipeline-gantt-scale { grid-template-columns: 1fr; grid-template-areas: "label" "slider" "legend"; min-width: 0; width: 100%; } + .pipeline-gantt-scale em { white-space: normal; } + .pipeline-gantt-toggle { width: 100%; justify-content: flex-start; } + .pipeline-gantt-actions .ghost-btn { width: 100%; } .pipeline-gantt-bar { left: 50%; width: 5px; padding: 0; } .pipeline-gantt-bar.selected { width: 9px; } - .pipeline-gantt-detail-panel { position: static; max-height: none; } + .pipeline-control-shell.detail-open, + .pipeline-gantt-detail-layout.detail-open { + display: block; + position: relative; + } + .pipeline-control-shell.detail-open .pipeline-node-control, + .pipeline-gantt-detail-layout.detail-open .pipeline-gantt-detail-panel { + position: absolute; + z-index: 24; + inset: 0 0 auto auto; + width: min(88vw, 360px); + max-height: min(78vh, 720px); + min-height: min(68vh, 420px); + box-shadow: -24px 0 48px rgba(0, 0, 0, 0.46); + } + .pipeline-gantt-detail-layout.detail-collapsed .pipeline-gantt-detail-panel { display: none; } + .pipeline-gantt-main-head { align-items: stretch; flex-direction: column; } + .pipeline-sidecar-tab.right { width: 100%; } .pipeline-flow-frame { min-height: 430px; } @@ -3598,6 +4443,28 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } min-height: 24px; padding: 2px 6px; } + .top-status-bar { + min-width: 0; + flex: 1 1 auto; + gap: 5px; + padding: 4px; + overflow-x: auto; + } + .top-status-main { + min-width: 0; + } + .global-top-status .top-status-title, + .global-top-status .top-status-chip:not([data-testid="conn-text"]):not(.user) { + display: none; + } + .top-status-chip { + min-height: 22px; + padding: 2px 6px; + } + .top-status-actions .ghost-btn { + min-height: 24px; + padding: 2px 6px; + } .tabs { height: 38px; align-items: center; @@ -3612,9 +4479,39 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } 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, .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; } + .compact-row, .heartbeat-row, .log-row, .endpoint-list article, .volume-route, .findjob-hero, .pipeline-hero, .codex-queue-hero, .claudeqq-login-card { grid-template-columns: 1fr; align-items: start; } .codex-output-line { grid-template-columns: 1fr; } .codex-transcript { min-height: 360px; } + .codex-output-panel .panel-head { align-items: flex-start; flex-direction: column; } + .codex-output-panel .panel-head > div:first-child, + .codex-output-panel .panel-actions { width: 100%; } + .codex-output-panel .panel-actions { justify-content: flex-start; } + .codex-queue-switcher { width: 100%; min-width: 0; } + .codex-submit-queue-row { grid-template-columns: 1fr; } + .codex-create-queue-btn { width: 100%; } + .codex-session-title-toggle { min-height: 40px; padding: 9px 15px; font-size: 14px; } + .codex-task-move-control { grid-template-columns: 1fr; } + .codex-task-move-control .ghost-btn { width: 100%; } + .codex-session-shell { + display: block; + position: relative; + } + .codex-session-sidebar { + position: absolute; + z-index: 24; + inset: 0 auto 0 0; + width: min(86vw, 340px); + max-height: 100%; + border-right: 1px solid var(--line); + border-bottom: 0; + box-shadow: 26px 0 48px rgba(0, 0, 0, 0.44); + } + .codex-session-sidebar .codex-task-list-session { + max-height: calc(100vh - 312px); + } + .codex-session-main { + min-width: 0; + } .process-resource-head { align-items: stretch; flex-direction: column; @@ -3929,3 +4826,182 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } grid-column: 1 / -1; } } + +.codex-transcript-item.toolGroup { + padding: 6px 0; + border-bottom-color: rgba(215, 161, 58, 0.075); +} +.codex-tool-group { + min-width: 0; + border: 1px solid rgba(215, 161, 58, 0.24); + background: + linear-gradient(135deg, rgba(215, 161, 58, 0.12), rgba(78, 183, 168, 0.035) 46%, rgba(255,255,255,0.018)), + rgba(8, 12, 15, 0.92); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.05); +} +.codex-tool-group > summary { + display: grid; + grid-template-columns: minmax(0, 1fr); + align-items: start; + gap: 5px; + position: relative; + min-width: 0; + padding: 8px 34px 8px 0; + cursor: pointer; + color: var(--text); + list-style: none; +} +.codex-tool-group > summary::-webkit-details-marker { display: none; } +.codex-tool-group > summary::before { + content: "+"; + position: absolute; + top: 8px; + right: 8px; + display: inline-grid; + place-items: center; + width: 18px; + height: 18px; + border: 1px solid rgba(215, 161, 58, 0.38); + color: var(--accent); + font-weight: 800; + font-size: 12px; +} +.codex-tool-group[open] > summary::before { content: "-"; } +.codex-tool-group-head { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} +.codex-tool-group > summary strong, +.codex-tool-group-head strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.codex-tool-group > summary time, +.codex-tool-group-head time { + margin-left: auto; + color: var(--muted); + font-size: 10px; + white-space: nowrap; +} +.codex-tool-group > summary code, +.codex-tool-group-head code { + color: var(--accent-2); + white-space: nowrap; +} +.codex-tool-group-digest { + grid-column: 1; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 3px 12px; + min-width: 0; + color: #a7c7c3; + font-size: 11px; + line-height: 1.35; +} +.codex-tool-group-digest span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.codex-tool-group-items { + display: grid; + gap: 0; + padding: 0 0 8px; +} +.codex-transcript-item.nested { + grid-template-columns: minmax(0, 1fr); + gap: 0; + padding: 7px 0; + border-bottom: 1px solid rgba(255,255,255,0.04); +} +.codex-transcript-item.nested:last-child { border-bottom: 0; } +.codex-transcript-item.nested .codex-transcript-bullet { + display: none; +} + +.pipeline-trace-timeline { + display: grid; + gap: 10px; + min-width: 0; + padding: 0; +} +.pipeline-trace-head { + display: flex; + justify-content: space-between; + align-items: start; + gap: 12px; + min-width: 0; + padding: 10px 12px; + border: 1px solid rgba(78, 183, 168, 0.20); + background: + radial-gradient(circle at top right, rgba(215, 161, 58, 0.12), transparent 34%), + linear-gradient(135deg, rgba(78, 183, 168, 0.10), rgba(255,255,255,0.018) 42%, transparent), + #060a0d; +} +.pipeline-trace-head b { + display: block; + color: var(--text); + letter-spacing: 0.04em; +} +.pipeline-trace-head span { + display: block; + color: var(--muted); + font-size: 11px; + line-height: 1.38; +} +.pipeline-trace-session-head { + display: grid; + justify-items: start; + gap: 6px; + min-width: 0; + max-width: 100%; +} +.pipeline-trace-focus { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + min-width: 0; + padding: 8px 10px; + border: 1px solid rgba(215, 161, 58, 0.24); + background: + linear-gradient(135deg, rgba(215, 161, 58, 0.12), rgba(78, 183, 168, 0.04) 48%, rgba(255,255,255,0.018)), + rgba(8, 12, 15, 0.92); + color: var(--text); +} +.pipeline-trace-focus strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.pipeline-trace-focus time { + color: var(--muted); + font-size: 10px; + white-space: nowrap; +} +.pipeline-trace { + min-height: 420px; + max-height: min(76vh, 780px); + margin-top: 0; + border: 1px solid rgba(78, 183, 168, 0.18); +} +.pipeline-trace .codex-tool-group-digest { + grid-template-columns: minmax(0, 1fr); +} +@media (max-width: 760px) { + .pipeline-trace-head, + .pipeline-trace-focus { + grid-template-columns: minmax(0, 1fr); + flex-direction: column; + align-items: stretch; + } + .pipeline-trace { + min-height: 360px; + } +} diff --git a/src/components/frontend/scripts/build.ts b/src/components/frontend/scripts/build.ts new file mode 100644 index 00000000..0635e855 --- /dev/null +++ b/src/components/frontend/scripts/build.ts @@ -0,0 +1,29 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +const componentRoot = join(import.meta.dir, ".."); +const outFile = join(componentRoot, "public", "app.js"); + +const result = await Bun.build({ + entrypoints: [join(componentRoot, "src", "app.tsx")], + target: "browser", + format: "iife", + define: { + "process.env.NODE_ENV": "\"production\"", + "import.meta.env": "{\"MODE\":\"production\"}", + "import.meta.env.MODE": "\"production\"", + }, + minify: true, + sourcemap: "none", +}); + +if (!result.success || result.outputs.length === 0) { + const messages = result.logs.map((item) => item.message).join("; "); + throw new Error(`frontend app build failed: ${messages || "no output"}`); +} + +const bundle = await result.outputs[0].text(); +mkdirSync(dirname(outFile), { recursive: true }); +writeFileSync(outFile, bundle, "utf8"); + +console.log(JSON.stringify({ ok: true, outFile, bytes: Buffer.byteLength(bundle, "utf8") })); diff --git a/src/components/frontend/src/app.tsx b/src/components/frontend/src/app.tsx index 875131eb..9c1f5369 100644 --- a/src/components/frontend/src/app.tsx +++ b/src/components/frontend/src/app.tsx @@ -1,34 +1,53 @@ import React from "react"; import { createRoot } from "react-dom/client"; +import { ClaudeQqPage } from "./claudeqq"; 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"; import { PipelinePage } from "./pipeline"; +import { ProjectManagerPage } from "./project-manager"; import { TodoNotePage } from "./todo-note"; +import { TopStatusBar } from "./top-status"; +import { errorMessage, requestJson } from "./unidesk-error"; +import { UniDeskErrorBanner } from "./unidesk-error-banner"; type AnyRecord = Record; -function readClientConfig(): AnyRecord { - const raw = document.getElementById("root")?.getAttribute("data-config"); - if (!raw) return { apiBaseUrl: "/api", authUsername: "admin" }; +function readRootJsonAttribute(name: string, fallback: any): any { + const raw = document.getElementById("root")?.getAttribute(name); + if (!raw) return fallback; try { const parsed = JSON.parse(raw) as unknown; - return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as AnyRecord : {}; + return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as AnyRecord : fallback; } catch { - return { apiBaseUrl: "/api", authUsername: "admin" }; + return fallback; } } -const cfg: AnyRecord = readClientConfig(); +const cfg: AnyRecord = readRootJsonAttribute("data-config", { apiBaseUrl: "/api", authUsername: "admin" }); +const initialCodexQueueOverview = readRootJsonAttribute("data-codex-overview", null); const h = React.createElement; const { useEffect, useMemo } = React; const useState: any = React.useState; const ROUTE_REGISTRY = createRouteRegistry(MODULES); - -function errorMessage(error: unknown, fallback = "操作失败"): string { - return error instanceof Error ? error.message : String(error || fallback); -} +const fastCodexQueueService = { + id: "codex-queue", + name: "Codex Queue", + providerId: "main-server", + description: "Codex Queue", + repository: { containerName: "codex-queue-backend" }, + backend: { + nodeBaseUrl: "http://codex-queue:4222", + nodeBindHost: "codex-queue", + nodePort: 4222, + public: false, + }, + runtime: { + providerStatus: "loading", + providerName: "main-server", + }, +}; function fmtDate(value: any): string { if (!value) return "--"; @@ -55,6 +74,15 @@ function fmtDuration(seconds: number): string { return `${Math.floor(wholeSeconds / 3600)}h ${Math.floor((wholeSeconds % 3600) / 60)}m`; } +function fmtMs(value: any): string { + const ms = Number(value); + if (!Number.isFinite(ms)) return "--"; + if (ms < 1) return `${Math.max(0, ms).toFixed(1)}ms`; + if (ms < 10) return `${ms.toFixed(1)}ms`; + if (ms < 1000) return `${Math.round(ms)}ms`; + return fmtDuration(ms / 1000); +} + function fmtBytes(value: any): string { const bytes = Number(value); if (!Number.isFinite(bytes) || bytes <= 0) return "--"; @@ -281,26 +309,6 @@ function microserviceRepository(service: any): AnyRecord { return service?.repository && typeof service.repository === "object" && !Array.isArray(service.repository) ? service.repository : {}; } -async function requestJson(path: string, options: AnyRecord = {}): Promise { - const headers = new Headers(options.headers || {}); - if (options.body && !headers.has("content-type")) headers.set("content-type", "application/json"); - const response = await fetch(path, { credentials: "same-origin", ...options, headers }); - const text = await response.text(); - let body = null; - try { - body = text ? JSON.parse(text) : null; - } catch { - body = { text }; - } - if (!response.ok || body?.ok === false) { - const message = body?.error?.message || body?.error || `HTTP ${response.status}`; - const error = new Error(message); - (error as Error & { status?: number }).status = response.status; - throw error; - } - return body; -} - function StatusBadge({ status, children }: AnyRecord) { const normalized = String(status || "unknown").toLowerCase(); return h("span", { className: `status-badge ${normalized}` }, children || status || "unknown"); @@ -442,7 +450,7 @@ function LoginScreen({ onLogin }: AnyRecord) { h("form", { className: "login-form", onSubmit: submit }, h("label", null, "账号", h("input", { name: "username", autoComplete: "username", value: username, onChange: (event: any) => setUsername(event.target.value) })), h("label", null, "密码", h("input", { name: "password", type: "password", autoComplete: "current-password", value: password, onChange: (event: any) => setPassword(event.target.value) })), - error ? h("div", { className: "form-error" }, error) : null, + h(UniDeskErrorBanner, { error }), h("button", { type: "submit", disabled: busy }, busy ? "登录中" : "登录"), ), h("div", { className: "login-note" }, "默认账号由 config.json 注入;公网入口只暴露前端登录面。"), @@ -450,18 +458,25 @@ function LoginScreen({ onLogin }: AnyRecord) { ); } -function TopBar({ connection, lastRefresh, onRefresh, onLogout, session, clock }: AnyRecord) { +function TopBar({ connection, lastRefresh, onRefresh, onLogout, session, clock, activeStatusItems = [] }: AnyRecord) { + const statusItems = [ + { key: "core", label: "核心", value: connection.text, tone: connection.ok ? "ok" : "fail", testId: "conn-text" }, + ...(Array.isArray(activeStatusItems) ? activeStatusItems : []), + { key: "refresh", label: "刷新", value: lastRefresh ? fmtClock(lastRefresh) : "未刷新" }, + { key: "clock", label: "时间", value: fmtClock(clock) }, + { key: "user", label: "用户", value: session?.user?.username || "--", tone: "user" }, + ]; return h("header", { className: "topbar" }, h("div", null, h("p", { className: "eyebrow" }, "Distributed Work Platform"), h("h1", null, "UniDesk 控制平面")), - h("div", { className: "status-strip" }, - h("span", { className: `dot ${connection.ok ? "ok" : "fail"}` }), - h("span", { "data-testid": "conn-text" }, connection.text), - h("span", null, lastRefresh ? `刷新 ${fmtClock(lastRefresh)}` : "未刷新"), - h("span", null, fmtClock(clock)), - h("span", { className: "user-pill" }, session?.user?.username || "--"), - h("button", { type: "button", className: "ghost-btn", onClick: onRefresh }, "刷新"), - h("button", { type: "button", className: "ghost-btn danger", onClick: onLogout }, "退出"), - ), + h(TopStatusBar, { + className: "global-top-status", + title: "状态", + items: statusItems, + actions: [ + h("button", { key: "refresh", type: "button", className: "ghost-btn", onClick: onRefresh }, "刷新"), + h("button", { key: "logout", type: "button", className: "ghost-btn danger", onClick: onLogout }, "退出"), + ], + }), ); } @@ -853,6 +868,288 @@ function MetricChart({ title, metricKey, current, points, detail, tone, testId } ); } +function asArray(value: any): any[] { + return Array.isArray(value) ? value : []; +} + +function summaryRows(snapshot: any): any[] { + const core = asArray(snapshot?.core?.requests?.componentSummary); + const frontend = asArray(snapshot?.frontend?.requests?.componentSummary); + return [...frontend, ...core].sort((left, right) => asNumber(right.requestCount) - asNumber(left.requestCount)); +} + +function operationRows(snapshot: any): any[] { + const core = asArray(snapshot?.core?.operations?.summary); + const frontend = asArray(snapshot?.frontend?.operations?.summary); + return [...frontend, ...core].sort((left, right) => asNumber(right.count) - asNumber(left.count)); +} + +function failureRows(snapshot: any): any[] { + const core = asArray(snapshot?.core?.requests?.recentFailures).map((row: any) => ({ source: "backend", ...row })); + const frontend = asArray(snapshot?.frontend?.requests?.recentFailures).map((row: any) => ({ source: "frontend", ...row })); + return [...frontend, ...core].sort((left, right) => (timeMs(right.at) ?? 0) - (timeMs(left.at) ?? 0)).slice(0, 20); +} + +function slowOperationRows(snapshot: any): any[] { + const core = asArray(snapshot?.core?.operations?.recentSlowOperations); + const frontend = asArray(snapshot?.frontend?.operations?.recentSlowOperations); + return [...frontend, ...core].sort((left, right) => asNumber(right.durationMs) - asNumber(left.durationMs)).slice(0, 20); +} + +function browserMemoryBytes(frontendPerformance: any): number { + const memory = (performance as any).memory; + const used = Number(memory?.usedJSHeapSize); + if (Number.isFinite(used) && used > 0) return used; + const bundleBytes = Number(frontendPerformance?.appBundleBytes); + if (Number.isFinite(bundleBytes) && bundleBytes > 0) return bundleBytes; + return asNumber(frontendPerformance?.process?.heapUsedBytes); +} + +function PerformanceMemoryChart({ points }: AnyRecord) { + const rows = asArray(points); + const values = rows.map((point: any) => asNumber(point.mb)); + const max = Math.max(1, ...values); + const min = Math.max(0, Math.min(...values, 0)); + const range = Math.max(1, max - min); + const chartValues = rows.length > 1 ? rows : [...rows, ...rows]; + const step = chartValues.length <= 1 ? 100 : 100 / (chartValues.length - 1); + const linePoints = chartValues.map((point: any, index: number) => { + const value = asNumber(point.mb); + return `${(index * step).toFixed(2)},${(48 - ((value - min) / range) * 42).toFixed(2)}`; + }).join(" "); + const areaPoints = `0,50 ${linePoints} 100,50`; + const latest = rows.at(-1); + const first = rows[0]; + return h("article", { className: "performance-memory-card", "data-testid": "performance-memory-chart" }, + h("div", { className: "performance-memory-head" }, + h("strong", null, `Bwebui: ${latest ? `${asNumber(latest.mb).toFixed(1)}MB` : "--"}`), + h("span", null, rows.length > 0 ? `${rows.length} samples` : "等待采样"), + ), + h("svg", { viewBox: "0 0 100 50", preserveAspectRatio: "none", role: "img", "aria-label": "Bwebui memory trend" }, + h("polygon", { points: areaPoints }), + h("polyline", { points: linePoints }), + h("line", { x1: "0", x2: "100", y1: "25", y2: "25" }), + ), + h("div", { className: "performance-axis-row" }, + h("span", null, first ? fmtClock(new Date(first.at)) : "--"), + h("span", null, "时间"), + h("span", null, latest ? fmtClock(new Date(latest.at)) : "--"), + ), + h("div", { className: "performance-axis-row" }, + h("span", null, `${min.toFixed(1)}`), + h("span", null, "(MB)"), + h("span", null, `${max.toFixed(1)}`), + ), + ); +} + +function PerformancePage({ onRaw }: AnyRecord) { + const [snapshot, setSnapshot] = useState({ core: null, frontend: null }); + const [memorySamples, setMemorySamples] = useState([]); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const [codexPerf, setCodexPerf] = useState(null); + const [codexPerfLoading, setCodexPerfLoading] = useState(false); + + async function load(): Promise { + setLoading(true); + setError(""); + try { + const [core, frontend] = await Promise.all([ + requestJson(`${cfg.apiBaseUrl}/performance`, { cache: "no-store" }), + requestJson(`${cfg.apiBaseUrl}/frontend-performance`, { cache: "no-store" }), + ]); + setSnapshot({ core, frontend }); + const bytes = browserMemoryBytes(frontend); + setMemorySamples((prev: any[]) => [...prev, { at: new Date().toISOString(), mb: bytes / (1024 * 1024) }].slice(-80)); + } catch (err) { + setError(errorMessage(err, "性能指标加载失败")); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void load(); + const timer = setInterval(() => void load(), 5000); + return () => clearInterval(timer); + }, []); + + async function runCodexQueueLoadTest(): Promise { + setCodexPerfLoading(true); + setError(""); + setCodexPerf(null); + try { + const result = await requestJson(`${cfg.apiBaseUrl}/codex-queue-load-test`, { + method: "POST", + body: JSON.stringify({ + targetMs: 1000, + timeoutMs: 90000, + url: cfg.frontendPublicUrl || window.location.origin, + }), + }); + setCodexPerf(result); + void load(); + } catch (err) { + setError(errorMessage(err, "Codex Queue Playwright 测量失败")); + } finally { + setCodexPerfLoading(false); + } + } + + const components = summaryRows(snapshot); + const failures = failureRows(snapshot); + const operations = operationRows(snapshot); + const slowRows = slowOperationRows(snapshot); + const backendProcess = snapshot.core?.process || {}; + const frontendProcess = snapshot.frontend?.process || {}; + const codexStorage = snapshot.core?.database?.codexQueueStorage || {}; + const codexTotal = asNumber(codexStorage.total); + const codexPerfResult = codexPerf?.result || {}; + const codexPerfWallMs = asNumber(codexPerfResult.wallMs, NaN); + const codexPerfNetworkIdleMs = asNumber(codexPerfResult.networkIdleMs, NaN); + const codexPerfWithinTarget = codexPerfResult.withinTarget === true; + const codexPerfStatus = codexPerfLoading + ? "running" + : codexPerf === null + ? "idle" + : codexPerf.measurementOk === true + ? codexPerfWithinTarget ? "passed" : "slow" + : "failed"; + + return h("div", { className: "performance-page", "data-testid": "performance-page" }, + h("div", { className: "performance-hero" }, + h("div", null, + h("p", { className: "panel-eyebrow" }, "Unified Performance"), + h("h2", null, "性能面板"), + h("p", null, "按组件统计 HTTP 请求、失败率、P95 延迟,并汇总 backend/frontend 内部操作耗时。"), + ), + h("div", { className: "inline-actions" }, + h("button", { type: "button", className: "ghost-btn", onClick: () => void runCodexQueueLoadTest(), disabled: codexPerfLoading, "data-testid": "codex-queue-load-test-button" }, codexPerfLoading ? "测试中..." : "测试 Codex Queue 加载"), + h("button", { type: "button", className: "ghost-btn", onClick: () => void load(), disabled: loading, "data-testid": "performance-refresh-button" }, loading ? "刷新中" : "刷新"), + h(RawButton, { title: "Performance Snapshot", data: snapshot, onOpen: onRaw, testId: "raw-performance" }), + ), + ), + h(UniDeskErrorBanner, { error }), + h("div", { className: "performance-top-grid" }, + h(PerformanceMemoryChart, { points: memorySamples }), + h("div", { className: "performance-metric-stack" }, + h(MetricCard, { label: "backend RSS", value: fmtBytes(backendProcess.rssBytes), hint: `heap ${fmtBytes(backendProcess.heapUsedBytes)}` }), + h(MetricCard, { label: "frontend RSS", value: fmtBytes(frontendProcess.rssBytes), hint: `bundle ${fmtBytes(snapshot.frontend?.appBundleBytes)}` }), + h(MetricCard, { label: "Codex PG 任务", value: codexTotal || "--", hint: codexStorage.ok ? "unidesk_codex_queue_tasks" : "等待表初始化", tone: codexStorage.ok ? "ok" : "warn" }), + h(MetricCard, { label: "请求样本", value: asNumber(snapshot.core?.requests?.sampleCount) + asNumber(snapshot.frontend?.requests?.sampleCount), hint: "rolling window 3000" }), + ), + ), + h(Panel, { + title: "Codex Queue 加载基准", + eyebrow: "Playwright / target <1s", + className: "codex-load-test-panel", + actions: h("div", { className: "panel-actions" }, + h("button", { type: "button", className: "primary-btn", onClick: () => void runCodexQueueLoadTest(), disabled: codexPerfLoading, "data-testid": "codex-queue-load-test-panel-button" }, codexPerfLoading ? "正在运行 Playwright..." : "手动触发测试"), + codexPerf ? h(RawButton, { title: "Codex Queue Load Test", data: codexPerf, onOpen: onRaw, testId: "raw-codex-queue-load-test" }) : null, + ), + }, + h("div", { className: "codex-load-test-grid", "data-testid": "codex-queue-load-test-result" }, + h(MetricCard, { + label: "总耗时", + value: codexPerfLoading ? "运行中" : Number.isFinite(codexPerfWallMs) ? fmtMs(codexPerfWallMs) : "--", + hint: codexPerf === null ? "点击按钮启动远端 Playwright" : `目标 ${fmtMs(codexPerfResult.targetMs || 1000)} / ${codexPerfResult.url || "Codex Queue"}`, + tone: codexPerfStatus === "passed" ? "ok" : codexPerfStatus === "failed" || codexPerfStatus === "slow" ? "warn" : "", + }), + h(MetricCard, { + label: "判定", + value: codexPerfLoading ? "RUNNING" : codexPerfStatus === "passed" ? "PASS <1s" : codexPerfStatus === "slow" ? "SLOW" : codexPerfStatus === "failed" ? "FAILED" : "--", + hint: codexPerf?.measurementOk === false ? String(codexPerf.error || codexPerfResult.error || "measurement failed").slice(0, 120) : "导航开始 -> DOMContentLoaded -> data-load-state=complete", + tone: codexPerfStatus === "passed" ? "ok" : codexPerfStatus === "idle" || codexPerfStatus === "running" ? "" : "fail", + }), + h(MetricCard, { + label: "Network idle", + value: Number.isFinite(codexPerfNetworkIdleMs) ? fmtMs(codexPerfNetworkIdleMs) : "--", + hint: `DOMContentLoaded ${fmtMs(codexPerfResult.domContentLoadedMs)} / ${codexPerfResult.networkIdleReached === false ? "未在 5s 内空闲" : "已空闲"}`, + tone: Number.isFinite(codexPerfNetworkIdleMs) && codexPerfNetworkIdleMs <= 1000 ? "ok" : "warn", + }), + h(MetricCard, { + label: "组件耗时", + value: Number.isFinite(asNumber(codexPerfResult.componentLoadMs, NaN)) ? fmtMs(codexPerfResult.componentLoadMs) : "--", + hint: `queue ${fmtMs(codexPerfResult.queueMs)} / detail ${fmtMs(codexPerfResult.detailMs)}`, + tone: asNumber(codexPerfResult.componentLoadMs) > 1000 ? "warn" : "ok", + }), + h(MetricCard, { + label: "Trace 规模", + value: Number.isFinite(asNumber(codexPerfResult.transcriptRows, NaN)) ? String(codexPerfResult.transcriptRows) : "--", + hint: `${codexPerfResult.visibleTaskCount ?? 0} visible tasks / ${codexPerfResult.partial ? "preview" : "complete"}`, + }), + ), + codexPerfLoading ? h("div", { className: "performance-empty-line" }, "正在通过 main-server Host SSH 启动 Playwright,完成后会显示 wall time、组件耗时和最慢 API。") : null, + codexPerf && Array.isArray(codexPerfResult.slowestApi) && codexPerfResult.slowestApi.length > 0 ? h("div", { className: "table-wrap performance-table-wrap compact codex-load-api-table" }, + h("table", { className: "performance-table" }, + h("thead", null, h("tr", null, ["API", "状态", "耗时"].map((label) => h("th", { key: label }, label)))), + h("tbody", null, codexPerfResult.slowestApi.slice(0, 5).map((row: any, index: number) => h("tr", { key: `${row.url}-${index}` }, + h("td", null, h("code", null, row.url)), + h("td", null, row.status), + h("td", null, fmtMs(row.durationMs)), + ))), + ), + ) : null, + ), + h("div", { className: "performance-grid" }, + h(Panel, { title: "组件汇总", eyebrow: "Requests" }, + components.length === 0 ? h(EmptyState, { title: "暂无请求样本", text: "刷新几次或打开页面后会自动形成组件统计" }) : + h("div", { className: "table-wrap performance-table-wrap" }, h("table", { className: "performance-table" }, + h("thead", null, h("tr", null, ["组件", "请求数", "失败数", "失败率", "平均延迟", "P95"].map((label) => h("th", { key: label }, label)))), + h("tbody", null, components.map((row: any) => h("tr", { key: row.component }, + h("td", null, h("code", null, row.component)), + h("td", null, row.requestCount), + h("td", null, row.failureCount), + h("td", null, fmtPercent(asNumber(row.failureRate) * 100)), + h("td", null, fmtMs(row.averageLatencyMs)), + h("td", null, fmtMs(row.p95LatencyMs)), + ))), + )), + ), + h(Panel, { title: "最近失败请求", eyebrow: "Failures" }, + failures.length === 0 ? h("div", { className: "performance-empty-line" }, "最近没有失败请求") : + h("div", { className: "table-wrap performance-table-wrap compact" }, h("table", { className: "performance-table" }, + h("thead", null, h("tr", null, ["时间", "来源", "组件", "状态", "路径"].map((label) => h("th", { key: label }, label)))), + h("tbody", null, failures.map((row: any, index: number) => h("tr", { key: `${row.at}-${index}` }, + h("td", null, fmtDate(row.at)), + h("td", null, row.source), + h("td", null, h("code", null, row.component)), + h("td", null, h(StatusBadge, { status: "failed" }, row.status)), + h("td", null, h("code", null, row.path)), + ))), + )), + ), + h(Panel, { title: "内部操作汇总", eyebrow: "Operations" }, + operations.length === 0 ? h(EmptyState, { title: "暂无内部操作样本", text: "API 查询和代理请求会自动记录内部操作耗时" }) : + h("div", { className: "table-wrap performance-table-wrap" }, h("table", { className: "performance-table" }, + h("thead", null, h("tr", null, ["服务", "操作", "次数", "平均延迟", "P95"].map((label) => h("th", { key: label }, label)))), + h("tbody", null, operations.map((row: any) => h("tr", { key: `${row.service}-${row.operation}` }, + h("td", null, row.service), + h("td", null, h("code", null, row.operation)), + h("td", null, row.count), + h("td", null, fmtMs(row.averageLatencyMs)), + h("td", null, fmtMs(row.p95LatencyMs)), + ))), + )), + ), + h(Panel, { title: "最近慢操作", eyebrow: "Slowest" }, + slowRows.length === 0 ? h(EmptyState, { title: "暂无慢操作", text: "后端会记录最近窗口内耗时最高的内部操作" }) : + h("div", { className: "table-wrap performance-table-wrap" }, h("table", { className: "performance-table" }, + h("thead", null, h("tr", null, ["时间", "操作", "耗时", "结果", "细节"].map((label) => h("th", { key: label }, label)))), + h("tbody", null, slowRows.map((row: any, index: number) => h("tr", { key: `${row.at}-${row.operation}-${index}` }, + h("td", null, fmtDate(row.at)), + h("td", null, h("code", null, row.operation)), + h("td", null, fmtMs(row.durationMs)), + h("td", null, row.ok ? "成功" : "失败"), + h("td", null, row.detail || "-"), + ))), + )), + ), + ), + ); +} + function UpgradeControl({ provider, refresh, onRaw }: AnyRecord) { const [busyMode, setBusyMode] = useState(""); const [result, setResult] = useState(null); @@ -891,7 +1188,7 @@ function UpgradeControl({ provider, refresh, onRaw }: AnyRecord) { h("button", { type: "button", className: "ghost-btn", disabled: Boolean(busyMode), onClick: () => run("plan"), "data-testid": "upgrade-plan-button" }, busyMode === "plan" ? "预检中" : "预检升级"), h("button", { type: "button", className: "ghost-btn danger", disabled: Boolean(busyMode), onClick: () => run("schedule"), "data-testid": "upgrade-schedule-button" }, busyMode === "schedule" ? "调度中" : "执行升级"), ), - error ? h("div", { className: "form-error" }, error) : null, + h(UniDeskErrorBanner, { error }), result ? h("div", { className: "upgrade-result" }, h(StatusBadge, { status: result.status || "queued" }, result.status || "queued"), h("span", null, `${result.mode === "schedule" ? "执行升级" : "预检升级"} 已下发`), @@ -1189,16 +1486,16 @@ function DockerSidePanel({ title, items, render, limit }: AnyRecord) { function MicroserviceCatalogPage({ microservices, onRaw, onNavigate }: AnyRecord) { const privateServices = microservices.filter((service: any) => microserviceBackend(service).public === false); return h("div", { className: "microservice-page", "data-testid": "microservice-catalog-page" }, - h(Panel, { title: "Microservice 目录", eyebrow: "Provider Mounted Backends" }, + h(Panel, { title: "用户服务目录", eyebrow: "Provider Mounted User Services" }, h("div", { className: "metric-grid" }, - h(MetricCard, { label: "服务总数", value: microservices.length, hint: "config.json microservices" }), + h(MetricCard, { label: "服务总数", value: microservices.length, hint: "config.json 用户服务登记" }), h(MetricCard, { label: "私有后端", value: privateServices.length, hint: "不直接暴露公网", tone: "ok" }), h(MetricCard, { label: "D601 服务", value: microservices.filter((service: any) => service.providerId === "D601").length, hint: "compute-node docker" }), h(MetricCard, { label: "集成前端", value: microservices.filter((service: any) => service.frontend?.integrated).length, hint: "UniDesk React 页面" }), ), ), h(Panel, { title: "服务映射", eyebrow: "Repo Reference + Runtime" }, - microservices.length === 0 ? h(EmptyState, { title: "暂无 Microservice", text: "在 config.json 的 microservices 中登记 provider、仓库引用和后端映射" }) : + microservices.length === 0 ? h(EmptyState, { title: "暂无用户服务", text: "在 config.json 的 microservices 中登记用户服务的 provider、仓库引用和后端映射" }) : h("div", { className: "table-wrap" }, h("table", { className: "microservice-table" }, h("thead", null, h("tr", null, h("th", null, "服务"), h("th", null, "Provider"), h("th", null, "代码引用"), h("th", null, "Docker 引用"), h("th", null, "后端映射"), h("th", null, "开发入口"), h("th", null, "运行态"), h("th", null, "操作"))), h("tbody", null, microservices.map((service: any) => { @@ -1222,8 +1519,10 @@ 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 === "claudeqq" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "claudeqq"), "data-testid": "open-claudeqq-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 }), + service.id === "project-manager" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "project-manager"), "data-testid": "open-project-manager-button" }, "打开") : null, + h(RawButton, { title: `用户服务 ${service.id}`, data: service, onOpen: onRaw }), ), ), ); @@ -1302,7 +1601,7 @@ function DispatchPage({ nodes, onDispatched, onRaw }: AnyRecord) { h("button", { type: "submit", disabled: busy || !providerId }, busy ? "下发中" : "下发任务"), ), rawOpen ? h("label", { className: "raw-editor-label" }, "高级 Payload", h("textarea", { className: "raw-editor", value: rawPayload, onChange: (event: any) => setRawPayload(event.target.value) })) : null, - error ? h("div", { className: "form-error wide" }, error) : null, + h(UniDeskErrorBanner, { error, wide: true }), ), ), h(Panel, { title: "下发结果", eyebrow: "Response" }, @@ -1466,6 +1765,7 @@ function SecurityPage() { function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw, onNavigate }: AnyRecord) { if (activeModule === "ops" && activeTab === "status") return h(OverviewPage, { data, onRaw, onNavigate }); + if (activeModule === "ops" && activeTab === "performance") return h(PerformancePage, { onRaw }); if (activeModule === "ops" && activeTab === "events") return h(EventsPage, { events: data.events, onRaw }); if (activeModule === "ops" && activeTab === "logs") return h(LogsPage, { logs: data.logs, onRaw }); if (activeModule === "nodes" && activeTab === "list") return h(NodeListPage, { nodes: data.nodes, onRaw }); @@ -1483,7 +1783,9 @@ 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 === "apps" && activeTab === "claudeqq") return h(ClaudeQqPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl }); + if (activeModule === "apps" && activeTab === "codex-queue") return h(CodexQueuePage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl, initialTasksData: initialCodexQueueOverview }); + if (activeModule === "apps" && activeTab === "project-manager") return h(ProjectManagerPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl }); if (activeModule === "config" && activeTab === "topology") return h(TopologyPage, { data }); if (activeModule === "config" && activeTab === "auth") return h(AuthPage, { session }); if (activeModule === "config" && activeTab === "security") return h(SecurityPage); @@ -1503,31 +1805,69 @@ function Shell({ session, onLogout }: AnyRecord) { const module = ROUTE_REGISTRY.moduleById[activeModule] || ROUTE_REGISTRY.modules[0]; const activeTab = activeTabs[activeModule] || DEFAULT_ACTIVE_TABS[activeModule] || module.tabs[0].id; + const microservices = Array.isArray(data.microservices) ? data.microservices : []; + const effectiveMicroservices = microservices.length === 0 && activeModule === "apps" && activeTab === "codex-queue" + ? [fastCodexQueueService] + : microservices; + const effectiveData = effectiveMicroservices === microservices ? data : { ...data, microservices: effectiveMicroservices }; + const activeService = activeModule === "apps" + ? effectiveMicroservices.find((service: any) => String(service?.id || "") === activeTab) + : null; + const activeServiceRuntime = activeService ? microserviceRuntime(activeService) : {}; + const activeTabTitle = module.tabs.find((tab: any) => tab.id === activeTab)?.label || activeTab; + const activeStatusItems = activeService ? [{ + key: "microservice", + label: "用户服务", + value: `${activeTabTitle} ${activeServiceRuntime.providerStatus === "online" ? "在线" : activeServiceRuntime.providerStatus || "未知"}`, + tone: activeServiceRuntime.providerStatus === "online" ? "ok" : "warn", + testId: "active-microservice-status", + }] : []; async function refresh(): Promise { try { - const [overview, nodes, systemStatuses, dockerStatuses, microservices, events, tasks, pendingTasks, logs] = await Promise.all([ - requestJson(`${cfg.apiBaseUrl}/overview`), - requestJson(`${cfg.apiBaseUrl}/nodes`), - requestJson(`${cfg.apiBaseUrl}/nodes/system-status?limit=120`), - requestJson(`${cfg.apiBaseUrl}/nodes/docker-status`), - requestJson(`${cfg.apiBaseUrl}/microservices`), - requestJson(`${cfg.apiBaseUrl}/events?limit=100`), - requestJson(`${cfg.apiBaseUrl}/tasks?limit=300`), - requestJson(`${cfg.apiBaseUrl}/tasks?status=pending&limit=100`), - requestJson("/logs?limit=100"), - ]); - setData({ - overview, - nodes: nodes.nodes || [], - systemStatuses: systemStatuses.systemStatuses || [], - dockerStatuses: dockerStatuses.dockerStatuses || [], - microservices: microservices.microservices || [], - events: events.events || [], - tasks: tasks.tasks || [], - pendingTasks: pendingTasks.tasks || [], - logs: logs.logs || [], - }); + const requests: Array<[string, Promise]> = []; + const add = (key: string, path: string): void => { + requests.push([key, requestJson(path)]); + }; + const isOverview = activeModule === "ops" && activeTab === "status"; + const needsOverviewSummary = activeModule !== "apps"; + const needsNodes = isOverview || activeModule === "nodes" || (activeModule === "tasks" && activeTab === "dispatch"); + const needsMicroservices = activeModule === "apps" && activeTab !== "codex-queue"; + if (needsOverviewSummary) add("overview", `${cfg.apiBaseUrl}/overview`); + if (needsNodes) add("nodes", `${cfg.apiBaseUrl}/nodes`); + if (activeModule === "nodes" && activeTab === "monitor") { + add("systemStatuses", `${cfg.apiBaseUrl}/nodes/system-status?limit=60`); + add("tasks", `${cfg.apiBaseUrl}/tasks?limit=120`); + } else if (activeModule === "nodes" && activeTab === "docker") { + add("dockerStatuses", `${cfg.apiBaseUrl}/nodes/docker-status`); + } else if (activeModule === "nodes" && activeTab === "gateway") { + add("tasks", `${cfg.apiBaseUrl}/tasks?limit=300`); + } else if (activeModule === "tasks" && activeTab === "pending") { + add("pendingTasks", `${cfg.apiBaseUrl}/tasks?status=pending&limit=100`); + } else if (activeModule === "tasks" && (activeTab === "history" || activeTab === "results")) { + add("tasks", `${cfg.apiBaseUrl}/tasks?limit=300`); + } else if (isOverview) { + add("tasks", `${cfg.apiBaseUrl}/tasks?limit=8&lite=1`); + add("pendingTasks", `${cfg.apiBaseUrl}/tasks?status=pending&limit=20&lite=1`); + } + if (needsMicroservices) add("microservices", `${cfg.apiBaseUrl}/microservices`); + if (activeModule === "ops" && activeTab === "events") add("events", `${cfg.apiBaseUrl}/events?limit=100`); + if (activeModule === "ops" && activeTab === "logs") add("logs", "/logs?limit=100"); + + await Promise.all(requests.map(async ([key, promise]) => { + const value = await promise; + const patch: AnyRecord = {}; + if (key === "overview") patch.overview = value; + if (key === "nodes") patch.nodes = value.nodes || []; + if (key === "systemStatuses") patch.systemStatuses = value.systemStatuses || []; + if (key === "dockerStatuses") patch.dockerStatuses = value.dockerStatuses || []; + if (key === "microservices") patch.microservices = value.microservices || []; + if (key === "events") patch.events = value.events || []; + if (key === "tasks") patch.tasks = value.tasks || []; + if (key === "pendingTasks") patch.pendingTasks = value.tasks || []; + if (key === "logs") patch.logs = value.logs || []; + setData((previous: AnyRecord) => ({ ...previous, ...patch })); + })); setConnection({ ok: true, text: "核心在线" }); setLastRefresh(new Date()); } catch (err) { @@ -1540,7 +1880,7 @@ function Shell({ session, onLogout }: AnyRecord) { refresh(); const timer = setInterval(refresh, 5000); return () => clearInterval(timer); - }, []); + }, [activeModule, activeTab]); useEffect(() => { const timer = setInterval(() => setClock(new Date()), 1000); @@ -1590,9 +1930,9 @@ function Shell({ session, onLogout }: AnyRecord) { return h("div", { className: `shell ${railCollapsed ? "rail-collapsed" : ""}`, "data-testid": "app-shell" }, h(Sidebar, { activeModule, activeTabs, onNavigate: navigate, collapsed: railCollapsed, onToggle: () => setRailCollapsed((value: boolean) => !value) }), h("main", { className: "workspace" }, - h(TopBar, { connection, lastRefresh, onRefresh: refresh, onLogout: () => onLogout(true), session, clock }), + h(TopBar, { connection, lastRefresh, onRefresh: refresh, onLogout: () => onLogout(true), session, clock, activeStatusItems }), h(TabBar, { module, activeTab, onNavigate: navigate }), - h(WorkArea, { activeModule, activeTab, data, session, refresh, onRaw: openRaw, onNavigate: navigate }), + h(WorkArea, { activeModule, activeTab, data: effectiveData, session, refresh, onRaw: openRaw, onNavigate: navigate }), ), h(RawDialog, { raw, onClose: () => setRaw(null) }), ); diff --git a/src/components/frontend/src/claudeqq.tsx b/src/components/frontend/src/claudeqq.tsx new file mode 100644 index 00000000..e7d38ee2 --- /dev/null +++ b/src/components/frontend/src/claudeqq.tsx @@ -0,0 +1,402 @@ +import React from "react"; +import { errorMessage, requestJson as requestUniDeskJson } from "./unidesk-error"; +import { UniDeskErrorBanner } from "./unidesk-error-banner"; + +type AnyRecord = Record; + +const h = React.createElement; +const { useEffect } = React; +const useState: any = React.useState; +const PRIMARY_PRIVATE_CHAT = { label: "主用户私聊账号", userId: 645275593 }; + +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 numberText(value: any): string { + const number = Number(value); + return Number.isFinite(number) ? number.toLocaleString("zh-CN") : "--"; +} + +async function requestJson(path: string, options: AnyRecord = {}): Promise { + return requestUniDeskJson(path, { failureFields: ["ok", "success"], ...options }); +} + +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: (event: any) => { + event?.stopPropagation?.(); + 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 claudeqqApi(apiBaseUrl: string, path: string): string { + return `${apiBaseUrl}/microservices/claudeqq/proxy${path}`; +} + +function eventRows(payload: any): any[] { + return Array.isArray(payload?.events) ? payload.events.slice(0, 80) : []; +} + +function subscriptionRows(payload: any): any[] { + return Array.isArray(payload?.subscriptions) ? payload.subscriptions.slice(0, 50) : []; +} + +function sentRows(payload: any): any[] { + return Array.isArray(payload?.messages) ? payload.messages.slice(0, 30) : []; +} + +function eventText(event: any): string { + const text = event?.text ?? event?.message ?? event?.raw?.raw_message; + if (typeof text !== "string") return "--"; + return text.length > 180 ? `${text.slice(0, 177)}...` : text; +} + +function targetText(record: any): string { + const groupId = record?.groupId ?? record?.group_id ?? (record?.message_type === "group" ? record?.target_id : undefined); + const userId = record?.userId ?? record?.user_id ?? (record?.message_type === "private" ? record?.target_id : undefined); + if (groupId) return `群 ${groupId}`; + if (userId) return `私聊 ${userId}`; + return "--"; +} + +export function ClaudeQqPage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyRecord) { + const service = microservices.find((item: any) => item.id === "claudeqq") || null; + const [state, setState] = useState({ loading: false, qrLoading: false, error: "", health: null, status: null, napcatLogin: null, napcatQrcode: null, qrcodeFetched: false, qrcodeRefreshedAt: null, events: null, subscriptions: null, sent: null, refreshedAt: null }); + const [pushForm, setPushForm] = useState({ targetType: "private", targetId: String(PRIMARY_PRIVATE_CHAT.userId), message: "" }); + const [subscriptionForm, setSubscriptionForm] = useState({ name: "unidesk-callback", targetUrl: "", eventTypes: "message", secret: "" }); + const [actionMessage, setActionMessage] = useState(""); + + async function load(): Promise { + if (!service) return; + setState((prev: any) => ({ ...prev, loading: true, error: "" })); + try { + const [health, status, events, subscriptions, sent] = await Promise.all([ + requestJson(`${apiBaseUrl}/microservices/claudeqq/health`), + requestJson(claudeqqApi(apiBaseUrl, "/api/server/status")), + requestJson(claudeqqApi(apiBaseUrl, "/api/events/recent?limit=60")), + requestJson(claudeqqApi(apiBaseUrl, "/api/events/subscriptions")), + requestJson(claudeqqApi(apiBaseUrl, "/api/messages/sent?limit=20")), + ]); + setState((prev: any) => ({ ...prev, loading: false, error: "", health, status, events, subscriptions, sent, refreshedAt: new Date() })); + if (!state.qrcodeFetched) { + void refreshNapcatQrcode(false); + } + } catch (err) { + setState((prev: any) => ({ ...prev, loading: false, error: errorMessage(err, "ClaudeQQ 加载失败") })); + } + } + + async function refreshNapcatQrcode(manual = true): Promise { + if (!service) return; + setState((prev: any) => ({ ...prev, qrLoading: true, error: manual ? "" : prev.error })); + try { + const napcatLogin = await requestJson(claudeqqApi(apiBaseUrl, "/api/napcat/login")); + const napcatQrcode = napcatLogin?.napcat?.qrcode || napcatLogin?.qrcode || null; + setState((prev: any) => ({ + ...prev, + qrLoading: false, + error: "", + napcatLogin, + napcatQrcode, + qrcodeFetched: true, + qrcodeRefreshedAt: new Date(), + })); + } catch (err) { + setState((prev: any) => ({ + ...prev, + qrLoading: false, + error: manual || !prev.napcatQrcode ? errorMessage(err, "NapCat 二维码加载失败") : prev.error, + })); + } + } + + async function sendPush(event: any): Promise { + event.preventDefault(); + setActionMessage(""); + const targetId = Number(pushForm.targetId); + if (!Number.isFinite(targetId) || targetId <= 0 || pushForm.message.trim().length === 0) { + setState((prev: any) => ({ ...prev, error: "请填写 QQ 目标和消息内容" })); + return; + } + try { + await requestJson(claudeqqApi(apiBaseUrl, "/api/push/text"), { + method: "POST", + body: JSON.stringify({ + userId: pushForm.targetType === "private" ? targetId : undefined, + groupId: pushForm.targetType === "group" ? targetId : undefined, + message: pushForm.message, + }), + }); + setPushForm((prev: any) => ({ ...prev, targetType: "private", targetId: String(PRIMARY_PRIVATE_CHAT.userId), message: "" })); + setActionMessage("消息推送请求已提交"); + await load(); + } catch (err) { + setState((prev: any) => ({ ...prev, error: errorMessage(err, "发送失败") })); + } + } + + async function createSubscription(event: any): Promise { + event.preventDefault(); + setActionMessage(""); + if (subscriptionForm.targetUrl.trim().length === 0) { + setState((prev: any) => ({ ...prev, error: "请填写订阅回调 URL" })); + return; + } + try { + await requestJson(claudeqqApi(apiBaseUrl, "/api/events/subscriptions"), { + method: "POST", + body: JSON.stringify({ + name: subscriptionForm.name, + targetUrl: subscriptionForm.targetUrl, + eventTypes: subscriptionForm.eventTypes.split(",").map((item: string) => item.trim()).filter(Boolean), + secret: subscriptionForm.secret || undefined, + enabled: true, + }), + }); + setActionMessage("事件订阅已创建"); + await load(); + } catch (err) { + setState((prev: any) => ({ ...prev, error: errorMessage(err, "订阅失败") })); + } + } + + async function deleteSubscription(id: string): Promise { + if (!id) return; + setActionMessage(""); + try { + await requestJson(claudeqqApi(apiBaseUrl, `/api/events/subscriptions/${encodeURIComponent(id)}`), { method: "DELETE" }); + setActionMessage("事件订阅已删除"); + await load(); + } catch (err) { + setState((prev: any) => ({ ...prev, error: errorMessage(err, "删除订阅失败") })); + } + } + + useEffect(() => { + if (!service) return undefined; + load(); + return undefined; + }, [service?.id, service?.runtime?.providerStatus]); + + if (!service) return h(EmptyState, { title: "ClaudeQQ 未登记", text: "请在 config.json 的 microservices 中登记用户服务 id=claudeqq" }); + + const runtime = microserviceRuntime(service); + const repository = microserviceRepository(service); + const backend = microserviceBackend(service); + const health = state.health || {}; + const status = state.status || {}; + const napcatLogin = state.napcatLogin || {}; + const napcatStatus = health.napcat || status.napcat || {}; + const napcat = { ...(napcatLogin.napcat || {}), ...napcatStatus, qrcode: state.napcatQrcode || {}, webui: napcatStatus.webui || napcatLogin.napcat?.webui }; + const login = napcatLogin.login || {}; + const qrcode = state.napcatQrcode || {}; + const events = eventRows(state.events); + const subscriptions = subscriptionRows(state.subscriptions); + const sent = sentRows(state.sent); + const loginReady = Boolean(napcat.httpConnected || login.ready); + const loginState = String(napcat.loginState || login.state || (loginReady ? "logged_in" : "unknown")); + const qrAvailable = Boolean(qrcode.available && qrcode.dataUrl); + + return h("div", { className: "claudeqq-page", "data-testid": "claudeqq-page" }, + h(Panel, { + title: "ClaudeQQ 工作台", + eyebrow: "D601 QQ Event Gateway", + actions: h("div", { className: "panel-actions" }, + h("button", { type: "button", className: "ghost-btn", onClick: load, disabled: state.loading, "data-testid": "claudeqq-refresh-button" }, state.loading ? "刷新中" : "刷新"), + h(RawButton, { title: "ClaudeQQ 用户服务", data: service, onOpen: onRaw, testId: "raw-claudeqq-service" }), + ), + }, + h("div", { className: "findjob-hero" }, + h("div", null, + h("div", { className: "node-version-line" }, + h(StatusBadge, { status: runtime.providerStatus === "online" ? "online" : "warn" }, runtime.providerStatus || "unknown"), + h("span", null, service.providerId), + h("span", null, backend.public ? "公网暴露" : "仅 UniDesk frontend 代理访问"), + ), + h("p", { className: "muted paragraph" }, service.description), + ), + h("div", { className: "microservice-ref-card" }, + h("span", null, "Repo"), + h("strong", null, repository.url || "--"), + h("code", null, repository.commitId || "--"), + ), + h("div", { className: "microservice-ref-card" }, + h("span", null, "D601 Docker"), + h("strong", null, `${backend.nodeBindHost || "--"}:${backend.nodePort || "--"}`), + h("code", null, `${repository.composeFile || "--"} / ${repository.composeService || "--"}`), + ), + ), + h(UniDeskErrorBanner, { error: state.error, wide: true }), + actionMessage ? h("div", { className: "form-success wide" }, actionMessage) : null, + ), + h("div", { className: "metric-grid" }, + h(MetricCard, { label: "Health", value: health.ok || health.status === "ok" ? "OK" : "--", hint: "D601 /health", tone: health.ok || health.status === "ok" ? "ok" : "warn" }), + h(MetricCard, { label: "NapCat HTTP", value: napcat.httpConnected || napcat.http?.connected ? "OK" : "离线", hint: `${napcat.httpHost || health.napcat?.httpHost || "--"}:${napcat.httpPort || health.napcat?.httpPort || "--"}` }), + h(MetricCard, { label: "NapCat WS", value: napcat.wsConnected || napcat.ws?.connected ? "OK" : "离线", hint: `${napcat.wsHost || health.napcat?.wsHost || "--"}:${napcat.wsPort || health.napcat?.wsPort || "--"}` }), + h(MetricCard, { label: "事件缓存", value: numberText(state.events?.count ?? events.length), hint: "recent QQ events" }), + h(MetricCard, { label: "订阅", value: numberText(state.subscriptions?.count ?? subscriptions.length), hint: "webhook subscribers" }), + h(MetricCard, { label: "已发送", value: numberText(state.sent?.count ?? sent.length), hint: "sent message log" }), + ), + h("div", { className: "findjob-grid" }, + h(Panel, { + title: "NapCat 容器登录", + eyebrow: "QR Login", + className: "claudeqq-login-panel", + actions: h("div", { className: "panel-actions inline-actions" }, + h("button", { type: "button", className: "ghost-btn", onClick: () => refreshNapcatQrcode(true), disabled: state.qrLoading, "data-testid": "claudeqq-napcat-refresh" }, state.qrLoading ? "刷新中" : "手动刷新二维码"), + h(RawButton, { title: "NapCat Login", data: state.napcatLogin, onOpen: onRaw, testId: "raw-claudeqq-napcat-login" }), + ), + }, + h("div", { className: "claudeqq-login-card", "data-testid": "claudeqq-napcat-login" }, + h("div", { className: "claudeqq-qr-frame" }, + qrAvailable + ? h("img", { src: qrcode.dataUrl, alt: "NapCat QQ 登录二维码", "data-testid": "claudeqq-napcat-qrcode" }) + : h(EmptyState, { title: "等待二维码", text: "NapCat 容器启动后会把登录二维码写入 cache/qrcode.png" }), + ), + h("div", { className: "claudeqq-login-copy" }, + h("div", { className: "node-version-line" }, + h(StatusBadge, { status: loginReady ? "online" : qrAvailable ? "warn" : "unknown" }, loginReady ? "已登录" : qrAvailable ? "待扫码" : "等待二维码"), + h("span", null, loginState), + h("span", null, "D601 containerized"), + ), + h("p", { className: "muted paragraph" }, loginReady + ? "NapCat 已登录,ClaudeQQ 可通过容器内 HTTP/WS 链路收发 QQ 消息。" + : "用手机 QQ 扫描二维码授权登录。二维码只在首次加载或手动刷新时更新,D601 的 NapCat 端口仍只绑定 127.0.0.1。"), + h("div", { className: "microservice-ref-card" }, + h("span", null, "NapCat WebUI"), + h("strong", null, napcat.webui?.url || "http://napcat:6099/webui"), + h("code", null, "local-only / proxied QR login"), + ), + h("div", { className: "microservice-ref-card" }, + h("span", null, "QR Source"), + h("strong", null, qrcode.modifiedAt ? fmtDate(qrcode.modifiedAt) : state.qrcodeRefreshedAt ? fmtDate(state.qrcodeRefreshedAt) : "--"), + h("code", null, qrcode.file || "/napcat/cache/qrcode.png"), + ), + ), + ), + ), + h(Panel, { title: "消息推送", eyebrow: "Push API" }, + h("div", { className: "microservice-ref-card" }, + h("span", null, PRIMARY_PRIVATE_CHAT.label), + h("strong", null, String(PRIMARY_PRIVATE_CHAT.userId)), + h("code", null, "private userId / 默认推送测试目标"), + ), + h("form", { className: "stack-form", onSubmit: sendPush, "data-testid": "claudeqq-push-form" }, + h("label", null, "目标类型", + h("select", { value: pushForm.targetType, onChange: (event: any) => setPushForm((prev: any) => ({ ...prev, targetType: event.target.value })) }, + h("option", { value: "private" }, "私聊 userId"), + h("option", { value: "group" }, "群 groupId"), + ), + ), + h("label", null, "QQ ID", h("input", { value: pushForm.targetId, onChange: (event: any) => setPushForm((prev: any) => ({ ...prev, targetId: event.target.value })), placeholder: String(PRIMARY_PRIVATE_CHAT.userId) })), + h("label", null, "消息内容", h("textarea", { value: pushForm.message, onChange: (event: any) => setPushForm((prev: any) => ({ ...prev, message: event.target.value })), rows: 4, placeholder: "通过 ClaudeQQ 推送一条 QQ 消息" })), + h("button", { type: "submit", className: "primary-btn" }, "发送 QQ 消息"), + ), + h("p", { className: "muted paragraph" }, `主 server 和其他用户服务可通过 UniDesk 同源代理调用 /api/push/text;当前人工推送测试默认使用 ${PRIMARY_PRIVATE_CHAT.label} ${PRIMARY_PRIVATE_CHAT.userId},不需要暴露 D601 后端端口。`), + ), + h(Panel, { title: "QQ 事件订阅", eyebrow: "Webhook Subscription" }, + h("form", { className: "stack-form", onSubmit: createSubscription, "data-testid": "claudeqq-subscription-form" }, + h("label", null, "订阅名称", h("input", { value: subscriptionForm.name, onChange: (event: any) => setSubscriptionForm((prev: any) => ({ ...prev, name: event.target.value })) })), + h("label", null, "回调 URL", h("input", { value: subscriptionForm.targetUrl, onChange: (event: any) => setSubscriptionForm((prev: any) => ({ ...prev, targetUrl: event.target.value })), placeholder: "http://host.docker.internal:18080/..." })), + h("label", null, "事件类型", h("input", { value: subscriptionForm.eventTypes, onChange: (event: any) => setSubscriptionForm((prev: any) => ({ ...prev, eventTypes: event.target.value })), placeholder: "message,notice" })), + h("label", null, "签名密钥", h("input", { value: subscriptionForm.secret, onChange: (event: any) => setSubscriptionForm((prev: any) => ({ ...prev, secret: event.target.value })), placeholder: "可选,生成 x-claudeqq-signature" })), + h("button", { type: "submit", className: "primary-btn" }, "创建订阅"), + ), + subscriptions.length === 0 ? h(EmptyState, { title: "暂无订阅", text: "可以为 main server 或其他用户服务注册 HTTP webhook" }) : + h("div", { className: "table-wrap", "data-testid": "claudeqq-subscription-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, subscriptions.map((item: any) => h("tr", { key: item.id }, + h("td", null, h("strong", null, item.name || item.id), h("code", null, item.id || "--")), + h("td", null, h(StatusBadge, { status: item.enabled ? "online" : "warn" }, item.enabled ? "enabled" : "disabled")), + h("td", null, Array.isArray(item.eventTypes) ? item.eventTypes.join(", ") : "message"), + h("td", null, item.targetUrl || "--"), + h("td", null, item.lastDelivery ? `${item.lastDelivery.ok ? "OK" : "FAIL"} ${fmtDate(item.lastDelivery.at)}` : "--"), + h("td", null, h("button", { type: "button", className: "ghost-btn", onClick: () => deleteSubscription(item.id) }, "删除")), + ))), + )), + h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "ClaudeQQ Subscriptions", data: state.subscriptions, onOpen: onRaw, testId: "raw-claudeqq-subscriptions" })), + ), + h(Panel, { title: "最近 QQ 事件", eyebrow: state.refreshedAt ? `Updated ${fmtClock(state.refreshedAt)}` : "Event Stream" }, + events.length === 0 ? h(EmptyState, { title: "暂无事件", text: "等待 NapCat WebSocket 上报 QQ 消息事件,或通过订阅 API 消费后续事件" }) : + h("div", { className: "table-wrap", "data-testid": "claudeqq-event-list" }, h("table", null, + h("thead", null, h("tr", null, h("th", null, "时间"), h("th", null, "类型"), h("th", null, "会话"), h("th", null, "消息"), h("th", null, "ID"))), + h("tbody", null, events.map((event: any) => h("tr", { key: event.id }, + h("td", null, fmtDate(event.receivedAt || event.timestamp)), + h("td", null, h(StatusBadge, { status: event.postType || event.eventType }, event.postType || event.eventType || "--")), + h("td", null, targetText(event)), + h("td", null, eventText(event)), + h("td", null, h("code", null, event.messageId || event.id || "--")), + ))), + )), + h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "ClaudeQQ Events", data: state.events, onOpen: onRaw, testId: "raw-claudeqq-events" })), + ), + h(Panel, { title: "已发送消息", eyebrow: `${sent.length} Sent` }, + sent.length === 0 ? h(EmptyState, { title: "暂无发送记录", text: "发送日志来自 ClaudeQQ bot_workspace/messages/sent_messages.jsonl" }) : + h("div", { className: "table-wrap" }, h("table", null, + h("thead", null, h("tr", null, h("th", null, "时间"), h("th", null, "目标"), h("th", null, "消息"), h("th", null, "结果"))), + h("tbody", null, sent.map((item: any, index: number) => h("tr", { key: item.id || index }, + h("td", null, fmtDate(item.timestamp || item.sentAt || item.createdAt)), + h("td", null, targetText(item)), + h("td", null, eventText(item)), + h("td", null, item.status || item.messageId || item.message_id || "--"), + ))), + )), + h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "ClaudeQQ Sent Messages", data: state.sent, onOpen: onRaw, testId: "raw-claudeqq-sent" })), + ), + ), + ); +} diff --git a/src/components/frontend/src/codex-queue-entry.tsx b/src/components/frontend/src/codex-queue-entry.tsx new file mode 100644 index 00000000..2ea80962 --- /dev/null +++ b/src/components/frontend/src/codex-queue-entry.tsx @@ -0,0 +1,86 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import { CodexQueuePage } from "./codex-queue"; + +type AnyRecord = Record; + +const h = React.createElement; +const useState: any = React.useState; + +const rootElement = document.getElementById("root"); +if (rootElement === null) throw new Error("root element not found"); +const root = rootElement as HTMLElement; + +function readJsonAttr(name: string, fallback: any): any { + const raw = root.getAttribute(name); + if (!raw) return fallback; + try { + const parsed = JSON.parse(raw) as unknown; + return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as AnyRecord : fallback; + } catch { + return fallback; + } +} + +const clientConfig = readJsonAttr("data-config", { apiBaseUrl: "/api" }); +const initialOverview = readJsonAttr("data-codex-overview", null); +const apiBaseUrl = String(clientConfig.apiBaseUrl || "/api"); + +const standaloneCodexQueueService = { + id: "codex-queue", + name: "Codex Queue", + providerId: "main-server", + description: "Codex Queue 独立入口,使用 summary 首屏和按需全量加载保持信息完整。", + repository: { containerName: "codex-queue-backend" }, + backend: { + nodeBaseUrl: "http://codex-queue:4222", + nodeBindHost: "codex-queue", + nodePort: 4222, + public: false, + }, + runtime: { + providerStatus: "online", + providerName: "main-server", + }, +}; + +function RawDialog({ raw, onClose }: AnyRecord) { + if (!raw) return null; + return h("div", { className: "modal-backdrop" }, + h("section", { className: "raw-dialog", role: "dialog", "aria-modal": "true", "aria-label": raw.title || "Raw JSON" }, + h("div", { className: "raw-dialog-head" }, + h("strong", null, raw.title || "Raw JSON"), + h("button", { type: "button", className: "ghost-btn", onClick: onClose }, "关闭"), + ), + h("pre", { className: "raw-json" }, JSON.stringify(raw.data, null, 2)), + ), + ); +} + +function StandaloneCodexQueueApp() { + const [raw, setRaw] = useState(null); + return h("div", { className: "shell codex-standalone-shell", "data-testid": "codex-queue-standalone" }, + h("main", { className: "workspace codex-standalone-workspace" }, + h("div", { className: "topbar codex-standalone-topbar" }, + h("div", null, + h("strong", null, "Codex Queue"), + h("span", { className: "muted" }, "Standalone progressive loader"), + ), + h("div", { className: "topbar-actions" }, + h("a", { className: "ghost-btn", href: "/ops/performance/" }, "性能面板"), + h("a", { className: "ghost-btn", href: "/" }, "完整工作台"), + ), + ), + h(CodexQueuePage, { + microservices: [standaloneCodexQueueService], + apiBaseUrl, + initialTasksData: initialOverview, + standalone: true, + onRaw: (title: string, data: any) => setRaw({ title, data }), + }), + ), + h(RawDialog, { raw, onClose: () => setRaw(null) }), + ); +} + +createRoot(root).render(h(StandaloneCodexQueueApp)); diff --git a/src/components/frontend/src/codex-queue.tsx b/src/components/frontend/src/codex-queue.tsx index 6dbfa304..2d94ecbd 100644 --- a/src/components/frontend/src/codex-queue.tsx +++ b/src/components/frontend/src/codex-queue.tsx @@ -1,14 +1,16 @@ import React from "react"; +import { TraceView, codexTracePort } from "./trace"; +import { errorMessage, requestJson as requestUniDeskJson } from "./unidesk-error"; +import { UniDeskErrorBanner } from "./unidesk-error-banner"; 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); -} +const codexTranscriptChunkLimit = 120; +const codexInitialTaskLimit = 24; +const codexMoreTaskLimit = 48; function fmtDate(value: any): string { if (!value) return "--"; @@ -21,35 +23,38 @@ function fmtClock(value: Date): string { return value.toLocaleTimeString("zh-CN", { hour12: false }); } +function fmtDuration(ms: any): string { + const value = Number(ms); + if (!Number.isFinite(value) || value < 0) return "--"; + const totalSeconds = Math.floor(value / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (hours > 0) return `${hours}h ${String(minutes).padStart(2, "0")}m`; + if (minutes > 0) return `${minutes}m ${String(seconds).padStart(2, "0")}s`; + return `${seconds}s`; +} + +function fmtPreciseMs(ms: any): string { + const value = Number(ms); + if (!Number.isFinite(value) || value < 0) return "--"; + if (value < 1000) return `${Math.round(value)}ms`; + return `${(value / 1000).toFixed(value < 10_000 ? 2 : 1)}s`; +} + 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; - let parseFailed = false; - try { - payload = text ? JSON.parse(text) : null; - } catch { - parseFailed = true; - payload = { text }; - } - if (parseFailed) { - throw new Error(`Codex Queue 返回了无效 JSON(${text.length} bytes),可能是代理响应过大或被截断`); - } - 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; + return requestUniDeskJson(path, { + strictJson: true, + retryInvalidJson: 1, + invalidJsonPrefix: "Codex Queue 返回了无效 JSON", + invalidJsonPreview: true, + ...options, + }); } function StatusBadge({ status, children }: AnyRecord) { @@ -65,12 +70,13 @@ function MetricCard({ label, value, hint, tone }: AnyRecord) { ); } -function Panel({ title, eyebrow, actions, children, className }: AnyRecord) { +function Panel({ title, eyebrow, summary, 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), + summary ? h("div", { className: "panel-summary" }, summary) : null, ), actions ? h("div", { className: "panel-actions" }, actions) : null, ), @@ -104,13 +110,17 @@ function microserviceRepository(service: any): AnyRecord { } function codexApi(apiBaseUrl: string, path: string): string { - return `${apiBaseUrl}/microservices/codex-queue/proxy${path}`; + return `${apiBaseUrl}/codex-queue-direct${path}`; } function taskRows(data: any): any[] { return Array.isArray(data?.tasks) ? data.tasks : []; } +function taskPagination(data: any): AnyRecord { + return data?.pagination && typeof data.pagination === "object" && !Array.isArray(data.pagination) ? data.pagination : {}; +} + function taskSortValue(task: any): number { const time = Date.parse(String(task?.updatedAt || task?.createdAt || "")); return Number.isFinite(time) ? time : 0; @@ -124,40 +134,257 @@ function mergeTaskRows(groups: any[][], activeTaskId = ""): any[] { if (id.length > 0 && !byId.has(id)) byId.set(id, task); } } - const statusRank: Record = { running: 0, judging: 1, retry_wait: 2, queued: 3 }; return Array.from(byId.values()).sort((left, right) => { + const rankDelta = taskListRank(left) - taskListRank(right); + if (rankDelta !== 0) return rankDelta; const leftActive = String(left?.id || "") === activeTaskId ? 0 : 1; const rightActive = String(right?.id || "") === activeTaskId ? 0 : 1; if (leftActive !== rightActive) return leftActive - rightActive; - const rankDelta = (statusRank[String(left?.status || "")] ?? 9) - (statusRank[String(right?.status || "")] ?? 9); - if (rankDelta !== 0) return rankDelta; return taskSortValue(right) - taskSortValue(left); }); } -async function loadTaskQueue(apiBaseUrl: string, healthResult: any): Promise { - const statuses = ["running", "judging", "retry_wait", "queued"]; - const results = await Promise.all(statuses.map(async (status) => { - try { - return await requestJson(codexApi(apiBaseUrl, `/api/tasks?status=${encodeURIComponent(status)}&limit=80`)); - } catch { - return null; +function mergeTaskRowsPreferLatest(groups: any[][], activeTaskId = ""): any[] { + const byId = new Map(); + for (const group of groups) { + for (const task of group) { + const id = String(task?.id || ""); + if (id.length === 0) continue; + byId.set(id, { ...(byId.get(id) || {}), ...task }); } - })); - const historyResult = await requestJson(codexApi(apiBaseUrl, "/api/tasks?limit=160")).catch(() => null); - const queue = results.find((item) => item?.queue)?.queue || historyResult?.queue || healthResult?.queue || healthResult?.body?.queue || {}; - const rows = mergeTaskRows([...results.map((item) => taskRows(item)), taskRows(historyResult)], String(queue?.activeTaskId || "")); - if (rows.length > 0) return { ok: true, queue, tasks: rows }; - return requestJson(codexApi(apiBaseUrl, "/api/tasks?limit=5")); + } + return mergeTaskRows([Array.from(byId.values())], activeTaskId); +} + +function activeTaskIds(queue: any): string[] { + return Array.isArray(queue?.activeTaskIds) ? queue.activeTaskIds.map((id: any) => String(id || "")).filter(Boolean) : [String(queue?.activeTaskId || "")].filter(Boolean); +} + +const allQueuesId = "__all__"; +const queueMobileMediaQuery = "(max-width: 760px)"; +const queueDesktopMediaQuery = "(min-width: 761px)"; +const codexReadAtStorageKey = "unidesk:codex-queue:read-at:v1"; + +function isAllQueues(queueId: string): boolean { + return !queueId || queueId === allQueuesId; +} + +function isMobileQueueViewport(): boolean { + return typeof window !== "undefined" && window.matchMedia(queueMobileMediaQuery).matches; +} + +function queueQuerySuffix(queueId: string): string { + return isAllQueues(queueId) ? "" : `&queueId=${encodeURIComponent(queueId)}`; +} + +function queueStatusCount(row: any, status: string): number { + return Number(row?.counts?.[status] || 0); +} + +function knownQueueRows(queue: any, currentQueueId = ""): any[] { + const byId = new Map(); + for (const row of Array.isArray(queue?.queues) ? queue.queues : []) { + const id = String(row?.id || "").trim(); + if (id.length > 0) byId.set(id, row); + } + for (const id of [String(queue?.defaultQueueId || "default"), currentQueueId].map((value) => value.trim()).filter(Boolean)) { + if (!byId.has(id)) byId.set(id, { id, total: 0, counts: {}, activeTaskId: null, runnableTaskId: null, processing: false }); + } + const rows = Array.from(byId.values()); + return rows.sort((left, right) => { + const leftDefault = String(left?.id || "") === String(queue?.defaultQueueId || "default") ? 0 : 1; + const rightDefault = String(right?.id || "") === String(queue?.defaultQueueId || "default") ? 0 : 1; + if (leftDefault !== rightDefault) return leftDefault - rightDefault; + return String(left?.id || "").localeCompare(String(right?.id || "")); + }); +} + +function queueOptionLabel(row: any): string { + const id = String(row?.id || "default"); + const running = queueStatusCount(row, "running") + queueStatusCount(row, "judging"); + const waiting = queueStatusCount(row, "queued") + queueStatusCount(row, "retry_wait"); + const total = Number(row?.total || 0); + const parts = [`${id}`, `${total} tasks`]; + if (running > 0) parts.push(`${running} running`); + if (waiting > 0) parts.push(`${waiting} queued`); + return parts.join(" · "); +} + +function selectedQueueRow(queueRows: any[], queueId: string): any | null { + if (isAllQueues(queueId)) return null; + return queueRows.find((row) => String(row?.id || "") === queueId) || null; +} + +function queueActiveTaskId(queue: any, queueRows: any[], queueId: string, rows: any[]): string { + if (isAllQueues(queueId)) { + const ids = activeTaskIds(queue); + return String(queue?.activeTaskId || ids[0] || rows.find((task: any) => taskIsActive(task))?.id || ""); + } + const row = selectedQueueRow(queueRows, queueId); + return String(row?.activeTaskId || rows.find((task: any) => taskIsActive(task))?.id || ""); +} + +function queueRunnableTaskId(queueRows: any[], queueId: string, rows: any[]): string { + if (!isAllQueues(queueId)) { + const row = selectedQueueRow(queueRows, queueId); + return String(row?.runnableTaskId || rows.find((task: any) => String(task?.status || "") === "queued" || String(task?.status || "") === "retry_wait")?.id || ""); + } + return String(rows.find((task: any) => String(task?.status || "") === "queued" || String(task?.status || "") === "retry_wait")?.id || ""); +} + +async function loadTaskQueue(apiBaseUrl: string, healthResult: any, queueId = allQueuesId): Promise { + const suffix = queueQuerySuffix(queueId); + try { + return await requestJson(codexApi(apiBaseUrl, `/api/tasks?limit=${codexInitialTaskLimit}&lite=1&devReady=0${suffix}`)); + } catch { + const statuses = ["running", "judging", "retry_wait", "queued"]; + const results = await Promise.all(statuses.map(async (status) => { + try { + return await requestJson(codexApi(apiBaseUrl, `/api/tasks?status=${encodeURIComponent(status)}&limit=80&lite=1&devReady=0${suffix}`)); + } catch { + return null; + } + })); + const historyResult = await requestJson(codexApi(apiBaseUrl, `/api/tasks?limit=${codexInitialTaskLimit}&lite=1&devReady=0${suffix}`)).catch(() => null); + const queue = results.find((item) => item?.queue)?.queue || historyResult?.queue || healthResult?.queue || healthResult?.body?.queue || {}; + const rows = mergeTaskRows([...results.map((item) => taskRows(item)), taskRows(historyResult)], String(queue?.activeTaskId || "")); + if (rows.length > 0) return { ok: true, queue, tasks: rows }; + return requestJson(codexApi(apiBaseUrl, `/api/tasks?limit=5&lite=1&devReady=0${suffix}`)); + } +} + +async function loadTaskOverview(apiBaseUrl: string, preferId: string, afterSeq = 0, queueId = allQueuesId): Promise { + return requestJson(codexApi( + apiBaseUrl, + `/api/tasks/overview?limit=${codexInitialTaskLimit}&transcriptLimit=3&compact=1&afterSeq=${encodeURIComponent(String(Math.max(0, afterSeq)))}&preferId=${encodeURIComponent(preferId)}${queueQuerySuffix(queueId)}`, + )); +} + +async function loadTaskPage(apiBaseUrl: string, queueId: string, beforeId: string, limit = codexMoreTaskLimit): Promise { + return requestJson(codexApi( + apiBaseUrl, + `/api/tasks?limit=${encodeURIComponent(String(limit))}&lite=1&devReady=0&includeActive=0&beforeId=${encodeURIComponent(beforeId)}${queueQuerySuffix(queueId)}`, + )); +} + +async function loadTaskTraceSummary(apiBaseUrl: string, taskId: string): Promise { + return requestJson(codexApi(apiBaseUrl, `/api/tasks/${encodeURIComponent(taskId)}/trace-summary`)); +} + +async function loadTaskPromptPart(apiBaseUrl: string, taskId: string, part: string, attemptIndex: any = null): Promise { + const attemptParam = attemptIndex === null || attemptIndex === undefined || String(attemptIndex).length === 0 + ? "" + : `&attempt=${encodeURIComponent(String(attemptIndex))}`; + return requestJson(codexApi(apiBaseUrl, `/api/tasks/${encodeURIComponent(taskId)}/prompt?part=${encodeURIComponent(part)}${attemptParam}`)); +} + +async function loadTaskTraceSteps(apiBaseUrl: string, taskId: string, afterSeq = 0, limit = 500, attemptIndex: any = null): Promise { + const attemptParam = attemptIndex === null || attemptIndex === undefined || String(attemptIndex).length === 0 + ? "" + : `&attempt=${encodeURIComponent(String(attemptIndex))}`; + return requestJson(codexApi(apiBaseUrl, `/api/tasks/${encodeURIComponent(taskId)}/trace-steps?afterSeq=${encodeURIComponent(String(afterSeq))}&limit=${encodeURIComponent(String(limit))}${attemptParam}`)); +} + +async function loadTaskTraceStep(apiBaseUrl: string, taskId: string, seq: any): Promise { + return requestJson(codexApi(apiBaseUrl, `/api/tasks/${encodeURIComponent(taskId)}/trace-step?seq=${encodeURIComponent(String(seq))}`)); +} + +async function markTaskReadRequest(apiBaseUrl: string, taskId: string): Promise { + return requestJson(codexApi(apiBaseUrl, `/api/tasks/${encodeURIComponent(taskId)}/read`), { method: "POST", body: {} }); +} + +async function markAllTerminalReadRequest(apiBaseUrl: string): Promise { + return requestJson(codexApi(apiBaseUrl, "/api/tasks/read-all"), { method: "POST", body: {} }); } function taskOutput(task: any): any[] { return Array.isArray(task?.output) ? task.output : []; } +function initialPromptTranscriptItem(task: any): any | null { + const promptView = taskPromptView(task); + const prompt = String(promptView.displayPrompt || "").trimEnd(); + if (!task || prompt.length === 0) return null; + return { + seq: 0.5, + at: task?.createdAt || task?.updatedAt, + kind: "message", + title: "Submitted prompt", + status: "enqueue", + bodyPreview: prompt, + fullPrompt: promptView.fullPrompt, + fullPromptLines: promptView.fullLines, + fullPromptChars: promptView.fullPrompt.length, + displayPromptLines: promptView.displayLines, + foldedReferencePrompt: promptView.folded, + referencePromptLines: promptView.referenceLines, + referencePromptChars: String(promptView.resolved?.reference || "").length, + rawSeqs: [], + }; +} + +function isInitialPromptTranscriptItem(item: any): boolean { + const title = String(item?.title || "").toLowerCase(); + return title === "submitted prompt" || title === "initial prompt"; +} + +function transcriptHasInitialPrompt(transcript: any[]): boolean { + return transcript.some(isInitialPromptTranscriptItem); +} + +function enrichInitialPromptTranscriptItem(task: any, item: any): any { + const initialPrompt = initialPromptTranscriptItem(task); + if (initialPrompt === null) return item; + return { + ...item, + bodyPreview: initialPrompt.bodyPreview, + fullPrompt: initialPrompt.fullPrompt, + fullPromptLines: initialPrompt.fullPromptLines, + fullPromptChars: initialPrompt.fullPromptChars, + displayPromptLines: initialPrompt.displayPromptLines, + foldedReferencePrompt: initialPrompt.foldedReferencePrompt, + referencePromptLines: initialPrompt.referencePromptLines, + referencePromptChars: initialPrompt.referencePromptChars, + }; +} + +function withInitialPromptTranscript(task: any, transcript: any[]): any[] { + if (transcriptHasInitialPrompt(transcript)) { + return transcript.map((item) => isInitialPromptTranscriptItem(item) ? enrichInitialPromptTranscriptItem(task, item) : item); + } + const initialPrompt = initialPromptTranscriptItem(task); + if (initialPrompt === null) return transcript; + return [initialPrompt, ...transcript].sort((left, right) => Number(left?.seq ?? 0) - Number(right?.seq ?? 0)); +} + +function taskPromptHistory(task: any): any[] { + return Array.isArray(task?.promptHistory) ? task.promptHistory : []; +} + +function withPromptHistoryTranscript(task: any, transcript: any[]): any[] { + const rows = [...transcript]; + for (const item of taskPromptHistory(task)) { + const seq = Number(item?.seq); + const text = String(item?.text || "").trimEnd(); + if (!Number.isFinite(seq) || text.length === 0) continue; + const exists = rows.some((row) => Number(row?.seq) === seq && (String(row?.title || "") === "Steer prompt" || String(row?.status || "") === "turn/steer")); + if (exists) continue; + rows.push({ + seq, + at: item?.at || task?.updatedAt, + kind: "message", + title: "Steer prompt", + status: item?.method || "turn/steer", + bodyPreview: text, + rawSeqs: [seq], + }); + } + return rows.sort((left, right) => Number(left?.seq ?? 0) - Number(right?.seq ?? 0)); +} + function taskTranscript(task: any): any[] { - if (Array.isArray(task?.transcript)) return task.transcript; - return taskOutput(task).map((item: any) => ({ + if (Array.isArray(task?.transcript)) return withPromptHistoryTranscript(task, withInitialPromptTranscript(task, task.transcript)); + const transcript = taskOutput(task).filter((item: any) => !(item.channel === "user" && item.method === "enqueue")).map((item: any) => ({ seq: item.seq, at: item.at, kind: item.channel === "error" ? "error" : item.channel === "command" ? "ran" : "message", @@ -165,6 +392,7 @@ function taskTranscript(task: any): any[] { bodyPreview: String(item.text || ""), rawSeqs: [item.seq], })); + return withPromptHistoryTranscript(task, withInitialPromptTranscript(task, transcript)); } function taskAttempts(task: any): any[] { @@ -187,6 +415,236 @@ function repeatCountValue(value: any): number { return Number.isFinite(count) ? Math.max(1, Math.min(50, Math.floor(count))) : 1; } +function parseReferenceTaskIds(value: string): string[] { + const ids: string[] = []; + for (const part of value.split(/[\s,,;;]+/u)) { + const id = part.trim(); + if (/^codex_\d+_[A-Za-z0-9_-]+$/u.test(id) && !ids.includes(id)) ids.push(id); + } + return ids; +} + +function withReferenceHint(text: string, taskId: string): string { + const ids = parseReferenceTaskIds(taskId); + if (ids.length === 0) return text; + return [ + `引用 Codex Queue 任务 ${ids.join(" ")}。后端会在入队时只注入这些任务的 initial prompt 和 final response 全文;中间执行过程不注入,如需补充核查可运行:${ids.map((id) => `bun scripts/cli.ts codex task ${id}`).join(";")}`, + "", + "本次任务:", + text, + ].join("\n"); +} + +function splitResolvedReferencePrompt(prompt: string): { hasInjection: boolean; reference: string; userPrompt: string } { + const marker = "\n# 本次任务\n"; + const title = "# Codex Queue 已解析引用上下文"; + const trimmed = prompt.trimStart(); + if (!trimmed.startsWith(title)) return { hasInjection: false, reference: "", userPrompt: prompt }; + const offset = prompt.length - trimmed.length; + const index = prompt.lastIndexOf(marker); + if (index < offset) return { hasInjection: false, reference: "", userPrompt: prompt }; + return { + hasInjection: true, + reference: prompt.slice(offset, index).trimEnd(), + userPrompt: prompt.slice(index + marker.length).trimStart(), + }; +} + +function stripAutoReferenceHint(text: string): string { + const trimmed = text.trimStart(); + if (!/^引用\s+Codex Queue\s+任务\s+codex_\d+_[A-Za-z0-9_-]+/u.test(trimmed)) return text; + const marker = "\n\n本次任务:"; + const index = trimmed.indexOf(marker); + if (index === -1) return text; + return trimmed.slice(index + marker.length).trimStart(); +} + +function promptLineCount(text: string): number { + return text.length > 0 ? text.split(/\r\n|\r|\n/u).length : 0; +} + +function taskDisplayPrompt(task: any): string { + const explicit = String(task?.displayPrompt || ""); + if (explicit.length > 0) return explicit; + const prompt = String(task?.prompt || ""); + return stripAutoReferenceHint(splitResolvedReferencePrompt(prompt).userPrompt); +} + +function taskPromptView(task: any): AnyRecord { + const fullPrompt = String(task?.prompt || "").trimEnd(); + const displayPrompt = taskDisplayPrompt(task).trimEnd(); + const resolved = splitResolvedReferencePrompt(fullPrompt); + const folded = fullPrompt.length > 0 && displayPrompt.length > 0 && fullPrompt !== displayPrompt; + return { + fullPrompt, + displayPrompt: displayPrompt || fullPrompt, + resolved, + folded, + displayLines: promptLineCount(displayPrompt || fullPrompt), + fullLines: promptLineCount(fullPrompt), + referenceLines: promptLineCount(resolved.reference || ""), + }; +} + +function taskTraceSummary(task: any): AnyRecord | null { + return task?._traceSummary && typeof task._traceSummary === "object" && !Array.isArray(task._traceSummary) ? task._traceSummary : null; +} + +function taskPromptDetails(task: any): AnyRecord { + return task?._promptDetails && typeof task._promptDetails === "object" && !Array.isArray(task._promptDetails) ? task._promptDetails : {}; +} + +function taskPromptSummary(task: any): AnyRecord { + const summary = taskTraceSummary(task)?.prompt; + return summary && typeof summary === "object" && !Array.isArray(summary) ? summary : {}; +} + +function taskExecutionSummary(task: any): AnyRecord { + const execution = taskTraceSummary(task)?.execution; + return execution && typeof execution === "object" && !Array.isArray(execution) ? execution : {}; +} + +function taskBasePromptText(task: any): string { + const summaryPrompt = taskPromptSummary(task); + const basePrompt = String(summaryPrompt.basePrompt || ""); + return basePrompt.length > 0 ? basePrompt : taskDisplayPrompt(task); +} + +function taskFinalResponseText(task: any): string { + const summary = taskTraceSummary(task); + const finalResponse = String(summary?.finalResponse || task?.finalResponse || ""); + return finalResponse.trimEnd(); +} + +function taskLastJudge(task: any): AnyRecord | null { + const summaryJudge = taskTraceSummary(task)?.lastJudge; + const judge = summaryJudge || task?.lastJudge; + return judge && typeof judge === "object" && !Array.isArray(judge) ? judge : null; +} + +function objectRecord(value: any): AnyRecord | null { + return value && typeof value === "object" && !Array.isArray(value) ? value : null; +} + +function taskProgressiveAttempts(task: any): any[] { + const summaryAttempts = taskTraceSummary(task)?.attempts; + if (Array.isArray(summaryAttempts) && summaryAttempts.length > 0) return summaryAttempts; + const attempts = taskAttempts(task); + if (attempts.length > 0) { + return attempts.map((attempt: any, index: number) => ({ + ...attempt, + index: Number(attempt?.index || index + 1), + execution: index === attempts.length - 1 ? taskExecutionSummary(task) : objectRecord(attempt?.execution) || {}, + finalResponse: String(attempt?.finalResponse || attempt?.finalResponsePreview || (index === attempts.length - 1 ? taskFinalResponseText(task) : "")), + judge: objectRecord(attempt?.judge) || (index === attempts.length - 1 ? taskLastJudge(task) : null), + })); + } + const execution = taskExecutionSummary(task); + const finalResponse = taskFinalResponseText(task); + const judge = taskLastJudge(task); + if (Object.keys(execution).length === 0 && finalResponse.length === 0 && judge === null) return []; + return [{ + index: Number(task?.currentAttempt || 1), + mode: task?.currentMode || "initial", + startedAt: task?.startedAt, + finishedAt: task?.finishedAt, + terminalStatus: task?.status, + execution, + finalResponse, + finalResponseChars: finalResponse.length, + judge, + }]; +} + +function attemptExecutionSummary(task: any, attempt: any): AnyRecord { + return objectRecord(attempt?.execution) || taskExecutionSummary(task); +} + +function attemptFinalResponseText(task: any, attempt: any): string { + const text = String(attempt?.finalResponse || attempt?.finalResponsePreview || ""); + if (Object.prototype.hasOwnProperty.call(attempt || {}, "finalResponse") || Object.prototype.hasOwnProperty.call(attempt || {}, "finalResponsePreview")) return text.trimEnd(); + return text.length > 0 ? text.trimEnd() : taskFinalResponseText(task); +} + +function attemptJudge(task: any, attempt: any): AnyRecord | null { + if (Object.prototype.hasOwnProperty.call(attempt || {}, "judge")) return objectRecord(attempt?.judge); + return taskLastJudge(task); +} + +function feedbackPromptDetailKey(attemptIndex: any): string { + return `feedback:${String(attemptIndex || "latest")}`; +} + +function attemptFeedbackPrompt(task: any, attempt: any, attemptIndex: any): AnyRecord | null { + const text = String(attempt?.feedbackPrompt || "").trimEnd(); + const preview = String(attempt?.feedbackPromptPreview || text || "").trimEnd(); + const chars = Number(attempt?.feedbackPromptChars || text.length || preview.length || 0); + const lines = Number(attempt?.feedbackPromptLines || promptLineCount(text || preview)); + if (text.length > 0 || preview.length > 0 || chars > 0) { + return { + text, + preview, + chars, + lines, + source: attempt?.feedbackPromptSource || "judge-feedback", + forAttempt: attempt?.feedbackPromptForAttempt || Number(attemptIndex || 0) + 1, + truncated: Boolean(attempt?.feedbackPromptTruncated), + }; + } + const judge = attemptJudge(task, attempt); + const continuePrompt = String(judge?.continuePrompt || "").trimEnd(); + if (judge?.decision === "retry" && continuePrompt.length > 0) { + return { + text: "", + preview: continuePrompt, + chars: continuePrompt.length, + lines: promptLineCount(continuePrompt), + source: "judge-continue-prompt", + forAttempt: Number(attemptIndex || 0) + 1, + truncated: false, + }; + } + return null; +} + +function taskHasReferencePrompt(task: any): boolean { + const promptSummary = taskPromptSummary(task); + return Boolean(promptSummary.hasReferenceInjection || Number(promptSummary.referencePromptChars || 0) > 0 || task?.referenceInjection || task?.referenceInjectionSummary); +} + +function taskTraceSteps(task: any, attemptIndex: any = null): any[] { + if (attemptIndex !== null && attemptIndex !== undefined) { + const byAttempt = objectRecord(task?._traceStepsByAttempt) || {}; + const rows = byAttempt[String(attemptIndex)]; + return Array.isArray(rows) ? rows : []; + } + return Array.isArray(task?._traceSteps) ? task._traceSteps : []; +} + +function taskTraceStepsLoaded(task: any, attemptIndex: any = null): boolean { + if (attemptIndex !== null && attemptIndex !== undefined) { + const byAttempt = objectRecord(task?._traceStepsLoadedByAttempt) || {}; + return Boolean(byAttempt[String(attemptIndex)]); + } + return Boolean(task?._traceStepsLoaded); +} + +function taskTraceStepDetails(task: any): AnyRecord { + return task?._traceStepDetails && typeof task._traceStepDetails === "object" && !Array.isArray(task._traceStepDetails) ? task._traceStepDetails : {}; +} + +function taskDurationLabel(task: any): string { + const timing = task?.timing && typeof task.timing === "object" ? task.timing : {}; + const status = String(task?.status || ""); + if (["queued"].includes(status)) return `等待 ${fmtDuration(timing.queueWaitMs ?? timing.totalElapsedMs)}`; + if (["running", "judging", "retry_wait"].includes(status)) return `耗时 ${fmtDuration(timing.durationMs ?? timing.totalElapsedMs)}`; + return `耗时 ${fmtDuration(timing.durationMs ?? timing.totalElapsedMs)}`; +} + +function taskQueueLabel(task: any): string { + return String(task?.queueId || "default"); +} + function channelLabel(channel: string): string { const labels: Record = { system: "SYS", @@ -201,24 +659,6 @@ function channelLabel(channel: string): string { return labels[channel] || channel.toUpperCase(); } -function transcriptKindLabel(kind: string): string { - const labels: Record = { - ran: "Ran", - explored: "Explored", - edited: "Edited", - plan: "Plan", - message: "Message", - system: "System", - error: "Error", - }; - return labels[kind] || "Message"; -} - -function omittedLabel(lines: any): string { - const count = Number(lines || 0); - return Number.isFinite(count) && count > 0 ? `… +${Math.floor(count)} lines` : ""; -} - function taskIsActive(task: any): boolean { return ["running", "judging", "retry_wait"].includes(String(task?.status || "")); } @@ -227,18 +667,154 @@ function taskIsTerminal(task: any): boolean { return ["succeeded", "failed", "canceled"].includes(String(task?.status || "")); } +function taskIsUnreadTerminal(task: any): boolean { + if (!taskIsTerminal(task)) return false; + if (task?.terminalUnread === true) return true; + if (task?.terminalUnread === false) return false; + return !task?.readAt; +} + +function loadLocalReadAt(): AnyRecord { + if (typeof window === "undefined") return {}; + try { + const parsed = JSON.parse(window.localStorage.getItem(codexReadAtStorageKey) || "{}"); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +function saveLocalReadAt(readAtByTask: AnyRecord): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(codexReadAtStorageKey, JSON.stringify(readAtByTask)); + } catch { + // Best-effort fallback only; backend readAt remains authoritative when deployed. + } +} + +function applyLocalReadState(task: any, readAtByTask: AnyRecord): any { + const taskId = String(task?.id || ""); + const localReadAt = String(readAtByTask?.[taskId] || ""); + if (!taskIsTerminal(task) || localReadAt.length === 0) return task; + return { ...task, readAt: task?.readAt || localReadAt, terminalUnread: false }; +} + +function countNumber(value: any): number { + const number = Number(value || 0); + return Number.isFinite(number) ? number : 0; +} + +function queueWaitingCount(counts: AnyRecord): number { + return countNumber(counts.queued) + countNumber(counts.retry_wait); +} + +function queueRunningCount(counts: AnyRecord): number { + return countNumber(counts.running) + countNumber(counts.judging); +} + +function taskListRank(task: any): number { + if (taskIsUnreadTerminal(task)) return 0; + const statusRank: Record = { running: 1, judging: 2, retry_wait: 3, queued: 4, succeeded: 8, failed: 8, canceled: 8 }; + return statusRank[String(task?.status || "")] ?? 9; +} + function taskHasDetail(task: any): boolean { return Boolean(task?._detailLoaded) + || Boolean(task?._traceSummaryLoaded) + || (Array.isArray(task?.promptHistory) && task.promptHistory.length > 0) || (Array.isArray(task?.transcript) && task.transcript.length > 0) || (Array.isArray(task?.output) && task.output.length > 0) || (Array.isArray(task?.events) && task.events.length > 0); } +function taskNeedsFullDetail(task: any): boolean { + if (!task) return false; + if (task?._traceSummaryLoaded === true) return false; + return task?.summaryOnly === true || task?._metaLoaded !== true; +} + +function taskHasFullMetadata(task: any): boolean { + return Boolean(task?._metaLoaded) || task?.summaryOnly === false; +} + +function preferLongerText(existingTask: any, patch: AnyRecord, key: string): string { + const existingText = String(existingTask?.[key] || ""); + const incomingText = String(patch?.[key] || ""); + return existingText.length > incomingText.length ? existingText : incomingText; +} + +function preferRicherArray(existingTask: any, patch: AnyRecord, key: string): any[] { + const existingRows = Array.isArray(existingTask?.[key]) ? existingTask[key] : []; + const incomingRows = Array.isArray(patch?.[key]) ? patch[key] : []; + if (incomingRows.length === 0 && existingRows.length > 0) return existingRows; + return existingRows.length > incomingRows.length ? existingRows : incomingRows; +} + +function mergeTaskPatch(existingTask: any, patch: AnyRecord): AnyRecord { + const incomingSummary = patch?.summaryOnly === true && taskHasFullMetadata(existingTask); + const merged: AnyRecord = { ...existingTask, ...patch }; + if (!incomingSummary) return merged; + for (const key of ["prompt", "basePrompt", "displayPrompt", "finalResponse"]) { + merged[key] = preferLongerText(existingTask, patch, key); + } + for (const key of ["promptHistory", "attempts", "output", "events"]) { + merged[key] = preferRicherArray(existingTask, patch, key); + } + if (existingTask?.referenceInjection?.items && !patch?.referenceInjection?.items) merged.referenceInjection = existingTask.referenceInjection; + if (existingTask?.referenceInjectionSummary && !patch?.referenceInjectionSummary) merged.referenceInjectionSummary = existingTask.referenceInjectionSummary; + merged.summaryOnly = existingTask?.summaryOnly === false ? false : patch.summaryOnly; + merged._metaLoaded = existingTask?._metaLoaded; + merged._detailLoaded = existingTask?._detailLoaded; + merged._transcriptComplete = existingTask?._transcriptComplete; + merged._transcriptPreview = Object.prototype.hasOwnProperty.call(patch, "_transcriptPreview") + ? patch._transcriptPreview + : existingTask?._transcriptPreview; + for (const key of ["_traceSummary", "_traceSummaryLoaded", "_traceSteps", "_traceStepsLoaded", "_traceStepsByAttempt", "_traceStepsLoadedByAttempt", "_traceStepDetails", "_promptDetails"]) { + if (!Object.prototype.hasOwnProperty.call(patch, key) && Object.prototype.hasOwnProperty.call(existingTask || {}, key)) { + merged[key] = existingTask[key]; + } + } + return merged; +} + +function overviewSelectedTask(data: any): any | null { + const selected = data?.selected; + const selectedTask = selected?.task && typeof selected.task === "object" ? selected.task : null; + if (selectedTask !== null) { + const previewOnly = Boolean(selected?.preview); + return { + ...selectedTask, + transcript: Array.isArray(selected?.transcript) ? selected.transcript : [], + _detailLoaded: Array.isArray(selected?.transcript) && selected.transcript.length > 0, + _transcriptComplete: Boolean(!previewOnly && !selected?.hasMore && taskIsTerminal(selectedTask)), + _transcriptPreview: previewOnly, + _summaryLoaded: true, + }; + } + const first = taskRows(data)[0]; + return first ? { ...first, _summaryLoaded: true } : null; +} + function mergeTranscriptRows(existing: any[], incoming: any[]): any[] { const byKey = new Map(); for (const item of [...(Array.isArray(existing) ? existing : []), ...(Array.isArray(incoming) ? incoming : [])]) { const key = `${Number(item?.seq ?? 0)}:${String(item?.kind || "message")}`; - byKey.set(key, item); + const previous = byKey.get(key); + if (!previous) { + byKey.set(key, item); + continue; + } + const next = { ...previous, ...item }; + for (const [textKey, omittedKey] of [["bodyPreview", "bodyOmittedLines"], ["commandPreview", "commandOmittedLines"]] as const) { + const previousText = String(previous?.[textKey] || ""); + const incomingText = String(item?.[textKey] || ""); + if (previousText.length > incomingText.length) { + next[textKey] = previous[textKey]; + next[omittedKey] = previous[omittedKey]; + } + } + byKey.set(key, next); } return Array.from(byKey.values()).sort((left, right) => Number(left?.seq ?? 0) - Number(right?.seq ?? 0)); } @@ -247,6 +823,16 @@ function transcriptMaxSeq(transcript: any[]): number { return (Array.isArray(transcript) ? transcript : []).reduce((max, item) => Math.max(max, Number(item?.seq ?? 0)), 0); } +function transcriptResumeSeq(transcript: any[], overlapRows = 8): number { + const seqs = Array.from(new Set((Array.isArray(transcript) ? transcript : []) + .map((item) => Number(item?.seq ?? 0)) + .filter((seq) => Number.isFinite(seq) && seq > 0))) + .sort((left, right) => left - right); + if (seqs.length === 0) return 0; + const anchor = seqs[Math.max(0, seqs.length - overlapRows)] ?? 0; + return Math.max(0, anchor - 0.001); +} + function countValue(counts: AnyRecord, key: string): string { const value = Number(counts[key] ?? 0); return Number.isFinite(value) ? String(value) : "0"; @@ -254,32 +840,82 @@ function countValue(counts: AnyRecord, key: string): string { function codexModelOptions(queue: any, currentModel: string): string[] { const configured = Array.isArray(queue?.codexModels) ? queue.codexModels : []; - const fallback = ["gpt-5.4-mini", "gpt-5.4", "gpt-5.5"]; + const fallback = ["gpt-5.5", "gpt-5.4-mini", "gpt-5.4"]; return Array.from(new Set([...configured, ...fallback, currentModel].map((item) => String(item || "").trim()).filter(Boolean))); } -function TaskCard({ task, selected, onSelect }: AnyRecord) { +function TaskCard({ task, selected, onSelect, onCopy, onReference, onMarkRead, copied, markingRead }: AnyRecord) { const judge = task?.lastJudge || {}; - return h("button", { - type: "button", - className: `codex-task-card ${selected ? "selected" : ""}`, + const taskId = String(task?.id || ""); + const unread = taskIsUnreadTerminal(task); + return h("article", { + role: "button", + tabIndex: 0, + className: `codex-task-card ${selected ? "selected" : ""} ${unread ? "unread-terminal" : ""}`, onClick: onSelect, + onKeyDown: (event: any) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelect(); + } + }, + "data-unread-terminal": unread ? "true" : "false", "data-testid": `codex-task-${task?.id || "unknown"}`, }, + unread ? h("span", { className: "codex-unread-badge", title: "待读", "aria-label": "待读", "data-testid": `codex-unread-task-${taskId || "unknown"}` }) : null, h("div", { className: "codex-task-card-head" }, - h(StatusBadge, { status: task?.status }, task?.status || "unknown"), + h("div", { className: "codex-task-status-line" }, + 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-id-row" }, + h("code", { title: taskId }, taskId || "unknown"), + h("div", { className: "codex-task-id-actions" }, + h("button", { + type: "button", + className: "codex-copy-id-btn", + onClick: (event: any) => { + event.stopPropagation(); + onReference(taskId); + }, + "data-testid": `codex-reference-task-${taskId || "unknown"}`, + }, "引用"), + h("button", { + type: "button", + className: "codex-copy-id-btn", + onClick: (event: any) => { + event.stopPropagation(); + onCopy(taskId); + }, + "data-testid": `codex-copy-task-id-${taskId || "unknown"}`, + }, copied ? "已复制" : "复制ID"), + unread ? h("button", { + type: "button", + className: "codex-copy-id-btn codex-mark-read-btn", + disabled: Boolean(markingRead), + onClick: (event: any) => { + event.stopPropagation(); + onMarkRead(taskId); + }, + "data-testid": `codex-mark-task-read-${taskId || "unknown"}`, + }, markingRead ? "标记中" : "标为已读") : null, + ), + ), + h("strong", null, shortText(taskDisplayPrompt(task), 120) || "空任务"), h("div", { className: "codex-task-meta" }, + h("span", null, `queue=${taskQueueLabel(task)}`), h("span", null, task?.model || "--"), + h("span", null, taskDurationLabel(task)), + ), + h("div", { className: "codex-task-meta" }, 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 TaskListSection({ title, tasks, selectedId, onSelect, emptyText }: AnyRecord) { +function TaskListSection({ title, tasks, selectedId, onSelect, onCopy, onReference, onMarkRead, copiedTaskId, markingReadTaskId, emptyText }: AnyRecord) { const rows = Array.isArray(tasks) ? tasks : []; return h("section", { className: "codex-task-section" }, h("div", { className: "codex-task-section-head" }, @@ -293,63 +929,348 @@ function TaskListSection({ title, tasks, selectedId, onSelect, emptyText }: AnyR task, selected: selectedId === task.id, onSelect: () => onSelect(task.id), + onCopy, + onReference, + onMarkRead, + copied: copiedTaskId === task.id, + markingRead: markingReadTaskId === task.id, }))), ); } -function Transcript({ task, autoScroll, loading }: AnyRecord) { - const ref = useRef(null); - const transcript = taskTranscript(task); +function TaskQueueMoveControl({ task, queueRows, busy, onMove }: AnyRecord) { + const taskId = String(task?.id || ""); + const currentQueue = taskQueueLabel(task); + const [targetQueue, setTargetQueue] = useState(currentQueue); useEffect(() => { - if (autoScroll && ref.current) ref.current.scrollTop = ref.current.scrollHeight; - }, [autoScroll, transcript.length, task?.id]); - if (!task) return h(EmptyState, { title: "未选择任务", text: "从左侧队列选择任务,或提交新 Codex 任务。" }); - if (loading && !taskHasDetail(task)) return h("div", { className: "codex-transcript", ref, "data-testid": "codex-output" }, - h("div", { className: "codex-output-empty" }, "正在加载完整 session 记录..."), + setTargetQueue(currentQueue); + }, [taskId, currentQueue]); + const disabled = !taskId || busy || ["running", "judging", "retry_wait"].includes(String(task?.status || "")); + return h("div", { className: "codex-task-move-control", "data-testid": "codex-task-queue-move-control" }, + h("label", null, "任务 queue", + h("select", { + value: targetQueue, + disabled: !taskId || busy, + onChange: (event: any) => setTargetQueue(String(event.target.value || currentQueue)), + "data-testid": "codex-task-queue-move-select", + }, + queueRows.map((row: any) => h("option", { key: String(row?.id || ""), value: String(row?.id || "") }, queueOptionLabel(row))), + ), + ), + h("button", { + type: "button", + className: "ghost-btn", + disabled: disabled || targetQueue === currentQueue, + onClick: () => onMove(targetQueue), + title: disabled ? "运行中 / judging / retry_wait 的任务不能移动;请先打断或等当前 turn 结束" : "移动已创建任务到另一个 queue", + "data-testid": "codex-task-queue-move-button", + }, "移动"), ); - return h("div", { className: "codex-transcript", ref, "data-testid": "codex-output" }, - transcript.length === 0 ? h("div", { className: "codex-output-empty" }, "等待 Codex 输出...") : transcript.map((item: any) => { - const kind = String(item.kind || "message"); - const isCommand = ["ran", "explored", "edited"].includes(kind); - const commandMore = omittedLabel(item.commandOmittedLines); - const bodyMore = omittedLabel(item.bodyOmittedLines); - const commandText = String(item.commandPreview || (isCommand ? item.title || "" : "")); - return h("article", { key: `${item.seq}-${kind}`, className: `codex-transcript-item ${kind}` }, - h("div", { className: "codex-transcript-bullet" }, "•"), - h("div", { className: "codex-transcript-main" }, - h("div", { className: "codex-transcript-title" }, - h("span", { className: "codex-output-channel" }, transcriptKindLabel(kind)), - isCommand ? null : h("strong", null, String(item.title || transcriptKindLabel(kind))), - item.status ? h("code", null, String(item.status)) : null, - h("time", null, fmtDate(item.at)), +} + +function listPreview(items: any[], limit = 4): string { + const rows = (Array.isArray(items) ? items : []).map((item) => String(item || "").trim()).filter(Boolean); + if (rows.length === 0) return "--"; + const shown = rows.slice(0, limit).join(" / "); + return rows.length > limit ? `${shown} +${rows.length - limit}` : shown; +} + +function ProgressivePromptBlock({ task, loading, onLoadPromptPart, testId = "codex-initial-prompt-full", textTestId = "codex-initial-prompt-full-text", baseTextTestId = "codex-initial-prompt-base" }: AnyRecord) { + const promptSummary = taskPromptSummary(task); + const promptDetails = taskPromptDetails(task); + const basePrompt = taskBasePromptText(task).trimEnd(); + const fullPrompt = String(promptDetails.full?.text || ""); + const hasReference = taskHasReferencePrompt(task); + const fullChars = Number(promptSummary.promptChars || task?.promptChars || fullPrompt.length); + const baseLines = Number(promptSummary.basePromptLines || promptLineCount(basePrompt)); + const fullLines = Number(promptSummary.promptLines || promptLineCount(fullPrompt)); + return h("section", { className: "codex-progressive-card codex-progressive-prompt", "data-testid": "codex-progressive-prompt" }, + h("div", { className: "codex-progressive-card-head" }, + h("span", { className: "codex-output-channel" }, "Prompt"), + h("strong", null, "Submitted prompt / 原始用户 prompt"), + h("code", null, `${baseLines || promptLineCount(basePrompt)} lines / ${basePrompt.length} chars`), + ), + h("pre", { className: "codex-prompt-full", "data-testid": baseTextTestId }, basePrompt || "空 prompt"), + hasReference ? h("details", { + className: "codex-reference-injection codex-progressive-full-prompt", + "data-testid": testId, + onToggle: (event: any) => { + if (event.currentTarget?.open && !fullPrompt) onLoadPromptPart?.("full"); + }, + }, + h("summary", null, + h("span", null, "引用注入已折叠,点击按需拉取最终进入 opencode 的完整 prompt"), + h("code", null, fullPrompt + ? `${fullLines || promptLineCount(fullPrompt)} lines / ${fullPrompt.length} chars` + : `${Number.isFinite(fullChars) && fullChars > 0 ? fullChars : "--"} chars`), + ), + h("pre", { className: "codex-prompt-full codex-prompt-final-full", "data-testid": textTestId }, fullPrompt || (loading ? "正在按需拉取完整 prompt..." : "展开后将只请求 full prompt,不拉取完整 transcript。")), + ) : null, + ); +} + +function ProgressiveExecutionSummary({ task, attempt, attemptIndex, loading, onLoadSteps, onLoadStep, testId = "codex-execution-summary" }: AnyRecord) { + const execution = attemptExecutionSummary(task, attempt); + const steps = taskTraceSteps(task, attemptIndex); + const stepDetails = taskTraceStepDetails(task); + const stepsLoaded = taskTraceStepsLoaded(task, attemptIndex); + const toolCount = Number(execution.toolCallCount || 0); + const editedFiles = Array.isArray(execution.editedFiles) ? execution.editedFiles : []; + const commands = Array.isArray(execution.commands) ? execution.commands : []; + const labelSuffix = attemptIndex ? ` #${attemptIndex}` : ""; + return h("details", { + className: "codex-progressive-card codex-execution-summary", + "data-testid": testId, + "data-attempt-index": attemptIndex ? String(attemptIndex) : undefined, + onToggle: (event: any) => { + if (event.currentTarget?.open && !stepsLoaded) onLoadSteps?.(attemptIndex); + }, + }, + h("summary", null, + h("div", { className: "codex-progressive-card-head" }, + h("span", { className: "codex-output-channel" }, "Summary"), + h("strong", null, `执行过程摘要${labelSuffix}`), + h("code", null, `${fmtDuration(execution.durationMs ?? execution.totalElapsedMs)} / ${toolCount} tools`), + ), + h("div", { className: "codex-execution-digest" }, + h("span", null, `read ${Number(execution.readCount || 0)}`), + h("span", null, `edit ${Number(execution.editCount || 0)}`), + h("span", null, `run ${Number(execution.runCount || 0)}`), + h("span", null, `${Number(execution.stepCount || steps.length || 0)} steps`), + ), + ), + h("div", { className: "codex-execution-digest expanded" }, + h("span", null, `修改文件:${listPreview(editedFiles, 6)}`), + h("span", null, `执行命令:${listPreview(commands, 4)}`), + ), + steps.length === 0 + ? h("div", { className: "codex-output-empty" }, loading ? "正在按需拉取步骤 summary..." : "展开后将只请求执行步骤 summary,不拉取单步骤全量。") + : h("div", { className: "codex-trace-step-list" }, steps.map((step: any) => { + const seq = String(step?.seq ?? ""); + const detail = stepDetails[seq]; + const summaryLines = Array.isArray(step?.summaryLines) ? step.summaryLines.slice(0, 4) : []; + return h("details", { + key: seq || `${step?.title}-${step?.at}`, + className: `codex-trace-step ${String(step?.kind || "message")}`, + "data-testid": `codex-trace-step-${seq || "unknown"}`, + onToggle: (event: any) => { + if (event.currentTarget?.open && !detail) onLoadStep?.(step?.seq); + }, + }, + h("summary", null, + h("span", { className: "codex-output-channel" }, traceStepKindLabel(step?.kind)), + h("strong", null, String(step?.title || "Trace step")), + step?.status ? h("code", null, String(step.status)) : null, + h("time", null, fmtDate(step?.at)), ), - commandText ? h("pre", { className: "codex-transcript-command" }, - commandText, - commandMore ? `\n${commandMore}` : "", - ) : null, - item.bodyPreview ? h("pre", { className: "codex-transcript-body" }, - String(item.bodyPreview), - bodyMore ? `\n${bodyMore} (查看原始JSON获取完整记录)` : "", - ) : null, - ), - ); + h("div", { className: "codex-trace-step-summary" }, + summaryLines.length > 0 ? summaryLines.map((line: any, index: number) => h("pre", { key: `${seq}-${index}` }, String(line || ""))) : h("span", null, "无 summary"), + ), + detail?.line + ? h(TraceView, { + items: [detail.line], + autoScroll: false, + loading: false, + hasDetail: true, + emptyText: "无步骤详情", + testId: `codex-trace-step-detail-${seq || "unknown"}`, + className: "codex-transcript codex-step-detail-transcript", + collapseTools: false, + }) + : h("div", { className: "codex-output-empty" }, loading ? "正在按需拉取这个步骤的全量数据..." : "展开后将只请求这个单步骤的全量数据。"), + ); + })), + ); +} + +function traceStepKindLabel(kind: any): string { + const value = String(kind || ""); + if (value === "ran") return "Ran"; + if (value === "explored") return "Explored"; + if (value === "edited") return "Edited"; + if (value === "error") return "Error"; + if (value === "system") return "System"; + return "Message"; +} + +function ProgressiveFinalResponse({ task, attempt, attemptIndex, testId = "codex-final-response" }: AnyRecord) { + const text = attemptFinalResponseText(task, attempt); + const chars = Number(attempt?.finalResponseChars || text.length); + const labelSuffix = attemptIndex ? ` #${attemptIndex}` : ""; + return h("section", { className: "codex-progressive-card codex-final-response", "data-testid": testId, "data-attempt-index": attemptIndex ? String(attemptIndex) : undefined }, + h("div", { className: "codex-progressive-card-head" }, + h("span", { className: "codex-output-channel" }, "Final"), + h("strong", null, `最终 response${labelSuffix}`), + h("code", null, `${Number.isFinite(chars) ? chars : text.length} chars`), + ), + h("pre", { className: "codex-transcript-body" }, text || "暂无最终 response"), + ); +} + +function ProgressiveJudge({ task, attempt, attemptIndex, testId = "codex-progressive-judge" }: AnyRecord) { + const judge = attemptJudge(task, attempt); + const labelSuffix = attemptIndex ? ` #${attemptIndex}` : ""; + return h("section", { className: "codex-progressive-card codex-progressive-judge", "data-testid": testId, "data-attempt-index": attemptIndex ? String(attemptIndex) : undefined }, + h("div", { className: "codex-progressive-card-head" }, + h("span", { className: "codex-output-channel" }, "Judge"), + h("strong", null, `完成判定${labelSuffix}`), + judge?.decision ? h("code", null, `${judge.decision} ${Math.round(Number(judge.confidence || 0) * 100)}%`) : null, + ), + judge ? h("div", { className: "codex-judge-card", "data-testid": `${testId}-card` }, + h(StatusBadge, { status: judge.decision }, judge.decision), + h("strong", null, `${Math.round(Number(judge.confidence || 0) * 100)}% confidence`), + h("p", { "data-testid": `${testId}-reason` }, judge.reason || "--"), + judge.continuePrompt ? h("pre", { "data-testid": `${testId}-continue-prompt` }, String(judge.continuePrompt || "")) : null, + ) : h("div", { className: "codex-output-empty" }, "尚未判定"), + ); +} + +function ProgressiveJudgeFeedbackPrompt({ task, attempt, attemptIndex, loading, onLoadPromptPart, testId = "codex-judge-feedback-prompt" }: AnyRecord) { + const feedback = attemptFeedbackPrompt(task, attempt, attemptIndex); + if (feedback === null) return null; + const detailKey = feedbackPromptDetailKey(attemptIndex); + const promptDetails = taskPromptDetails(task); + const detail = promptDetails[detailKey]; + const detailText = String(detail?.text || "").trimEnd(); + const preview = String(feedback.preview || feedback.text || "").trimEnd(); + const visibleText = detailText || String(feedback.text || "").trimEnd(); + const chars = Number(detail?.chars || feedback.chars || visibleText.length || preview.length); + const lines = Number(detail?.lines || feedback.lines || promptLineCount(visibleText || preview)); + const forAttempt = detail?.forAttempt || feedback.forAttempt || Number(attemptIndex || 0) + 1; + return h("details", { + className: "codex-progressive-card codex-judge-feedback-prompt", + "data-testid": testId, + "data-attempt-index": attemptIndex ? String(attemptIndex) : undefined, + onToggle: (event: any) => { + if (event.currentTarget?.open && !detailText) onLoadPromptPart?.("feedback", attemptIndex); + }, + }, + h("summary", null, + h("div", { className: "codex-progressive-card-head" }, + h("span", { className: "codex-output-channel" }, "Prompt"), + h("strong", null, `judge feedback prompt #${attemptIndex} -> #${forAttempt}`), + h("code", null, `${lines || "--"} lines / ${Number.isFinite(chars) ? chars : preview.length} chars`), + ), + h("p", { className: "codex-feedback-preview", "data-testid": `${testId}-preview` }, preview || "展开后按需拉取 judge feedback prompt。"), + ), + h("pre", { className: "codex-prompt-full codex-feedback-full", "data-testid": `${testId}-text` }, + visibleText || (loading ? "正在按需拉取 judge feedback prompt..." : "展开后将只请求这一次 judge feedback prompt。")), + ); +} + +function ProgressiveAttemptCycle({ task, attempt, position, loading, onLoadPromptPart, onLoadSteps, onLoadStep }: AnyRecord) { + const attemptIndex = Number(attempt?.index || position + 1); + const first = position === 0; + return h("section", { className: "codex-attempt-cycle", "data-testid": `codex-attempt-cycle-${attemptIndex}` }, + h("div", { className: "codex-attempt-cycle-head" }, + h("span", { className: "codex-output-channel" }, `Attempt ${attemptIndex}`), + h("strong", null, String(attempt?.mode || (attemptIndex <= 1 ? "initial" : "retry"))), + attempt?.terminalStatus ? h(StatusBadge, { status: attempt.terminalStatus }, attempt.terminalStatus) : null, + h("code", null, `${fmtDate(attempt?.startedAt)} -> ${fmtDate(attempt?.finishedAt)}`), + ), + h(ProgressiveExecutionSummary, { + task, + attempt, + attemptIndex, + loading, + onLoadSteps, + onLoadStep, + testId: first ? "codex-execution-summary" : `codex-execution-summary-attempt-${attemptIndex}`, + }), + h(ProgressiveFinalResponse, { + task, + attempt, + attemptIndex, + testId: first ? "codex-final-response" : `codex-final-response-attempt-${attemptIndex}`, + }), + h(ProgressiveJudge, { + task, + attempt, + attemptIndex, + testId: first ? "codex-progressive-judge" : `codex-progressive-judge-attempt-${attemptIndex}`, + }), + h(ProgressiveJudgeFeedbackPrompt, { + task, + attempt, + attemptIndex, + loading, + onLoadPromptPart, + testId: first ? "codex-judge-feedback-prompt" : `codex-judge-feedback-prompt-attempt-${attemptIndex}`, }), ); } -function PromptDetail({ task }: AnyRecord) { +function ProgressiveTrace({ task, loading, onLoadPromptPart, onLoadSteps, onLoadStep }: AnyRecord) { + if (!task) return h(EmptyState, { title: "未选择任务", text: "从左侧队列选择任务,或提交新 Codex 任务。" }); + const attempts = taskProgressiveAttempts(task); + return h("div", { className: "codex-transcript codex-progressive-trace", "data-testid": "codex-output" }, + loading && !taskTraceSummary(task) ? h("div", { className: "codex-output-empty" }, "正在加载 Trace Summary...") : null, + h(ProgressivePromptBlock, { task, loading, onLoadPromptPart }), + attempts.length > 0 + ? attempts.map((attempt: any, index: number) => h(ProgressiveAttemptCycle, { key: `${attempt?.index || index + 1}-${attempt?.startedAt || index}`, task, attempt, position: index, loading, onLoadPromptPart, onLoadSteps, onLoadStep })) + : [ + h(ProgressiveExecutionSummary, { key: "execution", task, loading, onLoadSteps, onLoadStep }), + h(ProgressiveFinalResponse, { key: "final", task }), + h(ProgressiveJudge, { key: "judge", task }), + ], + ); +} + + +function PromptDetail({ task, loading, onLoadPromptPart }: AnyRecord) { if (!task) return h(EmptyState, { title: "未选择任务", text: "选择队列或历史 session 后,这里显示完整 prompt、模型和工作目录。" }); - const promptText = String(task?.prompt || ""); - const lines = promptText.length > 0 ? promptText.split(/\r\n|\r|\n/u).length : 0; + const promptSummary = taskPromptSummary(task); + const promptDetails = taskPromptDetails(task); + const visiblePrompt = taskBasePromptText(task).trimEnd(); + const promptText = String(promptDetails.full?.text || ""); + const hasReference = taskHasReferencePrompt(task); + const visibleLines = Number(promptSummary.basePromptLines || promptLineCount(visiblePrompt)); + const totalLines = Number(promptSummary.promptLines || promptLineCount(promptText)); + const referenceLines = Number(promptSummary.referencePromptLines || 0); + const fullChars = Number(promptSummary.promptChars || task?.promptChars || promptText.length); return h("div", { className: "codex-prompt-detail", "data-testid": "codex-task-prompt-detail" }, h("div", { className: "codex-prompt-meta" }, h(StatusBadge, { status: task?.status }, task?.status || "unknown"), h("span", null, `model=${task?.model || "--"}`), h("span", null, `cwd=${task?.cwd || "--"}`), h("span", null, `created=${fmtDate(task?.createdAt)}`), - h("span", null, `${lines} lines / ${promptText.length} chars`), + h("span", null, hasReference + ? `task ${visibleLines} lines / total ${Number.isFinite(totalLines) && totalLines > 0 ? totalLines : "--"} lines` + : `${visibleLines} lines / ${visiblePrompt.length} chars`), ), - h("pre", { className: "codex-prompt-full", "data-testid": "codex-task-prompt-full" }, promptText || "空 prompt"), + h("div", { className: "codex-lazy-detail-callout", "data-testid": "codex-task-summary-callout" }, + h("div", null, + h("strong", null, "渐进式 Trace"), + h("span", null, "首屏使用后端 Summary;展开 prompt / 步骤时只按需拉取对应片段,不一次性拉取完整 transcript。"), + ), + ), + hasReference ? h("details", { + className: "codex-reference-injection codex-final-prompt-injection", + "data-testid": "codex-final-prompt-full", + onToggle: (event: any) => { + if (event.currentTarget?.open && !promptText) onLoadPromptPart?.("full"); + }, + }, + h("summary", null, + h("span", null, "最终传入 Codex 的真实完整 prompt"), + h("code", null, promptText ? `${totalLines || promptLineCount(promptText)} lines / ${promptText.length} chars` : `${Number.isFinite(fullChars) && fullChars > 0 ? fullChars : "--"} chars`), + ), + h("pre", { className: "codex-prompt-full codex-prompt-final-full", "data-testid": "codex-task-final-prompt-full" }, promptText || (loading ? "正在按需拉取完整 prompt..." : "展开后将只请求完整 prompt。")), + ) : null, + hasReference ? h("details", { + className: "codex-reference-injection", + "data-testid": "codex-reference-injection", + onToggle: (event: any) => { + if (event.currentTarget?.open && !promptDetails.reference?.text) onLoadPromptPart?.("reference"); + }, + }, + h("summary", null, + h("span", null, "引用注入已折叠"), + h("code", null, promptDetails.reference?.text ? `${promptLineCount(String(promptDetails.reference.text || ""))} lines / ${String(promptDetails.reference.text || "").length} chars` : `${referenceLines || "--"} lines`), + ), + h("pre", { className: "codex-prompt-full codex-prompt-reference-full", "data-testid": "codex-task-reference-full" }, String(promptDetails.reference?.text || "") || (loading ? "正在按需拉取引用注入..." : "展开后将只请求引用注入片段。")), + ) : null, + h("pre", { className: "codex-prompt-full", "data-testid": "codex-task-prompt-full" }, visiblePrompt || "空 prompt"), ); } @@ -396,81 +1317,369 @@ function AttemptTable({ task }: AnyRecord) { ); } -export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyRecord) { +export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initialTasksData = null, standalone = false }: AnyRecord) { const service = microservices.find((item: any) => item.id === "codex-queue") || null; - const selectedIdRef = useRef(""); + const initialSelectedTask = overviewSelectedTask(initialTasksData); + const initialSelectedId = String(initialSelectedTask?.id || ""); + const initialSessionCache = new Map(); + if (initialSelectedTask !== null && initialSelectedId.length > 0) { + initialSessionCache.set(initialSelectedId, { + task: initialSelectedTask, + maxSeq: transcriptMaxSeq(Array.isArray(initialSelectedTask.transcript) ? initialSelectedTask.transcript : []), + complete: Boolean(initialSelectedTask._transcriptComplete), + completeUpdatedAt: initialSelectedTask._transcriptComplete ? String(initialSelectedTask.updatedAt || "") : "", + }); + } + const initialLoadMs = typeof performance === "undefined" ? 0 : performance.now(); + const selectedIdRef = useRef(initialSelectedId); const queueLoadTokenRef = useRef(0); const detailLoadTokenRef = useRef(0); + const enqueueInFlightRef = useRef(false); + const loadMoreInFlightRef = useRef(false); const detailInFlightRef = useRef<{ taskId: string; token: number; promise: Promise } | null>(null); - const sessionCacheRef = useRef>(new Map()); + const traceSummaryInFlightRef = useRef>>(new Map()); + const promptDetailInFlightRef = useRef>>(new Map()); + const traceStepsInFlightRef = useRef>>(new Map()); + const traceStepInFlightRef = useRef>>(new Map()); + const autoTraceLoadKeysRef = useRef>(new Set()); + const trackedLoadInFlightRef = useRef(false); + const initialLoadSkippedRef = useRef(Boolean(initialTasksData)); + const sessionCacheRef = useRef>(initialSessionCache); + const tasksDataRef = useRef(initialTasksData); const [health, setHealth] = useState(null); - const [tasksData, setTasksData] = useState(null); - const [selectedId, setSelectedId] = useState(""); - const [selectedTask, setSelectedTask] = useState(null); + const [tasksData, setTasksDataState] = useState(initialTasksData); + const [selectedId, setSelectedId] = useState(initialSelectedId); + const [selectedTask, setSelectedTask] = useState(initialSelectedTask); const [selectedDetailLoading, setSelectedDetailLoading] = useState(false); - const [prompt, setPrompt] = useState("请在 UniDesk 工作区中完成一个很小的验证任务:读取 package.json 并总结项目名称,不要修改文件。"); - const [model, setModel] = useState("gpt-5.4-mini"); + const [prompt, setPrompt] = useState(""); + const [referenceTaskId, setReferenceTaskId] = useState(""); + const [queueId, setQueueId] = useState("default"); + const [selectedQueueId, setSelectedQueueId] = useState(allQueuesId); + const [model, setModel] = useState("gpt-5.5"); const [cwd, setCwd] = useState("/root/unidesk"); - const [maxAttempts, setMaxAttempts] = useState(3); + const [maxAttempts, setMaxAttempts] = useState(99); const [repeatCount, setRepeatCount] = useState(1); + const [batchConfirmed, setBatchConfirmed] = useState(false); + const [submitting, setSubmitting] = useState(false); const [steerPrompt, setSteerPrompt] = useState(""); const [autoScroll, setAutoScroll] = useState(true); - const [queueSidebarOpen, setQueueSidebarOpen] = useState(true); + const [queueSidebarOpen, setQueueSidebarOpen] = useState(() => typeof window === "undefined" ? true : window.matchMedia(queueDesktopMediaQuery).matches); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); - const [refreshedAt, setRefreshedAt] = useState(null); + const [notice, setNotice] = useState(""); + const [copiedTaskId, setCopiedTaskId] = useState(""); + const [markingReadTaskId, setMarkingReadTaskId] = useState(""); + const [markingAllRead, setMarkingAllRead] = useState(false); + const [localReadAt, setLocalReadAt] = useState(loadLocalReadAt); + const [loadStats, setLoadStats] = useState(initialTasksData ? { + phase: "complete", + taskId: initialSelectedId, + queueMs: 0, + detailMs: 0, + totalMs: initialLoadMs, + chunks: initialSelectedTask ? 1 : 0, + transcriptRows: Array.isArray(initialSelectedTask?.transcript) ? initialSelectedTask.transcript.length : 0, + partial: Boolean(initialTasksData?.selected?.hasMore || taskNeedsFullDetail(initialSelectedTask)), + completedAt: new Date(), + } : null); + const [refreshedAt, setRefreshedAt] = useState(initialTasksData ? new Date() : null); + const [loadingMoreTasks, setLoadingMoreTasks] = useState(false); - const tasks = taskRows(tasksData); + const tasks = taskRows(tasksData).map((task: any) => applyLocalReadState(task, localReadAt)); + const unreadTerminalTasks = tasks.filter(taskIsUnreadTerminal); const queuedTasks = tasks.filter((task: any) => !taskIsTerminal(task)); - const historyTasks = tasks.filter((task: any) => taskIsTerminal(task)); + const historyTasks = tasks.filter((task: any) => taskIsTerminal(task) && !taskIsUnreadTerminal(task)); 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 pagination = taskPagination(tasksData); + const queueRows = knownQueueRows(queue, queueId); + const viewQueueRow = selectedQueueRow(queueRows, selectedQueueId); + const totalTaskCount = Number((isAllQueues(selectedQueueId) ? queue?.total : viewQueueRow?.total) ?? pagination.total ?? tasks.length); + const hasMoreTasks = pagination.hasMore === true && String(pagination.nextBeforeId || "").length > 0; + const globalActiveIds = activeTaskIds(queue); + const activeIds = isAllQueues(selectedQueueId) + ? globalActiveIds + : [String(selectedQueueRow(queueRows, selectedQueueId)?.activeTaskId || "")].filter(Boolean); + const activeTaskId = queueActiveTaskId(queue, queueRows, selectedQueueId, tasks); + const counts = isAllQueues(selectedQueueId) ? queueCounts(queue) : queueCounts(viewQueueRow || {}); + const globalCounts = queueCounts(queue); + const overallQueuedCount = queueWaitingCount(globalCounts); + const overallRunningCount = Math.max(queueRunningCount(globalCounts), globalActiveIds.length); + const overallUnreadTerminalCount = countNumber(queue?.unreadTerminal ?? unreadTerminalTasks.length); + const selectedQueueName = isAllQueues(selectedQueueId) ? "All queues" : selectedQueueId; const runtime = service ? microserviceRuntime(service) : {}; const repository = service ? microserviceRepository(service) : {}; const backend = service ? microserviceBackend(service) : {}; const promptParts = useMemo(() => splitPromptTasks(prompt), [prompt]); const enqueueItems = useMemo(() => { const count = repeatCountValue(repeatCount); - return promptParts.flatMap((text) => Array.from({ length: count }, () => text)); - }, [promptParts, repeatCount]); + return promptParts.flatMap((text) => Array.from({ length: count }, () => withReferenceHint(text, referenceTaskId))); + }, [promptParts, repeatCount, referenceTaskId]); + const enqueueCount = enqueueItems.length; + const batchNeedsConfirmation = enqueueCount > 1 && !batchConfirmed; + const submitDisabled = submitting || busy || enqueueCount === 0 || batchNeedsConfirmation; const codexModels = codexModelOptions(queue, model); 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 || "")); + function setTasksData(nextOrUpdater: any): any { + const next = typeof nextOrUpdater === "function" ? nextOrUpdater(tasksDataRef.current) : nextOrUpdater; + tasksDataRef.current = next; + setTasksDataState(next); + return next; + } + + function patchLoadedReadState(taskIds: string[], readAt: string, queuePatch: any = null, taskPatch: any = null): void { + const ids = new Set(taskIds.map((id) => String(id || "")).filter(Boolean)); + if (ids.size === 0 && taskPatch === null && queuePatch === null) return; + setTasksData((previous: any) => { + if (!previous) return previous; + const rows = taskRows(previous).map((task: any) => { + const id = String(task?.id || ""); + if (!ids.has(id)) return task; + const patch = taskPatch && String(taskPatch?.id || "") === id ? taskPatch : {}; + return { ...task, ...patch, readAt, terminalUnread: false }; + }); + return { ...previous, queue: queuePatch || previous.queue, tasks: ids.size > 0 ? mergeTaskRowsPreferLatest([rows], activeTaskId) : rows }; + }); + for (const id of ids) { + const cached = sessionCacheRef.current.get(id); + if (cached?.task) { + const patch = taskPatch && String(taskPatch?.id || "") === id ? taskPatch : {}; + const task = { ...cached.task, ...patch, readAt, terminalUnread: false }; + sessionCacheRef.current.set(id, { ...cached, task }); + if (selectedIdRef.current === id) setSelectedTask(task); + } + } + } + + function rememberLocalRead(taskIds: string[], readAt: string): void { + const ids = taskIds.map((id) => String(id || "")).filter(Boolean); + if (ids.length === 0) return; + setLocalReadAt((previous: AnyRecord) => { + const next = { ...(previous || {}) }; + for (const id of ids) next[id] = readAt; + saveLocalReadAt(next); + return next; + }); + } + + useEffect(() => { + setBatchConfirmed(false); + }, [prompt, repeatCount, referenceTaskId]); + function publishCachedTask(taskId: string, patch: AnyRecord, token: number): AnyRecord { const cached = sessionCacheRef.current.get(taskId) || {}; const existingTask = cached.task || {}; + const existingTranscript = Array.isArray(existingTask.transcript) ? existingTask.transcript : []; + const mergedPatch = mergeTaskPatch(existingTask, patch); const nextTranscript = Object.prototype.hasOwnProperty.call(patch, "transcript") - ? patch.transcript - : (Array.isArray(existingTask.transcript) ? existingTask.transcript : []); + ? mergeTranscriptRows(existingTranscript, Array.isArray(patch.transcript) ? patch.transcript : []) + : existingTranscript; const task = { ...existingTask, - ...patch, + ...mergedPatch, transcript: nextTranscript, - output: Array.isArray(patch.output) ? patch.output : (Array.isArray(existingTask.output) ? existingTask.output : []), - events: Array.isArray(patch.events) ? patch.events : (Array.isArray(existingTask.events) ? existingTask.events : []), + output: Array.isArray(mergedPatch.output) ? preferRicherArray(existingTask, mergedPatch, "output") : (Array.isArray(existingTask.output) ? existingTask.output : []), + events: Array.isArray(mergedPatch.events) ? preferRicherArray(existingTask, mergedPatch, "events") : (Array.isArray(existingTask.events) ? existingTask.events : []), }; + const taskUpdatedAt = String(task?.updatedAt || ""); + const patchComplete = Boolean(patch._transcriptComplete) && taskIsTerminal(task); + const cachedComplete = Boolean(cached.complete) + && taskIsTerminal(task) + && String(cached.completeUpdatedAt || "") === taskUpdatedAt; + const complete = patchComplete || cachedComplete; const entry = { ...cached, task, maxSeq: transcriptMaxSeq(nextTranscript), - complete: Boolean(patch._transcriptComplete ?? cached.complete), + complete, + completeUpdatedAt: complete ? taskUpdatedAt : "", }; sessionCacheRef.current.set(taskId, entry); if (token === detailLoadTokenRef.current && selectedIdRef.current === taskId) setSelectedTask(task); return entry; } - async function loadTaskDetail(taskId: string): Promise { + async function ensureTraceSummary(taskId: string, force = false, loadStartedAt?: number, queueMs?: number): Promise { if (!service || !taskId) return; + const cached = sessionCacheRef.current.get(taskId); + const cachedTask = cached?.task; + const cachedSummaryAt = String(cachedTask?._traceSummaryUpdatedAt || ""); + const cachedUpdatedAt = String(cachedTask?.updatedAt || ""); + if (!force && cachedTask?._traceSummaryLoaded === true && cachedSummaryAt === cachedUpdatedAt) return; + const key = taskId; + const existing = traceSummaryInFlightRef.current.get(key); + if (existing) return existing; + const token = detailLoadTokenRef.current; + const startedAt = performance.now(); + if (selectedIdRef.current === taskId) setSelectedDetailLoading(true); + const promise = (async () => { + try { + const result = await loadTaskTraceSummary(apiBaseUrl, taskId); + if (token !== detailLoadTokenRef.current || selectedIdRef.current !== taskId) return; + const summary = result?.summary || {}; + publishCachedTask(taskId, { + id: taskId, + status: summary.status, + updatedAt: summary.updatedAt, + startedAt: summary.startedAt, + finishedAt: summary.finishedAt, + currentAttempt: summary.currentAttempt, + maxAttempts: summary.maxAttempts, + finalResponse: summary.finalResponse, + lastJudge: summary.lastJudge, + lastError: summary.lastError, + attempts: Array.isArray(summary.attempts) ? summary.attempts : [], + timing: summary.timing, + _traceSummary: summary, + _traceSummaryLoaded: true, + _traceSummaryUpdatedAt: String(summary.updatedAt || ""), + _detailLoaded: true, + }, token); + setLoadStats({ + phase: "complete", + taskId, + queueMs: queueMs ?? 0, + detailMs: performance.now() - startedAt, + totalMs: loadStartedAt === undefined ? performance.now() - startedAt : performance.now() - loadStartedAt, + chunks: 1, + transcriptRows: Number(summary?.execution?.stepCount || 0), + partial: false, + completedAt: new Date(), + }); + } finally { + traceSummaryInFlightRef.current.delete(key); + if (token === detailLoadTokenRef.current && selectedIdRef.current === taskId) setSelectedDetailLoading(false); + } + })(); + traceSummaryInFlightRef.current.set(key, promise); + await promise; + } + + async function ensurePromptPart(part: string, attemptIndex: any = null): Promise { + const taskId = selectedIdRef.current; + if (!service || !taskId || !part) return; + const cachedTask = sessionCacheRef.current.get(taskId)?.task; + const details = taskPromptDetails(cachedTask); + const detailKey = part === "feedback" || part === "judge-feedback" ? feedbackPromptDetailKey(attemptIndex) : part; + if (details[detailKey]?.text) return; + const key = `${taskId}:${detailKey}`; + const existing = promptDetailInFlightRef.current.get(key); + if (existing) return existing; + const token = detailLoadTokenRef.current; + if (selectedIdRef.current === taskId) setSelectedDetailLoading(true); + const promise = (async () => { + try { + const result = await loadTaskPromptPart(apiBaseUrl, taskId, part, attemptIndex); + if (token !== detailLoadTokenRef.current || selectedIdRef.current !== taskId) return; + const currentTask = sessionCacheRef.current.get(taskId)?.task; + const currentDetails = taskPromptDetails(currentTask); + publishCachedTask(taskId, { + ...(part === "full" ? { prompt: String(result?.text || ""), promptChars: Number(result?.chars || 0) } : {}), + _promptDetails: { ...currentDetails, [detailKey]: result }, + }, token); + } finally { + promptDetailInFlightRef.current.delete(key); + if (token === detailLoadTokenRef.current && selectedIdRef.current === taskId) setSelectedDetailLoading(false); + } + })(); + promptDetailInFlightRef.current.set(key, promise); + await promise; + } + + async function ensureTraceSteps(attemptIndex: any = null): Promise { + const taskId = selectedIdRef.current; + if (!service || !taskId) return; + const cachedTask = sessionCacheRef.current.get(taskId)?.task; + const attemptKey = attemptIndex === null || attemptIndex === undefined || String(attemptIndex).length === 0 ? "" : String(attemptIndex); + if (taskTraceStepsLoaded(cachedTask, attemptKey || null)) return; + const key = `${taskId}:${attemptKey || "all"}`; + const existing = traceStepsInFlightRef.current.get(key); + if (existing) return existing; + const token = detailLoadTokenRef.current; + if (selectedIdRef.current === taskId) setSelectedDetailLoading(true); + const promise = (async () => { + try { + const result = await loadTaskTraceSteps(apiBaseUrl, taskId, 0, 500, attemptKey || null); + if (token !== detailLoadTokenRef.current || selectedIdRef.current !== taskId) return; + const steps = Array.isArray(result?.steps) ? result.steps : []; + if (attemptKey) { + const currentTask = sessionCacheRef.current.get(taskId)?.task; + const byAttempt = objectRecord(currentTask?._traceStepsByAttempt) || {}; + const loadedByAttempt = objectRecord(currentTask?._traceStepsLoadedByAttempt) || {}; + publishCachedTask(taskId, { + _traceStepsByAttempt: { ...byAttempt, [attemptKey]: steps }, + _traceStepsLoadedByAttempt: { ...loadedByAttempt, [attemptKey]: true }, + }, token); + } else { + publishCachedTask(taskId, { + _traceSteps: steps, + _traceStepsLoaded: true, + _traceStepsHasMore: Boolean(result?.hasMore), + _traceStepsNextAfterSeq: result?.nextAfterSeq, + }, token); + } + } finally { + traceStepsInFlightRef.current.delete(key); + if (token === detailLoadTokenRef.current && selectedIdRef.current === taskId) setSelectedDetailLoading(false); + } + })(); + traceStepsInFlightRef.current.set(key, promise); + await promise; + } + + async function ensureTraceStep(seq: any): Promise { + const taskId = selectedIdRef.current; + const seqKey = String(seq ?? ""); + if (!service || !taskId || seqKey.length === 0) return; + const cachedTask = sessionCacheRef.current.get(taskId)?.task; + const details = taskTraceStepDetails(cachedTask); + if (details[seqKey]?.line) return; + const key = `${taskId}:${seqKey}`; + const existing = traceStepInFlightRef.current.get(key); + if (existing) return existing; + const token = detailLoadTokenRef.current; + if (selectedIdRef.current === taskId) setSelectedDetailLoading(true); + const promise = (async () => { + try { + const result = await loadTaskTraceStep(apiBaseUrl, taskId, seq); + if (token !== detailLoadTokenRef.current || selectedIdRef.current !== taskId) return; + const currentTask = sessionCacheRef.current.get(taskId)?.task; + const currentDetails = taskTraceStepDetails(currentTask); + publishCachedTask(taskId, { + _traceStepDetails: { ...currentDetails, [seqKey]: result }, + }, token); + } finally { + traceStepInFlightRef.current.delete(key); + if (token === detailLoadTokenRef.current && selectedIdRef.current === taskId) setSelectedDetailLoading(false); + } + })(); + traceStepInFlightRef.current.set(key, promise); + await promise; + } + + async function loadTaskDetail(taskId: string, loadStartedAt?: number, queueMs?: number): Promise { + if (!service || !taskId) return; + const detailStartedAt = performance.now(); const token = detailLoadTokenRef.current; const cached = sessionCacheRef.current.get(taskId); if (cached?.task) { setSelectedTask(cached.task); - setSelectedDetailLoading(false); - if (cached.complete && taskIsTerminal(cached.task)) return; + setSelectedDetailLoading(taskNeedsFullDetail(cached.task) || !cached.complete); + if (!taskNeedsFullDetail(cached.task) && cached.complete && taskIsTerminal(cached.task) && String(cached.completeUpdatedAt || "") === String(cached.task?.updatedAt || "")) { + setLoadStats({ + phase: "complete", + taskId, + queueMs: queueMs ?? 0, + detailMs: 0, + totalMs: loadStartedAt === undefined ? 0 : performance.now() - loadStartedAt, + chunks: 0, + transcriptRows: Array.isArray(cached.task.transcript) ? cached.task.transcript.length : 0, + completedAt: new Date(), + }); + return; + } } else { setSelectedDetailLoading(true); } @@ -483,18 +1692,24 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api" }: An const current = sessionCacheRef.current.get(taskId); const currentTranscript = Array.isArray(current?.task?.transcript) ? current.task.transcript : []; const metaTask = meta?.task || {}; - publishCachedTask(taskId, { ...metaTask, transcript: currentTranscript, _detailLoaded: currentTranscript.length > 0, _transcriptComplete: current?.complete }, token); - const startSeq = currentTranscript.length > 0 && !taskIsTerminal(metaTask) - ? Math.max(0, transcriptMaxSeq(currentTranscript) - 1) - : transcriptMaxSeq(currentTranscript); - let afterSeq = current?.complete && taskIsTerminal(metaTask) ? transcriptMaxSeq(currentTranscript) : startSeq; + const currentCompleteForMeta = Boolean(current?.complete) && String(current?.completeUpdatedAt || "") === String(metaTask?.updatedAt || ""); + publishCachedTask(taskId, { ...metaTask, summaryOnly: false, _metaLoaded: true, transcript: currentTranscript, _detailLoaded: currentTranscript.length > 0, _transcriptComplete: currentCompleteForMeta }, token); + const restartFromBeginning = taskNeedsFullDetail(current?.task) || Boolean(current?.task?._transcriptPreview); + const startSeq = restartFromBeginning ? 0 : currentTranscript.length > 0 ? transcriptResumeSeq(currentTranscript) : 0; + let afterSeq = !restartFromBeginning && current?.complete && taskIsTerminal(metaTask) && String(current?.completeUpdatedAt || "") === String(metaTask?.updatedAt || "") + ? transcriptMaxSeq(currentTranscript) + : startSeq; let hasMore = true; + let chunks = 0; + let transcriptRows = currentTranscript.length; while (hasMore) { - const chunk = await requestJson(codexApi(apiBaseUrl, `/api/tasks/${encodeURIComponent(taskId)}/transcript?afterSeq=${encodeURIComponent(String(afterSeq))}&limit=32`)); + const chunk = await requestJson(codexApi(apiBaseUrl, `/api/tasks/${encodeURIComponent(taskId)}/transcript?afterSeq=${encodeURIComponent(String(afterSeq))}&limit=${codexTranscriptChunkLimit}&fullText=1`)); if (token !== detailLoadTokenRef.current || selectedIdRef.current !== taskId) return; const cachedNow = sessionCacheRef.current.get(taskId); const existingTranscript = Array.isArray(cachedNow?.task?.transcript) ? cachedNow.task.transcript : []; const mergedTranscript = mergeTranscriptRows(existingTranscript, Array.isArray(chunk?.transcript) ? chunk.transcript : []); + chunks += 1; + transcriptRows = mergedTranscript.length; const complete = Boolean(!chunk?.hasMore); publishCachedTask(taskId, { status: chunk?.status || metaTask.status, @@ -502,12 +1717,23 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api" }: An transcript: mergedTranscript, _detailLoaded: complete || mergedTranscript.length > 0, _transcriptComplete: complete, + _transcriptPreview: restartFromBeginning && !complete, }, token); hasMore = Boolean(chunk?.hasMore); afterSeq = Number(chunk?.nextAfterSeq ?? transcriptMaxSeq(mergedTranscript)); if (!hasMore) break; await new Promise((resolve) => window.setTimeout(resolve, 0)); } + setLoadStats({ + phase: "complete", + taskId, + queueMs: queueMs ?? 0, + detailMs: performance.now() - detailStartedAt, + totalMs: loadStartedAt === undefined ? performance.now() - detailStartedAt : performance.now() - loadStartedAt, + chunks, + transcriptRows, + completedAt: new Date(), + }); } finally { if (detailInFlightRef.current?.taskId === taskId && detailInFlightRef.current?.token === token) detailInFlightRef.current = null; if (token === detailLoadTokenRef.current && selectedIdRef.current === taskId) setSelectedDetailLoading(false); @@ -517,21 +1743,68 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api" }: An await promise; } - async function load(preferId = selectedIdRef.current): Promise { + async function load(preferId = selectedIdRef.current, trackLoad = true, queueFilterId = selectedQueueId): Promise { if (!service) return; + if (!trackLoad && trackedLoadInFlightRef.current) return; + const startedAt = performance.now(); + if (trackLoad) trackedLoadInFlightRef.current = true; + if (trackLoad) { + setLoadStats({ + phase: "loading", + taskId: String(preferId || selectedIdRef.current || ""), + startedAt: new Date(), + }); + } const token = queueLoadTokenRef.current + 1; queueLoadTokenRef.current = token; const requestedId = String(preferId || selectedIdRef.current || ""); - const healthResult = await requestJson(`${apiBaseUrl}/microservices/codex-queue/health`); - const tasksResult = await loadTaskQueue(apiBaseUrl, healthResult); - if (token !== queueLoadTokenRef.current) return; + const requestedCached = requestedId ? sessionCacheRef.current.get(requestedId) : null; + const requestedTranscript = Array.isArray(requestedCached?.task?.transcript) ? requestedCached.task.transcript : []; + const overviewAfterSeq = transcriptResumeSeq(requestedTranscript); + const healthResult = health || {}; + let tasksResult = null; + try { + tasksResult = await loadTaskOverview(apiBaseUrl, requestedId, overviewAfterSeq, queueFilterId); + } catch { + tasksResult = await loadTaskQueue(apiBaseUrl, healthResult, queueFilterId); + } + if (token !== queueLoadTokenRef.current) { + if (trackLoad) trackedLoadInFlightRef.current = false; + return; + } + const queueMs = performance.now() - startedAt; setHealth(healthResult); - setTasksData(tasksResult); - const rows = taskRows(tasksResult); + const resultQueue = tasksResult?.queue || {}; + const activeSortId = String(resultQueue?.activeTaskId || activeTaskIds(resultQueue)[0] || ""); + let mergedTasksResult = tasksResult; + setTasksData((previous: any) => { + const incomingRows = taskRows(tasksResult); + const previousRows = taskRows(previous); + const mergedRows = previousRows.length > 0 + ? mergeTaskRowsPreferLatest([previousRows, incomingRows], activeSortId) + : mergeTaskRowsPreferLatest([incomingRows], activeSortId); + const incomingPagination = taskPagination(tasksResult); + const previousPagination = taskPagination(previous); + const preserveCursor = previousRows.length > incomingRows.length && (previousPagination.hasMore === false || String(previousPagination.nextBeforeId || "").length > 0); + const pagination = { + ...incomingPagination, + ...(preserveCursor ? { hasMore: previousPagination.hasMore, nextBeforeId: previousPagination.nextBeforeId } : {}), + returned: mergedRows.length, + }; + mergedTasksResult = { ...tasksResult, tasks: mergedRows, pagination }; + return mergedTasksResult; + }); + const rows = taskRows(mergedTasksResult); + const resultQueueRows = knownQueueRows(resultQueue, queueId); + const queuePrimaryId = queueActiveTaskId(resultQueue, resultQueueRows, queueFilterId, rows); + const queueRunnableId = queueRunnableTaskId(resultQueueRows, queueFilterId, rows); const latestRequestedId = requestedId || selectedIdRef.current; - const nextId = latestRequestedId && rows.some((task: any) => task.id === latestRequestedId) + const overviewSelected = mergedTasksResult?.selected || null; + const overviewTask = overviewSelected?.task || null; + const overviewTranscript = Array.isArray(overviewSelected?.transcript) ? overviewSelected.transcript : null; + const nextId = latestRequestedId && (rows.some((task: any) => task.id === latestRequestedId) || String(overviewTask?.id || "") === latestRequestedId) ? latestRequestedId - : (tasksResult?.queue?.activeTaskId || rows.find((task: any) => taskIsActive(task))?.id || rows[0]?.id || ""); + : (queuePrimaryId || queueRunnableId || rows[0]?.id || ""); const previousId = selectedIdRef.current; if (previousId !== nextId) detailLoadTokenRef.current += 1; selectedIdRef.current = nextId; @@ -541,13 +1814,106 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api" }: An const cached = sessionCacheRef.current.get(nextId); if (cached?.task) sessionCacheRef.current.set(nextId, { ...cached, task: { ...row, ...cached.task, status: row.status, updatedAt: row.updatedAt } }); } - if (nextId) void loadTaskDetail(nextId).catch((err) => setError(errorMessage(err, "加载 Codex session 详情失败"))); + if (overviewTask?.id === nextId && overviewTranscript !== null) { + const cached = sessionCacheRef.current.get(nextId); + const existingTranscript = Array.isArray(cached?.task?.transcript) ? cached.task.transcript : []; + const mergedTranscript = mergeTranscriptRows(existingTranscript, overviewTranscript); + const overviewPreviewOnly = Boolean(overviewSelected?.preview); + publishCachedTask(nextId, { + ...overviewTask, + _summaryLoaded: true, + transcript: mergedTranscript, + _detailLoaded: !overviewSelected?.hasMore || mergedTranscript.length > 0, + _transcriptComplete: !overviewPreviewOnly && !overviewSelected?.hasMore && taskIsTerminal(overviewTask), + _transcriptPreview: overviewPreviewOnly, + }, detailLoadTokenRef.current); + setSelectedDetailLoading(false); + if (trackLoad) { + setLoadStats({ + phase: "complete", + taskId: nextId, + queueMs, + detailMs: Math.max(0, performance.now() - startedAt - queueMs), + totalMs: performance.now() - startedAt, + chunks: 1, + transcriptRows: mergedTranscript.length, + partial: Boolean(overviewPreviewOnly || overviewSelected?.hasMore || taskNeedsFullDetail(overviewTask)), + completedAt: new Date(), + }); + } + setRefreshedAt(new Date()); + if (trackLoad) trackedLoadInFlightRef.current = false; + return; + } + if (trackLoad) { + setLoadStats({ + phase: "session", + taskId: nextId, + queueMs, + totalMs: queueMs, + startedAt: new Date(Date.now() - queueMs), + }); + } + if (nextId) void ensureTraceSummary(nextId, true, trackLoad ? startedAt : undefined, trackLoad ? queueMs : undefined).catch((err) => setError(errorMessage(err, "加载 Codex Trace Summary 失败"))); else { detailLoadTokenRef.current += 1; setSelectedTask(null); setSelectedDetailLoading(false); + if (trackLoad) { + setLoadStats({ + phase: "complete", + taskId: "", + queueMs, + detailMs: 0, + totalMs: performance.now() - startedAt, + chunks: 0, + transcriptRows: 0, + completedAt: new Date(), + }); + } } setRefreshedAt(new Date()); + if (trackLoad) trackedLoadInFlightRef.current = false; + } + + async function loadMoreTasks(): Promise { + if (!service || loadingMoreTasks || loadMoreInFlightRef.current) return; + const beforeId = String(taskPagination(tasksData).nextBeforeId || ""); + if (!beforeId) return; + loadMoreInFlightRef.current = true; + setLoadingMoreTasks(true); + setError(""); + try { + const result = await loadTaskPage(apiBaseUrl, selectedQueueId, beforeId); + const incomingRows = taskRows(result); + const resultQueue = result?.queue || queue || {}; + const activeSortId = String(resultQueue?.activeTaskId || activeTaskIds(resultQueue)[0] || activeTaskId || ""); + setTasksData((previous: any) => { + const mergedRows = mergeTaskRowsPreferLatest([taskRows(previous), incomingRows], activeSortId); + const incomingPagination = taskPagination(result); + return { + ...(previous || {}), + queue: resultQueue, + tasks: mergedRows, + pagination: { + ...incomingPagination, + returned: mergedRows.length, + }, + }; + }); + } catch (err) { + setError(errorMessage(err, "加载更早 Codex tasks 失败")); + } finally { + loadMoreInFlightRef.current = false; + setLoadingMoreTasks(false); + } + } + + function handleTaskListScroll(event: any): void { + const element = event.currentTarget as HTMLElement; + if (!element || loadingMoreTasks || !hasMoreTasks) return; + const distanceToBottom = element.scrollHeight - element.scrollTop - element.clientHeight; + if (distanceToBottom < 120) void loadMoreTasks(); } async function guarded(action: () => Promise, message: string): Promise { @@ -562,18 +1928,170 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api" }: An } } + async function copyTaskId(taskId: string): Promise { + if (!taskId) return; + try { + let copied = false; + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(taskId); + copied = true; + } + } catch { + copied = false; + } + if (!copied) { + const textarea = document.createElement("textarea"); + textarea.value = taskId; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + copied = document.execCommand("copy"); + document.body.removeChild(textarea); + } + if (!copied) throw new Error("browser clipboard rejected the copy request"); + setCopiedTaskId(taskId); + setNotice(`已复制任务 ID:${taskId}`); + window.setTimeout(() => setCopiedTaskId((value: string) => value === taskId ? "" : value), 1600); + } catch (err) { + setError(`复制任务 ID 失败:${errorMessage(err)}`); + } + } + + function referenceTask(taskId: string): void { + if (!taskId) return; + setReferenceTaskId(taskId); + setNotice(`已引用任务 ID:${taskId};提交时后端会读取并注入该任务上下文`); + } + + async function markTaskRead(taskId: string): Promise { + if (!service || !taskId) return; + setMarkingReadTaskId(taskId); + await guarded(async () => { + let result: any = null; + let localOnly = false; + try { + result = await markTaskReadRequest(apiBaseUrl, taskId); + } catch { + localOnly = true; + } + const task = result?.task || { id: taskId, readAt: new Date().toISOString(), terminalUnread: false }; + const readAt = String(task?.readAt || new Date().toISOString()); + rememberLocalRead([taskId], readAt); + patchLoadedReadState([taskId], readAt, result?.queue || null, task); + setNotice(localOnly ? `已在本浏览器将任务 ${taskId} 标为已读;后端升级后会同步持久化` : `已将任务 ${taskId} 标为已读`); + }, "标记 Codex task 已读失败"); + setMarkingReadTaskId((value: string) => value === taskId ? "" : value); + } + + async function markAllTerminalRead(): Promise { + if (!service || markingAllRead) return; + setMarkingAllRead(true); + await guarded(async () => { + let result: any = null; + let localOnly = false; + try { + result = await markAllTerminalReadRequest(apiBaseUrl); + } catch { + localOnly = true; + } + const readAt = String(result?.readAt || new Date().toISOString()); + const loadedUnreadIds = taskRows(tasksDataRef.current) + .map((task: any) => applyLocalReadState(task, localReadAt)) + .filter(taskIsUnreadTerminal) + .map((task: any) => String(task?.id || "")) + .filter(Boolean); + const cachedUnreadIds = Array.from(sessionCacheRef.current.entries()) + .filter(([, value]) => taskIsUnreadTerminal(applyLocalReadState(value?.task, localReadAt))) + .map(([id]) => id); + const readIds = Array.from(new Set([...loadedUnreadIds, ...cachedUnreadIds])); + rememberLocalRead(readIds, readAt); + patchLoadedReadState(readIds, readAt, result?.queue || null); + const markedCount = localOnly ? readIds.length : Number(result?.count || readIds.length); + setNotice(localOnly ? `已在本浏览器将 ${markedCount} 个已结束未读任务标为已读;后端升级后会同步持久化` : `已将 ${markedCount} 个已结束未读任务标为已读`); + }, "全部标为已读失败"); + setMarkingAllRead(false); + } + + function changeSelectedQueue(nextQueueId: string): void { + const next = nextQueueId || allQueuesId; + setSelectedQueueId(next); + if (!isAllQueues(next)) setQueueId(next); + setTasksData(null); + const keepSelected = isAllQueues(next) ? selectedIdRef.current : ""; + if (!keepSelected) { + selectedIdRef.current = ""; + detailLoadTokenRef.current += 1; + setSelectedId(""); + setSelectedTask(null); + setSelectedDetailLoading(true); + } + } + + async function createQueue(): Promise { + const proposed = typeof window === "undefined" ? "" : window.prompt("输入新的 Codex queue ID(字母/数字/._-,最长 64)", "new-lane"); + const nextQueueId = String(proposed || "").trim(); + if (!nextQueueId) return; + await guarded(async () => { + const result = await requestJson(codexApi(apiBaseUrl, "/api/queues"), { method: "POST", body: { queueId: nextQueueId } }); + const createdId = String(result?.queue?.id || nextQueueId); + setQueueId(createdId); + setSelectedQueueId(createdId); + setTasksData(null); + selectedIdRef.current = ""; + detailLoadTokenRef.current += 1; + setSelectedId(""); + setSelectedTask(null); + setNotice(`已创建并切换到 queue:${createdId}`); + await load("", true, createdId); + }, "创建 Codex queue 失败"); + } + async function enqueue(event: any): Promise { event.preventDefault(); + if (enqueueInFlightRef.current) { + setNotice("任务正在提交中,请等待当前请求完成,已阻止重复提交。"); + return; + } + if (enqueueItems.length > 1 && !batchConfirmed) { + setError(`检测到将创建 ${enqueueItems.length} 个任务;请先勾选“确认批量入队”,避免误传多个任务。`); + return; + } + enqueueInFlightRef.current = true; + setSubmitting(true); + setNotice("正在提交 Codex Queue 任务,请等待后端确认,输入已临时锁定。"); await guarded(async () => { if (enqueueItems.length === 0) throw new Error("prompt 不能为空"); - const body = enqueueItems.length === 1 - ? { prompt: enqueueItems[0], model, cwd, maxAttempts: Number(maxAttempts) } - : { tasks: enqueueItems.map((text) => ({ prompt: text, model, cwd, maxAttempts: Number(maxAttempts) })) }; - const result = await requestJson(codexApi(apiBaseUrl, enqueueItems.length === 1 ? "/api/tasks" : "/api/tasks/batch"), { method: "POST", body }); + const referenceTaskIds = parseReferenceTaskIds(referenceTaskId); + const submitQueueId = queueId.trim() || "default"; + const submittingItems = [...enqueueItems]; + const taskPayload = (text: string) => ({ + prompt: text, + queueId: submitQueueId, + model, + cwd, + maxAttempts: Number(maxAttempts), + ...(referenceTaskIds.length > 0 ? { referenceTaskIds } : {}), + }); + const body = submittingItems.length === 1 + ? taskPayload(submittingItems[0]) + : { tasks: submittingItems.map(taskPayload) }; + const result = await requestJson(codexApi(apiBaseUrl, submittingItems.length === 1 ? "/api/tasks" : "/api/tasks/batch"), { method: "POST", body }); const firstId = result?.tasks?.[0]?.id || ""; + const ids = Array.isArray(result?.tasks) ? result.tasks.map((task: any) => String(task?.id || "")).filter(Boolean) : []; + setNotice(`已创建 ${ids.length || submittingItems.length} 个任务${ids.length > 0 ? `:${ids.join(" / ")}` : ""}`); + setPrompt(""); + setReferenceTaskId(""); + setBatchConfirmed(false); selectedIdRef.current = firstId; - await load(firstId); + if (selectedQueueId !== submitQueueId) setTasksData(null); + setSelectedQueueId(submitQueueId); + setQueueId(submitQueueId); + await load(firstId, true, submitQueueId); }, "Codex 任务入队失败"); + enqueueInFlightRef.current = false; + setSubmitting(false); } async function steer(event: any): Promise { @@ -602,6 +2120,49 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api" }: An }, "重新入队失败"); } + async function moveSelectedTaskQueue(targetQueueId: string): Promise { + const taskId = String(selectedTask?.id || ""); + const nextQueueId = String(targetQueueId || "").trim(); + if (!taskId || !nextQueueId) return; + const currentQueue = taskQueueLabel(selectedTask); + if (nextQueueId === currentQueue) { + setNotice(`任务 ${taskId} 已在 queue=${nextQueueId}`); + return; + } + await guarded(async () => { + const result = await requestJson(codexApi(apiBaseUrl, `/api/tasks/${encodeURIComponent(taskId)}/move`), { method: "POST", body: { queueId: nextQueueId } }); + const movedTask = result?.task || { ...selectedTask, queueId: nextQueueId }; + sessionCacheRef.current.set(taskId, { ...(sessionCacheRef.current.get(taskId) || {}), task: movedTask }); + selectedIdRef.current = taskId; + setSelectedTask(movedTask); + setSelectedId(taskId); + setQueueId(nextQueueId); + if (!isAllQueues(selectedQueueId)) { + setTasksData(null); + setSelectedQueueId(nextQueueId); + } + setNotice(`已将任务 ${taskId} 从 ${currentQueue} 移动到 ${nextQueueId}`); + await load(taskId, true, isAllQueues(selectedQueueId) ? allQueuesId : nextQueueId); + }, "移动任务 queue 失败"); + } + + async function loadFullTrace(): Promise { + const taskId = selectedIdRef.current; + if (!taskId) return; + const startedAt = performance.now(); + await guarded(async () => { + setLoadStats({ + phase: "session", + taskId, + queueMs: 0, + totalMs: 0, + partial: true, + startedAt: new Date(), + }); + await ensureTraceSummary(taskId, true, startedAt, 0); + }, "刷新 Trace Summary 失败"); + } + function selectTask(taskId: string): void { selectedIdRef.current = taskId; detailLoadTokenRef.current += 1; @@ -619,26 +2180,66 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api" }: An void load(taskId).catch((err) => setError(errorMessage(err, "切换 Codex session 失败"))); } + function selectTaskFromSidebar(taskId: string): void { + selectTask(taskId); + if (isMobileQueueViewport()) setQueueSidebarOpen(false); + } + useEffect(() => { + if (initialLoadSkippedRef.current) { + initialLoadSkippedRef.current = false; + return; + } void guarded(() => load(selectedIdRef.current), "Codex Queue 加载失败"); - }, [service?.id, service?.runtime?.providerStatus]); + }, [service?.id, selectedQueueId]); useEffect(() => { if (!service) return undefined; const timer = window.setInterval(() => { - void load(selectedIdRef.current).catch((err) => setError(errorMessage(err, "Codex Queue 轮询失败"))); + void load(selectedIdRef.current, false).catch((err) => setError(errorMessage(err, "Codex Queue 轮询失败"))); }, 1500); return () => window.clearInterval(timer); - }, [service?.id]); + }, [service?.id, selectedQueueId]); + + useEffect(() => { + if (!service || !selectedTask || selectedDetailLoading) return; + const taskId = String(selectedTask.id || ""); + if (!taskId) return; + const updatedAt = String(selectedTask.updatedAt || ""); + const summaryAt = String(selectedTask._traceSummaryUpdatedAt || ""); + if (selectedTask._traceSummaryLoaded === true && summaryAt === updatedAt) return; + const key = `${taskId}:${updatedAt || "unknown"}`; + if (autoTraceLoadKeysRef.current.has(key)) return; + autoTraceLoadKeysRef.current.add(key); + void ensureTraceSummary(taskId, true).catch((err) => setError(errorMessage(err, "自动加载 Trace Summary 失败"))); + }, [service?.id, selectedTask?.id, selectedTask?.updatedAt, selectedTask?._traceSummaryUpdatedAt, selectedTask?._traceSummaryLoaded, selectedDetailLoading]); const taskListContent = tasks.length === 0 ? h(EmptyState, { title: "队列为空", text: "提交一个任务后,Codex 会串行执行并保存输出。" }) : [ + unreadTerminalTasks.length > 0 ? h(TaskListSection, { + key: "unread", + title: "已结束未读", + tasks: unreadTerminalTasks, + selectedId, + emptyText: "暂无已结束未读任务。", + onSelect: selectTaskFromSidebar, + onCopy: copyTaskId, + onReference: referenceTask, + onMarkRead: markTaskRead, + copiedTaskId, + markingReadTaskId, + }) : null, h(TaskListSection, { key: "active", title: "运行 / 排队", tasks: queuedTasks, selectedId, emptyText: "当前没有运行或排队任务。", - onSelect: selectTask, + onSelect: selectTaskFromSidebar, + onCopy: copyTaskId, + onReference: referenceTask, + onMarkRead: markTaskRead, + copiedTaskId, + markingReadTaskId, }), h(TaskListSection, { key: "history", @@ -646,15 +2247,60 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api" }: An tasks: historyTasks, selectedId, emptyText: "最近没有完成、失败或取消的 session。", - onSelect: selectTask, + onSelect: selectTaskFromSidebar, + onCopy: copyTaskId, + onReference: referenceTask, + onMarkRead: markTaskRead, + copiedTaskId, + markingReadTaskId, }), + h("div", { key: "pagination", className: "codex-task-pagination", "data-testid": "codex-task-pagination" }, + h("span", null, `已加载 ${tasks.length} / ${Number.isFinite(totalTaskCount) ? totalTaskCount : tasks.length}`), + hasMoreTasks ? h("button", { + type: "button", + className: "ghost-btn", + disabled: loadingMoreTasks, + onClick: () => void loadMoreTasks(), + "data-testid": "codex-load-more-tasks-button", + }, loadingMoreTasks ? "加载中" : "加载更早任务") : h("code", null, "已到队列末尾"), + ), ]; + const queueSelector = (testId: string, compact = false) => h("label", { className: `codex-queue-switcher ${compact ? "compact" : ""}` }, + h("span", null, compact ? "Queue" : "查看 queue"), + h("select", { value: selectedQueueId, onChange: (event: any) => changeSelectedQueue(String(event.target.value || allQueuesId)), "data-testid": testId }, + h("option", { value: allQueuesId }, `All queues · ${Number.isFinite(totalTaskCount) ? totalTaskCount : tasks.length} tasks · ${globalActiveIds.length} running`), + queueRows.map((row: any) => h("option", { key: String(row?.id || ""), value: String(row?.id || "") }, queueOptionLabel(row))), + ), + ); + + const traceStatusSummary = h("div", { className: "codex-trace-status", "data-testid": "codex-trace-status-summary" }, + h("span", { className: "codex-trace-status-chip queued" }, h("b", null, "排队"), String(overallQueuedCount)), + h("span", { className: "codex-trace-status-chip running" }, h("b", null, "运行"), String(overallRunningCount)), + h("span", { className: `codex-trace-status-chip unread ${overallUnreadTerminalCount > 0 ? "warn" : ""}` }, h("b", null, "结束未读"), String(overallUnreadTerminalCount)), + ); + const sessionPanel = h(Panel, { - title: selectedTask ? `Session ${String(selectedTask.id).slice(0, 22)}` : "Session 输出", - eyebrow: selectedTask ? `${selectedTask.status} / ${selectedTask.model}` : "Codex CLI-like stream", + title: selectedTask ? `Trace ${String(selectedTask.id).slice(0, 22)}` : "Trace 输出", + eyebrow: selectedTask ? `${selectedTask.status} / view=${selectedQueueName} / task queue=${taskQueueLabel(selectedTask)} / ${selectedTask.model} / agent loop trace` : `Agent loop trace / view=${selectedQueueName}`, + summary: traceStatusSummary, actions: h("div", { className: "panel-actions" }, - h("button", { type: "button", className: "ghost-btn", onClick: () => setQueueSidebarOpen((value: boolean) => !value), "data-testid": "codex-queue-sidebar-toggle" }, queueSidebarOpen ? "收起队列" : "显示队列"), + queueSelector("codex-queue-filter-select"), + h("button", { + type: "button", + className: "ghost-btn codex-mark-all-read-btn", + disabled: overallUnreadTerminalCount === 0 || busy || markingAllRead, + onClick: () => void markAllTerminalRead(), + "data-testid": "codex-mark-all-read-button", + }, markingAllRead ? "标记中" : `全部标已读${overallUnreadTerminalCount > 0 ? ` (${overallUnreadTerminalCount})` : ""}`), + selectedTask ? h("button", { + type: "button", + className: "ghost-btn", + disabled: selectedDetailLoading || busy, + onClick: () => void loadFullTrace(), + "data-testid": "codex-load-full-trace-button", + }, selectedDetailLoading ? "加载中" : taskTraceSummary(selectedTask) ? "刷新 Summary" : "加载 Summary") : null, + h("button", { type: "button", className: "codex-session-title-toggle", onClick: () => setQueueSidebarOpen((value: boolean) => !value), "data-testid": "codex-queue-sidebar-toggle" }, queueSidebarOpen ? "收起队列" : "展开队列"), 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() }, "重试"), @@ -666,31 +2312,137 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api" }: An queueSidebarOpen ? h("aside", { className: "codex-session-sidebar", "data-testid": "codex-session-sidebar" }, h("div", { className: "codex-session-sidebar-head" }, h("div", null, - h("span", null, "Queue"), - h("strong", null, `${tasks.length} sessions`), + h("span", null, isAllQueues(selectedQueueId) ? "All queues" : "Queue lane"), + h("strong", null, `${selectedQueueName} · ${tasks.length}/${Number.isFinite(totalTaskCount) ? totalTaskCount : tasks.length} sessions · 未读 ${overallUnreadTerminalCount}`), ), h("button", { type: "button", className: "ghost-btn", onClick: () => setQueueSidebarOpen(false) }, "收起"), ), - h("div", { className: "codex-task-list codex-task-list-session" }, taskListContent), + queueSelector("codex-queue-filter-sidebar", true), + h("div", { className: "codex-task-list codex-task-list-session", onScroll: handleTaskListScroll, "data-testid": "codex-task-list-scroll" }, taskListContent), ) : null, h("div", { className: "codex-session-main" }, h("div", { className: "codex-output-stack" }, - h(Transcript, { task: selectedTask, autoScroll, loading: selectedDetailLoading }), + h(ProgressiveTrace, { task: selectedTask, loading: selectedDetailLoading, onLoadPromptPart: ensurePromptPart, onLoadSteps: ensureTraceSteps, onLoadStep: ensureTraceStep }), h(RawTranscript, { task: selectedTask }), ), ), ), ); - if (!service) return h(EmptyState, { title: "Codex Queue 未登记", text: "请在 config.json 的 microservices 中登记 id=codex-queue" }); + 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" }, + const loadTotalMs = Number(loadStats?.totalMs); + const loadQueueMs = Number(loadStats?.queueMs); + const loadDetailMs = Number(loadStats?.detailMs); + const loadTranscriptRows = Number(loadStats?.transcriptRows); + const loadState = loadStats?.phase === "complete" ? "complete" : String(loadStats?.phase || "idle"); + + return h("div", { + className: `codex-queue-page ${standalone ? "codex-standalone-page" : ""}`, + "data-testid": "codex-queue-page", + "data-load-state": loadState, + "data-load-total-ms": Number.isFinite(loadTotalMs) ? String(Math.round(loadTotalMs * 10) / 10) : "", + "data-load-queue-ms": Number.isFinite(loadQueueMs) ? String(Math.round(loadQueueMs * 10) / 10) : "", + "data-load-detail-ms": Number.isFinite(loadDetailMs) ? String(Math.round(loadDetailMs * 10) / 10) : "", + "data-load-transcript-rows": Number.isFinite(loadTranscriptRows) ? String(loadTranscriptRows) : "", + "data-load-task-id": String(loadStats?.taskId || selectedId || ""), + "data-load-partial": loadStats?.partial ? "true" : "false", + }, + h(UniDeskErrorBanner, { error, wide: true }), + notice ? h("div", { className: "form-success wide", "data-testid": "codex-create-success" }, notice) : null, + h("div", { className: "codex-session-stage codex-session-stage-top" }, sessionPanel), + h("div", { className: "codex-queue-layout" }, + h("div", { className: "codex-left-rail" }, + h(Panel, { title: "提交任务", eyebrow: submitting ? "Submitting..." : enqueueItems.length > 1 ? `${enqueueItems.length} tasks` : "Single or Batch", className: "codex-compose-panel" }, + h("form", { className: `codex-task-form ${submitting ? "is-submitting" : ""}`, onSubmit: enqueue, "data-testid": "codex-queue-task-form", "aria-busy": submitting ? "true" : "false" }, + h("label", null, "Prompt / 多任务用单独一行 --- 分隔", + h("textarea", { value: prompt, rows: 8, disabled: submitting, onChange: (event: any) => setPrompt(event.target.value), placeholder: "写入 Codex 任务;多个任务之间用 --- 分隔。" }), + ), + h("label", { className: "codex-reference-field" }, "引用任务 ID(可选)", + h("input", { value: referenceTaskId, disabled: submitting, onChange: (event: any) => setReferenceTaskId(event.target.value), placeholder: "codex_...;支持空格/逗号分隔多个 ID", "data-testid": "codex-reference-task-id" }), + parseReferenceTaskIds(referenceTaskId).length > 0 ? h("code", null, `后端将解析并注入:${parseReferenceTaskIds(referenceTaskId).join(" / ")}`) : null, + ), + h("div", { className: "codex-form-grid" }, + h("label", { className: "codex-submit-queue-field" }, "Queue", + h("div", { className: "codex-submit-queue-row" }, + h("select", { value: queueId, disabled: submitting, onChange: (event: any) => setQueueId(String(event.target.value || "default")), "data-testid": "codex-queue-id-select" }, + queueRows.map((row: any) => h("option", { key: String(row?.id || ""), value: String(row?.id || "") }, queueOptionLabel(row))), + ), + h("button", { type: "button", className: "ghost-btn codex-create-queue-btn", onClick: () => void createQueue(), disabled: busy || submitting, "data-testid": "codex-create-queue-button" }, "创建 queue"), + ), + ), + h("label", null, "模型", + h("select", { value: model, disabled: submitting, onChange: (event: any) => setModel(event.target.value), "data-testid": "codex-model-select" }, + codexModels.map((name) => h("option", { key: name, value: name }, name)), + ), + ), + h("label", null, "工作目录", h("input", { value: cwd, disabled: submitting, onChange: (event: any) => setCwd(event.target.value), placeholder: queue?.defaultWorkdir || "/root/unidesk" })), + h("label", null, "最大尝试", h("input", { type: "number", min: 1, max: 99, value: maxAttempts, disabled: submitting, onChange: (event: any) => setMaxAttempts(Number(event.target.value)), "data-testid": "codex-max-attempts-input" })), + h("label", null, "入队份数", h("input", { type: "number", min: 1, max: 50, value: repeatCount, disabled: submitting, onChange: (event: any) => setRepeatCount(Number(event.target.value)), "data-testid": "codex-repeat-count-input" })), + ), + enqueueCount > 1 ? h("label", { className: `codex-batch-confirm ${batchConfirmed ? "confirmed" : ""}`, "data-testid": "codex-batch-confirm-row" }, + h("input", { + type: "checkbox", + checked: batchConfirmed, + disabled: submitting, + onChange: (event: any) => setBatchConfirmed(Boolean(event.target.checked)), + "data-testid": "codex-batch-confirm-checkbox", + }), + h("span", null, `确认批量入队 ${enqueueCount} 个任务(prompt 分段 ${promptParts.length} × 入队份数 ${repeatCountValue(repeatCount)})`), + ) : null, + submitting ? h("div", { className: "codex-submit-wait", "data-testid": "codex-submit-wait" }, "正在提交到后端,已锁定输入以防重复提交...") : null, + h("div", { className: "codex-form-actions" }, + h("button", { + type: "button", + className: "ghost-btn", + disabled: busy || submitting || (prompt.length === 0 && referenceTaskId.length === 0), + onClick: () => { + setPrompt(""); + setReferenceTaskId(""); + setBatchConfirmed(false); + setNotice("已清空任务输入栏"); + }, + "data-testid": "codex-clear-input-button", + }, "清空输入"), + h("button", { type: "submit", className: "primary-btn", disabled: submitDisabled, "data-testid": "codex-enqueue-button" }, submitting ? "提交中,请等待..." : batchNeedsConfirmation ? `请确认批量入队 ${enqueueCount} 个任务` : enqueueItems.length > 1 ? `批量入队 ${enqueueItems.length} 个任务` : "入队并运行"), + ), + ), + ), + ), + h("div", { className: "codex-main-stage" }, + h("div", { className: "codex-detail-grid" }, + h(Panel, { title: "Prompt 全量", eyebrow: selectedTask ? String(selectedTask.id) : "selected task", className: "codex-prompt-panel" }, + h(PromptDetail, { task: selectedTask, loading: selectedDetailLoading, onLoadPromptPart: ensurePromptPart }), + ), + h(Panel, { title: "运行控制", eyebrow: selectedCanSteer ? "Active turn steer" : "Steer when running" }, + h("div", { className: "codex-run-control-stack" }, + h(TaskQueueMoveControl, { task: selectedTask, queueRows, busy, onMove: moveSelectedTaskQueue }), + 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", "data-testid": "codex-task-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", { "data-testid": "codex-task-judge-reason" }, selectedTask.lastJudge.reason || "--"), + selectedTask.lastJudge.continuePrompt ? h("code", { "data-testid": "codex-task-judge-continue-prompt" }, 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 })), + ), + ), h(Panel, { - title: "Codex Queue 控制台", - eyebrow: "App-Server Task Deck", + title: "运行概要", + eyebrow: "用户服务", 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(RawButton, { title: "Codex Queue 用户服务", data: service, onOpen: onRaw, testId: "raw-codex-queue-service" }), ), }, h("div", { className: "codex-queue-hero" }, @@ -704,8 +2456,9 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api" }: An 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("span", null, "Queue view"), + h("strong", null, selectedQueueName), + h("code", null, `${tasks.length}/${Number.isFinite(totalTaskCount) ? totalTaskCount : tasks.length} loaded / ${globalActiveIds.length} active lanes`), h("code", null, `models: ${codexModels.join(" / ")}`), ), h("div", { className: "microservice-ref-card" }, @@ -714,61 +2467,22 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api" }: An 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: "Queues", value: String(queue?.queueCount ?? queueRows.length ?? 1), hint: `${Number(activeIds.length || 0)} active lanes`, tone: activeIds.length > 1 ? "warn" : "" }), 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, "running"), hint: activeIds.length > 1 ? `${activeIds.length} parallel` : 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: fmtPreciseMs(loadStats?.totalMs), + hint: loadStats?.phase === "complete" + ? `queue ${fmtPreciseMs(loadStats?.queueMs)} / session ${fmtPreciseMs(loadStats?.detailMs)} / ${loadStats?.chunks ?? 0} chunks${loadStats?.partial ? " / preview" : ""}` + : `${loadStats?.phase || "idle"}...`, + tone: Number(loadStats?.totalMs || 0) > 1000 ? "warn" : "ok", + }), h(MetricCard, { label: "最近刷新", value: refreshedAt ? fmtClock(refreshedAt) : "--", hint: "1.5s polling" }), ), - h("div", { className: "codex-session-stage" }, sessionPanel), - h("div", { className: "codex-queue-layout" }, - h("div", { className: "codex-left-rail" }, - h(Panel, { title: "提交任务", eyebrow: enqueueItems.length > 1 ? `${enqueueItems.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("select", { value: model, onChange: (event: any) => setModel(event.target.value), "data-testid": "codex-model-select" }, - codexModels.map((name) => h("option", { key: name, value: name }, name)), - ), - ), - h("label", null, "工作目录", h("input", { value: cwd, onChange: (event: any) => setCwd(event.target.value), placeholder: queue?.defaultWorkdir || "/root/unidesk" })), - h("label", null, "最大尝试", h("input", { type: "number", min: 1, max: 10, value: maxAttempts, onChange: (event: any) => setMaxAttempts(Number(event.target.value)) })), - h("label", null, "入队份数", h("input", { type: "number", min: 1, max: 50, value: repeatCount, onChange: (event: any) => setRepeatCount(Number(event.target.value)), "data-testid": "codex-repeat-count-input" })), - ), - h("button", { type: "submit", className: "primary-btn", disabled: busy || enqueueItems.length === 0, "data-testid": "codex-enqueue-button" }, enqueueItems.length > 1 ? `批量入队 ${enqueueItems.length} 个任务` : "入队并运行"), - ), - ), - ), - h("div", { className: "codex-main-stage" }, - h("div", { className: "codex-detail-grid" }, - h(Panel, { title: "Prompt 全量", eyebrow: selectedTask ? String(selectedTask.id) : "selected task", className: "codex-prompt-panel" }, - h(PromptDetail, { task: selectedTask }), - ), - 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/findjob.tsx b/src/components/frontend/src/findjob.tsx index 90f96d83..13559f0e 100644 --- a/src/components/frontend/src/findjob.tsx +++ b/src/components/frontend/src/findjob.tsx @@ -1,4 +1,6 @@ import React from "react"; +import { errorMessage, requestJson } from "./unidesk-error"; +import { UniDeskErrorBanner } from "./unidesk-error-banner"; type AnyRecord = Record; @@ -6,10 +8,6 @@ const h = React.createElement; const { useEffect } = 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); @@ -21,26 +19,6 @@ function fmtClock(value: Date): string { return value.toLocaleTimeString("zh-CN", { hour12: false }); } -async function requestJson(path: string, options: AnyRecord = {}): Promise { - const headers = new Headers(options.headers || {}); - if (options.body && !headers.has("content-type")) headers.set("content-type", "application/json"); - const response = await fetch(path, { credentials: "same-origin", ...options, headers }); - const text = await response.text(); - let body = null; - try { - body = text ? JSON.parse(text) : null; - } catch { - body = { text }; - } - if (!response.ok || body?.ok === false) { - const message = body?.error?.message || body?.error || `HTTP ${response.status}`; - const error = new Error(message); - (error as Error & { status?: number }).status = response.status; - throw error; - } - return body; -} - function StatusBadge({ status, children }: AnyRecord) { const normalized = String(status || "unknown").toLowerCase(); return h("span", { className: `status-badge ${normalized}` }, children || status || "unknown"); @@ -131,7 +109,7 @@ export function FindJobPage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyRe load(); }, [service?.id, service?.runtime?.providerStatus]); - if (!service) return h(EmptyState, { title: "FindJob 未登记", text: "请在 config.json 的 microservices 中登记 id=findjob" }); + if (!service) return h(EmptyState, { title: "FindJob 未登记", text: "请在 config.json 的 microservices 中登记用户服务 id=findjob" }); const runtime = microserviceRuntime(service); const repository = microserviceRepository(service); @@ -143,10 +121,10 @@ export function FindJobPage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyRe return h("div", { className: "findjob-page", "data-testid": "findjob-page" }, h(Panel, { title: "FindJob 工作台", - eyebrow: "D601 Backend Microservice", + eyebrow: "D601 用户服务", actions: h("div", { className: "panel-actions" }, h("button", { type: "button", className: "ghost-btn", onClick: load, disabled: state.loading, "data-testid": "findjob-refresh-button" }, state.loading ? "刷新中" : "刷新"), - h(RawButton, { title: "FindJob Microservice", data: service, onOpen: onRaw, testId: "raw-findjob-service" }), + h(RawButton, { title: "FindJob 用户服务", data: service, onOpen: onRaw, testId: "raw-findjob-service" }), ), }, h("div", { className: "findjob-hero" }, @@ -169,7 +147,7 @@ export function FindJobPage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyRe h("code", null, `${repository.composeFile || "--"} / ${repository.composeService || "--"}`), ), ), - state.error ? h("div", { className: "form-error wide" }, state.error) : null, + h(UniDeskErrorBanner, { error: state.error, wide: true }), ), h("div", { className: "findjob-grid" }, h(Panel, { title: "岗位指标", eyebrow: state.refreshedAt ? `Updated ${fmtClock(state.refreshedAt)}` : "Summary" }, diff --git a/src/components/frontend/src/index.ts b/src/components/frontend/src/index.ts index 25c90e01..eeeecf22 100644 --- a/src/components/frontend/src/index.ts +++ b/src/components/frontend/src/index.ts @@ -22,12 +22,31 @@ interface SessionPayload { type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; +interface RequestPerformanceSample { + at: string; + component: string; + method: string; + path: string; + status: number; + durationMs: number; + ok: boolean; +} + +interface OperationPerformanceSample { + at: string; + service: string; + operation: string; + durationMs: number; + ok: boolean; + detail: string; +} + const sessionCookieName = "unidesk_session"; const config = readConfig(); const logger = createLogger("frontend", config.logFile); const publicDir = join(import.meta.dir, "..", "public"); const vendorDir = join(import.meta.dir, "..", "node_modules"); -const appBundle = await buildFrontendApp(); +const appBundle = await buildFrontendApp("app.tsx"); const clientConfig = JSON.stringify({ frontendPublicUrl: config.frontendPublicUrl, providerIngressPublicUrl: config.providerIngressPublicUrl, @@ -35,14 +54,85 @@ const clientConfig = JSON.stringify({ sessionTtlSeconds: config.sessionTtlSeconds, apiBaseUrl: "/api", }); -const indexHtml = readFileSync(join(publicDir, "index.html"), "utf8").replace( - "__UNIDESK_CONFIG__", - escapeHtmlAttribute(clientConfig), -); +const indexHtmlTemplate = readFileSync(join(publicDir, "index.html"), "utf8"); +const indexHtmlRootMarker = '
'; +const codexQueueOverviewCache = new Map(); +const codexQueueOverviewCacheTtlMs = 10_000; +const defaultCodexQueueOverviewPath = "/api/tasks/overview?limit=24&transcriptLimit=3&compact=1&afterSeq=0&preferId="; -async function buildFrontendApp(): Promise { +function codexQueueOverviewCacheKey(pathWithQuery: string): string { + return pathWithQuery; +} + +function cachedCodexQueueOverview(pathWithQuery: string, maxAgeMs = codexQueueOverviewCacheTtlMs): { payload: JsonValue; text: string } | null { + const cached = codexQueueOverviewCache.get(codexQueueOverviewCacheKey(pathWithQuery)); + if (!cached || Date.now() - cached.at > maxAgeMs) return null; + return { payload: cached.payload, text: cached.text }; +} + +async function refreshCodexQueueOverview(pathWithQuery: string, timeoutMs = 800): Promise { + const started = performance.now(); + try { + const response = await fetch(`http://codex-queue:4222${pathWithQuery}`, { + signal: AbortSignal.timeout(timeoutMs), + headers: { accept: "application/json" }, + }); + const text = await response.text(); + const payload = text ? JSON.parse(text) as unknown : null; + const ok = response.ok && typeof payload === "object" && payload !== null; + recordOperationPerformance("frontend", "codex_queue_initial_overview", performance.now() - started, ok, pathWithQuery); + if (!ok) return null; + codexQueueOverviewCache.set(codexQueueOverviewCacheKey(pathWithQuery), { at: Date.now(), payload: payload as JsonValue, text }); + return payload as JsonValue; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + recordOperationPerformance("frontend", "codex_queue_initial_overview", performance.now() - started, false, message); + return null; + } +} + +async function readCodexQueueInitialOverview(req: Request): Promise { + if (sessionFromRequest(req) === null) return null; + const overviewPath = defaultCodexQueueOverviewPath; + const cached = cachedCodexQueueOverview(overviewPath); + if (cached !== null) return cached.payload; + const stale = codexQueueOverviewCache.get(codexQueueOverviewCacheKey(overviewPath)); + if (stale !== undefined) { + refreshCodexQueueOverview(overviewPath).catch(() => undefined); + return stale.payload; + } + return refreshCodexQueueOverview(overviewPath); +} + +function isCodexQueueRoute(pathname: string): boolean { + return pathname === "/app/codex-queue" || pathname.startsWith("/app/codex-queue/"); +} + +function renderIndexHtml(extraRootAttributes = ""): string { + return indexHtmlTemplate.replace( + indexHtmlRootMarker, + `
`, + ); +} + +async function spaShellHtml(req: Request, pathname: string): Promise { + if (!isCodexQueueRoute(pathname)) return renderIndexHtml(); + const initialOverview = await readCodexQueueInitialOverview(req); + const initialOverviewAttr = initialOverview === null ? "" : ` data-codex-overview="${escapeHtmlAttribute(JSON.stringify(initialOverview))}"`; + return renderIndexHtml(initialOverviewAttr); +} + +refreshCodexQueueOverview(defaultCodexQueueOverviewPath, 2_000).catch(() => undefined); +setInterval(() => { + refreshCodexQueueOverview(defaultCodexQueueOverviewPath, 2_000).catch(() => undefined); +}, 5_000); +const requestPerformanceSamples: RequestPerformanceSample[] = []; +const operationPerformanceSamples: OperationPerformanceSample[] = []; +const maxPerformanceSamples = 3000; + +async function buildFrontendApp(entrypoint: string): Promise { const result = await Bun.build({ - entrypoints: [join(import.meta.dir, "app.tsx")], + entrypoints: [join(import.meta.dir, entrypoint)], target: "browser", format: "iife", define: { @@ -50,12 +140,12 @@ async function buildFrontendApp(): Promise { "import.meta.env": "{\"MODE\":\"production\"}", "import.meta.env.MODE": "\"production\"", }, - minify: false, + minify: true, sourcemap: "none", }); if (!result.success || result.outputs.length === 0) { const messages = result.logs.map((item) => item.message).join("; "); - throw new Error(`frontend app.tsx build failed: ${messages || "no output"}`); + throw new Error(`frontend ${entrypoint} build failed: ${messages || "no output"}`); } return result.outputs[0].text(); } @@ -117,6 +207,15 @@ function contentType(pathname: string): string { return "text/plain; charset=utf-8"; } +function textResponse(req: Request, body: string, type: string): Response { + const headers = new Headers({ "content-type": type, "vary": "accept-encoding" }); + if (/\bgzip\b/u.test(req.headers.get("accept-encoding") ?? "")) { + headers.set("content-encoding", "gzip"); + return new Response(Bun.gzipSync(body), { headers }); + } + return new Response(body, { headers }); +} + function jsonResponse(body: unknown, status = 200, extraHeaders?: HeadersInit): Response { const headers = new Headers({ "content-type": "application/json; charset=utf-8" }); if (extraHeaders !== undefined) { @@ -128,6 +227,149 @@ function jsonResponse(body: unknown, status = 200, extraHeaders?: HeadersInit): }); } +function safePreview(value: string, max = 400): string { + const normalized = value.replace(/\s+/gu, " ").trim(); + return normalized.length > max ? `${normalized.slice(0, max)}...` : normalized; +} + +function trimPerformanceBuffers(): void { + while (requestPerformanceSamples.length > maxPerformanceSamples) requestPerformanceSamples.shift(); + while (operationPerformanceSamples.length > maxPerformanceSamples) operationPerformanceSamples.shift(); +} + +function classifyRequestComponent(pathname: string): string { + if (pathname === "/api/frontend-performance") return "webui_performance"; + if (pathname.startsWith("/api/") || pathname === "/logs") return "webui_api_proxy"; + if (pathname === "/login" || pathname === "/logout" || pathname === "/api/session") return "webui_auth"; + if (pathname === "/app.js" || pathname.startsWith("/vendor/") || /\/[^/]+\.[a-z0-9]+$/iu.test(pathname)) return "webui_static"; + return "webui_page"; +} + +function recordRequestPerformance(req: Request, pathname: string, response: Response | undefined, durationMs: number): void { + const status = response?.status ?? 500; + requestPerformanceSamples.push({ + at: new Date().toISOString(), + component: classifyRequestComponent(pathname), + method: req.method, + path: pathname, + status, + durationMs, + ok: status < 400, + }); + trimPerformanceBuffers(); +} + +function recordOperationPerformance(service: string, operation: string, durationMs: number, ok: boolean, detail = "-"): void { + operationPerformanceSamples.push({ + at: new Date().toISOString(), + service, + operation, + durationMs, + ok, + detail: detail.length > 260 ? `${detail.slice(0, 257)}...` : detail, + }); + trimPerformanceBuffers(); +} + +function percentile(values: number[], ratio: number): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((left, right) => left - right); + const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * ratio) - 1)); + return sorted[index] ?? 0; +} + +function average(values: number[]): number { + if (values.length === 0) return 0; + return values.reduce((sum, value) => sum + value, 0) / values.length; +} + +function roundMs(value: number): number { + return Math.round(value * 10) / 10; +} + +function summarizeRequestPerformance(): JsonValue[] { + const groups = new Map(); + for (const sample of requestPerformanceSamples) { + const rows = groups.get(sample.component) ?? []; + rows.push(sample); + groups.set(sample.component, rows); + } + return Array.from(groups.entries()).map(([component, rows]) => { + const durations = rows.map((row) => row.durationMs); + const failed = rows.filter((row) => !row.ok).length; + return { + component, + requestCount: rows.length, + failureCount: failed, + failureRate: rows.length === 0 ? 0 : failed / rows.length, + averageLatencyMs: roundMs(average(durations)), + p95LatencyMs: roundMs(percentile(durations, 0.95)), + maxLatencyMs: roundMs(Math.max(0, ...durations)), + }; + }).sort((left, right) => Number((right as Record).requestCount ?? 0) - Number((left as Record).requestCount ?? 0)) as JsonValue[]; +} + +function summarizeOperationPerformance(): JsonValue[] { + const groups = new Map(); + for (const sample of operationPerformanceSamples) { + const key = `${sample.service}:${sample.operation}`; + const rows = groups.get(key) ?? []; + rows.push(sample); + groups.set(key, rows); + } + return Array.from(groups.entries()).map(([key, rows]) => { + const [service, ...operationParts] = key.split(":"); + const durations = rows.map((row) => row.durationMs); + const failed = rows.filter((row) => !row.ok).length; + return { + service, + operation: operationParts.join(":"), + count: rows.length, + failureCount: failed, + averageLatencyMs: roundMs(average(durations)), + p95LatencyMs: roundMs(percentile(durations, 0.95)), + maxLatencyMs: roundMs(Math.max(0, ...durations)), + }; + }).sort((left, right) => Number((right as Record).count ?? 0) - Number((left as Record).count ?? 0)) as JsonValue[]; +} + +function frontendPerformanceResponse(req: Request): Response { + if (sessionFromRequest(req) === null) { + return jsonResponse({ ok: false, error: "authentication required" }, 401); + } + const memory = process.memoryUsage(); + const recentOperationCutoff = Date.now() - 10 * 60 * 1000; + return jsonResponse({ + ok: true, + service: "frontend", + generatedAt: new Date().toISOString(), + appBundleBytes: Buffer.byteLength(appBundle, "utf8"), + requests: { + sampleCount: requestPerformanceSamples.length, + componentSummary: summarizeRequestPerformance(), + recentFailures: requestPerformanceSamples.filter((sample) => !sample.ok).slice(-20).reverse(), + }, + operations: { + sampleCount: operationPerformanceSamples.length, + summary: summarizeOperationPerformance(), + recentSlowOperations: operationPerformanceSamples + .filter((sample) => { + const at = Date.parse(sample.at); + return Number.isFinite(at) && at >= recentOperationCutoff; + }) + .sort((left, right) => Date.parse(right.at) - Date.parse(left.at)) + .slice(0, 20), + }, + process: { + rssBytes: memory.rss, + heapUsedBytes: memory.heapUsed, + heapTotalBytes: memory.heapTotal, + externalBytes: memory.external, + arrayBuffersBytes: memory.arrayBuffers, + }, + }); +} + function escapeHtmlAttribute(value: string): string { return value .replace(/&/g, "&") @@ -240,6 +482,88 @@ function sessionResponse(req: Request): Response { return jsonResponse({ ok: true, authenticated: true, user: { username: session.username }, expiresAt: new Date(session.expiresAt).toISOString() }); } +async function proxyCodexQueueDirect(req: Request, url: URL): Promise { + if (sessionFromRequest(req) === null) { + return jsonResponse({ ok: false, error: "authentication required" }, 401); + } + const prefix = "/api/codex-queue-direct"; + const suffix = url.pathname === prefix ? "/" : `/${url.pathname.slice(prefix.length).replace(/^\/+/u, "")}`; + if (!(suffix === "/health" || suffix.startsWith("/api/"))) { + return jsonResponse({ ok: false, error: "codex queue direct path is not allowed", path: suffix }, 403); + } + const overviewCacheKey = `${suffix}${url.search}`; + const canUseOverviewCache = req.method === "GET" && suffix === "/api/tasks/overview"; + if (canUseOverviewCache) { + const cached = cachedCodexQueueOverview(overviewCacheKey); + if (cached !== null) { + recordOperationPerformance("frontend", "codex_queue_direct_proxy_cache", 0, true, overviewCacheKey); + return new Response(cached.text, { headers: { "content-type": "application/json; charset=utf-8" } }); + } + } + const upstreamUrl = new URL(`${suffix}${url.search}`, "http://codex-queue:4222"); + const headers = new Headers(req.headers); + headers.delete("host"); + headers.delete("connection"); + headers.delete("content-length"); + headers.delete("cookie"); + const init: RequestInit = { method: req.method, headers, redirect: "manual" }; + if (req.method !== "GET" && req.method !== "HEAD") { + init.body = await req.arrayBuffer(); + } + const started = performance.now(); + try { + const upstream = await fetch(upstreamUrl, init); + recordOperationPerformance("frontend", "codex_queue_direct_proxy", performance.now() - started, upstream.ok, `${req.method} ${suffix}`); + const responseHeaders = new Headers(); + const upstreamContentType = upstream.headers.get("content-type"); + if (upstreamContentType !== null) responseHeaders.set("content-type", upstreamContentType); + const upstreamBody = await upstream.arrayBuffer(); + const isJsonResponse = (upstreamContentType ?? "").toLowerCase().includes("json"); + let parsedJson: unknown = null; + let jsonText = ""; + if (isJsonResponse) { + jsonText = new TextDecoder().decode(upstreamBody); + try { + parsedJson = jsonText ? JSON.parse(jsonText) as unknown : null; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + logger("warn", "codex_queue_direct_proxy_invalid_json", { + path: suffix, + upstreamUrl: upstreamUrl.toString(), + status: upstream.status, + bodyBytes: upstreamBody.byteLength, + error: detail, + preview: safePreview(jsonText), + }); + return jsonResponse({ + ok: false, + error: { + message: "codex queue upstream returned invalid JSON", + detail, + status: upstream.status, + bodyBytes: upstreamBody.byteLength, + preview: safePreview(jsonText), + }, + }, 502); + } + } + if (canUseOverviewCache && upstream.ok && isJsonResponse) { + const text = new TextDecoder().decode(upstreamBody); + if (typeof parsedJson === "object" && parsedJson !== null) { + codexQueueOverviewCache.set(codexQueueOverviewCacheKey(overviewCacheKey), { at: Date.now(), payload: parsedJson as JsonValue, text }); + } + return new Response(text, { status: upstream.status, headers: responseHeaders }); + } + responseHeaders.set("content-length", String(upstreamBody.byteLength)); + return new Response(upstreamBody, { status: upstream.status, headers: responseHeaders }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + recordOperationPerformance("frontend", "codex_queue_direct_proxy", performance.now() - started, false, message); + logger("warn", "codex_queue_direct_proxy_failed", { path: suffix, upstreamUrl: upstreamUrl.toString(), error: message }); + return jsonResponse({ ok: false, error: { message: "codex queue direct proxy failed", detail: message } }, 502); + } +} + async function proxyApi(req: Request, url: URL): Promise { if (sessionFromRequest(req) === null) { return jsonResponse({ ok: false, error: "authentication required" }, 401); @@ -255,10 +579,13 @@ async function proxyApi(req: Request, url: URL): Promise { init.body = await req.arrayBuffer(); } let upstream: Response; + const started = performance.now(); try { upstream = await fetch(upstreamUrl, init); + recordOperationPerformance("frontend", "core_proxy", performance.now() - started, upstream.ok, `${req.method} ${url.pathname}`); } catch (error) { const message = error instanceof Error ? error.message : String(error); + recordOperationPerformance("frontend", "core_proxy", performance.now() - started, false, message); logger("warn", "proxy_upstream_failed", { path: url.pathname, upstreamUrl: upstreamUrl.toString(), error: message }); return jsonResponse({ ok: false, error: { message: "upstream proxy failed", detail: message } }, 502); } @@ -275,9 +602,9 @@ function vendorPath(pathname: string): string | null { return null; } -async function staticResponse(pathname: string): Promise { +async function staticResponse(req: Request, pathname: string): Promise { if (pathname === "/app.js") { - return new Response(appBundle, { headers: { "content-type": "text/javascript; charset=utf-8" } }); + return textResponse(req, appBundle, "text/javascript; charset=utf-8"); } const vendor = vendorPath(pathname); const filePath = vendor ?? join(publicDir, pathname.replace(/^\/+/, "")); @@ -292,35 +619,49 @@ function isStaticAssetPath(pathname: string): boolean { return /\/[^/]+\.[a-z0-9]+$/iu.test(pathname); } +async function handleRequest(req: Request): Promise { + const url = new URL(req.url); + logger("debug", "request", { path: url.pathname }); + try { + if (url.pathname === "/health") { + return jsonResponse({ ok: true, service: "unidesk-frontend", frontendPublicUrl: config.frontendPublicUrl }); + } + if (url.pathname === "/login" && req.method === "POST") return login(req); + if (url.pathname === "/logout" && req.method === "POST") return logout(); + if (url.pathname === "/api/session") return sessionResponse(req); + if (url.pathname === "/api/frontend-performance") return frontendPerformanceResponse(req); + if (url.pathname === "/api/codex-queue-direct" || url.pathname.startsWith("/api/codex-queue-direct/")) return proxyCodexQueueDirect(req, url); + if (url.pathname.startsWith("/api/") || url.pathname === "/logs") return proxyApi(req, url); + if (url.pathname === "/" || url.pathname === "/index.html") { + return textResponse(req, await spaShellHtml(req, url.pathname), "text/html; charset=utf-8"); + } + const safePath = url.pathname.replace(/^\/+/, ""); + if (safePath.includes("..") || safePath.includes("\0")) { + return jsonResponse({ ok: false, error: "invalid path" }, 400); + } + if (!isStaticAssetPath(url.pathname)) { + return textResponse(req, await spaShellHtml(req, url.pathname), "text/html; charset=utf-8"); + } + return staticResponse(req, url.pathname); + } catch (error) { + logger("error", "request_failed", { path: url.pathname, error: error instanceof Error ? error.message : String(error) }); + return jsonResponse({ ok: false, error: "request failed" }, 500); + } +} + const server = Bun.serve({ port: config.port, hostname: "0.0.0.0", idleTimeout: 120, async fetch(req) { + const started = performance.now(); const url = new URL(req.url); - logger("debug", "request", { path: url.pathname }); + let response: Response | undefined; try { - if (url.pathname === "/health") { - return jsonResponse({ ok: true, service: "unidesk-frontend", frontendPublicUrl: config.frontendPublicUrl }); - } - if (url.pathname === "/login" && req.method === "POST") return login(req); - if (url.pathname === "/logout" && req.method === "POST") return logout(); - if (url.pathname === "/api/session") return sessionResponse(req); - if (url.pathname.startsWith("/api/") || url.pathname === "/logs") return proxyApi(req, url); - if (url.pathname === "/" || url.pathname === "/index.html") { - return new Response(indexHtml, { headers: { "content-type": "text/html; charset=utf-8" } }); - } - const safePath = url.pathname.replace(/^\/+/, ""); - if (safePath.includes("..") || safePath.includes("\0")) { - return jsonResponse({ ok: false, error: "invalid path" }, 400); - } - if (!isStaticAssetPath(url.pathname)) { - return new Response(indexHtml, { headers: { "content-type": "text/html; charset=utf-8" } }); - } - return staticResponse(url.pathname); - } catch (error) { - logger("error", "request_failed", { path: url.pathname, error: error instanceof Error ? error.message : String(error) }); - return jsonResponse({ ok: false, error: "request failed" }, 500); + response = await handleRequest(req); + return response; + } finally { + recordRequestPerformance(req, url.pathname, response, performance.now() - started); } }, }); diff --git a/src/components/frontend/src/met-nonlinear.tsx b/src/components/frontend/src/met-nonlinear.tsx index 5a8c73d7..2871648f 100644 --- a/src/components/frontend/src/met-nonlinear.tsx +++ b/src/components/frontend/src/met-nonlinear.tsx @@ -1,4 +1,6 @@ import React from "react"; +import { errorMessage, requestJson } from "./unidesk-error"; +import { UniDeskErrorBanner } from "./unidesk-error-banner"; type AnyRecord = Record; @@ -6,10 +8,6 @@ const h = React.createElement; const { useEffect } = 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); @@ -68,24 +66,6 @@ function objectValue(value: any): AnyRecord { return value && typeof value === "object" && !Array.isArray(value) ? value : {}; } -async function requestJson(path: string, options: AnyRecord = {}): Promise { - const headers = new Headers(options.headers || {}); - if (options.body && !headers.has("content-type")) headers.set("content-type", "application/json"); - const response = await fetch(path, { credentials: "same-origin", ...options, headers }); - const text = await response.text(); - let body = null; - try { - body = text ? JSON.parse(text) : null; - } catch { - body = { text }; - } - if (!response.ok || body?.ok === false) { - const message = body?.error?.message || body?.error || `HTTP ${response.status}`; - throw new Error(message); - } - return body; -} - function StatusBadge({ status, children }: AnyRecord) { const normalized = String(status || "unknown").toLowerCase(); return h("span", { className: `status-badge ${normalized}` }, children || status || "unknown"); @@ -283,28 +263,45 @@ export function MetNonlinearPage({ microservices, onRaw, apiBaseUrl = "/api" }: setUi((prev: any) => ({ ...prev, ...patch })); } - async function load(): Promise { + async function load(tab = ui.activeTab): Promise { if (!service) return; setState((prev: any) => ({ ...prev, loading: true, error: "" })); try { - const [health, summary, queue, projectsRoot, exProjectsRoot, history, images] = await Promise.all([ - requestJson(`${apiBaseUrl}/microservices/met-nonlinear/health`), - requestJson(metApi(apiBaseUrl, "/api/summary")), - requestJson(metApi(apiBaseUrl, "/api/queue")), - requestJson(metApi(apiBaseUrl, "/api/projects?root=projects&limit=500")), - requestJson(metApi(apiBaseUrl, "/api/projects?root=ex_projects&limit=500")), - requestJson(metApi(apiBaseUrl, "/api/history")), - requestJson(metApi(apiBaseUrl, "/api/images")), - ]); - const projects = { - ok: projectsRoot?.ok !== false && exProjectsRoot?.ok !== false, - roots: [ - { root: "projects", count: allProjectRows(projectsRoot).length }, - { root: "ex_projects", count: allProjectRows(exProjectsRoot).length }, - ], - projects: [...allProjectRows(projectsRoot), ...allProjectRows(exProjectsRoot)], - }; - setState({ loading: false, actionBusy: false, error: "", health, summary, queue, projects, history, images, refreshedAt: new Date() }); + const requests: Array<[string, Promise]> = [ + ["health", requestJson(`${apiBaseUrl}/microservices/met-nonlinear/health`)], + ["summary", requestJson(metApi(apiBaseUrl, "/api/summary"))], + ]; + if (tab === "projects") { + requests.push(["projectsRoot", requestJson(metApi(apiBaseUrl, "/api/projects?root=projects&limit=500"))]); + requests.push(["exProjectsRoot", requestJson(metApi(apiBaseUrl, "/api/projects?root=ex_projects&limit=500"))]); + } + if (tab === "current" || tab === "completed" || tab === "failed") { + requests.push(["queue", requestJson(metApi(apiBaseUrl, "/api/queue"))]); + } + if (tab === "completed" || tab === "failed") { + requests.push(["history", requestJson(metApi(apiBaseUrl, "/api/history"))]); + } + if (tab === "gpu") { + requests.push(["images", requestJson(metApi(apiBaseUrl, "/api/images"))]); + } + const entries = Object.fromEntries(await Promise.all(requests.map(async ([key, promise]) => [key, await promise] as const))); + const patch: AnyRecord = { loading: false, actionBusy: false, error: "", health: entries.health, summary: entries.summary, refreshedAt: new Date() }; + if (entries.projectsRoot || entries.exProjectsRoot) { + const projectsRoot = entries.projectsRoot; + const exProjectsRoot = entries.exProjectsRoot; + patch.projects = { + ok: projectsRoot?.ok !== false && exProjectsRoot?.ok !== false, + roots: [ + { root: "projects", count: allProjectRows(projectsRoot).length }, + { root: "ex_projects", count: allProjectRows(exProjectsRoot).length }, + ], + projects: [...allProjectRows(projectsRoot), ...allProjectRows(exProjectsRoot)], + }; + } + if (entries.queue) patch.queue = entries.queue; + if (entries.history) patch.history = entries.history; + if (entries.images) patch.images = entries.images; + setState((prev: any) => ({ ...prev, ...patch })); } catch (err) { setState((prev: any) => ({ ...prev, loading: false, actionBusy: false, error: errorMessage(err, "MET Nonlinear 加载失败") })); } @@ -408,10 +405,10 @@ export function MetNonlinearPage({ microservices, onRaw, apiBaseUrl = "/api" }: } useEffect(() => { - load(); - }, [service?.id, service?.runtime?.providerStatus]); + load(ui.activeTab); + }, [service?.id, service?.runtime?.providerStatus, ui.activeTab]); - if (!service) return h(EmptyState, { title: "MET Nonlinear 未登记", text: "请在 config.json 的 microservices 中登记 id=met-nonlinear" }); + if (!service) return h(EmptyState, { title: "MET Nonlinear 未登记", text: "请在 config.json 的 microservices 中登记用户服务 id=met-nonlinear" }); const runtime = microserviceRuntime(service); const repository = microserviceRepository(service); @@ -430,7 +427,7 @@ export function MetNonlinearPage({ microservices, onRaw, apiBaseUrl = "/api" }: const terminalHistory = Array.isArray(state.history?.jobs) ? state.history.jobs.slice(0, 120) : []; const tabItems = [ { id: "projects", label: "项目库", count: projects.length }, - { id: "current", label: "当前队列", count: currentJobs.length }, + { id: "current", label: "当前队列", count: currentJobs.length || Number(counts.staged || 0) + Number(counts.queued || 0) + Number(counts.running || 0) }, { id: "completed", label: "已完成", count: completedJobs.length || Number(counts.succeeded || 0) }, { id: "failed", label: "失败诊断", count: failedJobs.length || Number(counts.failed || 0) + Number(counts.canceled || 0) }, { id: "gpu", label: "GPU/镜像", count: gpus.length }, @@ -569,7 +566,7 @@ export function MetNonlinearPage({ microservices, onRaw, apiBaseUrl = "/api" }: function renderDetailPanel() { if (detail.loading) return h("section", { className: "met-detail-panel", "data-testid": "met-detail-panel" }, h(EmptyState, { title: "详情加载中", text: detail.title || "正在读取 D601 data/ 和 config.json" })); - if (detail.error) return h("section", { className: "met-detail-panel", "data-testid": "met-detail-panel" }, h("div", { className: "form-error wide" }, detail.error)); + if (detail.error) return h("section", { className: "met-detail-panel", "data-testid": "met-detail-panel" }, h(UniDeskErrorBanner, { error: detail.error, wide: true })); if (!detail.data) return h("section", { className: "met-detail-panel muted", "data-testid": "met-detail-panel" }, h(EmptyState, { title: "选择一个项目或任务查看详情", text: "项目库、当前队列、已完成和失败诊断中的行都可以点击;默认只展示结构化字段,原始 JSON 需显式点击按钮。" })); const payload = detailPayload(detail); const job = detailJob(detail); @@ -660,10 +657,10 @@ export function MetNonlinearPage({ microservices, onRaw, apiBaseUrl = "/api" }: return h("div", { className: "met-page", "data-testid": "met-nonlinear-page" }, h(Panel, { title: "MET Nonlinear 训练编排", - eyebrow: "D601 GPU Microservice", + eyebrow: "D601 GPU 用户服务", actions: h("div", { className: "panel-actions" }, h("button", { type: "button", className: "ghost-btn", onClick: load, disabled: state.loading, "data-testid": "met-refresh-button" }, state.loading ? "刷新中" : "刷新"), - h(RawButton, { title: "MET Nonlinear Microservice", data: service, onOpen: onRaw, testId: "raw-met-service" }), + h(RawButton, { title: "MET Nonlinear 用户服务", data: service, onOpen: onRaw, testId: "raw-met-service" }), ), }, h("div", { className: "findjob-hero" }, @@ -686,7 +683,7 @@ export function MetNonlinearPage({ microservices, onRaw, apiBaseUrl = "/api" }: h("code", null, `${repository.composeFile || "--"} / ${repository.containerName || "--"}`), ), ), - state.error ? h("div", { className: "form-error wide" }, state.error) : null, + h(UniDeskErrorBanner, { error: state.error, wide: true }), ui.actionMessage ? h("div", { className: "met-action-log", "data-testid": "met-action-message" }, ui.actionMessage) : null, ), h("div", { className: "met-grid" }, diff --git a/src/components/frontend/src/navigation.ts b/src/components/frontend/src/navigation.ts index d26295ac..91b85bc7 100644 --- a/src/components/frontend/src/navigation.ts +++ b/src/components/frontend/src/navigation.ts @@ -41,6 +41,7 @@ export interface UniDeskRouteRegistry { export const MODULES: UniDeskModuleDefinition[] = [ { id: "ops", label: "运行总览", code: "OPS", tabs: [ { id: "status", label: "态势总览" }, + { id: "performance", label: "性能面板" }, { id: "events", label: "事件摘要" }, { id: "logs", label: "服务日志" }, ] }, @@ -58,13 +59,15 @@ export const MODULES: UniDeskModuleDefinition[] = [ { id: "history", label: "任务历史" }, { id: "results", label: "执行结果" }, ] }, - { id: "apps", label: "微服务", code: "APP", routeSegment: "app", tabs: [ + { id: "apps", label: "用户服务", code: "APP", routeSegment: "app", tabs: [ { id: "catalog", label: "服务目录" }, { id: "todo-note", label: "Todo Note" }, { id: "findjob", label: "FindJob" }, { id: "pipeline", label: "Pipeline" }, { id: "met-nonlinear", label: "MET Nonlinear" }, + { id: "claudeqq", label: "ClaudeQQ" }, { id: "codex-queue", label: "Codex Queue" }, + { id: "project-manager", label: "Project Manager" }, ] }, { 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 543008ba..3094b8fe 100644 --- a/src/components/frontend/src/pipeline.tsx +++ b/src/components/frontend/src/pipeline.tsx @@ -1,5 +1,8 @@ import React from "react"; import { Background, BaseEdge, Controls, Handle, MarkerType, Position, ReactFlow, type Edge, type Node } from "@xyflow/react"; +import { TraceView, opencodeTracePort } from "./trace"; +import { errorMessage, requestJson as requestUniDeskJson } from "./unidesk-error"; +import { UniDeskErrorBanner } from "./unidesk-error-banner"; type AnyRecord = Record; @@ -185,10 +188,6 @@ function PipelineCurveEdge({ id, sourceX, sourceY, targetX, targetY, targetPosit const pipelineEdgeTypes: any = { pipelineCurve: PipelineCurveEdge }; const pipelineNodeTypes: any = { pipelineNode: PipelineFlowNode }; -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); @@ -216,6 +215,18 @@ function fmtDurationMs(value: any): string { return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`; } +function fmtCount(value: any): string { + const number = Number(value); + if (!Number.isFinite(number)) return "--"; + return number.toLocaleString("zh-CN"); +} + +function fmtPercent(value: any): string { + const number = Number(value); + if (!Number.isFinite(number)) return "--"; + return `${Math.round(Math.max(0, Math.min(1, number)) * 100)}%`; +} + function isRecord(value: any): value is AnyRecord { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -346,66 +357,24 @@ function findProcedureRun(details: any, interval: any): AnyRecord | null { return procedures.find((procedure: any) => String(procedureRunIdOf(procedure)) === procedureRunId) || procedures.at(-1) || null; } +function findProcedureRunById(details: any, procedureRunId: string): AnyRecord | null { + const targetId = String(procedureRunId || ""); + if (!targetId) return null; + return asArray(details?.procedureRuns).find((procedure: any) => procedureRunIdOf(procedure) === targetId) || null; +} + +function procedureAttemptCount(procedure: any): number { + return asArray(procedure?.attempts).length; +} + +function procedureTraceStepCount(procedure: any): number { + return asArray(procedure?.attempts).reduce((sum: number, attempt: any) => sum + opencodeSteps(attempt).length, 0); +} + function opencodeSteps(attempt: any): AnyRecord[] { return asArray(attempt?.opencodeMessages?.steps).filter(isRecord); } -function opencodePartFacts(part: any): string[] { - return [ - `type ${part?.type || "unknown"}`, - part?.tool ? `tool ${part.tool}` : "", - part?.status ? `status ${part.status}` : "", - part?.durationMs !== undefined && part?.durationMs !== null ? `duration ${fmtDurationMs(part.durationMs)}` : "", - part?.outputSize !== undefined ? `output ${part.outputSize} chars` : "", - ].filter(Boolean); -} - -function PipelineFieldList({ fields }: AnyRecord) { - const safeFields = asArray(fields).filter((field: any) => field?.key); - if (safeFields.length === 0) return null; - return h("dl", { className: "pipeline-field-list" }, safeFields.flatMap((field: AnyRecord) => [ - h("dt", { key: `${field.key}-k` }, field.key), - h("dd", { key: `${field.key}-v` }, previewText(field.value, 260)), - ])); -} - -function firstFieldValue(fields: any, keys: string[]): string { - const normalized = new Set(keys.map((key) => key.toLowerCase())); - for (const field of asArray(fields)) { - const key = String(field?.key || "").toLowerCase(); - if (normalized.has(key)) return String(field?.value || ""); - } - return ""; -} - -function stepRoleLabel(role: any): string { - const value = String(role || "unknown").toLowerCase(); - if (value === "user") return "用户"; - if (value === "assistant") return "助手"; - if (value === "system") return "系统"; - return value || "其他"; -} - -function stepRoleTone(role: any): string { - const value = String(role || "unknown").toLowerCase(); - return ["user", "assistant", "system"].includes(value) ? value : "unknown"; -} - -function pipelineNoisePartType(type: any): boolean { - const normalized = String(type || "").toLowerCase(); - return normalized === "step-start" || normalized === "step-finish"; -} - -function partKindLabel(part: any): string { - const type = String(part?.type || "unknown").toLowerCase(); - if (type === "text") return "正文"; - if (type === "reasoning") return "思考"; - if (type === "tool") return "工具"; - if (type === "step-start") return "开始"; - if (type === "step-finish") return "结束"; - return type || "part"; -} - function partStatusTone(part: any): string { const status = String(part?.status || "").toLowerCase(); if (["error", "failed", "failure"].includes(status)) return "failed"; @@ -414,18 +383,6 @@ function partStatusTone(part: any): string { return "unknown"; } -function stepStatusTone(step: any): string { - const tools = asArray(step?.parts).filter((part: any) => String(part?.type || "").toLowerCase() === "tool"); - if (tools.some((part: any) => partStatusTone(part) === "failed")) return "failed"; - if (!step?.completedAt && String(step?.role || "").toLowerCase() === "assistant") return "running"; - return "succeeded"; -} - -function stepTokenLine(tokens: any): string { - const facts = tokenFacts(tokens); - return facts.length > 0 ? facts.join(" / ") : "tokens --"; -} - function pipelineStructuredSummary(value: any): AnyRecord { if (Array.isArray(value)) { const first = value[0]; @@ -575,147 +532,6 @@ function PipelineStructuredPayloadBlock({ label, value }: AnyRecord) { ); } -function PipelineStepTextBlock({ label, text, tone = "", previewLimit = 760, maxBlocks = Number.POSITIVE_INFINITY, compactText = false }: AnyRecord) { - const safePreviewLimit = Number.isFinite(Number(previewLimit)) ? Number(previewLimit) : 760; - const safeMaxBlocks = Number.isFinite(Number(maxBlocks)) ? Math.max(1, Number(maxBlocks)) : Number.POSITIVE_INFINITY; - const blocks = pipelineStructuredTextBlocks(text); - if (blocks.length === 0) return null; - const visibleBlocks = blocks.slice(0, safeMaxBlocks); - const hiddenCount = Math.max(0, blocks.length - visibleBlocks.length); - return h("div", { className: "pipeline-step-text-stack" }, - visibleBlocks.map((block: AnyRecord, index: number) => { - if (block.type === "structured") { - return h(PipelineStructuredPayloadBlock, { key: `structured-${index}-${block.label || "payload"}`, label: block.label, value: block.value }); - } - const sourceText = compactText ? String(block.text || "").replace(/\s+/gu, " ").trim() : block.text; - const preview = previewText(sourceText, safePreviewLimit); - if (!preview) return null; - return h("section", { key: `text-${index}`, className: `pipeline-step-text-block ${tone}` }, - h("b", null, label), - h("p", null, preview), - ); - }), - hiddenCount > 0 ? h("div", { className: "pipeline-step-text-overflow" }, `已折叠 ${hiddenCount} 段`) : null, - ); -} - -function normalizedPipelinePreview(value: any): string { - return previewText(value, 3200).replace(/\s+/gu, " ").trim().toLowerCase(); -} - -function uniquePipelinePartPreviews(parts: any[], blockedValues: Set = new Set()): string[] { - const seen = new Set(); - const values: string[] = []; - for (const part of asArray(parts)) { - const text = previewText(part?.textPreview, 3200); - const normalized = normalizedPipelinePreview(text); - if (!normalized || seen.has(normalized) || blockedValues.has(normalized)) continue; - seen.add(normalized); - values.push(text); - } - return values; -} - -function pipelineSummaryBlockProps(step: any, textParts: any[], reasoningParts: any[]): AnyRecord | null { - const role = String(step?.role || "").toLowerCase(); - const reasoningValues = uniquePipelinePartPreviews(reasoningParts); - const reasoningSet = new Set(reasoningValues.map((item) => normalizedPipelinePreview(item)).filter(Boolean)); - const textValues = uniquePipelinePartPreviews(textParts, role === "assistant" ? reasoningSet : new Set()); - const textSummary = textValues.join("\n\n"); - const stepPreview = previewText(step?.textPreview, 3200); - const normalizedStepPreview = normalizedPipelinePreview(stepPreview); - const hasIndependentStepPreview = Boolean( - normalizedStepPreview - && !reasoningSet.has(normalizedStepPreview) - && !Array.from(reasoningSet).some((item) => item && normalizedStepPreview.includes(item)), - ); - - if (role === "assistant") { - if (textSummary) { - return { label: "消息摘要", text: textSummary, tone: "assistant", previewLimit: 180, maxBlocks: 2, compactText: true }; - } - if (hasIndependentStepPreview) { - return { label: "消息摘要", text: stepPreview, tone: "assistant", previewLimit: 180, maxBlocks: 2, compactText: true }; - } - return null; - } - - const summaryText = textSummary || stepPreview; - if (!summaryText) return null; - return { - label: role === "user" ? "用户输入" : role === "system" ? "系统输入" : "消息摘要", - text: summaryText, - tone: role, - previewLimit: role === "user" ? 200 : 180, - maxBlocks: 2, - compactText: true, - }; -} - -function pipelineReasoningSummaryText(reasoningParts: any[]): string { - return uniquePipelinePartPreviews(reasoningParts).join("\n\n"); -} - -function pipelineMessageCardRows(step: any, textParts: any[], reasoningParts: any[]): AnyRecord[] { - const role = String(step?.role || "").toLowerCase(); - const roleTone = stepRoleTone(role); - const messageSummary = pipelineSummaryBlockProps(step, textParts, reasoningParts); - const reasoningSummary = role === "assistant" ? pipelineReasoningSummaryText(reasoningParts) : ""; - const normalizedReasoning = normalizedPipelinePreview(reasoningSummary); - const normalizedMessage = normalizedPipelinePreview(messageSummary?.text || ""); - const duplicateSummary = Boolean( - normalizedReasoning - && normalizedMessage - && ( - normalizedReasoning === normalizedMessage - || normalizedReasoning.includes(normalizedMessage) - || normalizedMessage.includes(normalizedReasoning) - ), - ); - const rows: AnyRecord[] = []; - if (reasoningSummary) rows.push({ key: "reasoning", label: "思考", text: reasoningSummary, tone: "reasoning", previewLimit: 104 }); - if (messageSummary?.text && !(role === "assistant" && duplicateSummary)) rows.push({ - key: "message", - label: role === "assistant" ? "消息" : messageSummary.label, - text: messageSummary.text, - tone: messageSummary.tone || roleTone, - previewLimit: role === "user" ? 124 : 110, - }); - if (rows.length === 0 && role === "assistant") rows.push({ key: "message", label: "消息", text: "无正文输出,仅工具调用。", tone: roleTone, previewLimit: 120 }); - return rows; -} - -function pipelineToolPrimarySummary(part: any): AnyRecord { - const inputFields = asArray(part?.inputFields); - const outputPreview = previewText(part?.outputPreview && part.outputPreview !== "--" ? part.outputPreview : "", 620); - const tool = String(part?.tool || part?.type || "tool").toUpperCase(); - const description = firstFieldValue(inputFields, ["description", "justification", "purpose", "summary"]); - const query = firstFieldValue(inputFields, ["q", "query"]); - const command = firstFieldValue(inputFields, ["command", "cmd"]); - const filePath = firstFieldValue(inputFields, ["filePath", "filepath", "path"]); - const primaryText = description - || query - || (tool === "BASH" ? command || filePath : filePath || command) - || previewText(part?.textPreview || outputPreview || part?.title || tool, 220); - return { - tool, - text: previewText(String(primaryText || "").replace(/\s+/gu, " ").trim(), 150), - }; -} - -function pipelineToolSummaryItems(parts: any[]): AnyRecord[] { - const seen = new Set(); - const items: AnyRecord[] = []; - for (const part of asArray(parts)) { - const summary = pipelineToolPrimarySummary(part); - const key = `${summary.tool}:${normalizedPipelinePreview(summary.text)}`; - if (!summary.text || seen.has(key)) continue; - seen.add(key); - items.push(summary); - } - return items; -} - function pipelineSessionFacts(steps: any[], sessionIds: string[]): string[] { const agents = uniqueStrings(steps.map((step: any) => step?.agent)).slice(0, 3); const models = uniqueStrings(steps.map((step: any) => step?.model)).slice(0, 3); @@ -729,198 +545,62 @@ function pipelineSessionFacts(steps: any[], sessionIds: string[]): string[] { ]; } -function PipelineStepMessageCard({ rows, compact = false, role = "", matched = false }: AnyRecord) { - const safeRows = asArray(rows).filter((row: any) => row?.text); - if (safeRows.length === 0) return null; - if (compact) { - return h("section", { className: `pipeline-step-message-card compact ${role}` }, - h("div", { className: "pipeline-step-message-rows" }, - safeRows.map((row: AnyRecord, index: number) => { - const text = previewText(String(row.text || "").replace(/\s+/gu, " ").trim(), row.previewLimit || (index === 0 ? 116 : 124)); - return h("section", { key: row.key || `${row.label}-${index}`, className: `pipeline-step-message-row compact ${row.tone || ""}` }, - h("p", null, - h("b", null, row.label), - h("span", null, text || "--"), - ), - ); - }), - ), - ); - } - return h("section", { className: `pipeline-step-message-card ${compact ? "compact" : "expanded"} ${role}` }, - h("div", { className: "pipeline-step-message-card-head" }, - h("span", { className: `pipeline-step-role ${role}` }, stepRoleLabel(role)), - matched ? h("span", { className: "pipeline-step-match-badge" }, "匹配点") : null, - ), - h("div", { className: "pipeline-step-message-rows" }, - safeRows.map((row: AnyRecord, index: number) => { - return h("div", { key: row.key || `${row.label}-${index}`, className: `pipeline-step-message-row expanded ${row.tone || ""}` }, - h(PipelineStepTextBlock, { - label: row.label, - text: row.text, - tone: row.tone || role, - previewLimit: row.previewLimitFull || 420, - maxBlocks: 3, - compactText: false, - }), - ); - }), - ), - ); -} - -function PipelineStepTimeSummary({ step, role = "", matched = false }: AnyRecord) { - const durationText = step?.durationMs !== undefined && step?.durationMs !== null ? fmtDurationMs(step.durationMs) : ""; - const meta = [ - `Step ${step?.index ?? "?"}`, - durationText, - step?.finish ? String(step.finish) : "", - ].filter(Boolean).join(" / "); - return h("section", { className: "pipeline-step-time-card" }, - h("b", null, "时间"), - h("strong", null, `${fmtClockValue(step?.createdAt)} -> ${fmtClockValue(step?.completedAt)}`), - h("div", { className: "pipeline-step-time-meta" }, - meta ? h("span", null, meta) : null, - role ? h("span", { className: `pipeline-step-role ${role}` }, stepRoleLabel(role)) : null, - matched ? h("span", { className: "pipeline-step-match-badge" }, "匹配点") : null, - ), - ); -} - -function PipelineStepToolSummary({ tools }: AnyRecord) { - const items = pipelineToolSummaryItems(tools); - return h("section", { className: `pipeline-step-tool-summary ${items.length > 0 ? "has-items" : "empty"}` }, - h("b", null, "工具调用"), - items.length === 0 - ? h("p", null, "无") - : h("div", { className: "pipeline-step-tool-summary-list" }, - items.slice(0, 2).map((item: AnyRecord, index: number) => h("div", { key: `${item.tool}-${index}`, className: "pipeline-step-tool-summary-item" }, - h("span", null, item.tool), - h("p", null, item.text), - )), - items.length > 2 ? h("small", null, `+${items.length - 2} more`) : null, - ), - ); -} - -function PipelineOpenCodePart({ part }: AnyRecord) { - const type = String(part?.type || "unknown").toLowerCase(); - if (pipelineNoisePartType(type)) return null; - const title = String(part?.title || part?.tool || partKindLabel(part)); - const facts = opencodePartFacts(part); - const tone = partStatusTone(part); - const inputFields = asArray(part?.inputFields); - const metadataFields = asArray(part?.metadataFields); - const outputPreview = previewText(part?.outputPreview && part.outputPreview !== "--" ? part.outputPreview : "", 620); - const command = firstFieldValue(inputFields, ["command", "cmd"]); - const filePath = firstFieldValue(inputFields, ["filePath", "filepath", "path"]); - const compactSummary = command || filePath || previewText(part?.textPreview || outputPreview || title, 120); - - if (type === "text") { - return h(PipelineStepTextBlock, { label: "用户输入 / 上下文", text: part?.textPreview, tone: "user-text" }); - } - - if (type === "reasoning") { - return h("details", { className: "pipeline-opencode-part reasoning" }, - h("summary", null, - h("span", { className: "pipeline-part-kind" }, "思考"), - h("strong", null, previewText(part?.textPreview || "reasoning", 96)), - h(PipelineChipRow, { items: facts }), - ), - h("div", { className: "pipeline-opencode-part-body" }, - h(PipelineStepTextBlock, { label: "reasoning preview", text: part?.textPreview, tone: "reasoning" }), - metadataFields.length > 0 ? h("div", null, h("b", null, "元数据"), h(PipelineFieldList, { fields: metadataFields })) : null, - ), - ); - } - - if (type === "tool") { - return h("details", { className: `pipeline-opencode-part tool ${tone}` }, - h("summary", null, - h("span", { className: `pipeline-tool-badge ${tone}` }, part?.tool || "tool"), - h("strong", null, compactSummary || title), - ), - h("div", { className: "pipeline-opencode-part-body" }, - facts.length > 0 ? h(PipelineChipRow, { items: facts }) : null, - inputFields.length > 0 ? h("div", null, h("b", null, "输入字段"), h(PipelineFieldList, { fields: inputFields })) : null, - outputPreview ? h(PipelineStepTextBlock, { label: "输出摘要", text: outputPreview, tone: tone === "failed" ? "failed" : "tool-output" }) : null, - metadataFields.length > 0 ? h("div", null, h("b", null, "元数据"), h(PipelineFieldList, { fields: metadataFields })) : null, - ), - ); - } - - return h("details", { className: `pipeline-opencode-part ${tone}` }, - h("summary", null, - h("span", { className: "pipeline-part-kind" }, partKindLabel(part)), - h("strong", null, title), - h(PipelineChipRow, { items: facts }), - ), - h("div", { className: "pipeline-opencode-part-body" }, - part?.textPreview ? h(PipelineStepTextBlock, { label: partKindLabel(part), text: part.textPreview }) : null, - inputFields.length > 0 ? h("div", null, h("b", null, "输入字段"), h(PipelineFieldList, { fields: inputFields })) : null, - outputPreview ? h(PipelineStepTextBlock, { label: "输出摘要", text: outputPreview }) : null, - metadataFields.length > 0 ? h("div", null, h("b", null, "元数据"), h(PipelineFieldList, { fields: metadataFields })) : null, - ), - ); -} - function opencodeStepKey(step: any, fallbackIndex = 0): string { const key = String(step?.messageId || step?.index || ""); return key || `step-${fallbackIndex}`; } -function PipelineOpenCodeStep({ step, matched = false }: AnyRecord) { - const parts = asArray(step?.parts); - const visibleParts = parts.filter((part: any) => !pipelineNoisePartType(part?.type)); - const tools = visibleParts.filter((part: any) => String(part?.type || "").toLowerCase() === "tool"); - const textParts = visibleParts.filter((part: any) => String(part?.type || "").toLowerCase() === "text"); - const reasoningParts = visibleParts.filter((part: any) => String(part?.type || "").toLowerCase() === "reasoning"); - const otherParts = visibleParts.filter((part: any) => !["tool", "text", "reasoning"].includes(String(part?.type || "").toLowerCase())); - const partTypes = isRecord(step?.partTypes) - ? Object.entries(step.partTypes) - .filter(([type, count]) => !pipelineNoisePartType(type) && Number(count) > 0) - .map(([type, count]) => `${type} ${count}`) - : []; - 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}` : "", - step?.durationMs !== undefined && step?.durationMs !== null ? `duration ${fmtDurationMs(step.durationMs)}` : "", - `parts ${visibleParts.length}`, - tools.length > 0 ? `tools ${tools.length}` : "", - ...toolNames.map((tool) => `tool ${tool}`), +function PipelineOpenCodeTrace({ steps, sessionIds, sessionFacts, matchedStepKey }: AnyRecord) { + const safeSteps = asArray(steps); + const matchedIndex = safeSteps.findIndex((step: any, index: number) => opencodeStepKey(step, index) === matchedStepKey); + const matchedStep = matchedIndex >= 0 ? safeSteps[matchedIndex] : null; + const stepTimes = safeSteps.flatMap((step: any) => [timeMs(step?.createdAt), timeMs(step?.completedAt)]).filter((value): value is number => value !== null); + const startMs = stepTimes.length > 0 ? Math.min(...stepTimes) : null; + const endMs = stepTimes.length > 0 ? Math.max(...stepTimes) : null; + const durationMs = startMs !== null && endMs !== null ? Math.max(0, endMs - startMs) : null; + const toolCount = safeSteps.reduce((sum: number, step: any) => sum + asArray(step?.parts).filter((part: any) => String(part?.type || "").toLowerCase() === "tool").length, 0); + const textCount = safeSteps.reduce((sum: number, step: any) => sum + asArray(step?.parts).filter((part: any) => ["text", "reasoning"].includes(String(part?.type || "").toLowerCase())).length, 0); + const failedToolCount = safeSteps.reduce((sum: number, step: any) => sum + asArray(step?.parts).filter((part: any) => String(part?.type || "").toLowerCase() === "tool" && partStatusTone(part) === "failed").length, 0); + const traceFacts = [ + `${safeSteps.length} steps`, + `${sessionIds.length} sessions`, + `${textCount} messages`, + `${toolCount} tools`, + durationMs !== null ? `duration ${fmtDurationMs(durationMs)}` : "", + failedToolCount > 0 ? `${failedToolCount} failed tools` : "", ].filter(Boolean); - const messageRows = pipelineMessageCardRows(step, textParts, reasoningParts); + const matchedFacts = matchedStep ? [ + `Step ${matchedStep?.index ?? matchedIndex + 1}`, + String(matchedStep?.role || "role --"), + matchedStep?.model ? `model ${matchedStep.model}` : "", + matchedStep?.finish ? `finish ${matchedStep.finish}` : "", + matchedStep?.durationMs !== undefined && matchedStep?.durationMs !== null ? `duration ${fmtDurationMs(matchedStep.durationMs)}` : "", + ].filter(Boolean) : []; - 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 }), - h(PipelineStepToolSummary, { tools }), - ), - h("div", { className: "pipeline-step-body", "data-testid": "pipeline-opencode-step-body" }, - h("div", { className: "pipeline-step-factbar" }, - h(StatusBadge, { status: statusTone }, statusTone), - h("span", { className: "pipeline-step-token-line" }, stepTokenLine(step?.tokens)), - h(PipelineChipRow, { items: facts }), - partTypes.length > 0 ? h(PipelineChipRow, { items: partTypes }) : null, + return h("section", { className: "pipeline-trace-timeline", "data-testid": "pipeline-step-timeline" }, + h("div", { className: "pipeline-trace-head" }, + h("div", null, + h("b", null, "OpenCode Trace"), + h("span", null, "Trace 使用 Codex Queue 统一样式展示完整 agent loop;Pipeline 旧 step/message/tool 卡片样式已废弃。"), + ), + h("div", { className: "pipeline-trace-session-head", "data-testid": "pipeline-step-timeline-session" }, + h("span", null, traceFacts.join(" / ") || "Trace"), + sessionFacts.length > 0 ? h(PipelineChipRow, { items: sessionFacts }) : null, ), - h(PipelineStepMessageCard, { rows: messageRows, role: roleTone, matched }), - tools.length > 0 ? h("div", { className: "pipeline-tool-call-strip" }, - h("div", { className: "pipeline-tool-call-title" }, h("b", null, "工具调用"), h("span", null, `${tools.length} calls`)), - h("div", { className: "pipeline-opencode-part-list" }, tools.map((part: any, index: number) => h(PipelineOpenCodePart, { key: part?.id || `tool-${index}`, part }))), - ) : null, - otherParts.length > 0 ? h("div", { className: "pipeline-opencode-part-list meta-list" }, otherParts.map((part: any, index: number) => h(PipelineOpenCodePart, { key: part?.id || `part-${index}`, part }))) : null, ), + matchedStep ? h("div", { className: "pipeline-trace-focus", "data-testid": "pipeline-trace-matched-step" }, + h("span", { className: "codex-output-channel" }, "Matched"), + h("strong", null, `Gantt selection -> ${matchedFacts.join(" / ")}`), + h("time", null, `${fmtClockValue(matchedStep?.createdAt)} -> ${fmtClockValue(matchedStep?.completedAt)}`), + ) : null, + h(TraceView, { + port: opencodeTracePort, + input: safeSteps, + className: "codex-transcript pipeline-trace", + testId: "pipeline-opencode-step-trace", + emptyText: "暂无 OpenCode Trace 输出", + keepRecentToolCalls: 3, + }), ); } @@ -1147,7 +827,7 @@ function pipelineSelectionContext(runDetails: any, selection: any): AnyRecord { function PipelineProcedureAttemptList({ procedure, matchedStepKey = "", matchedAttemptId = "" }: AnyRecord) { const attempts = asArray(procedure?.attempts); - if (attempts.length === 0) return h(EmptyState, { title: "暂无 attempt 详情", text: "后端还未返回该 procedure 的 attempt / opencodeMessages。" }); + if (attempts.length === 0) return h(EmptyState, { title: "暂无 attempt 详情", text: "当前 procedure 还没有可展示的 attempt / OpenCode Trace;若刚点击甘特线,请等待 node 详情抓取完成。" }); return attempts.map((attempt: any, attemptIndex: number) => { const messages = attempt?.opencodeMessages || {}; const steps = opencodeSteps(attempt); @@ -1175,29 +855,35 @@ 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-control 已重建并重新抓取。") : - h("section", { className: "pipeline-opencode-timeline", "data-testid": "pipeline-step-timeline" }, - h("div", { className: "pipeline-opencode-timeline-head" }, - h("div", null, - h("b", null, "OpenCode Step Timeline"), - h("span", null, "step 外层只保留时间 / 消息 / 工具调用;agent 与 model 聚合到 session 头部,统计信息默认折叠。"), - ), - h("div", { className: "pipeline-opencode-session-head", "data-testid": "pipeline-step-timeline-session" }, - h("span", null, `${steps.length} steps / ${sessionIds.length} sessions`), - sessionFacts.length > 0 ? h(PipelineChipRow, { items: sessionFacts }) : null, - ), - ), - h("div", { className: "pipeline-opencode-flow" }, steps.map((step: any, stepIndex: number) => { - const stepKey = opencodeStepKey(step, stepIndex); - return h(PipelineOpenCodeStep, { key: stepKey, step, matched: matchedStepKey === stepKey }); - })), - ), + steps.length === 0 ? h("p", { className: "muted paragraph" }, "当前 attempt 尚未返回 OpenCode Trace;请确认 D601 pipeline-control 已重建并重新抓取。") : + h(PipelineOpenCodeTrace, { steps, sessionIds, sessionFacts, matchedStepKey }), ); }); } +function pipelineNodeDetailKey(runId: string, nodeId: string): string { + return `${runId}::${nodeId}`; +} + +function pipelineFocusedNodeDetails(nodeDetails: any, runId: string, nodeId: string): AnyRecord | null { + if (!isRecord(nodeDetails)) return null; + return String(nodeDetails.runId || "") === runId && String(nodeDetails.nodeId || "") === nodeId ? nodeDetails : null; +} + +function mergeFocusedProcedure(baseProcedure: any, focusedProcedure: any): AnyRecord { + const base = isRecord(baseProcedure) ? baseProcedure : {}; + if (!isRecord(focusedProcedure)) return base; + const focusedAttempts = asArray(focusedProcedure.attempts); + const baseAttempts = asArray(base.attempts); + return { + ...base, + ...focusedProcedure, + attempts: focusedAttempts.length > 0 ? focusedAttempts : baseAttempts, + }; +} + function pipelineGanttDetailPayload(baseDetails: any, nodeDetails: any, runId: string, nodeId: string): any { - if (!isRecord(nodeDetails) || String(nodeDetails.runId || "") !== runId || String(nodeDetails.nodeId || "") !== nodeId) return baseDetails; + if (!pipelineFocusedNodeDetails(nodeDetails, runId, nodeId)) return baseDetails; const nodeProcedures = asArray(nodeDetails.procedureRuns); const base = isRecord(baseDetails) ? baseDetails : {}; return { @@ -1210,24 +896,51 @@ function pipelineGanttDetailPayload(baseDetails: any, nodeDetails: any, runId: s }; } -function PipelineGanttDetailPanel({ selection, runDetails, nodeDetails, onRaw }: AnyRecord) { +function PipelineGanttDetailPanel({ selection, runDetails, nodeDetails, nodeDetailsState, onRaw, onCollapse }: AnyRecord) { if (!selection?.mode) { return h("aside", { className: "pipeline-gantt-detail-panel empty", "data-testid": "pipeline-gantt-detail-panel" }, + h("div", { className: "pipeline-gantt-detail-head" }, + h("div", null, + h("span", { className: "panel-eyebrow" }, "Gantt Detail"), + h("h3", null, "未选择元素"), + ), + h("button", { type: "button", className: "ghost-btn mini", onClick: onCollapse, "data-testid": "pipeline-gantt-sidebar-collapse" }, "收起"), + ), h(EmptyState, { title: "选择一条执行线或一个控制点", text: "点击甘特图中的 node 执行线、prompt 点或控制点,在这里查看结构化过程和 OpenCode step。" }), ); } const runId = String(selection?.runId || ""); const selectedNodeId = String(selection?.interval?.nodeId || selection?.marker?.nodeId || ""); const baseDetails = runDetails?.runId === runId ? runDetails.details : null; - const details = pipelineGanttDetailPayload(baseDetails, nodeDetails, runId, selectedNodeId); - const loading = (String(runDetails?.runId || "") !== runId || Boolean(runDetails?.loading)) && !details; - const error = String(runDetails?.runId || "") === runId ? String(runDetails?.error || "") : ""; + const focusedNodeDetails = pipelineFocusedNodeDetails(nodeDetails, runId, selectedNodeId); + const nodeStateMatches = String(nodeDetailsState?.runId || "") === runId && String(nodeDetailsState?.nodeId || "") === selectedNodeId; + const details = pipelineGanttDetailPayload(baseDetails, focusedNodeDetails, runId, selectedNodeId); + const epochLoading = (String(runDetails?.runId || "") !== runId || Boolean(runDetails?.loading)) && !details; + const epochError = String(runDetails?.runId || "") === runId ? String(runDetails?.error || "") : ""; + const nodeError = nodeStateMatches ? String(nodeDetailsState?.error || "") : ""; const context = details ? pipelineSelectionContext(details, selection) : null; const interval = context?.interval || selection?.interval || null; const marker = context?.marker || selection?.marker || null; - const procedure = context?.procedure || (details ? findProcedureRun(details, interval) : null) || interval?.raw || {}; - const matchedAttemptId = attemptLabel(context?.attempt); - const matchedStepKey = String(context?.matchedStepKey || ""); + const selectedProcedureRunId = String(interval?.procedureRunId || marker?.procedureRunId || ""); + const focusedProcedure = focusedNodeDetails + ? findProcedureRunById(focusedNodeDetails, selectedProcedureRunId) || findProcedureRun(focusedNodeDetails, interval || { procedureRunId: selectedProcedureRunId }) + : null; + let procedure = context?.procedure || (details ? findProcedureRun(details, interval || { procedureRunId: selectedProcedureRunId }) : null) || interval?.raw || {}; + if (focusedProcedure && (procedureAttemptCount(procedure) === 0 || procedureTraceStepCount(focusedProcedure) >= procedureTraceStepCount(procedure))) { + procedure = mergeFocusedProcedure(procedure, focusedProcedure); + } + let matchedAttempt = context?.attempt || null; + let matchedStepKey = String(context?.matchedStepKey || ""); + if (!matchedAttempt && marker && procedureAttemptCount(procedure) > 0) { + matchedAttempt = nearestAttemptForMarker(procedure, marker); + matchedStepKey = String(nearestStepForMarker(matchedAttempt, Number.isFinite(Number(marker?.ms)) ? Number(marker.ms) : null).stepKey || ""); + } + const matchedAttemptId = attemptLabel(matchedAttempt); + const hasProcedureAttempts = procedureAttemptCount(procedure) > 0; + const nodeLoading = nodeStateMatches && Boolean(nodeDetailsState?.loading) && !hasProcedureAttempts; + const loading = Boolean(epochLoading || nodeLoading); + const error = [hasProcedureAttempts ? "" : epochError, nodeError].filter(Boolean).join(" / "); + const fetchedAt = nodeStateMatches && nodeDetailsState?.fetchedAt ? nodeDetailsState.fetchedAt : runDetails?.fetchedAt; const status = statusValue(procedure?.status || interval?.status || marker?.status || marker?.event); const detailTitle = selection?.mode === "event" ? (marker?.label || eventLabel(marker?.raw || marker) || "event") : (context?.nodeId || interval?.nodeId || "node"); const markerBlocks = marker ? eventTextBlocks(marker?.raw || marker) : []; @@ -1246,7 +959,10 @@ function PipelineGanttDetailPanel({ selection, runDetails, nodeDetails, onRaw }: h("span", { className: "panel-eyebrow" }, selection?.mode === "event" ? "Gantt Event Detail" : "Gantt Line Detail"), h("h3", null, detailTitle), ), - h(StatusBadge, { status }, status), + h("div", { className: "pipeline-gantt-detail-head-actions" }, + h(StatusBadge, { status }, status), + h("button", { type: "button", className: "ghost-btn mini", onClick: onCollapse, "data-testid": "pipeline-gantt-sidebar-collapse" }, "收起"), + ), ), marker ? h("article", { className: "pipeline-event-card" }, h("div", { className: "pipeline-event-card-head" }, @@ -1280,11 +996,11 @@ function PipelineGanttDetailPanel({ selection, runDetails, nodeDetails, onRaw }: { label: "started", value: fmtDate(interval?.startedAt || procedure?.startedAt) }, { label: "finished", value: fmtDate(interval?.finishedAt || procedure?.finishedAt) }, { label: "duration", value: fmtDurationMs(interval?.durationMs || procedure?.durationMs) }, - { label: "fetched", value: runDetails?.fetchedAt ? fmtClock(runDetails.fetchedAt) : "--" }, + { label: "fetched", value: fetchedAt ? fmtClock(fetchedAt) : "--" }, context?.matchedStep ? { label: "matched step", value: `Step ${context.matchedStep.index ?? context.matchedStepIndex + 1}` } : null, ] }), - loading ? h("div", { className: "form-success" }, "正在抓取 epoch 执行过程..." ) : null, - error ? h("div", { className: "form-error" }, error) : null, + loading ? h("div", { className: "form-success" }, nodeLoading ? "正在抓取该 node 的 attempt / Trace..." : "正在抓取 epoch 执行过程..." ) : null, + h(UniDeskErrorBanner, { error }), h("div", { className: "pipeline-gantt-detail-actions" }, h(RawButton, { title: `Procedure ${interval?.procedureRunId || marker?.procedureRunId || context?.nodeId || "node"}`, data: procedure, onOpen: onRaw, testId: "raw-pipeline-gantt-procedure" }), marker ? h(RawButton, { title: `Pipeline event ${marker?.id || marker?.commandId || marker?.eventId || context?.nodeId || "event"}`, data: marker?.raw || marker, onOpen: onRaw, testId: "raw-pipeline-gantt-event" }) : null, @@ -1304,23 +1020,10 @@ function GanttHeaderLabel({ value }: AnyRecord) { } async function requestJson(path: string, options: AnyRecord = {}): Promise { - const headers = new Headers(options.headers || {}); - if (options.body && !headers.has("content-type")) headers.set("content-type", "application/json"); - const response = await fetch(path, { credentials: "same-origin", ...options, headers }); - const text = await response.text(); - let body = null; - try { - body = text ? JSON.parse(text) : null; - } catch { - body = { text }; - } - if (!response.ok || body?.ok === false) { - const message = body?.error?.message || body?.error || `HTTP ${response.status}`; - const error = new Error(message); - (error as Error & { status?: number }).status = response.status; - throw error; - } - return body; + return requestUniDeskJson(path, { + invalidJsonPrefix: "Pipeline 返回了无效 JSON", + ...options, + }); } function StatusBadge({ status, children }: AnyRecord) { @@ -2190,9 +1893,13 @@ function pipelineGanttSvg(input: AnyRecord): { svg: string; width: number; heigh 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 sourceY = pipelineGanttLayoutUsesProvidedY(backendLayout) + ? (pipelineGanttNumber(arrow.sourceY ?? arrow.y1) + ?? (sourceMarker ? pipelineGanttMarkerY(sourceMarker, bounds, chartHeight, backendLayout) : pipelineGanttMarkerY(targetMarker, bounds, chartHeight, backendLayout))) + : (sourceMarker ? pipelineGanttMarkerY(sourceMarker, bounds, chartHeight, backendLayout) : pipelineGanttMarkerY(targetMarker, bounds, chartHeight, backendLayout)); + const targetY = pipelineGanttLayoutUsesProvidedY(backendLayout) + ? (pipelineGanttNumber(arrow.targetY ?? arrow.y2) ?? pipelineGanttMarkerY(targetMarker, bounds, chartHeight, backendLayout)) + : 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)); @@ -2386,9 +2093,16 @@ function pipelineRunIntervals(run: any, pipelineNodes: AnyRecord[], nowMs = Date }).sort((left: AnyRecord, right: AnyRecord) => left.startMs - right.startMs || left.endMs - right.endMs || left.nodeId.localeCompare(right.nodeId)); } -function pipelineRunTimeBounds(run: any, intervals: AnyRecord[]): AnyRecord { +function pipelineRunTimeBounds(run: any, intervals: AnyRecord[], markers: AnyRecord[] = []): AnyRecord { const starts = intervals.map((item) => Number(item.startMs)).filter(Number.isFinite); const ends = intervals.map((item) => Number(item.endMs)).filter(Number.isFinite); + for (const marker of markers) { + const markerMs = pipelineGanttNumber(marker?.eventMs ?? marker?.ms); + if (markerMs !== null) { + starts.push(markerMs); + ends.push(markerMs); + } + } const runStart = timeMs(run?.startedAt) ?? timeMs(run?.artifact?.startedAt) ?? timeMs(run?.request?.createdAt); const runEnd = timeMs(run?.finishedAt) ?? timeMs(run?.artifact?.finishedAt) ?? timeMs(run?.updatedAt); if (runStart !== null) starts.push(runStart); @@ -2454,11 +2168,6 @@ function markerPercent(ms: number, bounds: AnyRecord): number { return Math.max(0, Math.min(100, ((ms - Number(bounds.startMs)) / duration) * 100)); } -function pipelineGanttBackendLayout(gantt: any): AnyRecord | null { - const layout = isRecord(gantt?.layout) ? gantt.layout : null; - return layout && Number.isFinite(Number(layout.chartHeight)) && Number.isFinite(Number(layout.startMs)) && Number.isFinite(Number(layout.endMs)) ? layout : null; -} - function pipelineGanttNumber(value: any): number | null { const number = Number(value); return Number.isFinite(number) ? number : null; @@ -2468,14 +2177,19 @@ function pipelineGanttIntervalIsRunning(interval: AnyRecord): boolean { return runningStatus(interval?.status) && !terminalStatus(interval?.status); } +function pipelineGanttYForMs(ms: number, startMs: number, endMs: number, chartHeight: number): number { + const durationMs = Math.max(1, endMs - startMs); + const ratio = Math.max(0, Math.min(1, (ms - startMs) / durationMs)); + return Number((ratio * chartHeight).toFixed(3)); +} + 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); + return pipelineGanttYForMs(ms, startMs, endMs, chartHeight); } function pipelineGanttLiveEndMs(interval: AnyRecord, nowMs: number): number { @@ -2485,21 +2199,31 @@ function pipelineGanttLiveEndMs(interval: AnyRecord, nowMs: number): number { 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); +function pipelineGanttFrontendLayout(bounds: AnyRecord, scaleValue: number, intervals: AnyRecord[], nowMs: number): AnyRecord { + const startMs = pipelineGanttNumber(bounds?.startMs) ?? nowMs - 60_000; + const baseEndMs = pipelineGanttNumber(bounds?.endMs) ?? nowMs; + const liveEndMs = intervals.reduce((maxMs, interval) => Math.max(maxMs, pipelineGanttLiveEndMs(interval, nowMs)), baseEndMs); + const endMs = Math.max(startMs + 60_000, baseEndMs, liveEndMs); + const durationMs = Math.max(1, endMs - startMs); + const layoutBounds = { startMs, endMs, durationMs }; + const chartHeight = pipelineGanttHeight(layoutBounds, scaleValue); + const scaleConfig = pipelineGanttScaleConfig(scaleValue); + const tickCount = Math.max(5, Math.min(18, Math.round(chartHeight / 150))); + const ticks = pipelineGanttTicks(layoutBounds, tickCount).map((tick) => { + const ms = Number(tick.ms); + const y = pipelineGanttYForMs(ms, startMs, endMs, chartHeight); + return { ...tick, y, timestamp: isoFromMs(ms), offsetMs: ms - startMs }; + }); return { - ...layout, - endMs: liveEndMs, - durationMs: Math.max(1, liveEndMs - startMs), - chartHeight: Math.max(chartHeight, Math.round((liveEndMs - startMs) * pxPerMs)), - liveExtended: true, + source: "frontend-y", + startMs, + endMs, + durationMs, + chartHeight, + scale: clampPipelineGanttScale(scaleValue), + normalizedScale: Number((clampPipelineGanttScale(scaleValue) / 100).toFixed(3)), + pxPerMinute: Number(Number(scaleConfig.pxPerMinute || 0).toFixed(3)), + ticks, }; } @@ -2531,8 +2255,12 @@ function pipelineGanttFallbackY(ms: number, bounds: AnyRecord, chartHeight: numb return (markerPercent(ms, bounds) / 100) * chartHeight; } +function pipelineGanttLayoutUsesProvidedY(layout: AnyRecord | null): boolean { + return Boolean(layout && String(layout?.source || "") !== "frontend-y"); +} + function pipelineGanttItemY(item: AnyRecord, bounds: AnyRecord, chartHeight: number, backendLayout: AnyRecord | null, fields: string[]): number { - if (backendLayout) { + if (pipelineGanttLayoutUsesProvidedY(backendLayout)) { for (const field of fields) { const y = pipelineGanttNumber(item?.[field]); if (y !== null) return y; @@ -2547,7 +2275,7 @@ function pipelineGanttIntervalTop(interval: AnyRecord, bounds: AnyRecord, chartH } function pipelineGanttIntervalBottom(interval: AnyRecord, bounds: AnyRecord, chartHeight: number, backendLayout: AnyRecord | null): number { - if (backendLayout) { + if (pipelineGanttLayoutUsesProvidedY(backendLayout)) { const endY = pipelineGanttNumber(interval?.y2 ?? interval?.endY); if (endY !== null) return endY; } @@ -2556,11 +2284,12 @@ function pipelineGanttIntervalBottom(interval: AnyRecord, bounds: AnyRecord, cha } function pipelineGanttIntervalHeight(interval: AnyRecord, bounds: AnyRecord, chartHeight: number, backendLayout: AnyRecord | null): number { - if (backendLayout) { + if (pipelineGanttLayoutUsesProvidedY(backendLayout)) { const height = pipelineGanttNumber(interval?.height); if (height !== null) return Math.max(1, height); } - return Math.max(10, pipelineGanttIntervalBottom(interval, bounds, chartHeight, backendLayout) - pipelineGanttIntervalTop(interval, bounds, chartHeight, backendLayout)); + const minHeight = interval?.live ? 24 : 10; + return Math.max(minHeight, pipelineGanttIntervalBottom(interval, bounds, chartHeight, backendLayout) - pipelineGanttIntervalTop(interval, bounds, chartHeight, backendLayout)); } function pipelineGanttMarkerY(marker: AnyRecord, bounds: AnyRecord, chartHeight: number, backendLayout: AnyRecord | null): number { @@ -2568,7 +2297,7 @@ function pipelineGanttMarkerY(marker: AnyRecord, bounds: AnyRecord, chartHeight: } function pipelineGanttTickY(tick: AnyRecord, bounds: AnyRecord, chartHeight: number, backendLayout: AnyRecord | null): number { - if (backendLayout) { + if (pipelineGanttLayoutUsesProvidedY(backendLayout) || String(backendLayout?.source || "") === "frontend-y") { const y = pipelineGanttNumber(tick?.y); if (y !== null) return y; } @@ -2578,37 +2307,6 @@ function pipelineGanttTickY(tick: AnyRecord, bounds: AnyRecord, chartHeight: num return pipelineGanttFallbackY(ms, bounds, chartHeight); } -function pipelineGanttNormalizeBackendMarker(marker: AnyRecord): AnyRecord { - const kind = String(marker?.kind || "event"); - const event = String(marker?.event || ""); - const timestampIso = String(marker?.timestampIso || marker?.timestamp || isoFromMs(pipelineGanttNumber(marker?.eventMs ?? marker?.ms))); - const actionRecord = { ...marker, event, action: marker?.action || marker?.requestedAction }; - const label = marker?.label || (kind === "prompt" - ? pipelinePromptMarkerLabel(actionRecord, event) - : kind === "control-source" - ? `${controlActionLabel(actionRecord)} 发起` - : kind === "control-target" - ? controlTargetLabel(actionRecord, String(marker?.nodeId || marker?.targetNodeId || "")) - : eventLabel(actionRecord)); - const tone = marker?.tone || (kind === "prompt" - ? pipelinePromptMarkerTone(actionRecord, event) - : kind === "control-source" - ? controlSourceTone(actionRecord) - : controlTargetTone(actionRecord)); - const status = marker?.status || (event.startsWith("control-command-") ? event.replace(/^control-command-/u, "") : ""); - return { - ...marker, - kind, - tone, - status, - label, - timestampIso, - renderedTimestampIso: marker?.renderedTimestampIso || isoFromMs(pipelineGanttNumber(marker?.renderedMs ?? marker?.ms)), - snapped: Number(marker?.ms || 0) !== Number(marker?.eventMs ?? marker?.ms ?? 0), - raw: marker?.raw || marker, - }; -} - 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 ""; @@ -2648,13 +2346,6 @@ function pipelineGanttAddObservationArrows(markers: AnyRecord[], arrows: AnyReco return { markers, arrows: nextArrows }; } -function pipelineBackendGanttSignals(details: any): AnyRecord { - const gantt = isRecord(details?.gantt) ? details.gantt : {}; - 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 || ""); @@ -3182,53 +2873,94 @@ function PipelineOaEventFlowPanel({ diagnostics, onRaw }: AnyRecord) { ); } -function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes, pipelineEdges, runDetails, nodeDetails, ganttScale = pipelineDefaultGanttScale, onGanttScaleChange, onRunChange, onIntervalSelect, onMarkerSelect, selection, onRaw }: AnyRecord) { +function PipelineMinimaxQuotaPanel({ quota, onRaw }: AnyRecord) { + const summary = isRecord(quota?.summary) ? quota.summary : {}; + const target = isRecord(quota?.target) ? quota.target : {}; + const cache = isRecord(quota?.cache) ? quota.cache : {}; + const ok = quota?.ok === true; + const modelId = String(quota?.modelId || summary.modelName || target.modelName || "MiniMax-M2.7"); + const total = summary.totalCount ?? target.currentIntervalTotalCount; + const used = summary.usageCount ?? target.currentIntervalUsageCount; + const remaining = summary.remainingCount ?? target.currentIntervalRemainingCount; + const remainingRatio = summary.remainingRatio ?? ( + Number.isFinite(Number(total)) && Number(total) > 0 && Number.isFinite(Number(remaining)) ? Number(remaining) / Number(total) : undefined + ); + const usageRatio = summary.usageRatio ?? ( + Number.isFinite(Number(total)) && Number(total) > 0 && Number.isFinite(Number(used)) ? Number(used) / Number(total) : undefined + ); + const resetAt = summary.resetAt || target.endAt; + const remainsMs = summary.remainsMs ?? target.remainsMs; + const remainingNumber = Number(remaining); + const tone = !ok || (Number.isFinite(remainingNumber) && remainingNumber <= 0) ? "warn" : "ok"; + const facts = [ + ok ? `endpoint ${quota?.endpoint || "--"}` : "quota unavailable", + `fetched ${fmtClockValue(quota?.fetchedAt)}`, + cache.hit ? `cache ${fmtDurationMs(cache.ageMs)}` : "live quota", + ]; + return h("div", { className: "pipeline-minimax-quota-panel", "data-testid": "pipeline-minimax-quota-panel" }, + h("div", { className: "metric-grid compact" }, + h(MetricCard, { label: "MiniMax", value: ok ? modelId : "--", hint: quota?.modelComponent || quota?.error || "model/minimax-m27", tone }), + h(MetricCard, { label: "当前窗口", value: `${fmtCount(used)}/${fmtCount(total)}`, hint: `已用 ${fmtPercent(usageRatio)}`, tone }), + h(MetricCard, { label: "剩余额度", value: fmtCount(remaining), hint: `剩余 ${fmtPercent(remainingRatio)}`, tone }), + h(MetricCard, { label: "重置时间", value: fmtClockValue(resetAt), hint: remainsMs !== undefined ? `约 ${fmtDurationMs(remainsMs)}` : fmtDate(resetAt), tone }), + ), + h(PipelineChipRow, { items: facts }), + ok ? h("p", { className: "muted paragraph" }, `MiniMax 限额来自 D601 Pipeline 后端实时查询;当前模型匹配 ${summary.modelName || target.modelName || modelId}。`) + : h(UniDeskErrorBanner, { error: quota?.error || "MiniMax 限额查询失败" }), + quota ? h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "Pipeline MiniMax Quota", data: quota, onOpen: onRaw, testId: "raw-pipeline-minimax-quota" })) : null, + ); +} + +function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes, pipelineEdges, runDetails, nodeDetails, nodeDetailsState, ganttScale = pipelineDefaultGanttScale, onGanttScaleChange, onRunChange, onIntervalSelect, onMarkerSelect, selection, detailOpen, onDetailOpenChange, 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 ganttDetailOpen = Boolean(detailOpen); + const setGanttDetailOpen = (open: boolean) => { + if (typeof onDetailOpenChange === "function") onDetailOpenChange(open); + }; const timeScale = clampPipelineGanttScale(ganttScale ?? pipelineDefaultGanttScale); - const fallbackIntervals = pipelineRunIntervals(activeRun, pipelineNodes, liveNowMs); const runDetailPayload = String(runDetails?.runId || "") === activeRunId ? runDetails?.details : null; - const backendGantt = isRecord(runDetailPayload?.gantt) ? runDetailPayload.gantt : null; - const baseBackendLayout = pipelineGanttBackendLayout(backendGantt); - const hasBackendLayout = Boolean(baseBackendLayout); - const rawIntervals = hasBackendLayout - ? asArray(backendGantt?.intervals).filter(isRecord).map((interval: AnyRecord) => ({ ...interval, runId: activeRunId })) - : fallbackIntervals; + const timelineRun = runDetailPayload + ? { + ...(isRecord(activeRun) ? activeRun : {}), + ...(isRecord(runDetailPayload) ? runDetailPayload : {}), + runId: activeRunId, + procedureRuns: asArray(runDetailPayload?.procedureRuns).length > 0 ? runDetailPayload.procedureRuns : activeRun?.procedureRuns, + } + : activeRun; + const rawIntervals = pipelineRunIntervals(timelineRun, pipelineNodes, liveNowMs); + const ganttSignals = runDetailPayload ? pipelineRunGanttSignals(runDetailPayload, timelineRun) : { markers: [], arrows: [] }; + const allMarkers = asArray(ganttSignals.markers); + const initialBounds = pipelineRunTimeBounds(timelineRun, rawIntervals, allMarkers); + const ganttLayout = pipelineGanttFrontendLayout(initialBounds, timeScale, rawIntervals, liveNowMs); + const layoutSource = String(ganttLayout.source || "frontend-y"); + const intervals = rawIntervals.map((interval: AnyRecord) => pipelineGanttNormalizeLiveInterval(interval, ganttLayout, liveNowMs)); + const bounds = { + startMs: Number(ganttLayout.startMs), + endMs: Number(ganttLayout.endMs), + durationMs: Math.max(1, Number(ganttLayout.durationMs ?? Number(ganttLayout.endMs) - Number(ganttLayout.startMs))), + }; + const baseTimeScaleConfig = pipelineGanttScaleConfig(timeScale); + const timeScaleConfig: AnyRecord = { + ...baseTimeScaleConfig, + pxPerMinute: Number(ganttLayout.pxPerMinute ?? baseTimeScaleConfig.pxPerMinute), + }; + const chartHeight = Math.round(Number(ganttLayout.chartHeight || 360)); 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), - durationMs: Math.max(1, Number(backendLayout?.durationMs ?? Number(backendLayout?.endMs) - Number(backendLayout?.startMs))), - } : pipelineRunTimeBounds(activeRun, intervals); - const backendScaleValue = hasBackendLayout ? Number(backendLayout?.scale ?? timeScale) : timeScale; - const fallbackScaleConfig = pipelineGanttScaleConfig(backendScaleValue); - const timeScaleConfig = hasBackendLayout ? { - ...fallbackScaleConfig, - pxPerMinute: Number(backendLayout?.pxPerMinute ?? fallbackScaleConfig.pxPerMinute), - } : pipelineGanttScaleConfig(timeScale); - const chartHeight = hasBackendLayout ? Math.round(Number(backendLayout?.chartHeight || 360)) : pipelineGanttHeight(bounds, timeScale); - const ganttSignals = hasBackendLayout ? pipelineBackendGanttSignals(runDetailPayload) : runDetailPayload ? pipelineRunGanttSignals(runDetailPayload, activeRun) : { markers: [], arrows: [] }; - const allMarkers = asArray(ganttSignals.markers); const graphNodeOrder = pipelineGraphNodeOrder(activePipeline, pipelineNodes, Array.isArray(pipelineEdges) ? pipelineEdges : []); const configuredNodeIds = pipelineNodes.map((node: any) => String(node?.id || "")).filter(Boolean); - const layoutNodeIds = hasBackendLayout ? asArray(backendLayout?.nodeOrder).map((nodeId: any) => String(nodeId || "")).filter(Boolean) : []; const intervalNodeIds = intervals.map((interval: AnyRecord) => String(interval.nodeId || "")).filter(Boolean); const markerNodeIds = allMarkers.map((marker: AnyRecord) => String(marker.nodeId || "")).filter(Boolean); const allNodeIds = Array.from(new Set([ ...graphNodeOrder, - ...layoutNodeIds, ...configuredNodeIds, ...intervalNodeIds, ...markerNodeIds, @@ -3236,26 +2968,19 @@ function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes, const defaultVisibleRange = { startY: 0, endY: chartHeight, startMs: Number(bounds.startMs), endMs: Number(bounds.endMs) }; const safeRange = Number(visibleRange?.endY || 0) > 0 ? visibleRange : defaultVisibleRange; const intervalIsVisible = (interval: AnyRecord): boolean => { - if (hasBackendLayout) { - return pipelineGanttIntervalTop(interval, bounds, chartHeight, backendLayout) <= Number(safeRange.endY) - && pipelineGanttIntervalBottom(interval, bounds, chartHeight, backendLayout) >= Number(safeRange.startY); - } - return intervalOverlaps(interval, safeRange); + return pipelineGanttIntervalTop(interval, bounds, chartHeight, ganttLayout) <= Number(safeRange.endY) + && pipelineGanttIntervalBottom(interval, bounds, chartHeight, ganttLayout) >= Number(safeRange.startY); }; const markerIsVisible = (marker: AnyRecord): boolean => { - if (hasBackendLayout) { - const y = pipelineGanttMarkerY(marker, bounds, chartHeight, backendLayout); - return y >= Number(safeRange.startY) && y <= Number(safeRange.endY); - } - return Number(marker.ms) >= Number(safeRange.startMs) && Number(marker.ms) <= Number(safeRange.endMs); + const y = pipelineGanttMarkerY(marker, bounds, chartHeight, ganttLayout); + return y >= Number(safeRange.startY) && y <= Number(safeRange.endY); }; const activeNodeIds = new Set(allNodeIds.filter((nodeId) => intervals.some((interval: AnyRecord) => interval.nodeId === nodeId && intervalIsVisible(interval)) || allMarkers.some((marker: AnyRecord) => marker.nodeId === nodeId && markerIsVisible(marker)))); const visibleNodeIds = autoHideIdle ? allNodeIds.filter((nodeId) => activeNodeIds.has(nodeId)) : allNodeIds; const gridTemplateColumns = `${pipelineGanttTimeAxisWidth}px ${visibleNodeIds.length > 0 ? visibleNodeIds.map(() => `${pipelineGanttNodeColumnWidth}px`).join(" ") : "minmax(160px, 1fr)"}`; - const backendTicks = hasBackendLayout ? asArray(backendLayout?.ticks).filter(isRecord) : []; - const ticks = backendTicks.length > 0 ? backendTicks : pipelineGanttTicks(bounds, Math.max(5, Math.min(18, Math.round(chartHeight / 150)))); + const ticks = asArray(ganttLayout.ticks).filter(isRecord); const selectedIntervalKey = String(selection?.mode === "interval" ? selection?.interval?.procedureRunId || "" : ""); const selectedMarkerKey = String(selection?.mode === "event" ? selection?.marker?.id || "" : ""); const updateVisibleRange = () => { @@ -3268,13 +2993,11 @@ function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes, const visiblePx = Math.max(120, element.clientHeight - pipelineGanttHeaderHeight); const bottomY = Math.min(chartHeight, topY + visiblePx); const nextRange: AnyRecord = { startY: topY, endY: bottomY, startMs: Number(bounds.startMs), endMs: Number(bounds.endMs) }; - if (!hasBackendLayout) { - const startRatio = Math.max(0, Math.min(1, topY / chartHeight)); - const endRatio = Math.max(startRatio, Math.min(1, bottomY / chartHeight)); - const duration = Math.max(1, Number(bounds.endMs) - Number(bounds.startMs)); - nextRange.startMs = Number(bounds.startMs) + duration * startRatio; - nextRange.endMs = Number(bounds.startMs) + duration * endRatio; - } + const startRatio = Math.max(0, Math.min(1, topY / chartHeight)); + const endRatio = Math.max(startRatio, Math.min(1, bottomY / chartHeight)); + const duration = Math.max(1, Number(bounds.endMs) - Number(bounds.startMs)); + nextRange.startMs = Number(bounds.startMs) + duration * startRatio; + nextRange.endMs = Number(bounds.startMs) + duration * endRatio; setVisibleRange(nextRange); }; useEffect(() => { @@ -3287,7 +3010,7 @@ function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes, element?.removeEventListener("scroll", updateVisibleRange); window.removeEventListener("resize", updateVisibleRange); }; - }, [activeRunId, bounds.startMs, bounds.endMs, chartHeight, hasBackendLayout]); + }, [activeRunId, bounds.startMs, bounds.endMs, chartHeight]); const hiddenCount = Math.max(0, allNodeIds.length - visibleNodeIds.length); const visibleMarkerIds = new Set(allMarkers.filter((marker: AnyRecord) => visibleNodeIds.includes(String(marker.nodeId || "")) && markerIsVisible(marker)).map((marker: AnyRecord) => String(marker.id))); @@ -3321,9 +3044,9 @@ function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes, ticks, bounds, chartHeight, - backendLayout, + backendLayout: ganttLayout, }); - const diagnostics = isRecord(backendGantt?.diagnostics) ? backendGantt.diagnostics : null; + const diagnostics = isRecord(runDetailPayload?.gantt?.diagnostics) ? runDetailPayload.gantt.diagnostics : null; return h(Panel, { title: "Epoch 甘特图", eyebrow: `${activePipeline?.id || "pipeline"} / ${epochs.length} epochs`, @@ -3377,27 +3100,57 @@ function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes, !activeRun ? h(EmptyState, { title: "暂无 Epoch", text: "当前 pipeline 还没有完整运行记录。" }) : intervals.length === 0 ? h(EmptyState, { title: "暂无时间区间", text: "等待 D601 Pipeline backend 在 procedure summary 中返回 startedAt / finishedAt。" }) : h("div", { className: "pipeline-gantt-wrap" }, - h("div", { className: "pipeline-gantt-detail-layout" }, + h("div", { + className: `pipeline-gantt-detail-layout ${ganttDetailOpen ? "detail-open" : "detail-collapsed"}`, + "data-testid": "pipeline-gantt-detail-layout", + "data-sidebar-open": ganttDetailOpen ? "true" : "false", + }, h("div", { className: "pipeline-gantt-main" }, - 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} / ${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-main-head" }, + 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} / ${pipelineDisplayPxPerMinute(timeScaleConfig.pxPerMinute)} px/min`), + h("span", null, `layout ${layoutSource}`), + 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, + ), + !ganttDetailOpen ? h("button", { + type: "button", + className: "pipeline-sidecar-tab right", + disabled: !selection?.mode, + onClick: () => setGanttDetailOpen(true), + "data-testid": "pipeline-gantt-sidebar-toggle", + }, selection?.mode ? "展开详情" : "点击甘特图元素展开详情") : null, ), - 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-viewport", + ref: viewportRef, + "data-testid": "pipeline-epoch-gantt", + "data-pipeline-id": activePipeline?.id || "", + "data-run-id": activeRunId, + "data-layout-source": layoutSource, + "data-start-ms": String(bounds.startMs), + "data-end-ms": String(bounds.endMs), + "data-chart-height": String(chartHeight), + }, 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" }, "当前时间窗无工作节点") : visibleNodeIds.map((nodeId) => h("div", { key: `head-${nodeId}`, className: "pipeline-gantt-head node", title: nodeId, "data-testid": "pipeline-gantt-head-node", "data-node-id": nodeId }, h(GanttHeaderLabel, { value: nodeId }))), h("div", { className: "pipeline-gantt-time-axis", style: { height: `${chartHeight}px` } }, ticks.map((tick: AnyRecord) => { - const tickY = pipelineGanttTickY(tick, bounds, chartHeight, backendLayout); - return h("div", { key: `tick-${tick.ms}-${tickY}`, className: "pipeline-gantt-tick", style: { top: `${tickY}px` } }, + const tickY = pipelineGanttTickY(tick, bounds, chartHeight, ganttLayout); + return h("div", { + key: `tick-${tick.ms}-${tickY}`, + className: "pipeline-gantt-tick", + style: { top: `${tickY}px` }, + "data-testid": "pipeline-gantt-tick", + "data-ms": String(tick.ms), + "data-y": String(tickY), + }, h("b", null, fmtDate(tick.ms)), h("span", null, `+${fmtDurationMs(Number(tick.offsetMs ?? Number(tick.ms) - Number(bounds.startMs)))}`), ); @@ -3439,9 +3192,8 @@ function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes, 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) - ?? (sourceMarker ? pipelineGanttMarkerY(sourceMarker, bounds, chartHeight, backendLayout) : pipelineGanttMarkerY(targetMarker, bounds, chartHeight, backendLayout)); - const targetY = pipelineGanttNumber(arrow.targetY ?? arrow.y2) ?? pipelineGanttMarkerY(targetMarker, bounds, chartHeight, backendLayout); + const sourceY = sourceMarker ? pipelineGanttMarkerY(sourceMarker, bounds, chartHeight, ganttLayout) : pipelineGanttMarkerY(targetMarker, bounds, chartHeight, ganttLayout); + const targetY = pipelineGanttMarkerY(targetMarker, bounds, chartHeight, ganttLayout); return h("path", { key: arrow.id, className: `pipeline-gantt-arrow ${String(arrow.sourceKind || "").toLowerCase()} ${String(arrow.status || "").toLowerCase()} ${String(arrow.action || "").toLowerCase()}`, @@ -3452,6 +3204,8 @@ function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes, "data-target-node-id": String(arrow.targetNodeId || ""), "data-target-marker-id": String(arrow.targetMarkerId || ""), "data-action": String(arrow.action || ""), + "data-source-y": String(sourceY), + "data-target-y": String(targetY), }); }), ) : null, @@ -3461,8 +3215,9 @@ function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes, const nodeMarkers = allMarkers.filter((marker: AnyRecord) => String(marker.nodeId || "") === nodeId); return h("div", { key: `col-${nodeId}`, className: "pipeline-gantt-node-col", style: { height: `${chartHeight}px` } }, nodeIntervals.map((interval: AnyRecord) => { - const top = pipelineGanttIntervalTop(interval, bounds, chartHeight, backendLayout); - const height = pipelineGanttIntervalHeight(interval, bounds, chartHeight, backendLayout); + const top = pipelineGanttIntervalTop(interval, bounds, chartHeight, ganttLayout); + const bottom = pipelineGanttIntervalBottom(interval, bounds, chartHeight, ganttLayout); + const height = pipelineGanttIntervalHeight(interval, bounds, chartHeight, ganttLayout); const intervalKey = String(interval.procedureRunId || `${nodeId}-${interval.startMs}`); return h("button", { key: intervalKey, @@ -3476,6 +3231,11 @@ function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes, "data-procedure-run-id": String(interval.procedureRunId || ""), "data-status": String(interval.status || ""), "data-live": interval.live ? "true" : "false", + "data-start-ms": String(interval.startMs || ""), + "data-end-ms": String(interval.endMs || ""), + "data-y1": String(top), + "data-y2": String(bottom), + "data-natural-height": String(Math.max(0, bottom - top)), }, h("strong", null, interval.status || "working"), h("span", null, fmtDurationMs(interval.durationMs)), @@ -3485,18 +3245,20 @@ function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes, key: marker.id, type: "button", className: `pipeline-gantt-marker ${marker.kind} ${marker.tone || ""} ${marker.status || ""} ${selectedMarkerKey === String(marker.id) ? "selected" : ""}`, - style: { top: `${pipelineGanttMarkerY(marker, bounds, chartHeight, backendLayout)}px` }, + style: { top: `${pipelineGanttMarkerY(marker, bounds, chartHeight, ganttLayout)}px` }, 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 || ""), + "data-ms": String(marker.ms ?? marker.eventMs ?? ""), + "data-y": String(pipelineGanttMarkerY(marker, bounds, chartHeight, ganttLayout)), })), ); }), ), ), ), - h(PipelineGanttDetailPanel, { selection, runDetails, nodeDetails, onRaw }), + ganttDetailOpen ? h(PipelineGanttDetailPanel, { selection, runDetails, nodeDetails, nodeDetailsState, onRaw, onCollapse: () => setGanttDetailOpen(false) }) : null, ), ), ); @@ -3541,7 +3303,7 @@ function pipelineProxyPath(apiBaseUrl: string, path: string): string { return `${apiBaseUrl}/microservices/pipeline/proxy${path}`; } -function PipelineNodeControlPanel({ activeRun, pipelineRuns, selectedRunId, onRunChange, selectedNodeId, selectedNodeConfig, selectedNodeRuntime, control, onControlChange, onFetch, onAction, onRaw }: AnyRecord) { +function PipelineNodeControlPanel({ activeRun, pipelineRuns, selectedRunId, onRunChange, selectedNodeId, selectedNodeConfig, selectedNodeRuntime, control, onControlChange, onFetch, onAction, onRaw, onCollapse }: AnyRecord) { const runId = String(activeRun?.runId || ""); const status = String(selectedNodeRuntime?.status || "pending"); const disabled = !runId || !selectedNodeId || control.loading || Boolean(control.actionLoading); @@ -3553,7 +3315,10 @@ function PipelineNodeControlPanel({ activeRun, pipelineRuns, selectedRunId, onRu h("p", { className: "panel-eyebrow" }, "Manual Node Control"), h("h3", null, selectedNodeId || "点击控制图中的 node"), ), - selectedNodeId ? h(StatusBadge, { status }, status) : h(StatusBadge, { status: "pending" }, "idle"), + h("div", { className: "pipeline-node-control-head-actions" }, + selectedNodeId ? h(StatusBadge, { status }, status) : h(StatusBadge, { status: "pending" }, "idle"), + h("button", { type: "button", className: "ghost-btn mini", onClick: onCollapse, "data-testid": "pipeline-node-sidebar-collapse" }, "收起"), + ), ), h("div", { className: "pipeline-control-runbar" }, h("label", null, @@ -3581,7 +3346,7 @@ function PipelineNodeControlPanel({ activeRun, pipelineRuns, selectedRunId, onRu h("span", null, h("b", null, "updated"), fmtDate(activeRun?.updatedAt)), ), !selectedNodeId ? h(EmptyState, { title: "未选择 node", text: "点击 React Flow 控制图中的任意 node 后,可抓取执行过程、追加 prompt、下发引导、增量修改、审核通过或重做。" }) : null, - control.error ? h("div", { className: "form-error wide" }, control.error) : null, + h(UniDeskErrorBanner, { error: control.error, wide: true }), control.message ? h("div", { className: "form-success wide" }, control.message) : null, h("div", { className: "pipeline-control-actions" }, h("label", null, @@ -3655,18 +3420,24 @@ 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, oaDiagnostics: null, refreshedAt: null }); + const [state, setState] = useState({ loading: false, error: "", health: null, snapshot: null, oaDiagnostics: null, minimaxQuota: null, refreshedAt: null }); const [selectedPipelineId, setSelectedPipelineId] = useState(""); const [selectedRunId, setSelectedRunId] = useState(""); const [selectedNodeId, setSelectedNodeId] = useState(""); const [nodeControl, setNodeControl] = useState(pipelineNodeControlState()); + const [ganttNodeDetails, setGanttNodeDetails] = useState({}); const [ganttSelection, setGanttSelection] = useState(pipelineGanttSelectionState()); const [runDetails, setRunDetails] = useState(pipelineRunDetailsState()); const [ganttScale, setGanttScale] = useState(pipelineDefaultGanttScale); + const [nodeControlOpen, setNodeControlOpen] = useState(false); + const [ganttDetailOpen, setGanttDetailOpen] = useState(false); const loadRequestRef = useRef(0); const loadInFlightRef = useRef(false); const runDetailsRequestRef = useRef(0); const runDetailsInFlightRef = useRef(""); + const ganttNodeDetailsRequestRef = useRef({}); + const selectedNodeIdRef = useRef(""); + const activeRunIdRef = useRef(""); async function load(options: AnyRecord = {}): Promise { const silent = options.silent === true; @@ -3678,14 +3449,16 @@ 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, oaDiagnostics] = await Promise.all([ + const [snapshot, oaDiagnostics, minimaxQuota] = 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") })), + requestJson(`${apiBaseUrl}/microservices/pipeline/proxy/api/model-quota/minimax?_=${Date.now()}`, { cache: "no-store" }) + .catch((error: unknown) => ({ ok: false, error: errorMessage(error, "MiniMax quota failed") })), ]); if (requestId !== loadRequestRef.current) return; const health = { ok: snapshot?.ok !== false, service: "pipeline-v2-control snapshot" }; - setState({ loading: false, error: "", health, snapshot, oaDiagnostics, refreshedAt: new Date() }); + setState({ loading: false, error: "", health, snapshot, oaDiagnostics, minimaxQuota, refreshedAt: new Date() }); } catch (err) { if (requestId !== loadRequestRef.current) return; setState((prev: any) => ({ ...prev, loading: false, error: errorMessage(err, "Pipeline 加载失败") })); @@ -3708,6 +3481,7 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR const backend = microserviceBackend(service); const snapshot = state.snapshot || {}; const oaDiagnostics = state.oaDiagnostics || null; + const minimaxQuota = state.minimaxQuota || null; const { components, pipelines, runs } = pipelineSnapshotArrays(snapshot); const latestPipelineId = String(runs[0]?.pipelineId || ""); const defaultPipeline = (latestPipelineId ? pipelines.find((pipeline: any) => String(pipeline.id || "") === latestPipelineId) : null) || pipelines[0] || {}; @@ -3740,9 +3514,35 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR flow: pipelineFlowElements(pipeline, run, components), }; }); + const ganttSelectionRunId = String(ganttSelection?.runId || activeRunId || ""); + const ganttSelectionNodeId = String(ganttSelection?.interval?.nodeId || ganttSelection?.marker?.nodeId || ""); + const ganttNodeDetailState = ganttSelectionRunId && ganttSelectionNodeId + ? (ganttNodeDetails as AnyRecord)[pipelineNodeDetailKey(ganttSelectionRunId, ganttSelectionNodeId)] || null + : null; + const exactNodeControlDetails = pipelineFocusedNodeDetails(nodeControl.details, ganttSelectionRunId, ganttSelectionNodeId); + const focusedGanttNodeDetails = pipelineFocusedNodeDetails(ganttNodeDetailState?.details, ganttSelectionRunId, ganttSelectionNodeId) || exactNodeControlDetails; + const focusedGanttNodeState = ganttSelectionRunId && ganttSelectionNodeId + ? { + ...(isRecord(ganttNodeDetailState) ? ganttNodeDetailState : {}), + runId: ganttSelectionRunId, + nodeId: ganttSelectionNodeId, + details: focusedGanttNodeDetails, + loading: Boolean(ganttNodeDetailState?.loading) || (!focusedGanttNodeDetails && Boolean(nodeControl.loading) && selectedNodeId === ganttSelectionNodeId), + error: String(ganttNodeDetailState?.error || ""), + fetchedAt: ganttNodeDetailState?.fetchedAt || (exactNodeControlDetails ? nodeControl.fetchedAt : null), + } + : null; const pipelineRunIds = pipelineRuns.map((run: any) => String(run?.runId || "")).filter(Boolean).join("|"); const pipelineNodeIds = pipelineNodes.map((node: any) => String(node?.id || "")).filter(Boolean).join("|"); + useEffect(() => { + selectedNodeIdRef.current = selectedNodeId; + }, [selectedNodeId]); + + useEffect(() => { + activeRunIdRef.current = activeRunId; + }, [activeRunId]); + useEffect(() => { if (!selectedRunId || pipelineRunIds.split("|").includes(selectedRunId)) return; setSelectedRunId(""); @@ -3753,15 +3553,25 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR setSelectedNodeId(""); setNodeControl(pipelineNodeControlState()); setGanttSelection(pipelineGanttSelectionState()); + setNodeControlOpen(false); + setGanttDetailOpen(false); }, [selectedNodeId, pipelineNodeIds]); + useEffect(() => { + if (!selectedNodeId) setNodeControlOpen(false); + }, [selectedNodeId]); + + useEffect(() => { + if (!ganttSelection.mode) setGanttDetailOpen(false); + }, [ganttSelection.mode]); + async function fetchRunDetails(runId = activeRunId, options: AnyRecord = {}): Promise { if (!runId) { setRunDetails(pipelineRunDetailsState()); return; } const scale = clampPipelineGanttScale(options.scale ?? ganttScale ?? pipelineDefaultGanttScale); - const requestKey = `${runId}:${scale}`; + const requestKey = `${runId}:timeline`; if (runDetailsInFlightRef.current === requestKey) return; runDetailsInFlightRef.current = requestKey; const silent = options.silent === true; @@ -3777,7 +3587,7 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR })); try { 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/node-control/runs/${encodeURIComponent(runId)}?tail=160&view=timeline`)}&_=${Date.now()}`, { cache: "no-store", strictJson: true }), 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; @@ -3797,6 +3607,46 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR } } + function setGanttNodeDetailEntry(runId: string, nodeId: string, patch: AnyRecord): void { + const key = pipelineNodeDetailKey(runId, nodeId); + setGanttNodeDetails((prev: AnyRecord) => { + const next: AnyRecord = { + ...prev, + [key]: { + ...(isRecord(prev?.[key]) ? prev[key] : {}), + runId, + nodeId, + ...patch, + }, + }; + const keys = Object.keys(next); + if (keys.length > 32) { + for (const staleKey of keys.slice(0, keys.length - 32)) delete next[staleKey]; + } + return next; + }); + } + + async function fetchGanttNodeDetails(runId: string, nodeId: string): Promise { + if (!runId || !nodeId) return; + const key = pipelineNodeDetailKey(runId, nodeId); + const requestId = Number(ganttNodeDetailsRequestRef.current?.[key] || 0) + 1; + ganttNodeDetailsRequestRef.current = { ...ganttNodeDetailsRequestRef.current, [key]: requestId }; + setGanttNodeDetailEntry(runId, nodeId, { loading: true, error: "" }); + try { + const details = await requestJson(pipelineProxyPath(apiBaseUrl, `/api/node-control/runs/${encodeURIComponent(runId)}/nodes/${encodeURIComponent(nodeId)}?tail=160`), { cache: "no-store", strictJson: true }); + if (Number(ganttNodeDetailsRequestRef.current?.[key] || 0) !== requestId) return; + const fetchedAt = new Date(); + setGanttNodeDetailEntry(runId, nodeId, { loading: false, details, fetchedAt, error: "" }); + if (selectedNodeIdRef.current === nodeId && activeRunIdRef.current === runId) { + setNodeControl((prev: AnyRecord) => ({ ...prev, loading: false, details, fetchedAt, error: "" })); + } + } catch (err) { + if (Number(ganttNodeDetailsRequestRef.current?.[key] || 0) !== requestId) return; + setGanttNodeDetailEntry(runId, nodeId, { loading: false, error: errorMessage(err, "抓取 Gantt node 详情失败") }); + } + } + useEffect(() => { if (!activeRunId) { setRunDetails(pipelineRunDetailsState()); @@ -3807,7 +3657,7 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR void fetchRunDetails(activeRunId, { silent: true }); }, pipelineAutoRefreshMs); return () => window.clearInterval(timer); - }, [activeRunId, apiBaseUrl, ganttScale]); + }, [activeRunId, apiBaseUrl]); async function fetchNodeDetails(runId = activeRunId, nodeId = selectedNodeId): Promise { if (!runId || !nodeId) { @@ -3816,8 +3666,10 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR } setNodeControl((prev: AnyRecord) => ({ ...prev, loading: true, error: "", message: "" })); try { - const details = await requestJson(pipelineProxyPath(apiBaseUrl, `/api/node-control/runs/${encodeURIComponent(runId)}/nodes/${encodeURIComponent(nodeId)}?tail=160`)); - setNodeControl((prev: AnyRecord) => ({ ...prev, loading: false, details, fetchedAt: new Date(), error: "" })); + const details = await requestJson(pipelineProxyPath(apiBaseUrl, `/api/node-control/runs/${encodeURIComponent(runId)}/nodes/${encodeURIComponent(nodeId)}?tail=160`), { cache: "no-store", strictJson: true }); + const fetchedAt = new Date(); + setNodeControl((prev: AnyRecord) => ({ ...prev, loading: false, details, fetchedAt, error: "" })); + setGanttNodeDetailEntry(runId, nodeId, { loading: false, details, fetchedAt, error: "" }); } catch (err) { setNodeControl((prev: AnyRecord) => ({ ...prev, loading: false, error: errorMessage(err, "抓取 node 执行过程失败") })); } @@ -3827,25 +3679,27 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR const runId = String(interval?.runId || activeRunId || ""); const nodeId = String(interval?.nodeId || ""); setGanttSelection({ mode: "interval", runId, interval, marker: null }); + setGanttDetailOpen(true); if (!runId || !nodeId) return; if (runId !== activeRunId) setSelectedRunId(runId); setSelectedNodeId(nodeId); setNodeControl(pipelineNodeControlState()); void fetchRunDetails(runId, { silent: true }); - await fetchNodeDetails(runId, nodeId); + void fetchGanttNodeDetails(runId, nodeId); } async function selectGanttMarker(marker: AnyRecord): Promise { const runId = String(marker?.runId || activeRunId || ""); const nodeId = String(marker?.nodeId || ""); setGanttSelection({ mode: "event", runId, interval: null, marker }); + setGanttDetailOpen(true); if (!runId) return; if (runId !== activeRunId) setSelectedRunId(runId); void fetchRunDetails(runId, { silent: true }); if (!nodeId) return; setSelectedNodeId(nodeId); setNodeControl(pipelineNodeControlState()); - await fetchNodeDetails(runId, nodeId); + void fetchGanttNodeDetails(runId, nodeId); } async function postNodeAction(action: "append" | "guide" | "modify" | "approve" | "redo"): Promise { @@ -3898,15 +3752,15 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR } } - if (!service) return h(EmptyState, { title: "Pipeline 未登记", text: "请在 config.json 的 microservices 中登记 id=pipeline" }); + if (!service) return h(EmptyState, { title: "Pipeline 未登记", text: "请在 config.json 的 microservices 中登记用户服务 id=pipeline" }); return h("div", { className: "pipeline-page", "data-testid": "pipeline-page" }, h(Panel, { title: "Pipeline v2 工作台", - eyebrow: "D601 Snapshot Microservice", + eyebrow: "D601 Snapshot 用户服务", actions: h("div", { className: "panel-actions" }, h("button", { type: "button", className: "ghost-btn", onClick: load, disabled: state.loading, "data-testid": "pipeline-refresh-button" }, state.loading ? "刷新中" : "刷新"), - h(RawButton, { title: "Pipeline Microservice", data: service, onOpen: onRaw, testId: "raw-pipeline-service" }), + h(RawButton, { title: "Pipeline 用户服务", data: service, onOpen: onRaw, testId: "raw-pipeline-service" }), ), }, h("div", { className: "pipeline-hero" }, @@ -3929,37 +3783,9 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR h("code", null, `${repository.composeFile || "--"} / ${repository.composeService || "--"}`), ), ), - state.error ? h("div", { className: "form-error wide" }, state.error) : null, + h(UniDeskErrorBanner, { error: state.error, wide: true }), ), h("div", { className: "pipeline-grid" }, - h(Panel, { title: "观测指标", eyebrow: state.refreshedAt ? `Updated ${fmtClock(state.refreshedAt)}` : "Snapshot" }, - h("div", { className: "metric-grid" }, - h(MetricCard, { label: "Health", value: state.health?.ok ? "OK" : "--", hint: state.health?.service || "D601 /health", tone: state.health?.ok ? "ok" : "warn" }), - h(MetricCard, { label: "组件", value: componentCount, hint: "components registry", tone: snapshot?.registry?.ok === false ? "warn" : "ok" }), - h(MetricCard, { label: "Pipeline", value: pipelines.length, hint: `${pipelineNodes.length} nodes / ${pipelineEdges.length} edges` }), - 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" }, - h("span", null, item.name), - h("strong", null, item.count), - ))), - h("div", { className: "pipeline-component-list" }, - components.slice(0, 12).map((component: any) => h("span", { key: component.key, className: "data-chip" }, h("b", null, component.componentClass || "--"), h("span", null, component.id || component.key || "--"))), - ), - ), h(Panel, { title: "控制图", eyebrow: `${activePipeline.id || "pipeline"} / run ${activeRun?.status || "--"}`, @@ -3974,6 +3800,8 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR setSelectedNodeId(""); setNodeControl(pipelineNodeControlState()); setGanttSelection(pipelineGanttSelectionState()); + setNodeControlOpen(false); + setGanttDetailOpen(false); }, "data-testid": "pipeline-select", }, pipelines.map((pipeline: any) => h("option", { key: pipeline.id, value: pipeline.id }, pipeline.id || pipeline.key))), @@ -3984,6 +3812,8 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR setSelectedRunId(event.target.value); setNodeControl(pipelineNodeControlState()); setGanttSelection(pipelineGanttSelectionState()); + setNodeControlOpen(false); + setGanttDetailOpen(false); if (selectedNodeId) void fetchNodeDetails(event.target.value, selectedNodeId); }, "data-testid": "pipeline-run-select", @@ -4005,7 +3835,11 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR ), }, pipelineNodes.length === 0 ? h(EmptyState, { title: "暂无控制图", text: "等待 D601 pipeline backend 返回 config.nodes / config.edges" }) : - h("div", { className: "pipeline-control-shell" }, + h("div", { + className: `pipeline-control-shell ${nodeControlOpen ? "detail-open" : "detail-collapsed"}`, + "data-testid": "pipeline-control-shell", + "data-sidebar-open": nodeControlOpen ? "true" : "false", + }, h("div", { className: "pipeline-flow-frame", "data-testid": "pipeline-react-flow" }, h(ReactFlow, { nodes: flow.nodes, @@ -4024,14 +3858,22 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR const nodeId = String(node.id); setSelectedNodeId(nodeId); setNodeControl(pipelineNodeControlState()); + setNodeControlOpen(true); if (activeRunId) void fetchNodeDetails(activeRunId, nodeId); }, }, h(Background, { gap: 22, size: 1, color: "rgba(215, 161, 58, 0.24)" }), h(Controls, { showInteractive: false }), ), + !nodeControlOpen ? h("button", { + type: "button", + className: "pipeline-sidecar-tab right", + disabled: !selectedNodeId, + onClick: () => setNodeControlOpen(true), + "data-testid": "pipeline-node-sidebar-toggle", + }, selectedNodeId ? "展开 node 控制" : "点击 node 展开控制") : null, ), - h(PipelineNodeControlPanel, { + nodeControlOpen ? h(PipelineNodeControlPanel, { activeRun, pipelineRuns, selectedRunId, @@ -4049,7 +3891,8 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR onFetch: () => fetchNodeDetails(), onAction: postNodeAction, onRaw, - }), + onCollapse: () => setNodeControlOpen(false), + }) : null, ), h("div", { className: "pipeline-flow-summary" }, h("span", null, `${flow.nodes.length} nodes`), @@ -4068,8 +3911,11 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR pipelineNodes, pipelineEdges, selection: ganttSelection, + detailOpen: ganttDetailOpen, + onDetailOpenChange: setGanttDetailOpen, runDetails, - nodeDetails: nodeControl.details, + nodeDetails: focusedGanttNodeDetails, + nodeDetailsState: focusedGanttNodeState, ganttScale, onGanttScaleChange: setGanttScale, onIntervalSelect: selectGanttInterval, @@ -4078,10 +3924,42 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR setSelectedRunId(runId); setNodeControl(pipelineNodeControlState()); setGanttSelection(pipelineGanttSelectionState()); + setGanttDetailOpen(false); if (selectedNodeId) void fetchNodeDetails(runId, selectedNodeId); }, onRaw, }), + h(Panel, { title: "观测指标", eyebrow: state.refreshedAt ? `Updated ${fmtClock(state.refreshedAt)}` : "Snapshot" }, + h("div", { className: "metric-grid" }, + h(MetricCard, { label: "Health", value: state.health?.ok ? "OK" : "--", hint: state.health?.service || "D601 /health", tone: state.health?.ok ? "ok" : "warn" }), + h(MetricCard, { label: "组件", value: componentCount, hint: "components registry", tone: snapshot?.registry?.ok === false ? "warn" : "ok" }), + h(MetricCard, { label: "Pipeline", value: pipelines.length, hint: `${pipelineNodes.length} nodes / ${pipelineEdges.length} edges` }), + 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: "MiniMax 限额", eyebrow: "model/minimax-m27 quota" }, + h(PipelineMinimaxQuotaPanel, { quota: minimaxQuota, 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" }, + h("span", null, item.name), + h("strong", null, item.count), + ))), + h("div", { className: "pipeline-component-list" }, + components.slice(0, 12).map((component: any) => h("span", { key: component.key, className: "data-chip" }, h("b", null, component.componentClass || "--"), h("span", null, component.id || component.key || "--"))), + ), + ), 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) => { diff --git a/src/components/frontend/src/project-manager.tsx b/src/components/frontend/src/project-manager.tsx new file mode 100644 index 00000000..fda54773 --- /dev/null +++ b/src/components/frontend/src/project-manager.tsx @@ -0,0 +1,316 @@ +import React from "react"; +import { errorMessage, requestBlob, requestJson } from "./unidesk-error"; +import { UniDeskErrorBanner } from "./unidesk-error-banner"; + +type AnyRecord = Record; + +const h = React.createElement; +const { useEffect } = React; +const useState: any = React.useState; + +const EMPTY_FORM = { + id: "", + sequenceNo: "", + contractNo: "", + name: "", + currentStatus: "", + pending: "", + paymentStatus: "", + notes: "", +}; + +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 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 projectApi(apiBaseUrl: string, path: string): string { + return `${apiBaseUrl}/microservices/project-manager/proxy${path}`; +} + +function projectToForm(project: AnyRecord): AnyRecord { + return { + id: String(project.id || ""), + sequenceNo: project.sequenceNo === null || project.sequenceNo === undefined ? "" : String(project.sequenceNo), + contractNo: String(project.contractNo || ""), + name: String(project.name || ""), + currentStatus: String(project.currentStatus || ""), + pending: String(project.pending || ""), + paymentStatus: String(project.paymentStatus || ""), + notes: String(project.notes || ""), + }; +} + +function formToPayload(form: AnyRecord): AnyRecord { + return { + sequenceNo: form.sequenceNo === "" ? null : Number(form.sequenceNo), + contractNo: String(form.contractNo || "").trim(), + name: String(form.name || "").trim(), + currentStatus: String(form.currentStatus || "").trim(), + pending: String(form.pending || "").trim(), + paymentStatus: String(form.paymentStatus || "").trim(), + paymentRatio: String(form.paymentStatus || "").trim(), + notes: String(form.notes || "").trim(), + }; +} + +function safeId(value: any): string { + return String(value || "item").replace(/[^A-Za-z0-9_-]+/g, "-"); +} + +function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ""; + const chunkSize = 0x8000; + for (let index = 0; index < bytes.length; index += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize)); + } + return btoa(binary); +} + +function ProjectTable({ projects, activeId, onSelect, onRaw }: AnyRecord) { + if (!projects.length) return h(EmptyState, { title: "暂无项目", text: "可以从 Excel 导入,或用右侧表单新建项目。" }); + return h("div", { className: "table-wrap project-manager-table", "data-testid": "project-manager-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("th", null, "其它"), h("th", null, "操作"), + )), + h("tbody", null, projects.map((project: AnyRecord) => h("tr", { key: project.id, className: activeId === project.id ? "active-row" : "", "data-testid": `project-manager-row-${safeId(project.id)}` }, + h("td", null, project.sequenceNo ?? "--"), + h("td", null, h("strong", null, project.contractNo || "--"), h("code", null, project.id || "--")), + h("td", null, h("strong", null, project.name || "--"), h("span", { className: "muted block" }, project.sourceFile || "--")), + h("td", null, project.currentStatus || "--"), + h("td", null, h("span", { className: "preline" }, project.pending || "--")), + h("td", null, h(StatusBadge, { status: Number(project.paymentRatio || 0) >= 1 ? "online" : "warn" }, project.paymentStatus || "--")), + h("td", null, project.notes || "--"), + h("td", null, h("div", { className: "inline-actions" }, + h("button", { type: "button", className: "ghost-btn", onClick: () => onSelect(project), "data-testid": `project-manager-edit-${safeId(project.id)}` }, "编辑"), + h(RawButton, { title: `Project ${project.contractNo || project.id}`, data: project, onOpen: onRaw, testId: `raw-project-${safeId(project.id)}` }), + )), + ))), + )); +} + +export function ProjectManagerPage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyRecord) { + const service = microservices.find((item: any) => item.id === "project-manager") || null; + const [state, setState] = useState({ loading: false, saving: false, importing: false, exporting: false, error: "", notice: "", health: null, list: null, refreshedAt: null }); + const [form, setForm] = useState({ ...EMPTY_FORM }); + const [query, setQuery] = useState(""); + const [status, setStatus] = useState("all"); + + async function load(nextQuery = query, nextStatus = status): Promise { + if (!service) return; + setState((prev: any) => ({ ...prev, loading: true, error: "" })); + try { + const params = new URLSearchParams({ pageSize: "200", status: nextStatus }); + if (nextQuery.trim()) params.set("q", nextQuery.trim()); + const [health, list] = await Promise.all([ + requestJson(`${apiBaseUrl}/microservices/project-manager/health`), + requestJson(projectApi(apiBaseUrl, `/api/projects?${params.toString()}`)), + ]); + setState((prev: any) => ({ ...prev, loading: false, health, list, refreshedAt: new Date(), error: "" })); + } catch (err) { + setState((prev: any) => ({ ...prev, loading: false, error: errorMessage(err, "Project Manager 加载失败") })); + } + } + + useEffect(() => { + load(); + }, [service?.id, service?.runtime?.providerStatus]); + + async function submitProject(event: any): Promise { + event.preventDefault(); + setState((prev: any) => ({ ...prev, saving: true, error: "", notice: "" })); + try { + const payload = formToPayload(form); + if (form.id) { + await requestJson(projectApi(apiBaseUrl, `/api/projects/${encodeURIComponent(form.id)}`), { method: "PUT", body: JSON.stringify(payload) }); + } else { + await requestJson(projectApi(apiBaseUrl, "/api/projects"), { method: "POST", body: JSON.stringify(payload) }); + } + setState((prev: any) => ({ ...prev, saving: false, notice: form.id ? "项目已更新" : "项目已创建" })); + await load(); + } catch (err) { + setState((prev: any) => ({ ...prev, saving: false, error: errorMessage(err, "保存项目失败") })); + } + } + + async function deleteCurrent(): Promise { + if (!form.id) return; + if (!window.confirm(`删除项目 ${form.contractNo || form.name || form.id} ?`)) return; + setState((prev: any) => ({ ...prev, saving: true, error: "", notice: "" })); + try { + await requestJson(projectApi(apiBaseUrl, `/api/projects/${encodeURIComponent(form.id)}`), { method: "DELETE" }); + setForm({ ...EMPTY_FORM }); + setState((prev: any) => ({ ...prev, saving: false, notice: "项目已删除" })); + await load(); + } catch (err) { + setState((prev: any) => ({ ...prev, saving: false, error: errorMessage(err, "删除项目失败") })); + } + } + + async function importExcel(event: any): Promise { + const file = event.target.files?.[0]; + if (!file) return; + setState((prev: any) => ({ ...prev, importing: true, error: "", notice: "" })); + try { + const contentBase64 = arrayBufferToBase64(await file.arrayBuffer()); + const result = await requestJson(projectApi(apiBaseUrl, "/api/import/excel"), { + method: "POST", + body: JSON.stringify({ fileName: file.name, contentBase64, replace: false }), + }); + setState((prev: any) => ({ ...prev, importing: false, notice: `Excel 已导入 ${result.imported || 0} 条项目` })); + event.target.value = ""; + await load(); + } catch (err) { + setState((prev: any) => ({ ...prev, importing: false, error: errorMessage(err, "Excel 导入失败") })); + } + } + + async function exportExcel(): Promise { + setState((prev: any) => ({ ...prev, exporting: true, error: "" })); + try { + const blob = await requestBlob(projectApi(apiBaseUrl, "/api/projects/export.xlsx")); + const href = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = href; + anchor.download = `project-manager-${new Date().toISOString().slice(0, 10)}.xlsx`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(href); + setState((prev: any) => ({ ...prev, exporting: false, notice: "Excel 已导出" })); + } catch (err) { + setState((prev: any) => ({ ...prev, exporting: false, error: errorMessage(err, "Excel 导出失败") })); + } + } + + if (!service) return h(EmptyState, { title: "Project Manager 未登记", text: "请在 config.json 的 microservices 中登记用户服务 id=project-manager" }); + + const runtime = microserviceRuntime(service); + const repository = microserviceRepository(service); + const backend = microserviceBackend(service); + const projects = Array.isArray(state.list?.projects) ? state.list.projects : []; + const summary = state.list?.summary || {}; + const health = state.health || {}; + + return h("div", { className: "project-manager-page", "data-testid": "project-manager-page" }, + h(Panel, { + title: "项目管理工作台", + eyebrow: "Main Server PostgreSQL 用户服务", + actions: h("div", { className: "panel-actions" }, + h("button", { type: "button", className: "ghost-btn", disabled: state.loading, onClick: () => load(), "data-testid": "project-manager-refresh-button" }, state.loading ? "刷新中" : "刷新"), + h("button", { type: "button", className: "ghost-btn", disabled: state.exporting, onClick: exportExcel, "data-testid": "project-manager-export-button" }, state.exporting ? "导出中" : "导出 Excel"), + h(RawButton, { title: "Project Manager 用户服务", data: service, onOpen: onRaw, testId: "raw-project-manager-service" }), + ), + }, + h("div", { className: "project-manager-hero" }, + h(MetricCard, { label: "项目总数", value: summary.total ?? projects.length, hint: `PG 表 ${health.storage?.table || "project_manager_projects"}`, tone: "ok" }), + h(MetricCard, { label: "进行中", value: summary.active ?? "--", hint: "当前状态未完全完成" }), + h(MetricCard, { label: "已完成", value: summary.completed ?? "--", hint: "按 完成 关键字统计", tone: "ok" }), + h(MetricCard, { label: "未全款", value: summary.unpaid ?? "--", hint: "付款比例 < 1", tone: Number(summary.unpaid || 0) > 0 ? "warn" : "ok" }), + ), + h(UniDeskErrorBanner, { error: state.error }), + state.notice ? h("div", { className: "form-success" }, state.notice) : null, + ), + h("div", { className: "project-manager-hero" }, + h("div", { className: "microservice-ref-card" }, h("span", null, "Repo"), h("strong", null, repository.url || "--"), h("code", null, repository.commitId || "--")), + h("div", { className: "microservice-ref-card" }, h("span", null, "Main Server Docker"), h("strong", null, `${backend.nodeBindHost || "--"}:${backend.nodePort || "--"}`), h("code", null, `${repository.composeService || "--"} / ${repository.containerName || "--"}`)), + h("div", { className: "microservice-ref-card" }, h("span", null, "Runtime"), h("strong", null, runtime.providerName || service.providerId), h("code", null, `Health ${health.ok ? "OK" : "--"} / ${state.refreshedAt ? fmtClock(state.refreshedAt) : "--"}`)), + h("div", { className: "microservice-ref-card" }, h("span", null, "Import Source"), h("strong", null, "D601 WeChat Excel"), h("code", null, "合作项目列表_I_20260309.xlsx")), + ), + h("div", { className: "project-manager-layout" }, + h(Panel, { + title: "项目清单", + eyebrow: "CRUD + Excel Export", + actions: h("div", { className: "inline-actions project-manager-filters" }, + h("input", { value: query, onChange: (event: any) => setQuery(event.target.value), placeholder: "搜索合同号 / 项目名称 / 状态", "data-testid": "project-manager-search" }), + h("select", { value: status, onChange: (event: any) => { setStatus(event.target.value); load(query, event.target.value); }, "data-testid": "project-manager-status-filter" }, + h("option", { value: "all" }, "全部"), + h("option", { value: "active" }, "进行中"), + h("option", { value: "completed" }, "已完成"), + h("option", { value: "unpaid" }, "未全款"), + ), + h("button", { type: "button", className: "ghost-btn", onClick: () => load(query, status) }, "筛选"), + ), + }, h(ProjectTable, { projects, activeId: form.id, onSelect: (project: AnyRecord) => setForm(projectToForm(project)), onRaw })), + h(Panel, { title: form.id ? "编辑项目" : "新建项目", eyebrow: "PostgreSQL Write Path" }, + h("form", { className: "stack-form project-manager-form", onSubmit: submitProject, "data-testid": "project-manager-form" }, + form.id ? h("label", null, "项目 ID", h("input", { value: form.id, disabled: true })) : null, + h("label", null, "序号", h("input", { type: "number", value: form.sequenceNo, onChange: (event: any) => setForm((prev: any) => ({ ...prev, sequenceNo: event.target.value })) })), + h("label", null, "合同号", h("input", { value: form.contractNo, onChange: (event: any) => setForm((prev: any) => ({ ...prev, contractNo: event.target.value })), required: true })), + h("label", null, "项目名称", h("input", { value: form.name, onChange: (event: any) => setForm((prev: any) => ({ ...prev, name: event.target.value })), required: true })), + h("label", null, "当前状况", h("textarea", { value: form.currentStatus, onChange: (event: any) => setForm((prev: any) => ({ ...prev, currentStatus: event.target.value })) })), + h("label", null, "待完成", h("textarea", { value: form.pending, onChange: (event: any) => setForm((prev: any) => ({ ...prev, pending: event.target.value })) })), + h("label", null, "付款情况", h("input", { value: form.paymentStatus, onChange: (event: any) => setForm((prev: any) => ({ ...prev, paymentStatus: event.target.value })), placeholder: "例如 1 / 0.5 / 50%" })), + h("label", null, "其它", h("input", { value: form.notes, onChange: (event: any) => setForm((prev: any) => ({ ...prev, notes: event.target.value })) })), + h("div", { className: "inline-actions" }, + h("button", { type: "submit", className: "primary-btn", disabled: state.saving, "data-testid": "project-manager-save-button" }, state.saving ? "保存中" : form.id ? "保存修改" : "创建项目"), + h("button", { type: "button", className: "ghost-btn", onClick: () => setForm({ ...EMPTY_FORM }) }, "清空"), + form.id ? h("button", { type: "button", className: "danger-btn", disabled: state.saving, onClick: deleteCurrent, "data-testid": "project-manager-delete-button" }, "删除") : null, + ), + ), + h("div", { className: "project-manager-import" }, + h("p", { className: "muted paragraph" }, "浏览器只访问 UniDesk frontend;后端通过同源用户服务代理写入主 PostgreSQL,不暴露 4233 公网端口。"), + h("label", { className: "file-import" }, state.importing ? "导入中..." : "导入 Excel", h("input", { type: "file", accept: ".xlsx", onChange: importExcel, disabled: state.importing, "data-testid": "project-manager-import-input" })), + ), + ), + ), + ); +} diff --git a/src/components/frontend/src/todo-note.tsx b/src/components/frontend/src/todo-note.tsx index fbec8e14..a09b1069 100644 --- a/src/components/frontend/src/todo-note.tsx +++ b/src/components/frontend/src/todo-note.tsx @@ -1,4 +1,6 @@ import React from "react"; +import { errorMessage, requestJson } from "./unidesk-error"; +import { UniDeskErrorBanner } from "./unidesk-error-banner"; type AnyRecord = Record; type ReactNode = any; @@ -7,10 +9,6 @@ const h = React.createElement; const { useEffect } = 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); @@ -22,26 +20,6 @@ function fmtClock(value: Date): string { return value.toLocaleTimeString("zh-CN", { hour12: false }); } -async function requestJson(path: string, options: AnyRecord = {}): Promise { - const headers = new Headers(options.headers || {}); - if (options.body && !headers.has("content-type")) headers.set("content-type", "application/json"); - const response = await fetch(path, { credentials: "same-origin", ...options, headers }); - const text = await response.text(); - let body = null; - try { - body = text ? JSON.parse(text) : null; - } catch { - body = { text }; - } - if (!response.ok || body?.ok === false) { - const message = body?.error?.message || body?.error || `HTTP ${response.status}`; - const error = new Error(message); - (error as Error & { status?: number }).status = response.status; - throw error; - } - return body; -} - function StatusBadge({ status, children }: AnyRecord) { const normalized = String(status || "unknown").toLowerCase(); return h("span", { className: `status-badge ${normalized}` }, children || status || "unknown"); @@ -297,16 +275,16 @@ export function TodoNotePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR refresh(); }, [service?.id, service?.runtime?.providerStatus]); - if (!service) return h(EmptyState, { title: "Todo Note 未登记", text: "请在 config.json 的 microservices 中登记 id=todo-note" }); + if (!service) return h(EmptyState, { title: "Todo Note 未登记", text: "请在 config.json 的 microservices 中登记用户服务 id=todo-note" }); const activeSummary = rows.find((row: any) => row.id === activeId) || null; return h("div", { className: "todo-note-page", "data-testid": "todo-note-page" }, h(Panel, { title: "Todo Note 工作台", - eyebrow: "Main Server Backend Microservice", + eyebrow: "Main Server 用户服务", actions: h("div", { className: "panel-actions" }, h("button", { type: "button", className: "ghost-btn", disabled: loading, onClick: () => refresh(activeId), "data-testid": "todo-note-refresh-button" }, loading ? "刷新中" : "刷新"), - h(RawButton, { title: "Todo Note Microservice", data: service, onOpen: onRaw, testId: "raw-todo-note-service" }), + h(RawButton, { title: "Todo Note 用户服务", data: service, onOpen: onRaw, testId: "raw-todo-note-service" }), ), }, h("div", { className: "todo-note-hero" }, @@ -322,7 +300,7 @@ export function TodoNotePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR h("div", { className: "microservice-ref-card" }, h("span", null, "Repo"), h("strong", null, repository.url || "--"), h("code", null, repository.commitId || "--")), h("div", { className: "microservice-ref-card" }, h("span", null, "Main Server Docker"), h("strong", null, `${backend.nodeBindHost || "--"}:${backend.nodePort || "--"}`), h("code", null, `${repository.composeService || "--"} / ${repository.containerName || "--"}`)), ), - error ? h("div", { className: "form-error wide" }, error) : null, + h(UniDeskErrorBanner, { error, wide: true }), ), h("div", { className: "todo-note-layout" }, h(Panel, { title: "清单", eyebrow: `${rows.length} Instances`, className: "todo-list-panel" }, diff --git a/src/components/frontend/src/top-status.tsx b/src/components/frontend/src/top-status.tsx new file mode 100644 index 00000000..18de589d --- /dev/null +++ b/src/components/frontend/src/top-status.tsx @@ -0,0 +1,23 @@ +import React from "react"; + +type AnyRecord = Record; + +const h = React.createElement; + +export function TopStatusBar({ title, items, actions, className, testId }: AnyRecord) { + const rows = Array.isArray(items) ? items : []; + return h("section", { className: `top-status-bar ${className || ""}`, "data-testid": testId }, + h("div", { className: "top-status-main" }, + title ? h("strong", { className: "top-status-title" }, title) : null, + h("div", { className: "top-status-chips" }, rows.map((item: any, index: number) => h("span", { + key: item?.key || `${item?.label || "status"}-${index}`, + className: `top-status-chip ${item?.tone || ""}`, + "data-testid": item?.testId, + }, + item?.label ? h("b", null, item.label) : null, + h("span", null, item?.value ?? "--"), + ))), + ), + actions ? h("div", { className: "top-status-actions" }, actions) : null, + ); +} diff --git a/src/components/frontend/src/trace.tsx b/src/components/frontend/src/trace.tsx new file mode 100644 index 00000000..f3abc83f --- /dev/null +++ b/src/components/frontend/src/trace.tsx @@ -0,0 +1,768 @@ +import React from "react"; + +type AnyRecord = Record; + +const h = React.createElement; +const { useEffect, useRef } = React; + +export type TraceItemKind = "message" | "system" | "error" | "ran" | "explored" | "edited" | "toolGroup"; + +export interface TraceEditStage { + method: string; + status?: string; + at?: string; +} + +export interface TraceDiffFile { + path: string; + status: string; + additions?: number; + deletions?: number; +} + +export interface TraceDiffLine { + text: string; + kind: "meta" | "hunk" | "add" | "del" | "file" | "note" | "context"; + path?: string; + status?: string; +} + +export interface TraceEditObservation { + status: string; + summary: string; + files: TraceDiffFile[]; + stages: TraceEditStage[]; + lines: TraceDiffLine[]; + addedLines: number; + removedLines: number; + rawText: string; +} + +export interface TraceItem { + seq: number; + at?: string; + durationMs?: number; + kind: TraceItemKind | string; + title?: string; + status?: string; + commandPreview?: string; + commandOmittedLines?: number; + bodyPreview?: string; + bodyOmittedLines?: number; + stdoutPreview?: string; + stdoutOmittedLines?: number; + stderrPreview?: string; + stderrOmittedLines?: number; + rawSeqs?: any[]; + fullPrompt?: string; + fullPromptLines?: number; + fullPromptChars?: number; + foldedReferencePrompt?: boolean; + items?: TraceItem[]; + counts?: AnyRecord; + digest?: AnyRecord; + editObservation?: TraceEditObservation; +} + +export interface TracePort { + source: "codex" | "opencode" | string; + toTrace(input: Input): TraceItem[]; +} + +export function traceFromPort(port: TracePort, input: Input): TraceItem[] { + return normalizeTraceItems(port.toTrace(input)); +} + +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 fmtDuration(ms: any): string { + const value = Number(ms); + if (!Number.isFinite(value) || value < 0) return "--"; + const totalSeconds = Math.floor(value / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (hours > 0) return `${hours}h ${String(minutes).padStart(2, "0")}m`; + if (minutes > 0) return `${minutes}m ${String(seconds).padStart(2, "0")}s`; + return `${seconds}s`; +} + +function finiteMs(value: any): number | null { + const number = Number(value); + return Number.isFinite(number) && number >= 0 ? number : null; +} + +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; +} + +function promptLineCount(text: string): number { + if (!text) return 0; + return text.split(/\r?\n/u).length; +} + +function traceKindLabel(kind: string): string { + const labels: Record = { + ran: "Ran", + explored: "Explored", + edited: "Edited", + toolGroup: "Tool calls", + plan: "Plan", + message: "Message", + system: "System", + error: "Error", + }; + return labels[kind] || "Message"; +} + +function omittedLabel(lines: any): string { + const count = Number(lines || 0); + return Number.isFinite(count) && count > 0 ? `… +${Math.floor(count)} lines` : ""; +} + +export function traceMaxSeq(trace: any[]): number { + return (Array.isArray(trace) ? trace : []).reduce((max, item) => Math.max(max, Number(item?.seq ?? 0)), 0); +} + +export function traceResumeSeq(trace: any[], overlapRows = 8): number { + const seqs = Array.from(new Set((Array.isArray(trace) ? trace : []) + .map((item) => Number(item?.seq ?? 0)) + .filter((seq) => Number.isFinite(seq) && seq > 0))) + .sort((left, right) => left - right); + if (seqs.length === 0) return 0; + const anchor = seqs[Math.max(0, seqs.length - overlapRows)] ?? 0; + return Math.max(0, anchor - 0.001); +} + +function isToolTraceItem(item: any): boolean { + return ["explored", "edited", "ran"].includes(String(item?.kind || "")); +} + +function toolTraceCounts(items: any[]): AnyRecord { + const counts = { read: 0, edit: 0, run: 0 }; + for (const item of items) { + const kind = String(item?.kind || ""); + if (kind === "explored") counts.read += 1; + else if (kind === "edited") counts.edit += 1; + else if (kind === "ran") counts.run += 1; + } + return counts; +} + +function toolTraceSummary(items: any[]): string { + const counts = toolTraceCounts(items); + return `${counts.read} read, ${counts.edit} edit, ${counts.run} run`; +} + +function cleanTracePath(value: string): string { + return value + .replace(/^['"`([{<]+/u, "") + .replace(/['"`)\]}>.,;:]+$/u, "") + .replace(/:\d+(?::\d+)?$/u, "") + .trim(); +} + +function extractTracePaths(text: string): string[] { + const source = String(text || ""); + const matches = source.match(/(?:~|\.{1,2}|\/)?(?:[A-Za-z0-9_.@+-]+\/)+[A-Za-z0-9_.@+-]+|[A-Za-z0-9_.@+-]+\.(?:c|cc|cpp|h|hpp|js|jsx|ts|tsx|json|md|py|sh|toml|ya?ml|txt|log|lock)/gu) || []; + const paths: string[] = []; + for (const raw of matches) { + const path = cleanTracePath(raw); + if (path.length < 2 || path.includes("...")) continue; + if (/^(http|https|status|method)$/iu.test(path)) continue; + if (!paths.includes(path)) paths.push(path); + } + return paths; +} + +function compactList(items: string[], limit = 4): string { + if (items.length === 0) return "--"; + const shown = items.slice(0, limit).join(", "); + return items.length > limit ? `${shown} +${items.length - limit}` : shown; +} + +function joinTraceText(parts: string[]): string { + let text = ""; + for (const part of parts) { + if (part.length === 0) continue; + if (text.length > 0 && !text.endsWith("\n") && !part.startsWith("\n")) text += "\n"; + text += part; + } + return text; +} + +function traceTextLines(text: string): string[] { + const normalized = String(text || "").replace(/\r\n/gu, "\n").replace(/\r/gu, "\n").trimEnd(); + return normalized.length > 0 ? normalized.split("\n") : []; +} + +function fileChangeStageMethod(item: TraceItem): string { + const status = String(item.status || "").trim(); + if (status.length > 0) return status; + const text = String(item.bodyPreview || ""); + const match = /^(item\/[A-Za-z]+(?:\/[A-Za-z]+)?):/u.exec(text); + return match?.[1] || "item/fileChange"; +} + +function fileChangeStageStatus(item: TraceItem): string | undefined { + const text = String(item.bodyPreview || ""); + return /file changes status=([A-Za-z0-9_-]+)/u.exec(text)?.[1]; +} + +function isFileChangeLifecycleText(text: string): boolean { + return /^item\/(?:started|completed): file changes status=/u.test(String(text || "").trim()); +} + +function isEditedFileChangeItem(item: TraceItem): boolean { + if (String(item.kind || "") !== "edited") return false; + const status = String(item.status || ""); + const title = String(item.title || ""); + const body = String(item.bodyPreview || ""); + const command = String(item.commandPreview || ""); + if (title === "Edited files") return true; + if (/^item\/fileChange\//u.test(status)) return true; + if ((status === "item/started" || status === "item/completed") && /file changes status=/u.test(body)) return true; + if (/^Success\. Updated the following files:/mu.test(body)) return true; + if (/^diff --git /mu.test(body)) return true; + return command.length === 0 && /^([AMDRCU?]{1,2})\s+\S+/mu.test(body); +} + +function cleanDiffPath(value: string): string { + return cleanTracePath(String(value || "").replace(/^[ab]\//u, "").trim()); +} + +function parseStatusDiffFile(line: string): TraceDiffFile | null { + const match = /^([AMDRCU?]{1,2})\s+(.+)$/u.exec(line); + if (!match) return null; + const path = cleanDiffPath(match[2] || ""); + return path.length > 0 ? { status: match[1] || "M", path } : null; +} + +function parsePatchDiffFile(line: string): TraceDiffFile | null { + const match = /^\*\*\*\s+(Add|Update|Delete)\s+File:\s+(.+)$/u.exec(line); + if (match) { + const status = match[1] === "Add" ? "A" : match[1] === "Delete" ? "D" : "M"; + const path = cleanDiffPath(match[2] || ""); + return path.length > 0 ? { status, path } : null; + } + const moveMatch = /^\*\*\*\s+Move to:\s+(.+)$/u.exec(line); + if (moveMatch) { + const path = cleanDiffPath(moveMatch[1] || ""); + return path.length > 0 ? { status: "R", path } : null; + } + return null; +} + +function parseTraceDiffFiles(text: string): TraceDiffFile[] { + const files: TraceDiffFile[] = []; + const addFile = (status: string, path: string) => { + const cleanPath = cleanDiffPath(path); + if (cleanPath.length === 0 || cleanPath === "/dev/null") return; + const existing = files.find((file) => file.path === cleanPath); + if (existing) { + if (existing.status === "M" && status !== "M") existing.status = status; + return; + } + files.push({ status, path: cleanPath }); + }; + let lastDiffPath = ""; + for (const line of traceTextLines(text)) { + const summaryFile = parseStatusDiffFile(line) || parsePatchDiffFile(line); + if (summaryFile !== null) { + addFile(summaryFile.status, summaryFile.path); + lastDiffPath = summaryFile.path; + continue; + } + const diffMatch = /^diff --git a\/(.+?) b\/(.+)$/u.exec(line); + if (diffMatch) { + const path = diffMatch[2] || diffMatch[1] || ""; + addFile("M", path); + lastDiffPath = cleanDiffPath(path); + continue; + } + const plusMatch = /^\+\+\+ b\/(.+)$/u.exec(line); + if (plusMatch && plusMatch[1] !== "/dev/null") { + addFile("M", plusMatch[1] || ""); + lastDiffPath = cleanDiffPath(plusMatch[1] || ""); + continue; + } + const createMatch = /^new file mode /u.exec(line); + if (createMatch && lastDiffPath) addFile("A", lastDiffPath); + const deleteMatch = /^deleted file mode /u.exec(line); + if (deleteMatch && lastDiffPath) addFile("D", lastDiffPath); + const renameMatch = /^rename to (.+)$/u.exec(line); + if (renameMatch) addFile("R", renameMatch[1] || ""); + } + return files; +} + +function diffLineKind(line: string): TraceDiffLine["kind"] { + if (parseStatusDiffFile(line) !== null || parsePatchDiffFile(line) !== null) return "file"; + if (/^(diff --git |index |--- |\+\+\+ |\*\*\* Begin Patch|\*\*\* End Patch)/u.test(line)) return "meta"; + if (/^@@ /u.test(line)) return "hunk"; + if (/^\+/u.test(line)) return "add"; + if (/^-/u.test(line)) return "del"; + if (/^(Success\.|No changes|Updated\b|Created\b|Deleted\b|Added\s+\d+\s+lines?|Wrote\s+\d+\s+lines?|Read\s+\d+\s+files?|\.\.\.\[patch content truncated)/iu.test(line)) return "note"; + return "context"; +} + +function traceDiffLines(text: string): TraceDiffLine[] { + return traceTextLines(text).map((line) => { + const file = parseStatusDiffFile(line) || parsePatchDiffFile(line); + if (file !== null) return { text: line, kind: "file", path: file.path, status: file.status }; + return { text: line, kind: diffLineKind(line) }; + }); +} + +function traceDiffChangeStats(lines: TraceDiffLine[]): { added: number; removed: number } { + return lines.reduce((stats, line) => { + if (line.kind === "add") stats.added += 1; + else if (line.kind === "del") stats.removed += 1; + return stats; + }, { added: 0, removed: 0 }); +} + +function lineCountLabel(count: number, label: string): string { + return `${label} ${count} line${count === 1 ? "" : "s"}`; +} + +function changeStatLabel(added: number, removed: number): string { + const parts: string[] = []; + if (added > 0) parts.push(lineCountLabel(added, "Added")); + if (removed > 0) parts.push(lineCountLabel(removed, "removed")); + return parts.join(", "); +} + +function latestStageStatus(stages: TraceEditStage[]): string { + for (let index = stages.length - 1; index >= 0; index -= 1) { + const status = String(stages[index]?.status || "").trim(); + if (status.length > 0) return status; + } + const method = String(stages[stages.length - 1]?.method || "").trim(); + if (method === "item/fileChange/outputDelta") return "updated"; + if (method === "item/started") return "started"; + if (method === "item/completed") return "completed"; + return method.replace(/^item\//u, "") || "changed"; +} + +function fileCountLabel(count: number): string { + return `${count} file${count === 1 ? "" : "s"}`; +} + +function editObservationFromItems(items: TraceItem[]): TraceEditObservation { + const rows = items.length > 0 ? items : []; + const allText = joinTraceText(rows.map((item) => String(item.bodyPreview || ""))); + const visibleText = joinTraceText(rows + .map((item) => String(item.bodyPreview || "")) + .filter((text) => text.trim().length > 0 && !isFileChangeLifecycleText(text))); + const diffText = visibleText || allText; + const files = parseTraceDiffFiles(diffText || allText); + const stages = rows.map((item) => ({ + method: fileChangeStageMethod(item), + status: fileChangeStageStatus(item), + at: item.at, + })); + const lines = traceDiffLines(diffText || allText); + const stats = traceDiffChangeStats(lines); + const changeSummary = changeStatLabel(stats.added, stats.removed); + const fileSummary = files.length > 0 ? fileCountLabel(files.length) : ""; + const summary = changeSummary.length > 0 + ? `${changeSummary}${fileSummary ? ` in ${fileSummary}` : ""}` + : files.length > 0 ? fileSummary : shortText(diffText || allText || "File changes", 72); + return { + status: latestStageStatus(stages), + summary, + files, + stages, + lines, + addedLines: stats.added, + removedLines: stats.removed, + rawText: allText, + }; +} + +function mergeEditedFileChangeGroup(group: TraceItem[]): TraceItem { + const first = group[0]; + const last = group[group.length - 1] || first; + const observation = editObservationFromItems(group); + return { + ...first, + seq: Number.isFinite(Number(last?.seq)) ? Number(last?.seq) : Number(first?.seq ?? 0), + at: last?.at || first?.at, + title: observation.files.length > 0 ? `Edited ${observation.summary}` : "Edited files", + status: observation.status, + commandPreview: "", + commandOmittedLines: undefined, + bodyPreview: observation.rawText, + bodyOmittedLines: group.reduce((sum, item) => sum + Number(item.bodyOmittedLines || 0), 0) || undefined, + rawSeqs: group.flatMap((item: any) => Array.isArray(item?.rawSeqs) ? item.rawSeqs : [item?.seq]).filter((seq) => seq !== undefined), + editObservation: observation, + }; +} + +function coalesceEditedFileChanges(trace: TraceItem[]): TraceItem[] { + const rows = Array.isArray(trace) ? trace : []; + const merged: TraceItem[] = []; + let group: TraceItem[] = []; + const flush = () => { + if (group.length === 0) return; + merged.push(mergeEditedFileChangeGroup(group)); + group = []; + }; + for (const item of rows) { + if (isEditedFileChangeItem(item)) { + if (fileChangeStageMethod(item) === "item/started" && group.length > 0) flush(); + group.push(item); + if (fileChangeStageMethod(item) === "item/completed") flush(); + continue; + } + flush(); + merged.push(item); + } + flush(); + return merged; +} + +function toolGroupDigest(items: any[]): AnyRecord { + const readFiles: string[] = []; + const editedFiles: string[] = []; + const runCommands: string[] = []; + const addUnique = (target: string[], values: string[]) => { + for (const value of values) if (!target.includes(value)) target.push(value); + }; + for (const item of items) { + const kind = String(item?.kind || ""); + const text = [item?.commandPreview, item?.bodyPreview, item?.title].map((value) => String(value || "")).join("\n"); + if (kind === "explored") addUnique(readFiles, extractTracePaths(text)); + else if (kind === "edited") addUnique(editedFiles, extractTracePaths(text)); + else if (kind === "ran") { + const command = String(item?.commandPreview || item?.title || "").trim(); + if (command.length > 0 && !runCommands.includes(command)) runCommands.push(shortText(command, 90)); + } + } + const times = items + .map((item) => Date.parse(String(item?.at || ""))) + .filter((time) => Number.isFinite(time)); + const spanMs = times.length >= 2 ? Math.max(0, Math.max(...times) - Math.min(...times)) : 0; + const explicitMs = items.reduce((sum, item) => sum + (finiteMs(item?.durationMs) ?? finiteMs(item?.elapsedMs) ?? 0), 0); + const durationMs = spanMs > 0 ? spanMs : explicitMs; + return { readFiles, editedFiles, runCommands, durationLabel: fmtDuration(durationMs) }; +} + +export function collapseToolTraceRuns(trace: TraceItem[], keepRecentToolCalls = 3): TraceItem[] { + const rows = Array.isArray(trace) ? trace : []; + const collapsed: TraceItem[] = []; + let group: TraceItem[] = []; + let remainingRecentToolCalls = Math.max(0, keepRecentToolCalls); + const keepOpen = new Set(); + for (let index = rows.length - 1; index >= 0 && remainingRecentToolCalls > 0; index -= 1) { + const item = rows[index]; + if (!isToolTraceItem(item)) continue; + keepOpen.add(item); + remainingRecentToolCalls -= 1; + } + const flush = () => { + if (group.length >= 2) { + const counts = toolTraceCounts(group); + collapsed.push({ + seq: Number(group[0]?.seq ?? 0), + at: group[0]?.at || group.at(-1)?.at, + kind: "toolGroup", + title: toolTraceSummary(group), + status: `${group.length} calls`, + items: group, + counts, + digest: toolGroupDigest(group), + rawSeqs: group.flatMap((item: any) => Array.isArray(item?.rawSeqs) ? item.rawSeqs : [item?.seq]).filter((seq) => seq !== undefined), + }); + } else { + collapsed.push(...group); + } + group = []; + }; + for (const item of rows) { + if (isToolTraceItem(item) && !keepOpen.has(item)) { + group.push(item); + continue; + } + flush(); + collapsed.push(item); + } + flush(); + return collapsed; +} + +export function normalizeTraceItems(items: any[]): TraceItem[] { + return (Array.isArray(items) ? items : []).map((item: any, index: number) => ({ + ...item, + seq: Number.isFinite(Number(item?.seq)) ? Number(item.seq) : index + 1, + kind: String(item?.kind || "message"), + at: item?.at === undefined ? undefined : String(item.at), + durationMs: finiteMs(item?.durationMs) ?? undefined, + title: item?.title === undefined ? undefined : String(item.title), + status: item?.status === undefined ? undefined : String(item.status), + })); +} + +export function codexTranscriptToTrace(transcript: any[]): TraceItem[] { + return normalizeTraceItems(transcript); +} + +function opencodePartDurationMs(part: any): number | undefined { + return finiteMs(part?.durationMs) + ?? finiteMs(part?.elapsedMs) + ?? finiteMs(part?.timing?.durationMs) + ?? finiteMs(part?.metadata?.durationMs) + ?? undefined; +} + +function opencodePartCompletedAt(part: any, fallback: any): string | undefined { + return part?.createdAt || part?.updatedAt || part?.completedAt || fallback || undefined; +} + +function opencodePartRawSeq(part: any, fallback: any): any { + return part?.id || part?.messageId || fallback; +} + +function partFieldValue(part: any, keys: string[]): string { + const normalized = new Set(keys.map((key) => key.toLowerCase())); + for (const field of Array.isArray(part?.inputFields) ? part.inputFields : []) { + const key = String(field?.key || "").toLowerCase(); + if (normalized.has(key)) return String(field?.value || ""); + } + return ""; +} + +function opencodeToolKind(part: any): TraceItemKind { + const tool = String(part?.tool || part?.title || "").toLowerCase(); + if (/read|grep|glob|list|ls|find|search|view|cat|sed|rg/u.test(tool)) return "explored"; + if (/edit|write|patch|apply|update|create|delete/u.test(tool)) return "edited"; + const command = partFieldValue(part, ["command", "cmd"]); + if (/\b(rg|grep|find|ls|cat|sed|tail|head|git status|git diff|ps)\b/u.test(command)) return "explored"; + if (/\b(apply_patch|git apply|cat >|tee .*<<|sed -i|python3? .*write_text)\b/u.test(command)) return "edited"; + return "ran"; +} + +export function opencodeStepsToTrace(steps: any[]): TraceItem[] { + const rows: TraceItem[] = []; + let seq = 1; + for (const step of Array.isArray(steps) ? steps : []) { + const at = step?.createdAt || step?.updatedAt || step?.completedAt; + const role = String(step?.role || "assistant").toLowerCase(); + const parts = Array.isArray(step?.parts) ? step.parts : []; + for (const part of parts) { + const type = String(part?.type || "").toLowerCase(); + if (type === "step-start" || type === "step-finish") continue; + if (type === "text" || type === "reasoning") { + const text = String(part?.textPreview || step?.textPreview || "").trim(); + if (text.length === 0) continue; + rows.push({ + seq: seq++, + at: opencodePartCompletedAt(part, at), + kind: "message", + title: type === "reasoning" ? "Reasoning" : role === "user" ? "User message" : role === "system" ? "System message" : "Assistant message", + status: type === "reasoning" ? "reasoning" : role, + bodyPreview: text, + durationMs: opencodePartDurationMs(part), + rawSeqs: [opencodePartRawSeq(part, seq)], + }); + continue; + } + if (type === "tool") { + const command = partFieldValue(part, ["command", "cmd"]) || partFieldValue(part, ["filePath", "filepath", "path"]) || String(part?.title || part?.tool || "tool"); + const output = String(part?.outputPreview && part.outputPreview !== "--" ? part.outputPreview : part?.textPreview || ""); + rows.push({ + seq: seq++, + at: opencodePartCompletedAt(part, at), + kind: opencodeToolKind(part), + title: String(part?.title || part?.tool || "tool"), + status: String(part?.status || ""), + commandPreview: command, + bodyPreview: output, + durationMs: opencodePartDurationMs(part), + rawSeqs: [opencodePartRawSeq(part, seq)], + }); + continue; + } + const text = String(part?.textPreview || part?.title || type || "").trim(); + if (text) rows.push({ seq: seq++, at: opencodePartCompletedAt(part, at), kind: "system", title: type || "part", bodyPreview: text, status: String(part?.status || ""), durationMs: opencodePartDurationMs(part), rawSeqs: [opencodePartRawSeq(part, seq)] }); + } + if (parts.length === 0 && step?.textPreview) { + rows.push({ seq: seq++, at, kind: "message", title: `${role || "assistant"} message`, status: role, bodyPreview: String(step.textPreview), rawSeqs: [step?.messageId || seq] }); + } + } + return rows; +} + +export const codexTracePort: TracePort = { source: "codex", toTrace: codexTranscriptToTrace }; +export const opencodeTracePort: TracePort = { source: "opencode", toTrace: opencodeStepsToTrace }; + +function safeClassToken(value: string): string { + return String(value || "unknown").toLowerCase().replace(/[^a-z0-9_-]+/gu, "-") || "unknown"; +} + +function diffStatusClass(status: string): string { + const value = String(status || "M").toUpperCase(); + if (value.startsWith("A") || value === "??") return "added"; + if (value.startsWith("D")) return "deleted"; + if (value.startsWith("R")) return "renamed"; + return "modified"; +} + +function stageLabel(method: string): string { + if (method === "item/fileChange/outputDelta") return "delta"; + return method.replace(/^item\//u, ""); +} + +function renderDiffLine(line: TraceDiffLine, index: number): any { + if (line.kind === "file") { + const status = String(line.status || "M"); + return h("div", { key: `${index}-${line.text}`, className: `codex-edit-diff-line file ${diffStatusClass(status)}` }, + h("span", { className: `codex-edit-file-status ${diffStatusClass(status)}` }, status), + h("code", null, line.path || line.text.replace(/^([AMDRCU?]{1,2})\s+/u, "")), + ); + } + const sign = line.kind === "add" || line.kind === "del" ? line.text.slice(0, 1) : line.kind === "hunk" ? "@@" : line.kind === "note" ? "ok" : ""; + const text = line.kind === "add" || line.kind === "del" ? line.text.slice(1) : line.text; + return h("div", { key: `${index}-${line.text}`, className: `codex-edit-diff-line ${line.kind}` }, + h("span", { className: "codex-edit-diff-sign" }, sign), + h("code", null, text || " "), + ); +} + +function renderEditObservation(observation: TraceEditObservation, bodyMore: string): any { + const lines = observation.lines.length > 0 + ? observation.lines + : observation.files.map((file) => ({ text: `${file.status} ${file.path}`, kind: "file" as const, path: file.path, status: file.status })); + const hasChangedContent = Number(observation.addedLines || 0) + Number(observation.removedLines || 0) > 0; + return h("div", { className: "codex-edit-observation", "data-testid": "codex-edit-observation" }, + h("div", { className: "codex-edit-observation-head" }, + h("span", { className: "codex-edit-window-controls", "aria-hidden": "true" }, + h("i", null), + h("i", null), + h("i", null), + ), + h("strong", null, hasChangedContent ? "git diff" : "git diff --stat"), + h("code", null, observation.summary || "File changes"), + ), + observation.stages.length > 0 ? h("div", { className: "codex-edit-stage-strip" }, + observation.stages.map((stage, index) => h("span", { key: `${stage.method}-${index}`, className: `codex-edit-stage ${safeClassToken(stage.status || stage.method)}` }, + h("b", null, stageLabel(stage.method)), + stage.status ? h("em", null, stage.status) : null, + )), + ) : null, + lines.length > 0 ? h("div", { className: "codex-edit-diff", role: "list" }, lines.map(renderDiffLine)) : null, + bodyMore ? h("div", { className: "codex-edit-omitted" }, `${bodyMore} (查看原始JSON获取完整记录)`) : null, + ); +} + +function renderCommandStream(label: "stdout" | "stderr", text: string, omittedLines: any): any { + const more = omittedLabel(omittedLines); + return h("div", { className: `codex-transcript-stream ${label}`, "data-testid": `codex-trace-${label}` }, + h("span", { className: "codex-transcript-stream-label" }, label), + h("pre", { className: "codex-transcript-body" }, text, more ? `\n${more} (查看原始JSON获取完整记录)` : ""), + ); +} + +function renderTraceItem(item: TraceItem, nested = false): any { + const kind = String(item.kind || "message"); + const isCommand = ["ran", "explored", "edited"].includes(kind); + const commandMore = omittedLabel(item.commandOmittedLines); + const bodyMore = omittedLabel(item.bodyOmittedLines); + const commandText = String(item.commandPreview || (isCommand ? item.title || "" : "")); + const stdoutText = String(item.stdoutPreview || ""); + const stderrText = String(item.stderrPreview || ""); + const hasCommandStreams = stdoutText.length > 0 || stderrText.length > 0; + const foldedFullPrompt = Boolean(item.foldedReferencePrompt) && String(item.fullPrompt || "").length > 0; + const editObservation = kind === "edited" && (item.editObservation !== undefined || isEditedFileChangeItem(item)) + ? item.editObservation || editObservationFromItems([item]) + : null; + return h("article", { key: `${item.seq}-${kind}`, className: `codex-transcript-item ${kind} ${nested ? "nested" : ""}` }, + h("div", { className: "codex-transcript-main" }, + h("div", { className: "codex-transcript-title" }, + h("span", { className: "codex-output-channel" }, traceKindLabel(kind)), + isCommand && editObservation === null ? null : h("strong", null, editObservation !== null ? "File changes" : String(item.title || traceKindLabel(kind))), + item.status ? h("code", null, String(editObservation?.status || item.status)) : null, + h("time", null, fmtDate(item.at)), + ), + commandText && editObservation === null ? h("pre", { className: "codex-transcript-command" }, commandText, commandMore ? `\n${commandMore}` : "") : null, + editObservation !== null + ? renderEditObservation(editObservation, bodyMore) + : hasCommandStreams ? h("div", { className: "codex-transcript-streams" }, + stdoutText.length > 0 ? renderCommandStream("stdout", stdoutText, item.stdoutOmittedLines) : null, + stderrText.length > 0 ? renderCommandStream("stderr", stderrText, item.stderrOmittedLines) : null, + ) + : item.bodyPreview ? h("pre", { className: "codex-transcript-body" }, String(item.bodyPreview), bodyMore ? `\n${bodyMore} (查看原始JSON获取完整记录)` : "") : null, + foldedFullPrompt ? h("details", { className: "codex-initial-prompt-full", "data-testid": "codex-initial-prompt-full" }, + h("summary", null, + h("span", null, "引用注入已折叠,点击查看最终传入 Codex 的完整 prompt"), + h("code", null, `${item.fullPromptLines || promptLineCount(String(item.fullPrompt || ""))} lines / ${item.fullPromptChars || String(item.fullPrompt || "").length} chars`), + ), + h("pre", { className: "codex-transcript-body codex-transcript-full-prompt", "data-testid": "codex-initial-prompt-full-text" }, String(item.fullPrompt || "")), + ) : null, + ), + ); +} + +function renderToolGroup(item: TraceItem): any { + const toolItems = Array.isArray(item.items) ? item.items : []; + const digest = item.digest && typeof item.digest === "object" ? item.digest : toolGroupDigest(toolItems); + return h("article", { key: `${item.seq}-toolGroup`, className: "codex-transcript-item toolGroup" }, + h("div", { className: "codex-transcript-main" }, + h("details", { className: "codex-tool-group", "data-testid": "codex-tool-group" }, + h("summary", null, + h("div", { className: "codex-tool-group-head" }, + h("span", { className: "codex-output-channel" }, traceKindLabel("toolGroup")), + h("strong", null, String(item.title || toolTraceSummary(toolItems))), + h("code", null, String(item.status || `${toolItems.length} calls`)), + h("time", null, fmtDate(item.at)), + ), + ), + h("div", { className: "codex-tool-group-digest" }, + h("span", null, `read: ${compactList(Array.isArray(digest.readFiles) ? digest.readFiles : [])}`), + h("span", null, `edit: ${compactList(Array.isArray(digest.editedFiles) ? digest.editedFiles : [])}`), + h("span", null, `run: ${compactList(Array.isArray(digest.runCommands) ? digest.runCommands : [], 2)}`), + h("span", null, `duration: ${digest.durationLabel || "--"}`), + ), + h("div", { className: "codex-tool-group-items" }, toolItems.map((toolItem) => renderTraceItem(toolItem, true))), + ), + ), + ); +} + +const traceAutoScrollBottomThresholdPx = 16; + +function traceIsScrolledToBottom(element: HTMLElement): boolean { + const distanceToBottom = element.scrollHeight - element.scrollTop - element.clientHeight; + return distanceToBottom <= traceAutoScrollBottomThresholdPx; +} + +export function TraceView({ items, input, port, autoScroll = false, loading = false, hasDetail = true, emptyText = "等待 Trace 输出...", loadingText = "正在加载完整 Trace...", testId = "trace-output", className = "codex-transcript", keepRecentToolCalls = 3, collapseTools = true }: AnyRecord) { + const ref = useRef(null); + const shouldFollowTailRef = useRef(true); + const rawTrace = coalesceEditedFileChanges(port ? traceFromPort(port, input) : normalizeTraceItems(items)); + const trace = collapseTools ? collapseToolTraceRuns(rawTrace, keepRecentToolCalls) : rawTrace; + const maxSeq = traceMaxSeq(rawTrace); + useEffect(() => { + const element = ref.current; + if (!autoScroll || !element) return; + if (!shouldFollowTailRef.current && !traceIsScrolledToBottom(element)) return; + element.scrollTop = element.scrollHeight; + shouldFollowTailRef.current = true; + }, [autoScroll, rawTrace.length, maxSeq]); + const handleScroll = (event: React.UIEvent) => { + const element = event.currentTarget; + shouldFollowTailRef.current = traceIsScrolledToBottom(element); + }; + const traceProps = { className, ref, onScroll: handleScroll, "data-testid": testId }; + if (loading && !hasDetail) return h("div", traceProps, h("div", { className: "codex-output-empty" }, loadingText)); + return h("div", traceProps, + trace.length === 0 ? h("div", { className: "codex-output-empty" }, emptyText) : trace.map((item) => String(item.kind || "") === "toolGroup" ? renderToolGroup(item) : renderTraceItem(item)), + ); +} diff --git a/src/components/frontend/src/unidesk-error-banner.tsx b/src/components/frontend/src/unidesk-error-banner.tsx new file mode 100644 index 00000000..76245021 --- /dev/null +++ b/src/components/frontend/src/unidesk-error-banner.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { describeUniDeskError } from "./unidesk-error"; + +type AnyRecord = Record; + +const h = React.createElement; + +function row(label: string, value: any) { + return value ? [h("dt", { key: `${label}-label` }, label), h("dd", { key: label }, value)] : null; +} + +export function UniDeskErrorBanner({ error, wide = false, fallback = "操作失败", className = "" }: AnyRecord) { + if (!error) return null; + const detail = describeUniDeskError(error, fallback); + const rows = [ + row("请求", [detail.method, detail.url].filter(Boolean).join(" ")), + row("状态", detail.status ? `HTTP ${detail.status}${detail.statusText ? ` ${detail.statusText}` : ""}` : ""), + row("时间", detail.occurredAt), + row("解析错误", detail.parseError), + row("响应预览", detail.responsePreview), + ].filter(Boolean); + return h("div", { + className: `form-error unidesk-error${wide ? " wide" : ""}${className ? ` ${className}` : ""}`, + role: "alert", + "data-testid": "unidesk-error", + }, + h("div", { className: "unidesk-error-title" }, + h("strong", null, detail.title), + detail.status ? h("span", { className: "unidesk-error-code" }, `HTTP ${detail.status}`) : null, + ), + detail.message ? h("pre", { className: "unidesk-error-message" }, detail.message) : null, + rows.length > 0 ? h("dl", { className: "unidesk-error-details" }, rows) : null, + ); +} diff --git a/src/components/frontend/src/unidesk-error.ts b/src/components/frontend/src/unidesk-error.ts new file mode 100644 index 00000000..f4d2f46f --- /dev/null +++ b/src/components/frontend/src/unidesk-error.ts @@ -0,0 +1,316 @@ +type AnyRecord = Record; + +export type UniDeskFailureField = string | false; + +export interface UniDeskRequestJsonOptions extends Omit { + body?: BodyInit | AnyRecord | any[] | null; + failureFields?: UniDeskFailureField[]; + strictJson?: boolean; + retryInvalidJson?: number; + retryDelayMs?: number; + invalidJsonPrefix?: string; + invalidJsonPreview?: boolean; + responsePreviewLength?: number; +} + +export interface UniDeskRequestErrorMeta { + kind: "http" | "network" | "parse"; + method: string; + url: string; + occurredAt: string; + status?: number; + statusText?: string; + upstreamMessage?: string; + responsePreview?: string; + parseError?: string; +} + +export interface UniDeskErrorView { + title: string; + message: string; + status?: number; + statusText?: string; + method?: string; + url?: string; + occurredAt?: string; + responsePreview?: string; + parseError?: string; + structured: boolean; +} + +export class UniDeskRequestError extends Error { + readonly unideskRequestError = true; + readonly meta: UniDeskRequestErrorMeta; + + constructor(message: string, meta: UniDeskRequestErrorMeta) { + super(message); + this.name = "UniDeskRequestError"; + this.meta = meta; + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function basicErrorMessage(error: unknown, fallback = "操作失败"): string { + return error instanceof Error ? error.message : String(error || fallback); +} + +function safePreview(value: unknown, max = 500): string { + if (value === null || value === undefined) return ""; + const raw = typeof value === "string" ? value : JSON.stringify(value); + const text = String(raw || "").replace(/\s+/gu, " ").trim(); + return text.length > max ? `${text.slice(0, max)}...` : text; +} + +function requestUrl(path: string): string { + try { + const origin = typeof location !== "undefined" && location.origin ? location.origin : "http://localhost"; + return new URL(path, origin).toString(); + } catch { + return path; + } +} + +function requestMethod(options: AnyRecord): string { + return String(options.method || "GET").toUpperCase(); +} + +function isJsonBody(value: unknown): boolean { + if (value === null || value === undefined) return false; + if (typeof value !== "object") return false; + if (typeof Blob !== "undefined" && value instanceof Blob) return false; + if (typeof FormData !== "undefined" && value instanceof FormData) return false; + if (typeof URLSearchParams !== "undefined" && value instanceof URLSearchParams) return false; + if (typeof ArrayBuffer !== "undefined" && value instanceof ArrayBuffer) return false; + return true; +} + +function normalizeFetchOptions(options: UniDeskRequestJsonOptions): RequestInit { + const headers = new Headers(options.headers || {}); + const body = isJsonBody(options.body) ? JSON.stringify(options.body) : options.body; + if (body && !headers.has("content-type") && typeof body === "string") headers.set("content-type", "application/json"); + return { ...options, credentials: options.credentials || "same-origin", body: body as BodyInit | null | undefined, headers }; +} + +function responseMessage(body: any): string { + if (body?.error && typeof body.error === "object" && typeof body.error.message === "string") return body.error.message; + if (typeof body?.error === "string") return body.error; + if (typeof body?.message === "string") return body.message; + if (typeof body?.detail === "string") return body.detail; + return ""; +} + +function bodyIndicatesFailure(body: any, fields: UniDeskFailureField[]): boolean { + if (!body || typeof body !== "object" || Array.isArray(body)) return false; + return fields.some((field) => field !== false && body[field] === false); +} + +function requestMeta( + kind: UniDeskRequestErrorMeta["kind"], + path: string, + method: string, + occurredAt: Date, + extra: Partial = {}, +): UniDeskRequestErrorMeta { + return { + kind, + method, + url: requestUrl(path), + occurredAt: occurredAt.toISOString(), + ...extra, + }; +} + +function httpTitle(status?: number, statusText?: string): string { + if (!status) return "请求失败"; + return `HTTP ${status}${statusText ? ` ${statusText}` : ""}`; +} + +function parseJson(text: string): { body: any; parseError: string } { + try { + return { body: text ? JSON.parse(text) : null, parseError: "" }; + } catch (error) { + return { body: { text }, parseError: basicErrorMessage(error, "parse failed") }; + } +} + +export async function requestJson(path: string, options: UniDeskRequestJsonOptions = {}, attempt = 0): Promise { + const { + failureFields = ["ok"], + strictJson = false, + retryInvalidJson = 0, + retryDelayMs = 120, + invalidJsonPrefix = "服务返回了无效 JSON", + invalidJsonPreview = false, + responsePreviewLength = 500, + ...fetchOptions + } = options; + const method = requestMethod(fetchOptions); + const occurredAt = new Date(); + let response: Response; + try { + response = await fetch(path, normalizeFetchOptions(fetchOptions)); + } catch (error) { + const message = basicErrorMessage(error, "网络请求失败"); + throw new UniDeskRequestError(message, requestMeta("network", path, method, occurredAt, { upstreamMessage: message })); + } + + const text = await response.text(); + const parsed = parseJson(text); + if (parsed.parseError) { + if (strictJson && method === "GET" && attempt < retryInvalidJson) { + await sleep(retryDelayMs); + return requestJson(path, options, attempt + 1); + } + if (strictJson) { + const preview = invalidJsonPreview ? `;响应预览:${safePreview(text, 180)}` : ""; + throw new UniDeskRequestError( + `${invalidJsonPrefix}(${text.length} bytes):${parsed.parseError}${preview}`, + requestMeta("parse", path, method, occurredAt, { + status: response.status, + statusText: response.statusText, + parseError: parsed.parseError, + responsePreview: safePreview(text, responsePreviewLength), + }), + ); + } + } + + if (!response.ok || bodyIndicatesFailure(parsed.body, failureFields)) { + const upstreamMessage = responseMessage(parsed.body); + const message = upstreamMessage || httpTitle(response.status, response.statusText); + throw new UniDeskRequestError( + message, + requestMeta("http", path, method, occurredAt, { + status: response.status, + statusText: response.statusText, + upstreamMessage, + responsePreview: safePreview(parsed.parseError ? text : parsed.body, responsePreviewLength), + }), + ); + } + + return parsed.body; +} + +export async function requestBlob(path: string, options: RequestInit = {}): Promise { + const method = requestMethod(options as AnyRecord); + const occurredAt = new Date(); + let response: Response; + try { + response = await fetch(path, normalizeFetchOptions(options as UniDeskRequestJsonOptions)); + } catch (error) { + const message = basicErrorMessage(error, "网络请求失败"); + throw new UniDeskRequestError(message, requestMeta("network", path, method, occurredAt, { upstreamMessage: message })); + } + if (response.ok) return response.blob(); + + const text = await response.text(); + const parsed = parseJson(text); + const upstreamMessage = responseMessage(parsed.body); + const message = upstreamMessage || httpTitle(response.status, response.statusText); + throw new UniDeskRequestError( + message, + requestMeta("http", path, method, occurredAt, { + status: response.status, + statusText: response.statusText, + upstreamMessage, + responsePreview: safePreview(parsed.parseError ? text : parsed.body), + parseError: parsed.parseError || undefined, + }), + ); +} + +export function isUniDeskRequestError(error: unknown): error is UniDeskRequestError { + return Boolean(error && typeof error === "object" && (error as AnyRecord).unideskRequestError === true && (error as AnyRecord).meta); +} + +function fmtDateTime(value: string | undefined): string { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return `${date.toLocaleString("zh-CN", { hour12: false })} / ${date.toISOString()}`; +} + +export function describeUniDeskError(error: unknown, fallback = "操作失败"): UniDeskErrorView { + if (isUniDeskRequestError(error)) { + const title = error.meta.kind === "parse" + ? "响应解析失败" + : error.meta.kind === "network" + ? "网络请求失败" + : error.meta.status && (error.meta.status < 200 || error.meta.status >= 300) + ? httpTitle(error.meta.status, error.meta.statusText) + : "应用请求失败"; + const plainHttpTitle = error.meta.status ? httpTitle(error.meta.status) : ""; + const isDuplicateTitle = (value: string | undefined) => !value || value === title || value === plainHttpTitle; + const message = !isDuplicateTitle(error.message) + ? error.message + : isDuplicateTitle(error.meta.upstreamMessage) + ? "" + : error.meta.upstreamMessage || ""; + return { + title, + message, + status: error.meta.status, + statusText: error.meta.statusText, + method: error.meta.method, + url: error.meta.url, + occurredAt: fmtDateTime(error.meta.occurredAt), + responsePreview: error.meta.responsePreview, + parseError: error.meta.parseError, + structured: true, + }; + } + const text = basicErrorMessage(error, fallback); + const lines = text.split(/\r?\n/u); + return { + title: lines[0] || fallback, + message: lines.slice(1).join("\n"), + structured: lines.length > 1, + }; +} + +export function formatUniDeskError(error: unknown, fallback = "操作失败"): string { + const detail = describeUniDeskError(error, fallback); + const lines = [detail.title]; + if (detail.message) lines.push(`原因: ${detail.message}`); + if (detail.method || detail.url) lines.push(`请求: ${[detail.method, detail.url].filter(Boolean).join(" ")}`); + if (detail.status) lines.push(`状态: ${httpTitle(detail.status, detail.statusText)}`); + if (detail.occurredAt) lines.push(`时间: ${detail.occurredAt}`); + if (detail.parseError) lines.push(`解析错误: ${detail.parseError}`); + if (detail.responsePreview && detail.responsePreview !== detail.message) lines.push(`响应预览: ${detail.responsePreview}`); + return lines.filter(Boolean).join("\n"); +} + +export function errorMessage(error: unknown, fallback = "操作失败"): string { + return isUniDeskRequestError(error) ? formatUniDeskError(error, fallback) : basicErrorMessage(error, fallback); +} + +function escapeHtml(value: unknown): string { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export function renderUniDeskErrorHtml(error: unknown, options: { wide?: boolean; fallback?: string } = {}): string { + if (!error) return ""; + const detail = describeUniDeskError(error, options.fallback); + const className = `form-error unidesk-error${options.wide ? " wide" : ""}`; + const rows = [ + detail.method || detail.url ? ["请求", [detail.method, detail.url].filter(Boolean).join(" ")] : null, + detail.status ? ["状态", httpTitle(detail.status, detail.statusText)] : null, + detail.occurredAt ? ["时间", detail.occurredAt] : null, + detail.parseError ? ["解析错误", detail.parseError] : null, + detail.responsePreview ? ["响应预览", detail.responsePreview] : null, + ].filter((item): item is string[] => Array.isArray(item)); + const message = detail.message ? `
${escapeHtml(detail.message)}
` : ""; + const details = rows.length > 0 + ? `
${rows.map(([label, value]) => `
${escapeHtml(label)}
${escapeHtml(value)}
`).join("")}
` + : ""; + return ``; +} diff --git a/src/components/microservices/codex-queue/Dockerfile b/src/components/microservices/codex-queue/Dockerfile index de92de85..103bc3b9 100644 --- a/src/components/microservices/codex-queue/Dockerfile +++ b/src/components/microservices/codex-queue/Dockerfile @@ -1,5 +1,7 @@ FROM oven/bun:1-debian +ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright + RUN apt-get update \ && apt-get install -y --no-install-recommends \ bash \ @@ -31,12 +33,14 @@ RUN apt-get update \ && mkdir -p /usr/local/lib/docker/cli-plugins /root/.docker/cli-plugins \ && ln -sf /usr/bin/docker-compose /usr/local/lib/docker/cli-plugins/docker-compose \ && ln -sf /usr/bin/docker-compose /root/.docker/cli-plugins/docker-compose \ - && npm install -g @openai/codex@0.128.0 \ + && npm install -g @openai/codex@0.128.0 playwright@1.59.1 \ + && playwright install --with-deps chromium \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY src/components/microservices/codex-queue/package.json ./package.json +RUN bun install --production COPY src/components/microservices/codex-queue/tsconfig.json ./tsconfig.json COPY src/components/microservices/codex-queue/src ./src diff --git a/src/components/microservices/codex-queue/package.json b/src/components/microservices/codex-queue/package.json index b98b389e..e6258545 100644 --- a/src/components/microservices/codex-queue/package.json +++ b/src/components/microservices/codex-queue/package.json @@ -5,5 +5,8 @@ "scripts": { "start": "bun run src/index.ts", "check": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "postgres": "latest" } } diff --git a/src/components/microservices/codex-queue/src/index.ts b/src/components/microservices/codex-queue/src/index.ts index e338191a..84b7e935 100644 --- a/src/components/microservices/codex-queue/src/index.ts +++ b/src/components/microservices/codex-queue/src/index.ts @@ -1,7 +1,8 @@ import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync, type Dirent } from "node:fs"; import { dirname, resolve } from "node:path"; import * as readline from "node:readline"; +import postgres from "postgres"; type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; type TaskStatus = "queued" | "running" | "judging" | "retry_wait" | "succeeded" | "failed" | "canceled"; @@ -15,6 +16,7 @@ interface RuntimeConfig { host: string; port: number; statePath: string; + outputArchiveDir: string; logFile: string; defaultWorkdir: string; codexHome: string; @@ -22,6 +24,7 @@ interface RuntimeConfig { defaultModel: string; codexModels: string[]; defaultReasoningEffort: string | null; + modelReasoningEfforts: Record; sandbox: "read-only" | "workspace-write" | "danger-full-access"; approvalPolicy: "untrusted" | "on-failure" | "on-request" | "never"; defaultMaxAttempts: number; @@ -30,14 +33,31 @@ interface RuntimeConfig { minimaxModel: string; judgeTimeoutMs: number; judgeRepairAttempts: number; + turnNoActivityTimeoutMs: number; + databaseUrl: string | null; + databaseFlushIntervalMs: number; + notifyClaudeQqEnabled: boolean; + notifyClaudeQqBaseUrl: string; + notifyClaudeQqTargetType: "private" | "group"; + notifyClaudeQqUserId: string; + notifyClaudeQqGroupId: string; + notifyClaudeQqMaxResponseChars: number; + notifyClaudeQqTimeoutMs: number; + notifyClaudeQqSendAttempts: number; + maxInMemoryOutputRecords: number; + maxInMemoryEventRecords: number; } interface QueueTaskRequest { prompt: string; + queueId?: string; cwd?: string; model?: string; reasoningEffort?: string; maxAttempts?: number; + referenceTaskIds?: string[]; + basePrompt?: string; + referenceInjection?: ReferenceInjectionRecord | null; } interface LiveOutput { @@ -49,6 +69,10 @@ interface LiveOutput { itemId?: string; } +interface ArchivedLiveOutput extends LiveOutput { + op?: "set" | "append"; +} + interface TranscriptLine { seq: number; at: string; @@ -59,7 +83,15 @@ interface TranscriptLine { commandOmittedLines?: number; bodyPreview?: string; bodyOmittedLines?: number; + stdoutPreview?: string; + stdoutOmittedLines?: number; + stderrPreview?: string; + stderrOmittedLines?: number; rawSeqs: number[]; + fullPrompt?: string; + fullPromptLines?: number; + fullPromptChars?: number; + foldedReferencePrompt?: boolean; } interface CodexEventSummary { @@ -81,8 +113,25 @@ interface AttemptSummary { appServerExitCode: number | null; appServerSignal: string | null; error: string | null; + inputPrompt?: string; + inputPromptPreview?: string; + inputPromptChars?: number; + inputPromptLines?: number; + finalResponse?: string; finalResponsePreview: string; + finalResponseChars?: number; + judge?: JudgeResult | null; + judgeAt?: string | null; + judgeSeq?: number | null; + feedbackPrompt?: string; + feedbackPromptPreview?: string; + feedbackPromptChars?: number; + feedbackPromptLines?: number; + feedbackPromptSource?: string; + feedbackPromptForAttempt?: number | null; stderrTail: string; + outputStartSeq?: number | null; + outputEndSeq?: number | null; } interface JudgeResult { @@ -94,6 +143,16 @@ interface JudgeResult { raw?: JsonValue; } +interface FeedbackPromptRecord { + text: string; + preview: string; + chars: number; + lines: number; + source: string; + forAttempt: number | null; + truncated: boolean; +} + interface ParsedJudgeJson { value: Record; source: string; @@ -104,9 +163,49 @@ interface MiniMaxJudgeResponse { content: string; } +interface ReferenceInjectionSummaryItem { + round: number; + roundIndex: number; + taskId: string; + viaTaskId: string | null; + status: TaskStatus; + model: string; + cwd: string; + createdAt: string; + updatedAt: string; + promptChars: number; + finalResponseChars: number; + finalResponseAt: string | null; + finalResponseSource: string; + referenceTaskIds: string[]; + cliHint: string; +} + +interface ReferenceInjectionRecord { + version: 2; + injectedAt: string; + basePrompt: string; + directReferenceTaskIds: string[]; + maxRounds: number | null; + truncated: boolean; + itemCount: number; + items: ReferenceInjectionSummaryItem[]; +} + +interface PromptHistoryItem { + seq: number; + at: string; + method: "turn/steer"; + text: string; +} + interface QueueTask { id: string; + queueId: string; prompt: string; + basePrompt: string; + referenceTaskIds: string[]; + referenceInjection: ReferenceInjectionRecord | null; cwd: string; model: string; reasoningEffort: string | null; @@ -116,6 +215,7 @@ interface QueueTask { updatedAt: string; startedAt: string | null; finishedAt: string | null; + readAt: string | null; currentAttempt: number; currentMode: RunMode | null; codexThreadId: string | null; @@ -123,6 +223,8 @@ interface QueueTask { finalResponse: string; lastError: string | null; lastJudge: JudgeResult | null; + judgeFailCount: number; + promptHistory: PromptHistoryItem[]; output: LiveOutput[]; events: CodexEventSummary[]; attempts: AttemptSummary[]; @@ -135,9 +237,16 @@ interface PersistedState { version: 1; updatedAt: string; nextSeq: number; + queues: QueueRecord[]; tasks: QueueTask[]; } +interface QueueRecord { + id: string; + createdAt: string; + updatedAt: string; +} + interface AppServerExit { code: number | null; signal: string | null; @@ -155,6 +264,23 @@ interface CodexRunResult { events: CodexEventSummary[]; } +interface SessionFileChange { + callId: string; + at: string; + name: string; + input: string; + output: string; +} + +interface SessionCommandOutput { + callId: string; + at: string; + stdout: string; + stderr: string; + output: string; + exitCode: number | null; +} + interface JudgeProbeCase { id: string; prompt: string; @@ -171,22 +297,55 @@ interface JudgeProbeCase { interface ActiveRun { taskId: string; + queueId: string; app: AppServerClient; threadId: string; turnId: string | null; } +type SqlClient = postgres.Sql; +type SqlExecutor = postgres.Sql | postgres.TransactionSql; + const recentLogs: JsonValue[] = []; const serviceStartedAt = new Date().toISOString(); +const defaultQueueId = "default"; +const judgeFailRetryLimit = 3; +const fallbackJudgeRetryLimit = 1; +const maxTaskAttempts = 99; +const referenceInjectionMaxRounds: number | null = null; +const retryBackoffBaseMs = 1000; +const retryBackoffMaxMs = 10 * 60 * 1000; +const queueIdPattern = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/u; const config = readConfig(); const logger = createLogger("codex-queue", config.logFile); const state = readState(config.statePath); let processing = false; -let activeRun: ActiveRun | null = null; +const processingQueues = new Set(); +const activeRuns = new Map(); let devReadyCache: { checkedAtMs: number; value: JsonValue } | null = null; let persistTimer: ReturnType | null = null; let persistDirty = false; let shutdownRequested = false; +let serviceReady = false; +const transcriptCache = new Map(); +const codexSessionPathCache = new Map(); +const codexSessionFileChangeCache = new Map }>(); +const codexSessionCommandOutputCache = new Map }>(); +const outputArchiveSeededTasks = new Set(); +const sql: SqlClient | null = config.databaseUrl === null ? null : postgres(config.databaseUrl, { + max: 4, + idle_timeout: 20, + connect_timeout: 10, +}); +let databaseReady = false; +let databaseLastError: string | null = null; +let databaseFlushTimer: ReturnType | null = null; +let databaseFlushInFlight = false; +const dirtyDatabaseTaskIds = new Set(); +const sentTaskNotificationKeys = new Set(); +const inFlightTaskNotificationKeys = new Set(); +let idleNotificationSent = true; +let idleNotificationInFlight = false; function envString(name: string, fallback: string): string { const value = process.env[name]; @@ -205,12 +364,39 @@ function envNumber(name: string, fallback: number): number { return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback; } +function envNonNegativeNumber(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 envBool(name: string, fallback: boolean): boolean { + const raw = process.env[name]; + if (raw === undefined || raw.trim().length === 0) return fallback; + const value = raw.trim().toLowerCase(); + if (value === "1" || value === "true" || value === "yes" || value === "on") return true; + if (value === "0" || value === "false" || value === "no" || value === "off") return false; + return fallback; +} + function envList(name: string, fallback: string[]): string[] { const raw = process.env[name]; const source = raw === undefined || raw.length === 0 ? fallback.join(",") : raw; return Array.from(new Set(source.split(",").map((item) => item.trim()).filter(Boolean))); } +function envModelReasoningEfforts(name: string, fallback: Record): Record { + const raw = process.env[name]; + const map: Record = { ...fallback }; + if (raw === undefined || raw.trim().length === 0) return map; + for (const item of raw.split(/[,;]+/u)) { + const [model, effort] = item.split("=", 2).map((part) => part.trim()); + if (model && effort) map[model.toLowerCase()] = effort; + } + return map; +} + function withRequiredModel(models: string[], model: string): string[] { return models.includes(model) ? models : [model, ...models]; } @@ -226,26 +412,43 @@ function approvalValue(raw: string): RuntimeConfig["approvalPolicy"] { } function readConfig(): RuntimeConfig { - const defaultModel = envString("CODEX_QUEUE_DEFAULT_MODEL", "gpt-5.4-mini"); + const defaultModel = envString("CODEX_QUEUE_DEFAULT_MODEL", "gpt-5.5"); + const statePath = envString("CODEX_QUEUE_STATE_PATH", "/var/lib/unidesk/codex-queue/state.json"); + const notifyTargetTypeRaw = envString("CODEX_QUEUE_NOTIFY_CLAUDEQQ_TARGET_TYPE", "private").toLowerCase(); 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"), + statePath, + outputArchiveDir: envString("CODEX_QUEUE_OUTPUT_ARCHIVE_DIR", resolve(dirname(statePath), "output-archive")), logFile: envString("LOG_FILE", "/var/log/unidesk/codex-queue.jsonl"), defaultWorkdir: envString("CODEX_QUEUE_WORKDIR", "/root/unidesk"), 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, - codexModels: withRequiredModel(envList("CODEX_QUEUE_MODELS", ["gpt-5.4-mini", "gpt-5.4", "gpt-5.5"]), defaultModel), + codexModels: withRequiredModel(envList("CODEX_QUEUE_MODELS", ["gpt-5.5", "gpt-5.4-mini", "gpt-5.4"]), defaultModel), defaultReasoningEffort: envNullableString("CODEX_QUEUE_REASONING_EFFORT"), + modelReasoningEfforts: envModelReasoningEfforts("CODEX_QUEUE_MODEL_REASONING_EFFORTS", { "gpt-5.5": "xhigh" }), 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))), + defaultMaxAttempts: Math.max(1, Math.min(maxTaskAttempts, envNumber("CODEX_QUEUE_MAX_ATTEMPTS", maxTaskAttempts))), minimaxApiKey: envString("MINIMAX_API_KEY", ""), minimaxApiBase: envString("MINIMAX_API_BASE", "https://api.minimaxi.com/v1").replace(/\/+$/u, ""), minimaxModel: envString("MINIMAX_MODEL", "MiniMax-M2.7"), judgeTimeoutMs: envNumber("MINIMAX_JUDGE_TIMEOUT_MS", 60_000), judgeRepairAttempts: Math.max(0, Math.min(5, envNumber("MINIMAX_JUDGE_REPAIR_ATTEMPTS", 2))), + turnNoActivityTimeoutMs: Math.max(60_000, Math.min(30 * 60_000, envNumber("CODEX_TURN_NO_ACTIVITY_TIMEOUT_MS", 6 * 60_000))), + databaseUrl: envNullableString("DATABASE_URL"), + databaseFlushIntervalMs: Math.max(100, Math.min(10_000, envNumber("CODEX_QUEUE_DATABASE_FLUSH_INTERVAL_MS", 1000))), + notifyClaudeQqEnabled: envBool("CODEX_QUEUE_NOTIFY_CLAUDEQQ_ENABLED", false), + notifyClaudeQqBaseUrl: envString("CODEX_QUEUE_NOTIFY_CLAUDEQQ_BASE_URL", "http://backend-core:8080/api/microservices/claudeqq/proxy").replace(/\/+$/u, ""), + notifyClaudeQqTargetType: notifyTargetTypeRaw === "group" ? "group" : "private", + notifyClaudeQqUserId: envString("CODEX_QUEUE_NOTIFY_CLAUDEQQ_USER_ID", "645275593").trim(), + notifyClaudeQqGroupId: envString("CODEX_QUEUE_NOTIFY_CLAUDEQQ_GROUP_ID", "").trim(), + notifyClaudeQqMaxResponseChars: Math.max(500, Math.min(50_000, envNumber("CODEX_QUEUE_NOTIFY_CLAUDEQQ_MAX_RESPONSE_CHARS", 12_000))), + notifyClaudeQqTimeoutMs: Math.max(1000, Math.min(60_000, envNumber("CODEX_QUEUE_NOTIFY_CLAUDEQQ_TIMEOUT_MS", 15_000))), + notifyClaudeQqSendAttempts: Math.max(1, Math.min(10, envNumber("CODEX_QUEUE_NOTIFY_CLAUDEQQ_SEND_ATTEMPTS", 3))), + maxInMemoryOutputRecords: envNonNegativeNumber("CODEX_QUEUE_IN_MEMORY_OUTPUT_RECORDS", 600), + maxInMemoryEventRecords: envNonNegativeNumber("CODEX_QUEUE_IN_MEMORY_EVENT_RECORDS", 400), }; } @@ -272,17 +475,110 @@ function nowIso(): string { return new Date().toISOString(); } +function resolveReasoningEffort(model: string, explicit?: string | null): string | null { + const requested = explicit?.trim(); + if (requested) return requested; + const modelEffort = config.modelReasoningEfforts[model.toLowerCase()]; + return modelEffort ?? config.defaultReasoningEffort; +} + +function normalizeQueueId(value: unknown, fallback = defaultQueueId): string { + const text = typeof value === "string" ? value.trim() : ""; + if (text.length === 0) return fallback; + if (!queueIdPattern.test(text)) throw new Error("queueId must match /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/"); + return text; +} + +function safeQueueId(value: unknown): string { + try { + return normalizeQueueId(value); + } catch { + return defaultQueueId; + } +} + function emptyState(): PersistedState { - return { version: 1, updatedAt: nowIso(), nextSeq: 1, tasks: [] }; + const at = nowIso(); + return { version: 1, updatedAt: at, nextSeq: 1, queues: [{ id: defaultQueueId, createdAt: at, updatedAt: at }], tasks: [] }; +} + +function normalizeQueueRecord(value: unknown): QueueRecord | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + const record = value as Partial; + const id = safeQueueId(record.id); + const createdAt = typeof record.createdAt === "string" && record.createdAt.length > 0 ? record.createdAt : nowIso(); + const updatedAt = typeof record.updatedAt === "string" && record.updatedAt.length > 0 ? record.updatedAt : createdAt; + return { id, createdAt, updatedAt }; +} + +function ensureQueue(queueId: string): QueueRecord { + const id = normalizeQueueId(queueId); + const existing = state.queues.find((queue) => queue.id === id); + if (existing !== undefined) return existing; + const at = nowIso(); + const queue = { id, createdAt: at, updatedAt: at }; + state.queues.push(queue); + state.queues.sort((left, right) => left.id.localeCompare(right.id)); + return queue; +} + +function queueIdOf(task: QueueTask): string { + return safeQueueId(task.queueId); +} + +function normalizeSteerPromptText(text: string): string { + return text.replace(/^\s*\[steer\]\s*/u, "").trimEnd(); +} + +function outputPromptHistory(task: QueueTask): PromptHistoryItem[] { + return task.output + .filter((item) => item.channel === "user" && item.method === "turn/steer") + .map((item) => ({ + seq: item.seq, + at: item.at, + method: "turn/steer" as const, + text: normalizeSteerPromptText(item.text), + })) + .filter((item) => item.text.trim().length > 0); +} + +function mergePromptHistory(items: PromptHistoryItem[]): PromptHistoryItem[] { + const byKey = new Map(); + for (const item of items) { + const seq = Number(item.seq); + const text = normalizeSteerPromptText(String(item.text || "")); + if (!Number.isFinite(seq) || text.trim().length === 0) continue; + byKey.set(`${seq}:${item.method}`, { seq, at: item.at || nowIso(), method: "turn/steer", text }); + } + return Array.from(byKey.values()).sort((left, right) => left.seq - right.seq); +} + +function judgeFailCountFromOutput(task: QueueTask): number { + return (task.output ?? []).filter((item) => item.method === "judge" && /\bjudge=fail\b/u.test(item.text)).length; +} + +function fallbackJudgeRetryCount(task: QueueTask): number { + const attemptCount = (task.attempts ?? []).filter((attempt) => attempt.judge?.source === "fallback" && attempt.judge.decision === "retry").length; + const outputCount = (task.output ?? []).filter((item) => item.method === "judge" && /\bjudge=retry\b/u.test(item.text) && /\bsource=fallback\b/u.test(item.text)).length; + return Math.max(attemptCount, outputCount); } function normalizeTask(task: QueueTask): QueueTask { + task.queueId = safeQueueId(task.queueId); task.output ??= []; task.events ??= []; task.attempts ??= []; + task.readAt = typeof task.readAt === "string" && task.readAt.length > 0 ? task.readAt : null; task.activeTurnId ??= null; task.model ||= config.defaultModel; task.cwd ||= config.defaultWorkdir; + task.reasoningEffort = resolveReasoningEffort(task.model, task.reasoningEffort); + task.basePrompt ||= userPromptForDisplay(task.prompt); + task.referenceTaskIds ??= referenceTaskIdsFromPrompt(task.prompt); + task.referenceInjection ??= null; + task.judgeFailCount = Number.isInteger(task.judgeFailCount) && task.judgeFailCount >= 0 ? task.judgeFailCount : judgeFailCountFromOutput(task); + const persistedPromptHistory = Array.isArray(task.promptHistory) ? task.promptHistory : []; + task.promptHistory = mergePromptHistory([...persistedPromptHistory, ...outputPromptHistory(task)]); return task; } @@ -294,13 +590,25 @@ function readState(path: string): PersistedState { 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 }; + const queueMap = new Map(); + for (const queue of Array.isArray(record.queues) ? record.queues : []) { + const normalized = normalizeQueueRecord(queue); + if (normalized !== null) queueMap.set(normalized.id, normalized); + } + const at = String(record.updatedAt ?? nowIso()); + if (!queueMap.has(defaultQueueId)) queueMap.set(defaultQueueId, { id: defaultQueueId, createdAt: at, updatedAt: at }); + for (const task of tasks) { + const id = queueIdOf(task); + if (!queueMap.has(id)) queueMap.set(id, { id, createdAt: task.createdAt, updatedAt: task.updatedAt }); + } + const queues = Array.from(queueMap.values()).sort((left, right) => left.id.localeCompare(right.id)); + return { version: 1, updatedAt: at, nextSeq: Number(record.nextSeq ?? 1), queues, tasks }; } catch { return emptyState(); } } -function persistState(): void { +function persistState(markAllDatabaseTasks = true): void { persistDirty = false; if (persistTimer !== null) { clearTimeout(persistTimer); @@ -311,6 +619,8 @@ function persistState(): void { const tmp = `${config.statePath}.tmp`; writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, "utf8"); renameSync(tmp, config.statePath); + if (markAllDatabaseTasks) markAllDatabaseTasksDirty(); + scheduleDatabaseFlush(); } function schedulePersistState(delayMs = 1000): void { @@ -318,10 +628,288 @@ function schedulePersistState(delayMs = 1000): void { if (persistTimer !== null) return; persistTimer = setTimeout(() => { persistTimer = null; - if (persistDirty) persistState(); + if (persistDirty) persistState(false); }, delayMs); } +function redactDatabaseUrl(value: string): string { + try { + const url = new URL(value); + if (url.password) url.password = "***"; + return url.toString(); + } catch { + return ""; + } +} + +function errorToJson(error: unknown): JsonValue { + if (error instanceof Error) return { name: error.name, message: error.message, stack: error.stack ?? null }; + return String(error); +} + +function databaseErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + return String(error); +} + +function markTaskDirty(taskId: string): void { + if (sql === null) return; + dirtyDatabaseTaskIds.add(taskId); + scheduleDatabaseFlush(); +} + +function markAllDatabaseTasksDirty(): void { + if (sql === null) return; + for (const task of state.tasks) dirtyDatabaseTaskIds.add(task.id); +} + +function scheduleDatabaseFlush(delayMs = config.databaseFlushIntervalMs): void { + if (sql === null || !databaseReady || dirtyDatabaseTaskIds.size === 0 || shutdownRequested) return; + if (databaseFlushTimer !== null) return; + databaseFlushTimer = setTimeout(() => { + databaseFlushTimer = null; + void flushDirtyTasksToDatabase().catch((error) => { + databaseLastError = databaseErrorMessage(error); + logger("error", "database_flush_failed", { error: errorToJson(error), dirtyTaskCount: dirtyDatabaseTaskIds.size }); + }); + }, delayMs); +} + +function taskTimestamp(value: string | null): string | null { + if (value === null || value.length === 0) return null; + const time = Date.parse(value); + return Number.isFinite(time) ? new Date(time).toISOString() : null; +} + +function lastOutputSeq(task: QueueTask): number { + return task.output.at(-1)?.seq ?? 0; +} + +function updateNextSeqFromTasks(): void { + let nextSeq = Math.max(1, Number.isFinite(state.nextSeq) ? Math.floor(state.nextSeq) : 1); + for (const task of state.tasks) { + for (const output of task.output) nextSeq = Math.max(nextSeq, Number(output.seq) + 1); + } + state.nextSeq = nextSeq; +} + +async function upsertTaskToDatabase(client: SqlExecutor, task: QueueTask): Promise { + await client` + INSERT INTO unidesk_codex_queue_tasks ( + id, + queue_id, + status, + model, + cwd, + prompt, + base_prompt, + reference_task_ids, + reference_injection, + reasoning_effort, + max_attempts, + current_attempt, + current_mode, + codex_thread_id, + active_turn_id, + created_at, + updated_at, + started_at, + finished_at, + last_error, + last_judge, + output_count, + event_count, + attempt_count, + last_output_seq, + task_json + ) VALUES ( + ${task.id}, + ${queueIdOf(task)}, + ${task.status}, + ${task.model}, + ${task.cwd}, + ${task.prompt}, + ${task.basePrompt}, + ${client.json(task.referenceTaskIds as unknown as postgres.JSONValue)}, + ${task.referenceInjection === null ? null : client.json(task.referenceInjection as unknown as postgres.JSONValue)}, + ${task.reasoningEffort}, + ${task.maxAttempts}, + ${task.currentAttempt}, + ${task.currentMode}, + ${task.codexThreadId}, + ${task.activeTurnId}, + ${taskTimestamp(task.createdAt) ?? nowIso()}, + ${taskTimestamp(task.updatedAt) ?? nowIso()}, + ${taskTimestamp(task.startedAt)}, + ${taskTimestamp(task.finishedAt)}, + ${task.lastError}, + ${task.lastJudge === null ? null : client.json(task.lastJudge as unknown as postgres.JSONValue)}, + ${task.output.length}, + ${task.events.length}, + ${task.attempts.length}, + ${lastOutputSeq(task)}, + ${client.json(task as unknown as postgres.JSONValue)} + ) + ON CONFLICT (id) DO UPDATE SET + status = EXCLUDED.status, + queue_id = EXCLUDED.queue_id, + model = EXCLUDED.model, + cwd = EXCLUDED.cwd, + prompt = EXCLUDED.prompt, + base_prompt = EXCLUDED.base_prompt, + reference_task_ids = EXCLUDED.reference_task_ids, + reference_injection = EXCLUDED.reference_injection, + reasoning_effort = EXCLUDED.reasoning_effort, + max_attempts = EXCLUDED.max_attempts, + current_attempt = EXCLUDED.current_attempt, + current_mode = EXCLUDED.current_mode, + codex_thread_id = EXCLUDED.codex_thread_id, + active_turn_id = EXCLUDED.active_turn_id, + created_at = EXCLUDED.created_at, + updated_at = EXCLUDED.updated_at, + started_at = EXCLUDED.started_at, + finished_at = EXCLUDED.finished_at, + last_error = EXCLUDED.last_error, + last_judge = EXCLUDED.last_judge, + output_count = EXCLUDED.output_count, + event_count = EXCLUDED.event_count, + attempt_count = EXCLUDED.attempt_count, + last_output_seq = EXCLUDED.last_output_seq, + task_json = EXCLUDED.task_json + `; +} + +async function flushDirtyTasksToDatabase(force = false): Promise { + if (sql === null || !databaseReady) return; + if (databaseFlushInFlight && !force) { + scheduleDatabaseFlush(); + return; + } + const ids = Array.from(dirtyDatabaseTaskIds); + if (ids.length === 0) return; + dirtyDatabaseTaskIds.clear(); + databaseFlushInFlight = true; + try { + const byId = new Map(state.tasks.map((task) => [task.id, task])); + await sql.begin(async (client) => { + for (const id of ids) { + const task = byId.get(id); + if (task !== undefined) await upsertTaskToDatabase(client, task); + } + }); + databaseLastError = null; + } catch (error) { + for (const id of ids) dirtyDatabaseTaskIds.add(id); + throw error; + } finally { + databaseFlushInFlight = false; + if (dirtyDatabaseTaskIds.size > 0) scheduleDatabaseFlush(); + } +} + +async function initDatabasePersistence(): Promise { + if (sql === null) { + logger("warn", "database_persistence_disabled", { reason: "DATABASE_URL is not configured" }); + return; + } + logger("info", "database_persistence_init_start", { databaseUrl: config.databaseUrl === null ? null : redactDatabaseUrl(config.databaseUrl) }); + await sql` + CREATE TABLE IF NOT EXISTS unidesk_codex_queue_tasks ( + id TEXT PRIMARY KEY, + queue_id TEXT NOT NULL DEFAULT 'default', + status TEXT NOT NULL, + model TEXT NOT NULL, + cwd TEXT NOT NULL, + prompt TEXT NOT NULL, + base_prompt TEXT NOT NULL DEFAULT '', + reference_task_ids JSONB NOT NULL DEFAULT '[]'::jsonb, + reference_injection JSONB, + reasoning_effort TEXT, + max_attempts INTEGER NOT NULL, + current_attempt INTEGER NOT NULL DEFAULT 0, + current_mode TEXT, + codex_thread_id TEXT, + active_turn_id TEXT, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + started_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ, + last_error TEXT, + last_judge JSONB, + output_count INTEGER NOT NULL DEFAULT 0, + event_count INTEGER NOT NULL DEFAULT 0, + attempt_count INTEGER NOT NULL DEFAULT 0, + last_output_seq BIGINT NOT NULL DEFAULT 0, + task_json JSONB NOT NULL + ) + `; + await sql`ALTER TABLE unidesk_codex_queue_tasks ADD COLUMN IF NOT EXISTS queue_id TEXT NOT NULL DEFAULT 'default'`; + await sql`ALTER TABLE unidesk_codex_queue_tasks ADD COLUMN IF NOT EXISTS base_prompt TEXT NOT NULL DEFAULT ''`; + await sql`ALTER TABLE unidesk_codex_queue_tasks ADD COLUMN IF NOT EXISTS reference_task_ids JSONB NOT NULL DEFAULT '[]'::jsonb`; + await sql`ALTER TABLE unidesk_codex_queue_tasks ADD COLUMN IF NOT EXISTS reference_injection JSONB`; + await sql`CREATE INDEX IF NOT EXISTS idx_unidesk_codex_queue_tasks_status_updated ON unidesk_codex_queue_tasks(status, updated_at DESC)`; + await sql`CREATE INDEX IF NOT EXISTS idx_unidesk_codex_queue_tasks_queue_status_updated ON unidesk_codex_queue_tasks(queue_id, status, updated_at DESC)`; + await sql`CREATE INDEX IF NOT EXISTS idx_unidesk_codex_queue_tasks_created ON unidesk_codex_queue_tasks(created_at DESC)`; + await sql`CREATE INDEX IF NOT EXISTS idx_unidesk_codex_queue_tasks_model_updated ON unidesk_codex_queue_tasks(model, updated_at DESC)`; + + const rows = await sql>` + SELECT id, updated_at, task_json + FROM unidesk_codex_queue_tasks + ORDER BY created_at ASC, id ASC + `; + const merged = new Map(); + for (const row of rows) { + try { + const task = normalizeTask(row.task_json as QueueTask); + merged.set(task.id, task); + } catch (error) { + logger("warn", "database_task_row_ignored", { id: String(row.id), error: errorToJson(error) }); + } + } + let fileTaskWins = 0; + for (const task of state.tasks) { + const existing = merged.get(task.id); + const fileUpdated = timestampMs(task.updatedAt) ?? 0; + const databaseUpdated = timestampMs(existing?.updatedAt) ?? 0; + if (existing === undefined || fileUpdated >= databaseUpdated) { + merged.set(task.id, normalizeTask(task)); + fileTaskWins += 1; + } + } + if (merged.size > 0) { + const tasks = Array.from(merged.values()).sort((left, right) => (timestampMs(left.createdAt) ?? 0) - (timestampMs(right.createdAt) ?? 0) || left.id.localeCompare(right.id)); + state.tasks.splice(0, state.tasks.length, ...tasks); + updateNextSeqFromTasks(); + state.updatedAt = nowIso(); + } + databaseReady = true; + markAllDatabaseTasksDirty(); + await flushDirtyTasksToDatabase(true); + logger("info", "database_persistence_init_complete", { databaseTaskCount: rows.length, mergedTaskCount: state.tasks.length, fileTaskWins }); +} + +async function initDatabasePersistenceWithRetry(): Promise { + if (sql === null) return; + const started = Date.now(); + let attempt = 0; + while (!databaseReady) { + attempt += 1; + try { + await initDatabasePersistence(); + return; + } catch (error) { + databaseLastError = databaseErrorMessage(error); + const elapsedMs = Date.now() - started; + logger("warn", "database_persistence_init_retry", { attempt, elapsedMs, error: errorToJson(error) }); + if (elapsedMs > 90_000) { + logger("error", "database_persistence_init_failed_falling_back_to_file", { attempt, elapsedMs, error: errorToJson(error) }); + return; + } + await Bun.sleep(Math.min(1000 * attempt, 5000)); + } + } +} + function prepareCodexHome(): void { mkdirSync(config.codexHome, { recursive: true }); if (existsSync(config.sourceCodexConfig)) { @@ -336,6 +924,11 @@ function safePreview(value: string, max = 900): string { return compact.length > max ? `${compact.slice(0, max)}...` : compact; } +function prefixPreview(value: string, max = 900): string { + const trimmed = value.trim(); + return trimmed.length > max ? `${trimmed.slice(0, max)}...` : trimmed; +} + function linePreview(text: string, maxLines: number, maxChars: number): { text: string; omittedLines: number } { const clean = text.replace(/\u001b\[[0-9;]*m/gu, "").trimEnd(); if (clean.length === 0) return { text: "", omittedLines: 0 }; @@ -350,6 +943,297 @@ function linePreview(text: string, maxLines: number, maxChars: number): { text: return { text: kept.join("\n"), omittedLines: Math.max(0, lines.length - kept.length) }; } +function completeTraceText(text: string): { text: string; omittedLines: number } { + return { text: text.replace(/\u001b\[[0-9;]*m/gu, "").trimEnd(), omittedLines: 0 }; +} + +function editedOutputPreview(text: string): { text: string; omittedLines: number } { + return linePreview(compactTranscriptBody(text), 120, 24_000); +} + +function codexSessionDateDir(value: string | null | undefined): string | null { + if (typeof value !== "string" || value.length === 0) return null; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return null; + return resolve( + config.codexHome, + "sessions", + String(date.getUTCFullYear()), + String(date.getUTCMonth() + 1).padStart(2, "0"), + String(date.getUTCDate()).padStart(2, "0"), + ); +} + +function codexSessionSignature(path: string): string | null { + try { + const stat = statSync(path); + return `${stat.size}:${Math.floor(stat.mtimeMs)}`; + } catch { + return null; + } +} + +function findCodexSessionFile(task: QueueTask): string | null { + const threadId = task.codexThreadId; + if (threadId === null || threadId.length === 0) return null; + const cached = codexSessionPathCache.get(threadId); + if (cached !== undefined && existsSync(cached)) return cached; + + const roots = Array.from(new Set([ + codexSessionDateDir(task.startedAt), + codexSessionDateDir(task.createdAt), + codexSessionDateDir(task.updatedAt), + resolve(config.codexHome, "sessions"), + ].filter((value): value is string => value !== null && existsSync(value)))); + const matches: string[] = []; + let scanned = 0; + const scan = (dir: string, depth: number): void => { + if (depth < 0 || scanned > 4000) return; + scanned += 1; + let entries: Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const path = resolve(dir, entry.name); + if (entry.isDirectory()) { + scan(path, depth - 1); + } else if (entry.isFile() && entry.name.endsWith(".jsonl") && entry.name.includes(threadId)) { + matches.push(path); + } + } + }; + for (const root of roots) scan(root, root.endsWith("/sessions") ? 4 : 1); + matches.sort((left, right) => (statMtimeMs(right) - statMtimeMs(left)) || right.localeCompare(left)); + const match = matches[0] ?? null; + if (match !== null) codexSessionPathCache.set(threadId, match); + return match; +} + +function statMtimeMs(path: string): number { + try { + return statSync(path).mtimeMs; + } catch { + return 0; + } +} + +function parseCodexToolOutputText(raw: string): string { + const text = String(raw || ""); + try { + const parsed = JSON.parse(text) as Record; + if (typeof parsed.output === "string") return parsed.output; + } catch { + // Keep the raw tool output if it is not the JSON wrapper used by Codex. + } + return text; +} + +function recordStringField(record: Record | null, keys: string[]): string { + if (record === null) return ""; + for (const key of keys) { + const value = record[key]; + if (typeof value === "string") return value; + } + return ""; +} + +function recordNumberField(record: Record | null, keys: string[]): number | null { + if (record === null) return null; + for (const key of keys) { + const value = Number(record[key]); + if (Number.isFinite(value)) return value; + } + return null; +} + +function parseToolOutputStreams(raw: string): { stdout: string; stderr: string; output: string; exitCode: number | null } { + const text = String(raw || ""); + try { + const parsed = JSON.parse(text) as unknown; + const record = extractRecord(parsed); + const stdout = recordStringField(record, ["stdout", "stdoutText"]); + const stderr = recordStringField(record, ["stderr", "stderrText"]); + const output = recordStringField(record, ["output", "text"]); + const exitCode = recordNumberField(record, ["exitCode", "code", "status"]); + if (stdout.length > 0 || stderr.length > 0 || output.length > 0) { + return { stdout: stdout || output, stderr, output: output || [stdout, stderr].filter(Boolean).join("\n"), exitCode }; + } + } catch { + // Not a JSON wrapper; parse the Codex CLI tool-result envelope below. + } + + const outputMatch = /(?:^|\n)Output:\n([\s\S]*)$/u.exec(text); + const exitMatch = /Process exited with code\s+(-?\d+)/u.exec(text); + if (outputMatch !== null) { + const output = (outputMatch[1] ?? "").trimEnd(); + return { + stdout: output, + stderr: "", + output, + exitCode: exitMatch === null ? null : Number(exitMatch[1]), + }; + } + return { stdout: text, stderr: "", output: text, exitCode: exitMatch === null ? null : Number(exitMatch[1]) }; +} + +function formatCommandOutput(output: SessionCommandOutput | null | undefined): string { + if (output === null || output === undefined) return ""; + const parts: string[] = []; + const stdout = output.stdout.trimEnd(); + const stderr = output.stderr.trimEnd(); + if (stdout.length > 0) parts.push(stderr.length > 0 ? `[stdout]\n${stdout}` : stdout); + if (stderr.length > 0) parts.push(`[stderr]\n${stderr}`); + if (parts.length > 0) return parts.join("\n"); + return output.output.trimEnd(); +} + +function addCommandOutputStreams(line: TranscriptLine, output: SessionCommandOutput | null | undefined, fullText: boolean): TranscriptLine { + if (output === null || output === undefined) return line; + const stdout = output.stdout.trimEnd(); + const stderr = output.stderr.trimEnd(); + if (stdout.length > 0) { + const preview = fullText ? completeTraceText(stdout) : outputPreview(stdout); + if (preview.text.length > 0) { + line.stdoutPreview = preview.text; + line.stdoutOmittedLines = preview.omittedLines || undefined; + } + } + if (stderr.length > 0) { + const preview = fullText ? completeTraceText(stderr) : outputPreview(stderr); + if (preview.text.length > 0) { + line.stderrPreview = preview.text; + line.stderrOmittedLines = preview.omittedLines || undefined; + } + } + return line; +} + +function parseCodexSessionCommandOutputs(path: string): Map { + const signature = codexSessionSignature(path); + const cached = codexSessionCommandOutputCache.get(path); + if (signature !== null && cached?.signature === signature) return cached.outputs; + + const outputs = new Map(); + try { + const text = readFileSync(path, "utf8"); + for (const line of text.split(/\r?\n/u)) { + if (line.trim().length === 0) continue; + let record: Record; + try { + record = JSON.parse(line) as Record; + } catch { + continue; + } + const payload = extractRecord(record.payload); + if (payload === null) continue; + const type = String(payload.type || ""); + if (type !== "function_call_output" && type !== "custom_tool_call_output") continue; + const callId = typeof payload.call_id === "string" ? payload.call_id : ""; + if (callId.length === 0) continue; + const parsed = parseToolOutputStreams(String(payload.output || "")); + if (parsed.output.trim().length === 0 && parsed.stdout.trim().length === 0 && parsed.stderr.trim().length === 0) continue; + outputs.set(callId, { + callId, + at: typeof record.timestamp === "string" ? record.timestamp : "", + stdout: parsed.stdout, + stderr: parsed.stderr, + output: parsed.output, + exitCode: parsed.exitCode, + }); + } + } catch (error) { + logger("warn", "codex_session_command_output_parse_failed", { path, error: errorToJson(error) }); + } + if (signature !== null) codexSessionCommandOutputCache.set(path, { signature, outputs }); + if (codexSessionCommandOutputCache.size > 40) { + const firstKey = codexSessionCommandOutputCache.keys().next().value; + if (typeof firstKey === "string") codexSessionCommandOutputCache.delete(firstKey); + } + return outputs; +} + +function codexSessionCommandOutputsByCallId(task: QueueTask): Map { + const path = findCodexSessionFile(task); + return path === null ? new Map() : parseCodexSessionCommandOutputs(path); +} + +function isInlineFileChangeInput(name: string, input: string): boolean { + return name === "apply_patch" && /^\*\*\* Begin Patch/mu.test(input); +} + +function trimmedPatchForTrace(input: string): string { + const normalized = input.replace(/\r\n/gu, "\n").replace(/\r/gu, "\n").trimEnd(); + const maxChars = 120_000; + if (normalized.length <= maxChars) return normalized; + return `${normalized.slice(0, maxChars)}\n...[patch content truncated: ${normalized.length - maxChars} chars omitted]`; +} + +function parseCodexSessionFileChanges(path: string): Map { + const signature = codexSessionSignature(path); + const cached = codexSessionFileChangeCache.get(path); + if (signature !== null && cached?.signature === signature) return cached.changes; + + const changes = new Map(); + try { + const text = readFileSync(path, "utf8"); + for (const line of text.split(/\r?\n/u)) { + if (line.trim().length === 0) continue; + let record: Record; + try { + record = JSON.parse(line) as Record; + } catch { + continue; + } + const payload = extractRecord(record.payload); + if (payload === null) continue; + const type = String(payload.type || ""); + const callId = typeof payload.call_id === "string" ? payload.call_id : ""; + if (callId.length === 0) continue; + if (type === "custom_tool_call") { + const name = String(payload.name || ""); + const input = typeof payload.input === "string" ? payload.input : ""; + if (!isInlineFileChangeInput(name, input)) continue; + changes.set(callId, { + callId, + at: typeof record.timestamp === "string" ? record.timestamp : "", + name, + input: trimmedPatchForTrace(input), + output: "", + }); + continue; + } + if (type === "custom_tool_call_output") { + const existing = changes.get(callId); + if (existing === undefined) continue; + existing.output = parseCodexToolOutputText(String(payload.output || "")); + } + } + } catch (error) { + logger("warn", "codex_session_file_change_parse_failed", { path, error: errorToJson(error) }); + } + if (signature !== null) codexSessionFileChangeCache.set(path, { signature, changes }); + if (codexSessionFileChangeCache.size > 40) { + const firstKey = codexSessionFileChangeCache.keys().next().value; + if (typeof firstKey === "string") codexSessionFileChangeCache.delete(firstKey); + } + return changes; +} + +function codexSessionFileChangesByCallId(task: QueueTask): Map { + const path = findCodexSessionFile(task); + return path === null ? new Map() : parseCodexSessionFileChanges(path); +} + +function fileChangeTextWithInlinePatch(item: LiveOutput, changes: Map): string { + if (item.method !== "item/fileChange/outputDelta" || typeof item.itemId !== "string") return item.text; + const change = changes.get(item.itemId); + if (change === undefined || change.input.length === 0 || item.text.includes(change.input)) return item.text; + return `${item.text.trimEnd()}\n\n${change.input}\n`; +} + function compactNoisyLine(line: string): string { const compact = line.replace(/\s+/gu, " ").trimEnd(); const hasEncodedBlob = /[A-Za-z0-9+/=]{220,}/u.test(compact); @@ -421,12 +1305,40 @@ function outputPreview(text: string): { text: string; omittedLines: number } { } function fullMessageBody(text: string): { text: string; omittedLines: number } { - return { text: text.replace(/\u001b\[[0-9;]*m/gu, "").trimEnd(), omittedLines: 0 }; + return linePreview(text.replace(/\u001b\[[0-9;]*m/gu, "").trimEnd(), 12, 4000); } -function transcriptLine(kind: TranscriptKind, at: string, seq: number, title: string, rawSeqs: number[], body = "", command = "", status?: string): TranscriptLine { - const bodyInfo = kind === "message" ? fullMessageBody(body) : outputPreview(body); - const commandInfo = command.length > 0 ? commandPreview(command) : { text: "", omittedLines: 0 }; +function timestampMs(value: string | null | undefined): number | null { + if (typeof value !== "string" || value.length === 0) return null; + const time = Date.parse(value); + return Number.isFinite(time) ? time : null; +} + +function nonNegativeElapsed(startMs: number | null, endMs: number | null): number | null { + if (startMs === null || endMs === null) return null; + return Math.max(0, endMs - startMs); +} + +function taskTiming(task: QueueTask): JsonValue { + const nowMs = Date.now(); + const createdMs = timestampMs(task.createdAt); + const startedMs = timestampMs(task.startedAt); + const finishedMs = timestampMs(task.finishedAt); + const updatedMs = timestampMs(task.updatedAt); + const terminal = task.status === "succeeded" || task.status === "failed" || task.status === "canceled"; + const effectiveEndMs = finishedMs ?? (terminal ? updatedMs : nowMs); + return { + queueWaitMs: nonNegativeElapsed(createdMs, startedMs ?? (terminal ? effectiveEndMs : nowMs)), + durationMs: nonNegativeElapsed(startedMs, effectiveEndMs), + totalElapsedMs: nonNegativeElapsed(createdMs, effectiveEndMs), + effectiveEndAt: finishedMs !== null ? task.finishedAt : terminal ? task.updatedAt : null, + running: !terminal && startedMs !== null, + }; +} + +function transcriptLine(kind: TranscriptKind, at: string, seq: number, title: string, rawSeqs: number[], body = "", command = "", status?: string, fullText = false): TranscriptLine { + const bodyInfo = fullText ? completeTraceText(body) : kind === "message" ? fullMessageBody(body) : kind === "edited" ? editedOutputPreview(body) : outputPreview(body); + const commandInfo = command.length > 0 ? fullText ? completeTraceText(displayCommand(command)) : commandPreview(command) : { text: "", omittedLines: 0 }; return { seq, at, @@ -533,32 +1445,85 @@ function makeTaskId(): string { return `codex_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`; } +function isCodexTaskId(value: string): boolean { + return /^codex_\d+_[A-Za-z0-9_-]+$/u.test(value.trim()); +} + +function addUniqueTaskId(ids: string[], value: string): void { + const id = value.trim(); + if (isCodexTaskId(id) && !ids.includes(id)) ids.push(id); +} + +function collectTaskIdsFromValue(value: unknown, ids: string[]): void { + if (typeof value === "string") { + for (const part of value.split(/[\s,,;;]+/u)) addUniqueTaskId(ids, part); + return; + } + if (Array.isArray(value)) { + for (const item of value) collectTaskIdsFromValue(item, ids); + } +} + +function referenceTaskIdsFromPrompt(prompt: string): string[] { + const ids: string[] = []; + const patterns = [ + /引用\s+Codex Queue\s+任务\s+(codex_\d+_[A-Za-z0-9_-]+)/giu, + /\bcodex\s+task\s+(codex_\d+_[A-Za-z0-9_-]+)/giu, + /(?:引用|上下文|context|reference)[^\n]{0,160}\b(codex_\d+_[A-Za-z0-9_-]+)/giu, + ]; + for (const pattern of patterns) { + for (const match of prompt.matchAll(pattern)) addUniqueTaskId(ids, String(match[1] ?? "")); + } + return ids; +} + +function collectReferenceTaskIds(record: Record, prompt: string): string[] { + const ids: string[] = []; + collectTaskIdsFromValue(record.referenceTaskId, ids); + collectTaskIdsFromValue(record.referenceTaskIds, ids); + for (const id of referenceTaskIdsFromPrompt(prompt)) addUniqueTaskId(ids, id); + return ids; +} + 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.queueId === "string" && record.queueId.trim().length > 0) request.queueId = normalizeQueueId(record.queueId); 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); + if (typeof record.maxAttempts === "number" && Number.isInteger(record.maxAttempts) && record.maxAttempts > 0) request.maxAttempts = Math.min(maxTaskAttempts, record.maxAttempts); + const referenceTaskIds = collectReferenceTaskIds(record, record.prompt); + if (referenceTaskIds.length > 0) request.referenceTaskIds = referenceTaskIds; return request; } function createTask(request: QueueTaskRequest): QueueTask { const at = nowIso(); + const basePrompt = request.basePrompt ?? userPromptForDisplay(request.prompt); + const referenceTaskIds = request.referenceTaskIds ?? []; + const model = request.model ?? config.defaultModel; + const queueId = normalizeQueueId(request.queueId); + ensureQueue(queueId); return { id: makeTaskId(), + queueId, prompt: request.prompt, + basePrompt, + referenceTaskIds, + referenceInjection: request.referenceInjection ?? null, cwd: resolve(request.cwd ?? config.defaultWorkdir), - model: request.model ?? config.defaultModel, - reasoningEffort: request.reasoningEffort ?? config.defaultReasoningEffort, + model, + reasoningEffort: resolveReasoningEffort(model, request.reasoningEffort), maxAttempts: request.maxAttempts ?? config.defaultMaxAttempts, status: "queued", createdAt: at, updatedAt: at, startedAt: null, finishedAt: null, + readAt: null, currentAttempt: 0, currentMode: null, codexThreadId: null, @@ -566,6 +1531,8 @@ function createTask(request: QueueTaskRequest): QueueTask { finalResponse: "", lastError: null, lastJudge: null, + judgeFailCount: 0, + promptHistory: [], output: [], events: [], attempts: [], @@ -575,23 +1542,150 @@ function createTask(request: QueueTaskRequest): QueueTask { }; } -function appendOutput(task: QueueTask, channel: OutputChannel, text: string, method?: string, itemId?: string, append = false): void { - if (text.length === 0) return; +function taskOutputArchivePath(taskId: string): string { + return resolve(config.outputArchiveDir, `${taskId}.jsonl`); +} + +function serializeArchivedOutput(output: LiveOutput, op: ArchivedLiveOutput["op"], text: string): string { + const record: ArchivedLiveOutput = { + seq: output.seq, + at: output.at, + channel: output.channel, + text, + ...(output.method === undefined ? {} : { method: output.method }), + ...(output.itemId === undefined ? {} : { itemId: output.itemId }), + op, + }; + return `${JSON.stringify(record)}\n`; +} + +function ensureTaskOutputArchiveSeeded(task: QueueTask): void { + if (outputArchiveSeededTasks.has(task.id)) return; + mkdirSync(config.outputArchiveDir, { recursive: true }); + const path = taskOutputArchivePath(task.id); + if (!existsSync(path) && task.output.length > 0) { + const seed = task.output.map((output) => serializeArchivedOutput(output, "set", output.text)).join(""); + appendFileSync(path, seed, "utf8"); + } + outputArchiveSeededTasks.add(task.id); +} + +function appendOutputArchive(task: QueueTask, output: LiveOutput, op: ArchivedLiveOutput["op"], text: string): void { + try { + mkdirSync(config.outputArchiveDir, { recursive: true }); + appendFileSync(taskOutputArchivePath(task.id), serializeArchivedOutput(output, op, text), "utf8"); + } catch (error) { + logger("error", "codex_output_archive_write_failed", { taskId: task.id, error: errorToJson(error) }); + } +} + +function archiveRecordToOutput(value: unknown): ArchivedLiveOutput | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + const record = value as Record; + const seq = Number(record.seq); + if (!Number.isFinite(seq)) return null; + const channel = String(record.channel || "system") as OutputChannel; + if (!["system", "user", "assistant", "reasoning", "command", "diff", "tool", "error"].includes(channel)) return null; + return { + seq, + at: typeof record.at === "string" ? record.at : nowIso(), + channel, + text: typeof record.text === "string" ? record.text : "", + ...(typeof record.method === "string" ? { method: record.method } : {}), + ...(typeof record.itemId === "string" ? { itemId: record.itemId } : {}), + op: record.op === "append" ? "append" : "set", + }; +} + +function archivedTaskOutput(task: QueueTask): LiveOutput[] { + const path = taskOutputArchivePath(task.id); + if (!existsSync(path)) return []; + const bySeq = new Map(); + try { + const text = readFileSync(path, "utf8"); + for (const line of text.split(/\r?\n/u)) { + if (line.trim().length === 0) continue; + const record = archiveRecordToOutput(JSON.parse(line) as unknown); + if (record === null) continue; + const existing = bySeq.get(record.seq); + if (record.op === "append" && existing !== undefined) { + existing.text += record.text; + existing.at = record.at; + existing.channel = record.channel; + existing.method = record.method; + existing.itemId = record.itemId; + } else { + const { op: _op, ...output } = record; + bySeq.set(record.seq, output); + } + } + } catch (error) { + logger("warn", "codex_output_archive_read_failed", { taskId: task.id, error: errorToJson(error) }); + return []; + } + return Array.from(bySeq.values()).sort((left, right) => Number(left.seq) - Number(right.seq)); +} + +function taskFullOutput(task: QueueTask): LiveOutput[] { + const bySeq = new Map(); + for (const output of archivedTaskOutput(task)) bySeq.set(output.seq, output); + for (const output of task.output) bySeq.set(output.seq, output); + return Array.from(bySeq.values()).sort((left, right) => Number(left.seq) - Number(right.seq)); +} + +function outputArchiveSignature(task: QueueTask): string { + try { + const stat = statSync(taskOutputArchivePath(task.id)); + return `${stat.size}:${Math.floor(stat.mtimeMs)}`; + } catch { + return "none"; + } +} + +function appendOutput(task: QueueTask, channel: OutputChannel, text: string, method?: string, itemId?: string, append = false): LiveOutput | null { + if (text.length === 0) return null; + try { + ensureTaskOutputArchiveSeeded(task); + } catch (error) { + logger("error", "codex_output_archive_seed_failed", { taskId: task.id, error: errorToJson(error) }); + } const last = task.output[task.output.length - 1]; + let output: LiveOutput; + let archiveOp: ArchivedLiveOutput["op"] = "set"; + let archiveText = text; if (append && last !== undefined && last.channel === channel && last.itemId === itemId && last.method === method && last.text.length < 24_000) { last.text += text; last.at = nowIso(); + output = last; + archiveOp = "append"; } else { - task.output.push({ seq: state.nextSeq++, at: nowIso(), channel, text, method, itemId }); + output = { seq: state.nextSeq++, at: nowIso(), channel, text, method, itemId }; + task.output.push(output); } - if (task.output.length > 600) task.output.splice(0, task.output.length - 600); + appendOutputArchive(task, output, archiveOp, archiveText); + if (config.maxInMemoryOutputRecords > 0 && task.output.length > config.maxInMemoryOutputRecords) task.output.splice(0, task.output.length - config.maxInMemoryOutputRecords); task.updatedAt = nowIso(); + markTaskDirty(task.id); + schedulePersistState(); + return output; +} + +function appendPromptHistory(task: QueueTask, output: LiveOutput | null, method: PromptHistoryItem["method"], text: string): void { + if (output === null) return; + task.promptHistory = mergePromptHistory([...(Array.isArray(task.promptHistory) ? task.promptHistory : []), { + seq: output.seq, + at: output.at, + method, + text, + }]); + markTaskDirty(task.id); schedulePersistState(); } function addEvent(task: QueueTask, event: CodexEventSummary): void { task.events.push(event); - if (task.events.length > 400) task.events.splice(0, task.events.length - 400); + if (config.maxInMemoryEventRecords > 0 && task.events.length > config.maxInMemoryEventRecords) task.events.splice(0, task.events.length - config.maxInMemoryEventRecords); + markTaskDirty(task.id); } function commandKind(command: string): TranscriptKind { @@ -606,27 +1700,117 @@ function commandKindLabel(kind: TranscriptKind): string { return "Ran"; } -function buildTaskTranscript(task: QueueTask, limit = 180): TranscriptLine[] { +function promptLineCount(text: string): number { + return text.length > 0 ? text.split(/\r\n|\r|\n/u).length : 0; +} + +function promptSnapshot(text: string, maxPreviewChars = 1200): { text: string; preview: string; chars: number; lines: number; truncated: boolean } { + const normalized = text.trimEnd(); + const preview = safePreview(normalized, maxPreviewChars); + return { + text: normalized, + preview, + chars: normalized.length, + lines: promptLineCount(normalized), + truncated: preview.length < normalized.length, + }; +} + +function setAttemptInputPrompt(attempt: AttemptSummary, prompt: string): void { + const snapshot = promptSnapshot(prompt, 1200); + if (snapshot.chars === 0) return; + attempt.inputPrompt = snapshot.text; + attempt.inputPromptPreview = snapshot.preview; + attempt.inputPromptChars = snapshot.chars; + attempt.inputPromptLines = snapshot.lines; +} + +function setAttemptFeedbackPrompt(attempt: AttemptSummary | undefined, prompt: string, source: string, forAttempt: number | null): void { + if (attempt === undefined) return; + const snapshot = promptSnapshot(prompt, 1600); + if (snapshot.chars === 0) return; + attempt.feedbackPrompt = snapshot.text; + attempt.feedbackPromptPreview = snapshot.preview; + attempt.feedbackPromptChars = snapshot.chars; + attempt.feedbackPromptLines = snapshot.lines; + attempt.feedbackPromptSource = source; + attempt.feedbackPromptForAttempt = forAttempt; +} + +function taskInitialPromptLine(task: QueueTask, fullText = false): TranscriptLine | null { + const prompt = (task.basePrompt || userPromptForDisplay(task.prompt)).trimEnd(); + if (prompt.length === 0) return null; + const line = transcriptLine("message", task.createdAt, 0.5, "Submitted prompt", [], prompt, "", "enqueue", fullText); + const fullPrompt = task.prompt.trimEnd(); + if (fullText && fullPrompt.length > 0 && fullPrompt !== prompt) { + line.fullPrompt = fullPrompt; + line.fullPromptLines = promptLineCount(fullPrompt); + line.fullPromptChars = fullPrompt.length; + line.foldedReferencePrompt = true; + } + return line; +} + +function promptHistoryTranscriptLines(task: QueueTask, fullText = false): TranscriptLine[] { + return mergePromptHistory([...(Array.isArray(task.promptHistory) ? task.promptHistory : []), ...outputPromptHistory(task)]) + .map((item) => transcriptLine("message", item.at, item.seq, "Steer prompt", [item.seq], item.text, "", item.method, fullText)); +} + +function sortTranscript(entries: TranscriptLine[]): TranscriptLine[] { + return entries.sort((left, right) => Number(left.seq) - Number(right.seq)); +} + +function boundedTranscript(entries: TranscriptLine[], limit: number): TranscriptLine[] { + sortTranscript(entries); + if (entries.length <= limit) return entries; + const first = entries[0]; + if (first?.title === "Submitted prompt" && first.rawSeqs.length === 0 && limit > 1) { + return [first, ...entries.slice(-(limit - 1))]; + } + return entries.slice(-limit); +} + +function buildTaskTranscript(task: QueueTask, limit = 180, rawOutputWindow = 0, fullText = false): TranscriptLine[] { const entries: TranscriptLine[] = []; - let activeCommand: { seq: number; at: string; command: string; status?: string; body: string; rawSeqs: number[] } | null = null; + const initialPrompt = taskInitialPromptLine(task, fullText); + if (initialPrompt !== null) entries.push(initialPrompt); + const promptHistoryLines = promptHistoryTranscriptLines(task, fullText); + const promptHistorySeqs = new Set(promptHistoryLines.map((line) => line.seq)); + entries.push(...promptHistoryLines); + let activeCommand: { seq: number; at: string; command: string; status?: string; body: string; rawSeqs: number[]; itemId?: string } | null = null; const flushCommand = (): void => { if (activeCommand === null) return; const kind = commandKind(activeCommand.command); - entries.push(transcriptLine( + const output = activeCommand.itemId === undefined ? null : commandOutputs.get(activeCommand.itemId) ?? null; + const body = activeCommand.body.length > 0 ? activeCommand.body : formatCommandOutput(output); + entries.push(addCommandOutputStreams(transcriptLine( kind, activeCommand.at, activeCommand.seq, shortCommandTitle(activeCommand.command), activeCommand.rawSeqs, - activeCommand.body, + body, activeCommand.command, activeCommand.status, - )); + fullText, + ), output, fullText)); activeCommand = null; }; - for (const item of task.output) { + const outputSource = rawOutputWindow > 0 ? task.output : taskFullOutput(task); + const outputItems = rawOutputWindow > 0 && outputSource.length > rawOutputWindow + ? outputSource.slice(-rawOutputWindow) + : outputSource; + const commandOutputs = outputItems.some((item) => item.channel === "command" && typeof item.itemId === "string") + ? codexSessionCommandOutputsByCallId(task) + : new Map(); + const fileChangeInputs = outputItems.some((item) => item.channel === "diff" && item.method === "item/fileChange/outputDelta" && typeof item.itemId === "string") + ? codexSessionFileChangesByCallId(task) + : new Map(); + for (const item of outputItems) { + if (initialPrompt !== null && item.channel === "user" && item.method === "enqueue") continue; + if (item.channel === "user" && item.method === "turn/steer" && promptHistorySeqs.has(item.seq)) continue; if (item.channel === "command" && item.method === "item/started") { flushCommand(); const parsed = parseCommandLine(item.text); @@ -637,6 +1821,7 @@ function buildTaskTranscript(task: QueueTask, limit = 180): TranscriptLine[] { status: parsed?.status, body: "", rawSeqs: [item.seq], + ...(typeof item.itemId === "string" ? { itemId: item.itemId } : {}), }; continue; } @@ -645,7 +1830,8 @@ function buildTaskTranscript(task: QueueTask, limit = 180): TranscriptLine[] { activeCommand.body += item.text; activeCommand.rawSeqs.push(item.seq); } else { - entries.push(transcriptLine("ran", item.at, item.seq, "Command output", [item.seq], item.text)); + const output = typeof item.itemId === "string" ? commandOutputs.get(item.itemId) ?? null : null; + entries.push(addCommandOutputStreams(transcriptLine("ran", item.at, item.seq, "Command output", [item.seq], item.text || formatCommandOutput(output), "", undefined, fullText), output, fullText)); } continue; } @@ -658,22 +1844,24 @@ function buildTaskTranscript(task: QueueTask, limit = 180): TranscriptLine[] { } else { const command = parsed?.command || item.text; const kind = commandKind(command); - entries.push(transcriptLine(kind, item.at, item.seq, shortCommandTitle(command), [item.seq], "", command, parsed?.status)); + const output = typeof item.itemId === "string" ? commandOutputs.get(item.itemId) ?? null : null; + entries.push(addCommandOutputStreams(transcriptLine(kind, item.at, item.seq, shortCommandTitle(command), [item.seq], formatCommandOutput(output), command, parsed?.status, fullText), output, fullText)); } continue; } flushCommand(); if (item.channel === "diff") { - entries.push(transcriptLine("edited", item.at, item.seq, "Edited files", [item.seq], item.text, "", item.method)); + const text = fileChangeTextWithInlinePatch(item, fileChangeInputs); + entries.push(transcriptLine("edited", item.at, item.seq, "Edited files", [item.seq], text, "", item.method, fullText)); } else if (item.channel === "error") { - entries.push(transcriptLine("error", item.at, item.seq, "Error", [item.seq], item.text, "", item.method)); + entries.push(transcriptLine("error", item.at, item.seq, "Error", [item.seq], item.text, "", item.method, fullText)); } else if (item.channel === "assistant") { - entries.push(transcriptLine("message", item.at, item.seq, "Assistant message", [item.seq], item.text, "", item.method)); + entries.push(transcriptLine("message", item.at, item.seq, "Assistant message", [item.seq], item.text, "", item.method, fullText)); } else if (item.channel === "reasoning") { - entries.push(transcriptLine("message", item.at, item.seq, "Reasoning", [item.seq], item.text, "", item.method)); + entries.push(transcriptLine("message", item.at, item.seq, "Reasoning", [item.seq], item.text, "", item.method, fullText)); } else if (item.channel === "user") { - entries.push(transcriptLine("message", item.at, item.seq, item.method === "enqueue" ? "Submitted prompt" : "User prompt", [item.seq], item.text, "", item.method)); + entries.push(transcriptLine("message", item.at, item.seq, item.method === "enqueue" ? "Submitted prompt" : "User prompt", [item.seq], item.text, "", item.method, fullText)); } else { const title = item.method === "queue" && item.text.startsWith("attempt ") ? "Attempt started" @@ -682,38 +1870,167 @@ function buildTaskTranscript(task: QueueTask, limit = 180): TranscriptLine[] { : item.method === "judge" ? "Judge result" : "System"; - entries.push(transcriptLine("system", item.at, item.seq, title, [item.seq], item.text, "", item.method)); + entries.push(transcriptLine("system", item.at, item.seq, title, [item.seq], item.text, "", item.method, fullText)); } } flushCommand(); - return entries.slice(-limit); + return boundedTranscript(entries, limit); +} + +function compactTaskTranscriptLine(item: LiveOutput, title: string, kind: TranscriptKind): TranscriptLine { + return { + seq: item.seq, + at: item.at, + kind, + title, + status: item.method, + bodyPreview: prefixPreview(item.text.replace(/\u001b\[[0-9;]*m/gu, ""), 900), + rawSeqs: [item.seq], + }; +} + +function buildCompactTaskTranscript(task: QueueTask, limit = 12, rawOutputWindow = 24): TranscriptLine[] { + const entries: TranscriptLine[] = []; + for (const item of task.promptHistory.slice(-2)) { + entries.push({ + seq: item.seq, + at: item.at, + kind: "message", + title: "Steer prompt", + status: item.method, + bodyPreview: prefixPreview(item.text, 900), + rawSeqs: [item.seq], + }); + } + const outputItems = task.output.slice(-rawOutputWindow); + const fileChangeInputs = outputItems.some((item) => item.channel === "diff" && item.method === "item/fileChange/outputDelta" && typeof item.itemId === "string") + ? codexSessionFileChangesByCallId(task) + : new Map(); + for (const item of outputItems) { + if (item.channel === "user" && item.method === "enqueue") continue; + if (item.channel === "command") { + const isOutput = item.method === "item/commandExecution/outputDelta"; + entries.push({ + seq: item.seq, + at: item.at, + kind: isOutput ? "ran" : item.method === "item/started" ? "ran" : "system", + title: isOutput ? "Command output" : item.method === "item/started" ? "Command started" : "Command completed", + status: item.method, + commandPreview: isOutput ? undefined : prefixPreview(item.text, 900), + bodyPreview: isOutput ? prefixPreview(item.text, 900) : undefined, + rawSeqs: [item.seq], + }); + } else if (item.channel === "diff") { + entries.push(compactTaskTranscriptLine({ ...item, text: fileChangeTextWithInlinePatch(item, fileChangeInputs) }, "Edited files", "edited")); + } else if (item.channel === "error") { + entries.push(compactTaskTranscriptLine(item, "Error", "error")); + } else if (item.channel === "assistant" || item.channel === "reasoning" || item.channel === "user") { + entries.push(compactTaskTranscriptLine(item, item.channel === "assistant" ? "Assistant message" : item.channel === "reasoning" ? "Reasoning" : "User prompt", "message")); + } else { + entries.push(compactTaskTranscriptLine(item, item.method === "judge" ? "Judge result" : "System", "system")); + } + } + return boundedTranscript(entries, limit); +} + +function transcriptSignature(task: QueueTask): string { + const last = task.output.at(-1); + return `${task.updatedAt}:${task.output.length}:${last?.seq ?? 0}:${last?.text.length ?? 0}:${outputArchiveSignature(task)}:${task.status}:${task.createdAt}:${task.basePrompt.length}:${task.prompt.length}:${task.promptHistory.length}:${task.promptHistory.at(-1)?.seq ?? 0}`; +} + +function cachedTranscript(task: QueueTask, fullText: boolean): TranscriptLine[] { + const signature = transcriptSignature(task); + const cached = transcriptCache.get(task.id); + if (cached?.signature === signature) { + const cachedTranscript = fullText ? cached.fullTranscript : cached.previewTranscript; + if (cachedTranscript !== undefined) return cachedTranscript; + } + const transcript = buildTaskTranscript(task, Number.MAX_SAFE_INTEGER, 0, fullText); + const next: { signature: string; previewTranscript?: TranscriptLine[]; fullTranscript?: TranscriptLine[] } = cached?.signature === signature ? cached : { signature }; + if (fullText) next.fullTranscript = transcript; + else next.previewTranscript = transcript; + transcriptCache.set(task.id, next); + if (transcriptCache.size > 80) { + const firstKey = transcriptCache.keys().next().value; + if (typeof firstKey === "string") transcriptCache.delete(firstKey); + } + return transcript; +} + +function cachedFullTranscript(task: QueueTask): TranscriptLine[] { + return cachedTranscript(task, true); +} + +function cachedPreviewTranscript(task: QueueTask): TranscriptLine[] { + return cachedTranscript(task, false); } function outputForResponse(task: QueueTask, includeRaw: boolean): LiveOutput[] { - if (includeRaw) return task.output; + if (includeRaw) return taskFullOutput(task); return task.output.slice(-80).map((item) => ({ ...item, text: safePreview(item.text, 4000) })); } +function attemptForResponse(attempt: AttemptSummary, full = false): JsonValue { + const finalResponse = String(attempt.finalResponse ?? ""); + const inputPrompt = String(attempt.inputPrompt ?? ""); + const feedbackPrompt = String(attempt.feedbackPrompt ?? ""); + return { + ...attempt, + inputPrompt: full ? inputPrompt : undefined, + inputPromptPreview: safePreview(String(attempt.inputPromptPreview || inputPrompt), full ? 3000 : 1200), + inputPromptChars: Number(attempt.inputPromptChars ?? inputPrompt.length), + inputPromptLines: Number(attempt.inputPromptLines ?? promptLineCount(inputPrompt)), + finalResponse: full ? finalResponse : safePreview(finalResponse, 1200), + finalResponsePreview: safePreview(String(attempt.finalResponsePreview || finalResponse), full ? 3000 : 1200), + finalResponseChars: Number(attempt.finalResponseChars ?? finalResponse.length), + feedbackPrompt: full ? feedbackPrompt : undefined, + feedbackPromptPreview: safePreview(String(attempt.feedbackPromptPreview || feedbackPrompt), full ? 3000 : 1200), + feedbackPromptChars: Number(attempt.feedbackPromptChars ?? feedbackPrompt.length), + feedbackPromptLines: Number(attempt.feedbackPromptLines ?? promptLineCount(feedbackPrompt)), + stderrTail: full ? attempt.stderrTail : safePreview(attempt.stderrTail, 1200), + } as unknown as JsonValue; +} + function taskForResponse(task: QueueTask, full = false, includeRaw = full): JsonValue { + const displayPrompt = task.basePrompt || userPromptForDisplay(task.prompt); return { ...task, + judgeFailRetryLimit, prompt: full ? task.prompt : safePreview(task.prompt, 2000), + basePrompt: full ? task.basePrompt : safePreview(task.basePrompt, 2000), + displayPrompt: full ? displayPrompt : safePreview(displayPrompt, 2000), + referenceTaskIds: task.referenceTaskIds, + referenceInjection: task.referenceInjection, finalResponse: full ? task.finalResponse : safePreview(task.finalResponse, 5000), + terminalUnread: terminalTaskUnread(task), + attempts: task.attempts.map((attempt) => attemptForResponse(attempt, full)), output: outputForResponse(task, includeRaw), events: includeRaw ? task.events : task.events.slice(-120), - transcript: buildTaskTranscript(task, full ? 360 : 120), + transcript: full ? fullTranscript(task) : buildTaskTranscript(task, 120, 0), } as unknown as JsonValue; } function fullTranscript(task: QueueTask): TranscriptLine[] { - return buildTaskTranscript(task, Number.MAX_SAFE_INTEGER); + return cachedFullTranscript(task); } function taskForMetaResponse(task: QueueTask): JsonValue { - const transcript = fullTranscript(task); + const fullOutput = taskFullOutput(task); + const lastOutputSeq = fullOutput.at(-1)?.seq ?? 0; + const displayPrompt = task.basePrompt || userPromptForDisplay(task.prompt); return { id: task.id, + queueId: queueIdOf(task), prompt: task.prompt, + basePrompt: task.basePrompt, + displayPrompt, + promptChars: task.prompt.length, + basePromptChars: task.basePrompt.length, + displayPromptChars: displayPrompt.length, + finalResponseChars: task.finalResponse.length, + summaryOnly: false, + referenceTaskIds: task.referenceTaskIds, + referenceInjection: task.referenceInjection, cwd: task.cwd, model: task.model, reasoningEffort: task.reasoningEffort, @@ -723,51 +2040,55 @@ function taskForMetaResponse(task: QueueTask): JsonValue { updatedAt: task.updatedAt, startedAt: task.startedAt, finishedAt: task.finishedAt, + readAt: task.readAt, currentAttempt: task.currentAttempt, currentMode: task.currentMode, + judgeFailCount: task.judgeFailCount, + judgeFailRetryLimit, codexThreadId: task.codexThreadId, activeTurnId: task.activeTurnId, - finalResponse: safePreview(task.finalResponse, 20000), + finalResponse: task.finalResponse, lastError: task.lastError, lastJudge: task.lastJudge, + promptHistory: task.promptHistory, attempts: task.attempts, cancelRequested: task.cancelRequested, + terminalUnread: terminalTaskUnread(task), nextMode: task.nextMode, - outputCount: task.output.length, + outputCount: fullOutput.length, + retainedOutputCount: task.output.length, eventCount: task.events.length, - transcriptCount: transcript.length, - transcriptMaxSeq: transcript.at(-1)?.seq ?? 0, + transcriptCount: null, + transcriptMaxSeq: lastOutputSeq, + timing: taskTiming(task), transcript: [], output: [], events: [], } as unknown as JsonValue; } -function transcriptChunkResponse(task: QueueTask, url: URL): Response { - const afterSeqRaw = Number(url.searchParams.get("afterSeq") ?? 0); - const afterSeq = Number.isFinite(afterSeqRaw) ? afterSeqRaw : 0; - const limit = parseLimit(url); - const transcript = fullTranscript(task); - const chunk = transcript.filter((line) => Number(line.seq) > afterSeq).slice(0, limit); - const nextAfterSeq = chunk.at(-1)?.seq ?? afterSeq; - return jsonResponse({ - ok: true, - taskId: task.id, - status: task.status, - updatedAt: task.updatedAt, - transcript: chunk, - afterSeq, - nextAfterSeq, - hasMore: transcript.some((line) => Number(line.seq) > Number(nextAfterSeq)), - total: transcript.length, - maxSeq: transcript.at(-1)?.seq ?? 0, - }); -} - -function taskForListResponse(task: QueueTask): JsonValue { +function taskForCompactMetaResponse(task: QueueTask): JsonValue { + const displayPrompt = task.basePrompt || userPromptForDisplay(task.prompt); + const lastOutputSeq = task.output.at(-1)?.seq ?? 0; return { id: task.id, - prompt: safePreview(task.prompt, 2000), + queueId: queueIdOf(task), + prompt: prefixPreview(task.prompt, 900), + basePrompt: prefixPreview(task.basePrompt, 900), + displayPrompt: prefixPreview(displayPrompt, 900), + promptChars: task.prompt.length, + basePromptChars: task.basePrompt.length, + displayPromptChars: displayPrompt.length, + finalResponseChars: task.finalResponse.length, + summaryOnly: true, + referenceTaskIds: task.referenceTaskIds, + referenceInjection: task.referenceInjection === null ? null : { + injectedAt: task.referenceInjection.injectedAt, + itemCount: task.referenceInjection.itemCount, + directReferenceTaskIds: task.referenceInjection.directReferenceTaskIds, + maxRounds: task.referenceInjection.maxRounds, + truncated: task.referenceInjection.truncated, + }, cwd: task.cwd, model: task.model, reasoningEffort: task.reasoningEffort, @@ -777,39 +2098,1326 @@ function taskForListResponse(task: QueueTask): JsonValue { updatedAt: task.updatedAt, startedAt: task.startedAt, finishedAt: task.finishedAt, + readAt: task.readAt, currentAttempt: task.currentAttempt, currentMode: task.currentMode, + judgeFailCount: task.judgeFailCount, + judgeFailRetryLimit, + codexThreadId: task.codexThreadId, + activeTurnId: task.activeTurnId, + finalResponse: prefixPreview(task.finalResponse, 1200), + lastError: task.lastError, + lastJudge: task.lastJudge, + promptHistory: task.promptHistory.slice(-8), + attempts: task.attempts.slice(-3).map((attempt) => attemptForResponse(attempt, false)), + cancelRequested: task.cancelRequested, + terminalUnread: terminalTaskUnread(task), + nextMode: task.nextMode, + outputCount: task.output.length, + eventCount: task.events.length, + transcriptCount: null, + transcriptMaxSeq: lastOutputSeq, + timing: taskTiming(task), + transcript: [], + output: [], + events: [], + } as unknown as JsonValue; +} + +function lastAssistantMessage(task: QueueTask, transcript = fullTranscript(task)): JsonValue { + const assistantLine = transcript.slice().reverse().find((line) => line.kind === "message" && line.title === "Assistant message"); + const text = task.finalResponse.trim().length > 0 ? task.finalResponse.trim() : String(assistantLine?.bodyPreview ?? "").trim(); + return { + text, + at: assistantLine?.at ?? task.finishedAt ?? task.updatedAt, + seq: assistantLine?.seq ?? null, + source: task.finalResponse.trim().length > 0 ? "finalResponse" : assistantLine !== undefined ? "transcript" : "none", + }; +} + +function parseToolLimit(url: URL): number { + const value = Number(url.searchParams.get("toolLimit") ?? 160); + return Number.isInteger(value) && value > 0 ? Math.min(500, value) : 160; +} + +function taskToolSummaryForLimit(task: QueueTask, limit: number, transcript = fullTranscript(task)): JsonValue { + const allTools = transcript.filter((line) => line.kind === "ran" || line.kind === "explored" || line.kind === "edited"); + const boundedLimit = Math.max(1, Math.min(500, Math.floor(limit))); + const start = Math.max(0, allTools.length - boundedLimit); + return { + count: allTools.length, + returned: allTools.length - start, + limit: boundedLimit, + truncated: start > 0, + items: allTools.slice(start).map((line) => ({ + seq: line.seq, + at: line.at, + kind: line.kind, + title: line.title, + status: line.status ?? null, + commandPreview: line.commandPreview ?? "", + commandOmittedLines: line.commandOmittedLines ?? 0, + outputPreview: line.bodyPreview ?? "", + outputOmittedLines: line.bodyOmittedLines ?? 0, + rawSeqs: line.rawSeqs, + })), + } as unknown as JsonValue; +} + +function taskToolSummary(task: QueueTask, url: URL, transcript = fullTranscript(task)): JsonValue { + return taskToolSummaryForLimit(task, parseToolLimit(url), transcript); +} + +function uniqueStrings(values: string[], limit = 20): string[] { + const result: string[] = []; + for (const value of values) { + const normalized = value.trim(); + if (normalized.length === 0 || result.includes(normalized)) continue; + result.push(normalized); + if (result.length >= limit) break; + } + return result; +} + +function cleanTracePath(value: string): string { + return value + .replace(/^['"`([{<]+/u, "") + .replace(/['"`)\]}>.,;:]+$/u, "") + .replace(/:\d+(?::\d+)?$/u, "") + .replace(/^[ab]\//u, "") + .trim(); +} + +function extractTracePaths(text: string): string[] { + const source = String(text || ""); + const matches = source.match(/(?:~|\.{1,2}|\/)?(?:[A-Za-z0-9_.@+-]+\/)+[A-Za-z0-9_.@+-]+|[A-Za-z0-9_.@+-]+\.(?:c|cc|cpp|h|hpp|js|jsx|ts|tsx|json|md|py|sh|toml|ya?ml|txt|log|lock)/gu) || []; + return uniqueStrings(matches.map(cleanTracePath).filter((path) => path.length >= 2 && !path.includes("...") && !/^(http|https|status|method)$/iu.test(path)), 40); +} + +function parseEditedFilesFromText(text: string): string[] { + const files: string[] = []; + const addFile = (path: string): void => { + const cleanPath = cleanTracePath(path); + if (cleanPath.length === 0 || cleanPath === "/dev/null" || files.includes(cleanPath)) return; + files.push(cleanPath); + }; + for (const line of text.split(/\r?\n/u)) { + const statusMatch = /^([AMDRCU?]{1,2})\s+(.+)$/u.exec(line); + if (statusMatch) { + addFile(statusMatch[2] || ""); + continue; + } + const patchMatch = /^\*\*\*\s+(?:Add|Update|Delete)\s+File:\s+(.+)$/u.exec(line); + if (patchMatch) { + addFile(patchMatch[1] || ""); + continue; + } + const moveMatch = /^\*\*\*\s+Move to:\s+(.+)$/u.exec(line); + if (moveMatch) { + addFile(moveMatch[1] || ""); + continue; + } + const diffMatch = /^diff --git a\/(.+?) b\/(.+)$/u.exec(line); + if (diffMatch) { + addFile(diffMatch[2] || diffMatch[1] || ""); + continue; + } + const plusMatch = /^\+\+\+ b\/(.+)$/u.exec(line); + if (plusMatch) addFile(plusMatch[1] || ""); + } + return files.length > 0 ? files : extractTracePaths(text); +} + +function transcriptPreviewLines(text: string, maxLines: number, maxChars: number): string[] { + const preview = linePreview(text, maxLines, maxChars).text; + return preview.length === 0 ? [] : preview.split(/\r?\n/u).slice(0, maxLines); +} + +function transcriptLineSummaryLines(line: TranscriptLine): string[] { + const lines: string[] = []; + const command = String(line.commandPreview || "").trim(); + if (command.length > 0) { + for (const item of transcriptPreviewLines(command, 2, 420)) lines.push(`$ ${item}`); + } + const body = String(line.bodyPreview || "").trim(); + const remaining = Math.max(0, 4 - lines.length); + if (body.length > 0 && remaining > 0) { + for (const item of transcriptPreviewLines(body, remaining, 700)) lines.push(item); + } + if (lines.length === 0) lines.push(line.status ? `${line.title} (${line.status})` : line.title); + return lines.slice(0, 4); +} + +function executionSummaryFromTranscript( + task: QueueTask, + transcript: TranscriptLine[], + timing: Record, + outputCount = taskFullOutput(task).length, + retainedOutputCount = task.output.length, +): JsonValue { + const toolLines = transcript.filter((line) => line.kind === "ran" || line.kind === "explored" || line.kind === "edited"); + const editedFiles = uniqueStrings(toolLines.flatMap((line) => line.kind === "edited" ? parseEditedFilesFromText(`${line.title}\n${line.bodyPreview ?? ""}`) : []), 40); + const commands = uniqueStrings(toolLines + .map((line) => String(line.commandPreview || "").split(/\r?\n/u).find((row) => row.trim().length > 0)?.trim() ?? "") + .filter(Boolean), 30); + const counts = toolLines.reduce>((memo, line) => { + memo[line.kind] = (memo[line.kind] ?? 0) + 1; + return memo; + }, {}); + return { + durationMs: timing.durationMs ?? timing.totalElapsedMs ?? null, + totalElapsedMs: timing.totalElapsedMs ?? null, + queueWaitMs: timing.queueWaitMs ?? null, + toolCallCount: toolLines.length, + readCount: counts.explored ?? 0, + editCount: counts.edited ?? 0, + runCount: counts.ran ?? 0, + editedFiles, + commands, + stepCount: transcript.filter((line) => line.title !== "Submitted prompt").length, + transcriptMaxSeq: transcript.at(-1)?.seq ?? 0, + outputCount, + retainedOutputCount, + } as unknown as JsonValue; +} + +function taskExecutionSummary(task: QueueTask, transcript = cachedPreviewTranscript(task)): JsonValue { + return executionSummaryFromTranscript(task, transcript, taskTiming(task) as Record); +} + +function parseAttemptIndex(text: string): number | null { + const match = /\battempt\s+(\d+)\s*\/\s*\d+/iu.exec(text); + if (match === null) return null; + const value = Number(match[1]); + return Number.isInteger(value) && value > 0 ? value : null; +} + +function traceAttemptIndexFromLine(line: TranscriptLine): number | null { + return parseAttemptIndex(`${line.bodyPreview ?? ""}\n${line.commandPreview ?? ""}\n${line.title}`); +} + +function parseJudgeLine(text: string): JudgeResult | null { + const match = /\bjudge=(complete|retry|fail)\s+confidence=([0-9.]+)\s+source=([A-Za-z0-9_-]+):\s*([\s\S]*)$/u.exec(text.trim()); + if (match === null) return null; + const confidence = Number(match[2]); + const source = match[3] === "minimax" ? "minimax" : "fallback"; + return { + decision: match[1] as JudgeDecision, + confidence: Number.isFinite(confidence) ? confidence : 0, + source, + reason: (match[4] ?? "").trim(), + }; +} + +function judgeFromAttemptLines(lines: TranscriptLine[]): { judge: JudgeResult; at: string | null; seq: number | null } | null { + for (const line of lines.slice().reverse()) { + if (line.title !== "Judge result" && line.status !== "judge") continue; + const judge = parseJudgeLine(String(line.bodyPreview || "")); + if (judge === null) continue; + return { judge, at: line.at || null, seq: Number.isFinite(Number(line.seq)) ? Number(line.seq) : null }; + } + return null; +} + +function attemptFeedbackPromptRecord(task: QueueTask, attemptIndex: number, attempt: AttemptSummary | null, judge: JudgeResult | null = attempt?.judge ?? null): FeedbackPromptRecord | null { + const directPrompt = String(attempt?.feedbackPrompt ?? "").trimEnd(); + if (directPrompt.length > 0) { + const snapshot = promptSnapshot(directPrompt, 1600); + return { + text: snapshot.text, + preview: String(attempt?.feedbackPromptPreview || snapshot.preview), + chars: Number(attempt?.feedbackPromptChars ?? snapshot.chars), + lines: Number(attempt?.feedbackPromptLines ?? snapshot.lines), + source: String(attempt?.feedbackPromptSource || "judge-feedback"), + forAttempt: Number.isFinite(Number(attempt?.feedbackPromptForAttempt)) ? Number(attempt?.feedbackPromptForAttempt) : attemptIndex + 1, + truncated: snapshot.truncated, + }; + } + + const hasPendingRetry = task.status === "retry_wait" || task.status === "queued"; + if (attemptIndex === task.attempts.length && hasPendingRetry && task.nextMode === "retry" && task.nextPrompt !== null && task.nextPrompt.trim().length > 0) { + const snapshot = promptSnapshot(task.nextPrompt, 1600); + return { ...snapshot, source: "pending-retry", forAttempt: attemptIndex + 1 }; + } + + const nextAttempt = task.attempts.find((item) => Number(item.index) === attemptIndex + 1) ?? task.attempts[attemptIndex] ?? null; + const nextInput = String(nextAttempt?.inputPrompt ?? "").trimEnd(); + if (nextInput.length > 0) { + const snapshot = promptSnapshot(nextInput, 1600); + return { ...snapshot, source: "attempt-input", forAttempt: attemptIndex + 1 }; + } + + if (judge?.decision === "retry") { + const generated = retryPrompt(task, judge); + const snapshot = promptSnapshot(generated, 1600); + return { ...snapshot, source: judge.continuePrompt?.trim() ? "judge-continue-prompt" : "judge-retry-generated", forAttempt: attemptIndex + 1 }; + } + + return null; +} + +function attemptTimingSummary(attempt: AttemptSummary | null, lines: TranscriptLine[]): Record { + const startedAt = attempt?.startedAt || lines[0]?.at || null; + const finishedAt = attempt?.finishedAt || lines.at(-1)?.at || null; + const startedMs = timestampMs(startedAt); + const finishedMs = timestampMs(finishedAt); + const durationMs = nonNegativeElapsed(startedMs, finishedMs); + return { + durationMs, + totalElapsedMs: durationMs, + queueWaitMs: null, + effectiveEndAt: finishedAt, + }; +} + +function traceAttemptWindows(task: QueueTask, transcript: TranscriptLine[]): Array<{ index: number; attempt: AttemptSummary | null; startSeq: number | null; endSeq: number | null; lines: TranscriptLine[] }> { + const starts = transcript + .map((line, position) => ({ line, position, index: line.title === "Attempt started" ? traceAttemptIndexFromLine(line) : null })) + .filter((item): item is { line: TranscriptLine; position: number; index: number } => item.index !== null) + .sort((left, right) => Number(left.line.seq) - Number(right.line.seq)); + const maxStartIndex = starts.reduce((max, item) => Math.max(max, item.index), 0); + const maxIndex = Math.max(task.attempts.length, task.currentAttempt || 0, maxStartIndex); + const windows: Array<{ index: number; attempt: AttemptSummary | null; startSeq: number | null; endSeq: number | null; lines: TranscriptLine[] }> = []; + for (let index = 1; index <= maxIndex; index += 1) { + const attempt = task.attempts.find((item) => Number(item.index) === index) ?? task.attempts[index - 1] ?? null; + const start = starts.find((item) => item.index === index) ?? starts[index - 1] ?? null; + const nextStart = starts.find((item) => item.index > index) ?? null; + const explicitStartSeq = Number((attempt as AttemptSummary | null)?.outputStartSeq ?? NaN); + const explicitEndSeq = Number((attempt as AttemptSummary | null)?.outputEndSeq ?? NaN); + const startSeq = Number.isFinite(explicitStartSeq) ? explicitStartSeq : start?.line.seq ?? null; + const hasExplicitEndSeq = Number.isFinite(explicitEndSeq); + const endSeq = hasExplicitEndSeq ? explicitEndSeq : nextStart !== null ? nextStart.line.seq : null; + let lines = startSeq === null + ? [] + : transcript.filter((line) => Number(line.seq) >= startSeq && (endSeq === null || (hasExplicitEndSeq ? Number(line.seq) <= endSeq : Number(line.seq) < endSeq))); + if (lines.length === 0 && attempt !== null) { + const startedMs = timestampMs(attempt.startedAt); + const finishedMs = timestampMs(attempt.finishedAt); + lines = transcript.filter((line) => { + const lineMs = timestampMs(line.at); + return lineMs !== null + && (startedMs === null || lineMs >= startedMs) + && (finishedMs === null || lineMs <= finishedMs); + }); + } + windows.push({ index, attempt, startSeq, endSeq, lines }); + } + return windows; +} + +function executionLinesForAttempt(lines: TranscriptLine[]): TranscriptLine[] { + return lines.filter((line) => line.title !== "Submitted prompt" && line.title !== "Attempt started" && line.title !== "Judge result"); +} + +function taskTraceAttemptSummaries(task: QueueTask, transcript: TranscriptLine[]): JsonValue[] { + const windows = traceAttemptWindows(task, transcript); + return windows.map((window) => { + const attempt = window.attempt; + const parsedJudge = judgeFromAttemptLines(window.lines); + const storedJudge = attempt?.judge ?? null; + const judge = storedJudge ?? parsedJudge?.judge ?? (window.index === task.attempts.length && task.lastJudge !== null ? task.lastJudge : null); + const finalResponse = String(attempt?.finalResponse ?? attempt?.finalResponsePreview ?? (window.index === task.attempts.length ? task.finalResponse : "")); + const finalResponseChars = Number(attempt?.finalResponseChars ?? finalResponse.length); + const executionLines = executionLinesForAttempt(window.lines); + const feedbackPrompt = attemptFeedbackPromptRecord(task, window.index, attempt, judge); + const inputPrompt = promptSnapshot(String(attempt?.inputPrompt ?? ""), 1200); + return { + ...(attempt ?? {}), + index: attempt?.index ?? window.index, + mode: attempt?.mode ?? (window.index <= 1 ? "initial" : "retry"), + startedAt: attempt?.startedAt ?? window.lines[0]?.at ?? task.startedAt, + finishedAt: attempt?.finishedAt ?? null, + terminalStatus: attempt?.terminalStatus ?? null, + transportClosedBeforeTerminal: attempt?.transportClosedBeforeTerminal ?? false, + appServerExitCode: attempt?.appServerExitCode ?? null, + appServerSignal: attempt?.appServerSignal ?? null, + error: attempt?.error ?? null, + stderrTail: attempt?.stderrTail ?? "", + startSeq: window.startSeq, + endSeq: window.endSeq, + inputPrompt: undefined, + inputPromptPreview: inputPrompt.preview, + inputPromptChars: Number(attempt?.inputPromptChars ?? inputPrompt.chars), + inputPromptLines: Number(attempt?.inputPromptLines ?? inputPrompt.lines), + finalResponse, + finalResponsePreview: attempt?.finalResponsePreview ?? safePreview(finalResponse, 3000), + finalResponseChars, + finalResponseTruncated: finalResponse.length < finalResponseChars, + judge, + judgeAt: attempt?.judgeAt ?? parsedJudge?.at ?? null, + judgeSeq: attempt?.judgeSeq ?? parsedJudge?.seq ?? null, + feedbackPrompt: undefined, + feedbackPromptPreview: feedbackPrompt?.preview ?? "", + feedbackPromptChars: feedbackPrompt?.chars ?? 0, + feedbackPromptLines: feedbackPrompt?.lines ?? 0, + feedbackPromptSource: feedbackPrompt?.source ?? null, + feedbackPromptForAttempt: feedbackPrompt?.forAttempt ?? null, + feedbackPromptTruncated: feedbackPrompt?.truncated ?? false, + execution: executionSummaryFromTranscript(task, executionLines, attemptTimingSummary(attempt, executionLines.length > 0 ? executionLines : window.lines), window.lines.length, window.lines.length), + }; + }) as unknown as JsonValue[]; +} + +function resolvedReferencePromptParts(prompt: string): { reference: string; userPrompt: string } { + const withoutEnvironment = stripCodexQueueEnvironmentHint(prompt); + const trimmed = withoutEnvironment.trimStart(); + if (!trimmed.startsWith(resolvedReferenceContextTitle)) { + return { reference: "", userPrompt: userPromptForDisplay(prompt) }; + } + const offset = withoutEnvironment.length - trimmed.length; + const index = withoutEnvironment.lastIndexOf(currentTaskPromptMarker); + if (index < offset) return { reference: "", userPrompt: userPromptForDisplay(prompt) }; + return { + reference: withoutEnvironment.slice(offset, index).trimEnd(), + userPrompt: withoutEnvironment.slice(index + currentTaskPromptMarker.length).trimStart(), + }; +} + +function taskTracePromptSummary(task: QueueTask): JsonValue { + const displayPrompt = task.basePrompt || userPromptForDisplay(task.prompt); + const parts = resolvedReferencePromptParts(task.prompt); + return { + basePrompt: displayPrompt, + basePromptChars: displayPrompt.length, + basePromptLines: promptLineCount(displayPrompt), + promptChars: task.prompt.length, + promptLines: promptLineCount(task.prompt), + referencePromptChars: parts.reference.length, + referencePromptLines: promptLineCount(parts.reference), + hasReferenceInjection: parts.reference.length > 0 || task.referenceInjection !== null, + referenceTaskIds: task.referenceTaskIds, + referenceInjectionSummary: task.referenceInjection === null ? null : { + injectedAt: task.referenceInjection.injectedAt, + itemCount: task.referenceInjection.itemCount, + directReferenceTaskIds: task.referenceInjection.directReferenceTaskIds, + maxRounds: task.referenceInjection.maxRounds, + truncated: task.referenceInjection.truncated, + }, + } as unknown as JsonValue; +} + +function taskTraceSummaryResponse(task: QueueTask): JsonValue { + const transcript = cachedPreviewTranscript(task); + const attempts = taskTraceAttemptSummaries(task, transcript); + return { + id: task.id, + queueId: queueIdOf(task), + status: task.status, + model: task.model, + cwd: task.cwd, + reasoningEffort: task.reasoningEffort, + createdAt: task.createdAt, + startedAt: task.startedAt, + finishedAt: task.finishedAt, + updatedAt: task.updatedAt, + currentAttempt: task.currentAttempt, + maxAttempts: task.maxAttempts, + prompt: taskTracePromptSummary(task), + execution: taskExecutionSummary(task, transcript), + finalResponse: task.finalResponse, + finalResponseChars: task.finalResponse.length, + lastJudge: task.lastJudge, + lastError: task.lastError, + attempts, + timing: taskTiming(task), + } as unknown as JsonValue; +} + +function taskFeedbackPromptDetail(task: QueueTask, attemptIndex: number | null): FeedbackPromptRecord | null { + const index = attemptIndex ?? (task.attempts.length > 0 ? task.attempts.length : task.currentAttempt || 1); + if (!Number.isInteger(index) || index <= 0) return null; + const attempt = task.attempts.find((item) => Number(item.index) === index) ?? task.attempts[index - 1] ?? null; + let judge = attempt?.judge ?? null; + if (judge === null) { + const window = traceAttemptWindows(task, cachedPreviewTranscript(task)).find((item) => item.index === index) ?? null; + judge = judgeFromAttemptLines(window?.lines ?? [])?.judge ?? null; + } + return attemptFeedbackPromptRecord(task, index, attempt, judge); +} + +function taskPromptDetailResponse(task: QueueTask, url: URL): Response { + const part = String(url.searchParams.get("part") || "full"); + const parts = resolvedReferencePromptParts(task.prompt); + const basePrompt = task.basePrompt || userPromptForDisplay(task.prompt); + if (part === "feedback" || part === "judge-feedback") { + const attemptIndex = parseSeqParam(url, "attempt", null); + const detail = taskFeedbackPromptDetail(task, attemptIndex); + const text = detail?.text ?? ""; + return jsonResponse({ + ok: true, + taskId: task.id, + queueId: queueIdOf(task), + status: task.status, + updatedAt: task.updatedAt, + part: "feedback", + attempt: attemptIndex, + forAttempt: detail?.forAttempt ?? null, + source: detail?.source ?? null, + text, + preview: detail?.preview ?? "", + chars: detail?.chars ?? text.length, + lines: detail?.lines ?? promptLineCount(text), + truncated: detail?.truncated ?? false, + }); + } + const text = part === "base" + ? basePrompt + : part === "reference" + ? parts.reference + : task.prompt; + return jsonResponse({ + ok: true, + taskId: task.id, + queueId: queueIdOf(task), + status: task.status, + updatedAt: task.updatedAt, + part, + text, + chars: text.length, + lines: promptLineCount(text), + promptChars: task.prompt.length, + basePromptChars: basePrompt.length, + referencePromptChars: parts.reference.length, + }); +} + +function taskTraceStepsResponse(task: QueueTask, url: URL): Response { + const limit = parseLimit(url); + const attemptIndex = parseSeqParam(url, "attempt", null); + const previewTranscript = cachedPreviewTranscript(task); + const attemptWindow = attemptIndex === null ? null : traceAttemptWindows(task, previewTranscript).find((window) => window.index === attemptIndex) ?? null; + const sourceTranscript = attemptWindow === null ? previewTranscript : executionLinesForAttempt(attemptWindow.lines); + const transcript = sourceTranscript.filter((line) => line.title !== "Submitted prompt"); + const page = pageBySeq(transcript, url, limit); + return jsonResponse({ + ok: true, + taskId: task.id, + queueId: queueIdOf(task), + status: task.status, + updatedAt: task.updatedAt, + attempt: attemptIndex, + steps: page.chunk.map((line) => ({ + seq: line.seq, + at: line.at, + kind: line.kind, + title: line.title, + status: line.status ?? null, + rawSeqs: line.rawSeqs, + summaryLines: transcriptLineSummaryLines(line), + hasDetail: true, + })), + mode: page.mode, + afterSeq: page.afterSeq, + nextAfterSeq: page.nextAfterSeq, + beforeSeq: page.beforeSeq, + previousBeforeSeq: page.previousBeforeSeq, + hasMore: page.hasMore, + hasBefore: page.hasBefore, + total: transcript.length, + maxSeq: transcript.at(-1)?.seq ?? 0, + }); +} + +function taskTraceStepDetailResponse(task: QueueTask, url: URL): Response { + const seq = Number(url.searchParams.get("seq")); + if (!Number.isFinite(seq)) return jsonResponse({ ok: false, error: "seq is required" }, 400); + const transcript = fullTranscript(task).filter((line) => line.title !== "Submitted prompt"); + const line = transcript.find((item) => Number(item.seq) === seq || item.rawSeqs.includes(seq)); + if (line === undefined) return jsonResponse({ ok: false, error: "trace step not found", seq }, 404); + return jsonResponse({ + ok: true, + taskId: task.id, + queueId: queueIdOf(task), + status: task.status, + updatedAt: task.updatedAt, + seq, + line, + }); +} + +function taskSummaryResponse(task: QueueTask, url: URL): JsonValue { + const transcript = fullTranscript(task); + const fullOutput = taskFullOutput(task); + return { + id: task.id, + queueId: queueIdOf(task), + status: task.status, + model: task.model, + cwd: task.cwd, + reasoningEffort: task.reasoningEffort, + maxAttempts: task.maxAttempts, + currentAttempt: task.currentAttempt, + currentMode: task.currentMode, + judgeFailCount: task.judgeFailCount, + judgeFailRetryLimit, + codexThreadId: task.codexThreadId, + activeTurnId: task.activeTurnId, + createdAt: task.createdAt, + startedAt: task.startedAt, + finishedAt: task.finishedAt, + updatedAt: task.updatedAt, + timing: taskTiming(task), + initialPrompt: task.prompt, + basePrompt: task.basePrompt, + prompt: task.prompt, + referenceTaskIds: task.referenceTaskIds, + referenceInjection: task.referenceInjection, + lastAssistantMessage: lastAssistantMessage(task, transcript), + toolSummary: taskToolSummary(task, url, transcript), + attempts: task.attempts, + lastJudge: task.lastJudge, + lastError: task.lastError, + cancelRequested: task.cancelRequested, + transcriptCount: transcript.length, + transcriptMaxSeq: transcript.at(-1)?.seq ?? 0, + outputCount: fullOutput.length, + retainedOutputCount: task.output.length, + outputMaxSeq: fullOutput.at(-1)?.seq ?? 0, + eventCount: task.events.length, + cliHint: `bun scripts/cli.ts codex task ${task.id}`, + traceHint: `bun scripts/cli.ts codex task ${task.id} --trace --tail --limit 80`, + rawOutputHint: `bun scripts/cli.ts codex output ${task.id} --tail --limit 20`, + } as unknown as JsonValue; +} + +const resolvedReferenceContextTitle = "# Codex Queue 已解析引用上下文"; +const currentTaskPromptMarker = "\n# 本次任务\n"; +const codexQueueEnvironmentHintTitle = "# Codex Queue 运行环境提示"; +const codexQueueEnvironmentHint = [ + codexQueueEnvironmentHintTitle, + "如果当前 Codex Queue Docker 容器缺少完成任务所需的环境、系统包或语言依赖,可以先在容器内临时安装以推进当前任务;同时必须把该依赖补到 `src/components/microservices/codex-queue/Dockerfile`,让后续任务重建镜像后可直接使用。", +].join("\n"); + +function stripAutoReferenceHint(prompt: string): string { + const trimmed = prompt.trimStart(); + if (!/^引用\s+Codex Queue\s+任务\s+codex_\d+_[A-Za-z0-9_-]+/u.test(trimmed)) return prompt; + const marker = "\n\n本次任务:"; + const index = trimmed.indexOf(marker); + if (index === -1) return prompt; + return trimmed.slice(index + marker.length).trimStart(); +} + +function stripResolvedReferenceContext(prompt: string): string { + const trimmed = prompt.trimStart(); + if (!trimmed.startsWith(resolvedReferenceContextTitle)) return prompt; + const offset = prompt.length - trimmed.length; + const index = prompt.lastIndexOf(currentTaskPromptMarker); + if (index < offset) return prompt; + return prompt.slice(index + currentTaskPromptMarker.length).trimStart(); +} + +function stripCodexQueueEnvironmentHint(prompt: string): string { + const trimmed = prompt.trimStart(); + if (!trimmed.startsWith(codexQueueEnvironmentHintTitle)) return prompt; + const offset = prompt.length - trimmed.length; + const index = prompt.indexOf(currentTaskPromptMarker, offset); + if (index < offset) return prompt; + return prompt.slice(index + currentTaskPromptMarker.length).trimStart(); +} + +function userPromptForDisplay(prompt: string): string { + return stripAutoReferenceHint(stripResolvedReferenceContext(stripCodexQueueEnvironmentHint(prompt))); +} + +function promptWithCodexQueueEnvironmentHint(prompt: string): string { + if (prompt.trimStart().startsWith(codexQueueEnvironmentHintTitle)) return prompt; + return [codexQueueEnvironmentHint, "", "# 本次任务", prompt.trim()].join("\n"); +} + +function injectCodexQueueEnvironmentHint(request: QueueTaskRequest): QueueTaskRequest { + if (request.prompt.trimStart().startsWith(codexQueueEnvironmentHintTitle)) return request; + const basePrompt = request.basePrompt ?? userPromptForDisplay(request.prompt); + return { ...request, prompt: promptWithCodexQueueEnvironmentHint(request.prompt), basePrompt }; +} + +function taskReferenceIds(task: QueueTask): string[] { + const ids: string[] = []; + for (const id of task.referenceTaskIds ?? []) addUniqueTaskId(ids, id); + for (const id of referenceTaskIdsFromPrompt(task.basePrompt || userPromptForDisplay(task.prompt))) addUniqueTaskId(ids, id); + return ids; +} + +function taskBasePrompt(task: QueueTask): string { + return (task.basePrompt || userPromptForDisplay(task.prompt)).trimEnd(); +} + +function referenceSummaryItem(task: QueueTask, round: number, roundIndex: number, viaTaskId: string | null): ReferenceInjectionSummaryItem { + const lastMessage = lastAssistantMessage(task) as Record; + const lastText = typeof lastMessage.text === "string" ? lastMessage.text : ""; + return { + round, + roundIndex, + taskId: task.id, + viaTaskId, + status: task.status, + model: task.model, + cwd: task.cwd, + createdAt: task.createdAt, + updatedAt: task.updatedAt, + promptChars: taskBasePrompt(task).length, + finalResponseChars: lastText.trimEnd().length, + finalResponseAt: typeof lastMessage.at === "string" ? lastMessage.at : null, + finalResponseSource: typeof lastMessage.source === "string" ? lastMessage.source : "unknown", + referenceTaskIds: taskReferenceIds(task), + cliHint: `bun scripts/cli.ts codex task ${task.id}`, + }; +} + +function collectReferenceGraph(rootIds: string[], maxRounds: number | null, finder: (id: string) => QueueTask | null = findTask): { items: ReferenceInjectionSummaryItem[]; tasks: QueueTask[]; truncated: boolean } { + const seen = new Set(); + let frontier = rootIds.map((id) => ({ id, viaTaskId: null as string | null })); + const rawItems: Array<{ task: QueueTask; depth: number; viaTaskId: string | null; discoveryIndex: number }> = []; + let truncated = false; + let discoveryIndex = 0; + for (let depth = 1; frontier.length > 0; depth += 1) { + if (maxRounds !== null && depth > maxRounds) { + truncated = frontier.some((entry) => !seen.has(entry.id)); + break; + } + const next: Array<{ id: string; viaTaskId: string | null }> = []; + for (const entry of frontier) { + if (seen.has(entry.id)) continue; + const task = finder(entry.id); + if (task === null) continue; + seen.add(entry.id); + discoveryIndex += 1; + rawItems.push({ task, depth, viaTaskId: entry.viaTaskId, discoveryIndex }); + for (const childId of taskReferenceIds(task)) { + if (!seen.has(childId)) next.push({ id: childId, viaTaskId: task.id }); + } + } + frontier = next; + } + if (frontier.some((entry) => !seen.has(entry.id))) truncated = true; + const sorted = rawItems.sort((left, right) => { + if (left.depth !== right.depth) return right.depth - left.depth; + const createdDelta = Date.parse(left.task.createdAt) - Date.parse(right.task.createdAt); + if (Number.isFinite(createdDelta) && createdDelta !== 0) return createdDelta; + return left.discoveryIndex - right.discoveryIndex; + }); + const depthToRound = new Map(); + Array.from(new Set(sorted.map((item) => item.depth))).forEach((depth, index) => depthToRound.set(depth, index + 1)); + const roundCounts = new Map(); + const items = sorted.map((item) => { + const round = depthToRound.get(item.depth) ?? item.depth; + const roundIndex = (roundCounts.get(round) ?? 0) + 1; + roundCounts.set(round, roundIndex); + return referenceSummaryItem(item.task, round, roundIndex, item.viaTaskId); + }); + const tasks = sorted.map((item) => item.task); + return { items, tasks, truncated }; +} + +function referenceRoundSeparator(round: number, totalRounds: number, items: ReferenceInjectionSummaryItem[]): string { + const createdTimes = items.map((item) => item.createdAt).filter(Boolean).sort(); + const updatedTimes = items.map((item) => item.updatedAt).filter(Boolean).sort(); + return [ + `----- Reference Round ${round}/${totalRounds} -----`, + `order: upstream/oldest context first; direct references appear in the last round`, + `tasks: ${items.length}`, + `createdRange: ${createdTimes[0] ?? "--"} -> ${createdTimes.at(-1) ?? "--"}`, + `updatedRange: ${updatedTimes[0] ?? "--"} -> ${updatedTimes.at(-1) ?? "--"}`, + "--------------------------------", + ].join("\n"); +} + +function referencedTaskContext(task: QueueTask, summary: ReferenceInjectionSummaryItem): string { + const lastMessage = lastAssistantMessage(task) as Record; + const lastMessageText = typeof lastMessage.text === "string" ? lastMessage.text : ""; + return [ + `## Round ${summary.round}.${summary.roundIndex} referenced task ${task.id}`, + `- via: ${summary.viaTaskId ?? "direct"}`, + `- status/model/cwd: ${task.status} / ${task.model} / ${task.cwd}`, + `- created/updated: ${task.createdAt} / ${task.updatedAt}`, + `- cli: bun scripts/cli.ts codex task ${task.id}`, + "", + "### Initial prompt", + taskBasePrompt(task) || "(empty)", + "", + "### Final/last response", + lastMessageText.trimEnd() || "(none yet)", + "", + "### Query more context", + `Run: bun scripts/cli.ts codex task ${task.id}`, + ].join("\n"); +} + +function injectReferencedTaskContext(request: QueueTaskRequest, finder: (id: string) => QueueTask | null = findTask, injectedAt = nowIso()): QueueTaskRequest { + const ids = request.referenceTaskIds ?? []; + if (ids.length === 0 || request.prompt.includes(resolvedReferenceContextTitle)) return request; + if (ids.length > 5) throw new Error(`referenceTaskIds supports at most 5 task ids, got ${ids.length}`); + const referencedTasks = ids.map((id) => finder(id)); + const missing = ids.filter((_id, index) => referencedTasks[index] === null); + if (missing.length > 0) throw new Error(`referenced Codex Queue task not found: ${missing.join(", ")}`); + const userPrompt = request.basePrompt ?? userPromptForDisplay(request.prompt); + const graph = collectReferenceGraph(ids, referenceInjectionMaxRounds, finder); + const injection: ReferenceInjectionRecord = { + version: 2, + injectedAt, + basePrompt: userPrompt, + directReferenceTaskIds: ids, + maxRounds: referenceInjectionMaxRounds, + truncated: graph.truncated, + itemCount: graph.items.length, + items: graph.items, + }; + const taskById = new Map(graph.tasks.map((task) => [task.id, task])); + const groupedItems = Array.from(new Set(graph.items.map((item) => item.round))).map((round) => ({ + round, + items: graph.items.filter((item) => item.round === round), + })); + const context = [ + resolvedReferenceContextTitle, + `injectedAt: ${injectedAt}`, + `directReferences: ${ids.join(", ")}`, + `referenceGraphItems: ${graph.items.length}${graph.truncated ? " (truncated)" : ""}`, + "说明:Codex Queue 后端只读取每个被引用任务的结构化 basePrompt(注入前 prompt)和 final/last response;不会把历史引用注入块继续套入。多轮引用按上游/最早上下文在前、直接引用在后的顺序注入;中间执行过程不注入,只保留 CLI 查询提示。", + "", + ...(groupedItems.flatMap((group) => [ + referenceRoundSeparator(group.round, groupedItems.length, group.items), + "", + ...group.items.map((item) => { + const task = taskById.get(item.taskId); + return task === undefined ? "" : referencedTaskContext(task, item); + }).filter((text) => text.length > 0), + "", + ])), + "", + "# 本次任务", + userPrompt.trim(), + ].join("\n"); + return { ...request, prompt: context, basePrompt: userPrompt, referenceTaskIds: ids, referenceInjection: injection }; +} + +function truthyParam(url: URL, name: string): boolean { + const value = url.searchParams.get(name); + return value === "1" || value === "true" || value === "yes"; +} + +function parseSeqParam(url: URL, name: string, defaultValue: number | null): number | null { + const raw = url.searchParams.get(name); + if (raw === null) return defaultValue; + const value = Number(raw); + return Number.isFinite(value) ? value : defaultValue; +} + +function parseTextLimit(url: URL): number { + const value = Number(url.searchParams.get("maxTextChars") ?? 12_000); + return Number.isInteger(value) && value > 0 ? Math.min(500_000, value) : 12_000; +} + +function pageBySeq(items: T[], url: URL, limit: number): { + mode: "tail" | "after" | "before"; + afterSeq: number; + beforeSeq: number | null; + nextAfterSeq: number; + previousBeforeSeq: number | null; + hasMore: boolean; + hasBefore: boolean; + chunk: T[]; +} { + const beforeSeq = parseSeqParam(url, "beforeSeq", null); + const afterSeq = parseSeqParam(url, "afterSeq", 0) ?? 0; + const mode = beforeSeq !== null ? "before" : truthyParam(url, "tail") ? "tail" : "after"; + const boundedBeforeSeq = beforeSeq ?? 0; + const chunk = mode === "before" + ? items.filter((item) => Number(item.seq) < boundedBeforeSeq).slice(-limit) + : mode === "tail" + ? items.slice(-limit) + : items.filter((item) => Number(item.seq) > afterSeq).slice(0, limit); + const firstSeq = chunk[0]?.seq; + const lastSeq = chunk.at(-1)?.seq; + return { + mode, + afterSeq, + beforeSeq, + nextAfterSeq: lastSeq ?? afterSeq, + previousBeforeSeq: firstSeq ?? beforeSeq, + hasMore: lastSeq !== undefined && items.some((item) => Number(item.seq) > Number(lastSeq)), + hasBefore: firstSeq !== undefined && items.some((item) => Number(item.seq) < Number(firstSeq)), + chunk, + }; +} + +function transcriptChunkResponse(task: QueueTask, url: URL): Response { + const limit = parseLimit(url); + const fullText = truthyParam(url, "fullText") || truthyParam(url, "raw"); + const transcript = fullText ? fullTranscript(task) : cachedPreviewTranscript(task); + const page = pageBySeq(transcript, url, limit); + return jsonResponse({ + ok: true, + taskId: task.id, + queueId: queueIdOf(task), + status: task.status, + updatedAt: task.updatedAt, + mode: page.mode, + transcript: page.chunk, + afterSeq: page.afterSeq, + nextAfterSeq: page.nextAfterSeq, + beforeSeq: page.beforeSeq, + previousBeforeSeq: page.previousBeforeSeq, + hasMore: page.hasMore, + hasBefore: page.hasBefore, + total: transcript.length, + maxSeq: transcript.at(-1)?.seq ?? 0, + fullText, + }); +} + +function outputChunkResponse(task: QueueTask, url: URL): Response { + const limit = parseLimit(url); + const fullText = truthyParam(url, "fullText") || truthyParam(url, "raw"); + const maxTextChars = parseTextLimit(url); + const fullOutput = taskFullOutput(task); + const page = pageBySeq(fullOutput, url, limit); + const output = page.chunk.map((item) => { + const truncated = !fullText && item.text.length > maxTextChars; + return { + ...item, + text: truncated ? item.text.slice(0, maxTextChars) : item.text, + textChars: item.text.length, + textTruncated: truncated, + omittedChars: truncated ? item.text.length - maxTextChars : 0, + }; + }); + return jsonResponse({ + ok: true, + taskId: task.id, + queueId: queueIdOf(task), + status: task.status, + updatedAt: task.updatedAt, + mode: page.mode, + output, + afterSeq: page.afterSeq, + nextAfterSeq: page.nextAfterSeq, + beforeSeq: page.beforeSeq, + previousBeforeSeq: page.previousBeforeSeq, + hasMore: page.hasMore, + hasBefore: page.hasBefore, + total: fullOutput.length, + retainedTotal: task.output.length, + maxSeq: fullOutput.at(-1)?.seq ?? 0, + fullText, + maxTextChars, + }); +} + +function taskForListResponse(task: QueueTask, lite = false): JsonValue { + const timing = taskTiming(task); + const displayPrompt = task.basePrompt || userPromptForDisplay(task.prompt); + if (lite) { + return { + id: task.id, + queueId: queueIdOf(task), + prompt: prefixPreview(displayPrompt, 360), + basePrompt: prefixPreview(task.basePrompt, 360), + displayPrompt: prefixPreview(displayPrompt, 360), + promptChars: task.prompt.length, + basePromptChars: task.basePrompt.length, + displayPromptChars: displayPrompt.length, + finalResponseChars: task.finalResponse.length, + summaryOnly: true, + referenceTaskIds: task.referenceTaskIds, + referenceInjectionSummary: task.referenceInjection === null ? null : { + injectedAt: task.referenceInjection.injectedAt, + itemCount: task.referenceInjection.itemCount, + directReferenceTaskIds: task.referenceInjection.directReferenceTaskIds, + maxRounds: task.referenceInjection.maxRounds, + truncated: task.referenceInjection.truncated, + }, + cwd: task.cwd, + model: task.model, + reasoningEffort: task.reasoningEffort, + maxAttempts: task.maxAttempts, + status: task.status, + createdAt: task.createdAt, + updatedAt: task.updatedAt, + startedAt: task.startedAt, + finishedAt: task.finishedAt, + readAt: task.readAt, + currentAttempt: task.currentAttempt, + currentMode: task.currentMode, + judgeFailCount: task.judgeFailCount, + judgeFailRetryLimit, + codexThreadId: task.codexThreadId, + activeTurnId: task.activeTurnId, + lastError: task.lastError, + lastJudge: task.lastJudge === null ? null : { + decision: task.lastJudge.decision, + confidence: task.lastJudge.confidence, + source: task.lastJudge.source, + reason: safePreview(task.lastJudge.reason, 260), + }, + cancelRequested: task.cancelRequested, + terminalUnread: terminalTaskUnread(task), + outputCount: task.output.length, + eventCount: task.events.length, + attemptCount: task.attempts.length, + timing, + } as unknown as JsonValue; + } + return { + id: task.id, + queueId: queueIdOf(task), + prompt: safePreview(displayPrompt, 2000), + basePrompt: safePreview(task.basePrompt, 2000), + displayPrompt: safePreview(displayPrompt, 2000), + promptChars: task.prompt.length, + basePromptChars: task.basePrompt.length, + displayPromptChars: displayPrompt.length, + finalResponseChars: task.finalResponse.length, + summaryOnly: true, + referenceTaskIds: task.referenceTaskIds, + referenceInjection: task.referenceInjection, + cwd: task.cwd, + model: task.model, + reasoningEffort: task.reasoningEffort, + maxAttempts: task.maxAttempts, + status: task.status, + createdAt: task.createdAt, + updatedAt: task.updatedAt, + startedAt: task.startedAt, + finishedAt: task.finishedAt, + readAt: task.readAt, + currentAttempt: task.currentAttempt, + currentMode: task.currentMode, + judgeFailCount: task.judgeFailCount, + judgeFailRetryLimit, codexThreadId: task.codexThreadId, activeTurnId: task.activeTurnId, lastError: task.lastError, lastJudge: task.lastJudge, cancelRequested: task.cancelRequested, + terminalUnread: terminalTaskUnread(task), outputCount: task.output.length, eventCount: task.events.length, attemptCount: task.attempts.length, attempts: task.attempts.slice(-3), + timing, } as unknown as JsonValue; } -function queueSummary(): JsonValue { +function perQueueSummaries(): JsonValue[] { + const summaries = new Map; + unreadTerminal: number; + activeTaskId: string | null; + runnableTaskId: string | null; + createdAt: string | null; + updatedAt: string | null; + }>(); + for (const queue of state.queues) { + summaries.set(queue.id, { + total: 0, + counts: {}, + unreadTerminal: 0, + activeTaskId: activeRuns.get(queue.id)?.taskId ?? null, + runnableTaskId: null, + createdAt: queue.createdAt, + updatedAt: queue.updatedAt, + }); + } + for (const task of state.tasks) { + const queueId = queueIdOf(task); + let summary = summaries.get(queueId); + if (summary === undefined) { + summary = { + total: 0, + counts: {}, + unreadTerminal: 0, + activeTaskId: activeRuns.get(queueId)?.taskId ?? null, + runnableTaskId: null, + createdAt: null, + updatedAt: null, + }; + summaries.set(queueId, summary); + } + summary.total += 1; + summary.counts[task.status] = (summary.counts[task.status] ?? 0) + 1; + if (terminalTaskUnread(task)) summary.unreadTerminal += 1; + if (summary.activeTaskId === null && (task.status === "running" || task.status === "judging")) summary.activeTaskId = task.id; + if (summary.runnableTaskId === null && (task.status === "queued" || task.status === "retry_wait")) summary.runnableTaskId = task.id; + } + const rows = Array.from(summaries.entries()).sort(([left], [right]) => left.localeCompare(right)).map(([queueId, summary]) => { + return { + id: queueId, + total: summary.total, + counts: summary.counts, + unreadTerminal: summary.unreadTerminal, + activeTaskId: summary.activeTaskId, + runnableTaskId: summary.runnableTaskId, + processing: processingQueues.has(queueId), + createdAt: summary.createdAt, + updatedAt: summary.updatedAt, + } as unknown as JsonValue; + }); + return rows; +} + +function queueSummary(includeDevReady = true): 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 { + const unreadTerminal = state.tasks.reduce((total, task) => total + (terminalTaskUnread(task) ? 1 : 0), 0); + const activeTaskIdSet = new Set(Array.from(activeRuns.values()).map((run) => run.taskId)); + for (const task of state.tasks) { + if (task.status === "running" || task.status === "judging") activeTaskIdSet.add(task.id); + } + const activeTaskIds = Array.from(activeTaskIdSet).sort(); + const activeTaskId = activeTaskIds[0] ?? state.tasks.find((task) => task.status === "running" || task.status === "judging")?.id ?? null; + const queues = perQueueSummaries(); + const summary: Record = { total: state.tasks.length, + defaultQueueId, + queueCount: queues.length, + queues, + activeQueueIds: Array.from(processingQueues).sort(), + activeTaskIds, activeTaskId, processing, counts, + unreadTerminal, judgeConfigured: config.minimaxApiKey.length > 0, + judgeFailRetryLimit, minimaxModel: config.minimaxModel, minimaxJudgeRepairAttempts: config.judgeRepairAttempts, defaultModel: config.defaultModel, codexModels: config.codexModels, + defaultReasoningEffort: config.defaultReasoningEffort, + modelReasoningEfforts: config.modelReasoningEfforts, defaultWorkdir: config.defaultWorkdir, - devReady: collectDevReady(), + notifications: { + claudeqq: { + enabled: config.notifyClaudeQqEnabled, + configured: notificationTargetConfigured(), + targetType: config.notifyClaudeQqTargetType, + target: notificationTargetLabel(), + baseUrl: config.notifyClaudeQqBaseUrl, + maxResponseChars: config.notifyClaudeQqMaxResponseChars, + timeoutMs: config.notifyClaudeQqTimeoutMs, + sendAttempts: config.notifyClaudeQqSendAttempts, + }, + }, + storage: { + primary: databaseReady ? "postgres" : "file", + postgresConfigured: sql !== null, + postgresReady: databaseReady, + dirtyTaskCount: dirtyDatabaseTaskIds.size, + lastError: databaseLastError, + statePath: config.statePath, + outputArchiveDir: config.outputArchiveDir, + inMemoryOutputRecords: config.maxInMemoryOutputRecords, + inMemoryEventRecords: config.maxInMemoryEventRecords, + }, }; + if (includeDevReady) summary.devReady = collectDevReady(); + return summary; +} + +function terminalTask(task: QueueTask): boolean { + return task.status === "succeeded" || task.status === "failed" || task.status === "canceled"; +} + +function terminalTaskUnread(task: QueueTask): boolean { + return terminalTask(task) && task.readAt === null; +} + +function durationMsBetween(startAt: string | null | undefined, endAt: string | null | undefined): number | null { + const startMs = timestampMs(startAt); + const endMs = timestampMs(endAt); + return nonNegativeElapsed(startMs, endMs); +} + +function formatDurationMs(value: number | null): string { + if (value === null) return "-"; + const totalSeconds = Math.max(0, Math.floor(value / 1000)); + const days = Math.floor(totalSeconds / 86_400); + const hours = Math.floor((totalSeconds % 86_400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + const parts: string[] = []; + if (days > 0) parts.push(`${days}d`); + if (hours > 0 || parts.length > 0) parts.push(`${hours}h`); + if (minutes > 0 || parts.length > 0) parts.push(`${minutes}m`); + parts.push(`${seconds}s`); + return parts.join(" "); +} + +function queueNotificationStats(): Record { + const counts = state.tasks.reduce>((memo, task) => { + memo[task.status] = (memo[task.status] ?? 0) + 1; + return memo; + }, {}); + const activeTaskIds = Array.from(new Set([ + ...Array.from(activeRuns.values()).map((run) => run.taskId), + ...state.tasks.filter((task) => task.status === "running" || task.status === "judging").map((task) => task.id), + ])).sort(); + const activeTask = activeTaskIds.length > 0 ? state.tasks.find((task) => task.id === activeTaskIds[0]) ?? null : null; + const queued = (counts.queued ?? 0) + (counts.retry_wait ?? 0); + const running = (counts.running ?? 0) + (counts.judging ?? 0); + return { + running, + queued, + retryWait: counts.retry_wait ?? 0, + judging: counts.judging ?? 0, + total: state.tasks.length, + queueCount: perQueueSummaries().length, + processingQueueCount: processingQueues.size, + activeRunCount: activeRuns.size, + activeTaskIds, + activeTaskElapsed: activeTask === null ? "-" : formatDurationMs(durationMsBetween(activeTask.startedAt ?? activeTask.createdAt, nowIso())), + }; +} + +function notificationTargetConfigured(): boolean { + if (!config.notifyClaudeQqEnabled) return false; + return config.notifyClaudeQqTargetType === "group" + ? config.notifyClaudeQqGroupId.length > 0 + : config.notifyClaudeQqUserId.length > 0; +} + +function claudeQqTargetPayload(message: string): Record { + if (config.notifyClaudeQqTargetType === "group") { + return { targetType: "group", groupId: config.notifyClaudeQqGroupId, message }; + } + return { targetType: "private", userId: config.notifyClaudeQqUserId, message }; +} + +function notificationTargetLabel(): string { + return config.notifyClaudeQqTargetType === "group" + ? `group:${config.notifyClaudeQqGroupId || "-"}` + : `private:${config.notifyClaudeQqUserId || "-"}`; +} + +function truncateNotificationText(value: string, maxChars: number): string { + if (value.length <= maxChars) return value; + return `${value.slice(0, maxChars)}\n\n...[Codex Queue notification truncated: ${value.length - maxChars} chars omitted; use CLI/WebUI for the full trace]`; +} + +function taskFinalResponseForNotification(task: QueueTask): string { + const last = lastAssistantMessage(task) as Record; + const text = typeof last.text === "string" ? last.text.trimEnd() : ""; + if (text.trim().length > 0) return text; + if (typeof task.lastError === "string" && task.lastError.trim().length > 0) return `(没有最终 assistant response;lastError: ${task.lastError.trim()})`; + return "(没有最终 assistant response)"; +} + +function taskNotificationKey(task: QueueTask): string { + return `${task.id}:${task.status}:${task.finishedAt ?? task.updatedAt}:${task.attempts.length}`; +} + +function rememberTaskNotificationKey(key: string): void { + sentTaskNotificationKeys.add(key); + while (sentTaskNotificationKeys.size > 1000) { + const oldest = sentTaskNotificationKeys.values().next().value as string | undefined; + if (oldest === undefined) break; + sentTaskNotificationKeys.delete(oldest); + } +} + +async function postClaudeQqText(kind: string, message: string): Promise { + if (!notificationTargetConfigured()) return; + const url = `${config.notifyClaudeQqBaseUrl}/api/push/text`; + let lastError: unknown = null; + for (let attempt = 1; attempt <= config.notifyClaudeQqSendAttempts; attempt += 1) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), config.notifyClaudeQqTimeoutMs); + let responseText = ""; + try { + const response = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(claudeQqTargetPayload(message)), + signal: controller.signal, + }); + responseText = await response.text(); + if (!response.ok) throw new Error(`ClaudeQQ proxy returned HTTP ${response.status}: ${safePreview(responseText, 500)}`); + try { + const parsed = JSON.parse(responseText) as Record; + if (parsed.ok === false || parsed.success === false || parsed.status === "napcat_offline") { + throw new Error(`ClaudeQQ push failed: ${safePreview(responseText, 500)}`); + } + } catch (error) { + if (error instanceof SyntaxError) { + // Some deployments return plain text; HTTP 2xx is still accepted. + } else { + throw error; + } + } + logger("info", "claudeqq_notify_sent", { kind, target: notificationTargetLabel(), attempt, chars: message.length, responsePreview: safePreview(responseText, 500) }); + return; + } catch (error) { + lastError = error; + if (attempt >= config.notifyClaudeQqSendAttempts) break; + const delayMs = Math.min(30_000, 1000 * (2 ** (attempt - 1))); + logger("warn", "claudeqq_notify_retry", { kind, target: notificationTargetLabel(), attempt, nextDelayMs: delayMs, error: errorToJson(error) }); + await Bun.sleep(delayMs); + } finally { + clearTimeout(timer); + } + } + if (lastError !== null) { + throw lastError instanceof Error ? lastError : new Error(String(lastError)); + } +} + +async function notifyTaskTerminal(task: QueueTask): Promise { + if (!terminalTask(task) || !notificationTargetConfigured()) return; + const key = taskNotificationKey(task); + if (sentTaskNotificationKeys.has(key) || inFlightTaskNotificationKeys.has(key)) return; + inFlightTaskNotificationKeys.add(key); + try { + const stats = queueNotificationStats(); + const totalElapsed = formatDurationMs(durationMsBetween(task.createdAt, task.finishedAt ?? task.updatedAt)); + const runElapsed = formatDurationMs(durationMsBetween(task.startedAt ?? task.createdAt, task.finishedAt ?? task.updatedAt)); + const response = truncateNotificationText(taskFinalResponseForNotification(task), config.notifyClaudeQqMaxResponseChars); + const message = [ + "Codex Queue 任务结束", + `task: ${task.id}`, + `queue: ${queueIdOf(task)}`, + `status: ${task.status}`, + `model: ${task.model}${task.reasoningEffort ? ` (${task.reasoningEffort})` : ""}`, + `attempts: ${task.attempts.length}/${task.maxAttempts}`, + `elapsed: total=${totalElapsed}, run=${runElapsed}`, + `current queue: running=${stats.running}, queued=${stats.queued}, retry_wait=${stats.retryWait}`, + "", + "Final response:", + response, + ].join("\n"); + await postClaudeQqText("task_terminal", message); + rememberTaskNotificationKey(key); + } catch (error) { + logger("warn", "claudeqq_task_notify_failed", { taskId: task.id, status: task.status, target: notificationTargetLabel(), error: errorToJson(error) }); + } finally { + inFlightTaskNotificationKeys.delete(key); + } +} + +function armIdleNotification(): void { + if (notificationTargetConfigured()) idleNotificationSent = false; +} + +async function maybeNotifyQueueIdle(triggerTaskId: string | null = null): Promise { + if (!notificationTargetConfigured() || idleNotificationSent || idleNotificationInFlight) return; + const stats = queueNotificationStats(); + if (Number(stats.running) !== 0 || Number(stats.queued) !== 0 || processingQueues.size !== 0 || activeRuns.size !== 0) return; + idleNotificationInFlight = true; + try { + const message = [ + "Codex Queue 已空闲", + "running=0, queued=0", + `total tasks=${stats.total}, queues=${stats.queueCount}`, + triggerTaskId === null ? "" : `last task=${triggerTaskId}`, + ].filter((line) => line.length > 0).join("\n"); + await postClaudeQqText("queue_idle", message); + idleNotificationSent = true; + } catch (error) { + logger("warn", "claudeqq_idle_notify_failed", { triggerTaskId: triggerTaskId ?? "", target: notificationTargetLabel(), error: errorToJson(error) }); + } finally { + idleNotificationInFlight = false; + } } function textInput(text: string): JsonValue[] { @@ -863,6 +3471,7 @@ class AppServerClient { async startOrResumeThread(): Promise { if (this.task.codexThreadId !== null) { + appendOutput(this.task, "system", `thread resume requested ${this.task.codexThreadId}\n`, "thread/resume"); const response = await this.request("thread/resume", { threadId: this.task.codexThreadId, model: this.task.model, @@ -872,8 +3481,12 @@ class AppServerClient { }); const threadId = extractString(extractRecord(response)?.thread, "id"); if (threadId === null) throw new Error("thread/resume response did not include thread.id"); + appendOutput(this.task, "system", `thread resumed ${threadId}\n`, "thread/resume"); return threadId; } + if (this.task.currentMode === "retry") { + throw new Error("Retry requires an existing codexThreadId; refusing to start a new thread for retry."); + } const response = await this.request("thread/start", { model: this.task.model, cwd: this.task.cwd, @@ -894,7 +3507,8 @@ class AppServerClient { approvalPolicy: config.approvalPolicy, model: this.task.model, }; - if (this.task.reasoningEffort !== null) params.effort = this.task.reasoningEffort; + const effort = resolveReasoningEffort(this.task.model, this.task.reasoningEffort); + if (effort !== null) params.effort = effort; 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"); @@ -1006,6 +3620,27 @@ function eventSummary(message: Record): CodexEventSummary { }; } +function commandCompletionStreams(item: Record | null): { stdout: string; stderr: string; output: string; exitCode: number | null } { + if (item === null) return { stdout: "", stderr: "", output: "", exitCode: null }; + const result = extractRecord(item.result); + const stdout = recordStringField(item, ["stdout", "stdoutText"]) || recordStringField(result, ["stdout", "stdoutText"]); + const stderr = recordStringField(item, ["stderr", "stderrText"]) || recordStringField(result, ["stderr", "stderrText"]); + const output = recordStringField(item, ["output", "outputText", "text"]) || recordStringField(result, ["output", "outputText", "text"]); + const exitCode = recordNumberField(item, ["exitCode", "code"]) ?? recordNumberField(result, ["exitCode", "code"]); + return { stdout: stdout || (stderr.length > 0 ? "" : output), stderr, output: output || [stdout, stderr].filter(Boolean).join("\n"), exitCode }; +} + +function hasRecentCommandOutputDelta(task: QueueTask, itemId: string | undefined): boolean { + if (itemId === undefined) return false; + for (let index = task.output.length - 1; index >= 0 && index >= task.output.length - 80; index -= 1) { + const output = task.output[index]; + if (output?.itemId !== itemId) continue; + if (output.method === "item/commandExecution/outputDelta") return true; + if (output.method === "item/started") return false; + } + return false; +} + 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); @@ -1042,7 +3677,20 @@ function handleNotification(task: QueueTask, message: Record, t 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 === "commandExecution") { + const itemId = extractString(item, "id") ?? undefined; + if (method === "item/completed") { + const streams = commandCompletionStreams(item); + const hasDelta = hasRecentCommandOutputDelta(task, itemId); + const completedOutput = hasDelta && streams.stderr.trim().length > 0 + ? `\n[stderr]\n${streams.stderr.trimEnd()}` + : hasDelta + ? "" + : formatCommandOutput({ callId: itemId ?? "", at: nowIso(), stdout: streams.stdout, stderr: streams.stderr, output: streams.output, exitCode: streams.exitCode }); + if (completedOutput.trim().length > 0) appendOutput(task, "command", `${completedOutput.trimEnd()}\n`, "item/commandExecution/outputDelta", itemId, true); + } + appendOutput(task, "command", `${method}: ${String(item?.command ?? "command")} status=${String(item?.status ?? "unknown")}\n`, method, itemId); + } 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; @@ -1068,12 +3716,16 @@ function terminalStatus(value: string): TerminalStatus { } async function runCodexTurn(task: QueueTask, prompt: string): Promise { + const queueId = queueIdOf(task); const events: CodexEventSummary[] = []; let terminalSeen = false; + let lastAppActivityAt = Date.now(); 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 method = typeof message.method === "string" ? message.method : "unknown"; + if (method !== "thread/started" && method !== "turn/started") lastAppActivityAt = Date.now(); const before = task.events.length; handleNotification(task, message, (status, error) => { terminalSeen = true; @@ -1087,12 +3739,23 @@ async function runCodexTurn(task: QueueTask, prompt: string): Promise { + if (terminalSeen) return; + const idleMs = Date.now() - lastAppActivityAt; + if (idleMs < config.turnNoActivityTimeoutMs) return; + const message = `No Codex app activity for ${Math.round(idleMs / 1000)}s; stopping app-server so the existing thread can retry.`; + appendOutput(task, "error", `${message}\n`, "turn/no-activity-watchdog"); + logger("warn", "turn_no_activity_watchdog", { taskId: task.id, turnId, idleMs, timeoutMs: config.turnNoActivityTimeoutMs }); + app.stop(); + }, 15_000); const race = await Promise.race([terminalPromise.then(() => "terminal" as const), app.closedPromise.then(() => "closed" as const)]); + clearInterval(activityWatchdog); const closedBeforeTerminal = race === "closed" && !terminalSeen; if (terminalSeen) app.stop(); const exit = await app.closedPromise; @@ -1122,7 +3785,7 @@ async function runCodexTurn(task: QueueTask, prompt: string): Promise ({ 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 explicitly interrupted/canceled by the user, blocked on missing user input, or failed for deterministic non-retryable reasons. Do not retry explicit user interrupts.", + complete: "Use only when the transcript/final answer shows the current task is actually done and every explicit acceptance criterion has evidence; 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, drifted to a side task after steer prompts, 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 only for explicit user interruption/cancel, missing credentials/permissions/user input that the agent cannot supply, or a proven deterministic non-retryable external blocker. Do not use fail merely because the agent missed the core objective, produced wrong/partial work, or did not run required validation; those are retry.", }, + strictCompletionRules: [ + "If terminalStatus is failed/null/interrupted, transportClosedBeforeTerminal is true, or the last turn ended with a Codex/API infrastructure error, complete is forbidden unless it was an explicit user interrupt that should fail. Choose retry for service/rate-limit/transport/internal failures.", + "If stderr, terminalError, recentOutput, or recentEvents contain 429, Too Many Requests, rate limit, quota, overloaded, exceeded retry limit, stream disconnected, no activity timeout, or app-server closed, choose retry even if some code edits or rebuilds happened before the error.", + "If the original task asks to run, pass, reproduce, compare, score, benchmark, validate, or prove an empirical result, complete requires transcript evidence of the requested command/pipeline/scorer runs and the resulting numbers/status.", + "If the task requires an A/B, ablation, with/without, before/after, skill/no-skill, baseline/optimized, or positive/negative comparison, complete requires evidence for every requested side of the comparison; implementing code or tests for only one side is incomplete.", + "If the final response says a required benchmark/pipeline was not run, was skipped to save quota/time, should be run as the next step, or is only expected to pass based on code inspection, choose retry even when unit tests passed.", + "Do not accept self-justification that unit/type/component tests are a substitute for an explicitly requested benchmark score or pipeline run.", + "For frontend or WebUI-visible changes, source edits and type checks are not enough. Complete requires evidence that the served frontend bundle was rebuilt/restarted or otherwise refreshed, plus a browser/E2E/UI verification against the running UniDesk frontend when the user-visible behavior is the acceptance target.", + "If a frontend/UI task final answer says public/full E2E was not run, or the transcript lacks a frontend rebuild/server rebuild plus served-UI verification, choose retry when the requested change could be invisible in the deployed UI bundle.", + "For PikaPython/PikaBench tasks mentioning scores such as 4/4, 8/8, 40/40, or skill/no-skill behavior, require actual scorer or pipeline run evidence for the requested score and comparison before complete.", + "For hardware, firmware, provider, WSL, SSH passthrough, skill, compile, flash/download, serial, or board-comm tasks, complete requires evidence for the requested operational steps; partial documentation or a single successful build is retry if download/board/serial validation was requested but not evidenced.", + "If the final answer mostly addresses a later steer prompt while the original task remains partly unfinished, choose retry with a continuation prompt that reconciles both the steer and the original task.", + ], + classicFailureExamples: [ + { + pattern: "Original asks: without a generalized PikaPython benchmark skill it must not reach 4/4, with the skill it must reach 4/4.", + incompleteEvidence: "Agent only moved targeted prompt content into a skill, ran type/component/unit tests, and says it did not start the PikaBench-4 baseline/no-skill run to save quota.", + requiredDecision: "retry", + reason: "The requested empirical with-skill/no-skill pipeline comparison was not run, so the stated acceptance criterion is not proven.", + }, + { + pattern: "Original asks to learn a D601 ConStart/constar firmware workspace, use skills for compile/download/etc., and update long-term docs. Later steer asks to move project docs into constar/docs.", + incompleteEvidence: "Agent performed some skill discovery and documentation migration, but the final response is mainly about the steer-driven doc migration and does not prove all requested compile/download/serial/board-comm operational acceptance points were completed.", + requiredDecision: "retry", + reason: "This is unfinished original work, not a non-retryable failure; continue the same session and complete the missing operational validation/docs.", + }, + { + pattern: "Original asks to stop auto-refreshing a ClaudeQQ login QR code. Code was edited and frontend rebuild succeeded, but the Codex turn later ended failed with 429 Too Many Requests / exceeded retry limit while E2E or dependency validation was still running.", + incompleteEvidence: "The terminal turn status is failed, the final response is empty or missing a complete summary, and validation did not finish because of a service rate limit.", + requiredDecision: "retry", + reason: "A rate-limit or retry-limit termination is infrastructure interruption; it cannot be counted as complete and must continue the existing session.", + }, + { + pattern: "Original asks to rename a UniDesk sidebar/WebUI label from 微服务 to 用户服务 and update long-term docs.", + incompleteEvidence: "Agent edited frontend source files, docs, and E2E selectors and ran `bun scripts/cli.ts check`, but did not rebuild/restart the frontend bundle and did not verify the running served UI; final answer also says full public E2E was not run. User later observes the UI change did not actually take effect.", + requiredDecision: "retry", + reason: "A WebUI-visible change is not complete until the deployed/served frontend has been rebuilt or refreshed and verified. Source edits plus type checks can leave the live UI unchanged.", + }, + ], }); } @@ -1388,6 +4096,118 @@ const defaultJudgeProbeCases: JudgeProbeCase[] = [ ], events: [{ at: nowIso(), method: "turn/completed", status: "completed" }], }, + { + id: "pikapython_skill_ablation_not_run", + prompt: "从这个结果看,是通过专门定向优化prompt解决了pikabench-4的可解性,并且使得其他组件都没有明显贡献,这个思路是错误的,输入任务prompt应当是通用的,需求导向而非精确控制内部过程的。改进方案是将专门定向的prompt改为泛化的经验型skill来注入,预期的结果是,不注入这个泛化的经验型skill,就不能跑出4/4,注入了就能跑出。将禁止针对任务定向优化prompt的原则也固化到 pipeline 长期文档。", + finalResponse: "已把这轮“定向 prompt 解题”的路径改成“通用需求 prompt + 可消融的经验型 skill”。D601 Pipeline prompt 已去定向化,泛化经验已移入 PikaPython skill,no-skill 边界已恢复,长期规则已固化,测试已补防回归。验证已通过:npm run typecheck、npm run components:validate、npm run test,50 tests passed。我没有启动新的 PikaBench-4 baseline/no-skill 实跑,避免继续消耗 MiniMax 限额;现在代码与测试层面已经把“skill 可消融”边界重新建立起来。下一步如果要验证预期,应只跑最小对照:pikabench-4-ablation-baseline vs pikabench-4-no-skill。", + expected: "retry", + terminalStatus: "completed", + outputs: [ + { + channel: "user", + text: "从这个结果看,是通过专门定向优化prompt解决了pikabench-4的可解性,并且使得其他组件都没有明显贡献,这个思路是错误的,输入任务prompt应当是通用的,需求导向而非精确控制内部过程的。改进方案是将专门定向的prompt改为泛化的经验型skill来注入,预期的结果是,不注入这个泛化的经验型skill,就不能跑出4/4,注入了就能跑出。将禁止针对任务定向优化prompt的原则也固化到 pipeline 长期文档。\n", + method: "enqueue", + }, + { + channel: "command", + text: "item/completed: bun scripts/cli.ts ssh D601 'cd /home/ubuntu/pipeline && npm run typecheck && npm run components:validate && npm run test' status=completed; 50 tests passed", + method: "item/completed", + }, + { + channel: "assistant", + text: "已把这轮“定向 prompt 解题”的路径改成“通用需求 prompt + 可消融的经验型 skill”。验证已通过:npm run typecheck、npm run components:validate、npm run test,50 tests passed。我没有启动新的 PikaBench-4 baseline/no-skill 实跑,避免继续消耗 MiniMax 限额;下一步如果要验证预期,应只跑最小对照:pikabench-4-ablation-baseline vs pikabench-4-no-skill。", + method: "item/agentMessage/delta", + }, + { channel: "system", text: "turn completed status=completed\n", method: "turn/completed" }, + ], + events: [{ at: nowIso(), method: "turn/completed", status: "completed" }], + }, + { + id: "constar_docs_steer_unfinished_should_retry", + prompt: "constar的工作目录在D601 /mnt/f/work/constar,你先摸清楚这个工程怎么用D601的skills做编译,下载等,摸熟悉之后更新长期参考文档,包括unidesk中简要引导,constar 里面补充在wsl中开发的注意事项等", + finalResponse: "已按你的更正处理:长期文档可以留在 `constar/docs/`,不再放在 `constar/projects/` 里维护。已迁移非 docx 项目文档,已更新长期规则与索引,并验证项目文档目录只剩 docx。注意:根仓库新增/修改 `docs/reference/...`,FREQ 子仓库中 `README.md` 修改、`项目文档/quick_start_debug.md` 删除。", + expected: "retry", + terminalStatus: "completed", + outputs: [ + { + channel: "user", + text: "constar的工作目录在D601 /mnt/f/work/constar,你先摸清楚这个工程怎么用D601的skills做编译,下载等,摸熟悉之后更新长期参考文档,包括unidesk中简要引导,constar 里面补充在wsl中开发的注意事项等\n", + method: "enqueue", + }, + { + channel: "command", + text: "item/completed: bun scripts/cli.ts ssh D601 skills --limit 120 status=completed; keil/serial-monitor/board-comm wrappers discovered", + method: "item/completed", + }, + { + channel: "command", + text: "item/completed: bun scripts/cli.ts ssh D601 -- 'cd /mnt/f/Work/ConStart && keil build --wait -p projects/71-00075-11/FirmWare/MDK-ARM/FREQ_Controller_FW.uvprojx -t FREQ_Controller_FW' status=completed; return_code=0 success=true", + method: "item/completed", + }, + { + channel: "user", + text: "[steer] freq项目里面的项目文档要迁移到constar/docs/ 里面(docx不迁移),并在长期文档中说明只创建和维护constar/docs里面的长期文档\n", + method: "turn/steer", + }, + { + channel: "assistant", + text: "已按你的更正处理:长期文档可以留在 `constar/docs/`,不再放在 `constar/projects/` 里维护。已迁移非 docx 项目文档,并验证相关 Markdown。", + method: "item/agentMessage/delta", + }, + { channel: "system", text: "turn completed status=completed\n", method: "turn/completed" }, + ], + events: [{ at: nowIso(), method: "turn/completed", status: "completed" }], + }, + { + id: "rate_limit_after_partial_work_should_retry", + prompt: "不要总是刷新登录二维码,这样容易过期,只在没有获取二维码的时候获取,之后不要自动刷新,只保留手动刷新", + finalResponse: "", + expected: "retry", + terminalStatus: "failed", + terminalError: "exceeded retry limit, last status: 429 Too Many Requests, request id: 21zqfw7apcg", + outputs: [ + { + channel: "user", + text: "不要总是刷新登录二维码,这样容易过期,只在没有获取二维码的时候获取,之后不要自动刷新,只保留手动刷新\n", + method: "enqueue", + }, + { channel: "command", text: "item/completed: git diff -- src/components/frontend/src/claudeqq.tsx status=completed", method: "item/completed" }, + { channel: "command", text: "item/completed: bun run check status=completed", method: "item/completed" }, + { channel: "command", text: "item/completed: bun scripts/cli.ts server rebuild frontend status=completed; job succeeded", method: "item/completed" }, + { channel: "command", text: "item/completed: bun scripts/cli.ts e2e run --only frontend:claudeqq status=failed; Playwright browser/dependency issue", method: "item/completed" }, + { channel: "command", text: "item/started: bunx playwright install-deps chromium status=inProgress", method: "item/started" }, + { channel: "error", text: "exceeded retry limit, last status: 429 Too Many Requests, request id: 21zqfw7apcg\n", method: "error" }, + { channel: "error", text: "turn completed status=failed\n", method: "turn/completed" }, + ], + events: [ + { at: nowIso(), method: "item/completed", itemType: "commandExecution", status: "failed", message: "Playwright dependency validation not complete" }, + { at: nowIso(), method: "turn/completed", status: "failed", message: "exceeded retry limit, last status: 429 Too Many Requests" }, + ], + }, + { + id: "frontend_visible_change_without_rebuild_should_retry", + prompt: "unidesk 把侧边栏的 `微服务` 改成 `用户服务`,单纯进行改名,长期文档中也要同步修改命名,明确概念,用户服务指的是在 unidesk 核心服务上挂载的用户使用的服务,unidesk 核心缺少这些服务也依然可以运行", + finalResponse: "已完成,属于纯命名调整,没有改路由/API/CLI 的兼容命名。\n\n- 前端侧边栏主模块从 `微服务` 改为 `用户服务`:`src/components/frontend/src/navigation.ts`\n- 同步了前端可见文案,包括用户服务目录、顶部状态、各服务页 eyebrow/raw title/空状态。\n- 更新 E2E 选择器和断言,Playwright 现在点击/校验 `用户服务`。\n- 长期文档已同步概念。\n\n验证已跑:\n- `bun scripts/cli.ts check` 通过\n- `bun scripts/cli.ts help` 过滤确认 `microservice` 命令描述已改为 user services\n\n未跑完整公网 E2E;本次只做命名和文档同步。", + expected: "retry", + terminalStatus: "completed", + outputs: [ + { + channel: "user", + text: "unidesk 把侧边栏的 `微服务` 改成 `用户服务`,单纯进行改名,长期文档中也要同步修改命名,明确概念,用户服务指的是在 unidesk 核心服务上挂载的用户使用的服务,unidesk 核心缺少这些服务也依然可以运行\n", + method: "enqueue", + }, + { channel: "diff", text: "item/completed: file changes status=completed; M src/components/frontend/src/navigation.ts M src/components/frontend/src/app.tsx M docs/reference/microservices.md", method: "item/completed" }, + { channel: "command", text: "item/completed: bun scripts/cli.ts check status=completed; ok=true", method: "item/completed" }, + { channel: "command", text: "item/completed: bun scripts/cli.ts help | python3 ... status=completed; microservice descriptions mention user services", method: "item/completed" }, + { + channel: "assistant", + text: "已完成,属于纯命名调整。验证已跑:bun scripts/cli.ts check 通过;bun scripts/cli.ts help 过滤确认 microservice 命令描述已改为 user services。未跑完整公网 E2E;本次只做命名和文档同步。", + 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.", @@ -1432,16 +4252,21 @@ function taskForJudgeProbe(probe: JudgeProbeCase): QueueTask { const at = nowIso(); return { id: `judge_probe_${probe.id}`, + queueId: defaultQueueId, prompt: probe.prompt, + basePrompt: probe.prompt, + referenceTaskIds: [], + referenceInjection: null, cwd: config.defaultWorkdir, model: config.defaultModel, - reasoningEffort: config.defaultReasoningEffort, + reasoningEffort: resolveReasoningEffort(config.defaultModel, config.defaultReasoningEffort), maxAttempts: 3, status: "judging", createdAt: at, updatedAt: at, startedAt: at, finishedAt: null, + readAt: null, currentAttempt: 1, currentMode: "initial", codexThreadId: "judge-probe-thread", @@ -1449,6 +4274,8 @@ function taskForJudgeProbe(probe: JudgeProbeCase): QueueTask { finalResponse: probe.finalResponse, lastError: null, lastJudge: null, + judgeFailCount: 0, + promptHistory: [], output: (probe.outputs ?? []).map(outputForProbe), events: probe.events ?? [], attempts: [], @@ -1508,8 +4335,9 @@ async function runJudgeProbe(): Promise { }); } -function attemptFromResult(task: QueueTask, mode: RunMode, startedAt: string, finishedAt: string, result: CodexRunResult): AttemptSummary { - return { +function attemptFromResult(task: QueueTask, mode: RunMode, startedAt: string, finishedAt: string, result: CodexRunResult, outputStartSeq: number | null = null, outputEndSeq: number | null = null, inputPrompt: string | null = null): AttemptSummary { + const finalResponse = result.finalResponse; + const attempt: AttemptSummary = { index: task.currentAttempt, mode, startedAt, @@ -1519,14 +4347,68 @@ function attemptFromResult(task: QueueTask, mode: RunMode, startedAt: string, fi appServerExitCode: result.appServerExit.code, appServerSignal: result.appServerExit.signal, error: result.terminalError, - finalResponsePreview: safePreview(result.finalResponse, 3000), + finalResponse, + finalResponsePreview: safePreview(finalResponse, 3000), + finalResponseChars: finalResponse.length, + judge: null, + judgeAt: null, + judgeSeq: null, stderrTail: safePreview(result.appServerExit.stderrTail, 3000), + outputStartSeq, + outputEndSeq, }; + if (inputPrompt !== null) setAttemptInputPrompt(attempt, inputPrompt); + return attempt; +} + +function taskPromptWithReferenceContext(task: QueueTask): string { + const referenceTaskIds = task.referenceTaskIds.length > 0 ? task.referenceTaskIds : referenceTaskIdsFromPrompt(task.basePrompt || userPromptForDisplay(task.prompt)); + let taskPrompt = stripCodexQueueEnvironmentHint(task.prompt); + if (referenceTaskIds.length > 0) { + try { + const basePrompt = task.basePrompt || userPromptForDisplay(task.prompt); + taskPrompt = injectReferencedTaskContext({ prompt: basePrompt, basePrompt, referenceTaskIds }).prompt; + } catch (error) { + logger("warn", "retry_reference_context_injection_failed", { taskId: task.id, error: error instanceof Error ? error.message : String(error) }); + } + } + return taskPrompt; } 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"); + const taskPrompt = taskPromptWithReferenceContext(task); + return [retryInstruction, "原始任务/引用上下文:", taskPrompt].join("\n\n"); +} + +function retryBackoffMs(completedAttempts: number): number { + const retryIndex = Math.max(1, Math.floor(completedAttempts)); + const exponent = Math.min(20, retryIndex - 1); + return Math.min(retryBackoffMaxMs, retryBackoffBaseMs * (2 ** exponent)); +} + +async function sleepForRetryBackoff(task: QueueTask, delayMs: number): Promise { + let remaining = delayMs; + while (remaining > 0 && !task.cancelRequested && !shutdownRequested) { + const chunk = Math.min(1000, remaining); + await Bun.sleep(chunk); + remaining -= chunk; + } +} + +function judgeFailContinuationPrompt(task: QueueTask, judge: JudgeResult, failCount: number): string { + const taskPrompt = taskPromptWithReferenceContext(task); + const parts = [ + `上一次 judge 判定为 fail (${failCount}/${judgeFailRetryLimit}),但 Codex Queue 策略要求:非用户取消、非确定不可恢复的情况必须当作“未完成”继续当前 session,直到 fail 累计 ${judgeFailRetryLimit} 次才真正放弃。`, + "这是同一个 Codex thread 的 continuation,不是新任务;不要重新开局或从头摸索,只补齐缺失验收项。", + `judge 理由:${judge.reason}`, + "请不要放弃或新开任务。继续完成原始任务中尚未完成/未验证的验收项,优先补齐 judge 指出的缺口,并给出真实命令、文件或运行结果证据。", + ]; + if (judge.continuePrompt !== undefined && judge.continuePrompt.trim().length > 0) { + parts.push("judge 建议的继续提示:", judge.continuePrompt.trim()); + } + parts.push("原始任务/引用上下文:", taskPrompt); + return parts.join("\n\n"); } function queueActiveTasksForRestartRetry(reason: string, method: string): number { @@ -1538,31 +4420,63 @@ function queueActiveTasksForRestartRetry(reason: string, method: string): number task.lastError = reason; task.nextMode = "retry"; task.nextPrompt = retryPrompt(task, { decision: "retry", confidence: 1, reason, source: "fallback" }); + task.maxAttempts = Math.max(task.maxAttempts, maxTaskAttempts, task.attempts.length + 1); + setAttemptFeedbackPrompt(task.attempts.at(-1), task.nextPrompt, "queue-recovery-retry", task.attempts.length + 1); task.updatedAt = nowIso(); appendOutput(task, "system", `${reason}; task queued for retry\n`, method); recovered += 1; } + if (recovered > 0) armIdleNotification(); return recovered; } +function failTaskForFallbackRetryLimit(task: QueueTask, judge: JudgeResult | null): void { + const count = fallbackJudgeRetryCount(task); + const reason = `Fallback judge retry limit reached (${count}/${fallbackJudgeRetryLimit}). ${judge?.reason ?? task.lastJudge?.reason ?? "MiniMax judge was unavailable."}`; + task.status = "failed"; + task.finishedAt = nowIso(); + task.updatedAt = task.finishedAt; + task.activeTurnId = null; + task.nextPrompt = null; + task.nextMode = null; + task.lastError = safePreview(reason, 2000); + appendOutput(task, "error", `${reason}\n`, "queue"); + persistState(); + logger("warn", "task_failed_by_fallback_retry_limit", { + taskId: task.id, + fallbackRetryCount: count, + fallbackJudgeRetryLimit, + reason: safePreview(reason, 500), + }); + void notifyTaskTerminal(task); +} + 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) }); + logger("info", "task_run_start", { taskId: task.id, queueId: queueIdOf(task), maxAttempts: task.maxAttempts, model: task.model, promptPreview: safePreview(task.prompt, 240) }); + if (task.status === "retry_wait" && task.lastJudge?.source === "fallback" && task.lastJudge.decision === "retry" && fallbackJudgeRetryCount(task) > fallbackJudgeRetryLimit) { + failTaskForFallbackRetryLimit(task, task.lastJudge); + return; + } + armIdleNotification(); + task.maxAttempts = Math.max(task.maxAttempts, maxTaskAttempts); task.startedAt ??= nowIso(); task.lastError = null; while (task.attempts.length < task.maxAttempts && !task.cancelRequested && !shutdownRequested) { const mode = task.nextMode ?? (task.attempts.length === 0 ? "initial" : "retry"); - const prompt = task.nextPrompt ?? task.prompt; + const rawPrompt = task.nextPrompt ?? task.prompt; + const prompt = promptWithCodexQueueEnvironmentHint(rawPrompt); const startedAt = nowIso(); task.currentAttempt = task.attempts.length + 1; task.currentMode = mode; task.status = "running"; + task.readAt = null; task.updatedAt = startedAt; - appendOutput(task, "system", `attempt ${task.currentAttempt}/${task.maxAttempts} mode=${mode} model=${task.model}\n`, "queue"); + const attemptStartOutput = appendOutput(task, "system", `attempt ${task.currentAttempt}/${task.maxAttempts} queue=${queueIdOf(task)} 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.attempts.push(attemptFromResult(task, mode, startedAt, finishedAt, result, attemptStartOutput?.seq ?? null, taskFullOutput(task).at(-1)?.seq ?? null, rawPrompt)); task.status = "judging"; task.updatedAt = nowIso(); persistState(); @@ -1570,7 +4484,14 @@ async function runTask(task: QueueTask): Promise { 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"); + const judgeOutput = 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"); + const latestAttempt = task.attempts.at(-1); + if (latestAttempt !== undefined && latestAttempt.index === task.currentAttempt) { + latestAttempt.judge = judge; + latestAttempt.judgeAt = judgeOutput?.at ?? nowIso(); + latestAttempt.judgeSeq = judgeOutput?.seq ?? null; + latestAttempt.outputEndSeq = judgeOutput?.seq ?? latestAttempt.outputEndSeq ?? null; + } 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") { @@ -1581,22 +4502,58 @@ async function runTask(task: QueueTask): Promise { task.nextMode = null; persistState(); logger("info", "task_succeeded", { taskId: task.id, attempts: task.attempts.length }); + void notifyTaskTerminal(task); return; } if (judge.decision === "fail") { + task.judgeFailCount += 1; + if (!explicitUserInterrupt(task, result) && task.judgeFailCount < judgeFailRetryLimit) { + task.status = "retry_wait"; + const nextPrompt = judgeFailContinuationPrompt(task, judge, task.judgeFailCount); + task.nextPrompt = nextPrompt; + task.nextMode = "retry"; + task.maxAttempts = Math.max(task.maxAttempts, task.attempts.length + 1); + setAttemptFeedbackPrompt(latestAttempt, nextPrompt, "judge-fail-retry", task.attempts.length + 1); + task.updatedAt = nowIso(); + appendOutput(task, "system", `judge=fail treated as retry (${task.judgeFailCount}/${judgeFailRetryLimit}); appending continuation prompt to existing session\n`, "queue"); + persistState(); + logger("warn", "task_judge_fail_treated_as_retry", { + taskId: task.id, + attempt: task.currentAttempt, + judgeFailCount: task.judgeFailCount, + judgeFailRetryLimit, + reason: safePreview(judge.reason, 500), + }); + const delayMs = retryBackoffMs(task.attempts.length); + appendOutput(task, "system", `retry backoff ${Math.round(delayMs / 1000)}s before appending continuation to existing session\n`, "queue"); + await sleepForRetryBackoff(task, delayMs); + continue; + } task.status = "failed"; task.finishedAt = nowIso(); + task.updatedAt = task.finishedAt; task.activeTurnId = null; task.lastError = judge.reason; + appendOutput(task, "system", `judge=fail reached terminal threshold (${task.judgeFailCount}/${judgeFailRetryLimit}); queue will continue to the next queued task\n`, "queue"); persistState(); - logger("warn", "task_failed_by_judge", { taskId: task.id, reason: safePreview(judge.reason, 500) }); + logger("warn", "task_failed_by_judge_queue_continues", { taskId: task.id, judgeFailCount: task.judgeFailCount, judgeFailRetryLimit, reason: safePreview(judge.reason, 500) }); + void notifyTaskTerminal(task); + return; + } + if (judge.source === "fallback" && fallbackJudgeRetryCount(task) > fallbackJudgeRetryLimit) { + failTaskForFallbackRetryLimit(task, judge); return; } task.status = "retry_wait"; - task.nextPrompt = retryPrompt(task, judge); + const nextPrompt = retryPrompt(task, judge); + task.nextPrompt = nextPrompt; task.nextMode = "retry"; + setAttemptFeedbackPrompt(latestAttempt, nextPrompt, judge.continuePrompt?.trim() ? "judge-continue-prompt" : "judge-retry-generated", task.attempts.length + 1); task.updatedAt = nowIso(); persistState(); + const delayMs = retryBackoffMs(task.attempts.length); + appendOutput(task, "system", `retry backoff ${Math.round(delayMs / 1000)}s before appending continuation to existing session\n`, "queue"); + await sleepForRetryBackoff(task, delayMs); } if (shutdownRequested) { @@ -1619,15 +4576,38 @@ async function runTask(task: QueueTask): Promise { 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 ?? "" }); + void notifyTaskTerminal(task); } -async function processQueue(): Promise { - if (processing || shutdownRequested) return; - processing = true; +function updateProcessingFlag(): void { + processing = processingQueues.size > 0; +} + +function runnableQueueIds(): string[] { + const ids: string[] = []; + const seen = new Set(); + for (const task of state.tasks) { + if (task.status !== "queued" && task.status !== "retry_wait") continue; + const queueId = queueIdOf(task); + if (seen.has(queueId)) continue; + seen.add(queueId); + ids.push(queueId); + } + return ids; +} + +function nextRunnableTask(queueId: string): QueueTask | null { + return state.tasks.find((item) => queueIdOf(item) === queueId && (item.status === "queued" || item.status === "retry_wait")) ?? null; +} + +async function processQueue(queueId: string): Promise { + if (!serviceReady || processingQueues.has(queueId) || shutdownRequested) return; + processingQueues.add(queueId); + updateProcessingFlag(); try { while (true) { if (shutdownRequested) break; - const task = state.tasks.find((item) => item.status === "queued" || item.status === "retry_wait") ?? null; + const task = nextRunnableTask(queueId); if (task === null) break; try { await runTask(task); @@ -1641,33 +4621,55 @@ async function processQueue(): Promise { task.updatedAt = nowIso(); persistState(); logger("error", "task_failed_by_queue_exception", { taskId: task.id, error: safePreview(message, 1000) }); + void notifyTaskTerminal(task); } } } finally { - processing = false; + processingQueues.delete(queueId); + updateProcessingFlag(); persistState(); + if (!shutdownRequested && nextRunnableTask(queueId) !== null) scheduleQueue(queueId); + void maybeNotifyQueueIdle().catch((error) => logger("warn", "claudeqq_idle_notify_schedule_failed", { error: errorToJson(error) })); } } -function scheduleQueue(): void { - if (shutdownRequested) return; - void processQueue().catch((error) => { - logger("error", "queue_loop_failed", { error: error instanceof Error ? error.stack ?? error.message : String(error) }); - processing = false; - activeRun = null; - }); +function scheduleQueue(queueId?: string): void { + if (!serviceReady || shutdownRequested) return; + const ids = queueId === undefined ? runnableQueueIds() : [queueId]; + for (const id of ids) { + void processQueue(id).catch((error) => { + logger("error", "queue_loop_failed", { queueId: id, error: error instanceof Error ? error.stack ?? error.message : String(error) }); + processingQueues.delete(id); + updateProcessingFlag(); + const run = activeRuns.get(id); + if (run !== undefined) { + run.app.stop(); + activeRuns.delete(id); + } + void maybeNotifyQueueIdle().catch((idleError) => logger("warn", "claudeqq_idle_notify_schedule_failed", { error: errorToJson(idleError) })); + }); + } } function hasRunnableTask(): boolean { return state.tasks.some((task) => task.status === "queued" || task.status === "retry_wait"); } +function activeRunForTask(task: QueueTask): ActiveRun | null { + const queueRun = activeRuns.get(queueIdOf(task)); + if (queueRun?.taskId === task.id) return queueRun; + return Array.from(activeRuns.values()).find((run) => run.taskId === task.id) ?? null; +} + function installShutdownHandlers(): void { const stop = (signal: NodeJS.Signals): void => { if (shutdownRequested) process.exit(0); shutdownRequested = true; const recovered = queueActiveTasksForRestartRetry("Service stopping while task was active", "shutdown"); - if (activeRun !== null) activeRun.app.stop(); + for (const run of activeRuns.values()) run.app.stop(); + activeRuns.clear(); + processingQueues.clear(); + updateProcessingFlag(); persistState(); logger("warn", "service_shutdown_requeued_active_tasks", { signal, recovered }); process.exit(0); @@ -1677,8 +4679,10 @@ function installShutdownHandlers(): void { } setInterval(() => { - if (!processing && hasRunnableTask()) { - logger("warn", "queue_watchdog_rescheduled", { runnable: state.tasks.filter((task) => task.status === "queued" || task.status === "retry_wait").length }); + if (!serviceReady) return; + const pendingQueues = runnableQueueIds().filter((queueId) => !processingQueues.has(queueId)); + if (pendingQueues.length > 0) { + logger("warn", "queue_watchdog_rescheduled", { runnable: state.tasks.filter((task) => task.status === "queued" || task.status === "retry_wait").length, pendingQueues }); scheduleQueue(); } }, 5000).unref?.(); @@ -1695,6 +4699,18 @@ function jsonResponse(body: unknown, status = 200): Response { }); } +function compactJsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + 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 {}; @@ -1710,28 +4726,264 @@ function parseLimit(url: URL): number { return Number.isInteger(value) && value > 0 ? Math.min(500, value) : 100; } +function parseNamedLimit(url: URL, name: string, defaultValue: number): number { + const value = Number(url.searchParams.get(name) ?? defaultValue); + return Number.isInteger(value) && value > 0 ? Math.min(500, value) : defaultValue; +} + +function activePriority(task: QueueTask): number { + const statusRank: Record = { + running: 0, + judging: 1, + retry_wait: 2, + queued: 3, + succeeded: 9, + failed: 9, + canceled: 9, + }; + return statusRank[task.status] ?? 9; +} + +function taskUpdatedSortValue(task: QueueTask): number { + const time = Date.parse(task.updatedAt || task.createdAt); + return Number.isFinite(time) ? time : 0; +} + +function taskPageRows(filteredTasks: QueueTask[], url: URL, limit: number): { + rows: QueueTask[]; + total: number; + returned: number; + hasMore: boolean; + nextBeforeId: string | null; + beforeId: string | null; + includeActive: boolean; +} { + const beforeId = url.searchParams.get("beforeId"); + const includeActive = url.searchParams.get("includeActive") !== "0"; + const beforeIndex = beforeId === null ? -1 : filteredTasks.findIndex((task) => task.id === beforeId); + const safeEndIndex = beforeId === null || beforeIndex < 0 ? filteredTasks.length : beforeIndex; + const pageSource = filteredTasks.slice(Math.max(0, safeEndIndex - limit), safeEndIndex).reverse(); + const unreadTerminalRows = includeActive + ? filteredTasks + .filter(terminalTaskUnread) + .sort((left, right) => taskUpdatedSortValue(right) - taskUpdatedSortValue(left)) + : []; + const activeRows = includeActive + ? filteredTasks + .filter((task) => activePriority(task) < 3) + .sort((left, right) => { + const rankDelta = activePriority(left) - activePriority(right); + if (rankDelta !== 0) return rankDelta; + return taskUpdatedSortValue(right) - taskUpdatedSortValue(left); + }) + : []; + const byId = new Map(); + for (const task of [...unreadTerminalRows, ...activeRows, ...pageSource]) { + if (!byId.has(task.id)) byId.set(task.id, task); + } + const rows = Array.from(byId.values()); + const pageOldest = pageSource.at(-1) ?? null; + return { + rows, + total: filteredTasks.length, + returned: rows.length, + hasMore: safeEndIndex - limit > 0, + nextBeforeId: safeEndIndex - limit > 0 ? pageOldest?.id ?? null : null, + beforeId, + includeActive, + }; +} + +function tasksOverviewResponse(url: URL): Response { + const limit = parseLimit(url); + const compact = truthyParam(url, "compact"); + const queueFilter = url.searchParams.get("queueId"); + const filteredTasks = queueFilter === null ? state.tasks : state.tasks.filter((task) => queueIdOf(task) === safeQueueId(queueFilter)); + const page = taskPageRows(filteredTasks, url, limit); + const rowsSource = page.rows; + const queue = queueSummary(false) as Record; + const preferId = url.searchParams.get("preferId") ?? ""; + const activeTaskId = typeof queue.activeTaskId === "string" ? queue.activeTaskId : ""; + const selectedTask = filteredTasks.find((task) => task.id === preferId) + ?? filteredTasks.find((task) => task.id === activeTaskId) + ?? rowsSource[0] + ?? null; + let selected: JsonValue = null; + if (selectedTask !== null) { + const afterSeqRaw = Number(url.searchParams.get("afterSeq") ?? 0); + const afterSeq = Number.isFinite(afterSeqRaw) ? afterSeqRaw : 0; + const transcriptLimit = parseNamedLimit(url, "transcriptLimit", 500); + const transcript = compact + ? buildCompactTaskTranscript(selectedTask, transcriptLimit, Math.max(12, transcriptLimit * 3)) + : buildTaskTranscript(selectedTask, transcriptLimit, 0); + const chunk = transcript.filter((line) => Number(line.seq) > afterSeq).slice(0, transcriptLimit); + const nextAfterSeq = chunk.at(-1)?.seq ?? afterSeq; + const outputMaxSeq = selectedTask.output.at(-1)?.seq ?? 0; + const promptHistoryMaxSeq = selectedTask.promptHistory.at(-1)?.seq ?? 0; + const maxSeq = Math.max(outputMaxSeq, promptHistoryMaxSeq, transcript.at(-1)?.seq ?? 0); + selected = { + task: compact ? taskForCompactMetaResponse(selectedTask) : taskForMetaResponse(selectedTask), + transcript: chunk, + afterSeq, + nextAfterSeq, + hasMore: maxSeq > Number(nextAfterSeq), + preview: true, + total: transcript.length, + maxSeq, + } as unknown as JsonValue; + } + return compactJsonResponse({ + ok: true, + queue, + tasks: rowsSource.map((task) => taskForListResponse(task, true)), + selected, + pagination: { + limit, + returned: page.returned, + total: page.total, + hasMore: page.hasMore, + nextBeforeId: page.nextBeforeId, + beforeId: page.beforeId, + includeActive: page.includeActive, + }, + }); +} + async function createTasks(req: Request): Promise { const body = await readJson(req); + const batchRecord = typeof body === "object" && body !== null && !Array.isArray(body) ? body as Record : {}; + const batchQueueId = typeof batchRecord.queueId === "string" && batchRecord.queueId.trim().length > 0 ? normalizeQueueId(batchRecord.queueId) : undefined; 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))); + const tasks = records.map((record) => { + const normalized = normalizeRequest(record); + if (normalized.queueId === undefined && batchQueueId !== undefined) normalized.queueId = batchQueueId; + return createTask(injectCodexQueueEnvironmentHint(injectReferencedTaskContext(normalized))); + }); for (const task of tasks) appendOutput(task, "user", `${task.prompt}\n`, "enqueue"); state.tasks.push(...tasks); + if (tasks.length > 0) armIdleNotification(); persistState(); - logger("info", "tasks_enqueued", { count: tasks.length, ids: tasks.map((task) => task.id) }); + logger("info", "tasks_enqueued", { count: tasks.length, ids: tasks.map((task) => task.id), queueIds: Array.from(new Set(tasks.map(queueIdOf))) }); scheduleQueue(); return jsonResponse({ ok: true, tasks: tasks.map((task) => taskForResponse(task)), queue: queueSummary() }, 202); } +function testTask(id: string, prompt: string, finalResponse: string, referenceTaskIds: string[] = [], createdAt = nowIso()): QueueTask { + return normalizeTask({ + id, + queueId: defaultQueueId, + prompt, + basePrompt: prompt, + referenceTaskIds, + referenceInjection: null, + cwd: config.defaultWorkdir, + model: config.defaultModel, + reasoningEffort: resolveReasoningEffort(config.defaultModel, config.defaultReasoningEffort), + maxAttempts: 1, + status: "succeeded", + createdAt, + updatedAt: createdAt, + startedAt: createdAt, + finishedAt: createdAt, + readAt: null, + currentAttempt: 1, + currentMode: "initial", + codexThreadId: null, + activeTurnId: null, + finalResponse, + lastError: null, + lastJudge: null, + judgeFailCount: 0, + promptHistory: [], + output: finalResponse.length > 0 ? [{ seq: 1, at: createdAt, channel: "assistant", text: finalResponse, method: "item/agentMessage" }] : [], + events: [], + attempts: [], + cancelRequested: false, + nextPrompt: null, + nextMode: null, + }); +} + +function assertReferenceTest(condition: boolean, message: string): void { + if (!condition) throw new Error(message); +} + +function runReferenceInjectionSelfTest(): JsonValue { + const at = "2026-05-08T00:00:00.000Z"; + const taskA = testTask("codex_1000_aaaaaa", "A base prompt", "A final", [], at); + const injectedB = injectReferencedTaskContext({ + prompt: "引用 Codex Queue 任务 codex_1000_aaaaaa。\n\n本次任务:\nB user prompt", + referenceTaskIds: [taskA.id], + }, (id) => id === taskA.id ? taskA : null, "2026-05-08T00:01:00.000Z"); + const taskB = testTask("codex_1001_bbbbbb", injectedB.prompt, "B final", injectedB.referenceTaskIds, "2026-05-08T00:02:00.000Z"); + taskB.basePrompt = injectedB.basePrompt ?? ""; + taskB.referenceInjection = injectedB.referenceInjection ?? null; + const injectedC = injectReferencedTaskContext({ + prompt: "C user prompt", + referenceTaskIds: [taskB.id], + }, (id) => id === taskA.id ? taskA : id === taskB.id ? taskB : null, "2026-05-08T00:03:00.000Z"); + const promptC = injectedC.prompt; + const hintedC = injectCodexQueueEnvironmentHint(injectedC); + assertReferenceTest(injectedB.basePrompt === "B user prompt", "B basePrompt should strip frontend reference hint"); + assertReferenceTest(taskB.referenceInjection?.items.length === 1, "B should have one reference item"); + assertReferenceTest(injectedC.basePrompt === "C user prompt", "C basePrompt should be raw C prompt"); + assertReferenceTest((injectedC.referenceInjection?.items.length ?? 0) === 2, "C should include B and A as two structured graph items"); + assertReferenceTest(hintedC.prompt.startsWith(codexQueueEnvironmentHintTitle), "C should include the Codex Queue environment hint"); + assertReferenceTest(userPromptForDisplay(hintedC.prompt) === "C user prompt", "C display prompt should strip environment and reference injection"); + assertReferenceTest(promptWithCodexQueueEnvironmentHint(hintedC.prompt) === hintedC.prompt, "environment hint injection should be idempotent"); + const indexA = promptC.indexOf("Round 1.1 referenced task codex_1000_aaaaaa"); + const indexB = promptC.indexOf("Round 2.1 referenced task codex_1001_bbbbbb"); + assertReferenceTest(indexA >= 0, "C should include upstream A as round 1"); + assertReferenceTest(indexB > indexA, "C should include direct B after upstream A"); + assertReferenceTest(promptC.includes("----- Reference Round 1/2 -----"), "C should include explicit round 1 separator"); + assertReferenceTest(promptC.includes("----- Reference Round 2/2 -----"), "C should include explicit round 2 separator"); + assertReferenceTest(promptC.indexOf("----- Reference Round 1/2 -----") < indexA, "round 1 separator should appear before A"); + assertReferenceTest(promptC.indexOf("----- Reference Round 2/2 -----") < indexB, "round 2 separator should appear before B"); + assertReferenceTest(promptC.includes("### Initial prompt\nB user prompt"), "C should inject B base prompt, not B injected prompt"); + assertReferenceTest(!promptC.includes("### Initial prompt\n# Codex Queue 已解析引用上下文"), "C should not nest prior injected prompt blocks"); + assertReferenceTest(promptC.includes("injectedAt: 2026-05-08T00:03:00.000Z"), "C should include injection timestamp"); + assertReferenceTest(promptC.includes("# 本次任务\nC user prompt"), "C should include current user prompt"); + const deepTasks: QueueTask[] = []; + for (let index = 0; index < 8; index += 1) { + const id = `codex_${2000 + index}_${"abcdef".slice(0, 6 - String(index).length)}${index}`; + const parent = deepTasks.at(-1); + deepTasks.push(testTask(id, `deep prompt ${index}`, `deep final ${index}`, parent === undefined ? [] : [parent.id], `2026-05-08T00:${String(10 + index).padStart(2, "0")}:00.000Z`)); + } + const deepById = new Map(deepTasks.map((task) => [task.id, task])); + const injectedDeep = injectReferencedTaskContext({ + prompt: "Deep user prompt", + referenceTaskIds: [deepTasks.at(-1)?.id ?? ""], + }, (id) => deepById.get(id) ?? null, "2026-05-08T00:30:00.000Z"); + assertReferenceTest(injectedDeep.referenceInjection?.itemCount === 8, "deep reference chain should not truncate at six rounds"); + assertReferenceTest(injectedDeep.referenceInjection?.truncated === false, "deep reference chain should be marked complete"); + assertReferenceTest(injectedDeep.prompt.includes("----- Reference Round 8/8 -----"), "deep reference chain should expose all eight rounds"); + return { + ok: true, + cases: [ + { name: "strip_frontend_hint_to_basePrompt", ok: true }, + { name: "multi_round_reference_graph", ok: true, itemCount: injectedC.referenceInjection?.itemCount ?? 0 }, + { name: "no_nested_injection_block", ok: true }, + { name: "chronological_round_order", ok: true }, + { name: "timestamp_and_round_separators", ok: true }, + { name: "environment_hint_injected_and_stripped_from_display", ok: true }, + { name: "deep_reference_graph_not_six_round_truncated", ok: true, itemCount: injectedDeep.referenceInjection?.itemCount ?? 0 }, + ], + promptPreview: safePreview(promptC, 1200), + }; +} + 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) { + const activeRun = activeRunForTask(task); + if (activeRun === null || 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"); + const output = appendOutput(task, "user", `\n[steer] ${prompt}\n`, "turn/steer"); + appendPromptHistory(task, output, "turn/steer", prompt); await activeRun.app.steer(activeRun.threadId, activeRun.turnId, prompt); return jsonResponse({ ok: true, task: taskForResponse(task), queue: queueSummary() }); } @@ -1743,7 +4995,8 @@ async function interruptTask(task: QueueTask): Promise { task.cancelRequested = true; task.updatedAt = nowIso(); appendOutput(task, "system", "interrupt requested\n", "turn/interrupt"); - if (activeRun !== null && activeRun.taskId === task.id) { + const activeRun = activeRunForTask(task); + if (activeRun !== null) { 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"); @@ -1757,24 +5010,96 @@ async function interruptTask(task: QueueTask): Promise { task.finishedAt = nowIso(); } persistState(); + if (terminalTask(task)) { + void notifyTaskTerminal(task).then(() => maybeNotifyQueueIdle(task.id)).catch((error) => logger("warn", "claudeqq_interrupt_notify_failed", { taskId: task.id, error: errorToJson(error) })); + } return jsonResponse({ ok: true, task: taskForResponse(task), queue: queueSummary() }); } -function manualRetry(task: QueueTask): Response { +async function manualRetry(task: QueueTask, req: Request): Promise { 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); } + const body = await readJson(req); + const record = extractRecord(body) ?? {}; + const explicitPrompt = typeof record.prompt === "string" ? record.prompt.trim() : typeof record.continuePrompt === "string" ? record.continuePrompt.trim() : ""; task.status = "queued"; task.finishedAt = null; + task.readAt = null; task.cancelRequested = false; task.lastError = null; - task.maxAttempts = Math.max(task.maxAttempts, task.attempts.length + 1); + task.maxAttempts = Math.max(task.maxAttempts, maxTaskAttempts, task.attempts.length + 1); task.nextMode = "retry"; - task.nextPrompt = retryPrompt(task, { decision: "retry", confidence: 1, reason: "Manual retry", source: "fallback" }); + task.nextPrompt = explicitPrompt.length > 0 ? explicitPrompt : retryPrompt(task, { decision: "retry", confidence: 1, reason: "Manual retry", source: "fallback" }); + setAttemptFeedbackPrompt(task.attempts.at(-1), task.nextPrompt, explicitPrompt.length > 0 ? "manual-retry-explicit" : "manual-retry-generated", task.attempts.length + 1); task.updatedAt = nowIso(); - appendOutput(task, "system", "manual retry queued\n", "manual-retry"); + appendOutput(task, "system", explicitPrompt.length > 0 ? "manual retry queued with explicit continuation prompt\n" : "manual retry queued\n", "manual-retry"); + armIdleNotification(); persistState(); - scheduleQueue(); + scheduleQueue(queueIdOf(task)); + return jsonResponse({ ok: true, task: taskForResponse(task), queue: queueSummary() }, 202); +} + +function markTaskRead(task: QueueTask): Response { + if (!terminalTask(task)) { + return jsonResponse({ ok: false, error: `task is not terminal: ${task.status}`, task: taskForResponse(task) }, 409); + } + if (task.readAt === null) { + task.readAt = nowIso(); + markTaskDirty(task.id); + persistState(false); + logger("info", "task_marked_read", { taskId: task.id, queueId: queueIdOf(task), status: task.status }); + } + return jsonResponse({ ok: true, task: taskForResponse(task), queue: queueSummary(false) }); +} + +function markTerminalTasksRead(url: URL): Response { + const queueFilter = url.searchParams.get("queueId"); + const queueId = queueFilter === null || queueFilter.length === 0 ? null : safeQueueId(queueFilter); + const readAt = nowIso(); + let count = 0; + for (const task of state.tasks) { + if (queueId !== null && queueIdOf(task) !== queueId) continue; + if (!terminalTaskUnread(task)) continue; + task.readAt = readAt; + markTaskDirty(task.id); + count += 1; + } + if (count > 0) { + persistState(false); + logger("info", "terminal_tasks_marked_read", { count, queueId }); + } + return jsonResponse({ ok: true, count, readAt, queue: queueSummary(false) }); +} + +async function createQueue(req: Request): Promise { + const body = await readJson(req); + const record = typeof body === "object" && body !== null && !Array.isArray(body) ? body as Record : {}; + const queueId = normalizeQueueId(record.queueId ?? record.id); + const beforeCount = state.queues.length; + const queue = ensureQueue(queueId); + queue.updatedAt = nowIso(); + persistState(false); + logger("info", "queue_created", { queueId, existed: beforeCount === state.queues.length }); + return jsonResponse({ ok: true, queue, queues: perQueueSummaries(), summary: queueSummary(false) }, beforeCount === state.queues.length ? 200 : 201); +} + +async function moveTaskToQueue(task: QueueTask, req: Request): Promise { + if (task.status === "running" || task.status === "judging") { + return jsonResponse({ ok: false, error: `cannot move active task ${task.id} while status=${task.status}`, task: taskForResponse(task) }, 409); + } + const body = await readJson(req); + const record = typeof body === "object" && body !== null && !Array.isArray(body) ? body as Record : {}; + const queueId = normalizeQueueId(record.queueId ?? record.id); + const previousQueueId = queueIdOf(task); + ensureQueue(queueId).updatedAt = nowIso(); + task.queueId = queueId; + task.updatedAt = nowIso(); + appendOutput(task, "system", `moved from queue=${previousQueueId} to queue=${queueId}\n`, "queue/move"); + if (task.status === "queued" || task.status === "retry_wait") armIdleNotification(); + persistState(); + logger("info", "task_moved_queue", { taskId: task.id, previousQueueId, queueId, status: task.status }); + if (task.status === "queued" || task.status === "retry_wait") scheduleQueue(queueId); return jsonResponse({ ok: true, task: taskForResponse(task), queue: queueSummary() }, 202); } @@ -1786,26 +5111,90 @@ async function route(req: Request): Promise { if (url.pathname === "/logs") return jsonResponse({ ok: true, logs: recentLogs.slice(-parseLimit(url)) }); if (url.pathname === "/api/dev-ready" && req.method === "GET") return jsonResponse({ ok: true, devReady: collectDevReady() }); if (url.pathname === "/api/judge/probe" && (req.method === "GET" || req.method === "POST")) return runJudgeProbe(); + if (url.pathname === "/api/reference-injection/self-test" && (req.method === "GET" || req.method === "POST")) return jsonResponse(runReferenceInjectionSelfTest()); + if (url.pathname === "/api/queues" && req.method === "GET") return jsonResponse({ ok: true, queues: perQueueSummaries(), queue: queueSummary(false) }); + if (url.pathname === "/api/queues" && req.method === "POST") return createQueue(req); + if (url.pathname === "/api/tasks/read-all" && req.method === "POST") return markTerminalTasksRead(url); + if (url.pathname === "/api/tasks/overview" && req.method === "GET") return tasksOverviewResponse(url); 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) => taskForListResponse(task)); - return jsonResponse({ ok: true, queue: queueSummary(), tasks }); + const queueId = url.searchParams.get("queueId"); + const lite = url.searchParams.get("lite") === "1"; + const includeDevReady = url.searchParams.get("devReady") !== "0" && !lite; + const filteredTasks = state.tasks + .filter((task) => status === null || task.status === status) + .filter((task) => queueId === null || queueIdOf(task) === safeQueueId(queueId)); + const limit = parseLimit(url); + const page = taskPageRows(filteredTasks, url, limit); + const tasks = page.rows.map((task) => taskForListResponse(task, lite)); + return jsonResponse({ + ok: true, + queue: queueSummary(includeDevReady), + tasks, + pagination: { + limit, + returned: page.returned, + total: page.total, + hasMore: page.hasMore, + nextBeforeId: page.nextBeforeId, + beforeId: page.beforeId, + includeActive: page.includeActive, + }, + }); } if ((url.pathname === "/api/tasks" || url.pathname === "/api/tasks/batch") && req.method === "POST") return createTasks(req); + const outputMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/output$/u); + if (outputMatch !== null && req.method === "GET") { + const task = findTask(decodeURIComponent(outputMatch[1] ?? "")); + if (task === null) return jsonResponse({ ok: false, error: "task not found" }, 404); + return outputChunkResponse(task, url); + } const transcriptMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/transcript$/u); if (transcriptMatch !== null && req.method === "GET") { const task = findTask(decodeURIComponent(transcriptMatch[1] ?? "")); if (task === null) return jsonResponse({ ok: false, error: "task not found" }, 404); return transcriptChunkResponse(task, url); } - const match = url.pathname.match(/^\/api\/tasks\/([^/]+)(?:\/(retry|steer|interrupt))?$/u); + const promptMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/prompt$/u); + if (promptMatch !== null && req.method === "GET") { + const task = findTask(decodeURIComponent(promptMatch[1] ?? "")); + if (task === null) return jsonResponse({ ok: false, error: "task not found" }, 404); + return taskPromptDetailResponse(task, url); + } + const traceSummaryMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/trace-summary$/u); + if (traceSummaryMatch !== null && req.method === "GET") { + const task = findTask(decodeURIComponent(traceSummaryMatch[1] ?? "")); + if (task === null) return jsonResponse({ ok: false, error: "task not found" }, 404); + return jsonResponse({ ok: true, summary: taskTraceSummaryResponse(task) }); + } + const traceStepsMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/trace-steps$/u); + if (traceStepsMatch !== null && req.method === "GET") { + const task = findTask(decodeURIComponent(traceStepsMatch[1] ?? "")); + if (task === null) return jsonResponse({ ok: false, error: "task not found" }, 404); + return taskTraceStepsResponse(task, url); + } + const traceStepMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/trace-step$/u); + if (traceStepMatch !== null && req.method === "GET") { + const task = findTask(decodeURIComponent(traceStepMatch[1] ?? "")); + if (task === null) return jsonResponse({ ok: false, error: "task not found" }, 404); + return taskTraceStepDetailResponse(task, url); + } + const summaryMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/summary$/u); + if (summaryMatch !== null && req.method === "GET") { + const task = findTask(decodeURIComponent(summaryMatch[1] ?? "")); + if (task === null) return jsonResponse({ ok: false, error: "task not found" }, 404); + return jsonResponse({ ok: true, summary: taskSummaryResponse(task, url) }); + } + const match = url.pathname.match(/^\/api\/tasks\/([^/]+)(?:\/(retry|steer|interrupt|move|read))?$/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 === "retry" && req.method === "POST") return manualRetry(task, req); if (action === "steer" && req.method === "POST") return steerTask(task, req); if (action === "interrupt" && req.method === "POST") return interruptTask(task); + if (action === "move" && req.method === "POST") return moveTaskToQueue(task, req); + if (action === "read" && req.method === "POST") return markTaskRead(task); if (action !== undefined) return jsonResponse({ ok: false, error: "not found" }, 404); if (req.method === "GET") { if (url.searchParams.get("meta") === "1") return jsonResponse({ ok: true, task: taskForMetaResponse(task) }); @@ -1824,8 +5213,9 @@ async function route(req: Request): Promise { installShutdownHandlers(); prepareCodexHome(); +await initDatabasePersistenceWithRetry(); Bun.serve({ hostname: config.host, port: config.port, idleTimeout: 120, fetch: route }); -logger("info", "service_started", { port: config.port, statePath: config.statePath, workdir: config.defaultWorkdir, defaultModel: config.defaultModel, judgeConfigured: config.minimaxApiKey.length > 0 }); +logger("info", "service_started", { port: config.port, statePath: config.statePath, workdir: config.defaultWorkdir, defaultModel: config.defaultModel, judgeConfigured: config.minimaxApiKey.length > 0, storage: databaseReady ? "postgres" : "file" }); { const devReady = collectDevReady() as Record; logger(devReady.ok === true ? "info" : "warn", "dev_ready_check", devReady); @@ -1833,4 +5223,5 @@ logger("info", "service_started", { port: config.port, statePath: config.statePa const startupRecovered = queueActiveTasksForRestartRetry("Service restarted while task was active", "startup"); if (startupRecovered > 0) logger("warn", "startup_requeued_active_tasks", { recovered: startupRecovered }); persistState(); +serviceReady = true; scheduleQueue(); diff --git a/src/components/microservices/project-manager/Dockerfile b/src/components/microservices/project-manager/Dockerfile new file mode 100644 index 00000000..6a085c9d --- /dev/null +++ b/src/components/microservices/project-manager/Dockerfile @@ -0,0 +1,10 @@ +FROM oven/bun:1-alpine + +WORKDIR /app +COPY src/components/microservices/project-manager/package.json ./package.json +RUN bun install --production +COPY src/components/microservices/project-manager/tsconfig.json ./tsconfig.json +COPY src/components/microservices/project-manager/src ./src + +EXPOSE 4233 +CMD ["bun", "run", "src/index.ts"] diff --git a/src/components/microservices/project-manager/bun.lock b/src/components/microservices/project-manager/bun.lock new file mode 100644 index 00000000..c8ea5709 --- /dev/null +++ b/src/components/microservices/project-manager/bun.lock @@ -0,0 +1,34 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@unidesk/project-manager", + "dependencies": { + "postgres": "latest", + "xlsx": "0.18.5", + }, + }, + }, + "packages": { + "adler-32": ["adler-32@1.3.1", "", {}, "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A=="], + + "cfb": ["cfb@1.2.2", "", { "dependencies": { "adler-32": "~1.3.0", "crc-32": "~1.2.0" } }, "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA=="], + + "codepage": ["codepage@1.15.0", "", {}, "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA=="], + + "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="], + + "frac": ["frac@1.1.2", "", {}, "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA=="], + + "postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="], + + "ssf": ["ssf@0.11.2", "", { "dependencies": { "frac": "~1.1.2" } }, "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g=="], + + "wmf": ["wmf@1.0.2", "", {}, "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw=="], + + "word": ["word@0.3.0", "", {}, "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA=="], + + "xlsx": ["xlsx@0.18.5", "", { "dependencies": { "adler-32": "~1.3.0", "cfb": "~1.2.1", "codepage": "~1.15.0", "crc-32": "~1.2.1", "ssf": "~0.11.2", "wmf": "~1.0.1", "word": "~0.3.0" }, "bin": { "xlsx": "bin/xlsx.njs" } }, "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ=="], + } +} diff --git a/src/components/microservices/project-manager/package.json b/src/components/microservices/project-manager/package.json new file mode 100644 index 00000000..9c21320c --- /dev/null +++ b/src/components/microservices/project-manager/package.json @@ -0,0 +1,13 @@ +{ + "name": "@unidesk/project-manager", + "private": true, + "type": "module", + "scripts": { + "start": "bun run src/index.ts", + "check": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "postgres": "latest", + "xlsx": "0.18.5" + } +} diff --git a/src/components/microservices/project-manager/src/index.ts b/src/components/microservices/project-manager/src/index.ts new file mode 100644 index 00000000..60db10ef --- /dev/null +++ b/src/components/microservices/project-manager/src/index.ts @@ -0,0 +1,612 @@ +import { createHash, randomUUID } from "node:crypto"; +import { appendFileSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import postgres from "postgres"; +import * as XLSX from "xlsx"; + +type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; +type JsonRecord = Record; + +type ProjectStatusFilter = "all" | "completed" | "active" | "unpaid"; + +interface RuntimeConfig { + host: string; + port: number; + databaseUrl: string; + logFile: string; +} + +interface ProjectRow { + id: string; + sequence_no: number | null; + contract_no: string; + name: string; + current_status: string; + pending: string; + payment_status: string; + payment_ratio: string | number | null; + notes: string; + raw: JsonValue; + source_file: string; + imported_at: string | null; + created_at: string; + updated_at: string; +} + +interface ProjectRecord extends JsonRecord { + id: string; + sequenceNo: number | null; + contractNo: string; + name: string; + currentStatus: string; + pending: string; + paymentStatus: string; + paymentRatio: number | null; + notes: string; + raw: JsonValue; + sourceFile: string; + importedAt: string | null; + createdAt: string; + updatedAt: string; +} + +interface ProjectInput { + sequenceNo?: unknown; + contractNo?: unknown; + name?: unknown; + currentStatus?: unknown; + status?: unknown; + pending?: unknown; + todo?: unknown; + paymentStatus?: unknown; + paymentRatio?: unknown; + notes?: unknown; + raw?: unknown; + sourceFile?: unknown; +} + +interface ImportCandidate { + id?: string; + sequenceNo: number | null; + contractNo: string; + name: string; + currentStatus: string; + pending: string; + paymentStatus: string; + paymentRatio: number | null; + notes: string; + raw: JsonRecord; + sourceFile: string; +} + +const serviceStartedAt = new Date().toISOString(); +const recentLogs: JsonRecord[] = []; +const EXCEL_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + +function configFromEnv(): RuntimeConfig { + const databaseUrl = process.env.DATABASE_URL || ""; + if (!databaseUrl) throw new Error("DATABASE_URL is required"); + return { + host: process.env.HOST || "0.0.0.0", + port: Number(process.env.PORT || 4233), + databaseUrl, + logFile: process.env.LOG_FILE || "", + }; +} + +const config = configFromEnv(); +const sql = postgres(config.databaseUrl, { max: 8, idle_timeout: 20, connect_timeout: 10 }); + +function log(event: string, detail: JsonRecord = {}): void { + const record: JsonRecord = { at: new Date().toISOString(), event, ...detail }; + recentLogs.push(record); + if (recentLogs.length > 300) recentLogs.shift(); + if (config.logFile) { + try { + mkdirSync(dirname(config.logFile), { recursive: true }); + appendFileSync(config.logFile, `${JSON.stringify(record)}\n`, "utf8"); + } catch { + // Logging must not break request handling. + } + } +} + +function jsonResponse(body: JsonValue, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json; charset=utf-8" }, + }); +} + +function errorToJson(error: unknown): JsonRecord { + if (error instanceof Error) return { name: error.name, message: error.message, stack: error.stack || "" }; + return { message: String(error) }; +} + +function errorResponse(error: unknown, status = 500): Response { + log("request_error", { status, error: errorToJson(error) }); + return jsonResponse({ ok: false, error: error instanceof Error ? error.message : String(error) }, status); +} + +function textValue(value: unknown): string { + if (value === null || value === undefined) return ""; + if (typeof value === "string") return value.trim(); + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value).trim(); + return ""; +} + +function numberOrNull(value: unknown): number | null { + if (value === null || value === undefined || value === "") return null; + if (typeof value === "number") return Number.isFinite(value) ? value : null; + const raw = String(value).trim(); + if (!raw) return null; + const percent = raw.endsWith("%"); + const normalized = raw.replace(/[%\s,]/g, ""); + const parsed = Number(normalized); + if (!Number.isFinite(parsed)) return null; + return percent ? parsed / 100 : parsed; +} + +function integerOrNull(value: unknown): number | null { + const number = numberOrNull(value); + if (number === null) return null; + return Number.isInteger(number) ? number : Math.round(number); +} + +function jsonRecordOrEmpty(value: unknown): JsonRecord { + if (typeof value === "object" && value !== null && !Array.isArray(value)) return value as JsonRecord; + return {}; +} + +function projectIdFromContract(contractNo: string): string { + const compact = contractNo.trim().replace(/[^A-Za-z0-9_-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 96); + if (compact) return `pm_${compact}`; + return `pm_${randomUUID()}`; +} + +function projectFromRow(row: ProjectRow): ProjectRecord { + return { + id: row.id, + sequenceNo: row.sequence_no, + contractNo: row.contract_no, + name: row.name, + currentStatus: row.current_status, + pending: row.pending, + paymentStatus: row.payment_status, + paymentRatio: numberOrNull(row.payment_ratio), + notes: row.notes, + raw: row.raw, + sourceFile: row.source_file, + importedAt: row.imported_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function rowHash(input: ImportCandidate): string { + return createHash("sha256").update(JSON.stringify({ contractNo: input.contractNo, name: input.name, raw: input.raw })).digest("hex"); +} + +async function ensureSchema(): Promise { + await sql` + CREATE TABLE IF NOT EXISTS project_manager_projects ( + id TEXT PRIMARY KEY, + sequence_no INTEGER, + contract_no TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL DEFAULT '', + current_status TEXT NOT NULL DEFAULT '', + pending TEXT NOT NULL DEFAULT '', + payment_status TEXT NOT NULL DEFAULT '', + payment_ratio NUMERIC, + notes TEXT NOT NULL DEFAULT '', + raw JSONB NOT NULL DEFAULT '{}'::jsonb, + source_file TEXT NOT NULL DEFAULT '', + imported_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `; + await sql`CREATE INDEX IF NOT EXISTS idx_project_manager_projects_sequence ON project_manager_projects(sequence_no NULLS LAST, created_at ASC)`; + await sql`CREATE INDEX IF NOT EXISTS idx_project_manager_projects_contract ON project_manager_projects(contract_no)`; + await sql`CREATE INDEX IF NOT EXISTS idx_project_manager_projects_updated ON project_manager_projects(updated_at DESC)`; +} + +async function waitForSchema(): Promise { + const started = Date.now(); + let lastError: unknown = null; + for (let attempt = 1; attempt <= 30; attempt += 1) { + try { + await ensureSchema(); + log("schema_ready", { attempt }); + return; + } catch (error) { + lastError = error; + log("schema_wait", { attempt, error: errorToJson(error) }); + await Bun.sleep(Math.min(1000 + attempt * 250, 5000)); + } + } + const elapsedMs = Date.now() - started; + throw new Error(`Project Manager schema initialization failed after ${elapsedMs}ms: ${JSON.stringify(errorToJson(lastError))}`); +} + +async function readJson(req: Request): Promise { + const text = await req.text(); + if (text.length > 1_000_000) throw new Error("request body is too large"); + if (!text.trim()) return {}; + return JSON.parse(text) as unknown; +} + +function normalizeProjectInput(input: ProjectInput, existing?: ProjectRecord): ImportCandidate { + const currentStatus = "currentStatus" in input ? textValue(input.currentStatus) : "status" in input ? textValue(input.status) : existing?.currentStatus ?? ""; + const pending = "pending" in input ? textValue(input.pending) : "todo" in input ? textValue(input.todo) : existing?.pending ?? ""; + const contractNo = "contractNo" in input ? textValue(input.contractNo) : existing?.contractNo ?? ""; + const name = "name" in input ? textValue(input.name) : existing?.name ?? ""; + const paymentRatio = "paymentRatio" in input ? numberOrNull(input.paymentRatio) : existing?.paymentRatio ?? null; + const raw = "raw" in input ? jsonRecordOrEmpty(input.raw) : jsonRecordOrEmpty(existing?.raw); + if (!contractNo && !name) throw new Error("contractNo or name is required"); + return { + sequenceNo: "sequenceNo" in input ? integerOrNull(input.sequenceNo) : existing?.sequenceNo ?? null, + contractNo, + name, + currentStatus, + pending, + paymentStatus: "paymentStatus" in input ? textValue(input.paymentStatus) : existing?.paymentStatus ?? "", + paymentRatio, + notes: "notes" in input ? textValue(input.notes) : existing?.notes ?? "", + raw, + sourceFile: "sourceFile" in input ? textValue(input.sourceFile) : existing?.sourceFile ?? "manual", + }; +} + +async function getProject(id: string): Promise { + const rows = await sql` + SELECT id, sequence_no, contract_no, name, current_status, pending, payment_status, payment_ratio, notes, raw, source_file, imported_at, created_at, updated_at + FROM project_manager_projects + WHERE id = ${id} + LIMIT 1 + `; + return rows[0] ? projectFromRow(rows[0]) : null; +} + +async function insertProject(candidate: ImportCandidate, id = projectIdFromContract(candidate.contractNo)): Promise { + const rows = await sql` + INSERT INTO project_manager_projects (id, sequence_no, contract_no, name, current_status, pending, payment_status, payment_ratio, notes, raw, source_file, imported_at) + VALUES (${id}, ${candidate.sequenceNo}, ${candidate.contractNo}, ${candidate.name}, ${candidate.currentStatus}, ${candidate.pending}, ${candidate.paymentStatus}, ${candidate.paymentRatio}, ${candidate.notes}, ${sql.json(candidate.raw)}, ${candidate.sourceFile}, now()) + RETURNING id, sequence_no, contract_no, name, current_status, pending, payment_status, payment_ratio, notes, raw, source_file, imported_at, created_at, updated_at + `; + return projectFromRow(rows[0]!); +} + +async function updateProject(id: string, candidate: ImportCandidate): Promise { + const rows = await sql` + UPDATE project_manager_projects + SET sequence_no = ${candidate.sequenceNo}, + contract_no = ${candidate.contractNo}, + name = ${candidate.name}, + current_status = ${candidate.currentStatus}, + pending = ${candidate.pending}, + payment_status = ${candidate.paymentStatus}, + payment_ratio = ${candidate.paymentRatio}, + notes = ${candidate.notes}, + raw = ${sql.json(candidate.raw)}, + source_file = ${candidate.sourceFile}, + updated_at = now() + WHERE id = ${id} + RETURNING id, sequence_no, contract_no, name, current_status, pending, payment_status, payment_ratio, notes, raw, source_file, imported_at, created_at, updated_at + `; + if (!rows[0]) throw new Error(`project not found: ${id}`); + return projectFromRow(rows[0]); +} + +function andFragments(fragments: any[]): any { + if (fragments.length === 0) return sql`TRUE`; + return fragments.slice(1).reduce((acc, fragment) => sql`${acc} AND ${fragment}`, fragments[0]); +} + +function statusFilterFromUrl(url: URL): ProjectStatusFilter { + const value = String(url.searchParams.get("status") || "all").toLowerCase(); + if (value === "completed" || value === "active" || value === "unpaid") return value; + return "all"; +} + +function projectFilters(url: URL): any[] { + const filters: any[] = []; + const q = String(url.searchParams.get("q") || "").trim(); + if (q) { + const like = `%${q}%`; + filters.push(sql`(contract_no ILIKE ${like} OR name ILIKE ${like} OR current_status ILIKE ${like} OR pending ILIKE ${like} OR notes ILIKE ${like})`); + } + const status = statusFilterFromUrl(url); + if (status === "completed") filters.push(sql`(current_status ILIKE ${"%完成%"} OR pending ILIKE ${"%完成%"} OR notes ILIKE ${"%完成%"})`); + if (status === "active") filters.push(sql`NOT (current_status ILIKE ${"%完成%"} AND pending ILIKE ${"%完成%"})`); + if (status === "unpaid") filters.push(sql`COALESCE(payment_ratio, 0) < 1`); + return filters; +} + +async function listProjects(url: URL): Promise { + const page = Math.max(1, Number(url.searchParams.get("page") || 1) || 1); + const pageSize = Math.max(1, Math.min(200, Number(url.searchParams.get("pageSize") || 100) || 100)); + const offset = (page - 1) * pageSize; + const where = andFragments(projectFilters(url)); + const countRows = await sql<{ count: number }[]>`SELECT count(*)::int AS count FROM project_manager_projects WHERE ${where}`; + const rows = await sql` + SELECT id, sequence_no, contract_no, name, current_status, pending, payment_status, payment_ratio, notes, raw, source_file, imported_at, created_at, updated_at + FROM project_manager_projects + WHERE ${where} + ORDER BY sequence_no NULLS LAST, created_at ASC, id ASC + LIMIT ${pageSize} + OFFSET ${offset} + `; + const summaryRows = await sql<{ total: number; completed: number; unpaid: number; average_payment: number | null }[]>` + SELECT + count(*)::int AS total, + count(*) FILTER (WHERE current_status ILIKE ${"%完成%"} OR pending ILIKE ${"%完成%"} OR notes ILIKE ${"%完成%"})::int AS completed, + count(*) FILTER (WHERE COALESCE(payment_ratio, 0) < 1)::int AS unpaid, + avg(payment_ratio)::float8 AS average_payment + FROM project_manager_projects + `; + const summary = summaryRows[0] ?? { total: 0, completed: 0, unpaid: 0, average_payment: null }; + return jsonResponse({ + ok: true, + projects: rows.map(projectFromRow), + page, + pageSize, + total: Number(countRows[0]?.count ?? 0), + summary: { + total: Number(summary.total || 0), + completed: Number(summary.completed || 0), + active: Math.max(0, Number(summary.total || 0) - Number(summary.completed || 0)), + unpaid: Number(summary.unpaid || 0), + averagePayment: numberOrNull(summary.average_payment), + }, + }); +} + +async function createProject(req: Request): Promise { + const body = jsonRecordOrEmpty(await readJson(req)); + const candidate = normalizeProjectInput(body); + const id = textValue(body.id) || projectIdFromContract(candidate.contractNo); + const existing = await getProject(id); + if (existing !== null) return jsonResponse({ ok: false, error: `project already exists: ${id}` }, 409); + const project = await insertProject(candidate, id); + log("project_created", { id: project.id, contractNo: project.contractNo, name: project.name }); + return jsonResponse({ ok: true, project }, 201); +} + +async function updateProjectRoute(req: Request, id: string): Promise { + const existing = await getProject(id); + if (existing === null) return jsonResponse({ ok: false, error: `project not found: ${id}` }, 404); + const body = jsonRecordOrEmpty(await readJson(req)); + const candidate = normalizeProjectInput(body, existing); + const project = await updateProject(id, candidate); + log("project_updated", { id, contractNo: project.contractNo, name: project.name }); + return jsonResponse({ ok: true, project }); +} + +async function deleteProjectRoute(id: string): Promise { + const rows = await sql<{ id: string }[]>`DELETE FROM project_manager_projects WHERE id = ${id} RETURNING id`; + if (!rows[0]) return jsonResponse({ ok: false, error: `project not found: ${id}` }, 404); + log("project_deleted", { id }); + return jsonResponse({ ok: true, id }); +} + +function normalizeHeader(value: unknown): string { + return textValue(value).replace(/\s+/g, ""); +} + +function readCell(row: unknown[], index: number): string { + return textValue(row[index]); +} + +function sheetRowsFromWorkbook(buffer: Buffer, sheetName?: string): unknown[][] { + const workbook = XLSX.read(buffer, { type: "buffer", cellDates: false }); + const effectiveSheet = sheetName && workbook.Sheets[sheetName] ? sheetName : workbook.SheetNames[0]; + if (!effectiveSheet) throw new Error("Excel workbook has no sheets"); + return XLSX.utils.sheet_to_json(workbook.Sheets[effectiveSheet]!, { header: 1, raw: false, defval: "" }) as unknown[][]; +} + +function parseExcelProjects(buffer: Buffer, sourceFile: string, sheetName?: string): ImportCandidate[] { + const rows = sheetRowsFromWorkbook(buffer, sheetName); + const headerIndex = rows.findIndex((row) => row.some((cell) => normalizeHeader(cell) === "合同号") && row.some((cell) => normalizeHeader(cell) === "项目名称")); + if (headerIndex < 0) throw new Error("Excel header row not found; expected 合同号 and 项目名称"); + const headerRow = rows[headerIndex] ?? []; + const headers = headerRow.map((cell) => normalizeHeader(cell)); + const indexOf = (header: string): number => headers.findIndex((cell) => cell === header); + const sequenceIndex = indexOf("序号"); + const contractIndex = indexOf("合同号"); + const nameIndex = indexOf("项目名称"); + const statusIndex = indexOf("当前状况"); + const pendingIndex = indexOf("待完成"); + const paymentIndex = indexOf("付款情况"); + const notesIndex = indexOf("其它"); + const projects: ImportCandidate[] = []; + for (const row of rows.slice(headerIndex + 1)) { + const record: JsonRecord = {}; + headers.forEach((header, index) => { + if (header) record[header] = readCell(row, index); + }); + const contractNo = contractIndex >= 0 ? readCell(row, contractIndex) : ""; + const name = nameIndex >= 0 ? readCell(row, nameIndex) : ""; + if (!contractNo && !name) continue; + const paymentStatus = paymentIndex >= 0 ? readCell(row, paymentIndex) : ""; + const candidate: ImportCandidate = { + sequenceNo: sequenceIndex >= 0 ? integerOrNull(readCell(row, sequenceIndex)) : null, + contractNo, + name, + currentStatus: statusIndex >= 0 ? readCell(row, statusIndex) : "", + pending: pendingIndex >= 0 ? readCell(row, pendingIndex) : "", + paymentStatus, + paymentRatio: numberOrNull(paymentStatus), + notes: notesIndex >= 0 ? readCell(row, notesIndex) : "", + raw: { ...record, _sourceRowHash: "" }, + sourceFile, + }; + candidate.raw._sourceRowHash = rowHash(candidate); + projects.push(candidate); + } + return projects; +} + +async function importProjects(candidates: ImportCandidate[], replace: boolean): Promise { + const unique = new Map(); + for (const candidate of candidates) { + const id = candidate.id || projectIdFromContract(candidate.contractNo); + unique.set(id, { ...candidate, id }); + } + let imported = 0; + await sql.begin(async (tx) => { + if (replace) await tx`DELETE FROM project_manager_projects`; + for (const [id, candidate] of unique.entries()) { + await tx` + INSERT INTO project_manager_projects (id, sequence_no, contract_no, name, current_status, pending, payment_status, payment_ratio, notes, raw, source_file, imported_at, updated_at) + VALUES (${id}, ${candidate.sequenceNo}, ${candidate.contractNo}, ${candidate.name}, ${candidate.currentStatus}, ${candidate.pending}, ${candidate.paymentStatus}, ${candidate.paymentRatio}, ${candidate.notes}, ${tx.json(candidate.raw)}, ${candidate.sourceFile}, now(), now()) + ON CONFLICT (id) DO UPDATE SET + sequence_no = EXCLUDED.sequence_no, + contract_no = EXCLUDED.contract_no, + name = EXCLUDED.name, + current_status = EXCLUDED.current_status, + pending = EXCLUDED.pending, + payment_status = EXCLUDED.payment_status, + payment_ratio = EXCLUDED.payment_ratio, + notes = EXCLUDED.notes, + raw = EXCLUDED.raw, + source_file = EXCLUDED.source_file, + imported_at = EXCLUDED.imported_at, + updated_at = now() + `; + imported += 1; + } + }); + const totalRows = await sql<{ count: number }[]>`SELECT count(*)::int AS count FROM project_manager_projects`; + return { imported, replace, total: Number(totalRows[0]?.count ?? 0) }; +} + +async function importExcelRoute(req: Request): Promise { + const body = jsonRecordOrEmpty(await readJson(req)); + const base64 = textValue(body.contentBase64); + if (!base64) return jsonResponse({ ok: false, error: "contentBase64 is required" }, 400); + const fileName = textValue(body.fileName) || "import.xlsx"; + const sheetName = textValue(body.sheetName) || undefined; + const replace = body.replace === true; + const buffer = Buffer.from(base64, "base64"); + const candidates = parseExcelProjects(buffer, fileName, sheetName); + const result = await importProjects(candidates, replace); + log("projects_imported_excel", { fileName, sheetName: sheetName || "", imported: result.imported, replace }); + return jsonResponse({ ok: true, fileName, parsed: candidates.length, ...result }); +} + +async function importRowsRoute(req: Request): Promise { + const body = jsonRecordOrEmpty(await readJson(req)); + const rows = Array.isArray(body.projects) ? body.projects : Array.isArray(body.rows) ? body.rows : []; + const sourceFile = textValue(body.sourceFile) || "api"; + const candidates = rows.map((row) => normalizeProjectInput(jsonRecordOrEmpty(row), undefined)).map((candidate) => ({ ...candidate, sourceFile })); + const result = await importProjects(candidates, body.replace === true); + log("projects_imported_rows", { sourceFile, imported: result.imported, replace: body.replace === true }); + return jsonResponse({ ok: true, parsed: candidates.length, ...result }); +} + +async function exportProjects(): Promise { + const rows = await sql` + SELECT id, sequence_no, contract_no, name, current_status, pending, payment_status, payment_ratio, notes, raw, source_file, imported_at, created_at, updated_at + FROM project_manager_projects + ORDER BY sequence_no NULLS LAST, created_at ASC, id ASC + `; + const table = [ + ["序号", "合同号", "项目名称", "当前状况", "待完成", "付款情况", "其它", "ID", "来源", "更新时间"], + ...rows.map((row) => { + const project = projectFromRow(row); + return [ + project.sequenceNo ?? "", + project.contractNo, + project.name, + project.currentStatus, + project.pending, + project.paymentStatus, + project.notes, + project.id, + project.sourceFile, + project.updatedAt, + ]; + }), + ]; + const workbook = XLSX.utils.book_new(); + const worksheet = XLSX.utils.aoa_to_sheet(table); + worksheet["!cols"] = [ + { wch: 8 }, { wch: 22 }, { wch: 28 }, { wch: 32 }, { wch: 38 }, { wch: 12 }, { wch: 18 }, { wch: 30 }, { wch: 24 }, { wch: 24 }, + ]; + XLSX.utils.book_append_sheet(workbook, worksheet, "项目列表"); + const buffer = XLSX.write(workbook, { type: "buffer", bookType: "xlsx" }) as Buffer; + const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, ""); + const body = new Uint8Array(buffer.byteLength); + body.set(buffer); + return new Response(body.buffer, { + status: 200, + headers: { + "content-type": EXCEL_CONTENT_TYPE, + "content-disposition": `attachment; filename="project-manager-${stamp}.xlsx"`, + "cache-control": "no-store", + }, + }); +} + +async function health(): Promise { + const rows = await sql<{ count: number }[]>`SELECT count(*)::int AS count FROM project_manager_projects`; + return jsonResponse({ + ok: true, + service: "project-manager", + storage: { primary: "postgres", table: "project_manager_projects", projects: Number(rows[0]?.count ?? 0) }, + capabilities: ["crud", "excel-import", "excel-export"], + startedAt: serviceStartedAt, + }); +} + +function notFound(pathname: string): Response { + return jsonResponse({ ok: false, error: `not found: ${pathname}` }, 404); +} + +async function handleRequest(req: Request): Promise { + const url = new URL(req.url); + const method = req.method.toUpperCase(); + if (method === "OPTIONS") return new Response(null, { status: 204 }); + if (url.pathname === "/health" && (method === "GET" || method === "HEAD")) return health(); + if (url.pathname === "/logs" && (method === "GET" || method === "HEAD")) return jsonResponse({ ok: true, logs: recentLogs.slice(-200) }); + if (url.pathname === "/api/projects/export.xlsx" && (method === "GET" || method === "HEAD")) return exportProjects(); + if (url.pathname === "/api/projects" && (method === "GET" || method === "HEAD")) return listProjects(url); + if (url.pathname === "/api/projects" && method === "POST") return createProject(req); + if (url.pathname === "/api/import/excel" && method === "POST") return importExcelRoute(req); + if (url.pathname === "/api/import/projects" && method === "POST") return importRowsRoute(req); + if (url.pathname.startsWith("/api/projects/")) { + const id = decodeURIComponent(url.pathname.slice("/api/projects/".length)); + if (!id) return jsonResponse({ ok: false, error: "project id is required" }, 400); + if (method === "GET" || method === "HEAD") { + const project = await getProject(id); + return project === null ? jsonResponse({ ok: false, error: `project not found: ${id}` }, 404) : jsonResponse({ ok: true, project }); + } + if (method === "PUT" || method === "POST") return updateProjectRoute(req, id); + if (method === "DELETE") return deleteProjectRoute(id); + } + return notFound(url.pathname); +} + +async function main(): Promise { + await waitForSchema(); + const server = Bun.serve({ + hostname: config.host, + port: config.port, + async fetch(req): Promise { + try { + return await handleRequest(req); + } catch (error) { + return errorResponse(error, 500); + } + }, + }); + log("server_started", { host: config.host, port: config.port, url: server.url.toString() }); + console.log(`project-manager listening on ${server.url.toString()}`); +} + +main().catch((error) => { + log("server_failed", { error: errorToJson(error) }); + console.error(error); + process.exit(1); +}); diff --git a/src/components/microservices/project-manager/tsconfig.json b/src/components/microservices/project-manager/tsconfig.json new file mode 100644 index 00000000..f62969e7 --- /dev/null +++ b/src/components/microservices/project-manager/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 959e712b..ec5c9b34 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.9", + "version": "0.2.12", "private": true, "type": "module", "scripts": { diff --git a/src/components/provider-gateway/src/index.ts b/src/components/provider-gateway/src/index.ts index 96e32566..18999294 100644 --- a/src/components/provider-gateway/src/index.ts +++ b/src/components/provider-gateway/src/index.ts @@ -66,6 +66,12 @@ let stopping = false; let upgradeSleepUntil = 0; let upgradeSleepTimer: ReturnType | null = null; +interface MicroserviceHttpCacheEntry { + createdAt: number; + expiresAt: number; + result: JsonValue; +} + interface HostSshSession { proc: ReturnType; openedAt: number; @@ -78,7 +84,10 @@ interface HostSshStdin { } const hostSshSessions = new Map(); +const microserviceHttpCache = new Map(); +const microserviceHttpInFlight = new Map>(); const gatewayMetadata = readGatewayMetadata(); +const microserviceHttpMaxBodyTextLength = 8 * 1024 * 1024; function readGatewayMetadataFile(path: string): { name: string; version: string } | null { try { @@ -201,6 +210,7 @@ function currentLabels(): ProviderLabels { providerGatewayVersion: gatewayMetadata.version, providerGatewayStartedAt: startedAt.toISOString(), providerGatewayUpgradePolicy: "always-enabled", + providerGatewayMicroserviceHttpCache: true, gatewayUptimeSeconds: Math.floor((Date.now() - startedAt.getTime()) / 1000), }; } @@ -211,7 +221,7 @@ function sendJson(value: unknown): void { } function sendRegister(): void { - const capabilities = ["heartbeat", "system.status", "docker.status", "docker.ps", "provider.upgrade", "microservice.http", "echo"]; + const capabilities = ["heartbeat", "system.status", "docker.status", "docker.ps", "provider.upgrade", "microservice.http", "microservice.http.cache", "echo"]; if (isHostSshConfigured()) capabilities.push("host.ssh"); sendJson({ type: "register", @@ -1334,6 +1344,13 @@ function payloadNumber(payload: Record, key: string, fallback return Math.floor(value); } +function payloadBoundedNumber(payload: Record, key: string, fallback: number, min: number, max: number): number { + const raw = payload[key]; + const value = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : fallback; + if (!Number.isFinite(value)) return fallback; + return Math.max(min, Math.min(max, Math.floor(value))); +} + function payloadJsonArrayLimits(payload: Record): Record { const raw = payload.jsonArrayLimits; if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {}; @@ -1391,6 +1408,92 @@ function applyJsonArrayLimits(bodyText: string, contentType: string, limits: Rec } } +function contentTypeIsJson(contentType: string): boolean { + return contentType.toLowerCase().includes("json"); +} + +function boundedMicroserviceBodyText( + bodyText: string, + contentType: string, + metadata: { serviceId: string; path: string; status: number; upstreamBodyBytes: number }, +): { bodyText: string; truncated: boolean } { + if (bodyText.length <= microserviceHttpMaxBodyTextLength) { + return { bodyText, truncated: false }; + } + if (contentTypeIsJson(contentType)) { + return { + bodyText: JSON.stringify({ + ok: false, + error: "microservice proxy response body is too large", + serviceId: metadata.serviceId, + path: metadata.path, + upstreamStatus: metadata.status, + upstreamBodyBytes: metadata.upstreamBodyBytes, + transformedBodyBytes: bodyText.length, + responseBodyLimitBytes: microserviceHttpMaxBodyTextLength, + hint: "Use a paged endpoint or tighten __unideskArrayLimit so the response stays below the proxy safety limit.", + }), + truncated: true, + }; + } + return { bodyText: truncateText(bodyText, microserviceHttpMaxBodyTextLength), truncated: true }; +} + +function microserviceHttpCacheKey(serviceId: string, targetBaseUrl: string, method: string, path: string, query: string, jsonArrayLimits: Record): string { + return JSON.stringify([serviceId, targetBaseUrl, method, path, query, jsonArrayLimits]); +} + +function microserviceHttpCachePrefix(serviceId: string, targetBaseUrl: string): string { + return JSON.stringify([serviceId, targetBaseUrl]).slice(0, -1); +} + +function cloneJsonObject(value: JsonValue): Record { + return value && typeof value === "object" && !Array.isArray(value) ? { ...value as Record } : { value }; +} + +function resultWithCacheMetadata(result: JsonValue, metadata: Record): JsonValue { + const record = cloneJsonObject(result); + record.cache = metadata; + return record; +} + +function readMicroserviceHttpCache(key: string): JsonValue | null { + const entry = microserviceHttpCache.get(key); + if (entry === undefined) return null; + const now = Date.now(); + if (entry.expiresAt <= now) { + microserviceHttpCache.delete(key); + return null; + } + return resultWithCacheMetadata(entry.result, { + hit: true, + ageMs: now - entry.createdAt, + ttlRemainingMs: entry.expiresAt - now, + layer: "provider-gateway", + }); +} + +function rememberMicroserviceHttpCache(key: string, ttlMs: number, result: JsonValue): void { + if (ttlMs <= 0 || !result || typeof result !== "object" || Array.isArray(result)) return; + const record = result as Record; + if (record.ok !== true || record.upstreamOk !== true) return; + if (record.truncated === true) return; + const now = Date.now(); + microserviceHttpCache.set(key, { createdAt: now, expiresAt: now + ttlMs, result: { ...record } }); + if (microserviceHttpCache.size > 300) { + for (const [cacheKey, entry] of microserviceHttpCache) { + if (entry.expiresAt <= now || microserviceHttpCache.size > 240) microserviceHttpCache.delete(cacheKey); + } + } +} + +function invalidateMicroserviceHttpCache(serviceId: string, targetBaseUrl: string): void { + const prefix = microserviceHttpCachePrefix(serviceId, targetBaseUrl); + for (const key of microserviceHttpCache.keys()) { + if (key.startsWith(prefix)) microserviceHttpCache.delete(key); + } +} + async function runMicroserviceHttp(payload: Record): Promise { const rawMethod = String(payload.method || "GET").toUpperCase(); const allowedMethods = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"]); @@ -1407,15 +1510,35 @@ async function runMicroserviceHttp(payload: Record): Promise< const timeoutMs = Math.max(1000, Math.min(30_000, payloadNumber(payload, "timeoutMs", 10_000))); const jsonArrayLimits = payloadJsonArrayLimits(payload); const bodyText = payloadString(payload, "bodyText") ?? ""; + const serviceId = payloadString(payload, "serviceId") ?? "unknown"; + const cacheTtlMs = payloadBoundedNumber(payload, "cacheTtlMs", 0, 0, 60_000); + const cacheable = cacheTtlMs > 0 && (method === "GET" || method === "HEAD") && bodyText.length === 0; + const cacheKey = cacheable ? microserviceHttpCacheKey(serviceId, targetBaseUrl, method, path, query, jsonArrayLimits) : ""; + if (cacheable) { + const cached = readMicroserviceHttpCache(cacheKey); + if (cached !== null) return cached; + const inFlight = microserviceHttpInFlight.get(cacheKey); + if (inFlight !== undefined) { + const result = await inFlight; + return resultWithCacheMetadata(result, { + hit: true, + deduped: true, + layer: "provider-gateway", + }); + } + } else if (method !== "GET" && method !== "HEAD") { + invalidateMicroserviceHttpCache(serviceId, targetBaseUrl); + } const requestHeaders = typeof payload.requestHeaders === "object" && payload.requestHeaders !== null && !Array.isArray(payload.requestHeaders) ? payload.requestHeaders as Record : {}; const headers = new Headers(); const contentType = typeof requestHeaders["content-type"] === "string" ? requestHeaders["content-type"] : ""; if (contentType.length > 0) headers.set("content-type", contentType); + const requestStartedAt = Date.now(); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); - try { + const requestPromise = (async (): Promise => { const response = await fetch(url, { method, headers, @@ -1425,39 +1548,56 @@ async function runMicroserviceHttp(payload: Record): Promise< const rawBodyText = await response.text(); const contentType = response.headers.get("content-type") ?? "text/plain; charset=utf-8"; const transformed = applyJsonArrayLimits(rawBodyText, contentType, jsonArrayLimits); + const bounded = boundedMicroserviceBodyText(transformed.bodyText, contentType, { + serviceId, + path, + status: response.status, + upstreamBodyBytes: rawBodyText.length, + }); return { ok: true, - serviceId: payloadString(payload, "serviceId") ?? "unknown", + serviceId, method, url: url.toString(), status: response.status, upstreamOk: response.ok, contentType, - bodyText: truncateText(transformed.bodyText, 1024 * 1024), + bodyText: bounded.bodyText, upstreamBodyBytes: rawBodyText.length, - returnedBodyBytes: Math.min(transformed.bodyText.length, 1024 * 1024), - truncated: transformed.bodyText.length > 1024 * 1024, + transformedBodyBytes: transformed.bodyText.length, + returnedBodyBytes: bounded.bodyText.length, + responseBodyLimitBytes: microserviceHttpMaxBodyTextLength, + truncated: bounded.truncated, transform: transformed.transform, + upstreamDurationMs: Date.now() - requestStartedAt, }; + })(); + if (cacheable) microserviceHttpInFlight.set(cacheKey, requestPromise); + try { + const result = await requestPromise; + if (cacheable) rememberMicroserviceHttpCache(cacheKey, cacheTtlMs, result); + return result; } catch (error) { return { ok: false, - serviceId: payloadString(payload, "serviceId") ?? "unknown", + serviceId, method, url: url.toString(), timeoutMs, error: error instanceof Error ? error.message : String(error), }; } finally { + if (cacheable && microserviceHttpInFlight.get(cacheKey) === requestPromise) microserviceHttpInFlight.delete(cacheKey); clearTimeout(timer); } } async function handleDispatch(message: CoreDispatchMessage): Promise { logger("info", "dispatch_received", { taskId: message.taskId, command: message.command, payload: message.payload }); - await sendTaskStatus(message.taskId, "accepted", "provider accepted task"); + const verboseLifecycle = message.command !== "microservice.http"; + if (verboseLifecycle) await sendTaskStatus(message.taskId, "accepted", "provider accepted task"); try { - await sendTaskStatus(message.taskId, "running", "provider running task"); + if (verboseLifecycle) await sendTaskStatus(message.taskId, "running", "provider running task"); if (message.command === "docker.ps") { const result = await runDockerPs(); await sendTaskStatus(message.taskId, "succeeded", "docker ps completed", result); diff --git "a/summary).scrollIntoViewIfNeeded();\n await page.locator([data-testid=codex-execution-summary]" "b/summary).scrollIntoViewIfNeeded();\n await page.locator([data-testid=codex-execution-summary]" new file mode 100644 index 00000000..e69de29b